
写 Python 神经网络代码敲到第十二篇我越来越觉得很多人对反向传播的态度就是“知道大概别让我推”。框架里一个 backward() 就把梯度全算出来了训练得不好就调学习率实在不行换网络结构总之很少人会去怀疑梯度本身是不是算错了。这篇我打算逆着来回到最朴素的三层网络用 Python 把反向传播从前向后、从后向前完整刷一遍再做数值梯度校验最后顺手聊一个经常被问到的问题人脸图像进入神经网络之后是怎么变成高维向量的。如果你已经会调框架但不懂梯度细节或者正在准备手推 BP我建议先放下那些“反向传播详解 PDF”直接跟着这篇把代码跑起来收获会大得多。1. 为什么都第十二篇了还要回头手推反向传播1.1 框架帮我们省掉的恰是最容易出错的地方现在的深度学习框架把自动微分封装得极其友好。你在模型里定义好 forward写一句 loss.backward()所有参数梯度就齐了。但问题也在这里一旦训练不收敛你根本不知道拿到的梯度是不是对的那个。这种“黑箱排查”特别消耗时间。我见过不少同学遇到 loss 不降第一反应是调学习率第二反应是改网络结构很少有人会先验证梯度计算本身。因为梯度错误不会报错只会让损失曲线诡异。我们做一个类比自动挡汽车开久了不一定知道发动机怎么工作但至少要会看仪表盘知道踩油门和刹车分别是什么效果。反向传播就是神经网络的仪表盘。如果你只把框架当成黑箱出了问题就只能靠猜。而自己动手用 Python 推一遍梯度相当于把仪表盘的接线图摸清了后面排查问题时会快很多。这一节的内容属于“必要的时候能拆开仪表盘看看”不是让你以后不用框架。1.2 前向是算值反向是传梯度要理解反向传播脑子里要有一个对立直觉前向传播负责算数值反向传播负责传梯度。输入数据进入网络经过每一层的矩阵乘法、激活函数最后得到一个预测值再和真实标签比较得到损失。这个过程就是从输入到输出的“快递分发”。反向传播则是从输出端出发把“误差信号”沿着相同路径反向送回到每个参数决定每个参数该往哪个方向调整。我经常跟人讲计算图比公式直观。每个节点保存两个东西正向的输出值和反向收到的梯度。正向阶段你在每个节点做基础运算反向阶段你用链式法则把一个节点的梯度拆成上游梯度乘以局部导数继续往后传。这个思路和理解微积分里的复合函数求导一模一样只是规模大了很多。一旦你建立起“前向传数值、反向传梯度”的计算图直觉后面看任何网络结构都不会再害怕因为无非是节点多了、路径长了。1.3 网络记号约定为了不把时间浪费在记符号上我们固定一个足够小的例子。输入层 2 个节点隐藏层 3 个节点输出层 1 个节点。权重 W1 的形状是 3×2W2 是 1×3偏置 b1 是 3×1b2 是 1×1。激活函数全部用 sigmoid损失函数用均方误差。为什么不直接拿一个复杂网络来演示因为小到每一行计算都能手算验证才是搞懂反向传播的最佳规模。等这个例子推明白换到任意层数的网络只是矩阵行列变多数学本质完全一样。提示sigmoid 的好处是导数写起来简单f(x)f(x)(1-f(x))在反向传播推导里特别直观。缺点是容易饱和后面会提到这个坑。2. 用 Python 手推一个三层网的梯度2.1 构造一个能验证的数值样例我们构造一个不能再小的训练样例输入 x(0.5, -0.2)期望输出 y0.7。参数初值如下W1 第一行是 [0.1, -0.2]第二行 [0.3, 0.4]第三行 [-0.1, 0.5]b1 取 [0.05, -0.03, 0.02]W2 取 [0.6, -0.5, 0.2]b2 取 -0.01。这些数字没有精心求解只是随手写的但写死了方便你复现。前向计算分四步z1 W1 x b1a1 sigmoid(z1)z2 W2 a1 b2a2 sigmoid(z2)最后 loss 0.5 * (a2 - y)^2。用上面的参数手算一遍大约得到 loss ≈ 0.0132。这里不追求用计算器核对每一步小数更值得关注的是反向传播如何算出与参数相同形状的梯度。你会看到每个梯度矩阵和对应权重矩阵的 shape 严格保持一致。2.2 输出层到隐藏层的误差传递反向传播第一步是算输出层的误差信号 delta2 (a2 - y) * a2 * (1 - a2)。这里的 (a2 - y) 是损失对 a2 的导数a2 * (1 - a2) 是 sigmoid 在 z2 处的导数。两者相乘就是损失函数对 z2 的梯度。为什么把它叫“误差信号”因为直觉上它同时包含了预测偏差和激活函数饱和程度。预测偏得越多信号越大激活越接近 0 或 1信号被压得越小这对应“饱和神经元学习慢”的现象。接下来 dW2 delta2 a1.Tdb2 delta2。这里的矩阵顺序不能乱delta2 形状是 1×1a1.T 形状是 1×3乘出来正好是 1×3和 W2 一致。如果你不小心写了 a1 delta2形状就错了框架会报错但你还是能侥幸在有些地方看不出问题。所以要养成习惯梯度形状必须和参数形状一致这是一个强有力的 sanity check。2.3 隐藏层误差与输入层梯度隐藏层误差 delta1 (W2.T * delta2) * a1 * (1 - a1)。注意这里的点乘法是逐元素乘不是矩阵乘法。W2.T 的形状是 3×1delta2 是 1×1广播后得到每个隐藏节点从输出层分到的误差再乘上本层 sigmoid 导数。这个式子里的关键就是把 W2 转置把输出误差“按照连接权重摊回”隐藏层。这种转置操作在神经网络里反复出现理解了它你就理解了连接权重的对称性。有了 delta1梯度就全部出来了dW1 delta1 x.Tdb1 delta1。输入层没有激活函数所以它的“局部导数”就是 1不用额外乘。到这里一个三层网络的所有解析梯度都已经显式算完。我们把上面过程组合成完整代码接下去可以运行并观察每个梯度值。2.4 完整代码与输出解读下面是完整的手推代码没有用任何自动微分库全部用矩阵运算完成前向和反向import numpy as np def sigmoid(z): return 1 / (1 np.exp(-z)) x np.array([[0.5], [-0.2]]) y np.array([[0.7]]) W1 np.array([[0.1, -0.2], [0.3, 0.4], [-0.1, 0.5]]) b1 np.array([[0.05], [-0.03], [0.02]]) W2 np.array([[0.6, -0.5, 0.2]]) b2 np.array([[-0.01]]) # 前向传播 z1 W1 x b1 a1 sigmoid(z1) z2 W2 a1 b2 a2 sigmoid(z2) loss ((a2 - y) ** 2) / 2 # 反向传播 delta2 (a2 - y) * a2 * (1 - a2) dW2 delta2 a1.T db2 delta2 delta1 (W2.T * delta2) * a1 * (1 - a1) dW1 delta1 x.T db1 delta1 print(loss:, loss) print(dW2 shape:, dW2.shape, value:, dW2) print(db2:, db2) print(dW1:, dW1) print(db1:, db1.reshape(-1))运行这段代码你会看到 loss 是一个很小的标量dW2 是 1×3dW1 是 3×2刚好和对应参数形状一致。这些数值后面就是要用来和数值梯度对比的解。注意我这里特意没有把损失除以样本数因为只有一个样本均方误差除以 2 只是为了消掉求导后出现的系数 2不影响梯度方向。如果你的真实任务里用了批量数据记得梯度计算也要对应除 batch size否则学习率语义会完全改变。3. 数值梯度校验验证手推结果是否靠谱3.1 数值梯度原理与为什么值得信赖手推的梯度对不对最直接的办法是用导数的定义去逼近。对任意标量函数 f(θ)θ 是某个参数它的数值梯度可以写作 (f(θε) - f(θ-ε)) / (2ε)。这个双侧差分公式比单侧差分 (f(θε)-f(θ))/ε 误差小一个数量级是校验代码的首选。它的原理就是导数定义只是把极限换成一个小步长 ε通常在 1e-5 到 1e-7 之间。数值梯度让人信任的原因很简单它不依赖任何反向传播推导只需要前向计算。只要前向计算正确数值梯度就接近真实梯度。因此把解析梯度和数值梯度做对比等于给手推的反向传播做了一次体检。数值梯度的问题是慢每算一个参数都要重新跑一次前向所以只适合小网络、小样本场景真正训练大模型不会用它但用来验证还是很有价值。3.2 用 Python 实现数值梯度数值梯度的实现思路很直白先把所有参数摊开对每个位置做 ε 和 -ε 两次前向拿到两个损失值相减除以 2ε然后还原参数。下面是一个直接修改原参数对象的实现因为我们的网络很小这样写最直观def compute_loss(params, x, y): W1, b1, W2, b2 params z1 W1 x b1 a1 sigmoid(z1) z2 W2 a1 b2 a2 sigmoid(z2) return ((a2 - y) ** 2) / 2 def numerical_grads(x, y, W1, b1, W2, b2, eps1e-6): grads [] for param in [W1, b1, W2, b2]: g np.zeros_like(param) it np.nditer(param, flags[multi_index], op_flags[readwrite]) while not it.finished: idx it.multi_index old param[idx] param[idx] old eps loss_plus compute_loss((W1, b1, W2, b2), x, y) param[idx] old - eps loss_minus compute_loss((W1, b1, W2, b2), x, y) param[idx] old g[idx] (loss_plus - loss_minus) / (2 * eps) it.iternext() grads.append(g) return grads num_grads numerical_grads(x, y, W1, b1, W2, b2, eps1e-6) for name, ng in zip([W1, b1, W2, b2], num_grads): print(name, ng)这段代码里的 np.nditer 是遍历参数每个元素的一种方式你可能更习惯用两个 for 循环效果一样。重点是修改参数前把旧值保存起来测试完一定要恢复原值否则参数被污染后续训练全乱。这种低级错误我踩过不止一次所以建议在循环外面最后把参数恢复一遍或者每次用副本。3.3 对比解析梯度和数值梯度的误差判据拿到解析梯度和数值梯度后判断两者是否一致不能只看小数点后几位因为不同参数的量纲差异很大。正确的做法是算相对误差。常见的判据是相对误差小于 1e-7 说明梯度计算基本没问题在 1e-4 到 1e-7 之间若网络比较深或用了 ReLU 这类非光滑激活函数也可能算正常大于 1e-4 则要仔细检查实现。相对误差公式可以写成 np.abs(g_analytical - g_numerical) / np.maximum(np.abs(g_analytical) np.abs(g_numerical), 1e-12)。加一个很小的分母是为了防止两个梯度都接近 0 时除零。拿上面的解析梯度和数值梯度跑一遍你会得到类似下面的对比表参数解析梯度数值梯度相对误差dW2[0][0]-0.02156-0.02155约 4.6e-06dW2[0][1]-0.02062-0.02061约 4.1e-06dW1[0][0]-0.00301-0.00301约 5.2e-06这里的数值是示意实际运行结果可能因为浮点精度有小差别但相对误差都会在 1e-6 左右足够说明反向传播实现正确。我特别喜欢这个环节因为它是可验证的只要你的前向计算对数值梯度不会骗人如果解析梯度对不上你不需要猜直接定位到具体参数元素。3.4 校验时最容易掉的坑数值梯度校验有几个非常隐蔽的坑。第一个是 ε 的选择。ε 太大差分公式的截断误差大ε 太小浮点舍入误差会吃掉差值。一般选 1e-6 到 1e-7 比较安全。第二个是使用了不光滑的激活函数。ReLU 在 x0 处不可导数值梯度如果用双侧差分会同时取到左右两侧的斜率得到平均值而解析梯度按约定一般取一侧为 0两者在 0 附近会差异很大。解决办法是避开这些特殊点或者在测试时换成 sigmoid/tanh 这种处处可导的激活函数。第三个是我亲身踩过的把参数扰动写到原地后忘了恢复导致后续迭代的 loss 一直沿着扰动后的参数计算。比如你在循环里写 param[idx] eps算完没改回来接着下一个参数的前向就已经带着前一个参数的污染值了。所以务必在每次测试后恢复或者整体用参数副本。第四个是梯度形状和参数形状不匹配。框架会报错但你自己写时可能只是打印出来看着没问题实际某个维度弄反了。每次算完梯度都用 assert g.shape param.shape 检查一遍这是最廉价又最有效的习惯。4. 梯度爆炸与梯度消失训练不收敛的真正原因4.1 一个直观案例权重过大引发的 NaN把上面例子里的初始权重 W1 整体乘以 5比如从均值 0.2 变成 1 左右再跑一次训练你很可能看到 loss 一开始就变成 NaN。原因很简单前向传播的数值越来越大sigmoid 的导数越来越小反向传播时梯度要么连续乘激活函数导数要么连续乘权重结果不是指数式放大就是指数式缩小。梯度爆炸的典型信号是 loss 变成 NaN梯度消失的典型信号是 loss 卡死。我印象很深的一次调试用一个小网络做回归loss 在前几个 epoch 下降到 0.1 后就不动了怎么看都觉得代码没错。后来打印每一层梯度均值发现第一层梯度的量级是 1e-8输出层却还在 1e-2这就是典型的梯度消失。原因出在 sigmoid 的饱和区深层网络经过好多层激活导数一路乘下去前面的参数根本收不到有效的梯度信号。4.2 权重初始化比调学习率更值得先动手遇到不收敛很多人先调学习率但我的建议是先检查权重初始化再调学习率。因为学习率只能控制更新的幅度不能解决信号传递本身的问题。一组接近 0 的初始权重会让所有神经元的激活都一样反向传播的梯度也会退化到非常小一组过大的初始权重会让激活进入饱和区同样没梯度。初始化要做的是让每一层输出的方差保持在合理范围。对于全连接网络常见的做法是 Xavier 初始化从均匀分布 U(-a, a) 里采样a sqrt(6/(fan_in fan_out))。它适合 sigmoid/tanh 这类激活函数能保证前向和反向的方差近似不变。对于 ReLU 激活一般用 He 初始化从均值为 0、标准差为 sqrt(2/fan_in) 的正态分布采样。为什么是 fan_in因为 ReLU 会把负半轴置 0输出方差约损失一半所以用 2 来补偿。这个补偿系数在推导时可能觉得小但实际影响很大。4.3 用 Python 看每一层梯度的“健康度”别等到 loss 不降才去怀疑梯度训练过程中就应该观察梯度的量级。在不引入复杂框架的情况下最简单的做法是在每次迭代后打印每一层梯度矩阵的均值、标准差和最大最小值。如果发现第一层梯度的标准差比最后一层小了三四个数量级说明梯度传递有问题。有个很实用的经验梯度标准差应该在 1e-3 到 1e-2 附近如果小于 1e-5基本可以判断网络学不动。下面是一个简短的检查片段def check_grads(grads): for name, g in grads.items(): print(name, std:, round(np.std(g), 8), max:, round(np.max(np.abs(g)), 8))如果你看到 W1 的 std 是 1e-8W2 是 1e-2先不要急着调学习率回去把初始化换掉。另外对于深层网络还可以配合残差连接、归一化技术改善但那已经超出了本篇的“从零开始”范围。本篇至少让你知道问题出在哪一层。5. 顺带聊透图像是怎么变成高维向量的5.1 从图像到特征向量的两条路最近总有人拿人脸识别高频话题来问我一张图片进入神经网络之后到底怎么变成一个高维向量的其实这条路径并不神秘。对于卷积神经网络图像从输入层开始卷积层在局部区域提取边缘、纹理、颜色块等低级特征池化层保留主要响应并缩小空间尺寸越到后面的卷积层感受野越大能提取“有没有眼睛”“脸型轮廓”这类语义特征。最后接全连接层将特征图拉平成向量再经过输出层得到最终的向量表示。在图像分类里这个向量的每个位置对应类别得分在人脸识别里最后一层往往不再是分类得分而是一个约定维度的特征向量比如 128 维或 512 维。输出这样一组数字本身没有意义意义在于度量关系同一个人的两张照片向量之间的距离应该很小不同人的照片向量之间的距离应该很大。所以训练过程会设计相应的损失函数让网络学习出“有区分度”的特征空间。5.2 用 Python 观察中间层输出与向量相似度虽然我们这里的例子只是三层全连接网络但它依然可以当作一个微型特征提取器。训练好之后把某个输入传到网络取隐藏层激活 a1 作为中间层向量然后计算不同输入的向量夹角。夹角越小表示两个样本在隐藏层空间里越接近。计算余弦相似度的代码如下def hidden_vector(x): z1 W1 x b1 a1 sigmoid(z1) return a1.reshape(-1) def cosine_similarity(v1, v2): return v1 v2 / (np.linalg.norm(v1) * np.linalg.norm(v2)) v1 hidden_vector(np.array([[0.5], [-0.2]])) v2 hidden_vector(np.array([[0.4], [-0.1]])) print(cosine_similarity(v1, v2))这里 W1、b1 要换成训练后的参数否则随机初始化下的向量没有语义。不过即使不训练这个代码也能让你直观看到“中间层输出是一个向量”。很多面试题喜欢问高维向量的维度灾难我的回答通常是维度越高表达能力越强但同时对距离度量和样本数量的要求也更苛刻。所以优质的人脸识别模型会用特殊损失来约束特征分布比如让同类聚集、异类分开。5.3 高维向量的局限与梯度训练的关系高维向量看起来酷但也有代价。在高维空间里欧氏距离和余弦相似度对特征的每个分量都很敏感一点点噪声会被放大。因此训练特征向量的过程本质上就是在反向传播的引导下不断调整高维空间里的几何形状。这就回到了这篇的主题如果梯度计算有问题网络学出来的特征向量就会“挤成一团”或者“分布错乱”后面再怎么调相似度阈值都救不回来。所以你现在应该能理解为什么很多调参高手见面第一句就问“梯度正常吗”。数值梯度校验、梯度分布检查、合理的初始化这些不起眼的功夫反而比刷一堆网络结构更能决定模型上线后的表现。我自己这几年也是踩过不少坑才慢慢把手推梯度当成常规武器。希望这篇能给你一个能落地的工具箱。最后说点个人的体会。很多初学者收藏了一堆反向传播详解 PDF却从不自己亲手写一遍梯度。这个习惯不改看再多资料也会在真实 debug 时卡住。我现在的习惯是每次自己实现一个新的网络层都会顺手写一个数值梯度校验函数跑一遍再放手。虽然会很慢但它能在我盲目调参前先排除“梯度算错”这个最致命的错误。你也不妨试试把手推梯度变成肌肉记忆比什么都管用。