第 7 章 · 学习是怎么发生的
激活函数全家福
第 2 章我们说过,一个神经元 = 加权求和 + 一个激活函数。那个不起眼的激活函数, 其实是让神经网络拥有“威力”的关键开关——没有它,再深的网络也退化成一条直线。 这一章把常见的几个激活函数摆到一起:看它们的曲线、导数、脾气,以及到底该用哪个。
读完这一章,你会明白
- 为什么必须有非线性——没有激活函数,堆一百层也等于一层;
- 怎么“读”一张激活函数图,以及为什么导数(斜率)决定梯度好不好传;
- Sigmoid / Tanh / ReLU / LeakyReLU / GELU / SwiGLU / SiTU-GLU 各自的样子和优缺点;
- 两个经典毛病:梯度消失 与 死亡 ReLU;
- 实战里到底该选哪个,以及它们在本仓库代码里长什么样。
1. 为什么非要有“非线性”?
假设我们把激活函数去掉,每层只做“加权求和”(线性变换)。那么两层叠起来是什么? 线性套线性,还是线性——相当于一个更大的矩阵乘法。换句话说:
若每层都只是仿射变换(线性变换 + 偏置),那么无论你堆 2 层还是 100 层,整体仍等价于 一层仿射变换——分类边界还是直线 / 超平面,分不开复杂的边界。激活函数往中间塞进一点“拐弯”, 层层叠加后,网络才能拟合任意复杂的函数。这就是“非线性”三个字的全部意义。
所以激活函数的职责就一句话:给每个神经元的输出加一道“非线性的弯”,让网络有能力表达复杂关系。
2. 怎么读一张激活函数图
激活函数是“一个数进、一个数出”的函数,所以能画成一条曲线:横轴是输入 z(加权求和的结果), 纵轴是输出。两件事最值得看:
- 形状:它把输入“压”成什么样(压到 0~1?还是砍掉负数?);
- 斜率(导数):曲线在各处有多陡。斜率≈0 的地方,梯度会“断流”——因为反向传播要乘上这个导数(第 6 章的链式法则),导数接近 0,传回去的梯度就几乎消失了。
示意图:左边 S 型两端“压平”(斜率趋 0),右边 ReLU 把负数砍成 0、GELU 是它的平滑版。
动手玩:换激活函数、拖动取值点 x,看曲线形状、函数值和导数(切线斜率)怎么变。把 Sigmoid 拖到两端,亲眼看它“变平、导数≈0”——梯度就是在这里消失的。
3. 全家福:逐个认识
| 激活 | 公式 | 输出范围 | 脾气(优/缺) |
|---|---|---|---|
| Step 阶跃 | x≥0 ? 1 : 0 | {0, 1} | 最早的“开关”;导数几乎处处为 0,没法训练,只剩历史意义 |
| Sigmoid | 1 / (1 + e−x) | (0, 1) | 平滑、像概率;但两端饱和 → 梯度消失,且非零中心 |
| Tanh | (ex−e−x)/(ex+e−x) | (−1, 1) | 零中心,通常比 Sigmoid 好;仍会饱和 |
| ReLU | max(0, x) | [0, ∞) | 又快又好,正区不饱和;但负区恒 0 → 死亡 ReLU |
| LeakyReLU | x>0 ? x : 0.01x | (−∞, ∞) | 负区留一条小斜率(默认 0.01),缓解死亡 ReLU |
| GELU | x · Φ(x) | ≈[−0.17, ∞) | ReLU 的平滑版,Transformer / GPT 类模型的常客 |
| SiLU / Swish | x · sigmoid(x) | ≈[−0.28, ∞) | 像一个平滑自门控:输入越大越放行,输入很负则慢慢压下去 |
| SwiGLU | SiLU(x Wgate) ⊙ x Wup | 随 FFN 输出层而定 | 现代 LLM 常用的门控 FFN:一路做内容,一路做开关,两路相乘再降维 |
| SiTU-GLU | softcap(门控) · sigmoid(门控) ⊙ softcap(内容) | |每个分量| < β1β2 | Kimi K3 的门控 FFN:保留 SwiGLU 在常用区间的形状,同时给极端激活加平滑上限 |
Φ(x) 是标准正态分布的累积函数。本仓库的 GELU 用 std::erf 精确实现。
Sigmoid / Tanh:平滑,但会“饱和”
Sigmoid 把任何输入压进 (0, 1),很像“概率”,历史上最流行。但它有个致命伤:当输入很大或很小时, 曲线几乎变平(饱和),此处导数趋近 0。Sigmoid 的导数最大也只有 0.25, 反向传播一层层乘下去,梯度会越乘越小——这就是梯度消失,深网络的前几层因此几乎学不动。 Tanh 把输出压到 (−1, 1),是零中心的,通常比 Sigmoid 收敛更好,但饱和问题依旧。
ReLU:简单粗暴,却成了默认选择
ReLU(修正线性单元)只做一件事:负数砍成 0,正数原样放行。 它便宜(一个比较就完事)、在正区导数恒为 1(不饱和,梯度传得动), 让深网络训练一下子顺畅了很多,至今仍是最常用的默认激活。
ReLU 的负区输出恒为 0,导数也为 0。如果某个神经元的输入长期落在负区,它就永远输出 0、永远没有梯度, 相当于“死掉”了,再也学不动。这在学习率过大时尤其容易发生。 LeakyReLU 的解法很直接:负区不砍成 0,而是给一条很小的斜率(如 0.01x),让它还留着一口气。
GELU:ReLU 的“平滑升级版”
GELU(高斯误差线性单元)可以理解成“软化了拐角”的 ReLU:它不像 ReLU 在 0 处 硬生生一个折角,而是平滑过渡,负区还允许一点点小小的负输出。它在 Transformer、BERT、GPT 这类模型里几乎是标配,实践中往往比 ReLU 略好。代价是计算稍贵(要算正态分布的积分)。
SiLU 和 SwiGLU:现代大模型里的门控激活
SiLU 也常被叫作 Swish,公式是
x · sigmoid(x)。它不像 ReLU 那样把负数一刀切掉,而是让输入自己决定“放行多少”:
x 越大,乘上的 sigmoid(x) 越接近 1; x 很负时,这个系数接近 0,
输出就被温和地压下去。
SwiGLU 则更进一步:它不是单纯替换 ReLU 的一条曲线,而是 FFN 里的 门控结构。普通 FFN 大致是“升维 → GELU/ReLU → 降维”,而 SwiGLU 会把升维拆成两路:
SwiGLU 的直觉:内容分支给出候选特征,门控分支给出一组平滑开关;两者逐元素相乘后,再用 Wdown 压回模型宽度。
SwiGLU(x) = g ⊙ u
FFN(x) = SwiGLU(x) Wdown u 是内容分支,g 是门控分支,⊙ 表示逐元素相乘
直觉上,u 负责生成要加工的内容,g 负责决定哪些维度该通过、该通过多少。 所以它比“过一个 GELU”多了一层可学习的开关。LLaMA、PaLM、Qwen、DeepSeek 等现代大模型的 FFN 常见的就是这类门控激活。
SiTU-GLU:给极端激活加一道“软上限”
SwiGLU 的两路都可能越变越大:门控路里的 SiLU 在正区近似线性增长,内容路 u 也是没有上限的线性投影; 两个大数再逐元素相乘,就可能产生很尖的激活异常值(activation outlier)。 模型做到数万亿参数、又用 BF16 / FP8 等低精度训练时,这些异常值更容易造成数值溢出和训练不稳定。
Kimi K3 使用的 SiTU-GLU(Sigmoid Tanh Unit GLU)没有扔掉 SwiGLU 的门控结构, 而是先用一个平滑函数给两路的线性部分限幅:
a = xWgate, u = xWup
SiTU-GLU(x) = [softcap(a, β1) ⊙ sigmoid(a)] ⊙ softcap(u, β2) 门控路和内容路都先“软封顶”,再像 SwiGLU 一样逐元素相乘
SiTU-GLU 不在正常区间粗暴截断数值,而是让极端值逐渐贴近上限。这样既抑制异常激活,曲线又保持连续可导。
为什么这种改法不会把正常信号也压坏?因为 z 接近 0 时,tanh(z/β) ≈ z/β,所以
softcap(z,β) ≈ z,SiTU-GLU 在常用的小值区近似 SwiGLU;只有数值变得很大时,tanh 才逐渐贴近 ±1,
把该分支压在 ±β 内。相比直接用 clip 一刀砍平,这种软限幅没有硬拐点,更适合反向传播。
门控路绝对值小于 4,内容路绝对值小于 25,所以两路相乘后每个输出分量的绝对值小于 4 × 25 = 100。这个明确的数值上界用来控制 2.8T 参数、低精度训练中的激活异常值 技术报告。
4. 到底用哪个?一张速查
- 默认从 ReLU 开始:简单、快、够用,大多数情况先上它。
- 担心死亡 ReLU(比如很深、或调大了学习率)→ 换 LeakyReLU。
- 做 Transformer / 语言模型 → 早期 BERT / GPT 常见 GELU;现代 LLM 的 FFN 常见 SwiGLU 这类门控激活;Kimi K3 用 SiTU-GLU 给极端激活平滑限幅。
- 输出层要“概率”→ 用 Sigmoid(二分类)或 Softmax(多分类,第 4 章);隐藏层一般不用 Sigmoid/Tanh(容易梯度消失)。
5. 在代码里长什么样
本仓库把激活函数抽象成一个统一接口:每个激活至少实现“正向 Activate”和
“求导 DerivActivate”两件事。反向传播时(第 6 章),就是要乘上这个导数。
enum ActivateType {
ACTIVATE_SIGMOID, ACTIVATE_RELU, ACTIVATE_TANH,
ACTIVATE_LEAKY_RELU, ACTIVATE_GELU, 1
};
class ActivateFunction {
virtual double Activate(const double &input) = 0; // 正向 2
virtual double DerivActivate(const double &output) = 0; // 求导 3
// GELU 等需要 pre-activation 的, 再重载一个 2 参版本
virtual double DerivActivate(const double &input, const double &output);
};
- 五种激活各是一个子类,用一个枚举 + 工厂(
ActivateFactory)来选,想加新激活只要照抄这套模式。 Activate是正向:输入 z,输出激活后的值。ReLU 就是一句max(0, x)。DerivActivate是求导,反向传播要用它。Sigmoid/ReLU 用输出就能算导数;GELU 需要额外知道输入 z,所以多了一个 2 参版本(第 6 章埋过这个伏笔)。
注意:SwiGLU、SiTU-GLU 这种门控 FFN 不太适合塞进这里的 ActivateFunction 接口,因为它们不是“一个数进、一个数出”的普通激活,
而是两路线性投影加逐元素相乘。真要在本仓库实现它,更自然的位置会是 Transformer 的 FFN 模块,而不是单独加一个普通激活函数子类。
反向传播时(第 6 章),每经过一个激活函数,就要乘一次它的导数。所以“导数会不会变成 0” 直接决定“梯度能不能传回去”——这正是我们挑激活函数时最在意的事,也是 ReLU 打败 Sigmoid 的根本原因。
小结
- 没有激活函数,再深的网络也只是一条直线;激活给网络加“非线性的弯”。
- 看激活函数,重点看两件事:形状和斜率(导数);斜率≈0 的地方梯度会断流。
- Sigmoid/Tanh 平滑但会饱和 → 梯度消失;ReLU 又快又不饱和,但有死亡 ReLU。
- LeakyReLU 给负区留小斜率救活;GELU 是平滑版 ReLU,Transformer 常用。
- SiLU 是 x · sigmoid(x) 的平滑自门控;SwiGLU 把 FFN 拆成“内容 × 门控”,是现代 LLM 常见配件。
- SiTU-GLU 在 SwiGLU 两路加入平滑 softcap;Kimi K3 取 β₁=4、β₂=25,把输出分量绝对值限制在 100 以内,抑制大规模低精度训练中的激活异常值。
- 实战默认 ReLU;深网络/大学习率考虑 LeakyReLU;早期 Transformer 常用 GELU,现代 LLM 常见 SwiGLU;输出层才用 Sigmoid/Softmax。
有了激活函数,前向和反向都能顺畅跑了。可参数到底该怎么更新才又快又稳? 下一章我们把优化器家族——从最朴素的 SGD 一路讲到 Adam——排成一条进化线。