上一个单元算出了梯度。这个单元来看怎么用梯度更新参数——也就是优化器(optimizer)。
梯度下降
最简单的更新规则:
参数_new = 参数_old − 学习率 × 梯度
梯度指向 loss 增大的方向,减去梯度就是往 loss 减小的方向走一步。学习率决定步长——每一步走多远。
这个方法叫梯度下降(Gradient Descent)。如果每一步只看一小批数据(而不是全部数据)来估计梯度,就叫随机梯度下降(Stochastic Gradient Descent,SGD)。
为什么只看一小批?因为 Sophia 有 200 亿个 token 的训练数据。如果每一步都在全部数据上算一次梯度,一步就需要做一次完整的前向和反向传播——几千个 GPU 小时才能走一步。实际训练需要走几万步,这完全不可行。SGD 每步只用几十万个 token(一个 batch),梯度是近似的,但更新频率高,总训练时间反而短得多。
用 Python 实现
SGD 的代码只需要两行:
for param in model.parameters(): # 遍历模型的每一个参数
param.data -= learning_rate * param.grad # 参数 -= 学习率 × 梯度
param.data 是参数的值,param.grad 是反向传播算出来的梯度。一个循环,所有参数各更新一次。
手动走几步
用第 25 章节的例子继续。假设一个两层的小网络:
输入 x = 2,目标 = 20
y = w₁ × x,z = w₂ × y,loss = (z − 20)²
初始值 w₁ = 3,w₂ = 4,学习率 = 0.001。
第 1 步:
前向:y = 3 × 2 = 6,z = 4 × 6 = 24,loss = (24−20)² = 16
梯度:∂loss/∂w₁ = 64,∂loss/∂w₂ = 48
更新:w₁ = 3 − 0.001 × 64 = 2.936
w₂ = 4 − 0.001 × 48 = 3.952
第 2 步(用新的 w₁ 和 w₂):
前向:y = 2.936 × 2 = 5.872,z = 3.952 × 5.872 = 23.206
loss = (23.206−20)² = 10.285
梯度:∂loss/∂w₁ = 2 × 3.206 × 3.952 × 2 = 50.67
∂loss/∂w₂ = 2 × 3.206 × 5.872 = 37.65
更新:w₁ = 2.936 − 0.001 × 50.67 = 2.885
w₂ = 3.952 − 0.001 × 37.65 = 3.914
两步之后,loss 从 16 降到了 10.285——参数在朝着正确的方向移动。继续走下去,z 会越来越接近 20。
SGD 的问题
SGD 在这种简单问题上工作得很好(第 01 章节的房价例子就是 SGD)。但在训练语言模型这种复杂问题上,它有两个严重的缺陷。
问题一:方向不稳。 SGD 每一步的梯度只基于当前这一小批数据。不同的 batch 给出的梯度方向不完全一致——有时候指东,有时候指北,整体大致向目标走,但路径非常曲折。
想象一条狭长的山谷。SGD 的行为是:在山谷两壁之间来回弹跳,同时沿山谷方向缓慢推进。一步冲上左壁,下一步弹回右壁,再弹到左壁……实际走过的距离远超直线距离。这种锯齿形的路径浪费了大量计算。
SGD 的路径(俯视图,从 A 走到 B):
A ╲ ╱╲ ╱╲ ╱╲ ╱╲
╲╱ ╲╱ ╲╱ ╲╱ ╲→ B
问题二:所有参数用同一个学习率。 Sophia 有十亿个参数。有些参数的梯度始终很大(它们所在的 loss 曲面很陡峭),有些参数的梯度始终很小(曲面很平缓)。用同一个学习率,陡峭方向上步子太大容易跳过最优点,平缓方向上步子太小进展缓慢。
这两个问题的解决方案分别催生了 Momentum 和 Adam——接下来两个章节的主题。
下一章节先解决第一个问题:怎么让更新方向更稳定。