autograd自动微分:反向传播的引擎
本文你将学到 :
requires_grad的传播规则、动态计算图的构建与反向传播原理、backward()四个参数的含义、grad_fn与next_functions如何串起整张图、叶子节点(leaf tensor)的判定标准、梯度为什么会累加以及如何正确清零、no_grad/detach/tensor.data三种"脱离计算图"手段的区别、如何用hook拿到非叶子节点的梯度、如何继承Function自定义求导规则,最后用纯autograd手写一个完整可运行的线性回归,与手动求导版本对照,直观感受自动微分的价值。
文章目录
- autograd自动微分:反向传播的引擎
-
- 一、为什么需要autograd
- 二、requires_grad与backward基本用法
- 三、验证:autograd算的和手推公式一致吗
- 四、计算图:autograd的底层原理
- [五、叶子节点(Leaf Tensor)](#五、叶子节点(Leaf Tensor))
- 六、梯度累加与清零
- 七、关闭求导:no_grad、detach与tensor.data
-
- [7.1 with t.no_grad():上下文级关闭](#7.1 with t.no_grad():上下文级关闭)
- [7.2 tensor.detach():切断某个变量](#7.2 tensor.detach():切断某个变量)
- [7.3 tensor.data:不推荐的老写法](#7.3 tensor.data:不推荐的老写法)
- 八、获取非叶子节点的梯度:autograd.grad与hook
- 九、扩展autograd:自定义Function
- 十、实战:用autograd手写线性回归
- 总结

一、为什么需要autograd
神经网络的训练靠反向传播:先前向计算损失,再逐层求偏导得到梯度,最后用梯度下降更新参数。对一个线性回归,手推梯度公式不难;但换成几十层的卷积网络,手动实现反向传播不仅费时费力,而且极易出错、难以排查。
torch.autograd就是为此而生的自动求导引擎:它在前向传播时自动记录每一步Tensor运算,构建出计算图;调用backward()时沿图反向遍历,用链式法则自动算出所有梯度。你只管写前向逻辑,导数交给框架。
二、requires_grad与backward基本用法
autograd的核心数据结构仍然是Tensor,只是多了一个开关属性requires_grad。它默认为False,设为True后,该Tensor上的所有运算都会被记录:
python
import torch as t
# 三种等价写法
a = t.randn(3, 4, requires_grad=True) # 创建时指定
a = t.randn(3, 4).requires_grad_() # inplace开启
a = t.randn(3, 4); a.requires_grad = True # 直接赋值属性
来看最小可用示例:
python
b = t.zeros(3, 4).requires_grad_()
c = (a + b).sum() # c是一个标量
c.backward() # 反向传播
a.grad
# tensor([[1., 1., 1., 1.],
# [1., 1., 1., 1.],
# [1., 1., 1., 1.]])
c对a的每个元素的偏导都是1,autograd算得完全正确。注意一个重要的传播规则 :我们从未设置c的requires_grad,但它自动变成了True------只要某个Tensor依赖于任何一个需要求导的Tensor,它自己也需要求导 。原因很直白:对x → y → z,要算∂z/∂x,按链式法则必须先算∂z/∂y,所以y也得被追踪。
python
a.requires_grad, b.requires_grad, c.requires_grad # (True, True, True)
backward()的完整签名
python
torch.autograd.backward(tensors, grad_tensors=None, retain_graph=None, create_graph=False)
- tensors :从哪个Tensor开始反向传播,
y.backward()等价于torch.autograd.backward(y)。 - grad_tensors:形状与y一致的"上游梯度"。若y是标量(如loss)可省略;若y是张量则必须传,它相当于链式法则 dz/dx = dz/dy × dy/dx 中的 dz/dy。
- retain_graph :反向传播会用到前向时缓存的中间结果,传播完这些缓存默认被清空。要对同一张图多次backward,需传
retain_graph=True。 - create_graph:对反向传播过程本身再建图,用于求高阶导数。
三、验证:autograd算的和手推公式一致吗
以函数 y = x²·eˣ 为例,手推的导函数是 dy/dx = 2x·eˣ + x²·eˣ。让autograd和公式对个账:
python
def f(x):
'''前向计算y'''
return x**2 * t.exp(x)
def gradf(x):
'''手动推导的导函数'''
return 2*x*t.exp(x) + x**2*t.exp(x)
x = t.randn(3, 4, requires_grad=True)
y = f(x)
y.backward(t.ones(y.size())) # y不是标量,需传入grad_tensors
assert t.all(x.grad == gradf(x)) # 断言通过:二者完全一致
这里y.backward(t.ones(y.size()))传入的全1张量,含义是"y的每个元素对最终目标的梯度都是1",相当于对y.sum()求导。断言没有抛异常,说明autograd的结果和手推公式逐元素相等。
四、计算图:autograd的底层原理
autograd底层用计算图 (Computation Graph)这种有向无环图(DAG)记录运算与数据的关系:椭圆表示变量(Tensor),矩形表示算子(Function)。以 z = wx + b 为例,它被分解为 y = w×x 和 z = y + b:
#mermaid-svg-k2sZB7BWtGsRRH5b{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-k2sZB7BWtGsRRH5b .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-k2sZB7BWtGsRRH5b .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-k2sZB7BWtGsRRH5b .error-icon{fill:#552222;}#mermaid-svg-k2sZB7BWtGsRRH5b .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-k2sZB7BWtGsRRH5b .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-k2sZB7BWtGsRRH5b .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-k2sZB7BWtGsRRH5b .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-k2sZB7BWtGsRRH5b .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-k2sZB7BWtGsRRH5b .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-k2sZB7BWtGsRRH5b .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-k2sZB7BWtGsRRH5b .marker{fill:#333333;stroke:#333333;}#mermaid-svg-k2sZB7BWtGsRRH5b .marker.cross{stroke:#333333;}#mermaid-svg-k2sZB7BWtGsRRH5b svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-k2sZB7BWtGsRRH5b p{margin:0;}#mermaid-svg-k2sZB7BWtGsRRH5b .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-k2sZB7BWtGsRRH5b .cluster-label text{fill:#333;}#mermaid-svg-k2sZB7BWtGsRRH5b .cluster-label span{color:#333;}#mermaid-svg-k2sZB7BWtGsRRH5b .cluster-label span p{background-color:transparent;}#mermaid-svg-k2sZB7BWtGsRRH5b .label text,#mermaid-svg-k2sZB7BWtGsRRH5b span{fill:#333;color:#333;}#mermaid-svg-k2sZB7BWtGsRRH5b .node rect,#mermaid-svg-k2sZB7BWtGsRRH5b .node circle,#mermaid-svg-k2sZB7BWtGsRRH5b .node ellipse,#mermaid-svg-k2sZB7BWtGsRRH5b .node polygon,#mermaid-svg-k2sZB7BWtGsRRH5b .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-k2sZB7BWtGsRRH5b .rough-node .label text,#mermaid-svg-k2sZB7BWtGsRRH5b .node .label text,#mermaid-svg-k2sZB7BWtGsRRH5b .image-shape .label,#mermaid-svg-k2sZB7BWtGsRRH5b .icon-shape .label{text-anchor:middle;}#mermaid-svg-k2sZB7BWtGsRRH5b .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-k2sZB7BWtGsRRH5b .rough-node .label,#mermaid-svg-k2sZB7BWtGsRRH5b .node .label,#mermaid-svg-k2sZB7BWtGsRRH5b .image-shape .label,#mermaid-svg-k2sZB7BWtGsRRH5b .icon-shape .label{text-align:center;}#mermaid-svg-k2sZB7BWtGsRRH5b .node.clickable{cursor:pointer;}#mermaid-svg-k2sZB7BWtGsRRH5b .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-k2sZB7BWtGsRRH5b .arrowheadPath{fill:#333333;}#mermaid-svg-k2sZB7BWtGsRRH5b .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-k2sZB7BWtGsRRH5b .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-k2sZB7BWtGsRRH5b .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-k2sZB7BWtGsRRH5b .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-k2sZB7BWtGsRRH5b .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-k2sZB7BWtGsRRH5b .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-k2sZB7BWtGsRRH5b .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-k2sZB7BWtGsRRH5b .cluster text{fill:#333;}#mermaid-svg-k2sZB7BWtGsRRH5b .cluster span{color:#333;}#mermaid-svg-k2sZB7BWtGsRRH5b div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-k2sZB7BWtGsRRH5b .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-k2sZB7BWtGsRRH5b rect.text{fill:none;stroke-width:0;}#mermaid-svg-k2sZB7BWtGsRRH5b .icon-shape,#mermaid-svg-k2sZB7BWtGsRRH5b .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-k2sZB7BWtGsRRH5b .icon-shape p,#mermaid-svg-k2sZB7BWtGsRRH5b .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-k2sZB7BWtGsRRH5b .icon-shape .label rect,#mermaid-svg-k2sZB7BWtGsRRH5b .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-k2sZB7BWtGsRRH5b .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-k2sZB7BWtGsRRH5b .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-k2sZB7BWtGsRRH5b :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} w
MUL
x
y
ADD
b
z
反向传播就是从根节点z出发沿箭头反向走,用链式法则逐层计算:∂z/∂b = 1,∂z/∂y = 1,∂y/∂w = x,于是 ∂z/∂w = 1×x。用代码把这张图解剖开:
python
x = t.ones(1) # 不需要求导
b = t.rand(1, requires_grad=True)
w = t.rand(1, requires_grad=True)
y = w * x # 等价于 y = w.mul(x)
z = y + b # 等价于 z = y.add(b)
grad_fn记录了每个Tensor是被哪个运算生成的,反向传播时就调用它对应的求导函数(名字通常以Backward结尾):
python
z.grad_fn
# <AddBackward0 ...> z是加法的输出
z.grad_fn.next_functions
# ((<MulBackward0 ...>, 0), (<AccumulateGrad ...>, 0))
next_functions保存了上游节点,正是它把整张图串了起来:
- 第一个是y的
MulBackward0------y由乘法生成; - 第二个是b的
AccumulateGrad------b是需要求导的叶子节点,它的梯度会累加保存(这个名字暗示了第六节的坑)。
python
y.grad_fn.next_functions
# ((<AccumulateGrad ...>, 0), (None, 0))
# w需要求导 → AccumulateGrad;x不需要求导 → None
w.grad_fn, x.grad_fn # (None, None),用户创建的叶子节点没有grad_fn
动态图:每次前向都重新建图
PyTorch采用动态图设计:计算图在每次前向传播时从头构建。这意味着可以用原生Python控制流(if/for)动态决定图结构,每次迭代的图甚至可以不一样:
python
def f(x):
result = 1
for ii in x:
if ii.item() > 0:
result = ii * result # 只有正数参与乘积
return result
x = t.arange(-2, 4).float().requires_grad_()
y = f(x) # 本次输入下 y = x[3]*x[4]*x[5] = 1*2*3
y.backward()
x.grad # tensor([0., 0., 0., 6., 3., 2.]),负数位置梯度为0
前3个元素根本没进计算图,所以梯度是0;后3个的梯度正是乘积对各自的偏导。这种"图随代码走"的特性在NLP等输入结构多变的场景中特别有用,也是PyTorch调试友好的根源。
五、叶子节点(Leaf Tensor)
计算图中is_leaf=True的Tensor称为叶子节点,判定规则:
requires_grad=False的Tensor都是叶子节点;requires_grad=True且由用户直接创建(而非运算生成)的Tensor是叶子节点。
设计叶子节点的目的是省内存 :backward之后,只有"需要求导的叶子节点"的梯度会保留在.grad里,非叶子节点(中间结果)的梯度算完即被释放,因为通常没人用它们。
python
a = t.rand(10, requires_grad=True)
a.is_leaf # True:用户创建
c = t.rand(10, requires_grad=True) + 2
c.is_leaf # False:由加法运算生成
b = t.rand(10, requires_grad=True).cuda(0)
b.is_leaf # False!.cuda()是一次运算,b是运算结果
e = t.rand(10).cuda(0).requires_grad_()
e.is_leaf # True:先搬到GPU(此时不追踪),再开启求导
b和e的对比很有实践意义:想在GPU上创建一个可训练的叶子参数,应当先转设备再开requires_grad ,否则拿到的是非叶子节点,.grad不会被保留。另外记住:叶子节点的grad_fn为None------它们不是任何运算的输出。
六、梯度累加与清零
autograd有一个必须刻进肌肉记忆的行为:backward得到的梯度不是覆盖,而是累加到.grad上(AccumulateGrad的字面含义)。
python
x = t.ones(2, 2, requires_grad=True)
y = x.sum()
y.backward(retain_graph=True); print(x.grad) # 全1
y.backward(retain_graph=True); print(x.grad) # 全2,累加了!
y.backward(retain_graph=True); print(x.grad) # 全3
x.grad.data.zero_() # 手动清零(inplace)
y.backward(); print(x.grad) # 恢复全1,正确
为什么这样设计?因为它给了框架灵活性------比如显存不够时,可以把大batch拆成几个小batch分别backward,梯度自动累加后再统一更新,等效于大batch训练。但代价是:常规训练中每次backward前必须清零,否则本轮梯度会混入上一轮的残留。实际写法有三种:
python
x.grad.data.zero_() # 对单个Tensor手动清零
net.zero_grad() # 对nn.Module的全部参数清零
optimizer.zero_grad() # 对优化器管理的全部参数清零(最常用)
另外注意上例中的retain_graph=True:计算梯度需要用到前向时缓存的中间值(比如算∂y/∂w需要x的值),backward后缓存默认清空,所以想对同一张图连续backward必须显式保留。
七、关闭求导:no_grad、detach与tensor.data
追踪运算要缓存中间结果,有内存/显存开销。推理、测试等不需要梯度的场景应当关闭求导,能明显提速并节省约一半显存。共有三种手段,语义各不相同。
7.1 with t.no_grad():上下文级关闭
python
x = t.ones(1)
w = t.rand(1, requires_grad=True)
with t.no_grad():
y = x * w
y.requires_grad # False!虽然w需要求导,但该运算未被记录
块内的所有运算都不进计算图,是推理阶段的标准写法 。类似的全局开关是t.set_grad_enabled(False),用完记得恢复。
7.2 tensor.detach():切断某个变量
detach()返回一个与原Tensor共享数据但脱离计算图 的新Tensor(requires_grad=False)。常用于"这段特征我只当常量用,别对它求导"的场景,例如GAN中固定判别器训练生成器、截断RNN的历史梯度。
python
a = t.ones(3, 4, requires_grad=True)
b = a.detach() # 与a共享内存,但b上的运算不再被追踪
b.requires_grad # False
7.3 tensor.data:不推荐的老写法
tensor.data同样返回脱离计算图、共享数据的Tensor。但它和detach()有个危险区别:通过.data修改数值,autograd完全无法察觉 ,如果这个值恰好是backward要用的中间结果,会得到静默的错误梯度。而修改detach()的结果在backward时会触发报错,起码错得明明白白。因此现代代码应优先用detach(),避免直接操作.data。
八、获取非叶子节点的梯度:autograd.grad与hook
第五节说过,非叶子节点的梯度算完即被清空。调试时若想看中间变量的梯度,有两种办法:
python
x = t.ones(3, requires_grad=True)
w = t.rand(3, requires_grad=True)
y = x * w
z = y.sum()
z.backward()
x.grad, w.grad, y.grad # (tensor(...), tensor([1., 1., 1.]), None)
# y是非叶子节点,grad为None
方法一:autograd.grad直接求:
python
y = x * w
z = y.sum()
t.autograd.grad(z, y) # (tensor([1., 1., 1.]),),隐式调用backward
方法二:注册hook(推荐)。hook是一个以梯度为输入的回调函数,在反向传播经过该Tensor时被触发:
python
def variable_hook(grad):
print('y的梯度:', grad)
y = x * w
hook_handle = y.register_hook(variable_hook) # 注册
z = y.sum()
z.backward() # 输出:y的梯度: tensor([1., 1., 1.])
hook_handle.remove() # 用完及时移除,避免每次backward都触发
九、扩展autograd:自定义Function
绝大多数运算autograd都能自动求导,但如果你实现了一个它不认识的复杂运算(比如调用了外部库),就需要继承torch.autograd.Function,手动写出前向和反向逻辑:
python
from torch.autograd import Function
class MultiplyAdd(Function):
@staticmethod
def forward(ctx, w, x, b):
ctx.save_for_backward(w, x) # 缓存反向传播要用的中间值
output = w * x + b
return output
@staticmethod
def backward(ctx, grad_output):
w, x = ctx.saved_tensors # 取回缓存
grad_w = grad_output * x # ∂z/∂w = x
grad_x = grad_output * w # ∂z/∂x = w
grad_b = grad_output * 1 # ∂z/∂b = 1
return grad_w, grad_x, grad_b
要点有四:
forward/backward都是静态方法 ,没有__init__;- backward的输出与forward的输入一一对应 (forward吃w, x, b,backward就得吐grad_w, grad_x, grad_b);backward的输入
grad_output对应forward的输出; - 反向要用的中间结果必须在forward里用
ctx.save_for_backward保存,否则前向结束就被释放了; - 调用时用
Function.apply(...),不要直接调forward:
python
x = t.ones(1)
w = t.rand(1, requires_grad=True)
b = t.rand(1, requires_grad=True)
z = MultiplyAdd.apply(w, x, b) # 前向
z.backward() # 反向,自动调用我们写的backward
x.grad, w.grad, b.grad # (None, tensor([1.]), tensor([1.]))
x不需要求导,所以最终x.grad是None(中间会算但随即被清空)。
十、实战:用autograd手写线性回归
最后用一个完整例子收尾。目标:从带噪声的数据里学出 y = 2x + 3 的参数w和b。用Tensor手动推导梯度也能实现同样的功能(书中3.2节就是这么做的),这次让autograd代劳,直接对比出它的价值。
python
import torch as t
import numpy as np
from matplotlib import pyplot as plt
t.manual_seed(1000) # 固定随机种子,保证结果可复现
def get_fake_data(batch_size=8):
'''产生随机数据:y = 2x + 3,加上噪声'''
x = t.rand(batch_size, 1) * 5
y = x * 2 + 3 + t.randn(batch_size, 1)
return x, y
# 随机初始化参数,开启求导------它们就是计算图的叶子节点
w = t.rand(1, 1, requires_grad=True)
b = t.zeros(1, 1, requires_grad=True)
losses = np.zeros(500)
lr = 0.005 # 学习率
for ii in range(500):
x, y = get_fake_data(batch_size=32)
# forward:计算均方误差损失
y_pred = x.mm(w) + b.expand_as(y) # expand_as利用了广播
loss = 0.5 * (y_pred - y) ** 2
loss = loss.sum()
losses[ii] = loss.item()
# backward:一行代码,自动计算所有梯度
loss.backward()
# 更新参数:通过.data操作,避免更新过程本身被记录进计算图
w.data.sub_(lr * w.grad.data)
b.data.sub_(lr * b.grad.data)
# 梯度清零:不清零下一轮就会累加出错
w.grad.data.zero_()
b.grad.data.zero_()
print(f'w: {w.item():.3f}, b: {b.item():.3f}')
# 输出约为 w: 2.026, b: 2.973,非常接近真实值2和3
plt.plot(losses) # loss曲线整体下降后趋于平稳
plt.show()
500次迭代后学到 w≈2.03、b≈2.97,与真实参数(2, 3)基本一致。回看这段代码,训练循环浓缩成了固定的四拍节奏:
- forward算loss;
- **
loss.backward()**自动求梯度------手动版里那五六行链式求导代码(dy_pred、dw、db......)全部消失; - 用
w.grad更新参数 (更新用.data绕开追踪,这里是它为数不多的合理用法;工程中更规范的做法是包在with t.no_grad():里,或者直接交给optimizer.step()); - 梯度清零。
模型再复杂,这个骨架也不变------第一篇CIFAR-10训练循环里的zero_grad → forward → backward → step就是它的优化器封装版。手动求导版本中最容易写错的部分(对矩阵求导、转置、求和的顺序)被autograd整个接管,这正是现代深度学习框架的核心生产力。
总结
- requires_grad:求导开关,具有传染性------依赖需要求导的Tensor的一切运算结果都会被追踪。
- 计算图 :椭圆是变量、矩形是算子的DAG;
grad_fn记录生成运算,next_functions串联上游;PyTorch是动态图,每次前向重新构建,支持原生Python控制流。 - backward :非标量输出要传
grad_tensors;多次反向传播需retain_graph=True;create_graph=True可求高阶导。 - 叶子节点 :用户直接创建的Tensor;
grad_fn为None;只有需要求导的叶子节点的梯度会保留在.grad中;.cuda()等运算会让结果变成非叶子节点。 - 梯度累加 :backward是累加而非覆盖,训练循环每轮必须
zero_grad();这一设计也支持了小batch梯度累积等技巧。 - 关闭求导 :推理用
with t.no_grad();切断单个变量用detach();尽量不要用.data直接改值,错误会静默发生。 - 中间梯度 :非叶子节点梯度默认被清空,可用
autograd.grad或hook获取。 - 自定义Function :静态的forward/backward、
ctx.save_for_backward缓存中间值、用apply()调用。
到这里,Tensor(数据)和autograd(梯度)两块地基都已打牢。**下一篇《nn模块详解:用积木搭建神经网络》**将进入更高层的抽象:nn.Module的参数管理机制、常用层与损失函数、Sequential等容器,以及如何优雅地组织一个真正的深度网络。