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(), lr0.1) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.1)这里有一个容易被忽略的细节调度器修改的是优化器param_groups中的lr字段。也就是说调度器并不直接修改模型参数而是通过修改优化器的学习率来间接影响参数更新。这意味着如果你在训练过程中重新创建了优化器之前绑定的调度器就失效了需要重新绑定。另外optimizer.param_groups是一个列表每个元素是一个字典包含了该参数组的所有配置。如果你对不同的层设置了不同的学习率比如微调时底层用小学习率、顶层用大学习率那么调度器会同时修改所有参数组的学习率但修改的比例是相同的。这一点在做迁移学习的时候要特别注意。2. 主流调度器逐一拆解与参数计算PyTorch目前提供了十几种学习率调度器我不打算把每个都念一遍文档而是按照实际使用频率和场景适配度把最常用的几种拿出来详细讲。每一种我都会给出参数含义、学习率变化公式、适用场景和实操注意事项。2.1 StepLR最经典的阶梯式衰减StepLR的逻辑非常简单每隔固定的epoch数学习率乘以一个衰减因子。scheduler torch.optim.lr_scheduler.StepLR( optimizer, step_size30, # 每30个epoch衰减一次 gamma0.1 # 衰减为原来的0.1倍 )学习率变化公式为lr initial_lr * gamma^(epoch // step_size)。假设初始学习率是0.1step_size30gamma0.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时衰减 gamma0.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, gamma0.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_max50, # 半个余弦周期的长度 eta_min1e-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_010, # 第一次重启的周期 T_mult2, # 每次重启后周期翻倍 eta_min1e-6 )热重启的核心价值在于当模型陷入局部最优时突然增大学习率可以帮助它跳出当前的盆地去寻找更好的解。T_mult2的设计让每次重启的周期越来越长相当于在训练后期减少重启频率让模型有足够时间在好的区域精细搜索。这个策略我在一些Kaggle比赛和实际项目中用过效果确实不错尤其是当训练数据比较复杂、loss曲面比较崎岖的时候。但它也有一个明显的缺点训练过程会变得不那么稳定loss曲线会出现周期性的上升再下降如果你对训练稳定性要求很高可能不太适合。2.6 OneCycleLR单周期策略OneCycleLR是Leslie Smith提出的超级收敛Super Convergence策略的核心组件。它的学习率变化分为两个阶段前一半时间从低学习率线性上升到最高学习率后一半时间从最高学习率线性下降到极低学习率。scheduler torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr0.1, # 峰值学习率 total_steps10000, # 总迭代次数 pct_start0.3, # 上升阶段占总步数的比例 anneal_strategycos # 衰减策略 )注意这个调度器是基于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, modemin, # 监控指标是越小越好 factor0.1, # 衰减因子 patience10, # 连续10个epoch指标没改善就衰减 threshold1e-4, # 改善幅度小于这个值算没改善 cooldown0, # 衰减后等待几个epoch再重新监控 min_lr1e-7 # 学习率下限 )这个调度器的调用方式和其他不同需要传入监控指标scheduler.step(val_loss)我通常会在验证集指标波动较大、或者不确定该在什么时候衰减学习率的场景下用这个。它的好处是自适应不需要你手动指定衰减节点。但缺点是如果验证集指标噪声很大可能会触发不必要的衰减导致学习率过早降到很低。注意ReduceLROnPlateau的patience参数需要根据验证集评估频率来设。如果你每个epoch都评估一次验证集patience10意味着连续10个epoch没改善才衰减。如果你每5个epoch评估一次那patience10实际上相当于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_lambdawarmup_cosine_lambda)这里的关键是理解lr_lambda返回的是一个倍数实际学习率等于optimizer初始lr * 返回值。所以如果你在优化器里设了lr0.001warmup阶段返回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上。你设了gamma0.1step_size10结果训练到第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} ], momentum0.9) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.1)经过一次衰减后base的学习率变成1e-5classifier的变成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 momentum0.9, weight_decay1e-4初始学习率0.1batch_size256时调度器CosineAnnealingLRT_max设为总epoch数训练轮次100-200个epoch这个组合的好处是稳定、可复现不需要太多调参。如果你训练轮次比较少比如30个epoch以内可以用OneCycleLR来加速收敛。5.2 自然语言处理任务NLP任务尤其是Transformer类模型warmup几乎是必须的。我的常用配置是优化器AdamW, betas(0.9, 0.999), weight_decay0.01初始学习率1e-4到5e-5调度器LinearLR warmup CosineAnnealingLR decayWarmup比例总步数的5%-10%这里要特别注意Transformer对学习率非常敏感初始学习率设大了很容易发散。我一般会从1e-5开始试确认稳定后再逐步增大。5.3 小样本或微调任务小样本学习或者微调预训练模型的时候训练轮次通常很少10-30个epoch这时候调度策略要保守一些优化器Adam, lr1e-4调度器ReduceLROnPlateaupatience3-5或者ConstantLR 手动在验证集不提升时衰减小样本场景下验证集指标波动大用ReduceLROnPlateau比固定步长衰减更合适。5.4 训练预算有限时的加速方案如果你只有一张卡训练时间有限OneCycleLR是最值得尝试的方案。我的经验是先用常规配置跑一个baseline记录达到目标精度需要的epoch数。换用OneCycleLRmax_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_factor0.1, total_iters5 ) cosine_scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max95 ) 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直接爆炸排查了半天才发现是学习率没跟着改。我个人在实际操作中的体会是学习率调度这件事没有银弹最好的策略永远是结合具体任务、具体数据、具体模型来实验。但有了上面这些框架性的认知和实操经验你至少可以少走很多弯路把精力集中在真正需要创造力的地方。
阅读完成 · 觉得有帮助?