资讯详情 PyTorch StepLR学习率调度器:原理、参数与最佳实践
📅 2026/10/17 17:15:40
1. 固定学习率为什么越训到后面越“费劲”在torch.optim.lr_scheduler里面StepLR是文档中出现频率最高、但同时也最容易被轻视的调度器。它的公式简单到一句话就能说清每隔step_size个训练周期把学习率乘上一个固定的gamma。可就是这么个不起眼的类我过去好几个视觉小项目、文本分类实验第一次跑通用的都是它。不是因为它最先进而是因为它最“稳定”你知道什么时候降学习率、降多少倍、怎么复现哪怕一个月后翻出实验记录也能一眼还原当时的节奏。先说一个很多人绕不开的现象用固定学习率训练前期 loss 掉得很快但到了某个 epoch 之后loss 曲线就像卡住了一样在高位附近来回抖动怎么都下不去。我有个朋友跑图像分类固定 lr0.1 训练前 20 个 epoch loss 从 2.x 降到 0.43然后就开始在 0.43 附近小幅震荡怎么都到不了 0.35。问题不出在模型结构也不出在数据增强纯粹是学习率太高参数更新时“步子迈得太大”在局部极小值附近反复横跳永远落不到谷底。1.1 高学习率与低学习率的两难机器学习训练本质上是一个在损失函数曲面上用梯度下降找极小值的过程。学习率高的时候前期梯度大、方向明确参数能快速从随机初始化的“荒原”跑到“盆地”附近但到了盆地梯度变小、噪声变大如果学习率还是原来的数值每一步都会跨过极小点系统就只能围着最小值打转。反过来如果一开始就用很低的学习率比如把 lr 设成 0.001前期参数移动极其缓慢在只有几十个 epoch 的预算下很可能还没走到“盆地”训练就已经结束了。这就形成了一个两难前期需要大学习率快速探索后期需要小学习率精细收敛而固定学习率只能在两者之间取一个折中两头都没讨好。1.2 人肉调学习率的三个真实痛点有人会说那我可以手动改啊——训练到第 30 个 epoch把 lr 从 0.1 改成 0.01再训 30 个 epoch再改成 0.001。听起来可行但实际操作有三个坑。第一改一次可以改两次三次之后实验记录的“清晰度”就下来了。你隔两周再回去看可能已经忘了每个 epoch 到底用的什么 lr复现成本变高。第二人肉调整的时机往往带主观性。有的人看 loss 平台就立刻降 lr有的人会再等等同样的模型和数据集不同人训出来的结果差异很大没办法横向比较。第三训练可能在半夜跑完等你第二天醒来发现 loss 已经在平台期白白震荡了几十轮训练时间和电费全浪费了。所以学习率调度器不是可选项而是训练流程里的“基础设施”。StepLR就是其中最直白、最不容易出错的一个简单方案先把它的行为摸透再去玩花式的 cosine、plateau 这些思路会顺很多。2. StepLR 的 API 拆解参数不多但每个都有脾气torch.optim.lr_scheduler.StepLR的官方签名是这样的torch.optim.lr_scheduler.StepLR( optimizer, step_size: int, gamma: float 0.1, last_epoch: int -1, verbose: bool False )单从参数数量看它确实友好但用好它并不意味着把参数填进去就行。我按自己的理解把每个参数的实际含义拆开讲。2.1 构造函数的四个参数都意味着什么optimizer是必须传的调度器本身不会修改优化器里的更新规则它只是不断修改optimizer.param_groups里的lr值。这里要注意构造StepLR的时候优化器的lr参数必须已经存在调度器会把这个值当作初始学习率并缓存起来。step_size控制“每隔多少个 epoch 衰减一次”。它的单位是 epoch不是 batch。举个例子step_size30就是在训练进行到第 30 个 epoch、第 60 个 epoch、第 90 个 epoch 的时候各衰减一次。如果把它误解成 batch衰减节奏可能会快得离谱后面会专门讲这个坑。gamma是衰减因子默认值是0.1含义是每次触发衰减时把当前学习率乘上gamma。所以gamma0.1意味着学习率每次变成原来的十分之一这是一个很激进的衰减策略gamma0.5则温和得多。last_epoch是调度器的起点。默认-1代表从初始状态开始调度器内部会把“当前 epoch”记为-1第一次调用scheduler.step()时变成 0。断点续训时这个参数参与度很高但直接手写它的机会反而少一般只需要正确加载scheduler.state_dict()即可。verbose在 PyTorch 2.x 里已经被标记为 deprecated不建议继续用。即使是旧版本它也只是在每次变化时打印一行学习率对长时间的分布式训练没什么帮助反而会留下大量日志。真需要打印学习率用scheduler.get_last_lr()更干净。2.2 衰减公式与内部两个隐藏状态StepLR 的实际衰减公式是lr_e base_lr * gamma ** floor(last_epoch / step_size)其中base_lr是第一次构造调度器时从优化器里读到的初始学习率last_epoch是调度器内部维护的当前步数每调用一次step()就加一。floor意味着只取整除结果所以在同一个step_size区间内学习率保持不变整个曲线是典型的“阶梯状”。理解这个公式之后就能看懂调度器内部为什么要存两个状态一个是base_lrs记录初始学习率另一个是_last_lr记录最近一次计算出来的学习率。base_lrs不是动态更新的如果你在构造完调度器之后又手动把optimizer.param_groups[0][lr]改成了别的值下一次调度器step()仍然会从最初缓存的学习率开始算手动修改会被直接覆盖。这不是 bug是特性但确实坑过不少人。2.3 最小可运行示例搭一个最简单的训练循环感受一下调度器的存在感import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import StepLR model nn.Linear(16, 2) optimizer optim.SGD(model.parameters(), lr0.1) scheduler StepLR(optimizer, step_size10, gamma0.5) for epoch in range(30): # 这里代替真实 dataloader跑完整的一轮训练 for batch_x, batch_y in sample_loader: optimizer.zero_grad() loss nn.functional.cross_entropy(model(batch_x), batch_y) loss.backward() optimizer.step() scheduler.step() current_lr optimizer.param_groups[0][lr] print(fepoch {epoch} finished, lr {current_lr})在这个例子里前 10 个 epoch 学习率都是 0.1第 10 个 epoch 结束时调用一次scheduler.step()第 11 个 epoch 开始学习率变成 0.05再往后第 21 个 epoch 开始变成 0.025。只要step_size10, gamma0.5曲线就严格按这个节奏走。3. 和其他调度器放一起什么时候选 StepLR 不后悔调度器不是一个非此即彼的选择。很多刚接触 PyTorch 的人会把学习率调度当成“某个库的功能列表”今天看这个教程说余弦好明天看那个实验说 plateau 好最后一把梭全放进去训练日志乱成一锅粥。我更愿意把调度器理解成一组各有适用边界的工具。StepLR 的定位很清晰当训练总预算已知、希望曲线可预测可复现的时候它就是最合适的 baseline。3.1 五类调度器的横向区别这里列一张对比表覆盖我日常使用频率最高的五类调度器调度器触发衰减的依据是否自适应适合场景StepLR每隔固定 epoch 数否基线实验、预算固定的对比MultiStepLR在人为指定的 epoch 列表衰减否已经清楚知道该在哪一步降 lrExponentialLR每个 epoch 都乘一次 gamma否需要平滑连续衰减CosineAnnealingLR余弦曲线从高到低平滑下降否训练周期较长、预算明确的任务ReduceLROnPlateau监控指标不再下降时触发是指标平台期不可预测的复杂任务StepLR 最大的特点是“机械”一旦确定step_size和gamma全程没有任何随机性也不会被某个 epoch 的指标波动带偏。如果你要做一组成对比实验比如换模型结构、换损失函数那么调度器最好也保持同一个机制否则你无法确定最终差异到底来自模型还是来自学习率变化曲线。这样的场景里StepLR 是最稳的对照组。MultiStepLR 看起来只是 StepLR 的推广但它依赖一个额外前提你必须预先知道衰减的最佳时机。如果你对任务不够熟悉没有历史上验证过的 milestone那milestones[30, 60, 90]和一串咒语没什么区别。反而是 StepLR 这种均匀划分让第一次尝试的人不必做太激进的决策。3.2 StepLR 明显不合适的两种情况第一种是任务的目标指标存在明显的平台期但什么时候出现平台完全不可预测。比如某些 NLP 微调任务不同 batch size 下平台期可能提前也可能推后十几个 epoch这时候固定间隔的 StepLR 很容易错过最佳降学习率时机ReduceLROnPlateau 才是更合理的工具。第二种是你想追求当前训练预算下的极限性能而不是稳定复现。StepLR 的阶梯衰减虽然清晰但每一次衰减学习率都会“跳一刀”如果跳的时机不对loss 在跳变后可能需要好几个 epoch 才能重新恢复。比较新的做法是用 cosine 或者带 warm restart 的曲线让学习率连续变化减少这种跳变带来的不稳定。这里也让我认识到一件事调度器的选择没有绝对最优只有“你当前最在意的目标是什么”。要在预算、稳定性、性能上限之间做取舍StepLR 通常在稳定性和可解释性上稳稳压过其他方案。4. 时序的坑optimizer.step 和 scheduler.step 到底谁先谁后刚接触 PyTorch 的人遇到最多的问题不是不会写StepLR而是把scheduler.step()放错了位置。这一步看起来无关紧要实际上直接决定调度器的行为是否符合直觉。4.1 最常见的错误代码我曾经看到过不少训练脚本写成这样for epoch in range(60): for batch_x, batch_y in dataloader: optimizer.zero_grad() loss loss_fn(model(batch_x), batch_y) loss.backward() optimizer.step() scheduler.step() # 放在这里是错的这段代码的问题很隐蔽它把调度器推进当成了 batch 级操作。每处理一个 batchlast_epoch就加一。如果step_size30那调度器实际触发衰减的节奏是每 30 个 batch而不是每 30 个 epoch。假设一个 epoch 有 200 个 batch那么原来计划在第 30 个 epoch 才衰减的学习率在第 30 个 batch 就衰减了等比于第 0.15 个 epoch学习率下降得比预期快了几百倍。正常情况下训练会提前进入小学习率阶段前期探索不足后期精度明显变差。4.2 正确的调用位置与理由涉及学习率调度的训练循环应该把调度器放在“epoch 级别”而不是“batch 级别”。标准写法是for epoch in range(total_epochs): for batch_x, batch_y in dataloader: optimizer.zero_grad() loss loss_fn(model(batch_x), batch_y) loss.backward() optimizer.step() scheduler.step()为什么这样放因为optimizer.step()是让优化器基于当前的学习率更新一次模型参数而scheduler.step()是更新一次学习率。在一个 epoch 内模型参数已经更新了多个 batch学习率保持恒定这样才能保证整个 epoch 的梯度统计是有意义的如果中途变了前半段和后半段的训练节奏就不一致了。在每个 epoch 结束、模型参数更新完一轮之后再推进调度器正好让下一个 epoch 从新的学习率开始。如果有些任务真的希望在 batch 粒度上连续改变学习率比如 warmup 或者 one-cycle那也不该用 StepLR而应该用LambdaLR或者专用的调度器。StepLR 的语义就是 epoch 级衰减不要试图改变它的语义。4.3 坚持在 batch 上使用 StepLR 的换算方式也有例外有人用单机多卡或者超大 batch 训练一个 epoch 只有一个 batch这种时候 batch 级和 epoch 级就重合了。更常见的是有人希望step_size以“优化器更新次数”为准而不是以遍历完一轮数据为准。此时可以做一次换算step_size_in_batchs step_size_in_epochs * batches_per_epoch比如计划每 30 个 epoch 衰减一次一个 epoch 有 214 个 batch那么step_size 30 * 214 6420。每个 batch 调用一次scheduler.step()依然是每隔 6420 个 batch 才衰减一次效果上等于每 30 个 epoch 衰减。只是这样写的代码可读性会差一些我一般会在注释里把换算关系写清楚防止半年后自己都看不懂。还有一个更隐蔽的点如果用了torch.utils.data.DataLoaderlen(dataloader)是整数但实际最后一个 batch 可能比batch_size小不影响换算可如果用了自定义采样器batches_per_epoch可能不固定这时候换算出来的step_size就不准确还是优先用 epoch 级写法更稳妥。5. 断点续训时如何把调度器一起救回来训练到第 50 个 epoch突然断电或者显存被别的进程挤爆是再常见不过的事。很多人会认真保存model.state_dict()和optimizer.state_dict()却把调度器状态忘得一干二净。这一步漏了后续训练的曲线会变得非常怪异。5.1 需要保存的状态清单一个标准 checkpoint我通常会维护这份清单checkpoint { epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), best_acc: best_acc, }scheduler.state_dict()里至少包含几个关键字段step_size、gamma、last_epoch、_last_lr以及base_lrs这样一个记录了初始学习率的列表。只要这个字典还在恢复训练时就能把调度器还原到完全相同的节奏。反过来说如果你只保存了模型和优化器没有保存调度器恢复训练时用同样的超参数重新构造一个StepLR它的last_epoch会从 -1 开始相当于调度器认为自己才刚起步于是学习率被重置回初始值后续训练等于用 0.1 的学习率“从头再来”。5.2 保存与恢复的完整代码保存侧的代码通常长这样import torch def save_checkpoint(model, optimizer, scheduler, epoch, path): torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), }, path)恢复侧则要严格按顺序加载先加载模型参数再加载优化器状态最后加载调度器状态。顺序不能乱因为optimizer.load_state_dict()会重建 param group 里的各个字段如果先加载调度器再加载优化器调度器内部缓存的base_lrs可能和新的优化器状态不一致。一个可以在中途恢复的循环checkpoint torch.load(checkpoint.pt) 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 for epoch in range(start_epoch, total_epochs): train_one_epoch(model, dataloader, optimizer) scheduler.step()从这里能看出恢复后不再需要重新设置last_epoch也不需要手动调整学习率一切都会沿着中断前的节奏继续走。验证方法也很简单在恢复后的第一个 batch 打印optimizer.param_groups[0][lr]它应该和中断前最后一个 epoch 的学习率一致。5.3 没保存调度器状态时的兜底方法现实中总会出现加载了模型却忘了调度器的情况。这时候别慌有两条路可以走。如果优化器状态还在可以重新创建一个和之前完全相同的StepLR配置然后把调度器的last_epoch手动设置为已经完成的 epoch 数scheduler StepLR(optimizer, step_size30, gamma0.1) scheduler.last_epoch resume_epoch但这里有一个危险性手动设置last_epoch之后scheduler._last_lr还是初始值需要手动同步。更稳妥的做法是构造好调度器后连续调用step()跳过对应步数for _ in range(resume_epoch 1): scheduler.step()缺点是当 resume_epoch 很大时这个循环会做很多次空转计算。考虑到调度器的计算量本身很小几十上百次循环根本不算什么所以这种方式反而最省心。它能把内部所有状态都推到正确的位置不需要关心那些带下划线的隐藏字段。6. step_size 和 gamma 怎么搭配我的经验与可视化验证最后这部分应该是所有人最关心的两个超参数到底怎么设。6.1 先按总 epoch 预算切分 step_sizeStepLR 的优势是“你的训练总长度决定了衰减节奏”。以最常见的视觉分类任务为例很多人把总 epoch 数定在 60 到 100。我常用的基准线是总 epoch 的三等分总 epoch 60step_size 20总 epoch 90step_size 30总 epoch 120step_size 40为什么要三等分因为大部分模型在训练早期处于“宽泛探索”阶段此时保持学习率有助于跳出较差的局部极值训练进行到中段后loss 曲线开始进入平台期第一次衰减提供一次有效的精细搜索最后一段学习率更低负责把参数稳稳收进极小值。step_size太大意味着在整个训练过程中只有一两次衰减最后一次衰减后可能没有足够的 epoch 完成收敛step_size太小学习率会过早进入低位模型还没来得及探索充分就失去灵活性。如果预算不固定或者你希望早点见效可以把首次衰减放在总 epoch 的 1/3 之后。有人会习惯在step_size上再叠加一个 warmup 阶段比如先用 5 个 epoch 从 0.01 升到 0.1再接 StepLR。这个做法是合理的但注意不要让 warmup 和 StepLR 两个调度器相互覆盖PyTorch 里的SequentialLR就是专门处理这种组合的。6.2 gamma 的激进与温和gamma的选择决定了每次衰减的“力度”。默认 0.1 看起来是官方推荐的但并不是所有任务都该这么激进。我整理过一组参考经验gamma每次衰减后学习率表现0.1变成原来的 1/10极强后期快速精细但可能错过中间的优质区域0.3变成原来的 3/10中等偏激进适合训练周期短的任务0.5变成原来的 1/2温和适合对学习率敏感的模型如某些 attention 结构0.9变成原来的 9/10很平缓适合需要平滑过渡的微调阶段在低 epoch 预算任务里gamma0.1很可能让第二次衰减后的学习率变得过小参数几乎冻结模型性能不升反降。此时把 gamma 调到 0.3 或 0.5让每次衰减更慢一些效果往往会更好。6.3 用一张图确认衰减曲线是否符合预期写完训练脚本后第一件事不是急着丢进 GPU 集群而是先画一张学习率变化图确认曲线符合心理预期。我常用的可视化代码很短import torch import matplotlib.pyplot as plt from torch import nn, optim from torch.optim.lr_scheduler import StepLR model nn.Linear(10, 1) optimizer optim.SGD(model.parameters(), lr0.1) scheduler StepLR(optimizer, step_size10, gamma0.5) lrs [] for epoch in range(50): # 这里不放真实训练只收集学习率 lrs.append(optimizer.param_groups[0][lr]) scheduler.step() plt.figure(figsize(8, 4)) plt.step(range(len(lrs)), lrs, wherepost) plt.xlabel(epoch) plt.ylabel(learning rate) plt.title(StepLR schedule) plt.show()执行之后应该看到一条严格的阶梯线平台期长度完全等于 step_size每次跳变高度取决于 gamma。如果你看到的是连续下降的斜线说明你在 batch 级调用了 scheduler如果阶梯高度忽大忽小就要检查是否在某个时机手动改过param_groups里的lr。把学习率曲线和训练 loss 曲线放在一起看还能快速判断参数是否合适。比如第一次衰减后 loss 不仅没下降反而上升多半是 gamma 太激进或者衰减时刻太早。如果衰减后 loss 出现一个明显的“阶梯式下落”说明这次衰减时机是合理的。6.4 给刚接触调度器的人一个实际起点如果现在让我给刚入门的人一个“抄作业”模板我会直接推荐一个非常普通的配置total_epochs90, lr0.05, step_size30, gamma0.1。这个组合不保证在所有任务上都是最优但足以作为一个干净的 baseline。跑完一轮之后再根据 loss 曲线的形状调整step_size或gamma每一次只动一个变量比较起来才有意义。我自己现在也依然会把 StepLR 当作默认开局。它没有余弦那么“性感”也没有 plateau 那么“聪明”但它让我对训练过程一直保持着一种确定感每一个 epoch 结束之后的去路都是清清楚楚的。先把这份确定性攥在手里再去探索更复杂的调度方案也不迟。