1.4 梯度
理解反向传播的核心:梯度!
创建日期: 2025-04-01
当单个神经元通过相互之间的连接成为神经网络,如果对每个参数都进行前向和反向操作,就会发现,每次增加一个参数,每个输入就需要多计算 3 次。如果有上千万的参数,那么计算量是网络无法承受的。
因此就需要想一种办法,只需要一次输入计算,就能够更新所有的参数。答案就是在微积分教材中学到的导数,根据导数的正负,是可以知道当前参数的变化对于网络的影响的。
神经网络中称之为 梯度(Gradient) ,模型中的所有函数都以平滑连续的方式转换输入,例如 \(z = x + y\) ,y 的微小变化只会导致 z 发生微小变化,如果知道 y 的变化方向,则可以推断出 z 变化的方向。从数学上来讲,这些函数是可微的,如果将这些函数连接在一起,得到的更大函数仍然是可微的。
1.4.1 介绍
在本节中,我们将直观地理解反向传播,这是一种通过递归应用链式法则来计算表达式梯度的方法。理解这个过程及其微妙之处对于我们理解和有效地开发、设计和调试神经网络至关重要。
研究的核心问题如下:给定一个函数 \(f(x)\) ,其中 \(x\) 是一个输入向量,我们对计算 \(x\) 处的梯度 \(\nabla{f(x)}\) 感兴趣。
我们对这个问题感兴趣的主要原因是,在神经网络这个具体的情景下,\(f\) 将对应于损失函数 (L) ,输入 \(x\) 将由训练数据和神经网络权重组成。比如,损失可以是 SVM 损失函数,输入是训练数据 \((x_i, y_i), i = 1 ... N\) 和权重 \(W\) 以及偏置 \(b\) 。请注意(机器学习中通常如此),我们认为训练数据是给定且固定的,权重是可以控制的变量。因此尽管我们可以轻松地使用反向传播来计算输入示例 \(x_i\) 的梯度,在实践中仅计算相对于参数(比如 \(W, b\))的梯度,因此可以用它来进行参数更新。但是,在后续我们还是可以看到 \(x_i\) 的梯度是有用的,比如可以查看和解释神经网络内部正在做什么。
1.4.2 简单表达式
让我们从简单的开始,这样我们就可以计算更复杂的表达式。考虑一个简单的乘法函数 \(f(x, y) = xy\) 。推导出任意输入的偏导数都是一个简单的微积分问题:
记住导数告诉我们的内容:它们表示函数相对于特定点附近无穷小区域周围的变量的变化率:
技术说明:左侧的除法符号与右侧的除法符号不同,不是除法。相反,此符号表示操作符 \(\frac{d}{dx}\) 正在应用于函数 \(f\) ,并返回一个不同的函数(导数)。
思考上述表达式的一个好方法是,当 \(h\) 非常小时,则该函数可以用直线很好地近似,导数是其斜率。换句话说,每个变量的导数告诉我们整个表达式对其值的敏感度。
比如 \(x = 4, y = -3\) ,那么 \(f(x, y) = -12\) ,在 \(x\) 上的导数 \(\frac{\partial f}{\partial x} = -3\) 。这告诉我们,如果我们将这个变量的值增加一点点,对整个表达式的影响将是减少它(由于负号),并且是这个量的三倍。这可以通过重新写上面的等式 \(f(x + h) = f(x) + h\frac{df(x)}{dx}\) 看出来。
同样的,因为 \(\frac{\partial f}{\partial y} = 4\) ,我们可以通过将 \(y\) 的值增加一个非常小的量 \(h\) ,会增加函数的输出(因为是正数),大小为 \(4h\) 。
每个变量的导数告诉您整个表达式对其值的敏感度。
如上所述,梯度 \(\delta{f}\) 是偏导数的向量,因为我们有 \(\nabla{f} = [\frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}] = [y, x]\) 。尽管从技术上来说,梯度是一个向量,但为了简单起见,我们通常会使用诸如“\(x\) 上的梯度”这样的术语,而不是技术上正确的短语“\(x\) 上的偏导数”。
我们还可以推导出加法运算的导数:
也就是说,对 \(x, y\) 的导数值和 \(x, y\) 它们本身值无关。这很有作用,因为增加 \(x, y\) 任意一个都会增加输出 \(f\) 的值,增加的速率和 \(x, y\) 的真实值无关(和上面的乘法不同)。最后一个我们经常使用的函数是计算最大值:
也就是说,梯度在较大的输入上是 1,其它的输入为 0 。如果输入 \(x = 4, y = 2\) ,那么较大值是 4 ,函数不受 \(y\) 的值影响。如果我们将 \(y\) 增加一点 \(h\) ,那么函数的输出仍然为 4 ,因此它的梯度为 0 :没有效果。
当然,如果我们将 \(y\) 增加一个非常大的值(比如大于 2),那么函数值就会发生变化,但导数并没有告诉我们如此大的变化对函数输入的影响;它们只对输入的微小、无限小的变化提供信息,就像它定义中 \(\lim_{h \to 0}\) 表示的那样。
1.4.3 复合表达式
现在让我们开始考虑涉及多个组合函数的更复杂的表达式,例如 \(f(x, y, z) = (x + y)z\) 。这个表达式足够简单,可以直接计算,但我们将采用一个特殊的方法,这将有助于理解反向传播背后的知识。特别注意的是,这个表达式可以分解为两个表达式:\(q = x + y\) 和 \(f = qz\) 。
此外,我们知道如何分别计算两个表达式的导数,如上一个小节所示。\(f\) 只是 \(q\) 和 \(z\) 进行相乘,因此 \(\frac{\partial f}{\partial q} = z\) 以及 \(\frac{\partial f}{\partial z} = q\) 。同时 \(q\) 是 \(x\) 和 \(y\) 进行相加,因此 \(\frac{\partial q}{\partial x} = 1\) 以及 \(\frac{\partial q}{\partial y} = 1\) 。
然而,我们并不关心中间值 \(q\) 的梯度 ,\(\frac{\partial f}{\partial q}\) 是没有作用的。相反,我们最终感兴趣的是函数 \(f\) 相对于输入 \(x, y, z\) 的梯度。链式法则 (Chain Rule) 告诉我们,将这些梯度表达式“链接”在一起的正确方法是通过乘法。例如 \(\frac{\partial f}{\partial x} = \frac{\partial f}{\partial q} \frac{\partial q}{\partial x}\) 。实际上,这只是保存有两个梯度的数值相乘。打开 compound.py 文件,让我们看一个具体的例子:
# set some inputs
x = -2; y = 5; z = -4
# perform the forward pass
q = x + y # q becomes 3
f = q * z # f becomes -12
# perform the backward pass (backpropagation) in reverse order:
# first backprop through f = q * z
df_dz = q # df/dz = q, so gradient on z becomes 3
df_dq = z # df/dq = z, so gradient on q becomes -4
dq_dx = 1.0
dq_dy = 1.0
# now backprop through q = x + y
df_dx = df_dq * dq_dx # the multiplication here is the chain rule
df_dy = df_dq * dq_dy
assert df_dx == -4
assert df_dy == -4
assert df_dz == 3
在变量 [df_dx, df_dy, df_dz]
中的梯度,告诉我们变量 \(x, y, z\) 在 \(f\) 函数上的敏感度!这是反向传播最简单的示例,接下来我们将省略前缀 df
,让标记看起来更简洁。比如写成 dq
而不是 df_dq
,并且始终假设梯度是在最终输出上计算的。
这个计算也可以用线路图很好地形象化:
上面的线路图是计算的视觉表示。正向传递计算从输入到输出的值(以绿色显示)。然后,反向传递执行反向传播,从末尾开始并递归地应用链式法则来计算梯度(以红色显示),一直到线路图的输入。梯度可以倍认为是通过线路向后传递。
1.4.4 直观理解
请注意,反向传播是一个非常局部的过程。线路图中的每个门都会获得一些输入,并且可以立即计算出两件事:1. 其输出值;2. 其输出相对于其输入的局部梯度。请注意,门可以完全独立地执行此操作,而无需了解它们所嵌入的完整电路的任何细节。但是,一旦前向传递结束,在反向传播过程中,门需要知道它的输出相对于整个线路输出的梯度。链式法则表示,门应该采用那个梯度,然后为它的所有输入计算每个梯度。
由于链式法则而产生的这种额外的乘法(对于每个输入)可以将一个相对无用的门变成复杂线路(例如整个神经网络)中的一个齿轮。
让我们再次参考示例来直观地了解其工作原理。加法门接收输入 \([-2, 5]\) 并计算输出 3 。由于门是进行加法运算,因此其两个输入的局部梯度为 +1 线路其余部分计算的最终值是 -12 。在反向传播的过程中,链式法则在电路中以递归方式向后应用。加法门的输出梯度是 -4 。
如果我们想要最终的输出结果更高,可以让加法门的输出更低(因为负数梯度)。为了继续递归并链接梯度,加法门采用该梯度并将其乘以所有的局部梯度(使 \(x\) 和 \(y\) 的梯度为 \(1 * -4 = -4\) 。请注意,这是预期的效果:如果 \(x\) 和 \(y\) 减少,则加法门的输入将减少,这反过来似的乘法门的输出增加。
因此反向传播可以被认为是门通过梯度信号相互通信,告知他们是否希望其输出增加或减少(以及增加的强度),从而使最终的输出值更高。
1.4.5 Sigmoid
上面介绍的门是相对任意的,任何可微分函数都可以充当门,我们可以将多个门组合成一个门,或者在方便的时候将一个函数分解为多个门。让我们看看另一个说明这一点的表达式:
我们后续会看到,上述表达式描述了 2 维神经元(包括输入 \(x\) 和权重 \(w\)),它们用在 sigmoid 激活函数上。但是就此时来说,可以简单地看成一个函数,输入 \(x, w\) ,得到一个输出值。这个函数由多个门组成,除了上面描述的(加法、乘法、最大值),还是如下四个:
\(f(x) = \frac{1}{x} \rightarrow \frac{df}{dx} = \frac{-1}{x^2}\)
\(f_{c}(x) = c + x \rightarrow \frac{df}{dx} = 1\)
\(f(x) = e^x \rightarrow \frac{df}{dx} = e^x\)
\(f_{a}(x) = ax \rightarrow \frac{df}{dx} = a\)
其中函数 \(f_c\) 将输入加上常数 \(c\) ,\(f_a\) 分别将输入缩放常数 \(a\) 倍。它们只是加法和乘法的特殊例子,但是我们还是将它们看作是新的单元门,因为我们不需要对常数 \(c\) 和 \(a\) 进行梯度计算。完整的线路图如下所示:
示例线路图展示了带有 sigmoid 激活函数的 2 维神经元。输入是 \([x0, x1]\) ,神经元的可学习权重是 \([w0, w1, w2]\) 。我们后面可以看到,神经元和输入进行点积后,通过激活函数将它的输出值映射成 0 - 1 的范围。
\(w\) 和 \(x\) 进行点积之后,一长串的函数应用于该结果上,这些函数统称为 sigmoid 函数 \(\sigma(x)\) 。如果将它的输入简化,对 sigmoid 函数进行求导,就会发现导函数是非常简洁的(仅仅使用一个在分子上使用加减一的小技巧):
\(\sigma(x) = \frac{1}{1 + e^{-x}}\)
\(\frac{d\sigma(x)}{dx} = \frac{e^{-x}}{(1 + e^{-x})^2} = (\frac{1 + e^{-x} - 1}{1 + e^{-x}})(\frac{1}{1 + e^{-x}}) = (1 - \sigma(x))\sigma(x)\)
正如我们所见,梯度变得非常简单。比如 sigmoid 函数接收一个 1.0 的输入,那么前向传递过程的输出结果是 0.73 。那么局部梯度就是 \((1 - 0.73) * 0.73 ~= 0.2\) ,就像上面的计算图那样,只不过这里使用了一个非常有效的表达式。因此,对于实际的应用程序来说,将这些操作集合成单个门是非常有用的。文件 sigmoid.py 展示了这个神经元的反向传播过程:
w = [2, -3, -3] # assume some random weights and data
x = [-1, -2]
# forward pass
dot = w[0] * x[0] + w[1] * x[1] + w[2]
f = 1.0 / (1 + math.exp(-dot))
# backward pass through the neuron (backpropagation)
ddot = (1 - f) * f # gradient on dot variable, using the sigmoid gradient derivation
dx = [w[0] * ddot, w[1] * ddot] # backprop into x
dw = [x[0] * ddot, x[1] * ddot, 1.0 * ddot]
print([round(x, 2) for x in dx])
print([round(w, 2) for w in dw])
[0.39, -0.59] [-0.2, -0.39, 0.2]
实现提示:分阶段反向传播。如上面代码所示,在实践中,将前向传播分解为易为反向传播的阶段总是有帮助的。例如,我们创建了一个 dot
中间变量,它保存 \(w\) 和 \(x\) 之间的点积运算结果。在反向传播过程中,我们依次计算(以相反的顺序)相应的变量(例如 ddot
),它们保存了中间梯度,最终能够使用它计算出 dw
和 dx
的值。
本小节的重点是如何计算反向传播,以及为了方便,将多个操作整合成门。了解表达式的哪些部分具有简单的局部梯度很有帮助,这样就可以用最少的代码和精力将它们串联在一起。
1.4.6 分阶段计算
让我们用另一个例子来看一下,代码在文件 staged.py 中,假设我们有一个如下形式的函数:
需要清楚的是,这个函数完全没用,除了能够很好地展示反向传播过程中的梯度计算。需要强调的是,如果我们对 \(x\) 或者 \(y\) 进行微分,将会得到一个复杂的表达式。然后完全没有必要这样做,因为我们不需要写下整个函数来进行梯度计算,只需要知道如何计算它。以下是我们如何构造这种表达式的前向传递:
x = 3 # example values
y = -4
# forward pass
sigy = 1.0 / (1 + math.exp(-y)) # sigmoid in numerator
num = x + sigy # numerator
sigx = 1.0 / (1 + math.exp(-x)) # sigmoid in denominator
xpy = x + y
xpysqr = xpy**2
den = sigx + xpysqr # denominator
invden = 1.0 / den
f = num * invden # done!
assert round(f, 2) == 1.55
在表达式的末尾,我们已经计算出了前向传递。请注意,以这样的方式构建代码,它包含多个中间变量,每个变量都只是简单的表达式,我们已经知道它们的局部梯度。因此,计算反向传播很容易:沿着前向传播的参数 (sigy, num, sigx, xpy, xpysqr, den, invden
) 计算每一个的梯度。我们有相同的变量,只是每个变量前增加字母 d
,它存储的是变量的反向传播过程的梯度值。
此外,请注意,反向传播中的每个部分都涉及计算局部梯度,并通过乘法将其与其它梯度链接起来。如下所示:
# backprop f = num * invden
dnum = invden # gradient on numerator
dinvden = num
# backprop invden = 1.0 / den
dden = (-1.0 / (den**2)) * dinvden
# backprop den = sigx + xpysqr
dsigx = (1) * dden
dxpysqr = (1) * dden
# backprop xpysqr = xpy**2
dxpy = (2 * xpy) * dxpysqr
# backprop xpy = x + y
dx = (1) * dxpy
dy = (1) * dxpy
# backprop sigx = 1.0 / (1 + math.exp(-x))
dx += ((1 - sigx) * sigx) * dsigx # Notice += !! See notes below
# backprop num = x + sigy
dx += (1) * dnum
dsigy = (1) * dnum
# backprop sigy = 1.0 / (1 + math.exp(-y))
dy += ((1 - sigy) * sigy) * dsigy
assert round(dx, 2) == 2.06
assert round(dy, 2) == 1.59
注意有几点:
缓存前向传递的变量。要计算后向传递,拥有前向传递中使用的一些变量非常有帮助。实际上,我们需要缓存这些变量,并在反向传播期间使用它们。如果这太困难,可以重新计算它们。
梯度在分叉处累加。正向表达式涉及变量 \(x, y\) 多次,因此在进行反向传播时,需要使用 \(+=\) 而不是 \(=\) 去雷杰这些变量的梯度。这遵循微积分中的多变量链式法则,该法则指出,如果一个变量分支到电路的不同部分,那么流回它的梯度将相加。
1.4.7 回流模式
值得注意的是,在许多情况下,反向流动梯度可以在直观层面上进行解释。例如,神经网络中最常用的三个门 (add, mul, max) 在反向传播过程中的行为都有非常简单的解释。考虑下面这个示例线路:
以上图为例,我们可以看到:
加法门始终会获取其输出上的梯度并将其均匀分配给其所有输入,而不管它们的值在前向传递期间是多少。这是因为加法运算的局部梯度只是 +1.0,因此所有输入上的梯度将完全等于输出上的梯度,因为它将乘以 x1.0(并且保持不变)。在上面的示例电路中,请注意 + 门将 2.00 的梯度均匀且不变地路由到其两个输入。
最大门路由梯度。与将梯度不变地分布到其所有输入的加法门不同,最大门将梯度(不变)分布到其输入之一(前向传递期间具有最高值的输入)。这是因为最大门的局部梯度对于最高值是 1.0,对于所有其他值是 0.0。在上面的示例电路中,最大操作将梯度 2.00 路由到z变量,该变量的值高于w ,而w上的梯度保持为零。
乘法门不太容易解释。它的局部梯度是输入值(切换值除外),并在链式法则期间将其乘以其输出上的梯度。在上面的例子中,x上的梯度是 -8.00,即 -4.00 x 2.00。
请注意,如果乘法门的一个输入非常小,而另一个输入非常大,那么乘法门将做一些稍微不直观的事情:它将为小输入分配一个相对较大的梯度,为大输入分配一个微小的梯度。记住在线性分类器中,点积运算是 \(w^Tx_i\) ,这意味着对输入数据进行缩放会影响最终的梯度值。
比如将所有的输入样本 \(x_i\) 在预处理的时候乘以 1000 ,那么权重的梯度也会乘以 1000 倍,反过来导致需要更小的学习率。这就是为什么预处理非常重要,有时甚至以微妙的方式!直观地了解梯度如何流动可以帮助您调试其中一些情况。
1.4.8 数组梯度
以上都是涉及的单个变量,但所有概念都可以直接延伸到矩阵和向量运算。然而,必须更加关注维度和转置运算。
可能最棘手的运算之一是矩阵与矩阵乘法,代码在文件 vectorized.py 中:
# forward pass
W = np.random.randn(5, 10)
X = np.random.randn(10, 3)
D = W.dot(X)
# now suppose we had the gradient on D from above in the circuit
dD = np.random.randn(*D.shape) # same shape as D
dW = dD.dot(X.T) # .T gives the transpose of the matrix
dX = W.T.dot(dD)
我们不需要记住 dW
和 dX
的表达式,因为很容易推导。比如,我们知道权重 dW
的维度和 W
的维度是一样。它的大小是 X
和 dD
相乘(与 X
和 W
是单个数值时的梯度计算规则类似)。
比如上面的例子中,X
的维度是 [10x3] ,dD
的维度是 [5, 3] ,W
的维度是 [5x10],我们想要得到 dW
,那么只能通过 dD.dot(X.T)
计算得到。
有些人一开始可能发现很难推导某些矢量化表达式的梯度,我们的建议是,先明确写出一个最小的矢量化示例,在纸上推导梯度,然后将该模式推导到其高效的矢量化形式。