ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

误差反向传播原理详解:从链式法则到手算实例

误差反向传播原理详解:从链式法则到手算实例 前几天有读者私信我一个问题什么叫误差反向传播我回答完之后发现三两句话根本说不透。这问题在深度学习中属于“地基中的地基”但很多教程一上来就抛公式把“反向传播”讲得像天书一样初学者很容易被吓退。今天我就用一篇完整的文章从概念到原理再到手算一遍带数值的例子把误差反向传播这件事彻底讲清楚。这篇文章适合谁刚接触神经网络、想搞懂梯度下降和反向传播关系的同学以及用了很久TensorFlow/PyTorch、但一直想知道框架底层到底在算什么的开发者。看完之后你可以不看任何框架源码自己手写一个两层的反向传播更新流程也会理解为什么训练时“loss一大一小”跟网络结构、激活函数之间的关系是什么。1. 正向传播是整个反向传播的地基反向传播这个名字里的“反向”对应的自然是“正向”。想彻底搞懂反向先把正向传播的完整路径刻在脑子里否则后面的梯度链会完全找不到方向。1.1 数据在网络里是怎么“走”完一遍的一个标准的全连接网络一次前向计算大概是这样的输入层接收样本特征比如一张28x28的图片展开成784维向量。每个神经元把上一层的输出做加权求和再加上偏置得到一个线性结果 z。线性结果 z 经过激活函数比如 Sigmoid、ReLU变成当前层的输出 a。这个 a 会作为下一层的输入一层层往后传直到输出层。输出层的结果和真实标签做对比算出一个“差距”也就是损失值 L。我习惯把整个前向过程理解成一条工厂流水线。原始原料特征 从进料口进去经过一道道加工工序层每道工序会改动物料的形态激活函数最后生产线末端产出一个成品预测值。质量检测员损失函数拿到成品和标准样品一对比算出“这批货差了多少”这个差值就是损失。1.2 “误差”到底是什么很多人一听到“误差反向传播”第一反应是误差不就是预测值和真实值的差吗对但又不全对。损失函数计算出的误差是整个网络输出层面的总误差。但网络里有很多层每一层的权重都对这个总误差有“贡献”。误差反向传播要做的就是把这个总误差“分摊”回每一层、每一个权重上告诉我们这一次预测差了多少你每个权重该负多少责任。举个例子。一个团队做项目最终搞砸了老板很生气。老板只知道整个项目失败这个总结果但具体是需求分析没做好、代码写崩了还是测试漏了要追责到每个人头上就得从结果一层层往回推。反向传播就是这个“追责”过程。而且这个责任不是一个模糊的定性描述而是一个精确的数字——梯度。梯度大说明这个权重对误差影响大梯度小说明影响小梯度为负说明这个权重把误差推小了是“功臣”该奖励。1.3 为什么不能直接求导就行有人会问神经网络归根结底是一个多层复合函数我对每个权重求偏导不就行了吗理论上是这样但实际操作行不通。一个稍大点的网络参数动辄几百万、几千万甚至上亿。如果你把网络看成一个超级复杂的复合函数要对每个参数直接求导计算量会爆炸。更重要的是很多中间层的参数跟最终输出隔着好几层复合关系直接用数值差分法去逼近每一个参数的导数光是做一次更新就要把前向推理跑无数遍这根本没法用。反向传播的聪明之处在于它把复杂的求导过程拆分成两部分先做一次前向传播记下每一层的中间结果再从输出层开始利用这些中间结果和链式法则把梯度一层层往回传。整个过程里前向计算一次反向计算一次两趟完成所有参数的梯度计算效率完全不在一个量级。2. 反向传播的核心原理链式法则和误差信号理解了前向传播现在我们正式进入反向传播的核心。这一节我会把“误差怎么往回传”的底层逻辑拆开讲清楚。2.1 链式法则才是真正的引擎反向传播的数学基础就是微积分里的链式法则。简单来说如果一个变量经过一系列函数影响到最终结果那么最终结果对这个变量的导数等于顺着影响路径上每一段导数的乘积。这个法则放在神经网络里是什么意思假设有一个两层网络输出是 a损失是 L某个隐藏层权重是 w那么[ \frac{\partial L}{\partial w} \frac{\partial L}{\partial a} \cdot \frac{\partial a}{\partial z} \cdot \frac{\partial z}{\partial w} ]这个式子的含义是权重 w 对最终损失的影响等于损失对输出 a 的影响乘以输出对线性值 z 的影响再乘以 z 对权重 w 的影响。这些都是已经知道结果的东西。前向传播时我们已经算出了 a、z、损失 L所以这些导数都能算出来。真正麻烦的是第一项 (\partial L / \partial a)——当它对应的 a 不是输出层而是隐藏层时你就得往下继续拆。2.2 误差信号反向传播里的核心变量在反向传播的实际计算里我们不会笨拙地直接对每个参数求导而是会定义一个中间量专业名词叫误差信号通常记作 (\delta)。第 l 层的误差信号定义为[ \delta^{(l)} \frac{\partial L}{\partial z^{(l)}} ]也就是损失对当前层线性输出 z 的偏导数。为什么要定义这个量因为一旦算出了某层的 (\delta)这一层所有参数的梯度就能很轻松地得到权重梯度 上一层输出 × 本层误差信号偏置梯度 本层误差信号这个巧妙的递推正是反向传播能高效运行的秘诀先算出输出层的 (\delta)然后利用当前层的权重矩阵把误差信号“映射”回上一层继续计算上一层的 (\delta)一路推进到输入层旁边。每一层只需要做一次矩阵乘法和一次逐元素乘法非常轻快。2.3 输出层和隐藏层的梯度计算差别在哪输出层的 (\delta) 计算是最直接的。假设损失函数是均方误差激活函数是 Sigmoid[ \delta^{(out)} (a - y) \cdot \sigma(z) ]这里 (a - y) 是预测值和真实值的差距(\sigma(z)) 是激活函数在 z 处的导数。为什么是“乘以”而不是“等于”因为误差是损失和预测值之间的差距而预测值本身受到 z 的挤压变形影响sigmoid 的压缩效应中间还隔着一层变换。隐藏层的 (\delta) 则需要“接力”。第 l 层的误差信号 下一层误差信号经过权重回传的结果再乘以本层激活函数的导数[ \delta^{(l)} (W^{(l1)T} \delta^{(l1)}) \odot \sigma(z^{(l)}) ]符号 (\odot) 表示逐元素相乘。这里有个生活化的类比下游工序发现产品有问题于是沿着流水线往上游追责但问题在传到上一道工序前先要经过“该工序的质检标准”过滤一遍——也就是激活函数的导数。如果某一层神经元已经饱和了导数接近0那么无论下游怎么追责这层神经元的责任都很小梯度天然被切断了。2.4 为什么要保存前向传播的中间结果细心的读者会发现计算 (\delta^{(l)}) 时用到了 (z^{(l)}) 和 (a^{(l-1)})这些值都是前向传播时算出来的。所以反向传播不是单独跑一遍它必须依赖前向传播留下的“缓存”。这就是现代深度学习框架里那些“叶子张量”“中间张量”的作用。PyTorch 的requires_gradTrue和 TensorFlow 的GradientTape本质上都是在记录前向传播的计算图。计算图里存的是运算关系和各节点的值反向传播时顺着这张图往回走逐个节点求梯度。所以耗内存也耗在这。理解了这一点你就能理解为什么很多人会在训练前用torch.no_grad()来冻结推理过程因为它们不需要保存中间结果。3. 手把手算一遍最小网络的反向传播全流程讲再多公式不如亲手算一遍。这一节我来带大家走一个最袖珍的神经网络2个输入、2个隐藏神经元、1个输出激活函数全部用 Sigmoid损失用均方误差。我用手算加代码验证把每一步数值都拿出来展示。3.1 网络结构与参数初始化先设定网络结构和初始参数方便后续对账。输入(x_1 0.5)(x_2 0.1)真实标签(y 0.8)学习率(\eta 0.5)从输入层到隐藏层的权重和偏置变量含义初始值(w_{11})(x_1) 到 (h_1) 的权重0.1(w_{21})(x_2) 到 (h_1) 的权重0.2(w_{12})(x_1) 到 (h_2) 的权重0.3(w_{22})(x_2) 到 (h_2) 的权重0.4(b_1)隐藏神经元1的偏置0.05(b_2)隐藏神经元2的偏置0.1从隐藏层到输出层的权重和偏置(v_1 0.5)(v_2 0.6)(b_3 0.15)Sigmoid 函数定义[ \sigma(z) \frac{1}{1 e^{-z}} ]它的导数有一个漂亮的性质[ \sigma(z) \sigma(z) \cdot (1 - \sigma(z)) ]均方误差损失[ L \frac{1}{2}(a_3 - y)^2 ]这里加不加 1/2 都一样因为梯度更新时会和指数 2 约掉纯粹是为了求导公式好看。3.2 前向传播数值计算先算隐藏层[ z_1 0.5 \times 0.1 0.1 \times 0.2 0.05 0.12 ][ a_1 \sigma(0.12) \frac{1}{1 e^{-0.12}} \approx 0.5300 ][ z_2 0.5 \times 0.3 0.1 \times 0.4 0.1 0.29 ][ a_2 \sigma(0.29) \frac{1}{1 e^{-0.29}} \approx 0.5720 ]再算输出层[ z_3 0.5300 \times 0.5 0.5720 \times 0.6 0.15 0.7582 ][ a_3 \sigma(0.7582) \frac{1}{1 e^{-0.7582}} \approx 0.6810 ]最后算损失[ L \frac{1}{2}(0.6810 - 0.8)^2 \frac{1}{2}(-0.119)^2 \approx 0.00708 ]前向传播完成。我们的网络预测值 0.681离真实值 0.8 还差 0.119模型需要学习。3.3 反向传播从输出层开始往回推先算输出层的误差信号[ \delta_3 \frac{\partial L}{\partial z_3} (a_3 - y) \cdot \sigma(z_3) ][ \delta_3 (0.6810 - 0.8) \times 0.6810 \times (1 - 0.6810) \approx -0.119 \times 0.2172 \approx -0.02585 ]这是整个反向传播的起点。它表示输出层的线性值 (z_3) 每增加一点点损失会减少大约 0.02585。有了 (\delta_3)输出层的权重梯度就很好算了[ \frac{\partial L}{\partial v_1} \delta_3 \times a_1 -0.02585 \times 0.5300 \approx -0.01370 ][ \frac{\partial L}{\partial v_2} \delta_3 \times a_2 -0.02585 \times 0.5720 \approx -0.01479 ][ \frac{\partial L}{\partial b_3} \delta_3 \approx -0.02585 ]这些梯度都是负数说明如果把对应的权重调大损失会变小方向是对的。3.4 误差信号继续回传到隐藏层现在要算隐藏层的误差信号 (\delta_1) 和 (\delta_2)。核心公式是“用下一层的误差信号乘以该路径权重再乘以本层激活函数导数”[ \delta_1 \delta_3 \times v_1 \times \sigma(z_1) ]先算 (\sigma(z_1))[ \sigma(z_1) a_1 \times (1 - a_1) 0.5300 \times 0.4700 \approx 0.2491 ]所以[ \delta_1 (-0.02585) \times 0.5 \times 0.2491 \approx -0.00322 ]同理[ \sigma(z_2) 0.5720 \times (1 - 0.5720) \approx 0.2448 ][ \delta_2 (-0.02585) \times 0.6 \times 0.2448 \approx -0.00380 ]到这里你可以看到误差信号在逐层变小从输出层的 -0.02585 衰减到隐藏层的 -0.00322、-0.00380。这个现象就是梯度衰减的雏形信号传到更深层时会越来越弱这也是深度网络难训的一个核心原因后面我会专门展开。3.5 隐藏层权重的梯度与参数更新有了隐藏层的误差信号隐藏层权重梯度随手可得[ \frac{\partial L}{\partial w_{11}} \delta_1 \times x_1 -0.00322 \times 0.5 \approx -0.00161 ][ \frac{\partial L}{\partial w_{21}} \delta_1 \times x_2 -0.00322 \times 0.1 \approx -0.00032 ][ \frac{\partial L}{\partial w_{12}} \delta_2 \times x_1 -0.00380 \times 0.5 \approx -0.00190 ][ \frac{\partial L}{\partial w_{22}} \delta_2 \times x_2 -0.00380 \times 0.1 \approx -0.00038 ]偏置梯度[ \frac{\partial L}{\partial b_1} \delta_1 \approx -0.00322 ][ \frac{\partial L}{\partial b_2} \delta_2 \approx -0.00380 ]梯度全部算完后用梯度下降更新参数[ w_{11}^{new} 0.1 - 0.5 \times (-0.00161) 0.10081 ][ w_{21}^{new} 0.2 - 0.5 \times (-0.00032) 0.20016 ][ w_{12}^{new} 0.3 - 0.5 \times (-0.00190) 0.30095 ][ w_{22}^{new} 0.4 - 0.5 \times (-0.00038) 0.40019 ][ b_1^{new} 0.05 - 0.5 \times (-0.00322) 0.05161 ][ b_2^{new} 0.1 - 0.5 \times (-0.00380) 0.10190 ][ v_1^{new} 0.5 - 0.5 \times (-0.01370) 0.50685 ][ v_2^{new} 0.6 - 0.5 \times (-0.01479) 0.60739 ][ b_3^{new} 0.15 - 0.5 \times (-0.02585) 0.16293 ]更新完之后你可以重新做一次前向传播会得到一个新的预测值它比 0.6810 更接近 0.8。这就是一次完整的训练迭代整个过程包含了一遍前向、一遍反向、一遍参数更新。3.6 用 Python 验证手算结果手算容易出错这里附上用 Numpy 手写反向传播的完整代码你可以自己跑一下对比import numpy as np def sigmoid(z): return 1 / (1 np.exp(-z)) def sigmoid_derivative(a): return a * (1 - a) # 初始化参数 x np.array([0.5, 0.1]) y 0.8 W1 np.array([[0.1, 0.2], # 到 h1 的权重 [0.3, 0.4]]) # 到 h2 的权重 b1 np.array([0.05, 0.1]) W2 np.array([0.5, 0.6]) # 输出层权重 b2 0.15 lr 0.5 # 前向传播 z1 np.dot(W1, x) b1 a1 sigmoid(z1) # [a1, a2] z2 np.dot(W2, a1) b2 a2 sigmoid(z2) # 预测值 loss 0.5 * (a2 - y) ** 2 # 反向传播 delta2 (a2 - y) * sigmoid_derivative(a2) dW2 delta2 * a1 db2 delta2 delta1 (delta2 * W2) * sigmoid_derivative(a1) dW1 np.outer(delta1, x) db1 delta1 # 参数更新 W2 - lr * dW2 b2 - lr * db2 W1 - lr * dW1 b1 - lr * db1 print(第一次前向loss:, loss) print(更新后 W1:, W1) print(更新后 b1:, b1) print(更新后 W2:, W2) print(更新后 b2:, b2)运行这个脚本得到的数值和我手算结果基本一致微小误差来自小数点取位。建议你把这段代码跑通然后手动改一下初始权重再拿笔算一遍很快就能建立完整的直觉。4. 工程上真正在用的向量化实现上面手算的是标量形式方便理解原理。但真实框架里不会一个权重一个权重地去算因为 Python 循环太慢了GPU 也发挥不出威力。工程上用的是矩阵向量化计算。4.1 从标量到矩阵的跳跃把上面的例子扩展到一层包含多个神经元前向传播可以用矩阵一次性算完[ Z^{[l]} W^{[l]} A^{[l-1]} b^{[l]} ][ A^{[l]} \sigma(Z^{[l]}) ]反向传播的四个核心公式也同步矩阵化[ dZ^{[L]} A^{[L]} - Y ][ dW^{[L]} \frac{1}{m} dZ^{[L]} A^{[L-1]T} ][ dZ^{[l]} W^{[l1]T} dZ^{[l1]} \odot \sigma(Z^{[l]}) ][ dW^{[l]} \frac{1}{m} dZ^{[l]} A^{[l-1]T} ]其中 (m) 是批次大小batch size。这里最核心的一步是 (W^{[l1]T} dZ^{[l1]})它把误差信号从第 (l1) 层“传递”回第 (l) 层方式是权重矩阵的转置乘以误差向量。这就是为什么很多框架实现里都有.T这个操作。4.2 小批量训练中的反向传播实际操作中我们几乎不会一次只喂一个样本而是每次喂一个批次比如 32、64、128 个样本。这时输入 (X) 的形状是 ((特征数, batch_size))前向传播要按矩阵方式一次算完一个批次损失通常是这一个批次所有样本损失的平均值。反向传播时(dW) 也是“这个批次的平均梯度”。为什么要用平均而不是求和因为如果不平均梯度的大小会随 batch size 变大而线性增大学习率就得跟着 batch size 去调这会让超参数之间的耦合很严重。平均之后梯度大小跟 batch size 基本无关调参体验会好很多。4.3 和深度学习框架的关系PyTorch 里的loss.backward()到底干了什么一句话执行我上面手算的那套链式法则过程。它内部维护一张动态计算图。比如z x w b会被记录成“乘法加法”两个节点。调用backward()时框架从输出节点出发根据链式法则自动地把梯度传递到所有叶子张量。w.grad里存的就是 (\partial L/\partial w)和你手算出来的值意义一模一样只是它用矩阵运算和 GPU 并行速度远超手写循环。所以你看框架只是把数学过程帮你封装了底层原理和手算完全一致。理解了手算过程再去看框架文档里的 API 名称会觉得亲切很多。5. 常见理解误区和排错经验这块内容是实践过程中最容易踩的坑。我带过不少新人很多问题翻来覆去就那几个点。5.1 梯度消失为什么网络越深越难训我在 3.4 节手算时误差信号从输出层的 -0.02585 变成了隐藏层的 -0.00322缩水了大约 8 倍。如果网络有 10 层、20 层呢信号传到前面几层时会缩小到几乎为 0梯度没法更新前面的层等于在“躺平”。这就是经典的梯度消失问题。为什么会消失主要原因是 Sigmoid 激活函数的导数最大只有 0.25。每经过一层误差信号理论上最多被压缩到原来的 0.25 倍多层连乘下来指数级衰减。这也是后来 ReLU、He 初始化、残差结构被提出来的核心动机。排错经验如果你的深层网络训练时发现靠前的层权重基本不动大概率是梯度消失。解法不是无脑加大学习率会炸后面层而是换激活函数、用合适的初始化、加残差连接或者用归一化技术。5.2 梯度爆炸loss 突然变成 NaN与梯度消失相对梯度爆炸常见于网络层数多、权重初始化过大或者学习率过高的场景。训练到一半 loss 突然变成 NaN多半就是这个原因。我的排查经验是三步走第一步把学习率调小100倍如果不再 NaN说明是学习率问题第二步检查输入数据有没有极端值比如某个特征有 1e10 这种量级的数值直接让加权和爆掉第三步在反向传播后打印一下各层梯度的范数看数值是否正常。如果梯度范数始终在 1 万以上就要考虑梯度裁剪。5.3 为什么有时候梯度为 0但损失还没降到最低Sigmoid 的饱和区会导致局部梯度为 0。比如 z 很大时Sigmoid 输出接近 1导数接近 0z 很小时输出接近 0导数也接近 0。这时无论真实误差有多大权重更新量都微乎其微网络看起来“卡住”了。这也是为什么 ReLU 一族激活函数能流行起来。ReLU 在正半轴的导数恒为 1误差信号能稳定地往浅层传。不过 ReLU 也有自己的问题比如神经元“坏死”如果某个神经元在训练中被激活函数输出为 0 且一直为 0它的梯度永远为 0以后再也无法激活。这就是 LeakyReLU、ELU 等变体存在的意义。5.4 梯度检查手写反向传播时最靠得住的手段如果你自己实现了一个反向传播怎么确认写对了写一个数值梯度检查。原理很简单直接用导数的定义去近似求梯度。[ \frac{\partial L}{\partial w} \approx \frac{L(w \epsilon) - L(w - \epsilon)}{2\epsilon} ]把某个权重先加一个极小值算一次前向损失再减一个极小值算一次前向损失两个损失的差值除以 (2\epsilon)就是这个权重的数值梯度。然后和你的反向传播结果对比。epsilon 1e-7 def numerical_gradient(param, loss_fn): original param.copy() param epsilon loss_plus loss_fn() param original.copy() param - epsilon loss_minus loss_fn() param original.copy() return (loss_plus - loss_minus) / (2 * epsilon)如果两者误差在 1e-6 到 1e-7 量级基本可以认定反向传播写对了。要是误差在 1e-2 甚至更大说明实现有 bug不要急着调学习率先排查代码。我自己的习惯是任何自定义网络结构先在小数据上用梯度检查验证一遍再拉大规模训练。这也帮我避免过无数次“训了很久才发现代码有 bug”的尴尬。5.5 学习率与反向传播的关系反向传播计算的是梯度方向学习率决定沿着这个方向走多远。两者是“方向”和“步长”的关系。学习率太大参数更新幅度过大loss 会震荡甚至发散。学习率太小参数更新幅度过小训练半天 loss 下降缓慢。一个实用的经验是刚开始训练时先观察前几个 epoch 的 loss 变化如果 loss 完全不动学习率太小如果 loss 忽高忽低甚至变成 NaN学习率太大。可以把学习率按对数刻度从 1e-1 到 1e-6 各试几次找到最平滑下降的那个区间。另外反向传播的梯度只是“当前点的切面方向”它没法预测走远之后地形会怎么变。所以学习率策略学习率衰减、Adam 自适应能帮我们更好地应对损失曲面本身的复杂性。5.6 一个容易忽略的细节在反向传播里用错了“上一层输出”这是个新手特别容易犯的错。算 (dW^{[l]}) 时用的是 (A^{[l-1]})上一层的激活输出不是本层的 (A^{[l]})。我自己刚入门时手推代码把这个搞混过一次梯度方向完全不对排查了大半天。建议你把每一层的输入输出在纸上标清楚再写代码能少走很多弯路。另外一个相关细节偏置项的梯度是 (dZ) 逐行求和不是直接等于 (dZ)。在批次训练里每个样本都会对偏置产生一个导数最终梯度要汇总所有样本得到 (隐藏层神经元数,) 形状的梯度向量。这一点在写代码时很容易踩雷。讲到这里反向传播从概念到手算再到底层机制基本都说透了。我个人在实际操作中最大的体会是这个算法真正的精髓不在于那一串公式而在于“利用前向计算缓存把全局优化问题拆解成局部可计算的链式梯度”。理解这一点之后不管以后网络结构怎么变CNN、RNN、Transformer你都能一眼看穿它们在反向传播时的核心逻辑。最后再分享一个小技巧想真正检验自己是否学会了反向传播别用现成框架手写一个两层网络在随机生成的小数据集上跑通训练再用梯度检查验证梯度。这个过程我做过至少五遍每一次都能有新的理解。训练模型翻车的挫败感有时让人想放弃但把这些底层的“为什么”搞清楚后调试问题时会笃定很多。
返回列表