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

优化器:从 SGD 到 Adam

第 5 章我们学了梯度下降:参数 −= 学习率 × 梯度。这就是最朴素的优化器。 但真拿它去训大网络,你会发现它又慢又爱震荡。这一章我们把优化器家族排成一条进化线—— SGD → Momentum → RMSProp → Adam → AdamW,看每一代到底改进了什么。

读完这一章,你会明白

  • 纯 SGD 的两个痛点:只有一个统一步长在峡谷里来回震荡;
  • Momentum 怎么用“惯性”让下山更顺、冲过小坑;
  • RMSProp 怎么给每个参数配一个自适应步长;
  • Adam 怎么把两者合体(还加了偏差校正),成为如今的默认选择;
  • AdamW 又修正了 Adam 的什么,以及这些在仓库代码里怎么统一实现。

1. 先回顾:优化器在解决什么

训练的每一步,反向传播都会算出每个参数的梯度(第 6 章)。优化器的活儿,就是决定 “拿到梯度后,参数具体怎么挪”。最朴素的挪法就是第 5 章那条:

参数 ← 参数 − 学习率 × 梯度 这就是 SGD(随机梯度下降)。简单,但不够聪明

它有两个一眼可见的短板,后面每一代优化器都在补它们:

谷底 SGD:在陡壁间来回震荡 Momentum:更顺地冲向谷底

狭长“峡谷”式损失面:SGD(黄)反复横跳,加了惯性的 Momentum(蓝)更直地滑向谷底。学完四种优化器后,第 8 节有一个能亲手让它们赛跑的可交互版本。

2. SGD 与 mini-batch:一次用多少数据?

“随机”指的是:每一步不用全部数据算梯度,而是抽一小批(mini-batch)来估计。这是速度与稳定的折中:

3. Momentum:给下山加“惯性”

Momentum(动量)的想法特别形象:别让参数“每步只看当前坡度”, 而是像一个滚下山的球,带着惯性。它把历史的更新方向累积成一个“速度”:

速度 vβ · v + 梯度     参数 ← 参数 − 学习率 × v β(默认 0.9)是“保留多少惯性”;v 把连续同向的梯度越滚越大

好处正好对上 SGD 的痛点:

4. RMSProp:每个参数配一个自适应步长

Momentum 解决了“方向”,但学习率仍是一个统一值。RMSProp换个角度: 让每个参数自己有自己的步长。它记录每个参数梯度平方的滑动平均(衡量这个方向“一贯有多陡”), 再拿它去缩放步长:

sβ · s + (1−β) · 梯度2     参数 ← 参数 − 学习率 × 梯度s + ε 一贯很陡的方向(s 大)→ 步子自动变小;一贯平缓的方向 → 步子放大

直觉:陡的方向容易冲过头,就自动小步;平的方向进展慢,就自动大步。这样峡谷里也能走得又稳又快。 (ε 是个很小的数,防止除以 0。)

5. Adam:把两者合体(默认之选)

Adam = Momentum(管方向) + RMSProp(管步长),外加一个“偏差校正”。 它同时维护两个滑动平均:一阶矩 m(梯度的均值,即惯性)和二阶矩 v(梯度平方的均值,即陡峭度):

mβ1m + (1−β1)·g    vβ2v + (1−β2)·g2
= m / (1−β1t)    = v / (1−β2t)    参数 ← 参数 − 学习率 · + ε 默认 β₁=0.9、β₂=0.999、ε=1e-8;t 是步数
“偏差校正”是干嘛的?

mv 都从 0 开始,训练刚起步的几步会被这个 0 拖得偏小(估计不准)。 除以 (1−βt) 就是把这个“起步偏差”补回来——t 越大,这个修正越接近 1、逐渐消失。 这一步就是仓库里 BeforeStep() 每步推进 t 计数要做的事。

正因为“方向 + 自适应步长 + 起步校正”都照顾到了,Adam 成了今天绝大多数模型的默认优化器, 省心、对学习率不那么敏感。

6. AdamW:把 weight decay 解耦

最后一小步升级。为了防过拟合,常给权重加 weight decay(权重衰减,和 L2 正则密切相关,第 10 章)。 先别急着把“L2 正则”和“weight decay”当成两个招:在普通 SGD 里,它们其实是同一件事。设 g 是原损失给出的梯度、w 是当前权重、wd 是衰减系数, 把 wd·w 加进梯度后,一步更新正好可以改写成“先按比例缩水,再照梯度走”:

ww − η · (g + wd · w) = (1−η·wd) · w − η · g SGD 里:把 L2 塞进梯度,等价于每步把权重按 1−η·wd 的比例缩一点

问题出在 Adam 会先用梯度更新 m/v,再按 √v̂ 给每个参数各自缩放步长。 若仍把 wd·w塞进梯度里,这份“把权重拉回 0”的力也会进入 m/v,带上历史、再被每个参数不同的自适应比例重标定。 于是 wd 不再表示“每步统一缩一点”:不同权重得到的有效收缩比例会不同,有的比预期强、有的比预期弱,正则力度和 Adam 的自适应更新搅在了一起。

经典 Adam: g′ = g + wd·w   →   ww − η · m̂(g′)√v̂(g′) + ε
AdamW: ww − η · m̂(g)√v̂(g) + ε − η·wd·w = (1−η·wdw − 自适应更新 关键不在“有没有拉回 0”,而在这股力是否进入 Adam 的 m/v 和自适应分母
AdamW 的修正:分开处理

AdamW 把权重衰减从梯度里拆出来、单独作用在参数上(decoupled weight decay): m/v 只看任务本身的梯度,自适应步长照常算;衰减则单算一份,让每个权重在当步按 1 − lr·wd 的比例收缩。这样 wd 的含义更直接、更好调,也不会污染 Adam 对梯度方向和陡峭度的判断。 它不是“不做 L2”,而是把同样的“别让权重太大”这件事,放在自适应更新之外完成。这正是 AdamW 在 Transformer 和大模型训练中成为默认选择的原因。

两份优化器源码真正分叉的地方(精简)
// Adam · adam_optimizer.cpp
double g = delta;
if (weight_pos != -1 && weight_decay_ != 0.0)
  g += weight_decay_ * param_value;       1  // wd 进入 m/v

// AdamW · adamw_optimizer.cpp
double g = delta;                          2  // m/v 只看原梯度
double adaptive = learning_rate * m_hat / (std::sqrt(v_hat) + epsilon_);
if (weight_pos != -1 && weight_decay_ != 0.0)
  adaptive += learning_rate * weight_decay_ * param_value;  3
  1. Adam 在算 m/v 之前wd*w 混进 g;所以正则项也会积累历史、经过自适应缩放。
  2. AdamW 的 g 保持为反向传播给出的原梯度;先完成与 Adam 相同的自适应更新。
  3. 最后才把 lr·wd·w 加到“本次该扣多少”上。框架随后执行 param -= adaptive,所以它只对真正的权重生效,bias 不衰减。

7. 代码里怎么统一起来

本仓库把所有优化器抽象成一个接口:每步开始调一次 BeforeStep()(Adam 用它推进 t), 然后对每个参数调 CalcChangeValue(...) 算出“该减多少”。想换优化器,只需换一个子类。

src/deeplearning/optimizer/optimizer_base.h(精简)
enum OptimizerType {
  OPTIMIZER_SGD, OPTIMIZER_MOMENTUM,
  OPTIMIZER_ADAM, OPTIMIZER_RMSPROP, OPTIMIZER_ADAMW,   1
};

class OptimizerFunction {
  virtual void BeforeStep() {}                          // Adam 在此推进 t  2
  // 返回“该从参数里扣除多少”, 即 param -= 返回值
  virtual double CalcChangeValue(double delta, double learning_rate,
      const std::pair<int,int> &pos, int weight_pos = -1,
      double param_value = 0.0) = 0;                     3
  void set_weight_decay(double wd);                      // 只对 weight 生效  4
};
  1. 五种优化器各是一个子类,用枚举 + 工厂选择——和激活函数(第 7 章)是同一套“可插拔”模式。
  2. BeforeStep() 每个 minibatch 步开头调一次;SGD 是空的,Adam/AdamW 在这里把步数 t 加一、算好偏差校正。
  3. CalcChangeValue 拿到平均梯度 delta、学习率和参数位置,算出该扣除的量。SGD 就返回 lr·delta;Adam 则套用上面那串 m/v 公式。
  4. weight_pos == -1 表示这是 bias(不做衰减);weight decay 只作用在真正的权重上,Adam 与 AdamW 在这里体现耦合方式的差异。

8. 到底用哪个?

动手玩:四个优化器同一起点、同一学习率赛跑。先在狭长峡谷里看 SGD(黄)来回弹跳、Momentum(蓝)靠惯性把横跳抚平;再切到弯谷,或把 η 调大, 看谁先冲出发散。RMSProp(青)和 Adam(红)的步长是逐方向自适应的,轨迹明显更「懂路」。

动手比一比

仓库里有个现成的对比程序 src/demo/optimizer_bench,在同一个 MNIST 任务上并排跑 SGD / Momentum / Adam / AdamW / RMSProp / Adam+CosineLR。编译后运行,就能亲眼看到不同优化器 收敛速度和最终精度的差别——比读十遍公式都直观。

小结

  • 优化器决定“拿到梯度后参数怎么挪”。SGD 最朴素,但统一步长 + 峡谷震荡是两大痛点。
  • Momentum:累积“速度”,靠惯性冲得更快、抚平横跳。
  • RMSProp:按梯度平方缩放,给每个参数自适应步长(陡则小步、平则大步)。
  • Adam = Momentum + RMSProp + 偏差校正,今天的默认优化器。
  • AdamW 把 weight decay 从梯度里解耦,更干净,是训大模型的标配。
  • 仓库用统一接口(BeforeStep + CalcChangeValue)实现全家,optimizer_bench 可直接对比。

激活函数选好了、优化器也选好了,可训练还可能发散、卡住、忽快忽慢。 下一章我们把参数初始化、学习率调度、梯度裁剪这套“现代训练栈”一次配齐,让它真的训得动。