在过去的几年里,卷积神经网络(CNN)引起了人们的广泛关注,尤其是因为它彻底改变了计算机视觉领域。在这篇文章中,我们将以神经网络的基本背景知识为基础,探索什么是CNNs,了解它们是如何工作的,并在Python中从头开始构建一个真正的CNNs(仅使用numpy)
卷积神经网络(CNN):全名Convolutional Neural Networks,是一类包含卷积计算且具有深度结构的前馈神经网络(Feedforward Neural Networks),是深度学习(deep learning)的代表算法之一 。卷积神经网络具有表征学习(representation learning)能力,能够按其阶层结构对输入信息进行平移不变分类(shift-invariant classification),因此也被称为“平移不变人工神经网络(Shift-Invariant Artificial Neural Networks, SIANN)。
这篇文章假设只有神经网络的基本知识。我对神经网络的介绍涵盖了你需要知道的一切,所以你可能想先读一下。
准备好了吗?让我们go。
动机
CNN的经典用例是执行图像分类,例如查看宠物的图像并确定它是猫还是狗。这是一项看似简单的任务 – 为什么不使用普通的神经网络呢?
好问题。
原因1:图像很大
现在用于计算机视觉问题的图像通常是224×224或更大。想象一下,构建一个神经网络来处理224×224彩色图像:包括图像中的3个彩色通道(RGB),得到224×224×3 = 150,528个输入特征!在这样的网络中,一个典型的隐含层可能有1024个节点,因此我们必须为第一层单独训练150,528 x 1024 = 1.5 +亿个权重。我们的网络将是巨大的,几乎不可能训练。
我们也不需要那么多砝码。图像的好处是,我们知道像素在相邻的上下文中最有用。图像中的物体是由小的局部特征组成的,比如眼睛的圆形虹膜或一张纸的方角。第一个隐藏层中的每个节点都要查看每个像素,这不是很浪费时间吗?
原因2:Position可以改变
如果你训练一个网络来检测狗,你希望它能够检测狗,不管它出现在图像的什么地方。想象一下,训练一个网络,它能很好地处理特定的狗的图像,然后给它喂食同一图像的一个稍微移动的版本。狗不会激活相同的神经元,所以网络的反应会完全不同!
我们很快就会看到CNN如何帮助我们缓解这些问题。
数据集
在这篇文章中,我们将解决计算机视觉的“Hello,World!”:MNIST手写数字分类问题。这很简单:给定图像,将其分类为数字。
来自MNIST数据集的样本图像
MNIST数据集中的每个图像都是28×28,包含一个居中的灰度数字。
说实话,一个正常的神经网络实际上可以很好地解决这个问题。您可以将每个图像视为一个28×28 = 784维的向量,将其提供给一个784-dim的输入层,堆叠几个隐藏层,最后输出层包含10个节点,每个数字对应一个节点。
这会起作用,因为MNIST数据集包含居中的小图像,因此我们不会遇到上述大小或移位问题。但是,请记住,在本文的整个过程中,大多数现实世界中的图像分类问题并没有这么简单。
让我们进入CNN吧!
卷积
什么是卷积神经网络?
它们基本上只是使用卷积层的神经网络,也就是Conv层,它基于卷积的数学运算。Conv层由一组过滤器组成,您可以将其看作是数字的二维矩阵。这里有一个例子3×3过滤器:
一个3×3过滤器
我们可以使用一个输入图像和一个滤波器通过将滤波器与输入图像进行卷积来生成一个输出图像。这包括
- 将过滤器覆盖在图像的某个位置上。
- 在过滤器中的值与其在图像中的对应值之间执行元素级乘法。
- 总结所有元素产品。此总和是输出图像中目标像素的输出值。
- 重复所有位置。
这个4步描述有点抽象,所以让我们举个例子吧。考虑这个微小的4×4灰度图像和这个3×3滤镜:
4×4图像(左)和3×3滤镜(右)
图像中的数字表示像素强度,其中0是黑色,255是白色。我们将对输入图像和过滤器进行卷积以生成2×2输出图像:
2×2输出图像
首先,让我们将滤镜叠加在图片的左上角:
第1步:将滤镜(右)叠加在图像上方(左)
接下来,我们执行重叠图像值和过滤器值之间的元素级乘法。结果如下,从左上角开始,向右,然后向下:
第2步:执行逐元素乘法。
接下来,我们总结所有结果。这很容易:
最后,我们将结果放在输出图像的目标像素中。由于我们的过滤器覆盖在输入图像的左上角,因此我们的目标像素是输出图像的左上角像素:
我们做同样的事情来生成输出图像的其余部分:
这有用吗?
让我们缩小一下,在更高的层次上看这个。将图像与过滤器进行卷积会做什么?我们可以从我们一直使用的例子3×3过滤器开始,它通常被称为垂直Sobel过滤器:
垂直索贝尔过滤器
以下是垂直Sobel过滤器的示例:
与垂直Sobel过滤器卷积的图像
同样,还有一个水平Sobel过滤器:
水平Sobel过滤器
与水平Sobel过滤器卷积的图像
看发生了什么?索贝尔过滤器是一种边缘检测器。垂直Sobel过滤器检测垂直边缘,水平Sobel过滤器检测水平边缘。输出图像现在很容易解释:输出图像中的亮像素(高值像素)表示在原始图像中有一个强边缘。
您能看出为什么边缘检测图像可能比原始图像更有用吗?回想一下MNIST手写数字分类问题。训练MNIST的CNN可能会寻找数字1,例如,使用边缘检测过滤器并检查图像中心附近的两个突出的垂直边缘。一般来说,卷积帮助我们寻找特定的局部图像特征(如边缘),我们可以在以后的网络中使用。
填充
还记得先用3×3过滤器对4×4输入图像进行卷积,以产生2×2输出图像吗?通常,我们希望输出图像的大小与输入图像的大小相同。为此,我们在图像周围添加零,以便我们可以在更多位置叠加过滤器。3×3滤镜需要1个像素的填充:
4×4输入与3×3过滤器卷积,使用相同的填充产生4×4输出
这称为“相同的”填充,因为输入和输出具有相同的尺寸。
Conv图层
现在我们知道了图像卷积是如何工作的以及它为什么有用,让我们看看它在CNNs中的实际应用。如前所述,cnn包括conv层,它使用一组过滤器将输入图像转换为输出图像。conv层的主要参数是它拥有的过滤器的数量。
对于MNIST CNN,我们将使用一个带有8个过滤器的小conv层作为网络的初始层。这意味着它将把28×28的输入图像转换成26x26x8的输出卷:
提醒:输出为26x26x8而不是28x28x8,因为我们使用有效填充,这会将输入的宽度和高度减少2。
conv层中的8个过滤器中的每一个都产生一个26×26的输出,因此它们叠加在一起构成一个26x26x8卷。所有这一切都是因为
3 × 3 (过滤器尺寸) × 8 (过滤器数量) = 72 weights!
实现卷积
是时候把我们学到的东西写进代码里了!我们将实现conv层的前馈部分,它负责将过滤器与输入图像进行卷积以生成输出卷。为了简单起见,我们假设过滤器总是3×3(这不是真的——5×5和7×7过滤器也很常见)。
让我们开始实现一个conv层类:
conv.py
import numpy as np
class Conv3x3:
# A Convolution layer using 3x3 filters.
def __init__(self, num_filters):
self.num_filters = num_filters
# filters is a 3d array with dimensions (num_filters, 3, 3)
# We divide by 9 to reduce the variance of our initial values
self.filters = np.random.randn(num_filters, 3, 3) / 9
Conv3x3类只接受一个参数:过滤器的数量。在构造函数中,我们存储过滤器的数量,并使用NumPy的randn()方法初始化一个随机过滤器数组。
注意:在初始化期间用9来除比您想象的更重要。如果初始值太大或太小,则训练网络将无效。要了解更多信息,请阅读Xavier Initialization。
接下来,实际卷积:
conv.py
class Conv3x3:
# ...
def iterate_regions(self, image):
'''
Generates all possible 3x3 image regions using valid padding.
- image is a 2d numpy array
'''
h, w = image.shape
for i in range(h - 2):
for j in range(w - 2):
im_region = image[i:(i + 3), j:(j + 3)]
yield im_region, i, j
def forward(self, input):
'''
Performs a forward pass of the conv layer using the given input.
Returns a 3d numpy array with dimensions (h, w, num_filters).
- input is a 2d numpy array
'''
h, w = input.shape
output = np.zeros((h - 2, w - 2, self.num_filters))
for im_region, i, j in self.iterate_regions(input):
output[i, j] = np.sum(im_region * self.filters, axis=(1, 2))
return output
iterate_regions()是一个帮助生成器方法,它为我们生成所有有效的3×3图像区域。这对于以后实现类的向后部分很有用。
上面突出显示了实际执行卷积的代码行。让我们来分解一下:
- 我们有im_region一个包含相关图像区域的3×3阵列。
- 我们有self.filters一个3d数组。
- 我们做im_region * self.filters,它使用numpy的广播特性以元素的方式将两个数组相乘。结果是一个与self.filters具有相同维度的3d数组。
- sum()使用axis=(1,2)得到上一步的结果,它生成一个长度为num_filters的一维数组,其中每个元素包含对应滤波器的卷积结果。
- 我们将结果赋值给output[i, j],其中包含输出中的像素(i, j)的卷积结果。
对输出中的每个像素执行上面的序列,直到得到最终的输出卷为止!让我们测试一下我们的代码:
cnn.py
import mnist
from conv import Conv3x3
# The mnist package handles the MNIST dataset for us!
# Learn more at https://github.com/datapythonista/mnist
train_images = mnist.train_images()
train_labels = mnist.train_labels()
conv = Conv3x3(8)
output = conv.forward(train_images[0])
print(output.shape) # (26, 26, 8)
到目前为止看起来不错
注意:在Conv3x3实现中,为了简单起见,我们假设输入是一个2d numpy数组,因为MNIST图像就是这样存储的。这对我们有用,因为我们使用它作为我们网络的第一层,但大多数cnn有更多的Conv层。如果我们要构建一个更大的网络,需要多次使用Conv3x3,那么我们必须将输入设置为3d numpy数组。
池化
图像中的相邻像素往往具有相似的值,因此conv层通常也会为输出中的相邻像素生成相似的值。因此,conv层输出中包含的大部分信息都是冗余的。例如,如果我们使用边缘检测过滤器,并在某个位置找到一个强边缘,那么我们很可能也会在距离原始位置1像素的位置找到一个相对较强的边缘。然而,这些都是相同的边缘!我们没有发现任何新东西。
池化层解决了这个问题。它们所做的就是减小输入的大小(您猜对了)。池通常由一个简单的操作完成,比如max、min或average。以下是池化大小为2的Max Pooling图层的示例::
4×4图像上的最大池(池大小为2)以产生2×2输出
为了执行最大池化,我们以2×2块(因为池大小= 2)遍历输入图像,并将最大值放入相应像素的输出图像中。就是这样!
Pooling将输入的宽度和高度除以池大小。对于我们的MNIST CNN,我们将在初始转换层之后放置一个池大小为2的Max Pooling层。池化层将26x26x8输入转换为13x13x8输出:
实现池化
我们将实现以个MaxPool2的类,与上一节中的conv类具有相同的方法:
maxpool.py
import numpy as np
class MaxPool2:
# A Max Pooling layer using a pool size of 2.
def iterate_regions(self, image):
'''
Generates non-overlapping 2x2 image regions to pool over.
- image is a 2d numpy array
'''
h, w, _ = image.shape
new_h = h // 2
new_w = w // 2
for i in range(new_h):
for j in range(new_w):
im_region = image[(i * 2):(i * 2 + 2), (j * 2):(j * 2 + 2)]
yield im_region, i, j
def forward(self, input):
'''
Performs a forward pass of the maxpool layer using the given input.
Returns a 3d numpy array with dimensions (h / 2, w / 2, num_filters).
- input is a 3d numpy array with dimensions (h, w, num_filters)
'''
h, w, num_filters = input.shape
output = np.zeros((h // 2, w // 2, num_filters))
for im_region, i, j in self.iterate_regions(input):
output[i, j] = np.amax(im_region, axis=(0, 1))
return output
这个类的工作原理类似于我们之前实现的Conv3x3类。关键行再次突出显示:要从给定的图像区域找到最大值,我们使用np.amax(), numpy的array max方法。我们设置axis=(0,1),因为我们只想最大化前两个维度(高度和宽度),而不是第三个维度(num_filters)。
我们来试试吧!
cnn.py
import mnist
from conv import Conv3x3
from maxpool import MaxPool2
# The mnist package handles the MNIST dataset for us!
# Learn more at https://github.com/datapythonista/mnist
train_images = mnist.train_images()
train_labels = mnist.train_labels()
conv = Conv3x3(8)
pool = MaxPool2()
output = conv.forward(train_images[0])
output = pool.forward(output)
print(output.shape) # (13, 13, 8)
我们的MNIST CNN开始走到一起了!
Softmax
为了完成我们的CNN,我们需要赋予它实际预测的能力。我们将通过使用一个多类分类问题的标准最终层来实现这一点:Softmax层,一个使用Softmax函数作为其激活的全连接(密集)层。
提醒:全连接层将每个节点连接到前一层的每个输出。如果你需要复习的话,我们在介绍神经网络时使用了全连接层。
如果您之前没有听说过Softmax,请在继续之前查阅相关资料。
用法
我们将使用一个包含10个节点的softmax层,每个节点代表一个数字,作为CNN的最后一层。层中的每个节点都将连接到每个输入。应用softmax变换后,以概率最高的节点表示的数字为CNN的输出!
交叉熵损失
你可能会想,为什么要把输出转化为概率呢?最高的产值不总是有最高的概率吗?如果你这么做了,你绝对是对的。我们实际上不需要使用softmax来预测一个数字——我们只需要从网络中选择输出最高的数字即可!
softmax真正做的是帮助我们量化我们对预测的确信程度,这在训练和评估CNN时非常有用。更具体地说,使用softmax允许我们使用交叉熵损失,它考虑到我们对每个预测的确定程度。下面是我们计算交叉熵损失的方法::
L=−ln(p_c)
其中c是正确的类(在本例中是指正确的数字),p_c
是对C的预测概率,ln是自然对数。一如既往,损失越小越好。例如,在最好的情况下,我们有
p_c =1,L=−ln(1)=0
在更现实的情况下,我们可能会有
p_c = 0.8,L = – ln(0.8)= 0.223
我们将在本文稍后再次看到交叉熵损失,所以请记住它!
实现Softmax
您现在已经知道了这个练习—让我们实现一个Softmax层类:
softmax.py
import numpy as np
class Softmax:
# A standard fully-connected layer with softmax activation.
def __init__(self, input_len, nodes):
# We divide by input_len to reduce the variance of our initial values
self.weights = np.random.randn(input_len, nodes) / input_len
self.biases = np.zeros(nodes)
def forward(self, input):
'''
Performs a forward pass of the softmax layer using the given input.
Returns a 1d numpy array containing the respective probability values.
- input can be any array with any dimensions.
'''
input = input.flatten()
input_len, nodes = self.weights.shape
totals = np.dot(input, self.weights) + self.biases
exp = np.exp(totals)
return exp / np.sum(exp, axis=0)
这里没什么太复杂的。一些亮点:
- flatten()输入以使其更容易使用,因为我们不再需要它的形状。
- np.dot()input与self.weights元素相乘,然后对结果求和。
- np.exp()计算用于Softmax的指数。
我们现在已经完成了CNN的整个步骤!把它放在一起:
cnn.py
import mnist
import numpy as np
from conv import Conv3x3
from maxpool import MaxPool2
from softmax import Softmax
# We only use the first 1k testing examples (out of 10k total)
# in the interest of time. Feel free to change this if you want.
test_images = mnist.test_images()[:1000]
test_labels = mnist.test_labels()[:1000]
conv = Conv3x3(8) # 28x28x1 -> 26x26x8
pool = MaxPool2() # 26x26x8 -> 13x13x8
softmax = Softmax(13 * 13 * 8, 10) # 13x13x8 -> 10
def forward(image, label):
'''
Completes a forward pass of the CNN and calculates the accuracy and
cross-entropy loss.
- image is a 2d numpy array
- label is a digit
'''
# We transform the image from [0, 255] to [-0.5, 0.5] to make it easier
# to work with. This is standard practice.
out = conv.forward((image / 255) - 0.5)
out = pool.forward(out)
out = softmax.forward(out)
# Calculate cross-entropy loss and accuracy. np.log() is the natural log.
loss = -np.log(out[label])
acc = 1 if np.argmax(out) == label else 0
return out, loss, acc
print('MNIST CNN initialized!')
loss = 0
num_correct = 0
for i, (im, label) in enumerate(zip(test_images, test_labels)):
# Do a forward pass.
_, l, acc = forward(im, label)
loss += l
num_correct += acc
# Print stats every 100 steps.
if i % 100 == 99:
print(
'[Step %d] Past 100 steps: Average Loss %.3f | Accuracy: %d%%' %
(i + 1, loss / 100, num_correct)
)
loss = 0
num_correct = 0
Running cnn.py给我们输出类似于:
MNIST CNN initialized!
[Step 100] Past 100 steps: Average Loss 2.302 | Accuracy: 11%
[Step 200] Past 100 steps: Average Loss 2.302 | Accuracy: 8%
[Step 300] Past 100 steps: Average Loss 2.302 | Accuracy: 3%
[Step 400] Past 100 steps: Average Loss 2.302 | Accuracy: 12%
这是有道理的:随机权重初始化,你会期望CNN只能随机猜测一样好。随机猜测将产生10%的准确度(因为有10个类)和交叉熵损失−ln(0.1)=2.302,这就是我们得到的!
想亲自尝试或修补这些代码?在浏览器中运行此CNN。它也可以在Github上找到。
结论
这是对CNN的介绍的结束!在这篇文章中,我们
- 激发了为什么CNN可能对某些问题更有用,例如图像分类。
- 介绍了MNIST手写数字数据集。
- 了解Conv图层,它将滤镜与图像进行卷积,以产生更有用的输出。
- 谈到Pooling图层,它可以帮助删除除了最有用的特性之外的所有东西。
- 实现了Softmax层,因此我们可以使用交叉熵损失。
还有很多东西我们还没有讲到,比如如何训练CNN。本系列的下一部分将对CNN进行了深入的训练,包括推导梯度和实现backprop。或者,您也可以学习使用Keras实现自己的CNN,这是一个用于Python的深度学习库。
如果你渴望看到一个训练有素的CNN,这里有个例子:Keras CNN训练MNIST,达到99.25%的准确率!