1. 学习率调整:训练中最容易忽略却最要命的那个旋钮
我先说个真实经历。早期训练一个图像分类模型,loss 前几个 epoch 下降得挺漂亮,结果到了第 10 个 epoch 左右直接震荡到起飞,loss 曲线跟心电图似的。我以为是数据问题,折腾了三天数据增强和清洗,最后才意识到是学习率压根没调过。换了正确的衰减策略之后,同样的模型结构,收敛速度和最终精度都上了一个台阶。后来我复盘所有卡过的训练任务,发现一多半的问题根源都指向同一个东西:学习率调整。
"了解 PyTorch 学习率调整和迁移学习"这个话题,表面上看是两个独立知识点,实际是一条完整的实操链路。学习率决定你"怎么走",迁移学习决定你"从哪出发"。这篇文章不打算讲教科书式的理论推导,直接说清楚几个核心策略、工程实现上的常见坑,以及把预训练模型接进来做微调时学习率该怎么配合。
按我多年的实操经验,任何一个训练任务,都应该先想清楚三个问题:当前阶段该用什么学习率、什么时候该降低学习率、降低多少合适。这三个问题的答案,就是一套完整的学习率调整策略。而迁移学习呢,本质上是换一个更好的起点——用别人在大规模数据上练出来的特征作为初始状态,而不是从随机初始化开始死磕。
先说第一个问题:为什么学习率是训练里最关键的参数?因为它的数值直接决定参数更新的步长。步长太大,loss 会在最优点附近反复横跳,甚至直接发散;步长太小,训练半天模型还在原地踏步,浪费的时间和算力都是真金白银。更麻烦的是,训练初期和训练后期对步长的需求完全不同,初期需要大步快速接近最优区域,后期需要小步精细收敛。这就是为什么要动态调整学习率,而不是固定一个值从头训到尾。
2. 常见学习率策略拆解:从阶梯衰减到自适应监控
2.1 按固定轮次衰减的阶梯策略
PyTorch 里最简单、也最容易理解的是StepLR。它做的事很直白:每训练若干个 epoch,就把学习率乘以一个缩放因子。代码层面就是两行的事:
import torch.optim as optim optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)step_size=30表示每 30 个 epoch 衰减一次,gamma=0.1表示每次衰减到原来的 10%。这种策略最典型的使用场景是批量数据跑固定轮次的训练,比如 CIFAR 之类公开数据集的标准 baseline。它的优点是好理解、易复现、训练轨迹可预测。
缺点是太机械。如果 loss 在第 25 个 epoch 就已经平台期了,Schedule 不会因此加快衰减;反过来,如果第 50 个 epoch 还没收敛到理想区域,它也不会"通融"一下,让模型在多跑会再降学习率。所以后来在实际工程里,我更倾向于用MultiStepLR——它允许你指定具体的衰减节点,比如第 50、80、120 个 epoch 各衰减一次,比固定步长灵活得多。
scheduler = optim.lr_scheduler.MultiStepLR( optimizer, milestones=[50, 80, 120], gamma=0.1 )2.2 余弦退火:平滑衰减不是玄学
如果你嫌阶梯式衰减变化太突兀,CosineAnnealingLR是很香的选择。它让学习率按余弦函数平滑下降,整个过程从最大值逐渐降到最小值附近。这个策略在图像分类任务里实测效果普遍比阶梯式好一点,尤其是配合较长的训练轮次。
scheduler = optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=100, eta_min=0 )这里的T_max是半周期长度,表示从初始学习率衰减到eta_min需要多少个 epoch。eta_min通常设 0 或者一个很小的数。为什么余弦退火效果好?直觉上的解释是:训练初期学习率下降得慢,可以相对自由地探索;越接近后期衰减越快,参数朝着最优点快速收敛。相比阶梯式的"陡降",余弦策略在陡降之后还留了一个继续精细调整的缓冲,减少了因为学习率一下缩太多导致的损失反弹。
CosineAnnealingWarmRestarts是它的升级版,支持周期性的"重启"——每隔一段时间把学习率拉回高位,再重新走一遍余弦下降。这种带热重启的做法,本质上是让模型多次跳出局部最优点,在复杂损失面上往往能有意外收获。
2.3 ReduceLROnPlateau:自适应监控才是工程首选
前面几种策略有一个共同前提:你知道大概该在什么轮次降低学习率。但实际训练中,收敛节奏往往跟你预想的不一样。这时候就该上ReduceLROnPlateau,它不管训练了多少轮,只盯着你指定的指标看,指标不涨了,就把学习率降下来。
scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.1, patience=10, verbose=True )mode='min'表示监控的值越小越好,通常是 validation loss。patience=10的意思是连续 10 个 epoch 验证损失都没有刷新最低纪录,就触发一次衰减。这个策略好用的地方在于完全由模型的实际表现驱动,不需要人工预估训练到第几个 epoch 会进入平台期。
我个人在迁移学习任务里几乎必用这个策略。微调预训练模型时,初始阶段损失下降很快,后面会突然进入一个类似"撞墙"的平台期——这种时候 ReduceLROnPlateau 会在几个 epoch 之内自动把学习率降下来,模型的损失接着继续往下走。这个"撞墙再突破"的过程,用固定步长的策略很难卡准时机。
2.4 自定义调度器:LambdaLR 与 warmup
官方提供的策略够用,但有时候你需要完全自定义的学习率变化曲线。LambdaLR就是干这个的,它接受一个函数,这个函数根据当前 epoch 次数返回学习率的缩放系数。
def warmup_cosine(epoch): if epoch < 5: return (epoch + 1) / 5 else: import math return 0.5 * (1 + math.cos(math.pi * (epoch - 5) / 95)) scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=warmup_cosine)上面的函数实现了 5 个 epoch 的线性 warmup,接着自动进入余弦衰减。warmup 是很多大规模训练里的标准前置操作——训练刚开始时参数离最优区域很远,梯度方向也不可靠,如果一上来就用大步长,容易直接把训练带偏。先用小学习率走几个 epoch,让模型"热身"起来,再切回正常的学习率计划,稳定性会好很多。
3. 调度器使用的工程细节与踩坑记录
3.1 scheduler.step() 到底该放在哪
这是 PyTorch 里一个出了名的坑,我见过无数人在这里翻车。StepLR、MultiStepLR、CosineAnnealingLR这类基于 epoch 的调度器,必须在每个 epoch 结束后调用scheduler.step(),注意是 epoch 粒度,不是 batch 粒度。
for epoch in range(num_epochs): train_one_epoch(model, train_loader, optimizer, criterion) val_loss = validate(model, val_loader, criterion) scheduler.step() # epoch 结束时调用但ReduceLROnPlateau不一样,它的step()需要传入监控指标,也就是验证损失:
scheduler.step(val_loss)如果把scheduler.step()放在每个 batch 之后,学习率更新频率就会快得离谱,前期直接把学习率降没了。这个问题发生得很隐蔽,因为模型还是能训练的,loss 也会下降,只是训练轨迹跟预期差了很多。我在自查训练代码的时候,第一件事永远是检查scheduler.step()的位置和粒度。
3.2 checkpoint 里的调度器也要一起保存
中断训练、恢复训练是家常便饭,但很多人只保存了模型的state_dict,优化器和调度器的状态被扔了。结果就是:断点续训的那一刻,学习率被重置回了初始值。轻则训练轨迹稍微偏一下,重则直接用个大学习率继续跑,把已经收敛好的参数干废。
正确的做法是训练循环里同时保存三个东西:
torch.save({ 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'scheduler': scheduler.state_dict(), 'epoch': epoch, }, 'checkpoint.pth')恢复的时候对应加载并设置last_epoch-1,或者直接把调度器的last_epoch属性恢复出来。这一条属于那种"平时想不起来、出问题才意识到"的关键细节。
3.3 一个批次大小模型上的直觉参考
很多新手会问:初始学习率到底该设多少?我给一个经验参考:batch size 256 左右、Adam 优化器,初始学习率从 1e-3 起调,观察前几个 epoch 的 loss 变化;SGD 加 momentum 的话从 0.1 起调。如果 loss 刚开始就飞涨,说明学习率太大了;如果 loss 在缓慢下降但慢得像蜗牛,可以适当加大。核心原则是让 loss 在 2-3 个 epoch 内出现肉眼可见的下降趋势。
4. 迁移学习:换个起点的降维打击
4.1 为什么预训练模型真的有用
迁移学习的核心假设是:底层特征具有通用性。图像分类模型的前几层学到的往往是边缘、纹理、颜色渐变这种基础视觉特征,这些特征在猫狗分类任务里有用,在工业缺陷检测里同样有用。所以与其从随机初始化开始在少量数据上从零学这些基础特征,不如直接加载别人在大规模数据集上练好的权重作为起点。
打个比方:从随机初始化开始训练,相当于让一个从没见过世面的新生儿从零开始学习辨认物体。而迁移学习是直接让一个已经见过上百万张图片的"成年",去看少量新类别的图——它要做的只是适应一点新领域的特殊性,大部分通用感知能力已经具备了。
4.2 特征提取与微调的区别
这里有两条岔路:"特征提取"和"微调"。
特征提取的做法是:加载预训练模型,把所有层冻结(requires_grad=False),只把最后一层分类头的参数设为可训练。整个模型相当于一套"现成的特征提取器",只需要训练最后的分类器。这种做法速度快、数据需求量小、几乎不会过拟合,适合数据集很小的情况。
微调的做法是:把预训练模型的所有层都保留可训练状态,以较低的学习率继续在目标任务上训练。底层特征会根据新数据做一定程度的适配,在新领域上能取得更高的精度上限,代价是训练时间长、对数据量和调参水平要求更高。
两张方式没有绝对的好坏。我的选择经验是:数据量少于几千张,优先特征提取;数据量过万、且新领域和预训练任务差异较大,再考虑全局微调。
5. 微调实操:从加载权重到最终收敛
5.1 加载预训练模型并替换分类头
直接用标准的分类模型来举例,假设我要用 ResNet 结构做自定义数据集分类。PyTorch 官方带权重的预训练模型很容易加载:
import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) num_classes = 10 model.fc = torch.nn.Linear(model.fc.in_features, num_classes)这里最常见的问题是:忘了修改最后一层输出的维度。预训练模型在 ImageNet 上训练的时候最后一层是 1000 维,直接拿来做二分类或者十分类,会直接报维度不匹配。替换掉model.fc之后还要注意一件事:只有新加的层是随机初始化的,它的梯度更新需求跟预训练层完全不一样。后面的分组学习率设置就是为这个准备的。
5.2 分组学习率:分类头大步走,预训练层小步走
微调预训练模型,一个关键调参原则是:预训练层和新增分类头使用不同的学习率。分类头是随机初始化的,需要用相对大的学习率(比如 1e-3)快速学习;预训练层已经有很好的特征表达能力,只需要用很小的学习率(比如 1e-4 甚至更低)做轻微适配。实现方式用参数分组:
optimizer = torch.optim.SGD([ {'params': model.fc.parameters(), 'lr': 1e-3}, {'params': model.layer4.parameters(), 'lr': 1e-4}, ], momentum=0.9)如果只给优化器传单个lr参数,那预训练层和分类头共享同一个学习率。实践中想让预训练层"微调"而不是"洗掉",必须用小一点的、甚至小一个数量级的学习率。踩过几次坑之后我的体会是:全局微调最忌讳的是"一碗水端平",参数更新的节奏差异直接决定你是把模型原有的能力保留下来,还是把大量随机梯度搞出来的噪声覆盖上去。
5.3 冻结反向传播的层
单个层级的冻结和分组参数同时用,能做到最精细的控制。比如我们只想要layer4之后的层参与训练:
for param in model.parameters(): param.requires_grad = False for param in model.layer4.parameters(): param.requires_grad = True for param in model.fc.parameters(): param.requires_grad = True这样反向传播的时候只计算被标记层的梯度,显存占用和训练时间都能省不少。常见做法是先全部冻结,再选择性打开某些层,最后加上新分类头。
5.4 微调过程中的学习率策略组合
微调阶段我通常用的是两段式组合。前面若干轮使用一个较低且稳定的学习率,让模型先熟悉新数据和新的分类头;后段切到ReduceLROnPlateau按验证指标自动衰减。这样既避免了初期学习率太大把预训练特征冲掉,又在收敛后期能自动找到最合适的低位学习率。
optimizer = optim.AdamW([ {'params': model.fc.parameters(), 'lr': 1e-3}, {'params': model.layer4.parameters(), 'lr': 1e-4}, ]) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', patience=3, factor=0.5 ) for epoch in range(num_epochs): train... val_loss = validate... scheduler.step(val_loss)6. 迁移学习中的高频问题与排查指南
6.1 冻结层没生效?梯度还在更新怎么办
有时候你把requires_grad=False设置好了,但发现训练时间一点没降,损失也在变,怀疑冻结没生效。排查方法很简单:训练前后打印某一层的权重值,看有没有变化。
weight_before = model.conv1.weight.data.clone() train...() weight_after = model.conv1.weight.data print(torch.equal(weight_before, weight_after))如果前后一致,说明冻结生效了;如果不一致,检查是不是有代码里对model.parameters()整体做了操作,或者你在调用优化器的时候没过滤requires_grad=False的参数。最稳妥的做法是在构造优化器的时候显式过滤:
optimizer = optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4 )这样即使前面冻结没设置干净,优化器内部也不会为冻结参数缓存状态,避免了额外显存和微小的数值更新。
6.2 BN 层的坑:冻结语义不等于不更新
很多人都踩过这个坑:冻结特征提取层之后,BN 层的running_mean和running_var依然在更新。PyTorch 里requires_grad=False只影响梯度计算,不影响 BN 层的统计量更新。特征提取模式下,BN 层如果持续根据当前 batch 更新统计量,在小 batch 场景下会引入噪声,表现反而比预训练冻结的统计量差。
解决办法有几个。一是把模型切到model.eval()模式,这个模式下 BN 层会使用固定的 running 统计量;二是如果要在微调阶段就让 BN 跟着适配,那就别冻结 BN 层,这属于有意为之的精细控制。重点在于你要清楚知道模型在 train/eval 模式下 BN 的行为差异,否则很容易出现"训练时表现不错、验证时崩了"的现象。
6.3 微调时验证集表现越调越差
这种情况我在小型数据集上遇到不止一次。训练损失在降,验证损失反而在升,典型的过拟合信号。很多人第一反应是加正则化或者加数据增强,但其实首先要审视的是:你微调的层是不是太多了?预训练模型在大数据集上学的特征,往往比你在小数据上能学到的更鲁棒。数据集很小的时候,只训练最后的分类头,反而比微调全部层效果更好。
另一个容易忽视的点是学习率。微调阶段全部层用同一个学习率,会导致预训练层的特征被过度修改。实测下来的经验是:只要把预训练层的学习率降到分类头的五分之一到十分之一,过拟合现象通常会有明显缓解。
6.4 从 Loss 曲线判断策略选得对不对
我用几个典型形态做判断参考。Loss 平稳下降并最终收敛在低位:策略没问题,训练继续。Loss 在某个点突然跳升:检查是不是scheduler.step()触发衰减后学习率骤降导致,这种一般 2-3 个 epoch 会自动恢复。Loss 一直在一个区间震荡、毫无下降迹象:要么学习率太大了,要么模型已经收敛得很彻底,该衰减学习率了。Loss 下降但验证指标跟不上训练指标:过拟合,减学习率或者提高数据增强强度。
7. 送一套可以直接落地的工作流
最后分享一套我自己在图像分类微调场景里反复使用、效果一直稳定的流程。先加载预训练模型,替换分类头;冻结全部层打开最后一个残差块和新分类头;分组设置学习率,预训练层 1e-4、分类头 1e-3;训练过程用 ReduceLROnPlateau 自动衰减;每个 epoch 结束记录学习率、训练损失和验证指标,监控过拟合倾向。
这套流程的好处是省心,不需要频繁手动调参。训练过程中唯一要留意的就是 loss 曲线震荡幅度和收敛趋势。如果训练结束后精度还不满意,再考虑往更深的层逐步开放requires_grad,同时把预训练层学习率往低调,迭代几次就能找到当前数据规模下最合适的微调深度。
关于学习率调整和迁移学习,我在这两个方向踩过的坑比谁都多。以前总喜欢追求"精确的最优学习率",后来发现训练任务里的不确定性太多,与其费劲去找那个不存在的最优解,不如把策略设计得足够稳,让模型在合理的范围内自己找到好的路径。学习率调整的本质不是猜一个数字,而是设计一个能让模型在训练过程中不断自适应、稳步收敛的过程。迁移学习也一样,它不是简单的"加载权重 + 接着训练",而是在理解预训练特征能力边界的基础上,做一次有节制的知识迁移——把这个理解到位了,你在不同领域、不同数据量下的所有任务都能有一个平稳可靠的起点。