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 预热与学习率衰减的配合

预热只是学习率策略的前半段。完整的周期应该是:预热 → 保持 → 衰减。我常用的组合方案:

  1. 线性预热 + 余弦退火:最经典的搭配,很多主流框架默认就是这么干的
  2. 指数预热 + 阶梯式衰减:当任务中每若干轮后需要重新调整时,我偏好这种
  3. 常数预热 + 线性衰减:适合微调,简单可控
阶段 步数范围 学习率变化 我的建议
预热 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%),配合衰减方案。你按这个套路走,基本不会出大问题。