梯度下降 入门

一行算术,重复几十万次,训练出了你听说过的每一个模型。本文把它拆开:这一步本身、决定它是收敛还是爆炸的那一个数、动量和 Adam 为什么存在、每一步能吃到多少数据,以及把一次训练带到终点的那张调度表。

01

往下走一步

你听说过的每一个模型,训练时干的都是重复同一行算术:量一下斜率,逆着它走一步,再量一次。

想象一颗弹珠停在山坡上。弹珠是参数向量 θ;山坡是损失 L(θ),每一组 θ 对应一个数;「往下」就是梯度 ∇L 的反方向。在一维里,山坡是一条曲线,弹珠是上面的一个点。

弹珠脚下的斜率是这套算法唯一的输入:切线在两侧很陡,在极小值处是平的。拖动小球,从它下面的切线读出 ∇L:

θ = 3.20. 拖动小球沿曲线移动;方向键每次移动 0.2,Home 键回到 3.2
θ = 3.20

注意 ∇L 是带符号的数,不是一个方向词:在 θ = 3.20 处它是 +3.20,到了左侧就变成负的。梯度永远指向上山。它还会随着小球靠近极小值而变短,而这正是更新规则要利用的第一件事。

规则是 θ ← θ − η∇L:把梯度乘上学习率 η,然后减掉。在 η = 0 时什么都不动。把它调大,看这一步长起来:

η = 0 —— 还没有走

负号才是把「正梯度」变成「向左的箭头」的东西。看落点:在 η = 1.00 处它正好落在极小值上,到 1.05 就冲过去了。步长是一个乘积 —— 我们选的旋钮乘上我们量到的斜率 —— 所以同一个 η 在不同问题上表现完全不同。

一切都压在这个符号上。开关用θ ← θ − η∇L 和θ ← θ + η∇L 各走同样的四步:

θ ← θ − η∇L —— 下山

加号不会大声失败。它照跑,照返回数字,损失在四步里从 5.12 爬到 15.66 —— 一次报告「损失在上升」而完全不报错的训练。框架把符号藏在优化器里,所以你真正会碰到的版本,是自己手写的梯度符号写反了。

回到下山那一侧,还有一个性质是白送的。以固定的 η = 0.35 一步一步走十二步,边走边读这一步的长度:

已走 0 步

因为梯度随着到极小值的距离一起变小,步长也跟着变小 —— 第一步 1.12,第十二步 0.010。这不是谁排的日程,它就是规则自己的算术;这也是为什么一次训练看起来「收敛了」的时候,其实远远还没有。

所以不变式可以直接当断言写下来:对曲率不超过 a 的损失,任何满足 η < 2/a 的一步,都让参数更靠近极小值 ——L(θ − η∇L) − L(θ) ≤ −η(1 − ηa/2)‖∇L‖²。这个界里带着一个 a,而没有人会把 a 交到你手上。

02

一步该迈多大

一个数决定了训练是收敛、是爬行,还是炸掉 —— 而同一个数,放在不同的损失上意思并不一样。

§01 那个碗的曲率是 a = 1,所以更新就正好是 θ ← (1 − η)θ。这一个系数就是全部故事:到极小值的距离,每一步都被乘上 |1 − η|。

同一个碗,十二步,学习率在你手上。小的学习率只会爬; 一步到位;越过 ,小球就飞出碗外:

η = 0.20

看 1.00 到 2.00 之间发生了什么:每一步都冲过极小值落到另一侧,但仍然比之前更近。冲过头不等于发散。左右来回但幅度一直缩小的那种在收敛;左右来回而幅度在涨的那种不是 —— 两者之间的分界线是精确的。

这条分界线是一个可以画出来的数。|1 − ηa| 就是距离每一步被乘上的东西 —— 拖动标记沿它移动:

η = 0.20. 左右拖动沿曲线移动;方向键每次改变 η 0.05,Home 键回到 0.20
η = 0.20

这个 V 形在 η = 1/a 处触底,那里系数是 0,一步就精确解决问题。两侧的进展都是几何式的:η = 0.20 时系数是 0.80,到 1% 要 21 步;η = 0.05 时系数是 0.95,同样的 1% 要 90 步。学习率变成四分之一,步数就变成四倍。

但训练时你看到的并不是这些。你看到的是损失 —— 每步一个数,画在对数轴上。设一个 η,读出它的形状:

η = 0.20

三种形状,一个旋钮。η = 1 以下是笔直下落,因为在对数轴上几何衰减就是一条直线。 1 到 2 之间是锯齿状地往下走。越过 2 就往上爬,而且是几何式地爬 —— 这就是为什么发散的训练是在几十步、而不是几千步之内变成 inf。

这个界是 2/a,所以安全的学习率是曲面的属性,不是优化器的属性。把点在曲率和学习率之间拖动,越过那条边界:

a = 1.0 · η = 0.20. 左右拖动沿曲线移动;方向键每次改变 η 0.05,Home 键回到 0.20
右边 a = 1.0

注意两条曲线形状相同:η = 2/a 是发散的地方,η = 1/a 是一步解决的地方,而且它们都随曲率移动。曲率翻倍,学习率就得减半 —— 这也是为什么在一个模型上调出来的学习率,换个规模就不能直接搬过去。

实践中 a 不是一个数。它是训练走过的所有地方里最大的那个曲率,而且随着训练移动一直在变。所以常见的失败不是第 0 步就干净利落地发散,而是训练了一个小时,走到曲面更尖的一块之后才炸掉。

03

两个方向,一个学习率

真实模型有几百万个参数,而它们的曲率并不一样。一个学习率必须同时伺候所有人。

两维就足够看清整个问题。损失是 ½(axx² + ayy²) —— 一条山谷,一个方向很长,横过另一个方向则很陡。比值 κ = ay/ax 就是条件数。

先看几何。把这个点拖到谷里任意位置,看梯度会把它往哪儿送:

θ = (-0.88, 0.62). 把点拖到谷里任意位置;方向键移动它,Home 键放回原处
θ = (-0.88,…)

箭头几乎是笔直横穿山谷,而不是沿着山谷。在初始位置,它横向比纵向长 8.5 倍,所以这一步基本是垂直的 —— 全花在那个本来就已经差不多对了的方向上。梯度下降并不指向极小值,它指向最陡的那面墙。

从那个点走四十步,学习率在你手上。陡的那个方向决定了它最大能到多少 —— 推过 它就发散:

η = 0.40

因为陡方向定了天花板,这条谷允许的最好学习率是 ,而它关掉最后 1% 仍然要 28 步:缓的那个方向,正在用为陡方向挑的学习率慢慢爬。把 η 调到 0.40,锯齿没了,步数变成 130。

糟到什么程度,是一个数的函数。给每个 κ 都取它最好的学习率,把山谷从一个圆一路拉长到 :

κ = 1

看步数随 κ 一路爬上去。圆一步就解决 —— 一个曲率,一个完美的学习率 —— 从那儿开始: κ = 2 是 5 步,12 是 28 步,40 是 93 步,因为每步的收缩系数是 (κ − 1)/(κ + 1),从下面逼近 1。让优化变慢的是条件数,不是维度。

一个便宜的改动能修掉大半。把上一步的 β 那么一份留下来加到这一步上 —— 这就是速度 —— 然后沿着它走,而不是沿着原始梯度走:

β = 0 —— 没有动量

看横向的分量互相抵消。相邻两步在同一面墙上一上一下,所以相减;沿谷的分量方向一致,于是速度越积越大。在 处,同一次训练只要 9 步而不是 28 步 —— 锯齿被花在了前进上,而不是花在它自己身上。

动量有它自己的失败方式,而且不是发散。把 β 推过这条谷想要的值,在对数轴上读到极小值的距离:

β = 0.45

在 β = 0.90 时它仍然收敛 —— 只是要 74 步而不是 9 步,而且大部分步都花在离开极小值再回来。动量过大就是碗里的一颗重球:它掉不出去,只是在里面滚。信号是一条以几十步为尺度来回振荡的损失曲线。

理论说,配对选对了能把 κ 变成 √κ:取 η = 4/(√amax + √amin)² 和 β = ((√κ−1)/(√κ+1))²,收缩系数就是 (√κ−1)/(√κ+1) —— κ = 12 时 12 步对 28 步。两个设定都要 κ,而 κ 同样没人会交给你。

04

给每个参数一个学习率

真实网络的条件数大得吓人,而其中最糟的一点是:不同参数需要的步长,大小差得离谱。

从一个模型里挑六个参数 —— 一个 LayerNorm 的缩放、一个偏置、一个注意力投影、一个罕见 token 的嵌入行。它们的梯度在同一次反向传播里跨了两个数量级,而一个 η 要同时乘到这六个上。

假设它们每个都想要一个接近 0.05 的步长,允许上下两倍。挪动 ,数一数这六个里有几个落进那条带子:

η = 0.02

看着 η 移动时那个计数:最好的情况是六里有二。梯度跨了 120 倍,步长就跨 120 倍,而那条带子只有四倍宽 —— 怎么挑 η 都赢不了这个算术。要么最大的那个参数迈出一步冲过头,要么最小的那个迈出一步永远走不完。

Adam 的答案是除以梯度自己的大小。它为每个参数各维护一个滑动均值 m 和一个滑动均方 v;拖动步数计数器,看√v 一路走到|g| 上:

第 1 步之后

因为√v 收敛到梯度自己的幅度,m̂/√v̂ 不管尺度多大都是 ±1,于是每个参数每步都走一个学习率。这就是 Adam 买到的东西:不是速度,而是一个不再依赖「你没得选的那个尺度」的步长。η 不再表示「乘上梯度」,而开始表示「每一步」。

两个累加器都从 0 开始,所以早期它们都偏小 —— 而且两个衰减的速度并不一样。先把修正关掉,再把计数器走过头两百步:

第 1 步

β₂ = 0.999 的预热比 β₁ = 0.9 慢一百倍,所以偏小的是分母。不做修正时,Adam 的第一步是3.16 个学习率,并在 。它是静默失败的:训练照跑,损失照降,而最初几百步是用你设定值的六倍走出来的。

这份状态并不免费。两个累加器都是参数的完整副本,而且是 fp32 的。选一个模型和一个优化器,对着一张 80 GB 的卡读这笔账:

模型 6.7B

Adam 每个参数要 16 字节,SGD 只要 8 —— 权重和梯度各一份 fp16、一份 fp32 主副本,再加两份 fp32 的矩。一个 的模型,Adam 要100 GB,SGD 要 50 GB —— 于是放不下的那个模型,只要放弃本节要讲的东西,立刻就放得下了。

坑也在这里。Adam 的 weight decay 不是 L2 正则:把 λθ 加到梯度里,它就会一起过那道 1/√v̂ 的除法,于是梯度大的参数反而被衰减得更少。AdamW 是在除法之后直接从参数里减掉 λθ —— 一行之差,换来的是实打实的精度。

05

每一步吃多少数据

更新规则里的那个梯度,是整个数据集上的平均值。没有人真的去算它。每一步真实的训练,用的都是一个样本。

一个参数、六十四个训练样本、每个样本一个梯度。全量梯度是它们的均值 ——规则真正想要的那个数。小批量取其中 B 个,拿这 B 个的均值顶上去。

把批调大,看估计值一路走到真值上:

B = 1

在 B = 1 时估计值是 0.452,真值是 0.528 —— 而这个误差的符号每抽一次都会变。 SGD 之所以能用,原因在于误差的期望是 0:估计值是无偏的,所以走很多步之后,错的部分互相抵消,对的部分累积起来。噪声不是偏差。

误差缩小的速度,是普通的平方根律。拖动标记沿这条直线移动,把标准误读出来:

B = 1. 左右拖动沿直线移动;方向键改变批大小,Home 键回到 1
B = 1

两个轴都是对数的,所以这条幂律就是一条斜率 −½ 的直线:买到的是噪声减半,不是减到四分之一。批翻倍要花两倍算力,换回 1.41 倍的精度,而这个比例在任何规模上都不会变好。

这套词汇也从同一张图里掉出来。把数据的一遍切成若干批:每一批是一次迭代、一次优化器更新,整行是一轮(epoch):

B = 1

一轮是把数据过一遍;一步是一次更新。这是两个不同的单位,它们之间的比是 ⌈N/B⌉ —— 所以「我们训了十轮」在你不知道 B 之前,完全没有说清楚到底发生了多少次更新。调度表是按步索引的,从来不是按轮。

放上真实数字。ImageNet 的训练集是 1,281,167 张图,经典配方跑九十轮 —— 拖动批大小,把两个计数都读出来:

B = 256. 左右拖动沿直线移动;方向键把批大小加倍或减半,Home 键回到 256
B = 256

在 B = 256 时,那是每轮 5,005 步、整次训练450,450 次更新。把批推到 ,同样的九十轮就只有 28,170 次更新 —— 在完全相同的数据上,优化器能动的机会少了十六倍。

这是有代价才换来的。一步里有一部分成本跟 B 无关 —— kernel 启动、优化器自己那一遍逐元素运算、一次梯度 all-reduce —— 再加上跟 B 有关的那部分算术,正是它把吞吐推向上限:

B = 32. 左右拖动沿曲线移动;方向键改变批大小,Home 键回到 32
B = 32

注意曲线在哪里拐弯。B = 80 以下,固定的 12 ms 占主导,吞吐几乎跟批成正比:32 → 256 快 2.7 倍。再往上算术占主导,曲线压向上限: 批变四倍,吞吐只涨 1.22 倍,而更新次数只剩四分之一。

所以批大小是一笔三方交易:噪声按 1/√B 下降,每轮墙钟时间一直降到硬件吃饱为止,而更新次数按 1/B 下降。通常的解法是让学习率跟着批一起涨 —— 线性地涨,到某个上限为止 —— 好让同样的距离用更少、更大的步走完。

06

怎么收尾

会收敛的训练,不等于会结束的训练。有三件事决定它什么时候停、停在哪儿。

到目前为止,η 在训练途中一直没变过。真实的大模型都会变:从接近 0 开始,爬几千步,然后一路衰减到快没有为止。这个形状的两半,各自在修不同的问题。

先看预热那一半。拖动预热长度,看第一步被允许有多大:

没有预热

不做预热的话,第一步就用峰值学习率走出去 —— 走进一个随机初始化里,用一个批估出来的梯度,而 Adam 的二阶矩还是空的。 §04 已经给出,最后这一条自己就值 3–6 倍。预热不是玄学,它是熬过最初几百步最便宜的办法。

衰减那一半修的是另一件事。用固定学习率跑两百个带噪声的步,看损失在哪儿停止下降:

η = 0.10

损失并不收敛到极小值;它收敛到极小值周围的一团云,而这团云的大小是学习率定的。地板是 ησ²/2(2 − ηa) ——,地板也减半。这就是衰减调度买到的东西,也是为什么一次训练的最后五分之一、在几乎没有学习率的情况下,仍然能把数字往下推。

还剩一件,而这件是最容易把人吓醒的。第 24 步来了一个坏批,梯度是平常的二十六倍。设一下裁剪阈值:

裁剪关闭

不裁剪的话,就这一个批走出 2.00 的一步,把损失从 0.004 甩回1.84 —— 几个小时的进展,毁在一个样本上。裁剪把任何超过阈值的梯度按比例缩回阈值,于是 就是 η·c。

那怎么知道它收敛了?不能看损失 —— 损失被噪声地板主导着。诚实的信号是梯度范数:在真正的极小点上它趋于 0,而损失并不。

07

那个循环,和三个优化器

两行放错位置的代码、三个优化器跑同一个问题,以及那八行就是全部的训练循环。

下面两个错误都只是一行放错了位置,而且都不会报错。开关把其中一个放回原位:

照写的循环

漏掉 zero_grad()是吵闹的那个 —— 梯度累加,等效学习率每步都在长,损失停在 2.0。按轮走调度表是安静的那个:它确实收敛,只是收敛到 8.7e−3 而不是 6.1e−4,因为它没离开过 §06 量到的噪声地板。

下面每一行,都是上面的某一节:

for epoch in range(epochs):
  for x, y in loader:      # 1 iteration
    opt.zero_grad()        # grads add up
    loss = criterion(model(x), y)
    loss.backward()        # fills p.grad
    clip_grad_norm_(params, 1.0)
    opt.step()             # theta -= lr*g
    sched.step()           # per step

另外两个:Adam 的 weight decay 不是 AdamW,批 256 的学习率也不是批 2,048 的。最后,三个优化器跑 §03 那条山谷,各自用最好的固定学习率:

SGD

动量完胜:12 步,对 SGD 的 28 步和 Adam 的 98 步。Adam 是一个归一化方法,不管梯度多大都让每个坐标每步走大约 η,所以它的步数随距离伸缩,而不是随条件数。它赢的地方在 §04:什么都不用按张量去调。