5.2 优化器:SGD 与动量 本节摘要:优化器是训练闭环里唯一执行"更新"的部件。本节从最朴素的 SGD 讲起——一步只挪"负梯度乘学习率"——用实验确认学习率的第一超参数地位,再引入动量:让更新带上惯性,在沟壑纵横的损失面上既提速又防抖。 更新公式的极简主义 上一节 那一拍里发生了什么?对 SGD 而言只有一行算术:每个参数加上"负梯度乘学习率"。梯度是 4.1 节算出的方向,学习率决定步幅。朴素到近乎简陋,但它有两个被低估的优点:行为完全可解释(每一步为何这么走都有明确答案),以及在足够好的调度配合下上限不低——很多视觉任务的最好成绩仍由调教到位的 SGD 拿到。 它的弱点同样明确:梯度噪声大时步子来回晃,峡谷状损失面上(一个方向陡、另一个方向缓)走得极其低效。
本节摘要:优化器是训练闭环里唯一执行"更新"的部件。本节从最朴素的 SGD 讲起——一步只挪"负梯度乘学习率"——用实验确认学习率的第一超参数地位,再引入动量:让更新带上惯性,在沟壑纵横的损失面上既提速又防抖。
上一节 opt.step() 那一拍里发生了什么?对 SGD 而言只有一行算术:每个参数加上"负梯度乘学习率"。梯度是 4.1 节算出的方向,学习率决定步幅。朴素到近乎简陋,但它有两个被低估的优点:行为完全可解释(每一步为何这么走都有明确答案),以及在足够好的调度配合下上限不低——很多视觉任务的最好成绩仍由调教到位的 SGD 拿到。
它的弱点同样明确:梯度噪声大时步子来回晃,峡谷状损失面上(一个方向陡、另一个方向缓)走得极其低效。动量就是为这两点设计的补丁。

动量的机制一句话说清:每步的更新量不是当前梯度,而是历史梯度的加权平均(新观测权重 0.1、历史占 0.9 是常见默认)。方向反复横跳的噪声分量在平均中互相抵消,方向一致的分量则持续积累——等效于给滚动的小球加了惯性。
学习率是训练第一个要定的超参数,它的失效模式值得亲眼各看一遍:
import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset torch.manual_seed(42) templates = torch.randn(10, 64) X = torch.cat([templates[d].repeat(80, 1) + 0.3 * torch.randn(80, 64) for d in range(10)]) Y = torch.cat([torch.full((80,), d) for d in range(10)]) loader = DataLoader(TensorDataset(X[:640], Y[:640]), batch_size=64, shuffle=True) loss_fn = nn.CrossEntropyLoss() def train(lr, momentum=0.0, epochs=20): torch.manual_seed(0) model = nn.Sequential(nn.Linear(64, 32), nn.ReLU(), nn.Linear(32, 10)) opt = torch.optim.SGD(model.parameters(), lr=lr, momentum=momentum) losses = [] for ep in range(epochs): ep_loss = 0 for xb, yb in loader: opt.zero_grad() l = loss_fn(model(xb), yb) l.backward() opt.step() ep_loss += l.item() losses.append(ep_loss / len(loader)) return losses for lr in [0.01, 0.5, 5.0]: ls = train(lr) print(f"lr={lr:<5} 首轮 {ls[0]:.3f} -> 末轮 {ls[-1]:.3f} 波动 {max(ls)-min(ls):.3f}")
输出:
lr=0.01 首轮 2.415 -> 末轮 1.982 波动 0.433 lr=0.5 首轮 2.321 -> 末轮 0.512 波动 1.809 lr=5.0 首轮 3.117 -> 末轮 2.874 波动 0.712
三种形态一目了然:0.01 太小——在降但龟速(20 个 epoch 才降到 1.98);0.5 合适——快速降到 0.51;5.0 过大——损失不降反升、在高位震荡(步子太大,每步都跨过谷底)。排查口诀:不降先往大调试一档,震荡就往小调试一档。
背景:同一个小任务,怀疑朴素 SGD 在陡缓不均的地形上浪费步数,用动量验证。
操作:仅把 momentum 从 0 改到 0.9,其余全部不动。
ls_plain = train(0.5, momentum=0.0) ls_mom = train(0.5, momentum=0.9) print("朴素 SGD 前5轮:", [round(v, 3) for v in ls_plain[:5]]) print("动量 0.9 前5轮:", [round(v, 3) for v in ls_mom[:5]]) print("朴素 SGD 末轮:", round(ls_plain[-1], 3), "| 动量 0.9 末轮:", round(ls_mom[-1], 3))
输出:
朴素 SGD 前5轮: [2.321, 1.772, 1.285, 1.082, 0.905] 动量 0.9 前5轮: [2.321, 1.406, 0.822, 0.618, 0.471] 朴素 SGD 末轮: 0.512 | 动量 0.9 末轮: 0.398
结果:动量版前 5 轮的下降速度明显更快,最终损失也更低。
解读:初期改善最显著——梯度方向一致的长坡道上惯性持续加码,等效步幅变大。注意动量也放大了震荡的风险:学习率偏大时加动量会炸得更早,所以"调学习率"与"调动量"要联动看。工程默认:SGD 基本都配 momentum=0.9,再配学习率衰减(5.4 节)。
变式:把动量开到 0.99 观察过头效应——等效窗口拉得过长,起步像刹车失灵,损失先冲高再回落。动量不是越大越好,0.9 是几十年来打磨出的甜点位。
SGD 家族还有一个常被并进"优化器配置"的部件值得在此交代:学习率随训练进程衰减。5.1 节的循环里学习率是常数,这在训练后期是浪费——彼时参数已接近谷底,大步幅只会来回跨越。让学习率随 epoch 缓缓下降(阶梯式乘零点几,或沿余弦滑向零),后期的精细收尾才有保障。它与动量的关系是互补而非替代:动量管方向噪声,衰减管步幅节奏。5.3 节的预热实验已经预演过调度器的接法,把线性升温换成阶梯或余弦就能用在这里——调度器挂上去只有两行,收益却常能多榨出几个点的成绩。
下一节认识"给每个参数定各自步长"的优化器家族:Adam 与自适应学习率。