Python深度学习(二)深度学习用于计算机视觉

本节将介绍卷积神经网络,也叫convnet,它是计算机视觉应用几乎都在使用的一种深度学习模型。你将学到将卷积神经网络应用于图像分类问题,特别是那些训练数据集较小的问题。如果你工作的地方并非大型科技公司,这也将是你最常见的使用场景。

一、卷积神经网络简介

我们将深入讲解卷积神经网络的原理,以及它在计算机视觉任务上为什么如此成功。但在此之前,我们先来看一个简单的卷积神经网络示例,即使用卷积神经网络对MNIST 数字进行分类,这个任务我们在第2 章用密集连接网络做过(当时的测试精度为97.8%)。虽然本例中的卷积神经网络很简单,但其精度肯定会超过前面的密集连接网络。

下列代码将会展示一个简单的卷积神经网络。它是Conv2D 层和MaxPooling2D 层的堆叠,很快你就会知道这些层的作用。

1. 实例化一个小型的卷积神经网络

1
2
3
4
5
6
7
8
from keras import layers
from keras import models
model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
Using TensorFlow backend.

重要的是,卷积神经网络接收形状为(image_height, image_width, image_channels)的输入张量(不包括批量维度)。本例中设置卷积神经网络处理大小为(28, 28, 1) 的输入张量,这正是MNIST 图像的格式。我们向第一层传入参数input_shape=(28, 28, 1) 来完成此设置。

我们来看一下目前卷积神经网络的架构。

1
model.summary()
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_1 (Conv2D)            (None, 26, 26, 32)        320       
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 13, 13, 32)        0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 11, 11, 64)        18496     
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 5, 5, 64)          0         
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 3, 3, 64)          36928     
=================================================================
Total params: 55,744
Trainable params: 55,744
Non-trainable params: 0
_________________________________________________________________

可以看到,每个Conv2D 层和MaxPooling2D 层的输出都是一个形状为(height, width,channels) 的3D 张量。宽度和高度两个维度的尺寸通常会随着网络加深而变小,通道数量由传
入Conv2D 层的第一个参数所控制(32 或64)。

下一步是将最后的输出张量[大小为(3, 3, 64)]输入到一个密集连接分类器网络中,即Dense 层的堆叠,你已经很熟悉了。这些分类器可以处理1D 向量,而当前的输出是3D 张量。首先,我们需要将3D 输出展平为1D,然后在上面添加几个Dense 层。

2. 在卷积神经网络上添加分类器

1
2
3
model.add(layers.Flatten())
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dense(10, activation='softmax'))

我们将进行10 类别分类,最后一层使用带10 个输出的softmax 激活。现在网络的架构如下。

1
model.summary()
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_1 (Conv2D)            (None, 26, 26, 32)        320       
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 13, 13, 32)        0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 11, 11, 64)        18496     
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 5, 5, 64)          0         
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 3, 3, 64)          36928     
_________________________________________________________________
flatten_1 (Flatten)          (None, 576)               0         
_________________________________________________________________
dense_1 (Dense)              (None, 64)                36928     
_________________________________________________________________
dense_2 (Dense)              (None, 10)                650       
=================================================================
Total params: 93,322
Trainable params: 93,322
Non-trainable params: 0
_________________________________________________________________

如你所见,在进入两个Dense层之前,形状(3, 3, 64) 的输出被展平为形状(576,) 的向量。

下面我们在MNIST数字图像上训练这个卷积神经网络。我们将复用MNIST示例中的很多代码。

3. 在MINST图像上训练卷积神经网络

1
2
3
4
5
6
7
8
9
10
11
12
13
from keras.datasets import mnist
from keras.utils import to_categorical
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images = train_images.reshape((60000, 28, 28, 1))
train_images = train_images.astype('float32') / 255
test_images = test_images.reshape((10000, 28, 28, 1))
test_images = test_images.astype('float32') / 255
train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)
model.compile(optimizer='rmsprop',
loss='categorical_crossentropy',
metrics=['accuracy'])
model.fit(train_images, train_labels, epochs=5, batch_size=64)
Epoch 1/5
60000/60000 [==============================] - 25s 419us/step - loss: 0.1652 - accuracy: 0.9488
Epoch 2/5
60000/60000 [==============================] - 26s 429us/step - loss: 0.0458 - accuracy: 0.9864
Epoch 3/5
60000/60000 [==============================] - 24s 400us/step - loss: 0.0320 - accuracy: 0.9897
Epoch 4/5
60000/60000 [==============================] - 24s 396us/step - loss: 0.0247 - accuracy: 0.9924
Epoch 5/5
60000/60000 [==============================] - 24s 393us/step - loss: 0.0196 - accuracy: 0.9940

我们在测试数据上对模型进行评估。

1
2
test_loss, test_acc = model.evaluate(test_images, test_labels)
test_acc
10000/10000 [==============================] - 1s 112us/step


0.9923999905586243

密集连接网络的测试精度为97.8%,但这个简单卷积神经网络的测试精度达到了99.1%,我们将错误率降低了68%(相对比例)。相当不错!与密集连接模型相比,为什么这个简单卷积神经网络的效果这么好?要回答这个问题,我们来深入了解Conv2D 层和MaxPooling2D 层的作用。

4. 卷积神经网络

密集连接层和卷积层的根本区别在于,Dense 层从输入特征空间中学到的是全局模式(比如对于MNIST 数字,全局模式就是涉及所有像素的模式),而卷积层学到的是局部模式。对于图像来说,学到的就是在输入图像的二维小窗口中发现的模式。在上面的例子中,这些窗口的大小都是3×3。

图像可以被分解为局部模式,如边缘、纹理等
图像可以被分解为局部模式,如边缘、纹理等

这个重要特性使卷积神经网络具有以下两个有趣的性质。

  • 卷积神经网络学到的模式具有平移不变性(translation invariant)。卷积神经网络在图像右下角学到某个模式之后,它可以在任何地方识别这个模式,比如左上角。对于密集连接网络来说,如果模式出现在新的位置,它只能重新学习这个模式。这使得卷积神经网络在处理图像时可以高效利用数据(因为视觉世界从根本上具有平移不变性),它只需要更少的训练样本就可以学到具有泛化能力的数据表示。
  • 卷积神经网络可以学到模式的空间层次结构(spatial hierarchies of patterns)。第一个卷积层将学习较小的局部模式(比如边缘),第二个卷积层将学习由第一层特征组成的更大的模式,以此类推。这使得卷积神经网络可以有效地学习越来越复杂、越来越抽象的视觉概念(因为视觉世界从根本上具有空间层次结构)。

对于包含两个空间轴(高度和宽度)和一个深度轴(也叫通道轴)的3D 张量,其卷积也叫特征图(feature map)。对于RGB 图像,深度轴的维度大小等于3,因为图像有3 个颜色通道:红色、绿色和蓝色。对于黑白图像(比如MNIST 数字图像),深度等于1(表示灰度等级)。卷积运算从输入特征图中提取图块,并对所有这些图块应用相同的变换,生成输出特征图(output feature map)。该输出特征图仍是一个3D 张量,具有宽度和高度,其深度可以任意取值,因为输出深度是层的参数,深度轴的不同通道不再像RGB 输入那样代表特定颜色,而是代表过滤器(filter)。过滤器对输入数据的某一方面进行编码,比如,单个过滤器可以从更高层次编码这样一个概念:“输入中包含一张脸。”

cat
视觉世界形成了视觉模块的空间层次结构:超局部的边缘组合成局部的对象,比如眼睛或耳朵,这些局部对象又组合成高级概念,比如“猫”

在MNIST示例中,第一个卷积层接收一个大小为(28, 28, 1)的特征图,并输出一个大小为(26, 26, 32)的特征图,即它在输入上计算32个过滤器。对于这32个输出通道,每个通道都包含一个26×26的数值网格,它是过滤器对输入的响应图(response map),表示这个过滤器模式在输入中不同位置的响应。这也是特征图这一术语的含义:深度轴的每个维度都是一个特征(或过滤器),而2D 张量output[:, :, n]是这个过滤器在输入上的响应的二维空间图(map)。

响应图
响应图的概念:某个模式在输入中的不同位置是否存在的二维图

卷积由以下两个关键参数所定义:

  • 从输入中提取的图块尺寸:这些图块的大小通常是 3×3 或 5×5。本例中为 3×3,这是很常见的选择。
  • 输出特征图的深度:卷积所计算的过滤器的数量。本例第一层的深度为32,最后一层的深度是64。

对于Keras 的Conv2D 层,这些参数都是向层传入的前几个参数:Conv2D(output_depth,(window_height, window_width))

卷积的工作原理:在3D 输入特征图上滑动(slide)这些3×3 或5×5 的窗口,在每个可能的位置停止并提取周围特征的3D图块[形状为(window_height, window_width, input_depth)]。然后每个3D 图块与学到的同一个权重矩阵[叫作卷积核(convolution kernel)]做张量积,转换成形状为(output_depth,) 的1D 向量。然后对所有这些向量进行空间重组,使其转换为形状为(height, width, output_depth)的3D 输出特征图。输出特征图中的每个空间位置都对应于输入特征图中的相同位置(比如输出的右下角包含了输入右下角的信息)。举个例子,利用3×3的窗口,向量output[i, j, :]来自3D 图块input[i-1:i+1,j-1:j+1, :]。整个过程详见下图:

卷积的工作原理
卷积的工作原理

注意,输出的宽度和高度可能与输入的宽度和高度不同,不同的原因可能有两点。

  • 边界效应,可以通过对输入特征图进行填充来抵消。
  • 使用了步幅(stride),稍后会给出其定义。

5. 最大池化运算

在卷积神经网络示例中,你可能注意到,在每个MaxPooling2D层之后,特征图的尺寸都会减半。例如,在第一个MaxPooling2D层之前,特征图的尺寸是26×26,但最大池化运算将其减半为13×13。这就是最大池化的作用:对特征图进行下采样,与步进卷积类似。最大池化是从输入特征图中提取窗口,并输出每个通道的最大值。它的概念与卷积类似,但是最大池化使用硬编码的max张量运算对局部图块进行变换,而不是使用学到的线性变换(卷积核)。最大池化与卷积的最大不同之处在于,最大池化通常使用2×2的窗口和步幅2,其目的是将特征图下采样2倍。与此相对的是,卷积通常使用3×3 窗口和步幅1。为什么要用这种方式对特征图下采样?为什么不删除最大池化层,一直保留较大的特征图?我们来这么做试一下。这时模型的卷积基(convolutional base)如下所示。

1
2
3
4
5
model_no_max_pool = models.Sequential()
model_no_max_pool.add(layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)))
model_no_max_pool.add(layers.Conv2D(64, (3, 3), activation='relu'))
model_no_max_pool.add(layers.Conv2D(64, (3, 3), activation='relu'))
model_no_max_pool.summary()
Model: "sequential_2"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_4 (Conv2D)            (None, 26, 26, 32)        320       
_________________________________________________________________
conv2d_5 (Conv2D)            (None, 24, 24, 64)        18496     
_________________________________________________________________
conv2d_6 (Conv2D)            (None, 22, 22, 64)        36928     
=================================================================
Total params: 55,744
Trainable params: 55,744
Non-trainable params: 0
_________________________________________________________________

这种架构有什么问题?有如下两点问题:

  • 这种架构不利于学习特征的空间层级结构。第三层的 3×3 窗口中只包含初始输入的 7×7 窗口中所包含的信息。卷积神经网络学到的高级模式相对于初始输入来说仍然很小,这可能不足以学会对数字进行分类(你可以试试仅通过7 像素×7 像素的窗口观察图像来识别其中的数字)。我们需要让最后一个卷积层的特征包含输入的整体信息。
  • 最后一层的特征图对每个样本共有 22×22×64=30 976 个元素。这太多了。如果你将其展平并在上面添加一个大小为512 的Dense 层,那一层将会有1580 万个参数。这对于这样一个小模型来说太多了,会导致严重的过拟合。

简而言之,使用下采样的原因,一是减少需要处理的特征图的元素个数,二是通过让连续卷积层的观察窗口越来越大(即窗口覆盖原始输入的比例越来越大),从而引入空间过滤器的层级结构。

注意,最大池化不是实现这种下采样的唯一方法。你已经知道,还可以在前一个卷积层中使用步幅来实现。此外,你还可以使用平均池化来代替最大池化,其方法是将每个局部输入图块变换为取该图块各通道的平均值,而不是最大值。但最大池化的效果往往比这些替代方法更好。

简而言之,原因在于特征中往往编码了某种模式或概念在特征图的不同位置是否存在(因此得名特征图),而观察不同特征的最大值而不是平均值能够给出更多的信息。因此,最合理的子采样策略是首先生成密集的特征图(通过无步进的卷积),然后观察特征每个小图块上的最大激活,而不是查看输入的稀疏窗口(通过步进卷积)或对输入图块取平均,因为后两种方法可能导致错过或淡化特征是否存在的信息。

现在你应该已经理解了卷积神经网络的基本概念,即特征图、卷积和最大池化,并且也知道如何构建一个小型卷积神经网络来解决简单问题,比如MNIST 数字分类。下面我们将介绍更加实用的应用。

二、在小型数据集上从头开始训练一个卷积神经网络

使用很少的数据来训练一个图像分类模型,这是很常见的情况,如果你要从事计算机视觉方面的职业,很可能会在实践中遇到这种情况。“很少的”样本可能是几百张图像,也可能是几万张图像。来看一个实例,我们将重点讨论猫狗图像分类,数据集中包含4000 张猫和狗的图像(2000 张猫的图像,2000 张狗的图像)。我们将2000 张图像用于训练,1000 张用于验证,1000张用于测试。

本节将介绍解决这一问题的基本策略,即使用已有的少量数据从头开始训练一个新模型。首先,在2000 个训练样本上训练一个简单的小型卷积神经网络,不做任何正则化,为模型目标设定一个基准。这会得到71% 的分类精度。此时主要的问题在于过拟合。然后,我们会介绍数据增强(data augmentation),它在计算机视觉领域是一种非常强大的降低过拟合的技术。使用数据增强之后,网络精度将提高到82%。随后我们会介绍将深度学习应用于小型数据集的另外两个重要技巧:用预训练的网络做特征提取(得到的精度范围在90%~96%),对预训练的网络进行微调(最终精度为97%)。总而言之,这三种策略——从头开始训练一个小型模型、使用预训练的网络做特征提取、对预训练的网络进行微调——构成了你的工具箱,未来可用于解决小型数据集的图像分类问题。

1. 深度学习与小数据问题的相关性

有时你会听人说,仅在有大量数据可用时,深度学习才有效。这种说法部分正确:深度学习的一个基本特性就是能够独立地在训练数据中找到有趣的特征,无须人为的特征工程,而这只在拥有大量训练样本时才能实现。对于输入样本的维度非常高(比如图像)的问题尤其如此。

但对于初学者来说,所谓“大量”样本是相对的,即相对于你所要训练网络的大小和深度而言。只用几十个样本训练卷积神经网络就解决一个复杂问题是不可能的,但如果模型很小,并做了很好的正则化,同时任务非常简单,那么几百个样本可能就足够了。由于卷积神经网络学到的是局部的、平移不变的特征,它对于感知问题可以高效地利用数据。虽然数据相对较少,但在非常小的图像数据集上从头开始训练一个卷积神经网络,仍然可以得到不错的结果,而且无须任何自定义的特征工程。

此外,深度学习模型本质上具有高度的可复用性,比如,已有一个在大规模数据集上训练的图像分类模型或语音转文本模型,你只需做很小的修改就能将其复用于完全不同的问题。特别是在计算机视觉领域,许多预训练的模型(通常都是在ImageNet 数据集上训练得到的)现在都可以公开下载,并可以用于在数据很少的情况下构建强大的视觉模型。我们先来看一下数据。

2. 下载数据

本节用到的猫狗分类数据集不包含在Keras 中。它由Kaggle 在2013 年末公开并作为一项计算视觉竞赛的一部分,当时卷积神经网络还不是主流算法。你可以从https://www.kaggle.com/c/dogs-vs-cats/data 下载原始数据集。

这些图像都是中等分辨率的彩色JPEG 图像:

猫狗分类数据集的一些样本。没有修改尺寸:样本在尺寸、外观等方面是不一样的
猫狗分类数据集的一些样本。没有修改尺寸:样本在尺寸、外观等方面是不一样的

不出所料,2013 年的猫狗分类Kaggle 竞赛的优胜者使用的是卷积神经网络。最佳结果达到了95% 的精度。本例中,虽然你只在不到参赛选手所用的10% 的数据上训练模型,但结果也和这个精度相当接近。

这个数据集包含25 000 张猫狗图像(每个类别都有12 500 张),大小为543MB(压缩后)。下载数据并解压之后,你需要创建一个新数据集,其中包含三个子集:每个类别各1000 个样本的训练集、每个类别各500 个样本的验证集和每个类别各500 个样本的测试集。创建新数据集的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 将图像复制到训练、验证和测试的目录
import os, shutil
original_dataset_dir = 'data/cat_dog/kaggle_original_data'
base_dir = 'data/cat_dog/cats_and_dogs_small'
os.mkdir(base_dir)
train_dir = os.path.join(base_dir, 'train')
os.mkdir(train_dir)
validation_dir = os.path.join(base_dir, 'validation')
os.mkdir(validation_dir)
test_dir = os.path.join(base_dir, 'test')
os.mkdir(test_dir)
train_cats_dir = os.path.join(train_dir, 'cats')
os.mkdir(train_cats_dir)
train_dogs_dir = os.path.join(train_dir, 'dogs')
os.mkdir(train_dogs_dir)
validation_cats_dir = os.path.join(validation_dir, 'cats')
os.mkdir(validation_cats_dir)
validation_dogs_dir = os.path.join(validation_dir, 'dogs')
os.mkdir(validation_dogs_dir)
test_cats_dir = os.path.join(test_dir, 'cats')
os.mkdir(test_cats_dir)
test_dogs_dir = os.path.join(test_dir, 'dogs')
os.mkdir(test_dogs_dir)
fnames = ['cat.{}.jpg'.format(i) for i in range(1000)]
for fname in fnames:
src = os.path.join(original_dataset_dir, fname)
dst = os.path.join(train_cats_dir, fname)
shutil.copyfile(src, dst)
fnames = ['cat.{}.jpg'.format(i) for i in range(1000, 1500)]
for fname in fnames:
src = os.path.join(original_dataset_dir, fname)
dst = os.path.join(validation_cats_dir, fname)
shutil.copyfile(src, dst)
fnames = ['cat.{}.jpg'.format(i) for i in range(1500, 2000)]
for fname in fnames:
src = os.path.join(original_dataset_dir, fname)
dst = os.path.join(test_cats_dir, fname)
shutil.copyfile(src, dst)
fnames = ['dog.{}.jpg'.format(i) for i in range(1000)]
for fname in fnames:
src = os.path.join(original_dataset_dir, fname)
dst = os.path.join(train_dogs_dir, fname)
shutil.copyfile(src, dst)
fnames = ['dog.{}.jpg'.format(i) for i in range(1000, 1500)]
for fname in fnames:
src = os.path.join(original_dataset_dir, fname)
dst = os.path.join(validation_dogs_dir, fname)
shutil.copyfile(src, dst)
fnames = ['dog.{}.jpg'.format(i) for i in range(1500, 2000)]
for fname in fnames:
src = os.path.join(original_dataset_dir, fname)
dst = os.path.join(test_dogs_dir, fname)
shutil.copyfile(src, dst)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import os, shutil
base_dir = 'data/cat_dog/cats_and_dogs_small'
train_dir = os.path.join(base_dir, 'train')
test_dir = os.path.join(base_dir, 'test')
validation_dir = os.path.join(base_dir, 'validation')
train_cats_dir = os.path.join(train_dir, 'cats')
test_cats_dir = os.path.join(test_dir, 'cats')
validation_cats_dir = os.path.join(validation_dir, 'cats')
train_dogs_dir = os.path.join(train_dir, 'dogs')
test_dogs_dir = os.path.join(test_dir, 'dogs')
validation_dogs_dir = os.path.join(validation_dir, 'dogs')
print('total training cat images:', len(os.listdir(train_cats_dir)))
print('total training dog images:', len(os.listdir(train_dogs_dir)))
print('total validation cat images:', len(os.listdir(validation_cats_dir)))
print('total validation dog images:', len(os.listdir(validation_dogs_dir)))
print('total test cat images:', len(os.listdir(test_cats_dir)))
print('total test dog images:', len(os.listdir(test_dogs_dir)))
total training cat images: 1000
total training dog images: 1000
total validation cat images: 500
total validation dog images: 500
total test cat images: 500
total test dog images: 500

所以我们的确有2000 张训练图像、1000 张验证图像和1000 张测试图像。每个分组中两个类别的样本数相同,这是一个平衡的二分类问题,分类精度可作为衡量成功的指标。

3. 构建网络

在前一个MNIST示例中,我们构建了一个小型卷积神经网络,所以你应该已经熟悉这种网络。我们将复用相同的总体结构,即卷积神经网络由Conv2D层(使用relu激活)和MaxPooling2D层交替堆叠构成。

但由于这里要处理的是更大的图像和更复杂的问题,你需要相应地增大网络,即再增加一个Conv2D+MaxPooling2D的组合。这既可以增大网络容量,也可以进一步减小特征图的尺寸,使其在连接Flatten层时尺寸不会太大。本例中初始输入的尺寸为150×150(有些随意的选择),所以最后在Flatten层之前的特征图大小为7×7。

你面对的是一个二分类问题,所以网络最后一层是使用sigmoid激活的单一单元(大小为 1 的Dense层)。这个单元将对某个类别的概率进行编码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 将猫狗分类的小型卷积神经网络实例化
from keras import layers
from keras import models

model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation="relu", input_shape=(150, 150, 3)))
model.add(layers.MaxPooling2D(2, 2))
model.add(layers.Conv2D(64, (3, 3), activation="relu"))
model.add(layers.MaxPooling2D(2, 2))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Flatten())
model.add(layers.Dense(512, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))
model.summary()
Model: "sequential_4"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_8 (Conv2D)            (None, 148, 148, 32)      896       
_________________________________________________________________
max_pooling2d_4 (MaxPooling2 (None, 74, 74, 32)        0         
_________________________________________________________________
conv2d_9 (Conv2D)            (None, 72, 72, 64)        18496     
_________________________________________________________________
max_pooling2d_5 (MaxPooling2 (None, 36, 36, 64)        0         
_________________________________________________________________
conv2d_10 (Conv2D)           (None, 34, 34, 128)       73856     
_________________________________________________________________
max_pooling2d_6 (MaxPooling2 (None, 17, 17, 128)       0         
_________________________________________________________________
flatten_2 (Flatten)          (None, 36992)             0         
_________________________________________________________________
dense_3 (Dense)              (None, 512)               18940416  
_________________________________________________________________
dense_4 (Dense)              (None, 1)                 513       
=================================================================
Total params: 19,034,177
Trainable params: 19,034,177
Non-trainable params: 0
_________________________________________________________________

在编译这一步,和前面一样,我们将使用RMSprop 优化器。因为网络最后一层是单一sigmoid单元,所以我们将使用二元交叉熵作为损失函数

1
2
3
# 配置模型用于训练
from keras import optimizers
model.compile(loss='binary_crossentropy',optimizer=optimizers.RMSprop(lr=1e-4),metrics=['acc'])

4. 数据预处理

你现在已经知道,将数据输入神经网络之前,应该将数据格式化为经过预处理的浮点数张量。现在,数据以 JPEG 文件的形式保存在硬盘中,所以数据预处理步骤大致如下:

  1. 读取图像文件
  2. 将JPEG文件解码为RGB像素网格
  3. 将这些像素网格转换为浮点数张量
  4. 将像素值(0~255 范围内)缩放到 [0, 1] 区间(正如你所知,神经网络喜欢处理较小的输
    入值)

这些步骤可能看起来有点吓人,但幸运的是,Keras 拥有自动完成这些步骤的工具。Keras有一个图像处理辅助工具的模块,位于keras.preprocessing.image。特别地,它包含ImageDataGenerator类,可以快速创建Python生成器,能够将硬盘上的图像文件自动转换为预处理好的张量批量。下面我们将用到这个类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 使用ImageDataGenerator 从目录中读取图像
from keras.preprocessing.image import ImageDataGenerator
train_datagen = ImageDataGenerator(rescale=1./255)
test_datagen = ImageDataGenerator(rescale=1./255)
train_generator = train_datagen.flow_from_directory(
train_dir,
target_size=(150, 150),
batch_size=20,
class_mode='binary')
validation_generator = test_datagen.flow_from_directory(
validation_dir,
target_size=(150, 150),
batch_size=20,
class_mode='binary')
Found 2000 images belonging to 2 classes.
Found 1000 images belonging to 2 classes.

我们来看一下其中一个生成器的输出:它生成了150×150 的RGB 图像[形状为(20,150, 150, 3)]与二进制标签[形状为(20,)]组成的批量。每个批量中包含20 个样本(批量大小)。注意,生成器会不停地生成这些批量,它会不断循环目标文件夹中的图像。因此,你需要在某个时刻终止(break)迭代循环。

1
2
3
4
for data_batch, labels_batch in train_generator:
print('data batch shape:', data_batch.shape)
print('labels batch shape:', labels_batch.shape)
break
data batch shape: (20, 150, 150, 3)
labels batch shape: (20,)

利用生成器,我们让模型对数据进行拟合。我们将使用fit_generator方法来拟合,它在数据生成器上的效果和fit 相同。它的第一个参数应该是一个Python生成器,可以不停地生成输入和目标组成的批量,比如train_generator。因为数据是不断生成的,所以Keras模型要知道每一轮需要从生成器中抽取多少个样本。这是steps_per_epoch参数的作用:从生成器中抽取steps_per_epoch个批量后(即运行了steps_per_epoch次梯度下降),拟合过程将进入下一个轮次。本例中,每个批量包含20个样本,所以读取完所有2000 个样本需要100个批量。

使用fit_generator时,你可以传入一个validation_data参数,其作用和在fit方法中类似。值得注意的是,这个参数可以是一个数据生成器,但也可以是Numpy数组组成的元组。如果向validation_data传入一个生成器,那么这个生成器应该能够不停地生成验证数据批量,因此你还需要指定validation_steps参数,说明需要从验证生成器中抽取多少个批次用于评估。

5. 拟合模型

1
2
3
4
5
6
history = model.fit_generator(
train_generator,
steps_per_epoch=100,
epochs=30,
validation_data=validation_generator,
validation_steps=50)

始终在训练完成后保存模型,这是一种良好实践。

1
2
# 保存模型
model.save('model/ComputerVersion/cats_and_dogs_small_1.h5')

我们来分别绘制训练过程中模型在训练数据和验证数据上的损失和精度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import matplotlib.pyplot as plt

acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']
epochs = range(1, len(acc) + 1)

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.figure()

plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()
训练损失和验证损失
训练损失和验证损失
训练精度和验证精度
训练精度和验证精度

从这些图像中都能看出过拟合的特征。训练精度随着时间线性增加,直到接近100%,而验证精度则停留在70%~72%。验证损失仅在5 轮后就达到最小值,然后保持不变,而训练损失则一直线性下降,直到接近于0。

因为训练样本相对较少(2000 个),所以过拟合是你最关心的问题。前面已经介绍过几种降低过拟合的技巧,比如dropout 和权重衰减(L2 正则化)。现在我们将使用一种针对于计算机视觉领域的新方法,在用深度学习模型处理图像时几乎都会用到这种方法,它就是数据增强(data augmentation)。

6. 使用数据增强

过拟合的原因是学习样本太少,导致无法训练出能够泛化到新数据的模型。如果拥有无限的数据,那么模型能够观察到数据分布的所有内容,这样就永远不会过拟合。数据增强是从现有的训练样本中生成更多的训练数据,其方法是利用多种能够生成可信图像的随机变换来增加(augment)样本。其目标是,模型在训练时不会两次查看完全相同的图像。这让模型能够观察到数据的更多内容,从而具有更好的泛化能力。

在Keras 中,这可以通过对ImageDataGenerator实例读取的图像执行多次随机变换来实现。我们先来看一个例子。

1
2
3
4
5
6
7
8
9
# 利用ImageDataGenerator 来设置数据增强
datagen = ImageDataGenerator(
rotation_range=40,
width_shift_range=0.2,
height_shift_range=0.2,
shear_range=0.2,
zoom_range=0.2,
horizontal_flip=True,
fill_mode='nearest')

这里只选择了几个参数(想了解更多参数,请查阅Keras 文档)。我们来快速介绍一下这些
参数的含义。

  • rotation_range是角度值(在 0~180 范围内),表示图像随机旋转的角度范围。
  • width_shiftheight_shift 是图像在水平或垂直方向上平移的范围(相对于总宽度或总高度的比例)。
  • shear_range是随机错切变换的角度。
  • zoom_range是图像随机缩放的范围。
  • horizontal_flip 是随机将一半图像水平翻转。如果没有水平不对称的假设(比如真实世界的图像),这种做法是有意义的。
  • fill_mode是用于填充新创建像素的方法,这些新像素可能来自于旋转或宽度/高度平移。

我们来看一下增强后的图像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 显示几个随机增强后的训练图像
from keras.preprocessing import image
fnames = [os.path.join(train_cats_dir, fname) for fname in os.listdir(train_cats_dir)]
img_path = fnames[3]
img = image.load_img(img_path, target_size=(150, 150))
x = image.img_to_array(img)
x = x.reshape((1,) + x.shape)
i = 0
for batch in datagen.flow(x, batch_size=1):
plt.figure(i)
imgplot = plt.imshow(image.array_to_img(batch[0]))
i += 1
if i % 4 == 0:
break
plt.show()
增强后的图像
增强后的图像(1)
增强后的图像
增强后的图像(2)
增强后的图像
增强后的图像(3)
增强后的图像
增强后的图像(4)

如果你使用这种数据增强来训练一个新网络,那么网络将不会两次看到同样的输入。但网络看到的输入仍然是高度相关的,因为这些输入都来自于少量的原始图像。你无法生成新信息,而只能混合现有信息。因此,这种方法可能不足以完全消除过拟合。为了进一步降低过拟合,你还需要向模型中添加一个Dropout层,添加到密集连接分类器之前。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
model = models.Sequential()

model.add(layers.Conv2D(32, (3, 3), activation='relu',input_shape=(150, 150, 3)))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Flatten())
model.add(layers.Dropout(0.5))
model.add(layers.Dense(512, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))

model.compile(loss='binary_crossentropy',optimizer=optimizers.RMSprop(lr=1e-4),metrics=['acc'])

我们来训练这个使用了数据增强和dropout 的网络。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 利用数据增强生成器训练卷积神经网络
train_datagen = ImageDataGenerator(
rescale=1./255,
rotation_range=40,
width_shift_range=0.2,
height_shift_range=0.2,
shear_range=0.2,
zoom_range=0.2,
horizontal_flip=True,)

test_datagen = ImageDataGenerator(rescale=1./255)

train_generator = train_datagen.flow_from_directory(
train_dir,
target_size=(150, 150),
batch_size=32,
class_mode='binary')

validation_generator = test_datagen.flow_from_directory(
validation_dir,
target_size=(150, 150),
batch_size=32,
class_mode='binary')

history = model.fit_generator(
train_generator,
steps_per_epoch=100,
epochs=100,
validation_data=validation_generator,
validation_steps=50)
1
2
# 保存模型
model.save('model/ComputerVersion/cats_and_dogs_small_2.h5')

我们再次绘制结果,使用了数据增强和dropout 之后,模型不再过拟合:训练曲线紧紧跟随着验证曲线。现在的精度为82%,比未正则化的模型提高了15%(相对比例)。

通过进一步使用正则化方法以及调节网络参数(比如每个卷积层的过滤器个数或网络中的层数),你可以得到更高的精度,可以达到86%或87%。但只靠从头开始训练自己的卷积神经网络,再想提高精度就十分困难,因为可用的数据太少。想要在这个问题上进一步提高精度,下一步需要使用预训练的模型,这是接下来两节的重点。

三、使用预训练的卷积神经网络

想要将深度学习应用于小型图像数据集,一种常用且非常高效的方法是使用预训练网络。预训练网络(pretrained network)是一个保存好的网络,之前已在大型数据集(通常是大规模图像分类任务)上训练好。如果这个原始数据集足够大且足够通用,那么预训练网络学到的特征的空间层次结构可以有效地作为视觉世界的通用模型,因此这些特征可用于各种不同的计算机视觉问题,即使这些新问题涉及的类别和原始任务完全不同。举个例子,你在ImageNet 上训练了一个网络(其类别主要是动物和日常用品),然后将这个训练好的网络应用于某个不相干的任务,比如在图像中识别家具。这种学到的特征在不同问题之间的可移植性,是深度学习与许多早期浅层学习方法相比的重要优势,它使得深度学习对小数据问题非常有效。

本例中,假设有一个在ImageNet 数据集(140 万张标记图像,1000 个不同的类别)上训练好的大型卷积神经网络。ImageNet 中包含许多动物类别,其中包括不同种类的猫和狗,因此可以认为它在猫狗分类问题上也能有良好的表现。我们将使用VGG16 架构,它由Karen Simonyan 和Andrew Zisserman 在2014 年开发a。对于ImageNet,它是一种简单而又广泛使用的卷积神经网络架构。虽然VGG16 是一个比较旧的模型,性能远比不了当前最先进的模型,而且还比许多新模型更为复杂,但我之所以选择它,是因为它的架构与你已经熟悉的架构很相似,因此无须引入新概念就可以很好地理解。这可能是你第一次遇到这种奇怪的模型名称——VGG、ResNet、Inception、Inception-ResNet、Xception 等。你会习惯这些名称的,因为如果你一直用深度学习做计算机视觉的话,它们会频繁出现。使用预训练网络有两种方法:特征提取(feature extraction)和微调模型(fine-tuning)。两种方法我们都会介绍。首先来看特征提取。

1. 特征提取

特征提取是使用之前网络学到的表示来从新样本中提取出有趣的特征。然后将这些特征输入一个新的分类器,从头开始训练。

如前所述,用于图像分类的卷积神经网络包含两部分:首先是一系列池化层和卷积层,最后是一个密集连接分类器。第一部分叫作模型的卷积基(convolutional base)。对于卷积神经网络而言,特征提取就是取出之前训练好的网络的卷积基,在上面运行新数据,然后在输出上面训练一个新的分类器

保持卷积基不变,改变分类器
保持卷积基不变,改变分类器

为什么仅重复使用卷积基?我们能否也重复使用密集连接分类器?一般来说,应该避免这么做。原因在于卷积基学到的表示可能更加通用,因此更适合重复使用。卷积神经网络的特征图表示通用概念在图像中是否存在,无论面对什么样的计算机视觉问题,这种特征图都可能很有用。但是,分类器学到的表示必然是针对于模型训练的类别,其中仅包含某个类别出现在整张图像中的概率信息。此外,密集连接层的表示不再包含物体在输入图像中的位置信息。密集连接层舍弃了空间的概念,而物体位置信息仍然由卷积特征图所描述。如果物体位置对于问题很重要,那么密集连接层的特征在很大程度上是无用的。

注意,某个卷积层提取的表示的通用性(以及可复用性)取决于该层在模型中的深度。模型中更靠近底部的层提取的是局部的、高度通用的特征图(比如视觉边缘、颜色和纹理),而更靠近顶部的层提取的是更加抽象的概念(比如“猫耳朵”或“狗眼睛”)。因此,如果你的新数据集与原始模型训练的数据集有很大差异,那么最好只使用模型的前几层来做特征提取,而不是使用整个卷积基。

本例中,由于ImageNet的类别中包含多种狗和猫的类别,所以重复使用原始模型密集连接层中所包含的信息可能很有用。但我们选择不这么做,以便涵盖新问题的类别与原始模型的类别不一致的更一般情况。我们来实践一下,使用在ImageNet上训练的VGG16 网络的卷积基从猫狗图像中提取有趣的特征,然后在这些特征上训练一个猫狗分类器。VGG16 等模型内置于Keras 中。你可以从keras.applications模块中导入。下面是keras.applications中的一部分图像分类模型(都是在ImageNet数据集上预训练得到的):

  • Xception
  • Inception V3
  • ResNet50
  • VGG16
  • VGG19
  • MobileNet

我们将VGG16模型实例化。

1
2
3
# 将 VGG16 卷积基实例化
from keras.applications import VGG16
conv_base = VGG1616(weights='imagenet', include_top=False, input_shape=(150, 150, 3))

这里向构造函数中传入了三个参数。

  • weights指定模型初始化的权重检查点。
  • include_top 指定模型最后是否包含密集连接分类器。默认情况下,这个密集连接分类器对应于ImageNet的1000个类别。因为我们打算使用自己的密集连接分类器(只有两个类别:cat和dog),所以不需要包含它。
  • input_shape是输入到网络中的图像张量的形状。这个参数完全是可选的,如果不传入这个参数,那么网络能够处理任意形状的输入。VGG16卷积基的详细架构如下所示。它和你已经熟悉的简单卷积神经网络很相似。
1
conv_base.summary()
Model: "vgg16"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
input_1 (InputLayer)         (None, 150, 150, 3)       0         
_________________________________________________________________
block1_conv1 (Conv2D)        (None, 150, 150, 64)      1792      
_________________________________________________________________
block1_conv2 (Conv2D)        (None, 150, 150, 64)      36928     
_________________________________________________________________
block1_pool (MaxPooling2D)   (None, 75, 75, 64)        0         
_________________________________________________________________
block2_conv1 (Conv2D)        (None, 75, 75, 128)       73856     
_________________________________________________________________
block2_conv2 (Conv2D)        (None, 75, 75, 128)       147584    
_________________________________________________________________
block2_pool (MaxPooling2D)   (None, 37, 37, 128)       0         
_________________________________________________________________
block3_conv1 (Conv2D)        (None, 37, 37, 256)       295168    
_________________________________________________________________
block3_conv2 (Conv2D)        (None, 37, 37, 256)       590080    
_________________________________________________________________
block3_conv3 (Conv2D)        (None, 37, 37, 256)       590080    
_________________________________________________________________
block3_pool (MaxPooling2D)   (None, 18, 18, 256)       0         
_________________________________________________________________
block4_conv1 (Conv2D)        (None, 18, 18, 512)       1180160   
_________________________________________________________________
block4_conv2 (Conv2D)        (None, 18, 18, 512)       2359808   
_________________________________________________________________
block4_conv3 (Conv2D)        (None, 18, 18, 512)       2359808   
_________________________________________________________________
block4_pool (MaxPooling2D)   (None, 9, 9, 512)         0         
_________________________________________________________________
block5_conv1 (Conv2D)        (None, 9, 9, 512)         2359808   
_________________________________________________________________
block5_conv2 (Conv2D)        (None, 9, 9, 512)         2359808   
_________________________________________________________________
block5_conv3 (Conv2D)        (None, 9, 9, 512)         2359808   
_________________________________________________________________
block5_pool (MaxPooling2D)   (None, 4, 4, 512)         0         
=================================================================
Total params: 14,714,688
Trainable params: 14,714,688
Non-trainable params: 0
_________________________________________________________________

最后的特征图形状为(4, 4, 512)。我们将在这个特征上添加一个密集连接分类器。接下来,下一步有两种方法可供选择:

  • 在你的数据集上运行卷积基,将输出保存成硬盘中的Numpy数组,然后用这个数据作为输入,输入到独立的密集连接分类器中。这种方法速度快,计算代价低,因为对于每个输入图像只需运行一次卷积基,而卷积基是目前流程中计算代价最高的。但出于同样的原因,这种方法不允许你使用数据增强。
  • 在顶部添加Dense层来扩展已有模型(即conv_base),并在输入数据上端到端地运行整个模型。这样你可以使用数据增强,因为每个输入图像进入模型时都会经过卷积基。但出于同样的原因,这种方法的计算代价比第一种要高很多。

2. 不使用数据增强的快速特征提取

首先,运行ImageDataGenerator实例,将图像及其标签提取为Numpy数组。我们需要调用conv_base模型的predict方法来从这些图像中提取特征。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 使用预训练的卷积基提取特征
import os
import numpy as np
from keras.preprocessing.image import ImageDataGenerator
base_dir = 'data/cat_dog/cats_and_dogs_small'
train_dir = os.path.join(base_dir, 'train')
validation_dir = os.path.join(base_dir, 'validation')
test_dir = os.path.join(base_dir, 'test')
datagen = ImageDataGenerator(rescale=1./255)
batch_size = 20

def extract_features(directory, sample_count):
features = np.zeros(shape=(sample_count, 4, 4, 512))
labels = np.zeros(shape=(sample_count))
generator = datagen.flow_from_directory(
directory,
target_size=(150, 150),
batch_size=batch_size,
class_mode='binary')
i = 0
for inputs_batch, labels_batch in generator:
features_batch = conv_base.predict(inputs_batch)
features[i * batch_size : (i + 1) * batch_size] = features_batch
labels[i * batch_size : (i + 1) * batch_size] = labels_batch
i += 1
if i * batch_size >= sample_count:
break
return features, labels

train_features, train_labels = extract_features(train_dir, 2000)
validation_features, validation_labels = extract_features(validation_dir, 1000)
test_features, test_labels = extract_features(test_dir, 1000)
Found 2000 images belonging to 2 classes.
Found 1000 images belonging to 2 classes.
Found 1000 images belonging to 2 classes.

目前,提取的特征形状为(samples, 4, 4, 512)。我们要将其输入到密集连接分类器中,所以首先必须将其形状展平为(samples, 8192)

1
2
3
train_features = np.reshape(train_features, (2000, 4 * 4 * 512))
validation_features = np.reshape(validation_features, (1000, 4 * 4 * 512))
test_features = np.reshape(test_features, (1000, 4 * 4 * 512))

现在你可以定义你的密集连接分类器(注意要使用dropout正则化),并在刚刚保存的数据和标签上训练这个分类器。

定义并训练密集连接分类器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from keras import models
from keras import layers
from keras import optimizers
model = models.Sequential()
model.add(layers.Dense(256, activation='relu', input_dim=4 * 4 * 512))
model.add(layers.Dropout(0.5))
model.add(layers.Dense(1, activation='sigmoid'))
model.compile(optimizer=optimizers.RMSprop(lr=2e-5),
loss='binary_crossentropy',
metrics=['acc'])
history = model.fit(train_features, train_labels,
epochs=30,
batch_size=20,
validation_data=(validation_features, validation_labels))

训练速度非常快,因为你只需处理两个Dense层。我们来看一下训练期间的损失曲线和精度曲线:

绘制结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import matplotlib.pyplot as plt

acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']

epochs = range(1, len(acc) + 1)
plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.figure()
plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()
训练损失和验证损失
训练损失和验证损失
训练精度和验证精度
训练精度和验证精度

我们的验证精度达到了约90%,比上一节从头开始训练的小型模型效果要好得多。但从图中也可以看出,虽然dropout比率相当大,但模型几乎从一开始就过拟合。这是因为本方法没有使用数据增强,而数据增强对防止小型图像数据集的过拟合非常重要。

3. 使用数据增强的特征提取

下面我们来看一下特征提取的第二种方法,它的速度更慢,计算代价更高,但在训练期间可以使用数据增强。这种方法就是:扩展conv_base模型,然后在输入数据上端到端地运行模型。

注意 本方法计算代价很高,只在有GPU的情况下才能尝试运行。它在CPU上是绝对难以运行的。如果你无法在GPU上运行代码,那么就采用第一种方法。

模型的行为和层类似,所以你可以向Sequential模型中添加一个模型(比如conv_base),就像添加一个层一样。

1
2
3
4
5
6
7
8
# 在卷积基上添加一个密集连接分类器
from keras import models
from keras import layers
model = models.Sequential()
model.add(conv_base)
model.add(layers.Flatten())
model.add(layers.Dense(256, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))

现在模型的架构如下所示:

1
model.summary()
Model: "sequential_7"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
vgg16 (Model)                (None, 4, 4, 512)         14714688  
_________________________________________________________________
flatten_4 (Flatten)          (None, 8192)              0         
_________________________________________________________________
dense_9 (Dense)              (None, 256)               2097408   
_________________________________________________________________
dense_10 (Dense)             (None, 1)                 257       
=================================================================
Total params: 16,812,353
Trainable params: 16,812,353
Non-trainable params: 0
_________________________________________________________________

如你所见,VGG16的卷积基有14 714 688个参数,非常多。在其上添加的分类器有200万个参数。

在编译和训练模型之前,一定要“冻结”卷积基。冻结(freeze)一个或多个层是指在训练过程中保持其权重不变。如果不这么做,那么卷积基之前学到的表示将会在训练过程中被修改。因为其上添加的Dense层是随机初始化的,所以非常大的权重更新将会在网络中传播,对之前学到的表示造成很大破坏。

Keras中,冻结网络的方法是将其trainable属性设为False

1
2
3
print('This is the number of trainable weights before freezing the conv base:', len(model.trainable_weights))
conv_base.trainable = False
print('This is the number of trainable weights after freezing the conv base:', len(model.trainable_weights))
This is the number of trainable weights before freezing the conv base: 30
This is the number of trainable weights after freezing the conv base: 4

如此设置之后,只有添加的两个Dense 层的权重才会被训练。总共有4 个权重张量,每层 2 个(主权重矩阵和偏置向量)。注意,为了让这些修改生效,你必须先编译模型。如果在编译之后修改了权重的trainable属性,那么应该重新编译模型,否则这些修改将被忽略。

现在你可以开始训练模型了,使用和前一个例子相同的数据增强设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 利用冻结的卷积基端到端地训练模型
from keras.preprocessing.image import ImageDataGenerator
from keras import optimizers

train_datagen = ImageDataGenerator(
rescale=1./255,
rotation_range=40,
width_shift_range=0.2,
height_shift_range=0.2,
shear_range=0.2,
zoom_range=0.2,
horizontal_flip=True,
fill_mode='nearest')

test_datagen = ImageDataGenerator(rescale=1./255)

train_generator = train_datagen.flow_from_directory(
train_dir,
target_size=(150, 150),
batch_size=20,
class_mode='binary')

validation_generator = test_datagen.flow_from_directory(
validation_dir,
target_size=(150, 150),
batch_size=20,
class_mode='binary')

model.compile(loss='binary_crossentropy',
optimizer=optimizers.RMSprop(lr=2e-5),
metrics=['acc'])
Found 2000 images belonging to 2 classes.
Found 1000 images belonging to 2 classes.
1
2
3
4
5
6
history = model.fit_generator(
train_generator,
steps_per_epoch=100,
epochs=30,
validation_data=validation_generator,
validation_steps=50)
1
model.save('model/ComputerVersion/cats_and_dogs_small_dataEnforcementFeatureExtraction.h5')

如你所见,这比从头开始训练的小型卷积神经网络要好得多。

4. 微调模型

另一种广泛使用的模型复用方法是模型微调(fine-tuning),与特征提取互为补充。对于用于特征提取的冻结的模型基,微调是指将其顶部的几层“解冻”,并将这解冻的几层和新增加的部分(本例中是全连接分类器)联合训练。之所以叫作微调,是因为它只是略微调整了所复用模型中更加抽象的表示,以便让这些表示与手头的问题更加相关。

微调VGG16网络的最后一个卷积块
微调VGG16网络的最后一个卷积块

前面说过,冻结VGG16的卷积基是为了能够在上面训练一个随机初始化的分类器。同理,只有上面的分类器已经训练好了,才能微调卷积基的顶部几层。如果分类器没有训练好,那么训练期间通过网络传播的误差信号会特别大,微调的几层之前学到的表示都会被破坏。因此,微调网络的步骤如下。

  1. 在已经训练好的基网络(base network)上添加自定义网络。
  2. 冻结基网络。
  3. 训练所添加的部分。
  4. 解冻基网络的一些层。
  5. 联合训练解冻的这些层和添加的部分。

你在做特征提取时已经完成了前三个步骤。我们继续进行第四步:先解冻conv_base,然后冻结其中的部分层。提醒一下,卷积基的架构如下所示:

1
conv_base.summary()
Model: "vgg16"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
input_1 (InputLayer)         (None, 150, 150, 3)       0         
_________________________________________________________________
block1_conv1 (Conv2D)        (None, 150, 150, 64)      1792      
_________________________________________________________________
block1_conv2 (Conv2D)        (None, 150, 150, 64)      36928     
_________________________________________________________________
block1_pool (MaxPooling2D)   (None, 75, 75, 64)        0         
_________________________________________________________________
block2_conv1 (Conv2D)        (None, 75, 75, 128)       73856     
_________________________________________________________________
block2_conv2 (Conv2D)        (None, 75, 75, 128)       147584    
_________________________________________________________________
block2_pool (MaxPooling2D)   (None, 37, 37, 128)       0         
_________________________________________________________________
block3_conv1 (Conv2D)        (None, 37, 37, 256)       295168    
_________________________________________________________________
block3_conv2 (Conv2D)        (None, 37, 37, 256)       590080    
_________________________________________________________________
block3_conv3 (Conv2D)        (None, 37, 37, 256)       590080    
_________________________________________________________________
block3_pool (MaxPooling2D)   (None, 18, 18, 256)       0         
_________________________________________________________________
block4_conv1 (Conv2D)        (None, 18, 18, 512)       1180160   
_________________________________________________________________
block4_conv2 (Conv2D)        (None, 18, 18, 512)       2359808   
_________________________________________________________________
block4_conv3 (Conv2D)        (None, 18, 18, 512)       2359808   
_________________________________________________________________
block4_pool (MaxPooling2D)   (None, 9, 9, 512)         0         
_________________________________________________________________
block5_conv1 (Conv2D)        (None, 9, 9, 512)         2359808   
_________________________________________________________________
block5_conv2 (Conv2D)        (None, 9, 9, 512)         2359808   
_________________________________________________________________
block5_conv3 (Conv2D)        (None, 9, 9, 512)         2359808   
_________________________________________________________________
block5_pool (MaxPooling2D)   (None, 4, 4, 512)         0         
=================================================================
Total params: 14,714,688
Trainable params: 7,079,424
Non-trainable params: 7,635,264
_________________________________________________________________

我们将微调最后三个卷积层,也就是说,直到block4_pool的所有层都应该被冻结,而block5_conv1block5_conv2block5_conv3三层应该是可训练的。为什么不微调更多层?为什么不微调整个卷积基?你当然可以这么做,但需要考虑以下几点。

  • 卷积基中更靠底部的层编码的是更加通用的可复用特征,而更靠顶部的层编码的是更专业化的特征。微调这些更专业化的特征更加有用,因为它们需要在你的新问题上改变用途。微调更靠底部的层,得到的回报会更少。
  • 训练的参数越多,过拟合的风险越大。卷积基有 1500 万个参数,所以在你的小型数据集上训练这么多参数是有风险的。

因此,在这种情况下,一个好策略是仅微调卷积基最后的两三层。我们从上一个例子结束的地方开始,继续实现此方法。

1
2
3
4
5
6
7
8
9
10
# 冻结直到某一层的所有层
conv_base.trainable = True
set_trainable = False
for layer in conv_base.layers:
if layer.name == 'block5_conv1':
set_trainable = True
if set_trainable:
layer.trainable = True
else:
layer.trainable = False

现在你可以开始微调网络。我们将使用学习率非常小的RMSProp优化器来实现。之所以让学习率很小,是因为对于微调的三层表示,我们希望其变化范围不要太大,太大的权重更新可能会破坏这些表示。

1
2
3
4
5
6
7
8
9
10
# 微调模型
model.compile(loss='binary_crossentropy',
optimizer=optimizers.RMSprop(lr=1e-5),
metrics=['acc'])
history = model.fit_generator(
train_generator,
steps_per_epoch=100,
epochs=100,
validation_data=validation_generator,
validation_steps=50)
1
model.save('model/ComputerVersion/cats_and_dogs_small_dataEnforcementFineTuning.h5')

我们用和前面一样的绘图代码来绘制结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import matplotlib.pyplot as plt

acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']

epochs = range(1, len(acc) + 1)
plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.figure()
plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()
训练精度和验证精度
训练精度和验证精度
训练损失和验证损失
训练损失和验证损失

这些曲线看起来包含噪声。为了让图像更具可读性,你可以将每个损失和精度都替换为指数移动平均值,从而让曲线变得平滑。下面用一个简单的实用函数来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 使曲线变得平滑
def smooth_curve(points, factor=0.8):
smoothed_points = []
for point in points:
if smoothed_points:
previous = smoothed_points[-1]
smoothed_points.append(previous * factor + point * (1 - factor))
else:
smoothed_points.append(point)
return smoothed_points

plt.plot(epochs,smooth_curve(acc), 'bo', label='Smoothed training acc')
plt.plot(epochs,smooth_curve(val_acc), 'b', label='Smoothed validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.figure()
plt.plot(epochs,smooth_curve(loss), 'bo', label='Smoothed training loss')
plt.plot(epochs,smooth_curve(val_loss), 'b', label='Smoothed validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()
训练精度和验证精度
平滑后的训练精度和验证精度
训练损失和验证损失
平滑后的训练损失和验证损失

注意,从损失曲线上看不出与之前相比有任何真正的提高(实际上还在变差)。你可能感到奇怪,如果损失没有降低,那么精度怎么能保持稳定或提高呢?答案很简单:图中展示的是逐点(pointwise)损失值的平均值,但影响精度的是损失值的分布,而不是平均值,因为精度是模型预测的类别概率的二进制阈值。即使从平均损失中无法看出,但模型也仍然可能在改进。现在,你可以在测试数据上最终评估这个模型。

1
2
3
4
5
6
7
test_generator = test_datagen.flow_from_directory(
test_dir,
target_size=(150, 150),
batch_size=20,
class_mode='binary')
test_loss, test_acc = model.evaluate_generator(test_generator, steps=50)
print('test acc:', test_acc)
Found 1000 images belonging to 2 classes.
test acc: 0.9399999976158142

四、卷积神经网络的可视化

人们常说,深度学习模型是“黑盒”,即模型学到的表示很难用人类可以理解的方式来提取和呈现。虽然对于某些类型的深度学习模型来说,这种说法部分正确,但对卷积神经网络来说绝对不是这样。卷积神经网络学到的表示非常适合可视化,很大程度上是因为它们是视觉概念的表示。自2013 年以来,人们开发了多种技术来对这些表示进行可视化和解释。我们不会全部介绍,但会介绍三种最容易理解也最有用的方法。

  • 可视化卷积神经网络的中间输出(中间激活):有助于理解卷积神经网络连续的层如何对输入进行变换,也有助于初步了解卷积神经网络每个过滤器的含义。
  • 可视化卷积神经网络的过滤器:有助于精确理解卷积神经网络中每个过滤器容易接受的视觉模式或视觉概念。
  • 可视化图像中类激活的热力图:有助于理解图像的哪个部分被识别为属于某个类别,从而可以定位图像中的物体。

对于第一种方法(即激活的可视化),我们将使用猫狗分类问题上从头开始训练的小型卷积神经网络。对于另外两种可视化方法,我们将使用VGG16模型。

1. 可视化中间激活

可视化中间激活,是指对于给定输入,展示网络中各个卷积层和池化层输出的特征图(层的输出通常被称为该层的激活,即激活函数的输出)。这让我们可以看到输入如何被分解为网络学到的不同过滤器。我们希望在三个维度对特征图进行可视化:宽度、高度和深度(通道)。每个通道都对应相对独立的特征,所以将这些特征图可视化的正确方法是将每个通道的内容分别绘制成二维图像。我们首先来加载先前保存的模型。

1
2
3
from keras.models import load_model
model = load_model('model/ComputerVersion/cats_and_dogs_small_2.h5')
model.summary()
Model: "sequential_5"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_11 (Conv2D)           (None, 148, 148, 32)      896       
_________________________________________________________________
max_pooling2d_7 (MaxPooling2 (None, 74, 74, 32)        0         
_________________________________________________________________
conv2d_12 (Conv2D)           (None, 72, 72, 64)        18496     
_________________________________________________________________
max_pooling2d_8 (MaxPooling2 (None, 36, 36, 64)        0         
_________________________________________________________________
conv2d_13 (Conv2D)           (None, 34, 34, 128)       73856     
_________________________________________________________________
max_pooling2d_9 (MaxPooling2 (None, 17, 17, 128)       0         
_________________________________________________________________
conv2d_14 (Conv2D)           (None, 15, 15, 128)       147584    
_________________________________________________________________
max_pooling2d_10 (MaxPooling (None, 7, 7, 128)         0         
_________________________________________________________________
flatten_3 (Flatten)          (None, 6272)              0         
_________________________________________________________________
dropout_1 (Dropout)          (None, 6272)              0         
_________________________________________________________________
dense_5 (Dense)              (None, 512)               3211776   
_________________________________________________________________
dense_6 (Dense)              (None, 1)                 513       
=================================================================
Total params: 3,453,121
Trainable params: 3,453,121
Non-trainable params: 0
_________________________________________________________________

接下来,我们需要一张输入图像,即一张猫的图像,它不属于网络的训练图像。

1
2
3
4
5
6
7
8
from keras.preprocessing import image
import numpy as np
img_path = "data/cat_dog/cats_and_dogs_small/test/cats/cat.1700.jpg"
img = image.load_img(img_path, target_size=(150, 150))
img_tensor = image.img_to_array(img)
img_tensor = np.expand_dims(img_tensor, axis=0)
img_tensor /= 255.
print(img_tensor.shape)
(1, 150, 150, 3)

我们来显示这张图像

1
2
3
import matplotlib.pyplot as plt
plt.imshow(img_tensor[0])
plt.show()
a

为了提取想要查看的特征图,我们需要创建一个Keras模型,以图像批量作为输入,并输出所有卷积层和池化层的激活。为此,我们需要使用KerasModel类。模型实例化需要两个参数:一个输入张量(或输入张量的列表)和一个输出张量(或输出张量的列表)。得到的类是一个Keras模型,就像你熟悉的Sequential模型一样,将特定输入映射为特定输出。Model类允许模型有多个输出,这一点与Sequential模型不同。

1
2
3
4
# 用一个输入张量和一个输出张量列表将模型实例化
from keras import models
layer_outputs = [layer.output for layer in model.layers[:8]]
activation_model = models.Model(inputs=model.input, outputs=layer_outputs)

输入一张图像,这个模型将返回原始模型前8 层的激活值。这是第一次遇到的多输出模型,之前的模型都是只有一个输入和一个输出。一般情况下,模型可以有任意个输入和输出。这个模型有一个输入和8 个输出,即每层激活对应一个输出。

1
2
3
4
5
# 以预测模式运行模型
activations = activation_model.predict(img_tensor)
# 例如,对于输入的猫图像,第一个卷积层的激活如下所示。
first_layer_activation = activations[0]
print(first_layer_activation.shape)
(1, 148, 148, 32)

它是大小为148×148的特征图,有32个通道。我们来绘制原始模型第一层激活的第4个通道

1
2
3
# 将第4个通道可视化
import matplotlib.pyplot as plt
plt.matshow(first_layer_activation[0, :, :, 4], cmap='viridis')
通道激活
通道4激活

这个通道似乎是对角边缘检测器。我们再看一下第7个通道

1
2
# 将第7个通道可视化
plt.matshow(first_layer_activation[0, :, :, 7], cmap='viridis')
通道激活
通道7激活

下面我们来绘制网络中所有激活的完整可视化。我们需要在8个特征图中的每一个中提取并绘制每一个通道,然后将结果叠加在一个大的图像张量中,按通道并排。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 将每个中间激活的所有通道可视化
layer_names = []
for layer in model.layers[:8]:
layer_names.append(layer.name)
images_per_row = 16
for layer_name, layer_activation in zip(layer_names, activations):
n_features = layer_activation.shape[-1]
size = layer_activation.shape[1]
n_cols = n_features // images_per_row
display_grid = np.zeros((size * n_cols, images_per_row * size))
for col in range(n_cols):
for row in range(images_per_row):
channel_image = layer_activation[0,:, :,col * images_per_row + row]
channel_image -= channel_image.mean()
channel_image /= channel_image.std()
channel_image *= 64
channel_image += 128
channel_image = np.clip(channel_image, 0, 255).astype('uint8')
display_grid[col * size : (col + 1) * size,row * size : (row + 1) * size] = channel_image
scale = 1. / size
plt.figure(figsize=(scale * display_grid.shape[1],
scale * display_grid.shape[0]))
plt.title(layer_name)
plt.grid(False)
plt.imshow(display_grid, aspect='auto', cmap='viridis')
通道激活
通道激活
通道激活
通道激活
通道激活
通道激活
通道激活
通道激活

这里需要注意以下几点。

  • 第一层是各种边缘探测器的集合。在这一阶段,激活几乎保留了原始图像中的所有信息。
  • 随着层数的加深,激活变得越来越抽象,并且越来越难以直观地理解。它们开始表示更高层次的概念,比如“猫耳朵”和“猫眼睛”。层数越深,其表示中关于图像视觉内容的信息就越少,而关于类别的信息就越多。
  • 激活的稀疏度(sparsity)随着层数的加深而增大。在第一层里,所有过滤器都被输入图像激活,但在后面的层里,越来越多的过滤器是空白的。也就是说,输入图像中找不到这些过滤器所编码的模式。

我们刚刚揭示了深度神经网络学到的表示的一个重要普遍特征:随着层数的加深,层所提取的特征变得越来越抽象。更高的层激活包含关于特定输入的信息越来越少,而关于目标的信息越来越多(本例中即图像的类别:猫或狗)。深度神经网络可以有效地作为信息蒸馏管道(information distillation pipeline),输入原始数据(本例中是RGB 图像),反复对其进行变换,将无关信息过滤掉(比如图像的具体外观),并放大和细化有用的信息(比如图像的类别)。

这与人类和动物感知世界的方式类似:人类观察一个场景几秒钟后,可以记住其中有哪些抽象物体(比如自行车、树),但记不住这些物体的具体外观。事实上,如果你试着凭记忆画一辆普通自行车,那么很可能完全画不出真实的样子,虽然你一生中见过上千辆自行车。你可以现在就试着画一下,这个说法绝对是真实的。你的大脑已经学会将视觉输入完全抽象化,即将其转换为更高层次的视觉概念,同时过滤掉不相关的视觉细节,这使得大脑很难记住周围事物的外观。

(左图)试着凭记忆画一辆自行车;(右图)自行车示意图
(左图)试着凭记忆画一辆自行车;(右图)自行车示意图

2. 可视化卷积神经网络的过滤器

想要观察卷积神经网络学到的过滤器,另一种简单的方法是显示每个过滤器所响应的视觉模式。这可以通过在输入空间中进行梯度上升来实现:从空白输入图像开始,将梯度下降应用于卷积神经网络输入图像的值,其目的是让某个过滤器的响应最大化。得到的输入图像是选定过滤器具有最大响应的图像。

这个过程很简单:我们需要构建一个损失函数,其目的是让某个卷积层的某个过滤器的值最大化;然后,我们要使用随机梯度下降来调节输入图像的值,以便让这个激活值最大化。例如,对于在ImageNet上预训练的VGG16网络,其block3_conv1层第0 个过滤器激活的损失如下所示。

1
2
3
4
5
6
7
8
# 为过滤器的可视化定义损失张量
from keras.applications import VGG16
from keras import backend as K
model = VGG16(weights='imagenet',include_top=False)
layer_name = 'block3_conv1'
filter_index = 0
layer_output = model.get_layer(layer_name).output
loss = K.mean(layer_output[:, :, :, filter_index])

为了实现梯度下降,我们需要得到损失相对于模型输入的梯度。为此,我们需要使用Kerasbackend模块内置的gradients函数。

1
2
# 获取损失相对于输入的梯度
grads = K.gradients(loss, model.input)[0]

为了让梯度下降过程顺利进行,一个非显而易见的技巧是将梯度张量除以其L2范数(张量中所有值的平方的平均值的平方根)来标准化。这就确保了输入图像的更新大小始终位于相同的范围。

1
2
# 梯度标准化技巧
grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)

现在你需要一种方法:给定输入图像,它能够计算损失张量和梯度张量的值。你可以定义一个Keras后端函数来实现此方法:iterate是一个函数,它将一个Numpy张量(表示为长度为1的张量列表)转换为两个Numpy张量组成的列表,这两个张量分别是损失值和梯度值。

1
2
3
4
# 给定Numpy输入值,得到Numpy输出值
iterate = K.function([model.input], [loss, grads])
import numpy as np
loss_value, grads_value = iterate([np.zeros((1, 150, 150, 3))])

现在你可以定义一个Python 循环来进行随机梯度下降。

1
2
3
4
5
6
# 通过随机梯度下降让损失最大化
input_img_data = np.random.random((1, 150, 150, 3)) * 20 + 128.
step = 1.
for i in range(40):
loss_value, grads_value = iterate([input_img_data])
input_img_data += grads_value * step

得到的图像张量是形状为(1, 150, 150, 3)的浮点数张量,其取值可能不是[0, 255]区间内的整数。因此,你需要对这个张量进行后处理,将其转换为可显示的图像。下面这个简单的实用函数可以做到这一点。

1
2
3
4
5
6
7
8
9
10
# 将张量转换为有效图像的实用函数
def deprocess_image(x):
x -= x.mean()
x /= (x.std() + 1e-5)
x *= 0.1
x += 0.5
x = np.clip(x, 0, 1)
x *= 255
x = np.clip(x, 0, 255).astype('uint8')
return x

接下来,我们将上述代码片段放到一个Python函数中,输入一个层的名称和一个过滤器索引,它将返回一个有效的图像张量,表示能够将特定过滤器的激活最大化的模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 生成过滤器可视化的函数
def generate_pattern(layer_name, filter_index, size=150):
layer_output = model.get_layer(layer_name).output
loss = K.mean(layer_output[:, :, :, filter_index])
grads = K.gradients(loss, model.input)[0]
grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)
iterate = K.function([model.input], [loss, grads])
input_img_data = np.random.random((1, size, size, 3)) * 20 + 128.
step = 1.
for i in range(40):
loss_value, grads_value = iterate([input_img_data])
input_img_data += grads_value * step
img = input_img_data[0]
return deprocess_image(img)

我们来试用一下这个函数

1
plt.imshow(generate_pattern('block3_conv1', 0))
通道激活

看起来,block3_conv1层第0个过滤器响应的是波尔卡点(polka-dot)图案。下面来看有趣的部分:我们可以将每一层的每个过滤器都可视化。为了简单起见,我们只查看每一层的前64 个过滤器,并只查看每个卷积块的第一层(即block1_conv1block2_conv1block3_conv1block4_ conv1block5_conv1)。我们将输出放在一个8×8的网格中,每个网格是一个64像素×64像素的过滤器模式,两个过滤器模式之间留有一些黑边

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 生成某一层中所有过滤器响应模式组成的网格
layer_name = 'block1_conv1'
size = 64
margin = 5
results = np.zeros((8 * size + 7 * margin, 8 * size + 7 * margin, 3))
for i in range(8):
for j in range(8):
filter_img = generate_pattern(layer_name, i + (j * 8), size=size)
horizontal_start = i * size + i * margin
horizontal_end = horizontal_start + size
vertical_start = j * size + j * margin
vertical_end = vertical_start + size
results[horizontal_start: horizontal_end, vertical_start: vertical_end, :] = filter_img
plt.figure(figsize=(20, 20))
plt.imshow(results)

这些过滤器可视化包含卷积神经网络的层如何观察世界的很多信息:卷积神经网络中每一层都学习一组过滤器,以便将其输入表示为过滤器的组合。这类似于傅里叶变换将信号分解为一组余弦函数的过程。随着层数的加深,卷积神经网络中的过滤器变得越来越复杂,越来越精细。

  • 模型第一层(block1_conv1)的过滤器对应简单的方向边缘和颜色(还有一些是彩色边缘)。
  • block2_conv1层的过滤器对应边缘和颜色组合而成的简单纹理。
  • 更高层的过滤器类似于自然图像中的纹理:羽毛、眼睛、树叶等。

3. 可视化类激活的热力图

我还要介绍另一种可视化方法,它有助于了解一张图像的哪一部分让卷积神经网络做出了最终的分类决策。这有助于对卷积神经网络的决策过程进行调试,特别是出现分类错误的情况下。这种方法还可以定位图像中的特定目标。

这种通用的技术叫作类激活图(CAM,class activation map)可视化,它是指对输入图像生成类激活的热力图。类激活热力图是与特定输出类别相关的二维分数网格,对任何输入图像的每个位置都要进行计算,它表示每个位置对该类别的重要程度。举例来说,对于输入到猫狗分类卷积神经网络的一张图像,CAM 可视化可以生成类别“猫”的热力图,表示图像的各个部分与“猫”的相似程度,CAM 可视化也会生成类别“狗”的热力图,表示图像的各个部分与“狗”的相似程度。

我们将使用的具体实现方式是“Grad-CAM: visual explanations from deep networks via gradientbasedlocalization”a 这篇论文中描述的方法。这种方法非常简单:给定一张输入图像,对于一个卷积层的输出特征图,用类别相对于通道的梯度对这个特征图中的每个通道进行加权。直观上来看,理解这个技巧的一种方法是,你是用“每个通道对类别的重要程度”对“输入图像对不同通道的激活强度”的空间图进行加权,从而得到了“输入图像对类别的激活强度”的空间图。

我们再次使用预训练的VGG16网络来演示此方法。

1
2
3
# 加载带有预训练权重的VGG16网络
from keras.applications.vgg16 import VGG16
model = VGG16(weights='imagenet')
Downloading data from https://github.com/fchollet/deep-learning-models/releases/download/v0.1/vgg16_weights_tf_dim_ordering_tf_kernels.h5
553467904/553467096 [==============================] - 441s 1us/step
1
2
3
4
5
6
7
8
9
# 为VGG16模型预处理一张输入图像
from keras.preprocessing import image
from keras.applications.vgg16 import preprocess_input, decode_predictions
import numpy as np
img_path = 'data/pic_input/elephant1.jpg'
img = image.load_img(img_path, target_size=(224, 224))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
非洲象
非洲象
1
2
preds = model.predict(x)
print('Predicted:', decode_predictions(preds, top=3)[0])
Downloading data from https://storage.googleapis.com/download.tensorflow.org/data/imagenet_class_index.json
40960/35363 [==================================] - 0s 3us/step
Predicted: [('n02504458', 'African_elephant', 0.87728226), ('n01871265', 'tusker', 0.11725453), ('n02504013', 'Indian_elephant', 0.0054599163)]

对这张图像预测的前三个类别分别为:

  • 非洲象(African elephant,87.728226% 的概率)
  • 长牙动物(tusker,11.725453% 的概率)
  • 印度象(Indian elephant,0.54599163%的概率)

网络识别出图像中包含数量不确定的非洲象。预测向量中被最大激活的元素是对应“非洲象”类别的元素,索引编号为386。

1
2
np.argmax(preds[0])
# 386

为了展示图像中哪些部分最像非洲象,我们来使用Grad-CAM算法。

1
2
3
4
5
6
7
8
9
african_elephant_output = model.output[:, 386]
last_conv_layer = model.get_layer('block5_conv3')
grads = K.gradients(african_elephant_output, last_conv_layer.output)[0]
pooled_grads = K.mean(grads, axis=(0, 1, 2))
iterate = K.function([model.input],[pooled_grads, last_conv_layer.output[0]])
pooled_grads_value, conv_layer_output_value = iterate([x])
for i in range(512):
conv_layer_output_value[:, :, i] *= pooled_grads_value[i]
heatmap = np.mean(conv_layer_output_value, axis=-1)

为了便于可视化,我们还需要将热力图标准化到0~1范围内。

1
2
3
heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)
b

最后,我们可以用OpenCV 来生成一张图像,将原始图像叠加在刚刚得到的热力图上

1
2
3
4
5
6
7
8
import cv2
img = cv2.imread(img_path)
heatmap = cv2.resize(heatmap, (img.shape[1], img.shape[0]))
heatmap = np.uint8(255 * heatmap)
heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)
superimposed_img = heatmap * 0.4 + img
cv2.imwrite('data/pic_output/elephant_cam.jpg', superimposed_img)
# True
1
2
3
cv2.imshow('合并后的图像', superimposed_img)
cv2.waitKey(0)
# -1

五、总结

  • 卷积神经网络是解决视觉分类问题的最佳工具。
  • 卷积神经网络通过学习模块化模式和概念的层次结构来表示视觉世界。
  • 卷积神经网络学到的表示很容易可视化,卷积神经网络不是黑盒。
  • 现在你能够从头开始训练自己的卷积神经网络来解决图像分类问题。
  • 你知道了如何使用视觉数据增强来防止过拟合。
  • 你知道了如何使用预训练的卷积神经网络进行特征提取与模型微调。
  • 你可以将卷积神经网络学到的过滤器可视化,也可以将类激活热力图可视化。
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2020-2021 chenk
  • 由 帅气的CK本尊 强力驱动
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信