我不确定你是否有这种感觉:模型搬上GPU,loss像模像样地往下降,可降了几个epoch之后就像被按住了脖子,怎么调都上不去。这时候大家的第一反应往往是“改模型结构”“换优化器”,但很少有人意识到,元凶可能只是那个从头到尾纹丝不动的学习率。我也在踩过这个坑之后开始系统尝试各种学习率调度器,最后被torch.optim.lr_scheduler.CosineAnnealingWarmRestarts这个带“热重启”的余弦退火策略留下来用了很久。这篇文章就是想把它的原理、用法、参数细节和我在实际训练中踩过的坑一次性讲清楚,给正在跟训练收敛死磕的人一个能直接上手的参考答案。
1. 一个正在悄悄拖垮训练潜力的固定学习率
1.1 固定学习率的隐蔽困境
固定学习率看起来最省心,但它的矛盾非常明显:学习率设大了,训练初期loss下降很快,可接近收敛时参数会在最优解附近来回震荡,根本稳定不下来;学习率设小了,初期走了很久还在原地转悠,训练时间翻倍,效果还不一定好。很多人会用“手动降压”的办法,比如每训练30轮把学习率乘个0.1,这本质上就是在模仿调度器,只是颗粒度太粗,而且容易错过最佳切换点。
更麻烦的是,深度网络的误差曲面远比想象中复杂。它不是一个平滑的碗,而是布满了大大小小的局部极小值、鞍点和陡峭的峡谷。固定学习率一旦策略不对,模型很可能被困在一个不太好的盆地中。这也是为什么学界和工业界都在用调度器:通过周期性地调整学习率,让优化器在“快速探索”和“精调收敛”之间切换。
1.2 从“一步到位”到“热重启”的进化逻辑
早期的调度器大多是单调衰减式,比如StepLR每隔多少轮降一次,或者ExponentialLR指数衰减。这类调度的共同点是学习率只降不升,训练节奏是“从快到慢,一锤子买卖”。后来大家发现,如果学习率偶尔跳回一个较大的值,让模型有重新“弹出来”的机会,反而有可能跳出之前的坏局部极小值,找到更好的解。于是出现了各种重启式的调度策略,而余弦退火配合热重启就是其中表现相当稳定的一种。
torch.optim.lr_scheduler.CosineAnnealingWarmRestarts在PyTorch中实现的就是这个思路:它让学习率按照余弦函数从最大值降到最小值,然后在指定的间隔后猛然回升到最大值,再开始下一轮余弦下降。这种“下降—回升—再下降”的节奏,相当于给了模型一次次重新探索的机会。
2. 余弦退火与热重启的机制拆解
2.1 余弦退火的数学内涵
我们不用去背那堆希腊字母,也不难理解它的核心公式。调度器在某个周期内,学习率随迭代次数变化的关系是:
lr = eta_min + (base_lr - eta_min) / 2 * (1 + cos(pi * T_cur / T_i))
其中base_lr是初始学习率(通常是你喂给优化器的那个lr),eta_min是最低学习率下限,T_i是当前周期的总步数,T_cur是当前已经走过的步数。当T_cur=0时,余弦值为1,学习率是base_lr;随着T_cur增长到T_i,余弦值降到-1,学习率变成eta_min。
这里有意思的是余弦函数在0到π之间的形状:在靠近两端时斜率很小,学习率变化缓慢,有助于精细收敛;在中间段斜率较大,学习率下降快,实现快速扫荡。相比线性衰减或者阶梯衰减,余弦曲线更平滑,没有突变,训练过程更稳定。
2.2 热重启的“突然跳变”带来了什么
热重启的关键动作是:一个周期结束后,T_cur被重置为0,学习率瞬间从eta_min跳回base_lr。这个跳变会让模型参数在一段时间内受到较大步长的扰动,等效于引入了某种“随机重启”。在视觉任务里,我经常看到第一次重启后loss曲线会出现一个明显的小山峰,然后快速下降到比之前更低的水平。
这背后的原理并不神秘:较大的学习率会把参数从当前局部极小值区域推出去,让优化器有机会探索更广阔的损失平面;随后再经过一次完整的余弦衰减,模型会被引导到一个新的、更优的极小值附近。如果新探索区域的loss更低,那么这一轮热重启就是净赚;即便新区域不理想,训练也只会叠加一些震荡,通常不会比固定学习率更差。
2.3 与CosineAnnealingLR的分水岭
别忘了CosineAnnealingLR这个更早的调度器。它和CosineAnnealingWarmRestarts都使用余弦退火,但前者是“一条道走到黑”,学习率从base_lr单调降到eta_min后就不再变化。后者则在每个T_i步之后重置,周期性地回升。这个区别导致二者适用场景不同:如果你只是想将训练收尾阶段打磨得更细腻,用CosineAnnealingLR就够;如果模型容量很大、数据分布复杂,或者你感觉训练容易困在坏的局部解里,带重启的版本通常效果更好。
3. 动手用起来:初始化、参数选择与完整示例
3.1 参数逐个拆解
这个调度器的构造函数需要你重点关注四个参数,它们的含义和常见坑我都列在下面的表里。
| 参数 | 含义 | 典型设置 |
|---|---|---|
T_0 | 第一次热重启前经历多少次scheduler.step()调用 | 如果是按epoch调用,建议设成训练总epoch数的30%~50% |
T_mult | 每次重启后周期长度放大倍数 | 默认1,表示每个周期长度相同;建议尝试2 |
eta_min | 学习率下降的最低下限 | 默认0,通常设成base_lr的千分之一或0 |
last_epoch | 恢复训练时用的起始步数索引 | 默认-1,表示从头开始 |
T_mult这个参数非常有意思。当T_mult>1时,周期长度会依次递增:第i次重启后的周期长度为T_i = T_0 * T_mult^i。也就是说模型每次重启后,会以更长的时间去探索,这符合“初期快速试错,后期精细寻优”的训练直觉。但我第一次用时忽略了T_mult对训练步数的整体影响,导致实际epoch数远不够用,后面会细说。
3.2 一个可运行的小实验:在CIFAR-10上对比固定LR和WarmRestarts
为了让讲解不悬空,我搭了一个非常小的对比实验:用一枚轻量级卷积网络在CIFAR-10上从零训练50个epoch,batch size为128,优化器用SGD+momentum,初始学习率0.1,分别测试固定学习率、CosineAnnealingLR和CosineAnnealingWarmRestarts三种策略。
代码骨架如下,先创建一个简单的网络和优化器:
import torch import torch.nn as nn import torch.optim as optim import torch.optim.lr_scheduler as lr_scheduler import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) train_set = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train) train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=2, pin_memory=True) class SimpleNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Linear(128*4*4, 10) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) return self.classifier(x)然后是三组训练逻辑的骨架,其中固定学习率不创建任何调度器,其余两组各自创建调度器:
model = SimpleNet().cuda() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) # 固定LR:什么都不加 # cosine_anneal: scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=0) scheduler = lr_scheduler.CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2, eta_min=1e-4) criterion = nn.CrossEntropyLoss() for epoch in range(50): model.train() for inputs, targets in train_loader: inputs, targets = inputs.cuda(), targets.cuda() optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() scheduler.step() # 每个batch更新一次注意这里的scheduler.step()是在每个batch之后调用的,因为T_0的单位是“调度器step次数”,而不是epoch数。如果你习惯于每个epoch只调用一次,那就需要把T_0理解为epoch数,并把循环内部的调用移到每个epoch结束后。对于热重启调度器,我强烈建议每个batch都调用,这样周期的颗粒度更细,学习率曲线更平滑,实验也更可控。
在我这个50epoch、batch数量约为391的条件下,设置T_0=10*391可能更合理,但为了代码简洁,上面的写法用的是“每个batch调用一次 + T_0=10”,这实际上相当于大约每0.025个epoch就重启一次,太频繁了。真实项目中需要算一下总step数。
我把这种做法原样放上来,是想提醒你:T_0的单位是调度器step()的调用次数,不是epoch数。如果你在epoch循环外调用一次scheduler.step(),它就是epoch数;如果放在batch循环内,它就是batch迭代数。很多人一上来就把T_0=50丢进去,结果每个batch更新一次,模型每几十步就重启一次,学习率高频震荡,loss完全跑飞,就以为是调度器有问题。
3.3 如何确定自己到底该在batch还是epoch后调用
判断标准只有一个:你想让学习率曲线以多细的颗粒度变化。如果你的训练集很大,一个epoch有数千个batch,而你希望一个周期覆盖10个epoch,那么就应该每个batch调用一次,并设置T_0=10*num_batches;如果你偏爱简洁,每个epoch调用一次,那么T_0直接填写希望经历的epoch数即可。
从最终效果看,每个batch调用会让学习率曲线在连续的空间内变化,模型在每个小batch之后都改变步长,往往比每epoch整体跳跃更平滑。代价就是代码里多写一行,以及计算T_0时要先算好batch数量。
4. 调参实战:T_0、T_mult、eta_min怎么搭配才靠谱
4.1 T_0的选择策略
T_0决定了第一次重启之前模型以完整初始学习率探索多久。如果设得太小,比如小到不足一个epoch,学习率就会在高位反复起步,模型始终处于剧烈震荡中,很难收敛。如果设得太大,第一次周期占用太久,导致整个训练过程只完成了很少几个重启点,热重启的优势发挥不出来。
我在图像分类任务上的经验是:如果总共训练K个epoch,按epoch调用,初始T_0设在K/4到K/3之间。比如训练100轮,T_0取25~30。这样既能让模型在前几十轮用较高的学习率脱离初始点,也有足够空间完成至少三到四轮热重启。如果你的数据集很小,K比较小,T_0可以适当加大,比如K的一半,否则模型还没学到多少就开始频繁重启,可能学不稳。
4.2 T_mult>1的作用与风险
T_mult默认是1,也就是每个周期等长。设为2之后,周期长度分别是T_0、2T_0、4T_0、8*T_0...这意味着模型越到后面的周期,探索的时间越长,学习率下降得越缓慢,有助于后期深度精调。
但是风险也在这里:如果不控制总训练步数,周期无限变长,最后一个周期可能占到总训练的很大比例,导致热重启次数很少。比如总步数为1000,T_0=100,T_mult=2,那么第一个周期100步,第二个200步,第三个400步,加起来已经700步,第四个周期需要800步但只剩300步,也就是第四次重启根本走不完一个完整周期。这会影响最终学习率是否降到eta_min附近。所以当你调大T_mult时,总训练步数要预留得足够多,或者根据周期公式反推出总训练轮数。
4.3 eta_min设置的经验
eta_min不建议直接设成0。虽然官方默认是0,但在浮点环境下学习率变成0会让优化器的梯度更新完全失效,还可能让BatchNorm统计量出现奇异行为。我习惯设成初始学习率的1/1000或1/500。比如初始lr=0.1,eta_min=1e-4,或者保守一点取1e-3。如果你使用Adam这类自适应优化器,初始学习率本身就比较小,eta_min可以设成初始的1/10到1/100。
另外要注意,当调度器让学习率在最后阶段非常接近eta_min时,模型几乎是在做纯随机梯度采样,loss曲线可能出现平台。这很正常,不代表训练失败。
5. 踩坑日志:用CosineAnnealingWarmRestarts时我遇到过的五件事
5.1 scheduler.step()的调用时机混乱
很多从StepLR转过来的同学会习惯性地在epoch结束后调用scheduler.step(),这没问题。但如果你把T_0的值理解成了epoch数,实际上却是按batch调用,或者反过来,训练曲线就会表现得毫无规律,甚至直接发散。我建议每次改动调度器代码时,手动打印前几百个step的学习率,直观确认变化节奏对不对。
5.2 热重启后loss突然升高,别慌
我见过不少同事第一次看到热重启后的loss瞬间涨了一大截,第一反应是“模型坏了”,立刻把调度器停掉。其实这是热重启的正常现象,学习率从极低值跳到较高值,相当于重新开始大步搜索,loss短期反弹完全可预期。关键是观察之后几轮是否在更低的水平上收敛。如果每次重启后loss都能重回之前的低位甚至更低,那说明这个调度器正在发挥作用。
5.3 与优化器参数组的关系
如果你的优化器包含多个参数组,比如骨干网络采用较小学习率,分类头采用较大学习率,那么调度器会对所有参数组施加相同的余弦退化节奏,但各组的基础学习率保持不变。也就是说,各参数组的学习率会按各自base_lr乘上相同的余弦系数。这通常没问题,但你要清楚:调base_lr会影响余弦波动的上限,而不是整体的形状。如果想让不同参数组拥有不同的周期,那么需要构造多个调度器分别绑定不同优化器,操作上要复杂很多。
5.4 恢复训练时的last_epoch陷阱
深度学习的训练往往会中途断电,或者你想临时提高学习率继续训练。这时候如果直接创建一个新的调度器对象,不指定last_epoch,学习率就会重新从第一次开始。正确的做法是保存调度器状态字典,以及当前的epoch/step计数,恢复训练时用scheduler.load_state_dict(...)并正确传入last_epoch。但更稳妥的办法是:训练过程中每个epoch结束都保存scheduler.state_dict(),恢复时先构造调度器,再加载状态。
5.5 与ReduceLROnPlateau混用的冲突
ReduceLROnPlateau是一个基于验证集指标动态调整的调度器,它要求每次调用scheduler.step(metrics)传入loss指标。有人会想把它和WarmRestarts叠加在一起用,先跑几个周期的余弦重启,然后切到ReduceLROnPlateau。这种混用不是不行,但要注意两者都会改写优化器各参数组的lr字段。如果你在同一个优化器上先后挂了两个调度器,PyTorch的调度器默认不允许这样玩,后一个调度器会覆盖前一个的base_lrs和last_lr。建议这种场景下干脆不用调度器,或者自己写一个逻辑更清晰的自定义调度器,而不是让两个官方调度器争抢控制权。
6. 什么时候不该用它?和其他调度器的横向对比
6.1 常见调度器的适用边界
这里我不打算把所有调度器都列一遍,只挑几个最常和我混用的做对比:
| 调度器 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
StepLR | 简单、可控性强,手动指定衰减epoch | 阶梯跳变,不稳定 | 小规模实验、baseline |
CosineAnnealingLR | 平滑单调衰减,无跳变,适合扎实收敛 | 没有探索能力,过早陷入最终阶段 | 训练周期固定且较短 |
WarmRestarts | 周期探索能力强,不易陷入坏局部解 | 参数多,需要设置T_0、T_mult | 大数据集、训练周期长、模型容量大 |
ReduceLROnPlateau | 自适应,看验证集表现 | 指标噪声大时容易抖动,需要调patience | 较难提前制定学习率预算时 |
6.2 根据训练节奏选择
如果训练预算固定且偏短,比如只跑20-30个epoch,用CosineAnnealingLR往往比WarmRestarts更省心,因为WarmRestarts可能在最后一个周期来不及降好就被强制停掉。如果训练周期在50个epoch以上,且你有把握调好T_0和T_mult,那么WarmRestarts有更大概率把模型推向更好的极值点。如果你训练过程中可以随时观察验证集,且不想预先设定精确的周期,也可以考虑ReduceLROnPlateau作为兜底。
6.3 我的最终建议
我给团队内部定的默认配方是:先用CosineAnnealingWarmRestarts作为主力,初始学习率按经验设为0.1或0.05(配合SGD),T_0设成训练总步数的1/3,T_mult设为2,eta_min设为初始学习率的1/1000。这个配置不是万能,但至少能保证大多数视觉分类、语义分割任务在训练初期不失控,后期又能精调。如果你用Adam,初始学习率通常低于0.01,WarmRestarts的余弦幅度也会变得很小,这时热重启的探索优势会被削弱,可以考虑直接使用CosineAnnealingLR或者干脆不调学习率。
最后分享一个我在实际项目中的体会:调度器本质上是在和优化器一起做“全局寻优”,它的作用不只是让学习率降下去,更是给模型多次跳出次优解的机会。WarmRestarts是我用过最接近“自动帮你多做几次局部随机搜索”的官方调度器。但它的效果高度依赖你设置的周期与总训练步数的比例,如果连T_0到底代表多少个step都没弄清,再好的调度器也会被用废。先花十分钟打印学习率曲线,再花一小时跑对比实验,你很快就会摸清手头任务到底吃不吃这一套。