如果你调试一个训练不收敛的网络,第一步会检查什么?学习率、初始化、数据预处理——这些我闭着眼都能列出来。但有一回我调一个图像分类模型,怎么调 loss 都掉不下去,最后把隐藏层的 Sigmoid 全换成 ReLU,指标立刻开始松动。打那以后,我每次搭网络都会把激活函数拿到台面上先过一遍。
激活函数是深度学习里最基础、也最容易被当成“默认配置”直接跳过的东西。它要解决两件事:给线性运算注入非线性,让多层网络真正叠出表达能力;同时给反向传播提供可用的梯度信号,让权重有路可改。这篇文章把我这些年实测过的常见激活函数——Sigmoid、tanh、ReLU、Leaky ReLU、ELU、Softmax、Swish、Mish——拆开讲透,包括公式、导数、适用场景、踩坑点,以及 PyTorch 里怎么调试和排查问题。想从零开始搞懂深度学习的朋友可以当作一份“避坑手册”用,已经入门的同学也能在里头找找灵感。
1. 为什么要激活函数:非线性与梯度流动的双重使命
1.1 没有非线性的网络,堆多少层都是“线性回归”
先说个最核心的逻辑:神经网络的每一层,如果不加激活函数,本质就是一次仿射变换 y = Wx + b。连续叠两层,得到的是 W2(W1x + b1) + b2。把括号展开,它依然是一次线性的变换,只是系数变了。这意味着,哪怕你的网络有 100 层,计算出来的整体映射仍然是一张“平面”在维度空间里的变形,根本拟合不了 XOR、图像分类这种非线性边界。
有了激活函数,每一层输出都被“掰弯”一次,多层叠加后才有能力表达复杂的决策边界。我经常拿折纸来打比方:线性层是一张平整的纸,激活函数就是每层之间的一次折叠。折得越多,纸张的轮廓就越接近你想要的那个形状。没有折叠,纸永远铺平在地上。
所以,选用激活函数不是“随便挑一个”,而是直接决定网络表达能力上限的选择。
1.2 反向传播需要导数,梯度流动决定训练效率
深度学习训练依赖反向传播,核心动作就是链式法则:从 loss 出发,把误差梯度逐层往回传。激活函数是这条链路上的必经一站,它对应的导数直接参与每一层梯度的乘法运算。
这就带来一个很重要的约束:激活函数必须可导(至少分段可导),并且导数尽量不“消灭”梯度。拿 Sigmoid 来说,它在两端饱和区导数无限趋近于 0,如果网络层数一深,连乘几次之后梯度就几乎消失,前面的层根本学不到东西。这不是训练技巧能弥补的,是从函数本身的设计就注定了的问题。
理解这一点之后,你回头看 ReLU 的流行就非常自然:它在正区间的导数恒为 1,梯度经过正激活的神经元时不会衰减。梯度流得顺畅,网络就能训练得更深、更快。
1.3 看激活函数时,我会先扫这四个维度
- 单调性:单调函数在凸优化里比较友好,不容易产生振荡。
- 饱和性:饱和区导数接近 0,容易梯度消失;非饱和区(如 ReLU 正区间)梯度稳定。
- 输出是否零中心:输出全正会让下一层的参数梯度“单向化”,影响收敛速度,tanh 和 ELU 在这方面优于 Sigmoid。
- 计算成本:虽然现代 GPU 对指数运算已经很快,但在 Transformer 这种超大模型里,激活函数本身的计算量也会被放大。
训练的时候,我习惯把激活函数的输出分布也当成一个要监控的对象。它比 loss 更早暴露出问题——比如某一层输出几乎全为 0 或者全饱和,那大概率就是激活函数选错了,或者初始化配不上激活函数。
2. 常见激活函数逐个拆解:从 Sigmoid 到 Mish
2.1 Sigmoid:老前辈很稳,但只在特定位置发光
Sigmoid 的公式是:
σ(x) = 1 / (1 + e^(-x))
输出范围 (0, 1),天然适合表示概率、比例这类语义。它当年启发自生物神经元的“激活/抑制”机制,也是深度学习早期的绝对主力。它的导函数有一个非常优雅的表达式:σ'(x) = σ(x)(1 - σ(x))。
但问题也出在这个优雅上。当 x 的绝对值大于 4,σ(x) 要么接近 0,要么接近 1,导数基本等于 0。深层网络中每一层梯度都乘一个很小的数,很快梯度就消失。另外它的输出不是零中心的,全是正数,反向传播时同一层所有参数的梯度符号容易趋同,造成收敛变慢甚至锯齿状更新。
现在的实践里,Sigmoid 基本只出现在二分类输出层,以及注意力机制里的门控计算。隐藏层里用它,除非是 1 到 2 层的小浅网,否则基本就是自找麻烦。
2.2 tanh:零中心化的改良版压缩函数
tanh 本质是 Sigmoid 的平移缩放版本:tanh(x) = 2σ(2x) - 1,输出范围 (-1, 1),并且是零中心的。这解决了 Sigmoid 输出全正带来的梯度方向问题,所以同样的层数,tanh 通常比 Sigmoid 收敛得更快,这也是 LSTM 和 GRU 内部各种门控长期用它而不是 Sigmoid 的原因。
不过 tanh 的饱和问题依然存在。两端导数照样趋近 0,深度一深照样梯度消失。因此它在现代深层前馈网络里也不是首选,但如果你做 RNN 相关模型,或者需要把特征压缩到固定范围,tanh 依然能打。
有个小经验:如果输入数据本身范围很小,tanh 的饱和风险就低很多;如果数据量级没控制好,激活值直接冲进饱和区,网络这一步基本废了。所以配合 BN 或者合理的输入归一化非常重要。
2.3 ReLU:深度学习复兴的第一功臣
ReLU 的定义极简:f(x) = max(0, x)。正区间导数恒为 1,负区间导数为 0。它没有指数计算,前向和反向都非常快,实测训练速度比 Sigmoid/tanh 快好几倍不止。它的稀疏性也被很多人喜欢:负输入直接输出 0,相当于让部分神经元静默,模型在某种程度上自带稀疏化。
但 ReLU 有两个先天毛病。第一,输出无上界,如果某一层权重初始化过大或学习率太高,特征值会迅速膨胀,容易引起训练发散。第二就是臭名昭著的“死亡 ReLU”:当一个神经元碰到负输入时,梯度是 0,如果这个状态长期持续,权重永远得不到更新,该神经元就永久性“死亡”了。统计下来,如果网络里死亡比例超过 20%-30%,能力就会明显受损。
实操里 ReLU 依然是卷积网络和 Transformer 前的默认选择,但我会时刻留意死亡比例。一旦发现异常,优先检查学习率和初始化,其次才是换 Leaky ReLU。
2.4 Leaky ReLU、PReLU 与 ELU:给 ReLU 加救生圈
Leaky ReLU 解决死亡问题的方式很直接:负数部分也留一个微小梯度,f(x) = max(αx, x)。通常 α=0.01,让负区间的梯度不再是 0,神经元只要进了负区也能被“拉回来”。PReLU 更进一步,把 α 当作可学习参数,让网络自己决定负区间的斜率。
ELU 是另一种思路,公式如下:
ELU(x) = x,当 x > 0 ELU(x) = α(e^x - 1),当 x ≤ 0
负区间不再是一条直线,而是一条指数衰减曲线。它的好处是负区间的输出更平滑,均值接近 0,收敛更稳。代价是带了指数运算,速度比 Leaky ReLU 慢一点。实践中 Leaky ReLU 在普通 CNN 里提升没有传说中那么夸张,但到了 GAN、强化学习这类训练不稳定的场景,它的价值就很明显了。我自己的 GAN 项目基本都是 Leaky ReLU 起步。
2.5 Softmax:分类任务里的输出标配
严格来说 Softmax 通常只出现在最后一层,因为它的输出被归一化成一个和为 1 的概率分布:
Softmax(z_i) = e^(z_i) / Σ_j e^(z_j)
多分类问题的标配就是把 logits 过一遍 Softmax,再接交叉熵损失。它有两个特点很关键:对输入的“差值”敏感而不是绝对大小,所以某个类别的 logits 比别的大 2,指数放大后概率就会明显偏向它;同时对所有类别是竞争关系,总和恒为 1。
实际写代码时,几乎不会裸写 Softmax,而是用 PyTorch 里的CrossEntropyLoss,因为它在内部已经把 Softmax 和对数损失融合了,还做了数值稳定处理。手写 Softmax 时如果直接对超大 logits 求指数,非常容易溢出成 NaN,正确做法是先把 logits 减去最大值再求指数。
2.6 Swish 与 Mish:来自实验冲击波的新生代
Swish 是 Google Brain 在 2017 年左右做的激活函数搜索实验里发现的形式:f(x) = x · sigmoid(x)。它跟 ReLU 长得像,但没有硬转折,而是平滑过渡;负区间不是直接归 0,而是有一段轻微下探。研究者发现在相近参数量的 CV 模型里,Swish 经常比 ReLU 高出 0.5%-1% 的精度。
Mish 则是在目标检测框架里流行起来的:f(x) = x · tanh(softplus(x))。它在负区间的曲线更饱满,梯度流更平稳。我在自己实验中遇到的直观感受是:Mish 收敛更平滑,但显存和计算开销比 ReLU 高,小模型上性价比一般。
这类激活函数很适合“换上去看涨点”的调参场景。不过要注意,Swish/Mish 对初始化和学习率更敏感,想用好不能光换函数,还要检查权重初始化方法是否配套。
2.7 GeLU:Transformer 家族的实际宠儿
提到当前最热的深度学习模型,Transformer 和 BERT 系列绕不开 GeLU(Gaussian Error Linear Unit)。公式定义为:
GeLU(x) = x · Φ(x)
其中 Φ(x) 是标准正态分布的累积分布函数。为了方便计算,实践中常近似为:
GeLU(x) ≈ 0.5x(1 + tanh(√(2/π)(x + 0.044715x³)))
它比 ReLU 更平滑,负区间的处理方式也更有“概率”味,BERT、GPT、ViT 这些架构的 MLP 层基本都默认 GeLU。如果你要在 Transformer 里做实验,隐藏层激活先考虑它,别拿 ReLU 硬顶。
3. 任务与架构视角下的激活函数选择
3.1 隐藏层默认配置:卷积网络与 Transformer 各有所爱
我搭网络时的默认逻辑很固定:普通 CNN 分类模型,隐藏层先用 ReLU;如果模型很浅或者训练非常不稳定,就切到 Leaky ReLU;如果是 Transformer 结构,隐藏层默认 GeLU。这不是教条,而是大量实证项目的平均最优解。
选择依据不只是精度。ReLU 极简、少计算、少显存,模型规模越往上扩,这个优势越明显。GeLU 在 Transformer 里被验证过无数遍,改动它反而容易破坏预训练权重的分布习惯。要是你自己从头训练一个 Transformer,用 GeLU 起点更高。
3.2 输出层怎么选:分类、回归、多标签各归各位
- 二分类:Sigmoid 输出一个 0~1 概率。
- 多分类单标签:Softmax,输出各类别概率分布。
- 多标签分类:每个输出节点单独用 Sigmoid,因为各类别独立存在。
- 回归任务:输出层不加激活函数,直接让输出可以是任意实数。强行加 Sigmoid/tanh 会把预测值范围压到 (0,1) 或 (-1,1),导致回归误差被永久限制。
- 图像生成任务的输出层:GAN 里图像像素值如果想落在 [-1,1],用 tanh 非常合适。
这里我踩过的坑是在回归模型输出层套了 Sigmoid,结果模型怎么训练损失都降不到理想范围。后来发现是激活函数把输出空间卡死了,去掉之后训练立即正常。输出层激活函数永远要跟着“数据标签的分布”走,而不是看哪层流行就用哪个。
3.3 数值稳定性:初始化、归一化与激活函数的三角关系
激活函数不是孤立存在的。同样一个 ReLU,配 Xavier 初始化和配 He 初始化,训练表现差一大截。Xavier 初始化是为 Sigmoid/tanh 这种饱和函数设计的,它尽量让输入输出方差一致,避免直接进入饱和区。ReLU 这类非饱和函数更需要 He 初始化,把方差放大一些,避免输出全归 0。
BatchNorm 也是激活函数的好搭档。它把激活前的输入先归一化,让数值落在合理区间,这样 ReLU 不会因为负值太多而大面积死亡,Swish/Mish 也不会因为输入量级太大直接饱和。实际项目里我的顺序是:先确认激活函数,再配初始化,最后决定用不用 BN 或 LayerNorm,而不是反过来。
3.4 正则化与激活函数:L2 正则化不会改变激活,但会牵制它
有热搜词提到“深度学习 L2 正则化 PyTorch 代码”,这里一并说清楚。L2 正则化就是对权重做平方和惩罚,在 PyTorch 里最常用的是 optimizer 里的weight_decay参数:
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)它不会直接改变激活函数,但会通过压制权重的量级,让每一层的预激活值(进入激活函数前的值)不那么膨胀。这对饱和型激活函数帮助尤其大,因为权重一旦变得很大,输入就容易冲进 Sigmoid/tanh 的饱和区,梯度直接消失。对ReLU 来说,L2 正则化也能降低输出无上界带来的爆炸风险。
所以调参时如果发现激活值分布异常,不要只盯着激活函数本身,也要检查weight_decay和学习率是不是匹配。
4. PyTorch 实操:验证激活函数差异、检查死亡神经元
4.1 快速实验:对比不同激活函数对收敛的影响
想验证不同激活函数的实际差异,最快的方式是搭一个没有归一化的简单 CNN,固定参数只换激活函数,观察前几个 epoch 的 loss 变化。下面是我常用的最小验证代码:
import torch import torch.nn as nn import torch.nn.functional as F class SimpleNet(nn.Module): def __init__(self, activation="relu"): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), self._get_act(activation), nn.Conv2d(32, 64, 3, padding=1), self._get_act(activation), ) self.classifier = nn.Linear(64 * 32 * 32, 10) def _get_act(self, name): if name == "relu": return nn.ReLU(inplace=True) elif name == "leaky": return nn.LeakyReLU(0.01, inplace=True) elif name == "elu": return nn.ELU(alpha=1.0) elif name == "sigmoid": return nn.Sigmoid() elif name == "tanh": return nn.Tanh() else: raise ValueError(name) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)训练时我会用同一份数据,同一个随机种子,只切换activation参数。实测下来,ReLU 和 Leaky ReLU 的收敛曲线的差异在浅层模型上很小,但换成 Sigmoid 或 tanh 后,loss 下降肉眼可见地变慢,甚至卡住不动。这个实验非常适合新手建立直觉。
4.2 检查激活值分布与死亡 ReLU 比例
判断网络健康度,我有个习惯:跑一次完整的前向传播,统计每一层激活值的比例。核心指标就是“死亡比例”——ReLU 输出恰好为 0 的神经元占比。
def diagnose_dead_relu(model, dataloader, device="cpu"): model.eval() dead_ratios = {} hooks = [] def hook_fn(name): def forward_hook(module, input, output): ratio = (output == 0).float().mean().item() dead_ratios[name] = ratio return forward_hook for name, module in model.named_modules(): if isinstance(module, nn.ReLU): hooks.append(module.register_forward_hook(hook_fn(name))) with torch.no_grad(): x, _ = next(iter(dataloader)) x = x.to(device) model(x) for h in hooks: h.remove() for name, ratio in dead_ratios.items(): print(f"{name}: dead ratio {ratio:.4f}")如果某一层死亡比例超过 20%,我会警惕;超过 30%,基本认定模型表达能力受损。修复路径优先考虑降低学习率,再考虑把初始化换成 He 初始化,最后才会换激活函数。插一句:inplace=True 的 ReLU 在 register_forward_hook 里拿到的 output 可能是被覆盖过的值,调试时建议先用 inplace=False 的版本。
4.3 用 hook 抓出 Softmax 数值溢出
数值溢出这类问题非常隐蔽。Softmax 裸实现时,如果 logits 里有几个值是 100,e^100 直接就爆成 inf,loss 变成 NaN。虽然 PyTorch 的CrossEntropyLoss内部会做处理,但自己写损失函数或者做知识蒸馏这类自定义逻辑时,还是有踩坑风险。
建议在自己实现的 Softmax 里显式减去最大值:
def stable_softmax(logits, dim=-1): logits_max = logits.max(dim=dim, keepdim=True).values exp_logits = torch.exp(logits - logits_max) return exp_logits / exp_logits.sum(dim=dim, keepdim=True)如果训练日志还是出现 NaN,先去检查 logits 的绝对量级;接着查是不是学习率太大把权重推飞了;最后检查损失函数内部有没有先取 log 再进 Softmax 之类的不稳定操作。按照这个顺序排查,90% 的 NaN 都能定位到。
5. 常见问题与排查技巧实录
5.1 梯度消失:训练初期 loss 纹丝不动
表现是 loss 在前几个 epoch 几乎没有变化,或者下降极慢。优先怀疑隐藏层用了 Sigmoid/tanh 的深层网络。处理方式很简单:换成 ReLU/Leaky ReLU,检查是否有 BN 或残差连接。经典的 ResNet 之所以能训练到一百多层,正是因为它把激活层挪到了残差分支上,主路径的梯度可以一路直通。
5.2 死亡 ReLU 大面积出现:输出全是 0
表现是训练到中途 loss 突然不再下降,或者模型输出出现大量常数。先用上面的诊断脚本统计死亡比例。如果死亡率高,先降学习率,再把初始化从 Xavier 换成 He。还有一种情况是输入数据没有归一化,负值大量进入网络,导致 ReLU 输出被清零,这时加 BN 比换激活函数更直接。
5.3 Sigmoid/tanh 饱和:激活输出贴死在 0 或 1
如果打印激活值分布,看到大量值集中在 0.999 或 0.001 附近,说明神经元完全饱和了。这通常是权重初始化偏大、输入未归一化、或者没有加 BN 导致的。解决办法是检查初始化方差,并确认数据均值和方差是否合理。对 tanh 来说,输入数据如果本来就在 [-3, 3] 外,直接就得做归一化。
5.4 NaN 与 Inf:梯度爆炸的最终形态
NaN 比梯度消失更容易让人头大,因为原因多。最常见的就是激活函数没有上界(ReLU/Swish)加上权重循环膨胀,预激活值每层叠大,最终溢出。排查思路分三步:先把学习率降到原来的 1/10,看 NaN 是否消失;再检查损失函数里有没有自己实现的 Softmax 或 log;最后看数据里有没有异常大值的特征没有清洗。实践中我发现,很多 NaN 都是因为自定义损失函数里某个指数运算没做稳定处理。
5.5 排查速查表
| 症状 | 可能原因 | 排查/修复顺序 |
|---|---|---|
| loss 卡住不降 | 梯度消失 | 换 ReLU/Leaky ReLU;加 BN;检查是否饱和激活过深 |
| 大量神经元输出 0 | 死亡 ReLU | 降低学习率;改用 He 初始化;加 BN/残差 |
| 激活值全贴 0/1 | 饱和进入平坦区 | 检查输入归一化;调整初始化方差 |
| loss 出现 NaN | 梯度爆炸/Softmax 溢出 | 降学习率;稳定 Softmax;清洗特征 |
| 收敛极慢且来回振荡 | 输出非零中心 | 隐藏层换 tanh/ELU/Leaky ReLU;检查 BN |
6. 选择激活函数的个人习惯与一句话速记
6.1 我的默认选择清单
我搭模型的时候基本按下面这个流程走一遍,不套公式,但非常适合当起点:
- 快速原型:直接 ReLU,别多想,这是跑通流程的最快路径。
- CNN 分类/检测:ReLU 起步,若训练不稳定换 Leaky ReLU。
- 生成对抗网络:隐藏层 Leaky ReLU,输出层按生成数据的范围选 tanh 或纯线性。
- Transformer/BERT 类:隐藏层 GeLU 或 Swish,不要乱改预训练权重里的激活单元。
- RNN/LSTM 类:继续用 tanh,在门控里 Sigmoid,这已经是验证过的结构。
- 多分类输出:Softmax;二分类或多标签:Sigmoid;回归:不加激活。
6.2 几条自己踩坑换来的经验
第一,激活函数的效果严重依赖初始化、归一化和学习率。换激活函数不换配套设置,很容易得出“这个函数不好用”的错误结论。我见过有人把 ReLU 换成 Swish 没收敛,最后发现是学习率太高,根本不关 Swish 的事。
第二,监控激活值分布比监控 loss 更早发现病根。我训练中会定期算一下每层输出均值、方差和零值比例,这套数据在调参时比 loss 曲线直接得多。
第三,输出层激活函数务必和损失函数匹配。交叉熵损失不要自己先 Softmax 再算,CrossEntropyLoss内部自带稳定版;回归任务输出层不要乱加压缩型激活。这个小细节能让你的模型在第一天就把路走对。
顺着这些经验再往深挖,后面你还能玩出很多花活,比如给不同层配不同激活函数、做激活函数的可学习版本,这些都是把基本功练扎实之后才谈得上的东西。希望这篇笔记能帮你少走几个我没绕过去的弯路。