昨天调一个分割模型,Loss在第一个epoch从1.2直接跳到NaN,查了一圈数据、代码、显存占用,最后发现就是学习率设大了。说实话,这种问题在深度学习里太常见了,但我发现不少新手朋友对学习率的理解还停留在"学习率大好、学习率小好"这种二选一的阶段,遇到训练不收敛时,要么怀疑网络结构,要么怀疑数据,就是没往学习率上想。
这篇笔记我打算把学习率这个问题彻底说透——太大是什么表现、太小是什么表现、底层逻辑是什么、以及实践中应该怎么系统解决。这篇的内容主要来自我自己在图像分类、检测、分割任务里的调参经验,也参考了fast.ai、CS231n里关于学习率的一些做法,适合正在被"训练不收敛"折磨的深度学习入门选手,也适合想系统梳理学习率调参方法的同学。
1. 学习率过大过小的典型"症状"与快速判别
1.1 学习率过大:震荡、飞升、NaN,三个层次的翻车现场
学习率过大时,训练过程的表现其实是分层次的,不是一上来就崩。我把它分成三个层次,方便你对号入座。
第一层是Loss剧烈震荡但不下降。这种是最常见的,Loss曲线像心电图一样上下乱跳,整体不往下走。原因是学习率太大,每次参数更新的步长跨过了Loss曲面上的低谷,在谷底两侧来回横跳,始终落不到最低点。这种状态在训练日志里很容易识别——每个epoch的Loss忽高忽低,准确率也跟着上下波动,但整体没有明显提升趋势。
第二层是Loss先降后飞升。这种情况比较迷惑人,开始训练的几十个step里Loss正常下降,看起来一切正常,突然某个step之后Loss开始暴涨,直接变成原来的几倍甚至几十倍。这时候往往伴随着梯度爆炸——参数被更新到一个梯度特别大的区域,下一步更新直接"起飞"。我在训练目标检测模型时遇到过很多次这种情况,尤其是用了比较大的初始学习率加上没有warmup的时候。
第三层是直接NaN。Loss变成NaN是最彻底的翻车,通常是因为网络权重在更新过程中发散到了极大值,导致前向传播中某些层的输出溢出,或者反向传播时梯度出现了Inf/NaN。出现NaN后,训练基本等于白跑,因为权重已经坏了,即使后续学习率降下来也无法恢复。
这里有个容易误判的点:很多人以为NaN一定是数据里有脏值或者代码有bug。但根据我的经验,当Loss在训练初期(前几百个step内)出现NaN时,优先怀疑学习率过大,其次是检查模型内部的除零和log操作,最后才是数据问题。
1.2 学习率过小:不是不收敛,是"慢"得让人崩溃
学习率过小的表现要温和得多,但也更隐蔽——它不会报错,不会产生NaN,甚至不会让Loss完全不变,而是让训练过程缓慢得让人怀疑人生。
具体症状是这样的:训练日志里Loss确实在下降,但每过一个epoch只下降0.001甚至更少;跑了几十个epoch,验证集指标几乎纹丝不动;训练了半天的模型,效果还不如随机初始化后随便跑几个step。
这种情况新手很容易误判成"模型容量不够"或者"数据特征提取不出来",于是去改网络结构、加数据增强,结果折腾半天还是没动静。实际上你把训练曲线拉长看,它是能降下去的,只是需要成百上千个epoch,正常训练周期内根本看不到效果。
还有一种被忽视的情况:学习率过小导致模型卡在局部最优点或平坦区域。Loss曲面不是光滑的碗,而是充满坑坑洼洼、平台和高低起伏的地形。学习率太小时,模型的更新步长不足以翻越当前的"小土坡",于是就被困在一个局部的低洼处,虽然Loss也在缓慢下降,但永远到不了全局最优附近。这种情况下你去看训练曲线,会发现它后期几乎是一条平线,但离我们期望的精度还差很远。
1.3 一个不严谨但很实用的判别口诀
把我这些年看过的无数条Loss曲线总结一下,就是一句话:"大则震荡飞升,小则蠕动不停"。
- Loss震荡不降、随机乱跳 → 学习率偏大
- Loss前期快速下降后直接NaN → 学习率过大,或缺少warmup
- Loss缓慢下降、幅度极小 → 学习率偏小
- Loss前期正常下降、后期完全不动但精度不够 → 可能是学习率没有衰减,卡在局部最优附近来回震荡
这个口诀虽然粗糙,但它能帮你把排查方向缩小到学习率这一个维度上,后续再用下面要讲的系统方法去做精细诊断。
2. 从梯度下降的几何直觉看学习率为什么能卡死训练
2.1 一次参数更新到底发生了什么
要真正理解学习率的作用,还得回到梯度下降本身。深度学习训练的本质,是在一个高维空间里寻找Loss函数的最小值。每次迭代我们做的事情可以写成这样:
w_new = w_old - η * ∇L(w_old)其中∇L(w_old)是Loss对当前参数的梯度,它指示了当前位置"最陡峭的上升方向",我们取负号就是往下降方向走。学习率η就是这一步的步长。
想象一下你在山里蒙着眼,手里拿着一个高度计,目标是找到山谷最低点。梯度告诉你的只是"哪个方向是下坡",但没告诉你应该走多远。学习率就是你的步长——每步迈多大。步子迈得太大,你可能一步就跨过了谷底,跑到对面山坡上去了;步子迈得太小,你可能走了半天还在半山腰。
2.2 步子太大与步子太小的本质
学习率过大时,每次更新不仅可能跨过当前谷底,还可能跳到Loss值更高的地方。如果Loss曲面的形状恰好比较陡峭,那跳过去之后的位置梯度会更大,下一步更新幅度就更大,形成一个"正反馈"——参数越来越发散,最终走向NaN。
这里有个数学上的解释:梯度下降本质上是在用一阶泰勒展开近似Loss函数,这个近似只在当前点附近的小邻域内有效。学习率决定了我们敢在这个近似成立的范围内走多远。步子太大,就意味着我们相信一个"只在很近处才靠谱"的方向可以走很远,这当然会出问题。
学习率过小时,更新步长非常小,理论上只要迭代足够多次,它总能到达谷底。但问题在于:
- 训练时间有限,我们不可能跑无限个epoch
- 高维Loss曲面中有大量"鞍点"和"平坦区域",这些地方梯度接近于零。学习率太小的话,参数更新速度极慢,模型会卡在这些区域里很长时间出不来
- 在平坦区域,loss曲面高度差异本身就很小,需要大步长才能感受到明显变化,小步长等于原地踏步
2.3 不同Loss地形下学习率的"安全区间"在变
上面这些分析引出一个关键认知:学习率并不是一个"设对了就全程适用"的超参数。它对Loss局部地形的适应性要求很高。
训练初期,网络是随机初始化的,参数离最优解很远,Loss曲面往往比较陡峭——这个时候需要相对大的学习率来快速接近最优区域。训练后期,参数已经离最优解比较近了,Loss曲面也相对平缓,如果还用大的学习率,就会在最优点附近反复震荡无法收敛。这也是为什么"学习率衰减"和"warmup"这些策略存在的根本原因——它们本质上是在匹配训练不同阶段对步长的不同需求。
顺带多说一句,学习率和batch size之间也有耦合关系。batch size越大,梯度估计越稳定,可以使用更大的学习率;反过来,batch size小,梯度噪声大,学习率太大容易直接被噪声带跑偏。很多分布式训练里使用的"线性缩放法则"就是在这个逻辑上建立的,我建议你在调学习率时也把batch size这个变量固定住,否则很难判断变化到底是由哪个因素引起的。
3. 别靠猜:判断当前学习率是否合适的实操方法
3.1 多学习率对照实验怎么做才有效
很多人的做法是设一个学习率,跑几十个epoch,看效果不好再改,再跑几十个epoch。这种做法效率极低,而且由于训练过程中的随机性,可能A学习率先跑结果差,但如果你用相同的随机种子重跑,结果又不一样了,根本没法判断。
我推荐的做法是一次跑多个学习率的短实验。具体来说:
实验组1:lr = 1e-4 实验组2:lr = 3e-4 实验组3:lr = 1e-3 实验组4:lr = 3e-3 实验组5:lr = 1e-2每组只训练5到10个epoch(或者固定迭代步数),用相同的初始化种子和数据顺序,只改变学习率这一个变量。训练完成后,把五条Loss曲线画在一张图上,观察哪一组Loss下降最快、最稳定,那组对应的学习率就是"不坏"的起点。
如果你看到的是:
- 学习率大的那组Loss直接飞上天 → 说明学习率上界已经越过了safe line
- 学习率小的那组Loss稳但慢 → 说明下界方向还有空间
- 最优的学习率往往在"降得最快且不震荡"和"降得稍慢但极稳"之间的某处
这里要提醒一句:短实验的目标是找到量级区间,不是精确定参。差三倍以内的学习率在训练初期几乎看不出明显区别,你没必要在开始阶段就纠结1e-3还是1.2e-3这种细枝末节。
3.2 除了看Loss曲线,还要看梯度范数和更新幅度
Loss曲线是一种宏观视角,但它有一个问题——Loss下降不明显时,你没法区分是"学习率太小"还是"模型本身能力不够"。这时候需要去看更微观的信息:每层参数的梯度范数,以及参数更新幅度和参数本身的比值。
计算方式很简单:
grad_norm = 梯度向量的L2范数 weight_norm = 参数向量的L2范数 update_ratio = (lr * grad_norm) / weight_norm实践经验是,update_ratio在1e-3到1e-2量级是一个比较健康的范围。如果这个比值长期小于1e-4,说明参数几乎没在动,学习率大概率太小了;如果大于1e-1甚至更大,说明每一步更新都会大幅改变权重分布,网络很难稳定下来,学习率需要调小或者配合梯度裁剪。
观察梯度范数本身也有用。CNN任务里,如果梯度范数在训练开始后迅速降到极小值,但Loss还很大,说明模型陷入了某个梯度很平坦的区域(很有可能被学习率过小拖住了);如果梯度范数随训练反而在增大,甚至指数级增长,这就是梯度爆炸的早期预警,赶紧把学习率降下来,否则下一步就是NaN。
3.3 在几百张样本上做"过拟合测试"定位上下界
这个技巧我是在调试一个检测模型时学到的,之后几乎每次换新任务都会先做一遍。
方法很简单:从训练集里随机抽200到500个样本,把数据增强关掉(或尽量减弱),用较大的模型容量去训练这批小样本,目标是让模型在这批数据上完全过拟合(训练Loss降到接近0,训练准确率接近100%)。
这个测试的价值在于帮你快速定位学习率的上下界:
- 如果模型连这一点点数据都过拟合不了,或者Loss降不下去,说明学习率过小或者模型本身有问题
- 如果训练几个step就直接NaN/发散,说明学习率过大
一个小样本过拟合测试跑不了几分钟,却能把"这个任务配这个学习率是否在合理范围"这个问题从概率上确认掉。我自己的使用习惯是:先用1e-3跑小样本测试,能过拟合,再用1e-4跑小样本测试,也能过拟合,那就把初始学习率的合理区间定在1e-4到1e-3之间,后续正式训练时就不会跑偏太远。
4. 解决学习率问题的完整工具箱
4.1 先扫描找到一个靠谱的初始值
上面说的多实验对照法能判断量级,但如果你想知道一个更精确的"初始学习率",我推荐用学习率扫描(LR Finder)。
这个方法的思路异常简单:让学习率在一个范围内逐步增大(比如从1e-6到1e-1,每步乘以1.2),每个学习率只训练几个batch,记录下对应的Loss值。最后画出一条"学习率-Loss"曲线,你会看到Loss先降后升,那么最低点附近偏左的位置就是比较理想的初始学习率。
用PyTorch实现一个极简版的LR Scanner也就几十行代码:
import torch def find_lr(model, loader, criterion, optimizer, start_lr=1e-6, end_lr=1e-1, num_iter=200): model.train() lrs, losses = [], [] # 直接用乘性调整,模拟学习率递增 lr = start_lr ratio = (end_lr / start_lr) ** (1 / num_iter) for i, (inputs, targets) in enumerate(loader): if i >= num_iter: break optimizer.param_groups[0]['lr'] = lr optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() lrs.append(lr) losses.append(loss.item()) lr *= ratio return lrs, losses扫描完之后,把lrs和losses画出来,选择Loss降到最低点前一个数量级左右的学习率作为初始值。为什么是"最低点前"而不是"最低点"?因为到达Loss最低点时的学习率往往是"还能承受的最大值",训练中各种扰动下容易翻车,留一点安全余量更稳。
这个方法来自fast.ai的Leslie Smith,理论依据是"学习率和Loss之间存在一个量级关系",虽然不严格,但实测非常靠谱。我现在每次新任务的第一步就是做这个扫描,不花多少时间,却能直接跳过"猜学习率"的随机过程。
4.2 训练过程动态调整:从Step到Cosine
固定学习率训练全程是一种可行的做法,但效果通常不是最优的。更常见的做法是配合学习率衰减策略,让模型在后期用小学习率精细收敛。
最简单的就是按里程碑衰减(MultiStepLR),这也是老派CNN训练(如ResNet)的标配做法:
optimizer = torch.optim.SGD(model.parameters(), lr=0.1) scheduler = torch.optim.lr_scheduler.MultiStepLR( optimizer, milestones=[60, 90, 120], gamma=0.1 )意思是在第60、90、120个epoch时,把学习率乘以0.1。这种策略适应任务全体样本特征相对稳定的场景,配合大batch训练很有效。缺点是里程碑的位置需要自己试,靠经验。
另一种现在很流行的是余弦退火(CosineAnnealingLR):
optimizer = torch.optim.SGD(model.parameters(), lr=0.1) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=100, eta_min=1e-5 )这个策略会按照余弦曲线让学习率从初始值平滑降到最小值,不需要手动指定里程碑。它比MultiStepLR更平滑,而且有不少实验表明,SGD配上余弦退火的效果优于SGD配固定里程碑衰减。我自己在图像分类和分割任务中的经验其实偏向余弦退火:前期的平滑下降给模型更多探索空间,后期的缓慢衰减则有利于收敛到更好的局部最优点。
两种策略怎么选?我的选择依据很简单:训练epoch数固定,并且没有明显"阶段性"需求时用Cosine;数据集很大、训练周期长、而且你已经很熟悉这个任务通常要跑到多少epoch才收敛时,用MultiStep更可控。
4.3 进阶:warmup和OneCycle到底解决了什么
**Warmup(学习率预热)**的本质是:训练刚开始的几百个step里,用很小的学习率"预热",然后线性或指数地增长到目标学习率。它解决的核心问题是训练初期参数分布剧烈变化导致的梯度不稳定。
以前我用BERT/DETR这类Transformer结构时,没有warmup几乎必炸。原因在于Transformer的LayerNorm和残差连接在初期参数还比较乱时,输出分布剧烈变化,梯度很不稳定,此时如果直接上大学习率,等于在悬崖边跑步,很容易一步踩空。加上warmup相当于先小步慢跑热身,让参数进入一个相对稳定的区域后再加速。
PyTorch里实现warmup最方便的方式是用LambdaLR:
from torch.optim.lr_scheduler import LambdaLR def warmup_lambda(step): warmup_steps = 1000 if step < warmup_steps: return step / warmup_steps return 1.0 scheduler = LambdaLR(optimizer, lr_lambda=warmup_lambda)这段代码会让学习率在头1000步中从零线性升到初始值,之后保持不变。warmup是很多大规模训练任务必需的组件,特别是你使用了较大初始学习率、较大batch size,或者模型里有BatchNorm、LayerNorm、残差结构时。
OneCycleLR是我个人非常喜欢的一个策略,它在很多比赛中被广泛使用。它的思路是:先让学习率从较小值升到较大值(warmup阶段),再用余弦退火降到极小值。同时它还会联动调整momentum,学习率上升时momentum下降,学习率下降时momentum上升。
scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=0.01, total_steps=total_train_steps, pct_start=0.3, anneal_strategy='cos' )pct_start=0.3表示前30%的步数是上升段,后70%是衰减段。OneCycle的好处是在同样的epoch数里,既保证了前期的快速探索,又有后期的精细收敛。我用它跑过好几个分类和分割任务,通常比固定学习率+MultiStep衰减能再涨零点几个点,而且不容易出现Loss中途飞升的问题。
下面用一个表格汇总常见调度策略的机制和适用场景:
| 策略 | 学习率变化 | 适用场景 | 注意点 |
|---|---|---|---|
| 固定学习率 | 全程不变 | 小模型、快速实验 | 后期容易震荡,收敛精度有限 |
| StepLR | 每隔N步降一次 | 简单基线对比 | 衰减频率和幅度需要调 |
| MultiStepLR | 在指定epoch骤降 | ResNet类CNN、大batch训练 | 里程碑位置需要经验 |
| ExponentialLR | 每步乘固定系数 | 在线学习、串流数据 | 衰减过快会浪费前期进度 |
| CosineAnnealingLR | 余弦曲线平滑衰减 | 分类/分割/检测普遍适用 | 冷启动阶段容易不稳定,建议配合warmup |
| OneCycleLR | 先升后降 | 预算固定的训练任务 | 总步数必须提前算准 |
| LambdaLR | 完全自定义 | warmup、混合策略 | 灵活但需要自己写逻辑 |
4.4 自适应优化器不是"万能解药"
说到学习率,必须提一句自适应优化器。很多人觉得用了Adam就可以忽略学习率了,这是目前最大的误区之一。
Adam这类自适应方法确实会为每个参数单独调整更新步长,相当于内置了一个"学习率缩放器",但它依然有一个全局的初始学习率参数(PyTorch里默认是1e-3)。而且Adam对初始学习率的选择比SGD宽容一些,让你觉得"默认值也还能跑",但如果你追求更好的收敛效果,初始学习率仍需针对任务调。
我的经验是:用Adam时,初始学习率1e-3是个相对安全的起点,1e-4通常更稳但慢一点;用SGD + Momentum时,初始学习率通常要比Adam大5到10倍(比如1e-2、1e-1),因为Adam的更新量会自动缩小。
另外要注意的是,自适应的"即插即用"不代表它在所有任务上都优于SGD。尤其是在图像分类任务里,SGD + Momentum + 余弦退火 + 精心调过的初始学习率,效果往往能压过默认参数的Adam。在迁移学习/微调预训练模型时,SGD配合小学习率(1e-4左右)也是经验上很稳的选择,Adam则更适合从零训练、训练不稳定或NLP类的Transformer结构。
最后补充一个实操工具:如果训练中遇到"Loss下降正常,但某一步突然冲高"的情况,除了调低学习率,还可以用梯度裁剪(clip_grad_norm_)来做兜底。它不能替代一个合理的学习率策略,但可以在前期不稳定阶段防止单步更新过大导致的发散,给你留出观察和调参的窗口。
根据我个人的习惯,现在拿到一个新任务,标准流程基本固定下来了:先做一次LR Finder确定初始学习率量级,然后用Adam或AdamW配合warmup + 余弦退火训练一轮拿一个baseline,之后如果想把精度往上顶,再切换到SGD + Momentum + OneCycle跑长周期。整个流程跑下来,因为学习率问题导致的"神秘不收敛"几乎再也没出现过了。这套方法你拿去用,踩坑的概率应该也会小很多。