有一段时间,我做某个图像分类小项目,训练曲线一直下不去。层数、学习率、优化器都调过了,最后才怀疑到激活函数头上。说实话,激活函数的公式我都能背,但真到选型时,帮上忙的居然是“看图像”。
这次我用 Python 和 Keras 把常用激活函数全部画了出来,包括 Sigmoid、Tanh、ReLU 家族、ELU、Swish、GELU 这些。画完以后很多问题一下子清楚了:为什么 Sigmoid 堆多了梯度消失,为什么 ReLU 会有“死区”,为什么 LeakyReLU 常常是备胎首选。本文就把这套方法、完整代码和相关图像特征整理出来,适合正在用 Keras 做模型、被训练不收敛或收敛过慢折磨的朋友。看完你也能自己动手,把任何激活函数“拍一张照”。
1. 为什么要把激活函数“画”出来看
1.1 光背公式很难建立直觉
激活函数的数学表达式并不复杂。Sigmoid 是 $1/(1+e^{-x})$,ReLU 是 $\max(0,x)$,Tanh 是双曲正切。但公式只告诉我“在某个点等于多少”,没有告诉我“整个变化趋势意味着什么”。
举个具体例子:Sigmoid 在 $x=0$ 处的导数是 0.25,这很多人都知道。但真正影响训练的是:当 $x$ 走到 3 或 -3 时,导数已经衰减到 0.05 左右;走到 5 以上时,梯度基本就是 0。这个“梯度几乎为 0”的区域有多大,光看公式很难有体感,但图像一出来,两条曲线一对比,你会立刻记住:Sigmoid 两端是大片饱和区。
我在调模型时经常遇到一种情况:网络层数一多,Loss 曲线先降一点点,然后长时间横盘。这时候去翻每一层的梯度,发现越靠近输入层梯度越小。原因往往就是中间藏了几个 Sigmoid,梯度在反向传播时被连续“压缩”。如果把 Sigmoid 的导数曲线打印出来贴在显示器旁边,这个问题早期就能被识别出来。
1.2 看图像比看数值更接近“直觉”
数值表当然也能反映函数行为,比如列出 x = -5 到 5 的输出值。但人脑处理曲线走势的能力比处理一长串数字强得多。曲线能一眼告诉你:
- 函数有没有上界、下界;
- 在哪个区间变化剧烈,哪个区间趋于饱和;
- 导数是恒正、恒负,还是分段变化;
- 是否有不可导的转折点;
- 负半轴是“死区”还是“漏电区”。
这些特征直接对应到神经网络的训练表现。比如下界为负、上界为正的激活函数(Tanh、SELU 等)输出天然接近零中心化,下一层更不容易全部收到同号输入;而恒正的 Sigmoid 会导致后层输入全部为正,这在深度网络中会引发参数更新方向受限的问题。
所以我强烈建议:不要只背公式,画一次图。画完你对每个函数的“性格”会有一个完全不同的理解,能直接用图像语言去判断某层该用什么激活函数。
2. 用 Keras 搭一个“激活函数取景器”
2.1 环境准备:tensorflow + matplotlib
绘图脚本依赖很轻,核心就三个库:
pip install tensorflow numpy matplotlibPython 版本建议 3.9-3.11,TensorFlow 2.10 以上都可以。我本地用的是 TensorFlow 2.12,下面的代码对 2.x 系列基本通用。如果你用的是 Keras 3.x,需要确认后端是 TensorFlow 即可,API 层面这里用到的基础接口没有变化。
Matplotlib 版本没有硬性要求,中文字体可能需要额外处理,但本文图像标签我直接用英文,省去字体烦恼。
2.2 核心思路:让 Keras 自己算结果,而不是手推公式
你可能会说:“画激活函数图像,自己用 numpy 实现一下公式不就行了?”
理论上可以,但这里有一个很关键的理由,让我坚持用 Keras 内置激活层来计算:Keras 里的激活函数实现,和你印象中的“标准公式”可能不是完全一致的。
举两个例子:
leaky_relu在tf.nn.leaky_relu中的默认斜率是 0.2,而keras.layers.LeakyReLU的默认斜率是 0.3。如果你自己用 0.01 当斜率手画一条曲线,画出来的根本不是 Keras 模型里实际用的那个激活函数。selu是elu的缩放版本,缩放系数是固定常数。手推容易漏掉缩放因子,导致图像和 Keras 实际行为对不上。gelu在 TensorFlow 里有精确版本和近似版本之分,不同版本图像在尾部有细微差异。
为了避免“你画的是一套,模型里跑的是另一套”,直接让 Keras 自己计算激活输出最稳妥。我们要做的只是:给模型一个单输入单输出的激活层,喂进去一段均匀分布的 x 值,把输出 y 取出来画图。
2.3 通用绘图函数与代码
代码结构很简单,核心函数不到 20 行:
import numpy as np import matplotlib.pyplot as plt import tensorflow as tf from tensorflow.keras import layers, Model def make_activation_model(activation): """构建一个只有激活层的 Keras 模型""" inp = layers.Input(shape=(1,)) out = layers.Activation(activation)(inp) return Model(inp, out) def plot_activation(activation, save_path, x_range=(-6, 6), num_points=500): """绘制激活函数曲线及其导数曲线""" x = np.linspace(x_range[0], x_range[1], num_points).reshape(-1, 1) model = make_activation_model(activation) y = model.predict(x, verbose=0).flatten() x_flat = x.flatten() # 用 numpy 差分近似导数,足够观察整体趋势 dy = np.gradient(y, x_flat) fig, ax = plt.subplots(figsize=(7, 5)) ax.plot(x_flat, y, label="y", linewidth=2.5) ax.plot(x_flat, dy, "--", label="dy/dx", linewidth=1.5) ax.axhline(0, color="gray", linewidth=0.8) ax.axvline(0, color="gray", linewidth=0.8) ax.set_xlabel("x") ax.set_ylabel("y") ax.set_title(activation) ax.legend() ax.grid(alpha=0.3) fig.savefig(save_path, dpi=150, bbox_inches="tight") plt.close(fig)调用方式更简单:
for name in [ "sigmoid", "tanh", "relu", "leaky_relu", "elu", "selu", "softplus", "softsign", "swish", "gelu", ]: plot_activation(name, f"{name}.png")运行完,当前目录会多出 10 张 PNG 图片,每张图上同时有激活函数实线和导数虚线。
2.4 为什么要把导数曲线叠加在一起
不少教程只画函数曲线,不画导数。但我觉得导数曲线才是判断训练行为的关键。
反向传播的过程,本质上是损失对每层输入的偏导,逐层往回乘。这个“乘”的过程中,激活函数的导数扮演核心角色。如果导数在某段区间内接近 0,那么该区间对应的神经元即使被激活,对梯度传播也几乎没有贡献;一旦很多层都饱和,整体梯度就会指数级衰减。
把导数曲线和函数曲线画在同一张图上,你就能直观看到:哪个区域“在干活”,哪个区域“在沉睡”。这正是选择激活函数时最重要的参考信息。
np.gradient是数值差分近似,可能在高频噪声区域不太精确,但激活函数都是光滑或分段光滑的,500 个采样点足够还原真实走势,完全够用。
3. 饱和型激活函数:Sigmoid 与 Tanh 的图像语言
3.1 Sigmoid:两端平缓输出,且不关于原点对称
Sigmoid 的图像应该是最早被大家认识的:一条从 0 上升到 1 的 S 形曲线。
画出来之后,有几个细节值得盯住看:
- 值域是
(0, 1),也就是说输出永远正数; - 曲线在 x=0 附近斜率最陡,但最大导数也只有 0.25;
- 从 x=3 左右开始,曲线基本趋于水平,导数降到 0.05 以下;
- x=±6 时,导数已经小到几乎看不见。
这些图像特征直接解释了三个训练中的经典问题:
第一,梯度消失。多层感知机如果隐藏层全用 Sigmoid,反向传播时每一层梯度都要乘一个小于等于 0.25 的数,十层下来梯度变成原来的 $0.25^{10} \approx 9.5 \times 10^{-7}$,早没了。这就是训练曲线横盘的重要原因。
第二,输出非零中心化。Sigmoid 输出全部为正,下一层的输入全部为正,在反向传播时权重梯度往往会同号,导致参数更新轨迹呈锯齿状,收敛速度变慢。
第三,计算开销大。虽然现代设备上指数运算不是瓶颈,但对比 ReLU 的“取最大值”,Sigmoid 显然更重。
但 Sigmoid 也不是一无是处。二分类输出层配合binary_crossentropy,它输出的概率解释性仍然不可替代。图像告诉我们:只要把它放在最后一层,中间层不要碰它,问题就不大。
3.2 Tanh:零中心化解决了半个问题
Tanh 的图像和 Sigmoid 很像,但有两个关键变化:
- 值域变成
(-1, 1),穿过原点,关于原点对称; - 最大导数是 1,比 Sigmoid 的 0.25 大了四倍。
画出来以后,你会看到它在 x=0 附近更“陡峭”,也就是说同样数量的输入变化,Tanh 能产生更大的梯度响应。对于中等层数的网络,Tanh 比 Sigmoid 更容易训练,这就是原因。
但要注意,Tanh 的两端依然是饱和区。x 到 ±3 以后,导数同样快速衰减到接近 0。所以它只是“半个解决方案”:解决了零中心化,没有解决饱和问题。在很深的网络中,Tanh 照样会出现梯度衰减,只是比 Sigmoid 慢一些。
我自己的经验是:Tanh 在 RNN 类模型和某些自编码器的中间表示里仍然有价值,但在标准 CNN 或全连接网络中,它更多被 ReLU 家族取代。图像上最直观的对比就是:ReLU 正半轴导数恒为 1,而 Tanh 只有在原点附近才能接近 1。
| 激活函数 | 值域 | 最大导数 | 零中心化 | 两端饱和 | 典型场景 |
|---|---|---|---|---|---|
| Sigmoid | (0, 1) | 0.25 | 否 | 是 | 二分类输出层 |
| Tanh | (-1, 1) | 1 | 是 | 是 | RNN、自编码器 |
4. ReLU 家族的图像形态与“死亡区”
4.1 ReLU:正半轴一条直线,负半轴一条水平线
ReLU 的图像是所有激活函数里最简单的:负半轴 y=0,正半轴 y=x,在原点处有一个转角。
但这张“简单”的图像承载了非常重要的意义。它的导数在正半轴恒为 1,意味着反向传播经过 ReLU 层时,梯度不会再被额外压缩。这是它能成为默认选择的核心原因,数学上比 Sigmoid 和 Tanh 干净得多。
不过图像也精准暴露了 ReLU 的问题:负半轴导数恒为 0。一旦某个神经元的输入落到负区间,梯度就是 0,权重无法更新。如果大量神经元同时落入这个状态,网络就进入“死亡 ReLU”状态,表现为训练一段时间后某些层的输出几乎全是 0。
实际项目中,过大的学习率很容易引发这种情况。因为学习率过大会让权重更新幅度过大,进而让更多输入变成负数,接着这些神经元被“永久关闭”。
图像上怎么识别呢?如果你打印某个 Dense 层的输出,发现大部分神经元输出都是 0,且经过多个 epoch 都不恢复,那就要考虑死区问题。
4.2 Leaky ReLU 与 PReLU:给负半轴留一条活路
Leaky ReLU 图像看起来就是 ReLU 的“漏电版”:负半轴不再是水平 0 线,而是一条斜率很小的斜线。
# 在 Keras 中直接使用内置名字即可 plot_activation("leaky_relu", "leaky_relu.png", x_range=(-6, 6))在 TensorFlow 的tf.nn.leaky_relu中,默认 alpha 为 0.2,所以画出来负半轴的直线斜率约 0.2,并不是很多人默认的 0.01。如果你打开 Keras 的LeakyReLU层,会发现keras.layers.LeakyReLU默认 alpha 又变成了 0.3。这两个默认值不一样,实际使用中要看清楚,别被名称迷惑。
PReLU 是更进一步的版本,alpha 不是固定超参,而是可训练参数。模型会自己学出负半轴的斜率。这种灵活性让它在一些任务上表现比 Leaky ReLU 更好,但也增加了过拟合风险。画 PReLU 图像时,只能画某个固定 alpha 下的形态,比如 alpha=0.25 或 0.5;真实模型训练完后要再单独画一次。
我的经验是:如果 ReLU 出现明显的死亡神经元,第一替换项就是 Leaky ReLU,因为它改动最小、几乎不会引入额外调参复杂度,图像上也很好理解——负半轴有没有斜率,决定了这个神经元是“真死”还是“假死”。
4.3 ELU 与 SELU:负半轴是一条曲线滑梯
ELU 的图像更有意思:正半轴是直线,负半轴是一条渐近到负常数的曲线,而且在原点处是平滑连接的。
plot_activation("elu", "elu.png") plot_activation("selu", "selu.png")Keras 中 ELU 默认 alpha=1.0,所以 x 趋向负无穷时,y 趋向 -1。这个“有下界”的特征,让 ELU 既能缓解死亡 ReLU,又不会让输出无限制地变成很大负数。同时,它的导数在负半轴不是固定常数,而是逐步变化,理论上能让梯度更平滑地流经负区间。
SELU 是 ELU 的缩放版本,图像看起来“更高一些”:正半轴不再是 y=x,而是 y=scale*x;负半轴渐近线也不再是 -1,而是 scale * alpha * (-1),大约在 -1.758 左右。
用 SELU 有一个非常容易忽略的坑:它只有在配合lecun_normal初始化、并且网络结构比较“规整”时,才能实现自归一化。如果你在 Keras 里直接用Dense(64, activation='selu'),却忘了设置kernel_initializer='lecun_normal',那 SELU 的优势基本发挥不出来。这个细节,画完图之后反而更容易记住。
| 激活函数 | 负半轴行为 | 正半轴行为 | 原点平滑 | 主要优势 |
|---|---|---|---|---|
| ReLU | 恒为 0 | y=x | 否 | 梯度不衰减,简单 |
| Leaky ReLU | 固定斜率斜线 | y=x | 否 | 缓解死亡 ReLU |
| PReLU | 可学习斜率 | y=x | 否 | 自适应负斜率 |
| ELU | 渐近到 -alpha | y=x | 是 | 平滑、零均值更接近 |
| SELU | 渐近到约 -1.758 | y=scale*x | 是 | 自归一化 |
5. 新派平滑激活函数:Swish、GELU、Softplus 与 Softsign
5.1 Swish/SiLU:无上界但有下界的 S 形平滑曲线
Swish 的定义是 $x \cdot \text{sigmoid}(x)$,在 Keras 里可以直接用"swish"或者"silu"。它的图像相当有辨识度:
- x 很小时,y 趋近 0,但不是完全等于 0;
- x 在 -1 附近时,y 会出现一个小幅下探,最低点约为 -0.28;
- x 为正后,曲线进入近似线性的增长,且没有上界。
这种“有下界、无上界、处处平滑”的组合,正好弥补了 ReLU 在负半轴完全死亡的问题,同时保留了正半轴梯度不错的特性。它在深层卷积网络和 Transformer 类结构里,经常被用来替代 ReLU。
图像上看它的导数很有意思:导数在负半轴不是恒 0,而是有一段大于 0、一段小于 0,最终接近 0,然后再回升。这个“中间凹陷”的形状,让负区间的神经元也能获得一定的梯度信号,不至于直接死亡。
VGG 级别的纯卷积网络里用 Swish 通常能带来稳定的提升,但代价是计算量比 ReLU 大。图像上那点平滑优势是否值得换取额外计算,需要根据项目规模做权衡。
5.2 GELU:一条“软饱和”的类 ReLU 曲线
GELU 的完整定义是 $x \cdot \Phi(x)$,其中 $\Phi(x)$ 是标准正态分布的累积分布函数。TensorFlow 里也提供了近似实现。图像上看,它有点像一个被“磨圆了棱角”的 ReLU:
- x 很小时,y 缓慢逼近 0,不是瞬间切断;
- x 很大时,y 约等于 x;
- 在 0 附近,曲线是圆滑过渡,而不是 ReLU 那种硬转折。
为什么 GELU 会有效?它本质上在用输入的“百分位”作为门控信号:当 x 越小,被保留的概率越低;x 越大,被保留的比例越高。这种软门控思想和 Dropout 有异曲同工之妙,都是让模型对不同输入自适应地决定保留或丢弃信息。
在 Transformer 和不少现代 CNN 里,GELU 已经成为隐藏层标配。画图时注意:TensorFlow 2.4 以上才比较稳妥地支持内置的"gelu",如果你的环境版本旧,需要手动实现或升级。
5.3 Softplus 与 Softsign:两个“软”字开头的经典
Softplus 是 $\log(1+e^x)$,图像可以看成 ReLU 的平滑版本。画出来的曲线在负半轴逐渐逼近 0,正半轴则缓慢向直线 y=x 靠拢。但它和 ReLU 有一个显著差异:即使在正半轴,Softplus 的导数也始终小于 1,只是越来越接近 1。这意味着它对梯度同样有轻微的“压缩”作用。
Softsign 是 $x/(1+|x|)$,图像长得像 Tanh,但比 Tanh 更平缓,收敛速度也更慢。它的值域同样是(-1,1),但两端饱和的过程更加渐进,没有明显的“陡峭中心”。
这两个函数在现代深度网络里不常见,但它们在经典浅层模型、某些对输出平滑度要求较高的任务里仍然有使用空间。图像对比时,最容易记住的是:Softplus 和 ReLU 的形态差异集中在 x 接近 0 的区域,Softsign 和 Tanh 的差异则集中在收敛速度上。
6. Keras 中的实际选择与踩坑记录
6.1 从图像特征反推选型规则
画完这些图后,我建立了一套很实用的选择逻辑,分享出来供你参考:
- 二分类输出层:用
sigmoid,因为它输出可解释为概率; - 多分类输出层:用
softmax,注意这是向量函数,不要用单输入单输出的方式去画; - 回归任务输出层:通常不加激活函数,或者用
linear; - 隐藏层默认:先用
relu,如果出现死亡神经元,换leaky_relu; - 深度较大、希望梯度更平滑:考虑
swish或gelu; - 需要自归一化且愿意配合初始化设置:
selu+lecun_normal; - RNN 类模型:中间层常用
tanh,因为它输出有正有负,更适合门控结构。
| 任务类型 | 输出层激活 | 隐藏层参考 |
|---|---|---|
| 二分类 | sigmoid | relu / leaky_relu |
| 多分类 | softmax | relu / swish / gelu |
| 回归 | 无 (linear) | relu / elu |
| 自编码器 | tanh / linear | tanh / elu |
6.2 在 Keras 中部署与自定义激活函数
Keras 里使用激活函数有三种常见方式,都很好用:
第一种,直接写在层里:
model = tf.keras.Sequential([ layers.Dense(64, activation="relu"), layers.Dense(10, activation="softmax"), ])第二种,用独立的Activation层:
model.add(layers.Dense(64)) model.add(layers.Activation("swish"))第三种,自定义激活函数并直接传进去:
import tensorflow as tf from tensorflow.keras import layers def mish(x): return x * tf.math.tanh(tf.math.softplus(x)) model = tf.keras.Sequential([ layers.Dense(64, activation="relu"), layers.Dense(32, activation=mish), ])自定义函数注意一点:如果函数里有需要训练的变量,比如 PReLU 那种,需要封装成Layer,而不是简单的lambda。画图时也只能得到初始状态下的曲线,训练后要重新绘图观察变化。
6.3 画图与用图过程中的避坑记录
最后说几个我实际踩过的坑:
第一,预测输入必须保持二维。Activation层默认输入形状(None, 1),所以给它的 x 要reshape(-1, 1),只传一维数组会报维度错误。
第二,注意 keras 内置激活函数名的版本差异。老版本 Keras 可能没有"gelu"、"swish",运行时会直接报错。可以先打印tf.keras.activations.__dict__确认,或者直接升级 TensorFlow。
第三,leaky_relu默认斜率有迷惑性。tf.nn.leaky_relu的 alpha 默认 0.2,keras.layers.LeakyReLU默认 0.3。画图时先确认你用的是哪个接口,否则图像会和模型实际行为不符。
第四,不要试图用单输入单输出的方式画softmax。Softmax 的结果依赖整个输入向量,它不是一个“逐元素函数”。真想看它的行为,应该固定向量其他分量,画某个分量的输出曲面,或者直接看 2D 情况下的联合输出分布。
第五,x 的范围不能太小。如果只画 -2 到 2,Sigmoid 和 Tanh 的饱和区根本看不到,很容易误以为它们在整个定义域上都很“陡”。我一般用 -6 到 6,采样点 500 个,既能看清中心区域,也能看清两端的饱和形态。
我保留了这个绘图脚本,每次纠结激活函数选型时先跑一遍,几秒钟出图。有一次做某图像处理 Demo,模型老不收敛,最后发现就是隐藏层误用了 Sigmoid。画完图看到那两条“平坦的尾巴”,一切都明白了——换成 ReLU 后,同样配置下损失很快开始下降。这种直觉,不靠公式,靠的就是图像在脑子里留下的形状。建议你也把这份脚本存好,它值得成为你调参工具箱里的常备工具。