☰
深度学习学习率调优:损失震荡、NaN与精度停滞诊断
2026/10/1 1:37:58 网站建设 项目流程

有人拿着训练日志来问我:模型跑到第 7 个 epoch,损失值从 0.42 直接跳到 nan,是不是数据清洗没做好?我反问了一句学习率设了多少,答 0.1,优化器是 Adam。这个问题的答案基本就定了——不是数据,是学习率(Learning Rate)把参数一步迈出了损失曲面的有效区域。深度学习里能把"损失值不降""精度上不去""训练直接崩掉"这三件事同时解释清楚的超参数,学习率排第一,没有第二。

我打算把学习率对精度和损失值的影响讲透:它为什么会让损失震荡、为什么会让你误以为模型"收敛"了、为什么损失一路下降而精度原地踏步、以及在实际工程里怎么用十分钟找到一个可用的数量级。内容偏实战,会给出可以直接跑的复现代码、一维二次函数上的发散边界计算、从曲线形态反推病因的诊断表,还有我自己踩过的几个不写进文档的坑。适合刚入门深度学习、正在第一次调参的人,也适合已经能跑通训练但不太清楚曲线为什么长这样的人。

1. 梯度那一行乘法:学习率究竟改变的是损失还是精度

1.1 从 w ← w − lr·g 看"步长"的物理意义

所有基于梯度的优化,核心都是同一行更新式:

w = w - lr * grad

grad告诉你"往哪个方向走能让损失变小",lr告诉你"这一步走多远"。前者由反向传播算出,后者由你手填。很多人调参时把注意力全放在网络结构、数据增强、损失函数上,却忘了梯度只负责方向,不负责距离。方向错了模型永远学不会,距离错了模型会在"对的方向上走过头"。

把损失曲面想象成一片山谷。梯度是脚下最陡的下坡方向,学习率是你的步幅。步幅太小,你在谷底附近来回蹭,走一天也到不了最低点;步幅太大,你从山谷这头一步跨到那头更高的坡上,下一次再跨回来,来回震荡甚至越走越高。这个类比的准确之处在于:学习率的合理范围和曲面的"陡峭程度"绑定,而不是一个放之四海皆准的常数。

数学上这件事更干净。对二次损失 L(w) = (w − w*)² 求导得 g = 2(w − w*),代入更新式:

w_{t+1} - w* = (1 - 2·lr) · (w_t - w*)

误差每步乘以|1 − 2·lr|。这个系数的绝对值小于 1 才会收敛,等于 1 是原地打转(永不收敛),大于 1 就指数发散。也就是说:

  • 0 < lr < 0.5:单调收敛,且lr = 0.5时一步到位(系数为 0);
  • 0.5 < lr < 1:收敛但每步在谷底两侧横跳,损失曲线呈锯齿;
  • lr ≥ 1:发散,损失值指数爆炸,几十步内就会溢出成 inf 或 nan。

这个结论只对最简单的二次函数成立,但它给出的直觉是通用的:存在一个由曲面曲率决定的临界学习率,越过它就从"收敛"变成"发散",而在临界值下方还存在一段"能收敛但会震荡"的区间。真实网络的损失曲面各方向曲率差异巨大(也就是常说的病态条件数),所以临界值不是一个数,而是一个受最小曲率方向限制的上界——这解释了为什么学习率稍微调大一点,损失值不一定是缓慢变差,而是突然崩。

1.2 损失可导、精度不可导:两条曲线为什么不同步

这是我认为最值得讲清楚的一点:损失值和精度不是同一种东西的两个刻度,它们的数学性质完全不同。

损失值(交叉熵、MSE 之类)是参数的连续可导函数,梯度下降是专门为它设计的,所以损失曲线对学习率的变化极其敏感、响应也极其直接。精度是"预测类别等于真实类别"的计数比例,是 argmax 之后的结果,是一个阶梯函数——参数连续变动时,精度只会在某个样本刚好跨过决策边界的那一刻跳变一下。这意味着:

  • 损失值可以平滑下降,精度可能一格一格地跳,甚至在几万步里看起来完全不动;
  • 精度的噪声天然比损失大,因为一个 batch 里可能只有几个样本的分类结果发生翻转;
  • 精度到达平台期后继续训练,损失还在降,但精度不动,这不是训练出错,是任务本身的信息上限到了。

反过来也成立:有些时候损失下降得很快,精度却掉下去了。典型场景是学习率过大,参数在谷底附近大幅震荡,训练集上的平均损失因为样本多、平均化效应看起来还行,但模型实际上停在了一个很"尖"的位置,换到验证集上泛化很差,验证精度自然难看。

所以看日志时不要把"损失降了"当成"模型在变好"的同义词。我一般会同时盯四条曲线:训练损失、验证损失、训练精度、验证精度,再看它们两两之间的差距。差距的形态比单条曲线的数值更能说明问题。

1.3 三种学习率档位下的损失/精度形态对照

把学习率粗分成"过大、合适、过小"三档,对应的曲线形态差异非常明显。这张表是我带新人时最常拿出来的一张:

学习率档位训练损失形态验证损失形态精度形态参数状态
过大(越临界值)剧烈震荡、锯齿,甚至突然 nan很快反弹上升忽高忽低,可能一开始涨得很快随后崩在谷底两侧大幅横跳,甚至逃出有效区域
偏大但可用快速下降后小幅波动先降后缓慢上升(过拟合加速)前期上升快,后期卡住停在较尖的极小值附近
合适平滑下降,尾部趋于平缓与训练损失同步下降后趋于平缓稳定爬升,尾部平台稳定收敛到泛化较好的区域
过小近乎线性缓慢下降,尾部仍在降同训练损失一起缓慢下降爬升极慢,看起来像"学不动"还在半山腰,远未到谷底

有一个容易被忽略的细节:学习率偏大时,损失曲线的前几十步经常是"看起来很不错"的。因为初始参数离最优解很远,梯度很大,大步长正好合适,损失掉得飞快,你会觉得"这个学习率真香"。问题出现在几百步之后,参数接近谷底时,同样的步长就显得过大了,曲线开始抖动,然后验证损失慢慢抬头。这种"先好后坏"的形态是最容易骗人的,也是我强调必须看完整训练周期、而不是只看前几个 epoch 的原因。

2. 用一个二次函数和一段CNN代码把影响跑出来

2.1 先在一维二次函数上看发散与震荡的边界

在动 CNN 之前,我强烈建议先用最简的二次函数把三种行为亲眼看一遍,成本几乎为零,但建立起来的直觉能省掉后面几小时的无谓试错。

import numpy as np w_star = 3.0 def run(lr, steps=12, w0=0.0): w = w0 hist = [] for _ in range(steps): g = 2 * (w - w_star) # d/dw (w - 3)^2 w = w - lr * g hist.append(w) return hist for lr in [0.05, 0.3, 0.5, 0.9, 1.05]: h = run(lr) print(f"lr={lr:<5} 轨迹={['%.3f' % v for v in h[:6]]}")

跑出来的结果会是这样:

  • lr = 0.05:每步只走一小截,12 步后还在 2.4 附近,收敛但极慢;
  • lr = 0.3:几步之内逼近 3.0,干净利落;
  • lr = 0.5:第一步直接到 3.0,这是理论上的"一步到位";
  • lr = 0.9:在 3.0 两侧来回横跳,幅度每步衰减但看起来像锯齿;
  • lr = 1.05:一步比一步远,数值迅速膨胀到 10² 量级,这就是发散。

再补一个更贴近实际的版本:把损失换成L(w) = 0.5·wᵀAw,A 的两个特征值分别是 1 和 100(模拟病态条件数)。稳定的学习率要求lr < 2/λ_max = 0.02,但λ_min = 1这个方向上的收敛速度在lr = 0.02时只有1 − 0.02 ≈ 0.98的收缩率,需要几百步才能走完。这就是"学习率受最大曲率限制、收敛速度受最小曲率限制"的两难——也正因为它,才会有后面要讲的预热、余弦退火这类调度器,以及自适应优化器的存在。

2.2 最小可复现实验:五档学习率跑同一个CNN

下面是能直接跑的对比实验,目的是让你用自己的数据看到学习率对损失和精度的真实影响曲线。我用 FashionMNIST 和一个三层小 CNN,规模足够小,CPU 上十分钟内能跑完一轮。

import torch, torch.nn as nn, torch.nn.functional as F from torch.utils.data import DataLoader from torchvision import datasets, transforms def build_loaders(batch_size=128, seed=0): torch.manual_seed(seed) tf = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.2860,), (0.3530,))]) tr = datasets.FashionMNIST('./data', train=True, download=True, transform=tf) va = datasets.FashionMNIST('./data', train=False, download=True, transform=tf) g = torch.Generator().manual_seed(seed) return (DataLoader(tr, batch_size=batch_size, shuffle=True, generator=g, num_workers=2), DataLoader(va, batch_size=512, shuffle=False, num_workers=2)) class SmallCNN(nn.Module): def __init__(self, n_cls=10): super().__init__() self.c1 = nn.Conv2d(1, 32, 3, padding=1) self.c2 = nn.Conv2d(32, 64, 3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.bn2 = nn.BatchNorm2d(64) self.fc = nn.Linear(64 * 7 * 7, n_cls) def forward(self, x): x = F.max_pool2d(F.relu(self.bn1(self.c1(x))), 2) # 28 -> 14 x = F.max_pool2d(F.relu(self.bn2(self.c2(x))), 2) # 14 -> 7 return self.fc(x.flatten(1)) @torch.no_grad() def evaluate(model, loader, criterion): model.eval() tot_loss, correct, n = 0.0, 0, 0 for x, y in loader: out = model(x) tot_loss += criterion(out, y).item() * y.size(0) correct += (out.argmax(1) == y).sum().item() n += y.size(0) return tot_loss / n, correct / n def train_one(lr, epochs=3, tag=''): tr_loader, va_loader = build_loaders() torch.manual_seed(0) model = SmallCNN() criterion = nn.CrossEntropyLoss() opt = torch.optim.Adam(model.parameters(), lr=lr) log = [] for ep in range(epochs): model.train() for x, y in tr_loader: opt.zero_grad() loss = criterion(model(x), y) loss.backward() opt.step() if not torch.isfinite(loss): print(f'{tag} lr={lr} 第{ep}轮出现非有限损失,提前终止') return log, None, None tr_loss, tr_acc = evaluate(model, tr_loader, criterion) va_loss, va_acc = evaluate(model, va_loader, criterion) log.append((ep, tr_loss, va_loss, tr_acc, va_acc)) print(f'{tag} lr={lr:<7} ep={ep} tr_loss={tr_loss:.4f} ' f'va_loss={va_loss:.4f} tr_acc={tr_acc:.4f} va_acc={va_acc:.4f}') return log, va_loss, va_acc if __name__ == '__main__': for lr in [1e-1, 1e-2, 1e-3, 1e-4, 1e-5]: train_one(lr, epochs=3, tag='[adam]')

我实测下来,Adam 下的典型结果大致是:1e-1在第一个 epoch 内损失就震荡或者直接爆掉;1e-2能跑但验证精度明显低于1e-3,验证损失后期抬头;1e-3是这批配置里最稳的;1e-4和1e-5三个 epoch 结束时损失还在稳定下降,精度还没爬到平台——也就是"没训够",不是"学习率不对",这两者要在日志里区分开。

2.3 记录什么:四组曲线的落盘方式

上面那段代码只打印了每个 epoch 的四个数字。做学习率实验时,如果只记录 epoch 级的平均值,你会丢掉最重要的信息——batch 级的损失抖动形态。我习惯两种粒度都记:

# 在训练循环里累积 step_losses.append((global_step, loss.item(), current_lr)) ... # 每个epoch结束后落到文件 import json with open(f'log_lr{lr}.json', 'w') as f: json.dump({'step_losses': step_losses, 'epoch_metrics': log}, f)

关键字段只有四个:global_step、batch 损失、当前实际 lr、epoch 级验证指标。第三个字段经常被忽略——如果你用了调度器,optimizer.param_groups[0]['lr']才是这一步真实生效的学习率,配置里写的初始值只是起点。很多"我明明设了 1e-3 为什么表现得像 1e-2"的困惑,根因就是调度器状态没被记录下来。

落盘之后我一般画两张图:横轴是 step、纵轴是 batch 损失的散点(透明度调低,看密度),以及横轴 step、纵轴 lr 的曲线。两条线叠在一起看,几乎所有的学习率异常都能一眼认出来。

2.4 复现实验最容易翻车的三个细节

第一个是随机种子。同一个小 CNN,同一个学习率,只换随机种子,验证精度差 1 到 2 个百分点是常态。所以判断"学习率 A 比 B 好"时,单次实验的差异根本不构成证据。我的做法是关键结论至少跑 3 个种子,看均值和中位数,差异小于种子间波动的一律当作噪声。

第二个是batch size 与数据顺序。shuffle=True配generator固定住顺序,否则每个学习率跑的数据流不一样,损失曲线没有可比性。同时 batch size 必须统一,因为 batch size 变大本身会降低梯度噪声,等效于降低学习率——两者是耦合的,混着改就分不清是谁的功劳。

第三个是评估集的使用频率。上面代码每个 epoch 都全量评估一次训练集和验证集,这在 6 万样本下还行,但如果你的数据是百万级,评估开销会盖过训练。更省的做法是只评估验证集,训练损失用训练过程中的滑动平均代替。还有一个更严重的坑:拿验证集调学习率,再用同一个验证集报告最终结果,这是典型的乐观偏差。我会额外切一份只在最后用一次的 test 集,或者至少在报告里写清楚验证集参与过多少轮选择。

3. 从曲线形态反推:损失与精度分别暴露了什么问题

3.1 损失震荡不降与NaN:先怀疑学习率而不是数据

损失值出现周期性的上下摆动、或者干脆跳到 nan,排查顺序里学习率永远排在第一。原因很直接:数据问题(标签错误、脏样本)通常表现为损失偏高但有界的噪声,不会让数值溢出;而学习率过大导致参数跑到曲率极高的区域时,梯度随之变大,更新步长进一步变大,是一个正反馈,几步之内就能冲上 1e30 然后溢出成 inf,再经过 log 或除法就变 nan。

我自己遇到 nan 时的固定动作是:

  1. 看 nan 出现前 20 步的损失值,如果是"逐步放大后溢出",基本可以定性为学习率过大;如果是某一步毫无预兆地突然 nan,要查数据里有没有 inf 或者除零。
  2. 临时把学习率降 10 倍重跑,如果 nan 消失,结论闭环。
  3. 如果降了还是 nan,再查梯度裁剪是否缺失、是否有 log(0)、是否混合精度里 loss scale 溢出。

对于 Transformer、RNN 这类梯度容易爆炸的结构,我的默认配置是学习率配合torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)一起用。梯度裁剪不解决学习率选得不对的问题,但它能把"偶尔一步的异常大梯度"削掉,显著减少训练中途崩掉的概率。

3.2 损失像爬坡:学习率太小与"假收敛"

学习率太小的表现形式很好认:损失曲线几乎是斜向下的直线,没有拐点、没有平台,你关掉训练时它还在匀速下降。这时候如果去看精度,会发现精度也在缓慢上升但幅度很小,容易误判成"模型容量不够"或者"数据不够"。

判断标准其实很简单:观察训练最后 1/5 的步数里,损失还在降多少。如果最后一个 epoch 的训练损失比起始时还低了 10% 以上,说明模型根本没收敛,是优化没走完,不是容量不够。此时正确的动作是加大学习率或者延长训练步数,而不是加网络层数。

有个反直觉的经验值得分享:在小数据集上,学习率太小比学习率偏大更危险。偏大你能从震荡里立刻看出来,偏小则是一片看似平静的"能跑通、能收敛、精度不高"的假象,你可能在这个配置上耗掉一整天。我现在固定会在正式训练前先跑一个 20 步的快速试探,看损失在这 20 步里掉了多少量级——掉得太慢的直接换大。

3.3 损失一路降、精度卡住不动:三种常见成因

这种形态最容易被误解成"过拟合"或"模型不行",实际上至少有三种不同成因,处置方式完全不同。

第一种是学习率偏大导致参数停在尖锐极小值。训练损失确实在降,但验证精度停滞甚至倒退。判别方法是看训练损失与验证损失的差距:如果验证损失已经开始抬头而训练损失还在降,是过拟合;如果两条损失都在降,但验证精度就是不动,那更像是优化落点的问题,把学习率降一个数量级再试。

第二种是评价指标与损失不匹配。交叉熵对小概率的修正敏感,而精度只关心 argmax 谁是第一。类别严重不均衡时,模型把所有样本预测成多数类,损失能降到一个不低的水平,但精度(尤其是少数类的指标)很差。这时候要看混淆矩阵或每类指标,光看总精度会误判。

第三种是BatchNorm 的 running_mean/running_var 在训练早期还不稳定。用大学习率时,前几百步各层的统计量剧烈变动,model.eval()时用的滑动平均统计量和训练时的 batch 统计量差距很大,导致验证精度忽高忽低。常见处理是把学习率压小、加 warmup,或者等训练稳定后再评估。

3.4 训练精度高验证精度低:学习率是帮凶而非主犯

训练精度 99%、验证精度 80% 这种落差,主犯通常是数据量、分布差异或者过拟合,但学习率经常是帮凶。原因是大的学习率会让模型更倾向于收敛到"尖锐"的极小值:参数对输入扰动非常敏感,训练集上稍微变化的样本还能勉强分对(因为训练样本被反复见过),测试集上一点点分布偏移就崩。小的学习率配合适当的正则和噪声(如标签平滑、Dropout、数据增强),更容易找到平坦的极小值,泛化差距会明显收窄。

我自己的经验是:当发现训练/验证落差很大时,除了常规的加正则、加数据,我会顺手把学习率减半配合余弦退火再跑一次。大概有三分之一的场景能直接改善 1 到 3 个百分点,成本只有一次训练时间。这个操作性价比很高,值得放进常规动作里。

3.5 症状-原因-处置速查表

症状最可能的原因第一步处置
损失周期震荡学习率越过曲率上界降到 1/3 ~ 1/10 重跑
损失变 nan/inf学习率过大引起梯度爆炸降 10 倍 + 梯度裁剪
损失平滑缓慢下降、无平台学习率过小或步数不够放大 3 ~ 10 倍或延长训练
训练损失降、验证损失抬头过拟合(学习率偏大加速)正则 + 降学习率
损失降、精度长期不动优化落点尖锐 / 指标不匹配 / BN 统计量不稳降 10 倍重跑,或看每类指标
训练/验证落差大尖锐极小值 + 过拟合降学习率 + 数据增强
换优化器后表现突变默认学习率量级不同按优化器重设初值

这张表我在实际工作中贴在手边,绝大多数训练异常都能在三分钟内定到方向。

4. 调学习率的四件实操武器

4.1 lr range test:十分钟找到数量级

不要靠猜。Leslie Smith 提出的学习率范围测试(lr range test)是最省时间的做法:从一个极小值开始,每个 batch 把学习率按指数规律放大,同时记录损失,画出一条横轴为对数学习率、纵轴为损失的曲线。

def lr_range_test(model, loader, criterion, start=1e-6, end=1e-1, steps=300): opt = torch.optim.SGD(model.parameters(), lr=start, momentum=0.9) gamma = (end / start) ** (1 / steps) sched = torch.optim.lr_scheduler.ExponentialLR(opt, gamma) hist, it = [], iter(loader) for i in range(steps): try: x, y = next(it) except StopIteration: it = iter(loader); x, y = next(it) opt.zero_grad() loss = criterion(model(x), y) loss.backward() opt.step() cur = opt.param_groups[0]['lr'] hist.append((cur, loss.item())) sched.step() return hist

判读方式:把横轴取对数后,损失通常会先缓慢下降,到一个最低点后迅速上升。取最低点对应的学习率再除以 3 到 10,就是可以用于正式训练的初始值。除以这个系数的原因是该测试是单步更新的"乐观值",正式训练中参数会累积移动,需要留安全余量。

有三个使用注意:曲线在上升段末尾会有剧烈抖动,所以取点时不要看最后一个点,要用平滑后的最小值;测试用的模型应该是随机初始化的同结构模型;测试用的损失最好是滑动平均后的值,单点噪声很大。这个方法的成本大概是一次完整训练的百分之几,但换来的信息量远超手工试错。

4.2 warmup与余弦:为什么"先慢后快再慢"更稳

固定学习率的问题在于两个阶段的需求矛盾。训练初期参数随机,梯度方向不太可靠、BN 统计量不稳,用大学习率容易一步走偏;训练后期参数接近最优,需要用小学习率精细收敛。调度器(scheduler)就是用来调和这个矛盾的。

我常用的三种,适用场景差别很大:

  • StepLR / MultiStepLR:在固定的 epoch 乘 0.1。优点是简单可解释、行为可预测;缺点是需要提前知道总步数,且学习率突变那一瞬间损失会有个台阶式的小跳。
  • CosineAnnealingLR:学习率按余弦曲线平滑降到接近 0。平滑没有突变,尾部收敛精细,是我在图像分类任务上的默认选择。缺点是必须先确定总步数,中途延长训练会导致学习率重新变大。
  • OneCycleLR / warmup + cosine:先线性升到峰值再用余弦降下来。这是我做大 batch 训练和 Transformer 微调时的默认选择,收益最明显。

warmup 为什么能救命,说到底是把训练早期那段"参数离最优解很远、梯度的方向性和尺度都不可靠"的时间,用很小的步长平稳度过。如果开局就用大学习率,模型可能被一步推到一个很差的区域,之后再怎么调都爬不回来——这就是所谓的"坏初始化陷阱"。用 warmup 时通常从峰值学习率的 1/100 或 1/1000 开始,用几百到几千步线性升到峰值。

一个具体的配置示例:

opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.05) steps_per_epoch = len(train_loader) total_steps = steps_per_epoch * epochs warmup_steps = max(100, int(0.05 * total_steps)) def lr_lambda(step): if step < warmup_steps: return step / warmup_steps # 线性升 prog = (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.5 * (1.0 + math.cos(math.pi * prog)) # 余弦降 sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda) # 每个 batch 后调用 sched.step()

这里有个容易踩的坑:LambdaLR是按 step 调用的,如果你按 epoch 调用,学习率变化曲线会被压缩成几个台阶,warmup 完全失效。调度器按 step 还是按 epoch,必须和它的设计意图一致,PyTorch 不会替你检查这件事。

4.3 batch size变了学习率要不要跟着变

要变,而且有经验规律可循。batch size 从 B 变成 kB 时,梯度的方差大约降为 1/k,噪声变小意味着可以走更大的步子:

  • 线性缩放:lr_new = lr_base × (bs_new / bs_base),适合 SGD 类优化器,是最常被引用的规则;
  • 平方根缩放:lr_new = lr_base × sqrt(bs_new / bs_base),更保守,在噪声不需要被完全补偿时更安全。

我的实操建议是:batch size 变化不超过 4 倍时,先按平方根缩放试,稳定再往线性方向靠。线性缩放在大 batch(几千以上)时经常需要配合 warmup,否则前面几步就是灾难。另外要注意,换了 batch size,你不是在做一个干净的对照实验:优化轨迹、正则强度、BN 统计量的噪声全都变了。所以调 batch size 时最好固定学习率做一次基准,再单独调学习率,不要同时动两个变量。

4.4 换优化器必须换学习率:几组默认值对照

这是我见过最频繁的事故来源:代码里把 SGD 换成 Adam,学习率却还写着 0.1,结果第一个 epoch 就炸。不同优化器对梯度的归一化程度不同,可用的学习率量级差出一到两个数量级。

优化器常见初始学习率说明
SGD(不含动量)1e-2 ~ 1e-1对学习率最敏感,通常必须配调度器
SGD + Momentum1e-2 ~ 1e-1动量为 0.9 时收敛更快,可用更大步长
Adam1e-3自适应缩放使梯度被归一化,量级普遍小一档
AdamW1e-4 ~ 3e-4(Transformer 常用)解耦权重衰减,配合 warmup 更稳
RMSprop1e-3 ~ 1e-2与 Adam 接近,适用于非平稳目标

我自己的习惯是:每换一次优化器,就把整个 lr range test 重跑一遍。听起来费事,但它省下来的调试时间远超测试成本。还有个细节值得记住:Adam 的eps参数(默认 1e-8)在梯度极小的层上会起主导作用,如果把学习率调得极小(比如 1e-6),更新量可能被eps淹没,表现为"参数完全不动"。这种情况我在微调小模型时遇到过,困惑了挺久。

4.5 分层学习率:微调场景的常规操作

微调预训练模型时,一个学习率走天下不是最优解。原因很好理解:靠近输入的浅层学到的是一般性的底层特征(边缘、纹理、基本语法),微调时只需微调;靠近输出的层需要适配你的具体任务,需要更大的更新幅度。所以常见做法是按层分组给不同学习率:

backbone_params = [p for n, p in model.named_parameters() if n.startswith('backbone')] head_params = [p for n, p in model.named_parameters() if n.startswith('head')] opt = torch.optim.AdamW([ {'params': backbone_params, 'lr': 1e-5}, # 主干小学习率 {'params': head_params, 'lr': 3e-4}, # 头部大学习率 ], weight_decay=0.05)

一般主干的初始学习率是头部的 1/10 到 1/100。如果数据量和目标任务与预训练任务差异很大,可以把主干学习率再放大一些;如果数据集很小(比如只有几千条),主干学习率设到 1e-5 以下甚至干脆冻结前若干层,效果通常更稳。判断是否冻结,我的经验是看验证集指标:解冻主干后验证指标立刻变差,就说明主干不该在这个学习率下被更新。

5. 几个真实踩过的坑

5.1 加了BatchNorm之后把学习率开大,结果验证精度崩了

有一段时间我做图像分类,发现加了 BatchNorm 之后训练速度快了很多,就想当然地把学习率从 1e-2 提到 5e-2。训练损失确实降得飞快,但验证精度在两个 epoch 后开始掉头向下。当时我以为是过拟合,加了 Dropout、加了权重衰减,都没用。

真正的原因是:BatchNorm 让前向传播的激活尺度被归一化,反向传播中梯度的尺度也跟着变了,但这不等于所有层都变稳定了。BN 的running_mean和running_var是用指数滑动平均更新的,学习率大的时候每步参数变化大,每层的激活分布跟着剧烈变化,滑动平均会严重滞后于真实分布。评估时用滑动平均的统计量,和训练时的实际分布对不上,验证精度就崩了。

处置方式有三个,可以叠加:把学习率降到原来量级(我最后用 2e-2)、把动量参数momentum从 0.1 调到 0.01 让统计量更新更快,以及增加 warmup。后两个我平时不太提,但在大学习率场景下效果明显。另外一个没写进文档的经验:加了 BN 之后可用学习率的上界通常比不加时更大,但这个"更大"是有限的,不是随便开。

5.2 微调预训练模型沿用从头训练的lr

有位同事做文本分类,用预训练模型微调,学习率沿用了之前从头训练 RNN 时的 1e-2。结果训练损失在第 300 步左右突然飙升,模型完全学不动了。原因是预训练模型的参数已经在一个非常好的解附近,梯度的量级比随机初始化时小得多,1e-2 这种步长会一步把参数推出有效区域,然后再也回不来——这就是常说的"灾难性遗忘"的一种表现。

微调的起始学习率我一直用 1e-5 到 5e-5 这个区间,配 warmup 和较小的权重衰减。另外还有一个判断技巧:如果微调的前 100 步训练损失下降得太快(比如从 2.3 掉到 0.5 以下),反而要警惕,这通常是模型在快速丢弃预训练知识、过度拟合你的小数据集。

5.3 混合精度下loss scale导致的曲线抖动

混合精度训练通过把部分运算放到半精度来省显存、提速,为了防止小梯度在半精度下下溢成 0,框架会用 loss scaling。这里的问题是:loss scale 会动态调整,一旦检测到 inf 或 nan,这一步的更新会被跳过,loss scale 减半。表现出来就是训练损失曲线在正常下降轨迹上偶尔冒出几个孤立的高点,然后恢复。

很多人看到这种抖动第一反应是学习率出了问题,其实不是。识别方法是看梯度的范数日志和 loss scale 的日志:如果 loss scale 在那几个点上正好降了,说明是跳步导致的,属于正常现象,不用调学习率。但要注意一个真实风险:如果 loss scale 频繁减半(比如一百步内减了五次以上),说明梯度经常溢出,这时候确实需要降学习率或者加梯度裁剪。

5.4 断点续训忘了恢复scheduler状态

这个坑让我白白浪费了一个晚上的训练。当时在服务器上训练到第 20 个 epoch 断了,我写了断点恢复逻辑,保存并加载了模型权重和优化器状态,但没有保存 scheduler 的状态。恢复后模型权重是对的,但 scheduler 从第 0 步重新开始,学习率一下子从退火后的 1e-5 跳回初始的 1e-3。损失曲线在恢复的那个点直接跳了一个台阶,后面几个 epoch 的精度全废了。

正确的保存方式是把sched.state_dict()一起存,加载时sched.load_state_dict(),并且要保证调度器的创建顺序和步进次数与恢复点一致。现在我写训练脚本时的固定动作是:把 model、optimizer、scheduler、epoch、global_step、随机数状态全部打进一个 checkpoint,恢复时按顺序加载。随机数状态这一项很多人会漏,但它会直接影响数据打乱顺序和数据增强的结果。

5.5 日志里没写lr,实验全部作废

这件事不是技术问题,是流程问题,但它的破坏力最大。我曾经有一批对比实验,跑了六个配置,两周后想复盘"哪个学习率最好",打开日志发现只记了损失和精度,没有记当前生效的学习率。因为我中间动过调度器,配置文件和实际生效的学习率完全对不上。最后只能整批重跑。

从那以后我给自己定了条规矩:任何训练脚本,无论多临时,第一行日志必须是完整配置,每个 epoch 必须记录当前的实际学习率。这条规矩后来救过我很多次,尤其是在多个项目并行、间隔几周才复盘的时候。

6. 把学习率调优变成一套固定动作

6.1 每次实验必须落盘的字段

我把该记录的东西列成一张清单,写脚本时照着填:

  • 完整配置:优化器类型、初始学习率、调度器类型与参数、batch size、总步数、warmup 步数、权重衰减、随机种子;
  • 每步记录:global_step、batch 损失、实际生效学习率、梯度范数(配合clip_grad_norm_的返回值);
  • 每轮记录:训练损失、训练精度、验证损失、验证精度、当前学习率;
  • 环境信息:框架版本、硬件类型、是否混合精度。

梯度范数这一项我特别推荐加上。它和学习率是互相印证的一对指标:梯度范数突然放大往往先于损失值异常出现,是一个提前预警信号。

6.2 一份可以直接照着走的顺序

我现在拿到一个新任务、新模型,调学习率的顺序是固定的,基本四步:

  1. 先跑 lr range test,用 300 步找到损失曲线的最低点,得到候选值lr_cand,取lr_cand / 5作为初始学习率;
  2. 用初始学习率跑一个短周期(比如总步数的 10%),只看两件事:损失是不是平稳下降、有没有 nan。有 nan 就按 10 倍递减继续试;
  3. 加 warmup 和余弦退火,跑完整训练,观察最后 1/5 步的损失是否基本停止下降。还在明显下降就延长步数或加大学习率;
  4. 按种子重复 3 次,确认结论不是噪声,再固定配置。

这四步走下来,大概半天时间能定下一个可靠的配置,比盲目试十几个学习率的效率高得多。

6.3 三个红线信号

训练过程中有三个信号一旦出现,我会立刻停下来查,而不是继续等:

  • 损失连续 50 步中位数不降反升:不是过拟合就是学习率太大,先查学习率;
  • 某个 epoch 的验证精度比上一步掉了 5 个百分点以上:通常是学习率突变(调度器台阶)或者梯度爆炸;
  • 梯度范数连续多步超过正常量级 10 倍:哪怕损失还没炸,也已经很危险了,立刻检查学习率和裁剪阈值。

这三个信号背后是同一个逻辑:学习率问题在损失值暴露出异常之前,往往已经在梯度范数和精度波动上留下痕迹了。早发现一步,省下的是几小时的训练时间。

最后说个我自己的体会:学习率这个参数最反直觉的地方在于,它不是一个"越大越快"或者"越小越稳"的单向旋钮,而是一个和网络深度、归一化层、batch size、优化器、数据规模全部耦合的量。我见过太多人把调学习率当成运气活,其实它更像一个可以标准化流程的工程问题——先测范围、再加调度、最后按信号排查。把这套动作跑熟之后,你会发现大部分"玄学调参"都变成了有据可依的几步操作。我个人的习惯是,每个新项目开始前,哪怕再赶时间,也会先花二十分钟把学习率的范围测一遍,这二十分钟基本从没白花过。

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

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

立即咨询