前向(第 5~7 课)、损失(第 10 课)、梯度(第 11 课)—— 你手里已经有一套完整的零件。 把它们串起来,代码长这样: for step in range(8,000), 里面五行。真正难的不是这五行,而是第 4 行里那个数字: 这一步该迈多大?迈小了 8,000 步走不完, 迈大了第 7 步就飞到 nan。这一课把「迈步」这件事从一行公式, 拆成三个优化器、一条学习率曲线、两个限速器。
先说清楚这节课的位置。到第 11 课为止, 你学会了「怎么算出梯度」—— 那是一个数学问题,答案唯一。 这一课开始,换成工程问题:梯度到手之后, 往哪个方向走、一步走多远,没有唯一正确答案, 只有「这样调通常更好」。
所以我们不打算给你一堆名词。我们打算给你一座山谷和三条轨迹 —— 真的把三个优化器跑一遍,每一步的数字都印在页面上。 看完你会自己得出那个结论:这三个名字的差别, 全在「它怎么决定这一步迈多大」。
你笔记本上那五行代码,第一次跑起来时会卡在第一行:一次喂几条样本?语料一共 4,000 条, 一次全塞进去行不行?
先别急着回答。我们把循环里的五行动作摆出来,点开每一行看它在那一步里的真实数字。 模型还是第 10 课那座最简单的小山谷: loss(w) = (w − 3)²,起点 w = 9 —— 每一步都能按计算器核对。
loss.backward())就已经全部算完了。 第 4 行只是照着一个极简的公式挪一下: w ← w − 0.1 × 梯度。这一课要讲的, 就是这一行的四个变种(SGD / Momentum / Adam),以及它前面那些 「步子该多大」的开关。上面演示里 batch 那排按钮,换一换就能看到另一件容易搞混的事:「步」和「遍」不是一回事。
第 10 课那座山谷只有一维:一个参数,一个方向。真实模型不是 —— 它有 798,720 个参数,也就是 798,720 个方向。 而这 798,720 个方向里, 大部分几乎平坦,少数极其陡峭。
我们造一座最小的「病态」山谷,只留两个方向,陡峭程度差 20 倍:
L(w1, w2) = ½ · (1·w1² + 20·w2²)
w1 方向是一条又长又平的长廊,w2 方向是一堵陡壁。 从 (9, 1) 出发,loss = 50.500000。 先用最朴素的那个办法 —— SGD,也就是第 10 课学的 「w ← w − 学习率 × 梯度」—— 走几步看看,用 0.09 这个学习率:
看清楚 w2 那一行:1 → −0.8 → 0.64 → −0.512。 符号每步翻一次,绝对值每步乘 0.8 —— 它在陡壁上来回弹, 一步冲到对面,再一步弹回来。而 w1 那一行: 9 → 8.190000 → 7.452900 → 6.782139, 每步只乘 0.910000。 为什么差这么多?因为更新的系数是 1 − 学习率 × 这个方向的二阶导:
平方向:1 − 0.09 × 1 = 0.910000 ← 慢慢缩
陡方向:1 − 0.09 × 20 = -0.800000 ← 缩得很凶,而且是负数
那个负数就是「弹」的来源。同一个学习率,在平方向太小、在陡方向太大 —— 这就是所有优化器要解决的第一个问题。下面的图把这件事画出来了: 三条轨迹跑的是真代码,每一步都踩在页面上。
nan。 注意小档位那两行:Adam 反而最差。这不是 bug —— 它的步长被钉死在 lr 附近,60 步根本走不完从 9 到 0 这段路。下面这张表是这一站的全部答案。它把学习率从 0.01 一路扫到 2, 每一档都让三个优化器各跑 60 步,看谁还剩着:
请注意你刚刚做了什么 —— 你把「学习率」这个旋钮从头拧到尾, 看到了一件平时很难看清的事:每一档都换一个冠军。 小档位 Momentum 赢,中档 SGD 赢,两个大档只剩 Adam 没炸。 所以「哪个优化器更好」这个问题本身问错了, 真正该问的是:它能容忍多宽的学习率范围?SGD 的窗口只有 0.01~0.09 这么窄, Adam 从 0.09 到 2 都活着。
标准答案是「Adam 用梯度的一阶矩和二阶矩做自适应学习率」。 但面试官更想听的是代价那一半:
到这里为止,我们一直把学习率当成一个固定值。可真实的训练里, 它从第 0 步到第 8,000 步, 会变化整整 10 倍。
这不是什么玄学调参技巧,是两个非常具体的理由——一个管开局,一个管收尾。
开局:第 0 步时,参数是随机初始化的,算出来的梯度方向几乎全是噪声。 这时候如果直接用峰值学习率 3e-4, 等于闭着眼睛一脚油门踩到底 —— 第 11 课讲的那个梯度爆炸,在这里不是意外,是必然。 所以要先用几百步小碎步,让优化器摸清「这块地形的尺度大概是多大」, 再放开跑。这段越来越大的过程叫预热(warmup)。
收尾:训练后期,loss 已经躺在一个又窄又浅的坑底附近。 学习率不降,算法就会一直从坑壁上弹过去弹回来 —— 就是第 2 站 adam 那条轨迹。降下来才收得住。
lrAt(step, 800) 当场算的: 前 800 步从 0 线性升到峰值 3e-4, 之后按余弦从峰值慢慢降到 3e-5 —— 整条曲线的总跨度 10 倍。lrAt(step, 预热步数) 当场算的,不是画出来的示意折线。 末尾四条曲线会重合 —— 因为余弦的形状只由总步数和两端决定。常见的错误答案是「防止梯度太大」。更准确的说法是:防止「优化器对梯度尺度的估计」还没准的时候就下重手。对 Adam 尤其明显 —— 它第一步的分母 √s 只由一两个样本的梯度算出来, 非常不稳,会给出一个方向离谱、长度极大的步子。 预热就是给这个「估计过程」留出时间。这也是为什么换优化器时预热长度往往要跟着改,它不是个能照抄的超参。
学好了方向和步子大小,还有一类事故没防住:某一步的梯度突然大得离谱。语料里混进一条格式奇怪的样本、或者某个数值刚好卡在极端 —— 一步就能把攒了很久的参数踹飞,前面几万步白跑。
对策简单到粗暴:整批梯度加完之后,量一下它的长度; 如果超过阈值,就整体按比例缩回去。方向一点都不改,只把长度砍到阈值。
w ← w × (1 − lr × wd), 乘开之后约等于 w ← w − lr × wd × w —— 相当于在梯度上偷偷加了一项 wd × w,把参数往 0 的方向拉。第二个限速器是权重衰减。它跟梯度没关系, 看的是参数本身有多大:每一步顺手把参数往 0 的方向拽一点点。w ← w × (1 − 学习率 × wd),就这样一行。 为什么需要它?—— 因为「参数长得太大」通常是模型在死记硬背训练数据的信号。 这正是下一课的主题。
真实的训练脚本开头,都有一坨这样的常量。 现在你已经知道每一个的来历了 —— 我们把它整理成一张单子, 顺便把「这些东西一共要花多少代价」算清楚。
最后算一笔这一课反复提到、但一直没算清的账:优化器自己要占多少地方?模型有 798,720 个参数(第 8 课那本账)。 一个 float32 占 4 字节,那么光参数本身:
798,720 × 4 字节 = 3,194,880 字节 = 3.05 MiB
而 Adam 要多背两份同尺寸的账(一阶距 m、二阶距 s):
798,720 × 4 字节 × 3 份 = 9,584,640 字节 = 9.14 MiB
9.14 MiB 听着不多 —— 因为我们的模型本来就小。 换成 7B(78.2 GiB 光优化器状态), 这笔账就会变成决定「你买得起什么显卡」的东西。 第 16 课要把这笔账算到底。
| 每个参数身上挂着的东西 | 字节 | 为什么 |
|---|---|---|
| 参数本身(权重) | 28,000,000,000 | 模型自己,一个 float32 占 4 字节 |
| 一阶距 m | 28,000,000,000 | 梯度自己的滑动平均 —— 分子管方向 |
| 二阶距 s | 28,000,000,000 | 梯度平方的滑动平均 —— 分母管尺度 |
| 合计 | 84,000,000,000 | AdamW:每个参数 3 × 4 = 12 个字节 |
还有一件更隐蔽的代价:训练比推理慢得多,也吃显存得多。 第 11 课讲过原因 —— 反向传播要乘的那些数,是前向时存下来的中间结果。 一步训练的代价大约是 3 次前向(1 次前向 + 约 2 次前向的反向)。这也是为什么 第 9 课那种「生成一个字」的推理可以随手跑,而这一课的循环要跑几十分钟。
SGD 回答「往哪走」;Momentum 回答「平方向上怎么走快一点」; Adam 回答「每个参数怎么自己管自己的步子」。 学习率曲线的预热与衰减回答「什么时候谨慎、什么时候收敛」; 裁剪和权重衰减回答「怎么别翻车」。你调的不是一组超参,是一整套「步子该怎么迈」的策略。这一课留下最实用的那条直觉,是这个:学习率的意义永远要跟「谁在用」绑在一起说 ——3e-4 对 Adam 是正常的,对 SGD 是胡来。