3、学习率调优(下):学习率衰减策略与预热技巧
大家好,欢迎来到第三讲。上一讲我们聊了学习率的基础概念和几种常见优化器。今天咱们继续深入,专门讲讲学习率在训练过程中怎么动态调整——说白了,就是学习率衰减策略和预热(Warmup)技巧。
我个人觉得,学习率调优是模型训练里最「玄学」但也最见功力的部分。你想想看,同样的模型结构,同样的数据,学习率策略不同,最终效果可能差好几个点。我在项目中遇到过不少次,明明模型设计没问题,就是训练不收敛,最后发现是学习率衰减策略没选对。
3.1 为什么需要学习率衰减?
先问一个问题:训练初期和训练后期,模型对学习率的需求一样吗?
答案显然是否定的。训练初期,模型参数离最优解还很远,我们希望它大步流星地往前走,所以学习率可以大一些。到了后期,参数已经接近最优解附近了,如果学习率还很大,就容易在最优解附近来回震荡,甚至直接跳过最优解。嗯,这就是所谓的「收敛不稳定」问题。
所以,学习率衰减的核心思想就是:随着训练步数增加,逐步降低学习率。让模型前期快速探索,后期精细收敛。
核心原则:学习率衰减不是越复杂越好,关键是匹配你的数据规模和训练轮数。我见过有人用了一堆花哨的衰减策略,结果还不如简单的Step Decay效果好。
3.2 常见学习率衰减策略
下面我介绍三种最常用的衰减策略,也是我实际项目里用得最多的。每种我都会给出代码示例和适用场景。
3.2.1 Step Decay(阶梯式衰减)
Step Decay是最直观的一种方式。每隔固定的轮数(epoch),学习率乘以一个衰减因子(比如0.1)。举个例子:初始学习率0.01,每10个epoch衰减为原来的0.1倍。那么第0-9个epoch学习率是0.01,第10-19个epoch是0.001,以此类推。
import torch.optim.lr_scheduler as lr_scheduler
# 定义优化器
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
# Step Decay:每10个epoch,学习率乘以0.1
scheduler = lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)
for epoch in range(50):
train_one_epoch()
scheduler.step()
current_lr = optimizer.param_groups[0]['lr']
print(f'Epoch {epoch}: lr = {current_lr}')
我曾经在一个图像分类项目里用过Step Decay,数据集是CIFAR-100,训练100个epoch。我设step_size=30,gamma=0.1。结果发现第30个epoch之后学习率降得太快,模型还没来得及收敛就「卡住」了。后来我改成step_size=40,gamma=0.5,效果好了很多。所以啊,step_size和gamma这两个参数需要根据你的训练轮数来调,没有固定值。
我的建议:如果你不确定怎么设,可以先试试step_size=总epoch数/3,gamma=0.1。然后观察验证集loss曲线,如果loss下降变慢,说明衰减太快了,可以适当增大step_size或减小gamma。
3.2.2 Cosine Annealing(余弦退火)
Cosine Annealing是近年来非常流行的一种衰减策略。它的学习率变化曲线像余弦函数一样平滑下降,从初始值逐渐降到接近0。相比Step Decay的「阶梯式突变」,Cosine Annealing更平滑,不容易引起训练震荡。
# Cosine Annealing:T_max是半个周期长度,eta_min是最小学习率
scheduler = lr_scheduler.CosineAnnealingLR(
optimizer,
T_max=50, # 半个周期对应50个epoch
eta_min=1e-6 # 最小学习率
)
for epoch in range(100):
train_one_epoch()
scheduler.step()
这里有个小细节:T_max通常设为总epoch数的一半。比如你训练100个epoch,T_max=50,那么学习率会在前50个epoch从初始值降到eta_min,后50个epoch又从eta_min升回初始值?不对,Cosine Annealing默认是单调下降的,不会回升。除非你用了带重启的版本(CosineAnnealingWarmRestarts),那个会周期性回升。
我个人习惯在Transformer类模型(比如BERT、ViT)上优先使用Cosine Annealing。为什么呢?因为Transformer训练对学习率变化比较敏感,平滑衰减能减少训练过程中的loss震荡。我在一个NLP项目里对比过,Cosine Annealing比Step Decay的最终准确率高了约0.8%。
3.2.3 ReduceLROnPlateau(自适应衰减)
前面两种策略都是「无脑」按固定步数衰减,不管模型当前训练状态如何。但有时候我们希望学习率能根据模型的实际表现来调整——比如验证集loss连续几个epoch不下降了,那就说明模型可能陷入了局部最优或学习率太大,这时候应该降低学习率。
ReduceLROnPlateau就是干这个的。
# ReduceLROnPlateau:当验证loss连续5个epoch不下降时,学习率乘以0.1
scheduler = lr_scheduler.ReduceLROnPlateau(
optimizer,
mode='min', # 监控指标越小越好(比如loss)
factor=0.1, # 衰减因子
patience=5, # 容忍多少个epoch不下降
verbose=True, # 打印日志
min_lr=1e-7 # 最小学习率
)
for epoch in range(100):
train_one_epoch()
val_loss = validate()
scheduler.step(val_loss) # 传入监控指标
注意这里的scheduler.step()需要传入验证loss,而不是像前面那样直接调用。这也是ReduceLROnPlateau和前面两种策略最大的区别——它依赖外部指标。
避坑指南:我曾经在一个项目里用了ReduceLROnPlateau,但忘记在step()里传入val_loss,结果学习率一直没变,模型训了50个epoch还是初始学习率。排查了半天才发现是调用方式错了。所以,用这个策略时一定要检查你的step()传参是否正确。
ReduceLROnPlateau适合那种训练过程不太稳定的场景,比如小数据集、模型容易过拟合的情况。它的优点是自适应,缺点是引入了额外的超参数(patience、factor),需要根据实际训练曲线来调。
3.3 三种策略对比
| 策略 | 特点 | 适用场景 | 缺点 |
|---|---|---|---|
| Step Decay | 阶梯式下降,简单直观 | 训练轮数固定,数据量较大 | 衰减点选择敏感,可能突变导致震荡 |
| Cosine Annealing | 平滑下降,无突变 | Transformer、ViT等对学习率敏感模型 | 需要预设T_max,不够灵活 |
| ReduceLROnPlateau | 自适应,根据验证指标调整 | 小数据集、训练不稳定场景 | 依赖验证指标,可能过早或过晚衰减 |
3.4 预热(Warmup)技巧
聊完衰减,咱们再聊聊预热。预热和学习率衰减其实是「一对兄弟」——一个在训练初期用,一个在训练后期用。
预热的核心思想是:训练刚开始时,模型参数是随机初始化的,梯度方向不稳定。如果一开始就用较大的学习率,可能导致梯度爆炸或训练发散。所以,我们先让学习率从一个很小的值(比如0)逐渐增加到目标学习率,这个过程就叫预热。
预热通常持续几个epoch到几十个epoch不等。常见的预热方式有两种:
- 线性预热(Linear Warmup):学习率从0线性增加到目标值。
- 指数预热(Exponential Warmup):学习率从很小的值指数增长到目标值。
实际项目中,线性预热用得最多。下面是一个结合了预热和Cosine Annealing的完整代码示例:
import torch
import torch.optim as optim
from torch.optim.lr_scheduler import LambdaLR, CosineAnnealingLR
def get_cosine_schedule_with_warmup(optimizer, num_warmup_steps, num_training_steps):
"""带预热的Cosine Annealing调度器"""
def lr_lambda(current_step):
# 预热阶段:线性增加
if current_step < num_warmup_steps:
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 0.5 * (1.0 + torch.cos(torch.tensor(progress * 3.141592653589793)))
return LambdaLR(optimizer, lr_lambda)
# 使用示例
optimizer = optim.AdamW(model.parameters(), lr=1e-4)
total_steps = 10000
warmup_steps = 1000 # 前1000步预热
scheduler = get_cosine_schedule_with_warmup(
optimizer,
num_warmup_steps=warmup_steps,
num_training_steps=total_steps
)
for step in range(total_steps):
train_step()
scheduler.step()
这段代码里,前1000步学习率从0线性增加到1e-4,之后按照余弦曲线衰减到接近0。这种组合在NLP和CV的Transformer模型中几乎是标配。
重要经验:预热步数一般设为总训练步数的5%-10%。比如你训练10000步,预热500-1000步就差不多了。预热太长会浪费训练时间,太短又起不到稳定训练的作用。我在一个BERT微调项目里试过,预热步数从500调到2000,最终F1分数从0.89提升到了0.91。所以,预热这个参数值得认真调一调。
3.5 知识体系总览
为了让大家更直观地理解本章的知识结构,我画了一张流程图:
3.6 实战建议总结
最后,我给大家总结几条实战中的建议:
- 新手首选:如果你刚开始调参,先用Step Decay,简单可控。等熟悉了再尝试Cosine Annealing和ReduceLROnPlateau。
- Transformer模型:强烈建议用Warmup + Cosine Annealing的组合。这是目前最主流的做法,Hugging Face的transformers库默认就是这个策略。
- 小数据集:优先考虑ReduceLROnPlateau,因为它能根据验证集表现自适应调整,不容易过拟合。
- 预热步数:一般设为总步数的5%-10%。如果训练初期loss就很大,说明预热太短了,可以适当增加。
- 别忘了监控:无论用哪种策略,都要实时观察训练曲线(loss、准确率)。如果发现曲线异常,及时调整策略或超参数。
一个小技巧:我习惯在训练脚本里同时记录当前学习率,这样训练结束后可以画出学习率变化曲线,方便复盘。你可以在每个epoch结束时把scheduler.get_last_lr()保存下来,后面用matplotlib画出来看看。
好了,这一讲的内容就到这里。学习率衰减和预热是模型训练中非常实用的技巧,掌握了它们,你的模型训练会稳定很多。下一讲我们会聊聊权重初始化,这也是一个容易被忽视但影响很大的环节。