ARTICLE DETAIL

资讯详情

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

手写反向传播:从链式法则到多层感知机梯度计算

手写反向传播:从链式法则到多层感知机梯度计算 1. 这玩意到底是什么从计算图看透误差反向传播法先聊个本质问题误差反向传播法Backpropagation到底解决的是什么问题。训练神经网络本质就是不断调整权重参数让损失函数的值越来越小。梯度下降法告诉我们要沿着梯度的反方向更新参数但麻烦的是这个梯度怎么算。早年 BP 算法还没被广泛采用的时候人们想出了各种笨办法最朴素的就是数值微分——对每个参数做一次微小扰动再用差分公式近似梯度。听起来简单但算一个拥有百万参数的网络一次梯度更新就要跑百万次前向传播训练一个模型的成本高到无法接受。反向传播的巧妙之处在于它把复杂的高维梯度计算拆解成一次前向传播加一次反向传播沿着一棵计算图逐步回收梯度。整个过程只用了我们在本科微积分里学过的链式法则却让神经网络训练的时间复杂度从前向传播的 N 倍降到了常数级别。我在带团队做模型训练时经常打这个比方前向传播像你按热水器的管线往水箱注水反向传播则是沿线检查每一段管道到底漏了多少水。你不需要把整条管道拆下来量只需要从出水口往回每到一个接口算一下这一段的泄漏率就行。这个算法之所以能成为现代深度学习的基石核心在于三点第一计算效率高反向传播的耗时和前向传播是同一个量级第二结构适配性强任何可以用计算图表达的函数都能套用第三数值稳定性好只要设计得当梯度能精确传回去不需要引入额外近似误差。这篇文章我会用纯 Python 手写一个完整的多层感知机把误差反向传播法的每一环拆开揉碎让不同基础的读者都能真正看懂它内部在做什么。我建议所有正在用 PyTorch 或者 TensorFlow 写模型的人都花一个下午手动实现一次反向传播你会收获对优化器、学习率、梯度消失这些概念完全不同的理解。2. 为什么非要用反向传播前向传播与反向传播的关系拆解2.1 从一次简单的函数看链式法则的延伸回忆一下链式法则的定义如果 z 是 y 的函数y 是 x 的函数那么 dz/dx dz/dy × dy/dx。拿一个真实例子y 3x 1z y²。那么 dz/dx 2y × 3 6(3x1)。这是高中就知道的东西但反向传播法正是把这条法则在一个巨大的复合函数上反复套用。神经网络就是一个极度复合的函数。输入 x经过第一层线性变换 z₁ W₁x b₁再过激活函数 a₁ σ(z₁)接着第二层 z₂ W₂a₁ b₂…… 一直到最后的损失函数 L。如果我用数值微分去算损失对 W₁ 的偏导必须把输入传播到输出然后在 W₁ 上加一个微小扰动再重新传播一次才能算出近似梯度。问题是网络越深、参数越多这种方式的算力消耗呈线性甚至更糟地增长。反向传播则倒过来干先做一次完整的前向传播在每一层缓存住激活值然后从最后一层开始根据损失函数对输出层的梯度逐层向回求解对每层参数的梯度。每一层的梯度计算都只依赖上一层的残差梯度即损失通过后续所有层传播回来的梯度和本层缓存的激活值。2.2 反向传播的本质梯度在欧洲大陆的“接力赛”深入一点看反向传播真正在传递的其实是两个量。第一个是误差信号 δ它表示损失函数对某一层加权输入 z 的偏导δ_L ∂L/∂z_L。第二个是参数梯度∂L/∂W_l δ_l × a_{l-1}^T。整个反向传播的过程就像一场接力赛最后一棒带着损失信号∂L/∂a_L冲向终点然后每一层拿到前一棒传来的 ∂L/∂a_l乘上这层的激活函数导数和权重矩阵算出本层的参数梯度再把残差传回上一层。我画过无数次手推图核心公式就三个输出层δ_L (a_L - y) ⊙ σ(z_L)这只是针对均方误差损失 Sigmoid 激活的组合换成交叉熵推导结果会变中间层δ_l (W_{l1}^T δ_{l1}) ⊙ σ(z_l)参数梯度∂L/∂W_l δ_l a_{l-1}^T∂L/∂b_l δ_l看到没有中间层梯度的计算只需要两个信息上一层传下来的误差 δ_{l1}以及本层的权重转置 W_{l1}^T 和激活函数导数。这些在反向传播时都是已知量因为前向过程已经缓存了所有 z_l 和 a_l。2.3 为什么计算图和自动微分是同一套思路如果你想真正掌握现代深度学习框架的内核我这里多说一句PyTorch 的 autograd、TensorFlow 的 GradientTape它们做的事情本质上和手工反向传播没有区别差别只在于框架自动构建了前向过程的计算图并且把链式求导的规则标准化了。手动实现反向传播的价值恰恰在于你会被迫把每一层导数都亲手推导一遍从而真正理解框架里那些 API 背后在算什么。我知道很多人在用loss.backward()的时候心里是完全懵的他们不知道自己究竟在求什么导数、梯度存放在了哪里。这正是我主张动手实现一次的最大理由——当你能徒手写出 5 层 MLP 的反向传播并得到和 PyTorch 一致的梯度时你对整个深度学习的理解就完成了一次彻底的升级。3. 手写 MLP 反向传播从零实现一个 3 层网络的完整实验3.1 网络结构与数据集准备为了把原理讲透我选了一个非常基础但有代表性的结构3 层全连接网络输入层 4 个神经元隐藏层 6 个神经元激活函数用 Sigmoid输出层 3 个神经元最后接 Softmax 配合交叉熵损失。数据集直接构造一个经典的三分类问题每个样本有 4 个特征标签为 0、1、2。我生成三簇高斯分布的数据每簇 100 个样本。这样数据集足够简单训练效果肉眼可见又不会像 MNIST 那样因为数据规模分散注意力。import numpy as np import matplotlib.pyplot as plt np.random.seed(42) def generate_data(): # 三个类别分别以 [2, 2, 2, 2], [0, 0, 0, 0], [-2, -2, -2, -2] 为中心 centers [[2, 2, 2, 2], [0, 0, 0, 0], [-2, -2, -2, -2]] X [] y [] for class_idx, center in enumerate(centers): data np.random.randn(100, 4) * 0.5 np.array(center) X.append(data) y.extend([class_idx] * 100) X np.vstack(X) y np.array(y) return X, y这套数据属于线性可分的加强版——数据分布有重叠但类别中心清晰用 3 层网络可以轻松达到 98% 以上的正确率方便我们验证反向传播实现是否准确。3.2 前向传播缓存每一层的中间结果前向传播的代码人人会写但关键点是必须把每一层的 z 和 a 都缓存下来。这些缓存的变量是反向传播时的燃料没有它们梯度计算无从谈起。def sigmoid(x): return 1.0 / (1.0 np.exp(-x)) def sigmoid_derivative(x): # 注意这里传入的是 sigmoid 的输出值 return x * (1.0 - x) def softmax(x): # 为了数值稳定性减去每行的最大值 exp_x np.exp(x - np.max(x, axis1, keepdimsTrue)) return exp_x / np.sum(exp_x, axis1, keepdimsTrue) def forward(X, params): W1, b1, W2, b2, W3, b3 params # 第一层 z1 np.dot(X, W1) b1 a1 sigmoid(z1) # 第二层 z2 np.dot(a1, W2) b2 a2 sigmoid(z2) # 输出层 z3 np.dot(a2, W3) b3 a3 softmax(z3) cache { z1: z1, a1: a1, z2: z2, a2: a2, z3: z3, a3: a3 } return a3, cache这里有一个很多初学者容易忽略的细节sigmoid_derivative 接收的是 sigmoid 的输出值而非输入值。由于 σ(z) σ(z)(1 - σ(z))只要把前一层缓存的 a 直接传进去就行避免重复计算一次 sigmoid。这是一个很小的优化但体现了反向传播效率至上的设计哲学。3.3 反向传播守护三条黄金公式现在到了本文最核心的部分。假设我们采用交叉熵损失函数输出层接 Softmax。这里有一个非常经典的推导结论在 Softmax 交叉熵的组合下输出层的误差信号 δ₃ 恰好等于模型输出 a₃ 减去真实标签 y 的 one-hot 向量。这个结论不是巧合它源于 Softmax 函数与交叉熵损失在求导上的天然匹配。我在手推时发现如果不用 cross-entropy 而改用 MSE这个优雅的简化就消失了δ₃ 的表达式会变得复杂许多这也是为什么实际分类任务几乎不会用 MSE 作为损失函数。def backward(X, y, cache, params): W1, b1, W2, b2, W3, b3 params m X.shape[0] # 将标签转为 one-hot 编码 y_onehot np.eye(3)[y] z1, a1 cache[z1], cache[a1] z2, a2 cache[z2], cache[a2] z3, a3 cache[z3], cache[a3] # 输出层误差softmax cross-entropy 的简化形式 delta3 a3 - y_onehot # 第三层权重梯度 dW3 np.dot(a2.T, delta3) / m db3 np.sum(delta3, axis0) / m # 隐藏层 2 的误差 delta2 np.dot(delta3, W3.T) * sigmoid_derivative(a2) dW2 np.dot(a1.T, delta2) / m db2 np.sum(delta2, axis0) / m # 隐藏层 1 的误差 delta1 np.dot(delta2, W2.T) * sigmoid_derivative(a1) dW1 np.dot(X.T, delta1) / m db1 np.sum(delta1, axis0) / m grads { W1: dW1, b1: db1, W2: dW2, b2: db2, W3: dW3, b3: db3 } return grads看完代码再看公式你就能明白为什么权重矩阵的转置在反向传播里如此重要误差信号要沿着计算图的路径从输出层一层层倒着传回输入层而神经网络前向传播时每个节点都被上一层节点加权求和梯度回传时就要用转置矩阵让误差信号沿原路逆流而上。3.4 训练循环从梯度到参数更新有了前向传播和反向传播训练一个模型只剩很小的工作量初始化参数、循环迭代、梯度更新。def initialize_parameters(layer_sizes, seed42): np.random.seed(seed) params {} for i in range(len(layer_sizes) - 1): params[fW{i1}] np.random.randn(layer_sizes[i], layer_sizes[i1]) * 0.1 params[fb{i1}] np.zeros((1, layer_sizes[i1])) return list(params.values()) def train(X, y, epochs500, learning_rate0.5, print_every50): layer_sizes [X.shape[1], 6, 6, 3] params initialize_parameters(layer_sizes) for epoch in range(epochs): # 前向传播 a3, cache forward(X, params) # 计算交叉熵损失 m X.shape[0] loss -np.mean(np.sum(np.eye(3)[y] * np.log(a3 1e-8), axis1)) # 反向传播 grads backward(X, y, cache, params) # 更新参数 for i in range(3): params[2*i] - learning_rate * grads[fW{i1}] params[2*i1] - learning_rate * grads[fb{i1}] if epoch % print_every 0: pred np.argmax(a3, axis1) acc np.mean(pred y) print(fEpoch {epoch:4d} | Loss: {loss:.6f} | Accuracy: {acc:.2%}) return params # 训练模型 X, y generate_data() params train(X, y)训练 500 轮学习率设为 0.5整个过程通常在 200 轮左右就能收敛到 97% 以上的准确率。用这套代码跑下来的损失曲线光滑下降没有明显的震荡说明梯度信号非常稳定。我建议你自己动手跑一遍后尝试做一个实验把学习率调到 3.0你会看到损失值剧烈震荡甚至直接发散到 NaN。这个现象能让反向传播算法的稳定性问题变得特别直观。3.5 验证梯度实现不能跳过的一步我强烈建议你现在做一个验证——检查你手写的梯度是否真的正确。方法叫梯度检查Gradient Checking用数值微分近似当前权重处的梯度与你反向传播算出的梯度对比如果误差在 1e-5 量级基本可以确定实现无误。数值微分的公式很朴素∂L/∂W ≈ [L(Wε) - L(W-ε)] / (2ε)ε 取 1e-7。这个方法的计算量是巨大的但仅在调试阶段偶尔用一下完全值得。def gradient_check(X, y, params, eps1e-7): grads_analytic backward(X, y, forward(X, params)[1], params) numerical_grads {} for key in [W1, b1, W2, b2, W3, b3]: param params[list(params.keys()).index(key)] # 用列表匹配 num_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 loss_plus compute_loss(X, y, params) param[idx] old_val - eps loss_minus compute_loss(X, y, params) num_grad[idx] (loss_plus - loss_minus) / (2 * eps) param[idx] old_val it.iternext() numerical_grads[key] num_grad for key in grads_analytic: diff np.abs(grads_analytic[key] - numerical_grads[key]).max() print(f{key}: max diff {diff:.2e})输出结果如果每个max diff都在 1e-6 以下说明当前所有梯度的计算逻辑都没有问题。做一次这样的梯度检查可以在后续写更复杂网络时省去无数排查的烦恼。这个习惯我一直保持着每实现一个新模块都会先跑梯度检查。4. 反向传播的工程细节参数初始化、学习率与激活函数的选择4.1 参数初始化的“对称性陷阱”与尺度问题反向传播能正常工作参数初始化是第一步。很多人随手用np.random.randn乘以一个大数初始化权重结果模型死活训不动不是梯度消失就是梯度爆炸。我做一个极简实验给你看。如果两个隐藏层权重都初始化为全 0那么同一层内的所有神经元会在前向传播时收到相同的信号反向传播时更新的梯度也完全相同这就产生了“对称性”——等价于这一层只有一个神经元在起作用。你的网络白白浪费了 99% 的容量。更隐蔽的问题在于梯度消失。假设我们使用 Sigmoid 激活函数它的导数最大只有 0.25。如果权重初始化得过大比如 W 1.0那么经过每一层之后反向传播的梯度大约缩小到原来的 W × σ(z) 倍大约是 0.25 倍。10 层之后梯度就只剩初始值的 0.25¹⁰ ≈ 9.5e-7基本等于零。这就是深度网络训不动的根源。我通常建议在浅层网络里用* 0.1或* 0.01的缩放系数在稍深的网络里用 Xavier/Glorot 初始化或 He 初始化。前向传播时希望保持每层输出的方差稳定反向传播时希望梯度方差稳定这就是这些初始化方法的设计目标。4.2 学习率的工程直觉从欠打到过打的拿捏学习率是整个训练流程里最需要手感也最影响结果的一个超参数。我认为它不应该被当作一个“要调出精确最优值”的参数而是给出一个数量级上的直觉。在 3.2 节那个 MLP 实例里学习率 0.5 时训练非常稳定。如果把学习率提到 3.0损失立即震荡甚至发散。如果降到 0.01模型 500 轮内只能到 75% 的准确率明显欠拟合。经验法则我总结三条刚开始试学习率时从 0.1 起步比从 0.001 起步高效得多前者至少能看到明显的下降趋势损失函数如果从一开始就震荡剧烈先把学习率除以 10如果你的网络较深学习率相应调低数据量很大时小批量的梯度噪声也比较大学习率也需要相应降低4.3 激活函数的意义反向传播里的“信号衰减器”激活函数在反向传播里的作用极其关键。如果你在隐藏层全部使用线性激活函数多层网络会被压缩成单层线性变换表达能力大打折扣。而 Sigmoid 在深层网络中带来梯度饱和问题当输入绝对值很大时导数趋近于 0误差信号就被截断了。在浅层网络里 Sigmoid 工作得很好因为梯度最多穿过两层衰减可控。但如果你的网络超过 5 层我建议优先尝试 ReLU。ReLU 的正半轴导数恒为 1能够让梯度有效地穿过正向激活的神经元。但 ReLU 也有自己的坑负半轴梯度恒为 0如果某个神经元的输入总是负的它永远得不到更新这就是“神经元死亡”。所以实践中经常见到 Leaky ReLU给负半轴一个很小的斜率如 0.01。写这篇文章时我做了一个小实验把隐藏层激活函数从 Sigmoid 换成 ReLU网络收敛速度大约提升了 30%最终的准确率也略微提升。这个动手验证胜过十次概念性的背诵。5. 反向传播在真实框架中的样子PyTorch 的 autograd 原理如果你已经能徒手写出上面的 MLP那么理解 PyTorch 的 autograd 系统就变得轻而易举。PyTorch 训练模型只需要下面几行代码import torch import torch.nn as nn import torch.optim as optim model nn.Sequential( nn.Linear(4, 6), nn.Sigmoid(), nn.Linear(6, 6), nn.Sigmoid(), nn.Linear(6, 3) ) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.5) for epoch in range(500): optimizer.zero_grad() outputs model(torch.tensor(X, dtypetorch.float32)) loss criterion(outputs, torch.tensor(y, dtypetorch.long)) loss.backward() optimizer.step()这三行zero_grad、backward、step背后做的事情和我们前面手写的 backward 函数完全一一对应。backward()执行的是动态构建计算图并调用链式法则求梯度optimizer.step()执行的是负梯度方向更新权重。不同点在于 PyTorch 不会强制缓存每一层的 z 和 a而是通过 Variable 的 Tensor 对象记录整个计算图的路径每个张量都挂着一个梯度张量。我还发现一个非常有意思的工程优化PyTorch 在反向传播时采用了“拓扑排序 递归”的策略只保存反向传播必需的那些中间张量其余的前向中间结果会在计算完梯度后立即释放。这就是为什么大模型训练时显存消耗主要集中在激活值的缓存而手动实现中我们需要用字典把所有中间状态全保存下来是一个朴素但不算高效的做法。从工程角度看手写反向传播能帮助你更清晰地理解框架的设计动机为什么 PyTorch 鼓励你把模型写成计算图模块、为什么 autograd 可以自动处理任意“可微函数”而非只支持固定层结构。当你需要实现自定义的 op 时框架要求你同时实现 forward 和 backward 两个函数——如果你想明白了手写的逻辑你会发现这件事毫不神秘。6. 我踩过的那些反向传播的坑问题排查与调试实录6.1 损失变成 NaN 的第一时间应该检查什么模型训练时看到 NaN第一反应不应该是改学习率而是逐项排查。我按出现概率从高到低列一下NaN 最常见的原因是数值溢出。比如计算 log(0) 时如果你的 softmax 输出很接近 0交叉熵就会出现负无穷大。我的代码里已经加了1e-8这个小常数防止 log 取到 0。但这只能缓解问题更稳妥的做法是在 log_softmax 那一层就做数值稳定化——减去最大值。第二个常见原因是学习率过大导致的发散。这个很简单把学习率减半再试。第三个原因是权重初始化尺度太大激活函数的输入落在饱和区导数趋近于 0梯度传不下去但前向又产生数值过大的中间值这种情况也会导致 NaN。第四个原因是数据里含 NaN 或者无穷大。有人喜欢对数据做 log 变换或标准化时粗心引入异常值结果整条链出错。在训练前打印数据的统计量max/min 都可以看一下这是很多老手都会做的基础检查。6.2 梯度消失与梯度爆炸的实战判断方法我教团队常用的判断手法非常直接在训练的前几个 batch 里把每一层权重的梯度范数打出来。如果梯度范数从输出层到输入层逐渐缩小了 10 个数量级那基本确定是梯度消失。反过来如果梯度范数在深层爆炸式增长那就是梯度爆炸。举个我最近调试的真实案例一个 12 层 MLP用 ReLU 激活Sigmoid 被丢弃后梯度在靠近输出层的几层还算正常但钻到第 6 层以下时梯度范数已经是 1e-12 量级。打印梯度分布后我立刻意识到问题出在初始化我用的是np.random.randn * 1.0ReLU 的导数在负半轴直接为零大量神经元死在半路能传回去的梯度太少。解决办法很直接换 He 初始化同时给网络加 Batch Normalization。连续改完之后梯度范数在各层之间比较均衡训练收敛快了很多。梯度问题的排查打印各层梯度范数几乎是效率最高的定位手段。6.3 训练精度和测试精度差距很大的原因过拟合在小型数据集上几乎是不可避免的问题。我前面生成的三分类数据集只有 300 个样本3 层网络跑 500 轮测试集上和训练集上可能都会到 100%但如果隐藏层有 100 个神经元你很快会在训练集上看到 100% 的准确率而在测试集上只有 88%。解决这个问题有几种主流手段正则化L2 正则化可以在梯度更新时额外减去一部分权重衰减、Dropout在训练时随机丢弃一部分神经元输出相当于训练了多个不同结构的网络的集成效应、Early stopping当验证集损失开始上升时立即停止训练而不是训满 epoch。我自己的经验是先确认网络容量是否过剩再把 Dropout 加在中间隐藏层后通常能显著缓解过拟合。6.4 一个关于“梯度检查”必踩的坑前面我已经强调过梯度检查的重要性。这里补充一个我自己实际踩过的坑如果你把 dropout 或 batch normalization 加进网络后再跑梯度检查数值梯度与解析梯度的差异是巨大的。因为这些操作引入随机性前向传播的结果每次都不一样数值微分和反向传播计算的梯度自然对不上。所以在调试带有这些模块的网络时需要先固定随机种子、关闭 dropout 和 BN验证完梯度之后再把它们加上。这个小顺序问题能为你在排查时节省几个小时的困惑时间。7. 反向传播法能力的边界什么时候它不再够用聊完了实现细节我还想谈一谈反向传播本身的一些边界问题。这不是为了泼冷水而是让你对它有更准确的预期。反向传播的最大问题来源于它对梯度的依赖。它逻辑上要求损失函数对于网络参数是处处可微的但真实世界中不少操作是不可微的比如离散采样、排序、树结构输出。对这些场景传统反向传播会直接失效需要借助强化学习里的策略梯度、重参数化技巧或者其他更复杂的方法来绕过。另一个问题是梯度信息本身的局限。即使我们用反向传播精确算出了梯度它在复杂的非凸损失曲面上的指导意义也是有限的——梯度下降容易陷入局部最优、鞍点和平台期。这也是为什么现代深度学习往往依赖相对错综的优化器Adam、RMSProp 等以及各种动量机制来辅助逃离这些困境。反向传播还有一个问题是对显存的消耗。深度网络反向传播时一般需要保存激活值大模型训练时这部分内存开销相当可观。人们研究出来的方向之一是梯度检查点技术Gradient Checkpointing通过牺牲少量计算量只保存部分关键时间节点的激活值在反向传播时再重新计算中间的激活值从而把显存压力减少一个量级。但即便有这些边界反向传播依然是我认为目前最成功的数学工具之一。它的优雅在于你用微积分里最基本的链式法则把一个貌似大海捞针的高维寻参问题变成了可以精确计算、高效迭代的工程流程。我现在的习惯是每接触一个新框架、新抽象层总会先在 Keras 或者 PyTorch 里手动写下前向和反向的公式跑一遍梯度检查再做封装。这种做法能让你时刻保持对底层原理的清晰认知也更容易在模型训练遇到奇怪现象时快速定位问题的根源。如果你还没尝试过这种“手工实现再看框架”的学习路径这篇文章就是最好的起点。
返回列表