原文链接

在过去的几年里,关于卷积神经网络(Convolution Neural Networks,CNN)的讨论越来越多,特别是因为它彻底改变了计算机视觉领域。在本文中,我们将基于神经网络的基本背景知识,探索CNN是什么,了解它们的工作原理,并在Python中从头开始构建一个真正的CNN(仅使用numpy)。

这篇文章仅假设你拥有神经网络的基本知识。我对神经网络的介绍涵盖了您需要了解的所有内容,因此您可能需要首先阅读它。

为什么使用CNN

CNN的经典案例是图像分类,例如查看宠物的图像并确定它是猫还是狗。那么问题来了,为什么不使用普通的神经网络?

好问题

原因1:图片很大

如今,有关计算机视觉问题的图像通常为224x224或更大。想象一下,建立一个神经网络来处理224x224彩色图像:包括图像中的3个彩色通道(RGB),也就是说需要224 x 224 x 3 = 150528个输入特征!在这种网络中,典型的隐藏层可能有1024个节点,因此仅第一层就必须训练150,528 x 1024 = 150+百万个权重。我们的网络非常庞大,几乎无法训练。

而且我们也不需要那么多的权重。我们知道像素在与其相邻的环境中最有用。图像中的对象由小的局部特征组成,例如眼睛的圆形虹膜或一张纸的方形角。第一个隐藏层中的每个节点查看每个像素是否很浪费?

原因2:位置会发生变化

如果你训练了一个网络来检测狗,你会希望它能够检测狗,而不管它出现在图像的什么地方。想象一下,训练一个网络,它能很好地处理一个特定的狗的图像,但是给它一个稍微改变了的图像。狗不会激活相同的神经元,所以网络的反应会完全不同!

我们很快就会看到CNN如何帮助我们解决这些问题。

数据集

在本文中,我们将解决计算机视觉的“Hello, World!”:MNIST手写数字分类问题。很简单:给定图像,将其分类为数字。

MNIST数据集

MNIST数据集中的每个图像均为28x28像素,并包含居中的灰度数字。

说实话,一般的神经网络实际上可以很好地解决这个问题。您可以将每张图像视为28 x 28 = 784维向量,将其输入到784维输入层,堆叠一些隐藏层,最后得到10个节点的输出层,每个数字1个节点。

但是它只会在MNIST数据集包含居中的小图像时起作用,本例中我们不会遇到上述尺寸或偏移问题。请记住,大多数现实世界中的图像分类问题并非易事。

卷积

什么是卷积神经网络?

它是使用卷积层(又称为Conv层)的神经网络,其基于卷积的数学运算。Conv层由一组filter(卷积核)组成,您可以将其视为二维的数字矩阵。这是一个3x3卷积核示例:

我们可以输入图像和卷积核,将卷积核与输入图像卷积来生成输出图像。这包括

  1. 在某些位置将卷积核覆盖在图像顶部。
  2. 在卷积核中的值和图像中相应的值之间执行逐元素乘法(element-wise multiplication)
  3. 加总所有逐元素乘法的结果。和是输出图像中目标像素的输出值。
  4. 在所有位置重复该过程。

旁注:从技术上讲,我们(以及许多CNN)实际上在这里使用互相关而不是卷积,但是它们几乎是同一件事。我不会在这篇文章中介绍差异,因为它并不重要,但是如果您感到好奇,可以随时查找相关资料。

这个四步描述有点抽象,所以让我们做一个例子。考虑一下这个微小的4x4灰度图像和3x3卷积核:

4x4图像(左)和3x3卷积核(右)

图像中的数字表示像素强度,其中0为黑色,255为白色。我们将对输入图像和卷积核进行卷积以生成2x2的输出图像:

首先,让我们将卷积核覆盖在图像的左上角:

步骤1:将卷积核(右)覆盖在图像(左)的上方

接下来,我们在重叠的图像值和卷积核值之间执行逐元素乘法。结果如下,从左到右,从上到下:

图像值卷积核值结果
0-10
5000
010
0-20
8000
31262
33-1-33
9000
010

步骤2:执行逐元素乘法。

接下来,加总所有的结果,这很容易

$$ 62 - 33 = \boxed{29} $$

最后,我们将结果放置在输出图像的目标像素中。由于我们的卷积核覆盖在输入图像的左上角,因此我们的目标像素是输出图像的左上像素:

重复以上步骤,得到其他位置像素

convolve-output

它有什么用?

让我们缩小一下,在更高的层次上看。将一个图像与一个卷积核进行卷积有什么用?我们可以从我们一直使用的3x3卷积核开始,它通常被称为垂直索伯滤波器

垂直索伯滤波器

这是一个垂直索伯滤波器的示例:

图像与垂直索伯滤波器卷积

类似地,还有一个水平索伯滤波器卷积:

水平索伯滤波器卷积

图像与水平索伯滤波器卷积

看看发生了什么事?索伯滤波器是边缘检测算子(edge-detectors)。垂直索伯滤波器检测垂直边缘,而水平索伯滤波器检测水平边缘。现在可以轻松地解释输出图像:输出图像中的亮像素(值较高的像素)表明原始图像周围有很强的边缘。

您能看到为什么边缘检测图像比原始图像更有用的原因吗?回想一下我们的MNIST手写数字分类问题。经过MNIST训练的CNN可能会通过使用边缘检测算子,并检查图像中心附近的两个突出的垂直边缘来寻找数字1。通常,卷积可以帮助我们寻找特定的局部图像特征(例如边缘),以便以后在网络中使用。

填充(Padding)

还记得将4x4输入图像与3x3卷积核进行卷积以生成2x2的输出图像吗?通常,我们希望输出图像的大小与输入图像的大小相同。为此,我们在图像周围添加零,以便可以在更多位置覆盖卷积核。3x3卷积核需要填充1个像素:

4x4输入与3x3卷积核卷积产生4x4输出

这被称为“相同”填充,因为输入和输出具有相同的尺寸。不使用任何填充(这是我们一直在做的),有时也称为“有效”填充

卷积层

既然我们知道了图像卷积的工作原理以及为什么它有用,那么让我们看看它在CNN中的实际用法。如前所述,CNN包括使用一组卷积核将输入图像转换为输出图像的卷积层。卷积层的主要参数是一系列卷积核

对于我们的MNIST CNN,我们将使用带有8个卷积核的小型卷机层作为网络的初始层。这意味着它将把28x28的平面输入图像变成26x26x8的立体输出图像:

提醒:输出是26x26x8而不是28x28x8,因为我们使用的是有效的padding,它将输入的宽度和高度减少2。

卷积层中的8个卷积核中的每一个都产生26x26的输出,因此堆叠在一起就构成了26x26x8的立体。3*3(卷积核的大小)*8(卷积核数量)=72,也就是说我们只需要72个权重!

实现卷积

我们将实现一个conv层的前馈部分,该部分负责将卷积核与输入图像进行卷积以产生输出。为简单起见,我们假定卷积核始终为3x3(事实并非如此-5x5和7x7卷积核也很常见)。

# Header: 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类只接受一个参数:卷积核的数量。在init函数中,我们存储卷积核的数量,并使用NumPy的randn()方法初始化一个随机卷积核数组。

注意:在初始化过程中,除以9会比您想象的要重要。如果初始值太大或太小,训练网络将无效。要了解更多信息,请阅读Xavier Initialization

接下来,实际的卷积:

# Header: 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)) # highlight-line

    return output

python›iterate_regions()是一种辅助生成器方法,可以为我们生成所有有效的3x3图像区域。 对于该类的后半部分非常有用。

上面突出显示了实际执行卷积的代码行。让我们分解一下:

  • im_region, 一个包含相关图像区域的3x3数组.
  • self.filters, 一个三维数组.
  • 代码 im_region * self.filters, 利用了numpy的 broadcasting (广播)功能将两个数组逐元素相乘 。其结果是一个三维数组,与 self.filters维度相同.
  • 使用np.sum() axis=(1, 2)对上一步得到的结果相加,生成了一个一维数组 num_filters ,其中每个元素包含对应卷积核的卷积结果
  • 将结果分配给 output[i, j], 其中包含输出图像中像素的卷积结果

对输出中的每个像素执行以上过程,直到获得最终的输出!代码如下:

# Header: 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中,为简单起见,我们假设输入为2维numpy数组,因为这就是我们的MNIST图像的存储方式。这对我们有用,因为我们将其用作网络的第一层,但是大多数CNN都具有更多的Conv层。如果我们要建立一个更大的网络,需要多次使用 Conv3x3,则必须使输入为三维numpy数组。

池化层(Pooling)

图像中的相邻像素往往具有相似的值,因此经过卷积层输出中的相邻像素也有相似的值。也就是卷机层输出中包含了大量多余的信息。例如,如果我们使用边缘检测滤波器,并在某个位置找到了较强的边缘,则很有可能在从原始像素偏移1个像素的位置上也找到了相对较强的边缘。但是,这些都是相同的边缘!我们没有发现任何新东西。

池化层解决了这个问题。他们所做的只是减小输入的大小,通过与池化层的值一起输入。该池通常是通过简单的操作,比如maxminaverage来实现。这是最大池化层的示例,池化大小为2:

pool

在4x4图像上的最大池化(池大小2)产生2x2输出

为了执行最大池化(_max_ pooling),我们以2x2块(因为池大小= 2)遍历输入图像,并将最大值放入对应像素的输出图像中而已!

池化将输入的宽度和高度变为除以池大小的宽度和高度。对于我们的MNIST CNN,我们将在初始转换层之后立即放置一个池大小为2的最大池化层。池化层会将26x26x8输入转换为13x13x8输出:

Pooling divides the input's width and height by the pool size. For our MNIST CNN, we'll place a Max Pooling layer with a pool size of 2 right after our initial conv layer. The pooling layer will transform a 26x26x8 input into a 13x13x8 output:

池化的代码实现

我们将用与上一节中的conv类相同的方法来实现一个 MaxPool2 类::

# Header: 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)) # highlight-line

    return output

该类的工作方式与Conv3x3类似。要从给定图像区域中找到最大值,我们使用numpy的np.amax()方法。我们设定axis=(0, 1)因为我们只想最大化前两个维度,即宽高,并不包括第三个维度:num_filters(卷积核数量)

让我们测试一下!

# Header: 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)

Softmax

要完成我们的CNN,我们需要赋予它实际进行预测的能力。我们将使用标准的最终层解决多类分类问题:Softmax层,这是一个全连接(密集)层,使用Softmax函数作为激活函数。

提醒:全连接层将每个节点连接到上一层的每个输出。我们在神经网络入门中就使用了全连接层。

如果您以前从未听说过Softmax,请先阅读我对Softmax的快速介绍,然后再继续。

使用

我们将使用带有10个节点的softmax层作为CNN的最后一层,每个节点代表一个数字。层中的每个节点将连接到每个输入。应用softmax转换后,由节点表示的概率最高的数字将是CNN的输出!

交叉熵损失(cross-entropy loss)

您可能想问,为什么要将输出转换为概率?最高的输出值会不会总是有最高的概率?如果您这样做,那绝对是正确的。我们实际上不需要使用softmax来预测数字 -我们可以选择网络中输出最高的数字!

softmax的真正作用是帮助我们量化对预测的置信度,这在训练和评估CNN时很有用。更具体地说,使用softmax让我们能用交叉熵损失,它考虑了我们对每个预测的确定程度。这是我们计算交叉熵损失的方法:

$$ L = -\ln(p_c) $$

其中c是正确的组 (在本例中,就是正确的数字), $p_c$ 是对c的预测概率, and $\ln$ is the 自然对数. 一般来说, 更低的损失会更好. 例如在完美的情况下,我们有:

$$ p_c = 1, L = -\ln(1) = 0 $$

在更真实的情况下,我们有

$$ p_c = 0.8, L = -\ln(0.8) = 0.223 $$

在后续的文章中我们仍会看到交叉熵损失。

Softmax代码实现

# Header: 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()inputself.weights 逐元素相乘,并将结果加总。.
  • np.exp() 计算用于Softmax的指数。

接下来我们把代码放到一起

# Header: 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

运行之后得到以下(类似)的输出

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$,这就是我们得到的!

想亲自运行这段代码? 在浏览器中运行. 同样也可以在 Github上找到这段代码.

总结

到此为止,CNN的介绍到此结束!在这篇文章中,我们

  • 阐述了CNN对于某些问题(例如图像分类)可能更有用的原因。
  • 引入了MNIST手写数字数据集。
  • 了解了Conv图层,该图层将卷积核与图像进行卷积以产生更多有用的输出。
  • 讨论了Pooling层,它可以帮助去除最有用特征之外的所有内容。
  • 实现了Softmax层,因此我们可以使用交叉熵损失

我们还没有介绍更多内容,例如如何实际训练CNN。CNN系列的第2部分深入研究了CNN的训练,包括推导梯度和实现反向传播。另外,您还可以学习使用Keras(一个Python深度学习库)实现自己的CNN

如果您急切希望看到训练有素的CNN:示例在MNIST上训练的Keras CNN可以达到99.25%的准确性。

Last modification:October 23rd, 2019 at 03:33 pm