系列 · 优化理论 · 第 4 篇

优化理论(四):学习率与调度策略

从一维抛物线讲到 LLM 预训练配方,覆盖 cosine/WSD/Schedule-Free、LR range test、warmup 新理论与诊断 checklist。

模型崩溃了,你把学习率减半——模型终于能训练了,但速度慢得惊人;再减半,损失几乎不再下降,曲线趋于平缓。这种场景是不是很熟?在所有可调的超参数里,学习率(learning rate, LR)是最容易决定训练成败的那一个——它直接决定了模型是顺利收敛、进展极其缓慢,还是迅速发散。

这篇文章把学习率从最简单的一维抛物线讲起,一路讲到 LLM 预训练的真实配方。目标很明确:一是建立清晰的直觉,二是提供一套可立即落地的操作流程。


训练一开始模型就崩了,把学习率减半,模型终于能跑了,但慢得离谱;再减半,loss 几乎不动。这种循环熟不熟悉?在所有可调的超参里,学习率是最容易决定训练成败的那一个——不是“收敛与否”,就是“快与慢”。

这篇文章我想把学习率讲成一个连贯的故事:从最简单的一维抛物线开始(让你看清楚“为什么太大就炸”),一直讲到 LLM 预训练里 cosine、WSD、Schedule-Free 这些真实在用的调度。目标是两件事——一是让你建立稳的直觉,看到一条 loss 曲线就能反推“是不是 LR 的问题”;二是给你一套能直接抄走的操作流程。

你将学到什么#

  • 为什么“太大就炸、太小就慢”——从最干净的模型推出来
  • batch size、动量、权重衰减为什么必须和学习率一起调
  • 调度大全:constant、step、cosine、WSD、schedule-free 各自适合什么场景
  • LR range test:用 200 个 mini-batch 找到你的稳定上限
  • NaN、震荡、平台期的诊断 checklist
  • 2023 年以来的新进展:Schedule-Free AdamW、D-Adaptation、Power Scheduler、warmup 的新理论

前置知识:基础微积分(梯度、链式法则),且至少训练过一次神经网络。


一句话定义#

学习率 $\eta$ 决定了你每一步沿梯度方向迈多远。

$$\theta_{t+1} = \theta_t - \eta \cdot \tilde g_t,$$

其中 $\tilde g_t$ 通常是 mini-batch 下对真实梯度 $\nabla L(\theta_t)$ 的随机估计。

核心矛盾:

$\eta$ 越大,进步越快,但越容易不稳;$\eta$ 越小,越稳,但越慢,甚至原地不动。

后面的所有内容,本质上都是在讲:“研究者和工程师怎么在这根钢丝上走稳”。


为什么“太大就炸、太小就慢”#

从一维抛物线开始(最干净的直觉)#

$$L(\theta) = \tfrac{1}{2} a \theta^2, \qquad a > 0.$$ $$\theta_{t+1} = \theta_t - \eta a \theta_t = (1 - \eta a)\,\theta_t.$$

整条轨迹其实就是一个公比 $r = 1 - \eta a$ 的等比数列。三种情况一目了然:

  • $|r| < 1 \Leftrightarrow 0 < \eta < 2/a$ —— 收敛到 0;
  • $|r| = 1 \Leftrightarrow \eta = 2/a$ —— 永远在两侧弹跳;
  • $|r| > 1 \Leftrightarrow \eta > 2/a$ —— 直接发散。

稳定上限是 $\eta < 2/a$ ,其中 $a$ 是曲率。曲率越大,最大可用学习率越小。下面这张图把三种情况画在同一个损失碗里。

三种学习率在一维二次损失上的轨迹

注意右图中,参数并非简单越过极小值点,而是偏离幅度呈指数级增长。这正是真实训练里把 loss 推成 NaN 的那种“几何爆炸”。

高维情况:最陡的方向决定上限#

$$\eta < \frac{2}{\lambda_{\max}(H)}.$$

关键洞察:大多数方向再缓也没用——只要有一个特别陡的方向(最大特征值),它就独自决定了整个优化器的上限。即使多数方向曲率平缓,只要存在一个曲率极大(即 Hessian 最大特征值很大)的方向,优化过程就可能失稳。

这也解释了为什么训练比“理论上”更难:最大特征值会随训练单调上涨(Cohen et al. 2021 把这种现象叫做 progressive sharpening)。第 100 步还安全的学习率,到了第 10 000 步可能就直接爆炸。

$L$ -光滑:教科书里 $\eta \leq 1/L$ 的来源#

$$\|\nabla L(\theta) - \nabla L(\theta')\| \leq L \,\|\theta - \theta'\|.$$ $$L(\theta_{t+1}) \leq L(\theta_t) - \eta\left(1 - \tfrac{\eta L}{2}\right) \|\nabla L(\theta_t)\|^2.$$

这个表达式在 $\eta < 2/L$ 时单调下降,在 $\eta = 1/L$ 时下降最快。这就是优化教材里“安全选择”的由来——你也能看出 $L$$\lambda_{\max}(H)$ 在这件事上扮演的是同一个角色。

为什么必须有调度#

真实网络里,曲率、梯度噪声、甚至 Hessian 的特征向量都会随训练变化。不存在一个固定的常数学习率,能够在整个训练过程中始终保持最优性能。 一个典型的调度通常包含三个阶段:

  • Warmup——参数还是随机的,曲率巨大,先让 $\eta$ 慢慢爬上去;
  • Stable / 高 LR——曲率稳定下来,趁机狠狠推进;
  • Decay / cooldown——梯度均值很小但噪声不变,把 $\eta$ 降下来精修。

将常见调度绘制在同一张图上,差异一目了然:

常见学习率调度对比:cosine、step、linear、WSD

每条曲线分别适合什么场景,第五节会逐一拆解。


batch size、动量、权重衰减:藏起来的耦合#

学习率从来不是孤立的超参数。下面三个朋友一直跟着它跑。

batch size 与线性缩放法则#

mini-batch 梯度 $\tilde g_t$ 是真实梯度 $\nabla L(\theta_t)$ 的无偏估计,方差大约是 $\sigma^2 / B$$B$ 是 batch size。所以:

  • batch 越大 → 噪声越小 → 大 LR 更安全;
  • batch 越小 → 噪声越大 → 大 LR 容易被随机方向“抖飞”。

经典经验法则(Goyal et al. 2017,“Accurate, Large Minibatch SGD”)就是 线性缩放法则:batch 增大 $k$ 倍,LR 也乘 $k$但必须配 warmup——光靠线性缩放,训练初期会直接过冲。

后来 LAMB、LARS 等大 batch 算法把这一思路又推了一步,但本质没变:LR 和 $B$ 是绑在一起的

Linear scaling rule 与梯度噪声随 batch size 的变化

左图:经验上,线性规则 $\eta \propto B$ 在达到一个临界 batch size 之前都能在几个百分点以内成立,超过该点后开始走平——再加 batch 不会多出 LR 余量。右图:梯度标准误差以 $1/\sqrt B$ 衰减,这正是“大 batch 能承受大步长”背后的原因。

动量:藏在背后的 LR 放大器#

$$v_{t+1} = \beta v_t + g_t, \qquad \theta_{t+1} = \theta_t - \eta \, v_{t+1}.$$

稳态时 $v_t \approx g / (1 - \beta)$ ,所以等效步长大约是 $\eta / (1 - \beta)$ 。常用的 $\beta = 0.9$ 意味着动量把你的有效 LR 放大了 10 倍。这就是为什么 “SGD + momentum” 配方里的 $\eta$ 通常比裸 SGD 用的更小——动量已经替它踩了一脚油门。

Adam 的一阶矩本质上是同样的事情。

权重衰减:耦合得很紧的正则化#

$$\theta_{t+1} = \theta_t - \eta \, (\text{自适应更新}) - \eta \lambda \theta_t,$$

每步施加的“收缩”是 $\eta \lambda$LR 加倍 = 有效权重衰减加倍。稳态权重模长大致是 $\propto \sqrt{1/\lambda}$ ,与 $\eta$ 无关;但多快达到稳态取决于 $\eta$ 。所以“LR 越小,正则化越弱”是真实存在、却经常被忽视的效应。

实操结论:重新调 LR 的时候,把 weight decay 也一起再扫一遍。


自适应优化器:把学习率变成“一组学习率”#

如果说 SGD 的 LR 是一把大锤,Adam 就是装了一柜子小锤的工具箱——每个参数都有自己的步长。

Adam 的更新式#

$$ \begin{aligned} m_t &= \beta_1 m_{t-1} + (1-\beta_1) g_t, \\ v_t &= \beta_2 v_{t-1} + (1-\beta_2) g_t^2, \\ \hat m_t &= m_t / (1 - \beta_1^t), \quad \hat v_t = v_t / (1 - \beta_2^t), \\ \theta_{t+1} &= \theta_t - \eta \cdot \frac{\hat m_t}{\sqrt{\hat v_t} + \varepsilon}. \end{aligned} $$

关键就在 $\eta / \sqrt{\hat v_t}$ 这一项——每个参数的有效 LR 大约是 $\eta / |g|$ 。梯度持续大的参数被自动调小步长,梯度小的参数仍然能走完整 $\eta$ 。这就是 Adam 在不同尺度的参数(embedding、attention、layer norm)上都能开箱即用、而 SGD 必须靠 per-layer LR 才能勉强跟上的原因。

为什么 Adam 仍然需要 warmup#

很多人会想:Adam 都自适应了,还要 warmup 干嘛?两个理由:

  • 早期统计量非常不稳: $\hat v_t$ 是几个噪声梯度估出来的;偏置修正项 $(1 - \beta_2^t)$$t$ 很小时会被除得非常大。这是过去的标准解释。
  • 预条件化曲率(preconditioned sharpness)很大。 更新的解释来自 Kalra et al. 2024 的 Why Warmup the Learning Rate?:warmup 把网络推到一个“预条件化 Hessian 最大特征值更小”的区域——本质上是在重塑优化地形,让后面更大的峰值 LR 变得安全。

无论哪种解释,结论都一样:Adam 永远要 warmup。视觉/CNN 用 1–5% 的总步数;LLM 和超大 batch 推荐 5–10%。

Warmup 对早期训练的影响:损失更平滑,梯度范数不超限

不加 warmup 的崩坏样子很可观:前 30 步左右梯度范数冲出 clip 阈值很高,损失出现明显隐凸,之后的训练也几乎追不上加了 warmup 的曲线。几百步的 warmup 往往就是“能收敛”与“发散或卡住”的分界。


调度大全:从老办法到大模型默认#

上面那张图把四个最常用的家族画在了一起,下面看每条线分别适合什么场景。

常数 LR#

简单。基本上永远是错的——不是早期太慢,就是后期太抖,没有两全。

阶梯衰减#

到达指定 milestone 时把 $\eta$ 乘以 $\gamma$ (通常 0.1)。经典 ResNet 配方就用这个。优点:实现简单、手工调参直观。缺点:突然的下台阶可能让 weight decay 或 BN 敏感的网络出现 loss spike。

Cosine decay(深度学习的工作马)#

$$\eta_t = \eta_{\min} + (\eta_{\max} - \eta_{\min}) \cdot \tfrac{1}{2}\left[1 + \cos\left(\pi \cdot \tfrac{t - t_w}{T - t_w}\right)\right],$$

前面再接一段长度 $t_w$ 的线性 warmup。它的形状——前期降得慢、后期降得快——刚好契合直觉:在高 $\eta$ 上探索得越久越好,最后再快速收敛。

2019 到 2023 年间几乎所有“大模型”论文(BERT、RoBERTa、GPT-3、ViT、ImageNet 上的大规模 ResNet)默认就是它。最大的缺点是死板:cosine 半周期由已知的总步数 $T$ 决定。想延长训练?整条曲线得重做。

WSD:Warmup–Stable–Decay(现代 LLM 默认)#

Hägele et al. 2024(Scaling Laws and Compute-Optimal Training Beyond Fixed Training Durations)等工作把 WSD 推成了主流:

  • Warmup——和以前一样;
  • Stable——把 $\eta = \eta_{\max}$ 持续整个训练的大部分(60–90%);
  • Cooldown——最后 10–20% 线性(或多项式)降到 $\eta_{\min}$

它能在 LLM 圈成为默认有三个理由:

  1. 可续训、可扩展: 想多跑 2× tokens?继续 stable 阶段,再 cooldown 即可。Cosine 没法在不重新拟合曲线的情况下做到这点。
  2. “cooldown 最后一跳”现象。 经验上 cooldown 一开始 loss 就会出现一次明显下降——好像模型一直被“按着”,到此终于被允许精细贴合。
  3. 理论支持。 Schaipp et al. 2025(The surprising agreement between convex optimization theory and learning-rate schedulingarXiv:2501.18965 )证明 cooldown 形状刚好对上凸优化里一个紧的界,cooldown 这一段在界里对应的是“去掉对数项”。

Cosine vs WSD vs Schedule-Free 速览#

调度优点缺点适合
Cosine平滑、久经考验需要事先知道 $T$训练长度固定
WSD可续训、阶段清晰、cooldown 末段下降明显需要选 cooldown 起点长训练 / 可续训的 LLM
Schedule-Free不需要 $T$ 、几乎不用调较新、实战经验少原型迭代、训练预算不定

LR range test:200 个 batch 找到你的上限#

挑选 $\eta_{\max}$ 最有用的工具,最初由 Smith 2015(Cyclical Learning Rates)提出:

  1. $\eta$ 设成 $\eta_{\min} \approx 10^{-7}$
  2. 每个 mini-batch 之后把 $\eta$ 乘以一个固定因子(如 1.1),让它指数增长;
  3. loss 开始上升就停;
  4. 把 loss 对 $\log\eta$ 画图。

你会看到四个阶段:噪声平台 → 下降段 → 噪声最低点 → 爆炸。“边界”就在爆炸前那一点;正式训练的峰值 LR 取在 $[0.3 \times, 1.0 \times]$ 这个边界之间。

LR range test:取稳定边界的 0.3-1×

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import math
import torch

def lr_range_test(model, loader, loss_fn, optimizer,
                  lr_min=1e-7, lr_max=10, num_steps=200):
    """指数扫一遍 LR,返回 (lrs, losses) 用于画图。"""
    model.train()
    mult = (lr_max / lr_min) ** (1 / (num_steps - 1))
    lr = lr_min
    for g in optimizer.param_groups:
        g["lr"] = lr

    lrs, losses = [], []
    it = iter(loader)
    for _ in range(num_steps):
        try:
            x, y = next(it)
        except StopIteration:
            it = iter(loader)
            x, y = next(it)

        optimizer.zero_grad(set_to_none=True)
        loss = loss_fn(model(x), y)
        loss.backward()
        optimizer.step()

        lrs.append(lr)
        losses.append(loss.item())
        lr *= mult
        for g in optimizer.param_groups:
            g["lr"] = lr
    return lrs, losses

工程实现上更稳的做法是给 loss 加一个指数滑动平均,并且在 loss 超过历史最低值 $4\times$ 时自动停止——fastailr_find() 就是这么写的。


不同优化器,曲线长得不一样#

同一条调度套到不同优化器上,得到的 loss 曲线并不一样。下图把同一条 warmup-cosine 调度同时应用到 AdamW 和 SGD-with-momentum 的合成任务上:AdamW 早期下降更快,SGD 往往后段才追上来,且对峰值 LR 更敏感。

Adam vs SGD 在同一条 warmup-cosine 调度下的对比

十年配方浓缩成几条经验:

  • AdamW + lr ≈ 1e-4 ~ 5e-4——Transformer 类、NLP/多模态预训练。
  • AdamW + lr ≈ 1e-5 ~ 5e-5——预训练 Transformer 的微调。
  • SGD + momentum 0.9 + lr ≈ 0.1——从零训练 ResNet/CNN,配 cosine 或 step decay。
  • 想省显存(不存二阶矩)、且愿意花时间调参时,仍然首选 SGD + momentum。

Layer-wise / 判别式 LR:微调的杀手锏#

微调预训练模型时,底层已经学会了如何提取好的特征——你不希望大学习率把它们冲掉;高层是随机的、任务特定的,需要更大的更新。ULMFiT(Howard & Ruder 2018)把这件事系统化为 判别式学习率(discriminative learning rates):顶层用一个小的基础 LR,每往下一组就除以一个因子(通常 2.6 或 0.8)。

ULMFiT 风格的逐层判别式 LR

最小可用的 PyTorch 实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def layer_wise_param_groups(model, base_lr=3e-4, decay=0.8):
    """越靠近输入的层,LR 越小。"""
    layers = list(model.encoder.layer)            # Hugging Face 风格
    groups = []
    n = len(layers)
    for i, layer in enumerate(layers):
        groups.append({"params": layer.parameters(),
                       "lr": base_lr * (decay ** (n - 1 - i))})
    groups.append({"params": model.classifier.parameters(), "lr": base_lr})
    return groups

LLM 微调里同样的思路换了几张皮:

  • LoRA / adapter——只训练一小撮新参数,LR 给满;其余冻住。
  • LLaMA-Adapter 风格——逐渐解冻,被解冻部分用更小的 LR。

Schedule-free 与 learning-rate-free#

调度本身、甚至学习率这个标量,原则上都可以被消掉。最近两条研究路线在做这件事。

D-Adaptation(Defazio & Mishchenko, 2023)#

D-Adaptation 在训练过程中估计“当前点到最优点的距离”,再用这个距离反推步长。没有 $\eta$ 可调。在很多任务上能在几个百分点内追平手调的基线。

无调度 AdamW(Defazio 等, 2024,arXiv:2405.15682 #

它把 iterate averaging 和一个常数基础 LR 结合起来,得到“看起来像 cosine 衰减过”的轨迹,但全程并不显式地降 $\eta$ 。这意味着你不需要事先承诺训练总步数 $T$ :想停就停,想延长就延长,不必重调调度。

Schedule-free vs cosine:不必指定 T 仍能匹敌

什么时候考虑这两类方法?

  • 原型阶段,预算还没定;
  • 多预算研究(比较 5%、25%、100% tokens),每条都重做 cosine 太烦;
  • 弹性算力场景(被抢占、被重新调度)。

真实 LLM 调度长什么样#

GPT-3(175B)和 LLaMA(7B/13B/65B)用的是同一个模板:短的线性 warmup,然后 cosine 退到峰值 LR 的 10%。峰值本身随模型规模下降——按 GPT 缩放律大致是 $\eta_{\max} \propto 1/\sqrt{N}$

典型 LLM 预训练调度(GPT-3 / LLaMA 风格)

公开论文里的具体数字:

模型峰值 LR最小 LRWarmup调度Batch (tokens)
GPT-3 175B(Brown et al., 2020)0.6e-40.6e-5375M tokenscosine3.2M
LLaMA-7B(Touvron et al., 2023)3e-43e-52 000 步cosine4M
LLaMA-65B1.5e-41.5e-52 000 步cosine4M
Chinchilla 70B(Hoffmann et al., 2022)1e-41e-51 875 步cosine1.5M–3M
MiniCPM(Hu et al., 2024)1e-21e-32% 步数WSD视情况

几个几乎是常识但容易忽略的点:

  • 最小 LR ≈ 峰值 LR 的 10% 是事实标准,不是 0。一退到 0 经常意味着最后几千步等于白跑。
  • 梯度裁剪到 1.0 在这一规模几乎是必备的;不裁的话,偶尔一个坏 batch 就能把你推下悬崖。
  • weight decay = 0.1(decoupled,AdamW 风格)也是 LLM 配方里的常见默认——比视觉里的 1e-4 要大得多。

从“能跑”到“跑得好”:可操作的工作流#

Step 1 —— 先判断失败的类型#

训练失败有三种典型形态:

症状大概率原因
几步内 loss → NaN/InfLR 太大;缺 warmup;缺裁剪;AMP underflow
loss 大幅震荡LR 太大;动量过大;norm-decay 不匹配
loss 几乎不动LR 太小;调度衰得太快;数据/标签有 bug

Step 2 —— 找你的上限#

跑一次 LR range test(第六节)。绝大多数“我试了 1e-3 和 1e-4 都不行”的故事,到这一步就解决了。

Step 3 —— 选调度#

场景默认
中型模型(<1B 参数),预算固定warmup + cosine
LLM 预训练,可能续训warmup + WSD
微调预训练模型线性 warmup + 线性衰减,峰值 ≈ 1e-5 ~ 5e-5
预算未知 / 不固定Schedule-Free AdamW

Step 4 —— LR、batch、weight decay 联动#

不要孤立地动 LR。心里要装着这张三方耦合表:

问题错的做法更合理
训练不稳闷头降 LR加梯度裁剪、延长 warmup、加大 weight decay
Loss 卡在高位闷头加 LR增大 batch(降噪声);先排查数据流水线
过拟合降 LR加 weight decay;加 dropout/数据增强

Step 5 —— 监控三个量,不只是 loss#

  • 梯度模长——warmup 之后应该大致平稳;突然飙升通常是发散的前兆。
  • 更新 / 参数比 $\|\Delta\theta\| / \|\theta\|$ ——大约 $10^{-3}$ 是健康值。低于 $10^{-5}$ 是欠拟合,高于 $10^{-2}$ 接近不稳。
  • LR 敏感度——$\eta$ 的小变动如果造成最终 loss 大变,就说明你已经贴在稳定边界上了,留点余量。

故障排除检查表#

Loss 一开始就炸(NaN / Inf)#

按优先级排:

  1. 把峰值 LR 降一个数量级(如 3e-4 → 3e-5);
  2. 加上或延长 warmup(如从 0 → 5% 步数);
  3. 加梯度裁剪:torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
  4. 检查混合精度:fp16 是否在用 GradScaler,或者切到 bf16
  5. 加大 weight decay(尤其是 LLM)。

Loss 下降太慢#

常见原因:

  • LR 太小(先做一次 LR range test);
  • 调度衰得太快(用 WSD 拉长 stable 阶段);
  • batch 太小、噪声太大(增大 batch 或用 gradient accumulation);
  • 数据/标签有问题(这不是 LR 能救的,先查流水线)。

Loss 大幅震荡#

  • 把峰值 LR 降 10–30%;
  • 减小动量($\beta = 0.9 \to 0.85$ ,或 Adam 的 $\beta_1 = 0.9 \to 0.85$ );
  • 加梯度裁剪;
  • 检查优化器和归一化的搭配(AdamW + LayerNorm 很稳;SGD + BatchNorm 在大 LR 下脆弱)。

验证 loss 偏离训练 loss#

这是过拟合,不直接是 LR 问题,但 LR 影响隐式正则:

  • 加大 weight decay;
  • 把峰值 LR 略降(更慢的训练通常泛化更好);
  • 加 dropout、label smoothing、数据增强;
  • 配 patience 5–10 的 early stopping。

参考实现#

预热 + 余弦#

把这两段话先念清楚:cosine 调度让学习率沿着半个余弦周期从峰值滑到谷底,公式是 $\eta(t) = \eta_{\min} + \tfrac{1}{2}(\eta_{\max} - \eta_{\min})\bigl(1 + \cos(\pi t/T)\bigr)$ ,前半段几乎不掉、后半段加速衰减——这正是大多数任务想要的:先充分探索,再老老实实退火收敛。线性退火 $\eta(t) = \eta_{\max}(1 - t/T)$ 看上去更朴实,但在我自己的实验里同样的总步数下 cosine 的最终 loss 几乎总是更低,原因是它把“低学习率”的尾段拉得更长。阶梯衰减 $\eta_k = \eta_0 \cdot \gamma^{\lfloor t/s \rfloor}$ 是远古时代留下来的写法,现在只有在我明确知道训练动力学有“分阶段”特征(比如 ResNet on ImageNet 那种)时才会用。

warmup 这一段我习惯写得很死板:从 $\eta_0 = 10^{-7}$ 起步线性涨到峰值 $\eta_{\max}$ 。比方说我设 1000 步 warmup、峰值 $1\times 10^{-4}$ ,那么第 1 步是 $1\times 10^{-7}$ ,第 500 步大约 $5\times 10^{-5}$ ,第 1000 步正好打到 $1\times 10^{-4}$ 。这一段从直觉上看是“保护脆弱的早期统计量”,但更深层的理由(Kalra 2024)是它压住了预条件 Hessian 的最大特征值,让一个原本会炸的峰值 LR 变得可持续。下面是我用了好几个项目的实现版本,调度逻辑全在 lr_warmup_cosine 一个函数里。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import math

def lr_warmup_cosine(step, total_steps, warmup_steps, lr_max, lr_min=0.0):
    """线性 warmup,然后 cosine 从 lr_max 退到 lr_min。"""
    if step < warmup_steps:
        return lr_max * (step + 1) / max(1, warmup_steps)
    t = step - warmup_steps
    T = max(1, total_steps - warmup_steps)
    cos = 0.5 * (1.0 + math.cos(math.pi * t / T))
    return lr_min + (lr_max - lr_min) * cos

预热 + 稳定 + 衰减 (WSD)#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def lr_wsd(step, total_steps, warmup_steps, cooldown_steps,
           lr_max, lr_min=0.0):
    """warmup → 常数 lr_max → 线性 cooldown 到 lr_min。"""
    if step < warmup_steps:
        return lr_max * (step + 1) / max(1, warmup_steps)
    stable_end = total_steps - cooldown_steps
    if step < stable_end:
        return lr_max
    t = step - stable_end
    T = max(1, cooldown_steps)
    frac = min(1.0, (t + 1) / T)
    return lr_max + (lr_min - lr_max) * frac

接到训练循环里#

调度函数写好之后,剩下的事其实只有一件:每个 step 拿到 lr 之后,把 optimizer.param_groups 里所有组的 "lr" 字段改写一遍,然后照常 loss.backward()optimizer.step()。这里有几个我踩过的坑想先说在前头。第一,lr 必须每步刷新,不要图省事写成“epoch 末尾改一次”——cosine 在每一步的曲率不同,间隔太大会让损失曲线出现可见的阶梯。第二,optimizer.zero_grad(set_to_none=True)zero_grad() 更省内存、也更快,老代码里如果还没改过来值得顺手改掉。第三,梯度裁剪 clip_grad_norm_ 几乎是 LLM 训练的必备项,写在 backwardstep 之间,max_norm=1.0 是默认起点。

我喜欢把“调度函数”和“训练循环”彻底解耦:训练循环只负责接收一个 schedule_fn(step, total_steps) -> lr,至于这个函数内部是 cosine 还是 WSD、是否带 warmup,跟训练循环无关。这样换调度策略只需要换一个 lambda,不必动循环本身。下面这段就是我最近一个项目里直接复制粘贴在用的版本。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import torch

def train_one_epoch(model, loader, optimizer, step_offset, total_steps,
                    schedule_fn, device="cuda", clip_norm=1.0):
    model.train()
    step = step_offset
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        lr = schedule_fn(step, total_steps)
        for g in optimizer.param_groups:
            g["lr"] = lr

        optimizer.zero_grad(set_to_none=True)
        loss = torch.nn.functional.cross_entropy(model(x), y)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), clip_norm)
        optimizer.step()
        step += 1
    return step

典型配置:

1
2
3
4
5
6
schedule_fn = lambda s, T: lr_wsd(
    step=s, total_steps=T,
    warmup_steps=int(0.02 * T),
    cooldown_steps=int(0.10 * T),
    lr_max=3e-4, lr_min=3e-5,
)

2023 年以来的新进展#

下面五条线值得跟踪。

D-Adaptation —— 无学习率(2023)#

思路:在训练过程中估计“当前点到最优点的距离”,再用这个估计推算步长。没有可调的 $\eta$ 。原型迭代和 grid search 收敛阶段非常有用。

参考:Learning-Rate-Free Learning by D-Adaptation (Defazio & Mishchenko, 2023)

无调度 AdamW(2024)#

把 iterate averaging 和常数基础 LR 结合起来,达到与有调度方法相当的性能,却不需要显式衰减——更不需要事先承诺总步数 $T$

参考:Schedule-Free AdamW (Defazio et al., 2024, arXiv:2405.15682)

Warmup 为什么“真的”有效(2024)#

老解释——“等 Adam 统计量稳定下来”——并不完整。Kalra et al. 2024 证明 warmup 实际上降低了预条件化 Hessian 的最大特征值,从而让一个更大的可持续峰值 LR 变得安全。

参考:Why Warmup the Learning Rate? (Kalra et al., 2024, arXiv:2406.09405)

Power Scheduler —— 对 batch / token 不敏感(2024)#

换 batch、换训练 token 数,最优 LR 都会漂。Power Scheduler 利用 LR、batch、token 之间的幂律关系,给出一种在不同规模间可迁移的调度。

参考:Power Scheduler: A Batch Size and Token Number Agnostic Learning Rate Scheduler (Shen et al., 2024, arXiv:2408.13359)

用小模型复现 LLM 不稳定(2023–2024)#

很多“只有 LLM 才会出现”的 loss spike,把小模型的 LR 调高就能复现。这意味着你可以用 1/100 的成本调研真实的不稳定性。

参考:Small-scale proxies for large-scale Transformer training instabilities (Wortsman et al., 2023, arXiv:2309.14322)

Cosine ↔ WSD 的凸优化桥梁(2025)#

Schaipp et al. 2025(arXiv:2501.18965 )证明 WSD 的 cooldown 形状和某个紧的凸优化界完全对应,cooldown 这一段在界里负责“消去对数项”。这给“为什么 cooldown 有用”提供了一个有原则的解释。

参考:The surprising agreement between convex optimization theory and learning-rate scheduling (Schaipp et al., 2025, arXiv:2501.18965)


一页速查表#

AdamW 默认配方#

下面这份配方是我把过去两年用 AdamW 训练 vision、NLP、LLM 的经验压缩成的“无脑挡”——拿来当起点几乎不会出大错。具体每一项都还是要根据任务微调,但顺序我建议照着走:先固定 schedule 的形状、再定 warmup 长度、再决定 cooldown,最后才动峰值 LR。我观察过一个常见错误是初学者一上来就盯着峰值 LR 调,调了三五次还看不出规律,根本原因是底下的 schedule 形状没固定、变量太多。把 schedule 当作几何形状先画出来,再把峰值当成一个标量乘子去调,效率会高得多。

另一件事:weight decay 视觉任务我用 0.01,LLM 用 0.1,差一个量级。这不是因为 LLM 本身需要更强的正则——而是因为 AdamW 里 decoupled weight decay 实际作用强度跟 $\eta$ 成反比,LLM 的峰值 LR 比 vision 小 10 倍左右,所以名义值要拉高 10 倍才能维持同样的有效正则强度。把这条记牢可以省下很多盲目调参。

  • 调度:warmup + cosine 或 warmup + WSD;
  • warmup:1–5% 总步数(超大 batch / LLM 取 5–10%);
  • cooldown(仅 WSD):最后 10–20% 步数;最小 LR = 峰值的 10%;
  • 梯度裁剪max_norm = 1.0(LLM 几乎必备);
  • weight decay:视觉 0.01;LLM 0.1(decoupled);
  • 峰值 LR 经验值
    • 从零训练 Transformer:1e-4 ~ 5e-4
    • 微调 Transformer:1e-5 ~ 5e-5
    • 从零训练 CNN(SGD-momentum):0.05 ~ 0.1

监测三个信号(比看 loss 更靠谱)#

只盯 loss 曲线是新手最容易犯的毛病。loss 是滞后指标,等它出问题往往已经过了好几百步,而早期信号其实埋在三个量里。我现在每个项目第一天搭训练循环时,都会顺手把这三个量打到 wandb 或者 tensorboard 上,几乎不花时间,但救过我无数次。

第一个是梯度模长 $\|g\|$ 。warmup 结束后它应该稳定在某个范围内,几个小波动正常,但如果突然飙升一两个量级,就是马上要 NaN 的预警——这时候立刻降 LR 通常还来得及。第二个是参数相对变化率 $\|\Delta\theta\| / \|\theta\|$ ,稳态训练时大约在 $10^{-3}$ 量级;如果远超这个值,说明步子迈得太大,反过来如果远小于(比如 $10^{-5}$ ),说明根本没在学。第三个是 LR 敏感度——把 LR 改 ±20% 重跑一小段,如果最终 loss 变化明显,说明你已经踩在稳定边界上,再往上就会炸。这三个量加起来比单看 loss 更能早预警,也更能告诉我“现在到底是 LR 太大、太小、还是正合适”。

  • 梯度模长——warmup 之后应当平稳;飙升 = 麻烦;
  • $\|\Delta\theta\| / \|\theta\|$ ——稳态大约 $10^{-3}$
  • LR 敏感度——小变动就能影响最终 loss = 你贴着边界。

快速分诊表#

下面这张表是我自己常年贴在工位边上的版本——出现某个症状时第一步该怎么动、第二步该怎么动,写成肌肉记忆。它不能替代你对系统的理解,但能省掉“看到 NaN 之后慌乱地把所有超参数都改一遍”那种灾难性操作。这里特别想多说一句关于“早期 NaN”的:很多初学者一遇到 NaN 就把 LR 砍到 1/100,其实大多数时候只要砍 10 倍 + 加 warmup 就够了,砍太狠会让训练慢得看不出收敛趋势,反而会怀疑模型本身有问题。

还有一个反直觉的认知:初学者常以为学习率越小越好,反正“慢一点总比炸了好”。但实际上,过小的 LR 不只是慢——它会把模型困在初始化附近的烂局部解里,loss 曲线看上去“在下降”,可降到一定程度就再也不动了,最终 loss 比恰当 LR 高一截。我见过的最糟糕的案例是某个同事把 LR 设得比经验值小 50 倍,跑了两周才发现 loss 卡在 plateau,重新跑一次只用了三天就到了更好的点。所以下面这张表里“下降缓慢”那一行的第一动作永远是 LR range test,不是“再降 LR”。

症状第一步第二步
早期 NaN / Inf降 LR 10×加 warmup;裁剪到 1.0
下降缓慢LR range test拉长 stable(WSD)
大幅震荡降 LR 或动量加裁剪
训练-验证差距大加 weight decay略降 LR

总结#

  1. 跑一次 LR range test,找到稳定边界;
  2. 峰值 LR 取边界的 0.3–1×
  3. 加 warmup——视觉 1–5%,LLM 5–10%;
  4. 选调度——预算固定用 cosine,可续训 LLM 用 WSD,预算未知用 schedule-free;
  5. 联动调 batch、weight decay、梯度裁剪。永远不要孤立地调 LR。

如果只能记一句话:绝大多数被甩锅给“优化器”的训练问题其实是 LR 调度问题,而绝大多数 LR 调度问题,一个下午、一次 LR range test、一段 warmup 就能解决。


接下来#

这篇里讲到的工具——LR range test、warmup、cosine、WSD、三个监控信号——之后的几篇会被当作黑盒反复用到。如果某个概念你现在读着还有点滑、抓不住,不用强求一次理解透;标记一下回头再来看,这些想法会在整个 series 里反复出现,第二、第三遍碰到时往往会突然通了。我自己当年学的时候,cosine 这一段也是过了大半年才真的“看懂”——看懂的瞬间不是因为又读了一遍论文,而是因为某次训练失败之后画了张 LR vs loss 图,突然明白“这个形状就是为退火设计的”。

如果你想立刻把今天的内容用起来,最高 ROI 的练习是这个:找一个你已经训练过、对结果有大致预期的任务(哪怕是 MNIST 上的小 MLP),把它跑一次完整的 LR range test,然后用得到的边界值反过来检查你之前用的峰值 LR——绝大多数人会发现自己一直在远离最优区间的位置训练。这种“事后才看清”的练习比再读两篇论文有用得多,因为它直接把抽象概念绑定到你自己的训练曲线上。下一篇文章会建立在这些工具之上继续推进,所以现在让“做 LR range test”变成肌肉记忆,是非常划算的投资。


参考文献#

本系列

优化理论 12 篇

  1. 01 优化理论(一):凸分析基础
  2. 02 优化理论(二):光滑性、强凸性与 Nesterov 加速
  3. 03 优化理论(三):梯度下降族——从 SGD 到 AdamW
  4. 04 优化理论(四):学习率与调度策略 当前
  5. 05 优化理论(五):Nesterov 之外的加速
  6. 06 优化理论(六):复合优化与近端方法
  7. 07 优化理论(七):二阶方法
  8. 08 优化理论(八):Lagrangian 对偶与 KKT 条件
  9. 09 优化理论(九):内点法与自和谐障碍函数
  10. 10 优化理论(十):随机优化与方差缩减
  11. 11 优化理论(十一):非凸优化与鞍点逃逸
  12. 12 优化理论(十二):离散与全局优化

读有所得?

GitHub 关注我 → 新文周更

GitHub