PyTorch学习率调度实战:从StepLR到余弦退火与热重启 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.1step_size30gamma0.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(), lr0.1, momentum0.9, weight_decay1e-4) scheduler StepLR(optimizer, step_size30, gamma0.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], gamma0.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(), lr1e-3) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience5, verboseTrue) 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但验证损失其实一直在缓慢下降。如果你监控准确率patience5 的话学习率早就被降了但如果你监控损失调度器会看到损失还在降继续维持当前学习率让模型充分收敛。这个差异在训练后期尤其明显。当然也有例外。如果你的任务指标本身就是离散的比如某些生成任务的 BLEU 分数那监控这个指标也无可厚非但建议把 patience 设大一些给模型足够的观察窗口。4.3 一个真实项目中的调参记录我在一个多分类项目里用过 ReduceLROnPlateau初始学习率 1e-3factor0.5patience7。训练到第 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(), lr0.1, momentum0.9) scheduler CosineAnnealingLR(optimizer, T_max100, eta_min1e-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_max100 但你只训练了 50 轮就停了那学习率只降到了余弦曲线的一半位置没有充分衰减。反过来如果训练超过了 T_maxPyTorch 会让学习率在 eta_min 附近震荡这通常不是你想要的。所以用 CosineAnnealingLR 时训练轮数最好提前确定好。5.2 CosineAnnealingWarmRestarts 的热重启机制热重启warm restarts是对余弦退火的一个改进学习率不是一路降到底而是周期性地重启回初始值然后再降。这个思路来自 SGDR 论文目的是让模型在陷入局部最优时有机会跳出来。from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts scheduler CosineAnnealingWarmRestarts(optimizer, T_010, T_mult2, eta_min1e-5)T_0 是第一个重启周期的长度T_mult 是周期倍增系数。如果 T_mult2那么第一个周期 10 轮第二个周期 20 轮第三个 40 轮以此类推。这种设计让重启间隔越来越长前期频繁重启帮助模型跳出局部最优后期周期变长让模型有足够时间精细收敛。热重启在图像分类任务上效果尤其好我在一个细粒度分类项目里用 CosineAnnealingWarmRestarts 比用普通 CosineAnnealingLR 的验证准确率高了大约 1.5 个百分点。但它的代价是训练时间更长因为每次重启后模型都需要重新收敛。所以如果你的计算资源有限要权衡一下是否值得。5.3 OneCycleLR 与超收敛现象OneCycleLR 是另一个值得单独提的调度器。它的策略是学习率先从较低值线性上升到最大值然后再线性下降到很低的值。配合较大的最大学习率和动量变化可以在较少的 epoch 内达到很好的收敛效果这就是所谓的超收敛。from torch.optim.lr_scheduler import OneCycleLR optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9) scheduler OneCycleLR(optimizer, max_lr0.5, total_stepslen(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轮CosineAnnealingLR0.01~0.1T_max总轮数eta_min1e-5大规模图像分类100轮CosineAnnealingWarmRestarts0.1T_010T_mult2文本分类/序列标注ReduceLROnPlateau1e-3modeminpatience5factor0.5目标检测MultiStepLR0.01milestones[60, 80]gamma0.1快速原型验证OneCycleLR0.1max_lr0.5total_steps总步数不确定训练轮数ReduceLROnPlateau1e-3modeminpatience7这张表不是金科玉律但可以作为一个起点。实际项目中我通常会先用 ReduceLROnPlateau 跑一轮观察学习率在哪些位置衰减了然后根据这个信息决定是否换成固定节奏的调度器来复现和优化。还有一个经常被忽略的点是学习率调度和优化器类型是耦合的。SGD 对学习率变化比较敏感适合配合余弦退火或 StepLRAdam 本身有自适应学习率机制对外部调度的依赖相对小一些但配合 ReduceLROnPlateau 仍然有效。如果你用 Adam 还加很激进的衰减可能会导致学习率降得过低模型提前停止学习。另外batch size 也会影响学习率的选择。一般来说batch size 翻倍初始学习率也可以相应增大但这不是线性的。有研究表明batch size 增大到一定程度后学习率的增益会递减。所以不要简单地按比例缩放最好做几次小规模实验来确定。8. 我在实际项目中总结的几条经验说了这么多理论和方法最后分享几条我在实际项目中摸爬滚打总结出来的经验都是文档里不会写的。第一条先用小规模实验确定调度策略再上全量数据。很多人一上来就用全量数据跑结果发现学习率策略不合适又要重跑浪费大量时间。我的做法是先用 10% 的数据跑 20 个 epoch快速对比几种调度器的效果选出最有希望的两种再上全量数据做精细对比。这样能把调参时间压缩到原来的三分之一。第二条学习率曲线要可视化。不要只看最终指标把每个 epoch 的学习率打印出来或者画成曲线你会对调度器的行为有更直观的理解。我习惯在训练循环里加一行print(fEpoch {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_lambdawarmup_lambda)这段代码实现了一个 5 轮的线性 warmup之后学习率保持不变。你可以把它和后面的调度器串联使用先跑 warmup再切换到主调度器。第五条记录每次实验的完整配置。学习率调度涉及多个参数如果不记录清楚过几天回头看实验日志根本想不起来当时用的是什么配置。我习惯用配置文件或者命令行参数把所有超参数都记下来包括调度器类型、初始学习率、衰减参数、warmup 轮数等。这个习惯在需要复现结果或者写论文时特别重要。学习率调整这件事说复杂也复杂十几种调度器各有各的参数和适用场景说简单也简单核心就是让步长随着训练进程合理变化。关键是要理解每种策略背后的逻辑然后结合自己的任务特点去选择和调整。希望这些内容能帮你在下一个项目里少走一些弯路。