本节介绍如何训练CNN,一个简单的演练,为CNNs派生反向传播,并在Python中从头实现它。
在这篇文章中,我们将深入介绍卷积神经网络(CNNs)中大多数介绍都缺乏的东西:如何训练CNN,包括派生梯度、从零开始实现backprop(仅使用numpy),以及最终构建完整的训练管道!
本文假设您对CNNs有基本的了解。我对CNNs的介绍卷积神经网络(CNN)简介涵盖了您需要了解的所有内容,因此我强烈建议您首先阅读这些内容。如果您已经阅读了卷积神经网络(CNN)简介,欢迎回来!
这篇文章的一部分还假定了你对多元微积分的基本知识了解。如果你愿意,你可以跳过这些部分,但我建议你阅读它们,即使你不是什么都懂。我们将在获得结果时逐步编写代码,甚至表面级别的理解也会有所帮助。
系好安全带!我们的文章开始了。
舞台设置
我们使用CNN解决MNIST手写数字分类问题:
来自MNIST数据集的样本图像
我们的(简单的)CNN由Conv层、Max池化层和Softmax层组成。这是我们CNN的图表:
我们的CNN采用28×28灰度MNIST图像,输出10个概率,每个数字1个。
我们已经编写了3个类,每个层一个类:Conv3x3、MaxPool和Softmax。每个类都实现了一个forward()方法,我们用它来构建CNN的forward pass.
cnn.py:
您可以在浏览器中查看代码或运行CNN。它也可以在Github上使用。
下面是我们CNN现在的输出:
显然,我们想要比10%的准确率做得更好……让我们给CNN上一课。
训练概述
神经网络的训练一般分为两个阶段:
- 正向阶段,输入完全通过网络传递。
- 反向阶段,在此阶段中梯度反向传播(backprop)并更新权重。
我们将按照这个模式来训练我们的CNN。我们还将使用两个主要的特定于实现的思想:
- 在正向(forward)阶段,每一层都将缓存反向阶段所需的任何数据(如输入、中间值等)。这意味着任何反向阶段之前都必须有一个相应的正向阶段。
- 在反向(backward)阶段,每一层都将接收到梯度,并返回梯度。它将收到相对于输出的损失梯度,并返回相对于其输入的损失梯度。
这两个想法将有助于保持我们的训练执行干净和有组织。了解原因的最好方法可能是查看代码。训练我们的CNN最终会是这样的:
看到它看起来多漂亮多干净了吗?现在想象一下,构建一个50层而不是3层的网络——它甚至比拥有良好的系统更有价值。
Backprop: Softmax
我们将从结尾开始,然后一步步开始,因为这就是backprop的工作原理。首先,回顾交叉熵损失: L=−ln()
其中是正确的c类的预测概率(换句话说,我们当前图像的实际数字是多少)。
我们需要计算的第一件事是Softmax层反向阶段的输入,,其中是Softmax层的输出:一个10个概率的向量.这很简单,因为只有出现在损失方程中:
这是你看到上面提到的我们的初始梯度:
我们几乎准备好实现我们的第一个反向阶段-我们只需要首先执行我们前面讨论的正向阶段缓存:
softmax.py
我们在这里缓存了3个对实现反向阶段有用的东西:
– input的形状,然后我们把它压平。
– 压平后的input。
– 传递到softmax激活的值:total。
有了这个方法,我们可以开始推导反向阶段的梯度。我们已经推导出Softmax反向阶段的输入:。我们可以使用的一个事实是这个正确的分类c是非零的。
那就意味着除了(c)之外我们可以忽略任何东西。
首先,让我们计算(c)相对于总数的梯度(传递到softmax激活的值)。让作为i类的总数。我们因此可以将(c)写成:
这里S= 。
现在让我们考虑一些类k,例如kc,我们可以写(c)为下面这样:
(c) =
用链式法则来推导:
请注意,这里我们是假设kc。
现在我们对c求导,这次用除法法则(因为(c)的分子上有个):
唷。这是整篇文章中最难的微积分部分——从这里开始只会变得更容易!让我们开始实现这个:
softmax.py
还记得如何只对正确的类c是非零的吗?我们从寻找c开始在d_L_d_out中寻找非零梯度。一旦我们发现,我们计算梯度 (d_out_d_total)使用我们从上面得到的结果:
让我们继续。我们最终希望权重损失梯度、偏差和输入:
- 我们将使用权重梯度来更新图层的权重。
- 我们将使用偏差梯度来更新我们图层的偏差。
- 我们将从backprop()方法返回输入梯度,以便下一层可以使用它。这就是我们在培训概述部分中讨论的返回梯度!
要计算这3个损失梯度,我们首先需要得到另外3个结果:总计相对于权重、偏差和输入的梯度。相关方程为:
这些梯度很简单。
把所有相关的放在一起:
把它写进代码里就不那么简单了:
softmax.py
首先,我们预先计算d_L_d_t,因为我们将多次使用它。然后,我们计算每个梯度:
- d_L_d_w:我们需要二维数组来做矩阵乘法(@),但是d_t_d_w和d_L_d_t是一维数组。np.newaxis让我们可以很容易地创建一个长度为1的新轴,因此我们最终将矩阵与维度(input_len, 1)和(1,nodes)相乘。因此,d_L_d_w的最终结果将具有shape (input_len, nodes),它与self.weights相同!
- d_L_d_b:这个很简单,因为d_t_d_b是1。
- d_l_d_input:我们用维度(input_len, nodes)和(nodes, 1)相乘矩阵,得到长度为input_len的结果。
尝试通过上面的计算的小例子,特别是d_L_d_w和d_l_d_input的矩阵乘法。这是理解为什么这段代码正确计算梯度的最好方法。
计算完所有的梯度后,剩下的就是训练Softmax层了!我们将使用随机梯度下降(SGD)来更新权重和偏差,就像我在介绍神经网络时所做的那样,然后返回d_l_d_input:
softmax.py
class Softmax
# ...
def backprop(self, d_L_d_out, learn_rate):
'''
Performs a backward pass of the softmax layer.
Returns the loss gradient for this layer's inputs.
- d_L_d_out is the loss gradient for this layer's outputs.
- learn_rate is a float
'''
# We know only 1 element of d_L_d_out will be nonzero
for i, gradient in enumerate(d_L_d_out):
if gradient == 0:
continue
# e^totals
t_exp = np.exp(self.last_totals)
# Sum of all e^totals
S = np.sum(t_exp)
# Gradients of out[i] against totals
d_out_d_t = -t_exp[i] * t_exp / (S ** 2)
d_out_d_t[i] = t_exp[i] * (S - t_exp[i]) / (S ** 2)
# Gradients of totals against weights/biases/input
d_t_d_w = self.last_input
d_t_d_b = 1
d_t_d_inputs = self.weights
# Gradients of loss against totals
d_L_d_t = gradient * d_out_d_t
# Gradients of loss against weights/biases/input
d_L_d_w = d_t_d_w[np.newaxis].T @ d_L_d_t[np.newaxis]
d_L_d_b = d_L_d_t * d_t_d_b
d_L_d_inputs = d_t_d_inputs @ d_L_d_t
# Update weights / biases
self.weights -= learn_rate * d_L_d_w
self.biases -= learn_rate * d_L_d_b
return d_L_d_inputs.reshape(self.last_input_shape)
注意,我们添加了一个learn_rate参数来控制更新权重的速度。此外,在返回d_l_d_input之前,我们还必须reshape(),因为我们在向前传递时将输入压平:
softmax.py
将其重新映射为last_input_shape可以确保该层以与初始输入相同的格式返回其输入的梯度。
试驾:Softmax Backprop
我们已经完成了第一个backprop 实现!让我们快速测试一下,看看它是否有用。我们将在卷积神经网络(CNN)介绍的的cnn.py文件中开始实现一个train()方法:
cnn.py
运行该函数得到的结果类似于:
损失在下降,准确度在上升——我们的CNN已经在学习了!
Backprop:Max pooling
不能训练Max Pooling层,因为它实际上没有任何权重,但是我们仍然需要实现一个backprop()方法来计算梯度。我们将从再次添加正向阶段缓存开始。我们只需要缓存输入:
maxpool.py
在正向传递期间,Max pooling层接受一个输入卷,通过在2×2块上选择最大值,将其宽度和高度维度减半。反向遍历的作用正好相反:我们将通过将每个梯度值赋给原始最大值在其对应的2×2块中的位置,将损失梯度的宽度和高度加倍。
这是一个例子。考虑max pooling层的这个正向阶段:
将4×4输入转换为2×2输出的正向阶段示例
同一层的反向阶段是这样的:
一个将2×2梯度转换为4×4梯度的反向示例
每个梯度值都被分配到原始最大值所在的位置,其他值都为零。
为什么Max池化层的反向阶段是这样工作的?直观地思考应该是什么。如果输入像素在其2×2块中不是最大值,则对损失的边际影响为零,因为稍微改变该值并不会改变输出!换句话说,对于非最大像素,=0。另一方面,一个最大值的输入像素会将其值传递给输出,所以 =1,意味着 = 。
我们可以使用在卷积神经网络(CNN)中编写的iterate_regions() helper方法快速实现这一点。我将再次附上它作为提醒:
maxpool.py
对于每个过滤器中每个2×2图像区域中的每个像素,我们将梯度从d_L_d_out复制到d_L_d_input(如果它是转发过程中的最大值)。
就是这样!最后一层。
Backprop: Conv
我们终于到了这里:通过Conv层进行反向传播是训练CNN的核心。正向阶段缓存很简单:
conv.py
关于实现的提醒:为了简单起见,我们假设conv层的输入是一个2d数组。这只适用于我们,因为我们使用它作为网络的第一层。如果我们要构建一个更大的网络,需要使用Conv3x3多次,我们必须使输入成为一个3d数组。
我们主要对conv层中的过滤器的损失梯度感兴趣,因为我们需要它来更新过滤器的权重。我们已经有了conv层的,所以我们只需要。为了计算它,我们问自己:改变过滤器的权重将如何影响conv层的输出?
事实上,改变任何滤波器的权值都会影响该滤波器的整个输出图像,因为每个输出像素在卷积过程中都会使用每个像素的权值。为了使这一点更容易考虑,让我们一次只考虑一个输出像素:修改过滤器将如何更改一个特定输出像素的输出?
这里有一个超级简单的例子来帮助思考这个问题:
一个3×3图像(左)与一个3×3滤波器(中)卷积生成一个1×1输出(右)
我们有一个3×3的图像与一个3×3的滤波器卷积得到一个1×1的输出。如果我们把中心过滤器的重量增加1呢?输出将增加中心图像值,80:
类似地,将任何其他过滤器权重增加1,将增加相应图像像素值的输出!这说明一个特定的输出像素相对于一个特定的滤波权重的导数就是对应的图像像素值。计算结果证实了这一点:
我们可以把它们放在一起,找到特定滤波器权重的损失梯度:
我们准备为conv层实现backprop !
conv.py
我们通过迭代每个图像区域/过滤器并逐步建立损失梯度来应用我们的导出方程。一旦我们覆盖了所有内容,我们就和以前一样,使用SGD更新self.filters 。请注意解释我们为什么不返回None的注释—-输入的损失梯度的推导与我们刚才做的非常相似,留给读者作为练习:)。
这样,我们就做完了!我们实现了通过CNN的完全反向传递。是时候测试一下了……
训练一个CNN
我们将对CNN进行几轮的训练,在训练期间跟踪它的进程,然后在一个单独的测试集中测试它。下面是完整的代码:
cnn.py
运行代码的示例输出:
我们的代码可以工作!在仅仅3000个训练步骤中,我们从一个模型的2.3损失和10%的准确性到0.6损失和78%的准确性。
想自己尝试或修改这段代码吗?在浏览器中运行CNN。它也可以在Github上使用。
为了节省时间,我们仅在本例中使用了整个MNIST数据集的一个子集——我们的CNN实现并不是特别快。如果我们想训练一个真正的MNIST CNN,我们会使用像Keras这样的ML库。为了说明我们CNN的强大功能,我使用Keras来实现和训练我们刚刚从零开始构建的CNN:
cnn_keras.py
在完整MNIST数据集(60k训练图像)上运行该代码,结果如下:
用这个简单的CNN我们可以达到97.4%的测试精度!有了更好的CNN架构,我们可以进一步改进——在这个官方的Keras MNIST CNN示例中,经过12轮后,它们的测试精度达到99.25%。这是非常准确的。
这篇文章的所有代码都可以在Github上找到。
现在该做什么?
在本文和卷积神经网络(CNN)简介两部分的系列文章中,我们对卷积神经网络进行了全面的介绍,包括它们是什么、如何工作、为什么有用以及如何训练它们。然而,这仅仅是个开始。你可以做的还有很多:
- 使用适当的ML库(如Tensorflow、Keras或PyTorch)来试验更大/更好的CNNs。
- 了解如何使用CNNs批处理规范化。
- 了解如何使用数据增强来改进图像训练集。
- 请阅读有关ImageNet项目及其著名的计算机视觉竞赛ImageNet大型视觉识别挑战(ILSVRC)。