ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

反向传播与参数更新:从梯度计算到优化器的神经网络训练核心

反向传播与参数更新:从梯度计算到优化器的神经网络训练核心 说实话反向传播这四个字我第一次接触的时候觉得特别唬人感觉像是某种只有数学天才才能驾驭的黑魔法。后来自己动手搭了一个两层的全连接网络把梯度从损失函数一路推回每一个参数才恍然大悟它本质上就是微积分里那套链式法则只是被神经网络这个外壳包装得吓人了一点而已。这篇文章想聊的就是两件事反向传播怎么把梯度一分不差地传给每个参数参数更新又是怎么拿着梯度把损失一步步压下去。无论你是刚入门深度学习的学生还是已经开始调模型但被梯度搞晕的工程师这条主线都可以帮你把神经网络最核心的运行机制彻底理清。全文不会有太高深的数学只要你会一元函数求导就能跟得上。1. 反向传播和参数更新到底是一对什么组合1.1 用一个比喻理解“梯度”这件事先别急着看公式我们打一个比方。假设你在打篮球投篮命中率不高你想改进。影响命中率的因素很多手腕发力、起跳高度、出手角度、投篮位置。你怎么知道该改哪一项当然是一次只改一个变量反复试错但这样效率太低。更聪明的做法是找一台高速摄像机把每一次投篮拍下来分析出手角度变化对命中率的“影响程度”。这个影响程度在数学上就是“偏导数”。如果出手角度是影响命中率的最大因素你就重点调它如果起跳高度已经差不多了你就少动它。反向传播干的就是这件事给定一个损失函数它能计算出损失对网络中每一个参数的“影响程度”也就是梯度。参数更新干的是另一件事知道影响程度之后决定每个参数该往哪个方向调、调多少。一个是算账一个是行动两者缺一不可。1.2 为什么非得“反向”才算得动你可能想问既然是求导数为什么不能用最原始的办法对每个参数单独求导理论上可以。把某个参数 w 改变一个极小量 ε重新跑一次前向传播看看损失变了多少就能算出偏导数的近似值。但问题是一个一千万参数的模型做一次这样的操作就要跑一千万次前向传播训练一次要迭代几十万步这计算量放到任何机器上都撑不住。反向传播的聪明之处在于“复用中间结果”。前向传播时网络会生成一系列中间值线性层的输出、激活函数的输出。反向传播从损失函数出发沿着前向传播的路径倒着走一遍每经过一个中间结果就把已经算好的上游梯度“接力”给下游。这样整个网络只需要一次正向计算、一次反向计算就能拿到所有参数的梯度。整体复杂度大概是 O(前向计算量)而不是 O(参数个数 × 前向计算量)。这个思路本质上就是动态规划。你计算每一层的梯度时不需要重新算一遍前序的所有东西因为那些量的梯度已经被上一步算过了你只是不断做局部求导并相乘而已。1.3 参数更新拿着梯度该走多远梯度只是一个方向告诉你往哪个方向调损失会下降。真正决定下降快慢和稳定性的是“走多远”也就是学习率。举个例子你在山上想下山梯度告诉你北边是最陡的下坡方向。但这一脚迈多大迈大了可能直接冲到对面山坡迈小了要走几千步才到山脚。参数更新本质上就是回答这个问题沿着梯度方向迈出多大的一步。通常的更新公式极其简单θ_new θ_old − 学习率 × 梯度如果你只用单个样本的梯度来更新因为样本噪声太大更新方向会左右乱跳。所以实践中几乎都是用 mini-batch 的梯度来更新取一小批样本各自算出梯度取平均再更新。平均之后的方向更接近整体下降方向噪声小得多。反向传播负责算梯度更新规则负责决定怎么动。这两个环节一起构成了神经网络训练的“心脏”。理解了这一点后面的所有细节都是在围绕它们做优化。2. 拆开反向传播每一层梯度的流向2.1 线性层的三个梯度公式神经网络里最基础的层是线性层就是 y Wx b。输入 x 是一个向量W 是权重矩阵b 是偏置向量。前向传播很简单矩阵乘法加上加法。反向传播的关键问题是已知损失对 y 的梯度 dL/dy怎么得到损失对 W、x、b 的梯度答案来自链式法则。先看最简单的偏置 b。y Wx b所以 y 对 b 的导数是一个单位矩阵损失对 b 的梯度就等于损失对 y 的梯度本身。如果训练时用了 batch 大小为 N 的输入那么多个样本的梯度需要求和也就是把 dL/dy 按 batch 维度和特征维度做规约。再看权重 W。W 的维度是 (输出维度, 输入维度)它和 x 之间是线性关系。对 W 中某一个元素 W[i, j] 来说它只影响 y[i] 而不会影响 y 的其他维度所以 dL/dW[i, j] dL/dy[i] × x[j]。写成矩阵形式梯度就是 dL/dy 和 x 的外积一个“上游梯度向量”转置后乘以“输入向量”。最后看对输入 x 的梯度。这一步看起来是“反向”的因为前向时是 W 作用在 x 上反向时是 W 的转置作用在上游梯度上dL/dx W^T × dL/dy。为什么要转置因为前向传播时x 的每一个分量会同时影响 y 的所有输出所以反向传播时x 的梯度需要把 y 相同维度上的梯度按权重加权求和。转置运算做的正是这件事。这三个公式是所有全连接网络反向传播的基石。理解了它们CNN 里的全连接层、RNN 里的输出层本质都是一回事。2.2 激活函数的局部梯度激活函数是神经网络里“非线性”的来源。如果没有激活函数多层线性变换叠加起来还是线性变换网络再深也白搭。激活函数的前向计算很简单反向传播同样只多一步把上游梯度乘上激活函数在当前位置的导数。以最常用的 ReLU 为例它前向是 max(0, x)。当 x 大于 0 时导数是 1梯度直接透过当 x 小于等于 0 时导数是 0梯度被截断不再往后传。这个“截断”的副作用是如果一个神经元的输入长期小于 0它的梯度永远为 0参数永远得不到更新这就是大家常说的“神经元死亡”。Sigmoid 的导数则有一个非常漂亮的公式σ(x) σ(x) × (1 − σ(x))。意思是前向传播时算出的激活值 σ(x)在反向传播时可以直接用来计算局部梯度不需要重新算一次指数函数。但它的缺陷也很明显当激活值接近 0 或 1 时导数趋近于 0梯度会在多层传播中被乘成近似 0 的数导致梯度消失。这也是为什么现在深层网络里 ReLU 及其变体更流行的原因之一。写代码时特别容易犯一个错误把激活函数的导数和损失函数对输出层的梯度搞混。损失函数对输出 y 的梯度是 dL/dy它是一个由损失定义决定的量激活函数的局部梯度是 dy/dz它只和激活函数本身有关。反向传播时每经过一个运算节点就要把上游梯度乘以该节点的局部雅可比两个量虽然最后会相乘但各自的来源和含义完全不同。2.3 损失函数层的梯度损失函数是反向传播的起点所有梯度的“源头”。不同损失函数的梯度形式差异非常大这里只说两个最常见的。均方误差MSE通常配合回归问题使用表达式是 L 0.5 × (y_pred − y_true)²。如果前面没有接 sigmoid 这类激活函数那么它对输出的梯度就是 y_pred − y_true形式非常简洁误差越大梯度越强。严格说一下如果 y_pred 前面接的是 sigmoid那么 MSE 对 sigmoid 输入的梯度就不是这个简洁形式而是会多乘一个 sigmoid 导数这会造成“输出接近 0 或 1 时梯度很小”的问题训练会变得非常缓慢。交叉熵则更像分类问题的标准配置。对于二分类用 sigmoid 加二元交叉熵组合起来之后损失对输出的梯度也可以简化成 y_pred − y_true 这种简洁形式这才是“配对了”的损失和激活函数。如果是多分类softmax 加交叉熵也有同样的简化效果实现时把这两者合并成一个“softmaxloss”的节点来计算梯度不仅能提高计算效率还能避免中间项指数运算导致的数值溢出。所以我的习惯是除非你有特别的理由否则不要把损失函数和激活函数当作两个独立节点去手工推梯度而是把常用的组合当成一个整体来理解。很多深度学习框架内部也是这么做的。2.4 深层网络中的梯度流到多层网络里反向传播就是把这些局部规则串起来。假设网络是输入→线性层→激活→线性层→激活→损失梯度从损失出发逐层倒推dL/dy_pred → dL/dz_output → dL/dW2更新权重同时 dL/da1 → dL/dz_hidden → dL/dW1。每一层的梯度计算都依赖上一层已经传下来的 dL/dz。只要前一层算完了后一层的梯度就能接着往下传不需要回看更原始的输入。这里有个很关键的感受梯度在网络里是一路“连乘”过去的。每经过一个权重矩阵和激活函数梯度就乘以一次某个矩阵的范数或激活导数的标量。如果大部分因子都大于 1梯度会指数增长变成梯度爆炸如果大部分因子都小于 1梯度会指数衰减变成梯度消失。所以你看到的所有初始化方法、激活函数选型、BN批归一化、残差连接本质上都是在试图控制这条梯度连乘链的“量级”不要失控。3. 参数更新的几种主流策略3.1 朴素 SGD直接沿着梯度走最朴素的更新方式就是 SGD每个参数都按同一个学习率沿着梯度的反方向走一步。公式前面已经写过θ θ − η × grad。训练时把样本分成小 batch每个 batch 算一次梯度更新一次。用着用着你会发现它的几个问题。第一个是学习率的选择很讲究设小了训练半天不收敛设大了损失会在最优值附近来回震荡甚至直接发散。第二个是它对梯度的各个维度一视同仁但实际场景里参数的量级和使用频率差异很大有些参数应该走大步有些应该走小步统一用一个学习率不够灵活。不过朴素 SGD 有一个好处是简单、可解释而且在一些精心调整过的任务上加上动量和学习率衰减效果往往能逼近甚至超过更花哨的优化器。它仍然是理解后面所有优化器的基准。3.2 动量带着惯性更新动量Momentum的思想特别直觉既然单次梯度噪声大那我就记住历史梯度的“平均方向”让每次更新既参考当前梯度又参考过去一段时间的方向。更新公式一般写成v μ × v − η × gradθ θ v这里的 μ 通常取 0.9 左右意思是上一次的速度保留 90%再叠加当前这次梯度的贡献。你可以把它想象成小球从山上滚下来它不会每一步都被局部小坑带偏因为惯性会让它冲过那些狭窄的局部极小值最终更可能落到更低的坑里。实际使用中动量能明显加快收敛速度尤其是在损失 landscape 比较狭长的碗状区域。方向重复的成分会被动量积累放大方向来回震荡的成分会被逐步抵消。这也是为什么它比朴素 SGD 在深层网络里有用得多。3.3 自适应方法每个参数一个学习率模型里不同参数的梯度量级可能差好几个数量级。有的参数训练样本中出现频率高梯度大学习率应该小一点有的参数出现频率低梯度小学习率应该大一点。于是就有了自适应学习率方法。AdaGrad 会为每个参数累计历史梯度的平方和然后用当前梯度除以这个累计值的平方根作为实际更新步长。梯度大的参数被自动压低步长梯度小的参数被自动放大步长。但它的缺点是累计值只增不减训练时间长了学习率会变得无穷小直接停摆。RMSProp 针对这个问题做了改进用指数滑动平均代替了简单累加只记住近期的梯度平方信息。这样学习率不会一直衰减到 0能保持合理范围。本质上它是在估算“每个维度近期梯度的典型大小”再用这个典型大小去归一化当前梯度。3.4 Adam动量和自适应学习的合体Adam 在实践中几乎成了默认选项因为它同时融合了两代优化器的优点它既用一阶矩估算梯度方向的历史趋势相当于动量又用二阶矩估算梯度幅度的历史趋势相当于自适应学习率。为了防止训练前期历史信息太少导致的偏差它还会做一个偏差修正让前几步的估计更准确。常用的默认超参数是一阶矩衰减系数 β1 0.9二阶矩衰减系数 β2 0.999防除零的小项 ε 1e-8。这几个值在绝大多数问题上都能直接工作不需要你花太多时间去调。不过 Adam 也有被人吐槽的地方它对学习率依然敏感而且由于自适应机制的存在某些情况下权重更新步长会出现不自然的放大效应导致泛化效果不如精心调过的 SGD 动量。所以实践中大家会说“Adam 帮你快速找到一个好区域SGD 帮你精调”。3.5 优化器到底怎么选选优化器我认为分场景看更现实。表格列出常见对比优化器核心思路优点典型问题适用场景SGD统一学习率直走简单、可解释、泛化好学习率难调、收敛慢已经调好学习率的小中规模任务SGDMomentum累积历史梯度减少震荡、加速收敛仍需手动调学习率常规监督学习可精调RMSProp自适应缩放每参数对尺度差异不敏感二阶矩估计需要衰减参数非平稳目标、RNN 类任务Adam动量自适应默认好用、收敛快可能泛化不如精调 SGD探索期、大规模模型、图像分类等我的个人倾向是拿到一个新任务先无脑用 Adam 开跑把网络结构、数据预处理、损失函数这些更本质的问题调对。等到模型结构基本稳定、要刷最后几个点的精度时再切到 SGD 动量配合学习率衰减用网格搜索挑一个合适的学习率。这个流程我在几个项目里试过通常比一开始就死磕 SGD 学习率要快得多。4. 手算一遍从零做一次反向传播和参数更新4.1 网络设定与数值初始化看再多的公式都不如手算一遍来得透彻。我设计了一个极小但完整的网络输入 x 只有 1 个维度中间层只有 1 个神经元输出层也只有 1 个神经元。结构是这样的输入 x → 线性层w1, b1→ Sigmoid 激活 → 线性层w2, b2→ 输出 y_pred为了手算方便我不给最终输出接激活函数。损失函数用 MSE并且写成 L 0.5 × (y_pred − y_true)²这个 0.5 是为了求导后消掉平方指数让梯度表达式更干净。初始化数值如下输入 x 1目标 y_true 0.5w1 0.2b1 0.1w2 0.3b2 0.0学习率 η 0.54.2 前向传播计算按照网络结构一步步算下去。第一个线性层z1 w1 × x b1 0.2 × 1 0.1 0.3经过 Sigmoid 激活a1 sigmoid(0.3) 1 / (1 e^(-0.3))e^(-0.3) ≈ 0.7408所以 1 0.7408 1.7408a1 ≈ 0.5744。第二个线性层b2 为 0所以y_pred a1 × w2 b2 0.5744 × 0.3 0 ≈ 0.1723损失函数L 0.5 × (y_pred − y_true)² 0.5 × (0.1723 − 0.5)² 0.5 × (−0.3277)² 0.5 × 0.1074 ≈ 0.0537前向传播结束我们得到了预测值 0.1723它距离目标 0.5 还挺远损失大约 0.054。4.3 反向传播手工推导现在从损失开始一步一步往回推。我先写结论反向传播的所有中间量都依赖于“损失对当前节点输出的梯度”我按层切开计算。先从最末端开始。损失对 y_pred 的梯度dL/dy_pred y_pred − y_true 0.1723 − 0.5 −0.3277这个值是整个反向传播的源头它是负的意思是增加 y_pred 能降低损失。接着更新输出层的参数。因为 y_pred w2 × a1 b2所以dL/dw2 dL/dy_pred × a1 −0.3277 × 0.5744 ≈ −0.1882dL/db2 dL/dy_pred −0.3277然后继续往回传计算损失对 a1 的梯度。由于 y_pred 对 a1 的导数是 w2所以dL/da1 dL/dy_pred × w2 −0.3277 × 0.3 ≈ −0.0983接下来经过 Sigmoid 激活。Sigmoid 在 z1 0.3 处的局部导数是σ(0.3) a1 × (1 − a1) 0.5744 × (1 − 0.5744) 0.5744 × 0.4256 ≈ 0.2445所以损失对 z1 的梯度dL/dz1 dL/da1 × σ(0.3) −0.0983 × 0.2445 ≈ −0.0240最后回到第一个线性层。 z1 w1 × x b1于是dL/dw1 dL/dz1 × x −0.0240 × 1 −0.0240dL/db1 dL/dz1 −0.0240到此四个参数的梯度都拿到了dw1 ≈ −0.0240db1 ≈ −0.0240dw2 ≈ −0.1882db2 ≈ −0.3277。4.4 参数更新效果验证按前面设定的学习率 η 0.5用最朴素的 SGD 更新参数w1 0.2 − 0.5 × (−0.0240) 0.2 0.0120 0.2120b1 0.1 − 0.5 × (−0.0240) 0.1 0.0120 0.1120w2 0.3 − 0.5 × (−0.1882) 0.3 0.0941 0.3941b2 0.0 − 0.5 × (−0.3277) 0.0 0.1639 0.1639注意到所有更新都在“增大”参数因为损失对它们的梯度都是负的增大参数意味着减小损失。再用新参数做一次前向传播验证损失是否真下降了。第一个线性层z1 0.2120 × 1 0.1120 0.3240a1 sigmoid(0.3240) ≈ 0.5803第二个线性层y_pred 0.5803 × 0.3941 0.1639 ≈ 0.2287 0.1639 0.3926损失L 0.5 × (0.3926 − 0.5)² 0.5 × (−0.1074)² ≈ 0.0058看损失从 0.0537 降到了 0.0058一步就降了一个量级。这就是反向传播加参数更新配合起来的效果。你完全可以照着这个流程换成 ReLU、换成更大的网络用同样的链式法则层层往回推每步都是简单的乘法。我也把整个计算过程整理成一张表方便你对数节点前向值反向参数数值z10.3dL/dz1−0.0240a10.5744dL/da1−0.0983y_pred0.1723dL/dy_pred−0.3277w10.2dL/dw1−0.0240b10.1dL/db1−0.0240w20.3dL/dw2−0.1882b20.0dL/db2−0.32775. 调参实战中的常见问题与排查5.1 梯度消失和梯度爆炸梯度消失的特征很好辨认网络前面的层参数几乎不更新损失降了一段之后就不再变化或者训练曲线在某个平台期停滞特别久。梯度爆炸的特征更明显训练过程中可能出现极端大的参数值loss 直接变成 NaN权重更新一步后整个网络数值结构都被冲垮。原因刚才说过就是梯度连乘链的乘积要么趋近于 0要么趋近于无穷。排查的时候先从架构入手激活函数用 Sigmoid 的深层网络几乎都会遇到梯度消失换成 ReLU 或类 ReLU 激活是第一选择残差连接可以给梯度一条“高速公路”让信号不经过任何激活就直接向后传批归一化通过对每层输出做标准化把激活值拉回一个有效区间也能明显缓解梯度消失的问题。梯度爆炸的话最常用的急救方法是梯度裁剪算完梯度之后如果梯度的全局范数超过某个阈值就把它等比例缩小到阈值以内。我在实际项目中就把阈值设为 1.0 或 5.0效果非常直接训练稳定性立刻提升。注意裁剪不等于根治根本原因往往在初始化、数据尺度或者网络结构上。5.2 学习率导致的振荡与不收敛训练时如果发现 loss 曲线像心电图一样剧烈震荡第一反应永远是先调小学习率。学习率过大时每一次更新都会跨过最优点的“狭窄区域”在两边来回弹。我遇到过最经典的现象是用学习率 0.01 时 loss 完全不降改成 0.001 后开始稳定下降这基本就是学习率差了数量级的典型例子。反过来学习率太小也有问题loss 确实在降但每步只降一点点训练几万步还没到目标精度。这时候可以用学习率预热策略前几百步用一个很小的学习率慢慢热身之后再切到正常学习率最后再按 epoch 做余弦退火或分段衰减。这种三段式学习率调度在很多项目里都是稳定可靠的选择。怎么判断当前学习率方向对不对我的懒人法则是先尝试一小组数量级比如 0.1、0.01、0.001分别跑几千步画在同一个坐标系里看曲线趋势。哪个方向是平滑单调下降的就在哪个数量级附近微调。这个做法虽然简单但每次都能大大缩小搜索范围。5.3 梯度检查确认梯度没算错反向传播是最容易出现“差一个负号”“少乘一个权重”“搞混了转置”的地方。写网络代码时尤其是手写反向传播必须做梯度检查。原理是用数值微分来近似真实梯度。取一个很小的 h比如 1e-5对某个参数 w 计算前后向各一次差值数值梯度 ≈ (L(w h) − L(w − h)) / (2h)这就是中心差分它比单边差分 (L(w h) − L(w)) / h 更准确误差阶数更高所以我一直都用中心差分做检查。然后把算出来的“解析梯度”和“数值梯度”做相对比较公式是 |解析梯度 − 数值梯度| / (|解析梯度| |数值梯度|)。相对误差在 1e-6 到 1e-4 之内基本可以认为反向传播写对了。做梯度检查时有三个注意点第一检查时一定要关闭 dropout 这类随机操作和任何会改变计算图的东西否则两次前向传播之间的损失函数都不同数值梯度根本不准第二h 不能取得太小太小会因为浮点数精度损失让数值微分反而变差通常取 1e-5 到 1e-6 之间比较好第三如果模型太大不要所有参数都检查随机抽一小批参数比如每类层抽几组 W 和 b 检查即可因为同一个层的代码逻辑往往是共享的。5.4 训练中的异常值与隐蔽错误训练到一半 loss 变成 NaN 或者出现无穷大大概率的几类原因数据里有 NaN 值、数值运算中除以了 0、log 函数的输入为 0、或者梯度爆炸导致权重变为无穷。排查时先检查输入数据把任何 NaN 值都处理掉再检查损失函数看里边有没有 log、除法、sqrt 这类容易出问题的运算必要时在分母上加一个极小值 ε。还有一个很容易被忽略的问题数值稳定性。比如 softmax 的实现如果先算 exp(z) 再归一化当 z 很大时 exp(z) 会直接溢出为无穷大。正确的做法是先减去最大值再算 exp这样最大值变成 0其他项都不会溢出。类似这种实现细节在小规模测试时数据范围小看不出来一旦数据量增大就会突然爆炸。还有一些更隐蔽的问题和代码逻辑有关。比如权重初始化时把标准差设得过大第一层输出就落进 Sigmoid 的饱和区梯度瞬间被压成 0比如 batch size 太小且数据分批次不均导致梯度方向长期偏斜再比如更新参数时是在原权重上原地赋值还是拷贝后赋值深拷贝和浅拷贝用错可能会让上一次的更新被意外叠加。遇到 loss 反常时我的习惯是先把程序和训练流程中的“确定性”找回来固定随机种子然后一个一个因素去隔离而不是一股脑改十个超参数。6. 养成好习惯让我少走弯路的几条经验6.1 训练前先做“小数据过拟合”测试接手任何一个新模型我会先做一次过拟合测试只拿几十个样本训练足够多的 epoch看模型能不能把损失降到非常低。如果连几十个样本都过拟合不了那说明代码逻辑、梯度计算、数据管道里大概率有 bug这时候先不要动优化器也不要调学习率先把代码修对。这个习惯帮我排除了大量低级错误比自己对照公式一行行抠要省时间得多。过拟合测试通过之后再切换到正常数据量、正常正则化和数据增强。这时候出现的现象才值得用优化器、学习率这些手段去调。6.2 每次只改一个变量把实验记录当作资产训练深度学习模型非常容易陷入“同时改了三个东西”的混乱状态。调学习率的同时改了 batch size又换了数据增强方式最后损失确实下降了但你根本不知道功劳属于谁。我踩过几次这种坑之后给自己定了一条纪律任何一个实验周期所有变量保持不变只动其中一个。想调学习率就只调学习率想改 batch size就先冻结学习率。每一步改动都记录下来包括 IDE 里随手改的数值也要记因为“这次跑得好”和“上次跑得好”大家通常隔了一个星期才对比遗忘是常态。我会给每次实验记录这样的基本信息随机种子、学习率、优化器、batch size、网络层数、数据预处理方式、loss 曲线截图、跑出来的最终量化指标。排查问题时翻历史记录比重新跑一次实验高效得多。深度学习项目本质上是在大量实验里找规律没有记录的实验等于没做。反向传播和参数更新这条线是理解所有后续高级调优的底座。把第 4 节的例子亲手算一遍把常见问题和排查方法心里有数再去接触分布式训练、混合精度、大规模并行这些进阶话题时你会发现它们本质上都是在解决同一个问题如何让梯度更准确、更高效地流动并让参数沿着更好的方向更新。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表