我最早接触 GELU 时,心里一直有个疑惑:公式里明明写着 erf,打开 BERT 的源码看到的却是 tanh。我一度以为那是某些人为了方便“魔改”出来的替代品,直到翻开GELU 原始论文才发现——这两个写法都是 GELU,只是作者在同一种激活函数上给出了两种计算路径。后来我在部署时又因为混用这两种方式吃了个小亏。这篇文章就把这段经历讲透。

先复习一下 GELU 的动机。ReLU 的规则是“负数直接归零”,dropout 的规则是“随机清零”。GELU 把二者拧到一起:用 x 自己是正数的概率来缩放 x。如果假设网络层的输出近似服从标准正态分布,那么 x ≥ 0 的概率就是累积分布函数 Φ(x),于是 GELU 的定义写成
GELU(x) = x · Φ(x) = x · 0.5 · (1 + erf(x / √2))
这里的 erf 是误差函数,在统计学里就像正弦函数一样基础,但计算机拿它没办法:它没有一个能用四则运算和根号写死的封闭表达式,只能通过级数展开、多项式逼近或查表来算。这既让公式看起来“精确”,也让它在硬件上显得“昂贵”。老一代深度学习库里,有些实现直接调 std::erf,有些手写多项式版本,性能和精度千差万别。
原作者 Hendrycks 和 Gimpel 当然意识到了问题,于是他们在这篇 2016 年的论文里给出了 tanh 近似:
GELU(x) ≈ 0.5 · x · (1 + tanh(√(2/π) · (x + 0.044715·x³)))
这个式子不是随便拍的。为了让 tanh 曲线尽可能贴住 Φ(x),参数经过了一番设计:√(2/π)x 保证原点处斜率一致,0.044715x³ 修正三阶矩。这样就把最大误差压到 1e-4 量级。你肉眼根本看不出两条曲线的差距。
顺带回答一个常见疑问:为什么 GELU 不直接用 sigmoid 来近似 Φ?因为 Φ(x) 和 sigmoid(x) 形状虽然像,尾部行为却不同,直接替换误差偏大,需要再乘一个缩放系数才能用。有趣的是,后来有人把 Φ 换成 sigmoid 得到了 x·sigmoid(x),也就是 SiLU/Swish,它在很多任务上也表现不错。但 GELU 的理论根基是正态分布,所以在原论文框架里,erf 和 tanh 才是正主。
| 对比维度 | erf 精确计算 | tanh 近似 |
|---|---|---|
| 数学精度 | 浮点精度内准确 | 最大误差约 1e-4 量级 |
| 计算开销 | 级数/多项式,库函数调用 | 一次 tanh + 几次乘加 |
| 硬件支持 | GPU/CPU 上多为软件实现 | 有硬件指令,便于算子融合 |
| 训练效果 | 与近似版无统计显著差异 | 与精确版无统计显著差异 |
那对训练速度和最终精度的影响到底有多大?我用一个三层 MLP 在 MNIST 上做过一个不严谨的对比:同样的初始化、同样的优化器、同样的 batch size,分别用精确 erf 和 tanh 近似训练 20 轮。验证集准确率一个是 98.11%,一个是 98.12%——差的那 0.01% 换个随机种子就翻回来了。
为什么这么小的误差不影响训练?因为激活函数每层的输出都会经过权重矩阵的线性变换,微小误差会被下一层当成噪声吸收掉;再加上深度模型里普遍存在的 LayerNorm/BatchNorm 会把分布重新拉回标准位置,这点偏移根本上不了台面。
那为什么 tanh 近似依然到处可见?因为先入为主。Google 的 BERT 原始实现用的是 tanh 近似,OpenAI 的 GPT-2 用的也是。当年围绕这两个巨型模型做二次开发的人,顺着权重文件把 tanh 近似带进了各种框架。PyTorch 后来在 torch.nn.GELU 里同时提供了精确版和 tanh 版,但默认给了精确版;TensorFlow 的 tf.nn.gelu 则要显式指定 approximate=True 才会用 tanh。所以你今天看到的开源模型,可能楼上楼下跑着两种不同版本的 GELU。
真正的坑在这里:如果你用 PyTorch 默认精确版训练,导出到 TensorRT 或 ONNX 后,推理引擎里内置的可能是 tanh 近似——同一批输入,输出分布会有一丁点偏移。小模型无感,但在几百层的 Transformer 里,每一层偏移一点点,叠加起来足以让某些敏感任务的结果肉眼可见地变差。我最早是在导出蒸馏模型时踩到过:离线评测分数比 PyTorch 低了 0.3%,查了一下午才发现是 GELU 实现不一致。
所以我的建议就三条:
- 从零训练:用 PyTorch 默认精确版,省心,也不用担心数值边界。
- 加载预训练模型:先查源码里用的是哪个,必要时调整 approximate 参数。
- 部署到推理引擎:优先选引擎原生友好的版本,并导入真实数据做逐层输出对比。
顺手看一下 PyTorch 里怎么切
import torch.nn as nn
# 精确 erf(PyTorch 默认)
gelu_exact = nn.GELU(approximate='none')
# tanh 近似
gelu_tanh = nn.GELU(approximate='tanh')
在 Transformers 库中,老模型常出现 hidden_act=’gelu_new’,这个通常就是 tanh 近似;而 ‘gelu’ 在不同版本里含义可能变化,加载老权重前务必确认它的实现。
常见误解一:tanh 近似比 erf 快很多,所以我应该用 tanh
这是想当然。激活函数在模型总计算量中占比通常不到 1%,哪怕 erf 比 tanh 慢两倍,端到端速度也只差零点几个百分点。真正值得关心的是推理引擎支持哪个版本,以及能否做算子融合。
常见误解二:既然有精确版,为什么还要保留近似版?
因为历史包袱和生态惯性。BERT 时代大家就把 tanh 版本叫做 gelu,后续所有预训练权重和工具链都围绕它构建。PyTorch 引入精确版后,为了兼容老模型,只能两者并存。
常见误解三:这两个版本的梯度不同,会影响训练稳定性
在标准深度学习任务里,这个说法得不到数据支持。两种版本的梯度差异同样在 1e-4 量级,而现代训练本身带着随机噪声,这点差异早就被优化器的高阶动量和学习率调度淹没了。除非你在做训练动力学研究,否则不需要在意。
写到最后,给个判断:GELU 的两种计算路径,本质上是同一个数学思想在“精度”和“可计算性”之间的折中。它们的差异在理论上有趣,在实践中却远没有很多帖子渲染得那么大。你真正需要做的不是二选一,而是保证训练与推理的一致性。下次看到某个激活函数同时存在两个公式时,别急着争论哪个正统,先看看周围生态已经把哪个设为默认——那才是真实世界里决定选择的东西。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/501.html