第 7 章 · 学习是怎么发生的

激活函数全家福

第 2 章我们说过,一个神经元 = 加权求和 + 一个激活函数。那个不起眼的激活函数, 其实是让神经网络拥有“威力”的关键开关——没有它,再深的网络也退化成一条直线。 这一章把常见的几个激活函数摆到一起:看它们的曲线、导数、脾气,以及到底该用哪个。

读完这一章,你会明白

  • 为什么必须有非线性——没有激活函数,堆一百层也等于一层;
  • 怎么“读”一张激活函数图,以及为什么导数(斜率)决定梯度好不好传;
  • Sigmoid / Tanh / ReLU / LeakyReLU / GELU / SwiGLU / SiTU-GLU 各自的样子和优缺点;
  • 两个经典毛病:梯度消失死亡 ReLU;
  • 实战里到底该选哪个,以及它们在本仓库代码里长什么样。

1. 为什么非要有“非线性”?

假设我们把激活函数去掉,每层只做“加权求和”(线性变换)。那么两层叠起来是什么? 线性套线性,还是线性——相当于一个更大的矩阵乘法。换句话说:

没有激活函数,深度就白搭了

若每层都只是仿射变换(线性变换 + 偏置),那么无论你堆 2 层还是 100 层,整体仍等价于 一层仿射变换——分类边界还是直线 / 超平面,分不开复杂的边界。激活函数往中间塞进一点“拐弯”, 层层叠加后,网络才能拟合任意复杂的函数。这就是“非线性”三个字的全部意义。

所以激活函数的职责就一句话:给每个神经元的输出加一道“非线性的弯”,让网络有能力表达复杂关系。

2. 怎么读一张激活函数图

激活函数是“一个数进、一个数出”的函数,所以能画成一条曲线:横轴是输入 z(加权求和的结果), 纵轴是输出。两件事最值得看:

S 型:Sigmoid / Tanh(两端会“压平”) Sigmoid Tanh 两端斜率≈0 → 梯度消失 折线型:ReLU / GELU(负区处理不同) ReLU GELU 负区被砍成 0(ReLU)

示意图:左边 S 型两端“压平”(斜率趋 0),右边 ReLU 把负数砍成 0、GELU 是它的平滑版。

动手玩:换激活函数、拖动取值点 x,看曲线形状、函数值和导数(切线斜率)怎么变。把 Sigmoid 拖到两端,亲眼看它“变平、导数≈0”——梯度就是在这里消失的。

3. 全家福:逐个认识

激活公式输出范围脾气(优/缺)
Step 阶跃x≥0 ? 1 : 0{0, 1}最早的“开关”;导数几乎处处为 0,没法训练,只剩历史意义
Sigmoid1 / (1 + e−x)(0, 1)平滑、像概率;但两端饱和 → 梯度消失,且非零中心
Tanh(ex−e−x)/(ex+e−x)(−1, 1)零中心,通常比 Sigmoid 好;仍会饱和
ReLUmax(0, x)[0, ∞)又快又好,正区不饱和;但负区恒 0 → 死亡 ReLU
LeakyReLUx>0 ? x : 0.01x(−∞, ∞)负区留一条小斜率(默认 0.01),缓解死亡 ReLU
GELUx · Φ(x)≈[−0.17, ∞)ReLU 的平滑版,Transformer / GPT 类模型的常客
SiLU / Swishx · sigmoid(x)≈[−0.28, ∞)像一个平滑自门控:输入越大越放行,输入很负则慢慢压下去
SwiGLUSiLU(x Wgate) ⊙ x Wup随 FFN 输出层而定现代 LLM 常用的门控 FFN:一路做内容,一路做开关,两路相乘再降维
SiTU-GLUsoftcap(门控) · sigmoid(门控) ⊙ softcap(内容)|每个分量| < β1β2Kimi 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(dying ReLU)

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:把 FFN 的中间层拆成“内容 × 门控” 输入 x 内容分支 x · Wup 门控分支 x · Wgate SiLU 得到门 g 逐元素相乘 输出 再过 Wdown 上路给候选内容,下路给平滑开关;真正送出去的是两路相乘后的结果。

SwiGLU 的直觉:内容分支给出候选特征,门控分支给出一组平滑开关;两者逐元素相乘后,再用 Wdown 压回模型宽度。

u = x Wupg = SiLU(x Wgate)
SwiGLU(x) = gu
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 的门控结构, 而是先用一个平滑函数给两路的线性部分限幅:

softcap(z, β) = β · tanh(z / β)

a = xWgate, u = xWup
SiTU-GLU(x) = [softcap(a, β1) ⊙ sigmoid(a)] ⊙ softcap(u, β2) 门控路和内容路都先“软封顶”,再像 SwiGLU 一样逐元素相乘
SwiGLU:两路线性部分都没有上限 SiLU(a) 可持续增大 u 可持续增大 相乘后可能冒出异常大值 SiTU-GLU:两路先过平滑限幅,再相乘 softcap(a, β₁)·sigmoid(a) 门控路绝对值 < β₁ softcap(u, β₂) 内容路绝对值 < β₂ 输出有明确上界 |输出分量| < β₁β₂ 小值区近似原来的线性形状 · 极端大值区逐渐压平

SiTU-GLU 不在正常区间粗暴截断数值,而是让极端值逐渐贴近上限。这样既抑制异常激活,曲线又保持连续可导。

为什么这种改法不会把正常信号也压坏?因为 z 接近 0 时,tanh(z/β) ≈ z/β,所以 softcap(z,β) ≈ z,SiTU-GLU 在常用的小值区近似 SwiGLU;只有数值变得很大时,tanh 才逐渐贴近 ±1, 把该分支压在 ±β 内。相比直接用 clip 一刀砍平,这种软限幅没有硬拐点,更适合反向传播。

Kimi K3 的取值:β₁ = 4,β₂ = 25

门控路绝对值小于 4,内容路绝对值小于 25,所以两路相乘后每个输出分量的绝对值小于 4 × 25 = 100。这个明确的数值上界用来控制 2.8T 参数、低精度训练中的激活异常值 技术报告

4. 到底用哪个?一张速查

5. 在代码里长什么样

本仓库把激活函数抽象成一个统一接口:每个激活至少实现“正向 Activate”和 “求导 DerivActivate”两件事。反向传播时(第 6 章),就是要乘上这个导数。

src/deeplearning/activate/activate_base.h(精简)
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);
};
  1. 五种激活各是一个子类,用一个枚举 + 工厂(ActivateFactory)来选,想加新激活只要照抄这套模式。
  2. Activate 是正向:输入 z,输出激活后的值。ReLU 就是一句 max(0, x)
  3. 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——排成一条进化线。