反向传播算法:从计算图到梯度下降的完整推导与NumPy实现
最近整理深度学习系列读书笔记编号正好排到 033。这篇标了“-1”是因为内容量超出预期拆成上下两篇来写。主题选了反向传播算法——深度学习里最容易被当作“已知结论”跳过、却最值得亲手推一遍的知识点。我尽量把从计算图到梯度下降的完整链路讲透并带一个能跑的 NumPy 两层网络做验证。适合刚补完 Python 基础、想在理论学习上扎稳脚跟的读者也适合那些背过公式但始终觉得“差点意思”的开发者。先给我的基本结论如果你只能在深度学习理论里挑一个内容死磕我会选反向传播。它连接了损失函数、梯度下降、链式法则、矩阵求导和实际调参几乎所有训练相关的问题最终都会追到这里。接下来按我的笔记整理方式展开先讲它解决什么问题再拆原理、推公式、写代码最后给排坑技巧。1. 深度学习入门为什么要死磕反向传播1.1 从梯度下降到反向传播训练神经网络本质上是一个优化问题。网络有一堆参数权重和偏置我们想让模型在训练数据上的损失尽可能小。最常见的优化方法是梯度下降算出损失对每个参数的梯度然后把参数往梯度的反方向挪一步。问题在于一个像样的网络动辄成千上万个参数甚至上亿个。怎么高效计算所有这些参数的梯度早期有人想过用数值微分也就是对每个参数单独加一个小扰动再用差分公式近似梯度。这种做法正确性没问题但代价高到没法用模型每计算一次梯度都要额外跑一遍前向传播参数有百万个就跑百万次训练一轮的时间会直接爆炸。反向传播算法解决了这个效率问题。它的核心思想很朴素神经网络是层层嵌套的复合函数求导时正好可以用链式法则从输出层往回逐层传递误差。整个过程只做一次前向传播再反向传播一次就能把每一层参数的梯度同时算出来。教科书里把反向传播称为“自动微分的特例”实际训练里几乎所有框架都在用它。我在复盘时会这样理解反向传播并没有改变“用梯度下降更新参数”这件事它只是把一个看似不可能完成的梯度计算任务变成了两个可接受的遍历过程。前向传播走一遍图反向传播再走一遍图时间复杂度从 O(N) 次前向变成 O(1) 次前向加 O(1) 次反向这里的“1”指固定次数与网络层数线性相关。这个量级的差距让深度网络的训练从“理论可行”变成了“工程现实”。1.2 数值梯度为什么只能用来“验算”你可能会问既然数值微分这么慢是不是完全没用也不是。它有一个极其重要的价值验算手写反向传播是否正确。我自己在写网络代码时经常先用数值梯度做对照。做法是随机初始化一个极小的网络对某个参数 w 加一个极小值 epsilon分别计算损失值然后套导数定义公式算近似梯度。再和反向传播算出来的梯度对比如果两者误差在 1e-7 这个量级基本可以确认代码没有写错。这一招在面试、作业、复现论文时都特别实用能省下大量排查时间。但要注意数值梯度只适合小规模验算大规模训练绝对不能依赖它。epsilon 选大了误差大选小了会触发浮点数精度问题而且每算一个梯度都得重新前向传播这个开销在前几轮调试中能接受放真实训练里是灾难。我的建议是把数值梯度当作“单元测试”而不是“生产工具”。1.3 反向传播到底“反向”在哪很多人第一次接触反向传播容易把它理解成一个单独的新算法。其实它背后的数学就是链式法则没有引入任何新规则。“反向”只是相对于前向传播的方向而言输入从输入层流向输出层梯度从输出层流回输入层。关键在于深度学习模型通常不是一个简单的函数链而是带有分支、复用的有向无环图。比如残差连接里一条路径直接跳过某些层多分支网络里一个张量会被拆分到多个子网络。这时反向传播就要根据图结构把从不同路径传回来的梯度累加起来。换句话说反向传播不仅能处理“一条线串到底”的网络也能处理任意可微计算图。理解到这一层后面看 Batch Normalization、Dropout 这些层的自定义实现时就不会懵。2. 计算图视角前向传播与链式法则2.1 用一个三变量函数理解计算图我建议不要一上来就盯神经网络的复杂公式先拿一个简单的函数练手。看这个例子f(x, y, z) (x y) * z这是个非常简单的复合函数。先算加法得到中间变量 a x y再算乘法得到输出 f a * z。用计算图画出来就是三个输入节点 x、y、z一个加法节点产生 a一个乘法节点产生 f。这个图足够小每一步的梯度都能手算。假设 x 2, y 5, z 3。前向传播很容易a 7f 21。现在算梯度。这里我习惯用“上游梯度 × 局部梯度”的方式思考。局部梯度是当前节点输出对输入的偏导上游梯度是最终损失对当前节点输出的偏导。两者相乘就得到最终损失对该节点输入的偏导。先看乘法节点 z * a f。f 对 a 的偏导是 z 3f 对 z 的偏导是 a 7。这是局部梯度。再看加法节点 a x y。a 对 x 的偏导是 1a 对 y 的偏导也是 1。那么最终梯度是多少如果损失就是 f 本身f 对 x 的梯度 f 对 a 的梯度 × a 对 x 的梯度 3 × 1 3。同理f 对 y 的梯度 3 × 1 3。f 对 z 的梯度直接等于 7。这个例子虽然简单却包含了计算图和反向传播的所有关键要素中间变量、局部梯度、链式相乘。能把这张图想明白后面看神经网络那一大串符号就不会慌。2.2 手工走一遍反向传播的完整过程还是上面那个例子我把计算过程拆成表格来看这样每一步的输入输出都能对齐。前向传播从左到右反向传播从右到左。节点前向结果局部梯度上游梯度最终梯度x2133y5133z3717a73--这里值得注意的一个细节是反向传播的“上游梯度”其实是最终梯度在计算图上游方向的传递。比如加法节点 a 的上游梯度来自乘法节点等于 3然后 a 再把这个 3 分配给 x 和 y各自乘以局部梯度 1。我在实际教学里常用“分发”这个词上游梯度像一条通知每到一层节点就按局部梯度比例分发给下面的输入。这个直觉在处理减法、乘法、矩阵乘时特别有用。比如乘法节点会把上游梯度乘以“另一个输入”因为导数正好是另一个因子。2.3 生活化类比反向传播像在传话如果还是觉得抽象我提供一个生活化类比。想象一个工厂流水线原料经过加工变成零件零件经过组装变成产品。现在产品被发现有问题需要追查责任。老板发了一条通知从成品端往回传误差先到组装环节组装环节按自己的工艺参数决定上一道工序要承担多少责任然后把新误差传给前一道工序。就这样一层层倒着传直到每个原料供应商都收到自己该负的责任。这个类比和反向传播的数学过程精确对应“责任”是梯度“工艺参数”是局部梯度“产品到成品的转化”是损失函数。神经网络训练中的损失就是那条“问题通知”它从输出层一路传回输入层每个参数都会收到属于自己的责任量——也就是梯度。梯度越大说明这个参数对最终损失的影响越大更新时就要走得越远。3. 神经网络中的反向传播推导3.1 符号约定与前向传播进入神经网络之前先统一符号不然公式容易看乱。我用一个三层全连接网络做示例输入层 n 个节点隐藏层 h 个节点输出层 m 个节点。激活函数设为 sigmoid损失函数用均方误差。记第 1 层的输入为 x维度是 n。隐藏层的权重矩阵是 W1维度是 h × n偏置是 b1维度是 h。隐藏层的线性输出为 z1 W1 * x b1激活后得到 a1 sigmoid(z1)。输出层的权重矩阵是 W2维度是 m × h偏置是 b2维度是 m。输出层线性输出为 z2 W2 * a1 b2这里我暂时不加激活直接作为最终预测值。损失函数定义为 L 0.5 * || y_pred - y ||^2。y 是真实标签y_pred 是 z2。前面乘 0.5 是为了求导时抵消掉平方项的系数纯粹是计算方便。前向传播的代码实现一般长这样z1 W1.dot(x) b1 a1 sigmoid(z1) z2 W2.dot(a1) b2 y_pred z2 loss 0.5 * np.sum((y_pred - y) ** 2)这段代码没有任何循环全部是向量和矩阵运算。反向传播的推导也基于同样的向量化视角。3.2 输出层的梯度计算反向传播从损失函数开始一步步往回求导。损失对 y_pred 的梯度很好算dL / dy_pred y_pred - y这是个长度为 m 的向量。接下来要求输出层的权重 W2。因为 z2 W2 * a1 b2y_pred z2所以我们需要先求 dL / d z2。在这个例子里dL / d z2 恰好等于 dL / d y_pred因为中间没有激活函数。把它记作 delta2。delta2 y_pred - y再看局部梯度。z2 对 W2 的偏导用矩阵求导的规则看等于 a1 的转置。于是 W2 的梯度就是 delta2 外积 a1dL / d W2 delta2 * a1^T这里 delta2 维度是 ma1 维度是 h两者外积得到 m × h 的矩阵与 W2 的维度完全一致。偏置的梯度更简单直接等于 delta2 对 b2 的偏导也就是 delta2 本身但求平均时会按 batch 归一化。这是输出层最核心的结论。3.3 隐藏层的梯度与误差反向传递输出层算完之后梯度要继续往回传。现在需要求 dL / d a1。利用链式法则先通过 z2 传导dL / d a1 W2^T * delta2这一步很多初学者会卡住因为它不再是一维链式而是矩阵的转置相乘。直觉上可以这样理解输出层的误差 delta2 已经产生W2 相当于把误差从输出层“映射”回隐藏层。每个隐藏神经元的误差等于它参与的所有输出连接误差的加权和权重就是 W2 对应列的元素。转置正好完成了这个加权聚合。接着隐藏层有激活函数所以要继续穿过 sigmoid。a1 sigmoid(z1)sigmoid 的导数是 a1 * (1 - a1)。于是delta1 (W2^T * delta2) * (a1 * (1 - a1))注意这里 a1 * (1 - a1) 是逐元素相乘使用的是 Hadamard 积。至此梯度已经成功从输出层传到了隐藏层。隐藏层权重 W1 和偏置 b1 的梯度跟输出层的形式完全对称把 a1 替换成输入 x 即可dL / d W1 delta1 * x^T dL / d b1 delta1从这一组公式能看出为什么反向传播是“层层递推”的先算输出层的 delta再通过矩阵转置和激活函数导数算出上一层的 delta每一层的权重梯度都由该层输入和反传下来的 delta 做外积得到。这个过程可以无限向上延伸只要网络结构允许。3.4 权重梯度、偏置梯度与更新规则把上面所有梯度汇总参数更新就非常直白。使用批量梯度下降时一个 batch 里有 N 个样本通常先对样本维度求平均或求和再乘以学习率。更新规则公式化表达如下W1 W1 - learning_rate * dL / dW1 b1 b1 - learning_rate * dL / db1 W2 W2 - learning_rate * dL / dW2 b2 b2 - learning_rate * dL / db2这里有一个我在项目里踩过坑的点在 batch 训练时梯度方向要对所有样本的梯度求平均。如果直接求和而学习率没调Loss 曲线往往会在同一个量级上震荡很久甚至直接发散。求平均能让梯度量级与 batch 大小解耦这样当 batch size 改变时学习率不用做大幅调整。很多框架默认实现做了这个细节但自己写代码时容易漏掉。另外反向传播推导到这里只是全连接网络的最小闭环。真实项目里还有卷积、池化、RNN、注意力机制但这些都是在“局部梯度”上做文章。卷积层的局部梯度来自卷积核的互相关运算池化层的局部梯度按 max 或 avg 规则分配RNN 的局部梯度在时间维度上展开。只要掌握了通用套路遇到新结构只需要多推导一次局部梯度剩下的环节和这里完全一致。4. 从零手写一个可训练的两层网络4.1 网络结构与初始化方案理论部分讲完现在进入动手环节。我用 NumPy 手写一个隐藏层加 sigmoid 激活的两层网络任务选一个最简单的二分类问题。为了演示清晰数据集用两个可线性区分叠加非线性分隔的高斯簇样本量 200 个特征维度 2标签是 0 或 1。网络结构是2 个输入特征隐藏层 16 个神经元输出层 1 个节点。权重初始化很关键这里我采用 Xavier 初始化的简化版本用均匀分布生成随机数范围是正负 1/sqrt(fan_in)。fan_in 是当前层的输入神经元个数。这样做的原因是让每一层的输出方差在传播过程中大致保持不变避免梯度在深层消失或爆炸。初始化代码如下import numpy as np np.random.seed(0) n_x, n_h, n_y 2, 16, 1 W1 np.random.uniform(-1.0 / np.sqrt(n_x), 1.0 / np.sqrt(n_x), (n_h, n_x)) b1 np.zeros((n_h, 1)) W2 np.random.uniform(-1.0 / np.sqrt(n_h), 1.0 / np.sqrt(n_h), (n_y, n_h)) b2 np.zeros((n_y, 1))偏置全部初始化为 0实际训练中如果没有特殊原因没必要做非零初始化。我将训练数据组织成 (n_x, N) 的形状也就是列向量形式这种布局在写前向传播时代码更直观。4.2 前向传播实现前向传播按之前定义的公式实现把所有中间变量保存起来后面反向传播会用到。完整代码如下def sigmoid(x): return 1.0 / (1.0 np.exp(-x)) def forward(X, W1, b1, W2, b2): z1 W1.dot(X) b1 a1 sigmoid(z1) z2 W2.dot(a1) b2 y_pred z2 cache (X, z1, a1, z2) return y_pred, cache这里要注意 sigmoid 的实现。数值上直接写 np.exp(-x) 在 x 很大时会溢出真实场景建议做一次截断判断比如 x 大于 0 时换成 np.exp(-x) 的等价形式或者使用 scipy 里的稳定实现。这个网络规模小不会踩到溢出但养成稳定的写法不是坏事。损失函数用均方误差对二分类来说交叉熵通常效果更好不过这一篇重点在反向传播的机制均方误差求导更直观所以拿它演示。4.3 反向传播实现与向量化细节反向传播代码直接对照 3.2 和 3.3 的公式写def backward(X, y, y_pred, cache): N X.shape[1] z1, a1, z2 cache[1], cache[2], cache[3] dz2 y_pred - y dW2 dz2.dot(a1.T) / N db2 np.sum(dz2, axis1, keepdimsTrue) / N da1 W2.T.dot(dz2) dz1 da1 * a1 * (1 - a1) dW1 dz1.dot(X.T) / N db1 np.sum(dz1, axis1, keepdimsTrue) / N return dW1, db1, dW2, db2这段代码里藏着两个细节值得展开说。第一个细节是除以 N。正如前面提到的对 batch 内所有样本的梯度求平均可以让梯度量级更稳定。如果不除 NLoss 会比较敏感学习率的选择空间会小很多。第二个细节是 dz1 da1 * a1 * (1 - a1)。这里的乘法是逐元素的不是矩阵乘法。因为 sigmoid 的导数是对每个元素独立计算的计算图里激活函数不改变张量形状。我见过不少初学者在这里写成 np.dot 或者 np.matmul结果要么报维度错误要么数学上完全不对。4.4 训练循环与效果验证训练循环十分简单迭代若干轮每个 epoch 跑一次前向传播、一次反向传播再用梯度下降更新参数。我加了一个简单的预测函数将输出大于 0.5 的样本判为正类。learning_rate 0.1 epochs 2000 for i in range(epochs): y_pred, cache forward(X_train, W1, b1, W2, b2) loss 0.5 * np.mean((y_pred - y_train) ** 2) dW1, db1, dW2, db2 backward(X_train, y_train, y_pred, cache) W1 - learning_rate * dW1 b1 - learning_rate * db1 W2 - learning_rate * dW2 b2 - learning_rate * db2 if i % 200 0: print(fepoch {i}, loss {loss:.4f})实际运行后Loss 会从初始的 0.1 左右持续下降到接近 0.01最终分类准确率能到 95% 以上。这个网络虽然简单但完整走完一遍前向、反向、更新的过程足以验证反向传播的理解是否正确。如果你想进一步训练多种初始化、不同学习率可以把 Loss 曲线打印出来对比。我实测下来的经验是学习率 0.01 到 0.1 之间基本都能收敛但学习率超过 1 后Loss 很容易出现先降后炸的情况。不要迷信“大学习率能加速收敛”对于小网络稳定收敛比快速收敛重要得多。5. 训练中那些绕不开的坑5.1 梯度检查写代码前先证明自己没写错手写反向传播最怕的不是不懂原理而是某个细节写错但代码还能跑。比如把求和写成求平均、忘记对偏置做 reduce、矩阵乘法方向写反这些都可能导致模型不收敛却很难一眼看出错在哪。我的习惯是在正式训练之前先写一个梯度检查函数。随机挑几个参数分量用数值梯度与解析梯度做对比。数值梯度定义为grad_num (loss(x eps) - loss(x - eps)) / (2 * eps)eps 通常取 1e-5 到 1e-7。比较时看相对误差而不是绝对误差因为绝对误差会受损失量级影响。相对误差小于 1e-7 说明代码几乎肯定没问题小于 1e-5 基本也能接受如果大于 1e-3就要认真排查了。我在自己的实验里每次写完自定义网络层、修改损失函数、调整网络结构之后都会跑一次梯度检查。这个过程看似多花了时间实际上省掉的调参时间远大于它。特别是复现论文里的特殊结构时很多论文代码里的 bug 都能通过梯度检查照出原形。5.2 梯度消失与激活函数选择使用 sigmoid 作为隐藏层激活函数在浅层网络里效果还行但网络一深就很容易出问题原因就是梯度消失。sigmoid 的导数最大值只有 0.25误差每经过一层隐藏层梯度量级就可能缩水到原来的四分之一以下。十层网络下来梯度基本趋近于零前面几层彻底学不到东西。这也是为什么现代网络很少用 sigmoid 做隐藏层激活而普遍使用 ReLU。ReLU 在正区间的导数是常数 1梯度流经这些激活函数时不会衰减。当然 ReLU 也有自己的问题比如死亡 ReLU 现象输入落在负区间时梯度恒为 0神经元可能永远无法更新。缓解手段包括 Leaky ReLU、PReLU、GELU 等。在项目里我的选型建议是隐藏层优先用 ReLU 家族输出层根据任务选择二分类用 sigmoid多分类用 softmax回归用线性。只有在做某些特定网络压缩或解释性研究时我才会回到 sigmoid 类的平滑函数并且通常会搭配 Batch Normalization 一起用。5.3 学习率与 Loss 曲线解读学习率是训练里最值得反复观察的超参数。我见过很多人一上来学习率设成 0.01Loss 却纹丝不动于是怀疑网络写错了。其实可以画一个简单的实验固定网络结构尝试学习率 1e-4、1e-3、1e-2、1e-1观察 Loss 曲线形态。学习率过小时Loss 会缓慢下降看起来像一条直线容易让人误以为收敛。学习率合适时Loss 呈现平滑下降最后趋于平缓。学习率过大时Loss 可能出现剧烈震荡甚至一路涨到 NaN。我的经验法则是先从一个偏大的学习率开始观察 Loss 是否震荡再逐渐减小直到 Loss 平滑下降。这比自己猜一个“看起来合理”的值高效得多。还有一点Loss 曲线的解读必须结合任务。训练集和验证集的 Loss 同时下降、然后验证集开始回升这就是典型的过拟合信号。此时最先应该调整的是正则化系数、Dropout 概率或模型复杂度而不是盲目换学习率或加数据。5.4 数值稳定性与梯度量级监控训练过程中还要警惕数值溢出和异常梯度。常见场景是损失函数计算时如果输出层使用 softmax 而没有数值稳定处理中间可能会出现 exp 溢出。解决办法是在 softmax 前减去最大值或者不显式计算 softmax而是将 softmax 与交叉熵损失合并推导出更稳定的梯度形式。另一个隐藏问题是梯度的量级监控。我通常会在每个 epoch 记录日志打印梯度的均值和范数。如果梯度范数突然暴涨到远大于 1说明网络出现了梯度爆炸如果梯度范数持续接近 0则要考虑梯度消失。提前设置这两个监控指标能让你在模型崩溃之前就发现问题。实践中还有一种稳定的技巧就是梯度裁剪把梯度的范数限制在一个阈值内超过则等比缩放。这在 RNN、Transformer 训练里几乎成为标配。虽然梯度裁剪会改变更新的真实方向但它能防止训练发散是工程上极为实用的兜底手段。最后再分享一个我在项目里反复验证过的习惯不要一上来就追求复杂的神经网络结构先把一个最小网络的前向、反向、更新这条链路完整跑通再用梯度检查确认每一步的梯度方向正确。即使是多年经验的开发者也在自定义算子时出现过维度错误或符号写反的问题。把反向传播的推导、实现和验收三个环节都走一遍你对深度学习训练过程的理解会扎实很多后面学卷积、循环网络、注意力机制时也会顺理成章地迁移同一套方法论。这篇是 033-1重点放在全连接网络的反向传播推导与实现。下一篇我会继续展开交叉熵损失与 Softmax 的梯度推导以及多分类场景下的完整实现到时候再接着聊。