
炼丹这行干久了你会发现一个特别扎心的规律同一套模型和数据集别人能跑到 85% 的准确率你费了半天劲只有 83%一开始怀疑模型结构有问题又怀疑数据增强不够折腾一圈下来最后发现只是学习率调度方式不一样。学习率调度器CosineLRScheduler这几年在图像分类、目标检测、NLP 预训练里基本成了标配从 ResNet 的训练脚本到 BERT、CLIP 这类大模型的训练流程都能看到它的身影。简单说它让学习率随训练进程按余弦曲线从初始值平滑衰减到接近零从而让训练后期能更精细地收敛到更优解。这篇内容适合所有正在调模型性能、或者好奇训练脚本里那个 lr_scheduler 到底在做什么的读者我会从原理、代码到实战踩坑一层层讲透。1. 学习率调度到底在解决什么问题1.1 固定学习率的困境学习率是炼丹参数里最基础也最麻烦的一个它决定每次参数更新的步幅。学习率太大loss 会在最小值附近反复震荡甚至直接发散学习率太小前期逼近速度慢到让人怀疑人生训练预算被白白消耗。最麻烦的是一个学习率往往无法同时满足训练前期和后期的需求。拿开车导航打比方从北京去上海高速上你关心的是油门踩多大、速度跑不跑得起来不需要频繁微调方向盘但到了上海城区要在大街小巷里找到具体车位还以 120 公里的时速冲进去结果肯定是撞墙。模型训练也一样早期参数离最优解很远用比较大步幅的探索是有益的后期已经接近最优解区域步幅必须放小否则只能在极小值附近反复横跳永远落不到精度更高的地方。固定学习率本质上是在探索速度和收敛精度之间做个一次性妥协。调大了后期震荡调小了前期太慢这个问题在深层网络里尤其明显。因为越深的网络损失曲面越复杂不同训练阶段的梯度分布差异也更大单一学习率几乎不可能同时适配所有阶段。1.2 余弦退火如何兼顾探索与收敛学习率调度出现的原因就是想把什么时候应该大步探索、什么时候应该小步收敛这件事从人工粗调和运气里解放出来。CosineLRScheduler 的思路非常朴素用一个连续平滑的曲线把学习率从峰值慢慢降到最低。和多段阶梯下降相比余弦曲线的下降速度不是均匀的它在前期衰减很慢中段加速下降接近末尾又变得平缓。这个形态和损失曲面优化过程其实非常契合前期保持较高学习率探索后期在低学习率下慢慢磨相当于在损失曲面上耐心寻找更平坦的更优区域。顺便说一句深度学习的损失曲面上存在大量局部极小值但每个极小值的泛化能力差别很大。最近几年的研究普遍认为落在平坦极小值附近的解在测试集上表现通常更好而低学习率微调阶段恰恰能让参数从陡峭的极小值逐渐滑向更平坦的区域。CosineLRScheduler 构造出的长时间低学习率尾段无形中成了提升泛化的一个低成本手段。这也是为什么大规模预训练任务里大家越来越倾向于用余弦衰减而不是简单阶梯衰减的原因。2. CosineLRScheduler 原理拆解2.1 余弦退火的数学公式如果打开 PyTorch 源码核心公式其实就一行lr_t eta_min 0.5 * (base_lr - eta_min) * (1 cos(pi * t / T_max))其中base_lr初始学习率也就是峰值学习率eta_min最低学习率训练结束时到达的下限t当前已经进行的步数T_max总步数当 t0 时cos(0)1公式变成 base_lr当 tT_max 时cos(pi)-1公式变成 eta_min。中间路径不是线性插值而是沿着余弦曲线平滑过渡。为什么用余弦而不是直接线性下降到零线性下降在训练后期每步带来的学习率减少幅度是均匀的但损失曲面在接近极小值时梯度本身已经变得非常小这时需要的是更细腻的步幅去逼近目标。余弦曲线的好处在于它的导数本身也是一个正弦函数在训练末尾导数趋近于零也就是说学习率的变化率也在逐渐放缓相当于给了模型一个更长的微调窗口。PyTorch 的 CosineAnnealingLR 实现里还有一个细节last_epoch 参数会记录上一个 epoch/step方便恢复训练时能接着衰减。如果你只把它当黑盒调用可能永远发现不了这个小参数但它在断点续训时特别重要。注意这里说的步数可以是 epoch 数也可以是 batch 数关键是要和后续调度器 step() 的调用频率统一。我见过太多人混淆这个单位后面会专门展开讲。2.2 warmup 阶段的作用纯余弦衰减直接从峰值学习率开始训练在大多数任务上并不是最优选择。原因很简单训练最初的几个 step模型参数还处于随机初始化状态梯度方向噪声非常大如果一开始就顶着峰值学习率更新很容易让 loss 在初期冲高或者剧烈震荡后续再想拉回来代价很大。warmup 解决的办法是先用一小段步数把学习率从极小值比如峰值的 1/100 甚至 0线性增长到峰值让模型在小步慢跑中绕开初始不稳定区然后再切换到余弦衰减。这个策略在 batch size 较大的场景下尤其重要因为大 batch 带来的梯度估计更稳定却也更敏感于学习率过高初始阶段尤其容易爆。PyTorch 实现里 torch.optim.lr_scheduler.LinearLR 可以用来做线性 warmup然后用 SequentialLR 把它和 CosineAnnealingLR 串联。注意两个调度器作用的步数必须连续否则会出现学习率跳变。提示warmup 长度一般取总训练步数的 1% 到 10%。并不是越长越好比如 120 epoch 的任务5 个 epoch 的 warmup 已经能获得稳定效果如果你的任务只有 20 个 epoch5 个 epoch 的 warmup 就会吃掉 25% 的训练预算这时候要么缩短 warmup要么适当增加训练轮数。2.3 从一个周期看学习率变化假定你设置 T_max120 epochwarmup 5 epoch峰值学习率 0.01eta_min 设为 0.0001。那么整个训练过程的学习率变化如下训练位置进度 p学习率warmup 结束00.01约 1/4 总进度0.25约 0.0086约 1/2 总进度0.5约 0.005约 3/4 总进度0.75约 0.0014训练结束10.0001前 5 个 epoch学习率从大约 0.0001 线性爬升到 0.01从第 5 个 epoch 开始进入余弦衰减前 20 步左右下降缓慢中间 50 到 80 步下降最快末尾又趋于平缓。这种慢-快-慢的节奏正好对应了训练过程中先用小幅学习率站稳脚跟然后快速探索最后精细收敛的需求。3. 主流学习率调度器对比为什么我最后常选 Cosine3.1 阶梯下降简单但依赖人工设置早期大家最常用的还是 StepLR 或 MultiStepLR每训练 N 个 epoch学习率乘以一个 gamma比如 0.1。这种方法的优点是直觉、可复现缺点是衰减节点完全由人拍脑袋决定。换一个数据集、换一个 batch size甚至换一个模型最优的衰减节点可能完全不同。我见过太多训练脚本把衰减节点定在 epoch 30 和 60结果训练一跑完学习率早在 epoch 30 就掉下来了后面 30 个 epoch 全部在低学习率上干蹭白白浪费了训练预算。如果你对这种人工设定节点很有经验MultiStepLR 可以用但它在大规模训练里维护成本确实高。3.2 自适应调度靠指标但容易误判ReduceLROnPlateau 是另一种思路不预先指定衰减节点而是盯着验证集 loss连续几个 epoch 不下降就降一次学习率。听起来很智能但它有两个问题一是验证集 loss 本身带噪声平台期和噪声经常分不清导致误降二是它依赖验证集评估每次评估都要把整个验证集跑一遍在大规模数据集上成本不低。在小数据集上快速验证想法ReduceLROnPlateau 还能用但到大模型训练或者工业级数据集上这个调度的稳定性和可预测性都比较差。尤其当你同时在跑早停策略时两个机制很容易互相打架。3.3 余弦系列平滑衰减Cosine 系只需要两个关键参数——训练总步数和最低学习率——就能给出一条平滑、连续、几乎不依赖人工判断的衰减曲线。你不用再纠结应该在 epoch 30 还是 40 衰减只需要预估总训练步数。对从业者来说省掉的是一大堆 fallback 判断的成本。余弦家族里还有一个变体叫 CosineAnnealingWarmRestarts引入了热重启机制学习率周期性弹回峰值再余弦下降。它在周期性、非平稳任务里效果不错但在常规分类、检测任务里热重启往往会造成验证指标突跳导致模型选择变得麻烦。日常任务里我更常用 vanilla cosine 加 warmup 的组合简单稳定。3.4 一张表看调度器的区别调度器需要指定的关键参数是否需要验证指标对总训练步数依赖适用场景StepLR / MultiStepLRstep_size, gamma否弱比较简单、结构固定的训练CosineAnnealingLRT_max, eta_min否强稳定训练、预训练、竞赛CosineAnnealingWarmRestartsT_0, T_mult否中周期性任务、超参搜索ReduceLROnPlateaufactor, patience是弱小验证集、快速验证想法从我实际项目经验看CosineAnnealingLR 加 warmup 是性价比最高的组合超参数少、曲线稳定、可复现性好而且不容易因为衰减节点猜错导致整个训练白跑。4. 手把手实现 CosineLRScheduler4.1 PyTorch 内置版本PyTorch 里用起来非常简单import torch from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR optimizer AdamW(model.parameters(), lr1e-3, weight_decay5e-2) scheduler CosineAnnealingLR(optimizer, T_max100, eta_min1e-5)训练循环里每个 epoch 结束后调用一下 scheduler.step() 即可。但这有个前提T_max100 指的是调用 step() 的次数。如果你每个 epoch 调一次T_max 就是 epoch 数如果你每个 step 调一次T_max 就要设成总 batch 数。很多人踩坑就踩在这里。4.2 带 warmup 的余弦调度器推荐先组一个带 warmup 的版本因为纯余弦衰减在多数任务上不是最优from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR T_total 120 # 总 epoch 数 warmup_epochs 5 # warmup 的 epoch 数 warmup_sched LinearLR( optimizer, start_factor0.01, end_factor1.0, total_iterswarmup_epochs ) cos_sched CosineAnnealingLR( optimizer, T_maxT_total - warmup_epochs, eta_min1e-5 ) scheduler SequentialLR( optimizer, schedulers[warmup_sched, cos_sched], milestones[warmup_epochs] )这里的关键是 SequentialLR 的 milestones 参数它表示第一个调度器在第几个 step/epoch 结束、第二个调度器开始接管。所有调度器共享同一个优化器PyTorch 内部会记录每个 step 时该用哪一个。如果你的训练脚本每个 stepbatch调一次 scheduler.step()就把 warmup_epochs 和 T_total 换算成对应的总步数并把 T_max 设成 T_total_steps - warmup_steps。4.3 自己实现一个通用调度函数有些场景不在 PyTorch 里比如 JAX、TensorFlow 或者完全自定义的训练循环那就自己实现一个调度函数import math def cosine_lr(step, total_steps, warmup_steps, peak_lr, min_lr): if step warmup_steps: return peak_lr * (step 1) / warmup_steps progress (step - warmup_steps) / max(1, total_steps - warmup_steps) return min_lr 0.5 * (peak_lr - min_lr) * (1 math.cos(math.pi * progress))有了这个函数你在任何框架里都能算当前步数对应的学习率。注意我把 warmup 段的分子写成 step 1是为了让第 0 步不是 0 而是一个很小的正值这个细节在一些框架里能避免除数为 0 或者打印学习率为 0 的尴尬。4.4 scheduler.step() 的时机问题PyTorch 老版本里很多人会在每个 batch 之后调用 scheduler.step()现在官方文档更建议在 epoch 级别调用。如果你用 CosineAnnealingLR总步数不多时按 epoch 调即可如果训练过程中在使用累积梯度或混合精度务必统一计算步数否则学习率衰减速度和预期会差很多。注意在保存 checkpoint 时务必同步保存 scheduler.state_dict()并在恢复时 load_state_dict(scheduler.state_dict())否则恢复训练后会从初始学习率重新开始前面的衰减等于白跑。5. 实战案例与训练效果观察5.1 一个 CIFAR-10 实验为了直观展示 Cosine 调度的实际效果我拿 ResNet18 在 CIFAR-10 上跑了一个简单对照。超参数沿用常见配置SGD、momentum0.9、weight_decay5e-4、batch size128总计 200 epoch。方案 A固定学习率 lr0.05方案 BMultiStepLR在 epoch 100 和 150 分别乘 0.1方案 Cwarmup 5 epoch CosineAnnealingLR峰值学习率仍然 0.05三个方案唯一区别是学习率调度方式其他完全一致。5.2 不同调度策略的实测对比最终测试集准确率这类实验有随机性但趋势是稳定的固定学习率 0.05训练前期 loss 下降快但中后期在 88% 附近反复震荡很难继续突破。MultiStepLR0.05 到 0.005 再到 0.0005大概能到 88.5% 到 89%但衰减节点必须调准如果衰减太早后期基本没有提升。Cosine warmup一般能到 89% 以上最终收敛更平滑几乎不用手动干预。为什么固定学习率在 CIFAR-10 上也能不错因为任务相对简单但只要任务复杂度上升固定学习率后期的震荡就会被放大。在 ImageNet 或更大规模数据集上cosine 调度相对阶梯调度的优势会更明显。我自己的观察是在中小规模任务里cosine 能带来 0.3 到 1 个点的提升在大规模预训练里提升有时超过 1 个点。5.3 如何直观地阅读训练曲线训练时建议把学习率本身也 log 到 TensorBoard 之类的工具里方便确认调度是否生效。看 loss 曲线时我的经验是分阶段看前期 warmup 阶段loss 快速下降但可能伴随小波动这正常。中期余弦中段loss 稳步下降随着学习率衰减加快梯度更新步幅变小loss 下降速度也会放缓。后期低学习率阶段loss 可能进入平台期但验证集准确率往往还在缓慢上升值得继续等到最后阶段再做 checkpoint 选择。如果最终验证指标在训练末尾还在上升意味着学习率衰减得还不够快或者总步数不够可以适当增加训练轮数。如果最后很多 epoch 验证指标纹丝不动可能低学习率阶段太长浪费了预算。5.4 warmup、峰值学习率与 eta_min 的选择经验峰值学习率是最难确定的参数。我的建议是先用 learning rate finder或者参考同模型同 batch size 的公开发布配置跑 5 到 10 个小 epoch 看 loss 是否正常收敛再放大规模训练。warmup 比例1% 到 10%。任务越难或 batch 越大需要的 warmup 越长。eta_min设置成峰值学习率的 1/100 到 1/1000 就够不需要到 0避免后期完全无法更新。T_max设置为预计总步数的 90% 到 100%。如果预算紧张可以略短但别设成一半还不到否则后期长期处在极低学习率阶段其实是在浪费时间。优化器搭配AdamW 加 cosine 加 warmup 的组合在大规模任务上表现很稳值得长期使用。6. 常见问题与排查技巧实录6.1 学习率衰减过快/过慢怎么办先确认调度器的 step 调用位置。如果你在 optimizer.step() 之后每个 batch 调一次 scheduler.step()但 T_max 却按 epoch 数量来写那学习率会在极短的时间内衰减完训练基本不可能收敛。用 print 或 logger 打印前几个 step 的 get_last_lr()能快速确认问题。还有检查是否多个调度器同时挂在同一个优化器上。我见过有人同时创建 CosineAnnealingLR 和 ReduceLROnPlateau 挂在同一个 optimizer 上然后调两个 scheduler 的 step()学习率被两层衰减训练一跑 loss 就炸。一个 optimizer 只挂一个调度器组合场景用 SequentialLR 或 ChainedScheduler。6.2 要不要用带热重启的余弦变体CosineAnnealingWarmRestarts 通过周期性把学习率弹回峰值试图跳出当前局部区域在超参搜索、持续学习、非平稳目标中确实有效。但常规分类任务里热重启会让训练指标出现周期性波动模型验证和早停都变得不好处理反而增加了选择最佳 checkpoint 的难度。我的建议是不要默认使用 WarmRestarts。先跑标准 cosine 加 warmup除非你在做周期性持续训练或者超参搜索再切换过去。6.3 与 EMA、早停、checkpoint 配合的注意点EMA指数移动平均会对训练后期提升明显结合 cosine 的缓降阶段正好相配。但要注意 EMA 的 shadow weight 也要按相同节奏保存否则恢复训练时参数不一致精度会莫名其妙掉一点。早停要慎用cosine 后期学习率低指标可能暂时不动但这是正常的收敛过程不代表模型没有进步。设置 patience 时最好给低学习率微调阶段留出空间比如训练进度进入后 20% 再允许触发早停。checkpoint 的坑我之前提过scheduler 状态必须一起存。另要注意 SequentialLR 存 checkpoint 时恢复前尽量保持原调度器配置不变否则可能因为内部状态对不上直接报错。6.4 一个完整的排查流程遇到cosine 调度训练效果不好时按这个顺序排查打印学习率曲线确认调度曲线符合预期。确认 T_max 和 step 调用频率是否一致。确认 warmup 没有吃到过多训练预算。用小规模数据集快速测一下峰值学习率是否合适。观察 loss 曲线在不同阶段是否正常下降。检查是否有其他组件EMA、梯度裁剪、AMP干扰学习率更新。最后再考虑是否换成 warm restart 或自定义曲线。这套流程我用了很多次基本能覆盖 90% 的调度器问题强烈建议你把它贴到训练代码旁边。我个人在实际项目里的体会是CosineLRScheduler 真正节省的不只是那零点几个点的精度而是一大堆以为自己在调参、其实在猜衰减节点的时间成本。刚开始用的时候我也纠结过各种细节后来发现把 warmup 固定成总步数的 5%、峰值学习率用 lr finder 快速定下来、T_max 直接等于总步数剩下的交给曲线就好。最后再分享一个小技巧任何改动了调度器配置的训练都建议先跑 10 到 20 个 step把学习率曲线和 loss 曲线一起看一遍再放全量任务。这个习惯能帮你避免很多浪费了 100 个 GPU 小时后才发现参数错了的惨案。