AI激活函数:ReLU不够用了之后
这次就想把激活函数从 ReLU 到 Swish 的实践过程梳理一遍,毕竟这东西调不好,模型再复杂也白搭。
但问题就在于,不同的激活函数在训练时会带来完全不同的效果:
- Sigmoid/Tanh 在深层网络里会碰到梯度消失问题
- ReLU 虽然缓解了这个问题,但又有负区间"死亡"的坑
- 更新的 Swish、GELU 等函数试图在性能和训练稳定性之间找平衡
我这次的文本分类模型用的是 Transformer 架构,激活函数选的是 ReLU。
问题出在哪里
激活函数听起来挺玄乎,但干的事情其实很直接:给网络注入非线性。没有它,多层神经网络本质上还是线性变换,堆再深也等价于一层。
但问题就在于,不同的激活函数在训练时会带来完全不同的效果:
- Sigmoid/Tanh 在深层网络里会碰到梯度消失问题
- ReLU 虽然缓解了这个问题,但又有负区间"死亡"的坑
- 更新的 Swish、GELU 等函数试图在性能和训练稳定性之间找平衡
我这次的文本分类模型用的是 Transformer 架构,激活函数选的是 ReLU。按理说这应该是标准配置,但为什么还是训练不动?
定位过程
先看训练日志:
Epoch 10: loss=2.31, acc=0.45
Epoch 11: loss=2.30, acc=0.46
Epoch 12: loss=2.30, acc=0.46
...
Epoch 20: loss=2.30, acc=0.46
loss 卡在 2.30 不动。这数看着很眼熟——分类类别是 10,随机猜测的 loss 差不多就是 -log(0.1) ≈ 2.3。换句话说,模型已经退化成瞎猜了。
一层层排查下来,发现中间层输出的统计分布异常:标准差极小,大量节点输出为 0。这显然不是正常的训练状态,而是激活函数把梯度堵死了。
# 检查中间层输出分布
hidden_layer_output = model.get_layer('hidden_3').output
outputs = []
for batch in val_dataset:
out = hidden_layer_activation(batch[0])
outputs.append(out.numpy())
outputs = np.concatenate(outputs, axis=0)
print(f"Mean: {outputs.mean():.4f}, Std: {outputs.std():.4f}")
print(f"Zero ratio: {(outputs == 0).mean():.2%}")
# 输出:
# Mean: 0.0234, Std: 0.0056
# Zero ratio: 94.3%
94.3% 的节点输出都是 0——这显然不正常。ReLU 在负区间直接输出 0,如果大量节点的输入长期落在负区间,梯度就回传不回去。
ReLU 的坑
ReLU 的公式很简单:
def relu(x):
return max(0, x)
优点一大堆:计算快、缓解梯度消失、稀疏化表示。但缺点也明显:
- 负区间死亡:输入一旦长期为负,梯度就永远是 0,节点再也不会更新
- 输出无界:正区间输出可以无限大,容易导致数值爆炸
- 均值非零:输出分布不以 0 为中心,可能影响下一层的输入分布
我的问题属于第一种。文本分类任务里,词向量经过多层变换后,部分节点的输入分布确实可能长时间偏负。
先看看能不能通过调参缓解:
# 尝试 1:降低学习率
optimizer = Adam(learning_rate=0.0001) # 从 0.001 降到 0.0001
# 尝试 2:用 LeakyReLU 替代 ReLU
from tensorflow.keras.layers import LeakyReLU
model.add(LeakyReLU(alpha=0.01)) # 负区间保留 1% 的梯度
# 尝试 3:调整初始化
from tensorflow.keras.initializers import HeNormal
model.add(Dense(256, activation='relu', kernel_initializer=HeNormal()))
试了一圈,效果都不理想。学习率降太低训练太慢,LeakyReLU 稍微好点但治标不治本,初始化调整有改善但没根治。
换激活函数
既然调参不行,那就换激活函数。这几年研究出了不少新选择:
| 激活函数 | 核心特点 | 适用场景 |
|---|---|---|
| LeakyReLU | 负区间保留小梯度 | ReLU 死亡问题严重时 |
| ELU | 负区间曲线平滑,输出均值接近 0 | 需要更稳定训练时 |
| Swish | 平滑非单调,性能好 | 深度网络,追求性能 |
| GELU | 考虑随机正则化的平滑函数 | BERT 等预训练模型 |
考虑到我用的就是 Transformer,而且目标是提升性能,决定先试 Swish。
Swish 原理
Swish 的公式看起来有点怪:
def swish(x, beta=1.0):
return x * sigmoid(beta * x)
它不是一个单调函数(在一定范围内会有拐点),也不是简单的分段线性。这个设计是有原因的:
- 平滑可导:处处可导,避免了 ReLU 在 0 点的不可导问题
- 保留小负值:sigmoid 函数保证输出在 -0.278 左右有下界,不会像 ReLU 那样完全堵死
- 自适应门控:x * sigmoid(beta * x) 的结构可以理解为一种门控机制
实际用起来也很简单:
from tensorflow.keras.layers import Activation
model = Sequential()
model.add(Embedding(vocab_size, embedding_dim, input_length=max_len))
model.add(Bidirectional(LSTM(128, return_sequences=True)))
model.add(GlobalMaxPool1D())
model.add(Dense(256))
model.add(Activation('swish')) # 替代原来的 'relu'
model.add(Dropout(0.5))
model.add(Dense(num_classes, activation='softmax'))
效果对比
换完 Swish 后重新训练,效果立竿见影:
Epoch 1: loss=1.87, acc=0.52
Epoch 5: loss=0.89, acc=0.73
Epoch 10: loss=0.45, acc=0.86
Epoch 15: loss=0.32, acc=0.91
loss 一路下降到 0.32,准确率到 91%。中间层输出分布也正常了:
Mean: 0.0823, Std: 0.1245
Zero ratio: 0.7% # 只有不到 1% 的节点输出接近 0
对比一下两种激活函数在同一网络结构下的表现:

用同一组训练数据跑 20 轮:
| 激活函数 | 最终 Loss | 准确率 | 训练时间 |
|---|---|---|---|
| ReLU | 2.30 | 46% | 12.3s |
| Swish | 0.32 | 91% | 14.1s |

Swish 的训练时间稍微长了点(因为 sigmoid 计算比 ReLU 稍慢),但效果提升完全值得。从函数形状上看,Swish 在负区间保留了小的输出,避免像 ReLU 那样完全堵死梯度;在训练曲线中,ReLU 的 loss 早期就卡在 2.3 左右,而 Swish 能够持续下降,这正是解决了"死亡节点"问题后的正常表现。
踩坑记录
换激活函数的过程中也踩了几个坑,记一下:
参数敏感性
Swish 有一个 beta 参数,默认是 1.0。我试过调整这个值:
# beta=1.0 (默认)
model.add(Activation(lambda x: x * tf.keras.backend.sigmoid(1.0 * x)))
# beta=2.0 (更陡峭)
model.add(Activation(lambda x: x * tf.keras.backend.sigmoid(2.0 * x)))
# beta=0.5 (更平缓)
model.add(Activation(lambda x: x * tf.keras.backend.sigmoid(0.5 * x)))
实测下来,beta=1.0 的效果最好。beta 太大会让函数形状接近硬分段,太小又会让激活区域变窄。除非有明确的调参需求,用默认值就行。
数值稳定性
Swish 用了 sigmoid,当输入绝对值很大时,sigmoid 可能会遇到数值问题:
# 当 x 很大时,sigmoid(x) 可能返回 NaN
import numpy as np
x = 1000
sigmoid = 1 / (1 + np.exp(-x))
# 可能返回 NaN 或 Inf
实际训练中很少会遇到这种情况,因为网络权重不会长到这种离谱的值。但如果要在自定义层里用 Swish,最好做个限制:
def swish(x, beta=1.0):
# 限制输入范围,避免数值问题
x = tf.clip_by_value(x, -50.0, 50.0)
return x * tf.keras.backend.sigmoid(beta * x)
框架兼容性
不同框架对 Swish 的支持程度不一样:
- TensorFlow/Keras:直接支持
'swish'激活函数 - PyTorch:需要自定义或者用
torch.nn.functional.silu(SiLU 和 Swish 在 beta=1 时等价) - JAX:手动实现
PyTorch 里的实现:
import torch.nn.functional as F
# 方法 1:用 SiLU(等价于 Swish beta=1)
output = F.silu(x)
# 方法 2:手动实现
def swish(x, beta=1.0):
return x * torch.sigmoid(beta * x)
结果复盘
这次折腾下来,总结几个要点:
什么时候该换激活函数
出现以下情况时,考虑换激活函数:
- 训练早期 loss 卡住:尤其是 loss 卡在随机猜测的水平
- 中间层输出异常:大量节点输出为 0 或梯度消失
- 调参无效:学习率、初始化、正则化都试过了还是不行
- 深层网络性能瓶颈:网络够深但提升不明显
激活函数的切换成本其实很低,大部分框架只要改一行代码。
Swish 不是万能药
Swish 效果好,但也不是所有场景都合适:
- 小数据集上可能过拟合:Swish 表达能力更强,小数据集上容易学到噪声
- 推理敏感场景:Swish 计算成本比 ReLU 高,对延迟要求极高时需要权衡
- 浅层网络:2-3 层的浅层网络,ReLU 通常就够了
实践建议
给几个实用的建议:
- 默认先用 ReLU:简单任务、小数据集,从 ReLU 开始
- 遇到问题再换:不是所有任务都需要上 Swish,有问题再换
- 注意输出分布:训练过程中定期检查中间层输出,早发现早调整
- 保留实验记录:不同激活函数的效果对比能帮你积累经验
结语
激活函数这东西,平时可能根本不会多想——反正大家都在用 ReLU。但真到了模型训练不动、梯度消失、性能上不去的时候,你才会意识到这个"小小的非线性变换"有多关键。
从 ReLU 到 Swish,不是一个简单的升级,而是在解决不同训练阶段遇到的问题。ReLU 让深层网络变得可行,Swish 则在性能和稳定性上又往前推了一步。
选择激活函数,本质上是在算力、性能和训练稳定性之间找平衡。没有最好的选择,只有最适合当前任务的选择。
这次排查问题的过程也提醒我:深度学习里的调参不是玄学,每一步都有背后的逻辑。遇到问题时,多看数据、多查分布、多理解原理,比盲目试错要有效得多。
激活函数只是其中的一个例子,但这个思路其实适用于整个深度学习实践。
可用性说明:本文发布于 2020 年 11 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-AI激活函数:ReLU不够用了之后(https://blog.thinkmoon.cn/post/339-ai-activation-function-relu-swish-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。