ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

深度学习训练不收敛?学习率过大过小的症状、原理与系统调参方法

深度学习训练不收敛?学习率过大过小的症状、原理与系统调参方法 昨天调一个分割模型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 → 学习率过大或缺少warmupLoss缓慢下降、幅度极小 → 学习率偏小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学习率先跑结果差但如果你用相同的随机种子重跑结果又不一样了根本没法判断。我推荐的做法是一次跑多个学习率的短实验。具体来说实验组1lr 1e-4 实验组2lr 3e-4 实验组3lr 1e-3 实验组4lr 3e-3 实验组5lr 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_lr1e-6, end_lr1e-1, num_iter200): 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(), lr0.1) scheduler torch.optim.lr_scheduler.MultiStepLR( optimizer, milestones[60, 90, 120], gamma0.1 )意思是在第60、90、120个epoch时把学习率乘以0.1。这种策略适应任务全体样本特征相对稳定的场景配合大batch训练很有效。缺点是里程碑的位置需要自己试靠经验。另一种现在很流行的是余弦退火CosineAnnealingLRoptimizer torch.optim.SGD(model.parameters(), lr0.1) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max100, eta_min1e-5 )这个策略会按照余弦曲线让学习率从初始值平滑降到最小值不需要手动指定里程碑。它比MultiStepLR更平滑而且有不少实验表明SGD配上余弦退火的效果优于SGD配固定里程碑衰减。我自己在图像分类和分割任务中的经验其实偏向余弦退火前期的平滑下降给模型更多探索空间后期的缓慢衰减则有利于收敛到更好的局部最优点。两种策略怎么选我的选择依据很简单训练epoch数固定并且没有明显阶段性需求时用Cosine数据集很大、训练周期长、而且你已经很熟悉这个任务通常要跑到多少epoch才收敛时用MultiStep更可控。4.3 进阶warmup和OneCycle到底解决了什么**Warmup学习率预热**的本质是训练刚开始的几百个step里用很小的学习率预热然后线性或指数地增长到目标学习率。它解决的核心问题是训练初期参数分布剧烈变化导致的梯度不稳定。以前我用BERT/DETR这类Transformer结构时没有warmup几乎必炸。原因在于Transformer的LayerNorm和残差连接在初期参数还比较乱时输出分布剧烈变化梯度很不稳定此时如果直接上大学习率等于在悬崖边跑步很容易一步踩空。加上warmup相当于先小步慢跑热身让参数进入一个相对稳定的区域后再加速。PyTorch里实现warmup最方便的方式是用LambdaLRfrom 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_lambdawarmup_lambda)这段代码会让学习率在头1000步中从零线性升到初始值之后保持不变。warmup是很多大规模训练任务必需的组件特别是你使用了较大初始学习率、较大batch size或者模型里有BatchNorm、LayerNorm、残差结构时。OneCycleLR是我个人非常喜欢的一个策略它在很多比赛中被广泛使用。它的思路是先让学习率从较小值升到较大值warmup阶段再用余弦退火降到极小值。同时它还会联动调整momentum学习率上升时momentum下降学习率下降时momentum上升。scheduler torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr0.01, total_stepstotal_train_steps, pct_start0.3, anneal_strategycos )pct_start0.3表示前30%的步数是上升段后70%是衰减段。OneCycle的好处是在同样的epoch数里既保证了前期的快速探索又有后期的精细收敛。我用它跑过好几个分类和分割任务通常比固定学习率MultiStep衰减能再涨零点几个点而且不容易出现Loss中途飞升的问题。下面用一个表格汇总常见调度策略的机制和适用场景策略学习率变化适用场景注意点固定学习率全程不变小模型、快速实验后期容易震荡收敛精度有限StepLR每隔N步降一次简单基线对比衰减频率和幅度需要调MultiStepLR在指定epoch骤降ResNet类CNN、大batch训练里程碑位置需要经验ExponentialLR每步乘固定系数在线学习、串流数据衰减过快会浪费前期进度CosineAnnealingLR余弦曲线平滑衰减分类/分割/检测普遍适用冷启动阶段容易不稳定建议配合warmupOneCycleLR先升后降预算固定的训练任务总步数必须提前算准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跑长周期。整个流程跑下来因为学习率问题导致的神秘不收敛几乎再也没出现过了。这套方法你拿去用踩坑的概率应该也会小很多。
返回列表