1. 学习率调整的底层逻辑与整体设计思路
搞深度学习的人都有一个共识:模型能不能收敛、收敛得快不快、最终效果好不好,学习率这个参数的权重至少占一半。我见过太多案例,网络结构设计得漂漂亮亮,数据清洗也做得干干净净,结果训练的时候loss曲线跟过山车一样,或者干脆一动不动——十有八九,问题就出在学习率的设置上。
PyTorch把学习率调整这件事做得相当灵活,torch.optim.lr_scheduler这个模块里塞了十几种调度器,再加上你自己手写lambda函数的能力,基本上能想到的策略都能实现。但灵活带来的代价就是选择困难:到底该用哪个?什么时候用?参数怎么配?这些问题在官方文档里往往只有一句冷冰冰的公式,缺少实战视角的解读。
这篇文章要解决的问题很明确:把PyTorch中调整学习率的完整方法论拆开揉碎,从优化器与调度器的绑定关系讲起,到每一种主流调度器的适用场景和参数计算,再到自定义调度策略的实战写法,最后给出我在实际项目中反复验证过的组合方案和排错经验。不管你是刚入门的新手,还是已经跑过几个项目但总觉得调参靠玄学的朋友,应该都能从中找到可以直接抄作业的内容。
核心关键词就一个:PyTorch学习率调整。围绕它,我会把optim.lr_scheduler的调用机制、step()的时机、last_epoch的坑、以及不同调度器之间的组合逻辑全部串起来讲清楚。
1.1 为什么学习率不能一设了之
先想清楚一个根本问题:为什么不在训练开始的时候设一个固定学习率,从头跑到尾?
道理其实不复杂。训练初期,模型参数是随机初始化的,离最优解很远,这时候需要较大的学习率让参数快速向有意义的区域移动。训练后期,参数已经落在最优解附近了,这时候如果还用大学习率,参数就会在最优解附近反复横跳,甚至直接跳出去,导致loss震荡不收敛。所以理想的学习率曲线应该是前期大、后期小,这就是学习率衰减的基本动机。
但事情没这么简单。有些场景下学习率还需要周期性变大,比如模型陷入局部最优或者鞍点的时候,突然增大学习率可以帮助它跳出来。还有些场景需要学习率在训练中期保持稳定,只在特定节点做阶梯式下降。这些不同的需求,对应了PyTorch中不同的调度器实现。
我个人的经验是:学习率调度策略的选择,本质上是对训练过程中“探索”与“利用”两种需求的权衡。前期偏探索,后期偏利用,中间怎么过渡,就是调度器要回答的问题。
1.2 PyTorch调度器的两种调用范式
PyTorch中调整学习率有两种写法,这个必须一开始就分清楚,否则后面全是坑。
第一种是基于epoch的调度,也是最常见的用法。你在每个epoch训练结束后调用一次scheduler.step(),调度器根据当前epoch数计算新的学习率。这种方式的典型代表是StepLR、MultiStepLR、CosineAnnealingLR等。
第二种是基于迭代(batch)的调度,每个batch训练完就调用一次scheduler.step()。这种方式适合训练轮次少但每轮迭代次数多的场景,典型代表是OneCycleLR和CyclicLR。
两种范式的核心区别在于step()的调用频率。如果你把基于epoch的调度器按batch调用,学习率会衰减得飞快,模型还没学到东西就进入极小学习率状态了。反过来,把基于batch的调度器按epoch调用,学习率变化又太慢,起不到调度效果。
注意:PyTorch 1.1.0之后,
scheduler.step()必须在optimizer.step()之后调用。如果顺序反了,第一个epoch的学习率会被跳过,直接从第二个epoch的值开始。
1.3 调度器与优化器的绑定关系
所有调度器在初始化的时候都需要传入一个优化器实例,比如:
optimizer = torch.optim.SGD(model.parameters(), lr=0.1) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)这里有一个容易被忽略的细节:调度器修改的是优化器param_groups中的lr字段。也就是说,调度器并不直接修改模型参数,而是通过修改优化器的学习率来间接影响参数更新。这意味着如果你在训练过程中重新创建了优化器,之前绑定的调度器就失效了,需要重新绑定。
另外,optimizer.param_groups是一个列表,每个元素是一个字典,包含了该参数组的所有配置。如果你对不同的层设置了不同的学习率(比如微调时底层用小学习率、顶层用大学习率),那么调度器会同时修改所有参数组的学习率,但修改的比例是相同的。这一点在做迁移学习的时候要特别注意。
2. 主流调度器逐一拆解与参数计算
PyTorch目前提供了十几种学习率调度器,我不打算把每个都念一遍文档,而是按照实际使用频率和场景适配度,把最常用的几种拿出来详细讲。每一种我都会给出参数含义、学习率变化公式、适用场景和实操注意事项。
2.1 StepLR:最经典的阶梯式衰减
StepLR的逻辑非常简单:每隔固定的epoch数,学习率乘以一个衰减因子。
scheduler = torch.optim.lr_scheduler.StepLR( optimizer, step_size=30, # 每30个epoch衰减一次 gamma=0.1 # 衰减为原来的0.1倍 )学习率变化公式为:lr = initial_lr * gamma^(epoch // step_size)。
假设初始学习率是0.1,step_size=30,gamma=0.1,那么第0-29个epoch学习率是0.1,第30-59个epoch是0.01,第60-89个epoch是0.001,以此类推。
这种策略适合什么场景?我个人的经验是,StepLR适合训练轮次较长、且你对模型收敛速度有大致预期的情况。比如你知道大概在30个epoch左右模型会进入平台期,那就在那个位置设一个衰减点。但如果你不确定什么时候该衰减,StepLR的固定步长就可能错过最佳衰减时机。
有一个实操技巧:如果你发现模型在某个epoch之后loss突然不降了,可以把step_size设成比那个epoch稍小一点的值,让学习率提前衰减,往往能帮助模型继续下降。
2.2 MultiStepLR:多节点阶梯衰减
MultiStepLR是StepLR的升级版,允许你指定多个衰减节点,而不是等间隔衰减。
scheduler = torch.optim.lr_scheduler.MultiStepLR( optimizer, milestones=[30, 80, 120], # 在第30、80、120个epoch时衰减 gamma=0.1 )这种策略比StepLR灵活得多。实际项目中,我通常会在训练前先跑一个短周期的实验,观察loss曲线的拐点位置,然后把milestones设在这些拐点附近。比如loss在第25个epoch左右开始变平,第70个epoch又出现一次平台,那我就设milestones=[25, 70]。
提示:milestones列表中的epoch数是从0开始计数的。如果你在第30个epoch调用step(),此时epoch计数器已经是30,学习率会在这次调用后立即衰减。
2.3 ExponentialLR:指数衰减
ExponentialLR让学习率每个epoch都乘以一个衰减因子,变化曲线是平滑的指数函数。
scheduler = torch.optim.lr_scheduler.ExponentialLR( optimizer, gamma=0.95 # 每个epoch学习率乘以0.95 )公式为:lr = initial_lr * gamma^epoch。
这种策略的特点是衰减非常平滑,没有突变点。但缺点是衰减速度不好控制:gamma稍微小一点,学习率很快就趋近于零;gamma接近1,又几乎不起衰减作用。我一般只在训练轮次很短(比如20个epoch以内)且希望学习率缓慢下降的场景下用这个。
2.4 CosineAnnealingLR:余弦退火
CosineAnnealingLR是近年来非常流行的一种调度策略,学习率按照余弦函数的形状从最大值下降到最小值。
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=50, # 半个余弦周期的长度 eta_min=1e-6 # 学习率下限 )公式为:lr = eta_min + (initial_lr - eta_min) * (1 + cos(pi * epoch / T_max)) / 2。
这个策略的好处是前期衰减慢、中期衰减快、后期衰减又变慢,整体曲线很平滑。T_max通常设为总epoch数,这样学习率会在训练结束时刚好降到eta_min附近。
我在图像分类任务中特别偏好这个调度器,因为它不需要手动指定衰减节点,只要设一个T_max就行,省心且效果稳定。但要注意,如果你设的T_max小于实际训练轮次,学习率会在降到eta_min后重新上升(因为余弦函数是周期性的),这有时候是好事(可以帮助跳出局部最优),有时候是坏事(训练后期学习率突然变大导致loss震荡)。
2.5 CosineAnnealingWarmRestarts:带热重启的余弦退火
这是CosineAnnealingLR的增强版,每隔T_0个epoch,学习率会突然跳回初始值,然后重新开始余弦衰减。
scheduler = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts( optimizer, T_0=10, # 第一次重启的周期 T_mult=2, # 每次重启后周期翻倍 eta_min=1e-6 )热重启的核心价值在于:当模型陷入局部最优时,突然增大学习率可以帮助它跳出当前的盆地,去寻找更好的解。T_mult=2的设计让每次重启的周期越来越长,相当于在训练后期减少重启频率,让模型有足够时间在好的区域精细搜索。
这个策略我在一些Kaggle比赛和实际项目中用过,效果确实不错,尤其是当训练数据比较复杂、loss曲面比较崎岖的时候。但它也有一个明显的缺点:训练过程会变得不那么稳定,loss曲线会出现周期性的上升再下降,如果你对训练稳定性要求很高,可能不太适合。
2.6 OneCycleLR:单周期策略
OneCycleLR是Leslie Smith提出的超级收敛(Super Convergence)策略的核心组件。它的学习率变化分为两个阶段:前一半时间从低学习率线性上升到最高学习率,后一半时间从最高学习率线性下降到极低学习率。
scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=0.1, # 峰值学习率 total_steps=10000, # 总迭代次数 pct_start=0.3, # 上升阶段占总步数的比例 anneal_strategy='cos' # 衰减策略 )注意这个调度器是基于batch调用的,total_steps是总的batch迭代次数,不是epoch数。
OneCycleLR的威力在于:它允许你使用比常规训练大得多的峰值学习率,同时通过快速上升和下降来保持训练稳定。我实测下来,在同样的网络结构下,用OneCycleLR可以把训练时间缩短30%-50%,而且最终精度往往还略好于传统调度策略。
但它的使用门槛也高一些:你需要提前知道总迭代次数,而且max_lr的设置比较敏感。max_lr设得太小,超级收敛的效果出不来;设得太大,训练直接发散。我一般会先用一个较小的max_lr跑几个epoch,观察loss是否稳定下降,然后逐步增大max_lr,直到找到不发散的最大值。
2.7 ReduceLROnPlateau:基于指标的动态调整
前面几种调度器都是按照预设的规则调整学习率,而ReduceLROnPlateau是根据某个指标(通常是验证集loss)的变化来动态调整。
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', # 监控指标是越小越好 factor=0.1, # 衰减因子 patience=10, # 连续10个epoch指标没改善就衰减 threshold=1e-4, # 改善幅度小于这个值算没改善 cooldown=0, # 衰减后等待几个epoch再重新监控 min_lr=1e-7 # 学习率下限 )这个调度器的调用方式和其他不同,需要传入监控指标:
scheduler.step(val_loss)我通常会在验证集指标波动较大、或者不确定该在什么时候衰减学习率的场景下用这个。它的好处是自适应,不需要你手动指定衰减节点。但缺点是如果验证集指标噪声很大,可能会触发不必要的衰减,导致学习率过早降到很低。
注意:ReduceLROnPlateau的patience参数需要根据验证集评估频率来设。如果你每个epoch都评估一次验证集,patience=10意味着连续10个epoch没改善才衰减。如果你每5个epoch评估一次,那patience=10实际上相当于50个epoch。
3. 自定义学习率调度策略的实战写法
PyTorch内置的调度器虽然多,但实际项目中总会遇到一些特殊需求,比如warmup、分段线性衰减、或者根据训练进度动态调整。这时候就需要自己写调度逻辑。PyTorch提供了两种方式:LambdaLR和直接继承_LRScheduler。
3.1 用LambdaLR实现warmup加余弦衰减
Warmup是训练初期让学习率从很小的值逐渐上升到初始值的过程,目的是避免训练初期梯度过大导致模型不稳定。这个策略在Transformer类模型中几乎是标配。
import torch import math def warmup_cosine_lambda(epoch): warmup_epochs = 5 total_epochs = 100 base_lr = 1.0 # 这个值是乘在optimizer初始lr上的倍数 if epoch < warmup_epochs: # 线性warmup return (epoch + 1) / warmup_epochs else: # 余弦衰减 progress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs) return 0.5 * (1 + math.cos(math.pi * progress)) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=warmup_cosine_lambda)这里的关键是理解lr_lambda返回的是一个倍数,实际学习率等于optimizer初始lr * 返回值。所以如果你在优化器里设了lr=0.001,warmup阶段返回0.2,实际学习率就是0.0002。
3.2 继承_LRScheduler实现更复杂的逻辑
如果LambdaLR的表达能力不够,可以直接继承_LRScheduler类,重写get_lr()方法。
from torch.optim.lr_scheduler import _LRScheduler class CustomScheduler(_LRScheduler): def __init__(self, optimizer, total_epochs, last_epoch=-1): self.total_epochs = total_epochs super().__init__(optimizer, last_epoch) def get_lr(self): # 这里返回一个列表,对应每个参数组的学习率 progress = self.last_epoch / self.total_epochs # 分段:前30%保持,中间50%线性下降,后20%保持极小值 if progress < 0.3: factor = 1.0 elif progress < 0.8: factor = 1.0 - (progress - 0.3) / 0.5 * 0.99 else: factor = 0.01 return [base_lr * factor for base_lr in self.base_lrs]这种写法给了你完全的控制权,但也要注意几个细节:self.base_lrs是优化器初始的学习率列表,self.last_epoch是当前epoch数(从0开始)。另外,get_lr()在初始化时也会被调用一次,用来设置初始学习率。
3.3 手动调整学习率的兜底方案
有时候你可能不想用调度器,就想在训练循环里手动改学习率。这完全可行,而且有时候更直观。
for epoch in range(total_epochs): # 手动调整学习率 if epoch == 30: for param_group in optimizer.param_groups: param_group['lr'] = 0.001 elif epoch == 60: for param_group in optimizer.param_groups: param_group['lr'] = 0.0001 # 训练代码...这种方式的优点是逻辑完全透明,你想怎么改就怎么改。缺点是需要自己维护状态,而且如果用了多个参数组,要确保每个组都改到。
实操心得:手动调整学习率的时候,建议把调整逻辑写成一个函数,在训练循环开头调用。这样代码更清晰,也方便后续修改。
4. 常见问题与排查技巧实录
学习率调度这块,坑其实挺多的。我把自己和身边朋友踩过的坑整理了一下,按问题类型分类,附上排查思路和解决方法。
4.1 学习率没有按预期变化
这是最常见的问题。你设了StepLR,结果发现学习率一直没变,或者变化的时间点不对。
排查步骤:
- 检查
scheduler.step()是否被调用了。很多人写训练循环的时候忘了加这一行,或者把它写在了if分支里面,导致某些epoch没有调用。 - 检查调用顺序。
scheduler.step()必须在optimizer.step()之后调用。如果顺序反了,第一个epoch的学习率会被跳过。 - 检查
last_epoch参数。如果你在恢复训练的时候重新创建了调度器,但没有正确设置last_epoch,学习率会从初始值重新开始。 - 打印学习率确认。在训练循环里加一行
print(optimizer.param_groups[0]['lr']),直接看实际学习率是多少。
4.2 恢复训练后学习率不对
断点续训是实际项目中很常见的需求,但调度器的状态恢复经常出问题。
正确的做法是同时保存和加载优化器和调度器的状态字典:
# 保存 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']) start_epoch = checkpoint['epoch'] + 1如果你只保存了模型参数,没有保存调度器状态,恢复训练后调度器会从初始状态开始,学习率会跳回初始值,导致训练不稳定。
4.3 学习率衰减太快导致模型欠拟合
这个问题通常出现在StepLR或ExponentialLR上。你设了gamma=0.1,step_size=10,结果训练到第30个epoch学习率就降到1e-6了,模型还没学好就学不动了。
解决方法:
- 增大step_size或gamma,让衰减更慢。
- 设置学习率下限。StepLR本身没有min_lr参数,但你可以通过LambdaLR自己实现一个带下限的版本。
- 换用CosineAnnealingLR,它的衰减曲线更平滑,不会出现突然降到极小值的情况。
4.4 OneCycleLR的total_steps设错了
OneCycleLR要求你提前指定total_steps,也就是总的batch迭代次数。如果你设小了,学习率会在训练结束前就降到最低;设大了,训练结束了学习率还没降到位。
正确的计算方式是:total_steps = epochs * len(train_dataloader)。如果你用了梯度累积,还要除以累积步数。
提示:如果你不确定总步数,可以先跑一个epoch,看看有多少个batch,然后乘以预期的epoch数。
4.5 多参数组场景下的学习率调整
微调预训练模型的时候,通常会给底层设置较小的学习率,顶层设置较大的学习率。这时候调度器会同时调整所有参数组,但调整的比例是相同的。
optimizer = torch.optim.SGD([ {'params': model.base.parameters(), 'lr': 1e-4}, {'params': model.classifier.parameters(), 'lr': 1e-3} ], momentum=0.9) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)经过一次衰减后,base的学习率变成1e-5,classifier的变成1e-4。比例关系保持不变。如果你希望不同参数组有不同的衰减策略,那就需要为每个参数组单独创建调度器,或者自己写调度逻辑。
4.6 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 学习率始终不变 | scheduler.step()未调用 | 打印学习率确认 | 在正确位置添加step()调用 |
| 学习率变化时间点不对 | step()调用频率错误 | 检查是epoch级还是batch级 | 调整step()调用频率 |
| 恢复训练后学习率跳变 | 调度器状态未保存 | 检查checkpoint内容 | 保存并加载scheduler.state_dict() |
| 训练后期loss震荡 | 学习率过大或衰减不足 | 观察loss曲线 | 增大衰减因子或换用余弦退火 |
| 训练早期loss不降 | warmup不足或初始学习率过小 | 检查前几个epoch的学习率 | 增加warmup或增大初始学习率 |
| OneCycleLR效果不佳 | max_lr设置不当 | 尝试不同的max_lr | 从较小值开始逐步增大 |
5. 不同任务场景下的调度策略选型建议
说了这么多调度器的细节,最后落到实际选型上。不同的任务类型、不同的数据规模、不同的训练预算,适合的调度策略是不一样的。我按几个常见场景给出我的推荐方案。
5.1 图像分类任务
图像分类是我做得最多的任务类型。对于ResNet、EfficientNet这类经典网络,在中等规模数据集(比如几万到几十万张图片)上训练,我最常用的组合是:
- 优化器:SGD with momentum=0.9, weight_decay=1e-4
- 初始学习率:0.1(batch_size=256时)
- 调度器:CosineAnnealingLR,T_max设为总epoch数
- 训练轮次:100-200个epoch
这个组合的好处是稳定、可复现,不需要太多调参。如果你训练轮次比较少(比如30个epoch以内),可以用OneCycleLR来加速收敛。
5.2 自然语言处理任务
NLP任务,尤其是Transformer类模型,warmup几乎是必须的。我的常用配置是:
- 优化器:AdamW, betas=(0.9, 0.999), weight_decay=0.01
- 初始学习率:1e-4到5e-5
- 调度器:LinearLR warmup + CosineAnnealingLR decay
- Warmup比例:总步数的5%-10%
这里要特别注意:Transformer对学习率非常敏感,初始学习率设大了很容易发散。我一般会从1e-5开始试,确认稳定后再逐步增大。
5.3 小样本或微调任务
小样本学习或者微调预训练模型的时候,训练轮次通常很少(10-30个epoch),这时候调度策略要保守一些:
- 优化器:Adam, lr=1e-4
- 调度器:ReduceLROnPlateau,patience=3-5
- 或者:ConstantLR + 手动在验证集不提升时衰减
小样本场景下,验证集指标波动大,用ReduceLROnPlateau比固定步长衰减更合适。
5.4 训练预算有限时的加速方案
如果你只有一张卡,训练时间有限,OneCycleLR是最值得尝试的方案。我的经验是:
- 先用常规配置跑一个baseline,记录达到目标精度需要的epoch数。
- 换用OneCycleLR,max_lr设为baseline初始学习率的3-5倍。
- total_steps设为baseline epoch数的1/3到1/2。
- 观察loss曲线,如果发散就降低max_lr,如果收敛太慢就增大max_lr。
实测下来,这个方案通常能把训练时间压缩到原来的40%-60%,而且最终精度不会明显下降。
5.5 调度器组合使用的注意事项
有时候你会想组合多个调度器,比如先warmup再余弦衰减。PyTorch从1.4版本开始支持SequentialLR,可以按顺序组合多个调度器:
warmup_scheduler = torch.optim.lr_scheduler.LinearLR( optimizer, start_factor=0.1, total_iters=5 ) cosine_scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=95 ) scheduler = torch.optim.lr_scheduler.SequentialLR( optimizer, schedulers=[warmup_scheduler, cosine_scheduler], milestones=[5] )这种写法比用LambdaLR更清晰,也更容易维护。但要注意milestones的设置要和各个调度器的周期匹配,否则学习率曲线会出现不连续的跳变。
实操心得:组合调度器的时候,建议先用小规模数据跑几个epoch,把学习率曲线画出来看看。如果曲线有突变或者不连续的地方,说明milestones设置有问题。
5.6 学习率与batch size的关系
最后提一个经常被忽略的点:学习率的最优值和batch size是相关的。一般来说,batch size增大k倍,学习率也应该增大k倍左右(线性缩放规则)。但这个规则不是绝对的,当batch size很大时(比如超过4096),学习率的增长应该放缓(平方根缩放规则)。
如果你在调整batch size之后发现模型不收敛了,先检查学习率是不是需要同步调整。这个坑我在实际项目中踩过好几次,明明只是把batch size从64改到了256,结果loss直接爆炸,排查了半天才发现是学习率没跟着改。
我个人在实际操作中的体会是,学习率调度这件事没有银弹,最好的策略永远是结合具体任务、具体数据、具体模型来实验。但有了上面这些框架性的认知和实操经验,你至少可以少走很多弯路,把精力集中在真正需要创造力的地方。