1. 学习率调整的底层逻辑与整体设计思路
做深度学习训练的人,迟早都会撞上同一个问题:模型训练到一半,loss 不降了,准确率卡在一个尴尬的位置上不去。很多人第一反应是换模型、加数据、调 batch size,但折腾一圈之后发现,真正卡住训练效果的,往往是那个最不起眼的超参数——学习率。
学习率决定了每次参数更新的步长。步子太小,训练慢得像蜗牛爬,loss 曲线几乎是一条平线;步子太大,loss 直接震荡甚至发散,模型根本收敛不了。更关键的是,一个固定的学习率几乎不可能从头到尾都好用:训练初期需要较大的步长快速下降,训练后期则需要较小的步长精细收敛。这就是学习率调整策略存在的意义。
PyTorch 在torch.optim.lr_scheduler模块里提供了一整套学习率调整工具,从最经典的 StepLR、MultiStepLR,到平滑衰减的 ExponentialLR、CosineAnnealingLR,再到基于指标动态调整的 ReduceLROnPlateau,基本覆盖了主流训练场景的需求。但工具多不代表好用,很多人用错了 scheduler 的调用时机,或者搞混了 epoch 级和 iteration 级的调整节奏,结果训练效果反而更差。
这篇文章面向的是已经能跑通基本训练流程、但在学习率调优上还比较模糊的读者。我会从 scheduler 的设计逻辑讲起,把每种策略的适用场景、参数含义、调用方式拆开说清楚,然后给出可以直接复现的完整代码,最后把我自己在实际训练中踩过的坑和排查经验整理出来。读完你应该能做到:拿到一个训练任务,能判断该用哪种 scheduler,参数怎么设,代码怎么写,出问题怎么查。
1.1 为什么固定学习率不够用
先把这个核心问题讲透。假设你有一个损失函数曲面,训练的过程就是在这个曲面上找最低点。学习率就是你每一步迈多大。
训练刚开始的时候,参数离最优点很远,梯度方向比较明确,这时候大步走效率高。如果一开始就用很小的学习率,可能几百个 epoch 都走不到有效区域。反过来,训练后期参数已经接近最优点了,曲面局部比较平坦,这时候还迈大步,就会在最低点附近来回跳,永远落不到那个坑里。
我用一个实际场景说明。之前做一个图像分类任务,数据集大概五万张图,用 ResNet 结构训练。固定学习率设 0.1,前 30 个 epoch loss 降得很快,从 2.3 降到 0.8,但之后就一直卡在 0.75 到 0.85 之间震荡,验证集准确率停在 72% 左右上不去。后来换成每 30 个 epoch 把学习率乘以 0.1,同样的网络和数据,最终验证准确率到了 79%。差别就是这么直接。
所以学习率调整不是锦上添花,而是训练策略里必须认真对待的一环。它的本质是:让优化过程在不同阶段有不同的“步长预算”,前期敢走,后期敢收。
1.2 PyTorch scheduler 的设计哲学
PyTorch 把学习率调整抽象成了一个独立的 scheduler 对象,和 optimizer 解耦。这个设计有好有坏。
好处是灵活。你可以自由组合 optimizer 和 scheduler,换策略不用改优化器代码。scheduler 内部维护一个last_epoch计数,每次调用step()就更新一次,然后根据策略计算新的学习率写回 optimizer 的 param_group。
需要注意的是,scheduler 并不直接修改 optimizer 的学习率,而是通过optimizer.param_groups里的lr字段来间接生效。也就是说,如果你手动改过 optimizer 的 lr,scheduler 下一次 step 时会覆盖掉你的修改。这一点后面讲坑的时候还会细说。
另一个设计点是:scheduler 的step()调用频率决定了调整的粒度。有的策略是按 epoch 调,有的是按 iteration 调,还有的是根据验证指标调。调用频率搞错,策略效果会完全走样。
2. 主流学习率调整策略逐一拆解
PyTorch 提供的 scheduler 大概可以分成几类:按固定步长衰减的、按平滑曲线衰减的、按指标动态调整的、以及自定义的。下面逐个说清楚每种的特点和适用场景。
2.1 StepLR 与 MultiStepLR:最经典的阶梯衰减
StepLR 的逻辑最简单:每隔固定的 epoch 数,学习率乘以一个衰减系数。
import torch import torch.optim as optim from torch.optim.lr_scheduler import StepLR optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9) scheduler = StepLR(optimizer, step_size=30, gamma=0.1)这段代码的意思是:每训练 30 个 epoch,学习率变成原来的 0.1 倍。初始 0.1,第 30 个 epoch 后变 0.01,第 60 个 epoch 后变 0.001,以此类推。
step_size和gamma是两个核心参数。step_size设太小,学习率掉得太快,模型还没学好就没步长可用了;设太大,衰减效果不明显。gamma一般取 0.1 或 0.5,取 0.1 是断崖式下降,取 0.5 相对温和。
MultiStepLR 是 StepLR 的升级版,可以指定在哪些 epoch 衰减,而不是等间隔。
from torch.optim.lr_scheduler import MultiStepLR scheduler = MultiStepLR(optimizer, milestones=[30, 60, 90], gamma=0.1)milestones列表里的数字就是衰减点。这种策略适合你对训练节奏有明确预期的情况,比如你知道模型大概在 30 epoch 左右收敛变慢,就在那里降一次。
注意:
milestones必须递增,且不能有重复值,否则行为未定义。
2.2 ExponentialLR 与 CosineAnnealingLR:平滑衰减路线
阶梯衰减的问题是学习率变化不平滑,每次衰减都会给训练带来一个小的“冲击”。如果你的任务对训练稳定性要求高,可以考虑平滑衰减策略。
ExponentialLR 每个 epoch 都把学习率乘以gamma:
from torch.optim.lr_scheduler import ExponentialLR scheduler = ExponentialLR(optimizer, gamma=0.95)gamma=0.95意味着每个 epoch 学习率降 5%,100 个 epoch 后大概降到初始值的 0.6%。这种衰减很温和,适合训练周期长、不想让学习率掉太快的场景。
CosineAnnealingLR 走的是余弦曲线:
from torch.optim.lr_scheduler import CosineAnnealingLR scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-5)T_max是半个余弦周期的长度,eta_min是学习率的下限。学习率会从初始值按余弦曲线降到eta_min,然后再升回去(如果训练超过T_max的话)。这种策略在近几年的论文里很常见,尤其是配合 warm restarts 使用时效果不错。
2.3 ReduceLROnPlateau:看指标说话的动态策略
前面几种策略都是“按计划”衰减,不管模型实际学得怎么样。ReduceLROnPlateau 不一样,它盯着一个指标(通常是验证集 loss),指标不降了就降学习率。
from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=10, verbose=True)参数含义:
mode='min':指标越小越好(loss);如果是准确率就设'max'factor=0.1:指标停滞时学习率乘以 0.1patience=10:连续 10 个 epoch 指标没改善才触发衰减verbose=True:衰减时打印日志
这个策略的好处是自适应,不需要你猜衰减点。坏处是它依赖验证指标的质量,如果验证集太小或噪声大,可能会误判。
注意:ReduceLROnPlateau 的
step()必须传入指标值,即scheduler.step(val_loss),不能空调用。
2.4 自定义 LambdaLR:想怎么调就怎么调
如果内置策略都不满足需求,可以用 LambdaLR 自定义学习率曲线:
from torch.optim.lr_scheduler import LambdaLR lambda_fn = lambda epoch: 0.5 ** (epoch // 20) scheduler = LambdaLR(optimizer, lr_lambda=lambda_fn)lr_lambda接收 epoch 数,返回一个乘数因子,实际学习率 = 初始学习率 × 因子。上面这个例子等价于每 20 个 epoch 学习率减半。
LambdaLR 的灵活性最高,但需要你自己设计曲线,适合对训练过程有深入理解的情况。
3. 实操流程与完整代码实现
光看参数说明不够,得把 scheduler 放进完整的训练循环里才能看出门道。这一节给出一个可以直接跑的模板,然后逐段解释关键点。
3.1 标准训练循环中的 scheduler 调用
import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR # 假设 model, train_loader, val_loader 已经定义好 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-5) num_epochs = 100 for epoch in range(num_epochs): # 训练阶段 model.train() train_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() train_loss += loss.item() # 验证阶段 model.eval() val_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) val_loss += loss.item() _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() val_loss /= len(val_loader) val_acc = 100.0 * correct / total # 关键:scheduler.step() 的位置 scheduler.step() current_lr = optimizer.param_groups[0]['lr'] print(f'Epoch [{epoch+1}/{num_epochs}] ' f'Train Loss: {train_loss/len(train_loader):.4f} ' f'Val Loss: {val_loss:.4f} Val Acc: {val_acc:.2f}% ' f'LR: {current_lr:.6f}')这段代码里最关键的一行是scheduler.step()的位置。它放在 epoch 循环的末尾,验证阶段之后。对于 CosineAnnealingLR、StepLR 这类按 epoch 调整的策略,这个位置是对的。
但如果你用的是 ReduceLROnPlateau,就得改成:
scheduler.step(val_loss)而且必须放在验证阶段之后,因为你需要 val_loss 这个值。
3.2 参数选择的具体计算过程
很多人设 scheduler 参数是拍脑袋,其实可以算。
以 CosineAnnealingLR 为例,学习率的变化公式是:
lr = eta_min + (base_lr - eta_min) * (1 + cos(pi * epoch / T_max)) / 2假设base_lr=0.1,eta_min=1e-5,T_max=100。在第 50 个 epoch 时:
lr = 1e-5 + (0.1 - 1e-5) * (1 + cos(pi * 0.5)) / 2 = 1e-5 + 0.09999 * (1 + 0) / 2 = 1e-5 + 0.049995 ≈ 0.05也就是说,训练到一半时学习率降到初始值的一半左右。到第 100 个 epoch 时,cos(pi) = -1,学习率降到eta_min。
这个曲线的好处是前期降得慢、中期降得快、后期又变慢,整体比较符合训练的实际需求。
对于 StepLR,step_size可以这样估:假设你计划训练 100 个 epoch,希望学习率衰减 3 次,那step_size大概取 30 左右。gamma取 0.1 的话,最终学习率是0.1 * 0.1^3 = 1e-4,这个量级对大多数任务来说是合理的收敛步长。
3.3 学习率预热(Warmup)的配合使用
有些训练任务在初期需要 warmup,就是让学习率从很小的值慢慢升上去,避免一开始的大梯度把参数带偏。PyTorch 没有内置 warmup,但可以用 LambdaLR 组合实现:
from torch.optim.lr_scheduler import LambdaLR def warmup_cosine(epoch): warmup_epochs = 5 total_epochs = 100 if epoch < warmup_epochs: return (epoch + 1) / warmup_epochs else: progress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs) return 0.5 * (1 + math.cos(math.pi * progress)) scheduler = LambdaLR(optimizer, lr_lambda=warmup_cosine)前 5 个 epoch 学习率从 0 线性升到初始值,之后按余弦曲线衰减。这种组合在 Transformer 类模型训练里几乎是标配。
4. 常见问题与排查技巧实录
scheduler 用起来不难,但坑不少。这一节把我遇到过和自己踩过的问题整理出来,附上排查思路。
4.1 scheduler.step() 调用位置错误
这是最常见的问题。症状是学习率变化不符合预期,或者训练效果比不用 scheduler 还差。
排查方法很简单:在每个 epoch 结束后打印optimizer.param_groups[0]['lr'],看学习率序列对不对。
常见错误包括:
- 把
scheduler.step()放在了 batch 循环里,导致每个 iteration 都调整一次,学习率掉得飞快 - 把
scheduler.step()放在了optimizer.step()之前,导致第一次更新用的是错误的学习率 - 用 ReduceLROnPlateau 时忘了传指标值
提示:PyTorch 1.1 之后,
scheduler.step()必须在optimizer.step()之后调用。虽然旧版本允许在之前调用,但新版本会给出警告。
4.2 恢复训练时学习率不对
如果你用 checkpoint 保存和恢复训练,scheduler 的状态也需要保存:
# 保存 torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict(), }, 'checkpoint.pth') # 恢复 checkpoint = torch.load('checkpoint.pth') model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) scheduler.load_state_dict(checkpoint['scheduler_state_dict'])只保存 model 和 optimizer 是不够的。scheduler 内部有last_epoch计数,不恢复的话它会从 0 重新开始,学习率会跳回初始值。
4.3 学习率衰减太快导致欠拟合
有时候你会发现训练后期 loss 几乎不动了,验证准确率也上不去。打印学习率一看,已经降到 1e-7 这种量级了。
这种情况通常是衰减策略太激进。解决办法:
- 增大
step_size或T_max - 增大
gamma(比如从 0.1 改成 0.5) - 设置
eta_min或学习率下限,不让它降到太低
我一般会在训练脚本里加一个学习率下限检查,如果低于 1e-6 就手动停掉或调整策略。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 学习率不变化 | scheduler.step() 没调用 | 打印 lr 序列 | 检查调用位置 |
| 学习率掉太快 | step 调用频率过高 | 确认在 epoch 级调用 | 移到 epoch 循环末尾 |
| 恢复训练后 lr 跳变 | scheduler 状态未保存 | 检查 checkpoint 内容 | 保存/加载 scheduler state_dict |
| ReduceLROnPlateau 不触发 | patience 太大或指标没改善 | 打印指标序列 | 减小 patience 或检查验证集 |
| 训练后期 loss 不动 | 学习率过低 | 打印当前 lr | 调整衰减参数或设下限 |
| warmup 阶段 loss 爆炸 | warmup 太短 | 检查 warmup epoch 数 | 增加 warmup 长度 |
4.5 几个实战中总结的小技巧
第一,训练初期先跑几个 epoch 不调学习率,观察 loss 下降情况,再决定用哪种 scheduler。不要一上来就套模板。
第二,如果拿不准参数,先用 ReduceLROnPlateau 跑一遍,它会自动帮你找到合适的衰减点。然后根据它的衰减时机,反推 StepLR 或 CosineAnnealingLR 的参数。
第三,学习率曲线要配合 batch size 看。batch size 增大时,梯度估计更准,可以用更大的学习率。有个经验公式是 lr 和 batch size 的平方根成正比,但不是绝对的,还是要实验验证。
第四,别忘了在 TensorBoard 或 WandB 里记录学习率曲线。训练出问题时,学习率曲线往往能直接告诉你哪里不对。
5. 不同任务场景下的策略选择建议
说了这么多策略,实际用的时候到底怎么选?我按任务类型给一些建议。
图像分类任务,数据量中等(几万到几十万张),训练周期 50 到 200 epoch。这种场景我一般用 CosineAnnealingLR 配合 5 个 epoch 的 warmup,T_max设为总 epoch 数。如果训练不太稳定,换成 StepLR,step_size设为总 epoch 数的三分之一,gamma=0.1。
目标检测和分割任务,训练周期通常更长,loss 波动也更大。这种场景 ReduceLROnPlateau 更合适,patience设大一点(15 到 20),让它有足够的时间观察。factor取 0.1 或 0.5。
自然语言处理任务,尤其是 Transformer 类模型,warmup 几乎是必须的。warmup 长度一般是总步数的 5% 到 10%,之后用线性衰减或余弦衰减。LambdaLR 是最灵活的选择。
小数据集微调任务,训练 epoch 少(10 到 30),学习率本身就应该设小一点(1e-4 到 1e-5)。这种场景用简单的 StepLR 就够了,甚至可以不调,固定学习率也能跑。
提示:不管用什么策略,都建议先跑一个短周期(比如 10 个 epoch)的试验,确认学习率曲线和 loss 曲线都正常,再开完整训练。
5.1 策略对比与选型参考
| 策略 | 调整粒度 | 适用场景 | 参数敏感度 | 推荐指数 |
|---|---|---|---|---|
| StepLR | epoch | 通用,训练周期明确 | 中 | 四星 |
| MultiStepLR | epoch | 已知衰减点 | 中 | 四星 |
| ExponentialLR | epoch | 长周期平滑衰减 | 低 | 三星 |
| CosineAnnealingLR | epoch | 中短周期,配合 warmup | 中 | 五星 |
| ReduceLROnPlateau | epoch | 不确定衰减点,指标驱动 | 高 | 四星 |
| LambdaLR | epoch | 自定义曲线,warmup | 高 | 四星 |
这个表是我个人经验的总结,不是绝对标准。实际选型还是要结合具体任务和数据特点。
5.2 学习率与 batch size 的配合关系
最后说一个容易被忽略的点:学习率和 batch size 是联动的。
batch size 增大,每次更新用的样本更多,梯度估计的方差更小,理论上可以用更大的学习率。有个经验法则是线性缩放:batch size 翻倍,学习率也翻倍。但这个法则在 batch size 很大时会失效,这时候用平方根缩放更稳。
实际操作中,我一般先固定 batch size(根据显存能开多大开多大),然后在这个 batch size 下调学习率。调好之后再考虑要不要改 batch size,改了就重新调学习率。不要两个一起动,否则出了问题很难定位。
另外,如果你用了梯度累积来模拟大 batch,学习率也要相应调整。梯度累积 N 次相当于 batch size 扩大了 N 倍,学习率可以适当放大,但不用严格按线性缩放,因为梯度累积的方差特性和真实大 batch 不完全一样。
这些细节看起来琐碎,但训练效果往往就藏在这些地方。学习率调整没有万能公式,多试、多记录、多对比,慢慢就能找到适合自己任务的那套参数。