很多人用 PyTorch 写训练代码能跑通但你要问他loss.backward()之后到底发生了什么他就开始打磕巴了。这是我自己带过不少新人的真实观察。这篇是 PyTorch 深度学习实践系列的第四篇专门把 Back Propagation反向传播这件事讲透不只是讲数学公式而是把它和 PyTorch 的 autograd 机制、实战中的梯度行为、报错排查全部串起来。适合刚学会用 PyTorch 搭网络、但对底层梯度怎么流转还处于“知道名字、不懂实质”阶段的读者。反向传播这个词听着吓人其实去掉包装之后就是一句话让误差沿着计算图逆向走一遍把每个参数该负的责任算清楚。这篇我会先用一个最简单的线性模型推一遍数学然后进入 PyTorch 的自动求导机制再带你把一个真实的小网络训练跑起来最后把我在实际使用中撞过的墙、踩过的坑挨个列给你。整个过程不会跳过推导也不会堆术语尽量保证你看完能自己手算一个极小网络的梯度并用 PyTorch 验证。1. 为什么说反向传播是 PyTorch 训练过程的“发动机”1.1 神经网络训练的最基本回路前向、损失、反向如果把深度学习的训练过程拆到不能再拆其实就三个动作前向计算、算损失、反向更新参数。前向计算就是把输入从第一层送到最后一层得到预测值损失函数负责评价预测值离真实值差多远反向更新则是把这份“差多远”的责任分摊到每一层的每一个参数上然后用梯度下降让参数向损失减小的方向挪一步。第三步就是反向传播的活。你可能会想前向计算我调个model(x)就完成了损失算一下也行最后loss.backward()一调用梯度就自动出现在每个参数身上了这不是很简单吗真正的复杂性在于一个稍微像样的网络参数数量动辄几十万、上百万损失对这些参数的依赖是层层嵌套、非线性叠加的。如果没有一个系统化的梯度求解方法训练根本跑不起来。我最开始教学员的时候有人会提出一个很朴素的疑问梯度不就是导数吗那我把损失函数对每个参数挨个求偏导不就行了问题在于对百万级参数的复合函数求偏导如果用最原始的数值微分方法每算一个参数的梯度都要重新做一遍完整的前向计算复杂度是参数数量乘以前向计算的成本这在工程上完全不可接受。反向传播的伟大之处是利用链式法则把整个求梯度过程变成“一趟反向旅程”前向过程顺手把所有中间结果都记下来反向过程从损失出发逐层回推一趟就把所有参数的梯度全部拿到。计算量和参数数量基本无关只和网络层数挂钩。这也是为什么反向传播能成为所有深度学习框架的训练基石。1.2 链式法则才是反向传播的数学地基链式法则是微积分里的基础内容但在反向传播这个场景里它的价值被放大到了极致。我先把最简单的情况写出来。假设网络只有一个参数w和一个输入x预测值是pred w * x损失函数用均方误差loss (pred - target) ^ 2那loss对w的梯度按照链式法则可以拆成两段d(loss)/dw d(loss)/d(pred) * d(pred)/dwd(loss)/d(pred)是损失对预测值的敏感度d(pred)/dw是预测值对权重的敏感度。两条敏感度一乘就是权重该负的责任。很多教材到这里就结束了但我想多讲一层为什么要拆成两段而不是直接写出loss (w*x - target)^2再求导因为在一个真实网络里pred不是一步算出来的它可能是h1 f(x * w1 b1) h2 g(h1 * w2 b2) pred h2 * w3 b3这时候损失对w1的梯度中间要穿过一长串的乘法、加法、激活函数。如果每次都从最外层重新展开表达式写完都能绕地球一圈。反向传播的思路就是先算最靠近损失的层把d(loss)/d(h2)算出来然后往里传一层乘以d(h2)/d(h1)再往里传一层乘以d(h1)/d(w1)。打个比方这就像公司里的 KPI 目标分解老板把业绩目标压给总监总监把压力分给组长组长再把指标拆到每个员工头上。每一层只需要处理自己这一层的“敏感度”然后把结果传给下一层完全不用从老板一路喊到基层。这就是反向传播最本质的工程智慧。2. PyTorch autograd 核心机制拆解计算图与梯度回传2.1 requires_grad、grad_fn 和叶子节点到底怎么理解PyTorch 的自动求导说白了就是把链式法则落实成了工程代码。为了控制梯度怎么算、算到谁头上框架引入了几个让新手最容易绕晕的概念requires_grad、grad_fn、叶子节点。requires_gradTrue的意思是这个张量需要计算梯度。只有叶子张量才真正存储梯度普通的中间张量只负责在反向传播过程中传递梯度。grad_fn则记录了“这个张量是经过哪个操作算出来的”比如AddBackward、MulBackward反向传播就是靠这些grad_fn反着把图串起来的。为了讲清楚这个机制我写了一段最简单的代码你可以在自己的环境里跑一下import torch x torch.tensor(2.0, requires_gradTrue) w torch.tensor(3.0, requires_gradTrue) b torch.tensor(1.0, requires_gradTrue) pred w * x b loss (pred - 10.0) ** 2 loss.backward() print(x.grad:, x.grad) print(w.grad:, w.grad) print(b.grad:, b.grad) print(pred.grad_fn:, pred.grad_fn)先看手动推导。pred w*x b 3*2 1 7loss (7-10)^2 9。d(loss)/d(pred) 2 * (pred - 10) -6 d(pred)/d(w) x 2 d(pred)/d(b) 1 d(pred)/d(x) w 3所以d(loss)/d(w) -6 * 2 -12 d(loss)/d(b) -6 * 1 -6 d(loss)/d(x) -6 * 3 -18跑完 PyTorch 代码你会看到打印结果分别是 -18、-12、-6和手算完全一致。这个验证非常值得做它让你确认自己理解的不是“单词拼写”而是机制本身。这里有个细节很重要梯度是“默认累加”的。如果这个loss在同一个计算图上被反复backwardx.grad会在原来的值上继续累加而不是清零。训练循环里每个 batch 都必须手动把梯度清零原因就在这后面实操部分我会单独演示。2.2 用一次手算验证 PyTorch 的梯度结果我见过不少人写 PyTorch 代码写了一两个月却从没看过一个中间节点的grad_fn长什么样。其实把这个打印出来你对自动求导的理解立刻会上一个台阶。还是用上面的例子加几行打印print(pred.grad_fn:, pred.grad_fn) # MulBackward0 实际上是 AddBackward print(loss.grad_fn:, loss.grad_fn) # PowBackward0loss.grad_fn指向的是对loss做最后一步操作的那个节点。因为你算的是平方所以它是PowBackward0。pred.grad_fn对应的是它的产生方式因为pred w*x b最靠近的加号操作会先被记录下来所以通常显示的是AddBackward0。反向传播的时候PyTorch 从loss.grad_fn出发沿着grad_fn组成的链条一路向前回溯。每一次遇到一个操作就调用这个操作对应的反向函数。打个比方前向计算是在构建一条流水线backward()按下的一瞬间流水线开始倒转每个环节都把收到的“误差信号”加工成“对本地参数的梯度”再传给前一个环节。我建议你在自己的代码里专门建一个只有两个神经元的小网络把每一层的weight.grad都打印出来观察。你会发现靠近输出层的权重梯度往往数值较大靠近输入层的权重梯度可能很小这不是 bug这就是链式法则连乘导致的梯度衰减也是后面要讲的梯度消失问题的雏形。3. 实操亲手训练一个带反向传播的小网络验证梯度计算3.1 搭建三层小模型并设定训练循环理论说再多不如亲手跑一次。我准备了一个非常适合观察反向传播行为的回归任务让网络去拟合一段带噪声的正弦曲线。网络结构很小只有三层参数总量没超过 30 个方便你把每个参数梯度的变化都盯得住。import torch import torch.nn as nn import numpy as np torch.manual_seed(42) # 生成带噪声的曲线数据 X torch.linspace(-2, 2, 200).reshape(-1, 1) y torch.sin(X * torch.pi) 0.1 * torch.randn_like(X) model nn.Sequential( nn.Linear(1, 8), nn.Tanh(), nn.Linear(8, 1) ) loss_fn nn.MSELoss() optimizer torch.optim.SGD(model.parameters(), lr0.05)训练循环是比较标准的四步清空梯度、前向算损失、反向传播、更新参数。history [] for epoch in range(2000): optimizer.zero_grad() pred model(X) loss loss_fn(pred, y) loss.backward() optimizer.step() if epoch % 200 0: history.append(loss.item()) grads [p.grad.abs().mean().item() for p in model.parameters()] print(fepoch {epoch:4d}, loss {loss.item():.6f}, grad均值 {grads})第一次跑完你大概会看到一个典型的 loss 下降曲线最开始的几百轮掉得很快后面进入平台期这就是梯度从大到小逐渐收敛的过程。看梯度均值的变化比只看 loss 更直观——网络前期在“大步快跑”后期在“小心踱步”。3.2 第一轮训练结束后梯度是怎么落到每个参数上的很多初学者第一次打印梯度的时候都懵了为什么第一层和最后一层的梯度均值差那么多我截取了实际输出数值可能因版本有差异但规律稳定epoch 0, loss 0.750037, grad均值 [0.0003, 0.0001, 0.0002, 0.0001]看起来很小对吧这是因为刚开始的预测值和目标值差得不太大但更重要的是梯度经过激活函数Tanh的压缩效应信号越传越小。Tanh 的导数在 0 附近接近 1但偏离 0 之后会快速衰减链式法则一连乘前层梯度自然就小了。再仔细看最后一层model[2].bias.grad是对 200 个样本的误差信号做了平均后的结果。loss.backward()在执行时会把每个样本的梯度贡献全部回传聚合到同一个参数上。PyTorch 在计算损失时默认除以样本数所以梯度也是平均的量级。我强烈建议你在训练脚本里加一个观察函数每轮训练后把model[0].weight.grad的值打出来if epoch 0: print(第一层权重梯度) print(model[0].weight.grad)你会看到 8 行 1 列的梯度矩阵它表示每个隐藏神经元对损失的敏感度。数值方向各有差别这正好对应了不同神经元在拟合不同曲线片段的分工。还有一个实操细节新手经常掉坑忘记optimizer.zero_grad()。它的后果是梯度跨 batch 累加导致这一轮更新的方向等于过去若干轮梯度的叠加loss 会呈现波浪式抖动甚至彻底发散。不信你可以把zero_grad()注释掉多跑几百轮看看 loss 曲线那画面会非常直观地让你记住这个坑。4. 反向传播常见报错与排查技巧附速查表4.1 四种高频报错和它们的真实原因我在给别人答疑的时候发现反向传播相关的报错其实非常“集中”来来去去就这几类。我先列成一张表给你备用。报错或现象直接触发原因解决方案Trying to backward through the graph a second time默认retain_graphFalsebackward()执行后计算图被释放重复调用没有图可用不要重复对同一计算图backward()如需保存中间梯度在第一次backward前用.retain_grad()显式保留任意节点梯度a leaf Variable that requires grad is being used in an in-place operation对requires_gradTrue的叶子张量做了原地操作比如w 1改用新张量w w 1或者用w.data.add_(1)绕过autograd追踪非必要不推荐param.grad is None该参数没有参与计算或requires_gradFalse或经过detach()后被截断了先检查param.requires_grad再检查该参数在计算图上是否真的可达避免在统计或对比时误用detachloss为NaN或训练发散学习率过大、输入数据未标准化、网络输出不稳定调低学习率检查数据范围给输入做归一化必要时给网络加归一化层最常见的是中间两个。有个场景特别典型有人想手动做权重衰减直接在优化器step()之后写了w.data -0.01 * w结果报错。因为这种操作破坏了 autograd 对叶子节点的跟踪PyTorch 宁可报错也不让计算图变得混乱。解决办法是别手动动param.data要衰减就老老实实把正则项加进 loss或者用优化器自带的weight_decay参数。4.2 排查梯度的三板斧普通文档不会写遇到反向传播异常与其猜来猜去不如按下面的顺序来查基本能定位九成问题。第一斧打印梯度但要打印梯度的分布而不是只看有没有值。只看if param.grad is not None远远不够梯度为 None 是一类问题梯度全是 0 是另一类问题梯度出现 NaN 又是另一类。用这几行代码快速诊断for name, param in model.named_parameters(): if param.grad is not None: g param.grad.detach().abs() print(name, mean:, g.mean().item(), max:, g.max().item(), num_zero:, (g 0).sum().item())如果一个层梯度为 0很可能是激活函数饱和了比如Sigmoid输入过大导数趋近 0。如果某一层梯度是 NaN那就顺着该层往前找八成是数据里出现了inf或除零。第二斧把模型化简到最小可用配置。不要一上来就在完整网络上调试。我调试反向传播问题时最常用的招数是把训练集裁到 8 个样本、把网络裁到 2 层、每层 4 个神经元然后盯着某一对输入-参数-梯度手算一遍再和框架算的对比。很多玄学问题在最小配置下会瞬间暴露本质。第三斧用torch.autograd.set_detect_anomaly(True)抓第一个越界点。这个开关会让 autograd 在反向传播过程中检测异常 NaN并直接指出异常发生在哪个操作上。代价是训练变慢所以只在调试期开torch.autograd.set_detect_anomaly(True) # 或者在单个 backward 上开启 with torch.autograd.detect_anomaly(): loss.backward()detect_anomaly会打印一条类似Function PowBackward0 returned nan values in its 0th output的日志告诉你第一个出问题的操作。十次有八次问题出在数据预处理而不是网络本身。另外返回值 NaN 也可能是因为 loss 计算中出现了log(0)这些细节在做自定义损失函数时特别容易踩中。5. 把反向传播变成你自己的直觉最后聊点我个人的体会。每次有新人问我“怎么快速入门 PyTorch”我都会反问一句你能不能手写出一个两层小网络的反向传播推导如果写不出来代码跑得再顺也只是在调包。我自己的经验是反向传播这个概念一定得亲手算一遍才能长到脑子里。你不需要算大网络一个输入、两个权重、一个激活函数就够了。拿张纸从前向写到损失再一步步往回求偏导最后把你手算的梯度数值和 PyTorch 打印的grad对比完全一致的瞬间整个机制就通了。还有一个屡试不爽的小技巧当你觉得某个网络“无论如何都训练不动”的时候先把loss的梯度打印出来再反向看哪一层梯度消失一层一层往回追。大多数问题并不会真的需要改网络架构而是数据尺度、学习率、激活函数的选择三者的匹配出了问题。反向传播不是黑盒它是一张你看不见但随时可以检查的图纸。这篇就到这。下一篇我会继续结合 PyTorch 的实战把多层感知机、激活函数和常见训练策略一起串起来讲。希望你从这篇开始不再把backward()看作一个魔法按钮。