把固定换到动态时踩过的坑

把固定换到动态时踩过的坑一旦进项目,好看的架构图就没那么管用了。

很多人一上来就讲把固定换到动态时踩过的坑的全景图;我更想先把这次卡住的点说清楚。

背景与需求

最近在做一个图像分类任务,用 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.0181.2%100 epochs
StepLR0.0184.5%100 epochs
ReduceLROnPlateau0.0186.1%120 epochs
CosineAnnealingLR0.0187.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()

总结

学习率调度虽然看起来是个小细节,但对模型性能影响很大。从这次实践中我学到几点:

  1. 不要用固定学习率:除非有特殊理由,否则一定用动态学习率
  2. 选择合适的策略:不同任务适合不同调度器,多尝试几种
  3. 注意调用时机:记得在每个 epoch 结束时调用 scheduler.step()
  4. 监控学习率变化:定期检查学习率是否按预期变化

现在训练模型时,我都会先配置好学习率调度,这是提升性能最简单有效的方法之一。不用改模型、不用调数据结构,仅仅换个调度策略,就能提升几个点的准确率,性价比超高。

最后提醒一点:学习率调度不是万能的。如果模型本身有问题,或者数据质量差,再好的调度策略也救不了。但在合理的模型和数据基础上,学习率调度能让训练效果上一个台阶。

版权声明: 本文首发于 指尖魔法屋-把固定换到动态时踩过的坑https://blog.thinkmoon.cn/post/282-ai-learning-rate-schedule-fixed-dynamic-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!