在深度学习训练里学习率调度是最容易“出效果”也最容易“翻车”的环节之一。很多人第一次接触 Warmup Scheduler是在某个开源项目里看到get_cosine_schedule_with_warmup这个函数或者在某个训练技巧分享里听到“前多少步用小的学习率热热身”。但真要自己动手配一个预热策略并且和前后的衰减策略搭配好很多人是懵的——要么照搬别人的参数要么发现预热结束之后 loss 剧烈震荡甚至直接发散。这篇文章就专门聊 Warmup它是干什么的为什么能稳定训练怎么实现以及最容易被忽略的“预热之后怎么办”。内容更像是我的实战记录包含了代码、调参思路和踩坑经过适合那些已经在跑深度学习训练、想把手里的训练配置调得更稳的同学也适合刚接触学习率调度、想系统搞明白原理的入门者。1. 为什么要做Warmup从Adam的“翻车现场”说起1.1 训练初期的大梯度冲击问题我第一次意识到 Warmup 的必要性不是看论文而是训练直接崩了。当时跑一个图像分类任务用的是 Adam 优化器初始学习率按业界常见值设成 0.001结果没跑几步 loss 就变成了 NaN。查了半天最后定位到问题是学习率太大但奇怪的是同样的学习率在另一个任务上跑得很好。后来仔细看训练日志才发现前几十步的梯度范数特别大经常是 10 以上而正常训练到了后期梯度范数通常稳定在 1 以下。也就是说训练初期模型参数还是随机初始化状态输出层计算出的预测和真实标签差距极大反向传播回来的梯度自然也非常夸张。这时候拿着一个比较大的学习率去做梯度更新一步跨出去就是“万丈深渊”参数直接飞到数值溢出区域。这就是 Warmup 要解决的第一个问题训练初期模型处于“不稳定区”此阶段必须用低学习率来控制更新步长防止梯度爆炸。其实借助一个生活类比就能理解你学开车拿到驾照第一次上路不可能直接上高速。你得先在小区里低速绕几圈熟悉方向盘、油门刹车配合等手感稳了再逐步提高车速。Warmup 就是“低俗起步”前几百步学习率小参数更新幅度小让模型从随机状态慢慢找到一个初步合理的参数方向后面才能大胆加速。1.2 Warmup的核心原理从优化器状态说起光解释“防爆炸”还不够Warmup 能起作用还有一个更深层的原因这和优化器内部的动量估计有关。以 Adam 为例它维护两个状态变量一阶动量梯度的指数滑动平均和二阶动量梯度平方的指数滑动平均。问题在于训练刚开始时这两个状态都是从零初始化的。Adam 论文里专门提到了这个问题并提出一个偏差修正公式m_hat m_t / (1 - beta1^t) v_hat v_t / (1 - beta2^t)其中beta1通常取 0.9beta2通常取 0.999。在 t 很小的时候1 - beta^t的值也很小除以一个很小的数会把累积量放大很多。这个偏差修正在数学上保证了早期估计不偏但在实际训练中会造成有效学习率不稳定。具体来说训练刚开始的几步梯度带有很强的随机性这些随机梯度被动量机制“记住”之后在后面很长一段时间里都会影响参数的更新方向。而二阶动量的数值刚开始比较小导致每个参数维度的有效步长lr * m_hat / sqrt(v_hat)异常偏大。因此Warmup 不仅是在“降低步长”更是在给优化器的动量状态一个缓冲期让它看到的梯度逐步从“剧烈波动”过渡到“相对平稳”避免一开始就被极端梯度污染。注意SGD 和 SGD with Momentum 对 Warmup 的敏感程度不同。纯 SGD 没有自适应梯度缩放对学习率的反应比较直接Momentum 版本因为有动量积累也容易出现初期方向被带偏的问题。Adam 对 Warmup 的依赖最强这也是为什么很多预训练代码里用 Adam 时几乎标配 Warmup。2. 主流的预热策略与实现细节2.1 线性预热最简单也最稳的选择线性预热是目前使用最广的方案。它的思路很直观在预热阶段学习率从初始值通常是 0 或者极小值均匀线性增加到目标峰值学习率。公式为lr_step d_model * (lr_peak - lr_start) / warmup_steps lr_start其中lr_start是起始学习率通常设为 0lr_peak是预热结束时达到的峰值学习率warmup_steps是预热持续的总步数。PyTorch 里实现线性预热非常方便用内置的LambdaLR就行import torch from torch.optim import Adam from torch.optim.lr_scheduler import LambdaLR def get_linear_warmup_scheduler(optimizer, warmup_steps, peak_lr, base_lr0.0): def lr_lambda(current_step: int): if current_step warmup_steps: return 1.0 # 线性从 base_lr 升到 peak_lrLambdaLR 最终返回的是缩放系数 progress current_step / warmup_steps return base_lr (peak_lr - base_lr) * progress return LambdaLR(optimizer, lr_lambda)但有个细节需要注意LambdaLR返回的是一个乘法系数PyTorch 会用它乘以优化器里已经设置的初始学习率。所以更规范的做法是把优化器的初始学习率设成峰值学习率然后在lr_lambda里只返回从 0 到 1 的缩放因子def get_linear_warmup_factor(warmup_steps): def lr_lambda(current_step: int): if current_step warmup_steps: return 1.0 return current_step / warmup_steps return lr_lambda optimizer Adam(model.parameters(), lrpeak_lr) scheduler LambdaLR(optimizer, lr_lambdaget_linear_warmup_factor(warmup_steps))这样做的好处是优化器的初始学习率始终是峰值学习率后续再接余弦退火等衰减策略时只需要在衰减阶段把缩放系数乘上一个衰减曲线即可逻辑上更统一。2.2 指数预热当你想要更“缓”的起步线性预热的缺点是曲线太“硬”——学习率每个 step 都在匀速增长一旦到了峰值就直接切换到衰减阶段。如果在峰值点附近梯度依旧不稳定从一个快速增长的学习率直接接一个快速衰减loss 曲线容易出现一个小“驼峰”。指数预热则让学习率按照指数曲线渐进增长刚开始的步伐更小、更谨慎后半段追赶速度快。公式如下lr base_lr (peak_lr - base_lr) * (1 - exp(-t / tau))其中tau是控制上升速度的时间常数t是当前步数。tau越大曲线越平缓tau越小越早逼近峰值。在代码里实现import math def get_exp_warmup_factor(warmup_steps, tauNone): if tau is None: tau warmup_steps / 3.0 def lr_lambda(current_step: int): if current_step warmup_steps: return 1.0 return 1 - math.exp(-current_step / tau) return lr_lambda这个方案我用的场景比较特殊在训练一些梯度本身就不太稳定的生成模型比如早期的 GAN时线性预热容易在峰值附近产生振荡指数预热能把早期震荡压得更低模型更容易稳定下来。代价是预热阶段实际用到的平均学习率比线性方案更低总训练时间会稍微拉长。2.3 预热阶段到底应该持续多少步关于 Warmup 的步数业界不存在“放之四海而皆准”的答案。很多人会直接抄大模型预训练的配置比如几千步的 Warmup但自己的任务总共才训练几千步一半时间都拿去热身了这显然不对。我自己的经验是这样对于训练总步数在 1 万步以内的中小型任务Warmup 通常取总步数的 5% 到 10%。对于大规模训练例如目标检测、Transformer 类模型训练几万甚至几十万步Warmup 可以放宽到总步数的 1% 到 3%这时 Warmup 的主要意义已经不再是“防初期不稳定”而是为后续的衰减策略提供一条平滑的“起跑线”。如果训练时使用了很大的 batch size比如通过梯度累积模拟超大 batchWarmup 步数要适当增加因为大 batch 的梯度更“锐利”需要更多步数来让优化器状态稳定。我常用的判断方法观察 loss 曲线的形态。如果训练初期 loss 曲线出现陡峭的下降后紧跟着一个反弹尖峰说明 Warmup 太短峰值学习率接手太早如果整个 Warmup 阶段 loss 变化非常平缓甚至没有明显下降说明 Warmup 太长浪费了训练预算。训练场景总步数推荐Warmup比例推荐Warmup步数小规模分类任务5千步10%500步中等规模目标检测2万步5%1000步大规模预训练20万步2%4000步超大Batch微调5千步15%-20%750-1000步重要提示Warmup 的步数必须和数据集的 epoch 数挂钩联调。同样的步数在总 epoch 数不同的情况下对应的是完全不同的训练阶段。建议统一用 step 来配置调度器而不是用 epoch。3. 预热后怎么衰减完整调度器设计实战场3.1 预热配合余弦退火目前最主流的黄金组合如果你去看近几年各类视觉模型的训练配置会发现它们几乎都采用了同一种模式“Warmup Cosine Annealing”预热 余弦退火。这套组合流行起来不是没有原因的余弦退火曲线的衰减过程非常平滑它不像阶梯下降那样在某个点上突然把学习率砍掉一大截而是逐渐降低更新步长让模型在训练后期能够收敛到一个更平稳的局部最优点。PyTorch 官方库中提供了CosineAnnealingLR但它本身不支持 Warmup。想要两者叠加常见做法是使用LambdaLR自己组装一个“预热 余弦衰减”的完整调度器import math from torch.optim.lr_scheduler import LambdaLR def get_cosine_schedule_with_warmup( optimizer, num_warmup_steps, num_training_steps, num_cycles0.5, last_epoch-1 ): def lr_lambda(current_step): if current_step num_warmup_steps: # 预热阶段线性从0升到1 return float(current_step) / float(max(1, num_warmup_steps)) # 衰减阶段余弦退火 progress float(current_step - num_warmup_steps) / float(max(1, num_training_steps - num_warmup_steps)) return max(0.0, 0.5 * (1.0 math.cos(math.pi * float(num_cycles) * 2.0 * progress))) return LambdaLR(optimizer, lr_lambda, last_epoch)这里的num_cycles0.5就代表标准的半周期余弦衰减学习率从峰值逐渐下降到接近 0。在实际使用中我通常把num_training_steps设为“总步数 - 预热步数”这样衰减恰好从预热结束开始、到训练结束完成。仔细拆解一下这套组合的优势预热阶段模型学习率的“热身期”参数从随机初始化状态平稳过渡到正常训练轨道。衰减阶段学习率按余弦曲线先缓慢下降、再加速下降、最后再减速逼近 0。这个后段减速过程很有价值它让模型在最优解附近能够做更精细的“微调搜索”而不是在后期还大步走动导致无法收敛。很多人担心余弦退火会把学习率降到 0 导致模型停止学习实际上因为后期梯度本身就非常小学习率虽然趋近于 0但更新依然在发生只是步长非常小罢了。3.2 和其他衰减策略的配合阶梯下降、多项式衰减、循环周期Warmup 不是一个“孤立”的模块它是整条学习率曲线的第一段。所以每一个衰减策略和 Warmup 配合时都有一些不同的注意点阶梯下降Step Decay阶梯下降的特点是学习率每隔固定步数乘一个衰减因子比如每 30 个 epoch 乘 0.1。这种策略和 Warmup 搭配时最常犯的错误是Warmup 结束之后直接把学习率定在峰值然后马上就走阶梯下降——这会让峰值学习率存在的时间非常短模型还没在峰值上充分探索就进入衰减训练效果“不上不下”非常尴尬。解决办法把阶梯下降的第一个阶梯长度拉长让模型在峰值学习率上停留足够长的时间通常至少占整个训练周期的 30% 以上。def get_step_with_warmup(optimizer, warmup_steps, total_steps, step_size, gamma0.1): def lr_lambda(current_step): if current_step warmup_steps: return current_step / max(1, warmup_steps) # 从阶梯中扣除预热阶段的影响 adjusted_step current_step - warmup_steps return gamma ** (adjusted_step // step_size) return LambdaLR(optimizer, lr_lambda)多项式衰减Polynomial Decay多项式衰减的公式是lr peak_lr * (1 - progress)^power。这里的power参数控制曲线弯曲程度power1是线性衰减power 1时衰减前期较快、后期缓慢power 1时前期几乎持平、后期快速跳水。和 Warmup 组合时我一般推荐power取 2 到 3。这样模型在峰值学习率附近的时间不会太短中期的退火过程平滑后期又能精确收敛。循环学习率Cyclical LR如果你喜欢让学习率在训练中多次“退火再重启”也就是周期性循环那么“Warmup”这个概念会变成每个周期都有的一个环节。每个新的循环周期开始前学习率被重置为较低值然后快速升到新的峰值。这种策略下的 Warmup 通常很短只占每个周期的 5% 左右主要目的是平滑重置时带来的扰动。3.3 框架自带实现PyTorch和TensorFlow的快捷配置如果不想每次都手写调度器可以借助第三方库或框架自带能力。PyTorch 生态中最常用的transformers库提供了一个专门的接口from transformers import get_cosine_schedule_with_warmup scheduler get_cosine_schedule_with_warmup( optimizer, num_warmup_steps500, num_training_stepstotal_steps )这个接口内部实现的就是我上面手写的逻辑稳定且经过大量用户验证。如果你的项目里没有引入transformers也可以直接用我的手写版本两者行为一致。TensorFlow / Keras 中tf.keras.optimizers.schedules下没有现成的“Warmup Cosine”需要自己拼import tensorflow as tf class WarmUpCosine(tf.keras.optimizers.schedules.LearningRateSchedule): def __init__(self, warmup_steps, total_steps): super().__init__() self.warmup_steps warmup_steps self.total_steps total_steps def __call__(self, step): if step self.warmup_steps: return float(step) / float(max(1, self.warmup_steps)) progress float(step - self.warmup_steps) / float(max(1, self.total_steps - self.warmup_steps)) return 0.5 * (1.0 tf.cos(tf.constant(math.pi) * progress))用WarmUpCosine直接作为优化器的学习率即可lr_schedule WarmUpCosine(warmup_steps500, total_steps10000) optimizer tf.keras.optimizers.Adam(learning_ratelr_schedule)4. 超参数选择与实操心得总结4.1 峰值学习率怎么定怎么判断Warmup该在什么时候结束Warmup 的终点就是峰值学习率出现的位置。这个峰值并不是拍脑袋定的它直接决定了整个训练过程的稳定性。我试过两种比较靠谱的定峰值学习率的方法第一种保持在训练集的一小部分上做短周期的“学习率扫描”learning rate finder。做法是把学习率从很小逐步增大观察每步 loss 的变化loss 下降快且稳定的区域就是合适的峰值区间。这个方法在 Warmup 设计中同样适用你可以在一个很短的预跑中模拟 Warmup 结束后以不同的峰值学习率训练几十步看哪一组峰值产生的 loss 震荡最小。第二种沿用已有任务的成功经验。如果同类型任务、同数量级 batch size 下别人用过 1e-3 的峰值那可以从 1e-3 开始。但要注意模型大小、数据规模、优化器类型都会影响最佳峰值不可盲搬。具体判断 Warmup 是否结束得合适我会盯两个信号训练损失曲线在预热结束点附近是否出现一个明显的折点或鼓包。如果出现说明峰值学习率偏大Warmup 步数偏短。梯度范数是否已经从初期的剧烈波动回落到一个稳定范围。用torch.nn.utils.clip_grad_norm_之前先把梯度的norm打印出来只要它连续若干步都在同一个数量级Warmup 就可以收手了。4.2 Warmup的“大坑”这些情况千万要注意坑一改 batch size 后忘记调整 Warmup 步数这是最常见的坑没有之一。你原来用 32 的 batch总步数 10000Warmup 设 500。后来为了加速训练把 batch 翻倍到 64总步数减半变成 5000。如果你还是用 500 步的 Warmup那相当于 Warmup 占了总训练周期的 10% 变成了 20%严重挤压后面的衰减过程。正确做法是Warmup 步数应该随总步数同比例调整至少保持占比不变。坑二早停Early Stopping和 Warmup 节奏冲突用早停时训练可能在某个节点意外终止。如果你的模型在 Warmup 阶段就被早停判定了“性能不佳”很可能是误判——因为预热阶段的学习率还没到位模型能力没完全发挥。所以我建议早停的启动条件要设置在 Warmup 结束之后至少等模型跑完热身阶段再评估。坑三梯度裁剪Gradient Clipping与 Warmup 的关系很多人设置了梯度裁剪就放松了对学习率的警惕。实际上梯度裁剪只是把梯度的量级限制住并不能改变参数更新方向是否正确。如果 Warmup 阶段过短即使梯度被裁剪到固定范数更新方向依然可能是错误积累的模型照样会走偏。我通常的做法是Warmup 期间梯度裁剪阈值可以放宽比如从 1.0 放宽到 5.0让梯度真实信息充分流动等 Warmup 结束后再收紧裁剪范围。坑四迁移学习和微调场景下的 Warmup 设置如果你在加载预训练模型做微调初始状态不是随机初始化而是已经接近一个较好的局部区域。此时 Warmup 的作用不再是“防随机初始化震荡”而是“避免微调初期对预训练特征的大幅度破坏”。这种情况下Warmup 步数可以适当缩短但峰值学习率一定不能太大通常是全量训练的 1/10 到 1/5。4.3 实战效果记录三次调整Warmup的真实对比分享一组我最近跑分割任务的数据方便大家直观感受 Warmup 参数变化对训练曲线的影响固定总训练步数 1 万步优化器 AdamW峰值学习率 2e-4。配置Warmup步数训练后loss收敛情况无Warmup0最后卡在0.223后期loss波动明显线性Warmup 500步500最终0.154训练稳定后期平滑线性Warmup 1500步1500最终0.176热身时间过长浪费预算第一次跑不加 Warmup前 200 步 loss 直接飙到 8 以上然后慢慢回落但整个训练中后期 loss 一直带着一种“锯齿感”怎么调学习率衰减策略都没用。加了 500 步线性 Warmup 之后初期那个尖峰消失了中后期曲线也平坦了很多最终精度提升了 1.2 个点。后来我把 Warmup 加长到 1500 步发现前 1000 步基本在“空转”loss 下降曲线几乎是一条平线白白浪费了训练时间。所以 Warmup 确实不是越长越好合适的时长才是关键。4.4 我个人经验什么时候该怀疑Warmup的配置根据我调试无数训练任务的经验当训练出现以下三种现象时八成和 Warmup 配置有关现象一训练刚开始几十步就出现 loss 尖峰或 NaN。说明初始学习率太高或 Warmup 步数太短、起始学习率没有从足够低的位置开始。现象二Warmup 阶段 loss 不降反升。说明起始学习率可能设得偏大不是说 0 就是安全的或者 Warmup 步数太长导致梯度方向一直在震荡。现象三Warmup 结束后 loss 突然跳水。如果损失曲线在预热结束点出现一个急剧下降再回升的“V”形大概率是峰值学习率偏高衰减策略没能及时“接管”。当然还有一个容易被忽视的隐藏因素随机种子。Warmup 阶段的梯度轨迹和初始化状态强相关。同一个配置换一个随机种子可能出现完全不同的前期曲线。所以我做对比实验时一定会固定随机种子否则很容易把“种子差异”误判成“超参数差异”。收尾几个立即能用的调整建议坦白说Warmup 这个技术本身很简单——就是训练初期把学习率从小变大。但它牵扯出来的问题一点都不简单峰值学习率怎么选、预热多久、预热曲线选线性还是指数、预热之后接什么衰减策略每一步都要结合具体的模型、数据和训练预算来定。没有万能公式但确实有一些经验可以参考。如果你想在下一个训练任务里快速落地我给三个具体建议第一默认配置直接用“线性预热 余弦退火”的组合预热步数占训练总步数的 5%优化器初始学习率设为峰值。跑完一个完整训练后根据 loss 曲线前段的形态再微调这两个参数。第二把学习率曲线画出来看。不要只盯着 loss把每个 step 的实际学习率打印成 log和 loss 曲线叠加观察。很多问题一眼就能看出来曲线在预热结束点有没有突变、衰减阶段有没有断崖。第三做实验时固定一个随机种子专门留一组对比实验用不同种子确认“Warmup 的效果”在种子变化下依然稳定而不是偶然现象。Warmup 看似是一个小技巧但它是整个学习率调度体系的第一块砖第一块砖放歪了后面整面墙都会歪。值得花点时间把它调明白。