刚接触PyTorch的时候我一度觉得autograd是个玄学凭什么一个张量自带梯度为什么loss.backward()一调用模型参数的.grad就齐刷刷地填上了数字直到我花了一个周末亲手写了一个三十行的微型自动微分框架再回头看PyTorch的反向传播很多模糊的地方一下就通了。标题里的“PyTorch6”我默认理解成沿用PyTorch命名习惯的版本叫法但这不重要——autograd这套核心引擎从早期版本到现在思想几乎没有变过。这篇笔记就按这个思路来先讲清楚自动微分与反向传播的核心原理再带你把计算图、链式法则、backward()的一堆参数逐一看透最后附上我实际踩过的坑和排查方法。不管你刚入门想搞懂底层还是用了很久但被某个报错卡住应该都能在里边找到答案。1. 手推一次梯度我就明白了autograd有多救命1.1 三层MLP的链式法则手算过程先说个很朴素的例子。假设网络就三层没有激活函数简化成z1 w1 * x b1 z2 w2 * z1 b2 y w3 * z2 b3 loss (y - target) ^ 2 / 2你真要手写梯度得从最外层的loss往回推d loss / d y y - target d loss / d w3 (y - target) * z2 d loss / d w2 (y - target) * w3 * z1 d loss / d w1 (y - target) * w3 * w2 * x发现规律了吗想算w1的梯度前面那一串乘积里的(y - target)、w3、w2其实在算w3、w2梯度时都已经算过了。如果从头到尾正向硬算每个参数都要重复算一遍公共子项白白浪费计算量。反向传播本质是一种动态规划式的梯度复用从输出往输入方向走每个中间结果只算一次然后一路把梯度“乘”回去。所以当你写出loss.backward()时心里应该有个画面一个从loss出发、沿计算图向后流动的过程。每个操作节点都知道自己“输出对输入的局部导数”反向时用它把上游梯度转成下游梯度。1.2 autograd的核心理念前向建图反向算导PyTorch的autograd做了一件很讨巧的事前向传播时它并不去算具体梯度而是悄悄把每个操作记录成一个节点节点上保存了操作类型、输入和输出引用。等到你调用backward()它才从最终的标量出发沿着这些节点反着走一遍依次计算梯度。这就是所谓“define-by-run”动态图机制计算图和你的Python代码同步生成。代码跑到哪图就建到哪。你写一个if循环改变网络结构图也跟着变。这一点和早期静态图框架完全不同后面会细说。对新手来说最需要记住的是Tensor上几个关键属性requires_grad是否参与梯度追踪grad_fn记录这个张量是怎么算出来的中间节点会有指向“生成它的操作”的引用is_leaf是不是叶子张量.grad保存累积梯度的地方。我见过不少人把.grad当成“当前梯度”其实它是累积梯度。默认情况下每次反向都会把新梯度加到旧梯度上所以训练循环里每隔一步要调用optimizer.zero_grad()把梯度清零否则梯度会越滚越大。1.3 动态图与静态图为什么PyTorch选这条路线大概五六年之前主流框架还流行“先定义完整计算图再喂数据执行”的静态图模式。静态图能对整个网络做编译优化性能上限高但调试体验很反人类你的if、for得按照符号API重新写一套不然图都建不出来。PyTorch选择动态图等于把“建图”和“执行”合并了。你在Python里正常写循环、分支、随时打印中间结果前向一跑完图自然就生成。付出的代价是每次反向后这张图会被释放如果你要在同一个图上做第二次反向就得提前跟PyTorch打招呼这就引出了后边要讲的retain_graphTrue。关于动态图还有一个经常被忽略的实际影响每次前向都是一张新图。哪怕公式一模一样连续跑两次前向再分别backward()框架也不会报错因为第二次是另一张图。真正报“Trying to backward through the graph a second time”的场景是在同一张图已经反向后还想再反传一次这个坑我们放到参数篇讲。2. 计算图是如何被反向“走”完的2.1 链式法则的矩阵视角VJP标量版本的链式法则大家都会dz/dx dz/dy * dy/dx。一旦进入向量世界事情就变成雅可比矩阵了。设输入x是n维向量输出y是m维向量那么y对x的雅可比矩阵J形状是m x n。如果下游给y传来一个梯度向量v形状m那么x得到的梯度是grad_x v^T * J也就是“上游梯度向量”乘“雅可比矩阵”。这个运算被称为向量-雅可比乘积英文缩写VJP或者叫vector-Jacobian product。PyTorch的autograd在反向传播时从来不显式构造完整的雅可比矩阵而是让每个算子实现“给一个上游梯度向量返回下游梯度向量”的VJP函数。原因很直接完整雅可比矩阵太大比如输入输出都是1024维矩阵就是1024x1024显存直接爆炸。而VJP只需要维护向量成本和前向差不多。这个点理解透了backward()里的gradient参数就非常好懂——它就是你从再下游传进来的那个v。2.2 前向建图反向释放一图两走看一段普通代码import torch x torch.tensor(2.0, requires_gradTrue) w torch.tensor(3.0, requires_gradTrue) b torch.tensor(1.0, requires_gradTrue) h w * x b # h 7 y h ** 2 # y 49 loss y.mean() # loss 49 loss.backward() print(w.grad) # 28.0 print(b.grad) # 14.0手动验算一下d loss / d h 2 * h 14d h / d w x 2所以w.grad 14 * 2 28d h / d b 1所以b.grad 14。和框架结果完全一致。这背后发生的事就是节点串联x, w, b -- mul/add -- h -- pow -- y -- mean -- loss前向的时候中间张量h的grad_fn指向某次乘法/加法生成节点loss的grad_fn指向mean节点。backward()从loss出发先告诉mean节点“当前梯度是1”mean再把梯度传给pow节点pow传给hh再分发给mul和add的输入最终逐个落到w、b、x的.grad。反向走完计算图默认被释放。如果此时你再调用一次loss.backward()PyTorch会告诉你“计算图已经不在了”。这个释放设计是为了省内存但也让不少新人第一次写双分支loss时一脸懵。2.3 叶子节点与非叶子节点梯度到底落在哪里你可能会问为什么h这种中间变量也有grad_fn但它没有.grad因为默认情况下PyTorch只给叶子节点存梯度。叶子节点是“由用户直接创建且requires_gradTrue的张量”比如模型参数、你手动定义的输入。非叶子节点是计算产生的中间结果例如h w * x b中的h。PyTorch为了省内存默认不保存非叶子节点的梯度所以你打印h.grad得到的是None。如果确实需要中间梯度可以显式调用h.retain_grad()告诉框架“这个节点的梯度别扔”。还有一个容易混的点requires_gradTrue的叶子节点不能被原地修改。因为反向时要拿叶子节点的原始值去算梯度你原地改了梯度就算错了。PyTorch干脆直接禁止报错信息会说“a leaf Variable that requires grad is being used in an in-place operation”。想更新参数走optimizer.step()它会操作param.data不破坏梯度追踪。3. backward()函数参数逐项拆解几个常被忽略的开关3.1 gradient参数非标量loss怎么反传正常情况下我们反传的loss都是标量。但有时候你会不小心对向量调用backward()比如x torch.tensor([1.0, 2.0], requires_gradTrue) y x * 2 y.backward() # RuntimeError: grad can be implicitly created only for scalar outputs报错的含义是反向传播需要一个初始梯度向量v但框架猜不出来你想要哪个方向。解决方法是显式传gradient参数y.backward(torch.tensor([3.0, -1.0]))这里[3.0, -1.0]就是上游梯度v它和y形状一致。实际效果等同于“有一个总损失L 3*y[0] - y[1]现在对L反传”。我们可以验证一下设x1 1、x2 2y1 x1^2、y2 2*x2那么x1 torch.tensor(1.0, requires_gradTrue) x2 torch.tensor(2.0, requires_gradTrue) y1 x1 ** 2 y2 2 * x2 (y1 * 3 y2 * (-1)).backward() print(x1.grad) # 6.0 等于 3 * 2*x1 print(x2.grad) # -2.0 等于 -1 * 2这个例子里的[3, -1]就是梯度向量雅可比矩阵被它乘过之后才落到输入上。3.2 retain_graph为什么第二次backward会报错很多人的第一次崩溃来自这个报错RuntimeError: Trying to backward through the graph a second time, but the saved intermediate results have already been freed.原因前文说了默认情况下图被释放了。什么时候需要同一张图反传两次常见场景是两个loss共用同一个前向结果比如总loss由“内容损失”和“风格损失”组成你想分别反传播两次loss1.backward(retain_graphTrue) loss2.backward()第一次必须保留图第二次就可以释放了。另一个典型场景是GAN里判别器对生成样本和真实样本共用生成器特征。但要提醒一句retain_graphTrue很吃内存能用“合并loss”解决的尽量合并。比如(loss1 loss2).backward()只反向一次梯度照样正确完全不需要保留图。如果两个loss对同一批参数都有梯度合并反传和分开反传结果是一样的因为梯度会累加。3.3 create_graph如何拿到“梯度的梯度”create_graphTrue的作用是在你求一阶梯度的时候把“求梯度这整个反向过程”也记录成一张图。这样一阶梯度本身变成可导的于是你可以继续对它求导得到二阶梯度。看一个最简单的二阶导数x torch.tensor(2.0, requires_gradTrue) y x ** 3 (g1,) torch.autograd.grad(y, x, create_graphTrue) # g1 3 * x^2 12 (g2,) torch.autograd.grad(g1, x) # g2 6 * x 12 print(g2) # 12.0这个能力不是一个花架子。很多对抗训练、梯度惩罚、正则化方法都需要对“梯度本身”再求导。比如WGAN-GP里的gradient penalty计算方式就是对判别器输出关于输入的梯度做一次约束这时候必须在第一次求梯度时设置create_graphTrue让梯度可以继续传。d torch.autograd.grad(loss_d, input, create_graphTrue)[0] gp (d.norm(2, dim1) - 1).pow(2).mean() gp.backward()第一次求梯度已经让d带着完整的反向图后面gp.backward()才能继续把梯度传到input里。3.4 torch.autograd.grad与inputs参数只回传你关心的变量tensor.backward()会把梯度填充到所有叶子节点的.grad上哪怕有些参数你根本不关心。而torch.autograd.grad函数更轻量它不会修改任何.grad字段只返回你指定变量的梯度元组。x torch.tensor(2.0, requires_gradTrue) w torch.tensor(3.0, requires_gradTrue) b torch.tensor(1.0, requires_gradTrue) loss (w * x b) ** 2 (gx,) torch.autograd.grad(loss, x) print(gx) # 42.0 d loss / d x 2*(w*xb)*w这个函数最大的优势是你指定哪个变量它就只算哪个变量的梯度不碰别的.grad。调试时特别有用比如想看看某个中间变量对loss的贡献中间变量不是叶子节点backward()默认不存它但torch.autograd.grad可以取。而且它接受grad_outputs参数与backward()里的gradient作用完全一致。底层逻辑都是VJP只是不同入口。4. 自己写一个30行微型autograd让原理不再抽象4.1 张量对象与操作节点设计说实话真正让我把autograd看透的是自己动手写了一个极简版框架。这里我抽出最核心的骨架你可以在本地直接跑。思路每个Tensor保存三个东西——数据、梯度、反向函数。每个操作在生成新Tensor的同时把“如何把梯度传回输入”的闭包挂到新节点的_backward上。反向时从输出出发按逆拓扑序依次执行这些闭包。class Tensor: def __init__(self, data, requires_gradFalse, children()): self.data float(data) self.grad 0.0 self.requires_grad requires_grad self._children children self._backward lambda: None def __add__(self, other): other other if isinstance(other, Tensor) else Tensor(other) out Tensor( self.data other.data, requires_gradself.requires_grad or other.requires_grad, children(self, other), ) def _backward(): if self.requires_grad: self.grad out.grad if other.requires_grad: other.grad out.grad out._backward _backward return out def __mul__(self, other): other other if isinstance(other, Tensor) else Tensor(other) out Tensor( self.data * other.data, requires_gradself.requires_grad or other.requires_grad, children(self, other), ) def _backward(): if self.requires_grad: self.grad out.grad * other.data if other.requires_grad: other.grad out.grad * self.data out._backward _backward return out def __repr__(self): return fTensor({self.data:.4f}, grad{self.grad:.4f}) def relu(x): out Tensor( max(0.0, x.data), requires_gradx.requires_grad, children(x,), ) def _backward(): x.grad out.grad * (1.0 if x.data 0 else 0.0) out._backward _backward return out def backward(root): # 后序遍历收集拓扑序 order [] visited set() def visit(node): if id(node) not in visited: visited.add(id(node)) for child in node._children: visit(child) order.append(node) visit(root) root.grad 1.0 # 逆序执行让梯度从输出向输入方向传播 for node in reversed(order): node._backward()这段代码没有处理矩阵乘法、广播这些复杂情况但教学已经完全够用。核心思想很清晰每个节点都知道自己如何把梯度传给输入。4.2 用一个小网络跑通反向我们拿这个微型框架算刚刚那个例子x Tensor(2.0, requires_gradTrue) w Tensor(3.0, requires_gradTrue) # 先把旧的梯度清零 x.grad 0.0 w.grad 0.0 h relu(w * x Tensor(1.0)) backward(h) print(x.grad, w.grad) # 3.0 2.0手动验算h max(0, 3*21) 7relu的局部导数是1所以d h / d x w 3d h / d w x 2。打印结果完全对得上。注意闭包里用的是self.grad ...而不是self.grad ...。这个“加等于”非常重要一个变量如果被多个下游节点使用它的梯度会从多条路径累加过来。这就是计算图中的“多路梯度累加”和PyTorch里反向多次梯度累加的机制是同一件事。4.3 用数值梯度验证微型autograd如果担心这个微型框架哪里有错可以用最朴素的数值差分来验证。中心差分公式df / dx ≈ (f(x eps) - f(x - eps)) / (2 * eps)def numerical_grad(f, var, eps1e-6): old var.data var.data old eps fp f().data var.data old - eps fm f().data var.data old return (fp - fm) / (2 * eps) def f(): return relu(w * x Tensor(1.0)) x Tensor(2.0, requires_gradTrue) w Tensor(3.0, requires_gradTrue) x.grad 0.0 w.grad 0.0 backward(f()) print(autograd:, x.grad, w.grad) # 3.0 2.0 print(numerical:, numerical_grad(f, x), numerical_grad(f, w)) # 约 3.0 约 2.0当手动反向、数值梯度两个结果一致我对这个微型框架才算真正放心。这种“自己写框架再对照验证”的学习方式比看十遍文档都管用。PyTorch里的每个算子其实就是类似的思路只是多了GPU、广播、稀疏梯度、视图等复杂细节。5. 高阶反传实操自定义Function、二次梯度与梯度流控制5.1 grad_outputs实战真的理解上手就快回到PyTorch。很多人写代码只调用loss.backward()很少接触torch.autograd.grad的grad_outputs。但一旦进入多输出、多任务场景这是绕不开的。举一个具体例子假设你有两个支路输出y1 x1^2和y2 2*x2最终任务希望y1方向的梯度权重是3y2方向的梯度权重是-1。与其手工拼loss 3*y1 - y2再反向不如直接用grad_outputsx1 torch.tensor(1.0, requires_gradTrue) x2 torch.tensor(2.0, requires_gradTrue) y1 x1 ** 2 y2 2 * x2 torch.autograd.grad((y1, y2), (x1, x2), grad_outputs(torch.tensor(3.0), torch.tensor(-1.0))) print(x1.grad, x2.grad) # None None注意torch.autograd.grad不会写.grad要拿到返回值需要用变量接(gx1, gx2) torch.autograd.grad( (y1, y2), (x1, x2), grad_outputs(torch.tensor(3.0), torch.tensor(-1.0)) ) print(gx1, gx2) # tensor(6.) tensor(-2.)是不是发现和我们前面手动构造y1*3 - y2再backward()的结果一摸一样所以grad_outputs的本质就是给每个输出指定一个“上游梯度”框架把这些梯度分别乘到雅可比上再合并到输入。5.2 用create_graph计算二阶导数Hessian向量积的雏形前面已经看过一阶、二阶导数的代码。实际工程中二阶导数最常见的使用场景之一是隐式正则化比如x torch.randn(8, requires_gradTrue) model_out (x ** 3).sum() (gx,) torch.autograd.grad(model_out, x, create_graphTrue) penalty gx.pow(2).mean() penalty.backward() # x.grad 就包含了二阶信息这里的计算量比单纯一阶反传大不少因为create_graphTrue会额外保留一阶反向的图。但它带来的好处是让优化器能感知“梯度变化趋势”很多元学习算法比如MAML的核心就是这一步。我个人的建议是不要在生产环境无脑开create_graph非必要别开。它至少会让显存翻一倍训练速度也会明显下降。先用普通反传验证问题能解确需高阶信息再上。5.3 自定义autograd.Function接管forward和backward框架内置算子覆盖不了所有场景时就该自己写torch.autograd.Function了。它的规则很简单forward做前向并可以保存中间量backward接收上游梯度返回对每个输入的梯度。我平时用得最多的一个自定义函数是“二值化直通估计器”STE常见于模型量化、蒸馏任务class Binarize(torch.autograd.Function): staticmethod def forward(ctx, x): # 前向输出 sign 二值结果 return (x 0).float() staticmethod def backward(ctx, grad_output): # 反向采用直通估计STE梯度原样通过 return grad_output调用方式x torch.tensor([-2.0, -1.0, 0.0, 1.0, 2.0], requires_gradTrue) y Binarize.apply(x).sum() y.backward() print(x.grad) # tensor([1., 1., 1., 1., 1.])这个函数看起来“不合逻辑”前向把x变成0/1反向却原样回传完全忽略了二值化本身的导数。但这就是STE的精髓——前向按期望行为走反向按可训练的近似走让梯度能穿过这个不可导的“闸门”。写自定义函数时如果需要在前向保存中间量用ctx.save_for_backward(x)反向里用ctx.saved_tensors取回来。这样能避免保存无关张量节省内存。需要特别注意forward的返回值必须和backward返回值数量、顺序对应有几个输入backward就返回几个梯度。6. 我踩过的autograd暗坑合集附排查方法6.1 in-place操作报错以后先检查叶子变量最典型的报错长这样RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.触发代码w torch.randn(3, requires_gradTrue) w 1w 1本质上调用了add_()在原地址修改了w。一个有梯度历史的叶子节点被原地改掉梯度就没法算了PyTorch会立刻拦下来。如果是在非叶子节点上原地操作报错会推迟到backward()阶段信息变成“one of the variables needed for gradient computation has been modified by an inplace operation”。这种更隐蔽因为它不告诉你具体是哪个变量。遇到这种情况我一般先做两件事全局搜一下代码里的、-、*、/、.add_()、.mul_()这类原地写法实在找不到就在训练脚本开头加一句torch.autograd.set_detect_anomaly(True)它会帮忙定位到具体计算行但会拖慢速度只在调试时开。一个简单的原则凡是你以后还想用它做反向传播的张量尽量不要原地修改。比如参数更新就让优化器去做不要自己写p lr * p.grad。6.2 非标量backward报错其实在提醒你指定梯度方向这个报错几乎每个新手都会碰到一次RuntimeError: grad can be implicitly created only for scalar outputs问题就出在你对向量调用了backward()。哪怕向量只有一个元素只要形状是(1,)它依然是向量不能隐式反传x torch.tensor([1.0, 2.0, 3.0], requires_gradTrue) y x * 2 y.backward() # 报错修改方案有两种y.sum().backward() # 方式是隐式梯度全1 # 或者 y.backward(torch.ones_like(y)) # 显式指定上游梯度这两种写法数学上是等价的都是把上游梯度设为全1。但如果你只想要第一个元素的梯度传到x上就传torch.tensor([1.0, 0.0, 0.0])。很多情况下你其实不需要对向量反传把loss先sum()或mean()成标量代码会更直观。6.3 no_grad下算loss梯度莫名消失的隐形原因有类问题是backward()调用后不报错但所有参数的.grad都是None训练loss完全不降。排查半天发现是用torch.no_grad()把loss计算包住了with torch.no_grad(): loss (model(x) - target) ** 2 # loss 不连接计算图 loss.backward() # 报错没有grad_fn更隐蔽的是这种写法with torch.no_grad(): features model.feature(x) loss loss_fn(features, target) loss.backward()features已经是从no_grad区域出来的和模型前面的层完全断开了后面的loss_fn再怎么算梯度也传不到模型前面部分。解决办法很简单需要梯度的地方就老老实实待在no_grad外面只对纯推理、评估、参数更新这类不需要梯度的操作使用no_grad。同理tensor.detach()也会切断梯度。你从loss计算链路里半路detach()就相当于告诉框架“这一支不参与反传”。6.4 梯度为None、梯度累积和梯度裁剪优化前的最后一步如果你发现某个参数.grad是None大概率是这三个原因之一参数的requires_gradFalse参数没有参与这一批计算的图模型有分支结构该参数所在分支这次前向压根没走到。这里提醒一下nn.Module里的参数默认都是requires_gradTrue但如果你手动构建网络、用了一些带no_grad的操作很容易踩中第二种。梯度累积这块网上写法很多我推荐的标准姿势是for i, (inputs, labels) in enumerate(dataloader): loss loss_fn(model(inputs), labels) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()每步反传时梯度自动累加到.grad上攒够accumulation_steps再更新一次参数。这种写法不需要retain_graphTrue因为每一步都是新的前向图。还有一点和后端无关但很实用梯度裁剪务必放在optimizer.step()之前而且要等所有反向都完成后loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() optimizer.zero_grad()顺序如果颠倒了裁剪完的梯度用完又会被新梯度覆盖等于白裁。如果用了混合精度训练还要先scaler.unscale_()再裁剪然后scaler.step()这个细节一不小心就能让loss曲线变成心电图。最后说一点个人体会。我调试和梯度相关的bug第一件事从来不是对着报错信息背答案而是把出问题的张量的requires_grad、grad_fn、is_leaf一个个打印出来沿着计算图往回走两步。这套方法比记报错好使得多。如果你能把前面那个微型autograd自己敲一遍再回来看PyTorch的文档很多模糊的地方会突然变得特别清楚。autograd说白了就是“前向建图反向算导梯度累加用完释放”牢牢记住这十六个字后面遇到再多花活都不会慌。