自动微分
自动微分是深度学习中"学习"的来源,其核心内容包括链式法则、偏导数、梯度、反向传播等。在工程实践中,梯度下降直接决定了模型能否收敛。
什么是自动微分
自动微分(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)\) 为例,前向计算过程可以分解为:
取 \(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 展开:
也就是说,如果把 \(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) 的原因。
反向模式计算过程
反向模式分两步:
- 前向传播(Forward Pass): 计算所有中间变量的值,并记录计算图
- 反向传播(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\) |
一次反向传播同时得到:
反向模式的特点
- 每次反向传播计算 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\),链式法则告诉我们:
前向模式从右往左累乘(从输入端开始),反向模式从左往右累乘(从输出端开始)。
示例:简单两层网络
考虑一个两层网络:
其中 \(\sigma\) 是激活函数。反向传播的计算过程:
第一步, 计算 Loss 对输出的导数:
第二步, 传播到第二层参数:
第三步, 继续传播到隐藏层:
第四步, 传播到第一层参数(注意激活函数的导数):
其中 \(\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),也是反向模式自动微分的本质操作。