1. 从"链式求导"到"计算图":为什么我们需要一张图
如果你学过微积分,一定记得链式法则:复合函数的导数等于外层函数导数乘以内层函数导数。公式写出来很简洁,但真正把它落到一个几十层的神经网络上时,事情就变得不那么直观了。我最早接触反向传播的时候,脑子里全是"这一层的梯度要乘上一层的梯度,再乘上上一层的梯度……"这种循环,写着写着就乱了,尤其是遇到分支结构、共享参数的时候,根本不知道哪个梯度该往哪里传。
后来我才明白,问题不在于链式法则本身,而在于我缺少一个可视化的中间表示。计算图(Computational Graph)就是干这个的。它把复杂的复合函数拆成一连串简单的运算节点,每个节点只负责一件小事——加法、乘法、或者某个初等函数——然后用有向边把这些节点连起来,表示数据的流动方向。这样一来,求导这件事就从"在一堆公式里绕圈"变成了"沿着图往回走,每经过一个节点就做一次局部求导,再把结果乘起来"。
这个思路的价值在于:它把全局的、容易出错的求导问题,分解成了局部的、机械化的操作。你不需要一次性记住整个函数的导数长什么样,只需要知道每个节点自己的导数怎么算,剩下的交给图的遍历逻辑。这也是为什么现代深度学习框架——不管是哪家的——底层都有一套计算图的实现。理解了计算图,你就理解了反向传播的骨架。
这篇文章我会从计算图的基本概念讲起,然后一步步推导反向传播在计算图上的运作方式,最后落到具体的节点类型(加法节点、乘法节点)上,把梯度怎么传、为什么这么传讲清楚。适合已经了解神经网络基本结构、但对反向传播内部机制还比较模糊的读者。如果你之前只是会调库、会写loss.backward(),但说不清楚里面到底发生了什么,那这篇内容应该能帮你把这块补上。
2. 计算图的基本构造:节点、边与局部计算
2.1 用"流水线"理解计算图
计算图这个概念,用工厂流水线来类比最合适。假设你要做一个木凳子,流程是:先把木头锯成板子,再把板子打磨光滑,然后组装成凳子。每一步都是一个"节点",每一步的产出物流向下一步,这就是"边"。如果你想知道"木头价格涨了,凳子成本会涨多少",你就需要沿着流水线往回追溯:凳子成本对组装环节的敏感度、组装对打磨的敏感度、打磨对锯木头的敏感度,一路乘回去。
计算图就是这个流水线的数学版本。一个简单的例子:假设有一个函数 ( z = (x + y) \times w )。我们可以把它拆成两个节点:
- 加法节点:接收 ( x ) 和 ( y ),输出 ( p = x + y )
- 乘法节点:接收 ( p ) 和 ( w ),输出 ( z = p \times w )
数据从输入流向输出,这叫正向传播(forward propagation)。而当我们想知道 ( z ) 对 ( x )、( y )、( w ) 各自的导数时,就从输出端往回走,这叫反向传播(backward propagation)。
这里有一个关键点很多人一开始会忽略:计算图上的每个节点只需要关心自己的局部计算。加法节点不需要知道自己是整个大函数里的哪一部分,它只知道"我的输出是两个输入之和,所以我对每个输入的偏导数都是1"。乘法节点也只知道"我的输出是两个输入的乘积,所以我对第一个输入的偏导数是第二个输入的值,对第二个输入的偏导数是第一个输入的值"。这种"局部性"是计算图能够模块化、可组合的根本原因。
2.2 正向传播:数据怎么流
正向传播的过程很直接,就是从输入节点开始,按照拓扑顺序依次计算每个节点的输出值,直到得到最终的输出。用上面 ( z = (x + y) \times w ) 的例子,假设 ( x = 2 ),( y = 3 ),( w = 4 ):
- 加法节点:( p = x + y = 2 + 3 = 5 )
- 乘法节点:( z = p \times w = 5 \times 4 = 20 )
正向传播的结果不仅是最终的 ( z ),还包括中间节点 ( p ) 的值。这一点非常重要,因为反向传播在计算梯度时,需要用到正向传播时保存下来的中间值。比如乘法节点在求对 ( p ) 的导数时,需要用到 ( w ) 的值;求对 ( w ) 的导数时,需要用到 ( p ) 的值。所以正向传播不只是算出一个结果,它还在为反向传播"备料"。
这也是为什么在实际实现中,计算图通常会把正向传播时每个节点的输入输出值缓存起来。如果你只关心推理(inference)不关心训练,那这些缓存可以省掉;但只要你需要反向传播,这些中间值就必须留着。我在早期自己手写简易神经网络的时候,就因为图省事没保存中间值,结果反向传播时只能重新算一遍正向,代码又臭又长,还容易出bug。
2.3 反向传播:梯度怎么回传
反向传播的核心思想是:从最终输出开始,沿着图的反方向,把"输出对某个节点的导数"逐层传递回去。这里引入一个概念叫上游梯度(upstream gradient),指的是"最终输出 ( z ) 对当前节点输出的导数"。每个节点接收上游梯度,乘以自己的局部导数,得到"最终输出对当前节点输入的导数",再把这个结果传给前面的节点。
用链式法则写出来就是:
[ \frac{\partial z}{\partial x} = \frac{\partial z}{\partial p} \cdot \frac{\partial p}{\partial x} ]
其中 ( \frac{\partial z}{\partial p} ) 是上游梯度,( \frac{\partial p}{\partial x} ) 是加法节点的局部导数。加法节点的局部导数是1,所以 ( \frac{\partial z}{\partial x} = \frac{\partial z}{\partial p} \times 1 )。
对于乘法节点 ( z = p \times w ):
[ \frac{\partial z}{\partial p} = w, \quad \frac{\partial z}{\partial w} = p ]
所以从 ( z ) 回传到 ( p ) 的梯度是 ( w ) 的值,回传到 ( w ) 的梯度是 ( p ) 的值。
把整个例子串起来,假设最终损失对 ( z ) 的梯度是 ( \frac{\partial L}{\partial z} = 1 )(这里 ( z ) 就是最终输出):
- 乘法节点:( \frac{\partial L}{\partial p} = 1 \times w = 4 ),( \frac{\partial L}{\partial w} = 1 \times p = 5 )
- 加法节点:( \frac{\partial L}{\partial x} = 4 \times 1 = 4 ),( \frac{\partial L}{\partial y} = 4 \times 1 = 4 )
验证一下:( z = (x + y) \times w ),所以 ( \frac{\partial z}{\partial x} = w = 4 ),( \frac{\partial z}{\partial y} = w = 4 ),( \frac{\partial z}{\partial w} = x + y = 5 )。结果完全一致。
这个过程的妙处在于,无论函数多复杂,每个节点做的事情都是一样的:接收上游梯度,乘以局部导数,往下传。你不需要为整个函数推导一个庞大的导数公式,只需要为每种节点类型定义好它的局部导数规则,剩下的交给图的遍历。
3. 加法节点与乘法节点的反向传播细节
3.1 加法节点:梯度"原样分发"
加法节点的反向传播是最简单的。设 ( z = x + y ),则 ( \frac{\partial z}{\partial x} = 1 ),( \frac{\partial z}{\partial y} = 1 )。这意味着上游梯度传过来是多少,就原样传给两个输入,不做任何缩放。
用代码表示就是:
class AddNode: def forward(self, x, y): self.x = x self.y = y return x + y def backward(self, dout): dx = dout * 1 dy = dout * 1 return dx, dy这里dout是上游梯度,dx和dy是要继续往下传的梯度。加法节点的作用可以理解为"梯度分发器"——它把一份梯度复制成两份,分别给两个输入。
有一个细节值得注意:如果加法节点有多个输入(比如三个、四个),那么每个输入收到的梯度都等于上游梯度。这在处理偏置项(bias)的时候很常见。比如一个神经元计算 ( z = w_1 x_1 + w_2 x_2 + b ),偏置 ( b ) 的梯度就是上游梯度本身,因为 ( \frac{\partial z}{\partial b} = 1 )。这也是为什么在神经网络中,偏置项的梯度往往就是该层输出的梯度之和(当batch处理时)。
3.2 乘法节点:梯度"交叉相乘"
乘法节点的反向传播稍微绕一点,但也不复杂。设 ( z = x \times y ),则 ( \frac{\partial z}{\partial x} = y ),( \frac{\partial z}{\partial y} = x )。也就是说,对第一个输入的梯度等于第二个输入的值,对第二个输入的梯度等于第一个输入的值。
class MulNode: def forward(self, x, y): self.x = x self.y = y return x * y def backward(self, dout): dx = dout * self.y dy = dout * self.x return dx, dy这里就能看出为什么正向传播时要保存输入值了——反向传播时需要用它们来计算梯度。如果正向传播时没保存self.x和self.y,反向传播就没法算了。
乘法节点在神经网络中极其常见,因为全连接层的核心运算就是矩阵乘法。一个全连接层可以写成 ( Y = XW + b ),其中 ( X ) 是输入,( W ) 是权重,( b ) 是偏置。这里的矩阵乘法可以看作是一系列乘法节点和加法节点的组合。反向传播时,对 ( W ) 的梯度需要用到输入 ( X ),对 ( X ) 的梯度需要用到权重 ( W )。这也是为什么全连接层在反向传播时,需要同时用到正向传播时保存的输入和权重。
3.3 一个完整的计算图反向传播示例
为了把加法节点和乘法节点串起来,我们来看一个稍微复杂一点的例子。假设有一个函数:
[ L = (x \times y) + (x \times z) ]
这个函数可以拆成:
- 乘法节点1:( a = x \times y )
- 乘法节点2:( b = x \times z )
- 加法节点:( L = a + b )
假设 ( x = 2 ),( y = 3 ),( z = 4 ),最终梯度 ( \frac{\partial L}{\partial L} = 1 )。
正向传播:
- ( a = 2 \times 3 = 6 )
- ( b = 2 \times 4 = 8 )
- ( L = 6 + 8 = 14 )
反向传播:
- 加法节点:( \frac{\partial L}{\partial a} = 1 ),( \frac{\partial L}{\partial b} = 1 )
- 乘法节点1:( \frac{\partial L}{\partial x} ) 收到来自 ( a ) 的贡献 ( 1 \times y = 3 ),( \frac{\partial L}{\partial y} = 1 \times x = 2 )
- 乘法节点2:( \frac{\partial L}{\partial x} ) 再收到来自 ( b ) 的贡献 ( 1 \times z = 4 ),( \frac{\partial L}{\partial z} = 1 \times x = 2 )
注意这里 ( x ) 被两个节点使用了,所以它的梯度是两路贡献之和:( 3 + 4 = 7 )。验证一下:( L = x(y + z) ),所以 ( \frac{\partial L}{\partial x} = y + z = 7 ),( \frac{\partial L}{\partial y} = x = 2 ),( \frac{\partial L}{\partial z} = x = 2 )。结果一致。
这个例子说明了一个重要问题:当一个变量被多个节点使用时,它的梯度是所有路径贡献的总和。这在计算图上的表现就是"梯度汇聚"——多条边指向同一个节点时,这些边上的梯度要相加。这一点在实现计算图框架时必须处理好,否则共享参数的梯度就会算错。
4. 从标量到张量:计算图在真实网络中的扩展
4.1 标量计算图的局限
上面讲的都是标量运算,每个节点处理一个数。但真实的神经网络处理的是张量——向量、矩阵、甚至更高维的数组。如果每个标量运算都要建一个节点,那一个简单的全连接层就会产生成千上万个节点,计算图的规模会爆炸。
所以实际实现中,计算图的节点是以张量为单位的。一个矩阵乘法节点,输入是两个矩阵,输出是一个矩阵,反向传播时也是以矩阵为单位计算梯度。这样做的好处是计算效率高(可以利用底层线性代数库),缺点是局部导数的计算从简单的乘法变成了矩阵乘法,需要一些线性代数的推导。
以矩阵乘法 ( Y = XW ) 为例,其中 ( X ) 是 ( m \times n ) 的矩阵,( W ) 是 ( n \times p ) 的矩阵,( Y ) 是 ( m \times p ) 的矩阵。反向传播时:
[ \frac{\partial L}{\partial X} = \frac{\partial L}{\partial Y} W^T, \quad \frac{\partial L}{\partial W} = X^T \frac{\partial L}{\partial Y} ]
这里的 ( \frac{\partial L}{\partial Y} ) 是上游梯度,形状和 ( Y ) 一样是 ( m \times p )。( W^T ) 是 ( W ) 的转置,形状是 ( p \times n )。两者相乘得到 ( m \times n ) 的矩阵,正好和 ( X ) 的形状一致。同理,( X^T ) 是 ( n \times m ),乘以 ( m \times p ) 的上游梯度,得到 ( n \times p ) 的矩阵,和 ( W ) 的形状一致。
这个推导看起来简单,但第一次自己推的时候很容易搞混转置的位置。我的经验是:先看形状,再看数值。梯度的形状必须和对应变量的形状一致,这是一个硬约束。如果推出来的形状对不上,那一定是哪里转置搞错了。形状对了之后,再检查数值关系是否符合链式法则。
4.2 广播机制带来的梯度求和
张量运算中还有一个容易踩坑的地方:广播(broadcasting)。比如一个形状为 ( (3, 4) ) 的矩阵加上一个形状为 ( (4,) ) 的向量,广播机制会把向量复制三份,变成 ( (3, 4) ) 再相加。正向传播没问题,但反向传播时,那个向量的梯度应该是三行梯度之和,因为它在正向传播时被"用"了三次。
# 正向:b 被广播了 a = np.random.randn(3, 4) b = np.random.randn(4) c = a + b # b 被广播成 (3, 4) # 反向:b 的梯度需要求和 dc = np.random.randn(3, 4) # 上游梯度 da = dc # a 的梯度不变 db = dc.sum(axis=0) # b 的梯度是 dc 在广播维度上的和这个求和操作在实现计算图时非常关键。如果忘了求和,梯度就会变成 ( (3, 4) ) 而不是 ( (4,) ),后续更新参数时就会出错。我在自己写简易框架的时候,就因为没处理广播的梯度求和,导致偏置项的梯度形状不对,训练时直接报错。后来养成了一个习惯:凡是正向传播中发生了广播,反向传播中就要检查是否需要求和。
4.3 计算图的两种实现风格
实际中计算图有两种常见的实现风格:静态图和动态图。
静态图的思路是"先定义,后运行"。你先用一套API把整个计算图搭好,编译成一个固定的结构,然后喂数据进去执行。这种方式的优点是图结构固定,可以做很多编译期优化,比如算子融合、内存复用。缺点是调试不方便,因为图定义和运行是分开的,出错时不容易定位。
动态图的思路是"边定义,边运行"。每做一次运算,就动态地建一个节点,图是随着代码执行逐步构建的。这种方式的优点是直观、灵活,调试起来和普通代码一样。缺点是每次运行都要重新建图,有一些额外的开销。
现在主流的框架大多采用动态图优先的策略,因为开发效率高,而且随着技术发展,动态图的性能开销也在不断降低。但理解静态图的思路仍然有价值,因为它能帮你更清楚地看到计算图的本质——图是一种中间表示,它把"计算是什么"和"计算怎么执行"分开了。
5. 自己动手实现一个迷你计算图
5.1 设计思路
理解了原理之后,最好的巩固方式是自己写一个迷你计算图。不需要支持复杂的算子,能把加法、乘法、以及一个简单的激活函数跑通就行。目标是通过这个练习,把正向传播、反向传播、梯度累积这几个环节都亲手实现一遍。
设计上我采用动态图风格,每个节点是一个对象,保存自己的输入、输出和局部导数规则。节点之间通过引用连接,反向传播时从最终输出节点开始,递归地调用每个节点的backward方法。
核心接口设计如下:
class Node: def __init__(self): self.inputs = [] # 输入节点列表 self.outputs = [] # 输出节点列表(用于反向传播时知道往哪传) self.value = None # 正向传播的值 self.grad = 0.0 # 最终输出对该节点值的梯度 def forward(self): raise NotImplementedError def backward(self): raise NotImplementedError每个具体节点继承Node,实现自己的forward和backward。forward负责计算当前节点的值,backward负责根据上游梯度计算对输入的梯度。
5.2 加法节点和乘法节点的实现
class AddNode(Node): def __init__(self, x, y): super().__init__() self.inputs = [x, y] x.outputs.append(self) y.outputs.append(self) def forward(self): self.value = self.inputs[0].value + self.inputs[1].value return self.value def backward(self): # 上游梯度已经存在 self.grad 中 self.inputs[0].grad += self.grad * 1 self.inputs[1].grad += self.grad * 1 class MulNode(Node): def __init__(self, x, y): super().__init__() self.inputs = [x, y] x.outputs.append(self) y.outputs.append(self) def forward(self): self.value = self.inputs[0].value * self.inputs[1].value return self.value def backward(self): self.inputs[0].grad += self.grad * self.inputs[1].value self.inputs[1].grad += self.grad * self.inputs[0].value注意这里用了+=而不是=,因为一个节点可能被多个下游节点使用,梯度需要累加。这就是前面说的"梯度汇聚"在代码层面的体现。
5.3 反向传播的调度
有了节点之后,还需要一个调度机制来按正确的顺序执行反向传播。最简单的方式是拓扑排序:从最终输出节点开始,按照正向传播的逆序依次调用每个节点的backward。
def backward(root): # 先做拓扑排序 topo = [] visited = set() def dfs(node): if node in visited: return visited.add(node) for inp in node.inputs: dfs(inp) topo.append(node) dfs(root) # 初始化根节点的梯度 root.grad = 1.0 # 按逆拓扑序调用 backward for node in reversed(topo): node.backward()这个调度逻辑虽然简单,但已经能处理任意有向无环图了。实际框架中的调度会更复杂,要考虑内存管理、并行执行、梯度检查点等,但核心思路是一样的。
5.4 实测中的几个坑
自己实现的过程中,我踩了几个坑,这里分享一下。
第一个坑是忘记初始化梯度。如果节点的grad没有初始化为0,累加时就会出错。尤其是多次调用反向传播时(比如处理多个样本),梯度必须每次清零。
第二个坑是拓扑排序的顺序。如果顺序不对,某个节点可能在它的所有下游节点都还没计算完梯度时就被调用了,导致梯度不完整。用DFS做拓扑排序时,要确保所有输入节点都访问完了再把当前节点加入列表。
第三个坑是共享节点的处理。如果同一个节点被多个下游节点使用,它的outputs列表会有多个元素,但反向传播时只需要根据grad累加值来算,不需要遍历outputs。这一点和正向传播不同,正向传播时一个节点只需要算一次值,反向传播时一个节点的梯度可能来自多个路径。
6. 计算图视角下的反向传播:几个容易混淆的点
6.1 梯度是"损失对节点的导数"还是"节点对输入的导数"
这是初学阶段最容易混淆的地方。在计算图中,每个节点保存的grad是最终损失对该节点输出的导数,也就是上游梯度。而节点在backward中计算的是最终损失对该节点输入的导数,这个值会被累加到输入节点的grad上。
换句话说,节点自己不保存"局部导数",它只在backward被调用时,用上游梯度和正向传播时保存的输入值,临时算出对输入的梯度。这个区分很重要,因为如果你把grad理解成局部导数,整个反向传播的逻辑就乱了。
6.2 为什么梯度要累加而不是覆盖
前面已经提到过,当一个变量被多个节点使用时,它的梯度是所有路径贡献之和。这在数学上对应的是多元链式法则:如果 ( L ) 通过多条路径依赖于 ( x ),那么 ( \frac{\partial L}{\partial x} ) 等于每条路径贡献之和。
在计算图上,这表现为:一个节点可能有多个"消费者"(下游节点),每个消费者在反向传播时都会往这个节点的grad里加一份贡献。所以必须用累加,不能用覆盖。如果用了覆盖,就只有最后一条路径的梯度被保留,其他路径的贡献全丢了。
6.3 正向传播保存中间值的代价
反向传播需要用到正向传播时的中间值,这意味着计算图必须保存这些值。对于一个深层网络,这些中间值占用的内存可能相当可观。比如一个 ( L ) 层的网络,每层的激活值都要保存,内存占用大致和层数成正比。
这也是为什么实际训练中,batch size 不能无限增大——除了计算量的考虑,内存也是一个硬约束。有一些技术可以缓解这个问题,比如梯度检查点(gradient checkpointing),它的思路是只保存部分中间值,其他值在反向传播时重新计算。这是一种用计算换内存的策略,在内存紧张时很有用。
理解这一点之后,你在设计网络结构时就会更有意识地去控制中间值的规模。比如在推理阶段,可以主动释放不需要的中间值;在训练阶段,如果内存不够,可以考虑减小batch size或者使用检查点技术。
7. 从计算图到自动微分:这套机制为什么能通用
计算图之所以能支撑起现代深度学习框架的自动微分能力,核心在于它的可组合性。每个节点只需要定义自己的局部导数规则,整个图的导数就自动出来了。这意味着你新增一个算子时,只需要实现它的正向计算和局部导数,不需要关心它在整个网络中的位置。
这种设计带来的好处是巨大的。一个框架只需要实现几十种基础算子,就能组合出无穷无尽的网络结构。用户搭网络时,不需要手动推导梯度,只需要把算子拼起来,框架会自动构建计算图并完成反向传播。
从更抽象的层面看,计算图实现了一种**"计算即数据"**的思想:计算过程本身被表示成了一种数据结构,可以被遍历、被变换、被优化。这不仅是反向传播的基础,也是很多其他技术的基础,比如计算图优化、算子融合、自动并行等。
我在理解这一点之后,再看那些深度学习框架的源码,感觉清晰了很多。以前觉得框架很神秘,现在知道它本质上就是一套计算图的构建和调度机制,加上各种性能优化。核心思想并不复杂,复杂的是工程实现中的各种细节。
如果你也想深入理解反向传播,我的建议是不要只停留在看公式的层面,动手写一个迷你计算图,把正向传播、反向传播、梯度累加都亲手实现一遍。写完之后,你对loss.backward()这行代码的理解会完全不一样。