计算图与自动微分
揭开 .backward() 的内部机制——一切都是图上的拓扑遍历。
什么是计算图?
上一课我们手推了反向传播。手推一个 5 层网络已经够累, 实际网络动辄成百上千层——靠手推显然不现实。 PyTorch 用一个简洁的工具把整套求导自动化了,叫做 计算图 (Computational Graph)。
计算图是一种用来记录张量运算的数据结构。它由两类元素组成:
- 节点 (node):一次具体的张量运算,例如加法、乘法、矩阵乘、ReLU、Linear 等
- 边 (edge):在节点之间传递的张量,箭头方向就是数据流向
因为张量只能从前面的运算流向后面的运算,不会出现循环依赖, 所以这张图是 有向无环图(DAG,Directed Acyclic Graph) 。 DAG 的关键性质:能给所有节点排出一个先后顺序,使得任何边都从「前面」指向「后面」—— 这正是后面我们做反向传播时所依赖的拓扑结构。
举一个最小的例子。考虑表达式 。 PyTorch 在内部并不会把它当成「一句完整的算术」一次算完, 而是会把它拆解成两步基本运算依次执行:
- 先把叶子张量 与 送入 加法运算 ,产生中间张量
- 再把中间张量 与叶子张量 送入 乘法运算 ,产生最终结果
把这两步运算各画成一个矩形 节点 , 把流入 / 流出节点的张量画成 有向边 , 就得到了下面这张计算图—— 三个叶子张量 在最左侧作为输入; 加法节点先执行,输出的中间张量 沿边流入乘法节点; 乘法节点再执行,得到最终输出 :
把这张图记在心里,后面所有讨论——动态构建、查看节点身份、反向遍历—— 都是在它的基础上展开的。
计算图是 动态 构建的
知道了「图长什么样」,下一个关键问题是: 这张图 什么时候、由谁造出来 , 在内存里又以什么形式存在?
PyTorch 采用 动态图 (dynamic graph)机制: 计算图 不是 预先在某处写死的结构, 而是在 每一次前向传播 过程中即时构建的。具体来说:
- 每当一个张量参与了一次运算(例如把 、 加起来得到中间张量 ), PyTorch 就在结果张量 上挂一个引用, 指向「产生它的那个运算节点」(这里是加法节点), 并由该运算节点继续记录它自己的两条输入边(指向 、 )
- 这些运算节点对象与它们之间的引用关系,都是普通的 Python 对象, 住在进程的堆内存里——一张计算图实质上就是 「一组运算节点对象通过引用串成的 DAG」, 没有专门的可视化容器,但实实在在地存在
- 一次前向跑完,整张 DAG 在内存里就搭好了; 调用 .backward() 触发反向传播之后,这张图会被立即释放
- 下一次前向再跑,会从头重建一张新图 —— 即使代码完全相同,对应的运算节点对象也是 新建 的实例; 只有叶子张量(参数、输入)会被复用
这种「随用随建」机制带来的直接好处可以归结为两点:
- 网络结构可以随输入而变 ——既然图是这一次前向运行时 临时记录下来的,就可以直接用普通的 if 、 for 、 while 控制要算什么、算几次, 不同 batch 得到的 DAG 在结构上也可以不同
- 调试方式和写普通 Python 没有任何区别 —— 中间随手打印张量、用 IDE 打断点单步运行,看到的都是真实的运行时值
代价是 每次前向都要重建图 ,比一次性编译成静态图理论上略慢; 但 PyTorch 把这部分开销优化得很小,绝大多数场景下感知不到。
一次前向构建出的图:最小 MLP 的实例
前面用的都是单条表达式的小图。把上一课那个最小 MLP—— 加 ReLU 与交叉熵损失—— 完整跑一次前向传播,PyTorch 在内存里临时构建出的 DAG 长这样 (节点用矩形标注算子名,边上标注张量形状):
看这张图时,要意识到它描绘的是 「这一次前向调用结束的瞬间, PyTorch 在堆内存里临时持有的一组对象的拓扑关系」 —— 下一次前向开始前,所有矩形(运算节点)都会被新建的实例替换; 蓝色与橙色叶子(输入、参数)则会被复用。
从这张图也能直接读出几件事:
- 每个参数都是图里的一个叶子节点 —— 没有任何特殊地位,只是恰好被设置了 requires_grad=True 的张量
- 一次前向,PyTorch 把这 7 个运算节点串成了一张完整的 DAG; 反向传播会沿着这些边逆向走一遍
- 整张图的深度(从输入到 loss 的最长路径)正比于模型层数; 上百层的 ResNet 也不过是把这个图拉长几十倍而已
查看计算图: .grad_fn
既然图是隐式构建的,怎么确认它真实存在? PyTorch 给每个 非叶子张量 都挂了一个 grad_fn 属性,记录「我是由哪个运算节点产生的」; 从结果张量出发沿着 next_functions 一层层回溯, 就能把整张图的骨架打印出来。
反向传播 = 计算图上的拓扑遍历
有了计算图这个数据结构,反向传播就有了一个非常清爽的描述: 从 loss 节点出发,沿图 反向 做一次拓扑遍历, 每经过一个节点就调用它的 本地反向函数 ,把上游传来的梯度乘以本地导数后传给上一层; 到达叶子节点时,把累积梯度写到该叶子张量的 .grad 字段。
把这段「反向遍历」直接画到前面 图 2 那张计算图上, 就得到它的反向版本——箭头全部调头,每条边上回传的是 loss 对该张量的梯度,最终汇入各参数的 .grad :
这里没有任何新东西——本地反向函数对应的就是上一课的链式法则 , 只是 PyTorch 把「应该对哪些节点、按什么顺序应用链式法则」这件事 从我们的脑子里搬到了 DAG 的拓扑序里,让它变成了纯机械化操作。
换句话说:autograd 并不会「智能地推导导数」, 它只是在 沿着前向时记录下的图,把链式法则机械地反向跑一遍 。 每一步调用的都是事先为该算子注册好的局部反向规则, 整个过程 逐节点可解释、不存在任何隐式或不可见的步骤 。
对比验证:手推 vs autograd
把上一课手推的梯度,和 PyTorch .backward() 给出的梯度逐项对比——它们在数值上应当几乎完全一致(误差只来自浮点精度)。
动态图 vs 静态图
前面我们一直在讲 动态图 ——边算边构图、跑完就释放、下一次重建。 历史上深度学习框架还存在另一类思路,叫做 静态图 (static graph): 先把整张计算图一次性定义清楚,再把数据喂进去执行。 这种「先建图、再执行」的范式在不同框架里有不同的实现, 本课程不展开使用这些框架,但你在后续阅读资料时会反复见到几个名字—— TensorFlow (简称 TF,Google 出品)和 JAX (同样来自 Google)—— 它们和 PyTorch 一样属于深度学习框架家族,只是在「图的构建时机」上选了静态图路线, 遇到时知道是同一类工具的不同选择即可。 本课程后续所有示例代码使用的都是 PyTorch —— 前面看到的 requires_grad=True 、 .backward() 、 .grad_fn 都是它的 API( 官网 )。
两类做法在使用体验和性能上的对照如下:
如今两个阵营已经基本融合:TF 2.x 默认使用动态图, PyTorch 也通过 torch.compile 提供类似静态图的编译加速。 总体趋势是 开发体验上动态图胜出,性能上静态图(编译后)略优 , 所以现代框架几乎都同时提供两种模式,让用户按需切换。
回顾:梯度累积陷阱(从计算图视角)
前一章我们学过:每次 .backward() 之后, 必须用 optimizer.zero_grad() 清零梯度, 否则梯度会"累加"。从计算图视角再看一次这个现象——
每个叶子节点的 .grad 是一个普通张量, 反向遍历每碰到该叶子,就会 把当前传到它的梯度 加 到 .grad 上 。 默认行为是累加,不是覆盖。
这个机制其实有用:
- 训练大模型时显存装不下大 batch,可以做 梯度累积 —— 连续几次 mini-batch 的反向"累计"在一起,再统一更新参数, 模拟更大的 batch_size
- 但常规训练不需要这个累加,所以每个 step 都得手动清零