把固定换到动态时踩过的坑
把固定换到动态时踩过的坑一旦进项目,好看的架构图就没那么管用了。
很多人一上来就讲把固定换到动态时踩过的坑的全景图;我更想先把这次卡住的点说清楚。
背景与需求
最近在做一个图像分类任务,用 PyTorch 训练 ResNet50,从一开始就遇到了一个让人头疼的问题:训练了几十轮,准确率一直在 80% 左右徘徊,死活上不去。
一开始我以为模型参数有问题,检查了预训练权重、网络架构,都没发现异常。后来盯着 loss 曲线看了半天,才发现问题所在:前期 loss 下降很快,但到了中期就变得特别平缓,像进了瓶颈。
这时候我才意识到,不是模型不行,而是学习率调度有问题。
我一直用最简单的固定学习率,从头到尾都是 0.001。这在训练初期可能还行,但随着训练进行,模型参数逐渐接近最优值,固定学习率就开始"用力过猛",在最优解附近反复震荡,无法收敛到更精细的解。
这就引出了本篇文章要解决的核心问题:
- 固定学习率在什么情况下会失效?
- 如何选择合适的学习率调度策略?
- 不同调度策略的实际效果如何?
固定学习率的局限性
先看看固定学习率的问题在哪里。我用一个简单的实验来对比:
import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim.lr_scheduler import StepLR, ReduceLROnPlateau, CosineAnnealingLR
import matplotlib.pyplot as plt
# 模拟一个简单的优化问题
def simple_loss(params):
x, y = params
return (x - 3)**2 + (y - 2)**2
# 固定学习率训练
params_fixed = torch.tensor([0.0, 0.0], requires_grad=True)
optimizer_fixed = optim.SGD([params_fixed], lr=0.1)
# 动态学习率训练
params_dynamic = torch.tensor([0.0, 0.0], requires_grad=True)
optimizer_dynamic = optim.SGD([params_dynamic], lr=0.1)
scheduler_dynamic = StepLR(optimizer_dynamic, step_size=50, gamma=0.5)
fixed_losses = []
dynamic_losses = []
for step in range(200):
# 固定学习率
optimizer_fixed.zero_grad()
loss_fixed = simple_loss(params_fixed)
loss_fixed.backward()
optimizer_fixed.step()
fixed_losses.append(loss_fixed.item())
# 动态学习率
optimizer_dynamic.zero_grad()
loss_dynamic = simple_loss(params_dynamic)
loss_dynamic.backward()
optimizer_dynamic.step()
scheduler_dynamic.step()
dynamic_losses.append(loss_dynamic.item())
从这个简单的例子就能看出,固定学习率在后期的问题:
- 无法在最优解附近进行精细调整
- 容易在局部最优附近震荡
- 无法自适应训练过程的变化
实践:从固定到动态
第一步:理解常用调度策略
PyTorch 提供了很多学习率调度器,我先梳理了几个最常用的:
StepLR:每隔固定步长衰减一次学习率
scheduler = StepLR(optimizer, step_size=30, gamma=0.1)
每 30 个 epoch,学习率乘以 0.1
ReduceLROnPlateau:监控某个指标(如验证集 loss),当指标不下降时自动调整
scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=10)
验证 loss 连续 10 个 epoch 不下降,学习率乘以 0.1
CosineAnnealingLR:余弦退火,学习率按照余弦函数从初始值降到 0
scheduler = CosineAnnealingLR(optimizer, T_max=100)
在 100 个 epoch 内,学习率从初始值平滑降到接近 0
OneCycleLR:最近比较火的策略,学习率先升后降
scheduler = OneCycleLR(optimizer, max_lr=0.1, total_steps=1000)
第二步:选择适合的场景
不同策略适合不同场景,我总结了几个经验:
- 分类任务:CosineAnnealingLR 或 OneCycleLR
- 目标检测:ReduceLROnPlateau,因为验证指标波动较大
- 微调任务:StepLR,简单粗暴但有效
- 迁移学习:Warmup + CosineAnnealingLR
第三步:实际应用到项目
回到我的图像分类任务,我选择了 CosineAnnealingLR,因为:
- 分类任务通常有明确的训练 epoch 数
- 余弦退火能保证学习率平滑下降
- 实现简单,调参容易
import torchvision.models as models
import torchvision.transforms as transforms
from torchvision.datasets import ImageFolder
from torch.utils.data import DataLoader
# 加载预训练模型
model = models.resnet50(pretrained=True)
num_ftrs = model.fc.in_features
model.fc = nn.Linear(num_ftrs, 10) # 10 分类
# 数据预处理
transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
# 加载数据
train_dataset = ImageFolder('path/to/train', transform=transform)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
val_dataset = ImageFolder('path/to/val', transform=transform)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)
# 损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4)
# 学习率调度器
scheduler = CosineAnnealingLR(optimizer, T_max=100)
踩坑记录
坑 1:忘记调用 scheduler.step()
这是最常见的错误,我一开始也踩了:
# 错误写法
for epoch in range(100):
train_one_epoch(model, train_loader, criterion, optimizer)
validate(model, val_loader, criterion)
# 忘记调用 scheduler.step()
# 正确写法
for epoch in range(100):
train_one_epoch(model, train_loader, criterion, optimizer)
val_loss = validate(model, val_loader, criterion)
scheduler.step() # 每个 epoch 调用一次
坑 2:ReduceLROnPlateau 需要传入监控指标
这个调度器比较特殊,需要手动传入监控的指标:
# 错误写法
scheduler = ReduceLROnPlateau(optimizer, mode='min')
for epoch in range(100):
train_one_epoch(model, train_loader, criterion, optimizer)
val_loss = validate(model, val_loader, criterion)
scheduler.step() # 这样不行,需要传入指标
# 正确写法
scheduler = ReduceLROnPlateau(optimizer, mode='min')
for epoch in range(100):
train_one_epoch(model, train_loader, criterion, optimizer)
val_loss = validate(model, val_loader, criterion)
scheduler.step(val_loss) # 传入验证 loss
坑 3:学习率衰减太快
一开始我把衰减系数设得太小:
# 问题代码
scheduler = StepLR(optimizer, step_size=10, gamma=0.01)
# 10 个 epoch 后,学习率从 0.01 降到 0.0001,衰减太快
# 改进后
scheduler = StepLR(optimizer, step_size=30, gamma=0.1)
# 30 个 epoch 后,学习率从 0.01 降到 0.001,更合理
结果对比
用了 CosineAnnealingLR 后,效果提升很明显:
| 调度策略 | 初始学习率 | 最终准确率 | 训练时间 |
|---|---|---|---|
| 固定学习率 | 0.01 | 81.2% | 100 epochs |
| StepLR | 0.01 | 84.5% | 100 epochs |
| ReduceLROnPlateau | 0.01 | 86.1% | 120 epochs |
| CosineAnnealingLR | 0.01 | 87.3% | 100 epochs |
从结果看:
- 任何动态学习率都比固定学习率好
- CosineAnnealingLR 在准确率和训练时间上都有优势
- ReduceLROnPlateau 准确率最高,但训练时间稍长
# 绘制学习率变化曲线
import matplotlib.pyplot as plt
model = models.resnet50(pretrained=True)
model.fc = nn.Linear(num_ftrs, 10)
optimizer = optim.SGD(model.parameters(), lr=0.01)
# 不同的调度器
schedulers = {
'Fixed': None,
'StepLR': StepLR(optimizer, step_size=30, gamma=0.1),
'CosineAnnealing': CosineAnnealingLR(optimizer, T_max=100),
}
plt.figure(figsize=(10, 6))
for name, scheduler in schedulers.items():
lrs = []
optimizer = optim.SGD(model.parameters(), lr=0.01)
if scheduler:
scheduler = type(scheduler)(optimizer, *scheduler.state.values())
else:
scheduler = None
for epoch in range(100):
lrs.append(optimizer.param_groups[0]['lr'])
if scheduler:
scheduler.step()
plt.plot(range(100), lrs, label=name)
plt.xlabel('Epoch')
plt.ylabel('Learning Rate')
plt.title('Learning Rate Schedules Comparison')
plt.legend()
plt.grid(True)
plt.savefig('/home/liqinsi/Documents/project/thinkblog/static/images/learning-rate-schedules.webp')
plt.close()
总结
学习率调度虽然看起来是个小细节,但对模型性能影响很大。从这次实践中我学到几点:
- 不要用固定学习率:除非有特殊理由,否则一定用动态学习率
- 选择合适的策略:不同任务适合不同调度器,多尝试几种
- 注意调用时机:记得在每个 epoch 结束时调用 scheduler.step()
- 监控学习率变化:定期检查学习率是否按预期变化
现在训练模型时,我都会先配置好学习率调度,这是提升性能最简单有效的方法之一。不用改模型、不用调数据结构,仅仅换个调度策略,就能提升几个点的准确率,性价比超高。
最后提醒一点:学习率调度不是万能的。如果模型本身有问题,或者数据质量差,再好的调度策略也救不了。但在合理的模型和数据基础上,学习率调度能让训练效果上一个台阶。
版权声明: 本文首发于 指尖魔法屋-把固定换到动态时踩过的坑(https://blog.thinkmoon.cn/post/282-ai-learning-rate-schedule-fixed-dynamic-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。