AI

深度学习框架自动微分原理

·24 分钟阅读·9229 字

深度学习框架中自动微分、计算图、链式法则和 PyTorch Autograd 的实现原理

📋 目录

深度学习框架自动微分原理

1. 什么是自动微分

自动微分(Automatic Differentiation,AD)是 PyTorch、TensorFlow、JAX 等深度学习框架自动计算梯度的核心技术。

它的主要思想是:

将一个复杂函数拆分为许多基本运算,在前向计算时记录这些运算,在反向传播时利用每个基本运算的局部导数和链式法则,自动计算最终梯度。

自动微分并没有发明新的求导公式。

每个基本运算的导数,仍然来自普通微积分中的求导公式。

基本运算局部导数
y=x+cy=x+cdydx=1\frac{dy}{dx}=1
y=x2y=x^2dydx=2x\frac{dy}{dx}=2x
y=sin⁡xy=\sin xdydx=cos⁡x\frac{dy}{dx}=\cos x
y=exy=e^xdydx=ex\frac{dy}{dx}=e^x
y=log⁡xy=\log xdydx=1x\frac{dy}{dx}=\frac{1}{x}
ReLUx>0x>0 时为 11,否则为 00
矩阵乘法使用对应的矩阵求导规则

自动微分真正自动完成的是:

  1. 记录计算过程;

  2. 构建计算图;

  3. 管理变量之间的依赖关系;

  4. 自动应用链式法则;

  5. 累积不同路径产生的梯度。


2. 自动微分与其他求导方式的区别

2.1 数值微分

数值微分利用导数定义近似计算:

f′(x)≈f(x+h)−f(x)hf'(x)\approx\frac{f(x+h)-f(x)}{h}

优点是实现简单,缺点是:

  • 存在截断误差和浮点误差;

  • 每个变量通常都需要重复计算;

  • 参数很多时效率非常低。

数值微分通常用于梯度检查,而不是神经网络训练。


2.2 符号微分

符号微分直接操作数学表达式。

例如:

y=x2+3xy=x^2+3x

经过符号求导得到:

dydx=2x+3\frac{dy}{dx}=2x+3

它的优点是结果是显式数学表达式,缺点是复杂函数可能发生表达式膨胀。


2.3 自动微分

自动微分既不是数值近似,也不是先推导出完整的符号表达式。

它会把函数拆成基础运算,然后在具体数值点上,通过链式法则计算精确梯度。

例如:

f(x)=sin⁡(x2+1)f(x)=\sin(x^2+1)

可以拆成:

t1=x2 t2=t1+1 f=sin⁡(t2)\begin{aligned} t_1 &= x^2 \ t_2 &= t_1+1 \ f &= \sin(t_2) \end{aligned}

局部导数分别为:

dt1dx=2x\frac{dt_1}{dx}=2x dt2dt1=1\frac{dt_2}{dt_1}=1 dfdt2=cos⁡(t2)\frac{df}{dt_2}=\cos(t_2)

根据链式法则:

$$

\frac{df}{dx}

\frac{df}{dt_2}
\frac{dt_2}{dt_1}
\frac{dt_1}{dx}

因此: # $$ \frac{df}{dx} \cos(x^2+1)\cdot2x

3. 计算图

计算图是自动微分的基础。

假设:

y=(a+b)cy=(a+b)c

可以拆成:

t=a+bt=a+b y=tcy=tc

计算关系为:

a ──┐
    ├── 加法 ── t ──┐
b ──┘               ├── 乘法 ── y
c ──────────────────┘

前向传播时,框架计算并保存中间变量:

a、b、c
   ↓
t = a + b
   ↓
y = t × c

反向传播时,从最终输出开始,沿计算图反方向传播梯度:

y
↓
t、c
↓
a、b

4. 链式法则与反向传播

对于:

t=a+bt=a+b y=tcy=tc

要求:

∂y∂a\frac{\partial y}{\partial a}

根据链式法则:

$$

\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

优化器需要的正是:

∇θL\nabla_\theta L

其中 θ\theta 表示所有模型参数。


7. 向量对向量求导与 Jacobian

当输入和输出都是向量时,导数通常不再是普通向量,而是 Jacobian 矩阵。

设:

x=[x1 x2]x= \begin{bmatrix} x_1\ x_2 \end{bmatrix} y=[y1 y2]y= \begin{bmatrix} y_1\ y_2 \end{bmatrix}

则:

$$

\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}

这个矩阵叫作Jacobian,也叫雅可比矩阵。例如: 这个矩阵叫作 Jacobian,也叫雅可比矩阵。 例如:

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

假设一个批次包含 BB 个样本,每个样本对应一个损失:

l=[l1 l2 ⋮ lB]\mathbf l= \begin{bmatrix} l_1\ l_2\ \vdots\ l_B \end{bmatrix}

模型共有 PP 个参数。

如果计算完整 Jacobian:

∂l∂θ\frac{\partial\mathbf l}{\partial\theta}

其形状为:

B×PB\times P

当模型有数十亿参数、批次又很大时,完整 Jacobian 会占用极大的显存和计算资源。

训练中通常只需要批次总损失或平均损失对模型参数的梯度:

L=∑i=1BliL=\sum_{i=1}^{B}l_i

或者:

L=1B∑i=1BliL=\frac{1}{B}\sum_{i=1}^{B}l_i

然后计算:

∂L∂θ\frac{\partial L}{\partial\theta}

根据求导的线性性质:

$$

\frac{\partial L}{\partial\theta}

\sum_{i=1}^{B}
\frac{\partial l_i}{\partial\theta}

如果使用平均损失: # $$ \frac{\partial L}{\partial\theta} \frac{1}{B} \sum_{i=1}^{B} \frac{\partial l_i}{\partial\theta}

因此,训练时并不需要显式保存每个样本对每个参数形成的巨大 Jacobian。


9. Loss 求和会不会造成抵消

假设有一个向量:

[7,−7][7,-7]

求和后确实得到:

7+(−7)=07+(-7)=0

但需要区分:

  • 误差;

  • 损失。

9.1 误差可以有正有负

预测误差可能为:

e=[7 −7]e= \begin{bmatrix} 7\ -7 \end{bmatrix}

如果直接把误差相加,正负误差会抵消,因此这种目标函数通常不合适。


9.2 损失通常设计为非负

例如均方误差:

L=e12+e22L=e_1^2+e_2^2

对于:

e1=7,e2=−7e_1=7,\qquad e_2=-7

得到:

L=72+(−7)2=98L=7^2+(-7)^2=98

不会发生正负抵消。

常见损失函数包括:

  • 平方误差;

  • 绝对误差;

  • 交叉熵;

  • 负对数似然。

这些损失通常具有非负性或其他合理的优化性质。


9.3 标量值为零不代表梯度为零

设:

y1(x)=xy_1(x)=x y2(x)=−7y_2(x)=-7

当 x=7x=7 时:

[y1,y2]=[7,−7][y_1,y_2]=[7,-7]

总和为:

L=y1+y2=0L=y_1+y_2=0

但:

dLdx=1\frac{dL}{dx}=1

因此:

当前函数值为零,不代表梯度一定为零。

梯度描述的是函数值随参数变化的速度,而不是函数值本身的大小。


10. 向量输出调用 backward() 的含义

当输出 yy 是向量时,不能像标量一样直接确定唯一梯度。

设:

y=[y1 y2]y= \begin{bmatrix} y_1\ y_2 \end{bmatrix}

调用:

y.backward(torch.tensor([a, b]))

相当于构造标量目标:

L=ay1+by2L=ay_1+by_2

然后计算:

∂L∂x\frac{\partial L}{\partial x}

这就是所谓的加权求和。

其中:

[a b]\begin{bmatrix} a\ b \end{bmatrix}

表示每个输出分量在最终标量目标中的权重。


11. 权重向量的含义

继续使用:

y1=x12+x2y_1=x_1^2+x_2 y2=3x1+2x2y_2=3x_1+2x_2

Jacobian 为:

J=[2x11 32]J= \begin{bmatrix} 2x_1 & 1\ 3 & 2 \end{bmatrix}

11.1 权重为 [1, 1]

y.backward(torch.tensor([1.0, 1.0]))

相当于:

L=y1+y2L=y_1+y_2

梯度为:

$$

\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]))

相当于:

L=y1L=y_1

因此只计算:

∇xy1\nabla_xy_1

11.3 权重为 [0, 1]

y.backward(torch.tensor([0.0, 1.0]))

相当于:

L=y2L=y_2

因此只计算:

∇xy2\nabla_xy_2

11.4 权重为 [2, -1]

y.backward(torch.tensor([2.0, -1.0]))

相当于:

L=2y1−y2L=2y_1-y_2

因此:

$$

\nabla_xL

2\nabla_xy_1-\nabla_xy_2

权重决定了: 1. 哪些输出参与反向传播; 2. 每个输出参与多少; 3. 每个输出对应梯度如何组合。 --- ## 12. 向量–Jacobian 积 设:

v=
\begin{bmatrix}
a\
b
\end{bmatrix}

调用:‘‘‘pythony.backward(v)‘‘‘计算的不是完整Jacobian,而是: 调用: ```python y.backward(v) ``` 计算的不是完整 Jacobian,而是:

v^\mathsf{T}J

这叫作:>向量–Jacobian积,Vector−JacobianProduct,简称VJP。因为: 这叫作: > 向量–Jacobian 积,Vector-Jacobian Product,简称 VJP。 因为:

L=v^\mathsf{T}y

所以: # $$ \frac{\partial L}{\partial x} \frac{\partial L}{\partial y} \frac{\partial y}{\partial x}

其中:

∂L∂y=vT\frac{\partial L}{\partial y}=v^\mathsf{T}

因此:

$$

\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]))

传入的向量实际上表示:

∂L∂y\frac{\partial L}{\partial y}

它是从计算图后面传回来的梯度,因此也叫:

上游梯度。

框架将上游梯度与当前节点的局部 Jacobian 相乘,再继续向前传播。


14. 标量为什么可以直接调用 backward()

如果 yy 是标量:

y.backward()

等价于:

y.backward(torch.tensor(1.0))

因为可以把最终目标看成:

L=1⋅yL=1\cdot y

于是:

∂L∂y=1\frac{\partial L}{\partial y}=1

标量输出只有一个分量,默认权重自然就是 11。

而向量输出有多个分量,框架无法自动判断用户希望计算:

y1+y2y_1+y_2

还是:

y1y_1

或者:

2y1−y22y_1-y_2

所以必须显式传入上游梯度,或者先使用 sum()、mean() 等方法把向量转成标量。


15. sum() 与 mean() 的本质

假设:

l=[l1 l2 l3]\mathbf l= \begin{bmatrix} l_1\ l_2\ l_3 \end{bmatrix}

执行:

losses.sum().backward()

相当于权重:

[1,1,1][1,1,1]

因为:

L=l1+l2+l3L=l_1+l_2+l_3

执行:

losses.mean().backward()

相当于权重:

[13,13,13]\left[ \frac{1}{3}, \frac{1}{3}, \frac{1}{3} \right]

因为:

L=13(l1+l2+l3)L=\frac{1}{3}(l_1+l_2+l_3)

所以 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)

这里:

y=x2y=x^2 u=detach⁡(y)u=\operatorname{detach}(y) z=uxz=ux

从数值上看:

u=x2u=x^2

所以:

z=x3z=x^3

但反向传播时,框架将 uu 看成常量。

因此:

dudx=0\frac{du}{dx}=0

而:

dzdx=u\frac{dz}{dx}=u

最终得到:

dzdx=x2\frac{dz}{dx}=x^2

而不是正常情况下的:

d(x3)dx=3x2\frac{d(x^3)}{dx}=3x^2

18. detach() 前后的对比

18.1 不使用 detach()

y = x * x
z = y * x

有:

z=x3z=x^3

因此:

dzdx=3x2\frac{dz}{dx}=3x^2

这是因为 xx 通过两条路径影响 zz:

路径 1:x → y → z
路径 2:x → z

梯度会沿两条路径传播并累加。


18.2 使用 detach()

y = x * x
u = y.detach()
z = u * x

计算图在 u 处被切断:

x → y    u → z
    ×
  梯度切断

反向传播只看到:

z=uxz=ux

并将 uu 当作常量。

所以:

dzdx=u\frac{dz}{dx}=u

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=r+γQ(s′,a′)\text{target}=r+\gamma Q(s',a')

通常不希望梯度通过目标值传播:

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()

此时可以计算:

∂z∂u\frac{\partial z}{\partial u}

但是不能再通过 u 追溯到原来的 y 和 x。

u 会成为一张新计算图中的叶子节点:

旧图:x → y

新图:u → z

两个计算图之间的梯度联系已经被切断。


23. 自动微分的整体流程

一个典型的自动微分过程可以概括为:

复杂函数
↓
拆成基础算子
↓
前向计算并记录计算图
↓
得到最终标量目标
↓
从目标开始反向传播
↓
每个节点接收上游梯度
↓
乘以本节点的局部导数
↓
将结果继续向输入方向传播
↓
多条路径的梯度相加
↓
得到每个参数的最终梯度

24. 核心结论

24.1 关于自动微分

自动微分本质上是:

用基本算子的局部导数和链式法则,自动组合出复杂函数的梯度。


24.2 关于向量输出

向量对向量求导的完整结果是 Jacobian。

但反向传播通常不显式构建 Jacobian,而是计算:

vTJv^\mathsf{T}J

即向量–Jacobian 积。


24.3 关于 backward()

y.backward(v)

等价于先构造:

L=vTyL=v^\mathsf{T}y

再计算:

∇xL\nabla_xL

传入的 vv 是上游梯度,也是各个输出分量的组合权重。


24.4 关于批次损失

训练时通常将每个样本的损失求和或求平均,形成一个标量目标:

L=∑iliL=\sum_i l_i

或者:

L=1B∑iliL=\frac{1}{B}\sum_i l_i

优化器使用的是这个标量目标对模型参数的梯度。


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.])

因为:

∇xy1=[4,1]\nabla_xy_1=[4,1] ∇xy2=[3,2]\nabla_xy_2=[3,2]

所以:

$$

\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

Yanche Blog

记录云原生、Linux、数据库等技术领域的学习心得,以及日常生活的思考与感悟。

© 2026 Yanche Blog. All rights reserved.

Powered by Astro