本文是 Stanford CS231n 课程关于反向传播的核心讲义。它不是教你用框架去自动求导,而是带你建立一种"实数电路的反向流动"的工程直觉。读完这篇,你会知道:为什么加法门是"梯度分发器"、最大门是"梯度路由器"、乘法门是"梯度交换器";为什么矩阵梯度只看维度就能推出来;为什么数据预处理会影响学习率。这种直觉,是你设计、调试和发明新神经网络结构的内功。
本节的目标是建立对反向传播的直觉——它本质上是通过链式法则递归计算梯度的方法。理解它的过程与微妙之处,对你有效地设计、开发和调试神经网络是至关重要的。
给定一个函数 $f(x)$,其中 $x$ 是输入向量。我们关心的是:在某个点 $x$ 处计算 $f$ 的梯度,也就是 $\nabla f(x)$。
在神经网络的场景里,$f$ 是损失函数 $L$,输入 $x$ 包括训练数据和神经网络的权重。比如,$f$ 可以是 SVM 损失,输入是训练样本 $(x_i, y_i)$ 和参数 $(W, b)$。训练数据通常是给定且固定的,权重才是我们能调整的变量。所以虽然我们可以用反向传播算输入的梯度,但实际中我们主要算的是参数的梯度,用来做参数更新。
每个学神经网络的人,最终都要面对一个问题:这个庞大的、由成百上千万参数构成的模型,到底是怎么"学"起来的?
答案是反向传播——一种通过链式法则递归地计算梯度的方法。它是神经网络训练的核心引擎,是所有深度学习框架(PyTorch、TensorFlow、JAX)底层都在做的事情。
但本文不是教你怎么调框架的 API,而是要带你建立一种更深的认知:反向传播是一种实数电路中的反向流动。这种视角不仅会让你"理解"反向传播,更会让你在未来设计新结构、调试梯度异常、定位训练问题时,直接"看到"梯度在网络里的流动方式。
即使你已经能熟练用链式法则手推梯度,也建议你至少读一遍这篇文章。因为它呈现的视角——把反向传播看作实数电路里的反向流动——是一种很少有人系统讨论的角度,能在整个学习过程中持续给你启发。
我们先从最简单的两数运算(乘法、加法、最大值)开始,建立符号与约定。关键是要理解:导数告诉你的,是"整个表达式对该变量值的敏感程度"。这个直觉会贯穿后面所有的推导。
我们从最简单的入手——两个数相乘 $f(x,y) = xy$。求偏导数是基础微积分:
怎么解释它?记住,导数告诉你的是:函数在某个点附近、对该变量的变化率。形式化地写出来就是:
这里有个小细节:左边的"除号"和右边的除号不是一回事。左边其实是一个算子——$\frac{d}{dx}$ 作用在函数 $f$ 上,返回一个新的函数(导函数)。一个好的理解方式是:当 $h$ 很小时,函数被一条直线很好地近似,而导数就是这条直线的斜率。
换句话说,每个变量的导数告诉你"整个表达式对它的值有多敏感"。
举个例子:如果 $x = 4$、$y = -3$,那么 $f(x,y) = -12$,且 $\partial f / \partial x = -3$。这告诉我们:如果把 $x$ 增加一个极小的量,整个表达式会减少(因为符号是负的),减少的量是这个增量的 3 倍。这可以从一个变形看出:$f(x+h) = f(x) + h \cdot \frac{df(x)}{dx}$。同理,$\partial f / \partial y = 4$,意味着把 $y$ 增加一点 $h$,输出会增加 $4h$。
每个变量的导数,告诉你的是整个表达式对它的值有多敏感。
所谓"梯度"$\nabla f$,就是所有偏导数构成的向量。所以对乘法函数我们有:
虽然技术上"梯度"是一个向量,但为了行文方便,我们经常会说"$x$ 上的梯度",其实指的是"$f$ 对 $x$ 的偏导数"。
加法和最大值的导数也很简单:
无论 $x, y$ 的值是多少,加法对两个输入的偏导数都是 1。这很合理:增加 $x$ 或 $y$ 都会等量地增加 $f$,而且这个增加率与 $x, y$ 的具体取值无关(这一点和乘法相反)。
也就是说,最大值函数的"次梯度"(subgradient)是:在较大的那个输入上是 1,另一个输入上是 0。
直觉上想:如果输入是 $x = 4, y = 2$,那么最大值是 4,函数对 $y$ 的值不敏感——把 $y$ 加上一个很小的 $h$,函数还是输出 4,所以梯度是 0:没有效果。当然,如果你把 $y$ 改大一点(比如加上大于 2),那 $f$ 的值会变化,但导数只能告诉你输入有微小变化时的影响,对大变化的影响它什么也说明不了。这就是定义里那个 $\lim_{h \to 0}$ 的含义。
面对由多个函数复合的表达式,我们不直接求整体导数,而是拆成多个简单步骤,分别求局部梯度,然后通过乘法把它们链接起来——这就是链式法则。这是反向传播的雏形。
现在考虑更复杂的表达式,比如 $f(x, y, z) = (x + y) z$。这个表达式直接求导也不难,但我们要用一种不一样的方法——这种方法会帮你理解反向传播背后的直觉。
这个表达式可以拆成两步:
这两步我们都会算了。$f$ 是 $q$ 和 $z$ 的乘积,所以 $\partial f / \partial q = z$,$\partial f / \partial z = q$。$q$ 是 $x$ 和 $y$ 的和,所以 $\partial q / \partial x = 1$,$\partial q / \partial y = 1$。
但我们其实不关心中间变量 $q$ 的梯度——$\partial f / \partial q$ 这个值本身没用。我们最终想要的,是 $f$ 对它输入 $x, y, z$ 的梯度。
链式法则告诉我们:把这些梯度表达式正确地"链"起来的方式是乘法。比如:
在实践中,这就是把两个存放梯度的数字相乘。我们用代码看一个例子:
# 设定输入 x = -2; y = 5; z = -4 # 前向传播(forward pass) q = x + y # q = 3 f = q * z # f = -12 # 反向传播(backward pass),逆序执行: # 先经过 f = q * z dfdz = q # df/dz = q,所以 z 上的梯度是 3 dfdq = z # df/dq = z,所以 q 上的梯度是 -4 dqdx = 1.0 dqdy = 1.0 # 然后经过 q = x + y dfdx = dfdq * dqdx # 这里的乘法就是链式法则! dfdy = dfdq * dqdy
我们最终得到的是 [dfdx, dfdy, dfdz],它们告诉我们:变量 $x, y, z$ 对 $f$ 的敏感程度。这就是反向传播最简单的例子。
后面我们会用更简洁的记号:省略 df 前缀,直接写 dq 而不是 dfdq,并且始终默认梯度是相对于最终输出而言。
整个计算可以用电路图很直观地展示出来。前向传播从输入沿着边一路计算到输出(绿色数字),反向传播从末端开始,递归地应用链式法则,把梯度(红色数字)一路传回输入。梯度可以被想象成在电路里"反向流动"。
反向传播之所以漂亮,是因为它是一个纯粹局部的过程。每个"门"在接收输入后,立刻能算两件事:输出值和输出对输入的局部梯度。当反向传播来临时,它只需要把"从上游传下来的梯度"乘以"自己的局部梯度",再传给下游。门完全不需要知道自己嵌入在多大、多复杂的网络里。
注意一件事:反向传播是一个完全局部(local)的过程。电路图里的每个门,在拿到输入的那一刻,可以立刻独立完成两件事:
1. 计算它的输出值;
2. 计算输出相对于输入的局部梯度。
注意,门完全不需要知道自己嵌入在多大的电路里就能做这两件事。然而,前向传播一旦结束,在反向传播过程中,这个门最终会"得知"自己的输出对整个电路最终输出的梯度。链式法则说,门应该把这个梯度乘以它正常计算的每一个对输入的局部梯度,再传下去。
我们用前面的例子来培养直觉。加法门接收了输入 $[-2, 5]$,计算出输出 $3$。因为是加法运算,它对两个输入的局部梯度都是 $+1$。电路其余部分计算出了最终值 $-12$。
反向传播开始后,加法门(作为乘法门的输入)"得知"它的输出梯度是 $-4$。
如果我们把电路拟人化,想象它"希望输出一个更高的值",那么我们可以把电路理解为:它"希望"加法门的输出更低(因为符号是负的),并且"用力"4。为了继续递归并链接梯度,加法门拿着这个 $-4$,乘以它对每个输入的局部梯度(都是 1),得到 $x$ 和 $y$ 上的梯度都是 $1 \times -4 = -4$。
注意这正好达到了预期效果:如果 $x, y$ 响应它们的负梯度而减小,加法门的输出就会减小,进而让乘法门的输出增大。
反向传播,可以理解为电路里各个门之间通过梯度信号互相沟通:
它们各自表达"希望自己的输出变大还是变小,以及力度多大",
从而让整个电路的最终输出更高。
"门"是任意可微分函数。我们可以把多个门合并为一个,也可以把一个函数拆成多个门。合并的好处是:有些复杂表达式(比如 Sigmoid)的导数在合并后会简化成异常优雅的形式,计算更快、数值更稳。这就是模块化的力量。
前面引入的几个门(加、乘、最大)其实是相当任意的选择。任何可微分函数都可以充当一个门。我们可以把多个门组合成一个,也可以把一个函数拆解成多个门,只要方便。
看一个例子:
后面课程会讲到,这个表达式描述了一个使用 Sigmoid 激活函数的二维神经元(输入 $x$,权重 $w$)。但现在我们就把它当作一个简单的"从输入 $w, x$ 映射到一个数"的函数。
它由多个门组成。除了之前的加、乘、最大门,这里还多了四个:
其中 $f_c$ 和 $f_a$ 分别表示"将输入加上常数 $c$"和"将输入乘以常数 $a$"。它们技术上是加法和乘法的特例,这里把它们当成一元门引入,因为我们不需要对常数 $c, a$ 求梯度。
整个电路展开来是 8 个门,从输入端依次:乘法($w_0 x_0$)、乘法($w_1 x_1$)、加法、加法(加上 $w_2$)、乘以 $-1$、$e^x$、加 1、取倒数。
这是一长串函数依次应用,作用在 $w, x$ 的点积结果上。我们把这一连串运算实现的函数叫做 Sigmoid 函数 $\sigma(x)$。它的导数推导(稍微有点技巧,需要在分子里加 1 减 1)化简后会变得异常简洁:
这是一个惊人的简化。比如 Sigmoid 收到输入 $1.0$、计算出输出 $0.73$,那么它的局部梯度就直接是 $(1 - 0.73) \cdot 0.73 \approx 0.2$——和电路图里一步步算出来的结果一样,但这里用一个简单、高效的表达式就完成了(而且数值上更稳定)。
所以在实际应用中,把这些运算打包成一个门非常有用。我们看看这个神经元的反向传播代码:
w = [2, -3, -3] # 假设的随机权重 x = [-1, -2] # 输入数据 # 前向传播 dot = w[0]*x[0] + w[1]*x[1] + w[2] f = 1.0 / (1 + math.exp(-dot)) # sigmoid 函数 # 反向传播(通过整个神经元) ddot = (1 - f) * f # 用 sigmoid 的简化公式直接算 dx = [w[0] * ddot, w[1] * ddot] # 传到 x dw = [x[0] * ddot, x[1] * ddot, 1.0 * ddot] # 传到 w # 完成!我们得到了所有输入的梯度
如代码所示,实践中一个非常有用的习惯是:把前向传播拆分成容易反向传播的多个阶段。比如这里我们创建了一个中间变量 dot 来保存 $w$ 和 $x$ 的点积。反向传播时我们再依次倒着计算对应的梯度变量(如 ddot,以及最终的 dw、dx)。
这一节的重点是:反向传播的细节如何执行,以及我们把前向函数的哪些部分看作"门",是一种便利性选择。关键是要清楚哪些部分有简单的局部梯度,这样它们可以以最少的代码与精力链接在一起。
面对一个更复杂的、嵌套的函数,不要试图直接对原函数求导——那样会得到极其冗长、易错的表达式。正确做法是把前向传播拆分为多个中间变量,然后逐个反向传播。两个关键细节:缓存前向值、分叉处梯度要累加(用 += 而不是 =)。
我们用另一个例子来看这一点。假设有这样一个函数:
说实话,这个函数完全没用,也看不出谁会想算它的梯度——它纯粹是反向传播实战的一个好例子。要强调的是:如果你直接对 $x$ 或 $y$ 求导,会得到一个极其庞大、复杂的表达式。但好消息是——我们根本不需要写出梯度的显式公式。我们只要会计算它就行了。
下面是这种表达式的前向传播写法:
x = 3 # 示例值 y = -4 # 前向传播 sigy = 1.0 / (1 + math.exp(-y)) # 分子里的 sigmoid (1) num = x + sigy # 分子 (2) sigx = 1.0 / (1 + math.exp(-x)) # 分母里的 sigmoid (3) xpy = x + y # (4) xpysqr = xpy**2 # (5) den = sigx + xpysqr # 分母 (6) invden = 1.0 / den # (7) f = num * invden # 完成! (8)
呼,前向传播算完了。注意我们结构化了代码,让中间变量都很简单——每个都是我们已经知道局部梯度的小表达式。所以反向传播就很容易:我们倒着走,对每一个变量(sigy, num, sigx, xpy, xpysqr, den, invden)都会有一个 d 开头的对应变量,保存最终输出对它的梯度。每一步都涉及"计算局部梯度 + 与上游梯度相乘"。我们用注释标注每行对应前向传播的哪一步:
# backprop f = num * invden dnum = invden #(8) dinvden = num #(8) # backprop invden = 1.0 / den dden = (-1.0 / (den**2)) * dinvden #(7) # backprop den = sigx + xpysqr dsigx = (1) * dden #(6) dxpysqr = (1) * dden #(6) # backprop xpysqr = xpy**2 dxpy = (2 * xpy) * dxpysqr #(5) # backprop xpy = x + y dx = (1) * dxpy #(4) dy = (1) * dxpy #(4) # backprop sigx = 1.0 / (1 + math.exp(-x)) dx += ((1 - sigx) * sigx) * dsigx # 注意 += !见下方说明 (3) # backprop num = x + sigy dx += (1) * dnum #(2) dsigy = (1) * dnum #(2) # backprop sigy = 1.0 / (1 + math.exp(-y)) dy += ((1 - sigy) * sigy) * dsigy #(1) # 完成!
计算反向传播时,你会大量用到前向传播中算出的中间变量。所以实践中要把代码结构化好,让这些变量在反向传播时可用。如果实在不行,也可以重新计算它们(虽然有点浪费)。
前向表达式里 $x, y$ 出现了多次(比如 $x$ 既出现在分子 num,又出现在 sigx、又出现在 xpy),所以反向传播时必须用 += 而不是 =,来累加梯度,否则就会被覆盖。这遵循了多变量链式法则:如果一个变量在电路里分叉到不同部分,反向流回它的梯度需要相加。
神经网络里最常用的三个门——加、乘、最大——在反向传播时各自呈现出非常直观的"性格":
理解这些模式,你才能"看见"梯度在网络里的流动方式。
有意思的是,反向流动的梯度在许多场景下都有非常直观的解释。神经网络中最常用的三个门——加、乘、最大——在反向传播时各有非常简单的"行为模式"。
加法门是梯度的分发器,
最大门是梯度的路由器,
乘法门是梯度的交换器。
加法门会把"对它输出的梯度"原样分发给所有输入,无论它们在前向传播时的值是多少。这是因为加法的局部梯度都是 $+1.0$,所以输入梯度等于输出梯度乘以 1,保持不变。在前面的示例电路里,加法门把 $2.00$ 的梯度原封不动地分给了它的两个输入。
最大门会路由梯度。与加法门把梯度发给所有输入不同,最大门只把梯度(原样地)发给其中一个输入——那个在前向传播时值最大的输入。这是因为最大门的局部梯度对于最大值那个输入是 $1.0$,对其他输入是 $0$。在示例电路里,最大门把梯度 $2.00$ 路由给了 $z$(因为 $z$ 比 $w$ 大),而 $w$ 上的梯度仍然是零。
乘法门稍微难理解一点。它的局部梯度是输入值的"互换",再与上游梯度相乘。在示例里,$x$ 上的梯度是 $-8.00$,等于 $-4.00 \times 2.00$。
注意:如果乘法门的一个输入非常小,另一个非常大,乘法门会做一件略反直觉的事——它会给小输入分配相对巨大的梯度,给大输入分配微小的梯度。
在线性分类器中,权重和输入做点积(也就是乘法),这意味着:数据的尺度会影响权重梯度的大小。举个例子,如果你把所有输入数据都乘以 1000(预处理时不小心做了这件事),那么权重上的梯度就会大 1000 倍——你必须把学习率相应降低 1000 倍才能补偿。这就是为什么预处理至关重要,且有时影响微妙。理解梯度如何流动,能帮你 debug 这些隐蔽的问题。
前面讲的都是标量的运算,但所有概念都能直接推广到矩阵和向量。最 tricky 的运算是矩阵-矩阵乘法。这里有一个工程上极其有用的技巧——维度分析:你不需要死记 dW、dX 的公式,根据矩阵形状反推就行了。
之前讨论的都是单变量,但所有概念都可以直接推广到矩阵和向量运算。不过这里要多花点心思去关注维度和转置。
矩阵-矩阵乘法的梯度可能是最 tricky 的一个运算(它涵盖了所有矩阵-向量、向量-向量乘法的情况):
# 前向传播 W = np.random.randn(5, 10) X = np.random.randn(10, 3) D = W.dot(X) # 假设上游传过来的梯度是 dD dD = np.random.randn(*D.shape) # 和 D 形状相同 dW = dD.dot(X.T) # .T 是矩阵的转置 dX = W.T.dot(dD)
你不需要记住 dW 和 dX 的公式——根据维度可以重新推导出来!
比如我们知道:权重的梯度 dW 必须和 W 的形状一样,而它必须涉及到 X 和 dD 的某种矩阵乘法(就像标量情形那样)。只有一种排列方式能让维度对得上。
举例:X 的形状是 $[10 \times 3]$,dD 是 $[5 \times 3]$。如果我们想要 dW(形状必须是 $[5 \times 10]$),那么唯一的可能就是 dD.dot(X.T)——dD 是 $[5 \times 3]$,X.T 是 $[3 \times 10]$,乘出来正好是 $[5 \times 10]$。
如果你一开始觉得向量化推导很难,建议先写出一个最小的、显式的向量化例子,在纸上推导梯度,然后把这种模式泛化到高效的向量化形式。
Erik Learned-Miller 也写过一篇更长的关于矩阵/向量求导的笔记,需要时可以参考。
让我们快速回顾这一篇的核心收获。
第一,我们建立了对梯度含义的直觉:它告诉我们电路对每个输入的敏感程度。我们看到了它如何在电路中反向流动,如何"告诉"电路的不同部分:你应该增大还是减小,用多大的力,才能让最终输出更高。
第二,我们讨论了分阶段计算对实际反向传播的重要性。你应该总是把函数拆成你能轻易推导出局部梯度的模块,然后用链式法则把它们串起来。
关键一点:你几乎永远不会想把整个梯度表达式写在纸上然后符号微分。因为你根本不需要一个显式的梯度数学公式。所以——分解你的表达式为多个阶段,每个阶段能独立求导(这些阶段会是矩阵-向量乘法、最大值运算、求和运算等等),然后一步步地对每个变量反向传播。
反向传播不是"算"出来的,
是"流"出来的。
把网络看成电路,把梯度看成信号——这就是你的内功心法。
下一节我们会开始定义神经网络,而反向传播会让我们高效地计算损失函数对参数的梯度。换句话说,我们已经准备好训练神经网络了——这门课中最概念性、最难的部分已经过去了!之后讲卷积网络,就只是一小步距离了。