29

Momentum——给梯度加惯性

阅读约 5 分钟第 29/60 课

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 解决了这个问题。