行业资讯
📅 2026/8/11 7:08:17
反向传播算法全解:从链式法则到矩阵推导与代码实现
1. 从“黑盒”到“白盒”为什么我们必须亲手推导反向传播如果你用过TensorFlow、PyTorch这些框架搭建一个神经网络可能只需要几行代码调用一个loss.backward()梯度就自动算好了。这太方便了方便到我们常常忘了问这些梯度到底是怎么来的模型参数又是如何被精确调整的这就是反向传播算法现代深度学习的基石。但很多人对它的理解停留在“链式法则”四个字上或者看过几个示意图感觉懂了一动手就懵。更常见的情况是面试官让你手推一个两层网络的BP你卡在了矩阵维度对不上或者符号混乱。我经历过这个阶段。早期看教程总觉得反向传播像魔法前向算出损失魔法般地每个参数就知道自己该往哪个方向、移动多少。直到有一次我需要为一个非标准结构的自定义层手动实现梯度计算对着公式发呆了半天才发现之前的“懂”是假的。从那时起我强迫自己用最原始的方式——纸笔推导——把整个过程走通。走通之后再看任何复杂的网络结构心里都有了一张清晰的“计算图”问题在哪一目了然。所以这篇文章的目的不是让你“知道”反向传播而是让你能“亲手推导”出它。我们会从一个最简单的两层全连接网络开始用标量形式一步步算感受每个导数的物理意义。然后我们会把它升级到向量和矩阵形式这才是实际代码中使用的样子。最后我们会讨论几个推导时最容易卡壳的“魔鬼细节”比如偏置项的梯度、矩阵求导的布局约定以及激活函数求导的易错点。我的目标是看完这一篇你能关上文章在白纸上独立地、正确地把整个推导过程写出来。2. 预备知识你需要带上的“数学行囊”推导反向传播不需要高深的数学但有几样工具必须装进你的背包并且知道怎么用。如果你对其中任何一项感到模糊我强烈建议在这里多花几分钟否则后面会步步维艰。2.1 核心工具链式法则的多维面孔链式法则是反向传播的灵魂。对于标量函数我们都知道如果z f(y),y g(x)那么dz/dx (dz/dy) * (dy/dx)。但在神经网络里我们面对的是多维情况。这里有两种主要的链式法则形式标量对向量的链式法则这是最常见、最重要的形式。损失函数L是一个标量一个数字而我们要对权重向量w或中间变量z求导。假设L f(a),a g(z),z h(w)其中a是标量z是向量w是向量。那么梯度∂L/∂w是一个向量其计算为∂L/∂w (∂a/∂w) * (∂L/∂a) (∂z/∂w) * (∂a/∂z) * (∂L/∂a)注意∂z/∂w是一个雅可比矩阵后面会讲∂a/∂z是一个行向量梯度∂L/∂a是一个标量。实际上更常用的写法是从后往前利用上游梯度∂L/∂z (∂L/∂a) * (∂a/∂z)这里∂L/∂a是标量∂a/∂z是行向量结果∂L/∂z是行向量∂L/∂w (∂L/∂z) * (∂z/∂w)这里∂L/∂z是行向量∂z/∂w是矩阵结果∂L/∂w是行向量向量对向量的链式法则雅可比矩阵当中间变量也是向量时链式法则表现为雅可比矩阵的连乘。如果y f(u),u g(x)其中y, u, x均为向量则∂y/∂x (∂y/∂u) * (∂u/∂x)这里∂y/∂u和∂u/∂x都是雅可比矩阵。在反向传播中我们更多是计算标量损失对参数的梯度所以这种形式通常隐含在计算过程中我们更关注**梯度标量对向量的导数**的传递。关键理解反向传播中我们总是在计算标量损失L对某个参数或激活值的梯度。这个梯度指明了该参数应该如何微小变化才能降低损失。2.2 矩阵求导与布局约定混乱的根源这是推导中最容易出错的地方。矩阵求导有多种“布局约定”Layout Convention主要分为分子布局和分母布局。简单说就是结果矩阵的形状是由分子求导对象的形状决定还是由分母被求导变量的形状决定。在神经网络和深度学习框架中通常采用一种混合但一致的约定我称之为“梯度约定”标量y对向量x的导数∂y/∂x结果是一个行向量形状与x的转置(1, n)相同。这是因为在梯度下降更新w w - η * ∂L/∂w时我们希望∂L/∂w的形状和w完全一致方便做减法。向量y对向量x的导数∂y/∂x结果是雅可比矩阵其中∂y_i/∂x_j位于第i行第j列。即y的元素变化对x的元素变化的敏感度矩阵。一个必须记住的黄金法则在反向传播的每一步你都要非常清楚当前正在计算的梯度∂L/∂A的形状。它的形状必须与A本身完全相同。如果形状对不上99% 是因为链式法则相乘时顺序或转置搞错了。2.3 激活函数求导非线性之钥激活函数如Sigmoid, Tanh, ReLU的导数是反向传播路径上的“收费站”。每个神经元在反向传播时都需要乘上其激活函数的导数。Sigmoid:σ(z) 1 / (1 e^{-z})其导数非常优美σ(z) σ(z) * (1 - σ(z))。注意在前向传播时我们已经计算了a σ(z)因此反向传播时可以直接用a来计算导数a * (1 - a)无需重新计算σ(z)这是一个重要的优化技巧。Tanh:tanh(z) (e^z - e^{-z}) / (e^z e^{-z})导数tanh(z) 1 - tanh^2(z)。同样可以利用前向传播的结果。ReLU:ReLU(z) max(0, z)导数ReLU(z) 1 if z 0 else 0。这是一个分段函数实现时通常用一个掩码mask记录前向传播中哪些神经元被激活z0。一个实战心得在推导时把激活函数看作一个独立的层。它的反向传播规则就是上游传来的梯度∂L/∂a乘以该层激活函数关于其输入z的局部导数∂a/∂z得到传递给更前一层的梯度∂L/∂z。即∂L/∂z (∂L/∂a) ⊙ σ(z)其中⊙表示逐元素相乘Hadamard product。3. 从零开始一个两层网络的标量推导让我们搭建一个最简单的全连接神经网络并用标量形式推导。这是理解本质的最佳方式。网络结构输入层1个神经元x隐藏层1个神经元输出层1个神经元ŷ。为简化省略偏置b。前向传播隐藏层输入z1 w1 * x隐藏层输出a1 σ(z1)其中σ是Sigmoid函数。输出层输入z2 w2 * a1输出层输出即预测值ŷ a2 σ(z2)。这里我们让输出层也用Sigmoid假设是做二分类。损失函数采用均方误差MSEL 1/2 * (y - ŷ)^2其中y是真实标签。我们的目标是求损失L对两个权重w1和w2的梯度∂L/∂w1和∂L/∂w2。反向传播推导标量版我们从损失函数开始一步步往回反向计算梯度。第1步计算损失对输出ŷ的梯度∂L/∂ŷ ∂[1/2*(y-ŷ)^2]/∂ŷ -(y - ŷ)这个很好理解预测值ŷ离真实值y越远损失对它的梯度绝对值越大且方向是使ŷ向y靠近。第2步计算∂L/∂w2w2只通过z2影响ŷ再影响L。根据链式法则∂L/∂w2 (∂L/∂ŷ) * (∂ŷ/∂z2) * (∂z2/∂w2)∂L/∂ŷ -(y - ŷ)上一步已求∂ŷ/∂z2 σ(z2) ŷ * (1 - ŷ)Sigmoid导数公式用前向结果ŷ计算∂z2/∂w2 a1因为z2 w2 * a1对w2求导得a1 所以∂L/∂w2 -(y - ŷ) * ŷ * (1 - ŷ) * a1第3步计算∂L/∂a1a1影响z2进而影响ŷ和L。我们需要这个梯度因为它要继续向前传播。∂L/∂a1 (∂L/∂ŷ) * (∂ŷ/∂z2) * (∂z2/∂a1)∂L/∂ŷ和∂ŷ/∂z2同上。∂z2/∂a1 w2因为z2 w2 * a1对a1求导得w2 所以∂L/∂a1 -(y - ŷ) * ŷ * (1 - ŷ) * w2注意观察计算∂L/∂w2时我们乘了a1计算∂L/∂a1时我们乘了w2。这体现了权值和激活值在梯度传播中的对称性。第4步计算∂L/∂w1现在我们已经有了∂L/∂a1可以继续向前求∂L/∂w1。∂L/∂w1 (∂L/∂a1) * (∂a1/∂z1) * (∂z1/∂w1)∂L/∂a1上一步已求。∂a1/∂z1 σ(z1) a1 * (1 - a1)隐藏层Sigmoid的导数∂z1/∂w1 x因为z1 w1 * x 所以∂L/∂w1 [-(y - ŷ) * ŷ * (1 - ŷ) * w2] * [a1 * (1 - a1)] * x推导完成我们得到了∂L/∂w2 -(y - ŷ) * ŷ * (1 - ŷ) * a1∂L/∂w1 -(y - ŷ) * ŷ * (1 - ŷ) * w2 * a1 * (1 - a1) * x这个标量推导清晰地展示了梯度是如何从损失函数L一层层“反向传播”到每一个权重w的。每一步都是简单的链式法则应用。你会发现在反向传播路径上每个节点如a1,z2都需要计算一个局部梯度∂L/∂[该节点]并将其传递给前驱节点。∂L/∂w1的表达式比∂L/∂w2更长因为它离损失函数更远需要经过更多的链式法则。4. 升级到实战向量化与矩阵形式推导标量推导帮助我们理解了原理但实际的神经网络处理的是批量数据神经元也是多个。我们必须使用向量和矩阵利用线性代数的并行计算能力。现在我们将网络扩展为更通用的形态网络结构输入层x(形状n_in x 1)隐藏层有n_hid个神经元输出层有n_out个神经元。参数W1(形状n_hid x n_in)b1(形状n_hid x 1)W2(形状n_out x n_hid)b2(形状n_out x 1)。前向传播单个样本z1 W1 * x b1(形状n_hid x 1)a1 σ(z1)(形状n_hid x 1)z2 W2 * a1 b2(形状n_out x 1)ŷ a2 σ(z2)(形状n_out x 1)损失函数为了一般性我们使用交叉熵损失用于分类但推导逻辑与MSE相似。L是标量。我们的目标是求∂L/∂W2,∂L/∂b2,∂L/∂W1,∂L/∂b1。这里的关键是牢记我们的“梯度约定”梯度∂L/∂W的形状必须与W相同。反向传播推导矩阵版我们采用从后往前的顺序并定义每个节点的“误差信号”或“上游梯度”。第1步计算输出层的误差信号δ2定义δ2 ∂L/∂z2。这是损失对输出层加权输入z2的梯度。为什么对z2求导而不是直接对W2求导因为z2是激活函数的输入从这里开始计算更规整。δ2 ∂L/∂z2 (∂L/∂a2) ⊙ (∂a2/∂z2)∂L/∂a2取决于损失函数。对于MSE∂L/∂a2 -(y - a2)。对于交叉熵Sigmoid有一个非常简洁的结果∂L/∂a2 a2 - y这是一个很重要的结论可以单独推导这里先接受它。其形状为(n_out x 1)。∂a2/∂z2这是激活函数σ的导数是对z2的每个元素分别求导所以结果是一个对角矩阵吗不在向量化计算中我们通常进行逐元素乘法。因为a2 σ(z2)是逐元素的所以∂a2/∂z2是一个由σ(z2)组成的向量记作σ(z2)。形状也是(n_out x 1)。 因此δ2 (a2 - y) ⊙ σ(z2)。这里⊙是逐元素乘。δ2的形状是(n_out x 1)。第2步计算∂L/∂W2和∂L/∂b2现在我们有δ2可以计算对第二层参数的梯度了。∂L/∂W2根据链式法则∂L/∂W2 δ2 * (∂z2/∂W2)。但z2 W2 * a1 b2W2是一个矩阵∂z2/∂W2是一个三维张量直接求导很麻烦。这里有一个技巧考虑W2的单个元素W2_{ij}它只影响z2的第i个元素。所以∂L/∂W2_{ij} δ2_i * a1_j。把所有的i, j组合起来你会发现∂L/∂W2 δ2 * a1^T请仔细验证维度δ2是(n_out x 1)a1^T是(1 x n_hid)它们的矩阵乘法结果是(n_out x n_hid)与W2的形状完全一致这就是矩阵求导在深度学习中的常用结论参数矩阵W的梯度等于下一层的误差信号δ乘以前一层激活值a的转置。∂L/∂b2同理b2的每个元素b2_i只影响z2_i。所以∂L/∂b2_i δ2_i。因此∂L/∂b2 δ2其形状为(n_out x 1)与b2一致。偏置项的梯度就是其所在层的误差信号δ。第3步反向传播误差信号到隐藏层δ1现在我们需要计算隐藏层的误差信号δ1 ∂L/∂z1。δ1 ∂L/∂z1 (∂L/∂a1) ⊙ (∂a1/∂z1)先求∂L/∂a1。a1影响z2而z2 W2 * a1 b2。所以∂L/∂a1 W2^T * δ2。维度检查W2^T是(n_hid x n_out)δ2是(n_out x 1)结果∂L/∂a1是(n_hid x 1)。这揭示了反向传播的另一个关键前一层的梯度是后一层权重矩阵的转置乘以后一层的误差信号。你可以把它理解为误差信号沿着权重连接“反向流动”。∂a1/∂z1 σ(z1)形状(n_hid x 1)。 因此δ1 (W2^T * δ2) ⊙ σ(z1)。形状为(n_hid x 1)。第4步计算∂L/∂W1和∂L/∂b1有了δ1计算第一层参数的梯度和第二层如出一辙。∂L/∂W1 δ1 * x^T。维度δ1是(n_hid x 1)x^T是(1 x n_in)结果是(n_hid x n_in)与W1一致。∂L/∂b1 δ1。形状(n_hid x 1)与b1一致。矩阵形式总结 对于一个L层的全连接网络记输入为a0其通用反向传播公式如下对于第l层前向z_l W_l * a_{l-1} b_la_l σ(z_l)反向输出层Lδ_L (∂L/∂a_L) ⊙ σ(z_L)对于l L-1, ..., 1δ_l (W_{l1}^T * δ_{l1}) ⊙ σ(z_l)参数梯度∂L/∂W_l δ_l * a_{l-1}^T∂L/∂b_l δ_l这个公式非常优美且统一是任何深度学习框架实现自动微分的核心。它告诉我们反向传播本质上就是误差信号δ的层层反向传递每传递到一层就利用该层的本地信息前向传播时保存的激活值a和激活函数导数σ(z)以及连接权重W计算出该层参数的梯度和传递给前一层的误差信号。5. 推导中的“魔鬼细节”与避坑指南理论公式看起来干净但自己动手推导时总会遇到一些让人抓狂的细节。下面是我在多次推导和教学中总结的几个最容易出错的地方。5.1 维度对齐永远的检查点这是新手推导时出错的重灾区。记住一个铁律每次进行矩阵乘法或计算梯度后立即检查结果的维度是否与预期一致。∂L/∂W的维度必须等于W的维度。公式δ * a_{prev}^T保证了这一点。如果你写成了a_{prev} * δ^T维度就反了。δ的维度δ_l必须等于z_l的维度也就是该层的神经元个数。链式法则中的乘法顺序在δ_l (W_{l1}^T * δ_{l1}) ⊙ σ(z_l)中必须是W_{l1}^T左乘δ_{l1}。如果顺序反了维度不匹配物理意义也不对误差是从后往前传的。一个实用的检查方法为每个变量假设一个具体的维度。例如设输入x为(100, 1)W1为(50, 100)那么z1就是(50, 1)。然后一步步推导下去确保所有中间变量的维度都合理。5.2 偏置项b的梯度为什么是δ本身很多人在推导时会对∂L/∂b感到困惑。从公式z W*a b看b是直接加到z上的。所以对于b的每一个元素b_i有∂z_i/∂b_i 1且∂z_j/∂b_i 0 (j≠i)。因此∂L/∂b_i (∂L/∂z_i) * (∂z_i/∂b_i) δ_i * 1 δ_i。整个向量b的梯度就是向量δ。一个常见的错误试图对b求导时考虑它对其他z_j的影响或者错误地认为∂z/∂b是一个单位矩阵。记住b的每个元素是独立地加到对应的z元素上的所以其梯度就是对应的误差信号。5.3 激活函数求导的实践技巧前向传播时我们计算了a σ(z)并保存了z或a。反向传播时我们需要σ(z)。Sigmoid/Tanh最好保存前向传播的激活值a。因为σ(z) a * (1 - a)tanh(z) 1 - a^2。这样只需要一次计算避免了重复计算指数函数既快又准。ReLU保存一个掩码maskmask (z 0)。反向传播时ReLU(z) mask。在Python中这通常是一个布尔型或0/1的数组。Softmax配合交叉熵损失这是一个特例但极其重要。在分类任务中输出层常用Softmax损失用交叉熵。它们组合在一起时导数有非常简洁的形式。推导稍复杂但结论是∂L/∂z_L a_L - y其中y是one-hot编码的真实标签。这是一个需要记住的结论它比分别求Softmax导数和交叉熵导数再相乘要简单得多。如果你在推导多分类网络时输出层的δ_L变得非常复杂很可能是因为你没有利用这个组合求导的简化结果。5.4 批量处理Batch下的梯度计算上面推导的是单个样本。实际训练中我们使用一个批次Batch的N个样本。前向传播输入X的形状变为(n_in, N)。每一列是一个样本。Z1 W1 * X b1这里b1会通过广播加到每一列。A1 σ(Z1)形状(n_hid, N)。以此类推。反向传播损失L现在是N个样本损失的平均值。所以我们的δ也是针对每个样本的。δ2的形状是(n_out, N)。参数梯度计算∂L/∂W2应该如何计算对于单个样本我们有∂L/∂W2 δ2 * a1^T。对于批次数据我们需要对所有样本的梯度求平均或求和取决于损失函数的定义通常是平均。因此∂L/∂W2 (1/N) * (δ2 * A1^T)这里δ2是(n_out, N)A1^T是(N, n_hid)相乘得到(n_out, n_hid)再除以N得到平均梯度。∂L/∂b2则需要沿着样本维度axis1对δ2求平均∂L/∂b2 (1/N) * np.sum(δ2, axis1, keepdimsTrue)以保持(n_out, 1)的形状。一个易错点在代码实现时如果忘记了求平均会导致梯度是单样本情况的N倍如果学习率不变更新步长会巨大很可能导致训练瞬间发散。所以在推导和实现批量梯度时务必明确梯度是平均梯度。6. 从公式到代码实现一个简单的Numpy版BP理解了所有原理和细节后最好的巩固方式就是实现它。下面我用Python和Numpy实现一个两层网络的前向和反向传播并加上详细的注释。import numpy as np class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size, weight_init_std0.01): # 初始化参数 self.params {} self.params[W1] weight_init_std * np.random.randn(hidden_size, input_size) self.params[b1] np.zeros((hidden_size, 1)) self.params[W2] weight_init_std * np.random.randn(output_size, hidden_size) self.params[b2] np.zeros((output_size, 1)) def sigmoid(self, x): return 1 / (1 np.exp(-x)) def sigmoid_grad(self, x): # 这里输入x可以是z也可以是a。我们约定传入前向传播计算出的a因为a sigmoid(z) # 所以梯度 a * (1 - a) return x * (1 - x) def forward(self, x): # x shape: (input_size, batch_size) W1, b1, W2, b2 self.params[W1], self.params[b1], self.params[W2], self.params[b2] # 第一层 z1 np.dot(W1, x) b1 # (hidden_size, batch_size) a1 self.sigmoid(z1) # (hidden_size, batch_size) # 第二层 z2 np.dot(W2, a1) b2 # (output_size, batch_size) a2 self.sigmoid(z2) # (output_size, batch_size) # 缓存中间变量反向传播需要 self.cache {x: x, z1: z1, a1: a1, z2: z2, a2: a2} return a2 def loss(self, y_pred, y_true): # 使用均方误差损失y_true是one-hot或实数标签 batch_size y_true.shape[1] loss 0.5 * np.sum((y_pred - y_true) ** 2) / batch_size # 平均损失 return loss def backward(self, y_true): # 从缓存中取出前向传播的结果 x, z1, a1, z2, a2 self.cache[x], self.cache[z1], self.cache[a1], self.cache[z2], self.cache[a2] batch_size x.shape[1] W1, W2 self.params[W1], self.params[W2] grads {} # 用于保存梯度 # 1. 输出层误差信号 δ2 # 对于MSE损失 Sigmoid输出∂L/∂a2 -(y_true - a2) a2 - y_true # 这里我们直接用这个结果。如果是交叉熵公式不同。 d_a2 a2 - y_true # (output_size, batch_size) # σ(z2) a2 * (1 - a2) sigma_prime_z2 self.sigmoid_grad(a2) # (output_size, batch_size) delta2 d_a2 * sigma_prime_z2 # (output_size, batch_size)逐元素乘 # 2. 计算第二层参数的梯度 # ∂L/∂W2 (1/batch_size) * δ2 * a1^T grads[W2] np.dot(delta2, a1.T) / batch_size # (output_size, hidden_size) # ∂L/∂b2 (1/batch_size) * sum(δ2, axis1, keepdimsTrue) grads[b2] np.sum(delta2, axis1, keepdimsTrue) / batch_size # (output_size, 1) # 3. 反向传播误差信号到第一层 δ1 # ∂L/∂a1 W2^T * δ2 d_a1 np.dot(W2.T, delta2) # (hidden_size, batch_size) # σ(z1) a1 * (1 - a1) sigma_prime_z1 self.sigmoid_grad(a1) # (hidden_size, batch_size) delta1 d_a1 * sigma_prime_z1 # (hidden_size, batch_size)逐元素乘 # 4. 计算第一层参数的梯度 # ∂L/∂W1 (1/batch_size) * δ1 * x^T grads[W1] np.dot(delta1, x.T) / batch_size # (hidden_size, input_size) # ∂L/∂b1 (1/batch_size) * sum(δ1, axis1, keepdimsTrue) grads[b1] np.sum(delta1, axis1, keepdimsTrue) / batch_size # (hidden_size, 1) return grads def update_params(self, grads, learning_rate0.01): # 简单的梯度下降更新 for key in self.params: self.params[key] - learning_rate * grads[key] # 一个简单的测试 if __name__ __main__: np.random.seed(42) # 网络结构 net TwoLayerNet(input_size2, hidden_size4, output_size1) # 伪造一个批次数据 (2个特征3个样本) X np.random.randn(2, 3) Y np.random.randn(1, 3) # 回归任务真实值 # 前向传播 Y_pred net.forward(X) print(fPrediction shape: {Y_pred.shape}) loss net.loss(Y_pred, Y) print(fInitial loss: {loss:.4f}) # 反向传播 grads net.backward(Y) print(Gradients keys:, grads.keys()) print(fGrad W1 shape: {grads[W1].shape}, should be (4, 2)) print(fGrad b1 shape: {grads[b1].shape}, should be (4, 1)) # 更新参数 net.update_params(grads, learning_rate0.1) # 再次前向损失应该下降对于随机数据不一定但流程正确 Y_pred_new net.forward(X) loss_new net.loss(Y_pred_new, Y) print(fLoss after one update: {loss_new:.4f})这段代码严格遵循了我们推导出的矩阵公式。注意几个关键点backward函数中所有梯度都除以了batch_size计算的是平均梯度。我们缓存了前向传播的z和a用于反向传播计算激活函数导数和a_{prev}。参数更新是朴素的梯度下降W W - η * ∂L/∂W。运行这段代码如果维度没有报错并且梯度计算正确就证明你真正掌握了反向传播的矩阵推导。你可以尝试修改网络层数、激活函数如换成ReLU、损失函数如交叉熵来挑战更复杂的推导和实现。这才是从“看懂”到“会做”的关键一步。