4. 学习率预热(Warm-up)技术:缓解初期震荡,提升收敛质量
4.1 为什么训练初期需要"预热"?
训练刚开始的时候,模型参数是随机初始化的。这个时候如果直接给一个比较大的学习率,你想想看会发生什么?梯度方向非常不稳定,参数一步迈出去可能就飞到"悬崖边"上了。我最早做FRL训练时就吃过这个亏——loss直接炸到NaN,白白浪费了十几个小时。
预热(Warm-up)就是让学习率从0或很小的值,逐步增加到预设的目标值。说白了,给模型一个"热身"的阶段,让它先站稳了再冲刺。
核心作用:
- 降低初期梯度爆炸/梯度消失的风险
- 使Batch Normalization的统计量更稳定
- 避免参数初期大幅震荡导致收敛到差解
- 对FRL中带噪声的时序梯度,效果尤其明显
4.2 几种主流的预热策略
不同场景下预热的方式不太一样。我总结了三类最常用的,你可以根据任务特点选。
| 策略名称 | 变化方式 | 适用场景 | 我个人的建议 |
|---|---|---|---|
| 线性预热 | 学习率从0线性增长到目标值 | 大多数CV/NLP任务,稳定且通用 | 初次尝试首选,实现简单 |
| 指数预热 | 学习率按指数曲线上升 | 时序梯度噪声较大的FRL任务 | 我曾在梯度抖动大的项目里用过,效果不错 |
| 常数预热 | 先用一个极小的固定学习率跑几步再切换 | 预训练微调、迁移学习 | 适合已有较好初始化的场景 |
4.3 线性预热——最通用的方案
线性预热就是让学习率随着训练步数线性增长。假设预热步数为 W,目标学习率为 η_target,在第 t 步时的实际学习率为:
η = η_target * (t / W) (t < W 时)
η = η_target (t ≥ W 时)
这种方法实现起来非常简单,而且效果稳定。我记得有一次在时序预测模型上,不预热训练到第500步loss就开始剧烈震荡,加了100步线性预热后,曲线平滑多了。
避坑提醒
我曾经以为预热步数越大越好,结果在一个小数据集上预热了2000步,反而拖慢了收敛。后来总结:预热步数一般取总步数的5%~10%,或者按任务规模设为几百到一两千步。你先拿这个范围试,再微调。
4.4 指数预热——更适合带噪声的FRL梯度
FRL里时序依赖很重,梯度中经常混入随机噪声。线性增长在早期步数增长太快,指数预热可以更平缓地过渡。公式是:
η = η_target * (1 - exp(-t / τ))
其中 τ 控制上升速度。τ 越小,上升越快;τ 越大,预热越平缓。我一般设 τ = W/3 左右。
一个真实的坑:有次我把 τ 设成10,预热步数设了1000。结果前500步学习率几乎没有增长,完全浪费了训练时间。建议先用 τ = W/3 试,然后观察前几轮的loss下降速度,慢了就调小。
4.5 预热与学习率衰减的配合
预热只是学习率策略的前半段。完整的周期应该是:预热 → 保持 → 衰减。我常用的组合方案:
- 线性预热 + 余弦退火:最经典的搭配,很多主流框架默认就是这么干的
- 指数预热 + 阶梯式衰减:当任务中每若干轮后需要重新调整时,我偏好这种
- 常数预热 + 线性衰减:适合微调,简单可控
| 阶段 | 步数范围 | 学习率变化 | 我的建议 |
|---|---|---|---|
| 预热 | 0 ~ W | 0 → η_target | 线性和指数均可,先试线性 |
| 保持 | W ~ 2W | 稳定在 η_target | 给模型充分探索一段时间 |
| 衰减 | 2W ~ T | 余弦或阶梯下降 | 根据验证集loss曲线来手动卡点 |
4.6 完整代码示例(PyTorch)
下面给一个可以直接跑的线性预热 + 余弦退火实现。注意这里我用了自定义的LambdaLR,你可以直接集成到训练循环里。
import torch
import torch.optim as optim
import numpy as np
class WarmupCosineLR:
def __init__(self, optimizer, warmup_steps, total_steps, eta_min=0):
self.optimizer = optimizer
self.warmup_steps = warmup_steps
self.total_steps = total_steps
self.eta_min = eta_min
self.base_lr = [group['lr'] for group in optimizer.param_groups][0]
def get_lr(self, step):
if step < self.warmup_steps:
# 线性预热
lr = self.base_lr * (step / self.warmup_steps)
else:
# 余弦退火
progress = (step - self.warmup_steps) / (self.total_steps - self.warmup_steps)
lr = self.eta_min + 0.5 * (self.base_lr - self.eta_min) * (1 + np.cos(np.pi * progress))
return lr
def step(self, step):
lr = self.get_lr(step)
for param_group in self.optimizer.param_groups:
param_group['lr'] = lr
# 使用示例
model = torch.nn.Linear(10, 2)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
scheduler = WarmupCosineLR(optimizer, warmup_steps=500, total_steps=5000)
for step in range(5000):
# ... 前向和反向 ...
scheduler.step(step)
注意!
上面代码中的step()要在每次参数更新后调用,而不是在更新前。我曾经犯过错,把学习率更新放在了反向传播之前,结果前几步的学习率全是0,模型根本没训练。正确顺序是:optimizer.step() → scheduler.step(step)。
4.7 预热策略选择流程(内联SVG)
4.8 几个实战技巧
- 预热步数别死脑筋:尽量按照总迭代步数的5%~10%来设定。比如训练10000步,预热500步比较合理。我曾见过有人预热3000步,结果后面有效训练时间缩水。
- 观察loss曲线判断预热是否过度:如果前10%的训练步数里loss下降非常快(意味着学习率增长得太慢导致浪费),可以缩短预热步数或换用指数预热。
- 配合梯度裁剪效果更佳:预热只是缓解了学习率过大带来的震荡,但FRL中偶尔还会出现单个样本产生的梯度异常。我习惯加梯度裁剪,global norm设为10左右。
- 多卡训练时注意同步:如果用了分布式训练,预热步数是按照global step来算的。我在一个项目里忘了除以GPU数,结果预热时间被放大了4倍,后面赶紧改了。
记住:预热不是万能药。如果你的任务本身收敛稳定(比如很小的BERT微调),不加预热可能问题也不大。但FRL这种时序依赖强、梯度噪声大的场景,我强烈建议加上。这是我踩过坑后换来的经验——不加预热,有60%的项目前500步会出各种NaN。
好了,预热技术就聊到这儿。关键就三点:选对策略(线性和指数为主),设对步数(总步数的5%~10%),配合衰减方案。你按这个套路走,基本不会出大问题。