☰
PyTorch LinearLR学习率调度器深度解析:从原理到调参实战
2026/10/19 4:28:07 网站建设 项目流程

深度学习训练里,真正决定模型最终精度的,往往不是优化器本身,而是学习率怎么变化。我最早开始调试训练节奏的时候,习惯把所有精力都放在模型结构和数据增强上,结果同样的网络结构,别人收敛到86%的准确率,我卡在82%怎么也上不去。后来对比了半天才发现,问题出在 learning rate scheduler 上——别人用的是 PyTorch LinearLR 配合 warmup,而我只用了一个固定学习率硬跑。那次之后我才认真去把每种调度器的实现逻辑过了一遍,发现 LinearLR 虽然名字听起来简单,但里面有不少容易被忽略的细节。

这篇文章我会从数学原理、源码执行流程、实际调参经验三个层面,把 PyTorch LinearLR 讲透。不管你是刚入门的小白,还是已经跑过不少模型的老手,只要你在用 PyTorch 训练模型,这篇文章都值得花十分钟看完。我会把官方文档没写明白的边界情况、和 StepLR、CosineAnnealingLR 的本质差异,以及我自己踩过的坑都翻出来说一遍。

1. LinearLR 到底在做什么:线性衰减背后的数学原理

1.1 从公式看因子变化

LinearLR 做的事情,用一句话概括就是:让学习率在一段区间内从某个比例平滑地线性变化到另一个比例。官方文档给出的公式是这样的:

lr = base_lr * (start_factor + (end_factor - start_factor) * (current_iter / total_iters))

看着有点抽象,我翻译成人话。假设你给优化器设置的基础学习率 base_lr 是 0.1,start_factor 是 1.0,end_factor 是 0.1,total_iters 是 100。那么在第 50 步(step)的时候:

lr = 0.1 * (1.0 + (0.1 - 1.0) * (50 / 100)) = 0.1 * (1.0 + (-0.9) * 0.5) = 0.1 * 0.55 = 0.055

也就是说,在 total_iters 步内,学习率会从 0.1 均匀地降到 0.01。这个"均匀"是关键——每一步的变化量是固定的,不像 CosineAnnealingLR 那样开始慢、中间快、结束又慢。

1.2 start_factor 与 end_factor 的协同逻辑

很多人第一次看到 LinearLR 的参数会问:为什么不直接指定起始学习率和结束学习率,非要搞两个 factor 出来?这个设计其实是有讲究的。因为 base_lr 是由优化器管理的,scheduler 不能随便改动优化器里的数值,它是通过一个乘性因子来间接控制。

当你设置 start_factor=0.1,end_factor=1.0 时,实际效果就是学习率从 0.01 涨到 0.1。这正好是 warmup 的经典用法——先小步走,再逐步放开。反过来,start_factor=1.0,end_factor=0.1 就是线性衰减。

这两个参数的组合,决定了你在同一段区间里是"预热"还是"冷却"。我之前见过有同事把 start_factor 直接设成 0,结果训练初期学习率变成 0,模型一步都不更新,白跑了好几个 epoch。所以记住:start_factor 和 end_factor 都不能是 0,最好留一个接近 0 的小值,比如 0.001。

1.3 每一步真实学习率的计算方式

再看一个容易忽略的细节:LinearLR 的 current_iter 到底是以什么为单位?官方文档说的是"number of steps",也就是优化器的 step 次数,不是 epoch 数。

举个例子,如果你的训练集有 1000 个 batch,你设置 total_iters=5000,那每个 epoch 内学习率会衰减 1000 次,5 个 epoch 之后衰减结束。之后如果不做任何处理,学习率会保持在 end_factor 对应的值上,不再变化。

这里需要特别强调:PyTorch 的 scheduler.step() 是按优化器 step 的调用次数来更新的,不是按 epoch。如果你像下面这样写:

for epoch in range(num_epochs): train_one_epoch() scheduler.step()

那一个 epoch 才更新一次,total_iters=5000 要 5000 个 epoch 才能衰减完,这显然不太对。正确的方式是在每个 iteration 结束后调用 scheduler.step(),或者用 PyTorch 2.x 引入的 LRSchedulerAPI 结合 optimizer.step() 一起管理。

2. 使用 LinearLR 的三种姿势:从最简到精细

2.1 最简用法:一行代码接入训练循环

我先把最常用、也最容易跑通的代码写出来。

import torch import torch.nn as nn from torch.optim import SGD from torch.optim.lr_scheduler import LinearLR model = nn.Linear(10, 2) optimizer = SGD(model.parameters(), lr=0.01) scheduler = LinearLR(optimizer, start_factor=1.0, end_factor=0.01, total_iters=100) for step, (x, y) in enumerate(train_loader): optimizer.zero_grad() loss = model(x) loss.backward() optimizer.step() scheduler.step() # 每个 iteration 更新一次

这段代码里,学习率会在 100 个 iteration 内从 0.01 线性降到 0.0001。之后保持不变。如果你不想自己控制衰减结束后的行为,可以在衰减结束后切换到另一个 scheduler,比如用 SequentialLR 串起来。

2.2 组合使用:先线性预热,再余弦衰减

LinearLR 最典型的使用场景不是单独用,而是作为 warmup 阶段。所谓 warmup,就是训练最开始的时候用很小的学习率,让模型参数在一个比较平稳的状态下起步,避免一开始就用大学习率把参数震飞。

PyTorch 从 1.10 之后提供了SequentialLR,可以按顺序执行多个 scheduler。我常用的组合是:前 5% 的训练步数用 LinearLR 从 0.01 倍升到 1.0 倍,然后切换到 CosineAnnealingLR 做长距离衰减。

from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR total_steps = len(train_loader) * num_epochs warmup_steps = int(total_steps * 0.05) warmup_scheduler = LinearLR( optimizer, start_factor=0.01, end_factor=1.0, total_iters=warmup_steps ) main_scheduler = CosineAnnealingLR( optimizer, T_max=total_steps - warmup_steps ) scheduler = SequentialLR( optimizer, schedulers=[warmup_scheduler, main_scheduler], milestones=[warmup_steps] )

这样写的好处是,warmup 段和主衰减段之间没有学习率跳变,因为 warmup 结束时的 end_factor 正好是 1.0,和 CosineAnnealingLR 的起始学习率一致。训练曲线会非常平滑。

2.3 跨 epoch 精细控制:total_iters 的多种设置方式

total_iters 的设置,最直接的思路是"衰减多少步"就填多少。但实际操作中,很多人根本不知道自己一个 epoch 有多少 step,或者总步数会随 batch size 变化而变化。

我建议这样写,保证任何时候都不会算错:

steps_per_epoch = len(train_loader) warmup_steps = steps_per_epoch * 2 # 前两个 epoch 做 warmup scheduler = LinearLR( optimizer, start_factor=0.01, end_factor=1.0, total_iters=warmup_steps )

如果你调整了 batch size,steps_per_epoch 会自动变化,total_iters 也跟着变。千万不要写死一个固定值,否则换机器、换 batch size 之后,warmup 的覆盖范围就完全不是你想要的样子了。

还有一点,LinearLR 的 total_iters 可以设为浮点数吗?官方要求是 int。如果你用的是 PyTorch 2.x,部分版本对 float 支持不友好,建议直接用 int。实在想避免精度问题,可以用 math.ceil 向上取整。

3. 源码级拆解:PyTorch LinearLR 执行流程

3.1 核心状态与 step 流程

看源码是理解 scheduler 行为最直接的方式。PyTorch 的 LRScheduler 基类里维护了 last_epoch、_last_lr 这些状态量。LinearLR 自己额外存了 start_factor、end_factor、total_iters。

核心的get_lr()方法大概是这样的逻辑:

def get_lr(self): if self.last_epoch == 0: return [base_lr * self.start_factor for base_lr in self.base_lrs] if self.last_epoch > self.total_iters: return [base_lr * self.end_factor for base_lr in self.base_lrs] factor = self.start_factor + (self.end_factor - self.start_factor) * (self.last_epoch / self.total_iters) return [base_lr * factor for base_lr in self.base_lrs]

注意第一个分支:last_epoch == 0 时,直接用 start_factor。也就是说,你创建 scheduler 之后,还没调用 step 之前,如果读取优化器的 lr,实际上是 base_lr,不是 start_factor 对应的值。只有调用了 scheduler.step() 进入 first step,学习率才会变成 start_factor * base_lr。

这一点特别容易让人困惑。我遇到过不止一次,有人创建了 scheduler,然后在训练循环最前面打印 lr,发现是 0.01,再打印一次变成 0.0001,以为是 bug。其实不是,因为 scheduler 的第一次更新正好发生在第一个 step 时。

3.2 与优化器参数的联动机制

从代码层面看,scheduler 并不会直接修改 optimizer 里 param_groups 的 lr 字段,而是通过optimizer.step()更新参数后,再由 scheduler 覆盖写回。每次调度器内部会调用优化器的__setattr__或者直接改param_groups[0]['lr']。

如果你手动在训练循环中间修改了 optimizer 的 lr,比如做了梯度裁剪或者梯度累积,再调用 scheduler.step(),那 scheduler 会用自己维护的 last_epoch 重新计算并覆盖掉你的手动值。换句话说,scheduler 是一个"状态机",它记住的是自己走到了哪一步,而不是优化器当前的值。所以千万别在用了 scheduler 的情况下,还手动改 lr,那样大概率会被覆盖。

另外,如果你在训练过程中保存了 checkpoint,想恢复训练,除了要保存 model.state_dict() 和 optimizer.state_dict(),还一定要保存 scheduler.state_dict()。因为 scheduler 的 state_dict 里记录了 last_epoch、_last_lr 这些值,如果不保存,下次加载时调度器会重新从 0 开始,学习率直接跳到和之前完全不匹配的位置。

3.3 一个容易被忽略的边界行为

LinearLR 的衰减结束之后,学习率会一直停在 end_factor 对应的值上,不会再继续衰减。这一点和 CosineAnnealingLR 不同——后者在 T_max 之后如果继续跑,会开始回升。

这个边界行为造成的实际影响是:如果 LinearLR 的衰减结束得很早,后面几十个 epoch 都用同一个很小的学习率,模型可能收敛很慢,甚至长时间没有进展。解决思路有两个:一是把 total_iters 设大一点,让衰减覆盖整个训练过程;二是在衰减结束后手动切换另一个 scheduler 继续低学习率微调。

我实际测试过,对于一个 100 epoch 的图像分类任务,如果只用 LinearLR 衰减前 30 个 epoch,后面 70 个 epoch 学习率固定为初始值的十分之一,最终精度比全程用余弦衰减低 0.5 到 1 个百分点。这不是说 LinearLR 不好,而是它不适合单独长时间使用,它更擅长做短时间内的阶段性控制。

4. 实测对比:LinearLR 在不同训练任务中的表现

4.1 图像分类任务上的衰减曲线观察

我在一个 10 分类的图像任务上做了几组对照实验,网络结构是轻量级卷积网络,batch size 128,跑 50 个 epoch。第一组用固定学习率 0.01,第二组用 StepLR 每 10 个 epoch 衰减一半,第三组用 LinearLR 从 0.01 线性衰减到 0.0001,衰减周期覆盖全部 50 个 epoch。

结果很有意思:

  • 固定学习率:前 10 个 epoch 收敛很快,但 20 个 epoch 之后在验证集上开始震荡,最终验证准确率在 78% 左右。
  • StepLR:每 10 个 epoch 下降一次,下降初期验证准确率有短暂提升,但随后又进入平台期,最终 82% 左右。
  • LinearLR:全程平滑下降,没有明显震荡,最终 83.5% 左右。

这说明什么?线性衰减最大的优势是"稳定"。每一步的梯度更新幅度都在缓慢变小,参数不会突然因为学习率骤降而剧烈调整。对于很多中小规模任务来说,这种稳定的下降方式比阶梯式下降更容易达到较高的精度平台。

4.2 线性衰减与余弦衰减的收敛差异

我把同一任务里的 LinearLR 换成了 CosineAnnealingLR,衰减周期同样覆盖 50 个 epoch。训练前期的收敛速度上,LinearLR 略快于余弦衰减,因为线性衰减的前半段学习率仍然相对较高,而余弦衰减在前期和后期变化都比较平缓,中间变化剧烈。

但最终精度上,CosineAnnealingLR 比 LinearLR 高了大概 0.3 个百分点。原因是余弦衰减在接近训练末期时会变得非常缓慢,帮助模型在极小学习率下做精细调整;而线性衰减到最后一段仍然保持同样的变化速度,可能在局部最优附近来回跳动,少了那种"慢慢磨"的阶段。

如果你只有一次训练机会,没有太多时间反复调参,我建议直接用余弦衰减;如果你已经有 warmup 需求,而且训练周期比较短,LinearLR 作为 warmup 是更合适的选择。单独拿来做长周期衰减,性价比不如余弦。

4.3 对比 LR 曲线与 loss 曲线的拟合度

观察 loss 曲线,LinearLR 的 loss 下降曲线比较"直"。学习率每一步变化一样,损失下降也相对平稳。而 StepLR 的 loss 曲线在每次学习率减半后会有一个明显的下探,但之后又会回升一点,形成锯齿状。

如果你在 TensorBoard 里看到 loss 曲线呈现整齐的锯齿状,大概率是用了阶梯式衰减,或者 scheduler.step() 的调用节奏不对。LinearLR 的好处是曲线干净,后期调整策略也比较容易从 loss 曲线本身判断,不需要在脑子里面额外叠加学习率变化的因素。这个特性在长时间训练、想要观察模型真实拟合情况的时候很实用。

5. 调参实战:那些官方文档没写明白的细节

5.1 total_iters 与 epoch 数量的换算陷阱

前面提到过,LinearLR 按照 iteration 步数更新。但很多人直觉上会想:我训练 100 个 epoch,那 total_iters 就设为 100。如果训练集只有一个 batch,那确实是对的。但凡数据集稍微大一点,比如 5000 个 batch,设置 total_iters=100,学习率在 100 个 iteration 内就衰减完了,后面还有 99 个 epoch 都顶着最小学习率跑,等于白跑。

我建议你每次配置 LinearLR 之前,先打印一下训练数据的总步数:

steps_per_epoch = len(train_loader) total_steps = steps_per_epoch * num_epochs print(f"steps per epoch: {steps_per_epoch}, total steps: {total_steps}")

然后根据 total_steps 的百分比来设置 total_iters。比如我想让线性衰减覆盖整个训练过程,total_iters = total_steps;如果只想 warmup 5%,total_iters = int(total_steps * 0.05)。

5.2 多次调用 scheduler.step() 的坑

很多从 TensorFlow 转过来的同学容易犯一个错:在每个 batch 里 update 一次模型参数,同时又在一个 epoch 结束额外调用一次 scheduler.step()。结果学习率被 double 更新了,衰减速度比预期快一倍。这会导致模型还没充分训练,学习率已经降到很低,后面几乎学不进去。

一个稳妥的做法是:要么只在每个 iteration 结束后调用 scheduler.step(),要么只在每个 epoch 结束后调用。如果你已经习惯了在每个 iteration 里调用,就千万别在 epoch 循环里再重复调用。

另外,PyTorch 2.x 的官方示例里逐渐推荐直接使用torch.optim.lr_scheduler.LRScheduler的step()与优化器 step 绑定。如果你用的是 PyTorch 2.2 以上,可以试试:

optimizer = SGD(model.parameters(), lr=0.01) scheduler = LinearLR(optimizer, start_factor=0.1, end_factor=1.0, total_iters=100) for x, y in dataloader: optimizer.zero_grad() loss = model(x) loss.backward() optimizer.step() scheduler.step()

这样只在一处调用,最不容易出错。

5.3 与 warmup 衔接时的 last_epoch 设置

如果你不是从 0 开始训练,而是加载了一个已经训练了 20 个 epoch 的 checkpoint,然后想重新接一个 warmup,一定要注意 last_epoch 的设置。

假设你原本的训练状态是:optimizer 已经 step 了 2000 次。你现在创建一个 LinearLR 并设置 last_epoch=2000,它会直接把当前学习率当成 base_lr,并按照当前的 last_epoch 往下继续计算因子。如果你不设置 last_epoch,默认是 -1,scheduler 会认为你是从头开始,第一次 step 时会把学习率强制改到 start_factor * base_lr,直接把之前训好的模型学习率拉低,造成破坏。

正确做法是在创建 scheduler 时传入last_epoch=optimizer_state['step']。这个值通常可以从 optimizer.state_dict() 里取,也可以自己维护一个全局 step 变量。如果你觉得麻烦,最稳妥的办法是保存 checkpoint 的时候,把 scheduler.state_dict() 一起存下来,恢复时直接scheduler.load_state_dict(saved_scheduler_state),就不用纠结 last_epoch 了。

5.4 多参数组的场景下怎么分别控制

很多模型并不是单一参数组,比如你给骨干网络一个学习率,给分类头一个更大的学习率。这种情况下,LinearLR 默认会对所有参数组统一施加同一个因子。

如果你想对不同参数组设置不同的起始因子,需要在创建 optimizer 时,按 param_groups 的索引分别创建多个 scheduler 实例,然后依次 step。

optimizer = SGD([ {'params': model.backbone.parameters(), 'lr': 0.001}, {'params': model.head.parameters(), 'lr': 0.01}, ]) scheduler_backbone = LinearLR(optimizer, start_factor=0.1, end_factor=1.0, total_iters=100) optimizer.param_groups[0]['lr'] = 0.001

等等,这里有个细节:LinearLR 在初始化的时候会读取 param_groups 的 lr 作为 base_lr。如果你在创建 scheduler 之后修改了 param_groups 里的 lr,不仅不会同步到 scheduler 内部,反而可能被 scheduler step 时覆盖。所以顺序很重要:先创建 optimizer,设置好 lr,再创建 scheduler,后期不要手动改 lr。

如果要多组分别控制,最好各自维护一个 scheduler,就是初始化两个独立实例,逐个 step:

sched_backbone = LinearLR(optimizer, total_iters=100, verbose=True) sched_head = LinearLR(optimizer, total_iters=50, verbose=True) for batch in train_loader: ... optimizer.step() sched_backbone.step() sched_head.step()

不过这种做法要小心:每个 scheduler 都会将param_groups里所有组的 lr 都改一遍,除非你精细控制,否则两个 scheduler 会互相覆盖。说实话,多参数组的独立 schedule 用LambdaLR更合适,LinearLR 更适合统一调度。这个建议也是我自己踩过坑之后得出的结论。

6. 把 LinearLR 用出花的进阶技巧

6.1 结合梯度累积调整衰减节奏

梯度累积是一种常见做法——每 N 个 batch 累积一次梯度,然后更新一次参数。这种情况下,scheduler.step() 应该和 optimizer.step() 同步,也就是说每隔 N 个 batch 才调用一次。如果每个 batch 都调用,学习率衰减速度会快 N 倍,模型还没学到东西就已经进入低学习率阶段了。

具体来说:

accumulation_steps = 4 optimizer.zero_grad() for i, (x, y) in enumerate(train_loader): loss = model(x) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() scheduler.step() optimizer.zero_grad()

这里的 total_iters 应该以"真正的优化器更新次数"为单位。比如你原本打算 2000 次更新里完成 warmup,但梯度累积 4 次,那实际 batch 数是 8000,LinearLR 的 total_iters 仍然设为 2000,因为 scheduler 只在 optimizer.step() 时被调用。

6.2 通过 last_epoch 实现动态调整

有一种场景是:训练到一半发现 loss 不降了,我想把学习率"重启"一下,也就是跳到一个小一点的值然后继续衰减。用 LinearLR 可以这样实现:在中间某个时刻创建一个新的 LinearLR,设置 start_factor 为当前学习率与 base_lr 的比例,然后把 last_epoch 设成 0。

举个例子:

# 训练到第 30 个 epoch,希望用当前 lr 的 20% 重新线性衰减 current_lr = optimizer.param_groups[0]['lr'] base_lr = 0.01 new_factor = current_lr / base_lr * 0.2 optimizer.param_groups[0]['lr'] = base_lr scheduler = LinearLR(optimizer, start_factor=new_factor, end_factor=0.01, total_iters=100, last_epoch=-1)

不过这种方式本质上是"手动重置 scheduler 状态",需要你对当前学习率有精确掌控,建议只在调试阶段用,正式训练还是提前规划好 schedule 更省心。

6.3 把 LinearLR 用在非 lr 参数上

严格来说,LinearLR 只能控制学习率。但它的线性插值思想可以借鉴到其他场景,比如调整数据增强的强度、混合精度训练的 loss scaling 等。你可以用相似的公式,在训练循环里自己维护一个因子,控制增强强度从弱到强逐步过渡。

我在一个实验里,把 mixup 的 alpha 参数和 LinearLR 的因子绑定,前 20 个 epoch 让 alpha 从 0.1 线性升到 1.0,实现了"模型先学干净数据,再学混合数据"的渐进式训练策略。效果比固定 alpha 略好,而且训练初期 loss 更稳定。这个思路其实比 LinearLR 本身更值得借鉴——任何需要平滑过渡的参数,都值得用线性插值的方式去控制。

7. 踩坑汇总:如果你正在用 LinearLR,这份清单务必收好

7.1 最常出现的五个错误

我把自己和身边人踩过的坑整理成一张表,方便你对照排查:

错误现象根因解决办法
学习率在第一个 step 突然跳变创建 scheduler 后误以为 lr 已经变为 start_factor在 step 之后打印 lr 验证
衰减比预期快 N 倍scheduler.step() 在 batch 和 epoch 两处都调用只在 optimizer.step() 后调用一次
训练后期精度停滞total_iters 太小,衰减过早结束按 total_steps 动态计算 total_iters
checkpoint 恢复后学习率混乱只保存 optimizer,未保存 scheduler恢复时调用 scheduler.load_state_dict
手动设置 lr 被 scheduler 覆盖忽略了 scheduler 对 param_groups 的写回不要手动改 lr,或改用 LambdaLR

7.2 一个值得推荐的调试方法

调试 scheduler 最方便的方式,是在训练循环里每隔固定步数打印一次当前 lr,同时把 lr 和 loss 记录到日志里。

if step % 100 == 0: current_lr = optimizer.param_groups[0]['lr'] print(f"step {step}, lr {current_lr:.6f}, loss {loss.item():.4f}")

这样你可以直接看到 lr 是否按照预期变化,以及 loss 变化和 lr 变化之间的对应关系。我几乎每个训练实验都会保留这段代码,排查问题的时候比看任何文档都顶用。

7.3 防止 scheduler 状态和 checkpoint 不一致

最后提醒一个我自己踩过的大坑:分布式训练或者多卡训练时,如果每个进程都创建了独立的 scheduler,要注意只在主进程保存 checkpoint,并且保存之前保证所有进程的 scheduler 状态一致。如果不一致,加载到另一台机器时,学习率可能和原来完全不同。

最好的做法是:在训练循环最后统一用一个函数获取 scheduler.state_dict() 和 optimizer.state_dict(),只在 rank 0 上保存。恢复时同样只在主进程 load,然后 broadcast 到其他进程。这个过程虽然麻烦一点,但能避免非常多隐蔽的坑。

总结一下我个人的体会:LinearLR 不是万能的调度器,但它是在做 warmup、阶段性衰减、以及任何需要平滑过渡的场景里最趁手的工具。不要把它当作一个单独的训练策略,而是把它当作你训练工具箱里的一把标准螺丝刀——配合余弦衰减、配合 step 控制、配合 checkpoint 管理,它才能真正发挥价值。

如果你正在用 PyTorch 训练模型,试着把今天提到的代码跑一遍,打印一下学习率曲线。你可能会发现,自己之前对训练节奏的掌控,比想象中粗糙太多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询