做深度学习训练的同学,应该都有过这种经历:模型结构照着参考代码搭好了,loss 却怎么都不下降;或者训练一切正常,推理结果却奇奇怪怪。这种时候每个人的第一反应都是调学习率、换优化器,但很少有人会回头去看最基本的激活函数到底长什么样。我刚开始用 Keras 搭建网络时也是这样,直到有次在调试一个分类模型时,把每个激活函数的曲线和导数一起画出来,才真正理解为什么同一个结构换个激活函数,效果差距能那么大。
这个标题看起来是要“批量生成图像”,实际要解决的其实是两层问题:一是搞清 Keras 里各种激活函数(比如 Sigmoid、Tanh、ReLU、ELU、Swish、Softmax 这些)的数学形态和适用场景;二是用 Python 把它们批量可视化,让图像成为判断“该用哪个函数”的依据。这篇文章我会直接从实操角度出发,把常见的激活函数逐一拆开讲清楚,再给出一套可以直接复用的绘图脚本,最后聊聊我在实际选型过程中踩过的坑。
适合谁看呢?刚入门深度学习、想搞懂激活函数区别的新手,以及已经在用 Keras 训练模型但经常为“为什么我的网络不收敛”头疼的同学。看完之后,你能自己画出任意激活函数及其导数图像,也能在搭建网络时更有底气地选择激活函数组合。这篇文章不预设你已经懂数学公式,我会尽量用图像特征和实验现象来讲,代码部分保证能直接在你自己的环境里跑起来。
1. 为什么要把激活函数画出来看一遍
1.1 激活函数在神经网络里扮演的角色
神经网络本质上是一个巨型的复合函数。如果没有激活函数,每一层都只是线性变换的堆叠,无论堆多少层,整个网络表达能力的上限还是一条线性变换。激活函数的作用就是在每次线性变换之后注入非线性,让网络理论上能够拟合任意复杂的函数关系。
但激活函数不是随便选一个非线性形式就能用。它会直接影响前向传播时数值的分布范围、反向传播时梯度的大小,以及整个网络的收敛速度。这些影响光看公式很难直观感受到,尤其是当激活函数形式复杂的时候。把函数图像和导数图像画出来,是最直接的观察方式。
举个例子,Sigmoid 的公式很简洁,但你从公式里看不出“当输入绝对值大于 4 的时候,导数几乎为零”这种话的份量。只有在图像上看到整条曲线两端几乎贴着地面,你才会意识到,这个函数用在深层网络里会带来多大的梯度消失风险。
1.2 一张图像里藏着哪些选型信息
我每次拿到一个激活函数,都会在图像上重点看四个维度。
第一,取值范围的端点。范围在 0 到 1 之间的函数,输出的均值会偏向正数;范围在 -1 到 1 之间的函数,均值更接近 0。很多带 BatchNorm 的模型对输入分布有偏好,激活函数输出的均值会直接影响下一层接收到的数据形态。
第二,饱和区域是否明显。曲线在两端趋于平缓的区域被称为饱和区,在这些区域导数接近 0。梯度本身是靠误差乘以激活函数的导数来反向传播的,如果大量神经元落入饱和区,传回去的梯度就会非常小,网络训练基本停摆。Sigmoid 和 Tanh 典型的问题就在这里。
第三,负区间是否有响应。ReLU 在 x<0 时输出恒定为 0,好处是计算简单、稀疏性好,坏处是神经元一旦落入负区间,梯度为 0,可能再也没机会被更新。Leaky ReLU 这类变体就是为了解决这个问题而设计的,所以你会在图像上看到一个有斜率的负区间尾巴。
第四,导数是否是单调的、是否连续。导数不连续的激活函数,在某些情况下会导致训练不稳定;导数幅值小于 1 的函数,在深层网络里梯度会越传越小。
这四个维度的信息,从公式里看需要做很多数学推演,但画成曲线图一眼就能看出问题所在。这也是为什么我会把“画图像”当作激活函数选型的第一步,而不是先翻论文看谁的效果更好。
2. Keras 高频激活函数逐个拆解
2.1 Sigmoid 与 Tanh:经典函数的饱和度问题
Sigmoid 的表达式是 σ(x) = 1 / (1 + e^(-x)),输出范围是 (0, 1)。它的优点是光滑、处处可导,输出天然可以被解释成概率;缺点是两端导数趋近于 0。当网络层数较深时,多个接近 0 的导数相乘,梯度会急剧缩小,这就是常说的梯度消失问题。所以现在 Sigmoid 基本只出现在两类位置:二分类问题的输出层,以及某些注意力机制中需要输出 0 到 1 权重的场景。
Tanh 是 Sigmoid 的平移缩放版本,公式是 tanh(x) = (e^x - e^(-x)) / (e^x + e^(-x)),输出范围是 (-1, 1)。它对零均值更友好,因此比 Sigmoid 更适合放在隐藏层,但在深度网络中同样逃不过梯度消失。如果拿两者做对比,Tanh 的收敛速度通常好于 Sigmoid,因为它的输出均值接近 0,相当于对输入做了一次简单的中心化。
我在 Keras 里很少把 Sigmoid 用在隐藏层,除非是极浅的模型或者某些时间序列回溯场景。记得有一次我为了复现一个早期论文里的结构,把隐藏层激活函数写成了 Sigmoid,结果训练到第 30 个 epoch 时 loss 还在原地抖动。换成 Tanh 之后,同一个结构、同样的数据,十几个 epoch 就有了明显下降。从那之后我就养成了习惯:只要看到模型收敛异常,先检查隐藏层激活函数是不是用错了方向。
2.2 ReLU 家族:从软稀疏到可学习斜率
ReLU 公式是 f(x) = max(0, x)。x>0 时导数为 1,x<0 时导数为 0。它最大的贡献是解决了深层网络中梯度消失的问题——正区间的梯度恒定是 1,不会像 Sigmoid 那样越乘越小。它还能让一部分神经元输出精确为 0,形成稀疏激活,计算上也比指数函数便宜太多。
但 ReLU 有个著名的死亡问题:如果某个神经元在训练过程中不断收到负输入,它的梯度一直为 0,权重再也无法更新,等于这个神经元从网络中消失了。我在真实项目里见过神经元的输出长期卡在 0,打印统计信息时发现超过三成的神经元从不激活。这种情况下,Leaky ReLU 往往能立刻奏效。
Leaky ReLU 在负区间用了一个很小的斜率 αx(通常 α=0.01 或 0.1),让负区间还有一点梯度可以回流。Keras 里可以直接用keras.layers.LeakyReLU(alpha=0.3)这样的高级层,也可以直接用activation='relu'配合负斜率设置。PReLU 则进一步把负区间的斜率 α 当作可学习参数,由反向传播自动更新。它在一些大规模图像分类任务上报告过增益,但也引入了更多参数,小数据量时容易过拟合。
ReLU 家族的图像有一个非常醒目的共同特征:在 x=0 处有一个明显的折点。这个折点虽然让函数不是处处可导,但实践中并没有带来太大问题,反而因为“硬折”让神经元状态变得更加明确。如果你喜欢更平滑的版本,可以考虑下面要说的 ELU 和 Swish。
2.3 ELU 与 SELU:让负值区间有意义的尝试
ELU 在 x>0 时等于 x,在 x≤0 时等于 α(e^x - 1)。它和 ReLU 的区别是负区间的输出不是恒为 0,而是平滑地趋向于 -α。这个设计让输出均值更接近 0,同时保证了负区间的导数不为 0,缓解了神经元死亡问题。代价是需要计算指数函数,训练速度稍慢。
从图像上看,ELU 在负半轴的曲线是一个平滑的弯曲段,不像 Leaky ReLU 那样是直线。这个平滑特性让 ELU 的抗噪声能力更好,因为输入在 0 附近小幅抖动时,输出不会像 ReLU 那样出现剧烈的从 0 跳到正值的切换。
SELU 是 ELU 的缩放版本,其设计目标是让网络输出在深层传播时自动归一化,均值和方差保持稳定。使用 SELU 时,Keras 官方建议配合专用的初始化方式,否则很难发挥其特性。这里我要说一句:SELU 不是一个“用就完事”的激活函数,它对网络结构有一定约束,比如不建议搭配 Dropout 的某些模式使用。如果你只是想快速提点效果,SELU 不一定比 ELU 划算。
Keras 里除了把激活函数名传给activation参数,还有一批专门的激活层,比如Activation、LeakyReLU、PReLU、ELU、ThresholdedReLU。这些高级层的好处是能携带训练参数(比如 PReLU 的 alpha),或支持动态行为,因此在需要把激活函数作为单独一层插入时,它们比字符串写法更灵活。我在实现一些带条件分支的网络结构时,经常需要把激活层单独拿出来做控制流处理,这时候高级层就比字符串参数方便很多。
2.4 Swish、Mish 与 GELU:平滑型激活函数的崛起
Swish 的公式是 f(x) = x·sigmoid(x),在 x 约 -1.2 的地方有一个小的平滑低谷,整体曲线介于 ReLU 和 Sigmoid 之间。它的特性是“无上界、有下界、平滑、非单调”,这种非单调性让负输入也能产生微小输出。Swish 在深层网络上的表现通常比 ReLU 更稳定,训练时收敛曲线的抖动也更小。
Mish 的公式是 f(x) = x·tanh(softplus(x)),曲线形状和 Swish 类似,但低谷更明显,平滑度更高。在图像分类、目标检测等任务中,不少开源实现报告过 Swish 和 Mish 比 ReLU 有少量提升,但这种提升在浅层模型上基本可以忽略,反而是深层模型收益更大。
GELU 的公式是 f(x) = x·Φ(x),其中 Φ(x) 是标准正态分布的累积分布函数。它本质上是把输入按概率做门控,很多 Transformer 结构里的 FFN 层默认就在用它。Keras 内置了 GELU,可以直接用activation='gelu'调用。从图像上看,GELU 的形态介于 ReLU 和 Swish 之间,在 0 点附近比 ReLU 平滑,负半轴也不是完全熄灭。
这三类平滑激活函数有一个共同的图像特征:曲线在负半轴不是直线也不是完全归零,而是缓慢过渡。这种“软”的特性使得梯度在整个定义域内都相对有信息量,特别适合深层网络和需要稳定梯度的结构。当然,代价是计算量更大,对硬件不那么友好。
2.5 Softmax 与输出层的激活函数选择
Softmax 通常被当作分类问题的输出层激活函数。它把一组任意实数映射成和为 1 的概率分布。和前面所有激活函数不同的是,它是向量输入、向量输出,不是一个元素一个元素地处理。所以画 Softmax 的图像时,通常要固定某一维输入,观察其余维度的响应曲线。Keras 里可以直接传字符串activation='softmax'。
在二分类问题中,输出层可以用 Softmax(2 个神经元),也可以简化为 Sigmoid(1 个神经元)。多分类用 Softmax;回归问题输出层一般不用激活函数,或者用 linear;多标签分类一般每个输出节点用 Sigmoid。我经常提醒新手:输出层选错激活函数,即使隐藏层设计得再好,训练出来的分布也是错的。比如把隐藏层的 Swish 误用在输出层做回归,很可能会导致输出范围被压缩到 0 到几之间,跟真实值尺度对不上。
3. 用 Python 批量绘制激活函数图像
3.1 准备工作:环境与依赖
要完成这件事,只需要三样东西:一个能跑 Python 的环境,安装好带 Keras 接口的深度学习框架,装好 Matplotlib。
装依赖直接执行:
pip install tensorflow keras matplotlib numpy注意版本匹配问题。深度学习框架和 Keras 的版本如果差太多,某些高级激活层可能找不到。近几年 Keras 已经作为独立的keras包发布,底层可以接不同的后端实现。为了减少兼容性问题,我习惯在一个新的虚拟环境里安装,而不是直接升级全局环境。如果你之前已经装过 TensorFlow,只要版本不是太老,通常import keras就能直接用内置激活函数。
3.2 一套自包含的绘图脚本
下面这个脚本会画出 8 个常用激活函数:Sigmoid、Tanh、ReLU、ELU、Leaky ReLU、Swish、Mish、GELU。每个子图里同时包含函数曲线和导函数曲线,这样能同时看到取值和梯度变化。
import numpy as np import matplotlib.pyplot as plt from keras import activations x = np.linspace(-6, 6, 1200).astype(np.float32) def numerical_derivative(f, x, h=1e-4): return (f(x + h) - f(x - h)) / (2 * h) funcs = { "Sigmoid": activations.sigmoid, "Tanh": activations.tanh, "ReLU": activations.relu, "ELU": activations.elu, "LeakyReLU": lambda t: activations.relu(t, negative_slope=0.1), "Swish": activations.swish, "Mish": activations.mish, "GELU": activations.gelu, } rows, cols = 2, 4 fig, axes = plt.subplots(rows, cols, figsize=(18, 8)) for ax, (name, f) in zip(axes.flat, funcs.items()): y = f(x) dy = numerical_derivative(f, x) ax.plot(x, y, linewidth=2.2, label=name) ax.plot(x, dy, linestyle="--", linewidth=1.2, color="gray", label="导数") ax.axhline(0, color="black", linewidth=0.4) ax.axvline(0, color="black", linewidth=0.4) ax.set_title(name) ax.legend() ax.grid(True, alpha=0.3) plt.tight_layout() plt.show()这段代码跑起来很快,基本上从运行到出图也就是一两秒。如果你用的是无界面服务器环境,把plt.show()换成文件保存方式即可。
3.3 代码里的几个关键细节
np.linspace(-6, 6, 1200)生成从 -6 到 6 的均匀采样点。1200 个点是为了让曲线平滑,同时保证数值求导时差分步长不会因为采样点太少而失真。我试过用 200 个点和 1200 个点对比,200 个点虽然也能出图,但曲线边缘会出现锯齿,尤其是在 Swish 和 GELU 这类曲线变化较快的位置。
numerical_derivative用中心差分法近似导数,比前向差分(f(x+h)-f(x))/h精度高一个数量级。如果你需要严格精确的导数,可以手写每个函数的导数公式,但画曲线图用中心差分已经足够,而且这个函数可以复用给任意激活函数,不需要为每个函数单独写导数。
在funcs字典里,LeakyReLU 不是用高级层实现的,而是直接给activations.relu传了negative_slope参数。Keras 内置函数的调用方式比较统一,这意味着你可以把任意一个激活函数名替换成自己的实验函数,只要保证它接收 Tensor 并返回同样形状的 Tensor 即可。
用 Matplotlib 的subplots一次画出 2 行 4 列的子图,每个子图里把原函数画成实线,导数画成灰色虚线,这样既能快速对比函数形态,也能一眼看到导数归零的区域在哪里。如果你想把图像保存成文件,把plt.show()换成:
plt.savefig("activation_functions.png", dpi=150, bbox_inches="tight")3.4 图像上常见的观察结论
我每次给团队同学展示这套图,大家几乎都会注意到几个共性规律。
Sigmoid 的两端导数基本贴着横轴,这种区域在图像上看非常直观——曲线越平缓,反向传播的梯度就越趋近于零。Tanh 虽然形状类似,但中心附近的导数明显更陡,这就是它在实践中比 Sigmoid 更好用的原因之一。ReLU 图像则像一个“转折开关”,负半轴完全是一条水平线,正半轴是 45 度直线,这个锐利的折点让它的数值性质非常清晰。
Swish 和 Mish 的曲线整体比 ReLU“圆润”,没有明显折点,而且在负半轴会先下降再回升,形成一个浅谷。这个浅谷就是非单调性的体现。以前总觉得非单调是个坏事,看了图像才明白,这个浅谷恰恰让神经元在负区间能保留一点响应能力,同时不会像 Leaky ReLU 那样让负区间输出无限下探。GELU 的曲线更接近 ReLU 的“软版本”,在 0 点附近有一个平滑的过渡段。
这些观察不需要背公式,只要把图像在脑子里过一遍,以后遇到模型不收敛时,就能很快联想到“是不是有太多神经元落在饱和区或者死区”。
4. 激活函数在 Keras 模型里的落地姿势
4.1 三种激活函数注册方式及适用场景
Keras 里接激活函数有三种主要姿势。
第一种,在层创建时直接传字符串:
model.add(Dense(64, activation="relu"))第二种,使用Activation层,把激活当作独立网络层:
model.add(Dense(64)) model.add(Activation("relu"))第三种,使用高级激活层:
model.add(Dense(64)) model.add(LeakyReLU(alpha=0.2))三种方式在图上等价,但使用场景不同。方式一最简洁,适合原型验证;方式二适合让可视化网络图更清晰;方式三适合带参数的激活函数,比如 LeakyReLU 负斜率调参、PReLU 训练 alpha、ELU 固定 α。我在实际项目中,如果只是临时测试,会用字符串写法;如果已经决定某个激活函数要在多个层复用,我会单独定义一个函数或 Layer 把它包起来。
还有一种进阶用法,是在Sequential或函数式 API 里,对某个层的输出单独应用激活函数。比如在注意力机制中,经常需要把某个中间张量映射到 0-1 区间做门控,这时候可以单独写一行x = Activation('sigmoid')(x),不必把激活函数和全连接层绑在一起。这种写法更灵活,也更容易在模型结构图上看出每个分支的语义。
4.2 输出层激活函数与损失函数的匹配表
我整理了一个快速对照表,基本覆盖日常任务类型。
| 任务类型 | 输出层激活函数 | 损失函数 | 说明 |
|---|---|---|---|
| 二分类 | Sigmoid | binary_crossentropy | 输出单节点概率 |
| 多分类 | Softmax | categorical_crossentropy | 输出节点数=类别数 |
| 多标签分类 | Sigmoid(每个节点) | binary_crossentropy | 每个节点独立概率 |
| 回归 | linear 或无激活 | mse / mae | 输出未压缩数值 |
| 泊松回归 | 指数/softplus | poisson | 输出需为正数 |
这张表不是随口说说的,它背后是损失函数和激活函数两者需要匹配的问题。比如多分类如果用 ReLU 作为输出,不仅不能正确产生概率分布,反向传播时导数恒为 1 还会让损失无法正确作用到分类边界上。
另外,有一些新框架或高级 API 会在内部自动把损失函数和输出层激活融合,比如你指定from_logits=True时,模型会假设最后一层没有经过 Softmax,损失函数自己处理数值稳定性。这种设计在数值上更稳,但在动手写 Keras 模型之前,你需要先搞清楚自己的 API 版本到底期望你传原始 logits 还是已经过 Softmax 的概率值。我做项目时吃过这个亏,明明网络结构一样,因为激活函数重复写了一层,导致训练曲线怎么都平滑不下来。
4.3 可视化如何帮我解决真实调参问题
这一点是我自己体会最深的。有一次我训练一个网络,loss 降到一定程度就不再下降。当时怀疑是数据问题,反复清洗数据都没效果。后来我把每个隐藏层的激活函数输出分布画出来,发现绝大多数神经元输出都集中在 0 附近,而且偏负,说明很多 ReLU 单元实际已经死亡。换用 LeakyReLU 之后,loss 继续下降了几个点。这个排查过程,如果不是提前把激活函数的图像和导数图像存在脑子里,是很难第一时间想到这个方向的。
做可视化还有一个用途:观察过拟合。在训练集上画出各层输出的数值范围,如果某些层的输出方差过大,说明内部激活分布开始失控,这时候可以考虑往激活函数前加 BatchNorm,或者换成带平滑性质的激活函数,比如 Swish、Mish。这些判断不一定每次都对,但多一张图,就是多一个可靠的证据。
我现在几乎每个项目都会在训练初期加一段“激活函数可视化”环节。数据量比较大的时候,可能不会每层都画,但至少会把输入层附近和输出层附近的关键激活函数曲线放在一起对比。别小看这个步骤,它能帮你在训练之前就排除掉一批明显不合适的选择,省下大量调参时间。
5. 激活函数选型与排障实录
5.1 高频踩坑:神经元坏死、梯度爆炸与分布漂移
ReLU 神经元坏死是最高频的问题。我见过一个训练过程,明明 loss 在下降,但打印模型中间层发现一半以上的输出恒为 0。原因就是学习率设置偏高,权重更新幅度过大,让大量神经元长期处于负输入区间。解决思路一般是三个方向:调低学习率、换成 LeakyReLU 或 ELU、加入 BatchNorm 层。
梯度爆炸在深层网络中也很常见,尤其是没有归一化层的网络。它的表现是 loss 在某个 epoch 突然变成 NaN。用激活函数图像来看,ReLU 正区间导数为 1,深层反向传播时会累加,配合大学习率容易爆炸。使用带饱和特性的激活函数(如 Tanh、Sigmoid)可以压制,但会引入梯度消失;更好的做法是用残差结构、归一化层、梯度裁剪等多管齐下。
分布漂移是另一个容易被忽视的问题。有些激活函数输出范围是无限大的,比如 ReLU 的正区间没有上界,如果前一层权重初始化不当,激活值很快就会膨胀到几百甚至几千。这时候损失函数计算出的数值会异常大,梯度也会跟着异常,训练几乎无法进行。我在画激活函数图像时特别注意这个维度:函数的输出范围越宽,就越需要配合归一化层来控制网络内部的数值范围。
5.2 一套通用的激活函数排查流程
如果模型训练出了问题,我建议按下面这个顺序排查:
- 先用极小的数据量(比如 32 个样本)做一轮过拟合测试,确认模型本身有学习能力。
- 训练过程中打印每层的输出均值、方差,或者直接画直方图,看激活值是否发散或大量归零。
- 检查输出层的激活函数和损失函数是否匹配。
- 比较不同激活函数的训练曲线,用控制变量法观察收敛速度和最终指标。
- 如果某层输出出现大量极端值,优先考虑加归一化或者调整初始化方式。
这个顺序基本能定位绝大多数激活函数相关的问题。尤其是第 2 步,很多同学会忽略。模型训练时只看 loss 和 accuracy,很少看中间层的激活值分布,结果问题出在内部数值分布已经崩了,外面却还在纠结学习率。打印激活值分布并不复杂,Keras 里可以用回调函数在训练过程中定期回调某些层,把输出张量做一次mean和std统计,几行代码就能完成。
5.3 我给项目用的默认组合建议
根据我的经验,在没有特殊要求时,我的默认选择是这样:
- 隐藏层首选 ReLU,简单高效;网络加深或者训练不稳定时换 LeakyReLU 或 Swish。
- 分类任务输出层用 Softmax,二分类用 Sigmoid,回归用 linear。
- 需要训练速度兼顾稳定性时,在深层卷积网络里用 Swish 或 Mish,感受会比较明显。
- 在时间序列模型里,我习惯先试 Tanh。LSTM、GRU 这类门控结构本身设计就偏向于 Tanh 语义,强制换 ReLU 反而容易出问题。
- 注意力机制中的门控权重,用 Sigmoid;特征变换之间的非线性,用 GELU 或 Swish。
没有一种激活函数是万能的。最好的方式是先在自己设备上跑出所有候选函数的图像和训练曲线,再根据任务特征做选择。数据量、网络深度、优化器、学习率都会影响最终结果,只看别人论文里的激活函数排名,不如自己做一次可视化对比来得踏实。
我个人做项目时一直保留一个习惯:每次尝试一种新的激活函数,都会顺手把它在训练过程中的输出分布画出来。刚开始可能觉得麻烦,但看得多了,你对激活函数形态的记忆就会转化成一种直觉,模型出问题时,你甚至不需要打印日志,光看 loss 曲线的拐点特征就能猜个八九不离十。这算是画激活函数图像带来的一项隐藏收益吧。