先交代一个我自己的翻车现场。去年某次图像分类实验,我拿到一个预训练模型,准备在120类自定义数据集上做微调。当时图省事,直接开了0.3的学习率,没加任何warmup。结果第1个step loss就从2.31跳到8.9,第12个step直接出现NaN。我盯着终端日志看了很久,第一反应是数据有问题,第二反应是学习率太大,完全没意识到真正缺的是训练初期的warmup(预热)策略。
后来把学习率降到0.01,还是会在前几百步出现明显的梯度震荡,损失曲线像一个被反复挤压的弹簧。直到我给训练流程补上一段“线性warmup + 余弦退火”的schedule,那种一开局就失控的场面才彻底消失。warmup这名字听起来像个加了万金油的技巧,但只要你跑过那种“开局三分钟就崩掉”的训练,就会明白它其实是整个学习率策略里最值得认真对待的一段。
这篇内容我会从原理、代码实现、参数选择、实战排障四个方面展开,把warmup的细节讲透,最后给三套可以直接抄的配置模板。不管你是在做图像分类、目标检测,还是跑大规模预训练,看完应该都能直接上手用。
1. warmup到底解决了什么问题:从一次“开局就崩”的训练说起
1.1 训练初期的“危险区”:损失面越陡,初始化越敏感
深度学习训练的本质,是在一个高维损失曲面上做梯度下降。很多人只关注“最终收敛在哪里”,忽视了起点附近的“地形”是什么样的。实际情况是,网络刚初始化或刚加载预训练权重时,模型输出还很粗糙,这时候的损失曲面往往非常陡峭,梯度范数可能达到正常阶段的几十倍。
拿开车来类比。新车刚起步时,发动机转速、轮胎抓地力、道路状况都还没进入稳定态,你不会一脚油门踩到120。训练也是一样的道理:如果第一个step就用一个很大的学习率去更新权重,相当于在陡坡上猛踩油门,参数很容易一步冲出合理区域。等你想再拉回来,可能已经在损失曲面的某个“坏谷”里了,后面无论怎么调学习率都救不回来。
warmup的作用,就是给训练过程加一段“低速起步”的缓冲时间。让模型在很小的学习率下先走几百步,等参数方向大致稳定了、梯度范数回到合理区间了,再把学习率提升到目标值。这个阶段虽然不负责“学得多”,但负责“开局不乱”。
1.2 从优化器内部看:Adam的估计算子也需要预热
如果说SGD系列做warmup是因为“怕冲飞”,那自适应优化器(比如Adam、LAMB)做warmup的理由还要多一层:它们的内部统计量也需要时间积累。
Adam维护了两个指数滑动平均,一个是梯度的均值(一阶矩),一个是梯度平方的均值(二阶矩)。刚初始化时,这两个值都是0。虽然Adam设计了偏差修正(bias correction),但修正只能解决“统计量偏小”的问题,不能解决“统计量还没反映真实梯度分布”的问题。前几个step里,二阶矩会非常小,计算出来的有效学习率就会偏大,甚至出现极端的更新步长。
我见过一个很典型的现象:某个模型在训练前10步时,Adam的更新幅度比后1000步的平均更新幅度大了两个数量级。这种情况下,warmup不只是“调节学习率”,更像是在等Adam的估计算子走出“冷启动”阶段。等到二阶矩跑个几百步、方差估计接近真实分布之后,再把步子放大,才不会在训练初期就留下不可逆的权重扰动。
1.3 不只是学习率:BN统计量和模型结构也需要缓冲期
还有一个容易被忽略的点:BatchNorm层的统计量同样需要预热。
一个随机初始化的网络,或者刚加载预训练权重的网络,前向传播产生的feature map分布是很不稳定的。BatchNorm在训练模式下会维护running_mean和running_var,这两个统计量在训练初期会剧烈变动。如果你的学习率一开始就很大,BN统计量和网络权重之间会形成一个互相放大的正反馈:权重更新幅度大导致feature分布变化大,BN统计量被迫剧烈追踪,反过来又让梯度更不稳定。
这也是为什么迁移学习里微调任务对warmup不那么敏感(BN统计量已经由预训练阶段初始化好了),而从头训练的任务则非常依赖warmup。理解了这一层,你就明白为什么有些人在极小的数据集上从头训练一个小网络,即使学习率设得很离谱也不会崩——因为模型太小、BN层太少,整个系统对初始扰动的鲁棒性更强。模型越大、BN层越多、任务数据越复杂,warmup就越不是可选项,而是必选项。
2. 动手实现:三种主流warmup写法的完整代码与踩坑记录
2.1 最简版:手动控制学习率的Linear Warmup
先从最朴素的写法开始。假设你总训练步数是total_steps,warmup步数是warmup_steps,目标学习率是base_lr。最简单的线性warmup就是让学习率从0线性增长到base_lr:
import math import torch def adjust_lr_for_warmup(optimizer, step, warmup_steps, base_lr): if step < warmup_steps: # +1是为了避免第0步lr为0导致梯度异常,同时防止除零 lr = base_lr * (step + 1) / (warmup_steps + 1) for group in optimizer.param_groups: group['lr'] = lr optimizer = torch.optim.AdamW(model.parameters(), lr=0.001) total_steps = 100000 warmup_steps = 5000 for step in range(total_steps): loss = compute_loss(model, batch) loss.backward() optimizer.step() adjust_lr_for_warmup(optimizer, step, warmup_steps, 0.001)这个写法虽然简单,但有三个细节很容易踩坑。
第一个是分母的+1。如果不加,当step为0时学习率是0,优化器完全不动,这个本身问题不大,但如果你用了AMP混合精度,loss_scaler可能在这个step做特殊处理,容易引入不稳定的初始状态。第二个是step的计算口径:有的框架step从1开始,有的从0开始,务必在日志里打印确认,否则warmup时长会差一个step。第三个是训练结束后schedule函数要记得移除,否则后续如果复用optimizer做evaluation等操作,学习率可能停留在最后一个warmup值上。
2.2 和余弦退火配对:Linear Warmup + Cosine Decay
单独用warmup其实没有必要,warmup几乎总是和后续的decay策略配合使用。目前业界最主流的组合就是“线性warmup + 余弦退火”,也被很多开源框架当作默认schedule。实现方式用LambdaLambda表达式非常干净:
import math from torch.optim.lr_scheduler import LambdaLR def lr_lambda(current_step): if current_step < warmup_steps: return (current_step + 1) / (warmup_steps + 1) # 进入余弦退火阶段 progress = (current_step - warmup_steps) / (total_steps - warmup_steps) return 0.5 * (1.0 + math.cos(math.pi * progress)) scheduler = LambdaLR(optimizer, lr_lambda=lr_lambda) for step in range(total_steps): loss = compute_loss(model, batch) loss.backward() optimizer.step() scheduler.step()这个写法等价于:前5000步学习率从0平滑升到0.001,之后沿着余弦曲线从0.001衰减到接近0。全程没有任何突变点,梯度更新幅度是连续变化的,这对模型收敛非常有帮助。
我为什么推荐用LambdaLR而不是直接改param_groups?因为LambdaLR会自动维护和优化器之间的状态同步,你在代码里可以随时查看scheduler.get_last_lr()拿到当前学习率,调试时非常方便。而且后续如果改动训练框架(比如从单卡切到多卡),这段逻辑可以原样保留。
2.3 per-step还是per-epoch?两种步进粒度怎么选
warmup的调度粒度有两种:按step和按epoch。很多新手在TensorFlow代码里看到linear_warmup(1000)以为就是1000个step,其实那个参数可能是epoch数,这种情况在检测领域尤其常见。
按step的warmup更精确。无论数据集大小如何变化,每个step的更新次数是固定的,warmup行为可预测。按epoch的warmup写起来简单,但如果你中间改了数据集大小,或者用了不同数量的GPU(总batch size变了),每个epoch包含的step数会变,warmup时长无形中就变了。
我的经验是:默认按step来写。尤其是在分布式训练里,一个step对应一次optimizer更新,如果总batch size翻倍,step数直接减半,这时只有按step设计的warmup才能让你精确控制“模型每更新一次权重,学习率增长多少”。如果只是单卡小实验,按epoch和按step差别不大,按epoch反而更容易和数据集遍历逻辑对齐,视觉上更清楚。
3. warmup参数怎么定:步数、占比和初始学习率
3.1 warmup步数/比例:5%起步,最多不超过15%
warmup时长的经验值,很大程度上取决于训练总步数。我用过的配置里,warmup步数通常占总训练步数的4%到10%之间。低于2%,基本等于没做;高于15%,会浪费大量训练时间在低学习率阶段,整体训练效率下降。
一个比较好用的经验法则是三步走:
- 先算训练总步数。比如你有12.8万张图,batch size为128,跑90个epoch,总步数就是12.8万除以128再乘90,大约9万步。
- 按5%取warmup步数,也就是4500步左右。
- 跑一次完整训练,观察前1%步数的loss曲线和梯度范数。如果开局仍然有陡峭的loss尖峰,就把warmup提高到总步数的8%-10%。
有些任务类型可以打破这个通用值。自监督学习、对比学习这类“难优化”的任务,warmup占比经常需要到20%甚至30%。原因很简单:模型初期没有监督信号,梯度方向本身就不稳定,需要更长的缓冲期让表征先建立起来。反之,如果你做的是一个已经预训练好的模型微调,warmup 500步都嫌多,200步到1000步就够了,因为模型参数已经在一个比较平滑的区域里了。
3.2 初始学习率:从0开始还是从一个很小的正数开始
很多人会在warmup起点上纠结:是从0开始线性上升,还是从初始lr的1/10甚至1/20开始?
两种都有人用。从0开始最安全,学习率连续变化,不和任何中间状态挂钩。但问题是,warmup初期学习率太低,参数几乎不动,相当于白跑了一部分step。从一个很小的正数(比如base_lr的1/20)开始,可以让模型在warmup早期也保持学习状态,但代价是如果你选的这个“小正数”还是偏大了,warmup依然压不住早期震荡。
我现在的默认做法是:如果开了AMP或者用了大batch size训练,从0开始;如果是普通单卡训练,从一个很小的正数开始,具体为base_lr的1/20或1/50。这样既保证了平滑性,又节省了一部分warmup时间。另外,初始学习率也不一定非要设成base_lr的固定比例,你可以用base_lr * min(1, (step+1)/warmup_steps)这个speedup形式替代严格线性,效果差别不大,但省心很多。
3.3 不同优化器对warmup的敏感度差异
warmup并非对所有优化器都同等重要。我把常见优化器分成三档:
- SGD + Momentum:对warmup不敏感,但对学习率本身非常敏感。你可以不加warmup,只要lr设得够小,前200步的震荡也能自己恢复。不过加上warmup会让收敛曲线的早期形状更干净。
- Adam / AdamW:中等敏感。不加warmup能跑,但训练初期loss波动明显,尤其是在batch size较大时。建议加。
- LAMB / LARS:极度敏感。这类大batch训练器(常用于大规模预训练或超大batch)如果不用warmup,非常容易在初期出现参数发散。几乎我见过的所有大规模对比学习、NLP预训练配置都标配了一个不短于5%总步数的warmup。
关于LAMB多说一句。它本身就是为了解决“大batch下Adam更新步长过大”的问题,但它内部对二阶矩的依赖依然存在,而且大batch下梯度方差小、统计量更稳定,这反而会让一阶矩在初期显得“过于自信”。没有warmup时,前几步可能会用非常大的有效学习率对参数做一次巨幅修正,这个修正方向大概率是不准确的。所以你在读开源代码发现大规模预训练动辄warmup几万步,不要觉得夸张,那是大batch训练里的真实刚需。
4. 实战排障:没有warmup时我看到的梯度异常与修复过程
4.1 事故现场:没有warmup时我亲眼看到的梯度现象
我先记录一段真实日志(脱敏处理),便于大家对照自己的训练输出:
step 0 | loss 2.3100 | grad_norm 43.221 | lr 0.1000 step 5 | loss 3.4012 | grad_norm 67.893 | lr 0.1000 step 10 | loss 4.5190 | grad_norm 95.442 | lr 0.1000 step 15 | loss 6.1022 | grad_norm 132.771 | lr 0.1000 step 20 | loss 8.5080 | grad_norm 218.910 | lr 0.1000 step 25 | loss NaN | grad_norm NaN | lr 0.1000这个模式非常典型:loss没有缓慢下降,而是逐步升高,同时梯度范数持续放大。这种“越跑越离谱”的曲线,大概率不是数据问题,而是学习率起步太猛。正常训练下,loss可以上涨,但梯度范数不应该持续暴涨。如果看到grad_norm在前5%步数里还在不断创出新高,第一件事就该检查warmup是否生效。
修复方式也很直接。把学习率停在一个很小的值(比如0.001),加上500步warmup,让学习率从0平滑升到0.1。跑完同样25步,loss轨迹会从“持续上涨”变成“先小幅波动、然后缓慢下降”。
4.2 warmup时长与batch size、梯度累积的关系
有一个非常容易被忽略的坑:batch size变了,warmup步数必须跟着重算。
假设你原来单卡、batch size 64,总数据集12.8万张,90个epoch,总步数就是18万步,按5%取warmup 9000步。现在换成4卡并行,batch size变成256,总步数直接变成4.5万步,如果warmup还写9000步,那warmup占比就变成了20%,相当于白白浪费了15%的训练时间。反过来,如果你从4卡降回单卡,warmup比例可能从5%跌到1.25%,训练初期又出现震荡。
另外一个常常踩的坑是梯度累积。很多人用gradient accumulation模拟大batch,代码里optimizer.step()只在实际累积到目标步数时才执行。这种时候,warmup的“step”应该和optimizer.step()保持一致,而不是和batch遍历的step保持一致。否则warmup看起来走了很多步,实际上学习率根本没升上去。
我建议在代码里统一维护一个global_step计数器,凡是学习率策略都基于这个计数器来算,不管你是用梯度累积、混合精度还是分布式采样,都不会乱。
4.3 分布式训练和混合精度下warmup的注意点
分布式训练里,warmup本身没有特殊问题,但有一个信息同步的细节:有些框架的learning_rate_scheduler只会在rank 0进程上被调用,然后通过分布式参数广播同步到其他进程。如果你在自定义训练循环里手动修改param_groups,一定要确保所有rank都执行相同的逻辑。最简单的方式是用LambdaLR这类官方scheduler,它会自动处理状态同步。
混合精度(AMP)训练则要额外关注loss_scaling。开启AMP后,前几个step可能因为梯度溢出或下溢导致loss_scale剧烈波动,这对没有warmup的模型来说又是一层考验。实测中,AMP + 大学习率 + 无warmup,三者叠加时出NaN的概率会显著增加;而加入warmup之后,即使loss_scale在初期有波动,梯度更新幅度也很小,不容易触发精度溢出。另外,如果你用了梯度裁剪,建议在warmup阶段把clip值适当放宽(比如从1.0放宽到5.0),因为warmup初期梯度范数本来就会比较大,严格的clip反而会干扰有效的初始探索。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 前几步loss冲到正常值10倍以上 | 学习率过大或warmup太短 | 增大warmup步数或降低base_lr |
| 训练前5%步数内频繁NaN | AMP与无warmup叠加 | 加warmup,或调整loss_scale策略 |
| loss一开始能降,1000步后突然爆炸 | warmup结束后学习率直接跳到过高的base_lr | 把warmup终点和主训练学习率之间加一段平滑过渡 |
| 梯度范数持续上涨,完全不受控 | 可能不是warmup问题,而是模型本身出现huge loss spike | 先检查数据标签,再观察是否是初始化状态异常 |
| 换卡后收敛变慢 | batch size变化、warmup步数未重算 | 用global_step重新计算warmup步数 |
| warmup期间loss正常,一结束就震荡 | warmup步数过短,或base_lr偏大 | 延长warmup步数,或把base_lr下调20%-30% |
5. 可以照抄的三套“warmup配置模板”
5.1 中小规模分类/微调模板
适用场景:单卡或4卡以内,数据量几万到几十万张,训练总步数不超过10万步。
- 优化器:AdamW
- base_lr:0.001(微调场景可以降到0.0001到0.0003)
- batch size:128
- warmup步数:总步数的5%
- 初始lr:0(直接用线性warmup从0开始)
- 退火策略:余弦退火到0
- 附加建议:如果数据噪声比较大,warmup比例可以提到8%
这个模板我跑了无数个项目,目前没有遇到过开局崩掉的情况。如果你用的是SGD+Momentum,base_lr改成0.01或0.05也适用。
5.2 检测/分割任务模板
适用场景:目标检测、实例分割,使用经典检测框架训练,通常有多尺度训练和大量图像增强。
- 优化器:SGD + Momentum(momentum=0.9)或AdamW
- base_lr:SGD建议0.01,AdamW建议0.0005
- batch size:16或32(检测任务通常受限于显存)
- warmup步数:1000到3000步(按step算,不按epoch)
- warmup策略:线性或指数增长
- 附加建议:检测任务常配多尺度训练和mosaic增强,建议在warmup阶段就开启全部增强,但把mosaic的混入概率从0.5慢慢提升到0.9,本质上是对“数据难度”做warmup
检测任务里warmup和增强策略是强耦合的。如果你一上来就用mosaic + 多尺度 + copy-paste全套增强,模型在前几百步根本分不清自己应该学什么。把增强强度也做成渐进式,和warmup保持同一个节奏,训练会顺畅很多。
5.3 大规模自监督/预训练模板
适用场景:大规模对比学习、BERT类预训练、ViT预训练,动辄几十万步甚至上百万步。
- 优化器:LAMB或AdamW
- base_lr:0.001到0.01(视batch size而变,通常遵循线性缩放规则)
- batch size:4096或更大
- warmup步数:总步数的10%到20%
- 初始lr:0
- 退火策略:余弦退火或线性衰减后接一段固定低lr
- 附加建议:训练早期再做一次梯度裁剪,clip阈值从10慢慢降到1,相当于给参数更新加了第二层约束
对这种超大规模训练,warmup已经不单单是“训练技巧”了,而是训练稳定性的基石。我在某个千亿参数模型的训练日志里看到,前2万步的loss曲线几乎完全由warmup曲线主导,任何一次warmup步数的误判都可能导致后续几十万步的收敛状态偏移。所以代码里建议把warmup步数设为可配置参数,并且至少留出三个预设档位:保守、标准、激进。宁可先用保守档跑通,也不要一开始就激进。
结尾:我的一点个人体会
跑了这么多次训练之后,我对warmup的态度从“参数锦上添花”变成了“训练流程的一部分”。现在我做任何新任务,都会先花三分钟检查三件事:优化器选对没有、base_lr是不是合理、warmup步数有没有跟着batch size和总步数重算。这三件事不做好,后面花再多时间调数据增强和模型结构都是白搭。
一个小技巧分享给大家:在训练脚本里每隔10个step打印一次“global_step、梯度范数、学习率”三件套,前100步的日志就能看出warmup是否在工作。如果梯度范数在前几step就开始急剧下降,说明warmup起了作用;如果grad_norm忽上忽下,说明还要继续调。说白了,warmup就是一次给训练过程“踩刹车再慢慢松脚”的操作,你做得好,别人看不出来;你做得不好,loss曲线会第一时间让你难堪。希望这篇内容能帮你避开我当年踩过的那些坑。