1.3 前向传播、反向传播与计算图
在前两节中,我们已经把神经网络训练的基本问题一步一步搭了起来。
首先,神经网络可以看成一个可学习的参数化函数:
\[ \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 \]
这样,一个复杂函数就被拆成了乘法、加法、减法和平方几个简单操作。
我们可以把这些计算之间的依赖关系画成一张图:
这就是计算图(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.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 \]
在这个特意设计的简单例子中,一次更新就刚好到达了最小值。真实神经网络当然不会这么幸运,通常需要成千上万次甚至更多次迭代,而且学习率过大、过小都会带来新的问题。但我们已经能够画出神经网络训练的基本流程:
现在,我们终于可以把前两节中的内容真正连起来:
\[ \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,还是更大的语言模型,底层训练逻辑都没有发生本质变化。
到这里,我们已经知道了前向传播如何得到预测、损失函数如何衡量误差,以及反向传播如何计算参数的梯度。但梯度本身并不会改变模型。真正让模型“学起来”的,是接下来根据梯度更新参数的过程。下一节,我们就从最基本的梯度下降开始,看看神经网络到底是怎样一步步调整参数、降低损失的。