第 8 章 · 学习是怎么发生的
优化器:从 SGD 到 Adam
第 5 章我们学了梯度下降:参数 −= 学习率 × 梯度。这就是最朴素的优化器。 但真拿它去训大网络,你会发现它又慢又爱震荡。这一章我们把优化器家族排成一条进化线—— SGD → Momentum → RMSProp → Adam → AdamW,看每一代到底改进了什么。
读完这一章,你会明白
- 纯 SGD 的两个痛点:只有一个统一步长、在峡谷里来回震荡;
- Momentum 怎么用“惯性”让下山更顺、冲过小坑;
- RMSProp 怎么给每个参数配一个自适应步长;
- Adam 怎么把两者合体(还加了偏差校正),成为如今的默认选择;
- AdamW 又修正了 Adam 的什么,以及这些在仓库代码里怎么统一实现。
1. 先回顾:优化器在解决什么
训练的每一步,反向传播都会算出每个参数的梯度(第 6 章)。优化器的活儿,就是决定 “拿到梯度后,参数具体怎么挪”。最朴素的挪法就是第 5 章那条:
它有两个一眼可见的短板,后面每一代优化器都在补它们:
- 一个学习率走天下:所有参数用同一个步长。可有的方向该大步走、有的方向该小步挪,一刀切并不好。
- 在“峡谷”里来回震荡:损失面常常一个方向很陡、另一个方向很平(像狭长山谷)。纯 SGD 会在陡壁间来回弹跳,朝谷底前进得很慢。
狭长“峡谷”式损失面:SGD(黄)反复横跳,加了惯性的 Momentum(蓝)更直地滑向谷底。
2. SGD 与 mini-batch:一次用多少数据?
“随机”指的是:每一步不用全部数据算梯度,而是抽一小批(mini-batch)来估计。这是速度与稳定的折中:
- 全量梯度:每步用所有样本,方向最准,但每步极慢、还吃不下大数据;
- 单样本(纯 SGD):每步只用 1 个样本,快但噪声大、方向乱抖;
- mini-batch每步用几十到几百个样本——又快又稳,是实际训练的标准做法(第 5 章讲过 batch)。
3. Momentum:给下山加“惯性”
Momentum(动量)的想法特别形象:别让参数“每步只看当前坡度”, 而是像一个滚下山的球,带着惯性。它把历史的更新方向累积成一个“速度”:
好处正好对上 SGD 的痛点:
- 连续同方向的梯度会被累加,速度越来越大 → 该冲的方向冲得更快,还能冲过小坑(局部小凹陷);
- 来回反向的震荡会相互抵消 → 横跳被抚平,轨迹更直(上图的蓝线)。
4. RMSProp:每个参数配一个自适应步长
Momentum 解决了“方向”,但学习率仍是一个统一值。RMSProp换个角度: 让每个参数自己有自己的步长。它记录每个参数梯度平方的滑动平均(衡量这个方向“一贯有多陡”), 再拿它去缩放步长:
直觉:陡的方向容易冲过头,就自动小步;平的方向进展慢,就自动大步。这样峡谷里也能走得又稳又快。 (ε 是个很小的数,防止除以 0。)
5. Adam:把两者合体(默认之选)
Adam = Momentum(管方向) + RMSProp(管步长),外加一个“偏差校正”。 它同时维护两个滑动平均:一阶矩 m(梯度的均值,即惯性)和二阶矩 v(梯度平方的均值,即陡峭度):
m̂ = m / (1−β1t) v̂ = v / (1−β2t) 参数 ← 参数 − 学习率 · m̂√v̂ + ε 默认 β₁=0.9、β₂=0.999、ε=1e-8;t 是步数
m 和 v 都从 0 开始,训练刚起步的几步会被这个 0 拖得偏小(估计不准)。
除以 (1−βt) 就是把这个“起步偏差”补回来——t 越大,这个修正越接近 1、逐渐消失。
这一步就是仓库里 BeforeStep() 每步推进 t 计数要做的事。
正因为“方向 + 自适应步长 + 起步校正”都照顾到了,Adam 成了今天绝大多数模型的默认优化器, 省心、对学习率不那么敏感。
6. AdamW:把 weight decay 解耦
最后一小步升级。为了防过拟合,常给权重加 weight decay(权重衰减,一种 L2 正则,第 10 章)。
经典 Adam 的做法是把它塞进梯度里(g += wd·w),可这样一来它会被 Adam 那个自适应分母
√v̂ 给削弱,衰减力度变得不稳。
AdamW 把权重衰减从梯度里拆出来、单独作用在参数上(decoupled weight decay): 自适应步长照常算,衰减单算一份直接减。效果更干净、更可控,已成为训练大模型的标配。 仓库里 Adam 与 AdamW 的差别,正是 weight decay 这一处的耦合方式不同。
7. 代码里怎么统一起来
本仓库把所有优化器抽象成一个接口:每步开始调一次 BeforeStep()(Adam 用它推进 t),
然后对每个参数调 CalcChangeValue(...) 算出“该减多少”。想换优化器,只需换一个子类。
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
};
- 五种优化器各是一个子类,用枚举 + 工厂选择——和激活函数(第 7 章)是同一套“可插拔”模式。
BeforeStep()每个 minibatch 步开头调一次;SGD 是空的,Adam/AdamW 在这里把步数 t 加一、算好偏差校正。CalcChangeValue拿到平均梯度delta、学习率和参数位置,算出该扣除的量。SGD 就返回lr·delta;Adam 则套用上面那串 m/v 公式。weight_pos == -1表示这是 bias(不做衰减);weight decay 只作用在真正的权重上,Adam 与 AdamW 在这里体现耦合方式的差异。
8. 到底用哪个?
- 不知道选啥就用 Adam / AdamW:最省心,对学习率最不挑,收敛快。训大模型基本都是 AdamW。
- 想要极致泛化、且愿意仔细调学习率 → SGD + Momentum 有时能训出更好的最终精度(经典 CV 里常见)。
- 纯 SGD 一般只作为教学/基线。
仓库里有个现成的对比程序 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可直接对比。
激活函数选好了、优化器也选好了,可训练还可能发散、卡住、忽快忽慢。 下一章我们把参数初始化、学习率调度、梯度裁剪这套“现代训练栈”一次配齐,让它真的训得动。