SGD 的每一步只看当前 batch 的梯度,方向不稳定。Momentum 的思路是:不只看这一步的梯度,把之前几步的梯度也考虑进来。
滚球的类比
想象一个球在山坡上往下滚。
没有动量(SGD):球每一步只看脚下的坡度,在凸凹不平的地面上左右摇摆。如果遇到一个小坑,可能就停住了——因为小坑四周是上坡,球以为到了最低点。
有动量(Momentum):球不仅看脚下的坡度,还保留之前积累的速度。即使遇到一个小坑,球的惯性会带着它冲过去,继续往真正的最低点滚。
计算方式
Momentum 引入了一个新的变量:动量(velocity)。每一步的更新变成:
v_new = β × v_old + 梯度 ← β 通常是 0.9
参数_new = 参数_old − 学习率 × v_new
v 是动量向量,跟参数一样大——Sophia 有十亿个参数,就有十亿个 v。这意味着 Momentum 的内存开销是 SGD 的两倍:除了参数本身,还要存一份同样大小的 v。
β = 0.9 意味着:上一步的动量保留 90%,加上当前的梯度。
用数字看动量怎么积累
假设某个参数连续 5 步的梯度都是 +2.0(方向一致,都在往下坡走)。β = 0.9,v 的初始值是 0:
第 1 步:v = 0.9 × 0 + 2.0 = 2.0
第 2 步:v = 0.9 × 2.0 + 2.0 = 3.8
第 3 步:v = 0.9 × 3.8 + 2.0 = 5.42
第 4 步:v = 0.9 × 5.42 + 2.0 = 6.878
第 5 步:v = 0.9 × 6.878 + 2.0 = 8.190
v 从 2.0 涨到了 8.190——方向一致时,动量越积越大,步子越来越快。 如果没有动量(纯 SGD),每一步的更新量始终是 2.0。
现在换一种情况:梯度在 +2.0 和 −2.0 之间反复跳(山谷两壁弹跳):
第 1 步:v = 0.9 × 0 + 2.0 = 2.0
第 2 步:v = 0.9 × 2.0 + (−2.0) = −0.2
第 3 步:v = 0.9 × (−0.2) + 2.0 = 1.82
第 4 步:v = 0.9 × 1.82 + (−2.0) = −0.362
第 5 步:v = 0.9 × (−0.362) + 2.0 = 1.674
v 在 ±2 之间来回跳,但幅度被压制——正负相消让 v 的绝对值远小于原始梯度。方向不一致时,动量自动刹车。
这就是 Momentum 的核心效果:放大一致的信号,衰减噪声。
用 Python 实现
v = {} # 字典,为每个参数存一个动量向量
for param in model.parameters():
if param not in v:
v[param] = torch.zeros_like(param) # 初始动量为零
v[param] = 0.9 * v[param] + param.grad # 动量更新
param.data -= learning_rate * v[param] # 参数更新
PyTorch 内置的 torch.optim.SGD(model.parameters(), lr=..., momentum=0.9) 做的就是这件事。
Momentum 改善了什么
回到上一章节的狭长山谷。加了 Momentum 之后:
Momentum 的路径(同一条山谷,从 A 到 B):
A ─╲╱─╲╱───────────────→ B
山谷两壁方向的弹跳(梯度正负交替)被动量抑制了。山谷轴向的推进(梯度方向一致)被动量加速了。路径变得更短更直。
但 Momentum 没有解决 SGD 的第二个问题:所有参数用同一个学习率。十亿个参数中,有些参数的梯度始终是 0.001 量级,有些是 10.0 量级——它们需要的步长完全不同。下一章节的 Adam 解决了这个问题。