资讯动态

手推反向传播:从公式到NumPy实现

发布时间:2026/9/16 3:17:49 来源:尧图企业网站定制
反向传播这四个字几乎是每个学深度学习的人绕不开的第一座山。网上讲它的教程车载斗量可大多数不是直接用PyTorch调包就是甩出一堆符号推导让人看得云里雾里。我当初学的时候也踩过这个坑后来认认真真手推了一遍公式再用numpy从零撸了一个两层网络整个过程走完之后很多之前想不明白的地方一下子通了。这篇博文我打算把当时手推和实现的完整过程复刻一遍。内容包括反向传播的本质理解、单样本到批量的梯度推导、numpy代码的每一行细节以及我实际调试中踩过的坑。适合刚学完线性代数和Python基础、正准备啃神经网络的读者也适合那些调框架调了很久、想搞清楚梯度到底怎么传的朋友。看完你不仅能徒手写出一个可训练的BP网络还能理解为什么反向传播解决不了“局部最小值”这种经典误区。1. 先把反向传播这件事想清楚1.1 反向传播到底是什么不是什么反向传播Backpropagation简称BP是训练神经网络时计算参数梯度的一套方法。神经网络里参数动辄成千上万如果每次都靠解析推导一个一个手算复杂网络根本不可能完成。BP利用链式法则把损失函数对输出的梯度从最后一层往输入方向逐层传递每一层只需要计算“局部梯度”并乘以上游传下来的梯度就能得到这一层参数的梯度。但这里必须先说清楚一个关键点反向传播不是一个优化算法它只是梯度计算引擎。真正让参数更新的是梯度下降或其变体。很多人问“反向传播可以解决梯度下降局部最小值的问题吗”答案是不能。反向传播负责告诉你该往哪个方向走但至于这个方向通向的是局部最小值还是全局最小值它管不着也管不了。跳出局部最小值要靠随机初始化、动量、小批量随机性、Adam这类自适应学习率算法这些跟BP是两码事。1.2 为什么需要反向传播想象一下你手动对一个两层网络求梯度。假设输入维度是1隐藏层100个神经元输出1个值参数共100个权重加100个偏置再加1个输出权重和1个输出偏置一共202个参数。手推一次梯度勉强可以可当网络变成VGG、ResNet那种几百层的结构手动推导和手写梯度代码就是天方夜谭。数值微分倒是不用推导公式它用 (f(xε)-f(x-ε))/(2ε) 近似梯度但每个参数都要做两次前向传播参数量是100万时就要做200万次前向训练一次慢得离谱。反向传播的高明之处在于一次前向传播算损失一次反向传播把梯度全部算完总计算量大约是两次前向的量级跟参数数量是线性关系这在工程上是质的飞跃。1.3 从生活类比理解BP的传播逻辑你可以把反向传播想象成一条工厂流水线的反向质检。产品从原料经过多道工序变成成品最终质检发现不合格损失很大。要改善产品你得从最后一道工序往前查这道工序对不合格有多大责任上一道工序又间接贡献了多少每一道工序只需要接受下游传来的“责任值”再按自己的加工方式把它转成“原材料端的责任值”传给上游。在神经网络里这个“责任值”就是误差信号误差对某层输入的梯度工序参数就是每个神经元带的学习参数。理解了这条链条“反向传播”这个名字也自然通了误差从输出端反向流回输入端途经每一层参数时顺便把该参数的梯度记录下来。2. 手推反向传播从单神经元到两层网络2.1 网络结构与符号约定为了让推导有实际落点我用一个最简单的两层全连接网络做例子。输入 X 维度是 (m, n_in)经过隐藏层得到 (m, n_hidden)再经过输出层得到 (m, n_out)。为了直观我先用单个样本推导最后再改成矩阵形式。设输入 x ∈ R^n_in隐藏层权重 W1 ∈ R^(n_in×n_hidden)偏置 b1输出层权重 W2 ∈ R^(n_hidden×n_out)偏置 b2。隐藏层用sigmoid激活输出层是线性输出回归任务。单个样本的均方误差损失写作L (1/2) ||y - a2||²前面加1/2是为了求导方便2次方求导会乘2正好抵消。注意这是数学推导的写法后面写代码时是否保留这个1/2会直接影响梯度公式里的系数。2.2 前向传播公式前向过程按顺序是z1 x W1 b1 a1 sigmoid(z1) z2 a1 W2 b2 a2 z2这里 a2 就是模型的预测输出。需要强调的一点是sigmoid 激活函数在后向推导里是个关键“关卡”因为它的导数形式极其简洁sigmoid(z) sigmoid(z) * (1 - sigmoid(z))。这个性质让它成为BP时代最天然的激活函数但也是后面梯度消失的重要隐患我后面会详细说。2.3 反向传播的完整推导现在从损失开始反向推目标是求出 ∂L/∂W1、∂L/∂b1、∂L/∂W2、∂L/∂b2。第一步求输出层权重的梯度。根据链式法则∂L/∂W2 (∂L/∂z2) · (∂z2/∂W2)先算 ∂L/∂z2。因为 a2 z2且 L (1/2)(y - a2)²对 z2 求导就是δ2 ∂L/∂z2 a2 - y这里 δ2 就是输出层的误差信号它代表“输出离目标差多远”。有了 δ2输出层权重的梯度就是∂L/∂W2 a1ᵀ δ2∂L/∂b2 δ2第二步求隐藏层权重的梯度。这里要把梯度从 z2 传到 a1 再传到 z1∂L/∂a1 δ2 W2ᵀ∂L/∂z1 (∂L/∂a1) ⊙ sigmoid(z1) (δ2 W2ᵀ) ⊙ a1 ⊙ (1 - a1)令 δ1 (δ2 W2ᵀ) ⊙ a1 ⊙ (1 - a1)于是∂L/∂W1 xᵀ δ1∂L/∂b1 δ1到这里反向传播的骨架已经清晰了输出层的误差信号 δ2 根据当前层的连接权重“分配”回隐藏层隐藏层再把误差信号经过激活函数导数“过滤”一次就得到传到更前一层的信号。这就是“误差反向传播”字面上的含义。如果网络更深就重复第二步的操作δ_{k} (δ_{k1} W_{k1}ᵀ) ⊙ σ(z_k)每向前一层误差信号就被“过滤”一次。2.4 从单个样本推广到批量数据实际训练时不会一个一个样本算那太慢了通常用一个小批量mini-batch。批量计算时输入 X 变成 (m, n_in) 的矩阵上面公式里所有向量变成矩阵但形式几乎不用改Z1 X W1 b1 A1 sigmoid(Z1) Z2 A1 W2 b2 A2 Z2δ2 (A2 - Y) / m 除以m是因为损失函数对所有样本取平均 ∂L/∂W2 A1ᵀ δ2 ∂L/∂b2 np.sum(δ2, axis0, keepdimsTrue)δ1 (δ2 W2ᵀ) ⊙ A1 ⊙ (1 - A1) ∂L/∂W1 Xᵀ δ1 ∂L/∂b1 np.sum(δ1, axis0, keepdimsTrue)注意这里有个容易踩坑的细节如果是单样本损失能写成 L 0.5||y-a2||²那么批量平均损失是 L (1/m) ∑_{i1}^m 0.5||y_i - a2_i||²求导后 δ2 (A2 - Y) / m没有任何额外的2系数了因为0.5在求导时已经抵消。如果代码里损失函数直接用 np.mean((A2-Y)*2)它就相当于 L (1/m)∑||y-a2||²这时候 δ2 2(A2-Y)/m梯度公式就要多一个系数2。很多博客和代码在这个细节上混着写导致读者对不上号。我自己的习惯是统一用带0.5的写法推导和代码一一对应不容易乱。3. numpy实现代码比想象中简单3.1 初始化参数小随机数是关键理论推完了接下来用numpy把上面的公式一行行变成可运行代码。参数初始化有一个重要原则权重初始值必须是小随机数不能用全零偏置可以初始化为零。为什么不能全零如果所有权重初始化为0那么同一层每个神经元的输入完全一样梯度也完全一样这意味着所有神经元都成为“对称的复制品”无论怎么训练都不会分化网络的表达能力被死死限制住这个现象叫“对称性破坏失败”。用随机数打破这种对称性每个神经元才能学到不同的特征。初始值也不能太大否则信号在经过每个神经元时都会被放大或缩小层数多了之后梯度要么爆炸要么消失。常见做法是标准正态分布乘以一个较小的缩放系数比如0.1或更小import numpy as np def initialize_parameters(n_in, n_hidden, n_out, seed42): rng np.random.default_rng(seed) W1 rng.standard_normal((n_in, n_hidden)) * 0.1 b1 np.zeros((1, n_hidden)) W2 rng.standard_normal((n_hidden, n_out)) * 0.1 b2 np.zeros((1, n_out)) return W1, b1, W2, b2注意这里用 np.random.default_rng 而不是旧的 np.random.rand 或 np.random.randn。新版numpy推荐使用随机数生成器实例化对象它生成的随机序列更均匀也更便于复现传 seed 后每次跑出来的结果完全一致。3.2 前向与反向的完整代码前向传播非常直观就是按照公式从左往右算def sigmoid(x): return 1 / (1 np.exp(-x)) def sigmoid_derivative(s): return s * (1 - s) def forward(X, W1, b1, W2, b2): Z1 X W1 b1 A1 sigmoid(Z1) Z2 A1 W2 b2 A2 Z2 cache (X, Z1, A1, Z2, A2) return A2, cache接着是最核心的反向传播函数。这里每一行都对应手推出来的一个公式def backward(Y, cache, W1, W2): X, Z1, A1, Z2, A2 cache m X.shape[0] # 输出层误差 dZ2 A2 - Y dW2 A1.T dZ2 / m db2 np.sum(dZ2, axis0, keepdimsTrue) / m # 隐藏层误差 dA1 dZ2 W2.T dZ1 dA1 * sigmoid_derivative(A1) dW1 X.T dZ1 / m db1 np.sum(dZ1, axis0, keepdimsTrue) / m return dW1, db1, dW2, db2这里再强调一下前面说的系数问题我的损失函数设计中单个样本贡献是0.5倍的平方误差所以 dZ2 就是 A2-Y这是理论上最干净的写法。代码里除 m 是因为误差对所有样本取了平均。你完全可以换一种损失定义但必须保证前向计算的损失值和后向计算的梯度在数学上自洽否则训练表现会很诡异。3.3 参数更新与训练循环参数更新用最简单的随机梯度下降SGD每个参数减去学习率乘以梯度。学习率是另一个关键超参数太大会导致损失震荡甚至发散太小会让训练慢得令人发指。我通常先试0.1看损失曲线再调整def train(X, Y, n_hidden4, lr0.1, epochs2000, seed42): n_in, n_out X.shape[1], Y.shape[1] W1, b1, W2, b2 initialize_parameters(n_in, n_hidden, n_out, seed) loss_history [] for epoch in range(epochs): A2, cache forward(X, W1, b1, W2, b2) loss np.mean(np.square(A2 - Y)) / 2 loss_history.append(loss) dW1, db1, dW2, db2 backward(Y, cache, W1, W2) W1 - lr * dW1 b1 - lr * db1 W2 - lr * dW2 b2 - lr * db2 if epoch % 500 0: print(fepoch {epoch}, loss {loss:.6f}) return W1, b1, W2, b2, loss_history训练结束后拿一个简单数据集就能验证效果。我常用来做测试的是从两个高斯分布采样的二维二分类问题输入是两个坐标值输出是0或1。隐藏层设4个神经元就够了损失会从初始值平滑下降最后收敛到一个比较低的值。3.4 用真实数据验证代码真的在学东西上面代码如果只是跑通了还不能证明反向传播算对了梯度。一个很有效的验证办法是拿一个已知答案的简单函数去拟合。比如让网络去学会 y sin(x1 x2) 这种非线性映射生成几百个样本训练完后看预测值和真实值的散点图是否重合。如果训练过程中出现损失下降后突然升高或者损失卡住不动那基本可以肯定是梯度算错了常见的原因包括符号写反、矩阵乘法顺序反了、漏除m等。另一个更严谨的方法是梯度检查拿参数的解析梯度跟数值梯度对比。所谓数值梯度就是直接通过微扰定义近似计算∂L/∂θ ≈ (L(θε) - L(θ-ε)) / (2ε)实现起来很简单def numerical_gradient(X, Y, W1, b1, W2, b2, epsilon1e-5): grads [] eps epsilon for param in [W1, b1, W2, b2]: grad np.zeros_like(param) it np.nditer(param, flags[multi_index]) while not it.finished: idx it.multi_index old_val param[idx] param[idx] old_val eps loss1, _ forward(X, W1, b1, W2, b2) loss1 np.mean(np.square(loss1 - Y)) / 2 param[idx] old_val - eps loss2, _ forward(X, W1, b1, W2, b2) loss2 np.mean(np.square(loss2 - Y)) / 2 grad[idx] (loss1 - loss2) / (2 * eps) param[idx] old_val it.iternext() grads.append(grad) return grads然后对比解析梯度和数值梯度之间的相对误差通常小于1e-6就说明推导和代码完全一致。把这一步做通之后你对公式和代码的对应关系就有了绝对的信心之后无论换成什么激活函数或损失函数都能顺着同一套思路验证。4. 实现中的细节与边界情况4.1 维度不对齐新手最容易崩的地方用numpy做矩阵运算最让人崩溃的就是形状对不上。我列一下上述代码里所有核心矩阵的形状变量形状说明X(m, n_in)输入数据W1(n_in, n_hidden)隐藏层权重b1(1, n_hidden)隐藏层偏置Z1, A1(m, n_hidden)隐藏层线性输出与激活输出W2(n_hidden, n_out)输出层权重b2(1, n_out)输出层偏置Z2, A2(m, n_out)最终输出dW1(n_in, n_hidden)与W1同形db1(1, n_hidden)与b1同形dW2(n_hidden, n_out)与W2同形db2(1, n_out)与b2同形如果报错 “shapes (m,n_hidden) and (n_out,n_hidden) not aligned”十有八九是 A1.T dZ2 和 dZ2 W2.T 这里的转置搞反了。我的经验是推导时把公式写在纸上代码每个矩阵操作都回头核对公式里的维度检查运算符左右两边能乘得起来结果维度是不是跟参数一致。花10分钟做这个检查能省下1小时调试。4.2 sigmoid的数值稳定性问题np.exp(-x) 在 x 非常大时会发生溢出虽然numpy会给出 inf 而不是报错但 inf 经过后续运算会变成 nan整个网络就废了。处理办法有两个一是把输入数据做标准化让z1的取值落在合理范围二是在激活函数里做个截断保护比如对 x 0 和 x 0 分段计算。具体实现可以这样def sigmoid_safe(x): return np.where(x 0, 1 / (1 np.exp(-x)), np.exp(x) / (1 np.exp(x)))这种写法对正负输入都稳定因为 x 为负数时np.exp(x) 是把大指数放到分母里不会溢出。这个细节在深层网络中尤其重要浅层网络可能看不出区别但只要层数一多一个 nan 就能毁掉整个训练。4.3 梯度检查的边界条件梯度检查也不是万能药它有自身的使用限制。第一数值梯度计算本身耗时只适合在小网络、几百个训练样本上做验证大模型不可能每步都跑。第二epsilon 不能取得太大也不能太小太大了近似误差很大太小了浮点数精度不够一般用1e-5到1e-6。第三梯度检查应该在训练之前做而不是训练之后。因为训练之后权重已经接近最优解损失函数表面变得很平数值梯度在这种区域误差会显得偏大误导判断。我自己的习惯是在代码里写一个 check_gradients 函数每次修改网络结构或损失函数之后先跑一遍确认梯度无误再正式训练。这算是一种“工程护栏”确保后续所有实验都建立在正确的基础上。5. 常见问题与排查技巧实录5.1 “反向传播能解决局部最小值吗”的正确理解这个问题在搜索引擎里被问得非常多但它本身就是个理解误区。局部最小值是损失函数表面的几何特征而反向传播只是计算梯度的方法两者根本不在一个层面。反向传播可以精确算出当前的梯度方向让参数沿着下降方向走但如果起点恰好落在局部最小值附近梯度为零或接近零参数就卡住了。真正用来应对局部最小值的手段是随机初始化让不同起点尽可能覆盖不同区域、动量项积累历史梯度方向冲过小坑、Adam等自适应学习率算法根据每个参数的梯度历史调整步长以及SGD本身在批量采样时引入的随机噪声。这些都是优化策略层面的东西跟BP这个梯度计算器没有替代关系。我训练网络时确实遇到过损失停在0.3附近死活下降不了的情况改成带Momentum的优化器之后几轮训练就突破了平台期这个经历就很能说明问题。5.2 训练过程中损失变成nan或inf损失变成nan或inf是最吓人的场景往往一排epoch打印出来全是nan让人当场想删库。常见原因有三个第一是学习率太大梯度更新过头参数直接飞到损失函数的“荒蛮地带”梯度进一步爆炸形成恶性循环。排查方法很简单把学习率调小三个数量级比如从0.1改成0.0001如果损失恢复正常说明就是学习率的锅。第二是前面提到的sigmoid数值溢出问题激活函数的输入z太大导致exp溢出梯度计算后出现nan。解决办法是数据标准化加数值稳定版sigmoid。第三是梯度爆炸隐藏层权重更新幅度随着层数逐级放大这让深层网络的训练变得非常困难。虽然我这篇博文用的是浅层网络但你理解了这个问题后面看ResNet、BatchNorm这些trick就更有体会。5.3 训练效果好但测试效果差如果训练损失一路下降但拿到测试集上预测一塌糊涂这是典型的过拟合。网络隐藏层神经元数量过多时最容易出现因为模型容量大到足以“背下”训练样本却学不到泛化规律。缓解方法有很多增大训练数据量、加L2正则化把权重的平方和加到损失里、加Dropout训练时随机屏蔽部分神经元。这些技巧通常在层数较深的网络中效果更明显但网络容量较小也不代表一定不会过拟合还是要靠实验数据的表现来判断。我的经验是从“能学到东西”到“学得好”是需要一步步调出来的不要指望一次到位。5.4 numpy版本兼容问题的避坑经验跑深度学习相关的手写代码numpy版本也是个隐形坑。比较经典的一个报错是AttributeError: module numpy has no attribute float这是因为numpy 1.24之后移除了 np.float、np.bool 这类Python内置类型的别名一些旧代码或者依赖旧接口的第三方库就会直接原地爆炸。如果你在用最新版的numpy遇到这种错误解决方案有两种把numpy降到1.23.x版本或者把代码里的 np.float 改成 float。更推荐的思路是升级代码而不是降级环境因为新项目持续用旧版本会让后续依赖陷入泥潭。在Ubuntu环境下安装指定版本的numpy也很容易一条命令就能完成pip install numpy1.23.5如果同时要装科学计算全家桶推荐先安装Anaconda或Miniconda它自带的conda环境管理能避免很多依赖冲突比单纯用pip在系统环境里硬装要省心得多。最后说点实在的手推反向传播再加numpy实现这件事我做完之后最大的感受是以前调框架时觉得“梯度是模型自动算好的我只要调学习率就行”现在再看整个黑箱都透明了。你清楚每一步梯度是从哪个方向传来、经过哪个激活函数被压缩了多少、学习率到底在调节哪个环节的步幅这些感知对后续学习更复杂的模型结构特别有帮助。往后你可以在这个两层网络基础上继续扩展把sigmoid换成ReLU试试效果把SGD换成带动量的版本加一个Dropout层或者换成交叉熵损失做多分类任务。每改一处都按梯度检查的思路去验证我敢说你收获会远超看一百篇别人写的分析文章。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价