有人拿着训练日志来问我模型跑到第 7 个 epoch损失值从 0.42 直接跳到 nan是不是数据清洗没做好我反问了一句学习率设了多少答 0.1优化器是 Adam。这个问题的答案基本就定了——不是数据是学习率Learning Rate把参数一步迈出了损失曲面的有效区域。深度学习里能把损失值不降精度上不去训练直接崩掉这三件事同时解释清楚的超参数学习率排第一没有第二。我打算把学习率对精度和损失值的影响讲透它为什么会让损失震荡、为什么会让你误以为模型收敛了、为什么损失一路下降而精度原地踏步、以及在实际工程里怎么用十分钟找到一个可用的数量级。内容偏实战会给出可以直接跑的复现代码、一维二次函数上的发散边界计算、从曲线形态反推病因的诊断表还有我自己踩过的几个不写进文档的坑。适合刚入门深度学习、正在第一次调参的人也适合已经能跑通训练但不太清楚曲线为什么长这样的人。1. 梯度那一行乘法学习率究竟改变的是损失还是精度1.1 从 w ← w − lr·g 看步长的物理意义所有基于梯度的优化核心都是同一行更新式w w - lr * gradgrad告诉你往哪个方向走能让损失变小lr告诉你这一步走多远。前者由反向传播算出后者由你手填。很多人调参时把注意力全放在网络结构、数据增强、损失函数上却忘了梯度只负责方向不负责距离。方向错了模型永远学不会距离错了模型会在对的方向上走过头。把损失曲面想象成一片山谷。梯度是脚下最陡的下坡方向学习率是你的步幅。步幅太小你在谷底附近来回蹭走一天也到不了最低点步幅太大你从山谷这头一步跨到那头更高的坡上下一次再跨回来来回震荡甚至越走越高。这个类比的准确之处在于学习率的合理范围和曲面的陡峭程度绑定而不是一个放之四海皆准的常数。数学上这件事更干净。对二次损失 L(w) (w − w*)² 求导得 g 2(w − w*)代入更新式w_{t1} - w* (1 - 2·lr) · (w_t - w*)误差每步乘以|1 − 2·lr|。这个系数的绝对值小于 1 才会收敛等于 1 是原地打转永不收敛大于 1 就指数发散。也就是说0 lr 0.5单调收敛且lr 0.5时一步到位系数为 00.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, steps12, w00.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(flr{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ᵀAwA 的两个特征值分别是 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_size128, seed0): torch.manual_seed(seed) tf transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.2860,), (0.3530,))]) tr datasets.FashionMNIST(./data, trainTrue, downloadTrue, transformtf) va datasets.FashionMNIST(./data, trainFalse, downloadTrue, transformtf) g torch.Generator().manual_seed(seed) return (DataLoader(tr, batch_sizebatch_size, shuffleTrue, generatorg, num_workers2), DataLoader(va, batch_size512, shuffleFalse, num_workers2)) class SmallCNN(nn.Module): def __init__(self, n_cls10): super().__init__() self.c1 nn.Conv2d(1, 32, 3, padding1) self.c2 nn.Conv2d(32, 64, 3, padding1) 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, epochs3, tag): tr_loader, va_loader build_loaders() torch.manual_seed(0) model SmallCNN() criterion nn.CrossEntropyLoss() opt torch.optim.Adam(model.parameters(), lrlr) 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} fva_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, epochs3, 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(flog_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 与数据顺序。shuffleTrue配generator固定住顺序否则每个学习率跑的数据流不一样损失曲线没有可比性。同时 batch size 必须统一因为 batch size 变大本身会降低梯度噪声等效于降低学习率——两者是耦合的混着改就分不清是谁的功劳。第三个是评估集的使用频率。上面代码每个 epoch 都全量评估一次训练集和验证集这在 6 万样本下还行但如果你的数据是百万级评估开销会盖过训练。更省的做法是只评估验证集训练损失用训练过程中的滑动平均代替。还有一个更严重的坑拿验证集调学习率再用同一个验证集报告最终结果这是典型的乐观偏差。我会额外切一份只在最后用一次的 test 集或者至少在报告里写清楚验证集参与过多少轮选择。3. 从曲线形态反推损失与精度分别暴露了什么问题3.1 损失震荡不降与NaN先怀疑学习率而不是数据损失值出现周期性的上下摆动、或者干脆跳到 nan排查顺序里学习率永远排在第一。原因很直接数据问题标签错误、脏样本通常表现为损失偏高但有界的噪声不会让数值溢出而学习率过大导致参数跑到曲率极高的区域时梯度随之变大更新步长进一步变大是一个正反馈几步之内就能冲上 1e30 然后溢出成 inf再经过 log 或除法就变 nan。我自己遇到 nan 时的固定动作是看 nan 出现前 20 步的损失值如果是逐步放大后溢出基本可以定性为学习率过大如果是某一步毫无预兆地突然 nan要查数据里有没有 inf 或者除零。临时把学习率降 10 倍重跑如果 nan 消失结论闭环。如果降了还是 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, start1e-6, end1e-1, steps300): opt torch.optim.SGD(model.parameters(), lrstart, momentum0.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(), lr3e-4, weight_decay0.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-4Transformer 常用解耦权重衰减配合 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_decay0.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 一份可以直接照着走的顺序我现在拿到一个新任务、新模型调学习率的顺序是固定的基本四步先跑 lr range test用 300 步找到损失曲线的最低点得到候选值lr_cand取lr_cand / 5作为初始学习率用初始学习率跑一个短周期比如总步数的 10%只看两件事损失是不是平稳下降、有没有 nan。有 nan 就按 10 倍递减继续试加 warmup 和余弦退火跑完整训练观察最后 1/5 步的损失是否基本停止下降。还在明显下降就延长步数或加大学习率按种子重复 3 次确认结论不是噪声再固定配置。这四步走下来大概半天时间能定下一个可靠的配置比盲目试十几个学习率的效率高得多。6.3 三个红线信号训练过程中有三个信号一旦出现我会立刻停下来查而不是继续等损失连续 50 步中位数不降反升不是过拟合就是学习率太大先查学习率某个 epoch 的验证精度比上一步掉了 5 个百分点以上通常是学习率突变调度器台阶或者梯度爆炸梯度范数连续多步超过正常量级 10 倍哪怕损失还没炸也已经很危险了立刻检查学习率和裁剪阈值。这三个信号背后是同一个逻辑学习率问题在损失值暴露出异常之前往往已经在梯度范数和精度波动上留下痕迹了。早发现一步省下的是几小时的训练时间。最后说个我自己的体会学习率这个参数最反直觉的地方在于它不是一个越大越快或者越小越稳的单向旋钮而是一个和网络深度、归一化层、batch size、优化器、数据规模全部耦合的量。我见过太多人把调学习率当成运气活其实它更像一个可以标准化流程的工程问题——先测范围、再加调度、最后按信号排查。把这套动作跑熟之后你会发现大部分玄学调参都变成了有据可依的几步操作。我个人的习惯是每个新项目开始前哪怕再赶时间也会先花二十分钟把学习率的范围测一遍这二十分钟基本从没白花过。