上一课你把数据装进了张量(tensor),学会了在不同设备和 dtype 之间搬运。但训练一个模型,光有前向计算还不够——你需要梯度(gradient)。模块 1 里你已经手推过链式法则(chain rule),知道反向传播(backpropagation)数学上不过是链式求导的层层相乘。这一课不重推数学,而是把镜头对准工程机制:PyTorch 是怎么"记住"你做了哪些运算、又怎么在你调用 .backward() 的一瞬间把梯度算回去的?理解这套机制,是你下一课写 nn.Module、第六课写训练循环时不被"梯度怎么是 0/怎么爆显存"卡住的前提。
读完这一课,你将能够
- 用纯 Python 从零实现一个标量自动微分引擎(
Value类),跑通(a*b+c).backward()并与手算梯度对上。 - 解释动态计算图(dynamic computation graph)里
requires_grad、叶子节点(leaf)、grad_fn三者的关系。 - 说清
.grad为什么是"累加"而非"覆盖",并演示漏掉zero_grad会让梯度翻倍。 - 区分
torch.no_grad()/.detach()/requires_grad=False三种场景各自的用途。 - 识别"循环里累加带梯度张量"导致显存泄漏的坑,并写出正确写法。
动态计算图:前向时偷偷记账
机制直觉。当你写 L = a * b + c,PyTorch 不只是算出数值,它在背后顺手搭了一张图:每个中间结果都记下"我是由哪个运算、从哪些输入算出来的"。这张图是有向无环图(DAG, directed acyclic graph),节点是张量,边是运算。它叫"动态"图,因为它在每次前向(forward)时即时构建——你用 Python 的 if/for 怎么算,它就怎么连,跑完一次 backward 就丢弃,下次重建。记账时,它给每个张量贴三类标签:
requires_grad=True:告诉 PyTorch"这个张量我要算梯度,请记账"。模型参数默认开启。- 叶子节点(leaf):你直接创建、不是由运算得来的张量(如参数、输入)。梯度最终累加到叶子的
.grad上。 grad_fn(出生证明):非叶子张量带的"出生证明",记录它由哪个反向函数负责求导(如AddBackward、MulBackward)。叶子节点的grad_fn是None。
真实 API 当指针。下面这段在 PyTorch 里打印 grad_fn,让你直观看到图的存在(浏览器跑不了 torch,看即可;下方注释为示意,具体结构因 PyTorch 版本而异)。
import torch
a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(-3.0, requires_grad=True)
c = torch.tensor(10.0, requires_grad=True)
L = a * b + c
print(L.grad_fn) # 大致形如 <AddBackward0 object ...>
print(L.grad_fn.next_functions) # 指向 MulBackward 和 c 的 AccumulateGrad(叶子梯度累加器)
print(a.is_leaf, L.is_leaf) # True False
L.backward()
print(a.grad, b.grad, c.grad) # 大致形如 tensor(-3.) tensor(2.) tensor(1.)
注意 next_functions 里放的不是张量本身:叶子 c 对应的是它的 AccumulateGrad 节点(梯度累加器),梯度最终就由它累加到 c.grad 上。
从零写一个自动微分引擎(本课主线)
机制直觉。autograd 的全部魔法可以浓缩成三件事:(1) 每个运算除了算出 data,还要存下"如何把上游梯度传给我的输入"这段逻辑——我们用一个闭包 _backward 装它;(2) 每个结果记住自己的父节点,连成图;(3) .backward() 时按拓扑序(topological order)从输出往输入走,逐个调用 _backward,把梯度累加到每个节点的 .grad。理解了这 30 行,你就理解了 PyTorch autograd 的灵魂——它只是同样的思路扩展到张量并用 C++ 加速。
最小可跑实现。下面是一个 micrograd 式标量引擎,支持 +、*、tanh,并实现拓扑排序的反向传播。它真能跑:
import math
class Value:
def __init__(self, data, _children=(), _op=''):
self.data = data
self.grad = 0.0 # 梯度槽,初始为 0
self._backward = lambda: None # 默认啥也不做(叶子)
self._prev = set(_children) # 父节点(图的边)
self._op = _op # 记一下是什么运算,方便看
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward(): # 加法:上游梯度原样分给两个输入
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward(): # 乘法:各自乘上对方的 data
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), 'tanh')
def _backward(): # tanh'(x) = 1 - tanh(x)^2
self.grad += (1 - t**2) * out.grad
out._backward = _backward
return out
def backward(self):
topo, visited = [], set()
def build(v): # 拓扑排序:先父后子
if v not in visited:
visited.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
self.grad = 1.0 # dL/dL = 1,反向起点
for v in reversed(topo): # 从输出往输入走
v._backward()
# 最小例子:L = a*b + c
a, b, c = Value(2.0), Value(-3.0), Value(10.0)
L = a * b + c
L.backward()
print("L =", L.data) # 4.0
print("a.grad =", a.grad, "(=b=-3)") # -3.0
print("b.grad =", b.grad, "(=a= 2)") # 2.0
print("c.grad =", c.grad, "(= 1)") # 1.0
手算对照:\(L=ab+c\),\(\partial L/\partial a = b = -3\)、\(\partial L/\partial b = a = 2\)、\(\partial L/\partial c = 1\)。和打印完全一致。注意三个关键点:_backward 里用的是 +=(累加,下一节细说);build 保证调用顺序——一个节点的 _backward 跑之前,它的 out.grad 必须已被所有下游填好;起点 self.grad = 1.0 就是数学里那个 \(\partial L/\partial L = 1\)。
真实 API 当指针。PyTorch 的 torch.autograd 做的是同一件事:每个张量运算注册一个 Backward 节点(对应我们的 _backward),tensor.backward() 内部也是拓扑排序后逆序调用。区别只是它处理的是张量、用 C++/CUDA 加速、且默认要求标量起点(否则要传 gradient= 参数)。
梯度累加:为什么必须 zero_grad
机制直觉。注意上面 _backward 里写的是 self.grad += ...。为什么累加而不是覆盖?因为一个节点可能被多条路径用到(比如 x 同时喂给两个分支),它的总梯度是各路径梯度之和——这正是多元链式法则。累加是对的。但副作用是:.grad 不会自动清零,你再调一次 backward,新梯度会加到旧的上面。所以训练循环每步必须先手动清零(PyTorch 里是 optimizer.zero_grad() 或 model.zero_grad())。
最小可跑演示。用纯 numpy 模拟"梯度槽不清零会翻倍"。设 \(f(w)=\sum w_i^2\),则 \(\nabla f = 2w\):
import numpy as np
def grad_of_f(w):
return 2 * w # f = sum(w^2) 的梯度
w = np.array([1.0, 2.0, 3.0])
# 错误:忘了清零,梯度槽一直累加
grad_buf = np.zeros_like(w)
for step in range(2):
grad_buf += grad_of_f(w) # 漏掉 reset
print(f"step {step}: grad = {grad_buf}")
print("--- 正确做法:每步先清零 ---")
grad_buf = np.zeros_like(w)
for step in range(2):
grad_buf[:] = 0 # zero_grad!
grad_buf += grad_of_f(w)
print(f"step {step}: grad = {grad_buf}")
输出会看到错误版第二步是 [4. 8. 12.](翻倍),正确版始终是 [2. 4. 6.]。在真实训练里,漏掉 zero_grad 的典型症状是:loss 不降反而乱跳、或一步迈得过大直接发散——因为你用的"梯度"其实是历史梯度的累积和。
真实 API 当指针。
# 训练循环里的标准三连(第6课会完整讲)
optimizer.zero_grad() # 清空所有参数的 .grad(否则累加)
loss.backward() # 反向:把梯度累加进 .grad
optimizer.step() # 用 .grad 更新参数
no_grad / detach / 冻结参数:三种"别记账"
机制直觉。记账(建图)是有成本的——它要保存中间结果以备反向。有三种场景你想关掉它,目的各不相同:
| 手段 | 做什么 | 典型场景 |
|---|---|---|
torch.no_grad() | 上下文内所有运算都不建图、不存中间值 | 推理(inference)/ 验证:省显存、提速,反正不反传 |
x.detach() | 返回一个与原张量共享数据、但从图上"剪断"的新张量 | 只想取某个中间值参与计算但不让梯度流过它(如目标值、教师网络输出) |
p.requires_grad=False | 让某个参数不再要梯度 | 冻结(freeze)层:迁移学习时固定骨干网络,只训练新加的头 |
区别一句话:no_grad 是按时间段关(一整块代码),detach 是按张量切(在图中某点断开),requires_grad=False 是按参数关(这个权重永久不学)。
真实 API 当指针。
# 1) 推理省显存:整块不建图
with torch.no_grad():
preds = model(x_val) # 不会保存中间激活
# 2) detach 切断图:y_target 不参与梯度
loss = mse(pred, target.detach())
# 3) 冻结骨干,只训分类头
for p in model.backbone.parameters():
p.requires_grad = False # 这些参数 .grad 永远是 None
retain_graph / create_graph(简述,参考即可)。默认 backward() 跑完就释放图(省内存);若你要在同一张图上反传第二次,需 backward(retain_graph=True)。若你要对梯度再求导(二阶导,如某些元学习/正则项),需 create_graph=True,它会把反向过程本身也建成图。新手阶段几乎用不到,遇到 RuntimeError: Trying to backward through the graph a second time 时再回来。
loss.backward(retain_graph=True) # 保留图,准备再反传一次
loss.backward() # 第二次(默认会顺手释放)
工程坑:循环里累加带梯度的张量 → 显存泄漏
机制直觉。记得吗?只要一个张量 requires_grad,对它的运算就会建图、保存中间值。如果你在循环里写 total_loss += loss(loss 是带梯度的张量),那么 total_loss 会把每一步的整张计算图都挂在自己身上不放——循环跑得越久,累积的图越大,显存(GPU memory)一路涨直到 OOM(out of memory)。这是新手最常见的显存泄漏来源。
修复只需一字之差:累加用于记录/打印的标量时,先用 .item()(取出 Python 数值,脱离图)或 .detach()。只有真正要参与反传的那一项才保留梯度。
# 错误:total 拖着 N 张计算图,显存爆炸
total_loss = 0
for x, y in loader:
loss = criterion(model(x), y)
total_loss += loss # loss 带 grad_fn,整张图被挂住
# 正确:要记录就取标量值(三连顺序与上一节一致:先清零)
total_loss = 0.0
for x, y in loader:
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward() # 这一步才需要图
optimizer.step()
total_loss += loss.item() # 只取数值,图可被释放
调一调,观察现象
微任务 1:把 tanh 换成 relu,观察"死区"梯度为 0。预期现象:当输入为负,relu 的局部梯度是 0,反向传回去的梯度也是 0。为什么:relu 在负半轴是平的,导数为 0,梯度被"卡断"——这正是 dead ReLU 现象(神经元死亡 / 死区)的根源。
import math
class Value:
def __init__(self, data, _children=(), _op=''):
self.data = data; self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children); self._op = _op
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _b():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _b; return out
def relu(self):
out = Value(self.data if self.data > 0 else 0.0, (self,), 'relu')
def _b():
self.grad += (1.0 if self.data > 0 else 0.0) * out.grad
out._backward = _b; return out
def backward(self):
topo, vis = [], set()
def build(v):
if v not in vis:
vis.add(v)
for ch in v._prev: build(ch)
topo.append(v)
build(self); self.grad = 1.0
for v in reversed(topo): v._backward()
for x0 in [3.0, -3.0]: # 改这个数试试正负
x = Value(x0); w = Value(2.0)
y = (x * w).relu()
y.backward()
print(f"x={x0:+.1f} y={y.data} x.grad={x.grad} w.grad={w.grad}")
微任务 2:故意连续 backward 两次不清零。预期现象:第二次打印的 grad 正好是第一次的两倍。为什么:_backward 用 += 累加,没清零的旧梯度还在槽里。注意 backward() 开头的 self.grad=1.0 只重置了起点(输出)节点 L,叶子节点 a、b 的 .grad 槽没人清,所以它们才累加翻倍——这正好呼应 PyTorch 里 zero_grad 清的是参数(叶子)而非 loss。
import math
class Value:
def __init__(self, data, _children=(), _op=''):
self.data = data; self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children); self._op = _op
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data*other.data, (self, other), '*')
def _b():
self.grad += other.data*out.grad
other.grad += self.data*out.grad
out._backward=_b; return out
def backward(self):
topo, vis = [], set()
def build(v):
if v not in vis:
vis.add(v)
for ch in v._prev: build(ch)
topo.append(v)
build(self); self.grad=1.0
for v in reversed(topo): v._backward()
a, b = Value(2.0), Value(-3.0)
L = a * b
L.backward(); print("第1次 a.grad =", a.grad)
L.backward(); print("第2次 a.grad =", a.grad, "(翻倍了!)")
# 试试:在第二次 backward 前加 a.grad=0; b.grad=0 看看
微任务 3:一个节点被用两次,看梯度如何相加。预期现象:f = x*x 时 x.grad = 2x。为什么:乘法的 _backward 闭包里 self 和 other 都绑定到同一个 x,于是在同一次闭包调用内对 x.grad 先以 self 身份累加一次(加 other.data*out.grad)、再以 other 身份累加一次(加 self.data*out.grad),两次相加得 \(2x\)。这正是 += 累加在"同一节点被同一运算多次引用"上的体现。(小细节:因为 _prev 是集合,self 与 other 是同一对象时会去重,f._prev 实际只有一条边;梯度翻两份靠的是闭包里两次 +=,不是两条边。)
class Value:
def __init__(self, data, _children=(), _op=''):
self.data=data; self.grad=0.0
self._backward=lambda:None
self._prev=set(_children); self._op=_op
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data*other.data, (self, other), '*')
def _b():
self.grad += other.data*out.grad
other.grad += self.data*out.grad
out._backward=_b; return out
def backward(self):
topo, vis=[], set()
def build(v):
if v not in vis:
vis.add(v)
for ch in v._prev: build(ch)
topo.append(v)
build(self); self.grad=1.0
for v in reversed(topo): v._backward()
x = Value(5.0)
f = x * x # self 与 other 都绑定到同一个 x
f.backward()
print("f =", f.data, " x.grad =", x.grad, "(应为 2x = 10)")
动手练习
- 给引擎加
__pow__。实现Value.__pow__(self, k)(k为常数),其_backward应为self.grad += k * self.data**(k-1) * out.grad。用x**2验证x=3时x.grad==6。 - autograd 透视实验(项目映射)。用主线的
Value引擎搭一个两输入的小表达式(如(a*b + a).tanh()),打印每个节点的_op和反向后的.grad;手算对照。再试:第二次 backward 前不清零,记录 grad 如何变化。(若你已装好 PyTorch,可在本地对照打印真实张量的grad_fn与.grad,体会.detach()后grad_fn消失、requires_grad=False后.grad为None的差别。) - 写出错误与正确两版"累加 loss"。不用真跑,用注释解释为什么
total += loss会拖图、而total += loss.item()不会。这道题检验你是否真懂"带梯度张量 = 挂着一张图"。
掌握自检
- 我能不看讲义,写出
Value的__add__、__mul__的_backward闭包,并说出每行对应哪条求导规则。 - 我能解释为什么
.backward()必须按拓扑序逆向遍历,而不能随便挑节点先算。 - 我能说出
.grad用+=累加的两个后果:一个是对的(多路径求和),一个是坑(漏 zero_grad 致翻倍)。 - 给我一段代码,我能判断
no_grad/detach/requires_grad=False哪个合适。 - 我一眼能看出
total_loss += loss是显存泄漏,并改成.item()。
可以先放过的点
- retain_graph / create_graph 的细节:等你真遇到二阶导(高阶优化、某些正则项)或"backward 第二次报错"时再回来,现在知道它们存在即可。
- 张量版 autograd 里的广播与求和如何反向:标量引擎已讲透机制;张量情形下
backward要对广播维度求和,这属于实现细节,下一课用到nn.Module时 PyTorch 会替你处理。 - 自定义
autograd.Function:写自定义反向算子是进阶需求,等你需要一个 PyTorch 没有的可微操作时再学。