ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

PyTorch PolynomialLR学习率调度:原理、参数详解与工程实践避坑指南

PyTorch PolynomialLR学习率调度:原理、参数详解与工程实践避坑指南 聊到深度学习训练里那个“最后一段路怎么走”我最近几年最常写进代码的就是 PyTorch 自带的PolynomialLR。它其实很简单用一条多项式曲线把学习率从初始值平滑地拉低最后停在你指定的一个低位上。没有花哨的自动监控也不需要像余弦退火那样反复调周期特别适合训练预算固定、讲究稳定收敛的场景。这篇不打算铺垫太多理论直接说说它为什么值得用、参数到底怎么理解、代码里怎么落地以及一些我亲自踩过的边界坑。如果你正在折腾 PyTorch 训练脚本发现最后几十个 epoch 模型怎么都收敛不动或者想找一个比MultiStepLR更平滑、比ReduceLROnPlateau更省心的学习率调度器这篇文章应该能帮到你。1. 为什么学习率需要一个体面的收尾1.1 训练后期学习率直接决定你落在哪个解上很多刚接触深度学习的同学会觉得训练就是前向、反向、更新权重学习率设个差不多的常数就好。实际上训练前期和后期对学习率的诉求完全相反。前期参数离最优解远梯度方向大体可靠步子大一点能快速跨过平坦区域后期参数已经靠近局部最优如果学习率还很大就会在极小值附近反复横跳loss 降不下去。你可以把学习率想象成下山时的步幅刚下山时大步流星没问题快到谷底的时候还迈大步大概率直接跨过最低点跑到对面山坡上。所以训练后期需要的不是“大而有力”而是“小而精准”。PolynomialLR的核心价值就在这里它不关心当前 loss 是涨是跌不搞动态触发而是从第一天起就规划好整个训练周期内学习率的下落轨迹。你给它总步数和最终位置它就在这两点之间画一条可控的曲线让模型在收敛末期走得很稳。1.2 常见的调度器那么多PolyLR 凭什么占一席PyTorch 里调度器不少我列几个常用的一起对比调度器行为特征适合场景主要缺点StepLR/MultiStepLR每隔一段步数乘以固定系数手动试过关键节点后使用曲线不连续节点需要人工调ExponentialLR每一步都按指数衰减训练轮次非常多时后期学习率衰减过慢收尾不够干净CosineAnnealingLR按余弦曲线降到 0经典 CNN 训练周期设置不当容易中途降到底ReduceLROnPlateau监控验证集指标不降就减不好确定训练轮次时依赖指标、变化滞后容易误判PolynomialLR多项式曲线降到指定下限固定训练预算、平滑收尾只认步数不看指标不能回调我之前经常用MultiStepLR好处是直白坏处是每个 milestone 都要靠人去试后来换过CosineAnnealingLR曲线是真的顺滑但默认会一路降到 0训练还没结束学习率就已经消失。换成PolynomialLR之后至少“最后停在什么位置”这件事是完全可控的。1.3 最适合用它的情况从我的项目经验看这几个场景最值得考虑PolynomialLR训练总轮数是固定的。比如你定了 200 个 epoch那就把衰减曲线铺满这 200 个 epoch最后一轮刚好走到低位。微调预训练模型。预训练权重已经在一个不错的位置你只希望用小学习率平滑适配新数据PolynomialLR 能在后期把学习率压得很低避免把已有特征忘掉。不想维护一堆 milestone。相比MultiStepLR你只需要关心三个数衰减多少步、以什么幂次衰减、最后保留多少比例。2. PolynomialLR 的核心机制一条多项式曲线走到黑2.1 公式和三个关键参数PolynomialLR的衰减逻辑用一条幂函数表达式子很朴素lr_t lr_0 * (1 - t / T) ^ p其中t是已经走过的步数T是总衰减步数p是多项式幂次。t0时lr_t lr_0随着t接近T括号里的值逐渐变成 0学习率也就一路走低。走完之后学习率不会无限衰减而是由end_factor接管稳定在一个很低的保底值上。在 PyTorch 里构造方法是这样from torch.optim.lr_scheduler import PolynomialLR scheduler PolynomialLR( optimizer, total_iters5, power1.0, end_factor0.0001, last_epoch-1 )参数默认值含义我的推荐optimizer无要绑定的优化器必填total_iters5多项式衰减持续的总步数过了这个点学习率交给end_factor总 epoch 数或总 batch 数power1.0多项式的幂次决定曲线形状1.0 或 2.0end_factor0.0001最终学习率 初始学习率 * end_factor0.001 或 0.0001last_epoch-1当前已经走过的步数通常不用改保持默认2.2 power 不同曲线形状差很多power是最容易被人忽略的参数但它决定了学习率在每个阶段下降的节奏。同样total_iters100我算过不同power下的剩余比例tpower0.5power1.0power2.001.001.001.00250.870.750.56500.710.500.25750.500.250.061000.000.000.00你可以看到power2.0时前期学习率下降得很快后期曲线变得非常平缓power0.5则反过来前期还留了很多学习率越接近终点下降越猛。该选哪个取决于你项目的脾气如果模型前期不稳定用power1.0最稳如果希望早期快速走出初始区域后期精细打磨用power2.0更合适。我个人的习惯是从头训练用power1.0微调用power2.0。微调场景本身不希望前期步子太大破坏预训练特征所以前期迅速把学习率降下来更安全。2.3 容易被忽略的初始学习率陷阱使用PolynomialLR时衰减基准是优化器的initial_lr也就是构造优化器时写入的学习率。如果你在训练中途直接修改了optimizer.param_groups[0][lr]然后继续让PolynomialLR接管调度器计算时用的还是最初的initial_lr而不是你手动改过的值。我在这里栽过跟头有一次想在训练中把学习率人为放大一个阶段直接改了param_groups的lr结果下次scheduler.step()之后学习率跳回了一个莫名其妙的数值。后来发现PolynomialLR内部使用的是initial_lr作为基准修改当前的lr只会影响当下那一步不会改变衰减曲线。如果你确实需要中途调整学习率正确做法是重新构造一个调度器或者用一个自定义 wrapper 记录当前基准。别直接改optimizer.param_groups那样只会让曲线脱节。3. 直接可用的实操配置从优化器到训练循环3.1 按 epoch 调度的最小可运行示例先来一个最经典的使用方式每个 epoch 结束后调用一次scheduler.step()。import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import PolynomialLR model nn.Sequential( nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10) ) optimizer optim.SGD( model.parameters(), lr0.1, momentum0.9, weight_decay5e-4 ) total_epochs 60 scheduler PolynomialLR( optimizer, total_iterstotal_epochs, power1.0, end_factor0.001 ) for epoch in range(total_epochs): # 这里放你的训练循环 for batch_x, batch_y in train_loader: optimizer.zero_grad() loss model(batch_x).loss # 示意 loss.backward() optimizer.step() scheduler.step() current_lr optimizer.param_groups[0][lr] print(fepoch {epoch 1}, lr {current_lr:.6f})在第 60 个 epoch 结束时学习率会落在0.1 * 0.001 0.0001附近。这个位置足够低能保证模型最后阶段只做非常细微的权重修正同时也不会完全冻结。3.2 按 batch 步数调度怎么换算 total_iters很多任务不是按 epoch 调度而是希望每个 batch 都更新一次学习率尤其在大模型训练里很常见。这时total_iters应该设置成总 batch 步数而不是 epoch 数。total_epochs 30 steps_per_epoch len(train_loader) total_steps total_epochs * steps_per_epoch scheduler PolynomialLR( optimizer, total_iterstotal_steps, power1.0, end_factor0.001 ) for epoch in range(total_epochs): for batch_idx, (batch_x, batch_y) in enumerate(train_loader): optimizer.zero_grad() loss model(batch_x).loss loss.backward() optimizer.step() scheduler.step() if batch_idx % 100 0: current_lr optimizer.param_groups[0][lr] print(fepoch {epoch}, step {batch_idx}, lr {current_lr:.6f})每个 batch 都scheduler.step()会让学习率曲线更平滑但也需要注意一点如果训练中途中断恢复last_epoch的恢复逻辑要同步处理好否则调度器会从头重新计数。用 PyTorch 的torch.save/torch.load保存scheduler.state_dict()恢复时再scheduler.load_state_dict()这是最稳妥的做法。3.3 和 warmup 搭配先用 SequentialLR 串联很多人只关注衰减却忽略了训练初期的 warmup。如果初始学习率太高模型很容易在第一个 epoch 就发散所以理想方案是前面几步做线性热身后面再用PolynomialLR收尾。PyTorch 从 1.10 左右开始提供了SequentialLR可以把两个 scheduler 串起来from torch.optim.lr_scheduler import PolynomialLR, LinearLR, SequentialLR warmup_epochs 5 decay_epochs 55 warmup_scheduler LinearLR( optimizer, start_factor0.1, total_iterswarmup_epochs ) poly_scheduler PolynomialLR( optimizer, total_itersdecay_epochs, power2.0, end_factor0.001 ) scheduler SequentialLR( optimizer, schedulers[warmup_scheduler, poly_scheduler], milestones[warmup_epochs] )前面 5 个 epoch学习率从0.1 * 0.1 0.01逐步升到0.1第 6 个 epoch 开始进入PolynomialLR的二次衰减曲线一直降到0.0001。这个组合我实测下来非常稳定适合大部分从零训练的图像分类任务。需要注意一点SequentialLR切换时有一个小的 off-by-one 问题就是第一个 scheduler 的最后一步可能和第二个 scheduler 的初始步重叠。我的经验是打印出前 10 个 epoch 的真实学习率曲线确认没有异常跳变再放大训练。3.4 可视化检查直接画出学习率曲线调度器写没写对最好的检验方式就是把学习率画出来。训练前先用一个小脚本模拟不需要跑完整训练import matplotlib.pyplot as plt import torch import torch.optim as optim from torch.optim.lr_scheduler import PolynomialLR model torch.nn.Linear(16, 1) optimizer optim.SGD(model.parameters(), lr0.1) scheduler PolynomialLR( optimizer, total_iters100, power1.0, end_factor0.001 ) lrs [] for _ in range(120): optimizer.step() scheduler.step() lrs.append(optimizer.param_groups[0][lr]) plt.plot(lrs) plt.xlabel(step) plt.ylabel(learning rate) plt.show()画出来之后你会发现前 100 步学习率一路线性下滑最后停在0.0001左右后 20 步是一条平直线。如果曲线出现了突兀的跳变那基本可以确定是total_iters和训练总步数没对齐或者SequentialLR的切换点设置错了。4. 常见问题排查与避坑实录4.1 学习率在最后一步突然跳变是怎么回事这个是PolynomialLR新手最容易遇到的问题。我前面说过官方源码中多项式下降到total_iters附近时公式的括号内已经变成 0而end_factor的保持段是从“越过”total_iters之后才开始的。如果你的训练总步数刚好卡在total_iters上最后一步不一定能看到end_factor * lr_0而是会先看到一个接近 0 的极低学习率下一步才跳回end_factor * lr_0。我在自己项目里调试时发现训练没结束时多跑了两步学习率会从 0 突然变成0.0001虽然数值很小但曲线图上会有一个明显的折点看着非常难受。解决办法有两个把total_iters设小一点比如总训练步数减 1这样最终步还在多项式公式有效范围内不会触发边界。自定义一个子类把“到达total_iters”的判断改成让衰减结束时一步到位落到end_factor保持段from torch.optim.lr_scheduler import PolynomialLR class SafePolynomialLR(PolynomialLR): def get_lr(self): if self.last_epoch 0: return [group[initial_lr] for group in self.optimizer.param_groups] if self.last_epoch self.total_iters: return [ group[initial_lr] * self.end_factor for group in self.optimizer.param_groups ] return [ group[initial_lr] * (1.0 - self.last_epoch / self.total_iters) ** self.power for group in self.optimizer.param_groups ]这个自定义版本保证了最后一个total_iters恰好处在end_factor上逻辑上更符合大多数人“衰减到最后保留一点底量”的直觉。我用这个子类替换过好几次默认调度器曲线干净多了。4.2 end_factor 到底该设 0 还是设一个小数有些朋友为了省事直接把end_factor0想让它线性降到零。单纯看公式确实没问题但实际后果是训练最后阶段学习率为 0权重几乎不再更新模型相当于提前“毕业”了。如果训练刚好在那一刻结束影响不大但只要训练周期稍微延长或者你使用了早停机制后又继续训练学习率为 0 会让后续所有 epoch 都在空转loss 曲线直接变成一条直线。我建议至少保留1e-4这个量级的底量让模型在最后阶段仍然具备微调能力。4.3 total_iters 和总迭代数不匹配曲线会被截断PolynomialLR完全不关心你的训练实际跑多少步它只数自己内部累计的步数。常见错误包括按 epoch 调度时把total_iters设成了总 step 数导致曲线还没走完训练就结束。按 batch 调度时误把total_iters当成 epoch 数学习率在第一个 epoch 内就降到了底。我的排查技巧是在训练脚本里统一用一个变量表示总步数比如total_steps然后PolynomialLR的total_iters直接用这个变量。训练循环里每个调度步也从这个变量派生这样至少不会出现单位错乱。4.4 scheduler.step() 的调用顺序千万别弄反PyTorch 推荐optimizer.step()之后再调用scheduler.step()。如果你先调scheduler.step()再让优化器更新参数学习率就会提前降低一步导致同一个 epoch 内使用的学习率已经衰减过一次。这种顺序错误在单个 epoch 内影响可能不明显但整个训练跑下来每一步的学习率都比预期小。我踩过这个坑最后排查了很久才发现只是两个step()的顺序反了。# 推荐顺序 loss.backward() optimizer.step() scheduler.step()4.5 和 PyTorch Lightning 等框架配合时要注意什么如果你用的是 PyTorch LightningPolynomialLR也能接但调度器配置要写到configure_optimizers里并且明确intervaldef configure_optimizers(self): optimizer torch.optim.Adam(self.parameters(), lr1e-3) scheduler PolynomialLR( optimizer, total_iters100, power1.0, end_factor1e-4 ) return { optimizer: optimizer, lr_scheduler: { scheduler: scheduler, interval: step, frequency: 1 } }interval选step还是epoch要看你的total_iters是按哪种粒度设计的。很多人在 Lightning 里发现学习率没按照预期衰减就是因为默认是intervalepoch但total_iters是按 step 算的对应不起来。我自己最常用的组合是power1.0或2.0end_factor1e-3total_iters等于总训练步数同时给最后一个边界留出少量余量。这个组合不算惊艳但胜在稳定几乎每个固定预算的训练任务都能直接套上。学习率调度这件事很多时候不需要花哨只要让模型最后那段路走得稳当结果就不会差。希望这篇指南能帮你少踩一两个我踩过的坑。
返回列表