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

激活函数全家福

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

读完这一章,你会明白

  • 为什么必须有非线性——没有激活函数,堆一百层也等于一层;
  • 怎么“读”一张激活函数图,以及为什么导数(斜率)决定梯度好不好传;
  • Sigmoid / Tanh / ReLU / LeakyReLU / GELU 各自的样子和优缺点;
  • 两个经典毛病:梯度消失死亡 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 类模型的常客

Φ(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 略好。代价是计算稍贵(要算正态分布的积分)。

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 章埋过这个伏笔)。
和梯度的关系,一句话串起来

反向传播时(第 6 章),每经过一个激活函数,就要乘一次它的导数。所以“导数会不会变成 0” 直接决定“梯度能不能传回去”——这正是我们挑激活函数时最在意的事,也是 ReLU 打败 Sigmoid 的根本原因。

小结

  • 没有激活函数,再深的网络也只是一条直线;激活给网络加“非线性的弯”。
  • 看激活函数,重点看两件事:形状斜率(导数);斜率≈0 的地方梯度会断流。
  • Sigmoid/Tanh 平滑但会饱和 → 梯度消失;ReLU 又快又不饱和,但有死亡 ReLU
  • LeakyReLU 给负区留小斜率救活;GELU 是平滑版 ReLU,Transformer 常用。
  • 实战默认 ReLU;深网络/大学习率考虑 LeakyReLU;Transformer 用 GELU;输出层才用 Sigmoid/Softmax。

有了激活函数,前向和反向都能顺畅跑了。可参数到底该怎么更新才又快又稳? 下一章我们把优化器家族——从最朴素的 SGD 一路讲到 Adam——排成一条进化线。