搞深度学习训练的人,多少都遇到过这种场景:模型结构没问题、数据没问题、loss 曲线前几个 epoch 掉得挺猛,但训练跑到中后段就开始纹丝不动,甚至在验证集上反复横跳。你调大学习率,loss 直接飞了;调小,收敛速度又慢得让人心急。这时候最该检查的往往不是模型,而是学习率调度器(Learning Rate Scheduler)。在我现在的主力训练流程里,CosineLRScheduler 基本是默认选项,不管是用 timm 还是自己搭引擎,训练初期加 warmup、中后期走余弦退火,这套组合拳帮我省掉了大量手动调 milestone 的时间。这篇文章就围绕 CosineLRScheduler 的原理、代码实现、实际训练中的配置方法,以及我踩过的几个坑,做一次完整的梳理。
文章适合两类人:一类是刚接触学习率调度器、只知道StepLR的新手,想搞清楚余弦退火到底是怎么回事;另一类是已经在用 CosineLRScheduler,但遇到“学习率没按曲线走”“换 batch size 后训练崩了”“warmup 衔接处明显跳变”这类问题的从业者。我尽量把原理讲得直观,把代码给得能直接抄,同时把那些只在实践里才会碰到的坑也摆到台面上。
1. 为什么训练到一半 loss 不动了——从固定学习率到动态调度
1.1 固定学习率的本质困境
在引入调度器之前,先回到一个最基本的问题:学习率到底在控制什么?
一句话,学习率控制的是每次参数更新的步长。梯度告诉你“该往哪个方向走”,学习率告诉你“这一步迈多大”。
固定学习率之所以在很多任务上表现不佳,是因为不同训练阶段对步长的需求是完全相反的。训练初期,模型权重是随机初始化的,它对数据几乎没有任何先验理解,这时候需要大步子快速进入“有意义的参数区域”,所以学习率要偏大。但到了训练后期,模型已经逼近一个较好的局部最优解,参数在 loss landscape 的一个盆地底部来回震荡,这时候如果步长还很大,就很容易跨过最优点,在盆地边缘来回横跳,表现为验证 loss 高频抖动、迟迟不收敛。
有人会说,那把学习率设成一个中间值不行吗?不行。取中间值,前期收敛慢,后期精度也不够,两头不讨好。固定学习率本质上是在用同一个步长应对两个阶段完全不同的地形,这本身就是不合理的。
1.2 学习率调度器要解决的核心矛盾
动态学习率调度的思路很简单:让学习率随训练进度逐步变化,前期大、后期小。但“怎么从大变小”这个细节,直接决定了最终模型的收敛质量和泛化性能。
- 阶梯式下降:每训练 N 个 epoch,学习率直接乘以一个系数,典型如
StepLR、MultiStepLR。这种方式的问题是,每次突降都是一个“冲击”,模型需要重新适应新步长,在下降点附近验证指标经常出现明显波动。 - 指数衰减:学习率按指数曲线平滑下降,但下降速度在前面太快,后期又太慢,而且对衰减率的初始设置很敏感。
- 循环式调度:学习率在训练过程中周期性增减,比如
CyclicLR、OneCycleLR。这类调度器把“跳出局部最优”也纳入设计目标,曲线形态更复杂。 - 余弦退火调度:学习率按照余弦函数从较大值平滑衰减到较小值,也就是本文的主角
CosineLRScheduler。
这些调度方式本质都是在“前期大胆探索、后期精细收敛”这个框架下做变体。区别在于,阶梯式下降是人为主观设定“什么时候该变小”,余弦式下降则是用一条连续曲线自动完成全过程,不需要你盯着训练曲线去挑 milestone。
1.3 CosineLRScheduler 在深度学习生态中的位置
你可能已经在不少开源仓库里见过这个名字。CosineLRScheduler最出名的实现来自 Ross Wightman 的 timm 库,后来在 HuggingFace Transformers、Ultralytics YOLO 等项目中也被大量采用。PyTorch 官方也有对应的内置实现叫CosineAnnealingLR,但功能相对朴素,只支持单周期、没有 warmup 参数、也没有周期重启扩展。timm 版的CosineLRScheduler在工程上更完整,这也是我在这篇文章里主要以它为示例的原因。
需要注意的是,这里讨论的“调度器”指的是深度学习训练中的学习率调度器,跟“负载调度器”(比如 Kubernetes 里那种流量分发组件)完全是两码事。别搜到一堆 K8s 文档然后发现对不上号。
2. 余弦退火的数学原理与设计逻辑
2.1 余弦衰减公式拆解
CosineLRScheduler 的核心数学表达式长这样:
lr(t) = lr_min + 0.5 * (lr_max - lr_min) * (1 + cos(pi * t / T))其中:
lr_max是初始学习率(也就是峰值)lr_min是训练结束时的最小学习率t是当前训练进度(可以是 epoch 数,也可以是 step 数)T是总训练周期长度
这个公式看着抽象,拆开看就非常简单。cos(pi * t / T)在t = 0时等于 1,在t = T时等于 -1。那么(1 + cos(pi * t / T)) / 2就会从 1 平滑地线性映射到 0。换句话说,lr(t)就是从lr_max到lr_min的一条余弦曲线。
如果你把这条曲线画出来,会发现它的下降速度并不是均匀的:早期下降比较快,中间逐渐放缓,接近T的阶段曲线变得非常平缓,学习率几乎是在“慢悠悠地踱步”。这正是余弦退火最妙的地方——在模型接近收敛时,学习率不会突然归零,而是逐渐逼近一个极小值,给参数足够多的小步精细调整机会。
2.2 三种调度曲线形态对比
光看公式可能还是感受不到差异,我直接用行为来描述。
StepLR走的是楼梯:每到一个里程碑就突然降一段,其他时间完全不动。ExponentialLR走的是陡坡:从第一分钟开始就在快速下降,后期几乎贴地。CosineLRScheduler走的是滑梯:先快速下滑,再慢慢贴近地面,整个过程没有任何断点。
这里的“没有断点”在训练中是有实际意义的。每次StepLR突降学习率,相当于把所有参数的更新步长瞬间缩小了一个量级,模型在突变点之前的收敛状态会被打破,需要重新适应。而余弦曲线每个点的导数都是连续的,学习率的变化在每一步都平滑可预期,不会给优化过程引入额外的“冲击”。从我在 CIFAR-10 和 ImageNet 子集上的多次对比实验看,余弦退火省去了人工挑选milestones的成本,同时最终精度基本与精心调过的MultiStepLR持平,很多时候还要略高一点。
2.3 余弦下降与 warmup 为什么必须搭配
很多人在配置 CosineLRScheduler 时只设置了初始学习率和总 epoch 数,忽略了 warmup 部分,结果训练一开始 loss 就爆炸。这不是余弦调度的问题,而是大学习率在训练初期本来就危险。
训练最开始,模型权重完全随机,BatchNorm 的 running_mean/running_var 还没有积累起来,Adam 这类自适应优化器的动量估计也是从零开始。此时直接把学习率拉到峰值,等于让模型在完全未知的梯度地形上迈最大步,非常容易飞出正常范围,后面再好的调度曲线也救不回来。
warmup 的思路是:先用一个很小的学习率(比如1e-6)跑几个 epoch,让模型权重大致进入一个“看得过去”的状态,同时让 BN 统计量和优化器内部状态逐步热身,然后在几个 epoch 内把学习率线性(或按小段余弦)拉升到峰值,之后再进入正式的余弦衰减。这个“小步起步,随后冲顶,再逐步减速”的过程,和人类学习新技能很像:先慢速模仿,再大胆尝试,最后精细化打磨。
2.4 与 OneCycleLR 的关系
容易混淆的是CosineLRScheduler和OneCycleLR。PyTorch 的OneCycleLR曲线是一个“凸”字形:先从极低学习率热身到峰值,再从峰值直接余弦下降到极小值。也就是说,它把 warmup 阶段和余弦衰减阶段合并成了一个完整的非对称周期。
timm 版的CosineLRScheduler通过warmup_t和warmup_prefix参数也可以模拟出类似效果:先线性地从warmup_lr_init升到lr_max,再走余弦下降。区别在于 OneCycle 还附带一个“峰值学习率后快速下降”的机制,而 CosineLRScheduler 更简单、更可控。从我的实践看,两者在多数图像分类任务上精度差异不大,但 CosineLRScheduler 的参数更直观,调试成本更低。
3. 代码级实战:以 timm 的 CosineLRScheduler 为例
3.1 安装与环境准备
timm 库的安装很简单,直接 pip 就能搞定:
pip install timm确认一下版本,不同版本的参数名略有差异,但核心 API 保持稳定:
import timm print(timm.__version__)我用的版本是 1.0.x,下面代码在该系列版本上验证过。如果你的版本较老或者较新,个别参数默认值可能有出入,建议用inspect.signature查一下当前版本的构造函数签名。
3.2 参数逐个拆解
timm 的CosineLRScheduler构造函数长这样(我挑关键参数讲):
from timm.scheduler import CosineLRScheduler scheduler = CosineLRScheduler( optimizer, t_initial=100, # 总周期长度,单位取决于 t_in_epochs lr_min=1e-5, # 最小学习率 warmup_t=5, # warmup 持续多少个 epoch/step warmup_lr_init=1e-6, # warmup 起始学习率 warmup_prefix=False, # warmup 是否占据总周期的一部分 cycle_mul=1.0, # 每个周期的长度倍数 cycle_decay=1.0, # 每个周期峰值学习率衰减 cycle_limit=1, # 最多走几个周期 t_in_epochs=True, # t_initial 是否按 epoch 计算 k_decay=1.0, # 幂次衰减系数 )下表是每个参数的含义和典型值:
| 参数 | 作用 | 典型值 | 备注 |
|---|---|---|---|
t_initial | 一个周期的总长度 | 训练总 epoch 数或总 step 数 | 核心参数,必须和训练总步数匹配 |
lr_min | 学习率下限 | 初始 lr 的 0.01 倍左右 | 设为 0 有时会导致后期收敛过慢 |
warmup_t | warmup 持续长度 | 总 epoch 的 5%~10% | 太短起作用有限,太长浪费训练时间 |
warmup_lr_init | warmup 起始学习率 | 初始 lr 的千分之一量级 | 过大容易一上来就崩 |
warmup_prefix | warmup 是否计入总长度 | False | True 时 warmup 占t_initial的一部分 |
cycle_mul | 周期长度倍率 | 1.0 | >1 表示每个周期比上一个长 |
cycle_decay | 峰值学习率倍率 | 1.0 | <1 表示后一个周期的峰值更小 |
cycle_limit | 最多周期数 | 1 | 带重启的余弦退火需要 >1 |
t_in_epochs | 长度单位 | True/False | True 时按 epoch 调用,False 按 step 调用 |
我个人的配置习惯是:先定训练总 epoch,比如 100,那么t_initial=100;warmup 给 5 个 epoch;lr_min=1e-5(初始 lr 是1e-3时);t_in_epochs=True;单周期跑完,不做重启。对绝大多数任务,这一套已经够用。
3.3 两种调度节奏:按 epoch 还是按 step
这是 CosineLRScheduler 使用中最大的一个分水岭。
- 按 epoch 调度:
t_in_epochs=True,每个 epoch 结束调用一次scheduler.step(epoch)。曲线变化以 epoch 为粒度,一个 epoch 内学习率恒定。 - 按 step 调度:
t_in_epochs=False,每个 batch 训练完调用一次scheduler.step_update(step)。学习率在一个 epoch 内也会变化,曲线更平滑,适合大数据集、训练步数多的情况。
大多数中小规模任务,按 epoch 就够了。按 step 的好处是,当你的训练集特别大、一个 epoch 就要跑很久时,学习率能在一个 epoch 内更早开始衰减,而不是一直等到 epoch 结束才动。
需要注意,epoch 维度和 step 维度对应的方法不同:
# epoch 维度 scheduler.step(epoch) # step 维度 scheduler.step_update(global_step)两种方法不要混用。很多报错和“学习率没变”的问题,根源就是t_in_epochs=True却调用了step_update,或者反过来。timm 对不同调用方式做了兼容处理,但混用会导致内部train_steps和epoch状态错乱,曲线异常。
3.4 可视化验证:确认你这把“尺子”没标歪
不管用哪个调度器,我都会在正式训练前把学习率曲线画出来,和预期对比一次。这个习惯帮我挡掉了至少一半的配置错误。
import matplotlib.pyplot as plt total_epochs = 100 warmup_epochs = 5 initial_lr = 1e-3 scheduler = CosineLRScheduler( optimizer, t_initial=total_epochs, lr_min=1e-5, warmup_t=warmup_epochs, warmup_lr_init=1e-6, warmup_prefix=False, t_in_epochs=True, cycle_limit=1, ) lrs = [] for epoch in range(total_epochs): lrs.append(scheduler.get_epoch_values(epoch)[0]) plt.plot(range(total_epochs), lrs) plt.xlabel("epoch") plt.ylabel("lr") plt.title("CosineLRScheduler lr curve with warmup") plt.show()如果画出来的曲线是“前 5 个 epoch 从 1e-6 线性升到 1e-3,然后从 1e-3 平滑降到 1e-5”,那配置基本没问题。如果曲线出现了跳变、平台、或者根本没有下降,先别急着训练,回去查参数。
4. 完整训练案例:一个图像分类任务的调度器配置
4.1 任务设定与超参数
为了演示完整的训练流程,我用一个常见的图像分类任务作为例子:CIFAR-10 数据集 + ResNet18 模型 + AdamW 优化器。硬件不需要多好,单张普通显卡就能跑,重点是看学习率调度器在整个流程里的位置和作用。
超参数设定如下:
- 数据集:CIFAR-10,50000 张训练图、10000 张测试图
- 模型:ResNet18
- 优化器:AdamW,初始学习率
1e-3,weight decay5e-4 - 训练轮数:100 epoch
- batch size:128
- 数据增强:随机裁剪 + 随机水平翻转 + 标准化
- 损失函数:交叉熵
- 调度器:CosineLRScheduler + 5 epoch warmup
4.2 训练循环的标准写法
先定义优化器和调度器:
import torch import torch.nn as nn from torchvision import models, transforms, datasets from torch.utils.data import DataLoader from timm.scheduler import CosineLRScheduler model = models.resnet18(num_classes=10) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=5e-4) criterion = nn.CrossEntropyLoss() total_epochs = 100 warmup_epochs = 5 scheduler = CosineLRScheduler( optimizer, t_initial=total_epochs, lr_min=1e-5, warmup_t=warmup_epochs, warmup_lr_init=1e-6, warmup_prefix=False, t_in_epochs=True, cycle_limit=1, )训练循环里,每个 epoch 结束时调用一次scheduler.step(epoch + 1)。注意这里传的是“当前已经跑完的 epoch 数”,而不是当前的 epoch 编号,因为调度器内部要用它计算余弦函数的进度:
for epoch in range(total_epochs): model.train() train_loss = 0.0 for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() train_loss += loss.item() * images.size(0) # 每个 epoch 结束,更新学习率 scheduler.step(epoch + 1) current_lr = optimizer.param_groups[0]['lr'] print(f"Epoch {epoch+1}/{total_epochs}, loss: {train_loss/len(train_loader.dataset):.4f}, lr: {current_lr:.2e}") # 验证 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.cuda(), labels.cuda() outputs = model(images) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() acc = 100.0 * correct / total print(f"Epoch {epoch+1}/{total_epochs}, val acc: {acc:.2f}%")如果你更习惯按 step 调度,那就这样写:
num_steps = len(train_loader) * total_epochs scheduler = CosineLRScheduler( optimizer, t_initial=num_steps, lr_min=1e-5, warmup_t=warmup_epochs * len(train_loader), warmup_lr_init=1e-6, t_in_epochs=False, ) global_step = 0 for epoch in range(total_epochs): for images, labels in train_loader: ... optimizer.step() global_step += 1 scheduler.step_update(global_step)两种写法都能跑通,关键是不能混用。
4.3 和其他调度器的收敛对比
我在这套任务上做过一组对比实验,调度器分别用MultiStepLR、CosineLRScheduler不加 warmup、CosineLRScheduler加 warmup,其他条件完全一致。结果见下表中记录的训练趋势:
| 配置 | 前 30 epoch 的 loss 下降速度 | 最终 val acc |
|---|---|---|
| MultiStepLR(milestones=[50, 75], gamma=0.1) | 较快 | 92.1% |
| CosineLRScheduler,无 warmup,初始 lr=1e-3 | 初期不稳,偶尔 NaN | 90.8% |
| CosineLRScheduler,warmup=5 epoch | 稳定流畅 | 92.8% |
这个对比说明两件事:其一,MultiStepLR需要你事先挑选 milestone,挑得好精度不错,挑不好就得反复试;其二,CosineLRScheduler 本身收敛性很好,但必须配合 warmup 才发挥得出来。加了 warmup 的余弦退火,在几乎不用调参的前提下就能达到甚至超过手调 milestone 的效果。
4.4 一个容易忽略的点:scheduler 与 optimizer 的 lr 同步
PyTorch 里调度器是通过修改optimizer.param_groups中的lr字段生效的。如果你在训练循环中手动修改了optimizer.param_groups[0]['lr'],调度器内部维护的曲线状态并不会自动同步,两边就会互相覆盖,最终学习率走向完全不可控。我见过有人在训练循环里加“如果 val loss 连续 3 个 epoch 不降就手动把 lr 减半”的逻辑,和 CosineLRScheduler 混用,结果曲线乱七八糟。
建议:如果用 CosineLRScheduler,就完全信任它,不要在循环里额外手动改学习率。如果非要加 ReduceLROnPlateau 那种“根据指标动态调整”的策略,那就不要同时用 CosineLRScheduler,二选一。
5. 真实踩坑记录:排查调度器“失效”的完整链路
5.1 坑一:学习率根本不下降,训练后期等于“原地罚站”
现象:训练 80 个 epoch 后,loss 平台期一直不动,打印optimizer.param_groups[0]['lr']发现始终是初始值1e-3。
排查链路:
- 先确认调度器有没有被调用。很多人建了 scheduler 对象,但忘了在训练循环里写
scheduler.step()。这是最常见的原因。 - 确认调用方式。如果你的
t_in_epochs=True,只在每个 epoch 结束调用scheduler.step(epoch)即可;如果你按 step 调度,则每个 batch 后要调scheduler.step_update(global_step)。漏一个,曲线就会卡住。 - 检查
t_initial是否远大于训练总步数。如果你设置了t_initial=1000,但实际只训练 100 个 epoch,那么训练结束时余弦函数才走了 10%,学习率自然几乎没降。这种情况不叫“调度器失效”,而是“调度器和训练长度不匹配”。
我平时排查这类问题的标准动件是:每 5 个 epoch 打印一次 lr,和预期曲线对照。如果从一开始 lr 就不动,那是调用问题;如果开始动了但降得太慢,那是周期长度问题;如果降到某个值又突然回去了,那是周期重启问题。
5.2 坑二:warmup 结束时学习率出现“跳崖”
现象:warmup 从1e-6线性升了 5 个 epoch,到第 6 个 epoch 时学习率突然从接近1e-3的位置掉到很低,或者反过来出现一个尖刺。
排查链路:
这个问题的根源是warmup_prefix的设置。warmup_prefix=False时,warmup 阶段不算入t_initial,warmup 结束后的学习率应当等于峰值lr_max,随后从峰值开始余弦衰减,衔接是平滑的。warmup_prefix=True时,warmup 占据t_initial的一部分,余弦曲线会“提前开始”,warmup 结束点对应的学习率可能已经不是峰值,而是余弦曲线中途的值,于是出现明显跳变。
解决建议:绝大多数场景下,用warmup_prefix=False就好。它的语义更符合直觉——先用 N 个 epoch 热身到峰值,然后完整地走一条长度为t_initial的余弦曲线。如果你确实需要 warmup 计入总时间,务必先画图确认衔接点,不要凭感觉。
5.3 坑三:中间改了 batch size,学习率曲线彻底错乱
现象:训练到 40 个 epoch,为了省时间把 batch size 从 128 调到 256,之后验证准确率剧烈波动,loss 不降反升。
排查链路:
这是按 step 调度最容易踩的坑。你的t_initial=len(train_loader)*total_epochs,是拿“batch size=128 时每个 epoch 的 step 数”算出来的。改成 batch size=256 后,一个 epoch 的 step 数直接减半,总训练步数也减半,但t_initial还是原来的值,余弦曲线的进度直接错位。而且,batch size 翻倍本身也相当于改变了优化器的动态,学习率应该相应调整,但你完全没动,训练自然失控。
两个解决办法:
- 按 epoch 调度,把
t_in_epochs=True,这样 mid-training 改 batch size 不会影响调度器的周期长度,只影响每个 epoch 内部的 batch 数。 - 如果必须按 step 调度,改 batch size 的同时重新计算
t_initial和当前已走的步数,并且通常需要同步调整学习率(batch size 翻倍时初始 lr 也相应调大)。
我是从那次踩坑之后,把所有中小规模任务全部改成按 epoch 调度,省心太多。
5.4 坑四:早停恢复后,调度器状态和模型权重不匹配
现象:训练中用了 early stopping,保存了第 60 个 epoch 的最佳模型。后来 val acc 连续 20 个 epoch 没涨,你选择恢复到第 60 个 epoch 的权重继续训练,但发现 loss 直接从低谷开始反弹,效果还不如不恢复。
排查链路:
只恢复了模型权重,没有恢复调度器状态。第 60 个 epoch 的模型权重是在当时的学习率(假设是3e-4)下训练出来的,你恢复权重后如果调度器还停留在第 80 个 epoch 的状态(学习率已经降到2e-5),那模型就会在“大权重 + 极小步长”的错配状态下继续跑,既跳不出当前区域,又在已经收敛的位置反复打磨噪声。
解决办法:保存 checkpoint 时,把scheduler.state_dict()一起存下来,恢复时一并加载。如果你用的框架不直接支持调度器序列化,至少要在恢复后手动把optimizer.param_groups[0]['lr']设回最佳 epoch 对应的学习率。
5.5 坑五:lr_min 设为 0,后期模型反而变差
现象:训练后期 loss 一直在下降,但验证准确率出现下滑,最后几个 epoch 掉得特别明显。
排查链路:
余弦曲线的最后阶段学习率非常小,几乎趋近于 0。如果lr_min=0,最后的更新步长近似于“原地踏步”。这时如果数据集里有噪声标签或少量难样本,模型会在最后阶段拼命拟合这部分数据,验证集上出现轻微过拟合。此外,如果你的优化器带了较大的 weight decay,学习率降到极低时,梯度更新对参数的修正力量远小于正则化力量,参数会被缓慢地“压向”零附近,loss 反而上升。
我的经验是:lr_min不要设 0,设成初始学习率的 0.01 倍左右比较稳妥。比如初始 lr 是1e-3,那lr_min=1e-5就足够了。保留一点底线的更新能力,既不影响最后的精细收敛,又能避免后期完全失控。
6. 进阶玩法与个人经验
6.1 带重启的余弦退火:什么时候用 cycle_mul 和 cycle_decay
标准 CosineLRScheduler 是单周期平滑下降。但有时候我们不想一次走到黑,而是希望学习率在训练过程中周期性“重启”,让模型有机会跳出已经陷入的局部最优,再从头搜索一轮。这就是带重启的余弦退火(Cosine Annealing with Warm Restarts)。
timm 里这个功能是通过cycle_mul和cycle_decay实现的。cycle_mul控制每个周期的长度倍数,cycle_decay控制每个周期峰值学习率的衰减倍率。举个例子:
scheduler = CosineLRScheduler( optimizer, t_initial=20, # 第一个周期长度 cycle_mul=2.0, # 第二个周期长度变为 40 cycle_decay=0.5, # 第二个周期峰值 lr 变为原来的 0.5 cycle_limit=5, # 最多 5 个周期 warmup_t=2, warmup_lr_init=1e-6, t_in_epochs=True, )这样的曲线会呈现“长周期-短峰值”的波浪状:第一个周期 20 个 epoch 从峰值降到谷底,然后回到峰值的一半;第二个周期 40 个 epoch 再次降到谷底,峰值再减半;依此类推。每个新周期都给模型一次“重新洗牌”的机会,同时因为峰值逐周期递减,整体趋势仍然是收敛的。
我在一些模型结构较深、loss landscape 比较崎岖的任务(比如 Transformer 类的序列模型)上试过多周期余弦退火,确实能看到帮助跳出局部最优的效果。但在绝大多数图像分类任务上,单周期已经足够好,多周期反而拖长训练时间。我的建议是:默认单周期,只有当你发现模型反复收敛到同一个不理想的结果、且明显处于欠拟合状态时,再尝试带重启的变体。
6.2 与 EMA、SWA 等收敛后处理技巧的配合
余弦退火后期学习率很低,模型权重在局部最优附近小幅度震荡。这时候有两个后处理技巧经常和它配合使用:
- EMA(指数移动平均):对模型权重做滑动平均,相当于把最近几个 epoch 的权重做了平滑,可以压制震荡带来的噪声。CosineLRScheduler 的平滑下降曲线和 EMA 天然兼容,因为后期学习率变化极其平缓,EMA 的窗口不会因为调度器突变而失效。
- SWA(随机权重平均):在训练最后阶段,以固定间隔采集多个 checkpoint 的权重做平均。SWA 要求采集点处于学习率较低的区域,CosineLRScheduler 的最后 10%~20% 阶段正好满足这个条件。
我的实际操作中,CosineLRScheduler + EMA 通常能再带来 0.2~0.5 个百分点的验证精度提升,而且几乎不增加训练成本。如果你项目对精度要求比较高,很推荐在这个方向上加一层。
6.3 混合精度训练和数据并行下的注意事项
用 AMP(Automatic Mixed Precision)训练时,调度器本身不受影响,因为它只修改param_groups里的lr字段。但有一点需要留意:AMP 的 GradScaler 会在 loss 出现 NaN/Inf 时跳过梯度更新,这时候 optimizer 不会真正执行 step,但如果你在optimizer.step()之后无条件地调用了scheduler.step_update(),学习率步数就会比真实更新步数多,导致曲线比预期走得快。
解决办法很简单:把scheduler.step_update()放在scaler.scale(loss).backward()和scaler.step(optimizer)成功执行之后再调用。严格来说,需要配合scaler.update()和optimizer的_step_count来判断,但工程上更简单的做法是:尽量按 epoch 调度,一个 epoch 结束再scheduler.step(epoch),这样即使中间跳过了几个 batch 的更新,epoch 维度的学习率状态也不会有太大偏差。
数据并行(DataParallel/DistributedDataParallel)不影响调度器,但要注意:DDP 模式下,每个进程都会实例化一个调度器,状态也是一致的,只要保证所有进程调用scheduler.step的时机一致即可,不存在额外的同步问题。
6.4 我的收官习惯
最后分享一个我自己一直在用的套路。拿到一个新任务、新模型,我默认的“第一版训练配置”几乎永远是:AdamW + 初始学习率按 batch size 折算(batch 1024 时大约1e-3,再按比例微调)+ 5% 总长度的 warmup + CosineLRScheduler 单周期 +lr_min设为初始 lr 的 0.01 倍。这个组合在图像分类、目标检测、文本分类、对比学习等一大票任务上都表现稳定,基本不会翻车。
先跑通一版,拿到一个可用的 baseline,之后再根据验证集表现决定要不要换MultiStepLR、要不要加周期重启、要不要上 SWA。很多调参新手喜欢一上来就在调度器上玩花活,我反而是先固定一套最稳的配置,用最小成本排除“调度器配置错乱”这个变量,再去一个个调其他超参数。等你把余弦退火的标准姿势练熟了,再谈个性化的进阶玩法也不迟。