最近把CS231n关于反向传播这一节的官方笔记看了一下。这篇算是用中文把原文复述了一遍,中间一些地方加入了我自己的理解,很多地方的表述参考了 CS231n课程笔记翻译:反向传播笔记
Introduction
动机
这篇笔记介绍了一些有助于在直觉上理解反向传播(BP)的知识。反向传播是一种通过递归应用链式法则(chain rule)来计算表达式梯度的方法。理解反向传播过程及其精妙之处,对于理解、实现、设计和调试神经网络非常关键。
问题描述
这节要讨论的核心问题是:给定函数
f(x)
,
x
是一个输入向量,我们感兴趣的问题是计算
动机
回想起来,我们关注这个问题的主要原因是在神经网络中我们有损失函数(Loss function)
L
,输入
如果你之前对用chain rule推导偏微分已经很熟悉了,我们仍然建议你至少浏览一下这篇笔记,因为它呈现了一个相对成熟的反向传播视角,在该视角中能看见基于实数值链路的反向传播过程,而对其细节的理解和收获将帮助读者更好地通过本课程。
Simple expressions and interpretation of the gradient(简单表达式,梯度的解释)
先从简单的开始,这样我们就能把符号和规则推广到比较复杂的表达式了。先考虑一个简单的二元乘法函数 f(x,y) 。对两个输入变量分别求偏导数还是很简单的:
解释:牢记这些导数的意义:函数的自变量在某个点周围的极小区域内变化,而导数就是自变量变化导致的函数在该方向上的变化率:
注意上式等号左边的分号和等号右边的分号不同,它不是代表除法。这个符号表示操作符
ddx
被作用于函数
f
,并返回一个不同的函数(导数)。对于上述公式,可以认为当
比如,
x=4,y=−3,f(x,y)=−12
,
x
的导数
函数关于每个变量的导数指明了整个表达式对于该变量的敏感程度。
如上所述,梯度 ∇f 是偏导数的向量,所以有 ∇f(x)=[∂f∂x,∂f∂y]=[y,x] . 即便是梯度实际上是一个向量,但为了简单起见,我们仍然使用类似“x上的梯度”的术语,而不是使用如“x的偏导数”的正确说法。
我们也可以对加法操作求导:
还有一个常用的函数,最大值函数:
Compound expressions with chain rule(复杂表达式,链式法则)
现在考虑更复杂的包含多个函数的复合函数,比如
f(x,y,z)=(x+y)z
. 虽然这个表达式仍然足够简单,可以直接微分,但我们这里用一种特别的方法来处理它,有助于读者直观理解BP. 将这个表达式分成两部分
q=x+y
和
f=qz
. 我们已经知道如何对这两个分开的表示进行求导。
f
是
# 设置输入值
x = -2; y = 5; z = -4
# 进行前向传播
q = x + y # q becomes 3
f = q * z # f becomes -12
# 进行反向传播:
# 首先回传到 f = q * z
dfdz = q # df/dz = q, 所以关于z的梯度是3
dfdq = z # df/dq = z, 所以关于q的梯度是-4
# 现在回传到q = x + y
dfdx = 1.0 * dfdq # dq/dx = 1. 这里的乘法是因为链式法则
dfdy = 1.0 * dfdq # dq/dy = 1
最后得到变量的梯度[dfdx,dfdy,dfdz]
, 它告诉我们函数f
对于变量[x,y,z]
的敏感程度。这就是关于BP的一个最简单的例子。为了简洁,以后我们在表示梯度的时候都会把df
这部分略去,比如说,dfdq
我们会简写成dq
,而且我们总是假设梯度是关于最终输出的。
上述例子可以被可视化为以下计算链路图:
如上如图所示,该实值计算链路图呈现了本次计算的过程。FP是由输入值计算输出值(用绿色表示);BP则从尾端开始,根据链式法则递归地计算梯度(用红色表示),一直到网络的输入端。可以认为,梯度在从计算链路中往回流动。
Intuitive understanding of backpropagation
反向传播是一个优美的局部过程。在整个计算链路中,每个门单元都会得到一些输入并立即计算两个东西:1. 这个门的输出值,和2.其输出值关于输入值的局部梯度。门单元完成这两件事是完全独立的,它不需要知道计算链路中的其他细节。一旦FP完成,在BP过程中,每个门单元将最终获得整个网络的最终输出值在自己的输出值上的梯度(上图的红色部分)。链式法则指出,每个门单元应该将回传过来的梯度乘以它事先计算出来的其输出关于其输入的局部梯度(也就是上述要计算的第2件事的结果)。
这里对于每个输入的乘法操作是基于链式法则的。该操作让一个相对独立的、没什么用的门单元变成复杂计算线路中不可或缺的一部分,这个复杂计算线路可以是神经网络等。
下面我们重新过一遍这个例子,直观的理解这个计算过程。加法门收到了输入[-2, 5],计算输出是3。既然这个门是加法操作,那么对于两个输入的局部梯度都是+1。FP结束后,网络的最终输出为-12。BP开始,递归地使用链式法则,算到加法门(是乘法门的输入)的时候,知道加法门的输出的梯度是-4。如果网络想要最终输出值更高,那么可以认为它会想要加法门的输出更小一点(因为负号),而且还有一个4的倍数。继续递归并对梯度使用链式法则,加法门拿到梯度,然后把这个梯度分别乘到每个输入值的局部梯度(就是让-4乘以x和y的局部梯度,x和y的局部梯度都是1,所以最终都是-4)。可以看到得到了想要的效果:如果x,y减小(它们的梯度为负),那么加法门的输出值减小,这会让乘法门的输出值增大。
BP可是被看做是门单元之间通过梯度信号进行通信,只要让门单元的输入沿着梯度方向变化(正梯度就增大,负梯度就减小),那么不论这个门单元自己的输出是增大了还是减小了,网络的最终输出都会增大。(所以梯度下降法要求自变量逆梯度方向增长)
Modularity: Sigmoid example(模块化)
我们上面介绍的门都是比较随意的。任何可微的函数都可以看作一个门。可以将多个门组合成一个门,也可以根据需要将一个函数分拆成多个门。现在看看一个表达式:
这个表达式描述了一个含输入
x
和权重
其中,函数
fc
使用对输入值进行了常量的平移,
fa
将输入值扩大了常量
倍。它们是加法和乘法的特例,但是这里仍将它们看作一元门单元,因为确实需要计算常量
c,a
的梯度。整个计算链路图如下:
sigmoid激活函数的2维神经元的计算链路图。输入是 [x0,x1] , (可学习的)权重是 [w0,w1,w2] . 下面我们会看到,该神经元对输入做点积运算,然后其激活数据被sigmoid函数挤压到0到1之间。
在上图中可以看见一个函数操作的长链条,链条上的门都对w和x的点积结果进行操作。该函数被称为sigmoid函数 σ(x) . sigmoid函数关于其输入的求导是可以简化的(使用了在分子上先加后减1的技巧):
可以看出,梯度计算简化了许多。举个例子,sigmoid函数接收到输入1.0并且在FP结束后计算出输出0.73. 根据上面的公式,局部梯度为(1 - 0.73) * 0.73 ~= 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
# 完成!得到输入的梯度
实现技巧:分段反向传播。上面的代码展示了在实际操作中,为了使BP过程更加简洁,把FP分成不同的阶段是很有帮助的。比如说这里我们创建了一个中间变量dot
来装w
和x
的点积结果。在BP过程中,就可以(反向地)陆续计算出和这些中间变量相对应的梯度(比如ddot
是对应着中间变量dot
的梯度,最后计算出dw,dx
)。
这一部分的要点就是展示BP的细节过程,以及FP过程中,哪些函数可以被组合成门,从而可以进行简化。知道表达式中哪部分的局部梯度计算比较简洁非常有用,这样他们可以“链”在一起,让代码量更少,效率更高。
Backprop in practice: Staged computation(BP实践,分段计算)
让我们看另外一个例子,假设有如下函数:
首先要说的是,这个函数完全没用,你是不会用到它来进行梯度计算的,这里只是用来作为实践BP的一个例子。需要强调的是,如果你直接对
x
或者
# 输入样本值
x = 3
y = -4
# 开始FP
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 # FP完成 #(8)
啧啧,在表达式的最后我们完成了FP. 注意,我们在构建代码时创建了多个中间变量,每个都是比较简单的表达式,它们计算局部梯度的方法是已知的。因此,计算BP就简单了:我们对FP时产生每个变量sigy, num, sigx, xpy, xpysqr, den, invden
进行回传,我们会有同样数量的变量,但是都以d
开头,用来存储整个计算链路的最终输出关于对应变量的梯度。此外,注意在BP代码中每一块都包含了计算表达式的局部梯度,然后根据链式法则乘以上游回传过来的梯度。下面所示的每行代码我们都注明了它对应的是BP的哪一部分:
# 回传 f = num * invden
dnum = invden # 分子的梯度 #(8)
dinvden = num #(8)
# 回传 invden = 1.0 / den
dden = (-1.0 / (den**2)) * dinvden #(7)
# 回传 den = sigx + xpysqr
dsigx = (1) * dden #(6)
dxpysqr = (1) * dden #(6)
# 回传 xpysqr = xpy**2
dxpy = (2 * xpy) * dxpysqr #(5)
# 回传 xpy = x + y
dx = (1) * dxpy #(4)
dy = (1) * dxpy #(4)
# 回传 sigx = 1.0 / (1 + math.exp(-x))
dx += ((1 - sigx) * sigx) * dsigx # Notice += !! See notes below #(3)
# 回传 num = x + sigy
dx += (1) * dnum #(2)
dsigy = (1) * dnum #(2)
# 回传 sigy = 1.0 / (1 + math.exp(-y))
dy += ((1 - sigy) * sigy) * dsigy #(1)
# 完成!
将这个例子可视化为计算链路图:
可视化后的计算链路图,绿色为每个门单元的输出,红色为梯度。梯度 = (门输出对其输入的局部梯度)*(回传过来的梯度)
需要注意的两点:
- 对前向传播变量进行缓存:在BP过程中,FP过程中得到的一些中间变量非常有用。在实际操作中,最在代码中实现对于这些中间变量的缓存,这样在BP的时候也能用上它们。如果这样做过于困难,也可以(但是浪费计算资源)重新计算它们。
- 在不同分支的梯度要相加:如果变量x,y在前向传播的表达式中出现多次,那么进行反向传播的时候就要非常小心,使用+=而不是=来累计这些变量的梯度(不然就会造成覆盖)。这是遵循了在微积分中的多元链式法则,该法则指出如果变量在线路中分支走向不同的部分,那么梯度在回传的时候,就应该进行累加。(比如上图中的输入 x,y 的梯度,是由不同分支回传回来的梯度相加得到的)
Patterns in backward flow(回传流中的模式)
一个有趣的现象是在多数情况下,反向传播中的梯度可以被很直观地解释。例如神经网络中最常用的加法、乘法和取最大值这三个门单元,它们在反向传播过程中的行为都有非常简单的解释。先看下面这个例子:
一个展示反向传播的例子。加法操作将回传过来的梯度相等地分发给它的所有输入。取最大操作将回传梯度传给更大的输入。乘法门拿取输入激活数据,对它们进行交换,然后乘以回传梯度。
把上图作为例子进行观察我们可以发现:
- 加法门单元把输出的梯度(回传梯度)相等地分发给它所有的输入,这一行为与输入值在FP时的值无关。这是因为加法操作的局部梯度都是简单的+1,所以所有输入的梯度实际上就等于输出的梯度,因为乘以1.0保持不变。上例中,加法门把梯度2.00不变且相等地路由给了两个输入。
- 取最大值门单元对梯度做路由。和加法门不同,取最大值门将梯度转给其中一个输入,这个输入是在FP中值最大的那个输入。这是因为在取最大值门中,最大值的局部梯度是1.0,其余的是0。上例中,取最大值门将梯度2.00转给了z变量,因为z的值比w高,于是w的梯度保持为0。
- 乘法门单元相对不那么容易解释。它的局部梯度就是它的输入值,但是是经过交换之后的,然后根据链式法则乘以它输出值的梯度(回传过来的梯度)。
不直观的影响及其后果:如果乘法门的一个输入非常小,而另一个输入非常大,那么乘法门将会进行一些不是那么直观的操作:它将会把一个很大的梯度分配给小的输入,把一个很小的梯度分配给大的输入。需要注意的是在线性分类器(权值和输入做点积 wTxi )中,这意味着输入数据的大小对权值的梯度是有影响的()。比如,在计算过程中将所有输入样本 xi 乘以1000,那么权重的梯度将会增大1000倍,这样就必须以降低同样倍数学习率来弥补。这就是数据预处理为什么重要,有时候数据的微小变化也会造成不小的影响!在了解了梯度在计算链路中是怎样流动的有了一个直观的理解之后,读者可以更好的调试网络。
Gradients for vectorized operations(向量化操作中的梯度)
上述内容考虑的都是单个变量情况,但是所有概念都适用于矩阵和向量操作。然而,在操作的时候要注意关注维度和转置操作。
矩阵和矩阵相乘的梯度:最需要留心的操作可能就是矩阵和矩阵相乘的操作了(这些操作也适用于矩阵-向量、向量-向量相乘):
# FP
W = np.random.randn(5, 10)
X = np.random.randn(10, 3)
D = W.dot(X)
# 假设我们得到了 D 的梯度
dD = np.random.randn(*D.shape) # 和D相同的尺寸
dW = dD.dot(X.T) # X.T就是对矩阵X进行转置
dX = W.T.dot(dD)
提示:要去分析维度!不需要去记忆dW
和dX
的表达,因为它们很容易通过维度推导出来。比如,我们知道权值的梯度dW
一定是和权值矩阵W
的尺寸是一样的,而这个形状又是由X
和dD
的相乘决定的(上面的例子中X,W
都是一个数字而不是矩阵)。总有一个方式是能够让维度之间能够对的上的。例如,X
的尺寸是10x3,dD
的尺寸是5x3,如果你想要dW
和W
的尺寸是5x10,那就要dD.dot(X.T)
.
用小而具体的例子:有些读者可能觉得向量化的表达式的计算其梯度比较困难,建议是写出一个很小很明确的向量化例子,在纸上演算梯度,然后对其一般化,得到一个高效的向量化操作形式。
Summary
- 我们对梯度的含义建立起了直观的理解,知道了梯度是如何在网络中反向传播的,知道了它们是如何与网络的不同部分通信并控制其升高或者降低,并使得最终输出值更高的。
- 我们讨论了在反向传播实际实现的时候,分段计算的重要性。把一个复杂的函数不同的模块,每个模块都很容易推导其局部梯度,然后通过链式法则将它们“链”起来。关键是,你不需要把这些表达式写在在纸上,然后一次性的推导出所有变量的梯度公式,因为实际上我们并不需要这些关于输入的梯度的确切的数学公式。因此,只需要将表达式分成不同的可以求导的模块(模块可以是矩、向量的乘法操作,或者取最大值操作,或者加法操作等),然后在反向传播中一步一步地计算梯度。