第 7 章 · 学习是怎么发生的
激活函数全家福
第 2 章我们说过,一个神经元 = 加权求和 + 一个激活函数。那个不起眼的激活函数, 其实是让神经网络拥有“威力”的关键开关——没有它,再深的网络也退化成一条直线。 这一章把常见的几个激活函数摆到一起:看它们的曲线、导数、脾气,以及到底该用哪个。
读完这一章,你会明白
- 为什么必须有非线性——没有激活函数,堆一百层也等于一层;
- 怎么“读”一张激活函数图,以及为什么导数(斜率)决定梯度好不好传;
- Sigmoid / Tanh / ReLU / LeakyReLU / GELU 各自的样子和优缺点;
- 两个经典毛病:梯度消失 与 死亡 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 类模型的常客 |
Φ(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 略好。代价是计算稍贵(要算正态分布的积分)。
4. 到底用哪个?一张速查
- 默认从 ReLU 开始:简单、快、够用,大多数情况先上它。
- 担心死亡 ReLU(比如很深、或调大了学习率)→ 换 LeakyReLU。
- 做 Transformer / 语言模型 → 用 GELU(和主流保持一致)。
- 输出层要“概率”→ 用 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 章埋过这个伏笔)。
反向传播时(第 6 章),每经过一个激活函数,就要乘一次它的导数。所以“导数会不会变成 0” 直接决定“梯度能不能传回去”——这正是我们挑激活函数时最在意的事,也是 ReLU 打败 Sigmoid 的根本原因。
小结
- 没有激活函数,再深的网络也只是一条直线;激活给网络加“非线性的弯”。
- 看激活函数,重点看两件事:形状和斜率(导数);斜率≈0 的地方梯度会断流。
- Sigmoid/Tanh 平滑但会饱和 → 梯度消失;ReLU 又快又不饱和,但有死亡 ReLU。
- LeakyReLU 给负区留小斜率救活;GELU 是平滑版 ReLU,Transformer 常用。
- 实战默认 ReLU;深网络/大学习率考虑 LeakyReLU;Transformer 用 GELU;输出层才用 Sigmoid/Softmax。
有了激活函数,前向和反向都能顺畅跑了。可参数到底该怎么更新才又快又稳? 下一章我们把优化器家族——从最朴素的 SGD 一路讲到 Adam——排成一条进化线。