深度学习框架自动微分原理
1. 什么是自动微分
自动微分(Automatic Differentiation,AD)是 PyTorch、TensorFlow、JAX 等深度学习框架自动计算梯度的核心技术。
它的主要思想是:
将一个复杂函数拆分为许多基本运算,在前向计算时记录这些运算,在反向传播时利用每个基本运算的局部导数和链式法则,自动计算最终梯度。
自动微分并没有发明新的求导公式。
每个基本运算的导数,仍然来自普通微积分中的求导公式。
| 基本运算 | 局部导数 |
|---|---|
| ReLU | 时为 ,否则为 |
| 矩阵乘法 | 使用对应的矩阵求导规则 |
自动微分真正自动完成的是:
-
记录计算过程;
-
构建计算图;
-
管理变量之间的依赖关系;
-
自动应用链式法则;
-
累积不同路径产生的梯度。
2. 自动微分与其他求导方式的区别
2.1 数值微分
数值微分利用导数定义近似计算:
优点是实现简单,缺点是:
-
存在截断误差和浮点误差;
-
每个变量通常都需要重复计算;
-
参数很多时效率非常低。
数值微分通常用于梯度检查,而不是神经网络训练。
2.2 符号微分
符号微分直接操作数学表达式。
例如:
经过符号求导得到:
它的优点是结果是显式数学表达式,缺点是复杂函数可能发生表达式膨胀。
2.3 自动微分
自动微分既不是数值近似,也不是先推导出完整的符号表达式。
它会把函数拆成基础运算,然后在具体数值点上,通过链式法则计算精确梯度。
例如:
可以拆成:
局部导数分别为:
根据链式法则:
$$
\frac{df}{dx}
\frac{df}{dt_2}
\frac{dt_2}{dt_1}
\frac{dt_1}{dx}
3. 计算图
计算图是自动微分的基础。
假设:
可以拆成:
计算关系为:
a ──┐
├── 加法 ── t ──┐
b ──┘ ├── 乘法 ── y
c ──────────────────┘
前向传播时,框架计算并保存中间变量:
a、b、c
↓
t = a + b
↓
y = t × c
反向传播时,从最终输出开始,沿计算图反方向传播梯度:
y
↓
t、c
↓
a、b
4. 链式法则与反向传播
对于:
要求:
根据链式法则:
$$
\frac{\partial y}{\partial a}
\frac{\partial y}{\partial t}
\frac{\partial t}{\partial a}
\frac{\partial y}{\partial t}=c
\frac{\partial t}{\partial a}=1
\frac{\partial y}{\partial a}=c
所谓反向传播,本质上就是: > 从最终标量目标出发,沿计算图反向传播上游梯度,并将其与每个节点的局部导数相乘。 --- ## 5. PyTorch 中的自动微分 基本示例: ```python import torch x = torch.tensor(3.0, requires_grad=True) y = x ** 2 y.backward() print(x.grad) ``` 输出: ```text tensor(6.) ``` 过程如下: 1. `requires_grad=True` 表示需要追踪与 `x` 有关的运算; 2. 执行 `y = x ** 2` 时构建计算图; 3. `y.backward()` 从 `y` 开始反向传播; 4. 最终把梯度保存到 `x.grad`。 因为:y=x^2
\frac{dy}{dx}=2x=6
--- ## 6. 标量对向量求导 假设:x=
\begin{bmatrix}
x_1\
x_2
\end{bmatrix}
L=f(x_1,x_2)
那么 $L$ 对 $x$ 的导数是梯度向量: # $$ \nabla_xL \begin{bmatrix} \frac{\partial L}{\partial x_1}\ \frac{\partial L}{\partial x_2} \end{bmatrix}这是深度学习中最常见的情况:
大量模型参数
↓
一个标量 Loss
优化器需要的正是:
其中 表示所有模型参数。
7. 向量对向量求导与 Jacobian
当输入和输出都是向量时,导数通常不再是普通向量,而是 Jacobian 矩阵。
设:
则:
$$
\frac{\partial y}{\partial x}
\begin{bmatrix}
\frac{\partial y_1}{\partial x_1}
&
\frac{\partial y_1}{\partial x_2}
\
\frac{\partial y_2}{\partial x_1}
&
\frac{\partial y_2}{\partial x_2}
\end{bmatrix}
y_1=x_1^2+x_2
y_2=3x_1+2x_2
则: # $$ J= \frac{\partial y}{\partial x} \begin{bmatrix} 2x_1 & 1\ 3 & 2 \end{bmatrix}Jacobian 的每一行表示一个输出分量对全部输入变量的梯度。
8. 为什么训练时通常不计算完整 Jacobian
假设一个批次包含 个样本,每个样本对应一个损失:
模型共有 个参数。
如果计算完整 Jacobian:
其形状为:
当模型有数十亿参数、批次又很大时,完整 Jacobian 会占用极大的显存和计算资源。
训练中通常只需要批次总损失或平均损失对模型参数的梯度:
或者:
然后计算:
根据求导的线性性质:
$$
\frac{\partial L}{\partial\theta}
\sum_{i=1}^{B}
\frac{\partial l_i}{\partial\theta}
因此,训练时并不需要显式保存每个样本对每个参数形成的巨大 Jacobian。
9. Loss 求和会不会造成抵消
假设有一个向量:
求和后确实得到:
但需要区分:
-
误差;
-
损失。
9.1 误差可以有正有负
预测误差可能为:
如果直接把误差相加,正负误差会抵消,因此这种目标函数通常不合适。
9.2 损失通常设计为非负
例如均方误差:
对于:
得到:
不会发生正负抵消。
常见损失函数包括:
-
平方误差;
-
绝对误差;
-
交叉熵;
-
负对数似然。
这些损失通常具有非负性或其他合理的优化性质。
9.3 标量值为零不代表梯度为零
设:
当 时:
总和为:
但:
因此:
当前函数值为零,不代表梯度一定为零。
梯度描述的是函数值随参数变化的速度,而不是函数值本身的大小。
10. 向量输出调用 backward() 的含义
当输出 是向量时,不能像标量一样直接确定唯一梯度。
设:
调用:
y.backward(torch.tensor([a, b]))
相当于构造标量目标:
然后计算:
这就是所谓的加权求和。
其中:
表示每个输出分量在最终标量目标中的权重。
11. 权重向量的含义
继续使用:
Jacobian 为:
11.1 权重为 [1, 1]
y.backward(torch.tensor([1.0, 1.0]))
相当于:
梯度为:
$$
\nabla_xL
\nabla_xy_1+\nabla_xy_2
即: # $$ [2x_1,1]+[3,2] [2x_1+3,3]11.2 权重为 [1, 0]
y.backward(torch.tensor([1.0, 0.0]))
相当于:
因此只计算:
11.3 权重为 [0, 1]
y.backward(torch.tensor([0.0, 1.0]))
相当于:
因此只计算:
11.4 权重为 [2, -1]
y.backward(torch.tensor([2.0, -1.0]))
相当于:
因此:
$$
\nabla_xL
2\nabla_xy_1-\nabla_xy_2
权重决定了: 1. 哪些输出参与反向传播; 2. 每个输出参与多少; 3. 每个输出对应梯度如何组合。 --- ## 12. 向量–Jacobian 积 设:v=
\begin{bmatrix}
a\
b
\end{bmatrix}
v^\mathsf{T}J
L=v^\mathsf{T}y
所以: # $$ \frac{\partial L}{\partial x} \frac{\partial L}{\partial y} \frac{\partial y}{\partial x}其中:
因此:
$$
\frac{\partial L}{\partial x}
v^\mathsf{T}J
反向模式自动微分通常高效计算的就是 VJP,而不是显式构建 Jacobian。 --- ## 13. 为什么 `backward()` 参数叫 `gradient` 假设:L=2y_1-y_2
那么: # $$ \frac{\partial L}{\partial y} \begin{bmatrix} 2\ -1 \end{bmatrix}因此:
y.backward(torch.tensor([2.0, -1.0]))
传入的向量实际上表示:
它是从计算图后面传回来的梯度,因此也叫:
上游梯度。
框架将上游梯度与当前节点的局部 Jacobian 相乘,再继续向前传播。
14. 标量为什么可以直接调用 backward()
如果 是标量:
y.backward()
等价于:
y.backward(torch.tensor(1.0))
因为可以把最终目标看成:
于是:
标量输出只有一个分量,默认权重自然就是 。
而向量输出有多个分量,框架无法自动判断用户希望计算:
还是:
或者:
所以必须显式传入上游梯度,或者先使用 sum()、mean() 等方法把向量转成标量。
15. sum() 与 mean() 的本质
假设:
执行:
losses.sum().backward()
相当于权重:
因为:
执行:
losses.mean().backward()
相当于权重:
因为:
所以 sum() 和 mean() 都是在定义一个标量目标,只是使用了不同的权重。
16. 分离计算 detach()
分离计算指的是:
保留一个张量当前的数值,但切断它与此前计算图之间的梯度联系。
PyTorch 中使用:
u = y.detach()
u 和 y 数值相同,但它们的梯度属性不同:
y:保留此前的计算历史
u:不再追踪此前的计算历史
17. detach() 示例
import torch
x = torch.arange(4.0, requires_grad=True)
y = x * x
u = y.detach()
z = u * x
z.sum().backward()
print(x.grad)
这里:
从数值上看:
所以:
但反向传播时,框架将 看成常量。
因此:
而:
最终得到:
而不是正常情况下的:
18. detach() 前后的对比
18.1 不使用 detach()
y = x * x
z = y * x
有:
因此:
这是因为 通过两条路径影响 :
路径 1:x → y → z
路径 2:x → z
梯度会沿两条路径传播并累加。
18.2 使用 detach()
y = x * x
u = y.detach()
z = u * x
计算图在 u 处被切断:
x → y u → z
×
梯度切断
反向传播只看到:
并将 当作常量。
所以:
19. detach() 的直观理解
detach() 分离的是:
张量当前的数值
和
生成该数值的计算历史
它不是删除数值,也不是停止后续计算。
可以理解为:
前向传播:仍然正常使用这个值
反向传播:到这里停止,不再继续向前追踪
20. detach() 的常见用途
20.1 冻结部分网络
假设:
输入
↓
网络 A
↓
中间特征
↓
网络 B
↓
Loss
正常情况下,梯度会同时更新网络 A 和网络 B。
如果写:
middle = network_a(x).detach()
output = network_b(middle)
梯度只会更新网络 B,不会传回网络 A。
20.2 构造固定目标
target = model(x).detach()
loss = ((prediction - target) ** 2).mean()
虽然 target 是模型计算得到的,但本次优化中希望把它当作固定目标,因此切断其梯度。
20.3 强化学习中的目标值
例如:
通常不希望梯度通过目标值传播:
target = reward + gamma * next_q.detach()
loss = (current_q - target) ** 2
20.4 教师网络或目标网络
在知识蒸馏、对比学习、强化学习等任务中,某个网络的输出可能只作为监督信号,不应该参与当前梯度更新。
此时经常使用:
teacher_output = teacher_model(x).detach()
21. detach() 与 torch.no_grad() 的区别
21.1 detach()
针对某个具体张量:
u = y.detach()
含义是:
从当前张量开始,切断此前的计算图。
后续对 u 的计算仍然可以建立新的计算图。
21.2 torch.no_grad()
针对一整段计算:
with torch.no_grad():
y = model(x)
含义是:
这段代码中的运算都不要记录到计算图中。
常用于:
-
推理;
-
验证;
-
参数手动更新;
-
不需要梯度的模型计算。
22. detach() 后还能不能继续求导
可以。
例如:
u = y.detach().requires_grad_()
z = u * 3
z.sum().backward()
此时可以计算:
但是不能再通过 u 追溯到原来的 y 和 x。
u 会成为一张新计算图中的叶子节点:
旧图:x → y
新图:u → z
两个计算图之间的梯度联系已经被切断。
23. 自动微分的整体流程
一个典型的自动微分过程可以概括为:
复杂函数
↓
拆成基础算子
↓
前向计算并记录计算图
↓
得到最终标量目标
↓
从目标开始反向传播
↓
每个节点接收上游梯度
↓
乘以本节点的局部导数
↓
将结果继续向输入方向传播
↓
多条路径的梯度相加
↓
得到每个参数的最终梯度
24. 核心结论
24.1 关于自动微分
自动微分本质上是:
用基本算子的局部导数和链式法则,自动组合出复杂函数的梯度。
24.2 关于向量输出
向量对向量求导的完整结果是 Jacobian。
但反向传播通常不显式构建 Jacobian,而是计算:
即向量–Jacobian 积。
24.3 关于 backward()
y.backward(v)
等价于先构造:
再计算:
传入的 是上游梯度,也是各个输出分量的组合权重。
24.4 关于批次损失
训练时通常将每个样本的损失求和或求平均,形成一个标量目标:
或者:
优化器使用的是这个标量目标对模型参数的梯度。
24.5 关于 detach()
u = y.detach()
表示:
保留
y的当前数值,但切断y之前的梯度传播路径。
可以概括为:
前向可以继续使用
反向不能继续追溯
25. PyTorch 综合示例
25.1 向量输出与加权反向传播
import torch
# 创建需要求梯度的输入
x = torch.tensor([2.0, 4.0], requires_grad=True)
# 向量输出
y1 = x[0] ** 2 + x[1]
y2 = 3 * x[0] + 2 * x[1]
y = torch.stack([y1, y2])
# 相当于构造 L = y1 + y2
y.backward(torch.tensor([1.0, 1.0]))
print(x.grad)
输出:
tensor([7., 3.])
因为:
所以:
$$
\nabla_x(y_1+y_2)
[4,1]+[3,2]
[7,3]
--- ### 25.2 分离计算 ```python import torch x = torch.tensor(2.0, requires_grad=True) y = x ** 2 u = y.detach() z = u * x z.backward() print(x.grad) ``` 输出: ```text tensor(4.) ``` 虽然前向数值上:z=x^3=8
但反向传播将 $u=4$ 当作常量,因此:\frac{dz}{dx}=u=4
3x^2=12
