AI学习率调度踩坑记录

按教程设置0.001,开始训练,盯着loss曲线等它下降,然后隔几轮降低学习率。

用ResNet-18训ImageNet分类任务,学习率0.001,Batch Size 32。

固定学习率的坑

最先遇到的坑是训练初期loss不稳定。用ResNet-18训ImageNet分类任务,学习率0.001,Batch Size 32。前几个epoch的loss曲线像心电图一样:

import torch
import torch.nn as nn
import torch.optim as optim

model = torchvision.models.resnet18(pretrained=False)
optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
criterion = nn.CrossEntropyLoss()

# 训练循环
for epoch in range(100):
    for inputs, labels in train_loader:
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

现象是前10轮loss忽上忽下,到第20轮才慢慢稳定下来。当时以为是模型结构问题,换了几个网络结构结果一样。

查资料才发现,学习率过大导致训练初期参数更新过于激进。尤其是预训练模型的输出层权重还没适应新任务,一开始就用全量学习率更新,自然容易震荡。

第一次解决方案是手动分段降低学习率:

def adjust_learning_rate(optimizer, epoch):
    """手动降低学习率"""
    if epoch < 10:
        lr = 0.001
    elif epoch < 30:
        lr = 0.0005
    elif epoch < 60:
        lr = 0.0001
    else:
        lr = 0.00001
    for param_group in optimizer.param_groups:
        param_group['lr'] = lr

这招确实有效,loss曲线平滑了,但写起来很麻烦。每个任务都要手动调阈值和衰减比例,而且一旦训练轮数变化又得重新算。

另一个坑是学习率衰减策略和训练轮数不匹配。有次调到一个60轮的任务,想着"50轮时降一次学习率",结果后面10轮根本来不及收敛。反过来,任务跑到150轮时,学习率衰减得太早,后期全靠小学习率慢慢磨,效率极低。

预设调度策略的实际体验

PyTorch自带了几个调度器,我开始逐个试。

StepLR:最简单但不一定最实用

StepLR是最直观的:每隔几轮学习率乘以一个衰减因子。

from torch.optim.lr_scheduler import StepLR

scheduler = StepLR(optimizer, step_size=30, gamma=0.1)

for epoch in range(100):
    train_one_epoch()
    scheduler.step()

用在一个简单的图像分类任务上,效果比手动分段好。但很快发现一个问题:step_size是个硬编码值。任务跑100轮时设30轮衰减一次,改成150轮时又得调整。

更麻烦的是,不同任务的收敛节奏不一样。有的任务20轮就收敛差不多了,有的要到80轮。固定衰减步长要么早了要么晚了,完全不管实际训练状态。

MultiStepLR:更灵活但预设值问题依然

MultiStepLR允许指定多个衰减点:

from torch.optim.lr_scheduler import MultiStepLR

scheduler = MultiStepLR(optimizer, milestones=[30, 60, 90], gamma=0.1)

这下可以把衰减点设得更细,但问题本质上没变——还是在预设训练轮数。几次调整后发现,经验值在同类任务间通用性有限。换个数据集,原本的衰减点就不合适了。

ExponentialLR:衰减太激进

指数衰减看起来很科学:

from torch.optim.lr_scheduler import ExponentialLR

scheduler = ExponentialLR(optimizer, gamma=0.99)

但在实际任务中衰减太快。训练50轮后学习率已经降得很低,后期更新几乎停滞。除非任务确实需要前期快后期慢,否则容易变成前期还没收敛学习率就衰减了。

余弦退火调度器是一个更动态的解决方案,通过周期性调整学习率来帮助模型跳出局部最优。这种方法不再依赖固定的衰减步长,而是根据训练进程自动调整学习率,使优化过程更加灵活。关键在于它能在训练的不同阶段提供不同强度的参数更新,有助于模型更好地探索参数空间。 Warmup的尝试和教训

刚开始训练模型时,直接使用大学习率往往会导致模型训练初期loss剧烈震荡。这个现象在预训练模型上尤为明显,尤其是输出层和最后几层的权重还未适应新任务时。

一个自然的解决方案是让模型先"热身"几个epoch,用较小的学习率慢慢适应,然后再恢复到正常学习率。

def warmup_lr_scheduler(optimizer, warmup_epochs, target_lr):
    def lr_lambda(epoch):
        if epoch < warmup_epochs:
            return float(epoch + 1) / float(warmup_epochs) * target_lr
        return 1.0
    return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)

scheduler = warmup_lr_scheduler(optimizer, warmup_epochs=5, target_lr=0.001)

在一个迁移学习任务上,5个epoch的warmup确实让loss曲线平滑了很多。但后来发现,warmup不是灵丹妙药。有次在大规模数据集上训练,warmup时间太长导致前期收敛慢,浪费时间;warmup太短又起不到稳定作用。

而且warmup和后续调度策略的衔接是个问题。warmup结束后直接跳到学习率峰值,有时会造成训练loss突然上升。后来改成warmup + CosineAnnealing的组合:

from torch.optim.lr_scheduler import CosineAnnealingLR, SequentialLR

warmup_scheduler = LambdaLR(optimizer, lr_lambda=lambda epoch: epoch / warmup_epochs)
main_scheduler = CosineAnnealingLR(optimizer, T_max=100-warmup_epochs, eta_min=1e-6)
scheduler = SequentialLR(optimizer, schedulers=[warmup_scheduler, main_scheduler], milestones=[warmup_epochs])

这样的组合更平滑,但调参变得更复杂。warmup_epochs、T_max、eta_min都要考虑,而且不同任务之间通用性依然有限。

监控式调度:虽然慢但靠谱

预设调度策略最大的问题是不管训练状态。有时候模型提前收敛了还在保持高学习率,有时候loss还在震荡学习率就已经衰减了。

我开始尝试根据验证集loss来动态调整学习率。

ReduceLROnPlateau:最实用的监控调度

ReduceLROnPlateau会监控一个指标,当指标不再改善时降低学习率:

from torch.optim.lr_scheduler import ReduceLROnPlateau

scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=5)

for epoch in range(100):
    train_loss = train_one_epoch()
    val_loss = validate()
    scheduler.step(val_loss)

这个调度器在实际项目中用得最多。当验证集loss连续5轮不下降时,学习率乘以0.1。这在很多任务上效果都不错,比预设衰减点更贴合实际训练状态。

但ReduceLROnPlateau也有坑。patience设得太小会频繁触发学习率衰减,导致训练后期学习率降得太快;patience太大又会让训练浪费在没有意义的epoch上。

另一个问题是,如果初始学习率设置不合理,ReduceLROnPlateau也没法自动纠正。还是得靠手动调初始值。

自定义监控策略

有时候ReduceLROnPlateau的单一指标不够用。比如不仅要看loss,还要看accuracy,或者要监控多个指标。我写过一些自定义的监控策略:

def custom_lr_scheduler(optimizer, history):
    """
    根据历史指标调整学习率
    history: list of (epoch, train_loss, val_loss, val_acc)
    """
    if len(history) < 10:
        return  # 训练刚开始不调整

    recent_losses = [h[2] for h in history[-5:]]
    if max(recent_losses) - min(recent_losses) < 0.001:
        # loss基本稳定,降低学习率
        for param_group in optimizer.param_groups:
            param_group['lr'] *= 0.5

    if history[-1][3] > 0.95:  # accuracy超过95%时
        for param_group in optimizer.param_groups:
            param_group['lr'] *= 0.1

这种自定义策略更灵活,但写起来更复杂,而且不同任务之间通用性几乎为零。每个任务都要根据具体指标写逻辑。

分层学习率:迁移学习的专用工具

在迁移学习任务中,通常希望预训练的卷积层用较小学习率微调,而新添加的分类器用较大学习率从头训练。PyTorch可以通过为不同参数组设置不同学习率来实现:

model = torchvision.models.resnet18(pretrained=True)

# 冻结前面几层
for param in model.layer1.parameters():
    param.requires_grad = False
for param in model.layer2.parameters():
    param.requires_grad = False

# 不同参数组用不同学习率
optimizer = optim.SGD([
    {'params': model.layer3.parameters(), 'lr': 0.0001},
    {'params': model.layer4.parameters(), 'lr': 0.0001},
    {'params': model.fc.parameters(), 'lr': 0.001}
], momentum=0.9)

# 调度器会对所有参数组统一调整
scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=5)

分层学习率在迁移学习任务上效果明显。预训练层保持稳定,只做微调,而新层可以快速适应任务。

但分层学习率增加了调参复杂度。每一层的初始学习率都要考虑,而且调度器通常会按比例同时调整所有参数组的学习率。想要单独调整某一层的学习率衰减策略,就需要更复杂的调度逻辑。

OneCycle学习率:一次完整的生命周期

OneCycle学习率策略试图模拟一个完整的"生命周期":开始时学习率线性上升,然后线性下降,最后再小幅下降。

from torch.optim.lr_scheduler import OneCycleLR

scheduler = OneCycleLR(optimizer, max_lr=0.01, total_steps=10000)

OneCycle的特点是会自动计算总的步数,然后分配学习率的变化。这在总步数明确的情况下很方便,比如每个epoch的batch数量固定的训练任务。

我在几个快速训练任务上试过OneCycle,确实能在较少步数下获得不错的效果。但OneCycle有一个明显的限制:必须提前知道total_steps。如果训练过程中需要根据验证集指标提前停止,或者动态调整训练轮数,OneCycle就不太适用。

而且OneCycle的max_lr参数依然需要手动调整。调大了前期会震荡,调小了后期收敛慢。

实际项目中的选择策略

经过一段时间的折腾,我发现没有"万能"的学习率调度策略。不同的任务、不同的数据集、不同的模型,适合的策略都不一样。

在实际项目中,我的选择策略通常是:

标准分类任务:先用ReduceLROnPlateau,监控验证集loss,patience设为5-10轮,factor设为0.1。如果前期不稳定,加个3-5轮的warmup。

warmup_scheduler = LambdaLR(optimizer, lr_lambda=lambda epoch: epoch / warmup_epochs)
main_scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=10)
scheduler = SequentialLR(optimizer, schedulers=[warmup_scheduler, main_scheduler], milestones=[warmup_epochs])

迁移学习任务:用分层学习率 + ReduceLROnPlateau。预训练层用较小学习率,新层用较大学习率。

optimizer = optim.SGD([
    {'params': base_params, 'lr': 0.0001},
    {'params': new_params, 'lr': 0.001}
], momentum=0.9)

scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5)

总步数明确的快速训练:用OneCycle或者CosineAnnealing。这种情况下任务本身就会在固定步数结束,不需要后续调整。

大模型微调:用很小的学习率 + ReduceLROnPlateau,factor设为0.5,避免学习率衰减太激进。

这个策略不是标准答案,但在我的实际项目中还算管用。最重要的是,任何调度策略都不能替代合理的初始学习率选择和训练过程监控。

几个容易被忽略的细节

检查学习率是否真的在变化。有次调试了很久才发现,optimizer的learning rate没有变化,因为忘记在training loop里调用scheduler.step()。

# 正确的写法
for epoch in range(100):
    train_one_epoch()
    scheduler.step()  # 不要忘了这行

不同调度器的step()调用时机不同。像ReduceLROnPlateau需要在每个epoch结束后调用,并传入监控指标;而OneCycle需要在每个batch后调用。

# ReduceLROnPlateau
scheduler.step(val_loss)

# OneCycle
for batch in dataloader:
    train_batch()
    scheduler.step()

学习率调度和optimizer的保存加载要注意。保存checkpoint时要保存scheduler的状态,否则加载后学习率会重置。

checkpoint = {
    'model': model.state_dict(),
    'optimizer': optimizer.state_dict(),
    'scheduler': scheduler.state_dict()
}
torch.save(checkpoint, 'checkpoint.pth')

# 加载
checkpoint = torch.load('checkpoint.pth')
model.load_state_dict(checkpoint['model'])
optimizer.load_state_dict(checkpoint['optimizer'])
scheduler.load_state_dict(checkpoint['scheduler'])

打印学习率变化。训练过程中打印当前学习率,有助于调试和验证调度策略是否正常工作。

for epoch in range(100):
    train_one_epoch()
    current_lr = optimizer.param_groups[0]['lr']
    print(f'Epoch {epoch}, LR: {current_lr}')
    scheduler.step()

这些细节看起来琐碎,但调试学习率调度问题时往往能提供关键信息。

学习率调度这件事,说到底没有银弹。固定的学习率容易踩坑,预设的调度策略不够灵活,监控式调度又增加复杂度。在实际项目中,还是要根据任务特点选择合适的策略,并且一定要监控训练过程,及时调整。

这大概就是深度学习训练的现实:没有完美的超参数,只有不断试探和调整的过程。

可用性说明:本文发布于 2021 年 3 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-AI学习率调度踩坑记录https://blog.thinkmoon.cn/post/999-ai-learning-rate-scheduling-from-static-to-dynamic/) 转载或引用必须申明原指尖魔法屋来源及源地址!