学习率调度策略归纳总结

大家好,我是你们的讲师。今天这章我们聊聊学习率调度策略。说真的,这是我在项目中踩坑最多的地方之一。一个合适的调度策略,有时候比调模型结构还管用。我见过不少人把学习率设成固定值从头训到尾,结果模型要么不收敛,要么在局部最优里打转。你想想看,学习率本身就是个动态问题——训练初期需要大步探索,后期需要小步精细调整。所以,调度策略就是解决这个矛盾的。

为什么不能一直用固定学习率?

如果你用过一个固定学习率跑完整个训练过程,大概率会碰到这些情况:

  • 学习率太大:损失在震荡,死活降不下去
  • 学习率太小:前期训练像蜗牛爬,半天没进展
  • 中间某个阶段正好:但之后模型又卡住了

我记得有一次做Transformer文本生成,固定学习率1e-4训了50轮,loss一直在2.3附近晃悠。后来换了个余弦退火调度,同样轮次直接降到1.8。这就是调度的魔力。

常见调度策略分类

我把常用的学习率调度归结为四大类。下面这张图帮你快速建立整体认知:

学习率调度策略分类图 学习率调度 分段衰减 余弦周期 自适应调整 Step Decay Exponential Decay Cosine Annealing Warm Restarts ReduceLROnPlateau + Warmup 组合 提示:实际项目常将Warmup与分段衰减或余弦周期组合使用,效果更稳定。

逐策略解析与避坑

1. 分段衰减(Step Decay & Exponential Decay)

这是最经典的做法。每训练一定轮次,学习率乘以一个衰减因子。比如每10轮乘0.1。但我在项目里发现,Step Decay的问题是衰减时机很难把握。早了模型欠拟合,晚了震荡严重。

Exponential Decay更平滑,但衰减速度固定,不能根据实际loss动态调整。建议用在小数据集或baseline对比。

我曾经踩过的坑:用Step Decay时,步长设太大(比如每30轮衰减一次),结果模型在30轮前就过拟合了,后面衰减完全无效。后来改为每5轮监控一次validation loss,才找到合适步长。

2. 余弦周期(Cosine Annealing & Warm Restarts)

我超爱余弦退火。它的学习率按余弦曲线从大到小下降,再突然跳回大值(如果有重启)。Transformer训练中特别常用。为什么呢?因为它天然允许模型跳出局部最优再下降。

PyTorch里用起来很简单:

import torch.optim.lr_scheduler as lr_scheduler

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
scheduler = lr_scheduler.CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6)

如果你想要余弦重启:

scheduler = lr_scheduler.CosineAnnealingWarmRestarts(
    optimizer, T_0=10, T_mult=2, eta_min=1e-6
)

注意T_max是半个周期长度(从初始lr下降到eta_min的步数)。我一般设置T_max等于总轮次的一半,这样后半段一直用小学习率微调。

3. 自适应调整(ReduceLROnPlateau)

这个调度器不看轮次,看validation loss。当loss连续若干步不下降,就自动降低学习率。说白了就是“等模型自己告诉我该减速了”。在实际项目中,我基本都会加上这个作为兜底。

scheduler = lr_scheduler.ReduceLROnPlateau(
    optimizer, mode='min', factor=0.5, patience=5, verbose=True
)

# 每轮训练后调用
scheduler.step(val_loss)

注意patience设太小会导致学习率降太快,模型没时间充分探索。我在一个NLP项目里设patience=3,结果学习率一周内降了四次,模型直接学不进去了。后来改成patience=8才稳定。

4. 带上Warmup

Transformer训练几乎必加Warmup。因为刚开始模型参数随机,直接上大学习率会导致梯度爆炸。我的习惯是先用线性Warmup从0升到目标学习率,大概占总轮次的5%~10%。

class WarmupCosineScheduler:
    def __init__(self, optimizer, warmup_steps, total_steps, lr_max, lr_min=0):
        self.optimizer = optimizer
        self.warmup_steps = warmup_steps
        self.total_steps = total_steps
        self.lr_max = lr_max
        self.lr_min = lr_min
        self.step_num = 0

    def step(self):
        self.step_num += 1
        if self.step_num <= self.warmup_steps:
            lr = self.lr_max * (self.step_num / self.warmup_steps)
        else:
            progress = (self.step_num - self.warmup_steps) / (self.total_steps - self.warmup_steps)
            lr = self.lr_min + 0.5 * (self.lr_max - self.lr_min) * (1 + np.cos(np.pi * progress))
        for param_group in self.optimizer.param_groups:
            param_group['lr'] = lr

你也可以直接用PyTorch的LambdaLR,但自己写一个更灵活。我一般把Warmup和Cosine结合,效果在绝大多数Transformer变体上都稳定。

策略对比表

策略 适用场景 优点 缺点
Step Decay 小数据、baseline 简单直观,易调试 衰减时机需手动调
Exponential Decay 收敛速度已知 平滑连续 衰减过快或过慢
Cosine Annealing Transformer、CV大模型 自动降速,跳出局部最优 需配合Warmup
ReduceLROnPlateau 无法预估轮次的任务 自适应,兜底神器 延迟响应,可能降太晚
Warmup + 余弦 几乎所有Transformer项目 稳定、效果最好 实现稍复杂

个人经验:我在做机器翻译Transformer时,用了Warmup 4000步 + Cosine退火到1e-6,最终BLEU比固定学习率高了2.5分。这个组合我建议作为你调参的默认起点。

我的总结方法论

讲了这么多,其实就一句话:没有万能调度器,但有万能调试思路。我在项目中按以下优先级尝试:

  1. 先跑一个Warmup + Cosine Annealing基线
  2. 观察validation loss曲线,如果后期震荡明显,换成Exponential Decay或ReduceLROnPlateau
  3. 如果模型学得太慢,检查Warmup步数是否太长
  4. 如果模型过拟合,尝试缩短Warmup、加大衰减因子

嗯,上面这些都是我亲手踩过的坑总结出来的。尤其是最后一条——过拟合时,很多人第一反应是加正则,但其实学习率调得激进一点也能缓解。