🏠 总目录📚 本教程 09 · 优化器与学习率
📑 本页目录(点开跳转)

09 · 优化器与学习率

34 分钟 | ⭐ 核心


🎯 一句话

有了梯度(第 8 章),还得决定怎么迈这一步学习率是全深度学习最重要的超参数——没有之一。

最优SGDMomentumAdam来回横跳惯性抵消震荡每维自适应步长在这种「狭长山谷」地形上,三个优化器走出完全不同的路⭐ 问题不在「学习率大小」,在于<各个方向的陡峭程度差太多>SGD 只有一个全局步长:够小才不震荡,但那样在平缓方向就几乎不动Adam 给每个维度各自的步长 —— 这就是它在实践中好用的核心原因
在「狭长山谷」地形上,三个优化器走出完全不同的路。⭐ 问题不在学习率大小,在于各个方向的陡峭程度差太多 —— SGD 只有一个全局步长,够小才不震荡,但那样在平缓方向几乎不动。Adam 给每个维度各自的步长,这才是它好用的核心原因。
太小走一辈子也到不了底刚好稳稳下到谷底太大来回震荡,甚至发散同一个损失曲面,只是步子迈多大不同⭐ 学习率是最该先调的超参 —— 它错了,别的怎么调都没用
同一个损失曲面,只是每一步迈多大不同。⭐ 太小走不到底,太大来回震荡甚至发散 —— 学习率是最该先调的超参,它错了别的怎么调都没用

🏔️ 一、梯度下降的三种规模

   全批量 (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() 的位置是个高频错误

调度器 该在哪调用
CosineAnnealingLRStepLR 每个 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 倍,训练会不稳定甚至发散。


✅ 检查点

  1. mini-batch 相比全批量和单样本的优势?batch size 和 lr 什么关系?
  2. 为什么小 batch 的"噪声"反而对泛化有益?
  3. Adam 结合了哪两个想法?偏差修正为什么必要?AdamW 改进了什么?
  4. loss 变 NaN 该怎么办?loss 下降极慢呢?loss 完全不动呢?
  5. 微调预训练模型该用多大学习率?不注意会怎样?
  6. Warmup 为什么有用?什么时候必须加?
  7. zero_grad() 忘了会怎样?
  8. scheduler.step() 放错位置有什么症状?怎么快速发现?
  9. 显存不够的三个招是什么?梯度累积最常见的 bug 是什么?
👀 答案
  1. 兼顾速度和稳定,且能吃满 GPU 并行。batch size 翻倍,学习率大致也翻倍(线性缩放规则)。batch 很大(>8k)时该法则失效,必须配 warmup
  2. 小 batch 的梯度带噪声 → 更新方向随机抖动 → 抖得出尖锐极小值,抖不出平坦极小值 → 最终停在平坦极小值里,而平坦极小值泛化更好。这就是"SGD 的隐式正则化"。
  3. Momentum(一阶动量,方向惯性)+ RMSProp(二阶动量,每参数自适应步长)。偏差修正是因为 m、v 初始为 0,前几步被严重低估,除以 (1−βᵗ) 修正。AdamW 把权重衰减解耦出来,修了 Adam 里 L2 与自适应学习率纠缠的缺陷。
  4. NaN = lr 太大梯度爆炸 → ÷10;下降极慢 = lr 太小 → ×3。完全不动(逐位相同)不是 lr 问题,几乎肯定是 bug——查梯度断流或忘了 .step()
  5. 1e-5 ~ 5e-5,比从零训练小 100 倍。用从零训练的 lr 去微调会直接把预训练权重冲垮,效果还不如冻结主干。
  6. 初期参数随机、梯度方向不可靠(Adam 的二阶动量估计也不准),大步走容易带偏。大 batch(>8k)和大模型训练必须加
  7. 梯度会累加到上一步的梯度上,等于用了错误的(放大且混合的)梯度更新,训练直接废掉。
  8. 症状:学习率衰减快几百倍,loss 降一点就完全不动,看起来像模型不行。发现方法:训练时打印 optimizer.param_groups[0]["lr"]
  9. 梯度累积(慢 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

打卡记录保存在你的浏览器里,首页能看到总进度