首页 / 模块 3 · Python、NumPy、PyTorch 与实验工程 / 第 4 课(共 10 课)

Autograd 自动微分

从零到前沿 ML 自学课程 · 阶段0:数学与工具基础 · 能力点:Autograd——亲手写一个 micrograd 把"自动微分魔法"拆开(接 M1 反向传播)

上一课你把数据装进了张量(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 就丢弃,下次重建。记账时,它给每个张量贴三类标签:

∂u/∂a=b=-3 ∂u/∂b=a=2 ∂L/∂u=1 ∂L/∂c=1 a data=2 requires_grad grad_fn=None a.grad = -3 b data=-3 grad_fn=None b.grad = 2 c data=10 grad_fn=None c.grad = 1 × MulBackward u = a*b = -6 grad_fn=MulBackward + AddBackward grad_fn=AddBackward L data=4 ∂L/∂L=1 (反向起点) 动态计算图与反向梯度流 前向(实线)记录每个运算的 grad_fn 构成 DAG;反向(虚线)按链式法则逆向把局部梯度相乘传回叶子。 实线 = 前向构图 (forward) 虚线 = 反向梯度流 (backward)
动态计算图与反向梯度流:前向(实线)记录每个运算的 grad_fn 构成 DAG,反向(虚线箭头)按链式法则逆向把局部梯度相乘传回叶子节点。

真实 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 不降反而乱跳、或一步迈得过大直接发散——因为你用的"梯度"其实是历史梯度的累积和。

错误:忘记 zero_grad 时刻 0 时刻 1 时刻 2 .grad 槽 .grad 槽 .grad 槽 0 (空) backward() 注入 g g 再 backward() 累加 +g 2g 溢出 梯度翻倍 g→2g → 更新过大 / 训练发散 正确:每步先 zero_grad 时刻 0 时刻 1 时刻 2 .grad 槽 .grad 槽 .grad 槽 0 (空) zero_grad() 复位 0 → backward() 注入 g g 先 zero_grad 清回 0 再注入 g g 每步都是干净的 g → 更新稳定
梯度累加陷阱:.grad 槽不会自动清零,连续两次 backward 会把梯度从 g 累加成 2g;正确做法是每步先 zero_grad 把槽复位为 0。

真实 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 += lossloss 是带梯度的张量),那么 total_loss 会把每一步的整张计算图都挂在自己身上不放——循环跑得越久,累积的图越大,显存(GPU memory)一路涨直到 OOM(out of memory)。这是新手最常见的显存泄漏来源。

修复只需一字之差:累加用于记录/打印的标量时,先用 .item()(取出 Python 数值,脱离图)或 .detach()。只有真正要参与反传的那一项才保留梯度。

错误:total += loss 循环每步把整张计算图挂在 total 上 step1 loss grad_fn step2 loss grad_fn step3 loss grad_fn total_loss +图1 +图2 +图3 膨胀... GPU memory OOM 显存爆炸:N 张图全被挂住 正确:total += loss.item() .item() 取标量,图当步即回收 step1 loss grad_fn 图已释放 .item() 0.83 step2 loss grad_fn .item() 0.71 step3 loss grad_fn .item() 0.64 total_loss 0.83+0.71+0.64 只是数字累加 GPU memory 只存数值,图及时回收
循环里累加带梯度张量导致显存泄漏:total += loss 把每步整张计算图都挂住不释放;改成 total += loss.item() 取出标量数值,图即可回收。
# 错误: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,叶子节点 ab.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*xx.grad = 2x。为什么:乘法的 _backward 闭包里 selfother 都绑定到同一个 x,于是在同一次闭包调用内对 x.grad 先以 self 身份累加一次(加 other.data*out.grad)、再以 other 身份累加一次(加 self.data*out.grad),两次相加得 \(2x\)。这正是 += 累加在"同一节点被同一运算多次引用"上的体现。(小细节:因为 _prev 是集合,selfother 是同一对象时会去重,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)")

动手练习

  1. 给引擎加 __pow__实现 Value.__pow__(self, k)k 为常数),其 _backward 应为 self.grad += k * self.data**(k-1) * out.grad。用 x**2 验证 x=3x.grad==6
  2. autograd 透视实验(项目映射)。用主线的 Value 引擎搭一个两输入的小表达式(如 (a*b + a).tanh()),打印每个节点的 _op 和反向后的 .grad;手算对照。再试:第二次 backward 前不清零,记录 grad 如何变化。(若你已装好 PyTorch,可在本地对照打印真实张量的 grad_fn.grad,体会 .detach()grad_fn 消失、requires_grad=False.gradNone 的差别。)
  3. 写出错误与正确两版"累加 loss"。不用真跑,用注释解释为什么 total += loss 会拖图、而 total += loss.item() 不会。这道题检验你是否真懂"带梯度张量 = 挂着一张图"。

掌握自检