ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OneCycleLR 学习率调度实战:告别手动退火,加速深度学习训练

OneCycleLR 学习率调度实战:告别手动退火,加速深度学习训练

做深度学习训练的人,多半都经历过调学习率调到怀疑人生的阶段:固定学习率要么训不动要么发散,手动分段衰减又很难拿捏降幅和时机。前年我在某图像分类项目里一直卡在准确率平台期,换了几个常规调度器都没什么起色,后来换成torch.optim.lr_scheduler.OneCycleLR,同样的模型和数据集,只用原来大约六成的 epoch 就跨过了平台,最终指标还涨了一点。这之后它基本成了我训练视觉模型的默认调度器。这篇文章就围绕它展开,讲清楚调度逻辑、每个参数背后在控制什么、怎么和 DataLoader 正确配合,以及我实际项目里踩过的几个坑。准备换掉手动退火策略的人,或者刚打开官方文档被一堆参数弄懵的人,都可以参考一下。

1. 平台期不是模型的错:一个“训练策略”层面的大改动

1.1 固定学习率和分段衰减的老毛病

先说我在换调度器之前依赖的两种老办法,它们的问题很有代表性。

第一种是固定学习率。学习率取小了,前几十个 epoch 进展慢得让人焦虑;取大了,后期 loss 时常震荡,甚至直接发散。你只能在“慢”和“抖”之间选一边。第二种是分段衰减,也就是训练到某个里程碑把学习率乘以 0.1。这类策略看起来更主动,但真正的坑在于每个阶段的边界完全靠经验猜。衰减太早,模型会被锁在当前的局部区域里,后面的训练就是在原地打磨;衰减太晚,前面浪费的时间又捡不回来。更麻烦的是,一旦学习率降下去就再也升不回来了,如果你中途发现这个 basin 不够好,整个训练几乎重来。

当时我很自然地认为问题出在模型容量、数据增强或者初始化上,直到试了 OneCycleLR 才意识到,训练策略本身对收敛速度的影响被严重低估了。它不需要你手动规划衰减里程碑,而是把“升温—探索—退火”压缩到一个完整训练周期里,让学习率自己走完一条精心设计的路线。

1.2 超级收敛:先冲高再退回来为什么能省时间

OneCycleLR 的底层依据是早期一篇关于超级收敛的论文。论文里观察到一类反直觉的现象:在训练早期就把学习率推到很高,再配合余弦退火慢慢降下来,模型不仅不会崩,反而能用更少的迭代次数收敛到更好的结果。很多人第一次看到这条曲线都会疑问:把学习率抬那么高,不怕 loss 爆炸吗?

关键在于“高位”持续的时间其实很短,而且前有 warmup 阶段做缓冲,后有余弦退火及时刹车。高学习率在数学上的作用相当于更大的参数更新步长,它帮助模型快速离开初始化的不利区域,跳到更宽的损失平面上去;等到阶段后期,低学习率负责在优良区域里精雕细琢。和固定学习率相比,它不追求全程稳定,而是让学习率在不同阶段执行不同任务,该闯的时候闯,该稳的时候稳。

这个结论在中小规模数据集和图像类任务上非常有效。我的经验是,它尤其擅长解决那种“前一半训得好,后一半平台期磨洋工”的情况。表面上看只是换了个调度器,实际是把训练的一大部分决策权交给了曲线本身。

2. 读懂 OneCycleLR 曲线:两段式、三段式与动量的反向摆

2.1 默认落在纸上的形状:一段线性爬坡接一段余弦下落

先把默认参数下的完整曲线拆开看。OneCycleLR的核心行为是:学习率先从初始值max_lr / div_factor开始,在总步数的前pct_start比例内线性上升到max_lr;之后在剩余步数里按余弦退火降到最终学习率max_lr / (div_factor * final_div_factor)。

用默认值代入可能更直观。div_factor默认是 25,final_div_factor默认是 10000,这意味着初始学习率是max_lr / 25,最终学习率是max_lr / 250000。如果你的max_lr=0.3,那么学习率从 0.012 起步,在 30% 的位置冲到 0.3,再一路降到 0.0000012 左右。这个最终值比初始值还要低两个数量级,目的是用极其精细的步长收尾。

余弦退火的具体形态是:final_lr + 0.5 * (max_lr - final_lr) * (1 + cos(pi * progress))。它和线性下降最大的区别是,前期降得慢、后期降得快,模型可以在高学习率区域多停留一段时间,避免过早进入精细调节导致错过平坦最优。在实际 loss 曲线上的观感就是:中段看起来像平台期,后段会突然明显下降,这个节奏是设计出来的,不是训练出了问题。

2.2 three_phase 打开后发生了什么

默认three_phase=False是两段式。如果你把它设为True,调度逻辑会变成三段:前pct_start的阶段上升,中间1 - 2 * pct_start的阶段维持在max_lr,最后pct_start的阶段快速从max_lr下降到最终学习率。我觉得它比较适合大批量训练场景,因为大批量下梯度估计更准,可以在高学习率平台期做更充分的探索,同时避免单纯两段式里余弦退火占掉太多步数、后期学习率过于稀薄的问题。

2.3 lr 和动量之间的跷跷板效应

容易被忽略的是,cycle_momentum参数默认是开启的。当学习率在爬坡时,优化器的动量会从max_momentum(默认 0.95)逐步降到base_momentum(默认 0.85);当学习率在退火时,动量再反向回升。一句话总结:学习率越高,动量越低;学习率越低,动量越高。

这个设计的思路很清晰。高学习率本身就意味着大步长,如果再叠加高动量,参数更新很容易冲过头;低学习率阶段步长很小,此时用更大的动量可以帮助模型平滑地滑向极小值。我一般在使用带momentum的优化器时会保留默认的 0.85/0.95,不需要额外调。但是如果你用的是 Adam 或者 AdamW,这个参数实际不起作用——Adam 内部的 beta 值不会跟着调度器走,把cycle_momentum关掉可以减少困惑。

3. 参数设置实战:不是简单把构造器填满

3.1 max_lr:最值得花时间的一个数

max_lr是整条曲线唯一的峰值,也是影响训练结果最直接的因素。它不是靠试固定学习率拍脑袋定的,我一向建议先用学习率范围测试大致找下限:用很小的学习率开始训练几个 batch,每步线性增大学习率,同时记录 loss。loss 在某个区间开始快速下降的拐点附近,就是max_lr的合理区域,然后取这个值的 1/3 到 1/2 作为初始猜测。

如果没有跑范围测试,也可以参考经验区间。SGD 配合 momentum 时,视觉任务里我一般从 0.1 到 0.3 起步;Adam 一族通常取 1e-3 到 3e-3;迁移学习或者微调场景要在原基础上再缩小 3 到 5 倍。判断max_lr是否过大的标准很简单:前几个 epoch 的 loss 如果出现 NaN 或者比初始化时高几个数量级,基本就是峰值太高了,把max_lr除以 3 再来。

3.2 div_factor 和 final_div_factor 如何框定三档学习率

div_factor控制的是初始学习率和峰值之间的倍数。默认 25 意味着初始学习率是峰值的 1/25,这是一个兼顾 warmup 节奏和训练速度的取值。如果你想更保守一点,可以把div_factor调大到 50,让曲线从更低的位置出发,warmup 过程更平缓;想走激进路线则调小到 10。

final_div_factor决定曲线最终落到多低。默认 10000 配合前面的 25,整体缩放了 25 万倍,最终学习率几乎归零。如果你的训练轮数较短,又希望模型最后还有一点移动能力,可以把final_div_factor调低到 100 或者 10,最终学习率会对应升高。不过需要提醒的是,这么多缩放倍数乘在一起,初始学习率其实已经被压得很低了,千万不要看到曲线是从很低的地方起步就以为调度器出故障。

3.3 pct_start、anneal_strategy 和 three_phase 的组合选择

pct_start决定上升阶段占全程的比例。默认 0.3 是一个通用推荐值。我自己的经验是:如果你的数据集很小,或者是在大型预训练模型上微调,可以把pct_start调到 0.4 甚至 0.5,让模型有足够长的时间适应新的数据分布;如果数据集很大、训练轮数又很短,0.2 左右的快速 warmup 反而更好。

anneal_strategy只有'cos'和'linear'两个可选值。线性退火简单直接,曲线下降快,适合追求速度、不太在意最终精度的 baseline;余弦退火默认且通常效果更稳,后期下降速率由快变慢,模型能在接近最终位置附近多逗留一会儿。视觉分类这类追求精度的任务,我基本只选'cos'。

这四个参数不是孤立的。我的建议是:先定max_lr,再按默认的 25 和 10000 把整体框架搭起来,接着只动pct_start,最后再考虑three_phase和线性退火。一次只改一个变量,否则曲线形状的归因会非常难做。

4. 完整跑通训练循环:步数计算与调度器 step 时机

4.1 total_steps 怎么算:普通单卡、分布式与梯度累积

OneCycleLR需要预先知道一个完整训练周期里会发生多少次优化器更新,所以第一步就是把训练长度换算成步数。最常规的计算方法是:

import math total_samples = 60000 batch_size = 256 epochs = 40 steps_per_epoch = math.ceil(total_samples / batch_size) total_steps = steps_per_epoch * epochs

total_steps和steps_per_epoch都可以直接传给构造器,两者二选一。注意最后一个 batch 如果不足一个完整 batch 也要算一次更新,所以用math.ceil而不是除法直接取整。少算了末端不完整的 batch,调度器就会提前把学习率归零,最后几个 epoch 等于在冻结的模型上瞎磨。

如果跑分布式训练,每个进程的 DataLoader 只吃一部分数据,不能直接拿本地 batch 数去乘 epoch。应该先算全局有效 batch 数,再传入构建的 total_steps。我最初犯过的错误是本地算了一遍,结果分布式环境里调度器提前走完,后面全部白训。

还需要特别留意梯度累积。很多人用梯度累积模拟大批量,代码里写着每accum_iter个 batch 才调用一次optimizer.step(),但调度器如果也跟着每个 batchstep()一次,学习率推进速度会是实际更新速度的accum_iter倍。正确做法是让调度器也只在实际执行optimizer.step()的地方调用。

4.2 一个可以直接抄的训练循环骨架

下面是一个结合 SGD + momentum 的完整示例,这也是视觉任务里我最常用的组合:

import math import torch import torch.nn as nn from torch.optim import SGD from torch.optim.lr_scheduler import OneCycleLR from torch.utils.data import DataLoader # 假设模型已经定义好,train_loader 已经构建 train_loader = DataLoader(dataset, batch_size=256, shuffle=True) model = define_model() optimizer = SGD( model.parameters(), lr=0.1, # 会被调度器覆盖,真正生效的是 max_lr momentum=0.9, weight_decay=5e-4, ) steps_per_epoch = math.ceil(len(train_dataset) / 256) epochs = 40 scheduler = OneCycleLR( optimizer, max_lr=0.3, total_steps=steps_per_epoch * epochs, pct_start=0.3, anneal_strategy="cos", cycle_momentum=True, base_momentum=0.85, max_momentum=0.95, div_factor=25, final_div_factor=1e4, ) for epoch in range(epochs): model.train() for batch_idx, (inputs, targets) in enumerate(train_loader): optimizer.zero_grad() outputs = model(inputs) loss = loss_fn(outputs, targets) loss.backward() optimizer.step() scheduler.step() # 关键:每个 batch 更新一次,不是每个 epoch

如果你是 Adam 类的优化器,记得把cycle_momentum设为 False,max_lr换成 1e-3 到 3e-3 量级,其余不变。

4.3 为什么不能等到 epoch 结束再 step

这是 OneCycleLR 使用者最容易踩的时机问题。CosineAnnealingLR、ReduceLROnPlateau这类调度器要么以 epoch 为粒度,要么在 epoch 结束观察指标,于是很多人下意识地在 epoch 结束后调用scheduler.step()。但 OneCycleLR 的曲线是把总步数当作横轴的,total_steps里计入的是每个 batch 的更新次数,而不是 epoch 数。如果你只在每个 epoch 结束时 step 一次,学习率推进速度会慢 n 倍,整个周期会变得严重拉长,退火节奏全部错乱。

判断方法很简单:把学习率打印出来,看它在训练前 30% 的更新步数内是否从初始值平稳爬到了max_lr。如果你的学习率在 epochs 维度上看起来迟迟不涨,就是 step 时机错了。

5. 实际项目里帮我省时间的几个排查记录

5.1 少算不完整 batch:最后阶段学习率提前归零

某个检测任务里,总样本数是奇数,我当时图省事用len(dataset) // batch_size来算步数,没有用向上取整。结果 OneCycleLR 比实际训练提前几个 batch 走完全部周期,最后一段学习率直接变成 0,训练日志里 loss 在末尾几乎纹丝不动。因为曲线已经走完,内部计数器不会等 DataLoader,所以后面无论再训多少轮都等于冻结。排查时我先打印了scheduler.get_last_lr(),发现学习率为 0,再回头用ceil重新计算,问题立刻消失。这里的关键教训是:总步数必须与实际优化器更新次数完全一致。

5.2 断点续训丢调度器状态:恢复后学习率重新冲高

还有一个更隐蔽的坑。一次训练被中断后,我从 checkpoint 里加载了模型权重和优化器状态,但忘了保存调度器状态。恢复训练后,OneCycleLR 从last_epoch=-1重新开始,学习率又回到初始值并慢慢爬向max_lr。在已经训练了一半的模型上突然用高学习率冲一次,loss 直接飙到接近初始化水平,相当于前功尽弃。正确做法是恢复时先创建优化器,再创建调度器,然后依次加载 optimizer state 和 scheduler state:

optimizer = SGD(model.parameters(), lr=0.1, momentum=0.9) scheduler = OneCycleLR(optimizer, max_lr=0.3, total_steps=total_steps) checkpoint = torch.load("checkpoint.pt") model.load_state_dict(checkpoint["model"]) optimizer.load_state_dict(checkpoint["optimizer"]) scheduler.load_state_dict(checkpoint["scheduler"])

这也是我后来养成的习惯:checkpoint 里至少保存model、optimizer、scheduler三个字段。

5.3 loss 在中段上升:是正常退火还是 max_lr 过高的误判

OneCycleLR 跑起来以后,我见过不少人在训练前半段结束时被 loss 突然的反弹吓到,以为是 bug。其实这条曲线的中段往往正是学习率处在最高位的区域,模型在高学习率下波动是预期行为。区分标准是看反弹幅度:如果 loss 只是比前一个平台高一点并且之后还能明显回落,那就是正常探索;如果 loss 直接涨到最初的数倍甚至出现 NaN,才需要怀疑max_lr过高。前者不需要干预,后者立刻把max_lr调小。

5.4 不同优化器和 batch size 下的适配

SGD + momentum 是 OneCycleLR 的天然搭档,因为cycle_momentum能发挥反向调度作用。Adam 系优化器本身自带自适应学习率,对峰值学习率的敏感度比 SGD 低,用 1e-3 附近的max_lr通常很安全,但cycle_momentum必须关闭,否则代码不报错、实际也不影响任何东西。batch size 变大时,我一般会把max_lr适当调大,因为更大的 batch 意味着梯度估计更稳定,可以承受更大的学习率;同时如果大批量导致训练总步数变少,pct_start可以相应调小,保证 warmup 阶段仍有足够的迭代次数。

6. 和普通退火调度差在哪:选型对比与最终建议

6.1 各类调度器的行为差异

我常被问到 OneCycleLR 和余弦退火到底谁更好,其实它们的适用场景不太一样。下面这个表格列的是我自己做选型时的对比视角:

维度OneCycleLRCosineAnnealingLRStepLR / 手动退火ReduceLROnPlateau
步数依赖需要预先知道 total_steps需要知道 T_max只看 epoch 边界不需要,随时响应指标
学习率形态先升后降,含 warmup从当前值单调下降后再重启阶梯式下降基于指标动态降低
训练长度固定性强固定中固定弱固定不固定
典型场景固定 epoch 的常规训练略长训练、追求精细收敛baseline / 对比实验难以预先确定训练长度
中断续训成本需要保存并恢复 scheduler state需要恢复 T_max 状态低成本低成本

OneCycleLR 很多时候看起来像 CosineAnnealingLR 加了一个 warmup 前缀,区别在于它的 warmup 是从低于初始学习率的地方起步,而且动量和学习率方向相反。CosineAnnealingLR 适合你不想动太多超参数、也不需要冲刺高学习率的场景;OneCycleLR 则天然适合训练 epoch 数相对固定、目标是在更少迭代里收敛的任务。

6.2 什么情况我建议别用它

有些人把 OneCycleLR 当万能药,其实有三类情况我不太建议直接套用。

第一,无法预先确定训练总步数。比如效果达到某个指标就提前停掉的早停场景,使用它会导致停的时候曲线还没走完,浪费掉后半段退火带来的精度提升。第二,数据量非常小。几十个样本的训练,任何花哨的调度策略都不如把基础学习率和数据增强做好。第三,模型本身不够稳定。比如 GAN 或者训练中容易出现数值溢出的模型,高学习率爬坡阶段会放大不稳定性,优先保证训练平稳更重要。

如果要用,我最后分享一个我一直坚持的小习惯:正式跑长训练之前,先取一小部分数据跑 3 到 5 个 epoch,把学习率和 loss 曲线打印出来,检查三件事——初始学习率是否约等于max_lr / 25、最高点是否出现在总步数 30% 左右、末段是否趋近一个极小的值。这三项确认无误,再开全量训练。这套检查流程帮我挡掉了至少两次因为忘记保存调度器状态而导致的长时间无效训练,我认为它值得每一个使用 OneCycleLR 的人保留。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进