
做深度学习训练的人大概都经历过这种时刻模型训练到了最后一百个epochloss卡在平台附近上蹿下跳你知道该调学习率了但又怕手动调坏节奏。PyTorch自带的PolynomialLR就是为解决这种“优雅收尾”而生的学习率调度器让学习率沿一条多项式曲线平滑滑向终点模型在最后阶段能稳定落点而不是反复震荡。这篇文章我会把公式、源码逻辑、工程接入、Warmup搭配、调度器横向对比和实际踩坑全部串起来讲希望对踩在训练收尾门槛上的同学有点帮助。1. 为什么“收尾”阶段的学习率决定模型最终成绩1.1 训练后期 loss 震荡的本质神经网络训练的前中期参数离最优解还很远大学习率能快速下降损失。可到了后期loss曲线往往会在一个平台值附近反复横跳既不是发散也不是稳步下降。这个现象的本质是当前位置已经进入目标函数的一个相对低值区域但每一步的更新量依然太大参数每次都会“越过”极小值点在它附近来回摆荡。你可能会想那直接把学习率调小不就行了理论上没错但实际操作里有两个问题。一是调多少合适调太小参数几乎不动训练进度肉眼不可见调太大又回到震荡状态。二是调得太急会破坏前面几百个epoch好不容易建立的参数方向模型可能直接退到次优解。这种时候一个预先规划好的平滑衰减曲线远比人肉盯着曲线试探要可靠。PolynomialLR做的正是这件事它把学习率从初值沿一条幂函数曲线平滑地送到你指定的终值全程不需要人工干预。你只需要决定总步数和衰减形状剩下的交给调度器本身。1.2 为什么是“平滑衰减”而不是阶梯下降在PyTorch里学习率调度器大致分两类。一类是阶梯式比如StepLR在固定epoch点把学习率砍掉一截另一类是平滑式比如CosineAnnealingLR、PolynomialLR。阶梯下降适合训练阶段非常清晰的任务但深度学习模型本身是一个连续的非线性优化过程学习率突变很容易让参数方向出现“抖动”尤其是在已经接近收敛的区域。平滑衰减则让学习率在每个epoch都连续变化参数更新幅度也随之渐变。打个比方说阶梯下降是开车进停车场到了门口一脚急刹平滑衰减是提前减速顺着一条缓坡滑到车位旁边。模型的优化过程也类似——让权重在收敛区里慢慢“稳住”比突然急停然后期待它定在原处要自然得多。PolynomialLR这个名字看起来很数学但它表达的曲线很直观从当前学习率出发沿着一条可调节陡峭程度的曲线一路滑到你预设的终点。2. 公式、参数与源码逻辑PolynomialLR到底在算什么2.1 官方公式的第一性理解PyTorch官方文档给出的PolynomialLR更新公式是lr (initial_lr - lr_end) * (1 - last_epoch / total_iters)^power lr_end拆开看其实非常直白initial_lr是你创建优化器时设置的学习率也就是衰减的起点total_iters是完成整个衰减过程需要的步数通常一个epoch算一步last_epoch是当前已经进行的步数power是多项式指数控制曲线的形状lr_end是衰减达到终点后保持的学习率。中间那个(1 - last_epoch / total_iters)本质上是把训练进度映射到从1到0的范围。进度为0时它是1学习率等于初始值进度走完时它是0学习率等于lr_end。然后再给这个进度值加一个power次方让衰减形状变得可控。用一个具体数字感受一下。假设initial_lr0.1total_iters100lr_end0.001跑完第50个epoch也就是进度到一半时power2对应的学习率大约是0.0257power1对应的学习率是0.0505power0.5对应的学习率约0.0714。同样在50%进度不同power的剩余学习率差距很大。power越大学习率在前半段掉得越快后面那段贴着lr_end的长尾微调期也就越长。2.2 看一眼源码你会更明白实现上的细节PyTorch的PolynomialLR在_LRScheduler基类上实现核心逻辑在get_lr()方法里。如果你打开源码看到的不是直接套上面那个显式公式而是用了一个“递推因子”的写法。大致逻辑是这样的如果是第0个epoch返回initial_lr如果已经超过total_iters返回lr_end否则计算一个衰减因子decay_factor ( (1.0 - last_epoch / total_iters) / (1.0 - (last_epoch - 1) / total_iters) ) ** power然后用decay_factor乘以上一次的学习率。为什么官方不直接按显式公式算因为调度器基类step()时会把get_lr()的结果直接写进optimizer.param_groups里的lr字段。如果每次都从initial_lr重新计算理论上结果一致但使用递推因子可以让每一步相对上一步的变化保持稳定同时减少浮点数反复计算带来的累计误差。对使用者来说两种写法在数学上等价。调试阶段如果你想完全掌控曲线也可以用LambdaLR自己复现一份多项式衰减from torch.optim.lr_scheduler import LambdaLR def poly_lr(epoch): if epoch total_iters: return lr_end / initial_lr val (initial_lr - lr_end) * (1 - epoch / total_iters) ** power lr_end return val / initial_lr scheduler LambdaLR(optimizer, lr_lambdapoly_lr)注意LambdaLR返回的是“缩放系数”不是学习率本身所以要把绝对学习率除以initial_lr。这个方法用来快速验证曲线形状很方便。2.3 lr_end参数和版本历史PolynomialLR在PyTorch 1.11版本之前其实没有lr_end参数衰减终点直接按0处理而且对“超过total_iters后怎么办”这种边界情况处理得不够人性化训练末期学习率可能出现让人看不懂的跳变。从1.11开始官方加入了lr_end参数默认值是1e-7。它的作用是给衰减指定一个明确的终点值你希望最后保持1e-5就填1e-5希望保持1e-6就填1e-6。更重要的是total_iters走完后学习率会稳定在这个终值上不再有跳变。如果你在维护老项目或者打开别人的开源代码发现PolynomialLR没有lr_end参数基本可以判断这个项目基于PyTorch 1.10或者更早的版本。建议升级PyTorch之后显式配置lr_end。3. 工程接入最小可用代码与参数选择建议3.1 先跑通一套基础代码直接给一段最小可用的工程代码。这里用SGD示例换成AdamW也完全一样只需要替换优化器即可。import torch import torch.nn as nn from torch.optim import SGD from torch.optim.lr_scheduler import PolynomialLR model nn.Linear(784, 10) optimizer SGD(model.parameters(), lr0.1) scheduler PolynomialLR( optimizer, total_iters50, power2.0, lr_end1e-6, ) for epoch in range(60): train_one_epoch(model, optimizer) # 每个epoch结束后推进调度器 scheduler.step() current_lr optimizer.param_groups[0][lr] print(fepoch {epoch:02d}, lr{current_lr:.6f})几个容易被忽视的细节scheduler.step()应该在epoch末尾调用不要和optimizer.step()混在一起。optimizer.step()是每个batch执行一次而这里调度器默认每个epoch推进一次。查看当前学习率我习惯直接读optimizer.param_groups[0][lr]这是实际写入优化器的值。你也可以用scheduler.get_last_lr()但在某些组合调度器场景下内部记录值不一定等于真正生效值。total_iters50意味着从epoch 0到50走完整个衰减epoch 51到60学习率稳定在1e-6。3.2 power值怎么选先看曲线再谈理论很多人在power这个参数上纠结。我的建议是先别管数学名字直接把曲线画出来看一眼。用matplotlib写个小脚本import matplotlib.pyplot as plt initial_lr 0.1 lr_end 0.001 total_iters 100 for power in [0.5, 1.0, 2.0, 3.0]: lrs [] for epoch in range(total_iters 1): lr (initial_lr - lr_end) * (1 - epoch / total_iters) ** power lr_end lrs.append(lr) plt.plot(lrs, labelfpower{power}) plt.xlabel(epoch) plt.ylabel(lr) plt.legend() plt.show()跑完之后你会很直观地看到四个形状power取值曲线形状适合什么场景0.5前期几乎平直后期突然俯冲希望模型前中期保持较高学习率充分探索最后阶段快速收敛落点1.0线性下降最直观各类常规训练没有特殊偏好时可以用2.0前期下降较快后期长尾平缓常见的选择预留较长的低学习率微调期3.0前期下降很快后期极平缓迁移学习、微调阶段希望低层尽快稳定以我自己的经验power2是使用频率最高的。它让学习率在训练前半段快速进入合理区间后半段又留了足够长的时间做精细调整。power3则更激进适合微调预训练模型或者你明确知道训练前期不需要太多探索。3.3 total_iters的两种配置思路第一种思路让total_iters等于训练总epoch数。这是最常规的用法训练最后一个epoch时学习率恰好降到接近lr_end。最终模型以极低学习率完成最后一步更新损失曲线自然收敛到平台。第二种思路让total_iters小于训练总epoch数。比如你计划跑200个epoch把total_iters设为150那么前150个epoch走完多项式衰减最后50个epoch学习率一直保持lr_end。这种做法等于在正式收尾后再加一段“静置精修期”很多任务在这种极低学习率阶段验证指标还能再涨一点虽然训练loss几乎不再下降。不过要提醒一句如果lr_end设得太小比如1e-9那最后几十个epoch就相当于直接把网络冻结了还不如提前早停。我个人常用的lr_end范围是1e-6到1e-4这样既能保证最后的微调能力又不会让训练白跑。4. 和Warmup搭配训练启动与收尾的完整链条4.1 用SequentialLR串起Warmup和PolynomialLR理想的学习率曲线不应该直接顶着最大学习率起步。模型刚初始化时参数还很粗糙一上来就大步更新很容易让loss飞掉。所以现在的主流做法都是先warmup把学习率从一个小值逐渐升到目标值再接主衰减曲线。PyTorch从1.10开始提供了SequentialLR专门用来串接多个调度器。把warmup和PolynomialLR接在一起正好组成一条完整的“升-降”曲线from torch.optim.lr_scheduler import LinearLR, PolynomialLR, SequentialLR initial_lr 0.1 warmup LinearLR( optimizer, start_factor0.1, total_iters5, ) decay PolynomialLR( optimizer, total_iters95, power2.0, lr_end1e-6, ) scheduler SequentialLR( optimizer, schedulers[warmup, decay], milestones[5], )关键参数拆解start_factor0.1表示warmup从initial_lr的10%也就是0.01开始total_iters5表示5个epoch内线性升到0.1。milestones[5]告诉SequentialLR在第5个epoch结束时切换到下一个调度器。之后decay接管total_iters95刚好填满剩余训练轮次。这里最容易踩的点是曲线衔接。你需要保证warmup在切换那一刻的学习率和PolynomialLR在该时刻计算出的学习率一致。上面这个配置里warmup结束时是0.1而PolynomialLR在第5个epoch的初始lr同样是initial_lr0.1所以不会出现跳变。如果不确定打印几次get_last_lr()检查即可。4.2 checkpoint恢复时最容易掉的链子长训练中断后从checkpoint恢复是日常操作。很多人会恢复模型权重和优化器state_dict却忘了保存学习率调度器的状态。结果训练从断点继续调度器却从last_epoch-1重新开始学习率曲线整体平移模型等于白跑了后半程。正确的保存方式是这样torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), epoch: epoch, }, 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])这里有个隐藏的版本兼容坑如果你的checkpoint是老版本PyTorch保存的scheduler的state_dict里没有lr_end字段而当前代码用的是新版本PolynomialLRload_state_dict不会因为缺字段直接报错但恢复后的lr_end会变成当前代码里定义的值。如果你训练时用的本来就是默认值1e-7问题不大如果你自定义了lr_end一定要确保当前代码里的配置和checkpoint保存时一致。5. 和其他调度器横向对比什么时候该选PolynomialLR5.1 与CosineAnnealingLR的取舍CosineAnnealingLR应该是很多人梦寐以求的“默认答案”大量论文都在用。它在一个完整周期T_max内把学习率按余弦曲线从初始值降到最低点而且末期很平缓看起来相当优雅。但Cosine的问题在于曲线形状是固定的没有power这种可调旋钮。你想让它更“直线”一点做不到想让它前期下降更快后期更贴地也做不到。PolynomialLR的power参数恰恰给你这个自由度。power2的曲线和余弦有一定相似之处但前期下降速度比余弦快后期长尾更明显。选择建议如果你的训练步数规划明确希望精确控制终点学习率PolynomialLR是更直接的选择如果不想研究参数细节CosineAnnealingLR作为稳健默认值也完全没问题。5.2 与LambdaLR的边界LambdaLR接受一个lr_lambda函数可以定义任意形状的学习率曲线理论上它完全可以替代PolynomialLR。第二节里我也给出了用LambdaLR复现多项式衰减的代码。我的使用经验是如果训练脚本里只是需要标准的多项式衰减直接用PolynomialLR更省事因为边界情况比如last_epoch0、超过total_iters后保持lr_end官方都已经处理好。但如果你需要更“自由”的曲线比如warmup后先多项式衰减中途再阶梯下降那用LambdaLR自己写会更灵活。不要为了省一行import去手写PolynomialLR除非你需要改动基础公式本身。5.3 与ReduceLROnPlateau的定位差异ReduceLROnPlateau是另一种完全不同的脑回路它不按epoch规划而是监控一个指标当指标连续patience个epoch不下降时自动把学习率乘以一个factor。它适合完全没固定训练预算、只能边训边看的场景。比如你在探索一个新模型结构不知道多少epoch能收敛用ReduceLROnPlateau就能自动应对停滞。而PolynomialLR是“开环”控制时间到了就降不管模型状态如何。如果训练中loss波动很大可能学习率已经降到底了模型还没稳定下来就会提前进入冻结期。所以我不会建议所有任务一律用PolynomialLR。训练预算明确、模型训练行为稳定用PolynomialLR效果更好探索阶段、验证指标反复无常ReduceLROnPlateau更从容。5.4 我自己的选型经验简单归纳一下我的习惯已有确定epoch预算的标准训练PolynomialLR(power2)和CosineAnnealingLR都可以前者更容易控制终点。快速验证新想法StepLR或者ReduceLROnPlateau前者省心后者自适应。微调预训练模型PolynomialLR(power3)配合较短的total_iters让低层尽快稳定高层还有足够空间微调。没有特殊理由的常规中小模型先用CosineAnnealingLR保底再对比PolynomialLR看是否有提升。6. 踩坑实录PolynomialLR使用中我碰到过的问题6.1 学习率最后跳回初始值这是PolynomialLR最吓人的一个问题。我有一次在一个开源项目里把total_iters设成了20整个训练计划是50个epoch结果到第21个epoch时打印学习率突然从很小的值跳回了初始值。原因有两层。一是旧版本PyTorch对last_epoch大于total_iters的情况处理得不够好二是当total_iters小于总epoch数后调度器进入了超出范围的边界分支。升级到1.11并设置lr_end之后这种情况会收敛到lr_end不再跳回initial_lr。排查建议在训练脚本里每个epoch都打印一次lr只要发现跳升立刻检查三点是否在循环里new了一个新的scheduler导致last_epoch被重置total_iters是否意外小于总epoch数是否从老版本checkpoint恢复但没有load调度器的state_dict。6.2 scheduler.step()被每个batch调用这个坑相当隐蔽。很多人写训练循环时习惯在每个batch之后都调用一次scheduler.step()因为有些调度器确实支持按batch衰减。但PolynomialLR默认语义是total_iters按epoch计算如果你每个batch都step一次一个epoch内学习率就被砍到接近lr_end后面所有训练等于全部在终点学习率下进行模型基本没法继续收敛。我现在的习惯是把步数单位明确写进代码注释。按epoch调度就写成for epoch in range(epochs): for batch in dataloader: ... optimizer.step() scheduler.step() # 一个epoch结束后如果确实想按batch衰减就把total_iters设为总batch步数然后在每个batch结束后调用step()。两种模式对应不同的total_iters语义千万别混。6.3 verbose参数在新版本里被弃用很多旧教程里的写法是PolynomialLR(..., verboseTrue)希望通过调度器自带的日志看到学习率变化。PyTorch 2.0之后verbose已经被标记为deprecated传进去会出DeprecationWarning未来版本大概率直接移除。正确做法是直接读optimizer.param_groups[0][lr]或者使用scheduler.get_last_lr()。我更喜欢前者因为它返回的是真实生效的值。get_last_lr()在某些组合调度器场景下返回的可能是调度器内部的记录值不一定完全等于优化器里最终写入的值。6.4 混合使用SequentialLR时踩的切换坑用SequentialLR串warmup和PolynomialLR时milestones的设置必须和上游调度器的total_iters严格对齐。比如warmup的total_iters3但milestones[5]那么第3到第5个epoch之间SequentialLR会继续调用warmup但warmup已经跑完了自己的生命周期学习率停在一个并不处于正常下降曲线的值上。到了第5个epoch再切换到PolynomialLR就可能出现明显跳变。所以每次配完SequentialLR我都会把前10个epoch的学习率打印出来盯一遍确认没有奇怪的拐点再让它长跑。曲线平滑、没有突兀跳变才说明warmup和主衰减确实无缝衔接。这些坑基本覆盖了训练中常见的PolynomialLR使用问题。我自己的实践体会是学习率调度器虽然代码量不大但它直接决定了训练最后的落点。把它当成和模型结构、优化器同等重要的超参数去对待认真看曲线、认真保存状态训练效果往往比盲目调模型结构提升得更快。希望这篇关于PolynomialLR的梳理能帮你少走一些我走过的弯路。