📑 本页目录(点开跳转)
09 · 优化器与学习率
⏱ 34 分钟 | ⭐ 核心
🎯 一句话
有了梯度(第 8 章),还得决定怎么迈这一步。 学习率是全深度学习最重要的超参数——没有之一。
🏔️ 一、梯度下降的三种规模
全批量 (Batch GD):用全部数据算一次梯度,走一步
✅ 方向准 ❌ 慢、内存装不下
随机 (SGD):一个样本算一次梯度
✅ 快 ❌ 抖得厉害
小批量 (Mini-batch) ⭐ 实际都用这个
每次用 32~512 个样本
✅ 兼顾速度和稳定,且能吃满 GPU 并行
batch size 的影响:
| 小 batch (32) | 大 batch (1024) | |
|---|---|---|
| 梯度噪声 | 大 | 小 |
| 泛化 | 通常更好(噪声是隐式正则) | 可能略差 |
| 速度 | 慢(GPU 没吃满) | 快 |
| 显存 | 少 | 多 |
💡 经验法则:batch size 翻倍时,学习率也大致翻倍(线性缩放规则)。
📐 为什么是"线性"缩放(想看再点)
batch 里的梯度是 B 个样本梯度的平均。假设各样本梯度独立、方差为 σ²:
$$\text{Var}(\bar g) = \frac{\sigma^2}{B}$$
batch 翻倍 → 梯度噪声的标准差降到 1/√2
→ 但更新的"有效随机游走步长"是 lr·√Var
→ 想保持同样的探索强度,lr 要相应放大
⚠️ 线性缩放在 batch 很大时会失效(通常 >8k)—— 这时必须配 warmup,否则前期大步走会直接把模型带崩。 "大 batch 训练必须 warmup"的根源就在这。
💡 小 batch 的"噪声"为什么反而有益:
小 batch 的梯度是【带噪声的】真实梯度
→ 更新方向随机抖动
→ 抖得出尖锐的局部极小值,抖不出平坦的
→ 最终停在【平坦极小值】里
⭐ 而平坦极小值泛化更好(参数微小扰动不改变结果)
→ 这就是"SGD 的隐式正则化"
🚀 二、四代优化器
① SGD: w ← w − lr·g
最朴素。在峡谷地形里会来回震荡
② Momentum(动量):v ← βv + g; w ← w − lr·v
💡 像小球滚下山,累积惯性
✅ 穿过震荡、冲过小坑
③ Adagrad/RMSProp:每个参数有自己的学习率
💡 梯度一直很大的参数,学习率自动调小
✅ 稀疏特征友好
④ Adam = Momentum + RMSProp ⭐ 默认选它
同时用一阶动量(方向)和二阶动量(自适应步长)
✅ 几乎不用调就能work
📐 Adam 的四行完整公式(想看再点)
$$m_t = \beta_1 m_{t-1} + (1-\beta_1)g_t \qquad \text{(一阶动量:方向)}$$ $$v_t = \beta_2 v_{t-1} + (1-\beta_2)g_t^2 \qquad \text{(二阶动量:步长的尺度)}$$ $$\hat m_t = \frac{m_t}{1-\beta_1^t},\quad \hat v_t = \frac{v_t}{1-\beta_2^t} \qquad \text{(偏差修正)}$$ $$w \leftarrow w - \text{lr}\cdot\frac{\hat m_t}{\sqrt{\hat v_t}+\varepsilon}$$
三个值得注意的点:
| 点 | 说明 |
|---|---|
| 偏差修正为什么必要 | m、v 初始为 0,前几步会被严重低估。除以 (1−βᵗ) 修正回来 |
| 除以 √v 的效果 ⭐ | 梯度一直大的参数 → v 大 → 步长自动变小;梯度小的参数步长变大。相当于每个参数有自己的学习率 |
| ε 不只是防除零 | 它还限制了"梯度极小时步长能有多大"。默认 1e-8,某些任务调到 1e-6 更稳 |
💡 Adam 的默认 β₁=0.9, β₂=0.999 几乎不用改—— 但如果训练很不稳,把 β₂ 降到 0.95 常有奇效(让二阶动量对近期更敏感)。 大模型训练里 β₂=0.95 很常见。
实用选择:
| 场景 | 选择 |
|---|---|
| 不确定时 | Adam / AdamW,lr=1e-3 ⭐ |
| Transformer / 大模型 | AdamW(正确的权重衰减实现) |
| CNN 图像分类刷精度 | SGD + Momentum(0.9) 常能拿到更好的最终精度,但要调 |
| 稀疏特征(推荐系统) | Adagrad 类 |
⚠️ AdamW vs Adam:Adam 里的 L2 正则实现有缺陷(和自适应学习率纠缠)。 AdamW 把权重衰减解耦出来,是现在的正确做法。用 AdamW 就对了。
🎚️ 三、学习率:最重要的旋钮
lr 太大 lr 合适 lr 太小
loss ▲ loss ▲ loss ▲
│╲ ╱╲ │╲ │╲___
│ ╲ ╱ ╲ 震荡/NaN │ ╲___ │ ╲___ 慢
│ ╲╱ ╲ │ ╲__ │ ╲__
└────────► └────────► └────────►
诊断表(背下来,第 11 章要用):
| 现象 | 诊断 | 动作 |
|---|---|---|
| loss 变 NaN / inf | lr 太大,梯度爆炸 | lr ÷ 10 |
| loss 剧烈震荡不下降 | lr 太大 | lr ÷ 3 |
| loss 下降极慢但稳定 | lr 太小 | lr × 3 |
| loss 降到一半就平了 | 需要学习率衰减 | 加调度器 |
| loss 前几步就爆 | lr 太大 或 缺 warmup | 加 warmup |
| loss 完全不动(逐位相同) | 不是 lr 问题 ⭐ | 查梯度是否断流 / 忘了 .step() |
⚠️ 最后一行很重要:「下降极慢」和「完全不动」是两种不同的病。 完全不动(loss 数值一位不差)几乎肯定是 bug,不是超参问题—— 调学习率是浪费时间,去查梯度。🔗 第 11 章有完整流程。
📏 学习率的量级参考(不知道从哪开始时)
| 场景 | 起手 lr |
|---|---|
| 从零训练 MLP / CNN(Adam) | 1e-3 ⭐ |
| 从零训练(SGD+Momentum) | 0.1(配 cosine 衰减) |
| 微调预训练模型 | 1e-5 ~ 5e-5 ⭐(小 100 倍!) |
| 微调时的新分类头 | 1e-3(比主干大 10~100 倍) |
| LoRA 微调 | 1e-4 ~ 3e-4 |
🔑 "微调要用小 100 倍的学习率"是最容易忽略的一条—— 用从零训练的 lr 去微调,会直接把预训练权重冲垮,效果还不如冻结主干。
学习率调度(Scheduler)
固定 lr → 后期在最优点附近震荡,下不去
逐渐衰减 lr → 前期跑得快,后期精细收敛 ✅
常用三种:
├─ Step:每 N 轮 × 0.1
├─ Cosine ⭐:余弦曲线平滑衰减到 0,现在最主流
└─ ReduceLROnPlateau:验证集不降了就自动砍学习率
⭐ Warmup(大模型必备)
lr ▲
│ ╱╲___
│ ╱ ╲___
│ ╱ ╲___
└────────────────►
↑warmup ↑cosine衰减
前几百步 lr 从 0 线性升到目标值
为什么需要:训练最开始参数是随机的,梯度方向很不可靠, 大步走容易把模型带偏(尤其配 Adam 时二阶动量估计还不准)。先小步试探。
🔗 你在全景导论第 4 章看到的大模型训练,几乎都用 Warmup + Cosine。
🔨 四、怎么找学习率(LR Range Test)
不用瞎猜,有个标准方法:
# 让 lr 从 1e-7 指数增长到 1,记录每步的 loss
lrs, losses = [], []
lr = 1e-7
for batch in loader:
for g in optimizer.param_groups: g["lr"] = lr
loss = train_one_step(batch)
lrs.append(lr); losses.append(loss)
lr *= 1.1
if loss > 4 * min(losses): break # loss 炸了就停
# 画 loss vs log(lr),选【loss 下降最陡】的那个 lr
# 保守起见取那个点的 1/3 ~ 1/10
loss ▲
│────╲
│ ╲ ← 这段最陡,选这里的 lr
│ ╲___
│ ╲╱╲ ← 开始发散
└──────────────► log(lr)
📋 五、可以照抄的默认配置
import torch
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
for epoch in range(epochs):
for xb, yb in train_loader:
optimizer.zero_grad() # ⭐ 必须!梯度默认累加
loss = criterion(model(xb), yb)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 防梯度爆炸
optimizer.step()
scheduler.step()
四个不能省的细节:
1. zero_grad() —— 忘了它,梯度会累加,训练直接废掉(最常见的低级 bug)
2. clip_grad_norm_ —— 梯度裁剪,RNN/Transformer 上几乎必备
3. scheduler.step() 的位置 —— 通常每个 epoch 一次(有些调度器是每 step)
4. weight_decay —— AdamW 的正则化(第 10 章)
⚠️ scheduler.step() 的位置是个高频错误:
| 调度器 | 该在哪调用 |
|---|---|
CosineAnnealingLR、StepLR |
每个 epoch 一次(放在 epoch 循环末尾) |
OneCycleLR、带 warmup 的 |
每个 step 一次(放在 optimizer.step() 之后)⭐ |
ReduceLROnPlateau |
每个 epoch,且必须传验证指标:scheduler.step(val_loss) |
💡 放错位置的症状:学习率衰减得快几百倍(该按 epoch 却按 step 调用), loss 降一点就完全不动了——看起来像模型不行,实际是 lr 已经衰减到 0。 训练时打印一下
optimizer.param_groups[0]["lr"]就能立刻发现。
💾 六、显存不够时的三个招
| 招 | 做什么 | 代价 |
|---|---|---|
| 梯度累积 | 攒 N 个小 batch 的梯度再更新一次 = 模拟大 batch | 慢 N 倍 |
| 混合精度 AMP | 用 FP16 算,FP32 存主权重 | 显存减半,速度还更快 ⭐ 几乎白赚 |
| 梯度检查点 | 只存部分层的激活,反向时重算 | 慢约 30%,显存大降 |
# 梯度累积:注意 loss 要除以 accum_steps ⭐
accum = 4
for i, (xb, yb) in enumerate(loader):
loss = criterion(model(xb), yb) / accum # ⭐ 别忘了这个除法
loss.backward()
if (i + 1) % accum == 0:
optimizer.step()
optimizer.zero_grad()
⚠️ 梯度累积最常见的 bug 就是忘了除以
accum—— 等效于把学习率放大了 4 倍,训练会不稳定甚至发散。
✅ 检查点
- mini-batch 相比全批量和单样本的优势?batch size 和 lr 什么关系?
- 为什么小 batch 的"噪声"反而对泛化有益?
- Adam 结合了哪两个想法?偏差修正为什么必要?AdamW 改进了什么?
- loss 变 NaN 该怎么办?loss 下降极慢呢?loss 完全不动呢?
- 微调预训练模型该用多大学习率?不注意会怎样?
- Warmup 为什么有用?什么时候必须加?
zero_grad()忘了会怎样?scheduler.step()放错位置有什么症状?怎么快速发现?- 显存不够的三个招是什么?梯度累积最常见的 bug 是什么?
👀 答案
- 兼顾速度和稳定,且能吃满 GPU 并行。batch size 翻倍,学习率大致也翻倍(线性缩放规则)。batch 很大(>8k)时该法则失效,必须配 warmup。
- 小 batch 的梯度带噪声 → 更新方向随机抖动 → 抖得出尖锐极小值,抖不出平坦极小值 → 最终停在平坦极小值里,而平坦极小值泛化更好。这就是"SGD 的隐式正则化"。
- Momentum(一阶动量,方向惯性)+ RMSProp(二阶动量,每参数自适应步长)。偏差修正是因为 m、v 初始为 0,前几步被严重低估,除以 (1−βᵗ) 修正。AdamW 把权重衰减解耦出来,修了 Adam 里 L2 与自适应学习率纠缠的缺陷。
- NaN = lr 太大梯度爆炸 → ÷10;下降极慢 = lr 太小 → ×3。完全不动(逐位相同)不是 lr 问题,几乎肯定是 bug——查梯度断流或忘了
.step()。 - 1e-5 ~ 5e-5,比从零训练小 100 倍。用从零训练的 lr 去微调会直接把预训练权重冲垮,效果还不如冻结主干。
- 初期参数随机、梯度方向不可靠(Adam 的二阶动量估计也不准),大步走容易带偏。大 batch(>8k)和大模型训练必须加。
- 梯度会累加到上一步的梯度上,等于用了错误的(放大且混合的)梯度更新,训练直接废掉。
- 症状:学习率衰减快几百倍,loss 降一点就完全不动,看起来像模型不行。发现方法:训练时打印
optimizer.param_groups[0]["lr"]。 - 梯度累积(慢 N 倍)、混合精度 AMP(显存减半还更快,几乎白赚)、梯度检查点(慢 30% 换显存)。梯度累积最常见的 bug 是忘了把 loss 除以 accum——等效于学习率放大 N 倍。
🛑 可以停在这里
⚡ 走神救援
用mini-batch(32~512),batch翻倍lr也翻倍(>8k时失效,必须配warmup);小batch的噪声抖得出尖锐极小值抖不出平坦的 → 停在平坦极小值 → 泛化更好,这就是"SGD隐式正则"。优化器四代:SGD→Momentum(惯性)→RMSProp(自适应)→Adam=两者结合,默认AdamW lr=1e-3(偏差修正是因为m、v初始为0会被低估;AdamW把权重衰减解耦;训练不稳可把β₂降到0.95)。学习率是最重要超参:NaN→÷10,震荡→÷3,太慢→×3,降一半平了→加调度器(Cosine最主流);⭐"完全不动(逐位相同)"不是lr问题,是bug——查梯度断流。量级参考:从零训练1e-3,微调1e-5~5e-5(小100倍!否则冲垮预训练权重)。Warmup:初期梯度不可靠先小步试探。⚠️ scheduler.step()放错位置 → lr衰减快几百倍 → 打印 param_groups[0]["lr"] 立刻发现。zero_grad()忘了训练直接废。显存不够:梯度累积(别忘了 loss/=accum)、AMP(减半还更快,白赚)、梯度检查点。
下一节 👉 10-正则化全家桶.md