1. 先从激活函数说起:为什么 ReLU 不够用?
1.1 激活函数的本质
做深度学习的人,几乎每天都会跟激活函数打交道,但说实话,很多人对它的理解停留在“加一个非线性”这个层面。神经网络如果只有卷积、全连接这类线性操作,那不管堆多少层,整体依然是一个线性变换。你可以想象成一张纸,无论怎么折叠,它还是一个平面,只有引入非线性,纸张才能真正变成有起伏的立体结构。这个让网络“弯”起来的角色,就是激活函数。
早期主流是 Sigmoid 和 tanh。Sigmoid 把任意实数压到 (0,1) 区间,天然适合做概率输出,但问题也很明显:输入绝对值一大,函数就进入饱和区,梯度趋近于 0,反向传播时误差信号越传越弱,深层网络基本训不动。tanh 把输出中心改成了 0,收敛比 Sigmoid 好一些,但饱和区的梯度消失问题依然在。所以在 ResNet 出现前后,ReLU 迅速取代了它们,成为默认选择。
ReLU 的公式是 y = max(0, x),正半轴梯度恒为 1,计算几乎不花代价,这让训练速度和稳定性都有了质的提升。但 ReLU 不是没有短板。负半轴直接截断,意味着一旦某个神经元的输入长期落在负区间,它的输出会一直是 0,梯度也永远是 0,这个神经元就“死”了。虽然实际训练中 BN、较小的学习率可以缓解这个问题,但它始终是悬在头上的刀。另外 ReLU 在 x=0 处不可导,理论上看总归不够优雅。
1.2 深度网络对激活函数的新要求
当网络从十几层发展到上百层,激活函数的影响会被急剧放大。尤其是 MobileNet 这类轻量级模型,本身参数就不多,每一层的表达效率都要被尽量榨干,激活函数一旦选错,精度掉得比大模型明显得多。
现代深度网络对激活函数其实有几个隐含要求。第一,函数最好单调且有下界、无上界。有下界能避免输出在负方向无限漂移,无上界则保证深层响应不会被压死。第二,最好能兼顾稀疏性和梯度流动性。ReLU 的稀疏性很好,但负半轴太“死”;Sigmoid 梯度平滑,但会饱和。新的激活函数往往试图在这两者之间找平衡。第三,计算要便宜。这里说的便宜不是 GPU 上的浮点运算次数,而是移动端 NPU、DSP 上的实现成本,一次指数运算可能就让延迟预算破功。Swish 和 Hard-Swish 正是在这种需求下被推上前台的。
2. Swish 的来历与设计原理
2.1 自门控:把“门”做到激活函数里
Swish 是 Google Brain 团队在 2017 年提出的,论文标题是 Searching for Activation Functions。它不是某个研究员凭经验拍脑袋写的公式,而是通过强化学习在候选算子空间里搜索出来的。搜索得到的最终形式非常简单:
f(x) = x · sigmoid(β · x)
当 β = 1 时就是我们最常见的 Swish-1,PyTorch 里也把它叫做 SiLU。这个公式最大的特点就是“乘”。x 自己经过 sigmoid 生成一个 0 到 1 之间的门控系数,再乘回自己。等于说每个神经元都有一个软开关,开关的闭合程度由输入自己决定,输入越大,门开得越大,输入越小,门关得越紧。这就是自门控(self-gating)的核心思路。
这个设计的好处体现在负半轴。ReLU 对所有负输入一律输出 0,Swish 在输入接近于 0 的负区间会输出一个很小的负值,而不是直接截断。梯度因此可以在负半轴继续流动,神经元死亡的概率大幅下降。而且 Swish 的整个函数曲线是光滑的,处处可导,对梯度优化更友好。
2.2 与 GELU 和 Sigmoid 的家族关系
如果你看到 GELU 的公式 x · Φ(x),其中 Φ 是标准正态分布的累积分布函数,会发现它和 Swish 长得几乎一样。本质上两者都是“输入乘上一个值域在 (0,1) 之间的门控”。GELU 用的是正态分布 CDF,Swish 用的是 sigmoid,曲线形状高度重合,在很多任务上表现也极其接近。Transformer 里普遍使用 GELU,而近年来很多大模型又重新用回 SiLU,也就是 β=1 的 Swish,说明这个函数族已经成了现代网络的基础设施。
你也可以把 Swish 理解成一种平滑化的 ReLU。ReLU 在原点附近从 0 直接跳到一次函数,Swish 则是一个缓慢爬升的过渡曲线。这个平滑性带来的实际收益是:输入在小范围内抖动时,输出不会剧烈变化,模型的局部敏感度更低。这一点在训练初期权重还没稳定时尤为重要。
H-Swish 的“硬”版本就是在这种曲线的基础上做了一次分段线性近似。用分段函数去模拟原曲线,再把指数运算彻底去掉。如果你看过 Transformer 里的近似 softmax、某些硬件加速器里的激活函数查表实现,会发现这个思路在不断重复:保留函数的几何形状,去掉昂贵的计算原语。
3. Hard-Swish 诞生:移动端部署的妥协与智慧
3.1 为什么移动端不喜欢 Sigmoid
GPU 上有专门的指数运算硬件单元,计算 sigmoid 几乎是免费的,所以 Swish 在训练阶段跑得飞快。但手机 NPU、DSP、以及很多嵌入式芯片完全不同,它们对指数运算没有硬件加速,一个 sigmoid 可能需要调用软件库的泰勒展开或查表实现,延迟一下就上去了。
MobileNetV3 论文里专门提过这个问题:swish 里的 sigmoid 在移动端设备上实现开销过高,尤其是低精度量化场景,sigmoid 对数值范围太敏感,均匀量化会引入不小的误差。于是 Hard-Swish 被正式引入到网络设计中。它的公式可以写成:
hard_swish(x) = x · ReLU6(x + 3) / 6
也可以改成更工程化的写法:
hard_swish(x) = x · clip((x + 3) / 6, 0, 1)
两种形式本质一样,都是让门控值落在 [0,1] 区间,且完全不需要指数运算,操作只有加法、裁剪、乘法。这个函数在移动端可以拆解成几个极其高效的底层算子,非常符合 NPU 的执行模型。
3.2 Hard-Swish 的数学形式与近似质量
ReLU6 就是 y = min(max(0, x), 6)。把输入 x 加上 3,通过 ReLU6 后取值范围是 [0,6],再除以 6,就得到了 [0,1] 的门控系数。这个系数和 sigmoid(x) 在 x 取 -3 到 3 之间非常接近。超出这个区间,Swish 的门控会无限逼近 0 或 1,Hard-Swish 则直接截断到边界。
这种近似会引入两个不可导点,分别在 x=-3 和 x=3 处。理论上,这两个点的梯度不完全连续,但实际训练中几乎不会带来问题。MobileNetV3 在 ImageNet 上的实验显示,用 Hard-Swish 替换 Swish,准确率基本持平,但推理延迟有可感知的下降。对于追求极致的移动端模型来说,这就是把省下的开销用在了刀刃上。
| 对比项 | Swish | Hard-Swish |
|---|---|---|
| 公式 | x · sigmoid(x) | x · ReLU6(x+3) / 6 |
| 计算复杂度 | 指数 + 乘法 + 加法 | 加法 + 裁剪 + 乘法 + 除法 |
| 量化友好性 | 较差,sigmoid 区间敏感 | 好,分段线性易于量化 |
| 梯度平滑性 | 全区间光滑 | 在 x=-3 和 x=3 处有一个小折点 |
| 移动端速度 | 较慢 | 快,适合 NPU/DSP 部署 |
4. 实操:在 PyTorch 与 TensorFlow 里落地 Swish 和 Hard-Swish
4.1 PyTorch 实现与集成
PyTorch 目前没有把 Swish 直接放进 torch.nn 的顶层 API 里,Hard-Swish 反而有,就是 torch.nn.Hardswish。如果你做研究或者自定义模型时想用 Swish,通常需要自己封装一个模块,代码并不多:
import torch import torch.nn as nn import torch.nn.functional as F class Swish(nn.Module): def __init__(self, beta=1.0): super().__init__() self.beta = beta def forward(self, x): return x * torch.sigmoid(self.beta * x) class HardSwish(nn.Module): def forward(self, x): return x * F.relu6(x + 3) / 6实际用的时候,我强烈建议把激活函数做成一个可配置的模块,而不是直接写死在每个 Block 里。因为你大概率会在 Swish、Hard-Swish、ReLU 之间来回切换做对比实验。我之前吃过这个亏,结构里到处是 nn.ReLU(),后面想改成 Swish,只能全局搜索替换,非常被动。
4.2 TensorFlow / Keras 实现与导出注意点
TensorFlow 的 Keras 接口里直接提供了 hard_swish 和 swish 激活函数,使用起来更省事。不过自定义 beta 的 Swish 还是得自己来:
import tensorflow as tf def swish(x, beta=1.0): return x * tf.sigmoid(beta * x) inputs = tf.keras.layers.Input(shape=(32,)) x = tf.keras.layers.Dense(64)(inputs) x = tf.keras.layers.Activation(swish)(x)需要注意,Keras 内置的 tf.keras.activations.swish 实际对应的是 tf.nn.silu,也就是固定 β=1 的 Swish。如果想用可训练 β,就得写自定义层。模型导出到 TFLite 或 ONNX 时,自定义激活函数很可能会变成不支持的算子。所以在做部署转换前,建议先把这些激活函数替换成框架原生支持的形式,然后再导出。
4.3 在 MobileNetV3 中的使用位置
Hard-Swish 最出名的应用场景就是 MobileNetV3。在 MobileNetV3 的 Bottleneck 结构里,Hard-Swish 并不是被放在所有卷积层后面,而是有选择地放在最后几个 stage 的逐点卷积之后。前几个 stage 仍然使用 ReLU。
这个设计透露出一个重要的工程思想:不是越复杂的激活函数就越该到处用。前几层处理的是纹理、边缘这类低层特征,ReLU 的稀疏性和低成本优势更大。到了高层语义特征提取阶段,网络需要更强的非线性表达能力,这时再用 Hard-Swish,而它的额外计算量只作用于较少的通道和较小的特征图,延迟影响可以控制在合理范围。如果你在自定义网络里用 Hard-Swish,我建议也遵循这种“靠后放置”的原则,而不是无脑替换所有激活。
5. 训练细节与调参心得:为什么 Swish 能刷精度,却容易翻车
5.1 BN 顺序与学习率的敏感度
把网络中的 ReLU 直接替换成 Swish,最容易遇到的问题就是训练变慢,而且不是慢一点点。原因是 ReLU 会强制把负半轴清零,激活值稀疏,梯度流动路径干净;Swish 几乎所有输入都有非零梯度,梯度信号的方差更大,网络需要更长时间来稳定。
我最早做这个替换时,在一个图像分类模型上保持初始学习率不动,训练几个 epoch 后发现 loss 不降反升。把学习率从 0.1 降到 0.01 以后,训练才恢复稳定。所以如果你的基线网络是围绕 ReLU 调好的,切换到 Swish 之后一定要重新扫描学习率,通常需要降低 2 到 5 倍。
BatchNorm 的位置也不能忽略。我的推荐顺序是 Conv -> BN -> Swish。BN 先把卷积输出拉到一个相对稳定的分布,然后 Swish 再引入非线性,这样训练时会稳定很多。如果网络结构特殊,需要把激活放在 BN 前面,务必用实验验证,不要想当然。
5.2 β 参数到底要不要训练
原版 Swish 允许 β 作为可训练参数,这样网络能自己决定门控曲线的形状。β 小,Swish 接近线性;β 大,Swish 接近 ReLU。从表达能力上说,可训练 β 确实更有潜力,但收益并不稳定。
我自己做过一组对比:在相同参数量的分类模型上,固定 β=1 和可训练 β 的精度差距在 0.1% 以内,远小于不同随机种子带来的波动。而可训练 β 引入了额外的优化难度,有时 β 还会收敛到负值,导致激活曲线变得很奇怪。再加上部署时,可训练 β 会变成一个额外的动态参数,不利于模型转换。除非你专门研究激活函数,否则直接用固定 β=1 就好。
5.3 数值稳定性与初始化
Swish 的输出不像 ReLU 那样会截断负值,所以在没有 BatchNorm 的网络上,它对权重初始化更敏感。如果初始权重过大,深层激活值可能持续累积,最终导致数值溢出或梯度爆炸。尤其是在 FP16 混合精度训练下,sigmoid 输入一旦很大,就会饱和到 0 或 1,梯度变成 0,训练瞬间失效。
一个稳妥的做法是:切换 Swish 时,把卷积和全连接层的初始化尺度调小一些。比如使用 Xavier 初始化时,把 gain 设为 0.5,而不是默认的 1.0。另一个更省心的办法是直接用 Hard-Swish,它的值域更加可控,对低精度训练天然友好。如果你的部署目标是移动端,训练阶段直接用 Hard-Swish 从头训,能省去很多后期适配的麻烦。
6. 常见问题与排查技巧实录
6.1 模型从 Swish 换成 Hard-Swish 后准确率下降
这个问题我遇到过好几次。训练好的 Swish 模型,直接替换成 Hard-Swish 进行推理,准确率有明显下降。原因并不复杂:Hard-Swish 只是 Swish 的近似,两者的函数曲线在两端和原点附近都有细微差异,特征分布已经发生了偏移。
正确的做法是“用哪个函数训练,就用哪个函数推理”。如果最终要部署 Hard-Swish,那在训练时就用 Hard-Swish,或者至少在训练的最后阶段做若干轮 finetune。MobileNetV3 的官方实现就是从头训练时就使用 Hard-Swish,并不是训完 Swish 再换过去。这一点经常被忽略,但它决定了精度能不能保住。
6.2 FP16 训练时出现 NaN
FP16 的表示范围比 FP32 小得多,sigmoid 在输入绝对值较大的情况下很容易饱和。如果网络比较深,FP16 反向传播时梯度会出现 NaN。排查时先看 loss 曲线,如果前几步就跳到 NaN,基本可以断定是数值溢出。
我常用的解决方案有三个:一是在关键层插入 BN,把激活输入拉回安全范围;二是把 Swish 替换成 Hard-Swish;三是如果必须保留 Swish,就对 sigmoid 的输入做 clamp,限制在 [-15, 15] 范围。这样既不会完全失去梯度,也能避免溢出。这个方法我在多个模型上验证过,精度损失可以忽略,但训练稳定性提升明显。
6.3 部署到移动端算子不支持
很多移动端 NPU 对 sigmoid 的支持非常保守,因为硬件上没有对应的指令,运行时只能退回到 CPU 实现,延迟会一下子涨上去。如果模型导出成 ONNX 或 TFLite 时包含自定义 Swish,轻则出现警告,重则直接导出失败。
我的建议是写一个脚本,在导出前把所有 Swish 模块批量替换成 HardSwish:
def replace_swish_with_hardswish(model): for name, module in model.named_children(): if isinstance(module, Swish): setattr(model, name, nn.Hardswish()) else: replace_swish_with_hardswish(module) return model替换之后务必跑一遍精度对比。如果发现单算子输出差异大于 1e-3,需要检查 Hard-Swish 的实现版本,不同框架在边界位置的舍入方式可能不同,量化模型里这种细小的差异会被放大,最终影响精度。
7. Swish 家族在非卷积结构里的表现
7.1 Transformer 与 MLP 里的 SiLU
Swish 并不只属于卷积网络。Transformer 系列模型中,前馈网络 FFN 的激活函数普遍选择 GELU,但近年来很多开源大模型用了 SiLU,也就是 β=1 的 Swish。比如 LLaMA 系列中就在 MLP 结构里用 SiLU。原因之一是 SiLU 的梯度特性比 GELU 更平滑,另一个原因是它在很多深度学习框架里已经有高效实现,计算开销不比 GELU 高。
在一些多模态模型里,我也见过把 Hard-Swish 放在 token 融合层后面的做法。这类层通常处理的是高维特征,需要激活函数有较强的非线性表达能力,同时又要控制推理延迟,Hard-Swish 正好适合。
7.2 从激活函数的演进看模型优化
激活函数的发展过程,本质上是一个“表达能力和计算代价博弈”的过程。Sigmoid 提供了光滑非线性,但代价是梯度消失;ReLU 用稀疏性和零计算成本赢得了训练效率,但牺牲了负半轴的信息;Swish 在这两者之间找到了一个巧妙的折中,用一次乘法换来了更好的梯度流动性;Hard-Swish 又用分段线性近似把乘法里的指数部分省掉。
这种取舍思路可以用到整个模型优化中:理解每个算子的真实成本,而不是只看论文里的 FLOPs。FLOPs 只是理论计算量,实际部署耗时还取决于算子是否被硬件原生支持、是否能被融合、是否能量化。MobileNetV3 选择 Hard-Swish,本质上是“用可量化的分段线性去替代难量化的指数函数”。如果你在做模型压缩,这个思路非常值得借鉴。
最后再分享一个我自己的经验。做模型优化时间久了会发现,很多结构上的巧思,最后都会落到工程细节上。Swish 和 Hard-Swish 这对组合,恰好给了我们一个完整的观察窗口:一个用精妙的曲线换效果,一个用粗糙的直线换速度,两者并不冲突,各取所需。GPU 上训练时你可以放心享受 Swish 的平滑梯度,但如果目标是手机、嵌入式设备,那 Hard-Swish 基本就是更理性的选择。不要觉得它“不够高级”,在 100ms 延迟预算面前,少算一次指数,比什么都实在。下次做模型设计时,建议你把这两种函数都跑一遍,记录一下每层激活值的分布,再做决定。