Python-深度学习第三版-三-
Python 深度学习第三版(三)
原文:
deeplearningwithpython.io/chapters/译者:飞龙
第八章:图像分类
deeplearningwithpython.io/chapters/chapter08_image-classification
计算机视觉是深度学习第一个重大的成功故事。它导致了 2011 年至 2015 年间深度学习的初步兴起。一种名为卷积神经网络的深度学习类型在那个时期开始在图像分类竞赛中取得显著的好成绩,最初是丹·西雷桑赢得了两个小众竞赛(2011 年 ICDAR 汉字识别竞赛和 2011 年 IJCNN 德国交通标志识别竞赛),然后,更为显著的是,在 2012 年秋季,辛顿团队赢得了备受瞩目的 ImageNet 大规模视觉识别挑战赛。在其他计算机视觉任务中,更多有希望的结果也迅速涌现。
有趣的是,这些早期的成功并不足以使深度学习在当时成为主流——这需要几年时间。计算机视觉研究界已经投入了多年时间研究神经网络以外的其他方法,并且并不因为新来者的出现就准备放弃它们。2013 年和 2014 年,深度学习仍然面临着许多资深计算机视觉研究者的强烈怀疑。直到 2016 年,它才最终成为主流。一位作者记得在 2014 年 2 月敦促一位前教授转向深度学习。“这是下一个大趋势!”他会说。“嗯,也许这只是个潮流,”教授会回答。到 2016 年,他整个实验室都在做深度学习。一个时代到来了,任何阻止它的想法都是徒劳的。
现在,你不断地与基于深度学习的视觉模型互动——通过 Google Photos、Google 图片搜索、你手机上的相机、YouTube、OCR 软件等等。这些模型也是自动驾驶、机器人、AI 辅助医疗诊断、自主零售结账系统,甚至自主农业等尖端研究的核心。
本章介绍了卷积神经网络,也称为ConvNets或CNNs,这是一种大多数计算机视觉应用所使用的深度学习模型。你将学习如何将卷积神经网络应用于图像分类问题——特别是那些涉及小型训练数据集的问题,如果你不是大型科技公司,这将是最常见的用例。
卷积神经网络简介
我们即将深入探讨卷积神经网络(ConvNets)的理论,以及它们为何在计算机视觉任务中取得了如此巨大的成功。但首先,让我们先从实际的角度来看一个简单的卷积神经网络示例。它使用卷积神经网络来分类 MNIST 数字,这是我们第二章中使用密集连接网络(那时我们的测试准确率为 97.8%)所执行的任务。尽管这个卷积神经网络将非常基础,但它的准确率将远远超过第二章中密集连接模型的水平。
以下代码行展示了基本卷积神经网络(ConvNet)的外观。它是由一系列的Conv2D和MaxPooling2D层堆叠而成。你将在下一分钟内确切地看到它们的作用。我们将使用在上一章中介绍的功能 API 来构建模型。
import keras
from keras import layers
inputs = keras.Input(shape=(28, 28, 1))
x = layers.Conv2D(filters=64, kernel_size=3, activation="relu")(inputs)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=128, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=256, kernel_size=3, activation="relu")(x)
x = layers.GlobalAveragePooling2D()(x)
outputs = layers.Dense(10, activation="softmax")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
代码列表 8.1:实例化一个小型卷积神经网络
重要的是,卷积神经网络(ConvNet)接受形状为(image_height, image_width, image_channels)的张量作为输入(不包括批处理维度)。在这种情况下,我们将配置卷积神经网络(ConvNet)以处理大小为(28, 28, 1)的输入,这是 MNIST 图像的格式。
让我们展示我们的卷积神经网络(ConvNet)的架构。
>>> model.summary()
Model: "functional"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_layer (InputLayer) │ (None, 28, 28, 1) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d (Conv2D) │ (None, 26, 26, 64) │ 640 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d (MaxPooling2D) │ (None, 13, 13, 64) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_1 (Conv2D) │ (None, 11, 11, 128) │ 73,856 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d_1 (MaxPooling2D) │ (None, 5, 5, 128) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_2 (Conv2D) │ (None, 3, 3, 256) │ 295,168 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ global_average_pooling2d │ (None, 256) │ 0 │
│ (GlobalAveragePooling2D) │ │ │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense (Dense) │ (None, 10) │ 2,570 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
Total params: 372,234 (1.42 MB)
Trainable params: 372,234 (1.42 MB)
Non-trainable params: 0 (0.00 B)
代码列表 8.2:显示模型的摘要
你可以看到每个Conv2D和MaxPooling2D层的输出都是一个形状为(height, width, channels)的三维张量。随着你在模型中深入,宽度和高度维度往往会缩小。通道数由传递给Conv2D层的第一个参数控制(64、128 或 256)。
在最后一个Conv2D层之后,我们得到一个形状为(3, 3, 256)的输出——一个 256 通道的 3×3 特征图。下一步是将这个输出输入到一个密集连接的分类器中,就像你已经熟悉的那些:一系列的Dense层。这些分类器处理向量,它们是一维的,而当前的输出是一个三阶张量。为了弥合这个差距,我们在添加Dense层之前,使用一个GlobalAveragePooling2D层将 3D 输出展平到一维。这个层将张量形状为(3, 3, 256)中的每个 3×3 特征图的平均值取出来,结果得到一个形状为(256,)的输出向量。最后,我们将进行 10 种分类,所以我们的最后一层有 10 个输出和一个 softmax 激活函数。
现在,让我们在 MNIST 数字上训练卷积神经网络(ConvNet)。我们将重用第二章中 MNIST 示例中的大量代码。因为我们正在进行带有 softmax 输出的 10 种分类,所以我们将使用分类交叉熵损失,因为我们标签是整数,我们将使用稀疏版本,sparse_categorical_crossentropy。
from keras.datasets import mnist
(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
model.compile(
optimizer="adam",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
model.fit(train_images, train_labels, epochs=5, batch_size=64)
代码列表 8.3:在 MNIST 图像上训练卷积神经网络
让我们在测试数据上评估模型。
>>> test_loss, test_acc = model.evaluate(test_images, test_labels)
>>> print(f"Test accuracy: {test_acc:.3f}")
Test accuracy: 0.991
代码列表 8.4:评估卷积神经网络
与第二章中的密集连接模型相比,测试准确率为 97.8%,而基本的卷积神经网络(ConvNet)的测试准确率为 99.1%:我们降低了大约 60%(相对)的错误率。还不错!
但为什么与密集连接模型相比,这个简单的卷积神经网络(ConvNet)表现得如此出色?为了回答这个问题,让我们深入了解Conv2D和MaxPooling2D层的作用。
卷积操作
密集连接层和卷积层之间的基本区别是这样的:Dense层在其输入特征空间中学习全局模式(例如,对于一个 MNIST 数字,涉及所有像素的模式),而卷积层学习局部模式(见图 8.1):在图像的情况下,在输入的小 2D 窗口中找到的模式。在先前的例子中,这些窗口都是 3 × 3。

图 8.1:图像可以被分解成局部模式,如边缘、纹理等。
这种关键特性给卷积神经网络带来了两个有趣的特性:
-
它们学习的模式是平移不变的。在图片的右下角学习到一定模式后,卷积神经网络可以在任何地方识别它:例如,在左上角。如果密集连接模型在新的位置出现,它必须重新学习该模式。这使得卷积神经网络在处理图像时数据效率高——因为视觉世界在本质上具有平移不变性。它们需要更少的训练样本来学习具有泛化能力的表示。
-
它们可以学习模式的空间层次结构(见图 8.2)。第一层卷积层将学习小的局部模式,如边缘,第二层卷积层将学习由第一层特征组成的大模式,依此类推。这使得卷积神经网络能够高效地学习越来越复杂和抽象的视觉概念——因为视觉世界在本质上具有空间层次性。

图 8.2:视觉世界形成了一个视觉模块的空间层次结构:基本的线条或纹理组合成简单的物体,如眼睛或耳朵,这些物体再组合成高级概念,如“猫”。
卷积操作在称为特征图的三阶张量上操作,具有两个空间轴(高度和宽度)以及一个深度轴(也称为通道轴)。对于 RGB 图像,深度轴的维度是 3,因为图像有三个颜色通道:红色、绿色和蓝色。对于像 MNIST 数字这样的黑白图片,深度是 1(灰度级别)。卷积操作从其输入特征图中提取补丁,并将相同的变换应用于所有这些补丁,产生一个输出特征图。这个输出特征图仍然是一个三阶张量:它有宽度和高度。其深度可以是任意的,因为输出深度是层的参数,在该深度轴上的不同通道不再像 RGB 输入那样代表特定的颜色;相反,它们代表过滤器。过滤器编码输入数据的特定方面:在较高层次上,单个过滤器可以编码“输入中存在面部”的概念,例如。
在 MNIST 示例中,第一个卷积层接受大小为(28, 28, 1)的特征图,并输出大小为(26, 26, 64)的特征图:它在其输入上计算 64 个过滤器。这些 64 个输出通道中每个都包含一个 26 × 26 的值网格,这是过滤器在输入上的响应图,指示该过滤器模式在输入的不同位置的反应(参见图 8.3)。这就是术语特征图的含义:深度轴上的每个维度都是一个特征(或过滤器),而秩为 2 的张量output[:, :, n]是此过滤器在输入上的 2D 空间图。

图 8.3:响应图的概念:一个 2D 图,表示在输入的不同位置上模式的呈现
卷积由两个关键参数定义:
-
从输入中提取的补丁的大小 — 这些通常是 3 × 3 或 5 × 5。在示例中,它们是 3 × 3,这是一个常见的选择。
-
输出特征图的深度 — 通过卷积计算出的过滤器数量。示例从 32 个深度开始,以 64 个深度结束。
在 Keras 的Conv2D层中,这些参数是传递给层的第一个参数:Conv2D(output_depth, (window_height, window_width))。
卷积通过滑动大小为 3 × 3 或 5 × 5 的窗口在 3D 输入特征图上,在每个可能的位置停止,并提取形状为(window_height, window_width, input_depth)的周围特征 3D 补丁。然后,每个这样的 3D 补丁被转换成一个形状为(output_depth,)的 1D 向量,这是通过与一个称为卷积核的学习权重矩阵的张量积来完成的——相同的核在每一个补丁上被重复使用。然后,所有这些向量(每个补丁一个)在空间上重新组装成一个形状为(height, width, output_depth)的 3D 输出图。输出特征图中的每个空间位置对应于输入特征图中的相同位置(例如,输出图的右下角包含有关输入右下角的信息)。例如,使用 3 × 3 窗口时,向量output[i, j, :]来自 3D 补丁input[i-1:i+1, j-1:j+1, :]。整个过程在图 8.4 中详细说明。

图 8.4:卷积的工作原理
注意,输出宽度和高度可能与输入宽度和高度不同。它们可能因为以下两个原因而不同:
-
边界效应,可以通过填充输入特征图来抵消
-
使用步长,我们将在下一节定义
让我们更深入地探讨这些概念。
理解边界效应和填充
考虑一个 5 × 5 特征图(总共 25 个瓦片)。你只能在 9 个瓦片周围放置一个 3 × 3 窗口,形成一个 3 × 3 网格(见图 8.5)。因此,输出特征图将是 3 × 3。它略微缩小:在每个维度上精确地缩小两个瓦片。你可以在前面的例子中看到这个边界效应的实际应用:你从 28 × 28 的输入开始,经过第一层卷积后变成 26 × 26。

图 8.5:在 5 × 5 输入特征图中 3 × 3 补丁的有效位置
如果你想得到与输入具有相同空间维度的输出特征图,你可以使用填充。填充包括在输入特征图的每一边添加适当数量的行和列,以便在每个输入瓦片周围放置中心卷积窗口。对于 3 × 3 窗口,你需要在右边添加一列,左边添加一列,顶部添加一行,底部添加一行。对于 5 × 5 窗口,你需要在顶部添加两行(见图 8.6)。

图 8.6:填充 5 × 5 输入以提取 25 个 3 × 3 补丁
在Conv2D层中,填充可以通过padding参数进行配置,该参数接受两个值:"valid",表示没有填充(只使用有效的窗口位置);和"same",表示“以这种方式填充,以便输出具有与输入相同的宽度和高度。”padding参数的默认值为"valid"。
理解卷积步长
影响输出大小的另一个因素是步长的概念。到目前为止的卷积描述假设卷积窗口的中心瓦片都是连续的。但是,两个连续窗口之间的距离是卷积的一个参数,称为其步长,默认值为 1。可以有步长卷积:步长大于 1 的卷积。在图 8.7 中,你可以看到通过在 5 × 5 输入上使用步长为 2 的 3 × 3 卷积提取的补丁。

图 8.7:步长为 2 的 3 × 3 卷积补丁
使用步长 2 意味着特征图的宽度和高度以 2 的倍数下采样(除了任何由边界效应引起的改变)。步长卷积在分类模型中很少使用,但在某些类型的模型中很有用,你将在下一章中了解到。
在分类模型中,我们倾向于使用最大池化操作来下采样特征图——你可以在我们的第一个卷积神经网络示例中看到它的实际应用。让我们更深入地了解一下。
最大池化操作
在卷积神经网络(ConvNet)的示例中,你可能已经注意到,在每一个MaxPooling2D层之后,特征图的大小都会减半。例如,在第一个MaxPooling2D层之前,特征图的大小是 26 × 26,但最大池化操作将其减半到 13 × 13。这就是最大池化的作用:积极地对特征图进行下采样,就像步长卷积一样。
最大池化包括从输入特征图中提取窗口,并输出每个通道的最大值。从概念上讲,它与卷积相似,除了不是通过学习到的线性变换(卷积核)来转换局部块,而是通过硬编码的max张量操作来转换。与卷积的一个重大区别是,最大池化通常使用 2 × 2 窗口和步长 2 来将特征图下采样 2 倍。另一方面,卷积通常使用 3 × 3 窗口而没有步长(步长 1)。
为什么要以这种方式下采样特征图?为什么不移除最大池化层,而保持相当大的特征图直到最后?让我们看看这个选项。那么,我们的模型将看起来像这样。
inputs = keras.Input(shape=(28, 28, 1))
x = layers.Conv2D(filters=64, kernel_size=3, activation="relu")(inputs)
x = layers.Conv2D(filters=128, kernel_size=3, activation="relu")(x)
x = layers.Conv2D(filters=256, kernel_size=3, activation="relu")(x)
x = layers.GlobalAveragePooling2D()(x)
outputs = layers.Dense(10, activation="softmax")(x)
model_no_max_pool = keras.Model(inputs=inputs, outputs=outputs)
列表 8.5:缺少最大池化层的错误结构化的 ConvNet
这里是对模型的总结:
>>> model_no_max_pool.summary()
Model: "functional_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_layer_1 (InputLayer) │ (None, 28, 28, 1) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_3 (Conv2D) │ (None, 26, 26, 64) │ 640 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_4 (Conv2D) │ (None, 24, 24, 128) │ 73,856 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_5 (Conv2D) │ (None, 22, 22, 256) │ 295,168 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ global_average_pooling2d_1 │ (None, 256) │ 0 │
│ (GlobalAveragePooling2D) │ │ │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_1 (Dense) │ (None, 10) │ 2,570 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
Total params: 372,234 (1.42 MB)
Trainable params: 372,234 (1.42 MB)
Non-trainable params: 0 (0.00 B)
这个设置有什么问题?两点:
-
这不利于学习特征的空间层次。第三层的 3 × 3 窗口将只包含来自初始输入中 7 × 7 窗口的信息。卷积神经网络学习的高级模式与初始输入相比仍然非常小,这可能不足以学习对数字进行分类(尝试通过只有 7 × 7 像素的窗口来识别数字!)。我们需要最后卷积层的特征包含关于输入整体的信息。
-
最终的特征图尺寸为 22 × 22。这非常大——当你对每个 22 × 22 的特征图取平均值时,与你的特征图只有 3 × 3 时相比,你将丢失大量的信息。
简而言之,使用下采样的原因是为了减小特征图的大小,使得它们包含的信息在空间上的分布越来越不均匀,并且越来越多地包含在通道中,同时通过使连续的卷积层“观察”到越来越大的窗口(从原始输入图像覆盖的分数来看)来诱导空间滤波层次。
注意,最大池化并不是实现这种下采样的唯一方法。正如你所知,你还可以在先前的卷积层中使用步长。你还可以使用平均池化而不是最大池化,其中每个局部输入块通过取块中每个通道的平均值来转换,而不是取最大值。但最大池化通常比这些替代方案更有效。简而言之,原因在于特征倾向于在特征图的不同块中编码某种模式或概念的时空存在(因此得名特征图),观察不同特征的最大存在比观察它们的平均存在更有信息量。因此,最合理的下采样策略是首先生成特征(通过无步长的卷积)的密集图,然后观察特征在小型块上的最大激活,而不是观察输入的稀疏窗口(通过步长卷积)或平均输入块,这可能会导致你错过或稀释特征存在信息。
到目前为止,你应该已经了解了卷积神经网络的基本知识——特征图、卷积和最大池化——并且你知道如何构建一个小型卷积神经网络来解决玩具问题,例如 MNIST 数字分类。现在让我们继续探讨更有用、更实际的应用。
在小型数据集上从头开始训练卷积神经网络
必须使用非常少的数据来训练图像分类模型是一种常见情况,如果你在专业环境中进行计算机视觉,你可能会在实践中遇到这种情况。所谓的“少量”样本可能意味着从几百到几万张图片。作为一个实际例子,我们将专注于将图像分类为狗或猫。我们将使用一个包含 5,000 张猫和狗图片的数据集(2,500 只猫,2,500 只狗),这些图片来自原始的 Kaggle 数据集。我们将使用 2,000 张图片进行训练,1,000 张进行验证,2,000 张进行测试。
在本节中,我们将回顾一种基本策略来解决这个问题:从头开始训练一个新模型,使用我们拥有的少量数据。我们将首先天真地在一个包含 2,000 个训练样本的小型卷积神经网络(ConvNet)上训练,没有任何正则化,以设定一个基准,看看能实现什么效果。这将使我们的分类准确率达到大约 80%。在那个点上,主要问题将是过拟合。然后我们将介绍数据增强,这是一种在计算机视觉中减轻过拟合的强大技术。通过使用数据增强,我们将提高模型,使其测试准确率达到大约 84%。
在下一节中,我们将回顾两个将深度学习应用于小数据集的必要技术:使用预训练模型进行特征提取和微调预训练模型(这将使我们的最终准确率达到 98.5%)。这三个策略——从头开始训练小模型、使用预训练模型进行特征提取和微调预训练模型——将构成您未来解决使用小数据集进行图像分类问题的工具箱。
深度学习对于小数据集问题的相关性
“足够的样本”来训练一个模型是相对的——首先,相对于您试图训练的模型的大小和深度。不可能只用几十个样本来训练一个卷积神经网络来解决复杂问题,但如果模型很小且很好地正则化,任务简单,那么几百个样本可能就足够了。由于卷积神经网络学习局部、平移不变的特征,它们在感知问题上的数据效率很高。在非常小的图像数据集上从头开始训练卷积神经网络,尽管数据相对较少,但仍然可以产生合理的结果,而无需任何定制的特征工程。您将在本节中看到这一点。
此外,深度学习模型在本质上具有很强的可重用性:您可以从大型数据集上训练的图像分类或语音到文本模型中提取,并在一个显著不同的问题上仅进行少量修改后重用。具体来说,在计算机视觉领域,许多预训练的分类模型可供公开下载,并可用于从非常少的数据中启动强大的视觉模型。这是深度学习的一个最大优势:特征重用。您将在下一节中探索这一点。
让我们先着手获取数据。
下载数据
我们将要使用的 Dogs vs. Cats 数据集并不包含在 Keras 中。它是由 Kaggle 在 2013 年底作为计算机视觉竞赛的一部分提供的,那时卷积神经网络还不是主流。您可以从www.kaggle.com/c/dogs-vs-cats/data下载原始数据集(如果您还没有 Kaggle 账户,需要创建一个——别担心,这个过程很简单)。您还可以使用 Kaggle API 在 Colab 中下载数据集。
我们数据集中的图片是中等分辨率的彩色 JPEG。图 8.8 展示了几个示例。

图 8.8:Dogs vs. Cats 数据集的样本。大小未修改:样本有不同的尺寸、颜色、背景等。
不足为奇的是,原始的 2013 年狗与猫 Kaggle 竞赛,所有参赛者都使用了卷积神经网络。最佳参赛者实现了高达 95%的准确率。在这个例子中,我们将接近这个准确率(在下一节中),尽管我们将模型训练在竞争对手可用的数据不到 10%的情况下。
这个数据集包含 25,000 张狗和猫的图像(每个类别 12,500 张)和 543 MB(压缩)。在下载并解压缩数据后,我们将创建一个新的数据集,包含三个子集:一个包含每个类别 1,000 个样本的训练集,一个包含每个类别 500 个样本的验证集,以及一个包含每个类别 1,000 个样本的测试集。为什么要这样做?因为你在职业生涯中遇到的大多数图像数据集只包含几千个样本,而不是成千上万。有更多的数据可用会使问题更容易——因此,使用小数据集进行学习是一个好的实践。
我们将要工作的子采样数据集将具有以下目录结构:
dogs_vs_cats_small/
...train/
# Contains 1,000 cat images
......cat/
# Contains 1,000 dog images
......dog/
...validation/
# Contains 500 cat images
......cat/
# Contains 500 dog images
......dog/
...test/
# Contains 1,000 cat images
......cat/
# Contains 1,000 dog images
......dog/
让我们在shutil库的几次调用中实现它,这是一个用于运行类似 shell 命令的 Python 库。
import os, shutil, pathlib
# Path to the directory where the original dataset was uncompressed
original_dir = pathlib.Path("train")
# Directory where we will store our smaller dataset
new_base_dir = pathlib.Path("dogs_vs_cats_small")
# Utility function to copy cat (respectively, dog) images from index
# `start_index` to index `end_index` to the subdirectory
# `new_base_dir/{subset_name}/cat` (respectively, dog). "subset_name"
# will be either "train," "validation," or "test."
def make_subset(subset_name, start_index, end_index):
for category in ("cat", "dog"):
dir = new_base_dir / subset_name / category
os.makedirs(dir)
fnames = [f"{category}.{i}.jpg" for i in range(start_index, end_index)]
for fname in fnames:
shutil.copyfile(src=original_dir / fname, dst=dir / fname)
# Creates the training subset with the first 1,000 images of each
# category
make_subset("train", start_index=0, end_index=1000)
# Creates the validation subset with the next 500 images of each
# category
make_subset("validation", start_index=1000, end_index=1500)
# Creates the test subset with the next 1,000 images of each category
make_subset("test", start_index=1500, end_index=2500)
列表 8.6:将图像复制到训练、验证和测试目录
我们现在有 2,000 个训练图像,1,000 个验证图像和 2,000 个测试图像。每个分割包含每个类别的样本数量相同:这是一个平衡的二分类问题,这意味着分类准确率将是一个适当的成功衡量标准。
构建你的模型
我们将重用你在第一个示例中看到的相同的一般模型结构:卷积神经网络将是一系列交替的Conv2D(带有relu激活)和MaxPooling2D层。
但因为我们处理的是更大的图像和更复杂的问题,我们将使我们的模型更大,相应地:它将有两个额外的Conv2D + MaxPooling2D阶段。这既增加了模型的容量,也进一步减小了特征图的大小,以便在达到池化层时它们不会过大。在这里,因为我们从 180 × 180 像素的输入开始(这是一个有些任意的选择),我们最终在GlobalAveragePooling2D层之前得到 7 × 7 大小的特征图。
由于我们正在查看一个二分类问题,我们将以一个单元(大小为 1 的Dense层)和一个sigmoid激活函数结束模型。这个单元将编码模型正在查看一个类别还是另一个类别的概率。
最后一个小差异:我们将从Rescaling层开始构建模型,该层将重缩放图像输入(其值最初在[0, 255]范围内)到[0, 1]范围。
import keras
from keras import layers
# The model expects RGB images of size 180 x 180.
inputs = keras.Input(shape=(180, 180, 3))
# Rescales inputs to the [0, 1] range by dividing them by 255
x = layers.Rescaling(1.0 / 255)(inputs)
x = layers.Conv2D(filters=32, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=64, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=128, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=256, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=512, kernel_size=3, activation="relu")(x)
# Flattens the 3D activations with shape (height, width, 512) into 1D
# activations with shape (512,) by averaging them over spatial
# dimensions
x = layers.GlobalAveragePooling2D()(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
列表 8.7:实例化一个小型卷积神经网络进行狗与猫分类
让我们看看特征图维度是如何随着每一层连续变化的:
>>> model.summary()
Model: "functional_2"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_layer_2 (InputLayer) │ (None, 180, 180, 3) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ rescaling (Rescaling) │ (None, 180, 180, 3) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_6 (Conv2D) │ (None, 178, 178, 32) │ 896 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d_2 (MaxPooling2D) │ (None, 89, 89, 32) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_7 (Conv2D) │ (None, 87, 87, 64) │ 18,496 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d_3 (MaxPooling2D) │ (None, 43, 43, 64) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_8 (Conv2D) │ (None, 41, 41, 128) │ 73,856 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d_4 (MaxPooling2D) │ (None, 20, 20, 128) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_9 (Conv2D) │ (None, 18, 18, 256) │ 295,168 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d_5 (MaxPooling2D) │ (None, 9, 9, 256) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_10 (Conv2D) │ (None, 7, 7, 512) │ 1,180,160 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ global_average_pooling2d_2 │ (None, 512) │ 0 │
│ (GlobalAveragePooling2D) │ │ │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_2 (Dense) │ (None, 1) │ 513 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
Total params: 1,569,089 (5.99 MB)
Trainable params: 1,569,089 (5.99 MB)
Non-trainable params: 0 (0.00 B)
对于编译步骤,您将像往常一样使用 adam 优化器。因为您以单个 sigmoid 单元结束模型,所以您将使用二元交叉熵作为损失(作为提醒,请查看第六章中的表 6.1,以获取各种情况下使用损失函数的速查表)。
model.compile(
loss="binary_crossentropy",
optimizer="adam",
metrics=["accuracy"],
)
列表 8.8:配置模型以进行训练
数据预处理
如您所知,在输入模型之前,数据应格式化为适当的预处理的浮点张量。目前,数据以 JPEG 文件的形式存储在驱动器上,因此将数据输入模型的大致步骤如下:
-
读取图片文件。
-
将 JPEG 内容解码为像素的 RGB 网格。
-
将这些转换为浮点张量。
-
将它们调整到共享大小(我们将使用 180 x 180)。
-
将它们打包成批次(我们将使用 32 张图像的批次)。
这可能看起来有点令人畏惧,但幸运的是,Keras 提供了自动处理这些步骤的工具。特别是,Keras 特有的实用函数 image_dataset_from_directory 允许您快速设置一个数据管道,该管道可以自动将磁盘上的图像文件转换为预处理的张量批次。这就是您在这里要使用的。
调用 image_dataset_from_directory(directory) 将首先列出 directory 的子目录,并假设每个子目录包含您的一个类别的图像。然后,它将索引每个子目录中的图像文件。最后,它将创建并返回一个配置为读取这些文件的 tf.data.Dataset 对象,对它们进行洗牌,将它们解码为张量,将它们调整到共享大小,并将它们打包成批次。
from keras.utils import image_dataset_from_directory
batch_size = 64
image_size = (180, 180)
train_dataset = image_dataset_from_directory(
new_base_dir / "train", image_size=image_size, batch_size=batch_size
)
validation_dataset = image_dataset_from_directory(
new_base_dir / "validation", image_size=image_size, batch_size=batch_size
)
test_dataset = image_dataset_from_directory(
new_base_dir / "test", image_size=image_size, batch_size=batch_size
)
列表 8.9:使用 image_dataset_from_directory 从目录中读取图像
理解 TensorFlow 数据集对象
TensorFlow 提供了 tf.data API 来创建机器学习模型的效率输入管道。其核心类是 tf.data.Dataset。
Dataset 类可用于任何框架中的数据加载和预处理,而不仅仅是 TensorFlow。您可以使用它与 JAX 或 PyTorch 一起使用。当您与 Keras 模型一起使用时,它的工作方式相同,独立于您当前使用的后端。
Dataset 对象是一个迭代器:您可以在 for 循环中使用它。它通常会返回输入数据和标签的批次。您可以直接将 Dataset 对象传递给 Keras 模型的 fit() 方法。
Dataset 类处理了许多关键特性,否则您可能难以自行实现,特别是跨多个 CPU 核心的预处理逻辑并行化,以及异步数据预取(在处理前一个批次的同时预处理下一个批次的数据,这可以保持执行流程不间断)。
Dataset 类还公开了一个功能式 API,用于修改数据集。这里有一个快速示例:让我们从一个随机数的 NumPy 数组创建一个 Dataset 实例。我们将考虑 1,000 个样本,其中每个样本是一个大小为 16 的向量。
import numpy as np
import tensorflow as tf
random_numbers = np.random.normal(size=(1000, 16))
# The from_tensor_slices() class method can be used to create a Dataset
# from a NumPy array or a tuple or dict of NumPy arrays.
dataset = tf.data.Dataset.from_tensor_slices(random_numbers)
列表 8.10:从 NumPy 数组实例化Dataset
起初,我们的数据集只产生单个样本。
>>> for i, element in enumerate(dataset):
>>> print(element.shape)
>>> if i >= 2:
>>> break
(16,)
(16,)
(16,)
列表 8.11:迭代数据集
您可以使用.batch()方法对数据进行分批。
>>> batched_dataset = dataset.batch(32)
>>> for i, element in enumerate(batched_dataset):
>>> print(element.shape)
>>> if i >= 2:
>>> break
(32, 16)
(32, 16)
(32, 16)
列表 8.12:批处理数据集
更广泛地说,您可以使用一系列有用的数据集方法,例如这些:
-
.shuffle(buffer_size)将在缓冲区内部进行元素洗牌。 -
.prefetch(buffer_size)将预取 GPU 内存中的元素缓冲区,以实现更好的设备利用率。 -
.map(callable)将对数据集的每个元素应用任意转换(期望接受数据集产生的单个元素作为输入的函数callable)。
.map(function, num_parallel_calls)方法尤其是一个您会经常使用的方法。这里有一个例子:让我们用它来将我们的玩具数据集中的元素从形状(16,)重塑为形状(4, 4)。
>>> reshaped_dataset = dataset.map(
... lambda x: tf.reshape(x, (4, 4)),
... num_parallel_calls=8)
>>> for i, element in enumerate(reshaped_dataset):
... print(element.shape)
... if i >= 2:
... break
(4, 4)
(4, 4)
(4, 4)
列表 8.13:使用map()对Dataset元素应用转换
在接下来的章节中,您将看到更多的map()操作。
拟合模型
让我们查看这些Dataset对象之一的输出:它产生 180 × 180 RGB 图像的批次(形状(32, 180, 180, 3))和整数标签(形状(32,))。每个批次中有 32 个样本(批次大小)。
>>> for data_batch, labels_batch in train_dataset:
>>> print("data batch shape:", data_batch.shape)
>>> print("labels batch shape:", labels_batch.shape)
>>> break
data batch shape: (32, 180, 180, 3)
labels batch shape: (32,)
列表 8.14:显示Dataset产生的形状
让我们在我们的数据集上拟合模型。我们在fit()中使用validation_data参数来监控单独的Dataset对象上的验证指标。
注意,我们还在每个 epoch 后使用一个ModelCheckpoint回调来保存模型。我们配置它以保存文件的路径,以及参数save_best_only=True和monitor="val_loss":它们告诉回调只有在当前val_loss指标值低于训练过程中任何先前时间时才保存新文件(覆盖任何先前的文件)。这保证了您保存的文件将始终包含对应于其表现最佳训练 epoch 的模型状态,从其在验证数据上的性能来看。因此,如果我们开始过拟合,我们不需要重新训练一个具有较少 epoch 数的新模型:我们只需重新加载我们的保存文件即可。
callbacks = [
keras.callbacks.ModelCheckpoint(
filepath="convnet_from_scratch.keras",
save_best_only=True,
monitor="val_loss",
)
]
history = model.fit(
train_dataset,
epochs=50,
validation_data=validation_dataset,
callbacks=callbacks,
)
列表 8.15:使用Dataset拟合模型
让我们在训练过程中绘制模型在训练和验证数据上的损失和准确率(见图 8.9)。
import matplotlib.pyplot as plt
accuracy = history.history["accuracy"]
val_accuracy = history.history["val_accuracy"]
loss = history.history["loss"]
val_loss = history.history["val_loss"]
epochs = range(1, len(accuracy) + 1)
plt.plot(epochs, accuracy, "r--", label="Training accuracy")
plt.plot(epochs, val_accuracy, "b", label="Validation accuracy")
plt.title("Training and validation accuracy")
plt.legend()
plt.figure()
plt.plot(epochs, loss, "r--", label="Training loss")
plt.plot(epochs, val_loss, "b", label="Validation loss")
plt.title("Training and validation loss")
plt.legend()
plt.show()
列表 8.16:显示训练期间的损失和准确率曲线

图 8.9:简单卷积神经网络的训练和验证指标
这些图是过拟合的特征。训练准确率随时间线性增加,直到接近 100%,而验证准确率在 80% 左右达到峰值。验证损失在第 10 个 epoch 后达到最低点,然后停滞,而训练损失随着训练的进行而线性下降。
让我们检查测试准确率。我们将从其保存的文件中重新加载模型,以评估它在开始过拟合之前的状态。
test_model = keras.models.load_model("convnet_from_scratch.keras")
test_loss, test_acc = test_model.evaluate(test_dataset)
print(f"Test accuracy: {test_acc:.3f}")
列表 8.17:在测试集上评估模型
我们得到了 78.6% 的测试准确率(由于神经网络初始化的随机性,你可能会得到相差几个百分点的数字)。
由于你的训练样本相对较少(2,000 个),过拟合将成为你的首要关注点。你已经知道一些可以帮助减轻过拟合的技术,例如 dropout 和权重衰减(L2 正则化)。我们现在将使用一个新的技术,专门针对计算机视觉,并且在用深度学习模型处理图像时几乎被普遍使用:数据增强。
使用数据增强
过拟合是由于样本太少,无法学习,导致你无法训练一个可以泛化到新数据的模型。给定无限的数据,你的模型将接触到数据分布的每一个可能方面:你永远不会过拟合。数据增强通过通过一系列随机变换来增强样本,从而生成看起来可信的图像,从而从现有的训练样本中生成更多的训练数据。目标是,在训练时,你的模型永远不会看到完全相同的图片两次。这有助于使模型接触到数据的更多方面,并更好地泛化。
在 Keras 中,这可以通过 数据增强层 来实现。这些层可以通过两种方式之一添加:
-
在模型的开始处 — 在模型内部。在我们的例子中,层将直接位于
Rescaling层之前。 -
在数据管道内部 — 在模型外部。在我们的例子中,我们将通过
map()调用将它们应用于我们的Dataset。
这两种选项之间的主要区别在于,在模型内部进行的数据增强将在 GPU 上运行,就像模型的其他部分一样。同时,在数据管道中进行的数据增强将在 CPU 上运行,通常在多个 CPU 核心上并行运行。有时,进行前者可能会有性能上的好处,但后者通常是更好的选择。所以我们就这么做吧!
# Defines the transformations to apply as a list
data_augmentation_layers = [
layers.RandomFlip("horizontal"),
layers.RandomRotation(0.1),
layers.RandomZoom(0.2),
]
# Creates a function that applies them sequentially
def data_augmentation(images, targets):
for layer in data_augmentation_layers:
images = layer(images)
return images, targets
# Maps this function into the dataset
augmented_train_dataset = train_dataset.map(
data_augmentation, num_parallel_calls=8
)
# Enables prefetching of batches on GPU memory; important for best
# performance
augmented_train_dataset = augmented_train_dataset.prefetch(tf.data.AUTOTUNE)
列表 8.18:定义数据增强阶段
这些只是可用的几层(更多内容请参阅 Keras 文档)。让我们快速浏览一下这段代码:
-
RandomFlip("horizontal")将将水平翻转应用于通过它的随机 50% 的图像。 -
RandomRotation(0.1)将随机旋转输入图像,旋转角度在[–10%,+10%]范围内(这些是完整圆周的分数——以度为单位,范围将是[–36 度,+36 度])。 -
RandomZoom(0.2)将根据随机因子在[–20%,+20%]范围内放大或缩小图像。
让我们看看增强后的图像(见图 8.10)。
plt.figure(figsize=(10, 10))
# You can use take(N) to only sample N batches from the dataset. This
# is equivalent to inserting a break in the loop after the Nth batch.
for image_batch, _ in train_dataset.take(1):
image = image_batch[0]
for i in range(9):
ax = plt.subplot(3, 3, i + 1)
augmented_image, _ = data_augmentation(image, None)
augmented_image = keras.ops.convert_to_numpy(augmented_image)
# Displays the first image in the output batch. For each of the
# nine iterations, this is a different augmentation of the same
# image.
plt.imshow(augmented_image.astype("uint8"))
plt.axis("off")
列表 8.19:显示一些随机增强的训练图像

图 8.10:通过随机数据增强生成一个非常好的男孩的变体
如果你使用这个数据增强配置训练一个新的模型,模型将永远不会看到相同的输入两次。但是它看到的输入仍然高度相关,因为它们来自少量原始图像——你无法产生新信息;你只能重新混合现有信息。因此,这可能不足以完全消除过拟合。为了进一步对抗过拟合,你还将向你的模型中添加一个Dropout层,就在密集连接分类器之前。
inputs = keras.Input(shape=(180, 180, 3))
x = layers.Rescaling(1.0 / 255)(inputs)
x = layers.Conv2D(filters=32, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=64, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=128, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=256, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=512, kernel_size=3, activation="relu")(x)
x = layers.GlobalAveragePooling2D()(x)
x = layers.Dropout(0.25)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
model.compile(
loss="binary_crossentropy",
optimizer="adam",
metrics=["accuracy"],
)
列表 8.20:定义一个新的包含dropout的卷积神经网络
让我们使用数据增强和dropout来训练模型。因为我们预计过拟合将在训练后期发生,我们将训练两倍的周期数——100 个。请注意,我们将在未增强的图像上进行评估——数据增强通常仅在训练时执行,因为它是一种正则化技术。
callbacks = [
keras.callbacks.ModelCheckpoint(
filepath="convnet_from_scratch_with_augmentation.keras",
save_best_only=True,
monitor="val_loss",
)
]
history = model.fit(
augmented_train_dataset,
# Since we expect the model to overfit slower, we train for more
# epochs.
epochs=100,
validation_data=validation_dataset,
callbacks=callbacks,
)
列表 8.21:在增强图像上训练正则化卷积神经网络
让我们再次绘制结果;见图 8.11。多亏了数据增强和dropout,我们开始过拟合的时间大大推迟,大约在 60-70 个周期(与原始模型的第 10 个周期相比)。验证准确率最终达到 85% 以上——比我们第一次尝试有了很大的改进。

图 8.11:数据增强后的训练和验证指标
让我们检查测试准确率。
test_model = keras.models.load_model(
"convnet_from_scratch_with_augmentation.keras"
)
test_loss, test_acc = test_model.evaluate(test_dataset)
print(f"Test accuracy: {test_acc:.3f}")
列表 8.22:在测试集上评估模型
我们得到了 83.9% 的测试准确率。它开始看起来不错了!如果你使用 Colab,请确保下载保存的文件(convnet_from_scratch_with_augmentation.keras),因为我们将用它进行下一章的一些实验。
通过进一步调整模型的配置(例如每个卷积层的滤波器数量或模型中的层数),你可能能够获得更高的准确率,可能高达 90%。但是,仅通过从头开始训练自己的卷积神经网络来提高这个问题的准确率将非常困难,因为你可用的数据非常少。为了提高这个问题的准确率,下一步你将不得不使用预训练模型,这是下一两个章节的重点。
使用预训练模型
在小型图像数据集上进行深度学习的一种常见且高度有效的方法是使用预训练模型。预训练模型是在大型数据集上预先训练的模型,通常是在大规模图像分类任务上。如果这个原始数据集足够大且足够通用,那么预训练模型学习到的特征的空间层次可以有效地作为视觉世界的通用模型,因此其特征可以证明对许多不同的计算机视觉问题是有用的,即使这些新问题可能涉及与原始任务完全不同的类别。例如,你可以在 ImageNet(其中类别主要是动物和日常物品)上训练一个模型,然后将其重新用于识别图像中的家具项目等遥远的应用。这种在不同问题之间学习特征的便携性是深度学习相对于许多较老、较浅学习方法的显著优势,并且使得深度学习在小型数据问题上非常有效。
在这个例子中,让我们考虑一个在 ImageNet 数据集(1.4 百万个标记图像和 1,000 个不同类别)上训练的大型卷积神经网络(ConvNet)。ImageNet 包含许多动物类别,包括不同种类的猫和狗,因此你可以期待它在狗与猫的分类问题上表现良好。
我们将使用 Xception 架构。这可能是你第一次遇到这些可爱的模型名称之一——Xception、ResNet、EfficientNet 等等;如果你继续进行计算机视觉的深度学习,你会习惯它们的,因为它们会经常出现。你将在下一章中了解 Xception 的架构细节。
使用预训练模型有两种方式:特征提取和微调。我们将介绍这两种方法。让我们从特征提取开始。
使用预训练模型进行特征提取
特征提取包括使用先前训练的模型学习到的表示来从新的样本中提取有趣的特征。然后,这些特征将通过一个新的分类器进行处理,该分类器是从零开始训练的。
正如你之前看到的,用于图像分类的卷积神经网络由两部分组成:它们从一系列池化和卷积层开始,并以一个密集连接的分类器结束。第一部分被称为模型的卷积基或骨干。在卷积神经网络的情况下,特征提取包括从先前训练的网络中提取卷积基,将新数据通过它运行,并在输出之上训练一个新的分类器(见图 8.12)。

图 8.12:在保持相同的卷积基的同时交换分类器
为什么只重用卷积基?难道密集连接分类器也不能重用吗?通常,这样做应该避免。原因是卷积基学习到的表示可能更通用,因此更可重用:卷积神经网络的特征图是在图片上对通用概念的呈现图,这可能在处理任何计算机视觉问题时都很有用。但分类器学习到的表示必然是特定于模型训练的类别集的——它们只包含关于整个图片中某个或某个类别的存在概率的信息。此外,密集连接层中找到的表示不再包含任何关于输入图像中对象位置的信息:这些层消除了空间的概念,而对象位置仍然由卷积特征图描述。对于对象位置重要的问题,密集连接特征在很大程度上是无用的。
注意,通过特定卷积层提取的表示的通用性(以及因此的可重用性)取决于该层在模型中的深度。模型中较早的层提取的是局部、高度通用的特征图(例如视觉边缘、颜色和纹理),而较上面的层则提取更抽象的概念(例如“猫耳”或“狗眼”)。因此,如果你的新数据集与原始模型训练的数据集差异很大,你最好只使用模型的前几层来进行特征提取,而不是使用整个卷积基。
在这种情况下,由于 ImageNet 类别集包含多个狗和猫类别,重新使用原始模型密集连接层中的信息可能是有益的。但我们将选择不这样做,以便涵盖新问题的类别集与原始模型的类别集不重叠的更一般情况。让我们通过使用预训练模型的卷积基从猫和狗图像中提取有趣的特征,然后在这些特征之上训练一个猫狗分类器来将这一点付诸实践。
我们将使用 KerasHub 库来创建本书中使用的所有预训练模型。KerasHub 包含了流行预训练模型架构的 Keras 实现,并配以可以下载到您机器上的预训练权重。它包含许多卷积神经网络,如 Xception、ResNet、EfficientNet 和 MobileNet,以及我们将在本书后续章节中使用的更大型的生成模型。让我们尝试使用它来实例化在 ImageNet 数据集上训练的 Xception 模型。
import keras_hub
conv_base = keras_hub.models.Backbone.from_preset("xception_41_imagenet")
列表 8.23:实例化 Xception 卷积基
你会注意到几个问题。首先,KerasHub 使用术语 backbone 来指代没有分类头的底层特征提取网络(比“卷积基础”容易输入一些)。它还使用一个特殊的构造函数 from_preset(),该函数将下载 Xception 模型的配置和权重。
我们使用的模型名称中的“41”是什么意思?按照惯例,预训练的卷积神经网络通常根据它们的“深度”来命名。在这种情况下,41 表示我们的 Xception 模型有 41 个可训练层(卷积和密集层)堆叠在一起。这是我们迄今为止在书中使用的“最深层”模型,差距很大。
在我们能够使用这个模型之前,还有一个缺失的部分需要补充。每个预训练的卷积神经网络(ConvNet)在预训练之前都会对图像进行一些缩放和调整大小。确保我们的输入图像匹配是很重要的;否则,我们的模型将需要重新学习如何从具有完全不同输入范围的图像中提取特征。与其跟踪哪些预训练模型使用 [0, 1] 的像素值输入范围,哪些使用 [-1, 1] 的范围,我们不如使用一个名为 ImageConverter 的 KerasHub 层,它将把我们的图像缩放到与我们的预训练检查点匹配。它具有与骨干类相同的特殊 from_preset() 构造函数。
preprocessor = keras_hub.layers.ImageConverter.from_preset(
"xception_41_imagenet",
image_size=(180, 180),
)
列表 8.24:实例化与 Xception 模型配合使用的预处理
在这个阶段,你可以选择两种方法继续:
-
在你的数据集上运行卷积基础,将其输出记录到磁盘上的 NumPy 数组中,然后使用这些数据作为独立、密集连接的分类器的输入,类似于你在第四章和第五章中看到的。这个解决方案运行速度快且成本低,因为它只需要为每个输入图像运行一次卷积基础,而卷积基础是整个流程中最昂贵的部分。但出于同样的原因,这种技术不允许你使用数据增强。
-
通过在输入数据上添加
Dense层来扩展你现有的模型(conv_base),并从头到尾运行整个模型。这将允许你使用数据增强,因为每次模型看到输入图像时,每个输入图像都会通过卷积基础。但出于同样的原因,这种技术比第一种技术昂贵得多。
我们将介绍这两种技术。让我们通过设置第一个所需的代码来逐步进行:记录 conv_base 在你的数据上的输出,并使用这些输出作为新模型的输入。
无数据增强的快速特征提取
我们将首先通过调用 conv_base 模型的 predict() 方法来提取特征,作为我们的训练、验证和测试数据集。让我们遍历我们的数据集以提取预训练模型的特征。
def get_features_and_labels(dataset):
all_features = []
all_labels = []
for images, labels in dataset:
preprocessed_images = preprocessor(images)
features = conv_base.predict(preprocessed_images, verbose=0)
all_features.append(features)
all_labels.append(labels)
return np.concatenate(all_features), np.concatenate(all_labels)
train_features, train_labels = get_features_and_labels(train_dataset)
val_features, val_labels = get_features_and_labels(validation_dataset)
test_features, test_labels = get_features_and_labels(test_dataset)
列表 8.25:提取图像特征和相应的标签
重要的是,predict() 只期望图像,而不是标签,但我们的当前数据集生成的批次包含图像及其标签。
提取的特征当前形状为 (samples, 6, 6, 2048):
>>> train_features.shape
(2000, 6, 6, 2048)
在这一点上,你可以定义你的密集连接分类器(注意正则化时使用 dropout),并在你刚刚记录的数据和标签上对其进行训练。
inputs = keras.Input(shape=(6, 6, 2048))
# Averages spatial dimensions to flatten the feature map
x = layers.GlobalAveragePooling2D()(inputs)
x = layers.Dense(256, activation="relu")(x)
x = layers.Dropout(0.25)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(
loss="binary_crossentropy",
optimizer="adam",
metrics=["accuracy"],
)
callbacks = [
keras.callbacks.ModelCheckpoint(
filepath="feature_extraction.keras",
save_best_only=True,
monitor="val_loss",
)
]
history = model.fit(
train_features,
train_labels,
epochs=10,
validation_data=(val_features, val_labels),
callbacks=callbacks,
)
代码列表 8.26:定义和训练密集连接分类器
训练非常快,因为你只需要处理两个 Dense 层——即使是在 CPU 上,一个 epoch 也只需不到 1 秒。
让我们看看训练期间的损失和准确率曲线(见图 8.13)。
import matplotlib.pyplot as plt
acc = history.history["accuracy"]
val_acc = history.history["val_accuracy"]
loss = history.history["loss"]
val_loss = history.history["val_loss"]
epochs = range(1, len(acc) + 1)
plt.plot(epochs, acc, "r--", label="Training accuracy")
plt.plot(epochs, val_acc, "b", label="Validation accuracy")
plt.title("Training and validation accuracy")
plt.legend()
plt.figure()
plt.plot(epochs, loss, "r--", label="Training loss")
plt.plot(epochs, val_loss, "b", label="Validation loss")
plt.title("Training and validation loss")
plt.legend()
plt.show()
代码列表 8.27:绘制结果

图 8.13:平面特征提取的训练和验证指标
你达到了略高于 98% 的验证准确率——比你在上一节中使用从头开始训练的小模型所达到的准确率要好得多。然而,这种比较有点不公平,因为 ImageNet 包含许多狗和猫的实例,这意味着我们的预训练模型已经具备了完成任务所需的精确知识。当你使用预训练特征时,这种情况并不总是如此。
然而,这些图表也表明你几乎从一开始就过度拟合——尽管使用了相当大的 dropout 率。这是因为这项技术没有使用数据增强,这对于防止使用小型图像数据集时的过度拟合是至关重要的。
让我们检查测试准确率:
test_model = keras.models.load_model("feature_extraction.keras")
test_loss, test_acc = test_model.evaluate(test_features, test_labels)
print(f"Test accuracy: {test_acc:.3f}")
我们得到了 98.1% 的测试准确率——与从头开始训练模型相比,这是一个非常好的改进!
特征提取与数据增强相结合
现在,让我们回顾我们提到的第二个用于特征提取的技术,它速度较慢且成本更高,但允许你在训练期间使用数据增强:创建一个将 conv_base 与新的密集分类器链式连接的模型,并在输入上从头到尾进行训练。
要做到这一点,我们首先冻结卷积基。冻结一个层或一组层意味着防止它们在训练期间更新权重。在这里,如果你不这样做,那么卷积基之前学习到的表示将在训练期间被修改。因为顶部的 Dense 层是随机初始化的,非常大的权重更新将通过网络传播,实际上破坏了之前学习到的表示。
在 Keras 中,你可以通过将层的 trainable 属性设置为 False 来冻结一个层或模型。
import keras_hub
conv_base = keras_hub.models.Backbone.from_preset(
"xception_41_imagenet",
trainable=False,
)
代码列表 8.28:创建冻结的卷积基
将 trainable 设置为 False 将清空层或模型的可训练权重列表。
>>> conv_base.trainable = True
>>> # The number of trainable weights before freezing the conv base
>>> len(conv_base.trainable_weights)
154
>>> conv_base.trainable = False
>>> # The number of trainable weights after freezing the conv base
>>> len(conv_base.trainable_weights)
0
代码列表 8.29:冻结前后的可训练权重列表
现在,我们只需创建一个新的模型,将冻结的卷积基础和密集分类器链接在一起,如下所示:
inputs = keras.Input(shape=(180, 180, 3))
x = preprocessor(inputs)
x = conv_base(x)
x = layers.GlobalAveragePooling2D()(x)
x = layers.Dense(256)(x)
x = layers.Dropout(0.25)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(
loss="binary_crossentropy",
optimizer="adam",
metrics=["accuracy"],
)
使用这种设置,只有你添加的两个Dense层的权重将被训练。总共是四个权重张量:每个层两个(主权重矩阵和偏置向量)。请注意,为了使这些更改生效,你必须首先编译模型。如果你在编译后修改权重的可训练性,那么你应该重新编译模型,否则这些更改将被忽略。
让我们训练我们的模型。我们将重用我们的增强数据集augmented_train_dataset。多亏了数据增强,模型开始过度拟合需要更长的时间,因此我们可以训练更多的轮次——让我们做 30 轮:
callbacks = [
keras.callbacks.ModelCheckpoint(
filepath="feature_extraction_with_data_augmentation.keras",
save_best_only=True,
monitor="val_loss",
)
]
history = model.fit(
augmented_train_dataset,
epochs=30,
validation_data=validation_dataset,
callbacks=callbacks,
)
让我们再次绘制结果(见图 8.14)。这个模型达到了 98.2%的验证准确率。

图 8.14:使用数据增强进行特征提取的训练和验证指标
让我们检查测试准确率。
test_model = keras.models.load_model(
"feature_extraction_with_data_augmentation.keras"
)
test_loss, test_acc = test_model.evaluate(test_dataset)
print(f"Test accuracy: {test_acc:.3f}")
列表 8.30:在测试集上评估模型
我们得到了 98.4%的测试准确率。这并没有比之前的模型有改进,这有点令人失望。这可能表明我们的数据增强配置并不完全符合测试数据的分布。让我们看看我们能否在我们的最新尝试中做得更好。
微调预训练模型
另一种广泛使用的模型重用技术,与特征提取互补的是微调(见图 8.15)。微调包括解冻用于特征提取的冻结模型基础,并联合训练模型的新添加部分(在这种情况下,全连接分类器)和基础模型。这被称为微调,因为它略微调整了被重用的模型的更抽象的表示,使其更相关于当前的问题。
我们之前提到,为了能够在顶部训练一个随机初始化的分类器,首先需要冻结预训练的卷积基础。出于同样的原因,只有在顶部的分类器已经训练好的情况下,才能微调卷积基础。如果分类器尚未训练,那么在训练过程中通过网络传播的错误信号将太大,并且之前由正在微调的层学到的表示将被破坏。因此,微调网络的步骤如下:
-
在已经训练好的基础网络上添加你的自定义网络。
-
解冻基础网络。
-
训练你添加的部分。
-
解冻基础网络。
-
联合训练这两个层以及你添加的部分。
注意,你不应该解冻“批量归一化”层(BatchNormalization)。批量归一化及其对微调的影响将在下一章中解释。
当进行特征提取时,你已经完成了前三个步骤。让我们继续进行第 4 步:你将解冻你的conv_base。
让我们以一个非常低的学习率开始微调模型。使用低学习率的原因是,你希望限制你对正在微调的层的表示所做的修改的幅度。过大的更新可能会损害这些表示。
model.compile(
loss="binary_crossentropy",
optimizer=keras.optimizers.Adam(learning_rate=1e-5),
metrics=["accuracy"],
)
callbacks = [
keras.callbacks.ModelCheckpoint(
filepath="fine_tuning.keras",
save_best_only=True,
monitor="val_loss",
)
]
history = model.fit(
augmented_train_dataset,
epochs=30,
validation_data=validation_dataset,
callbacks=callbacks,
)
列表 8.31:微调模型
你现在可以最终在测试数据上评估这个模型了(见图 8.15):
model = keras.models.load_model("fine_tuning.keras")
test_loss, test_acc = model.evaluate(test_dataset)
print(f"Test accuracy: {test_acc:.3f}")

:微调的训练和验证指标
在这里,你得到了 98.6%的测试准确率(再次强调,你的结果可能在半个百分点之内)。在围绕这个数据集的原始 Kaggle 竞赛中,这将是一份顶尖的结果。然而,这种比较并不完全公平,因为你使用了已经包含有关猫和狗先前知识的预训练特征,而当时的竞争对手无法使用这些特征。
在积极的一面,通过使用现代深度学习技术,你只用到了竞赛中可用训练数据的一小部分(大约 10%)就达到了这个结果。与训练 2,000 个样本相比,能够在 20,000 个样本上进行训练有着巨大的差异!
现在你已经拥有了一套处理图像分类问题(特别是小型数据集)的强大工具。
摘要
-
卷积神经网络在计算机视觉任务中表现出色。即使是在非常小的数据集上,从头开始训练一个卷积神经网络也是可能的,并且可以得到相当不错的结果。
-
卷积神经网络通过学习一系列模块化的模式和概念来表示视觉世界,从而工作。
-
在小型数据集上,过拟合将是主要问题。数据增强是当你处理图像数据时对抗过拟合的一种强大方式。
-
通过特征提取,在新的数据集上重用现有的卷积神经网络(ConvNet)非常容易。这对于处理小型图像数据集来说是一种非常有价值的技巧。
-
作为特征提取的补充,你可以使用微调,它适应于新问题,并调整了现有模型之前学习的一些表示。这进一步提升了性能。
第九章:ConvNet 架构模式
原文:
deeplearningwithpython.io/chapters/chapter09_convnet-architecture-patterns
模型的“架构”是创建它时所做的所有选择的总和:使用哪些层,如何配置它们,以及如何连接它们的排列。这些选择定义了您模型的假设空间:梯度下降可以搜索的可能函数空间,由模型的权重参数化。就像特征工程一样,一个好的假设空间编码了您对当前问题和其解决方案的先验知识。例如,使用卷积层意味着您事先知道您输入图像中存在的相关模式是平移不变的。为了有效地从数据中学习,您需要就您要寻找的内容做出假设。
模型架构通常是成功与失败之间的区别。如果您做出不适当的架构选择,您的模型可能会陷入次优指标,并且无论多少训练数据都无法挽救它。相反,一个好的模型架构将加速学习,并使您的模型能够有效地利用可用的训练数据,减少对大型数据集的需求。一个好的模型架构是那种减少搜索空间大小或以其他方式使搜索空间中的良好点更容易收敛的架构。就像特征工程和数据整理一样,模型架构的全部都是为了使问题对梯度下降来说更简单——并且请记住,梯度下降是一个相当愚蠢的搜索过程,所以它需要所有它能得到的帮助。
模型架构更是一门艺术而非科学。经验丰富的机器学习工程师能够直观地组合出高性能模型,而初学者往往难以创建一个能够训练的模型。这里的关键词是直观:没有人能给出一个明确的解释,说明什么有效,什么无效。专家们依赖于模式匹配,这是一种他们通过大量实践经验获得的能力。您将在本书中发展自己的直觉。然而,这并不是全部关于直觉的——实际上并没有多少真正的科学,但就像任何工程学科一样,有最佳实践。
在接下来的章节中,我们将回顾一些基本的 ConvNet 架构最佳实践,特别是残差连接、批量归一化和可分离卷积。一旦您掌握了如何使用它们,您将能够构建高度有效的图像模型。我们将演示如何在我们的狗与猫分类问题上应用它们。
让我们从宏观的角度开始:系统架构的模块化-层次-重用(MHR)公式。
模块化、层次和重用
如果你想使一个复杂系统变得简单,有一个通用的配方你可以应用:只需将你的无序复杂汤结构化为模块,将模块组织成一个层次结构,并在适当的地方开始重复使用相同的模块(“重复使用”是抽象的另一个词)。这就是模块化-层次结构-重复(MHR)公式(见图 9.1),它几乎在所有使用术语架构的领域中都构成了系统架构的基础。它是任何有意义的复杂系统组织的核心,无论是大教堂、你自己的身体、美国海军,还是 Keras 代码库。

图 9.1:复杂系统遵循层次结构,并组织成不同的模块,这些模块被多次重复使用(例如你的 4 条肢体,它们都是同一蓝图的不同变体,或者你的 20 个手指)。
如果你是一名软件工程师,你已经非常熟悉这些原则:一个有效的代码库是一个模块化、层次化的代码库,你不会两次实现相同的事情,而是依赖于可重用的类和函数。如果你通过遵循这些原则来分解你的代码,你可以说你正在做“软件架构”。
深度学习本身仅仅是将这个配方应用于通过梯度下降的连续优化:你采用一个经典的优化技术(在连续函数空间上的梯度下降),并将搜索空间结构化为模块(层),组织成一个深度层次结构(通常只是一个堆栈,最简单的层次结构),在那里你可以重复使用任何可以重复的内容(例如,卷积全部关于在不同空间位置重复相同的信息)。
同样,深度学习模型架构主要关于巧妙地使用模块化、层次结构和重复。你会注意到所有流行的 ConvNet 架构不仅被结构化为层,还被结构化为重复的层组(称为块或模块)。例如,Xception 架构(在上一章中使用)被结构化为重复的SeparableConv - SeparableConv - MaxPooling块(见图 9.2)。
此外,大多数 ConvNets 通常具有金字塔状结构(特征层次结构)。回想一下,例如,我们在上一章中构建的第一个 ConvNet 中使用的卷积滤波器数量的进展:32、64、128。滤波器的数量随着层深度的增加而增加,而特征图的大小相应缩小。你会在 Xception 模型的块中注意到相同的模式(见图 9.2)。

图 9.2:Xception 架构的“入口流”:注意重复的层块和逐渐缩小和加深的特征图,从 299 x 299 x 3 变为 19 x 19 x 728。
深层层次结构本质上是有益的,因为它们鼓励特征重用,因此也鼓励抽象。一般来说,深层窄层堆叠比浅层大层堆叠表现更好。然而,层堆叠的深度有一个限制:梯度消失问题。这使我们来到了第一个基本模型架构模式:残差连接。
残差连接
你可能听说过游戏电话,在英国也称为Chinese whispers,在法国称为telephone arabe,在这个游戏中,一个初始信息被悄悄地告诉一个玩家,然后该玩家再悄悄地告诉下一个玩家,以此类推。最终的信息与原始版本相差甚远。这是一个有趣的隐喻,描述了在噪声信道上顺序传输过程中累积的错误。
事实上,在顺序深度学习模型中的反向传播与电话游戏非常相似。你有一系列函数,就像这样:
y = f4(f3(f2(f1(x))))
游戏的目的是根据记录在f4输出上的错误(模型的损失)来调整链中每个函数的参数。要调整f1,你需要通过f2、f3和f4传递错误信息。然而,链中的每个后续函数在过程中都会引入一些噪声。如果你的函数链太深,这种噪声开始压倒梯度信息,反向传播就不再起作用。你的模型将无法进行训练。这被称为梯度消失问题。
修复方法很简单:只需强制链中的每个函数都是非破坏性的——保留前一个输入中包含的无噪声版本的信息。实现这一点的最简单方法被称为残差连接。这非常简单:只需将层或层块的输入添加回其输出(见图 9.3)。残差连接在破坏性或噪声块(例如包含 ReLU 激活或 dropout 层的块)周围充当信息捷径,使早期层的错误梯度信息能够无噪声地通过深度网络传播。这项技术于 2015 年随着 ResNet 系列模型(由微软的 He 等人开发)的引入而提出。([1](#footnote-1))

图 9.3:一个处理块周围的残差连接
在实践中,你会实现一个像以下列表那样的残差连接。
# Some input tensor
x = ...
# Saves a reference to the original input. This is called the residual.
residual = x
# This computation block can potentially be destructive or noisy, and
# that's fine.
x = block(x)
# Adds the original input to the layer's output. The final output will
# thus always preserve full information about the original input.
x = add([x, residual])
代码清单 9.1:伪代码中的残差连接
注意,将输入加回到块的输出意味着输出应该与输入具有相同的形状。如果你的块包含具有增加的滤波器数量的卷积层或最大池化层,则这种情况不成立。在这种情况下,使用一个没有激活的 1 × 1 Conv2D层将残差线性投影到所需的输出形状。你通常会在目标块中的卷积层中使用padding="same"来避免由于填充造成的空间下采样,并且你会在残差投影中使用步长来匹配由最大池化层引起的任何下采样。
import keras
from keras import layers
inputs = keras.Input(shape=(32, 32, 3))
x = layers.Conv2D(32, 3, activation="relu")(inputs)
# Sets aside the residual
residual = x
# This is the layer around which we create a residual connection: it
# increases the number of output filers from 32 to 64\. We use
# padding="same" to avoid downsampling due to padding.
x = layers.Conv2D(64, 3, activation="relu", padding="same")(x)
# The residual only had 32 filters, so we use a 1 x 1 Conv2D to project
# it to the correct shape.
residual = layers.Conv2D(64, 1)(residual)
# Now the block output and the residual have the same shape and can be
# added.
x = layers.add([x, residual])
列表 9.2:改变输出滤波器数量的目标块
inputs = keras.Input(shape=(32, 32, 3))
x = layers.Conv2D(32, 3, activation="relu")(inputs)
# Sets aside the residual
residual = x
# This is the block of two layers around which we create a residual
# connection: it includes a 2 x 2 max pooling layer. We use
# padding="same" in both the convolution layer and the max pooling
# layer to avoid downsampling due to padding.
x = layers.Conv2D(64, 3, activation="relu", padding="same")(x)
x = layers.MaxPooling2D(2, padding="same")(x)
# We use strides=2 in the residual projection to match the downsampling
# created by the max pooling layer.
residual = layers.Conv2D(64, 1, strides=2)(residual)
# Now the block output and the residual have the same shape and can be
# added.
x = layers.add([x, residual])
列表 9.3:包含最大池化层的目标块
为了使这些想法更加具体,这里有一个简单的卷积神经网络示例,它由一系列块组成,每个块由两个卷积层和一个可选的最大池化层组成,每个块周围都有一个残差连接:
inputs = keras.Input(shape=(32, 32, 3))
x = layers.Rescaling(1.0 / 255)(inputs)
# Utility function to apply a convolutional block with a residual
# connection, with an option to add max pooling
def residual_block(x, filters, pooling=False):
residual = x
x = layers.Conv2D(filters, 3, activation="relu", padding="same")(x)
x = layers.Conv2D(filters, 3, activation="relu", padding="same")(x)
if pooling:
x = layers.MaxPooling2D(2, padding="same")(x)
# If we use max pooling, we add a strided convolution to
# project the residual to the expected shape.
residual = layers.Conv2D(filters, 1, strides=2)(residual)
elif filters != residual.shape[-1]:
# If we don't use max pooling, we only project the residual if
# the number of channels has changed.
residual = layers.Conv2D(filters, 1)(residual)
x = layers.add([x, residual])
return x
# First block
x = residual_block(x, filters=32, pooling=True)
# Second block. Note the increasing filter count in each block.
x = residual_block(x, filters=64, pooling=True)
# The last block doesn't need a max pooling layer, since we will apply
# global average pooling right after it.
x = residual_block(x, filters=128, pooling=False)
x = layers.GlobalAveragePooling2D()(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
让我们来看看模型摘要:
>>> model.summary()
Model: "functional"
┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃
┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
│ input_layer_2 │ (None, 32, 32, 3) │ 0 │ - │
│ (InputLayer) │ │ │ │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ rescaling (Rescaling)│ (None, 32, 32, 3) │ 0 │ input_layer_2[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ conv2d_6 (Conv2D) │ (None, 32, 32, 32) │ 896 │ rescaling[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ conv2d_7 (Conv2D) │ (None, 32, 32, 32) │ 9,248 │ conv2d_6[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ max_pooling2d_1 │ (None, 16, 16, 32) │ 0 │ conv2d_7[0][0] │
│ (MaxPooling2D) │ │ │ │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ conv2d_8 (Conv2D) │ (None, 16, 16, 32) │ 128 │ rescaling[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ add_2 (Add) │ (None, 16, 16, 32) │ 0 │ max_pooling2d_1[0]… │
│ │ │ │ conv2d_8[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ conv2d_9 (Conv2D) │ (None, 16, 16, 64) │ 18,496 │ add_2[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ conv2d_10 (Conv2D) │ (None, 16, 16, 64) │ 36,928 │ conv2d_9[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ max_pooling2d_2 │ (None, 8, 8, 64) │ 0 │ conv2d_10[0][0] │
│ (MaxPooling2D) │ │ │ │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ conv2d_11 (Conv2D) │ (None, 8, 8, 64) │ 2,112 │ add_2[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ add_3 (Add) │ (None, 8, 8, 64) │ 0 │ max_pooling2d_2[0]… │
│ │ │ │ conv2d_11[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ conv2d_12 (Conv2D) │ (None, 8, 8, 128) │ 73,856 │ add_3[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ conv2d_13 (Conv2D) │ (None, 8, 8, 128) │ 147,584 │ conv2d_12[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ conv2d_14 (Conv2D) │ (None, 8, 8, 128) │ 8,320 │ add_3[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ add_4 (Add) │ (None, 8, 8, 128) │ 0 │ conv2d_13[0][0], │
│ │ │ │ conv2d_14[0][0] │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ global_average_pool… │ (None, 128) │ 0 │ add_4[0][0] │
│ (GlobalAveragePooli… │ │ │ │
├──────────────────────┼────────────────────┼────────────┼─────────────────────┤
│ dense (Dense) │ (None, 1) │ 129 │ global_average_poo… │
└──────────────────────┴────────────────────┴────────────┴─────────────────────┘
Total params: 297,697 (1.14 MB)
Trainable params: 297,697 (1.14 MB)
Non-trainable params: 0 (0.00 B)
使用残差连接,你可以构建任意深度的网络,无需担心梯度消失问题。现在,让我们继续探讨下一个关键的卷积神经网络架构模式:批量归一化。
批量归一化
在机器学习中,归一化是一个广泛的类别,旨在使机器学习模型看到的不同样本彼此更加相似,这有助于模型学习并很好地泛化到新数据。最常见的数据归一化形式是你在这本书中已经看到几次的:通过从数据中减去均值来将数据中心化在零点,并通过将数据除以其标准差来给数据一个单位标准差。实际上,这假设数据遵循正态(或高斯)分布,并确保这个分布是中心化和缩放到单位方差的:
normalized_data = (data - np.mean(data, axis=...)) / np.std(data, axis=...)
你在这本书中看到的先前示例在将数据输入模型之前对数据进行归一化。但是,数据归一化可能是网络执行的每个转换之后的一个问题:即使进入Dense或Conv2D网络的数据具有 0 均值和单位方差,也没有理由事先期望输出数据也会是这样。对中间激活进行归一化能有所帮助吗?
批量归一化正是如此。这是一种层(在 Keras 中为BatchNormalization),由 Ioffe 和 Szegedy 于 2015 年引入;^([2])它可以在训练过程中,即使均值和方差随时间变化,也能自适应地归一化数据。在训练期间,它使用当前数据批次的均值和方差来归一化样本,在推理期间(当可能没有足够大的代表性数据批次时),它使用训练期间看到的批处理均值和方差的指数移动平均值。
虽然 Ioffe 和 Szegedy 的原始论文建议批归一化是通过“减少内部协变量偏移”来操作的,但没有人真正知道为什么批归一化有帮助。有各种假设但没有确定性。你会发现这在深度学习中很常见——深度学习不是一门精确的科学,而是一套不断变化、基于经验得出的工程最佳实践,由不可靠的叙述编织在一起。你有时会感觉你手中的书告诉你 如何 做某事,但并没有很好地解释 为什么 它有效:这是因为我们知道如何做,但不知道为什么。每当有可靠的解释时,我们都会确保提及它。批归一化不是那种情况。
实际上,批归一化的主要效果似乎是有助于梯度传播——就像残差连接一样——从而允许更深的网络。一些非常深的网络只有在包含多个 BatchNormalization 层的情况下才能进行训练。例如,批归一化在 Keras 包含的许多高级 ConvNet 架构中被广泛使用,如 ResNet50、EfficientNet 和 Xception。
BatchNormalization 层可以在任何层之后使用——Dense、Conv2D 等等:
x = ...
# Because the output of the Conv2D layer gets normalized, the layer
# doesn't need its own bias vector.
x = layers.Conv2D(32, 3, use_bias=False)(x)
x = layers.BatchNormalization()(x)
重要的是,我通常会推荐将前一个层的激活放在批归一化层之后(尽管这仍然是一个有争议的话题)。所以,而不是这样做
x = layers.Conv2D(32, 3, activation="relu")(x)
x = layers.BatchNormalization()(x)
列表 9.4:如何不使用批归一化
你实际上会做以下操作:
# Note the lack of activation here.
x = layers.Conv2D(32, 3, use_bias=False)(x)
x = layers.BatchNormalization()(x)
# We place the activation after the BatchNormalization layer.
x = layers.Activation("relu")(x)
列表 9.5:如何使用批归一化
直觉上,这是因为批归一化会将你的输入中心化在零点,而你的 ReLU 激活使用零作为保持或丢弃激活通道的支点:在激活之前进行归一化最大化了 ReLU 的利用率。尽管如此,这种排序最佳实践并不是绝对的,所以如果你进行卷积-激活-批归一化,你的模型仍然可以训练,你也不一定会看到更差的结果。
现在,让我们来看看我们系列中的最后一个架构模式:深度可分离卷积。
深度可分离卷积
假设我们告诉你有一个层可以作为 Conv2D 的直接替换,这将使你的模型更小(更少的可训练权重参数)、更精简(更少的浮点运算),并在其任务上提高几个百分点?这正是 深度可分离卷积 层所做的(在 Keras 中为 SeparableConv2D)。这个层对其输入的每个通道独立地进行空间卷积,然后通过点卷积(一个 1 × 1 卷积)混合输出通道,如图 9.4 所示。

图 9.4:深度可分离卷积:先进行深度卷积然后进行点卷积
这相当于将空间特征的学习和通道特征的学习分开。与卷积依赖于图像中的模式与特定位置无关的假设类似,深度可分离卷积依赖于中间激活中的空间位置高度相关,但不同通道高度独立的假设。由于这个假设通常适用于深度神经网络学习的图像表示,它作为一个有用的先验,有助于模型更有效地利用其训练数据。具有关于它将必须处理的信息结构的更强先验的模型是一个更好的模型——只要这些先验是准确的。
与常规卷积相比,深度可分离卷积需要显著更少的参数,涉及的计算也更少,同时具有可比的表示能力。它们导致模型更小,收敛更快,并且不太容易过拟合。当你在有限的数据上从头开始训练小型模型时,这些优势变得尤为重要。
当涉及到更大规模的模型时,深度可分离卷积是 Xception 架构的基础,这是一个高性能的 ConvNet,它包含在 Keras 中。你可以在论文“Xception: Deep Learning with Depthwise Separable Convolutions.”中了解更多关于深度可分离卷积和 Xception 的理论基础。[[2](#footnote-3)]
将其整合:一个类似 Xception 的小型模型
作为提醒,以下是你迄今为止学到的 ConvNet 架构原则:
-
你的模型应该组织成重复的块层,通常由多个卷积层和一个最大池化层组成。
-
你的层中的滤波器数量应随着空间特征图大小的减小而增加。
-
深而窄优于宽而浅。
-
在层块周围引入残差连接有助于你训练更深的网络。
-
在你的卷积层之后引入批量归一化层可能有益。
-
将
Conv2D层替换为更参数高效的SeparableConv2D层可能有益。
让我们将所有这些想法整合成一个单一模型。其架构类似于 Xception 的一个更小版本。我们将将其应用于上一章中的狗与猫的任务。对于数据加载和模型训练,只需重复使用与第八章第 8.2 节中完全相同的设置——但将模型定义替换为以下 ConvNet:
import keras
inputs = keras.Input(shape=(180, 180, 3))
# Don't forget input rescaling!
x = layers.Rescaling(1.0 / 255)(inputs)
# The assumption that underlies separable convolution, "Feature
# channels are largely independent," does not hold for RGB images! Red,
# green, and blue color channels are actually highly correlated in
# natural images. As such, the first layer in our model is a regular
# `Conv2D` layer. We'll start using `SeparableConv2D` afterward.
x = layers.Conv2D(filters=32, kernel_size=5, use_bias=False)(x)
# We apply a series of convolutional blocks with increasing feature
# depth. Each block consists of two batch-normalized depthwise
# separable convolution layers and a max pooling layer, with a residual
# connection around the entire block.
for size in [32, 64, 128, 256, 512]:
residual = x
x = layers.BatchNormalization()(x)
x = layers.Activation("relu")(x)
x = layers.SeparableConv2D(size, 3, padding="same", use_bias=False)(x)
x = layers.BatchNormalization()(x)
x = layers.Activation("relu")(x)
x = layers.SeparableConv2D(size, 3, padding="same", use_bias=False)(x)
x = layers.MaxPooling2D(3, strides=2, padding="same")(x)
residual = layers.Conv2D(
size, 1, strides=2, padding="same", use_bias=False
)(residual)
x = layers.add([x, residual])
# In the original model, we used a Flatten layer before the Dense
# layer. Here, we go with a GlobalAveragePooling2D layer.
x = layers.GlobalAveragePooling2D()(x)
# Like in the original model, we add a dropout layer for
# regularization.
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
该 ConvNet 的可训练参数数量为 721,857,显著低于上一章模型中 1,569,089 个可训练参数,但取得了更好的结果。图 9.5 显示了训练和验证曲线。

图 9.5:具有类似 Xception 架构的训练和验证指标
你会发现我们的新模型达到了 90.8% 的测试准确率——相比之下,前一个模型的准确率是 83.9%。正如你所看到的,遵循架构最佳实践确实对模型性能有立即和显著的影响!
到目前为止,如果你想进一步提高性能,你应该开始系统地调整你架构的超参数——这是我们在第十八章中详细讨论的主题。我们在这里没有进行这一步骤,所以前一个模型的配置纯粹是基于我们概述的最佳实践,以及,在评估模型大小时,一点直觉。
超越卷积:视觉 Transformer
虽然 ConvNets 自 2010 年中叶以来一直主导着计算机视觉领域,但它们最近正与一种替代架构竞争:视觉 Transformer(简称 ViTs)。很可能 ViTs 最终会取代 ConvNets,尽管目前来说,在大多数情况下 ConvNets 仍然是你的最佳选择。
你还不知道什么是 Transformer,因为我们将在第十五章中介绍它们。简而言之,Transformer 架构是为了处理文本而开发的——它本质上是一个序列处理架构。Transformer 在此方面非常出色,这引发了一个问题:我们是否也可以将它们用于图像?
因为 ViTs 是一种 Transformer,它们也处理序列:它们将图像分割成一系列 1D 补丁,将每个补丁转换成一个平面向量,并处理向量序列。Transformer 架构允许 ViTs 捕获图像不同部分之间的长距离关系,这是 ConvNets 有时难以做到的。
我们对 Transformer 的总体经验是,如果你正在处理大量数据集,它们是一个很好的选择。它们在利用大量数据方面表现得更好。然而,对于较小的数据集,由于两个原因,它们往往不是最佳选择。首先,它们缺乏 ConvNets 的空间先验——ConvNets 的基于 2D 补丁的架构包含了更多关于视觉空间局部结构的假设,这使得它们更有效率。其次,为了使 ViTs 发挥出色,它们需要非常大。对于小于 ImageNet 的任何东西,它们最终都会变得难以处理。
图像识别霸权的竞争远未结束,但 ViTs 无疑开启了一个新的、令人兴奋的篇章。你可能会在大规模生成图像模型的背景下使用这种架构——这是我们在第十七章中将要讨论的主题。然而,对于你的小规模图像分类需求,ConvNets 仍然是你的最佳选择。
这就结束了我们对关键卷积神经网络架构最佳实践的介绍。掌握这些原则后,你将能够开发出适用于广泛计算机视觉任务的性能更高的模型。你现在正朝着成为一名熟练的计算机视觉实践者的道路迈进。为了进一步深化你的专业知识,我们还需要讨论最后一个重要主题:解释模型如何得出其预测结果。
摘要
-
深度学习模型的架构编码了对待解决问题本质的关键假设。
-
模块化-层次-重用公式是几乎所有复杂系统架构的基础,包括深度学习模型。
-
计算机视觉的关键架构模式包括残差连接、批归一化和深度可分离卷积。
-
视觉 Transformer 是卷积神经网络在大型计算机视觉任务中的新兴替代方案。
脚注
-
Kaiming He 等人,“用于图像识别的深度残差学习”,计算机视觉与模式识别会议(2015 年),
arxiv.org/abs/1512.03385。[↩] -
Sergey Ioffe 和 Christian Szegedy,“通过减少内部协变量偏移加速深度网络训练:批归一化”,第 32 届国际机器学习会议论文集(2015 年),
arxiv.org/abs/1502.03167。[↩] -
François Chollet,“Xception:使用深度可分离卷积的深度学习”,计算机视觉与模式识别会议(2017 年),
arxiv.org/abs/1610.02357。[↩]
第十章:解释卷积神经网络学习的内容
原文:
deeplearningwithpython.io/chapters/chapter10_interpreting-what-convnets-learn
在构建计算机视觉应用时,一个基本问题是可解释性:为什么你的分类器认为某个图像包含冰箱,而你只能看到一辆卡车?这在深度学习用于补充人类专业知识的应用场景中尤为重要,例如医学影像应用场景。本章将使你熟悉一系列不同的技术,用于可视化卷积神经网络学习的内容以及理解它们的决策。
人们常说深度学习模型是“黑盒”:它们学习到的表示难以提取并以人类可读的形式呈现。虽然这在某些类型的深度学习模型中部分正确,但对于卷积神经网络来说绝对不是这样。卷积神经网络学习到的表示高度适合可视化,这在很大程度上是因为它们是视觉概念的表示。自 2013 年以来,已经开发出大量技术来可视化和解释这些表示。我们不会对所有这些技术进行综述,但我们将介绍三种最易于获取和最有用的技术:
-
可视化中间卷积神经网络输出(中间激活)——有助于理解连续的卷积神经网络层如何转换它们的输入,以及获得对单个卷积神经网络滤波器意义的初步了解
-
可视化卷积神经网络滤波器——有助于理解卷积神经网络中的每个滤波器对精确的视觉模式或概念的反应
-
可视化图像中类别激活的热图——有助于理解图像的哪些部分被识别为属于给定类别,从而允许你在图像中定位对象
对于第一种方法——激活可视化——你将使用你在第八章从头开始训练的小型卷积神经网络,用于处理狗与猫的分类问题。对于接下来的两种方法,你将使用预训练的 Xception 模型。
可视化中间激活
可视化中间激活包括显示模型中各种卷积和池化层在给定输入(层的输出通常称为其激活,激活函数的输出称为激活值)下返回的值。这提供了了解输入如何分解为网络学习到的不同滤波器的视角。你想要可视化的特征图有三个维度:宽度、高度和深度(通道)。每个通道编码相对独立的特点,因此可视化这些特征图的正确方法是独立地将每个通道的内容作为二维图像绘制出来。让我们先加载你在第 8.2 节中保存的模型:
>>> import keras
>>> model = keras.models.load_model(
... "convnet_from_scratch_with_augmentation.keras"
... )
>>> model.summary()
Model: "functional_3"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_layer_3 (InputLayer) │ (None, 180, 180, 3) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ rescaling_1 (Rescaling) │ (None, 180, 180, 3) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_11 (Conv2D) │ (None, 178, 178, 32) │ 896 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d_6 (MaxPooling2D) │ (None, 89, 89, 32) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_12 (Conv2D) │ (None, 87, 87, 64) │ 18,496 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d_7 (MaxPooling2D) │ (None, 43, 43, 64) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_13 (Conv2D) │ (None, 41, 41, 128) │ 73,856 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d_8 (MaxPooling2D) │ (None, 20, 20, 128) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_14 (Conv2D) │ (None, 18, 18, 256) │ 295,168 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ max_pooling2d_9 (MaxPooling2D) │ (None, 9, 9, 256) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_15 (Conv2D) │ (None, 7, 7, 512) │ 1,180,160 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ global_average_pooling2d_3 │ (None, 512) │ 0 │
│ (GlobalAveragePooling2D) │ │ │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dropout (Dropout) │ (None, 512) │ 0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_3 (Dense) │ (None, 1) │ 513 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
Total params: 4,707,269 (17.96 MB)
Trainable params: 1,569,089 (5.99 MB)
Non-trainable params: 0 (0.00 B)
Optimizer params: 3,138,180 (11.97 MB)
接下来,你将获得一个输入图像——一张猫的图片,这不是网络训练图像的一部分。
import keras
import numpy as np
# Downloads a test image
img_path = keras.utils.get_file(
fname="cat.jpg", origin="https://img-datasets.s3.amazonaws.com/cat.jpg"
)
def get_img_array(img_path, target_size):
# Opens the image file and resizes it
img = keras.utils.load_img(img_path, target_size=target_size)
# Turns the image into a float32 NumPy array of shape (180, 180, 3)
array = keras.utils.img_to_array(img)
# We add a dimension to transform our array into a "batch" of a
# single sample. Its shape is now (1, 180, 180, 3).
array = np.expand_dims(array, axis=0)
return array
img_tensor = get_img_array(img_path, target_size=(180, 180))
代码清单 10.1:预处理单个图像
让我们显示这张图片(见图 10.1)。
import matplotlib.pyplot as plt
plt.axis("off")
plt.imshow(img_tensor[0].astype("uint8"))
plt.show()
代码清单 10.2:显示测试图片

图 10.1:测试猫图片
要提取你想要查看的特征图,你需要创建一个 Keras 模型,该模型以图像批次作为输入,并输出所有卷积和池化层的激活。
from keras import layers
layer_outputs = []
layer_names = []
# Extracts the outputs of all Conv2D and MaxPooling2D layers and put
# them in a list
for layer in model.layers:
if isinstance(layer, (layers.Conv2D, layers.MaxPooling2D)):
layer_outputs.append(layer.output)
# Saves the layer names for later
layer_names.append(layer.name)
# Creates a model that will return these outputs, given the model input
activation_model = keras.Model(inputs=model.input, outputs=layer_outputs)
代码清单 10.3:实例化返回层激活的模型
当输入图像时,此模型返回原始模型中层激活的值,作为一个列表。这是你在第七章学习了多输出模型后,在本书中第一次在实践中遇到多输出模型:到目前为止,你看到的模型都有且只有一个输入和一个输出。这个模型有一个输入和九个输出——每个层的激活对应一个输出。
# Returns a list of nine NumPy arrays — one array per layer activation
activations = activation_model.predict(img_tensor)
代码清单 10.4:使用模型计算层激活
例如,这是猫图像输入的第一卷积层的激活:
>>> first_layer_activation = activations[0]
>>> print(first_layer_activation.shape)
(1, 178, 178, 32)
这是一个 178 × 178 的特征图,有 32 个通道。让我们尝试绘制原始模型第一层激活的第六通道(见图 10.2)。
import matplotlib.pyplot as plt
plt.matshow(first_layer_activation[0, :, :, 5], cmap="viridis")
代码清单 10.5:可视化第六通道

图 10.2:测试猫图片第一层的激活第六通道
这个通道似乎编码了一个对角线边缘检测器,但请注意,你自己的通道可能会有所不同,因为卷积层学习的特定滤波器不是确定的。
现在,让我们绘制网络中所有激活的完整可视化(见图 10.3)。我们将提取并绘制每个层激活中的每个通道,并将结果堆叠在一个大网格中,通道并排堆叠。
images_per_row = 16
# Iterates over the activations (and the names of the corresponding
# layers)
for layer_name, layer_activation in zip(layer_names, activations):
# The layer activation has shape (1, size, size, n_features).
n_features = layer_activation.shape[-1]
size = layer_activation.shape[1]
n_cols = n_features // images_per_row
# Prepares an empty grid for displaying all the channels in this
# activation
display_grid = np.zeros(
((size + 1) * n_cols - 1, images_per_row * (size + 1) - 1)
)
for col in range(n_cols):
for row in range(images_per_row):
channel_index = col * images_per_row + row
# This is a single channel (or feature).
channel_image = layer_activation[0, :, :, channel_index].copy()
# Normalizes channel values within the [0, 255] range.
# All-zero channels are kept at zero.
if channel_image.sum() != 0:
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")
# Places the channel matrix in the empty grid we prepared
display_grid[
col * (size + 1) : (col + 1) * size + col,
row * (size + 1) : (row + 1) * size + row,
] = channel_image
# Displays the grid for the layer
scale = 1.0 / size
plt.figure(
figsize=(scale * display_grid.shape[1], scale * display_grid.shape[0])
)
plt.title(layer_name)
plt.grid(False)
plt.axis("off")
plt.imshow(display_grid, aspect="auto", cmap="viridis")
代码清单 10.6:可视化每个中间激活中的每个通道

图 10.3:测试猫图片上每个层的每个通道的激活
这里有一些需要注意的事项:
-
第一层充当各种边缘检测器的集合。在那个阶段,激活保留了初始图片中几乎所有信息。
-
随着你向上移动,激活变得越来越抽象,视觉上可解释性越来越低。它们开始编码更高级的概念,如“猫耳”和“猫眼”。更高层的表示携带越来越少的关于图像视觉内容的信息,以及越来越多的与图像类别相关的信息。
-
激活稀疏度随着层深度的增加而增加:在第一层,所有滤波器都由输入图像激活,但在后续层中,越来越多的滤波器变为空白。这意味着滤波器编码的图案在输入图像中没有找到。
我们刚刚观察到了深度神经网络学习到的表示的一个重要普遍特征:层中提取的特征随着层深度的增加而变得越来越抽象。更高层的激活携带越来越少关于特定输入的信息,而越来越多关于目标(在这种情况下,图像的类别:猫或狗)的信息。深度神经网络有效地充当一个信息蒸馏管道,原始数据进入(在这种情况下,RGB 图片),并被反复转换,以便过滤掉无关信息(例如,图像的具体视觉外观),并放大和细化有用信息(例如,图像的类别)。
这与人类和动物感知世界的方式类似:观察场景几秒钟后,人类可以记住其中包含哪些抽象对象(自行车、树),但无法记住这些对象的具体外观。事实上,如果你试图凭记忆画一辆通用的自行车,你很可能连大致的样子都画不出来,尽管你一生中见过成千上万辆自行车(例如,见图 10.4)。现在试试看:这种效果绝对是真实的。你的大脑已经学会了完全抽象其视觉输入——将其转化为高级视觉概念,同时过滤掉无关的视觉细节——这使得记住周围事物的外观变得极其困难。

图 10.4:左:尝试凭记忆画一辆自行车。右:一个示意图自行车应该看起来像什么。
可视化 ConvNet 滤波器
检查 ConvNets 学习到的滤波器的一个简单方法是通过显示每个滤波器旨在响应的视觉模式。这可以通过在输入空间中进行梯度上升来实现,对 ConvNet 的输入图像的值应用梯度下降,以最大化特定滤波器的响应,从空白输入图像开始。结果输入图像将是选择滤波器响应最大的图像。
让我们用 Xception 模型的滤波器来尝试这个方法。过程很简单:我们将构建一个损失函数,该函数最大化给定卷积层中给定滤波器的值,然后我们将使用随机梯度下降来调整输入图像的值,以最大化这个激活值。这将是你的第二个低级梯度下降循环的例子(第一个在第二章中)。我们将为 TensorFlow、PyTorch 和 Jax 展示它。
首先,让我们实例化在 ImageNet 数据集上训练的 Xception 模型。我们可以再次使用 KerasHub 库,就像我们在第八章中所做的那样。
import keras_hub
# Instantiates the feature extractor network from pretrained weights
model = keras_hub.models.Backbone.from_preset(
"xception_41_imagenet",
)
# Loads the matching preprocessing to scale our input images
preprocessor = keras_hub.layers.ImageConverter.from_preset(
"xception_41_imagenet",
image_size=(180, 180),
)
代码列表 10.7:实例化 Xception 卷积基
我们对模型的卷积层感兴趣——Conv2D和SeparableConv2D层。我们需要知道它们的名称,以便检索它们的输出。让我们按深度顺序打印它们的名称。
for layer in model.layers:
if isinstance(layer, (keras.layers.Conv2D, keras.layers.SeparableConv2D)):
print(layer.name)
列表 10.8:打印 Xception 中所有卷积层的名称
你会注意到这里的SeparableConv2D层都命名为类似block6_sepconv1、block7_sepconv2等——Xception 被结构化为块,每个块包含几个卷积层。
现在,让我们创建第二个模型,该模型返回特定层的输出——“特征提取器”模型。由于我们的模型是功能 API 模型,它是可检查的:你可以查询其某个层的output并在新模型中重用它。无需复制整个 Xception 代码。
# You could replace this with the name of any layer in the Xception
# convolutional base.
layer_name = "block3_sepconv1"
# This is the layer object we're interested in.
layer = model.get_layer(name=layer_name)
# We use model.input and layer.output to create a model that, given an
# input image, returns the output of our target layer.
feature_extractor = keras.Model(inputs=model.input, outputs=layer.output)
列表 10.9:返回特定输出的特征提取器模型
要使用此模型,我们只需在输入数据上调用它即可,但我们应该小心应用我们模型特有的图像预处理,以确保我们的图像被缩放到与 Xception 预训练数据相同的范围。
activation = feature_extractor(preprocessor(img_tensor))
列表 10.10:使用特征提取器
让我们使用我们的特征提取器模型定义一个函数,该函数返回一个标量值,量化给定输入图像在层中激活给定滤波器的程度。这是我们将在梯度上升过程中最大化的损失函数:
from keras import ops
# The loss function takes an image tensor and the index of the filter
# we consider (an integer).
def compute_loss(image, filter_index):
activation = feature_extractor(image)
# We avoid border artifacts by only involving nonborder pixels in
# the loss: we discard the first 2 pixels along the sides of the
# activation.
filter_activation = activation[:, 2:-2, 2:-2, filter_index]
# Returns the mean of the activation values for the filter
return ops.mean(filter_activation)
一个有助于梯度上升过程顺利进行的非明显技巧是将梯度张量通过除以其 L2 范数(张量中值的平方和的平方根)进行归一化。这确保了对输入图像所做的更新幅度始终在相同的范围内。
让我们设置梯度上升步骤函数。任何涉及梯度的操作都需要调用后端 API,例如 TensorFlow 中的GradientTape、PyTorch 中的.backward()和 JAX 中的jax.grad()。让我们排列出三个后端中每个后端的代码片段,从 TensorFlow 开始。
TensorFlow 中的梯度上升
对于 TensorFlow,我们只需打开一个GradientTape作用域,在其中计算损失以检索所需的梯度。我们将使用@tf.function装饰器来加速计算:
import tensorflow as tf
@tf.function
def gradient_ascent_step(image, filter_index, learning_rate):
with tf.GradientTape() as tape:
# Explicitly watches the image tensor, since it isn't a
# TensorFlow Variable (only Variables are automatically watched
# in a gradient tape)
tape.watch(image)
# Computes the loss scalar, indicating how much the current
# image activates the filter
loss = compute_loss(image, filter_index)
# Computes the gradients of the loss with respect to the image
grads = tape.gradient(loss, image)
# Applies the "gradient normalization trick"
grads = ops.normalize(grads)
# Moves the image a little bit in a direction that activates our
# target filter more strongly
image += learning_rate * grads
# Returns the updated image, so we can run the step function in a
# loop
return image
列表 10.11:通过随机梯度上升进行损失最大化:TensorFlow
PyTorch 中的梯度上升
在 PyTorch 的情况下,我们使用loss.backward()和image.grad来获取相对于输入图像的损失梯度,如下所示。
import torch
def gradient_ascent_step(image, filter_index, learning_rate):
# Creates a copy of "image" that we can get gradients for.
image = image.clone().detach().requires_grad_(True)
loss = compute_loss(image, filter_index)
loss.backward()
grads = image.grad
grads = ops.normalize(grads)
image = image + learning_rate * grads
return image
列表 10.12:通过随机梯度上升进行损失最大化:PyTorch
由于图像张量在每个迭代中都会被重新创建,因此无需重置梯度。
JAX 中的梯度上升
在 JAX 的情况下,我们使用jax.grad()来获取一个函数,该函数返回相对于输入图像的损失梯度。
import jax
grad_fn = jax.grad(compute_loss)
@jax.jit
def gradient_ascent_step(image, filter_index, learning_rate):
grads = grad_fn(image, filter_index)
grads = ops.normalize(grads)
image += learning_rate * grads
return image
列表 10.13:通过随机梯度上升进行损失最大化:JAX
过滤器可视化循环
现在您已经拥有了所有部件。让我们将它们组合成一个 Python 函数,该函数接受一个过滤器索引作为输入,并返回一个表示在目标层中最大化指定过滤器激活的模式张量。
img_width = 200
img_height = 200
def generate_filter_pattern(filter_index):
# The number of gradient ascent steps to apply
iterations = 30
# The amplitude of a single step
learning_rate = 10.0
image = keras.random.uniform(
# Initialize an image tensor with random values. (The Xception
# model expects input values in the [0, 1] range, so here we
# pick a range centered on 0.5.)
minval=0.4, maxval=0.6, shape=(1, img_width, img_height, 3)
)
# Repeatedly updates the values of the image tensor to maximize our
# loss function
for i in range(iterations):
image = gradient_ascent_step(image, filter_index, learning_rate)
return image[0]
列表 10.14:生成过滤器可视化的函数
结果的图像张量是一个形状为 (200, 200, 3) 的浮点数组,其值可能不在 [0, 255] 的整数范围内。因此,您需要后处理这个张量,将其转换为可显示的图像。您可以使用以下简单的实用函数来完成此操作。
def deprocess_image(image):
# Normalizes image values within the [0, 255] range
image -= ops.mean(image)
image /= ops.std(image)
image *= 64
image += 128
image = ops.clip(image, 0, 255)
# Center crop to avoid border artifacts
image = image[25:-25, 25:-25, :]
image = ops.cast(image, dtype="uint8")
return ops.convert_to_numpy(image)
列表 10.15:将张量转换为有效图像的实用函数
让我们试试(见图 10.5):
>>> plt.axis("off")
>>> plt.imshow(deprocess_image(generate_filter_pattern(filter_index=2)))

图 10.5:层 block3_sepconv1 第二通道响应最大的模式
看起来,层 block3_sepconv1 中的过滤器 2 对水平线图案有响应,有点像水或毛发的样子。
现在是时候进行有趣的部分了:您可以从可视化层中的每个过滤器开始——甚至可以可视化模型中每一层的每个过滤器(见图 10.6)。
# Generates and saves visualizations for the first 64 filters in the
# layer
all_images = []
for filter_index in range(64):
print(f"Processing filter {filter_index}")
image = deprocess_image(generate_filter_pattern(filter_index))
all_images.append(image)
# Prepares a blank canvas for us to paste filter visualizations
margin = 5
n = 8
box_width = img_width - 25 * 2
box_height = img_height - 25 * 2
full_width = n * box_width + (n - 1) * margin
full_height = n * box_height + (n - 1) * margin
stitched_filters = np.zeros((full_width, full_height, 3))
# Fills the picture with our saved filters
for i in range(n):
for j in range(n):
image = all_images[i * n + j]
stitched_filters[
(box_width + margin) * i : (box_width + margin) * i + box_width,
(box_height + margin) * j : (box_height + margin) * j + box_height,
:,
] = image
# Saves the canvas to disk
keras.utils.save_img(f"filters_for_layer_{layer_name}.png", stitched_filters)
列表 10.16:生成所有过滤器响应模式的网格

图 10.6:block2_sepconv1、block4_sepconv1 和 block8_sepconv1 层的一些过滤器模式
这些过滤器可视化向您展示了卷积神经网络层如何观察世界:卷积神经网络中的每一层都学习一组过滤器,使得其输入可以表示为这些过滤器的组合。这类似于傅里叶变换将信号分解到一系列余弦函数中。随着你在模型中向上移动,这些卷积神经网络过滤器库中的过滤器变得越来越复杂和精细:
-
模型第一层的过滤器编码简单的方向边缘和颜色(或在某些情况下,彩色边缘)。
-
栈中更高层的层,如
block4_sepconv1,编码由边缘和颜色组合而成的简单纹理。 -
高层中的过滤器开始类似于自然图像中找到的纹理:羽毛、眼睛、叶子等等。
可视化类别激活的热图
这里是最后一种可视化技术——一种有助于理解给定图像的哪些部分导致卷积神经网络做出最终分类决策的技术。这对于“调试”卷积神经网络的决策过程非常有用,尤其是在分类错误的情况下(一个被称为 模型可解释性 的领域问题)。它还可以让您在图像中定位特定对象。
这种一般的技术类别被称为 类别激活图(CAM)的可视化,它包括在输入图像上生成类别激活的热图。一个类别激活热图是与特定输出类别相关联的分数的 2D 网格,为任何输入图像中的每个位置计算,指示每个位置相对于考虑的类别的相对重要性。例如,给定一个输入到狗与猫的 ConvNet 的图像,CAM 可视化将允许你为“猫”类别生成热图,指示图像的不同部分有多像猫,以及为“狗”类别生成热图,指示图像的不同部分有多像狗。我们将使用的具体实现是 Selvaraju 等人描述的([1])。
Grad-CAM 包括取一个卷积层的输出特征图,给定一个输入图像,并按类别相对于通道的梯度对该特征图中的每个通道进行加权。直观地说,理解这个技巧的一种方式是,你通过“每个通道相对于类别的相对重要性”来加权“输入图像激活不同通道的强度”的空间图,从而得到一个“输入图像激活类别的强度”的空间图。
让我们使用预训练的 Xception 模型来演示这项技术。考虑图 10.7 中显示的两只非洲象的图像,可能是一只母象和她的幼崽,在草原上漫步。我们可以从下载这张图像并将其转换为 NumPy 数组开始,如图 10.7 所示。

图 10.7:非洲象的测试图片
# Downloads the image and stores it locally under the path img_path
img_path = keras.utils.get_file(
fname="elephant.jpg",
origin="https://img-datasets.s3.amazonaws.com/elephant.jpg",
)
# Returns a Python Imaging Library (PIL) image
img = keras.utils.load_img(img_path)
img_array = np.expand_dims(img, axis=0)
代码列表 10.17:为 Xception 预处理输入图像
到目前为止,我们只使用了 KerasHub 来实例化一个使用骨干类预训练的特征提取网络。对于 Grad-CAM,我们需要整个 Xception 模型,包括分类头——回想一下,Xception 是在 ImageNet 数据集上训练的,该数据集包含约 100 万张标签图像,属于 1,000 个不同的类别。
KerasHub 提供了一个高级 任务 API,用于常见的端到端工作流程,如图像分类、文本分类、图像生成等。一个任务将预处理、特征提取网络和特定任务的头封装成一个易于使用的单个类。让我们试试看:
>>> model = keras_hub.models.ImageClassifier.from_preset(
... "xception_41_imagenet",
... # We can configure the final activation of the classifier. Here,
... # we use a softmax activation so our outputs are probabilities.
... activation="softmax",
... )
>>> preds = model.predict(img_array)
>>> # ImageNet has 1,000 classes, so each prediction from our
>>> # classifier has 1,000 entries.
>>> preds.shape
(1, 1000)
>>> keras_hub.utils.decode_imagenet_predictions(preds)
[[("African_elephant", 0.90331),
("tusker", 0.05487),
("Indian_elephant", 0.01637),
("triceratops", 0.00029),
("Mexican_hairless", 0.00018)]]
对于此图像预测的前五个类别如下:
-
非洲象(90% 的概率)
-
非洲象(5% 的概率)
-
亚洲象(2% 的概率)
-
三角龙和墨西哥无毛犬,概率低于 0.1%
网络已识别出图像包含不确定数量的非洲象。预测向量中激活程度最高的条目是对应于“非洲象”类别的,索引为 386:
>>> np.argmax(preds[0])
386
为了可视化图像中最像非洲象的部分,让我们设置 Grad-CAM 的过程。
你会注意到,在调用任务模型之前,我们不需要对图像进行预处理。这是因为 KerasHub 的 ImageClassifier 在 predict() 方法中为我们预处理输入。让我们自己预处理图像,以便我们可以直接使用预处理后的输入:
# KerasHub tasks like ImageClassifier have a preprocessor layer.
img_array = model.preprocessor(img_array)
首先,我们创建一个模型,将输入图像映射到最后一层卷积层的激活。
last_conv_layer_name = "block14_sepconv2_act"
last_conv_layer = model.backbone.get_layer(last_conv_layer_name)
last_conv_layer_model = keras.Model(model.inputs, last_conv_layer.output)
列表 10.18:返回最后一层卷积输出
第二,我们创建一个模型,将最后一层卷积层的激活映射到最终的类别预测。
classifier_input = last_conv_layer.output
x = classifier_input
for layer_name in ["pooler", "predictions"]:
x = model.get_layer(layer_name)(x)
classifier_model = keras.Model(classifier_input, x)
列表 10.19:从最后一层卷积输出到最终预测
然后,我们计算输入图像相对于最后一层卷积层激活的顶级预测类梯度。再次强调,需要计算梯度意味着我们必须使用后端 API。
获取顶级类梯度:TensorFlow 版本
让我们从 TensorFlow 版本开始,再次使用 GradientTape。
import tensorflow as tf
def get_top_class_gradients(img_array):
# Computes activations of the last conv layer and makes the tape
# watch it
last_conv_layer_output = last_conv_layer_model(img_array)
with tf.GradientTape() as tape:
tape.watch(last_conv_layer_output)
preds = classifier_model(last_conv_layer_output)
top_pred_index = ops.argmax(preds[0])
# Retrieves the activation channel corresponding to the top
# predicted class
top_class_channel = preds[:, top_pred_index]
# Gets the gradient of the top predicted class with regard to the
# output feature map of the last convolutional layer
grads = tape.gradient(top_class_channel, last_conv_layer_output)
return grads, last_conv_layer_output
grads, last_conv_layer_output = get_top_class_gradients(img_array)
grads = ops.convert_to_numpy(grads)
last_conv_layer_output = ops.convert_to_numpy(last_conv_layer_output)
列表 10.20:使用 TensorFlow 计算顶级类梯度
获取顶级类梯度:PyTorch 版本
接下来,这是 PyTorch 版本,使用 .backward() 和 .grad。
def get_top_class_gradients(img_array):
# Computes activations of the last conv layer
last_conv_layer_output = last_conv_layer_model(img_array)
# Creates a copy of last_conv_layer_output that we can get
# gradients for
last_conv_layer_output = (
last_conv_layer_output.clone().detach().requires_grad_(True)
)
# Retrieves the activation channel corresponding to the top
# predicted class
preds = classifier_model(last_conv_layer_output)
top_pred_index = ops.argmax(preds[0])
top_class_channel = preds[:, top_pred_index]
# Gets the gradient of the top predicted class with regard to the
# output feature map of the last convolutional layer
top_class_channel.backward()
grads = last_conv_layer_output.grad
return grads, last_conv_layer_output
grads, last_conv_layer_output = get_top_class_gradients(img_array)
grads = ops.convert_to_numpy(grads)
last_conv_layer_output = ops.convert_to_numpy(last_conv_layer_output)
列表 10.21:使用 PyTorch 计算顶级类梯度
获取顶级类梯度:JAX 版本
最后,让我们来做 JAX。我们定义一个单独的损失计算函数,它接受最终层的输出并返回对应于顶级预测类的激活通道。我们使用这个激活值作为我们的损失,从而计算梯度。
import jax
# Defines a separate loss function
def loss_fn(last_conv_layer_output):
preds = classifier_model(last_conv_layer_output)
top_pred_index = ops.argmax(preds[0])
top_class_channel = preds[:, top_pred_index]
# Returns the activation value of the top-class channel
return top_class_channel[0]
# Creates a gradient function
grad_fn = jax.grad(loss_fn)
def get_top_class_gradients(img_array):
last_conv_layer_output = last_conv_layer_model(img_array)
# Now retrieving the gradient of the top-class channel is just a
# matter of calling the gradient function!
grads = grad_fn(last_conv_layer_output)
return grads, last_conv_layer_output
grads, last_conv_layer_output = get_top_class_gradients(img_array)
grads = ops.convert_to_numpy(grads)
last_conv_layer_output = ops.convert_to_numpy(last_conv_layer_output)
列表 10.22:使用 Jax 计算顶级类梯度
显示类别激活热图
现在,我们将池化和重要性权重应用于梯度张量,以获得我们的类别激活热图。
# This is a vector where each entry is the mean intensity of the
# gradient for a given channel. It quantifies the importance of each
# channel with regard to the top predicted class.
pooled_grads = np.mean(grads, axis=(0, 1, 2))
last_conv_layer_output = last_conv_layer_output[0].copy()
# Multiplies each channel in the output of the last convolutional layer
# by how important this channel is
for i in range(pooled_grads.shape[-1]):
last_conv_layer_output[:, :, i] *= pooled_grads[i]
# The channel-wise mean of the resulting feature map is our heatmap of
# class activation.
heatmap = np.mean(last_conv_layer_output, axis=-1)
列表 10.23:梯度池化和通道重要性权重
为了可视化目的,你还需要将热图归一化到 0 到 1 之间。结果如图 10.8 所示。
heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)
列表 10.24:热图后处理

图 10.8:独立的类别激活热图
最后,让我们生成一个图像,将原始图像叠加到你刚刚获得的热图上(见图 10.9)。
import matplotlib.cm as cm
# Loads the original image
img = keras.utils.load_img(img_path)
img = keras.utils.img_to_array(img)
# Rescales the heatmap to the range 0–255
heatmap = np.uint8(255 * heatmap)
# Uses the "jet" colormap to recolorize the heatmap
jet = cm.get_cmap("jet")
jet_colors = jet(np.arange(256))[:, :3]
jet_heatmap = jet_colors[heatmap]
# Creates an image that contains the recolorized heatmap
jet_heatmap = keras.utils.array_to_img(jet_heatmap)
jet_heatmap = jet_heatmap.resize((img.shape[1], img.shape[0]))
jet_heatmap = keras.utils.img_to_array(jet_heatmap)
# Superimposes the heatmap and the original image, with the heatmap at
# 40% opacity
superimposed_img = jet_heatmap * 0.4 + img
superimposed_img = keras.utils.array_to_img(superimposed_img)
# Shows the superimposed image
plt.imshow(superimposed_img)
列表 10.25:将热图与原始图像叠加

图 10.9:测试图片上的非洲象类别激活热图
这种可视化技术回答了两个重要问题:
-
为什么网络认为这张图片包含非洲象?
-
图片中非洲象在哪里?
特别值得注意的是,小象的耳朵激活非常强烈:这可能是网络区分非洲象和亚洲象的方式。
摘要
-
卷积神经网络通过应用一系列学习到的过滤器来处理图像。早期层的过滤器检测边缘和基本纹理,而后期层的过滤器检测越来越抽象的概念。
-
您可以可视化过滤器检测到的模式和过滤器在整个图像中的响应图。
-
您可以使用 Grad-CAM 技术来可视化图像中哪些区域负责了分类器的决策。
-
这些技术共同使得卷积神经网络具有高度的可解释性。
脚注
- Ramprasaath R. Selvaraju 等人,“Grad-CAM: 通过基于梯度的定位从深度网络中获取视觉解释,” arxiv (2019),
arxiv.org/abs/1610.02391。[↩]
第十一章:图像分割
原文:
deeplearningwithpython.io/chapters/chapter11_image-segmentation
第八章通过一个简单的用例——二值图像分类,首次介绍了计算机视觉中的深度学习。但计算机视觉不仅仅是图像分类!本章将进一步深入探讨另一个重要的计算机视觉应用——图像分割。
计算机视觉任务
到目前为止,我们一直专注于图像分类模型:图像输入,标签输出。“这张图像可能包含一只猫;另一张可能包含一只狗。”但图像分类只是深度学习在计算机视觉中可能应用的几种可能性之一。一般来说,有三个基本的计算机视觉任务你需要了解:
-
图像分类,其目标是给图像分配一个或多个标签。这可能是单标签分类(意味着类别是互斥的)或多标签分类(标记图像所属的所有类别,如图 11.1 所示)。例如,当你在 Google Photos 应用中搜索关键词时,在幕后你正在查询一个非常大的多标签分类模型——一个拥有超过 20,000 个不同类别,并在数百万张图像上训练的模型。
-
图像分割,其目标是“分割”或“划分”图像为不同的区域,每个区域通常代表一个类别(如图 11.1 所示)。例如,当 Zoom 或 Google Meet 在视频通话中显示你背后的自定义背景时,它正在使用图像分割模型以像素级的精度区分你的面部和其后的内容。
-
目标检测,其目标是围绕图像中感兴趣的对象绘制矩形(称为边界框),并将每个矩形与一个类别关联。例如,自动驾驶汽车可以使用目标检测模型来监控其摄像头视野中的车辆、行人和标志。

:三种主要的计算机视觉任务:分类、分割和检测
除了这三个任务之外,计算机视觉的深度学习还包括一些相对较窄的任务,例如图像相似度评分(估计两张图像在视觉上的相似程度)、关键点检测(在图像中定位感兴趣的特征,如面部特征)、姿态估计、3D 网格估计、深度估计等等。但首先,图像分类、图像分割和目标检测构成了每个机器学习工程师都应该熟悉的基石。几乎所有的计算机视觉应用都可以归结为这三个中的某一个。
你在第八章中已经看到了图像分类的实际应用。接下来,让我们深入探讨图像分割。这是一个非常有用且非常通用的技术,你可以直接运用你到目前为止所学到的知识来接近它。然后,在下一章中,你将详细了解目标检测。
图像分割类型
使用深度学习进行图像分割是关于使用模型为图像中的每个像素分配一个类别,从而将图像分割成不同的区域(如“背景”和“前景”或“道路”、“汽车”和“人行道”)。这类技术可以用于支持图像和视频编辑、自动驾驶、机器人技术、医学成像等多种有价值的应用。
你应该了解三种不同的图像分割类型:
-
语义分割,其中每个像素都被独立地分类到语义类别,如“猫”。如果图像中有两只猫,相应的像素都将映射到相同的通用“猫”类别(见图 11.2)。
-
实例分割,旨在解析出单个对象实例。在一个有两只猫的图像中,实例分割将区分属于“猫 1”的像素和属于“猫 2”的像素(见图 11.2)。
-
全景分割,通过为图像中的每个像素分配语义标签(如“猫”)和实例标签(如“猫 2”)来结合语义分割和实例分割。这是三种分割类型中最具信息量的。

图 11.2:语义分割与实例分割的比较
为了更熟悉分割,让我们从从头开始在您自己的数据上训练一个小型分割模型开始。
从头开始训练分割模型
在这个第一个例子中,我们将专注于语义分割。我们将再次查看猫和狗的图像,这次我们将学习区分主要主题及其背景。
下载分割数据集
我们将使用牛津-IIIT 宠物数据集(www.robots.ox.ac.uk/~vgg/data/pets/),该数据集包含 7,390 张各种品种的猫和狗的图片,以及每张图片的前景-背景分割掩码。分割掩码是图像分割的等价物:它是一个与输入图像大小相同的图像,具有单个颜色通道,其中每个整数值对应于输入图像中相应像素的类别。在我们的情况下,我们的分割掩码的像素可以取三个整数值之一:
-
1 (前景)
-
2 (背景)
-
3 (轮廓)
首先,让我们通过使用wget和tarshell 工具下载和解压我们的数据集:
!wget http://www.robots.ox.ac.uk/~vgg/data/pets/data/images.tar.gz
!wget http://www.robots.ox.ac.uk/~vgg/data/pets/data/annotations.tar.gz
!tar -xf images.tar.gz
!tar -xf annotations.tar.gz
输入图片存储在images/文件夹中作为 JPG 文件(例如images/Abyssinian_1.jpg),相应的分割掩码存储在annotations/trimaps/文件夹中,文件名与图片相同,为 PNG 文件(例如annotations/trimaps/Abyssinian_1.png)。
让我们准备输入文件路径的列表,以及相应的掩码文件路径列表:
import pathlib
input_dir = pathlib.Path("images")
target_dir = pathlib.Path("annotations/trimaps")
input_img_paths = sorted(input_dir.glob("*.jpg"))
# Ignores some spurious files in the trimaps directory that start with
# a "."
target_paths = sorted(target_dir.glob("[!.]*.png"))
现在,这些输入及其掩码看起来是什么样子?让我们快速看一下(见图 11.3)。
import matplotlib.pyplot as plt
from keras.utils import load_img, img_to_array, array_to_img
plt.axis("off")
# Displays input image number 9
plt.imshow(load_img(input_img_paths[9]))

图 11.3:一个示例图像
让我们看看它的目标掩码(见图 11.4):
def display_target(target_array):
# The original labels are 1, 2, and 3\. We subtract 1 so that the
# labels range from 0 to 2, and then we multiply by 127 so that the
# labels become 0 (black), 127 (gray), 254 (near-white).
normalized_array = (target_array.astype("uint8") - 1) * 127
plt.axis("off")
plt.imshow(normalized_array[:, :, 0])
# We use color_mode='grayscale' so that the image we load is treated as
# having a single color channel.
img = img_to_array(load_img(target_paths[9], color_mode="grayscale"))
display_target(img)

图 11.4:相应的目标掩码
接下来,让我们将我们的输入和目标加载到两个 NumPy 数组中。由于数据集非常小,我们可以将所有内容加载到内存中:
import numpy as np
import random
# We resize everything to 200 x 200 for this example.
img_size = (200, 200)
# Total number of samples in the data
num_imgs = len(input_img_paths)
# Shuffles the file paths (they were originally sorted by breed). We
# use the same seed (1337) in both statements to ensure that the input
# paths and target paths stay in the same order.
random.Random(1337).shuffle(input_img_paths)
random.Random(1337).shuffle(target_paths)
def path_to_input_image(path):
return img_to_array(load_img(path, target_size=img_size))
def path_to_target(path):
img = img_to_array(
load_img(path, target_size=img_size, color_mode="grayscale")
)
# Subtracts 1 so that our labels become 0, 1, and 2
img = img.astype("uint8") - 1
return img
# Loads all images in the input_imgs float32 array and their masks in
# the targets uint8 array (same order). The inputs have three channels
# (RGB values), and the targets have a single channel (which contains
# integer labels).
input_imgs = np.zeros((num_imgs,) + img_size + (3,), dtype="float32")
targets = np.zeros((num_imgs,) + img_size + (1,), dtype="uint8")
for i in range(num_imgs):
input_imgs[i] = path_to_input_image(input_img_paths[i])
targets[i] = path_to_target(target_paths[i])
和往常一样,让我们将数组分成训练集和验证集:
# Reserves 1,000 samples for validation
num_val_samples = 1000
# Splits the data into a training and a validation set
train_input_imgs = input_imgs[:-num_val_samples]
train_targets = targets[:-num_val_samples]
val_input_imgs = input_imgs[-num_val_samples:]
val_targets = targets[-num_val_samples:]
构建和训练分割模型
现在,是时候定义我们的模型了:
import keras
from keras.layers import Rescaling, Conv2D, Conv2DTranspose
def get_model(img_size, num_classes):
inputs = keras.Input(shape=img_size + (3,))
# Don't forget to rescale input images to the [0–1] range.
x = Rescaling(1.0 / 255)(inputs)
# We use padding="same" everywhere to avoid the influence of border
# padding on feature map size.
x = Conv2D(64, 3, strides=2, activation="relu", padding="same")(x)
x = Conv2D(64, 3, activation="relu", padding="same")(x)
x = Conv2D(128, 3, strides=2, activation="relu", padding="same")(x)
x = Conv2D(128, 3, activation="relu", padding="same")(x)
x = Conv2D(256, 3, strides=2, padding="same", activation="relu")(x)
x = Conv2D(256, 3, activation="relu", padding="same")(x)
x = Conv2DTranspose(256, 3, activation="relu", padding="same")(x)
x = Conv2DTranspose(256, 3, strides=2, activation="relu", padding="same")(x)
x = Conv2DTranspose(128, 3, activation="relu", padding="same")(x)
x = Conv2DTranspose(128, 3, strides=2, activation="relu", padding="same")(x)
x = Conv2DTranspose(64, 3, activation="relu", padding="same")(x)
x = Conv2DTranspose(64, 3, strides=2, activation="relu", padding="same")(x)
# We end the model with a per-pixel three-way softmax to classify
# each output pixel into one of our three categories.
outputs = Conv2D(num_classes, 3, activation="softmax", padding="same")(x)
return keras.Model(inputs, outputs)
model = get_model(img_size=img_size, num_classes=3)
模型的前半部分与您用于图像分类的 ConvNet 非常相似:一系列Conv2D层,滤波器大小逐渐增加。我们通过每次减半的因子将图像下采样三次——最终得到大小为(25, 25, 256)的激活。这一半的目的是将图像编码成较小的特征图,其中每个空间位置(或“像素”)包含有关原始图像中较大空间块的信息。你可以将其理解为一种压缩。
这个模型的前半部分与您之前看到的分类模型的一个重要区别在于我们进行下采样的方式:在第八章的分类 ConvNets 中,我们使用了MaxPooling2D层来下采样特征图。在这里,我们通过在每个卷积层中添加步长来下采样(如果你不记得卷积步长的细节,请参阅第八章的 8.1.1 节)。我们这样做是因为,在图像分割的情况下,我们非常关注图像中信息的空间位置,因为我们需要将每个像素的目标掩码作为模型的输出。当你进行 2×2 最大池化时,你完全破坏了每个池化窗口内的位置信息:你为每个窗口返回一个标量值,而对四个位置中的哪一个值来自窗口一无所知。
因此,虽然最大池化层在分类任务中表现良好,但对于分割任务,它们可能会给我们带来相当大的伤害。同时,步长卷积在降采样特征图的同时,更好地保留了位置信息。在这本书的整个过程中,你会发现我们倾向于在关注特征位置的任何模型中使用步长而不是最大池化,例如第十七章中的生成模型。
模型的后半部分是一系列 Conv2DTranspose 层。那些是什么?嗯,模型前半部分的输出是一个形状为 (25, 25, 256) 的特征图,但我们希望最终的输出为每个像素预测一个类别,匹配原始的空间维度。最终的模型输出将具有形状 (200, 200, num_classes),在这里是 (200, 200, 3)。因此,我们需要应用一种 逆 变换,即 上采样 特征图而不是下采样它们。这就是 Conv2DTranspose 层的目的:你可以把它想象成一种 学习上采样 的卷积层。如果你有一个形状为 (100, 100, 64) 的输入,并且通过 Conv2D(128, 3, strides=2, padding="same") 层运行它,你会得到一个形状为 (50, 50, 128) 的输出。如果你将这个输出通过 Conv2DTranspose(64, 3, strides=2, padding="same") 层运行,你会得到一个形状为 (100, 100, 64) 的输出,与原始输入相同。因此,通过一系列 Conv2D 层将我们的输入压缩成形状为 (25, 25, 256) 的特征图后,我们可以简单地应用相应的 Conv2DTranspose 层序列,然后是一个最终的 Conv2D 层,以产生形状为 (200, 200, 3) 的输出。
为了评估模型,我们将使用一个名为 交并比 (IoU) 的指标。它是真实分割掩码与预测掩码之间匹配程度的度量。它可以针对每个类别单独计算,也可以在多个类别上平均计算。以下是它是如何工作的:
-
计算掩码之间的 交集,即预测和真实重叠的区域。
-
计算掩码的 并集,即两个掩码共同覆盖的总区域。这是我们感兴趣的全部空间——目标对象以及你的模型可能错误包含的任何额外部分。
-
将交集区域除以并集区域以获得 IoU。它是一个介于 0 和 1 之间的数字,其中 1 表示完美匹配,0 表示完全未命中。
我们可以直接使用内置的 Keras 指标,而不是自己构建:
foreground_iou = keras.metrics.IoU(
# Specifies the total number of classes
num_classes=3,
# Specifies the class to compute IoU for (0 = foreground)
target_class_ids=(0,),
name="foreground_iou",
# Our targets are sparse (integer class IDs).
sparse_y_true=True,
# But our model's predictions are a dense softmax!
sparse_y_pred=False,
)
现在,我们可以编译和拟合我们的模型:
model.compile(
optimizer="adam",
loss="sparse_categorical_crossentropy",
metrics=[foreground_iou],
)
callbacks = [
keras.callbacks.ModelCheckpoint(
"oxford_segmentation.keras",
save_best_only=True,
),
]
history = model.fit(
train_input_imgs,
train_targets,
epochs=50,
callbacks=callbacks,
batch_size=64,
validation_data=(val_input_imgs, val_targets),
)
让我们显示我们的训练和验证损失(见图 11.5):
epochs = range(1, len(history.history["loss"]) + 1)
loss = history.history["loss"]
val_loss = history.history["val_loss"]
plt.figure()
plt.plot(epochs, loss, "r--", label="Training loss")
plt.plot(epochs, val_loss, "b", label="Validation loss")
plt.title("Training and validation loss")
plt.legend()

图 11.5:显示训练和验证损失曲线
你可以看到,我们在大约第 25 个 epoch 时开始过拟合。让我们根据验证损失重新加载我们表现最好的模型,并展示如何使用它来预测一个分割掩码(见图 11.6):
model = keras.models.load_model("oxford_segmentation.keras")
i = 4
test_image = val_input_imgs[i]
plt.axis("off")
plt.imshow(array_to_img(test_image))
mask = model.predict(np.expand_dims(test_image, 0))[0]
# Utility to display a model's prediction
def display_mask(pred):
mask = np.argmax(pred, axis=-1)
mask *= 127
plt.axis("off")
plt.imshow(mask)
display_mask(mask)

图 11.6:一个测试图像及其预测的分割掩码
我们的预测掩码中存在一些小的伪影,这是由前景和背景中的几何形状造成的。尽管如此,我们的模型看起来工作得很好。
使用预训练的分割模型
在第八章的图像分类示例中,你看到了使用预训练模型如何显著提高你的准确率——尤其是在你只有少量样本进行训练时。图像分割也不例外。
Segment Anything 模型,或简称 SAM,是一个强大的预训练分割模型,你可以用它来做几乎所有的事情。它由 Meta AI 开发并于 2023 年 4 月发布。它是在 1100 万张图像及其分割掩码上训练的,覆盖了超过 10 亿个对象实例。如此大量的训练数据为模型提供了对自然图像中几乎任何出现的对象的内置知识。
SAM 的主要创新在于它不仅限于预定义的对象类别集合。你可以通过提供一个你正在寻找的示例来简单地用它来分割新的对象。你甚至不需要先微调模型。让我们看看它是如何工作的。
下载 Segment Anything 模型
首先,让我们实例化 SAM 并下载其权重。同样,我们可以使用 KerasHub 包来使用这个预训练模型,而无需从头开始实现它。
记得我们在上一章中使用的ImageClassifier任务吗?我们可以使用另一个 KerasHub 任务ImageSegmenter来将预训练的图像分割模型包装成一个具有标准输入和输出的高级模型。在这里,我们将使用sam_huge_sa1b预训练模型,其中sam代表模型,huge指的是模型中的参数数量,而sa1b代表与模型一起发布的 SA-1B 数据集,包含 10 亿个注释过的掩码。现在让我们下载它:
import keras_hub
model = keras_hub.models.ImageSegmenter.from_preset("sam_huge_sa1b")
我们可以立即注意到的是,我们的模型确实是巨大的:
>>> model.count_params()
641090864
在 6410 万个参数的情况下,SAM 是我们在这本书中使用的最大的模型。预训练模型越来越大,使用的数据越来越多这一趋势将在第十六章中更详细地讨论。
Segment Anything 是如何工作的
在我们尝试使用该模型进行一些分割之前,让我们更多地谈谈 SAM 是如何工作的。模型的大部分能力都来自于预训练数据集的规模。Meta 与模型一起开发了 SA-1B 数据集,其中部分训练的模型被用来辅助数据标注过程。也就是说,数据集和模型以一种反馈循环的方式共同开发。
使用 SA-1B 数据集的目标是创建完全分割的图像,其中图像中的每个对象都分配了一个唯一的分割遮罩。见图 11.7 作为示例。数据集中的每张图像平均有约 100 个遮罩,有些图像有超过 500 个单独遮罩的对象。这是通过一个越来越自动化的数据收集流程完成的。最初,人类专家手动分割了一个小型的图像示例数据集,该数据集用于训练初始模型。该模型被用来帮助推动数据收集的半自动化阶段,在这一阶段,图像首先由 SAM 分割,然后通过人工校正和进一步标注进行改进。

图 11.7:SA-1B 数据集的一个示例图像
该模型在(图像, 提示, 遮罩)三元组上进行训练。图像和提示是模型的输入。图像可以是任何输入图像,而提示可以采取几种形式:
-
遮罩对象内部的一个点
-
围绕遮罩对象的一个框
给定图像和提示输入,模型预计将产生一个准确的预测遮罩,该遮罩对应于提示中指示的对象,并将其与地面真实遮罩标签进行比较。
该模型由几个独立的组件组成。一个类似于我们在前几章中使用的 Xception 模型的图像编码器,将输入图像转换为更小的图像嵌入。这是我们已知如何构建的。
接下来,我们添加一个提示编码器,它负责将之前提到的任何形式的提示映射到一个嵌入向量,以及一个遮罩解码器,它接收图像嵌入和提示嵌入,并输出几个可能的预测遮罩。我们不会在这里详细介绍提示编码器和遮罩解码器的细节,因为它们使用了我们在后面的章节中才会看到的建模技术。我们可以将这些预测遮罩与我们的地面真实遮罩进行比较,就像我们在本章早期部分所做的那样(见图 11.8)。

图 11.8:Segment Anything 高级架构概述
所有这些子组件都是通过形成新的(图像, 提示, 遮罩)三元组批次来同时训练的,这些批次是从 SA-1B 图像和遮罩数据中训练的。这里的流程实际上相当简单。对于给定的输入图像,选择输入中的一个随机遮罩。接下来,随机选择是否创建一个框提示或一个点提示。要创建一个点提示,选择遮罩标签内的一个随机像素。要创建一个框提示,围绕遮罩标签内的所有点绘制一个框。我们可以无限重复这个过程,从每个图像输入中采样一定数量的(图像, 提示, 遮罩)三元组。
准备测试图像
让我们通过尝试模型来使这个例子更具体。我们可以从加载用于分割工作的测试图像开始。我们将使用一个水果碗的图片(见图 11.9):
# Downloads the image and returns the local file path
path = keras.utils.get_file(
origin="https://s3.amazonaws.com/keras.io/img/book/fruits.jpg"
)
# Loads the image as a Python Imaging Library (PIL) object
pil_image = keras.utils.load_img(path)
# Turns the PIL object into a NumPy matrix
image_array = keras.utils.img_to_array(pil_image)
# Displays the NumPy matrix
plt.imshow(image_array.astype("uint8"))
plt.axis("off")
plt.show()

图 11.9:我们的测试图像
SAM 预期输入的尺寸为 1024 × 1024。然而,强制将任意图像调整到 1024 × 1024 的大小会扭曲其宽高比——例如,我们的图像不是正方形。更好的做法是首先将图像调整到其最长边为 1,024 像素,然后用填充值(如 0)填充剩余的像素。我们可以通过在 keras.ops.image.resize() 操作中使用 pad_to_aspect_ratio 参数来实现这一点,如下所示:
from keras import ops
image_size = (1024, 1024)
def resize_and_pad(x):
return ops.image.resize(x, image_size, pad_to_aspect_ratio=True)
image = resize_and_pad(image_array)
接下来,让我们定义一些在使用模型时将很有用的实用工具。我们需要做的是
-
显示图像。
-
在图像上显示叠加的分割掩码。
-
在图像上突出显示特定的点。
-
在图像上显示叠加的框。
我们的所有工具都接受一个 Matplotlib axis 对象(记作 ax),这样它们就可以写入同一个图像:
import matplotlib.pyplot as plt
from keras import ops
def show_image(image, ax):
ax.imshow(ops.convert_to_numpy(image).astype("uint8"))
def show_mask(mask, ax):
color = np.array([30 / 255, 144 / 255, 255 / 255, 0.6])
h, w, _ = mask.shape
mask_image = mask.reshape(h, w, 1) * color.reshape(1, 1, -1)
ax.imshow(mask_image)
def show_points(points, ax):
x, y = points[:, 0], points[:, 1]
ax.scatter(x, y, c="green", marker="*", s=375, ec="white", lw=1.25)
def show_box(box, ax):
box = box.reshape(-1)
x0, y0 = box[0], box[1]
w, h = box[2] - box[0], box[3] - box[1]
ax.add_patch(plt.Rectangle((x0, y0), w, h, ec="red", fc="none", lw=2))
使用目标点提示模型
要使用 SAM,你需要提示它。这意味着我们需要以下之一:
-
点提示——在图像中选择一个点,并让模型分割该点所属的对象。
-
框提示——在对象周围画一个大致的框(不需要特别精确),然后让模型在框内分割对象。
让我们从点提示开始。点被标记,其中 1 表示前景(你想要分割的对象),0 表示背景(对象周围的一切)。在模糊的情况下,为了提高你的结果,你可以传递多个标记的点,而不是单个点,以细化你想要包含(标记为 1 的点)和排除(标记为 0 的点)的定义。
我们尝试一个单独的前景点(见图 11.10)。这是一个测试点:
import numpy as np
# Coordinates of our point
input_point = np.array([[580, 450]])
# 1 means foreground, and 0 means background.
input_label = np.array([1])
plt.figure(figsize=(10, 10))
# "gca" means "get current axis" — the current figure.
show_image(image, plt.gca())
show_points(input_point, plt.gca())
plt.show()

图 11.10:一个提示点,落在桃子上
让我们用这个图像提示 SAM:
outputs = model.predict(
{
"images": ops.expand_dims(image, axis=0),
"points": ops.expand_dims(input_point, axis=0),
"labels": ops.expand_dims(input_label, axis=0),
}
)
返回值 outputs 有一个 "masks" 字段,它包含四个 256 × 256 的候选掩码,按降低的匹配质量排序。掩码的质量分数作为模型输出的 "iou_pred" 字段的一部分提供:
>>> outputs["masks"].shape
(1, 4, 256, 256)
让我们在图像上叠加第一个掩码(见图 11.11):
def get_mask(sam_outputs, index=0):
mask = sam_outputs["masks"][0][index]
mask = np.expand_dims(mask, axis=-1)
mask = resize_and_pad(mask)
return ops.convert_to_numpy(mask) > 0.0
mask = get_mask(outputs, index=0)
plt.figure(figsize=(10, 10))
show_image(image, plt.gca())
show_mask(mask, plt.gca())
show_points(input_point, plt.gca())
plt.show()

图 11.11:分割的桃子
很不错!
接下来,让我们尝试一个香蕉。我们将用坐标 (300, 550) 来提示模型,这个坐标落在从左数第二个香蕉上(见图 11.12):
input_point = np.array([[300, 550]])
input_label = np.array([1])
outputs = model.predict(
{
"images": ops.expand_dims(image, axis=0),
"points": ops.expand_dims(input_point, axis=0),
"labels": ops.expand_dims(input_label, axis=0),
}
)
mask = get_mask(outputs, index=0)
plt.figure(figsize=(10, 10))
show_image(image, plt.gca())
show_mask(mask, plt.gca())
show_points(input_point, plt.gca())
plt.show()

图 11.12:分割的香蕉
现在,关于其他掩码候选者呢?它们对于模糊提示很有用。让我们尝试绘制其他三个掩码(见图 11.13):
fig, axes = plt.subplots(1, 3, figsize=(20, 60))
masks = outputs["masks"][0][1:]
for i, mask in enumerate(masks):
show_image(image, axes[i])
show_points(input_point, axes[i])
mask = get_mask(outputs, index=i + 1)
show_mask(mask, axes[i])
axes[i].set_title(f"Mask {i + 1}", fontsize=16)
axes[i].axis("off")
plt.show()

图 11.13:香蕉提示的替代分割掩码
如你所见,模型找到的替代分割包括两个香蕉。
使用目标框提示模型
除了提供一个或多个目标点之外,您还可以提供近似分割对象位置的框。这些框应通过其左上角和右下角的坐标传递。这里有一个围绕芒果的框(见图 11.14):
input_box = np.array(
[
# Top-left corner
[520, 180],
# Bottom-right corner
[770, 420],
]
)
plt.figure(figsize=(10, 10))
show_image(image, plt.gca())
show_box(input_box, plt.gca())
plt.show()

图 11.14:围绕芒果的框提示
让我们用它来提示 SAM(见图 11.15):
outputs = model.predict(
{
"images": ops.expand_dims(image, axis=0),
"boxes": ops.expand_dims(input_box, axis=(0, 1)),
}
)
mask = get_mask(outputs, 0)
plt.figure(figsize=(10, 10))
show_image(image, plt.gca())
show_mask(mask, plt.gca())
show_box(input_box, plt.gca())
plt.show()

图 11.15:分割的芒果
SAM 可以是一个强大的工具,快速创建带有分割掩码的大图像数据集。
摘要
-
图像分割是计算机视觉任务的主要类别之一。它包括计算分割掩码,这些掩码描述了图像在像素级别的内容。
-
要构建自己的分割模型,使用一系列步进
Conv2D层来“压缩”输入图像到一个较小的特征图,然后使用相应的Conv2DTranspose层堆叠来“扩展”特征图,使其大小与输入图像相同的分割掩码。 -
您还可以使用预训练的分割模型。KerasHub 中包含的 Segment Anything 是一个支持图像提示、文本提示、点提示和框提示的强大模型。
脚注
- Kirillov 等人,“Segment Anything”,在IEEE/CVF 国际计算机视觉会议论文集,arXiv (2023),
arxiv.org/abs/2304.02643。[↩]


浙公网安备 33010602011771号