1.3 前向传播、反向传播与计算图

Author

jshn9515

Published

2026-03-19

Modified

2026-08-17

在前两节中,我们已经把神经网络训练的基本问题一步一步搭了起来。

首先,神经网络可以看成一个可学习的参数化函数

\[ \hat{y} = f(x;\theta) \]

然后,我们使用损失函数比较模型预测 \(\hat{y}\) 和真实答案 \(y\),把“模型做得有多差”变成一个可以计算的数值:

\[ L = L(\hat{y},y) \]

于是,训练的目标也变得很明确:不断调整参数 \(\theta\),让损失越来越小。

但是这里还缺少最关键的一步:

参数到底应该怎么调整?

假设模型中有一个参数 \(w\)。现在我们只知道当前损失是 \(L=10\),并不知道应该把 \(w\) 调大还是调小,更不知道应该调整多少。如果模型中有几百万、几亿甚至更多参数,一个一个尝试显然不现实。

因此,我们真正需要知道的不是“损失是多少”,而是:

每一个参数发生一点变化时,损失会怎样变化?

这个信息由梯度(gradient)提供。而对于一个由许多层组成的神经网络,梯度又需要通过计算图(computation graph)反向传播(backpropagation)高效计算出来。

这一节,我们就把这几个概念串起来,完整地看一遍神经网络是怎样从一次预测,得到每一个参数对应的梯度的。

1.3.1 梯度:参数变化一点,损失会怎么变?

先从只有一个参数的情况开始。假设损失函数只和参数 \(w\) 有关:

\[ L(w) = (w-3)^2 \]

如果当前:

\[ w = 1 \]

那么损失就是:

\[ L(1) = (1-3)^2 = 4 \]

我们希望把这个损失继续减小。问题是,下一步应该让 \(w\) 变大,还是变小?

这时我们可以计算损失函数对 \(w\) 的导数:

\[ \frac{dL}{dw} = 2(w-3) \]

\(w=1\) 时:

\[ \frac{dL}{dw} = -4 \]

这个 -4 可以理解成:

在当前这个位置附近,如果让 \(w\) 稍微增大一点,损失会倾向于减小;如果让 \(w\) 稍微减小一点,损失反而会增大。

也就是说,导数告诉了我们损失函数在当前位置的局部变化趋势。

如果:

\[ \frac{dL}{dw} > 0 \]

说明在当前位置附近,增大 \(w\) 会让损失增大,因此如果想让损失下降,通常应该让 \(w\) 往更小的方向移动。

如果:

\[ \frac{dL}{dw} < 0 \]

说明增大 \(w\) 会让损失减小,因此通常应该让 \(w\) 往更大的方向移动。

导数的绝对值还反映了损失对参数变化的局部敏感程度。绝对值越大,参数发生同样大小的微小变化时,损失通常变化得越明显;绝对值越小,损失在当前附近就越平缓。

真实的神经网络当然不会只有一个参数。假设模型参数为:

\[ \theta = (w_1,w_2,\ldots,w_n) \]

损失函数写成:

\[ L(\theta) \]

我们就可以分别计算损失对每一个参数的偏导数:

\[ \frac{\partial L}{\partial w_1}, \frac{\partial L}{\partial w_2}, \ldots, \frac{\partial L}{\partial w_n} \]

把这些偏导数组合起来,就得到了损失函数关于参数的梯度

\[ \nabla_{\theta}L = \left( \frac{\partial L}{\partial w_1}, \frac{\partial L}{\partial w_2}, \ldots, \frac{\partial L}{\partial w_n} \right) \]

因此,梯度并不是一个神秘的新概念。它本质上就是:

把损失函数对所有参数的偏导数放在一起。

有了梯度,我们就知道当前这组参数附近,损失对每一个参数的变化有多敏感,以及参数应该朝什么方向调整,才能让损失更有可能下降。

但是新的问题马上又来了。神经网络中的损失并不是直接由参数算出来的。一个参数往往要经过线性层、激活函数、归一化、注意力等许多计算,最后才会影响模型输出和损失。那我们该怎么从最终的损失,一路算回某一个很早之前的参数呢?

答案就是:先把整个计算过程拆开。

1.3.2 计算图:把复杂函数拆成一连串简单计算

假设我们有一个非常简单的模型:

\[ \hat{y} = wx+b \]

并使用平方误差作为单个样本的损失:

\[ L = (\hat{y}-y)^2 \]

如果直接把所有计算写在一起,就是:

\[ L = (wx+b-y)^2 \]

这个函数还很简单,我们当然可以直接求导。但真实神经网络会由大量函数一层一层嵌套组成。如果始终把整个模型看成一个巨大的公式,很快就会变得难以分析。

更自然的做法,是把它拆成几个最基本的计算步骤:

\[ a = wx, \quad \hat{y} = a + b, \quad e = \hat{y} - y, \quad L = e^2 \]

这样,一个复杂函数就被拆成了乘法、加法、减法和平方几个简单操作。

我们可以把这些计算之间的依赖关系画成一张图:

图 1.3.2 一个简单模型的计算图

这就是计算图(computation graph)

计算图最重要的作用,是把一个复杂函数的计算过程显式展开,让我们清楚地看到:

  • 每一个中间结果是怎么得到的;
  • 一个变量依赖哪些变量;
  • 某个参数会通过哪些路径影响最终损失。

例如,在上面的图中,参数 \(w\) 并不会直接产生损失 \(L\)。它先影响 \(a\),然后影响 \(\hat{y}\),再影响误差 \(e\),最后才影响 \(L\)

\[ w \rightarrow a \rightarrow \hat{y} \rightarrow e \rightarrow L \]

参数 \(b\) 也有自己的路径:

\[ b \rightarrow \hat{y} \rightarrow e \rightarrow L \]

这条依赖关系非常重要,因为之后计算梯度时,我们正是沿着这些路径,把最终损失对参数的影响一步一步传回去。

从这个角度看,神经网络其实就是一张规模很大的计算图。每一层都接收前面的结果,执行自己的运算,再把输出交给后面的层。虽然模型结构可能非常复杂,但最终仍然可以拆成大量简单的张量运算。

1.3.3 前向传播:沿着计算图一路算到损失

有了计算图以后,我们先来看最自然的方向:从输入开始,按照箭头方向逐步计算每一个节点的值,直到得到最终损失。这个过程就是前向传播(forward propagation)

继续使用刚才的例子。假设:

\[ x=2,\quad w=3,\quad b=1,\quad y=5 \]

第一步,计算:

\[ a = wx = 3\times 2 = 6 \]

第二步,得到模型预测:

\[ \hat{y} = a+b = 6+1 = 7 \]

第三步,计算预测误差:

\[ e = \hat{y}-y = 7-5 = 2 \]

最后得到损失:

\[ L = e^2 = 2^2 = 4 \]

整个前向传播可以写成:

\[ (x,w) \rightarrow a \rightarrow \hat{y} \rightarrow e \rightarrow L \]

也就是说,前向传播做的事情非常直接:

使用当前参数,把输入一步一步变成模型预测,再把预测变成损失。

对于一个真正的神经网络来说,前向传播可能经过几十层甚至上百层,但基本逻辑并没有变化。每一层只是根据自己的输入和参数计算输出,然后继续向后传递。

这里还有一个后面会非常重要的细节:为了在反向传播时计算梯度,前向计算过程中通常需要保留一些必要的中间信息。

例如,平方操作:

\[ L = e^2 \]

它的局部导数是:

\[ \frac{\partial L}{\partial e}=2e \]

因此在反向传播时,我们需要知道前向传播时的 \(e\) 是多少。其他运算也可能需要自己的输入或中间结果来计算梯度。这也是为什么训练神经网络时,前向传播并不只是算出一个预测值这么简单。它还为之后的反向传播准备了必要的信息。

到这里,我们已经能够从输入得到损失:

\[ \text{Input} \rightarrow \text{Prediction} \rightarrow \text{Loss} \]

接下来,就要解决这一节最核心的问题:如何从这个损失反过来得到每个参数的梯度。

1.3.4 反向传播:用链式法则把梯度传回每一个参数

前向传播沿着计算图从左往右计算数值,而反向传播(backpropagation)做的事情正好相反:从最终损失开始,沿着计算图的反方向逐步计算梯度。它背后的核心数学工具,就是我们在微积分中学过的链式法则(chain rule)

还是刚才的计算过程:

\[ a = wx, \quad \hat{y} = a + b, \quad e = \hat{y} - y, \quad L = e^2 \]

现在我们想知道参数 \(w\) 对损失 \(L\) 的影响,也就是:

\[ \frac{\partial L}{\partial w} \]

但是 \(L\) 并不直接依赖 \(w\)。从计算图中可以看到,\(w\) 要经过:

\[ w \rightarrow a \rightarrow \hat{y} \rightarrow e \rightarrow L \]

因此,根据链式法则:

\[ \frac{\partial L}{\partial w} = \frac{\partial L}{\partial e} \frac{\partial e}{\partial \hat{y}} \frac{\partial \hat{y}}{\partial a} \frac{\partial a}{\partial w} \]

这些局部导数都很简单:

\[ \frac{\partial L}{\partial e} = 2e, \quad \frac{\partial e}{\partial \hat{y}} = 1, \quad \frac{\partial \hat{y}}{\partial a} = 1, \quad \frac{\partial a}{\partial w} = x \]

所以:

\[ \frac{\partial L}{\partial w} = 2e\cdot 1\cdot 1\cdot x \]

前向传播时我们已经得到:

\[ e=2,\quad x=2 \]

因此:

\[ \frac{\partial L}{\partial w} = 2\times 2\times 2 =8 \]

同样地,对于参数 \(b\)

\[ \frac{\partial L}{\partial b} = \frac{\partial L}{\partial e} \frac{\partial e}{\partial \hat{y}} \frac{\partial \hat{y}}{\partial b} \]

因为:

\[ \frac{\partial \hat{y}}{\partial b}=1 \]

所以:

\[ \frac{\partial L}{\partial b} = 2e = 4 \]

最终,我们得到了当前两个参数对应的梯度:

\[ \frac{\partial L}{\partial w} = 8, \qquad \frac{\partial L}{\partial b} = 4 \]

这就是反向传播最核心的思想:

不直接对整个复杂函数一次性求导,而是利用计算图,把梯度拆成一个个局部导数,再通过链式法则从后往前组合起来。

在实际实现中,我们经常把这个过程理解成:

上游梯度 × 局部导数 = 传给前一个节点的梯度

例如,从 \(L=e^2\) 这个节点开始,损失对自己的梯度是:

\[ \frac{\partial L}{\partial L} = 1 \]

平方操作接收到上游梯度 1,乘上自己的局部导数 \(2e\),就得到:

\[ \frac{\partial L}{\partial e} = 1\times 2e \]

接下来,减法节点再把这个梯度继续往前传;加法节点继续往前传;乘法节点再根据自己的局部导数,把梯度分别传给 \(w\)\(x\)

整个方向和前向传播正好相反:

图 1.3.4 前向传播与反向传播

因此,我们可以先形成一个最重要的直觉:

  • 前向传播:沿着计算图向前,计算每个节点的值;
  • 反向传播:沿着计算图向后,计算损失对每个节点的梯度。

1.3.5 为什么反向传播可以高效计算大量参数的梯度?

看到这里,你可能会觉得:链式法则本来就能求导,那反向传播到底特殊在哪里?

关键在于,神经网络通常有一个标量损失,却有大量参数:

\[ L = L(w_1,w_2,\ldots,w_n) \]

我们希望一次得到:

\[ \frac{\partial L}{\partial w_1}, \frac{\partial L}{\partial w_2}, \ldots, \frac{\partial L}{\partial w_n} \]

如果对每一个参数都从头展开整条链式法则,会产生大量重复计算。反向传播的聪明之处在于:

已经计算过的中间梯度可以被重复利用。

例如,在刚才的例子中,\(w\)\(b\) 最终都会经过 \(\hat{y}\)\(e\) 影响损失。我们只需要先计算一次:

\[ \frac{\partial L}{\partial e} \]

再继续得到:

\[ \frac{\partial L}{\partial \hat{y}} \]

之后,这个结果就可以分别传向 \(w\)\(b\) 所在的不同分支,而不需要重新从损失 \(L\) 开始计算。

这也是反向传播适合神经网络的重要原因。它本质上是反向模式自动微分(reverse-mode automatic differentiation)的一种应用,非常适合“一个标量输出对应大量输入参数”的情况,而训练神经网络恰好就是这种结构。

这里还有一个非常重要的细节:如果一个变量通过多条路径影响最终损失,那么来自这些路径的梯度需要相加。例如:

\[ L = w^2 + 3w \]

参数 \(w\) 通过两条不同路径影响 \(L\):一条经过 \(w^2\),另一条经过 \(3w\)。因此:

\[ \frac{dL}{dw} = \frac{d(w^2)}{dw} + \frac{d(3w)}{dw} = 2w + 3 \]

在更复杂的神经网络中,同一个张量经常会被送到多个分支中。反向传播遇到这种情况时,会把不同路径传回来的梯度累加起来。

所以,反向传播并不是简单地“把一个梯度一路往回传”。更准确地说,它是在计算图上从后向前遍历,利用每个操作的局部导数和已经得到的上游梯度,逐步得到所有节点对最终损失的梯度;如果有多条路径,就把这些贡献加在一起。

1.3.6 反向传播之后:梯度还需要真正用来更新参数

现在,我们已经计算出了:

\[ \frac{\partial L}{\partial w} = 8, \qquad \frac{\partial L}{\partial b} = 4 \]

是不是到这里模型就已经“学习”了?还没有。

这是一个非常容易混淆的地方:

反向传播只负责计算梯度,并不会自动把参数变成更好的值。

真正修改参数,还需要一个参数更新规则。最简单的梯度下降可以写成:

\[ \theta_{\text{new}} = \theta-\eta\nabla_{\theta}L \]

其中,\(\eta\)学习率(learning rate),控制每次参数更新的步长。

对于单个参数 \(w\)

\[ w_{\text{new}} = w-\eta\frac{\partial L}{\partial w} \]

我们为什么要减去梯度?因为梯度指向函数局部上升最快的方向,而我们希望让损失下降,所以通常朝负梯度方向移动。

继续刚才的例子。如果学习率取 \(\eta=0.1\),那么:

\[ \begin{align} w_{\text{new}} = 3 - 0.1\times 8 &= 2.2 \\ b_{\text{new}} = 1 - 0.1\times 4 &= 0.6 \end{align} \]

使用新的参数重新进行前向传播:

\[ \hat{y}_{\text{new}} = 2.2 \times 2 + 0.6 = 5 \]

于是新的损失变成:

\[ L_{\text{new}} = (5-5)^2 = 0 \]

在这个特意设计的简单例子中,一次更新就刚好到达了最小值。真实神经网络当然不会这么幸运,通常需要成千上万次甚至更多次迭代,而且学习率过大、过小都会带来新的问题。但我们已经能够画出神经网络训练的基本流程:

图 1.3.6 神经网络训练的基本流程

现在,我们终于可以把前两节中的内容真正连起来:

\[ \text{Forward} \rightarrow \text{Loss} \rightarrow \text{Backward} \rightarrow \text{Update Parameters} \]

这里每一步负责的事情都不同:

  • 前向传播负责用当前参数产生预测;
  • 损失函数负责衡量当前预测有多差;
  • 反向传播负责计算每个参数对应的梯度;
  • 优化算法负责根据梯度真正修改参数。

后面我们会学习 SGD、momentum、Adam 等不同优化算法,但无论更新规则怎么变化,反向传播的任务始终是:把损失对参数的梯度算出来。

1.3.7 自动微分:为什么框架能够自动帮我们算梯度?

如果每写一个神经网络,都要手动把所有链式法则推导一遍,那么深度学习几乎不可能发展到今天的规模。幸运的是,计算图给了我们一种非常系统的方法。对于计算图中的每一个基本操作,我们只需要知道它自己的局部导数。例如:

\[ \begin{align} z = x + y &\quad\Rightarrow\quad \frac{\partial z}{\partial x} = 1, \quad \frac{\partial z}{\partial y} = 1 \\ z = xy &\quad\Rightarrow\quad \frac{\partial z}{\partial x} = y, \quad \frac{\partial z}{\partial y} = x \\ z = x ^ 2 &\quad\Rightarrow\quad \frac{\partial z}{\partial x} = 2x \end{align} \]

只要框架记录了这些操作之间的依赖关系,就可以在前向传播之后,从最终损失开始,按照计算图反向应用链式法则,自动得到所有需要的梯度。这就是自动微分(automatic differentiation)背后的核心思想。

自动微分既不是简单地用有限差分去近似导数,也不是把整个神经网络先展开成一个巨大的符号公式再做符号求导。它更像是:

把复杂计算拆成框架已经认识的基本操作,然后利用这些操作的局部导数和链式法则自动组合出最终梯度。

PyTorch 中的 autograd 就是在做这件事情。我们写下张量运算并进行前向计算时,PyTorch 会建立相应的自动微分关系;当我们从损失调用反向传播时,它就可以沿着这些依赖关系把梯度计算回来。

当然,这里我们还没有讨论 PyTorch 具体怎样判断哪些张量需要梯度、梯度保存在哪里、为什么有些张量是 leaf tensor、detach()no_grad() 又会做什么。这些属于框架实现层面的内容,我们会在下一章专门展开。现在最重要的是先建立一个统一的认识:

计算图记录怎么计算,前向传播得到计算结果,反向传播利用链式法则得到梯度,优化器再利用梯度更新参数。

1.3.8 本章小结

这一节,我们终于把神经网络训练中缺失的最后一部分补上了。

在前两节中,我们知道神经网络是一个可学习的参数化函数:

\[ \hat{y} = f(x;\theta) \]

并通过损失函数把模型表现写成一个可以优化的目标:

\[ L = L(\hat{y},y) \]

这一节进一步解决了:参数到底应该怎么调整?

梯度描述了损失对参数变化的局部敏感程度:

\[ \nabla_{\theta}L \]

计算图把复杂的模型计算拆成一系列简单操作,并记录这些操作之间的依赖关系。前向传播沿着计算图向前,使用当前参数得到预测和损失;反向传播再沿着计算图向后,通过链式法则计算损失对每一个参数的梯度。

得到梯度之后,优化算法才能真正更新参数,例如最简单的梯度下降:

\[ \theta \leftarrow \theta-\eta\nabla_{\theta}L \]

因此,一次最基本的神经网络训练可以概括成:

\[ \text{Forward} \rightarrow \text{Loss} \rightarrow \text{Backward} \rightarrow \text{Update} \]

这套流程以后会反复出现。无论是 MLP、CNN、Transformer,还是更大的语言模型,底层训练逻辑都没有发生本质变化。

到这里,我们已经知道了前向传播如何得到预测、损失函数如何衡量误差,以及反向传播如何计算参数的梯度。但梯度本身并不会改变模型。真正让模型“学起来”的,是接下来根据梯度更新参数的过程。下一节,我们就从最基本的梯度下降开始,看看神经网络到底是怎样一步步调整参数、降低损失的。