查看全课大纲(第 8 / 40 节)
- 1.从规则到学习:深度学习解决什么问题
- 2.表示学习与端到端训练
- 3.张量、计算图与自动微分
- 4.训练三要素:数据、模型、目标函数
- 5.从感知机到多层感知机
- 6.反向传播:手推一个两层网络
- 7.激活函数与初始化
- 8.优化器:SGD → Momentum → Adam
- 9.正则化:权重衰减、Dropout、BN/LN
- 10.卷积的直觉与参数共享
- 11.池化、感受野与下采样
- 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
- 13.残差连接与归一化为什么有效
- 14.迁移学习与特征可视化
- 15.RNN 与 BPTT
- 16.LSTM/GRU:门控如何缓解梯度问题
- 17.Seq2Seq 与编码器-解码器
- 18.注意力前史:Bahdanau 加性注意力
- 19.Self-Attention:QKV 与缩放点积
- 20.多头注意力:并行的多种关系
- 21.位置编码:正弦与可学习
- 22.残差、LayerNorm 与前馈层
- 23.从零实现一个最小 Transformer block
- 24.训练稳定性、复杂度与常见误区
- 25.自监督与语言建模目标
- 26.BERT:掩码语言模型与双向编码
- 27.GPT:自回归生成与缩放定律
- 28.指令微调与对齐:SFT → RLHF/DPO
- 29.参数高效微调:LoRA 与适配器
- 30.自编码器与 VAE
- 31.GAN:对抗训练与模式崩塌
- 32.扩散模型:前向加噪与反向去噪
- 33.扩散模型的应用与采样加速
- 34.对比学习与 CLIP
- 35.ViT:把图像当序列
- 36.多模态架构:对齐、融合与生成
- 37.能力边界、幻觉与评测
- 38.PyTorch 2.x 训练循环与混合精度
- 39.实验管理、评测与复现
- 40.推理部署与成本:量化/蒸馏概览
优化器:SGD → Momentum → Adam
优化器:从 SGD 到 Adam,梯度下降如何“聪明”起来?
小象实战讲义 · 深度学习基础:从神经网络到 Transformer
在上一节,我们掌握了反向传播算法,它能高效地计算出神经网络中每个参数的梯度。然而,拿到梯度只是第一步,如何利用梯度来更新参数,让模型朝着损失最小的方向“走”得更快、更稳,是优化器的核心任务。本节将深入剖析从最基础的随机梯度下降(SGD)到 Momentum,再到现代深度学习标配 Adam 优化器的演进逻辑,回答“为什么有了梯度还要加动量?”以及“Adam 如何自适应地调整每个参数的学习步长?”这两个关键问题。理解这些优化器,是高效训练模型的基础。
💡 核心导读
- SGD 的困境:基础的随机梯度下降在复杂损失函数(如峡谷地形)中容易震荡、收敛慢。
- Momentum 的惯性:引入“动量”概念,让参数更新不仅考虑当前梯度,还累积历史梯度方向,从而抑制震荡、加速收敛。
- Adam 的自适应:结合 Momentum 和 RMSProp 思想,为每个参数维护独立的自适应学习率(一阶矩和二阶矩估计),使其在不同维度上更新步长不同,是目前最鲁棒、最常用的优化器。
- PyTorch 实现:掌握
torch.optim.SGD和torch.optim.Adam的关键参数(lr,momentum,betas,weight_decay)及其在代码中的真实用法。
🧭 前置知识
- 梯度下降法的基本思想(参数更新公式:
w = w - lr * grad)。 - 损失函数关于参数的梯度计算(反向传播)。
- 矩阵/张量的基本操作(不展开,见站内数学基础课)。
从随机梯度下降(SGD)说起
随机梯度下降(Stochastic Gradient Descent, SGD)是最基础的优化算法。其核心思想是:在每次迭代(或每个小批量)中,计算损失函数关于模型参数的梯度,然后沿着梯度的反方向(因为梯度指向函数增长最快的方向)以一定的步长(学习率 lr)更新参数,以期望降低损失。
基础 SGD 更新公式: 其中:
- 是第 步迭代后的参数。
- 是学习率(learning rate)。
- 是第 步计算得到的梯度。
在 PyTorch 中,基础的 SGD 优化器可以这样使用:
import torch
import torch.nn as nn
import torch.optim as optim
torch.manual_seed(0)
# 构造一个简单的线性模型和虚拟数据
model = nn.Linear(2, 1, bias=False) # y = w1*x1 + w2*x2
criterion = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=0.1)
# 虚拟一个小批量数据
X = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
y = torch.tensor([[5.0], [11.0]])
# 一次训练迭代
optimizer.zero_grad() # 清空历史梯度
output = model(X)
loss = criterion(output, y)
loss.backward() # 反向传播,计算梯度
print(f"梯度: w1_grad={model.weight.grad[0,0]:.4f}, w2_grad={model.weight.grad[0,1]:.4f}")
optimizer.step() # 执行 SGD 更新: w = w - lr * grad
print(f"更新后权重: w1={model.weight[0,0]:.4f}, w2={model.weight[0,1]:.4f}")梯度: w1_grad=-32.7424, w2_grad=-46.4877
更新后权重: w1=3.2689, w2=5.0281可以看到,权重按照 新权重 = 旧权重 - 0.1 * 梯度 的规则进行了更新。然而,当损失函数的等高线呈“峡谷”状(一个方向陡峭,另一个方向平缓)时,基础 SGD 的更新路径会像“之”字形一样剧烈震荡,收敛速度很慢。
引入动量(Momentum):让优化带上“惯性”
为了解决 SGD 的震荡问题,动量法(Momentum)被提出。其核心思想是模拟物理中的动量:参数更新不仅取决于当前的梯度,还累积了历史梯度的指数加权平均(动量项)。这有助于在相关梯度方向加速,并抑制震荡。
带动量的 SGD 更新公式(PyTorch 实现版本): 其中:
- 是第 步的动量(velocity)。
- 是动量因子(momentum),通常设为 0.9,决定了历史梯度的影响衰减速度。
- 注意:PyTorch 的实现与早期 Sutskever 等人的公式略有不同。早期课程里有时会将学习率 乘在 上(即 ),但 PyTorch 2.x 的官方实现是上述公式,学习率只在最后一步与 相乘。
import torch
import torch.nn as nn
import torch.optim as optim
torch.manual_seed(0)
# 使用相同的模型和数据
model_momentum = nn.Linear(2, 1, bias=False)
criterion = nn.MSELoss()
# 关键:设置 momentum 参数
optimizer_momentum = optim.SGD(model_momentum.parameters(), lr=0.1, momentum=0.9)
X = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
y = torch.tensor([[5.0], [11.0]])
# 模拟连续两次更新,观察动量的累积
for step in range(2):
optimizer_momentum.zero_grad()
output = model_momentum(X)
loss = criterion(output, y)
loss.backward()
grad = model_momentum.weight.grad.clone()
optimizer_momentum.step()
print(f"Step {step+1}: 梯度=({grad[0,0]:.1f}, {grad[0,1]:.1f}), "
f"更新后权重=({model_momentum.weight[0,0]:.4f}, {model_momentum.weight[0,1]:.4f})")Step 1: 梯度=(-32.7, -46.5), 更新后权重=(3.2689, 5.0281)
Step 2: 梯度=(65.1, 92.3), 更新后权重=(-0.2925, -0.0207)发生了什么? 上面这段代码用的是默认随机初始化,所以数字取决于初始权重;为了能逐项核对,下面把初始权重显式设为 0 再跑一遍。
import torch
import torch.nn as nn
torch.manual_seed(0)
model = nn.Linear(2, 1, bias=False)
# 手动设置一个已知的初始权重,便于跟踪
with torch.no_grad():
model.weight.fill_(0.0)
print(f"初始权重: {model.weight}")
lr = 0.1
momentum = 0.9
# 手动模拟带 momentum 的 SGD
v = None
X = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
y = torch.tensor([[5.0], [11.0]])
criterion = nn.MSELoss()
for step in range(2):
output = model(X)
loss = criterion(output, y)
loss.backward()
g = model.weight.grad.clone()
model.zero_grad()
if v is None:
v = g # PyTorch: 第一步动量初始化为梯度
else:
v = momentum * v + g # 阻尼 tau=0
# 更新参数
with torch.no_grad():
model.weight -= lr * v
print(f"Step {step+1}:")
print(f" 梯度 g = {[[round(x, 4) for x in row] for row in g.tolist()]}")
print(f" 动量 v = {[[round(x, 4) for x in row] for row in v.tolist()]}")
print(f" 更新后 w = {[[round(x, 4) for x in row] for row in model.weight.tolist()]}")初始权重: Parameter containing:
tensor([[0., 0.]], requires_grad=True)
Step 1:
梯度 g = [[-38.0, -54.0]]
动量 v = [[-38.0, -54.0]]
更新后 w = [[3.8, 5.4]]
Step 2:
梯度 g = [[75.6, 107.2]]
动量 v = [[41.4, 58.6]]
更新后 w = [[-0.34, -0.46]]逐项核对:
- 第 1 步:梯度 。动量缓冲初始为空,PyTorch 直接令 (不是 之外的任何东西)。参数更新为 。
- 第 2 步:参数已经越过最优点——本例数据满足 (、),最优解是 ,而当前在 。梯度因此反号,变成 。动量为 ,比 小——历史动量与当前梯度方向相反,两者部分抵消。参数更新为 。
这揭示了动量的两面性:梯度方向持续一致时,动量越滚越大、加速前进;一旦越过最优点、梯度反号,动量会带来滞后,把参数继续往旧方向推,造成过冲和来回震荡。所以动量常与较小的学习率或学习率衰减配合使用。
顺带说明:PyTorch 的动量实现是 (学习率只在最后一步乘),而不是早期文献里的 。不要拿教科书公式去对照 PyTorch 的中间变量,否则会以为实现有 bug。
Adam:自适应矩估计优化器
虽然 Momentum 解决了 SGD 的震荡问题,但它对所有参数使用相同的学习率。在现实中,不同参数的重要性、梯度量级和更新频率可能不同。Adam(Adaptive Moment Estimation)优化器应运而生,它结合了 Momentum 和 RMSProp 的思想,为每个参数维护两个状态:
- 一阶矩估计(First Moment) :梯度的指数移动平均(类似 Momentum),估计梯度的一阶矩(均值)。
- 二阶矩估计(Second Moment) :梯度平方的指数移动平均(来自 RMSProp),估计梯度的二阶矩(未中心化的方差)。
然后,Adam 利用这两个估计值对每个参数进行自适应的学习率缩放。
Adam 算法(根据 PyTorch 2.14.1 官方文档): 其中:
- 是衰减率,默认值通常为 。
- 是一个极小的数(如 ),防止除以零。
- 表示逐元素平方。
- 偏差校正(Bias Correction):由于 初始化为 0,在训练初期, 会偏向于 0。除以 可以校正这种偏差,使得估计更准确。
Adam 的核心优势在于: 提供了带有动量的梯度方向,而 近似于梯度各维度的幅度(标准差)。更新步长变为 ,这意味着:
- 对于梯度幅度大的维度(可能震荡), 大,有效学习率小,更新更谨慎。
- 对于梯度幅度小的维度(可能进展缓慢), 小,有效学习率大,更新更快。
- 从而实现了每个参数的自适应学习率。
import torch
import torch.nn as nn
import torch.optim as optim
torch.manual_seed(0)
model_adam = nn.Linear(2, 1, bias=False)
criterion = nn.MSELoss()
# 使用 Adam,默认 betas=(0.9, 0.999)
optimizer_adam = optim.Adam(model_adam.parameters(), lr=0.1)
X = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
y = torch.tensor([[5.0], [11.0]])
for step in range(2):
optimizer_adam.zero_grad()
output = model_adam(X)
loss = criterion(output, y)
loss.backward()
grad = model_adam.weight.grad.clone()
optimizer_adam.step()
print(f"Step {step+1}: 梯度=({grad[0,0]:.1f}, {grad[0,1]:.1f}), "
f"更新后权重=({model_adam.weight[0,0]:.4f}, {model_adam.weight[0,1]:.4f})")Step 1: 梯度=(-32.7, -46.5), 更新后权重=(0.0947, 0.4793)
Step 2: 梯度=(-30.3, -43.1), 更新后权重=(0.1944, 0.5791)注意第一步发生了什么:偏差校正让 、,于是更新量 。也就是说,Adam 的第一步在每一维上走的距离都约等于 lr,与梯度大小无关。上面代码里 lr=0.1,所以两个维度各自挪动约 0.1(从初始的 -0.0053 附近到 0.0947、从 0.379 附近到 0.4793)。对比 SGD 第一步(梯度 -32.7,一步就走 3.27),Adam 显得非常保守——这正是「自适应」的含义:它把每个维度的步长归一化到同一个量级,代价是需要更多步数。
关键参数与代码实践
权重衰减(Weight Decay / L2 正则化)
权重衰减通过在损失函数中添加参数的 L2 范数惩罚项()来防止过拟合。在优化器更新中,它等价于在梯度上直接加上 。
- SGD with weight decay:
optim.SGD(..., weight_decay=1e-4) - Adam with weight decay:
optim.Adam(..., weight_decay=1e-4) - 注意:
optim.Adam的weight_decay是耦合式 L2——它把λ·θ直接加进梯度,然后一起被自适应学习率缩放,效果与 SGD 的 weight decay 并不等价。想做「解耦」的权重衰减,可以直接用optim.AdamW,或给optim.Adam传decoupled_weight_decay=True(PyTorch 2.x 提供该开关,开启后等价于 AdamW)。解耦版本在参数更新时单独减去lr·λ·θ,不经过二阶矩归一化,通常泛化更好。本地 torch 2.14.1 实测两个入口都可用。
学习率调度
虽然本节聚焦优化器,但实践中常配合学习率调度器(如 StepLR、CosineAnnealingLR)在训练中动态调整 lr。用法是构造调度器,如 optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1),再在每个 epoch 末尾调用一次 scheduler.step()。调度器不改变优化器的更新规则,只是按计划修改 param_group['lr'],两者职责分离。
📝 动手练一练
题目:分别使用 SGD(无动量)、SGD(动量=0.9)和 Adam 优化器,在以下简单二次函数上优化参数 x:,初始值 ,学习率均为 0.5,迭代 10 步。观察并记录每一步的 x 值,比较三种方法的收敛速度。
👉 点击查看参考答案
思路:定义参数 x 为一个 torch.Tensor 并设置 requires_grad=True。为每种优化器创建一个副本,在循环中计算梯度并更新。注意每次更新前要清空梯度。
import torch
import torch.optim as optim
torch.manual_seed(0)
# 定义优化目标函数和初始参数
def f(x):
return 0.1 * x**2
# 为三种优化器创建三个独立的参数副本
x_sgd = torch.tensor([10.0], requires_grad=True)
x_momentum = torch.tensor([10.0], requires_grad=True)
x_adam = torch.tensor([10.0], requires_grad=True)
# 创建优化器
opt_sgd = optim.SGD([x_sgd], lr=0.5)
opt_momentum = optim.SGD([x_momentum], lr=0.5, momentum=0.9)
opt_adam = optim.Adam([x_adam], lr=0.5)
results = {'SGD': [], 'Momentum': [], 'Adam': []}
for step in range(10):
# 清空梯度
opt_sgd.zero_grad()
opt_momentum.zero_grad()
opt_adam.zero_grad()
# 计算损失和梯度 (三个参数独立计算)
loss_sgd = f(x_sgd)
loss_momentum = f(x_momentum)
loss_adam = f(x_adam)
loss_sgd.backward()
loss_momentum.backward()
loss_adam.backward()
# 执行一步更新
opt_sgd.step()
opt_momentum.step()
opt_adam.step()
# 记录结果
results['SGD'].append(round(x_sgd.item(), 4))
results['Momentum'].append(round(x_momentum.item(), 4))
results['Adam'].append(round(x_adam.item(), 4))
print("步数 | SGD | Momentum | Adam")
print("-" * 45)
for i in range(10):
print(f"{i:4d} | {results['SGD'][i]:9.4f} | {results['Momentum'][i]:9.4f} | "
f"{results['Adam'][i]:9.4f}")步数 | SGD | Momentum | Adam
---------------------------------------------
0 | 9.0000 | 9.0000 | 9.5000
1 | 8.1000 | 7.2000 | 9.0008
2 | 7.2900 | 4.8600 | 8.5031
3 | 6.5610 | 2.2680 | 8.0075
4 | 5.9049 | -0.2916 | 7.5148
5 | 5.3144 | -2.5661 | 7.0257
6 | 4.7830 | -4.3565 | 6.5410
7 | 4.3047 | -5.5322 | 6.0617
8 | 3.8742 | -6.0372 | 5.5887
9 | 3.4868 | -5.8879 | 5.1229观察:三条路径的差别正好对应前面讲的机制。
- SGD:,更新为 ,每步固定缩小 10%。10 步从 10 降到 3.49,稳但慢。
- Momentum:前四步明显更快(9.0 → 2.27),因为动量累积同向梯度、步长越滚越大;但它在第 4 步就冲过最优点 0 变成负值,之后在 0 附近来回震荡(-0.29 → -6.04 → -5.89),既没收敛也没发散。
- Adam:每步只走约
lr=0.5(第 1 步 10 → 9.5),因为更新量被归一化成lr × sign(梯度),与梯度大小无关。对这个单参数问题,0.5 的固定步长相对 x 的尺度偏小,10 步才到 5.12,是三组里最慢的。
所以「Adam 更好」不是无条件成立的:它擅长的是各维尺度差异大、梯度噪声重的非凸问题;在这个单参数、曲率固定的凸函数上,反而被归一化拖慢。选优化器要看问题形态,不要背结论。
常见错误
| 错误理解或写法 | 现象或报错 | 原因 | 正确做法 |
|---|---|---|---|
| 混淆 PyTorch 的 Momentum 公式 | 自己实现 Momentum 时更新效果与 torch.optim.SGD 不一致。 | PyTorch 的实现是 v_t = μ * v_{t-1} + g_t,更新为 θ_t = θ_{t-1} - lr * v_t。早期有些实现会将 lr 乘在 g_t 上。 | 以 PyTorch 官方文档为准,或直接使用 torch.optim.SGD(..., momentum=μ)。 |
忘记调用 optimizer.zero_grad() | 梯度值越累越大,更新方向错误,模型无法收敛。 | PyTorch 会累积梯度(grad 属性会累加)。如果不手动清零,每次 backward() 的梯度会加到历史梯度上。 | 在每个训练迭代(step)开始前,调用 optimizer.zero_grad()。 |
| Adam 学习率设置过大 | 模型训练初期震荡剧烈甚至发散。 | Adam 的自适应学习率机制使其有效步长与 lr 并非线性关系。初始二阶矩估计小,有效学习率可能很大。 | 对于 Adam,通常使用比 SGD 更小的学习率(如 1e-3 或 1e-4 作为起点)。 |
权重衰减值 (weight_decay) 设置过大 | 模型权重被过度压缩,导致欠拟合(训练集和验证集损失都高)。 | 过大的 L2 惩罚会迫使所有权重趋近于 0,模型能力严重受限。 | 权重衰减是一个需要调节的超参数,常用范围是 1e-4 到 1e-2,从小值开始尝试。 |
本章小结
- SGD 是优化器的基础,直接使用梯度乘以学习率进行更新,但在复杂地形中容易震荡。
- Momentum 通过累积历史梯度的指数移动平均(动量)来加速收敛并抑制震荡,其更新方向是当前梯度与历史动量的加权和。
- Adam 结合了 Momentum(一阶矩估计)和 RMSProp(二阶矩估计)的思想,为每个参数提供独立的自适应学习率,使其在深度学习中表现出优异的鲁棒性和收敛性能。
- 权重衰减 是内置于优化器的 L2 正则化技术,通过在梯度中添加
λ * θ项来防止过拟合。 - 优化器的选择与超参数(学习率、动量因子、权重衰减)设置高度依赖于具体任务和数据,需要通过实验来确定。
📋 行动清单
- 在 PyTorch 中,分别用
optim.SGD(无动量)、optim.SGD(带动量)和optim.Adam训练一个简单的线性回归模型,观察训练损失曲线的差异。 - 修改 Adam 优化器的
betas参数(例如改为(0.8, 0.888)),观察对同一模型收敛速度的影响。 - 尝试在优化器中使用
weight_decay(例如1e-4),并与不使用权重衰减的情况对比,观察验证集性能的变化。
延伸阅读
本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。
Adam: A Method for Stochastic Optimization(Kingma & Ba, 2015, ICLR, arXiv:1412.6980)—— Adam 原始论文 - https://arxiv.org/abs/1412.6980On the importance of initialization and momentum in deep learning(Sutskever et al., 2013, ICML)—— 讨论动量作用,注意其公式与 PyTorch 实现的差异 - https://proceedings.mlr.press/v28/sutskever13.html- PyTorch 官方文档
torch.optim(2.14.1,SGD / Adam / AdamW 的算法与参数) - https://pytorch.org/docs/stable/optim.html
—— 小象教研组