跳到主要内容

反向传播:原理与直观理解

本篇目录:

引言

动机。在本节中,我们将通过对反向传播的直观理解来培养专业知识,这是一种通过递归应用链式法则来计算表达式梯度的方法。了解这个过程及其微妙之处对于您理解并有效开发、设计和调试神经网络至关重要。

问题陈述。本节研究的核心问题如下:我们给出一些函数 \\(f(x)\\),其中 \\(x\\) 是输入向量,我们感兴趣的是计算 \\(f\\) 在 \\(x\\) 处的梯度(即 \\(\nabla f(x)\\) )。

动机。回想一下,我们对这个问题感兴趣的主要原因是,在神经网络的特定情况下,\\(f\\) 将对应于损失函数(\\(L\\)),而输入\\(x\\) 将由训练数据和神经网络权重组成。例如,损失可以是 SVM 损失函数,输入是训练数据 \\((x_i,y_i), i=1 \ldots N\\) 以及权重和偏差 \\(W,b\\)。请注意(机器学习中通常的情况)我们将训练数据视为给定且固定的,并将权重视为我们可以控制的变量。因此,尽管我们可以轻松地使用反向传播来计算输入示例 \\(x_i\\) 上的梯度,但实际上我们通常只计算参数的梯度(例如 \\(W,b\\)),以便我们可以使用它来执行参数更新。然而,正如我们稍后将在课程中看到的那样,\\(x_i\\) 上的梯度有时仍然有用,例如出于可视化和解释神经网络可能正在做什么的目的。

如果您即将参加这门课程,并且您对使用链式法则推导梯度感到满意,我们仍然鼓励您至少浏览一下本节,因为它提出了反向传播作为实值电路中的反向流的很少发展的观点,您将获得的任何见解都可能对您在整个课程中有所帮助。

简单表达式与梯度的含义

让我们从简单的开始,以便我们可以为更复杂的表达式开发符号和约定。考虑两个数字 \\(f(x,y) = x y\\) 的简单乘法函数。导出任一输入的偏导数是一个简单的微积分问题:

$$ f(x,y) = x y \hspace{0.5in} \rightarrow \hspace{0.5in} \frac{\partial f}{\partial x} = y \hspace{0.5in} \frac{\partial f}{\partial y} = x $$

解释。请记住导数告诉您的内容:它们表示函数相对于围绕特定点附近无限小区域的变量的变化率:

$$ \frac{df(x)}{dx} = \lim_{h\ \to 0} \frac{f(x + h) - f(x)}{h} $$

技术说明是,与右侧的除号不同,左侧的除号不是除号。相反,此表示法表明运算符 \\( \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\\)。

每个变量的导数告诉您整个表达式对其值的敏感性。

如前所述,梯度 \\(\nabla f\\) 是由各个偏导数组成的向量,因此 \\(\nabla f = [\frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}] = [y, x]\\)。严格来说梯度是向量,但为了简洁,后文也会使用“对 x 的梯度”来代指更严谨的说法——“对 x 的偏导数”。

我们还可以导出加法运算的导数:

$$ f(x,y) = x + y \hspace{0.5in} \rightarrow \hspace{0.5in} \frac{\partial f}{\partial x} = 1 \hspace{0.5in} \frac{\partial f}{\partial y} = 1 $$

也就是说,无论 \\(x,y\\) 的值是多少,\\(x,y\\) 的导数都是 1。这是有道理的,因为增加 \\(x,y\\) 都会增加 \\(f\\) 的输出,并且增加的速率将独立于 \\(x,y\\) 的实际值(与上面的乘法情况不同)。我们将在类中经常使用的最后一个函数是 max 操作:

$$ f(x,y) = \max(x, y) \hspace{0.5in} \rightarrow \hspace{0.5in} \frac{\partial f}{\partial x} = \mathbb{1}(x >= y) \hspace{0.5in} \frac{\partial f}{\partial y} = \mathbb{1}(y >= x) $$

也就是说,较大输入的(子)梯度为 1,另一个输入的(子)梯度为 0。直观上,如果输入为\\(x = 4,y = 2\\),则最大值为4,并且该函数对\\(y\\)的设置不敏感。也就是说,如果我们将其增加一点点 \\(h\\),该函数将继续输出 4,因此梯度为零:没有效果。当然,如果我们大幅改变 \\(y\\)(例如大于 2),那么 \\(f\\) 的值就会改变,但导数没有告诉我们如此大的变化对函数输入的影响;它们仅对输入的微小、无限小的变化提供信息,如其定义中的 \\(\lim_{h \rightarrow 0}\\) 所示。

使用链式法则处理复合表达式

现在让我们开始考虑涉及多个组合函数的更复杂的表达式,例如 \\(f(x,y,z) = (x + y) z\\)。这个表达式仍然足够简单,可以直接微分,但我们将采用一种特殊的方法来处理它,这将有助于理解反向传播背后的直觉。特别要注意的是,该表达式可以分为两个表达式:\\(q = x + y\\) 和 \\(f = q z\\)。此外,我们知道如何分别计算两个表达式的导数,如上一节所示。 \\(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\\) 的梯度。 链式法则告诉我们,将这些梯度表达式“链”在一起的正确方法是通过乘法。例如,\\(\frac{\partial f}{\partial x} = \frac{\partial f}{\partial q} \frac{\partial q}{\partial x} \\)。实际上,这只是保存两个梯度的两个数字的乘法。让我们看一个例子:

# 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
dfdz = q # df/dz = q, so gradient on z becomes 3
dfdq = z # df/dq = z, so gradient on q becomes -4
dqdx = 1.0
dqdy = 1.0
# now backprop through q = x + y
dfdx = dfdq * dqdx  # The multiplication here is the chain rule!
dfdy = dfdq * dqdy  

我们留下了变量 [dfdx,dfdy,dfdz] 中的梯度,它告诉我们变量 x,y,z 对 f! 的敏感度。这是反向传播最简单的例子。展望未来,我们将使用更简洁的表示法,省略 df 前缀。例如,我们将简单地写为 dq 而不是 dfdq,并始终假设梯度是在最终输出上计算的。

该计算也可以通过电路图很好地可视化:

-2-4x5-4y-43z3-4q+-121f*
左侧的实值"电路"显示了计算的直观表示。 前向传播计算从输入到输出的值(以绿色显示)。然后,反向传播执行从末尾开始的反向传播,并递归地应用链式法则来计算一直到电路输入的梯度(以红色显示)。梯度可以被认为是通过电路向后流动。

直观理解反向传播

请注意,反向传播是一个完美的局部过程。电路图中的每个门都会获得一些输入,并且可以立即计算两件事:1. 其输出值和 2. 其输出相对于输入的局部梯度。请注意,门可以完全独立地执行此操作,而无需了解它们嵌入的完整电路的任何细节。但是,一旦前向传播结束,在反向传播期间,门最终将了解其输出值在整个电路的最终输出上的梯度。链式法则规定,门应该采用该梯度并将其乘以通常为其所有输入计算的每个梯度。

由于链式法则而产生的这种额外的乘法(对于每个输入)可以将单个且相对无用的门变成复杂电路(例如整个神经网络)中的齿轮。

回到前面的例子,可以更直观地理解这一过程。加法节点接收 [-2, 5],输出 3。由于执行的是加法,输出对两个输入的局部梯度都为 +1。整个计算图的最终输出为 −12。反向传播时,链式法则沿计算图逐步向前面的节点传递:加法节点作为乘法节点的一个输入,接收到输出端梯度 −4。如果把计算图拟人化为“希望最终输出更大”,那么它就会“希望”加法节点的输出减小(负号所表示的方向),变化的强度为 4。为了继续传播梯度,加法节点将传入的 −4 与两个输入各自的局部梯度相乘,因此 x 和 y 的梯度都是 1 × (−4) = −4。这符合预期:如果 x、y 减小(它们的梯度为负,因此这样会增大最终输出),加法节点的输出会减小,继而使乘法节点的输出增大。

因此,反向传播可以被认为是相互通信的门(通过梯度信号)是否希望其输出增加或减少(以及多强烈),以便使最终输出值更高。

模块化计算:以 Sigmoid 为例

上面我们介绍的门是比较随意的。任何类型的可微函数都可以充当门,我们可以将多个门组合成一个门,或者在方便的时候将一个函数分解为多个门。让我们看另一个可以说明这一点的表达式:

$$ f(w,x) = \frac{1}{1+e^{-(w_0x_0 + w_1x_1 + w_2)}} $$

正如我们稍后将在课程中看到的,该表达式描述了一个使用 sigmoid 激活函数的二维神经元(具有输入 x 和权重 w)。但现在让我们将其简单地视为从输入 w,x 到单个数字的函数。该函数由多个门组成。除了上面已经描述的(add、mul、max)之外,还有四个:

$$ f(x) = \frac{1}{x} \hspace{1in} \rightarrow \hspace{1in} \frac{df}{dx} = -1/x^2 \\\\ f_c(x) = c + x \hspace{1in} \rightarrow \hspace{1in} \frac{df}{dx} = 1 \\\\ f(x) = e^x \hspace{1in} \rightarrow \hspace{1in} \frac{df}{dx} = e^x \\\\ f_a(x) = ax \hspace{1in} \rightarrow \hspace{1in} \frac{df}{dx} = a $$

其中函数 \\(f_c, f_a\\) 分别将输入转换为常数 \\(c\\) 并将输入缩放为常数 \\(a\\)。这些是加法和乘法的技术特殊情况,但我们在这里将它们引入为(新的)一元门,因为我们不需要常数 \\(c,a\\) 的梯度。完整电路如下所示:

2.00-0.20w0-1.000.39x0-3.00-0.39w1-2.00-0.59x1-3.000.20w2-2.000.20*6.000.20*4.000.20+1.000.20+-1.00-0.20*-10.37-0.53exp1.37-0.53+10.731.001/x
具有 S 形激活函数的 2D 神经元电路示例。输入为 [x0,x1],神经元的(可学习)权重为 [w0,w1,w2]。正如我们稍后将看到的,神经元与输入计算点积,然后其激活被 sigmoid 函数轻轻压缩到 0 到 1 的范围内。

在上面的示例中,我们看到一长串函数应用程序对 w,x 之间的点积结果进行运算。这些操作实现的函数称为 sigmoid 函数 \\(\sigma(x)\\)。事实证明,如果执行推导,则 sigmoid 函数相对于其输入的导数会简化(在一个有趣的棘手部分之后,我们在分子中添加和减去 1):

$$ \sigma(x) = \frac{1}{1+e^{-x}} \\\\ \rightarrow \hspace{0.3in} \frac{d\sigma(x)}{dx} = \frac{e^{-x}}{(1+e^{-x})^2} = \left( \frac{1 + e^{-x} - 1}{1 + e^{-x}} \right) \left( \frac{1}{1+e^{-x}} \right) = \left( 1 - \sigma(x) \right) \sigma(x) $$

正如我们所看到的,梯度变得简单并且变得令人惊讶的简单。例如,sigmoid 表达式接收输入 1.0 并在前向传播期间计算输出 0.73。上面的推导表明,局部梯度将简单地为 (1 - 0.73) * 0.73 ~= 0.2,正如之前计算的电路一样(参见上图),但这种方式可以通过单个、简单且高效的表达式来完成(并且数值问题较少)。因此,在任何实际应用中,将这些操作分组到单个门中将非常有用。让我们在代码中看看这个神经元的反向传播:

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)) # sigmoid function

# 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] # backprop into w
# we're done! we have the gradients on the inputs to the circuit

实现提示:分阶段反向传播。如上面的代码所示,在实践中,将前向传播分解为易于反向传播的阶段总是有帮助的。例如,在这里我们创建了一个中间变量 dot,它保存 w 和 x 之间的点积的输出。在反向传播过程中,我们依次计算(以相反的顺序)保存这些变量梯度的相应变量(例如 ddot,最终为 dw, dx)。

本节的要点是,反向传播如何执行的细节,以及我们将前向函数的哪些部分视为门,都是为了方便起见。它有助于了解表达式的哪些部分具有简单的局部梯度,以便可以用最少的代码和工作将它们链接在一起。

反向传播实践:分阶段计算

让我们用另一个例子来看看。假设我们有一个以下形式的函数:

$$ f(x,y) = \frac{x + \sigma(y)}{\sigma(x) + (x+y)^2} $$

需要明确的是,这个函数完全没有用,并且不清楚为什么你想要计算它的梯度,除了它是实践中反向传播的一个很好的例子。需要强调的是,如果您要开始执行 \\(x\\) 或 \\(y\\) 的微分,您最终会得到非常大且复杂的表达式。然而,事实证明这样做是完全没有必要的,因为我们不需要写下一个显式的函数来计算梯度。我们只需要知道如何计算它。以下是我们如何构建此类表达式的前向传播:

x = 3 # example values
y = -4

# forward pass
sigy = 1.0 / (1 + math.exp(-y)) # sigmoid in numerator   #(1)
num = x + sigy # numerator                               #(2)
sigx = 1.0 / (1 + math.exp(-x)) # sigmoid in denominator #(3)
xpy = x + y                                              #(4)
xpysqr = xpy**2                                          #(5)
den = sigx + xpysqr # denominator                        #(6)
invden = 1.0 / den                                       #(7)
f = num * invden # done!                                 #(8)

唷,在表达式结束时我们已经计算了前向传播。请注意,我们以包含多个中间变量的方式构建代码,每个中间变量只是我们已经知道局部梯度的简单表达式。因此,计算反向传播过程很容易:我们将向后进行,对于前向过程中的每个变量 (sigy, num, sigx, xpy, xpysqr, den, invden),我们将拥有相同的变量,但以 d 开头,它将保存电路输出相对于该变量的梯度。此外,请注意,反向传播中的每个部分都将涉及计算该表达式的局部梯度,并通过乘法将其与该表达式上的梯度链接起来。对于每一行,我们还突出显示它所指的是前向传播的哪一部分:

# backprop f = num * invden
dnum = invden # gradient on numerator                             #(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 # Notice += !! See notes below  #(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)
# done! phew

请注意以下几点:

缓存前向传播变量。为了计算后向传递,拥有前向传播中使用的一些变量非常有帮助。在实践中,您希望构建代码以便缓存这些变量,以便它们在反向传播期间可用。如果这太困难,则可以(但浪费)重新计算它们。

梯度在分叉处相加。前向表达式多次涉及变量 x,y ,因此当我们执行反向传播时,我们必须小心使用 += 而不是 = 来累积这些变量上的梯度(否则我们会覆盖它)。这遵循微积分中的多变量链式法则,该法则规定,如果一个变量分支到电路的不同部分,那么流回它的梯度将会相加。

梯度反向流动的规律

有趣的是,在许多情况下,向后流动的梯度可以在直观的层面上进行解释。例如,神经网络中最常用的三个门(add、mul、max)对于它们在反向传播期间的行为方式都有非常简单的解释。考虑这个示例电路:

3.00-8.00x-4.006.00y2.002.00z-1.000.00w-12.002.00*2.002.00max-10.002.00+-20.001.00*2
示例电路演示了反向传播在反向传播期间执行的操作背后的直觉,以计算输入上的梯度。求和运算将梯度平均分配给所有输入。最大操作将梯度路由到更高的输入。乘法门获取输入激活,交换它们并乘以其梯度。

以上图为例,我们可以看到:

添加门始终在其输出上获取梯度并将其平均分配给所有输入,无论它们在前向传播期间的值是什么。这是因为加法运算的局部梯度只是 +1.0,因此所有输入上的梯度将完全等于输出上的梯度,因为它将乘以 x1.0(并保持不变)。在上面的示例电路中,请注意 + 门将 2.00 的梯度同等且未改变地路由到其两个输入。

最大门路由梯度。与将梯度不变地分配给所有输入的加法门不同,最大门将梯度(不变)分配给其输入之一(在前向传播期间具有最高值的输入)。这是因为最大门的局部梯度对于最高值是 1.0,对于所有其他值是 0.0。在上面的示例电路中,max 操作将 2.00 的梯度路由到 z 变量,该变量的值高于 w,并且 w 上的梯度保持为零。

乘法节点稍难直观理解:输出对某个输入的局部梯度,等于另一个输入的值。反向传播时,再将这个局部梯度与输出端传来的梯度相乘。上例中,x 的梯度为 −8.00,即 −4.00 × 2.00。

不直观的影响及其后果。请注意,如果乘法门的一个输入非常小而另一个输入非常大,那么乘法门会做一些有点不直观的事情:它将为小输入分配一个相对较大的梯度,为大输入分配一个微小的梯度。请注意,在权重与输入进行点积 \\(w^Tx_i\\)(相乘)的线性分类器中,这意味着数据的规模对权重的梯度大小有影响。例如,如果您在预处理期间将所有输入数据示例 \\(x_i\\) 乘以 1000,则权重上的梯度将增大 1000 倍,并且您必须将学习率降低该因子进行补偿。这就是为什么预处理非常重要,有时甚至以微妙的方式!直观地了解梯度如何流动可以帮助您调试其中一些情况。

向量化运算的梯度

上面的部分涉及单个变量,但所有概念都以直接的方式扩展到矩阵和向量运算。然而,必须更加关注尺寸和转置操作。

矩阵-矩阵乘法梯度。最棘手的运算可能是矩阵-矩阵乘法(它概括了所有矩阵-向量和向量-向量)乘法运算:

# 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 的大小为 [10 x 3],dD 的大小为 [5 x 3],因此,如果我们想要 dW 且 W 的形状为 [5 x 10],那么实现此目的的唯一方法是使用 dD.dot(X.T),如上所示。

使用小而明确的示例。有些人可能会发现一开始很难导出某些向量化表达式的梯度更新。我们的建议是明确写出一个最小的向量化示例,在纸上导出梯度,然后将模式推广到其高效的向量化形式。

Erik Learned-Miller 还写了一篇关于矩阵/向量导数的较长相关文档,您可能会发现它很有帮助。 在这里找到它。

小结

  • 我们对梯度的含义、它们如何在电路中向后流动、它们如何传达电路的哪一部分应该增加或减少以及用什么力量使最终输出更高产生了直觉。
  • 我们讨论了分阶段计算对于反向传播实际实现的重要性。您总是希望将函数分解为可以轻松导出局部梯度的模块,然后用链式法则将它们链接起来。至关重要的是,您几乎不想在纸上写下这些表达式并以符号方式对它们进行完全微分,因为您永远不需要输入变量梯度的显式数学方程。因此,将表达式分解为多个阶段,以便可以独立区分每个阶段(这些阶段将是矩阵向量乘法、或最大运算、或求和运算等),然后一次一步地通过变量进行反向传播。

在下一节中,我们将开始定义神经网络,反向传播将使我们能够有效地计算损失函数相对于其参数的梯度。换句话说,我们现在已经准备好训练神经网络,并且本课程中概念上最困难的部分已经过去了!那么,ConvNets 就离我们只有一小步之遥了。

参考资料