在上一章,我们看到了神经网络如何使用梯度下降算法来学习他们自身的权重和偏差。但是,这里还留下了一个问题:我们并没有讨论如何计算代价函数的梯度。这是很大的缺失!在本章,我们会解释计算这些梯度的快速算法,也就是反向传播。
反向传播算法最初在 1970 年代被发现,但是这个算法的重要性直到 David Rumelhart、Geoffrey Hinton 和 Ronald Williams 的 1986年的论文 中才被真正认可。这篇论文描述了对一些神经网络反向传播要比传统的方法更快,这使得使用神经网络来解决之前无法完成的问题变得可行。现在,反向传播算法已经是神经网络学习的重要组成部分了。
本章在全书的范围内要比其他章节包含更多的数学内容。如果你不是对数学特别感兴趣,那么可以跳过本章,将反向传播当成一个黑盒,忽略其中的细节。那么为何要研究这些细节呢?
答案当然是理解。反向传播的核心是对代价函数
如上面所说,如果你想要粗览本章,或者直接跳到下一章,都是可以的。剩下的内容即使你是把反向传播看做黑盒也是可以掌握的。当然,后面章节中也会有部分内容涉及本章的结论,所以会常常给出本章的参考。不过对这些知识点,就算你对推导的细节不太清楚你还是应该要理解主要的结论的。
在讨论反向传播前,我们先熟悉一下基于矩阵的算法来计算网络的输出。事实上,我们在上一章的最后已经能够看到这个算法了,但是我在那里很快地略过了,所以现在让我们仔细讨论一下。特别地,这样能够用相似的场景帮助我们熟悉在反向传播中使用的矩阵表示。
我们首先给出网络中权重的清晰定义。我们使用
这样的表示粗看比较奇怪,需要花一点时间消化。但是,后面你会发现这样的表示会比较方便也很自然。奇怪的一点其实是下标
我们对网络偏差和激活值也会使用类似的表示。显式地,我们使用
有了这些表示,
其中求和是在
最后我们需要引入向量化函数(如
也就是说,向量化的
了解了这些表示,方程(23)就可以写成下面的这种美妙而简洁的向量形式了:
这个表达式给出了一种更加全局的思考每层的激活值和前一层的关联方式:我们仅仅用权重矩阵作用在激活值上,然后加上一个偏差向量,最后作用
其实,这就是让我们使用之前的矩阵下标
$$w_{jk}^l$$ 表示的初因。如果我们使用$$j$$ 来索引输入神经元,$$k$$ 索引输出神经元,那么在方程(25)中我们需要将这里的矩阵换做其转置。这只是一个小小的困惑的改变,这会使得我们无法自然地讲出(思考)“应用权重矩阵到激活值上”这样的简单的表达。
这种全局的观点相比神经元层面的观点常常更加简明(没有更多的索引下标了!)其实可以看做是在保留清晰认识的前提下逃离下标困境的方法。在实践中,表达式同样很有用,因为大多数矩阵库提供了实现矩阵乘法、向量加法和向量化的快速方法。实际上,上一章的代码其实已经隐式使用了使用这种表达式来计算网络行为。
在使用方程(25)计算
反向传播的目标是计算代价函数
其中
好了,为了应用反向传播,我们需要对代价函数做出什么样的前提假设呢?第一个假设就是代价函数可以被写成一个 在每个训练样本
需要这个假设的原因是反向传播实际上是对一个独立的训练样本计算了
第二个假设就是代价可以写成神经网络输出的函数:
例如,二次代价函数满足这个要求,因为对于一个单独的训练样本
这是输出的激活值的函数。当然,这个代价函数同样还依赖于目标输出
反向传播算法基于常规的线性代数运算——诸如向量加法,向量矩阵乘法等。但是有一个运算不大常见。特别地,假设
这种类型的按元素乘法有时候被称为 Hadamard 乘积或者 Schur 乘积。我们这里取前者。好的矩阵库通常会提供 Hadamard 乘积的快速实现,在实现反向传播的时候用起来很方便。
反向传播其实是对权重和偏差变化影响代价函数过程的理解。最终极的含义其实就是计算偏导数
反向传播将给出计算误差
为了理解误差是如何定义的,假设在神经网络上有一个恶魔:
这个小精灵在
现在,这个精灵变好了,试着帮助你来优化代价函数,他们试着找到可以让代价更小的
这里需要注意的是,只有在
$$\Delta z_j^l$$ 很小的时候才能够满足。我们需要假设小精灵只能进行微小的调整。
所以这里有一种启发式的认识,$$\frac{\partial C}{\partial z_j^l}$$ 是神经元的误差的度量。
按照上面的描述,我们定义
按照我们通常的惯例,我们使用
你可能会想知道为何精灵在改变带权输入
在分类问题中,误差有时候会用作分类的错误率。如果神经网络正确分类了 96.0% 的数字,那么其误差是 4.0%。很明显,这和我们上面提及的误差的差别非常大了。在实际应用中,区分这两种含义是非常容易的。
解决方案:反向传播基于四个基本方程。这些方程给我们一种计算误差和代价函数梯度的方法。我列出这四个方程。但是需要注意:你不需要一下子能够同时理解这些公式。因为过于庞大的期望可能会导致失望。实际上,反向传播方程内容很多,完全理解这些需要花费充分的时间和耐心,需要一步一步地深入理解。而好的消息是,这样的付出回报巨大。所以本节对这些内容的讨论仅仅是一个帮助你正确掌握这些公式的起步。
下面简要介绍我们的探讨这些公式的计划:首先给出这些公式的简短证明以解释他们的正确性;然后以伪代码的方式给出这些公式的算法形式,并展示这些伪代码如何转化成真实的可执行的 python 代码;在本章的最后,我们会发展处一个关于反向传播公式含义的直觉图景,以及人们如何能够从零开始发现这个规律。按照此法,我们会不断地提及这四个基本方程,随着你对这些方程理解的加深,他们会看起来更加舒服,甚至是美妙和自然的。
输出层误差的方程,$$\delta^L$$:每个元素定义如下:
这是一个非常自然的表达式。右式第一个项
注意到在 BP1 中的每个部分都是很好计算的。特别地,我们在前向传播计算网络行为时已经计算过
方程(BP1)对
这里
如你所见,这个方程中的每个项都有一个很好的向量形式,所以也可以很方便地使用像 Numpy 这样的矩阵库进行计算了。
使用下一层的误差
其中
通过组合(BP1)和(BP2),我们可以计算任何层的误差了。首先使用(BP1)计算$$\delta^l$$,然后应用方程(BP2)来计算$$\delta^{L-1}$$,然后不断作用(BP2),一步一步地反向传播完整个网络。
代价函数关于网络中任意偏差的改变率:就是
这其实是,误差
其中
代价函数关于任何一个权重的改变率:特别地,
这告诉我们如何计算偏导数
其中
方程(32)的一个结论就是当激活值很小,梯度
这四个公式同样还有很多观察。让我们看看(BP1)中的项
针对前面的层,我们也有类似的观点。特别地,注意在(BP2)中的项
如果
$$(w^{l+1})^T \delta^{l+1}$$ 拥有足够大的量能够补偿$$\sigma'(z_k^l)$$ 的话,这里的推导就不能成立了。但是我们上面是常见的情形。
总结一下,我们已经学习到权重学习缓慢如果输入神经元激活值很低,或者输出神经元已经饱和了(过高或者过低的激活值)。
这些观测其实也是不非常令人惊奇的。不过,他们帮助我们完善了关于神经网络学习的背后的思维模型。而且,我们可以将这种推断方式进行推广。四个基本方程也其实对任何的激活函数都是成立的(证明中也可以看到,其实推断本身不依赖于任何具体的代价函数)所以,我们可以使用这些方程来设计有特定属性的激活函数。我们这里给个例子,假设我们准备选择一个(non-sigmoid)的激活函数
另一种反向传播方程的表示方式:我已经给出了使用了 Hadamard 乘积的反向传播的公式。如果你对这种特殊的乘积不熟悉,可能会有一些困惑。下面还有一种表示方式,那就是基于传统的矩阵乘法,某些读者可能会觉得很有启发。(1) 证明 (BP1) 可以写成
其中
(3) 结合(1)和(2) 证明
对那些习惯于这种形式的矩阵乘法的读者,(BP1) (BP2) 应该更加容易理解。而我们坚持使用 Hadamard 乘积的原因在于其更快的数值实现。
我们现在证明这四个方程。所有这些都是多元微积分的链式法则的推论。如果你熟悉链式法则,那么我鼓励你在读之前自己证明一番。
- 证明方程 (BP3) 和 (BP4)
这样我们就完成了反向传播四个基本公式的证明。证明本身看起来复杂。但是实际上就是细心地应用链式法则。我们可以将反向传播看成是一种系统性地应用多元微积分中的链式法则来计算代价函数的梯度的方式。这些就是反向传播理论上的内容——剩下的是实现细节。
反向传播方程给出了一种计算代价函数梯度的方法。让我们显式地用算法描述出来:
-
输入
$$x$$ :为输入层设置对应的激活值$$a^l$$ 。 -
前向传播:对每个
$$l=2,3,...,L$$ 计算相应的$$z^l = w^la^{l-1} + b^l$$ 和$$a^l = \sigma(z^l)$$ -
输出层误差
$$\delta^L$$ :计算向量$$\delta^L = \nabla_a C \odot \sigma'(z^L)$$ -
反向误差传播:对每个
$$l=L-1, L-2,...,2$$ ,计算$$\delta^l = ((w^{l+1})^T\delta^{l+1})\odot \sigma'(z^l)$$ -
输出:代价函数的梯度由
$$\frac{C}{w_{jk}^l} = a_k^{l-1}\delta_j^j$$ 和$$\frac{\partial C}{\partial b_j^l} = \delta_j^l$$
看看这个算法,你可以看到为何它被称作反向传播。我们从最后一层开始向后计算误差向量
-
使用单个修正的神经元的反向传播
假设我们改变一个前向传播网络中的单个神经元,使得那个神经元的输出是
$$f(\sum_j w_jx_j + b)$$ ,其中$$f$$ 是和 sigmoid 函数不同的某一函数。我们如何调整反向传播算法? -
线性神经元上的反向传播
假设我们将非线性神经元替换为
$$\sigma(z) = z$$ 。重写反向传播算法。
正如我们上面所讲的,反向传播算法对一个训练样本计算代价函数的梯度,$$C=C_x$$。在实践中,通常将反向传播算法和诸如随机梯度下降这样的学习算法进行组合使用,我们会对许多训练样本计算对应的梯度。特别地,给定一个大小为
- 输入训练样本的集合
- 对每个训练样本
$$x$$ :设置对应的输入激活$$a^{x,1}$$ ,并执行下面的步骤:
- 前向传播:对每个
$$l=2,3,...,L$$ 计算$$z^{x,l} = w^la^{x,l-1} + b^l$$ 和$$a^{x,l} = \sigma(z^{x,l})$$ - 输出误差
$$\delta^{x,L}$$ :计算向量$$\delta^{x,L} = \nabla_a C_x \odot \sigma'(z^{x,L})$$ - 反向传播误差:对每个
$$l=L-1, L-2, ..., 2$$ 计算$$\delta^{x,l} = ((w^{l+1})^T\delta^{x,l+1})\odot \sigma'(z^{x,l})$$
- 梯度下降:对每个
$$l=L-1, L-2, ..., 2$$ 根据$$w^l \rightarrow w^l - \frac{\eta}{m}\sum_x \delta^{x,l}(a^{x,l-1})^T$$ 和$$b^l \rightarrow b^l - \frac{\eta}{m}\sum_x \delta^{x,l}$$ 更新权重和偏差
当然,在实践中实现随机梯度下降,我们还需要一个产生训练样本 minibatch 的循环,还有就是训练次数的循环。这里我们先省略了。
理解了抽象的反向传播的理论知识,我们现在就可以学习上一章中使用的实现反向传播的代码了。回想上一章的代码,需要研究的是在 Network
类中的 update_mini_batch
和 backprop
方法。这些方法的代码其实是我们上面的算法描述的直接翻版。特别地,update_mini_batch
方法通过计算当前 mini_batch
中的训练样本对 Network
的权重和偏差进行了更新:
class Network(object):
...
def update_mini_batch(self, mini_batch, eta):
"""Update the network's weights and biases by applying
gradient descent using backpropagation to a single mini batch.
The "mini_batch" is a list of tuples "(x, y)", and "eta"
is the learning rate."""
nabla_b = [np.zeros(b.shape) for b in self.biases]
nabla_w = [np.zeros(w.shape) for w in self.weights]
for x, y in mini_batch:
delta_nabla_b, delta_nabla_w = self.backprop(x, y)
nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)]
nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]
self.weights = [w-(eta/len(mini_batch))*nw
for w, nw in zip(self.weights, nabla_w)]
self.biases = [b-(eta/len(mini_batch))*nb
for b, nb in zip(self.biases, nabla_b)]
主要工作其实是在 delta_nabla_b, delta_nabla_w = self.backprop(x, y)
这里完成的,调用了 backprop
方法计算出了偏导数,$$\partial C_x/\partial b_j^l$$ 和 l[-3]
其实是列表中的倒数第三个元素。下面 backprop
的代码,使用了一些用来计算
class Network(object):
...
def backprop(self, x, y):
"""Return a tuple "(nabla_b, nabla_w)" representing the
gradient for the cost function C_x. "nabla_b" and
"nabla_w" are layer-by-layer lists of numpy arrays, similar
to "self.biases" and "self.weights"."""
nabla_b = [np.zeros(b.shape) for b in self.biases]
nabla_w = [np.zeros(w.shape) for w in self.weights]
# feedforward
activation = x
activations = [x] # list to store all the activations, layer by layer
zs = [] # list to store all the z vectors, layer by layer
for b, w in zip(self.biases, self.weights):
z = np.dot(w, activation)+b
zs.append(z)
activation = sigmoid(z)
activations.append(activation)
# backward pass
delta = self.cost_derivative(activations[-1], y) * \
sigmoid_prime(zs[-1])
nabla_b[-1] = delta
nabla_w[-1] = np.dot(delta, activations[-2].transpose())
# Note that the variable l in the loop below is used a little
# differently to the notation in Chapter 2 of the book. Here,
# l = 1 means the last layer of neurons, l = 2 is the
# second-last layer, and so on. It's a renumbering of the
# scheme in the book, used here to take advantage of the fact
# that Python can use negative indices in lists.
for l in xrange(2, self.num_layers):
z = zs[-l]
sp = sigmoid_prime(z)
delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
nabla_b[-l] = delta
nabla_w[-l] = np.dot(delta, activations[-l-1].transpose())
return (nabla_b, nabla_w)
...
def cost_derivative(self, output_activations, y):
"""Return the vector of partial derivatives \partial C_x /
\partial a for the output activations."""
return (output_activations-y)
def sigmoid(z):
"""The sigmoid function."""
return 1.0/(1.0+np.exp(-z))
def sigmoid_prime(z):
"""Derivative of the sigmoid function."""
return sigmoid(z)*(1-sigmoid(z))
- 在 minibatch 上的反向传播的全矩阵方法
我们对于随机梯度下降的实现是对一个 minibatch 中的训练样本进行遍历。所以也可以更改反向传播算法使得它同时对一个 minibatch 中的所有样本进行梯度计算。这个想法其实就是我们可以用一个矩阵
$$X=[x_1, x_2, ..., x_m]$$ ,其中每列就是在minibatch 中的向量,而不是单个的输入向量,$$x$$。我们通过乘权重矩阵,加上对应的偏差进行前向传播,在所有地方应用 sigmoid 函数。然后按照类似的过程进行反向传播。请显式写出这种方法下的伪代码。更改network.py
来实现这个方案。这样做的好处其实利用到了现代的线性代数库。所以,这会比在 minibatch 上进行遍历要运行得更快(在我的笔记本电脑上,在 MNIST 分类问题上,我相较于上一章的实现获得了 2 倍的速度提升)。在实际应用中,所有靠谱的反向传播的库都是用了类似的基于矩阵或者变体的方式来实现的。
为了回答这个问题,首先考虑另一个计算梯度的方法。就当我们回到上世界50、60年代的神经网络研究。假设你是世界上首个考虑使用梯度下降方法学习的那位!为了让自己的想法可行,就必须找出计算代价函数梯度的方法。想想自己学到的微积分,决定试试看链式法则来计算梯度。但玩了一会后,就发现代数式看起来非常复杂,然后就退缩了。所以就试着找另外的方式。你决定仅仅把代价看做权重
其中
这个观点看起来非常有希望。概念上易懂,容易实现,使用几行代码就可以搞定。看起来,这样的方法要比使用链式法则还要有效。
然后,遗憾的是,当你实现了之后,运行起来这样的方法非常缓慢。为了理解原因,假设我们有
反向传播聪明的地方就是它确保我们可以同时计算所有的偏导数
这个说法是合理的,但需要额外的说明来澄清这一事实。在前向传播过程中主要的计算代价消耗在权重矩阵的乘法上,而反向传播则是计算权重矩阵的转置矩阵。这些操作显然有着类似的计算代价。
所以最终的计算代价大概是两倍的前向传播计算大家。比起直接计算导数,显然 反向传播 有着更大的优势。所以即使 反向传播 看起来要比 (46) 更加复杂,但实际上要更快。
这个加速在1986年首次被众人接受,并直接导致神经网络可以处理的问题的扩展。这也导致了大量的研究者涌向了神经网络方向。当然,反向传播 并不是万能钥匙。在 1980 年代后期,人们尝试挑战极限,尤其是尝试使用反向传播来训练深度神经网络。本书后面,我们将看到现代计算机和一些聪明的新想法已经让 反向传播 成功地训练这样的深度神经网络。
正如我所讲解的,反向传播 提出了两个神秘的问题。首先,这个算法真正在干什么?我们已经感受到从输出处的错误被反向传回的图景。但是我们能够更深入一些,构造出一种更加深刻的直觉来解释所有这些矩阵和向量乘法么?第二神秘点就是,某人为什么能发现这个 反向传播?跟着一个算法跑一遍甚至能够理解证明算法可以运行这是一回事。这并不真的意味着你理解了这个问题到一定程度,能够发现这个算法。是否有一个推理的思路可以指引我们发现 反向传播 算法?本节,我们来探讨一下这两个谜题。
为了提升我们关于算法究竟做了什么的直觉,假设我们已经对
这个改变会导致在输出激活值上的相应改变:
然后,会产生对下一层激活值的改变:
接着,这些改变都将影响到一个个下一层,到达输出层,最终影响代价函数:
所以代价函数
这给出了一种可能的计算
我们尝试一下这个方法。$$\Delta w_{jk}^l$$ 导致了在
实际上,这会导致下面的变化:
将其代入方程(48),我们得到:
当然,这个变化又会去下一层的激活值。实际上,我们可以想象出一条从
我们已经对每个经过的神经元设置了一个
这里我们对路径中所有可能的中间神经元选择进行求和。对比 (47) 我们有
现在公式(53)看起来相当复杂。但是,这里其实有一个相当好的直觉上的解释。我们用这个公式计算
我们到现在所给出的东西其实是一种启发式的观点,一种思考权重变化对网络行为影响的方式。让我们给出关于这个观点应用的一些流程建议。首先,你可以推导出公式(53)中所有单独的的偏导数显式表达式。只是一些微积分的运算。完成这些后,你可以弄明白如何用矩阵运算写出对所有可能的情况的求和。这项工作会比较乏味,需要一些耐心,但不用太多的洞察。完成这些后,就可以尽可能地简化了,最后你发现,自己其实就是在做反向传播!所以你可以将反向传播想象成一种计算所有可能的路径变化率的求和的方式。或者,换句话说,反向传播就是一种巧妙地追踪权重(和偏差)微小变化的传播,抵达输出层影响代价函数的技术。
现在我不会继续深入下去。因为这项工作比较无聊。如果你想挑战一下,可以尝试与喜爱。即使你不去尝试,我也希望这种思维方式可以让你能够更好地理解反向传播。
那其他的一些神秘的特性呢——反向传播如何在一开始被发现的?实际上,如果你跟随我刚刚给出的观点,你其实是可以发现反向传播的一种证明的。不幸的是,证明会比本章前面介绍的证明更长和更加的复杂。那么,前面那个简短(却更加神秘)的证明如何被发现的?当你写出来所有关于长证明的细节后,你会发现其实里面包含了一些明显的可以进行改进的地方。然后你进行一些简化,得到稍微简短的证明,写下来。然后又能发现一些更加明显的简化。进过几次迭代证明改进后,你会发现最终的简单却看起来奇特的证明,因为你移除了很多构造的细节了!老实告诉你,其实最早的证明的出现也不是太过神秘的事情。因为那只是很多对简化证明的艰辛工作的积累。