☰
PyTorch反向传播与autograd机制详解:从手算梯度到异常排查
2026/10/22 14:32:16 网站建设 项目流程

很多人用 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)/dw

d(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_grad=True的意思是:这个张量需要计算梯度。只有叶子张量才真正存储梯度,普通的中间张量只负责在反向传播过程中传递梯度。grad_fn则记录了“这个张量是经过哪个操作算出来的”,比如AddBackward、MulBackward,反向传播就是靠这些grad_fn反着把图串起来的。

为了讲清楚这个机制,我写了一段最简单的代码,你可以在自己的环境里跑一下:

import torch x = torch.tensor(2.0, requires_grad=True) w = torch.tensor(3.0, requires_grad=True) b = torch.tensor(1.0, requires_grad=True) 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 = 7,loss = (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在同一个计算图上被反复backward,x.grad会在原来的值上继续累加,而不是清零。训练循环里每个 batch 都必须手动把梯度清零,原因就在这,后面实操部分我会单独演示。

2.2 用一次手算验证 PyTorch 的梯度结果

我见过不少人,写 PyTorch 代码写了一两个月,却从没看过一个中间节点的grad_fn长什么样。其实把这个打印出来,你对自动求导的理解立刻会上一个台阶。

还是用上面的例子,加几行打印:

print("pred.grad_fn:", pred.grad_fn) # <MulBackward0> 实际上是 AddBackward print("loss.grad_fn:", loss.grad_fn) # <PowBackward0>

loss.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(), lr=0.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(f"epoch {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_graph=False,backward()执行后计算图被释放,重复调用没有图可用不要重复对同一计算图backward(),如需保存中间梯度,在第一次backward前用.retain_grad()显式保留任意节点梯度
a leaf Variable that requires grad is being used in an in-place operation对requires_grad=True的叶子张量做了原地操作,比如w += 1改用新张量:w = w + 1,或者用w.data.add_(1)绕过autograd追踪(非必要不推荐)
param.grad is None该参数没有参与计算,或requires_grad=False,或经过detach()后被截断了先检查param.requires_grad,再检查该参数在计算图上是否真的可达,避免在统计或对比时误用detach
loss为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()看作一个魔法按钮。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询