你有没有遇到过这种情况:同一个模型、同一份数据,别人训练三四百个 epoch 就能收敛到一个漂亮的精度,你跑了一千个 epoch 还在震荡;或者 loss 前面降得飞快,到后半程直接卡死,怎么调都下不去。我早期做图像分类时,第一反应是改网络结构、换优化器、加大 batch size,折腾一圈下来才发现,很多人忽略的**学习率调度器(learning rate scheduler)**才是差距最大的那个变量。这篇文章我想把学习率调度器的原理、公式,以及 PyTorch 里的实战接线方式一次讲透。
如果你已经装好 PyTorch、跑通了一个基础训练循环,那这篇文章正好适合你。下面内容不绕弯子,直接从"为什么需要动态学习率"开始,拆公式、看 PyTorch API、讲踩坑经验,最后给出一套可以直接参考的选择思路。
1. 为什么同一个模型,别人收敛你震荡:学习率动态调整的必要性
1.1 固定学习率的两个极端:前期发飘与后期卡死
在讨论调度器之前,先看一个许多新手踩过的场景。我用 ResNet-18 在 CIFAR-10 上做实验时,固定学习率 0.1,用 SGD + Momentum 0.9,前 20 个 epoch loss 降得很快,到第 50 个 epoch 后 loss 开始进入平台期,最终精度大约 90%。我试着把学习率改成 0.01,发现收敛慢了很多,但最终精度反而略高一点。再改成 0.001,模型根本学不动。
这个实验说明了一个非常朴素的问题:固定学习率时,你必须在"前期收敛速度"和"后期稳定性"之间做 trade-off。学习率太大,前期参数更新幅度大,能快速跨过一些平坦区域,但到了损失函数比较陡峭或狭窄的谷底附近,参数容易来回震荡,甚至直接发散;学习率太小,训练稳定性好,但收敛速度慢,很多时候还没走到好的解,训练预算就用完了。
更麻烦的是,这个"前期"和"后期"的语义还会随着数据规模、模型深度、优化器类型变化。同一个学习率在小数据集上合适,换一个更大的数据集可能就完全失效。调度器解决的正是这个"步长随着训练过程动态变化"的问题:一开始步子迈大一点,快速跨越平坦区域;接近损失函数底部时,步子小一点,避免跳过极小值点。
1.2 调度器在优化轨迹上做了什么:从探索到利用
从优化角度讲,学习率调度器控制的是参数更新步长 $\eta_t$。SGD 更新公式是:
$\theta_{t+1} = \theta_t - \eta_t \nabla L(\theta_t)$
如果你把损失函数曲面可视化过,会发现它并不是一个光滑的碗,而是布满峰谷、鞍点和高原。大学习率可以帮你快速离开高原,但也容易在谷底附近来回震荡;小学习率能在谷底精细搜索,却可能连高原都走不出去。调度器本质上是在"探索"和"利用"之间做一个时间维度的权衡。
我个人的理解是:学习率调度器不是一种正则化手段,也不是锦上添花的小技巧,它直接决定了参数轨迹是否能够进入一个"好"的极小值盆地。尤其是 BatchNorm 层多的现代卷积网络,特征分布变化剧烈,一个合适的学习率曲线往往比换激活函数更有效。后面说的所有公式,本质上都是在描述"学习率怎么随时间下降"这个问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从公式看调度曲线:阶梯、指数、余弦与热重启发
2.1 StepLR 与 MultiStepLR:分段退火的分寸
先看最经典的阶梯式调度。PyTorch 中的 StepLR 公式很简单:
$\eta_t = \eta_0 \gamma^{\lfloor t / \text{step_size} \rfloor}$
其中 $\eta_0$ 是初始学习率,$t$ 是当前 epoch,$\gamma$ 是衰减因子,$\lfloor \cdot \rfloor$ 表示向下取整。举个例子:$\eta_0=0.1$,$\text{step_size}=30$,$\gamma=0.1$,那么在 epoch 30、60、90 时,学习率会依次跳变为 0.01、0.001、0.0001。每个固定周期内学习率保持不变,所以曲线呈阶梯状。
MultiStepLR 则更进一步,不按固定周期,而是在你指定的 milestones 处缩小学习率。它的公式可以写成:
$\eta_t = \eta_0 \gamma^{c(t)}$
其中 $c(t)$ 是 milestones 中小于等于当前 epoch 的个数。比如 milestones=[30, 60, 90],其实和上面 step_size=30 的 StepLR 行为一致。但 MultiStepLR 更灵活,你可以在 80 个 epoch 发现过拟合时,只设置 milestones=[80],而不是强迫自己按固定周期切分。
阶梯调度的特点是"突变"。每次学习率骤降时,验证 loss 往往会有一个小小的跳变,这是正常的。它的使用场景很明确:训练阶段可以预先划分,比如前 70 个 epoch 用较大学习率逼近,后 30 个 epoch 缩小学习率精调。在早期的 CNN 训练、迁移学习和一些稳定任务里,这种调度方式一直到今天都还在广泛使用。
2.2 ExponentialLR:连续衰减的适用范围
ExponentialLR 的公式非常简洁:
$\eta_t = \eta_0 \gamma^t$
它不像 StepLR 那样突变,而是每个 epoch 都平滑地乘一次 $\gamma$。$\gamma$ 通常取 0.95~0.99,训练 100 个 epoch 时,学习率大概降到初始值的 0.005~0.03 倍。
有人说指数衰减是"最稳"的调度器,因为它没有任何跳变,训练过程不会因为学习率突变产生 loss 尖峰。但它的缺点也很明显:如果 $\gamma$ 取得太小,学习率会在训练中段就衰减得过低,导致模型后期几乎没有学习能力;如果 $\gamma$ 取得太大,整个训练过程衰减不明显,相当于在用一个近似固定的中等学习率,收敛精度还是上不去。
实操中,我一般不会单独用 ExponentialLR 做主力调度器,而是用它作为基线对比。比如想快速验证模型能不能收敛,就开一个 ExponentialLR(optimizer, gamma=0.97),曲线平滑,参数少,不容易出幺蛾子。真正追求精度时,再切换到余弦退火这一类更讲究的曲线。
2.3 CosineAnnealingLR:余弦曲线为什么更"稳"
余弦退火是当前训练分类和 Transformer 模型最常用的调度器之一。PyTorch 中的 CosineAnnealingLR 遵循的公式是:
$\eta_t = \eta_{\min} + \frac{1}{2}(\eta_{\max} - \eta_{\min}) \left(1 + \cos\left(\frac{T_{\text{cur}}}{T_{\max}} \pi\right)\right)$
其中 $\eta_{\max}$ 一般是初始学习率,$\eta_{\min}$ 是最小学习率(默认 0),$T_{\max}$ 是总周期 epoch 数,$T_{\text{cur}}$ 是当前已经走过的 epoch 数。
这个公式什么时候用?它是"先慢后快再慢"的曲线:训练前期学习率下降得比较慢,所以能保持较长时间的大学习率探索;到中段下降速度加快,学习率快速缩小;最后接近 $T_{\max}$ 时又趋于平缓,相当于在极小值附近做精细搜索。
相比阶梯式突变,余弦退火最大的优点是光滑。整个训练过程中学习率没有任何跳变,loss 曲线也更稳定。另一个很实际的好处是:你不需要去猜测"哪个 epoch 该降学习率",只要定好总训练轮数 $T_{\max}$,它自己会平滑过渡。我在 CIFAR-10、ImageNet 子集、以及一些目标检测模型上都验证过,余弦退火往往比 StepLR 效果好一点点,原因就在曲线更平滑、后期保留的探索时间更长。
2.4 CosineAnnealingWarmRestarts:热重启如何逃离局部极小
带热重启的余弦退火有一个更响亮的名字:SGDR(Stochastic Gradient Descent with Warm Restarts)。它的公式和普通余弦退火类似:
$\eta_t = \eta_{\min} + \frac{1}{2}(\eta_{\max} - \eta_{\min}) \left(1 + \cos\left(\frac{T_{\text{cur}}}{T_i}\pi\right)\right)$
区别在于每经过一个周期 $T_i$,$T_{\text{cur}}$ 重置为 0,学习率会突然回到 $\eta_{\max}$,然后重新开始余弦下降。同时周期长度可以按 T_mult 成倍增大。比如 T_0=10, T_mult=2,那么第一个周期是 10 个 epoch,第二个是 20 个,第三个是 40 个,依此类推。
为什么要把学习率突然拉回高位?因为模型训练到后期,参数可能已经陷入一个局部极小值。此时突然升高学习率,相当于把参数从当前局部极小值"踢"出去,让它在更大的范围内重新搜索。这么做的动机是寻找更平坦、泛化能力更好的极小值区域。
实际使用时,CosineAnnealingWarmRestarts 在训练中期的验证 loss 会出现明显的锯齿形波动,这是正常现象。如果你的任务对训练时间非常敏感,热重启未必是好选择,因为它会反复打断收敛进程。但在预算充足、你想榨干模型精度上限的时候,它往往比普通余弦退火更有竞争力。
2.5 LambdaLR:把公式写成函数的万能方案
有时我们需要完全定制的学习率曲线,比如 warmup 后接余弦退火、周期性上升下降、或者复现论文里某个奇怪的公式。此时不必去改优化器,直接用 LambdaLR 就能解决。
LambdaLR 的核心是传入一个函数 lr_lambda(epoch),最终的学习率等于初始学习率乘以这个函数的返回值。例如你要做一个指数衰减:
python复制scheduler = torch.optim.lr_scheduler.LambdaLR(
optimizer,
lr_lambda=lambda epoch: 0.95 ** epoch
)
再比如经典的三段式:前 5 个 epoch 线性 warmup,之后余弦退火到 0:
python复制import math
def warmup_cosine(epoch, warmup_epochs=5, total_epochs=100):
if epoch < warmup_epochs:
return (epoch + 1) / warmup_epochs
else:
t = (epoch - warmup_epochs) / (total_epochs - warmup_epochs)
return 0.5 * (1 + math.cos(math.pi * t))
scheduler = LambdaLR(optimizer, lr_lambda=warmup_cosine)
注意这里 warmup 阶段返回 (epoch + 1) / warmup_epochs,意味着学习率从 $\eta_0 \times 1/\text{warmup_epochs}$ 逐渐涨到 $\eta_0$。为什么不用从 0 开始?因为从 0 开始的 warmup 会浪费太多训练步数在极小的学习率上,尤其在 batch size 很大时没必要那么保守。
LambdaLR 是理解和实现自定义调度器的最佳入口。一旦你搞明白了"学习率 = 初始学习率 × 某个关于 epoch 的函数"这个逻辑,再看 PyTorch 里所有调度器的源码,都会觉得异常清晰。
3. PyTorch 调度器 API:内置工具的使用边界
3.1 调度器共同接口与 param_groups 的关系
PyTorch 从 1.1 版本开始,torch.optim.lr_scheduler 变成了一组类,使用时需要传入一个优化器实例。所有调度器都有几个共通接口:
- 构造时读取
optimizer.param_groups里的lr作为基准学习率。 - 调用
scheduler.step()更新当前学习率。 scheduler.get_last_lr()返回当前每个参数组的学习率列表。scheduler.state_dict()保存调度器内部状态,方便断点恢复。
这里有个容易忽略的细节:如果你的 optimizer 对不同的层设置了不同学习率,比如:
python复制optimizer = optim.SGD([
{'params': model.backbone.parameters(), 'lr': 0.01},
{'params': model.head.parameters(), 'lr': 0.1},
], momentum=0.9)
那么调度器会同时作用于这两个参数组,只不过每个参数组各自从自己的初始学习率开始计算。打印 get_last_lr() 时你会得到一个列表,比如 [0.01, 0.1] 经过某个 epoch 后变成 [0.008, 0.08]。设计网络时,如果你想对骨干网络和分类头使用不同学习率,调度器天然支持,不需要额外处理。
3.2 ReduceLROnPlateau:验证指标驱动的自动降级
前面说的调度器都是按 epoch 或 step 机械地更新学习率,ReduceLROnPlateau 则是另一种思路:它监控一个指标(通常是验证 loss 或准确率),当指标连续 patience 个 epoch 没有改善时,学习率乘以 factor。它的使用要手动传入监控指标:
python复制scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer,
mode='min',
factor=0.5,
patience=5,
cooldown=0,
min_lr=1e-6,
)
# 每个 epoch 结束后:
scheduler.step(val_loss)
mode='min' 表示监控的指标越小越好,factor=0.5 表示每次触发时学习率减半,patience=5 表示连续 5 个 epoch 没有改善才降学习率,cooldown 是降一次后先冷却几个 epoch,避免连续触发。
这个调度器适合训练阶段不可预测的任务。比如目标检测、语义分割这类任务,验证 loss 的下降往往不是线性的,你可能说不准到底该在第几个 epoch 降温,这时候让指标来自动触发比手动指定 milestone 更合理。
但 ReduceLROnPlateau 也有一个容易踩的坑:如果验证集很小,指标抖动剧烈,patience 设太小时会误降学习率,过早把模型卡在一个次优解上。我的经验是从 factor=0.5, patience=10 起步,等看到验证曲线比较平稳后,再把 patience 调小,让它在后期更灵敏一些。
3.3 OneCycleLR 与 CyclicLR:按 batch 更新的现代策略
OneCycleLR 是循环学习率(CyclicLR)的一种现代变体。它不按 epoch 更新,而是按 batch 更新,训练曲线只有一个"山峰":学习率先从小值线性上升到 max_lr,再从 max_lr 余弦下降到极小值。
PyTorch 里的用法是:
python复制steps_per_epoch = len(train_loader)
scheduler = torch.optim.lr_scheduler.OneCycleLR(
optimizer,
max_lr=0.1,
total_steps=epochs * steps_per_epoch,
pct_start=0.3,
)
# 训练循环内:
for batch in train_loader:
...
loss.backward()
optimizer.step()
scheduler.step() # 每个 batch 后更新
参数 pct_start=0.3 表示前 30% 的训练步用于升温,剩余 70% 用于衰减。这个设计非常契合"大学习率快速探索 + 小学习率精细收敛"的节奏。我在几十个 epoch 内的分类实验里经常用它,效果往往不弱于 CosineAnnealingLR。
CyclicLR 则是更一般化的循环调度,可以设置多个上升下降周期。它的参数更多,使用频率不如 OneCycleLR 高,但如果你在写论文需要对比不同循环策略,它是一个很好的工具箱。使用这两类调度器时要特别注意:total_steps 必须准确等于训练步数,一旦你中途改了 epochs 或 batch size,学习率曲线就会提前或延后结束,影响训练结果。
4. 训练循环里的接线细节:step 位置、恢复与并行
4.1 最小可用代码:ResNet-18 + CosineAnnealingLR
说了这么多公式和 API,直接上一段可以跑通的代码骨架。假设你已经有一个 train_loader 和 val_loader,网络是 ResNet-18 分类模型:
python复制import torch
import torchvision.models as models
import torch.optim as optim
from torch.optim.lr_scheduler import CosineAnnealingLR
model = models.resnet18(num_classes=10)
optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4)
scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=0)
for epoch in range(50):
model.train()
for x, y in train_loader:
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
model.eval()
val_loss = 0
with torch.no_grad():
for x, y in val_loader:
val_loss += criterion(model(x), y).item()
val_loss /= len(val_loader)
scheduler.step()
current_lr = optimizer.param_groups[0]['lr']
print(f"epoch={epoch} lr={current_lr:.6f} val_loss={val_loss:.4f}")
这是最常见的 CosineAnnealingLR + 按 epoch 更新的写法。T_max 必须和你的总训练 epoch 数一致,否则学习率不会在最后一个 epoch 降到你想要的 eta_min。eta_min 我习惯设成 0 或初始学习率的千分之一,不建议设得太大,否则后期精细搜索的能力会被削弱。
4.2 scheduler.step() 到底放哪:一个顺序问题引发的事故
很多初学者会问:scheduler.step() 应该放在 optimizer.step() 之前还是之后?
PyTorch 官方推荐的是:先 optimizer.step() 更新参数,再 scheduler.step() 更新学习率。原因是调度器在 step() 时会读取 optimizer.param_groups 里的当前 lr,然后计算出下一步的学习率。如果你在 optimizer.step() 之前调用 scheduler.step(),那么这一轮训练实际使用的是"已经被更新过的新学习率",严格来说和传统定义差了一个 step。
对于 ReduceLROnPlateau,scheduler.step(val_loss) 必须放在你计算完验证指标之后,因为它要根据指标决定是否降低学习率。对于 OneCycleLR 和 CyclicLR,它们按 batch 更新,需要放在每个 batch 的 optimizer.step() 之后。如果你在写复杂训练逻辑,务必把每类调度器的 step 时机分开考虑。
我在早期做实验时,曾经把 scheduler.step() 放到了 optimizer.step() 之前,结果训练曲线前几个 epoch 看起来没问题,到后期学习率不断提前衰减,最终精度比正常情况低了两个点。这种问题很难通过代码审查察觉,最好的办法就是把学习率打出来,确认每一轮实际使用的 lr 是否符合预期。
4.3 checkpoint 恢复时调度器状态必须一起保存
长训练任务免不了要断点续跑。大多数人保存 checkpoint 时都知道要存 model.state_dict() 和 optimizer.state_dict(),但经常忘记存 scheduler.state_dict()。
正确做法是:
python复制torch.save({
'model': model.state_dict(),
'optimizer': optimizer.state_dict(),
'scheduler': scheduler.state_dict(),
'epoch': epoch,
}, 'checkpoint.pt')
恢复时:
python复制checkpoint = torch.load('checkpoint.pt')
model.load_state_dict(checkpoint['model'])
optimizer.load_state_dict(checkpoint['optimizer'])
scheduler.load_state_dict(checkpoint['scheduler'])
start_epoch = checkpoint['epoch'] + 1
如果不恢复调度器状态,学习率会从头开始。举个例子:假设你训练到第 40 个 epoch,学习率已经被余弦退火降到了 0.001 附近,保存后中断。恢复时如果忘了 scheduler.load_state_dict,新的调度器会把学习率重新置为初始的 0.1,模型的 loss 会瞬间暴涨,之前的训练基本白费。我在多次长训任务里吃过这个亏,后来在保存逻辑里强制要求 model、optimizer、scheduler 三者必须同时出现,缺一不可。
4.4 多 GPU 与 AMP 混合精度下的调度器注意事项
多卡训练时,使用 DistributedDataParallel 的话,调度器只需要在 rank 0 进程上创建并 step(),然后通过模型同步机制保证所有进程使用相同的参数和梯度。如果每个进程各自创建一个相同配置的调度器,结果通常也是相同的,但为了日志不乱,建议只在主进程打印学习率。
混合精度训练(AMP)本质上不改变调度器逻辑,因为 GradScaler 只负责缩放梯度,优化器和调度器的调用顺序不变。唯一需要注意的是:按 batch 更新的调度器(OneCycleLR)仍然是在 scaler.step(optimizer) 之后调用 scheduler.step()。另外,AMP 常常配合更大的 batch size 使用,如果 batch size 变大导致 dataloader 长度变化,记得同步更新 total_steps,否则学习率曲线对不上。
还有一个偏冷门但实用的细节:有些分布式训练框架会在内部包装优化器(例如 apex 的 FusedAdam),包装后的 optimizer 可能需要重新绑定调度器。遇到这种情况,优先看框架文档中调度器是否需要传入原始优化器,还是包装后的优化器。默认原则是:调度器里的 optimizer 和实际执行 step() 的优化器必须是同一个对象。
5. 调度器调试经验:可视化、warmup 断层与场景选择
5.1 不画学习率曲线,等于没有真正调过参
我几乎每个实验都会把 scheduler.get_last_lr() 存下来,训练结束后画出来看一眼。很多问题一眼就能看出,比如 warmup 阶段没有出现、学习率曲线断层、指数衰减太早导致无收敛。
一个最简单的画图框架:
python复制import matplotlib.pyplot as plt
lrs = []
for epoch in range(num_epochs):
# train and eval
scheduler.step()
lrs.append(scheduler.get_last_lr()[0])
plt.plot(lrs)
plt.xlabel('epoch')
plt.ylabel('learning rate')
plt.title('LR curve')
plt.savefig('lr_curve.png')
在这张图里,如果曲线是一条水平直线,说明调度器根本没生效,最常见的嫌疑是忘记调用 scheduler.step();如果在某个 epoch 突然掉到接近 0,说明 warmup 和主调度的衔接有问题;如果曲线形状和预期不符,比如余弦退火还没到最后一个 epoch 就提前归零,说明 T_max 设错了。
5.2 三个高频坑:忘 step、warmup 断层与早停恢复
忘记 scheduler.step()。这个坑初学者经常踩。症状是训练 loss 曲线看着没什么问题,但训练到后期一直不收敛,验证精度卡在某个值上不去。排查方法就是画出学习率曲线,发现是一条直线,然后到代码里一看,训练循环里压根没写 scheduler.step()。
warmup 和主调度衔接断层。如果你把 warmup 和余弦退火拆成两个调度器,或者写在两个不同的回调里,很容易出现断层。比如 warmup 结束的那一刻,LambdaLR 返回 1.0,然后下一个 epoch 直接切到 CosineAnnealingLR,由于 CosineAnnealingLR 在 T_cur=0 时的学习率等于 eta_max,看起来好像没问题。但如果你把 warmup 函数返回到 0.1 而不是 1.0,再切到余弦退火时学习率就会从 0.1 倍的初始值附近重新涨回初始值,形成一个明显的尖峰。正确做法是把 warmup 和主调度写在一个 lr_lambda 函数里,保证在临界点连续。
早停后重启的调度器状态。如果用了 ReduceLROnPlateau 配合早停,从 checkpoint 恢复时不仅要恢复 scheduler.state_dict(),还要注意 scheduler.best 和 scheduler.num_bad_epochs 这些内部变量是否被正确恢复。如果你在恢复之前已经跑出了一部分验证记录,但 scheduler 里的 best 还是旧值,它会基于旧的最佳指标继续判断,可能导致学习率过早下降。稳妥做法是恢复后立即打印 scheduler.best,确认它和最近验证日志一致。
5.3 时间序列与股票预测任务的调度器选择
近年来很多读者会用 PyTorch 做 TCN+Transformer 这类时间序列预测,甚至套用到股票数据上。这种任务和图像分类有很大差异:训练数据通常是非平稳的,loss 曲线波动大,模型很容易过拟合。我的经验是不要用太激进的调度器。
如果数据量不大,StepLR 或 ReduceLROnPlateau 比带热重启的 CosineAnnealingWarmRestarts 更可控。原因很简单:热重启会把模型反复从"快收敛"的状态踢出去,在非平稳任务里容易让验证 loss 持续波动,很难判断模型到底是收敛了还是在随机游走。如果一定要用余弦退火,可以把 eta_min 设得稍高一点,比如初始学习率的 0.01 倍,避免后期在噪声主导的梯度上过度拟合到训练集的细节。
另外,时间序列预测任务中验证集往往比较小,ReduceLROnPlateau 的 patience 需要设置得比图像任务更大。我通常从 patience=15 起步,因为验证指标抖动一两个 epoch 太常见了,设太小会误降学习率。
5.4 我的最终选择习惯:从曲线反推调度器
总结几个原则,可以直接当作决策参考:
- 不确定选什么时,先用
CosineAnnealingLR,设置T_max等于训练总轮数,这是大多数场景的"安全牌"。 - 训练阶段有明确节点(比如迁移学习先训主干再训全连接),用
MultiStepLR配合验证 loss 曲线,在平台期前后设置里程碑。 - 训练过程波动大、无法预判阶段,用
ReduceLROnPlateau,但patience要足够大。 - 训练步数固定、预算有限,想尽可能提高精度,用
OneCycleLR,但要确保total_steps准确。 - 复现论文或自定义策略,直接写
LambdaLR函数,不要绕道去改优化器。
调度器这东西,听起来只是训练代码里的一行,但真正决定结果的却是曲线形状和训练阶段的配合。上面这些经验,基本是我从早期盲调到后来稳定复现之间的全部总结。你可以在自己的数据集上多画几条学习率曲线对比一下,训练逻辑没有变化,只是调度策略换一换,最终指标很可能完全不是一个档次。
