1. 从玄学到工程:为什么说学习率调度是训练节奏的指挥棒
搞深度学习这些年,我越来越觉得训练模型这事儿像炖汤。数据是食材,模型结构是锅,优化器是火候,而学习率调度,就是那个决定什么时候大火煮沸、什么时候文火慢炖、什么时候关火收汁的节奏控制。学会控制学习率的节奏,往往比换一个更花哨的网络结构带来更实在的收益。
很多人刚开始训练模型的时候,习惯性把学习率设成一个固定值,比如 0.001,然后祈祷 loss 能一路降下去。但实际跑起来就会发现,训练前期 loss 降得还挺快,到了中期就开始来回震荡,后期干脆卡在一个平台上不动了。这时候你调大学习率,模型直接发散;调小,又半天不见动静。问题的根源,就在于学习率本身应该是一个动态变化的量,而不是一个静态的超参数。
学习率调度(Learning Rate Scheduling)要解决的,就是"在训练的不同阶段,用多大的步长去更新参数"这个问题。它直接决定了优化器在损失曲面上怎么走:步长太大容易迈过最优点甚至发散,步长太小又容易陷在局部最优里出不来。一个设计良好的调度策略,能让模型在前期快速探索、中期稳步收敛、后期精细打磨,最终拿到更好的精度和更稳定的训练过程。
这篇文章我想把学习率调度这件事从头到尾梳理一遍。从最基础的调度策略讲起,重点拆解Warmup和余弦退火(Cosine Annealing)这两大主流方案,结合我自己在图像分类、目标检测和 BERT 微调这几个场景里的实战经验,把每种策略的适用场景、参数设置和踩坑记录都写清楚。适合正在调模型但觉得学习率设置全靠碰运气的朋友,也适合想系统理解调度器原理、想从固定学习率切换到更高效训练节奏的读者。
2. 学习率调度到底在调什么
2.1 先搞清楚优化过程的关键要素
我们平时说的"训练模型",本质上是在做梯度下降:算出 loss 对每个参数的梯度,然后顺着梯度的反方向更新参数。更新公式长这样:
[ \theta_{t+1} = \theta_t - \eta \cdot \nabla L(\theta_t) ]
其中 (\eta) 就是学习率,决定了每一步迈多大。公式本身简单到不行,但把 (\eta) 换成常量还是变量、往大调还是往小调、在哪个阶段调,产生的效果天差地别。
我习惯把训练过程想成"下山"的场景。你站在山顶(损失曲面的高处),目标是走到山谷底部的全局最优点。学习率就是你每一步迈多远。步幅太大,可能直接从山这头跨到山那头,错过谷底;步幅太小,可能要花很久才走到,而且可能困在一个小坑(局部最优)里出不来。
但这只是静态视角。真实训练中还有一个关键因素:梯度本身的统计特性在变化。训练初期,模型参数还是随机初始化的,梯度的方向和大小都比较剧烈、不稳定。随着训练的推进,参数逐渐收敛到某个区域,梯度的分布也会慢慢平稳下来。这意味着,不同阶段实际上需要不同的步长策略,而这就是学习率调度存在的意义。
2.2 固定学习率的最大问题
固定学习率策略看起来省心,实际上是在用一种"一刀切"的逻辑处理所有训练阶段。它的表现往往两头不讨好。
训练早期,参数从随机初始化开始,离最优点很远,梯度方向信息量大,这时候固定学习率如果偏小,探索速度会非常慢,前几百个 epoch 可能都在原地打转。而如果固定学习率偏大,前期倒是走得快,但后期模型快收敛的时候,过大的步长会在最优点附近反复横跳,loss 出现明显震荡,甚至永远无法收敛到理想的精度。
我印象很深的一次经历:用 ResNet50 训练一个分类模型,固定学习率 0.1,前 30 个 epoch 指标看着还行,但过了 80 个 epoch 之后 loss 一直在 0.35 到 0.45 之间来回波动,就是下不去。后来换成分段衰减(每 30 个 epoch 学习率降为原来的 1/10),loss 才在 120 个 epoch 的时候顺利降到 0.2 附近。这个对比让我切身体会到:学习率不能只靠初始值,更要在训练过程中动态调整。
2.3 调度策略怎么分类
学习率调度策略五花八门,但从设计逻辑上大致可以分三类。
第一类是预设规则型,最常见的就是 Step Decay(分段衰减),比如每训练 N 个 epoch,学习率乘一个衰减系数。它简单直接,想用什么样的衰减节奏完全自己定。
第二类是自适应型,不依赖预设的 epoch 节点,而是根据训练过程中的某些指标实时调整。比如 ReduceLROnPlateau,监测 loss 或验证集准确率,连续几个 epoch 不下降了就自动降低学习率。这类策略的好处是"看情况办事",坏处是滞后性比较明显——等你观察到指标不降了,可能已经在这个平台期原地踏步了不少时间。
第三类是趋势型,按照一个预定义的数学曲线连续地改变学习率,典型代表就是余弦退火。它可以看作分段衰减的升级版,不需要人为设定衰减节点,而是用连续曲线平滑地把学习率从大调到小。
Warmup 严格来说不完全属于上面任何一类——它是一种前置策略,和很多调度器可以叠加使用。我在后面单独用一整节讲它。
3. Warmup 为什么那么重要
3.1 Warmup 机制的本质和作用阶段
Warmup,中文叫预热,指的是训练最开始的那一段"小步慢走"阶段。学习率从一个很小的值(比如 0.001 的 1/10)逐步线性或非线性地增长到设定的初始学习率,这个增长过程通常持续几个到几十个 epoch。
为什么要这么做?这得从深度网络的初始化状态说起。
以 Transformer、BERT 这类模型为例,网络在初始化时,各层的输出分布并不稳定。如果一上来就用较大的学习率,梯度信号会被一些层的极端输出干扰,造成参数剧烈震荡,甚至引发训练不收敛。2019 年 Google 的研究人员在分析 BERT 预训练时特别提到,大学习率加上显著的热启动,会让 Adam 优化器的二阶矩估计产生偏差,前几步更新幅度大得离谱,直接带偏整个训练进程。Warmup 的作用,就是给模型一个"热身适应"的时间,让梯度统计量稳定下来,再逐步加大步伐进入状态。
对于 CNN 网络,Warmup 没那么生死攸关,但在数据量小、batch size 大的场景下同样有奇效。大规模 batch 训练时,每个 step 的梯度估计更加稳定,但与此同时学习率的上限也变得非常敏感。这时候 Warmup 相当于在启动阶段给一个缓冲,避免初始的大学习率和大 batch 叠加引发前期不稳定。
3.2 不同 Warmup 的实现方式
我实际用过的 Warmup 实现主要有三种,这里分别说明。
线性 Warmup(Linear Warmup):学习率在每个 step 上等比例增长,公式是:
[ \eta_t = \eta_{base} \cdot \frac{t}{T_{warmup}} ]
其中 (T_{warmup}) 是预热的总步数。线性增长实现最简单,epoch 数不多的时候效果和复杂方案差别不大,是绝大多数框架默认支持的方案。
指数 Warmup(Exponential Warmup):学习率按指数曲线缓慢爬升,增长速度开始快、后面慢。适合那种希望快速达到目标学习率、但又不愿意一上来就猛冲的场景。
常数 Warmup:初始化后先用一个极小的学习率跑一段,之后直接跳到初始学习率。这种方式实现简单,但跳跃太突兀,我在实际项目中很少用,除非资源非常紧张、必须省那几个 epoch。
实现层面,PyTorch 的torch.optim.lr_scheduler没有直接提供 Warmup,需要自己包装一下,或者用transformers库里的get_linear_schedule_with_warmup。我常用的是自定义一个 wrapper scheduler,把 warmup 阶段的逻辑包在现有 scheduler 外面,这样代码结构调整最小。参考实现大概长这样:
class WarmupWrapper: def __init__(self, optimizer, warmup_epochs, scheduler): self.optimizer = optimizer self.warmup_epochs = warmup_epochs self.scheduler = scheduler self.current_epoch = 0 def step(self): if self.current_epoch < self.warmup_epochs: # 线性升温 warmup_ratio = (self.current_epoch + 1) / self.warmup_epochs base_lr = self.scheduler.get_last_lr()[0] # 注意:这里需要记录初始目标LR for pg, base in zip(self.optimizer.param_groups, self.base_lrs): pg["lr"] = base * warmup_ratio else: self.scheduler.step() self.current_epoch += 1这里有个细节要特别注意:实现 Warmup 时,要先把调度器预设的初始学习率记为"目标学习率",而不是在 steps 内直接用self.scheduler.step()去覆盖,不然会出现调度器状态错乱,后续学习率跳变的问题。
3.3 什么样的场景必须用 Warmup
根据我的实践,下面几类场景强烈建议加 Warmup。
一类是训练 Transformer 系模型,尤其是从零开始预训练或者在大规模语料上做 MLM 训练。这类模型的训练稳定性普遍依赖 Warmup,跳过它往往造成早期 loss 剧烈振荡。
一类是使用超大 batch size 训练视觉模型。比如用 batch size 4096 甚至更大训 ResNet,学习率往往要开到 0.4 以上才能和线性缩放法则对齐。这么大的学习率起步,不加 Warmup,通常几十个 step 之后 loss 就变成 NaN 了。我调过分布式训练任务,每一步的全局梯度是从 卡数×每卡 batch 汇总的,梯度方向和幅度在初期非常不稳定,Warmup 在这里几乎是"保命"一样的存在。
还有一类是微调预训练模型。此时模型的权重已经在一个较好的局部最优附近,学习率过大容易"破坏"已经学到的特征。先用 Warmup 从小学习率慢慢增加到目标值,能在适应新任务的同时保留预训练信息。
3.4 Warmup 步数怎么设
Warmup 持续时间没有绝对标准,但有两个经验锚点可以参考。
如果按 step 算,常见选择是训练总步数的 1% 到 10%。例如,ImageNet 上训练 ResNet 跑 90 个 epoch,大概 120 万步,Warmup 设 1 万到 3 万步比较常见。如果按 epoch 算,分类网络 2~5 个 epoch、BERT 类预训练 3% 到 6% 的总 epoch 数(比如 100 万步中取 1 万步)都比较标准化。
有个原则我想强调一下:Warmup 阶段是整个训练过程的一小部分,不是拖时间用的,如果发现 Warmup 太长导致训练时间明显变多,那大概率是没必要设那么长。
4. 余弦退火:让学习率学会"顺滑落地"
4.1 余弦退火的数学原理
余弦退火(Cosine Annealing)是当下最受欢迎的学习率调度策略之一。它的核心思想是让学习率按照余弦曲线从初始值平滑地下降到最小值,而不是像分段衰减那样"跳崖式"下降。
公式如下:
[ \eta_t = \eta_{min} + \frac{1}{2}(\eta_{max} - \eta_{min}) \left(1 + \cos\left(\frac{t}{T}\pi\right)\right) ]
其中 (T) 是总训练步数,(\eta_{max}) 是初始学习率,(\eta_{min}) 是最小学习率(可以设为 0)。从图形上看,学习率在训练开始时是最大值,然后沿余弦曲线的"半周期"逐渐平滑降到最小值。
为什么余弦曲线好?因为它在前中期下降速度较慢,给模型足够的探索空间,等训练到了后期,学习率已经变得非常小,模型可以在最优解附近细抠。相比线性衰减,余弦退火的衰减曲线更平滑,尤其是后半段,相当于自动进入了"微调模式"。
另外一个隐性优势是不需要人工设置衰减节点。分段衰减要你自己拍脑袋决定"第 40 个 epoch 降一次、第 70 个 epoch 再降一次",这种主观判断很难保证最优,还可能因为数据集变化需要反复调整。余弦退火只需要你给定总训练步数,曲线自动分布,省心不少。
4.2 为什么强训练器都爱用余弦退火
我现在已经习惯在绝大多数训练任务里默认使用余弦退火,一个重要原因是它对初始学习率不那么敏感。这和人类的学习逻辑很像——如果大方向错得不多,你给自己留了不断调整和收束的时间,即使开始快一点、慢一点,后期都能回到正轨。
有人可能担心:学习率降得太快,会不会后半段基本在"原地踏步"?实际上不会。在余弦退火的后半段,学习率很小,但还在缓慢变化,而这时模型通常已经进入收敛区域,需要的本来就是极小扰动。从 loss 曲线上看,后期可能出现一条几乎水平的线,但如果放大尺度,loss 仍然在稳定地、微小地降低。
另一个我特别常用的变体是带热重启的余弦退火(Cosine Annealing with Warm Restarts,简称 SGDR)。它的做法是把整个训练过程分成若干个小"周期",每个周期内部执行一次余弦退火,一个周期结束之后,学习率突然回升到一个较大值,再开始一个新的余弦周期。论文作者这个设计的初衷是利用周期性回暖来跳出局部最优:学习率回升相当于从当前盆地"跳"出来,换一个方向搜索可能的更优解。实际应用中,热重启确实能偶尔带来精度上的惊喜,但也会带来训练时间的增加,是否采用得看场景。
PyTorch 里实现余弦退火非常方便,一行代码:
torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=total_epochs, eta_min=1e-7 )T_max是总训练 epoch 数,eta_min是最小学习率,默认是 0。强烈建议eta_min不要设成 0,留一个 1e-7 或 1e-6 的极小值,这样模型后期还能有一点点微调能力。
4.3 余弦退火的分阶段变体:Linear Warmup 后接 Cosine
在真正的大规模训练中,我见过最高频的组合其实是"Linear Warmup + Cosine Annealing"这套组合拳:头几个 epoch 用线性 Warmup 慢慢爬到初始学习率,之后全程走余弦退火曲线,一路平滑下降。
这个组合在 NLP 领域几乎是事实标准。HuggingFace 的transformers库训练 BERT 系模型时,默认的训练配置就是这个。在视觉领域,不管是 Swin Transformer 还是 ConvNeXt,官方训练脚本里也基本全线采用这种方案。
原因也很容易理解:Warmup 解决了训练初期的稳定性问题,余弦退火解决了训练后期的收敛精细度问题。两者互补,前期的"热身"和后期的"平滑落地"天然衔接,中间不需要任何人为干预。
我自己复现 ImageNet 训练时,用的配置大致是:初始学习率 0.1,Warmup 5 个 epoch,之后按余弦退火衰减到 1e-6,总共训 90 个 epoch。跑出来的收敛精度比之前用分段衰减(在 30、60、80 epoch 各除以 10)高了大概 0.3~0.5 个百分点。听起来不多,但在 ImageNet 这个体量上,0.3 个点已经是不可忽略的收益了。
4.4 热重启到底要不要用
热重启这个策略,每次训练新任务的时候我都会纠结一下。用吧,训练时间变长,而且要重新设定周期长度;不用吧,又觉得可能错过了一点涨点的空间。
我的经验是:训练节奏本来就比较慢、或者模型容量较大的时候,热重启收益更明显。比如训练大规模语言模型,或者在很长的时间窗口内做一个困难的任务,热重启能带出 2~3 次"二次下降"的机会,这在长时间训练中值得考虑。但如果是常规尺寸的数据集、常规 CNN 分类任务,热重启的意义不大,反而破坏了原本平滑收敛的节奏,可能导致最终精度不稳定。
如果决定用热重启,周期长度一般按"总步数除以期望重启次数"来设定。想简单一点就做等间隔的,比如每 20 个 epoch 重启一次。想精细操作也可以做成不均匀的,比如前几个周期短一些,后面周期逐渐拉长,因为后期模型已经比较接近最优点,不需要频繁跳跃。
5. 主流调度策略的横向对比与选型建议
5.1 一张表看懂常见调度策略
为了方便大家在不同场景下快速选型,我把常用调度策略放在一起做了个对比。这里主要对比的是它们的行为模式、适用场景和需要注意的坑。
| 调度策略 | 行为模式 | 推荐场景 | 主要缺点 | 我对它的评价 |
|---|---|---|---|---|
| Step Decay | 每隔固定 epoch 数降低一次学习率 | 小型实验、基线模型快速验证 | 需要人工设定衰减节点,跳变可能降幅过大 | 简单可靠,适合快速跑通流程 |
| Exponential Decay | 每个 epoch 按指数比例衰减 | 训练节奏稳定的任务 | 后期学习率衰减太快,容易欠拟合 | 比 Step 平滑一些,但同样依赖超参 |
| ReduceLROnPlateau | 监控验证集 loss 连续不降时衰减 | 无法确定总训练时长、指标波动大的任务 | 滞后性明显,损失在降低但其实没下降、策略不动作 | CPU 友好,适合小规模实验 |
| Cosine Annealing | 按余弦曲线平滑下降 | 大规模训练、迁移学习、预训练微调 | 需要确定总训练步数,实际训练如果提前停止则曲线不完全 | 我最常用的默认选择 |
| Piecewise + Warmup | 预热后分段衰减 | 大规模 batch 训练 | 超参组合多,调起来略繁琐 | 传统方案,兼容性好 |
| Warmup + Cosine | 预热后余弦下降 | NLP、视觉大规模训练事实标准 | 前期预热时长需要额外设定 | 最推荐的默认组合 |
5.2 不同任务怎么选调度策略
图像分类中等规模数据集(比如 CIFAR、ImageNet):首选 Warmup + Cosine。如果是 CIFAR 那种小实验,训 200 个 epoch 的话,Warmup 可以只设 5~10 个 epoch,余弦退火 T_max 设成 200,eta_min 设成 1e-6。
目标检测与分割任务:这类任务的训练逻辑比较特殊,很多是先在 ImageNet 上初始化主干,再在自己的检测框架里继续训练。检测模型的训练一般时长较短(12~36 epoch),我通常直接用 Cosine,不额外加 Warmup,因为主干网络不是随机初始化,已经处于一个不错的起点,Warmup 的收益不明显。如果数据集是新领域的、与 ImageNet 分布差异很大,那就补一个 1~3 epoch 的 Warmup 更稳妥。
NLP 模型微调:BERT 时代开始,微调任务基本都使用 Warmup + 线性衰减或 Warmup + 余弦退火。实际经验是:小数据集(如几千条样本)用 Warmup + 线性衰减表现就足够好,因为训练时间短,余弦退火的优势还来不及发挥;大数据集或训练很多 epoch 才需要余弦退火的平滑下降。HuggingFace 的get_linear_schedule_with_warmup和get_cosine_schedule_with_warmup直接可用,参数里num_warmup_steps建议设为总步数的 6%~10%。
GAN 和强化学习:这两类模型训练更依赖交替优化,学习率调度要谨慎对待。GAN 里我通常两边都做同样的余弦退火,避免判别器和生成器的学习率差距过大;强化学习因为样本效率和梯度波动问题,一般用固定学习率配 Adam 就够用,不急着引入复杂的调度策略。
5.3 选型的一些通用心法
第一个心法是**"能平滑就不跳变"**。Step Decay 那种学习率瞬间掉一截的做法,容易造成训练指标出现瞬时震荡。这不是致命的,但会带来不必要的风险。Cosine 这类平滑策略没有跳变,训练曲线更干净,从稳定性角度来看更优。
第二个心法是**"同一份学习率计划不要套用到所有模型"**。不同优化器、不同 batch size、不同初始化方式,对学习率的敏感度都不一样。换模型结构、换数据大规模之前,先在小规模数据上做几个 epoch 的对照测试,搞清楚当前配置下学习率落在哪个量级合适,再套用完整调度策略。这样可以避免在完整训练流程中反复返工。
第三个心法比较隐性:调度器和优化器联动看。比如用了 AdamW 和权重衰减,学习率的变化会影响权重衰减在最终更新中的占比。调度学习率的时候,要观察权重衰减带来的实际影响,尤其在后半段,学习率已经很小的情况下,权重衰减项可能成为主要的更新来源。我这个点吃了不少亏,在很多任务上最后的精度瓶颈其实不是学习率调得不好,而是权重衰减项在那时已经主导了参数更新方向。
6. 实操记录:从 ResNet 到 BERT 的完整调度方案
6.1 手写一个可复用的学习率调度器
用 PyTorch 训练时,我最常用的组合是torch.optim.lr_scheduler.SequentialLR搭配自定义的LinearLR来做 Warmup。PyTorch 1.10 之后直接把预热合并进SequentialLR的设计让我省了很多事。写法大致是这样:
from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR def get_warmup_cosine_scheduler(optimizer, warmup_epochs, total_epochs, min_lr=1e-7): warmup_scheduler = LinearLR( optimizer, start_factor=0.01, # 从目标学习率的 1% 开始 total_iters=warmup_epochs ) cosine_scheduler = CosineAnnealingLR( optimizer, T_max=total_epochs - warmup_epochs, eta_min=min_lr ) return SequentialLR( optimizer, schedulers=[warmup_scheduler, cosine_scheduler], milestones=[warmup_epochs] )这一步踩过的坑提醒一下:CosineAnnealingLR的T_max必须设置为"预热之后剩余的 epoch 数",而不是总 epoch 数。如果填了总数,预热阶段结束后,cosine 曲线的相位就错了,学习率在预热结束时会从余弦曲线的中间位置接着走,根本不会从初始学习率开始,晚期衰减节奏全部错乱。
训练循环里调用 scheduler 的时机也很关键。千万注意每个 epoch 结束时调用scheduler.step(),不要每 100 个 step 就调一次,除非你用的是 step-based 的调度器。混用两者会导致学习率下降速飞快,基本等于提前结束训练。
6.2 实战一:ResNet50 图像分类
我之前用 PyTorch 在 ImageNet 子集上训了一个 ResNet50,配置如下:
初始学习率 0.1,batch size 256,优化器 SGD + Momentum 0.9,权重衰减 1e-4,总共 90 个 epoch,Warmup 5 个 epoch,然后接余弦退火到 1e-6。
训练到第 15~25 epoch 的时候,我观察到 loss 下降斜率变缓,这是一个正常现象,因为余弦退火在这个区间已经让学习率开始下降。到了第 60 epoch 之后,loss 变得非常平滑,几乎看不出明显下降,只有放大尺度再看才发现在那一段缓慢降低。最终在验证集上的 top-1 准确率是 77.3%,比我在同样预算下用分段衰减得到的 76.9% 要高一些。
这轮实验中,我试过把 Warmup 改成 10 个 epoch,发现前 5~10 个 epoch 的学习率上升过于缓慢,额外消耗了 5 个 epoch 的训练预算,但最终精度没有明显变化。所以如果训练预算紧张,Warmup 设短一点(1%~3% 总步数)比较经济。
6.3 实战二:BERT 微调
另一个高频场景是我在文本分类和实体识别任务上微调 BERT。这里我的配置是:初始学习率 2e-5,Warmup 设总训练步数的 10%,之后走余弦退火,AdamW 的权重衰减设 0.01。
这里有个很典型的经验:微调 BERT 时,学习率如果超过 5e-5,即便有 Warmup,也很容易出现灾难性遗忘——模型在训练集上表现很好,但验证集指标炸了,因为它学得太快、把预训练阶段学到的通用语义都覆盖掉了。2e-5 配合 Warmup 加余弦退火这种"温水煮青蛙"方式,能最大程度保留预训练知识,同时学习新任务的信息。
微调任务训练样本往往只有几千条,总步数也就几百到几千步。这时候我把 Warmup 步数设为 6%~10%,一方面让模型逐步适应新数据分布,另一方面避免在训练初期损失剧烈波动。从最终 F1 的分布来看,这种配置的方差比无 Warmup 直接干跑的版本明显更小。
6.4 训练过程中怎么看学习率调度是否合理
很多朋友问我:"训练的时候怎么判断当前学习率调得合不合理?"我的核心观察套路很简单:看 loss 曲线的形态特征,而不是只看绝对值。
如果训练初期的 loss 在几个 epoch 内快速下降,但出现过冲(先暴跌、再弹回),那么大概率是初始学习率偏大或 Warmup 太短。
如果训练中期的 loss 曲线出现频繁的小锯齿状波动,说明学习率仍然偏大,模型在最优解附近震荡。这时候如果用的是余弦退火,可以检查一下当前的调度曲线——如果训练已经过半,学习率应该已经降到了初始值的三分之一以下,还有明显震荡的话,说明初始学习率设得有点高。
如果训练后期的 loss 几乎完全不降了,不管过多少个 epoch 都是平的,那么有几种可能:一是模型容量不够,loss 就是下不来;二是学习率已经很小,梯度更新量微乎其微;三是训练数据和模型结构有更严重的匹配问题。这时候先把损失曲率放量看,或者把 learning rate 临时恢复到中期的值跑几个 epoch,看 loss 是否"解冻",就能定位问题出在哪一环。
6.5 日志和可视化:不止是记录
最后补一个不被重视但对实操非常重要的点:训练日志里一定要记录每个 epoch(或每个 500 步)的实际学习率。
我见过不止一次:开发和写代码的人在训练脚本里忘了调用scheduler.step(),结果跑了一整夜,模型训练曲线看着"完美"——因为 loss 确实在降,但其实学习率从头到尾都是固定值,根本没走预设的调度曲线。等发现时已经浪费了大量机时。
我的做法是在每个 epoch 结束后把optimizer.param_groups[0]["lr"]的值写进日志,隔一段时间看一次曲线,确认调度器真的在按照预期下降。另外,如果训练中途程序崩溃需要从 checkpoint 续训,务必记得把 scheduler 的 state_dict 一并保存和恢复。PyTorch 的 scheduler 状态里记录了当前 step 数、曲线相位,如果只恢复模型参数而把调度器从零开始,前半段的学习率会整个乱掉,最后一次续训的结果基本报废。
7. 那些年我踩过的学习率调度的坑
7.1 热身时间太长,其实是在浪费算力
不少人看到 Warmup 有稳定训练的作用,就喜欢把 Warmup 设得很长,比如 10%、20% 的训练预算。但实际效果往往是训练初期的探索效率被极大拖慢,最终精度也没什么提升。尤其在视觉任务上,模型本来就比 Transformer 鲁棒得多,Warmup 设到 2%~3% 完全足够。我在 ResNet 系列上做过完整对比,Warmup 从 1% 加到 10%,最终精度差异基本在 0.2 个百分点内,但训练消耗的时间差距是实实在在的。
7.2 换优化器忘记改学习率
这是一个特别常见、又特别隐蔽的错误。同一个模型,从 SGD 换成 Adam,学习率如果不做调整,训练很大概率会崩。我第一次从 SGD 切 Adam 的时候,保留了 0.1 的学习率,结果第一个 epoch loss 直接冲上 NaN。后面查资料才搞明白,SGD 的更新量级和 Adam 的自适应更新量级不在一个维度,Adam 实际步长通常要比 SGD 大几个数量级。常规适配经验是:Adam 对应的初始学习率通常是 SGD 的 1/10 到 1/100,比如 SGD 用 0.1,Adam 就试试 1e-3 或 3e-4,再加上 Warmup 平滑起步。
7.3 checkpoint 恢复后面目全非的调度器
前面提过 checkpoint 恢复的问题,这里再展开说一个具体场景。有一次我在训练到第 42 个 epoch 的时候因为机器问题中断了,程序里有保存模型和优化器的逻辑,但 scheduler 的 state_dict 被我漏了。第三次续训之后,loss 曲线出现了明显的"断崖",我以为模型出了问题,排查了半天才发现是调度器从第 0 步重新开始学习了。这里要再次强调:保存 checkpoint 时,optimizer 和 lr_scheduler 的 state_dict 一个都不能少。
checkpoint = { "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "epoch": epoch, "best_acc": best_acc, } torch.save(checkpoint, path)恢复时反过来恢复三个部分,继续训练之前,再手动设置scheduler.last_epoch为保存的 epoch 值,或者依赖load_state_dict内部恢复也行。
7.4 热重启不是免费的午餐
热重启的"周期性回暖"确实能带来惊喜,但它并不是没有代价。学习率回升的那几个 epoch,模型的指标往往会出现明显回退,表现为 loss 突然升高、评估准确率掉一截。总训练时间不变的情况下,热重启等于在训练中插入了几次"跳出来重来"的过程,最终的收敛点更多取决于最后一次降温的节奏。如果你追求一个稳定的、好解释的实验结果,我建议不要上热重启,而是用更长的普通余弦退火。
7.5 测试集调参是个隐藏大坑
学习率调度器的超参数(初始学习率、Warmup 长度、总步数、最小学习率)直接决定了模型最终能收敛到哪个位置。如果你频繁根据测试集指标来回改调度策略,那其实是在用测试集做学习率搜索,时间一长,测试集的评估参考价值就废了。正规做法是划出一部分验证集来做这层决策,最后只用测试集评估最终选定的模型。
8. 我自己的学习率调试工作流
聊了这么多理论、策略和踩坑,最后分享一套我这些年总结出来的、比较稳定的学习率调试工作流。这套流程的核心思想是:先把探索成本降下来,找到靠谱的初始学习率,再决定完整的调度方案。
第一步,在小数据集上跑一次学习率扫描(Learning Rate Finder)。从 1e-6 到 1e-1 以指数间隔设置几个值,每个值只训练几十个 step,画一条 loss vs 学习率的曲线。曲线 slope 最陡的那段,对应的学习率就是当前模型和数据组合的"好起步区间"。
第二步,确定初始学习率之后,设计完整的调度方案。我的默认配置是:Warmup 占总步数 2%~6%(大数据集取小值,小数据集取大值),之后接余弦退火,eta_min 设 1e-6 或 1e-7。
第三步,在完整训练的前 20% 时间做一次"预告检查"。看这一阶段的 loss 曲线是否平滑、是否出现剧烈波动、是否按照预期速度下降。如果发现问题,尽早调整初始学习率或 Warmup 时长,避免浪费完整训练预算。
第四步,按完整配置跑完整个训练流程,记录学习率日志和 loss 曲线。训完之后,如果 loss 曲线的最后一段仍然在明显下降,说明还有余量——可以把总步数拉长、或者把 eta_min 调低一点再试;如果最后一段基本水平,说明当前预算下已经收敛得差不多了,不需要过度纠缠。
这套流程并不能保证找到理论最优解,但能在有限资源下稳定地达到一个"高于平均水位"的结果。学习率调度的本质不是找到一个神秘的魔法数字,而是用合理的节奏让优化器把模型的潜力释放出来。真理解了这一点,你在不同的框架、不同的模型、不同的任务里就都能快速上手。
我个人这几年最深的体会是:学习率调度不是训练流程里的配角,它本身就是训练策略的一部分。同样的模型、同样的数据、同样的 epoch 数,调度方式不同,最终精度可以差出好几个点。这种差异,远超过动一两个网络结构细节带来的收益。所以每次开始一个新项目,我都建议把学习率方案当成一个值得认真对待的模块去设计,而不是最后随便填一个默认值了事。