动手学深度学习(九),拖了这么久终于写到这个被太多教程"一笔带过"的话题——深度学习优化算法。说实话我在前面几篇里也偷过懒,讲到训练的时候就一句"用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, lr=0.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, lr=0.01, beta=0.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的取值。beta=0.9意味着当前速度大约保留了过去10步左右的梯度平均信息;beta=0.99则会把历史拉长到近100步,轨迹更平滑,但对新方向变化的响应也变慢。
3.2 经典动量与Nesterov动量的差别体验
经典动量(Momentum)是先算当前位置的梯度,再叠加历史速度。Nesterov加速梯度(NAG)则多了一步"前瞻":先按照历史速度方向往前走一小步,然后在那个"未来位置"上计算梯度,用这个梯度去更新。
两者在数学上只差一个微小项,但Nesterov在凸优化理论里有更紧的收敛界;在实际训练中,某些任务上它的收敛会更平稳,尤其是学习率稍微偏大的时候,Nesterov版本的震荡幅度通常更小。
PyTorch里的SGD接口把Nesterov开关暴露出来了,开启方式非常简单;但要注意,nesterov=True只能在开启动量时使用。习惯上我会在图像分类任务里把它打开,代价可以忽略,但有时确实能带来一两个点的验证集提升。
3.3 动量系数的调参经验
动量beta最常用的默认值是0.9,我在绝大多数任务里都不会动它。什么情况下需要调整?当你的任务梯度本身就非常稀疏、或者训练集很小的时候,beta=0.99能让更新更平滑,防止参数被个别异常样本带跑偏。
反过来,如果你的loss曲线出现过冲或者快速反弹的尖峰,除了调低学习率,也可以先把动量系数降到0.8试试。我在一次文本分类实验里发现,beta=0.9时第20轮左右loss出现了一次明显反弹,降到0.85之后就消失了,而最终精度几乎不变。这类"玄学"问题,与其反复改学习率,不如先怀疑动量系数。
4. 自适应学习率算法:从AdaGrad到Adam的逻辑链
4.1 AdaGrad为何会"学习率提前死亡"
动量优化器对所有参数仍然共用一个学习率。但实际训练中,不同参数的梯度尺度差异可能很大:词向量矩阵的某些行几乎总是梯度很小,而接近输出层的参数梯度往往很大。共享学习率会导致"小梯度参数走得慢、大梯度参数震荡"。
AdaGrad的解决方案是为每个参数累积历史梯度的平方,并用它的倒数来调节这个参数的学习率。梯度大的参数,累积值大,学习率被压小;梯度小的参数,累积值小,学习率保持较大。
def adagrad_update(params, grads, lr=0.01, eps=1e-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, lr=0.01, rho=0.9, eps=1e-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的直接融合:用一阶矩(梯度的指数加权移动平均)代替原始梯度,用二阶矩(梯度平方的指数加权移动平均)对学习率做每参数缩放。两个默认系数,一阶beta1=0.9,二阶beta2=0.999。
def adam_update(params, grads, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8, t=1): 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。在框架默认设置里,eps=1e-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 不同任务类型的默认选择
我把自己在项目里常用的优化器选择整理成一个速查表,供参考:
| 场景 | 常用优化器 | 备注 |
|---|---|---|
| 图像分类CNN | SGD + 动量,或AdamW | SGD收敛慢但泛化好,AdamW训练快,验证精度看任务 |
| NLP / Transformer类 | AdamW | 配合warmup和余弦衰减,效果稳定 |
| 稀疏特征场景(推荐系统) | AdaGrad / Adam | 稀疏梯度下自适应学习率优势明显 |
| GAN训练 | Adam | 两侧生成器/判别器使用不同学习率,常用lr=1e-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默认参数、lr=3e-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曲线折磨,不妨把手头的优化器理解和调参水平补一补,这可能是性价比最高的一次投入。下一篇文章我会基于这里的优化器知识,展开聊聊学习率调度策略的配合用法,那种组合起来的提升空间比单换优化器还要大。