手撕反向传播:从计算图到代码,彻底搞懂神经网络凭什么“知错能改”

张开发
2026/4/8 4:39:02 15 分钟阅读

分享文章

手撕反向传播:从计算图到代码,彻底搞懂神经网络凭什么“知错能改”
别再只会调包了一文带你彻底搞懂BP算法的前世今生附可运行代码你有没有想过当你告诉一个神经网络“你猜错了”它是怎么知道应该怪罪哪个神经元、哪条连接的呢一个几百层的网络几百万个参数误差信号到底该如何逆流而上精准地给每个参数指明“改进方向”这个问题的答案就是今天的主角——反向传播Backpropagation。本文不堆砌晦涩公式而是从一张购物小票的计算图开始一路手写代码实现一个完整的二层神经网络。跟着我走完这一程你会发现反向传播原来如此简单。一、计算图把数学算式变成“水管线路”先看一个生活场景你买了100件衣服每件2元和150条裤子每条3元总价多少100 × 2 150 × 3 650我们可以把这段计算画成一张图每个圆圈代表一个运算箭头代表数据流动这就是计算图——把复杂计算拆成一个个简单的小节点每个节点只干一件事拿到输入算出输出传给下家。1.1 前向传播顺着箭头算出结果从左边输入开始一步步向右推进直到得到最终结果。这个过程就叫前向传播。如果你再给总价加个10%的涨价系数图就变长了前向传播很简单就是按顺序计算。但它的真正威力在于我们可以沿着同样的图反着走一遍就能知道每个变量对最终结果的影响有多大。这就是反向传播。二、链式法则反向传播的“导航地图”如果我们想知道“衣服单价上涨1元最终付款会变多少”其实就是求导数。在计算图上我们从最右边开始向左反向传播每经过一个节点就乘上该节点的局部导数。这个规则就是微积分里的链式法则。举个经典例子z (x y)²先令u x y则z u²。导数关系∂z/∂x (∂z/∂u) × (∂u/∂x) 2u × 1 2(xy)。画成计算图反向传播时从z出发先经过平方节点导数为2u再经过加法节点导数为1最终得到∂z/∂x和∂z/∂y。一句话总结反向传播就是把上游传来的梯度乘上当前节点的局部导数再传给下游。三、基础运算节点的反向传播规则3.1 加法节点我是“分流器”对于z x y有∂z/∂x 1∂z/∂y 1。加法节点的反向传播上游梯度原样复制给两个分支。def add_backward(dout): dx dout * 1.0 dy dout * 1.0 return dx, dy3.2 乘法节点我是“交换乘”对于z x × y有∂z/∂x y∂z/∂y x。乘法节点的反向传播上游梯度乘以另一个输入的值然后传给对方。def mul_backward(x, y, dout): dx dout * y dy dout * x return dx, dy小提示在神经网络里全连接层的反向传播就是乘法节点在矩阵版本下的推广。四、激活函数的反向传播带代码实现激活函数是神经网络引入非线性的关键。我们来实现最常用的两个ReLU和Sigmoid。4.1 ReLU简单粗暴负值“杀死”ReLU函数f(x) max(0, x)导数x ≤ 0 时导数为 0x 0 时导数为 1。反向传播时只需把上游梯度中对应于前向输入 ≤0 的位置置零。class Relu: def __init__(self): self.mask None # 记录哪些位置 0 def forward(self, x): self.mask (x 0) # True 表示该位置需要阻断 out x.copy() out[self.mask] 0 return out def backward(self, dout): dout[self.mask] 0 # 梯度清零 dx dout return dx4.2 Sigmoid优雅的“S”曲线Sigmoid函数f(x) 1 / (1 e^{-x})它的导数有一个漂亮的形式f(x) f(x) × (1 - f(x))。这意味着反向传播时我们可以直接复用前向传播的输出值不用重新计算指数。class Sigmoid: def __init__(self): self.out None def forward(self, x): self.out 1 / (1 np.exp(-x)) return self.out def backward(self, dout): dx dout * (1.0 - self.out) * self.out return dx为什么Sigmoid曾经很流行因为它输出范围在(0,1)适合作为概率。但缺点是容易导致梯度消失现在很多场合被ReLU取代。五、Affine层全连接层的反向传播矩阵版乘法全连接层的数学形式Y X·W b其中X是输入矩阵N×mW是权重矩阵m×nb是偏置1×n会广播到每一行。反向传播的公式设E ∂L/∂Y∂L/∂X E·Wᵀ∂L/∂W Xᵀ·E∂L/∂b 对 E 的行求和因为前向时 b 被广播了class Affine: def __init__(self, W, b): self.W W self.b b self.x None self.dW None self.db None def forward(self, x): self.x x.reshape(x.shape[0], -1) # 展平 out np.dot(self.x, self.W) self.b return out def backward(self, dout): dx np.dot(dout, self.W.T) self.dW np.dot(self.x.T, dout) self.db np.sum(dout, axis0) dx dx.reshape(*self.original_x_shape) # 恢复原形状 return dx形状检查举例X: (64, 784)W: (784, 10) → Y: (64, 10)dout: (64, 10) → dW Xᵀ·dout (784,64)×(64,10) (784,10) ✅db sum(dout, axis0) (10,) ✅六、输出层Softmax 交叉熵损失的“终极简化”在分类任务中输出层通常用Softmax将得分转为概率再用交叉熵损失计算误差。Softmax公式y_k e^{x_k} / Σ_j e^{x_j}交叉熵损失L - Σ t_k log(y_k)其中t是 one-hot 真实标签。当我们将 Softmax 和交叉熵合并成一个层时反向传播的梯度会出奇地简单∂L/∂x y - t也就是说上游梯度直接等于预测概率减去真实标签再除以 batch_size 取平均。而对于输出层一般会直接将结果代入损失函数的计算。对于我们之前介绍的分类问题这里选择交叉熵误差Cross Entropy Error作为损失函数就可以得到一个Softmax-with-Loss层它包含了Softmax和Cross Entropy Loss两部分。导数的计算会比较复杂可以用计算图表示如下简化得在代码中可以实现为一个类 SoftmaxWithLossclass SoftmaxWithLoss: def __init__(self): self.loss None self.y None # softmax 输出 self.t None # 真实标签one-hot def forward(self, x, t): self.t t self.y softmax(x) # 假设 softmax 已实现 self.loss cross_entropy_error(self.y, self.t) return self.loss def backward(self, dout1): batch_size self.t.shape[0] dx (self.y - self.t) / batch_size return dx这个简洁的结果是反向传播中最美的公式之一——它直接把“误差”定义为“预测减去真相”。七、组装一个完整的二层神经网络现在我们把所有层串起来搭建一个用于MNIST手写数字识别的两层网络隐藏层Affine → ReLU输出层Affine → SoftmaxWithLossimport numpy as np from collections import OrderedDict 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(input_size, hidden_size) self.params[b1] np.zeros(hidden_size) self.params[W2] weight_init_std * np.random.randn(hidden_size, output_size) self.params[b2] np.zeros(output_size) # 构建层有序字典保证前向/反向顺序 self.layers OrderedDict() self.layers[Affine1] Affine(self.params[W1], self.params[b1]) self.layers[Relu1] Relu() self.layers[Affine2] Affine(self.params[W2], self.params[b2]) self.last_layer SoftmaxWithLoss() def predict(self, x): for layer in self.layers.values(): x layer.forward(x) return x def loss(self, x, t): y self.predict(x) return self.last_layer.forward(y, t) def gradient(self, x, t): # 前向传播 self.loss(x, t) # 反向传播 dout 1 dout self.last_layer.backward(dout) layers_rev list(self.layers.values()) layers_rev.reverse() for layer in layers_rev: dout layer.backward(dout) # 收集梯度 grads {} grads[W1] self.layers[Affine1].dW grads[b1] self.layers[Affine1].db grads[W2] self.layers[Affine2].dW grads[b2] self.layers[Affine2].db return grads使用方式net TwoLayerNet(784, 50, 10) x_batch, t_batch get_mini_batch() # 获取一批数据 grads net.gradient(x_batch, t_batch) # 一次反向传播算出所有梯度 # 然后用 SGD 等优化器更新 net.params注意上面代码中的 softmax、cross_entropy_error 以及数值梯度函数需要你自己补充这里为了聚焦主题不再展开。八、反向传播 vs 数值梯度效率天差地别你可能会问既然有数值梯度用差分近似为什么还要费劲写反向传播方法原理一次梯度计算需要的前向次数数值梯度对每个参数微小扰动观察损失变化P 1次P为参数数量反向传播链式法则一次前向一次反向1次前向 1次反向对于一个100万参数的模型数值梯度需要100万次前向传播完全不可行。而反向传播只需要1次前向1次反向速度快了上百万倍。实际开发小技巧在自定义层时先用数值梯度验证反向传播的正确性梯度检查确认无误后再用反向传播进行训练。九、反向传播的“暗礁”梯度消失与梯度爆炸尽管反向传播无比强大但在极深网络中会遇到两个棘手问题梯度消失越靠近输入层梯度越小参数几乎不更新。常见于Sigmoid/Tanh。梯度爆炸梯度指数级增长导致参数更新过大训练崩溃。常用解决方案用ReLU代替Sigmoid梯度不饱和合理的权重初始化如He初始化批归一化Batch Normalization残差连接ResNet——让梯度有一条“高速公路”直达浅层总结反向传播你必须记住这几点反向传播 链式法则在计算图上的应用。它让神经网络能够高效地计算每个参数的梯度。每个层只管自己的局部导数把上游传来的梯度乘上局部导数再往下传。这种模块化设计让搭建复杂网络变得异常简单。加法节点是“分流器”乘法节点是“交换乘”ReLU会“杀死”负值梯度Sigmoid会乘上y(1-y)。Affine层的反向传播涉及矩阵转置和求和注意保持形状匹配。Softmax交叉熵的组合层反向传播梯度就是 y - t优美到让人拍案叫绝。一次前向 一次反向 全部参数的梯度效率远超数值梯度。梯度消失/爆炸是深度网络的敌人但有很多成熟技巧可以应对。反向传播不仅仅是一个算法更是一种思维方式把复杂的优化问题拆解成无数个简单的局部信息传递。当你理解了它你就真正打开了深度学习的大门。希望这篇文章能帮你彻底搞懂反向传播。如果你觉得有用欢迎点赞、收藏、转发让更多人一起进步

更多文章