资讯动态

深度学习优化算法详解:从梯度下降到Adam的调参实战

发布时间:2026/10/18 12:51:02 来源:尧图企业网站定制
动手学深度学习九拖了这么久终于写到这个被太多教程一笔带过的话题——深度学习优化算法。说实话我在前面几篇里也偷过懒讲到训练的时候就一句用Adam学习率设3e-4打发过去。直到自己在项目里被loss曲线折磨到怀疑人生之后才回过头来把所有主流优化器从头推导了一遍。这篇就是把那段补课经历整理出来的结果。这篇文章适合两类读者刚跑通第一个图像分类或文本分类任务、想搞清楚loss曲线为什么长这样的初学者以及已经有几个项目经验、想系统地理解优化器选型和调参逻辑的实践者。内容从最朴素的梯度下降出发沿着为什么需要小批量、为什么需要动量、为什么需要自适应学习率这条线索把每个算法的动机、公式、极简实现和实际训练中的坑都拆开讲。代码刻意保持精简可以直接复制去做对照实验。1. 优化器为什么往往比网络结构更决定能不能训起来1.1 网络结构决定上限优化器决定能否接近上限先说我观察到的现象训练停滞时新人第一反应是网络结构写错了其次是数据没处理好很少有人会盯住优化器不放。但实际排查下来结构正确、数据规范、却因为优化器配置不当导致训不动的案例占比高得惊人。为什么优化器这么关键因为参数更新本质上是一个三层嵌套的过程。第一层是用什么样的梯度这由损失函数和反向传播决定第二层是每一步走多远即学习率第三层才是用什么样的策略迈出这一步包括是否引入历史梯度、是否按参数维度自适应步长等等。我们常说的SGD、Momentum、RMSProp、Adam本质是在第三个层面做出不同决策后形成的算法族。可以这样理解网络结构定义了损失函数曲面的形状优化器负责在这片曲面上找到最低点。曲面陡峭还是平缓、是光滑还是布满局部极小值这些是结构决定的但你能不能高效地走到谷底取决于你选择哪条路、什么速度、什么节奏。一个表达能力很强的网络配上一个糟糕的优化器完全可能训练一百轮后损失还在原地打转。1.2 评判优化器好坏的三个标尺收敛速度、稳定性、泛化性我自己在评估一个优化器时只看三个指标。收敛速度达到相同训练损失所需要的迭代步数。这个最好量化画训练曲线就能对比。同样一个ResNet风格分类网络用SGD和用Adam前者可能需要25轮达到的损失后者可能15轮就到了差距非常直观。稳定性训练过程中loss曲线的平滑程度。有的优化器在梯度较陡的区域会剧烈震荡甚至一条直线直接冲到NaN有的优化器则平缓得多只是偶尔出现小波动。稳定性差不仅影响模型收敛还会让你很难判断当前这个学习率到底合不合适。泛化能力这一点最容易被初学者忽略。训练损失降得快的优化器验证集表现不一定好。我在多个图像分类任务里观察到SGD配合动量训练的最终模型验证精度往往高于Adam训练的模型哪怕两者的训练损失几乎一样。这和自适应学习率带来的参数更新过于平滑有关后面我会专门展开。2. 梯度下降的三种粒度从全量到小批量的必然演化2.1 为什么全量梯度下降只在教科书里好看最早接触深度学习的读者一定都见过标准的梯度下降更新公式对整个训练集计算平均梯度然后沿负梯度方向更新参数。这个流程数学上最干净收敛性也有大量理论保证但实操里几乎没人真这么干。原因有两个。第一个是计算资源的约束假设训练集有100万张图片每轮迭代都要把所有图片完整过一遍才算得出一次梯度一次参数更新慢到无法接受。第二个是优化层面的问题全量梯度是确定性的它只会沿着当前损失曲面上最陡的方向走一旦陷入局部极小值或者鞍点附近很容易停下因为没有噪声来刺激它跳出。所以后来大家转向了随机梯度下降SGD即每次只用一个样本或一小批样本计算梯度。单个样本的梯度当然是有噪声的但这个噪声恰恰成了逃离局部极值的推动力。我后来看很多分析文章里都提到一个有趣的类比全量梯度像是站在山顶拿望远镜找准方向再走小批量SGD则像是闭着眼往前小跑虽然路线歪歪扭扭但靠这种歪扭反而更容易翻过小土坡。2.2 批量大小、学习率与噪声的三角关系小批量的批量大小batch size是一个直接决定训练行为的超参数。批量越小梯度噪声越大收敛路径越曲折批量越大梯度估计越接近全量梯度方向更稳但每个step的计算时间也随之上升。批量大小和学习率之间存在一个经验上的搭配关系批量翻倍时学习率通常也可以相应放大因为更大的批量提供了更稳定的梯度估计允许迈更大的步子。实际训练里我习惯在64到512之间选择批量大小。小于32时梯度噪声大到曲线几乎没法看大于512时单卡的显存压力又会变大而且对于很多任务来说性能并不会因为批量继续增大而提升反而会在泛化性上吃亏。2.3 从零实现最朴素的SGD更新函数说了这么多先给一个最基础的实现。理解这一段之后后面所有优化器都只是在这个基础上加历史信息或自适应机制。import numpy as np def sgd_update(params, grads, lr0.01): 最朴素的SGD更新。 params: 模型参数列表每个元素是numpy数组 grads: 和params对应的梯度列表 lr: 学习率 updated [] for param, grad in zip(params, grads): updated.append(param - lr * grad) return updated这里的grad通常是当前小批量上计算出的平均梯度。你可能觉得这个函数简单到不值得写但正是从这个函数出发把用当前梯度直接更新改成用历史梯度平滑后的方向更新就得到了Momentum把所有参数共用一个学习率改成每个参数按历史梯度幅值单独缩放就走到了AdaGrad和RMSProp。后面所有算法我都会用类似的形式展开。如果你用的是PyTorch这类框架直接调用封装好的优化器接口就行。但我的建议是至少用numpy手写一遍更新过程因为框架里的state_dict会把这些逻辑藏得很深手写能帮你真正建立每一步参数是怎么变的感觉。3. 动量机制让参数向量像球一样滚过峡谷3.1 指数加权移动平均的物理直觉假设损失函数的等高线分布近似一个狭长的峡谷形状x方向平缓、y方向陡峭。朴素的SGD在这类地形上会表现出一种典型的锯齿状运动y方向更新幅度过大导致参数在峡谷两侧反复横跳x方向由于梯度太小又推进得极其缓慢。这个场景下收敛速度会被拖慢数倍。动量的想法非常直觉不要每次都只看当前梯度而是把历史梯度的趋势也累积起来。就像一个小球从山坡上滚下速度是逐回合叠加的方向主要受历史趋势支配局部陡坡只能短暂改变速度方向却无法彻底逆转它。这个机制在数学上叫指数加权移动平均。维护一个速度变量v每次更新时把它往当前梯度方向推一点同时保留上一次的大部分def momentum_update(params, grads, lr0.01, beta0.9): updated [] v [np.zeros_like(p) for p in params] for param, grad, vel in zip(params, grads, v): vel beta * vel (1 - beta) * grad updated.append(param - lr * vel) return updated注意beta的取值。beta0.9意味着当前速度大约保留了过去10步左右的梯度平均信息beta0.99则会把历史拉长到近100步轨迹更平滑但对新方向变化的响应也变慢。3.2 经典动量与Nesterov动量的差别体验经典动量Momentum是先算当前位置的梯度再叠加历史速度。Nesterov加速梯度NAG则多了一步前瞻先按照历史速度方向往前走一小步然后在那个未来位置上计算梯度用这个梯度去更新。两者在数学上只差一个微小项但Nesterov在凸优化理论里有更紧的收敛界在实际训练中某些任务上它的收敛会更平稳尤其是学习率稍微偏大的时候Nesterov版本的震荡幅度通常更小。PyTorch里的SGD接口把Nesterov开关暴露出来了开启方式非常简单但要注意nesterovTrue只能在开启动量时使用。习惯上我会在图像分类任务里把它打开代价可以忽略但有时确实能带来一两个点的验证集提升。3.3 动量系数的调参经验动量beta最常用的默认值是0.9我在绝大多数任务里都不会动它。什么情况下需要调整当你的任务梯度本身就非常稀疏、或者训练集很小的时候beta0.99能让更新更平滑防止参数被个别异常样本带跑偏。反过来如果你的loss曲线出现过冲或者快速反弹的尖峰除了调低学习率也可以先把动量系数降到0.8试试。我在一次文本分类实验里发现beta0.9时第20轮左右loss出现了一次明显反弹降到0.85之后就消失了而最终精度几乎不变。这类玄学问题与其反复改学习率不如先怀疑动量系数。4. 自适应学习率算法从AdaGrad到Adam的逻辑链4.1 AdaGrad为何会学习率提前死亡动量优化器对所有参数仍然共用一个学习率。但实际训练中不同参数的梯度尺度差异可能很大词向量矩阵的某些行几乎总是梯度很小而接近输出层的参数梯度往往很大。共享学习率会导致小梯度参数走得慢、大梯度参数震荡。AdaGrad的解决方案是为每个参数累积历史梯度的平方并用它的倒数来调节这个参数的学习率。梯度大的参数累积值大学习率被压小梯度小的参数累积值小学习率保持较大。def adagrad_update(params, grads, lr0.01, eps1e-8): updated [] r [np.zeros_like(p) for p in params] for param, grad, acc in zip(params, grads, r): acc grad * grad step lr * grad / (np.sqrt(acc) eps) updated.append(param - step) return updated问题出在acc grad * grad这行它只增不减随着训练推进累积平方和越来越大所有参数的学习率都会单调衰减到零。AdaGrad在训练后期往往就走不动了参数几乎停止更新这被称为学习率提前死亡。在深度网络上这个问题几乎是致命的所以AdaGrad如今只在小规模场景或特征极其稀疏的任务里偶有使用。4.2 RMSProp让二阶矩信息保持活性RMSProp的改进只有一行把acc的累加改成了指数加权移动平均。含义是只保留最近窗口内的梯度平方信息而不是无限累积。这样学习率不会单调衰减到零而是保持自适应地调节。def rmsprop_update(params, grads, lr0.01, rho0.9, eps1e-8): updated [] r [np.zeros_like(p) for p in params] for param, grad, acc in zip(params, grads, r): acc rho * acc (1 - rho) * grad * grad step lr * grad / (np.sqrt(acc) eps) updated.append(param - step) return updatedrho控制这个窗口的大小默认0.9意味着大约只看最近10步的梯度平方。RMSProp在实践中已经很好用了尤其在循环神经网络和强化学习这类损失曲面很不光滑的任务里稳定性优于SGD。4.3 Adam一阶动量与二阶动量的组合拳Adam自适应矩估计可以理解为Momentum和RMSProp的直接融合用一阶矩梯度的指数加权移动平均代替原始梯度用二阶矩梯度平方的指数加权移动平均对学习率做每参数缩放。两个默认系数一阶beta10.9二阶beta20.999。def adam_update(params, grads, lr0.001, beta10.9, beta20.999, eps1e-8, t1): updated [] m [np.zeros_like(p) for p in params] # 一阶矩估计 v [np.zeros_like(p) for p in params] # 二阶矩估计 for param, grad, m_est, v_est in zip(params, grads, m, v): m_est beta1 * m_est (1 - beta1) * grad v_est beta2 * v_est (1 - beta2) * grad * grad # 偏差校正抵消初始化时矩估计偏向0的问题 m_hat m_est / (1 - np.power(beta1, t)) v_hat v_est / (1 - np.power(beta2, t)) step lr * m_hat / (np.sqrt(v_hat) eps) updated.append(param - step) return updated上面代码里我特地把t作为参数传进来了t是当前训练步数从1开始计数。偏差校正不是Adam的可选优化而是一个必要步骤如果不做校正训练初期的m_est和v_est因为从零开始累积数值系统性偏小导致前几百步的更新步长远小于预期训练启动会变得很慢。4.4 Adam的参数细节epsilon与权重衰减Adam里最容易被忽略的是eps。在框架默认设置里eps1e-8但这个值在有些场景下偏小会让二阶矩的平方根接近零时数值上不太稳定。如果你是混合精度训练eps建议设到1e-4左右否则容易出现梯度过早溢出。我的习惯是先试1e-8如果loss曲线在早期就出现剧烈抖动再调到1e-4看看。另一个经典坑是权重衰减。直接在Adam上使用L2正则化把weight * weight_decay加到损失里其实和Adam并不完全兼容因为自适应学习率会把梯度缩放掉导致同样的weight_decay在SGD和Adam上对权重大小的约束力度不一样。这个问题在最近几年被广泛讨论社区给出的方案是AdamW在参数更新时直接减去weight_decay * weight而不让这个衰减项经过一阶、二阶矩的缩放。在PyTorch里这个改动已经有原生接口直接选用对应的实现即可。5. 实际项目里优化器选型与调参的实战经验5.1 不同任务类型的默认选择我把自己在项目里常用的优化器选择整理成一个速查表供参考场景常用优化器备注图像分类CNNSGD 动量或AdamWSGD收敛慢但泛化好AdamW训练快验证精度看任务NLP / Transformer类AdamW配合warmup和余弦衰减效果稳定稀疏特征场景推荐系统AdaGrad / Adam稀疏梯度下自适应学习率优势明显GAN训练Adam两侧生成器/判别器使用不同学习率常用lr1e-4级别强化学习Adam / RMSProp非平稳目标下RMSProp的历史表现很稳健这个表不是金科玉律。比如图像分类任务里近两年入行的朋友可能压根没用过SGD直接用AdamW也完全可以得到不错的分数。但如果你想让模型在验证集上再往上走一点尝试切换到SGD动量配合合适的学习率调度往往会有意外收获。5.2 从训练曲线反推优化器生病的原因有几种典型的训练曲线我已经在大大小小一堆项目里见惯了这里直接给判断思路。loss前几步就变成NaN大概率是学习率过大或梯度爆炸先调低学习率如果用的是Adam也可以先排查eps是否过小。loss一直不降但数值也不变先别动优化器检查数据的归一化和初始化确认没问题后把学习率调大5到10倍试试看loss是否出现变化。loss曲线剧烈锯齿状要么是批量太小、噪声太大要么是动量不足试着增大batch或者把动量从0.9提到0.99。训练loss下降但验证loss很快反弹这是过拟合信号不是优化器的问题优先考虑增加正则化或数据增强不要在优化器上折腾。训练前期正常、几百步后突然上升如果是Adam很可能是梯度平方累积太小、某一步更新过大试着调大eps或降低学习率。这些判断方向都是从结果倒推原因的启发式方法不保证每次都一次命中但至少能帮你把排查范围缩小。5.3 一些文档里不会写的实践细节最后分享几个我自己的习惯和踩坑记录都是实战里反复验证过的东西。第一先快速跑通再精细调优。新任务我从来不用最优配置起步而是先用Adam默认参数、lr3e-4跑一个短实验确认数据和模型链路是通的再去换SGD动量和调学习率。直接一步到位用复杂的调度策略一旦出问题你很难判断是优化器不对还是调度策略不对。第二warmup和cosine衰减要作为标配来考虑。尤其在Transformer类模型里训练初期直接上大学习率很容易让loss爆掉先线性warmup几百步、再余弦退火到接近零几乎成了这类任务的保底手段。对SGD来说cosine退火对最终精度的提升往往比换优化器还明显。第三优化器版本和配置要进实验记录。我吃过一次亏某次实验忘了记录weight_decay的具体值后来对比结果时发现两次实验唯一区别就是weight_decay从1e-4变成了5e-4直接把结论带偏了。现在我的实验模板里会专门写一行优化器全名、lr、beta、eps、weight_decay、是否开启Nesterov一个都不能少。第四必要时组合使用不同优化器。比如同一个模型里embedding层用SGD梯度稀疏SGD反而稳定而全连接层用Adam。框架里通过给不同参数组设置不同优化器配置可以轻松实现。这个方法不常用但在某些多模态任务里能同时兼顾稳定性与收敛速度值得知道有这样一个选项。我自己的体会是优化器算法看似只是参数更新公式的替换但它直接决定了你训练过程的体验——是丝滑收敛还是反复折腾。如果你现在正被loss曲线折磨不妨把手头的优化器理解和调参水平补一补这可能是性价比最高的一次投入。下一篇文章我会基于这里的优化器知识展开聊聊学习率调度策略的配合用法那种组合起来的提升空间比单换优化器还要大。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑