Skip to content

自动微分

自动微分是深度学习中"学习"的来源,其核心内容包括链式法则、偏导数、梯度、反向传播等。在工程实践中,梯度下降直接决定了模型能否收敛。

什么是自动微分

自动微分(Automatic Differentiation, AD) 既不是符号微分,也不是数值微分,而是一种利用计算图(Computation Graph) 精确高效地计算导数的技术。

它的核心思想很简单:任何复杂的函数,无论多复杂,最终都是由有限个基本运算(加减乘除、指数、对数、三角函数等)组合而成的。既然我们知道每个基本运算的导数,就可以通过链式法则(Chain Rule) 把它们"串"起来,自动地得到整个函数的精确导数。

为什么自动微分对深度学习至关重要?因为反向传播(Backpropagation)本质上就是反向模式的自动微分。当你在 PyTorch 中调用 loss.backward() 时,底层执行的就是自动微分。没有它,我们就无法高效地计算深度神经网络中数百万参数的梯度。

三种求导方式对比

方法 原理 精度 计算复杂度 优缺点
手动求导 用纸笔推导公式 精确 人工成本高 容易出错,无法扩展到复杂网络
数值微分(Numerical Differentiation) 有限差分 \(\frac{f(x+h)-f(x)}{h}\) 近似,有截断误差 \(O(n)\) 次前向传播(\(n\) 为参数数) 实现简单,但慢且不精确,常用于梯度检查(Gradient Checking)
符号微分(Symbolic Differentiation) 用代数规则对表达式求导 精确 表达式可能指数级膨胀 适合简单公式,但复杂函数会导致"表达式膨胀"问题
自动微分(Automatic Differentiation) 在计算图上逐步应用链式法则 精确(机器精度) 与前向传播同阶 兼顾精确和高效,是深度学习的标准选择

数值微分的问题举例: 计算 \(f'(x) \approx \frac{f(x+h) - f(x-h)}{2h}\)(中心差分),如果 \(h\) 太大,截断误差大;如果 \(h\) 太小,浮点数舍入误差大。对于有 \(10^8\) 个参数的网络,需要做 \(10^8\) 次前向传播才能算出完整梯度,这在实践中完全不可行。

符号微分的问题举例:\(f(x) = \sin(e^{x^2 + \ln x})\) 做符号微分,每一步都展开成新的子表达式,结果的长度可能远超原始函数。这就是表达式膨胀(Expression Swell) 问题。

自动微分巧妙地避开了这两个问题:它在数值层面逐步计算(不做符号展开),同时利用精确的导数规则(不做有限差分近似)。

计算图 (Computation Graph)

计算图是自动微分的核心数据结构。它是一个有向无环图(DAG),其中:

  • 节点(Node) 代表变量或运算
  • 边(Edge) 代表数据流向

以函数 \(f(x, y) = (x + y) \cdot \sin(x)\) 为例,前向计算过程可以分解为:

\[ v_1 = x, \quad v_2 = y \]
\[ v_3 = v_1 + v_2 \quad \text{(加法)} \]
\[ v_4 = \sin(v_1) \quad \text{(正弦)} \]
\[ v_5 = v_3 \cdot v_4 \quad \text{(乘法)} \]

\(x = \frac{\pi}{2}, \; y = 1\),前向传播的数值计算:

步骤 表达式 数值
\(v_1 = x\) \(1.571\)
\(v_2 = y\) \(1.000\)
\(v_3 = v_1 + v_2\) \(\frac{\pi}{2} + 1\) \(2.571\)
\(v_4 = \sin(v_1)\) \(\sin(\frac{\pi}{2})\) \(1.000\)
\(v_5 = v_3 \cdot v_4\) \(2.571 \times 1.000\) \(2.571\)

前向传播在构建计算图的同时完成了函数值的计算。接下来的问题是:如何利用这张图来计算导数?

前向模式 (Forward Mode AD)

前向模式自动微分的核心思想是:在前向传播的同时,携带一个"导数分量"一起传播。

对偶数 (Dual Numbers)

前向模式的数学基础是对偶数(Dual Numbers)。定义对偶数 \(a + b\epsilon\),其中 \(\epsilon^2 = 0\)(类似虚数单位 \(i^2 = -1\),但这里平方为零)。

将函数 \(f\) 在对偶数上进行 Taylor 展开:

\[ f(a + b\epsilon) = f(a) + f'(a) \cdot b\epsilon \]

也就是说,如果把 \(x = a + 1 \cdot \epsilon\) 代入函数 \(f\),结果的实数部分就是函数值 \(f(a)\)\(\epsilon\) 的系数就是导数 \(f'(a)\)。一次前向传播同时得到了函数值和导数。

前向模式计算过程

沿用上面的例子 \(f(x, y) = (x + y) \cdot \sin(x)\),计算 \(\frac{\partial f}{\partial x}\)

设定种子:\(\dot{v}_1 = \frac{\partial x}{\partial x} = 1\)\(\dot{v}_2 = \frac{\partial y}{\partial x} = 0\)(这里 \(\dot{v}\) 表示对 \(x\) 的导数)。

步骤 数值 导数 \(\dot{v}_i = \frac{\partial v_i}{\partial x}\)
\(v_1 = x\) \(1.571\) \(\dot{v}_1 = 1\)
\(v_2 = y\) \(1.000\) \(\dot{v}_2 = 0\)
\(v_3 = v_1 + v_2\) \(2.571\) \(\dot{v}_3 = \dot{v}_1 + \dot{v}_2 = 1\)
\(v_4 = \sin(v_1)\) \(1.000\) \(\dot{v}_4 = \cos(v_1) \cdot \dot{v}_1 = \cos(\frac{\pi}{2}) \cdot 1 \approx 0\)
\(v_5 = v_3 \cdot v_4\) \(2.571\) \(\dot{v}_5 = \dot{v}_3 \cdot v_4 + v_3 \cdot \dot{v}_4 = 1 \times 1 + 2.571 \times 0 = 1\)

所以 \(\frac{\partial f}{\partial x}\bigg|_{x=\frac{\pi}{2}, y=1} = 1\)

前向模式的特点

  • 每次前向传播计算 Jacobian 矩阵的一列:即固定一个输入变量,得到所有输出对它的偏导
  • 如果有 \(n\) 个输入,需要 \(n\) 次前向传播才能得到完整的 Jacobian
  • 适用场景:输入维度 < 输出维度(即 \(n \ll m\)

反向模式 (Reverse Mode AD)

反向模式是深度学习的核心。在深度学习中,我们通常有大量参数(输入维度 \(n\) 很大),但只有一个标量损失函数(输出维度 \(m = 1\))。反向模式只需一次反向传播就能计算所有参数的梯度,这正是它被称为反向传播(Backpropagation) 的原因。

反向模式计算过程

反向模式分两步:

  1. 前向传播(Forward Pass): 计算所有中间变量的值,并记录计算图
  2. 反向传播(Backward Pass): 从输出开始,沿着计算图反向传播,计算每个节点的伴随值(Adjoint) \(\bar{v}_i = \frac{\partial f}{\partial v_i}\)

继续用 \(f(x, y) = (x + y) \cdot \sin(x)\) 的例子,取 \(x = \frac{\pi}{2}, \; y = 1\)

前向传播(同上,得到所有 \(v_i\) 的值)。

反向传播从输出开始:\(\bar{v}_5 = \frac{\partial f}{\partial v_5} = 1\)(输出对自己的导数为 1)。

步骤 反向传播规则 数值
\(\bar{v}_5\) \(\frac{\partial f}{\partial f} = 1\) \(1\)
\(\bar{v}_3\) \(\bar{v}_5 \cdot \frac{\partial v_5}{\partial v_3} = \bar{v}_5 \cdot v_4\) \(1 \times 1.000 = 1\)
\(\bar{v}_4\) \(\bar{v}_5 \cdot \frac{\partial v_5}{\partial v_4} = \bar{v}_5 \cdot v_3\) \(1 \times 2.571 = 2.571\)
\(\bar{v}_1\) \(\bar{v}_3 \cdot \frac{\partial v_3}{\partial v_1} + \bar{v}_4 \cdot \frac{\partial v_4}{\partial v_1}\) \(1 \times 1 + 2.571 \times \cos(\frac{\pi}{2}) = 1\)
\(\bar{v}_2\) \(\bar{v}_3 \cdot \frac{\partial v_3}{\partial v_2}\) \(1 \times 1 = 1\)

一次反向传播同时得到:

\[ \frac{\partial f}{\partial x} = \bar{v}_1 = 1, \quad \frac{\partial f}{\partial y} = \bar{v}_2 = 1 \]

反向模式的特点

  • 每次反向传播计算 Jacobian 矩阵的一行:即固定一个输出,得到它对所有输入的偏导
  • 如果有 \(m\) 个输出,需要 \(m\) 次反向传播
  • 适用场景:输出维度 < 输入维度(即 \(m \ll n\)
  • 深度学习中 \(m = 1\)(标量 Loss),所以只需一次反向传播即可得到所有参数的梯度

前向模式 vs 反向模式

对比项 前向模式 反向模式
传播方向 与计算方向相同 与计算方向相反
每次计算 Jacobian 的一列 Jacobian 的一行
完整 Jacobian 代价 \(n\) 次传播 \(m\) 次传播
适用场景 \(n \ll m\) \(m \ll n\)(深度学习)
额外内存 几乎不需要 需要存储前向传播中间值

链式法则与反向传播

数学推导

反向模式自动微分的数学本质就是链式法则的反向应用

对于复合函数 \(f = f_L \circ f_{L-1} \circ \cdots \circ f_1\),链式法则告诉我们:

\[ \frac{\partial f}{\partial \mathbf{x}} = \frac{\partial f_L}{\partial \mathbf{h}_{L-1}} \cdot \frac{\partial f_{L-1}}{\partial \mathbf{h}_{L-2}} \cdots \frac{\partial f_1}{\partial \mathbf{x}} \]

前向模式从右往左累乘(从输入端开始),反向模式从左往右累乘(从输出端开始)。

示例:简单两层网络

考虑一个两层网络:

\[ \mathbf{h} = \sigma(W_1 \mathbf{x} + \mathbf{b}_1), \quad \hat{y} = W_2 \mathbf{h} + \mathbf{b}_2, \quad L = \frac{1}{2}(\hat{y} - y)^2 \]

其中 \(\sigma\) 是激活函数。反向传播的计算过程:

第一步, 计算 Loss 对输出的导数:

\[ \frac{\partial L}{\partial \hat{y}} = \hat{y} - y \]

第二步, 传播到第二层参数:

\[ \frac{\partial L}{\partial W_2} = \frac{\partial L}{\partial \hat{y}} \cdot \mathbf{h}^T, \quad \frac{\partial L}{\partial \mathbf{b}_2} = \frac{\partial L}{\partial \hat{y}} \]

第三步, 继续传播到隐藏层:

\[ \frac{\partial L}{\partial \mathbf{h}} = W_2^T \cdot \frac{\partial L}{\partial \hat{y}} \]

第四步, 传播到第一层参数(注意激活函数的导数):

\[ \frac{\partial L}{\partial W_1} = \left( \frac{\partial L}{\partial \mathbf{h}} \odot \sigma'(W_1 \mathbf{x} + \mathbf{b}_1) \right) \cdot \mathbf{x}^T \]

其中 \(\odot\) 表示逐元素乘法(Hadamard Product)。

这就是反向传播的完整过程:从 Loss 出发,逐层反向计算每个参数的梯度。每一步都只涉及局部的导数和上游传递下来的梯度,这正是自动微分的精髓。

PyTorch 中的自动微分

PyTorch 的 torch.autograd 模块实现了反向模式自动微分。以下是核心用法。

基本用法

import torch

# 创建张量,requires_grad=True 表示需要计算梯度
x = torch.tensor(2.0, requires_grad=True)
y = torch.tensor(3.0, requires_grad=True)

# 前向传播 — PyTorch 自动构建计算图
z = x**2 + 3*x*y + y**2  # z = x^2 + 3xy + y^2

# 反向传播 — 计算 dz/dx 和 dz/dy
z.backward()

# 读取梯度
print(x.grad)  # dz/dx = 2x + 3y = 2*2 + 3*3 = 13
print(y.grad)  # dz/dy = 3x + 2y = 3*2 + 2*3 = 12

梯度累积与清零

PyTorch 中的梯度是累积的,不会自动清零。这在训练循环中是一个常见陷阱:

optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

for data, target in dataloader:
    optimizer.zero_grad()      # 必须先清零,否则梯度会累积
    output = model(data)       # 前向传播
    loss = criterion(output, target)
    loss.backward()            # 反向传播,计算梯度
    optimizer.step()           # 更新参数

如果忘了调用 optimizer.zero_grad()(或 model.zero_grad()),每次 backward() 计算的梯度会叠加到 .grad 属性上,导致梯度错误地持续增大。

torch.no_grad() 上下文

在推理阶段(Inference)不需要计算梯度,使用 torch.no_grad() 可以节省内存并加速计算:

# 推理时关闭梯度计算
with torch.no_grad():
    output = model(test_input)
    # 此上下文中的运算不会被记录到计算图中

# 等价的装饰器写法
@torch.no_grad()
def inference(model, x):
    return model(x)

detach() 方法

detach() 用于将张量从计算图中分离,返回一个不再追踪梯度的新张量:

h = model.encoder(x)
h_detached = h.detach()  # 切断梯度流,encoder 不会收到来自后续计算的梯度

# 常见用途:作为另一个模型的输入,但不希望梯度回传
output = another_model(h_detached)

常见陷阱

1. In-place 操作破坏计算图

PyTorch 会检测 in-place 操作是否影响了梯度计算。某些 in-place 操作会导致运行时错误:

x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x * 2
y += 1          # in-place 操作,可能报错:
                # RuntimeError: one of the variables needed for gradient
                # computation has been modified by an inplace operation

# 正确写法
y = y + 1       # 创建新张量,不影响计算图

2. detach() vs no_grad() 的区别

# detach():从计算图中分离一个张量(计算图仍然存在)
z = x.detach()  # z 与 x 共享数据,但 z 不在计算图中

# no_grad():整个上下文中不构建计算图(更高效)
with torch.no_grad():
    z = x * 2   # 不记录任何操作到计算图
  • detach() 适合在训练过程中选择性地切断某些梯度路径(如 GAN 训练中的 stop-gradient)
  • no_grad() 适合推理阶段或不需要梯度的计算块

3. 梯度累积的正确使用

有时我们故意利用梯度累积来模拟更大的 batch size:

accumulation_steps = 4
optimizer.zero_grad()

for i, (data, target) in enumerate(dataloader):
    output = model(data)
    loss = criterion(output, target)
    loss = loss / accumulation_steps  # 缩放 loss
    loss.backward()                   # 梯度累积

    if (i + 1) % accumulation_steps == 0:
        optimizer.step()              # 每累积 4 步才更新一次参数
        optimizer.zero_grad()         # 更新后清零

4. 非标量输出的 backward()

backward() 默认只能对标量调用。如果输出不是标量,需要传入 gradient 参数:

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x * 2  # y 是向量,不是标量

# 不能直接调用 y.backward(),需要传入与 y 同形状的权重向量
y.backward(torch.tensor([1.0, 1.0, 1.0]))
print(x.grad)  # tensor([2., 2., 2.])

这在数学上等价于计算 \(\mathbf{v}^T \cdot J\),其中 \(J\) 是 Jacobian 矩阵,\(\mathbf{v}\) 是传入的 gradient 向量。这就是所谓的 VJP(Vector-Jacobian Product),也是反向模式自动微分的本质操作。


评论 #