整理深度学习笔记,正好写到第28讲的第一部分——学习率调度。说实话,这个主题我以前一直觉得“知道怎么用就行”,直到有次一个训练任务怎么都收敛不动,我才意识到自己对它的理解只停留在API层面。如果你也被这些问题困扰过:loss像心跳一样平稳震荡、训练曲线卡在某个值上死活不降、换了更大的模型之后原来那套参数突然全部失效,那这一讲的内容正是你需要的。
这篇先说明是写给谁看的:已经会写模型训练循环、想真正理解学习率调度原理的人;被loss震荡搞到怀疑人生的人;以及想把自己的训练流程从“能跑”提升到“靠谱”的人。我不堆论文公式,但会把为什么需要调度、每种策略到底在做什么、PyTorch里怎么落地讲透。A同学前两天跑分类实验,50个epoch下来loss卡在0.35附近像条直线,他第一反应是模型容量不够,其实是学习率在他看不到的地方悄悄毁掉了后半程训练。
1. 从一次“练不动的模型”说起:学习率调度的价值
1.1 固定学习率的困境
A同学用两层卷积网络在标准图像分类数据集上训练,SGD,学习率固定为0.1,动量0.9。前20个epoch loss降得很快,从2.3一路压到0.4附近,之后就开始不对劲了——loss在0.35到0.42之间来回跳动,像极了心电图,怎么等都不往下走。他试了加大batch、换初始化、加dropout,统统没用。
我让他把学习率改成0.01,在同样的模型上继续训练,loss又缓慢下降,最终到了0.2左右。问题不在模型容量,就在学习率。为什么同一个模型,同样的数据,仅仅把学习率调小,就能继续收敛?因为训练的不同阶段对参数更新步长的需求完全不同。初期参数离最优解很远,梯度幅值大,需要大步长快速靠近;后期参数已经进入一个相对不错的区域,真实梯度变小,如果步长还是那么大,参数就会在极小值附近来回振荡,永远落不到点上。
这就是固定学习率的本质困境:你只能在“前期大步长”和“后期小步长”之间选一个折中值。选了偏大的,后期震荡;选了偏小的,前期收敛慢到让人失去耐心。学习率调度做的事情很简单——把这条折中曲线变成动态的:前期走快点,后期走慢点。
1.2 学习率的双重面目:步长与噪声放大器
很多人把学习率单纯理解成“每步走多远”,这个理解不够。SGD每次更新用的梯度不是真实梯度,而是从mini-batch里估算出来的带噪声梯度:
theta <- theta - lr * grad_estimategrad_estimate = 真实梯度 + 随机噪声。学习率不只是步长缩放系数,它同时在缩放这个噪声的幅度。学习率越大,参数更新路径上的随机扰动越剧烈;学习率越小,扰动越微弱。
这对理解调度至关重要。训练前期,我们希望噪声大一点,因为随机扰动能帮助参数跳出浅的局部极小值,探索到更开阔的区域;训练后期,我们希望噪声越来越小,因为此时参数已经在一个较好的盆地里,噪声只会让它在这个盆地里晃荡,无法继续精确下降。学习率调度本质上是逐步调低这个噪声放大器,让优化过程从“粗探索”平滑过渡到“细收敛”。
这也是为什么自适应优化器如Adam看似不需要调度,实际却依然要配合衰减使用的原因——Adam按梯度幅值做了归一化,每个方向的更新步长相对固定,这削弱了梯度噪声天然缩小的趋势,所以后期同样需要外在调度来抑制噪声,否则一样会震荡。
2. 调度策略背后的梯度景观直觉
2.1 从空中看地形与落地细看
我们常把训练过程理解为“下坡找最低点”,但神经网络的损失曲面远比一个简单的碗要复杂。它更像一片你从未走过的山地:有巨大的山脉走势,也有密密麻麻的小坑小包。大步长就像站在飞机上看地形,你能看到大的盆地走向,但看不见脚下的碎石;小步长就像落地步行,每一步都踩得稳,但视野有限,容易走进一个不高不低的局部洼地就出不来了。
学习率调度做的是两者的折中:前半程用接近“飞机视角”的大步长,快速判断山势走向,跳开那些不值得停留的小坑;后半程切换成“步行视角”的小步长,在已经确定的盆地里精细下降。一个有意思的现象是,很多实验证明大学习率阶段走过的区域,最终收敛到的极值点往往泛化更好。这跟平坦极小值有关——大学习率的隐式正则化效应,让参数更容易落在“宽而浅”的平坦区域,而不是“窄而深”的尖锐区域。平坦极小值对新数据的扰动更鲁棒,泛化能力自然更好。
2.2 为什么需要衰减的三个真实理由
第一,减少参数空间的随机扰动。前面说了,mini-batch的梯度带噪声,学习率是噪声幅度乘子。训练后段如果不减小这个乘子,参数会一直在一个统计平衡点上抖动,无法逼近更优解。
第二,配合动量机制。带动量的SGD实际更新方向是历史梯度方向的指数加权平均,动量项天然有“加速过冲”的倾向。如果学习率固定,动量在平缓区域会累积出很大的速度,一旦遇到方向的剧烈变化就容易超调,冲过头再折返,训练曲线就会来回震荡。衰减学习率相当于给这辆高速行驶的车踩刹车,让动量积累的速度不至于失控。
第三,适应优化后期的几何特征。训练进入后期,参数往往处于一个相对平滑的谷底区域,这里的曲率变化变小,真实的梯度幅值也变小。此时固定的大学习率对应的是“在谷底跳来跳去”,而衰减后的学习率才能真正让参数留在谷底中心。这一点在与实践结合时尤为重要:如果你发现loss曲线在后期变成一条锯齿状的横线,第一反应不是换模型,而是检查学习率要不要衰减。
3. 主流调度策略对比:从Step到OneCycle
3.1 规则衰减的两员老将:StepLR与ExponentialLR
规则衰减是最好理解的两类策略。StepLR每隔固定epoch数把学习率乘以一个小于1的因子,阶梯式下降;ExponentialLR则是每个epoch都乘以一个略小于1的因子,平滑连续衰减。
| 策略 | 衰减方式 | 典型超参 | 优点 | 需要注意的问题 |
|---|---|---|---|---|
| StepLR | 每N个epoch乘gamma | step_size=30, gamma=0.1 | 简单、可控性强 | 阶梯突变,milestone选不好容易跳坏loss |
| ExponentialLR | 每个epoch乘gamma | gamma=0.95~0.98 | 平滑、无突变 | 容易衰减过快,后段学习率过小,训练进度停滞 |
| CosineAnnealingLR | 按余弦曲线从峰值降到最低 | T_max, eta_min | 无需选milestone,平滑自然 | 需要控制T_max与总epoch数匹配 |
| OneCycleLR | 先升后降,一个大周期 | max_lr, pct_start, div_factor | 兼顾探索与收敛,收敛快 | 学习率极值需要单独扫描,不适用所有任务 |
StepLR的问题在于阶梯的“台阶”选在哪。如果step_size设得太短,学习率还没发挥作用就骤降,训练直接被锁死在一个早期状态;设得太长,后期又会出现一段长期不衰减的震荡期。ExponentialLR看起来平滑,但每步都乘一个小于1的数,训练到后期学习率可能变成某个极小值,实际更新接近零,等于后面的epoch全部浪费。
这两者的共同设计思路是“提前制定一份时间表”,不关心模型实时训练状态。它们的特点是稳定,代价是僵硬。数据分布稳定、训练过程规整的任务,它们完全够用;但遇到loss下降路径不那么规律的任务,你会发现问题不在模型,而在时间表本身。
3.2 Cosine:让学习率按三角函数自然呼吸
CosineAnnealingLR是我现在用得最多的默认选择。它的公式朴素:eta_t = eta_min + 0.5 * (eta_max - eta_min) * (1 + cos(pi * t / T_max)),其中T_max是总周期长度,eta_max是初始学习率,eta_min是下限。视觉上的效果是:训练前期学习率缓慢下降,中段加速下降,末端又趋于平缓。
这个曲线的妙处在于它没有需要人工挑选的milestone。StepLR的gamma和step_size需要你猜,但Cosine只需要知道训练多少个epoch,曲线就自动铺开了。训练中段学习率快速下降,对应的是模型大幅调整参数的时期;训练末期学习率趋近于最低点,对应的是精细收敛阶段。大量预训练模型的微调实验里,Cosine配合warmup几乎成了标配,不是没有理由的。
在PyTorch里的用法非常简单:
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR optimizer = optim.Adam(model.parameters(), lr=3e-4) scheduler = CosineAnnealingLR(optimizer, T_max=200, eta_min=1e-6) for epoch in range(200): train_one_epoch() scheduler.step()注意T_max不是总epoch数减200,而是余弦周期长度。如果你设了T_max=200但实际只训练100个epoch,学习率只会降到周期中间值,后面的加速下降根本走不完。实务中T_max就设成总训练epoch数,最简单也最不容易出错。
3.3 OneCycle与热启动:进阶才会用到的方案
OneCycleLR的思路跟衰减正好相反——先让学习率从一个小值线性爬升到一个大值,再线性或余弦地衰减下来。前期上升阶段的逻辑是大学习率带来的正则化效应,帮助模型跳出尖锐的局部极小值;后期衰减阶段负责收敛。这个策略在图像分类和一批迁移学习任务中收敛速度相当快。
CosineAnnealingWarmRestarts是另一种思路:每训练T_0个epoch就把学习率重新拉回高位,形成“热启动”循环。它的价值在于周期性探索——训练卡在一个局部盆地里出不来时,周期性的大学习率能把模型重新“弹”出去,尝试新的区域。我在GAN训练里用过它,生成器和判别器轮流被高学习率扰动,反而能逃出模式坍缩的循环。
需要警惕的是,热启动策略的loss曲线不会单调下降。每个重启周期开始,loss都可能突然跳升,这是调度器的预期行为,不是bug。第一次用WarmRestarts的人常被这个跳变吓到,以为是模型炸了,其实只是学习率又回到了高位,模型重新开始探索而已。
4. Warmup阶段:常被忽略却能救回训练的关键前奏
4.1 为什么训练初期反而需要小学习率
Warmup指的是训练一开始先用很小的学习率跑几步,再逐渐爬到目标学习率。反直觉的地方在于:我们刚说了前期需要大步长探索,为什么又要从更小的步长开始?原因有三个。
第一,模型刚初始化的权重是随机的,此时各层输出的尺度不稳定,梯度的方向在很大程度上被少数几个样本主导。这时如果一上来就用大学习率,参数会被推向一个非常偏的方向,后期很难拉回来。用小学习率先走几步,等各层统计特征趋于稳定再放开步长,相当于让模型先站稳再迈大步。
第二,BatchNorm一类的层在训练初期统计量还在适应数据分布。running mean和running variance都是从头累积的,初期估计误差大,大学习率会把这种误差放大,导致训练一开始就处于一个抖动剧烈的状态。
第三,对Adam这类自适应优化器来说,前几步的一阶矩和二阶矩估计都是从零开始累积的,即使有偏差修正机制,初始阶段的方向估计依然不可靠。Warmup恰好覆盖了这个“估计器建立期”,让学习率随着估计质量的提高而爬升。
4.2 Warmup的工程实现与步数确定
PyTorch里实现warmup通常用LambdaLR组合自定义函数,或者直接借助transformers库提供的方法。偏重训练框架的工程中,线性warmup加余弦衰减是我最常用的组合:
from torch.optim.lr_scheduler import LambdaLR import math def warmup_cosine_schedule(step, warmup_steps, total_steps): if step < warmup_steps: return step / max(1.0, warmup_steps) progress = (step - warmup_steps) / max(1.0, total_steps - warmup_steps) return 0.5 * (1.0 + math.cos(math.pi * progress)) scheduler = LambdaLR( optimizer, lr_lambda=lambda step: warmup_cosine_schedule(step, warmup_steps=500, total_steps=5000) )warmup步数怎么定?我的经验是取总训练步数的5%到10%。5000步总共的话,warmup 250到500步比较合理。太短了起不到稳定统计量的作用,太长了则白白浪费训练时间——warmup阶段学习率太小,参数几乎不动,这段训练是没有产出的。小模型小数据上可以完全不加warmup,但对大模型、大批次训练而言,warmup基本属于必需品。
5. PyTorch中调度器的选型与实践细节
5.1 调度器与优化器的联动逻辑
新手最容易产生的误解是:创建scheduler之后,学习率会自动下降。不会。PyTorch的调度器需要你手动在每个epoch或每个step后调用scheduler.step()。这个“手动”是有原因的——框架无法替你做决定,比如你是按epoch衰减还是按batch迭代次数衰减,是等验证集loss下发指令还是纯按时间表走。
最常见的写法是按epoch步进:
for epoch in range(epochs): train() validate() optimizer.step() scheduler.step()要注意调度器的step位置。StepLR、ExponentialLR、CosineAnnealingLR默认按epoch调用,OneCycleLR设计上更适配每个batch调用。无论哪一种,都应该在optimizer.step()之后再调用scheduler.step(),不要反过来。原因在于optimizer.step()执行参数更新时用的是当前学习率,如果调度器先走一步,学习率就已经变了,这一步实际上用的是“下一阶段”的学习率,整个训练计划就错位了。
5.2 我踩过的几个坑
第一个坑是恢复checkpoint时忘了保存scheduler的状态。训练跑了100个epoch,程序中断,你从第50个epoch的checkpoint恢复。如果只恢复了model.state_dict()和optimizer.state_dict(),没恢复scheduler.state_dict(),调度器会从初始状态重新开始,学习率曲线全部错位——之前设好的衰减计划全部作废。训练继续,loss曲线突然变得很奇怪。
正确的保存与恢复方式:
# 保存 checkpoint = { 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'scheduler': scheduler.state_dict(), 'epoch': epoch, } torch.save(checkpoint, 'ckpt.pt') # 恢复 ckpt = torch.load('ckpt.pt') model.load_state_dict(ckpt['model']) optimizer.load_state_dict(ckpt['optimizer']) scheduler.load_state_dict(ckpt['scheduler'])第二个坑是ReduceLROnPlateau。这个调度器跟其他scheduler不一样,它不属于常规步进类,而是根据评测指标动态调整——当验证loss连续patience个epoch没下降,就把学习率乘一个factor。它的step函数需要传入当轮的验证loss:scheduler.step(val_loss)。很多人把它跟普通scheduler同样调用,不传参数,直接报错,然后一脸茫然。
第三个坑跟热重启相关。用CosineAnnealingWarmRestarts时,如果你没有意识到每个周期结束loss会反弹,容易误判模型发散,在恢复训练时做了多余操作,反而打断正常训练。所以用任何带重启性质的调度器前,先把它的学习率曲线打印出来看一眼,知道啥时候该有跳变,比啥都强。
6. 怎么判断一个调度策略适不适合你的任务
6.1 先做小规模快速扫描
别一上来就在完整数据集上调调度器。先用小规模数据子集跑一个短实验,对比两种策略:固定学习率基线,以及固定学习率加Cosine。观察loss曲线在几十个epoch内的形态,这一步只花很少时间,却能把很多明显不适配的策略淘汰掉。
怎么判断快扫结果?看两点:一是在同样的epoch数下,加了调度器的曲线是否比基线更低;二是后段震荡是否明显缓解。如果加了调度反而更差,先怀疑初始学习率设得不对——调度器只是改变学习率的轨迹,它没法凭空产生一个变好的起点。
6.2 通过loss曲线形态反推问题
曲线下降很快但平台很高,说明衰减偏慢,后期学习率还是太大,模型始终在一个损失稍高的区域震荡。解决办法是把T_max调短,让衰减提前开始,或者把eta_min设得更低。
曲线前期根本不动,后面才有动静,大概率是warmup太长或者初始学习率太低。warmup期间训练基本无产出,过长会在开头浪费大量步数。
曲线呈分层锯齿状,每一轮训练内先降后升,可能是bath size选小了,噪声太大。此时换调度器作用有限,优先加大batch size或降低初始学习率。
所有曲线问题都先划清归属:是调度器的问题,还是学习率初始值的问题,还是数据批次噪声的问题。我见过太多人把训练问题一股脑推到调度器头上,其实只是初始学习率选错了。
6.3 按任务规模给出朴素建议
小数据集、小模型,几十个epoch以内的训练,固定学习率完全够用。模型简单,收敛路径平稳,调度器带来的收益不明显,反而多引入几个超参数要调。
中大规模分类任务、CV模型、预训练微调,推荐Cosine加warmup的组合。收敛快,后期稳,几乎不需要人工盯milestone。
超大batch、超长训练周期,比如训练大规模Transformer类模型,OneCycle和WarmRestarts是更合理的选择,它们能在长时间训练中反复注入探索性扰动,避免模型在漫长的后段陷入一个平庸的盆地。
微调场景比较特殊,训练epoch数很短,用短的Cosine周期就行,把T_max设成实际训练epoch数,warmup设成总步数的5%以内,基本不用做太多复杂搜索。
最后分享一点我的个人习惯:面对一个新任务,我先用固定学习率跑通一个短流程,拿到一个合理的初始学习率,再叠加调度器。直接上来就配Cosine加warmup加WarmRestarts,出了问题时你根本分不清是哪个环节的锅。先把基线跑稳,再一步步增加复杂度,这比一开始就追求花哨配置要靠谱得多。学习率调度不会替你把模型变好,但当模型卡住不动时,它往往是那个最便宜、最先该检查的旋钮。