聚餐结束,三十个人的账单摆在桌上。小林点了牛排,有人点了沙拉,有人点了甜品,还有套餐、折扣、服务费——账单像一张密密麻麻的网。每个人都在问同一个问题:“我到底该摊多少钱?”最笨的办法,是让每个人重新点一遍整桌菜来模拟算账;聪明的收银员却拿起账单,从总价倒着往回推:总价对每道菜的价格有多敏感,每道菜又对每种食材有多敏感。一条一条倒推下去,所有人的分摊金额一次算清。这就是反向模式的智慧。 在人工智能的世界里,模型也是一个巨大的“菜谱网”,我们称它为计算图。训练模型时,要回答的问题和 AA 制一模一样:每个参数的微小变化,会让最终的损失(总账单)变化多少?这个答案叫梯度。反向模式(Reverse Mode)就是高效计算这个答案的通用算法——深度学习框架里的“反向传播”,正是它在神经网络上的特例。

岗位关联

学懂反向模式,你就看穿了模型训练最核心的“算账环节”。无论是调试损失不下降,还是理解大模型训练为何如此耗显存,你都能直击本质,不再对着框架报错干瞪眼。

目录
  1. 可视化演示
  2. 计算图与反向思维
  3. 链式法则基石
  4. 反向模式算法
  5. 前向模式对比
  6. 内存与Checkpointing
  7. 雅可比向量积VJP
  8. 实践与调试
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示反向模式Reverse Mode全流程:正向传播计算节点值、反向传播梯度从输出流向输入、链式法则逐层累积梯度,通过交互理解反向模式自动微分原理。

本节配有交互动效(进入视口后自动加载,约 3KB)。

前向传播:计算节点中间值 输入x经算子逐层计算中间值 最终得到输出y与损失 y = f(g(x)) 前向传播记录计算图中间结果

计算图与反向思维

小林走进后厨,墙上贴着一张巨大的“菜谱流程图”:牛肉切成片,片和酱汁拌成牛排,牛排与沙拉拼成套餐,套餐加上甜品凑成晚宴。每一步都有箭头指向下一步,从最底层的食材一直连到最顶层的晚宴。这张图就是这顿饭的“总账底稿”——任何一步的价格变了,最终账单都会跟着变。在 AI 里,这张图有个正式名字:计算图(Computational Graph)。一个深度模型,无非就是由加法、乘法、矩阵运算、激活函数这些“食材加工步骤”串起来的大网。

计算图的节点是数值或运算,边是数据流动的方向。比如画一个最简单的模型 z = x * y + x:先做乘法得到中间值 m,再加 x 得 z。正向传播就是顺着箭头方向,从输入开始逐步算出每个节点值——就像厨师按流程一道道菜做出来。而反向模式的直觉恰恰相反:收银员不看怎么做菜,只看最终账单,逆着箭头往回走,问每一步:“这一步对最终价格的影响有多大?”

为了量化“影响有多大”,反向模式为每个节点定义一个伴随值(adjoint),记作 $\bar{v}_i$,表示输出对它的偏导数。核心递推公式是:

有了这张共享的菜谱流程图,大家不用重新点菜。收银员只要顺着图倒推一遍,就能把每个人的分摊金额算得清清楚楚。这个“顺着图倒推”的思维方式,是整个反向模式的灵魂。

vˉi=∑j∈Succ(i)vˉj⋅∂vj∂vi\bar{v}_i = \sum_{j \in \mathrm{Succ}(i)} \bar{v}_j \cdot \frac{\partial v_j}{\partial v_i}
这个公式的意思是:一个节点的“贡献率”,等于所有用到它的后继节点贡献率,乘上它俩之间的局部敏感度,再全部加起来。
  1. 定义伴随值$\bar{v}_i$ 是损失对节点 $v_i$ 的偏导数,也是反向模式在节点 $i$ 上要计算的答案。
    vˉi=∂y∂vi\bar{v}_i = \frac{\partial y}{\partial v_i}
  2. 枚举后继$\mathrm{Succ}(i)$ 是“直接用到节点 $i$ 的输出”的节点集合,就像账单里列举“用到这份食材的所有菜品”。
    j∈Succ(i)j \in \mathrm{Succ}(i)
  3. 局部导数$\frac{\partial v_j}{\partial v_i}$ 是节点 $j$ 关于输入 $i$ 的局部敏感度,表示“食材涨价一块钱,这道菜涨多少”。
    ∂vj∂vi\frac{\partial v_j}{\partial v_i}
  4. 求和规则把每条路径的贡献相乘后再累加,这就是“多路径梯度相加”——一份食材可以同时进入多道菜,它对总账单的贡献当然要合并。
    vˉi=∑jvˉj⋅∂vj∂vi\bar{v}_i = \sum_{j} \bar{v}_j \cdot \frac{\partial v_j}{\partial v_i}

链式法则基石

现在问题来了:总账单对“牛肉价格”的敏感度,怎么算?答案藏在菜谱的分层结构里。牛肉只影响牛排,牛排只影响套餐,套餐影响晚宴。所以“总账单对牛肉的敏感度”可以拆成三段相乘:总账单对套餐的敏感度 × 套餐对牛排的敏感度 × 牛排对牛肉的敏感度。这个连锁反应,在数学上叫链式法则(Chain Rule),是反向模式最底层的乘法口诀。

想象一根接力棒从输出端出发,向后传递“敏感度”。每一段只需要知道两件事:从上一棒接到的敏感度,以及自己这一段的局部导数,两者相乘传给下一棒。每个步骤都不用关心整张网络,只需处理眼前这一段“食材加工关系”——这正是链式法则最优雅的地方:局部计算,全局传递。

对于复合函数 $y = g(u)$ 且 $u = f(x)$,链式法则写成:

在反向模式中,链式法则被反复使用。每经过一个节点,就把接到的伴随值乘以该节点的局部导数,再继续往回传。餐厅后厨的任何一步加工,都可以用这根“敏感度接力棒”连接到总账单,于是每个食材的贡献率都清晰可见。

∂y∂x=∂y∂u⋅∂u∂x\frac{\partial y}{\partial x} = \frac{\partial y}{\partial u} \cdot \frac{\partial u}{\partial x}
公式含义:总敏感度 = 外层的局部敏感度 × 内层的局部敏感度。两段接力棒的乘积。
  1. 复合函数$x$ 先经过 $f$ 变成 $u$,再经过 $g$ 变成 $y$,形成两级嵌套的“加工链”。
    y=g(f(x))y = g(f(x))
  2. 外层敏感度$\frac{\partial y}{\partial u}$ 是“中间品价格变动对成品价格的影响”,由外层函数 $g$ 决定。
    ∂y∂u=g′(u)\frac{\partial y}{\partial u} = g'(u)
  3. 内层敏感度$\frac{\partial u}{\partial x}$ 是“原材料价格变动对中间品价格的影响”,由内层函数 $f$ 决定。
    ∂u∂x=f′(x)\frac{\partial u}{\partial x} = f'(x)
  4. 敏感度相乘两段局部敏感度相乘得到全局敏感度,这就是梯度传递的“接力棒法则”。
    ∂y∂x=∂y∂u⋅∂u∂x\frac{\partial y}{\partial x} = \frac{\partial y}{\partial u} \cdot \frac{\partial u}{\partial x}

反向模式算法

现在我们把整套流程写下来,就像后厨的三步标准作业:先上菜,后收碗,再擦桌。第一步“上菜”是前向求值——按拓扑序从输入算到输出,同时把每个中间节点的值都存进“抽屉”,这些值是后面算局部导数时要用的“小票”。第二步“收碗”是反向遍历——从输出节点开始,沿逆拓扑序往输入方向走。第三步“擦桌”是整理梯度——每个节点把收到的伴随值汇总,就是它最终的梯度。

为什么一定要逆拓扑序?因为前向求值时,数据有严格先后依赖;反向传梯度时,必须保证一个节点在被处理时,它所有后继节点的伴随值都已经算完了。这就像收碗必须从最靠外的餐桌往回收,不能跳着收。

算法核心分三步:

整套操作下来,正向一遍求值、反向一遍传梯度,两个方向各扫一次计算图,所有参数的梯度就齐了。就像收银员只顺着流程图走一个来回,三十个人的分摊金额全部算清,谁也不用重新点菜。

1.前向:按拓扑序计算 vi,并保存中间值2.初始化:yˉ=13.反向:按逆拓扑序计算 vˉi=∑j∈Succ(i)vˉj⋅∂vj∂vi\begin{aligned}&1. \text{前向:按拓扑序计算 } v_i, \text{并保存中间值} \\&2. \text{初始化:} \bar{y} = 1 \\&3. \text{反向:按逆拓扑序计算 } \bar{v}_i = \sum_{j \in \mathrm{Succ}(i)} \bar{v}_j \cdot \frac{\partial v_j}{\partial v_i}\end{aligned}
输出标量的场景下,把输出的伴随值初始化为 1,反向遍历一遍,所有输入的梯度就全部到手。
  1. 前向求值从输入节点出发,按依赖顺序算出每个中间节点的值 $v_i$,并保存起来备用。
    vi=fi({vk}k∈Pred(i))v_i = f_i\left(\{v_k\}_{k \in \mathrm{Pred}(i)}\right)
  2. 初始化伴随值把输出节点的伴随值设为 1,相当于“总账单自身对自身的敏感度就是 1”。
    yˉ=1\bar{y} = 1
  3. 逆拓扑遍历从输出往回走,每到一个节点就收集所有后继传来的“梯度 × 局部导数”并求和。
    vˉi=∑j∈Succ(i)vˉj⋅∂vj∂vi\bar{v}_i = \sum_{j \in \mathrm{Succ}(i)} \bar{v}_j \cdot \frac{\partial v_j}{\partial v_i}
  4. 输出梯度遍历结束后,每个输入节点的伴随值就是所求的偏导数。
    ∇xf=[xˉ1xˉ2⋯xˉn]\nabla_x f = \begin{bmatrix} \bar{x}_1 & \bar{x}_2 & \cdots & \bar{x}_n \end{bmatrix}

前向模式对比

聚餐算账有两种方案。方案一叫前向模式(Forward Mode):让每个人轮流“假装重新点一遍菜”,模拟出总价变化——这等价于对每个输入分别求一次导数。方案二叫反向模式:从总账单倒推,一次算出所有人对总价的敏感度。如果聚餐有 n 个人,方案一要模拟 n 遍;方案二只需要 1 遍倒推。

在数学上,前向模式计算的是雅可比向量积(Jacobian-vector product, JVP),每次运行对一个方向向量求方向导数;反向模式计算的是向量雅可比积(vector-Jacobian product, VJP)。两者的复杂度差异,完全由输入输出维度决定:

这就引出了深度学习里的“鸡生蛋问题”:大模型有几十亿参数(n),却只有一个损失值(m)。如果使用前向模式,每算一轮梯度就要跑几十亿遍网络,训练一次要等几百年;反向模式把代价压缩到“一遍前向 + 一遍反向”,训练才成为可能。所以深度学习框架清一色采用反向模式,这就是它成为“训练引擎标配”的根本原因。

Tforward-mode=O(n)⋅Tforward,Treverse-mode=O(m)⋅TforwardT_{\mathrm{forward\text{-}mode}} = O(n) \cdot T_{\mathrm{forward}}, \qquad T_{\mathrm{reverse\text{-}mode}} = O(m) \cdot T_{\mathrm{forward}}
前向模式要跑 n 遍(n 为输入个数),反向模式跑 m 遍(m 为输出个数)。谁少用谁。
  1. 输入数量$n$ 是计算图的输入维度,比如神经网络里的参数量,动辄上亿。
    n=dim⁡(x)n = \dim(x)
  2. 输出数量$m$ 是输出维度。深度学习的损失函数通常输出一个标量,所以 $m = 1$。
    m=dim⁡(y)m = \dim(y)
  3. 前向模式复杂度每算一个输入的方向导数就要跑一遍前向,总耗时是 $O(n)$ 倍。
    Tforward-mode=O(n)T_{\mathrm{forward\text{-}mode}} = O(n)
  4. 反向模式复杂度输出是标量时,反向模式只需一遍前向加一遍反向,总耗时约一次前向的常数倍。
    Treverse-mode=O(m)=O(1)T_{\mathrm{reverse\text{-}mode}} = O(m) = O(1)

内存与Checkpointing

反向模式有一个“甜蜜的代价”——它必须保存前向传播过程中的中间结果。为什么?因为反向传播梯度时,每个节点要计算局部导数 $\frac{\partial v_j}{\partial v_i}$,这需要用到前向时那个节点的输入值。想想后厨收碗:如果洗碗员想知道“这盘菜用了多少牛肉”,他必须保留做菜时的配料单。配料单越多,追溯越方便,但后厨空间也越紧张。

深度网络的层数动辄几十上百,每一层都要保存激活值;如果同时训练大批量数据,显存立刻告急。这时就要用到Checkpointing(检查点策略):不在每一层都存“小票”,而是每隔几层存一张“汇总票据”。反向传播走到中间缺数据时,从最近的检查点重新做一遍前向,把缺的那几段“菜谱步骤”重跑出来。

如果计算图有 $T$ 层,每隔 $k$ 层设一个检查点,内存占用与时间开销的关系是:

这就像聪明的后厨只保留“每周采购汇总单”而不是每天的每张收据,需要细节时再去翻进货记录重算一遍。用时间换空间,是训练超大模型时常用的内存优化手段。PyTorch 的 activation checkpointing、DeepSpeed 的优化器状态切分,背后都是这个思路。

Mckpt=O(Tk+k),ΔT≈O(Tforward)M_{\mathrm{ckpt}} = O\left(\frac{T}{k} + k\right), \qquad \Delta T \approx O\left(T_{\mathrm{forward}}\right)
显然,$k$ 取 $\sqrt{T}$ 时内存最优:约 $O(\sqrt{T})$。代价是反向时要重新前向计算一次,训练时间约增加一倍。
  1. 分段检查点把 $T$ 层计算图分成 $\frac{T}{k}$ 段,每段开头存一个检查点。
    检查点数量=Tk\text{检查点数量} = \frac{T}{k}
  2. 内存上界需要同时保存检查点本身和当前段内的中间值,内存约为两部分之和。
    M=O(Tk+k)M = O\left(\frac{T}{k} + k\right)
  3. 最优分段令 $k = \sqrt{T}$ 可让两项平衡,内存降到约 $2\sqrt{T}$。
    min⁡k (Tk+k)=2T\min_k \, \left(\frac{T}{k} + k\right) = 2\sqrt{T}
  4. 时间代价反向时缺哪段就重算哪段,总重算量约等于一遍完整前向。
    ΔT≈Tforward\Delta T \approx T_{\mathrm{forward}}

雅可比向量积VJP

如果想把整本账单的“价格联动关系”完整记录下来,可以画一张巨大的表:行是每道菜,列是每种食材,每个格子写着“某食材涨一块钱,这道菜涨多少钱”。这张表在数学上叫雅可比矩阵(Jacobian Matrix)。可惜,食材种类和菜品数量都是海量的,完整展开这张表根本存不下。

反向模式的高明之处在于:它从不展开雅可比矩阵,而是直接用雅可比向量积(VJP)来高效计算。想象你手里有一个“伴随向量” $v$,它记录了输出端每个分量的初始敏感度;把它左乘雅可比矩阵 $J$,得到的就是所有输入的梯度向量。一次矩阵向量乘法,所有答案全部得到:

回到聚餐比喻:雅可比矩阵是那本“全食材价格联动账本”,但没人会真的打印出来。VJP 就像只带着“当前最关心的几道菜”,沿着菜谱图逐层问“这道菜对周围的联动影响”,走过一遍就全算清楚了。这也是为什么现代自动微分库(如 PyTorch 的 autograd)都按 VJP 方式来组织反向实现。

vTJ=[∑j=1mvj∂yj∂x1⋯∑j=1mvj∂yj∂xn]v^{\mathsf{T}} J = \begin{bmatrix} \displaystyle\sum_{j=1}^{m} v_j \frac{\partial y_j}{\partial x_1} & \cdots & \displaystyle\sum_{j=1}^{m} v_j \frac{\partial y_j}{\partial x_n} \end{bmatrix}
VJP 就是把雅可比矩阵当一个“黑箱算子”,只定义它和向量的乘积,而从不显式存下整个矩阵。反向模式本质上就是沿计算图逐步执行 VJP。
  1. 雅可比矩阵$J$ 的第 $j$ 行第 $i$ 列是 $\frac{\partial y_j}{\partial x_i}$,记录每个输出对每个输入的敏感度。
    Jji=∂yj∂xiJ_{ji} = \frac{\partial y_j}{\partial x_i}
  2. 伴随向量$v$ 是长度为 $m$ 的向量,通常在标量损失下取 $v = 1$。
    v=[1]v = \begin{bmatrix} 1 \end{bmatrix}
  3. 左乘结果$v^{\mathsf{T}} J$ 的第 $i$ 个分量是 $\sum_j v_j \frac{\partial y_j}{\partial x_i}$,即输出加权敏感度对输入 $i$ 的总贡献。
    (vTJ)i=∑j=1mvj∂yj∂xi\left(v^{\mathsf{T}} J\right)_i = \sum_{j=1}^{m} v_j \frac{\partial y_j}{\partial x_i}
  4. 反向等价性反向模式的每一次反向遍历,都等价于计算一次 $v^{\mathsf{T}} J$,因此它是“高效 VJP”的代名词。
    Reverse Mode⇔vTJ\text{Reverse Mode} \Leftrightarrow v^{\mathsf{T}} J

实践与调试

收银员算完账,发现总额对不上——怎么办?最可靠的办法是对账:用另一种独立的方式估算账单,比如拿计算器把每道菜的价格重新加一遍,与系统账单对比。梯度校验(Gradient Checking)干的就是这件事:用数值差分近似计算梯度,再和反向模式算出的梯度对比,误差很小就说明反向实现正确。

数值差分的原理来自导数的定义:给输入 $x_i$ 一个很小的扰动 $\varepsilon$,观察输出变化量,用差分近似导数。把它与反向模式的解析梯度做差,如果误差小于阈值,就认为计算图实现无误:

实战中,梯度校验发现的大部分问题都来自三类:梯度累积未清零——相当于上一桌的账单没擦干净就记到下一桌;计算图被复用——同一份菜谱被改了配方,旧梯度和新梯度混在一起;数值溢出——中间值太大或太小,比如计算 $\exp$ 时上溢。工程上还常用算子融合、剔除零梯度分支等技巧,让反向模式在编译器层面跑得更快。对账通过,账单无误,大家才能安心开席。

∣∂f∂xi−f(x+εei)−f(x−εei)2ε∣<τ\left| \frac{\partial f}{\partial x_i} - \frac{f(x + \varepsilon e_i) - f(x - \varepsilon e_i)}{2\varepsilon} \right| < \tau
这是训练自研算子、写自定义反向函数时的“金标准”检验手段。
  1. 解析梯度$\frac{\partial f}{\partial x_i}$ 是反向模式通过链式法则精确算出的梯度,作为被测对象。
    ∂f∂xi=xˉi\frac{\partial f}{\partial x_i} = \bar{x}_i
  2. 中心差分用 $\varepsilon$ 扰动输入,观察输出变化量与前向求值次数无关,纯靠两次函数求值近似导数。
    f(x+εei)−f(x−εei)2ε\frac{f(x + \varepsilon e_i) - f(x - \varepsilon e_i)}{2\varepsilon}
  3. 误差阈值$\tau$ 是容忍度,通常取 $10^{-6}$ 量级;超过则说明梯度实现有问题。
    ∣⋅∣<τ| \cdot | < \tau
  4. 判据两者误差小于阈值,反向模式实现正确,可以放心用于训练。
    通过  ⟺  误差<τ\text{通过} \iff \text{误差} < \tau

AI 应用场景

深度学习框架的自动求导引擎

阶段:模型训练与优化

解决的问题:手写梯度公式极易出错且无法复用。PyTorch 的 autograd 利用反向模式,在每次前向传播时自动记录计算图,训练时一行 .backward() 就能获得所有参数的梯度,省去数万行手写求导代码。

具体案例:数据科学家训练一个 ResNet 图像分类模型。定义好网络结构和损失函数后,调用 loss.backward(),框架自动完成反向模式遍历,把梯度写入每个参数张量的 .grad 属性,随后优化器一步更新。整个过程完全无需关心链式法则如何展开。

AI编译器中的反向算子展开

阶段:模型编译与算子优化

解决的问题:直接把反向模式运行时执行会引入大量动态调度开销。AI 编译器(如 XLA、TVM)在编译期将反向模式静态展开为前向与反向算子序列,再对算子做融合与内存复用,让梯度计算达到接近手写 CUDA 内核的性能。

具体案例:某推理引擎团队要优化一个大模型微调流程。编译器解析计算图后,自动生成搭配好的前向内核和反向内核,并对反向段做 checkpointing 切分,把显存峰值降低 40%,同时训练吞吐提升 25%。

大模型参数高效微调(LoRA)

阶段:迁移学习与微调

解决的问题:全量微调大模型需要对几十亿参数求梯度,显存和算力都难以承受。LoRA 冻结原模型参数,只训练注入的低秩矩阵——这恰好是反向模式的优势场景:输出(损失)是标量,需要梯度的参数大大减少。

具体案例:团队要对 70 亿参数的 Llama 模型做指令微调。通过 LoRA 只训练约 0.1% 的参数,反向模式计算量大幅下降,单卡即可完成训练,微调成本降低到原来的十分之一。

Python 演示

python45 行
class Var:    def __init__(self, value, children=(), grad_fn=''):        self.value = value        self.children = children        self.grad = 0.0        self.grad_fn = grad_fn     def __add__(self, other):        other = other if isinstance(other, Var) else Var(other)        return Var(self.value + other.value,                   children=[(self, 1.0), (other, 1.0)],                   grad_fn='add')     def __mul__(self, other):        other = other if isinstance(other, Var) else Var(other)        return Var(self.value * other.value,                   children=[(self, other.value), (other, self.value)],                   grad_fn='mul')     def backward(self, seed=1.0):        topo = []        visited = set()         def build(v):            if v not in visited:                visited.add(v)                for child, _ in v.children:                    build(child)                topo.append(v)         build(self)        self.grad = seed        for node in reversed(topo):            for child, local_grad in node.children:                child.grad += node.grad * local_grad  if __name__ == '__main__':    x = Var(2.0)    y = Var(3.0)    z = x * y + x  # z = x*y + x    z.backward()    print('z =', z.value)    print('dz/dx =', x.grad)    print('dz/dy =', y.grad)
目的:演示反向模式的核心机制——计算图构建、拓扑排序、伴随值逆向传播,运行后输出 z = 8、dz/dx = 4、dz/dy = 2。
  1. children=[(self, 1.0), (other, 1.0)]加法节点记录两个子节点及各自的局部导数(1.0),就像菜谱上写着“这两步对总价的敏感度是 1”
  2. def build(v)递归后序遍历生成拓扑序,保证反向时每个节点处理前其后继已处理完毕
  3. self.grad = seed输出节点的伴随值初始化为 1,对应“总账单对自己的敏感度是 1”
  4. for node in reversed(topo)逆拓扑序从输出向输入遍历,正是“收碗从最外层往回收”
  5. child.grad += node.grad * local_grad链式法则接力 + 多路径梯度累加,一个 += 同时实现两件事

常见误区

误区反向模式就是反向传播,只能用在神经网络训练上,其他场景用不到。

正解反向模式是自动微分在“输出维度远小于输入维度”时的一般性算法,神经网络只是它最著名的应用场景。任何计算图——物理仿真、金融风险模型、科学计算中的微分方程求解——只要需要“标量损失对大量参数的梯度”,都适合用反向模式。神经网络反向传播是反向模式的特例,而不是反向模式的全部。

为何常见:大多数入门者第一次接触反向模式就是在深度学习框架的 backward() 里,容易把“最常见的使用方式”误解为“唯一的使用方式”。

误区前向模式又慢又笨,有了反向模式就可以彻底抛弃它。

正解前向模式在“输入维度少、输出维度多”的场景下反而更快。比如计算一个函数在某点的方向导数,或黑箱物理模拟器的局部灵敏度分析,n 只有几个而 m 很大,前向模式只需几遍求值。JAX 等框架同时提供 jvp 和 vjp 两套 API,正是为了覆盖两种场景。

为何常见:深度学习里 n 巨大 m 很小,反向模式完胜,让前向模式被过度贬低。实际上两者互为对偶,选型依据始终是维度对比。

误区反向模式太吃内存,我把所有中间变量全删了,反正梯度是“从输出往回算”的,不需要中间值。

正解恰恰相反,反向传播计算局部导数 $\frac{\partial v_j}{\partial v_i}$ 时必须用到前向时的中间值。比如乘法节点 m = x * y,对 x 的局部导数是 y 的值,而 y 的值只在正向阶段出现。全部丢弃意味着无法反向。正确做法是 Checkpointing:有选择地保存部分中间值,缺失时重算,用时间换空间。

为何常见:初学者容易把“反向模式”想象成只靠输出就能倒推一切,忽略了局部导数对中间值的依赖。

误区梯度是框架“猜”出来的,或者像数值差分一样是近似值,所以模型训练的梯度多少有点不准。

正解反向模式通过链式法则解析地计算偏导数,结果在浮点精度下是精确的(与手推公式完全一致)。数值差分只是用来校验反向模式的工具,不是训练时实际使用的梯度。把“解析精确”与“数值近似”混为一谈,是很多调参新手对梯度下降信心不足的根源。

为何常见:很多人先学了数值微分的近似概念,又看到梯度检查时用差分对比,误以为框架内部也用同样方法估计梯度。

误区一个输入只连接一条路径时,它的梯度就等于沿路所有局部导数的乘积,不用额外操作。

正解这个说法只对“单一串联链”成立。一旦计算图出现分叉——比如 z = x * y + x 中 x 同时进入乘法和加法——x 的梯度是两条路径贡献之和,而不是单纯一条链上的乘积。反向模式的伴随值递推式里的 $\sum$ 符号,就是为这种多路径分支准备的。一个节点有多个后继时,它的梯度 = 各后继传来的梯度之和。

为何常见:入门时先用“一条链”的例子理解链式法则,容易把特例当全貌,忽略求和环节。

面试题

Q1. 反向模式自动微分的第一步是什么?

单选题 · easy

  • A.反向遍历并计算梯度
  • B.前向求值并保存中间结果← 正确答案
  • C.随机初始化所有伴随值
  • D.构建完整的雅可比矩阵

答案:B。反向模式必须先把计算图从前到后求值一遍,保存中间结果,之后才能逆着图反向传播梯度。这就像收银员先要看完整的菜谱流程图,才能从总账单倒推。选 B。

Q2. 反向模式最核心的数学基础是?

单选题 · mid

  • A.泰勒级数展开
  • B.链式法则← 正确答案
  • C.拉格朗日乘数法
  • D.梯度下降算法

答案:B。反向模式把输出对输入的偏导分解为沿途各局部导数的乘积,这正是链式法则。泰勒展开用于近似、拉格朗日乘数法用于约束优化、梯度下降是使用梯度的优化方法,都不承担“求梯度”本身的计算任务。答案选链式法则。

Q3. 对于标量损失函数 m=1,反向模式的整体耗时约为?

单选题 · mid

  • A.一次前向计算的常数倍← 正确答案
  • B.参数数量的 n 倍前向耗时
  • C.与参数数量的平方成正比
  • D.完全不需要任何计算

答案:A。反向模式复杂度是 O(m) 倍前向耗时,标量场景 m=1,所以是一次前向的常数倍(前向一遍 + 反向一遍)。B 是前向模式的复杂度,C 是平方级误解,D 显然错误。答案选 A。

Q4. 反向模式相对前向模式的优势有哪些?

多选题 · hard

  • A.一次反向遍历得到全部输入的梯度← 正确答案
  • B.所有场景下内存开销一定更小
  • C.输出维度远小于输入维度时更高效← 正确答案
  • D.不需要保存任何中间结果

答案:AC。反向模式一遍遍历算出所有输入梯度,且复杂度由输出维度 m 决定,当 m<<n 时远快于前向模式的 O(n) 倍,A、C 正确。反向模式需要保存中间结果,内存不一定更小(B 错),也不能无条件忽略存储(D 错)。答案选 A、C。

Q5. 反向模式中的“伴随值”(adjoint)指的是?

单选题 · mid

  • A.输入数据的镜像副本
  • B.输出对当前节点的偏导数← 正确答案
  • C.当前节点对输入的偏导数
  • D.优化器里的学习率数值

答案:B。伴随值 $\bar{v}_i = \partial y / \partial v_i$,即输出对某个中间节点的偏导,是反向模式在逐个节点上传递的“贡献率”。C 是局部导数的定义,与伴随值方向相反;A、D 与伴随值无关。答案选 B。

Q6. Checkpointing 的核心思想是什么?

单选题 · hard

  • A.增加计算图的深度以提高精度
  • B.保存部分中间结果,需要时重算缺失段← 正确答案
  • C.用更高精度保存所有中间变量
  • D.直接删除计算图的结构信息

答案:B。Checkpointing 每隔若干层保存检查点,反向传播缺中间结果时从最近检查点重算,本质是“用时间换空间”。它不会增加深度、不提高精度、也不删除结构信息。答案选 B。

Q7. 反向模式中多路径梯度汇聚使用哪种运算?

单选题 · easy

  • A.求和累加← 正确答案
  • B.累乘全部梯度
  • C.取所有梯度最大值
  • D.取所有梯度平均值

答案:A。伴随值递推公式 $\bar{v}_i = \sum_j \bar{v}_j \cdot \frac{\partial v_j}{\partial v_i}$ 中的求和符号表明,多路径的梯度贡献是累加的。累乘只在单条链的局部导数传递中出现,最大/平均都不是标准做法。答案选 A。

Q8. 关于 VJP 的说法,正确的是?

多选题 · mid

  • A.它是雅可比矩阵与向量的乘积← 正确答案
  • B.反向模式本质上是在计算 VJP← 正确答案
  • C.必须先显式构建雅可比矩阵
  • D.可高效计算标量输出的全部梯度← 正确答案

答案:ABD。VJP = 伴随向量左乘雅可比矩阵(A 对),反向模式每次反向遍历就是一次 VJP(B 对),标量输出时它能一次给出所有输入梯度(D 对)。VJP 的核心价值正是通过算子定义绕过显式矩阵构建,C 错。答案选 A、B、D。

Q9. 某节点在计算图中有两个后继节点,它的梯度应如何计算?

单选题 · mid

  • A.只取第一个后继传来的梯度
  • B.取两个后继传来梯度之和← 正确答案
  • C.取两个后继传来梯度之积
  • D.取两个后继传来梯度之差

答案:B。节点被多条路径使用时,它对输出的影响力是各路径影响力的叠加,所以梯度为各后继伴随值乘局部导数后求和。这是伴随值递推中 $\sum$ 的直接体现。答案选 B。

Q10. 关于反向模式内存优化,下列说法正确的是?

多选题 · extreme

  • A.主要内存开销是保存前向的中间结果← 正确答案
  • B.Checkpointing 本质是用时间换空间← 正确答案
  • C.内存和计算时间可以被同时最优化
  • D.分段保存检查点可将内存降到根号量级← 正确答案

答案:ABD。反向模式需要中间值算局部导数,中间结果是大头(A 对)。Checkpointing 牺牲部分前向重算换取显存下降(B 对),最优分段 $k=\sqrt{T}$ 时内存约 $O(\sqrt{T})$(D 对)。时间与空间是此消彼长的权衡,无法同时最优(C 错)。答案选 A、B、D。