1. 为什么学习率调整是训练成败的隐形分水岭
很多人第一次跑通一个神经网络之后,最直观的感受是"模型能跑了",但真正决定这个模型能不能收敛到理想效果、能不能在验证集上稳定发挥的,往往不是网络结构有多花哨,而是学习率这个看似不起眼的超参数。我在早期做图像分类项目时踩过一个大坑:同样的网络、同样的数据集,一组用固定学习率训练到死,另一组加了余弦退火,结果验证集准确率差了将近六个百分点。当时排查了很久,以为是数据增强或者权重初始化的问题,最后才发现罪魁祸首就是学习率从头到尾没变过。
学习率本质上控制的是参数更新的步长。步长太大,损失函数会在最优点附近来回震荡甚至发散;步长太小,训练慢得像蜗牛爬,还容易卡在局部极小值或者鞍点出不来。而"调整学习率"这件事,核心思路就是让步长在训练过程中动态变化——前期大步快跑快速下降,后期小步微调精细收敛。PyTorch 在这方面提供了相当完整的工具链,从最基础的等间隔衰减到近几年流行的余弦退火、热重启,基本覆盖了主流需求。
这篇文章面向的是已经能用 PyTorch 搭网络、跑训练循环,但对学习率调度策略还停留在"随便设个 0.01 就不管了"阶段的读者。我会把 PyTorch 中调整学习率的几种主流方式拆开讲清楚,包括它们各自适合什么场景、参数怎么设、有哪些容易踩的坑,以及我在实际项目中总结出来的一些经验。不管你是做计算机视觉、自然语言处理还是推荐系统,只要涉及模型训练,这些内容都用得上。
2. PyTorch 学习率调度的两条技术路线
在动手写代码之前,有必要先把 PyTorch 里调整学习率的整体框架理清楚。很多人一开始会混淆"优化器里的学习率"和"调度器里的学习率"到底谁说了算,这里先把关系说明白。
2.1 优化器与调度器的职责划分
在 PyTorch 中,学习率最终是挂在优化器(optimizer)的 param_group 上的。你创建优化器时传入的 lr 参数,就是初始学习率。而调度器(lr_scheduler)本身并不直接修改模型的参数,它做的事情是在每个 epoch 或每个 step 结束后,按照某种规则去改写优化器里 param_group 的 lr 值。
这个设计的好处是解耦:优化器负责"怎么更新参数",调度器负责"用多大的步长更新"。你可以随时替换调度策略而不影响优化器本身的逻辑。但要注意一个关键点——调度器的 step 调用时机非常讲究,调用早了或晚了,学习率曲线就会和你预期的不一样,后面我会专门讲这个问题。
2.2 三类主流调度方式的定位差异
PyTorch 的 torch.optim.lr_scheduler 模块里提供了十几种调度器,但按行为特征大致可以归为三类。
第一类是基于步数或轮数的固定衰减,代表是 StepLR、MultiStepLR、ExponentialLR。这类调度器的特点是学习率变化曲线是预先确定好的,跟模型训练状态无关。你设好衰减间隔和衰减系数,它就按部就班地降。适合训练轮数固定、对收敛节奏有明确预期的场景。
第二类是基于指标反馈的自适应衰减,代表是 ReduceLROnPlateau。它不按固定节奏走,而是盯着某个指标(比如验证集损失),当这个指标连续若干轮不再改善时,才把学习率降下来。适合你不确定该什么时候衰减、希望让模型"自己决定"的场景。
第三类是周期性或连续变化的调度,代表是 CosineAnnealingLR、CosineAnnealingWarmRestarts、OneCycleLR。这类调度器让学习率按照余弦曲线或其他连续函数变化,有的还带周期性重启。适合追求极致收敛效果、愿意花时间调参的场景,在竞赛和论文复现中非常常见。
理解这三类的定位差异,比死记每个调度器的参数更重要。选型的时候先问自己:我的训练轮数确定吗?我能不能拿到可靠的验证指标?我追求的是稳定复现还是极限性能?答案不同,选择就不同。
3. 固定节奏衰减:StepLR 与 MultiStepLR 的实战用法
固定节奏衰减是最容易上手的一类,也是很多教程里第一个介绍的。但"容易上手"不等于"随便设就行",里面的参数选择其实有不少讲究。
3.1 StepLR 的参数含义与典型配置
StepLR 的逻辑非常直白:每隔 step_size 个 epoch,学习率乘以 gamma。比如初始学习率 0.1,step_size=30,gamma=0.1,那么第 30 轮之后变成 0.01,第 60 轮之后变成 0.001,以此类推。
import torch import torch.optim as optim from torch.optim.lr_scheduler import StepLR model = ... # 你的模型 optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4) scheduler = StepLR(optimizer, step_size=30, gamma=0.1) for epoch in range(100): train_one_epoch(model, optimizer, train_loader) validate(model, val_loader) scheduler.step()这段代码看起来简单,但有几个细节值得展开说。首先是 step_size 的选择,它应该和你的总训练轮数挂钩。一个经验法则是:如果你打算训练 100 轮,step_size 设在总轮数的 1/3 到 1/4 之间比较合理,这样学习率大概衰减两到三次。如果 step_size 设得太小,学习率降得太频繁,模型还没充分探索就被压死了;设得太大,又起不到衰减的作用。
gamma 一般取 0.1 或 0.5。取 0.1 意味着每次衰减一个数量级,比较激进,适合训练初期损失下降很快的情况;取 0.5 则温和一些,适合需要精细收敛的任务。我在做文本分类时更倾向用 0.5,因为文本任务的损失曲面往往比图像任务更复杂,降太快容易错过好的区域。
3.2 MultiStepLR 的里程碑式衰减
MultiStepLR 是 StepLR 的升级版,它不要求等间隔衰减,而是让你指定在哪些 epoch 进行衰减。比如 milestones=[30, 60, 90],就是在第 30、60、90 轮各衰减一次。
from torch.optim.lr_scheduler import MultiStepLR scheduler = MultiStepLR(optimizer, milestones=[30, 60, 90], gamma=0.1)这个调度器在实际项目中用得非常多,因为它允许你根据训练曲线的实际表现来安排衰减点。比如你观察到模型在第 25 轮左右验证损失开始 plateau,那就可以把第一个 milestone 设在 30,给它几轮的缓冲。相比 StepLR 的机械等间隔,MultiStepLR 更灵活。
但这里有个容易忽略的坑:milestones 必须是有序递增的列表,而且不能有重复值。我曾经因为从配置文件里读出来的 milestones 顺序乱了,导致调度器行为异常,排查了半天才发现是列表没排序。另外,milestones 里的值如果超过了总训练轮数,那些衰减就永远不会触发,等于白设。
3.3 固定衰减的适用边界与局限
固定节奏衰减最大的优势是可预测、易复现。你跑两次实验,只要随机种子一样,学习率曲线就完全一致,这对做对比实验很重要。但它的局限也很明显:衰减时机是人为拍脑袋定的,跟模型实际训练状态无关。如果模型收敛比预期快,你可能在它已经收敛之后才衰减,浪费了训练资源;如果收敛比预期慢,衰减又来得太早,把还在下降的损失硬生生压住了。
所以我的建议是,固定衰减适合作为 baseline 或者训练轮数非常确定的场景。如果你在探索阶段,不确定模型需要训练多久,那最好用后面要讲的自适应方法。
4. 让模型自己决定何时减速:ReduceLROnPlateau 深度拆解
如果说固定衰减是"按时刻表开车",那 ReduceLROnPlateau 就是"看路况开车"。它不预设衰减时间点,而是监控一个指标,当指标不再改善时才减速。这个思路在实战中非常实用,因为你不需要提前知道该在第几轮衰减。
4.1 核心参数 mode、patience、factor 的配合逻辑
ReduceLROnPlateau 的几个关键参数需要配合理解。
- mode:取 'min' 还是 'max'。如果你监控的是验证集损失,用 'min',因为损失越小越好;如果监控的是准确率,用 'max'。这个参数设反了,调度器会在指标变好的时候反而衰减学习率,完全南辕北辙。
- factor:学习率衰减系数,新学习率 = 旧学习率 × factor。一般取 0.1 或 0.5。
- patience:容忍多少个 epoch 指标不改善之后才衰减。这个参数很关键,设太小会导致学习率频繁下降,模型还没缓过来就被压住了;设太大又起不到及时衰减的作用。通常取 5 到 10 比较合理。
- threshold:判断"有没有改善"的阈值,默认是 1e-4。只有改善幅度超过这个值才算改善,避免指标在微小波动时误判。
from torch.optim.lr_scheduler import ReduceLROnPlateau optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5, verbose=True) for epoch in range(100): train_loss = train_one_epoch(model, optimizer, train_loader) val_loss = validate(model, val_loader) scheduler.step(val_loss) # 注意这里要传入监控的指标注意最后一行,ReduceLROnPlateau 的 step 方法必须传入指标值,这是它和其他调度器最大的区别。如果你忘了传参,代码会直接报错。
4.2 监控指标的选择:验证损失还是验证准确率
这是一个经常被讨论的问题。我的经验是:优先监控验证损失,而不是准确率。原因是损失是连续值,对模型状态的细微变化更敏感;而准确率是离散的,可能在很长一段时间内保持不变,导致调度器误以为模型没有进步而提前衰减。
举个例子,假设你的模型准确率在 0.85 附近徘徊了 8 个 epoch,但验证损失其实一直在缓慢下降。如果你监控准确率,patience=5 的话,学习率早就被降了;但如果你监控损失,调度器会看到损失还在降,继续维持当前学习率,让模型充分收敛。这个差异在训练后期尤其明显。
当然也有例外。如果你的任务指标本身就是离散的(比如某些生成任务的 BLEU 分数),那监控这个指标也无可厚非,但建议把 patience 设大一些,给模型足够的观察窗口。
4.3 一个真实项目中的调参记录
我在一个多分类项目里用过 ReduceLROnPlateau,初始学习率 1e-3,factor=0.5,patience=7。训练到第 18 轮时,验证损失从 0.42 降到 0.38 之后开始停滞,调度器在第 25 轮把学习率降到 5e-4,之后验证损失又继续下降到 0.35。第 40 轮左右再次停滞,学习率降到 2.5e-4,最终验证损失收敛在 0.33 附近。
整个过程学习率一共降了两次,从 1e-3 到 2.5e-4。如果换成 StepLR 固定在第 30 轮和第 60 轮衰减,第一次衰减就来得太晚了,模型在第 25 到 30 轮之间其实是在浪费计算资源。这就是自适应衰减的价值所在——它跟着模型的节奏走,而不是让模型跟着你的预设走。
5. 余弦退火与热重启:追求极致收敛的进阶选择
余弦退火是近几年在论文和竞赛中出场率极高的调度策略。它的核心思想是让学习率按照余弦函数的形状从最大值平滑下降到最小值,而不是像 StepLR 那样阶梯式跳变。
5.1 CosineAnnealingLR 的曲线特征与参数设置
CosineAnnealingLR 的公式是:学习率 = eta_min + (base_lr - eta_min) × (1 + cos(π × current_epoch / T_max)) / 2。当 current_epoch 从 0 增加到 T_max 时,学习率从 base_lr 平滑下降到 eta_min。
from torch.optim.lr_scheduler import CosineAnnealingLR optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9) scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-5) for epoch in range(100): train_one_epoch(model, optimizer, train_loader) validate(model, val_loader) scheduler.step()T_max 通常设为你计划训练的总轮数,eta_min 是最小学习率,一般设 1e-5 或 1e-6。余弦退火的好处是学习率在训练中期下降得比较慢,给模型充分探索的时间;在训练后期下降得快,帮助模型快速收敛到局部最优。
但这里有个细节:如果你设的 T_max 和实际训练轮数不一致,比如 T_max=100 但你只训练了 50 轮就停了,那学习率只降到了余弦曲线的一半位置,没有充分衰减。反过来,如果训练超过了 T_max,PyTorch 会让学习率在 eta_min 附近震荡,这通常不是你想要的。所以用 CosineAnnealingLR 时,训练轮数最好提前确定好。
5.2 CosineAnnealingWarmRestarts 的热重启机制
热重启(warm restarts)是对余弦退火的一个改进:学习率不是一路降到底,而是周期性地"重启"回初始值,然后再降。这个思路来自 SGDR 论文,目的是让模型在陷入局部最优时有机会跳出来。
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts scheduler = CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2, eta_min=1e-5)T_0 是第一个重启周期的长度,T_mult 是周期倍增系数。如果 T_mult=2,那么第一个周期 10 轮,第二个周期 20 轮,第三个 40 轮,以此类推。这种设计让重启间隔越来越长,前期频繁重启帮助模型跳出局部最优,后期周期变长让模型有足够时间精细收敛。
热重启在图像分类任务上效果尤其好,我在一个细粒度分类项目里用 CosineAnnealingWarmRestarts 比用普通 CosineAnnealingLR 的验证准确率高了大约 1.5 个百分点。但它的代价是训练时间更长,因为每次重启后模型都需要重新收敛。所以如果你的计算资源有限,要权衡一下是否值得。
5.3 OneCycleLR 与超收敛现象
OneCycleLR 是另一个值得单独提的调度器。它的策略是:学习率先从较低值线性上升到最大值,然后再线性下降到很低的值。配合较大的最大学习率和动量变化,可以在较少的 epoch 内达到很好的收敛效果,这就是所谓的"超收敛"。
from torch.optim.lr_scheduler import OneCycleLR optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9) scheduler = OneCycleLR(optimizer, max_lr=0.5, total_steps=len(train_loader)*50) for epoch in range(50): for batch in train_loader: train_step(model, optimizer, batch) scheduler.step() # 注意 OneCycleLR 是每个 step 调用OneCycleLR 的一个关键点是它的 step 是每个 batch 调用的,而不是每个 epoch。因为它的整个周期是按总步数来算的。如果你按 epoch 调用,学习率曲线就完全不对了。这个坑我踩过,当时训练了 50 个 epoch 发现学习率几乎没怎么变,排查后才发现是 step 调用频率搞错了。
6. 调度器 step 调用时机的那些坑
前面几节分别讲了不同调度器的用法,但有一个贯穿所有调度器的共性问题必须单独拎出来讲:step 到底该在什么时候调用。这个问题看似简单,实际上是最容易出错的地方。
6.1 epoch 级调度与 step 级调度的区别
PyTorch 的调度器分两种调用粒度。大多数调度器(StepLR、MultiStepLR、CosineAnnealingLR 等)是 epoch 级的,也就是每个 epoch 结束后调用一次 scheduler.step()。而 OneCycleLR 和部分自定义调度器是 step 级的,每个 batch 结束后就要调用。
如果你把 epoch 级的调度器按 step 级调用,学习率会下降得飞快,模型根本来不及训练。反过来,把 step 级的调度器按 epoch 级调用,学习率几乎不变,等于没加调度。这两种错误我都犯过,而且因为训练不会报错,只是效果差,排查起来特别费劲。
一个简单的判断方法是看调度器的文档说明,或者看它的 step 方法签名。如果 step 方法不需要参数,通常是 epoch 级的;如果需要传入指标(如 ReduceLROnPlateau),那调用时机取决于你多久算一次指标。
6.2 PyTorch 1.1 之后 scheduler.step 位置的变化
在 PyTorch 1.1 之前,scheduler.step() 必须在 optimizer.step() 之前调用。1.1 之后这个顺序反过来了,推荐先调用 optimizer.step(),再调用 scheduler.step()。如果你用的是新版 PyTorch 但按老顺序写,会看到一个警告,而且学习率曲线可能不符合预期。
正确的顺序是这样的:
for epoch in range(total_epochs): for batch in train_loader: optimizer.zero_grad() loss = model(batch) loss.backward() optimizer.step() scheduler.step() # 在 epoch 结束后调用这个顺序变化的原因是,新版调度器在计算学习率时会考虑优化器已经完成的更新步数,顺序反了会导致步数计数偏差。
6.3 恢复训练时调度器状态的保存与加载
做深度学习项目时,训练中断是常有的事。如果你用了学习率调度器,恢复训练时不仅要加载模型参数和优化器状态,还要加载调度器的状态,否则学习率会从头开始,导致训练曲线出现异常跳变。
# 保存 torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict(), }, 'checkpoint.pth') # 加载 checkpoint = torch.load('checkpoint.pth') model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) scheduler.load_state_dict(checkpoint['scheduler_state_dict']) start_epoch = checkpoint['epoch'] + 1调度器的 state_dict 里保存了当前的 epoch 计数、上次衰减的位置等信息。加载之后,调度器就能从正确的位置继续工作。这个细节很多人会忽略,直到发现恢复训练后学习率突然变大了才追悔莫及。
7. 不同任务场景下的调度策略选型建议
讲了这么多调度器,最后落到实际选型上。不同的任务类型、不同的训练规模,适合的策略是不一样的。下面这张表是我根据自己经验总结的对照关系,供参考。
| 任务场景 | 推荐调度器 | 初始学习率 | 关键参数建议 |
|---|---|---|---|
| 小规模图像分类(<50轮) | CosineAnnealingLR | 0.01~0.1 | T_max=总轮数,eta_min=1e-5 |
| 大规模图像分类(>100轮) | CosineAnnealingWarmRestarts | 0.1 | T_0=10,T_mult=2 |
| 文本分类/序列标注 | ReduceLROnPlateau | 1e-3 | mode='min',patience=5,factor=0.5 |
| 目标检测 | MultiStepLR | 0.01 | milestones=[60, 80],gamma=0.1 |
| 快速原型验证 | OneCycleLR | 0.1 | max_lr=0.5,total_steps=总步数 |
| 不确定训练轮数 | ReduceLROnPlateau | 1e-3 | mode='min',patience=7 |
这张表不是金科玉律,但可以作为一个起点。实际项目中,我通常会先用 ReduceLROnPlateau 跑一轮,观察学习率在哪些位置衰减了,然后根据这个信息决定是否换成固定节奏的调度器来复现和优化。
还有一个经常被忽略的点是:学习率调度和优化器类型是耦合的。SGD 对学习率变化比较敏感,适合配合余弦退火或 StepLR;Adam 本身有自适应学习率机制,对外部调度的依赖相对小一些,但配合 ReduceLROnPlateau 仍然有效。如果你用 Adam 还加很激进的衰减,可能会导致学习率降得过低,模型提前停止学习。
另外,batch size 也会影响学习率的选择。一般来说,batch size 翻倍,初始学习率也可以相应增大,但这不是线性的。有研究表明,batch size 增大到一定程度后,学习率的增益会递减。所以不要简单地按比例缩放,最好做几次小规模实验来确定。
8. 我在实际项目中总结的几条经验
说了这么多理论和方法,最后分享几条我在实际项目中摸爬滚打总结出来的经验,都是文档里不会写的。
第一条:先用小规模实验确定调度策略,再上全量数据。很多人一上来就用全量数据跑,结果发现学习率策略不合适,又要重跑,浪费大量时间。我的做法是先用 10% 的数据跑 20 个 epoch,快速对比几种调度器的效果,选出最有希望的两种,再上全量数据做精细对比。这样能把调参时间压缩到原来的三分之一。
第二条:学习率曲线要可视化。不要只看最终指标,把每个 epoch 的学习率打印出来或者画成曲线,你会对调度器的行为有更直观的理解。我习惯在训练循环里加一行print(f"Epoch {epoch}, LR: {optimizer.param_groups[0]['lr']:.6f}"),简单但非常有用。有一次我发现学习率在第 5 轮就降到了 1e-6,排查后发现是 milestones 设错了,如果没有这行打印,可能训练完都发现不了。
第三条:不要迷信某种调度器"一定更好"。我见过有人在所有项目里都用 CosineAnnealingWarmRestarts,结果在一个小数据集上效果还不如 StepLR。调度器的效果和数据集规模、模型复杂度、训练轮数都有关系,没有万能方案。保持开放心态,多做对比实验。
第四条:warmup 和调度器配合使用效果更好。在训练最开始的前几个 epoch,模型参数是随机初始化的,此时用较大的学习率容易导致训练不稳定。可以在正式调度之前加一个 warmup 阶段,让学习率从很小的值线性上升到初始值,然后再交给调度器接管。PyTorch 本身没有内置 warmup,但可以用 LambdaLR 自己实现,或者用一些第三方库。这个技巧在训练大模型时尤其重要。
from torch.optim.lr_scheduler import LambdaLR def warmup_lambda(epoch): if epoch < 5: return (epoch + 1) / 5 return 1.0 warmup_scheduler = LambdaLR(optimizer, lr_lambda=warmup_lambda)这段代码实现了一个 5 轮的线性 warmup,之后学习率保持不变。你可以把它和后面的调度器串联使用,先跑 warmup,再切换到主调度器。
第五条:记录每次实验的完整配置。学习率调度涉及多个参数,如果不记录清楚,过几天回头看实验日志,根本想不起来当时用的是什么配置。我习惯用配置文件或者命令行参数把所有超参数都记下来,包括调度器类型、初始学习率、衰减参数、warmup 轮数等。这个习惯在需要复现结果或者写论文时特别重要。
学习率调整这件事,说复杂也复杂,十几种调度器各有各的参数和适用场景;说简单也简单,核心就是让步长随着训练进程合理变化。关键是要理解每种策略背后的逻辑,然后结合自己的任务特点去选择和调整。希望这些内容能帮你在下一个项目里少走一些弯路。