TensorFlow 实战(三)
原文:
zh.annas-archive.org/md5/63f1015b3af62117a4a51b25a6d19428译者:飞龙
第七章:教会机器更好地看:改善 CNNs 并让它们承认
本章内容包括
-
减少图像分类器的过拟合
-
通过更好的模型架构提升模型性能
-
使用预训练模型和迁移学习进行图像分类
-
现代 ML 解释技术来解剖图像分类器
我们已经开发并训练了一款名为 Inception net v1 的最先进的图像分类器,它在一个物体分类数据集上进行了训练。Inception net v1 是计算机视觉中一个广为人知的图像分类模型。你学习了 Inception 块是如何通过在多个尺度上聚合卷积窗口来创建的,这鼓励了模型中的稀疏性。你还看到了如何使用 1 × 1 卷积来保持层的维度最小。最后,我们观察到 Inception net v1 如何在网络中部使用辅助分类层来稳定和维持整个网络中的梯度流。然而,结果并没有真正达到模型的声誉,它在验证和测试准确性上过度拟合,验证和测试准确率约为 30%,而训练准确率则高达约 94%。在本章中,我们将讨论通过减少过拟合和提高验证和测试准确率来改善模型,最终将使我们得到一个在验证和测试集上达到约 80% 准确率(相当于能够准确识别 160/200 类物体)的模型。此外,我们还将研究允许我们探索模型思维的技术,以获得洞察。
本章将带你走过一个激动人心的旅程,在这个旅程中,我们将把一个次优的机器学习模型变成一个显著优秀的模型。这个过程将使人联想起我们在上一章所做的事情。我们将增加一个额外的步骤来解释/解读模型所做的决策。我们将使用特殊的技术来看看模型在做出预测时对图像的哪个部分支付了最多的注意力。这有助于我们对模型建立信任。在这个过程中,我们识别出模型中潜藏的问题,并系统地修复它们以提高性能。我们将讨论几种重要的技术,包括以下内容:
-
通过使用各种图像变换技术如亮度/对比度调整、旋转和平移来增广数据,为模型创建更多标记数据
-
实现一个更适合所使用的数据大小和类型的 Inception net 变体
-
使用迁移学习来利用已经在更大数据集上训练过的模型,并对其进行微调,以在我们拥有的数据集上表现良好
如果你曾经需要为不熟悉的问题实施深度学习解决方案,那么这一章可能会与你产生共鸣。通常,仅仅实施“某些”深度网络并不会让你登上成功的顶峰。如果问题的新颖性或手头问题的定制性质没有处理好,它会阻碍你的进展。这样的难题会把你带入未知领域,你需要小心行事,找到解决方案而不至于筋疲力尽。本章将为任何可能在计算机视觉领域面对类似情况的人提供指导。
7.1 减少过拟合的技术
我们正在追求一个雄心勃勃的目标,即开发一个智能购物助手应用程序,其中会使用图像/物体分类器作为重要组件。为此,我们将使用数据集 tiny-imagenet-200,它是大型 ImageNet 图像分类数据集的一个较小版本,由图像和表示该图像中存在的对象类别组成。该数据集有一个训练子集和一个测试子集。你进一步将训练子集分成训练集(原始的 90%)和验证集(原始的 10%)。
你已经基于著名的 Inception 网络模型开发了一个模型,但它严重过拟合。需要缓解过拟合,因为它会导致模型在训练数据上表现非常好,但在测试/真实世界数据上表现不佳。你知道几种减少过拟合的技术,即数据增强(从现有数据中创建更多数据;对于图像,这包括通过引入随机亮度/对比度调整、平移、旋转等方式创建相同图像的变体)、随机失活(即在训练期间随机关闭网络中的节点)以及提前停止(即在过拟合发生前终止模型训练)。你希望利用 Keras API 来减少过拟合。
通常,减少过拟合需要仔细检查整个机器学习流程。这涉及到输入的数据、模型结构和模型训练。在这一节中,我们将看看所有这些方面,并了解如何防止过拟合。此部分的代码可在 Ch07-Improving-CNNs-and-Explaining/7.1.Image_Classification_Advance.ipynb 中找到。
7.1.1 使用 Keras 进行图像数据增强
首先是在训练集中增加数据。 数据增强是一种常见的方法,可以增加深度学习网络可用的数据量,而无需对新数据进行标记。 例如,在图像分类问题中,您可以通过创建同一图像的多个变换版本(例如,移动图像,更改亮度)并具有与原始图像相同的标签(图 7.1)来从单个图像创建多个数据点。 如前所述,更多数据通过增加泛化能力(减少过拟合)来增强深度学习模型的强度,从而在实际应用中实现可靠的性能。 对于图像数据,您可以使用许多不同的增强技术:
-
随机调整亮度、对比度等
-
随机缩放、旋转、平移等
图 7.1 在增强步骤之后的训练数据和验证数据之间的差异。 图清楚地显示了对训练数据应用的各种转换,而对验证数据未应用,正如我们所预期的那样。
通过向我们之前使用的 ImageDataGenerator 提供几个额外参数,可以轻松地实现这种增强。 让我们定义一个新的 Keras ImageDataGenerator,具有数据增强功能。 在 Keras 中,您可以执行大多数这些增强,几乎不需要去其他地方寻找。 让我们看看 ImageDataGenerator 提供的各种选项(仅显示了最重要的参数)。 图 7.2 说明了此处列出的不同参数的效果。
data_gen = tf.keras.preprocessing.image.ImageDataGenerator(
featurewise_center=False, samplewise_center=False,
featurewise_std_normalization=False, samplewise_std_normalization=False,
zca_whitening=False, rotation_range=0, width_shift_range=0.0,
height_shift_range=0.0, brightness_range=None, shear_range=0.0,
➥ zoom_range=0.0,
channel_shift_range=0.0, horizontal_flip=False,
vertical_flip=False, fill_mode=”nearest”, rescale=None,
preprocessing_function=None, validation_split=0.0
)
其中
-
featurewise_center 指定是否通过减去整个数据集的平均值来使图像居中(例如,True/False)。
-
samplewise_center 指定是否通过减去每个图像的单个平均值来使图像居中(例如,True/False)。
-
featurewise_std_normalization 与 featurewise_center 相同,但是将图像除以标准偏差而不是减去平均值(True/False)。
-
samplewise_std_normalization 与 samplewise_center 相同,但是将图像除以标准偏差而不是减去平均值(True/False)。
-
zca_whitening 是一种特殊类型的图像归一化,旨在减少图像像素中存在的相关性(请参阅
mng.bz/DgP0)(True/False)。 -
rotation_range 指定在数据增强期间进行的随机图像旋转的边界(以度为单位)。 具有值在(0, 360)之间的浮点数; 例如,30 表示-30 到 30 的范围; 0 禁用。
-
width_shift_range 指定在数据增强期间在宽度轴上进行的随机移位的边界(作为比例或像素)。
-
值在(-1, 1)之间的元组被视为宽度的比例(例如,(-0.4, 0.3))。
-
像素的值在(-inf, inf)之间的元组被视为像素(例如,(-150, 250))。
-
height_shift_range 与 width_shift_range 相同,只是针对高度维度。
-
brightness_range指定在数据增强期间对数据进行的随机亮度调整的范围。 -
元组中的值介于(-inf,inf)之间,例如,(-0.2,0.5)或(-5,10);0 表示禁用。
-
shear_range与brightness_range相同,但用于在数据增强期间剪切(即倾斜)图像。 -
以度为单位的浮点数,例如,30.0。
-
zoom_range与brightness_range相同,除了在数据增强期间对图像进行缩放。 -
horizontal_flip指定在数据增强期间是否随机水平翻转图像(是/否)。 -
vertical_flip与horizontal_flip相同,但垂直翻转(是/否)。 -
fill_mode定义了通过各种图像变换(例如,将图像向左移动会在右侧创建空白空间)创建的空白空间如何处理。可能的选项是“reflect”,“nearest”和“constant”。图 7.2 的最后一行显示了差异。 -
rescale通过常量值重新缩放输入。 -
preprocessing_function接受一个 Python 函数,该函数可用于引入额外的数据增强/预处理步骤,这些步骤不容易获得。 -
validation_split解决了应该将多少数据用作验证数据的问题。我们不使用此参数,因为我们单独为验证集创建数据生成器,因为我们不希望有增强应用。一个浮点数,例如,0.2。
图 7.2 不同增强参数及其ImageDataGenerator的值的效果。
通过对不同参数有良好的理解,我们将定义两个图像数据生成器:一个用于数据增强(训练数据),另一个不用于数据增强(测试数据)。对于我们的项目,我们将以以下方式增强数据:
-
随机旋转图像。
-
在宽度维度上随机平移。
-
在高度维度上随机平移。
-
随机调整亮度。
-
随机剪切。
-
随机缩放。
-
随机水平翻转图像。
-
随机伽马校正(自定义实现)。
-
随机遮挡(自定义实现)。
以下列表显示了如何使用验证分割定义ImageDataGenerator。
列表 7.1 定义了具有验证分割的ImageDataGenerator。
image_gen_aug = ImageDataGenerator( ❶
samplewise_center=False, ❷
rotation_range=30, ❸
width_shift_range=0.2, height_shift_range=0.2, ❸
brightness_range=(0.5,1.5), ❸
shear_range=5, ❸
zoom_range=0.2, ❸
horizontal_flip=True, ❸
fill_mode='reflect', ❸
validation_split=0.1 ❹
)
image_gen = ImageDataGenerator(samplewise_center=False) ❺
❶ 定义用于训练/验证数据的ImageDataGenerator。
❷ 我们将暂时关闭samplewise_center并稍后重新引入它。
❸ 先前讨论的各种增强参数(经验设置)。
❹ 将训练数据的 10% 部分用作验证数据。
❺ 定义了用于测试数据的单独ImageDataGenerator。
我们经验地选择了这些参数的参数。随意尝试其他参数,并查看它们对模型性能的影响。一个重要的事情要注意的是,与以前的例子不同,我们设置了 samplewise_center=False。这是因为我们计划在标准化之前进行少量自定义预处理步骤。因此,我们将关闭 ImageDataGenerator 中的标准化,并稍后重新引入它(通过自定义函数)。接下来,我们将定义训练和测试数据生成器(使用流函数)。与上一章类似的模式,我们将通过同一数据生成器(使用 validation_split 和 subset 参数)获取训练和验证数据生成器(参见下一个列表)。
列表 7.2:定义训练、验证和测试集的数据生成器
partial_flow_func = partial( ❶
image_gen_aug.flow_from_directory,
directory=os.path.join('data','tiny-imagenet-200', 'train'),
target_size=target_size, classes=None,
class_mode='categorical', batch_size=batch_size,
shuffle=True, seed=random_seed
)
train_gen = partial_flow_func(subset='training') ❷
valid_gen = partial_flow_func(subset='validation') ❸
test_df = get_test_labels_df( ❹
os.path.join('data','tiny-imagenet-200', 'val',
➥ 'val_annotations.txt')
)
test_gen = image_gen.flow_from_dataframe( ❺
test_df, directory=os.path.join('data','tiny-imagenet-200', 'val',
➥ 'images'),
target_size=target_size, classes=None,
class_mode='categorical', batch_size=batch_size, shuffle=False
)
❶ 定义一个偏函数,除了子集参数之外所有参数都已固定。
❷ 获取训练数据子集。
❸ 获取验证数据子集。
❹ 读取存储在 txt 文件中的测试标签。
❺ 定义测试数据生成器。
为了恢复我们的记忆,flow_from_directory(...)具有以下函数签名:
image_gen.flow_from_directory (
directory=<directory where the images are>,
target_size=<height and width or target image>,
classes=None,
class_mode=<type of targets generated such as one hot encoded, sparse, etc.>,
batch_size=<size of a single batch>,
shuffle=<whether to shuffle data or not>,
seed=<random seed to be used in shuffling>,
subset=<set to training or validation>
)
train_gen 和 valid_gen 使用 image_gen_aug(进行数据增强)来获取数据。train_gen 和 valid_gen 被定义为原始 image_gen.flow_from_directory()的偏函数,它们共享除子集参数之外的所有参数。但是,重要的是要记住,增强仅应用于训练数据,不得应用于验证子集。这是我们需要的期望行为,因为我们希望验证数据集跨多个周期保持固定。接下来,test_gen 使用 image_gen(无数据增强)。
为什么不应该增强验证/测试数据?
在进行数据增强时,应该只对训练数据集进行增强,不要对验证和测试集进行增强。在验证和测试集上进行增强会导致不同测试/运行之间结果不一致(因为数据增强引入了随机修改)。我们希望保持验证和测试数据集在训练期间始终保持一致。因此,数据增强只针对训练数据进行。
记住,Inception Net v1 有三个输出层;因此,生成器的输出需要是一个输入和三个输出。我们通过定义一个新的 Python 生成器,修改内容以实现这一点(见下一个列表)。
列表 7.3:定义带有几个修饰的数据生成器
def data_gen_augmented_inceptionnet_v1(gen, random_gamma=False,
➥ random_occlude=False): ❶
for x,y in gen:
if random_gamma: ❷
# Gamma correction
# Doing this in the image process fn doesn't help improve
➥ performance
rand_gamma = np.random.uniform(0.9, 1.08, (x.shape[0],1,1,1)) ❸
x = x**rand_gamma ❸
if random_occlude: ❹
# Randomly occluding sections in the image
occ_size = 10
occ_h, occ_w = np.random.randint(0, x.shape[1]-occ_size),
➥ np.random.randint(0, x.shape[2]-occ_size) ❺
x[:,occ_h:occ_h+occ_size,occ_w:occ_w+occ_size,:] =
➥ np.random.choice([0.,128.,255.]) ❻
# Image centering
x -= np.mean(x, axis=(1,2,3), keepdims=True) ❼
yield x,(y,y,y) ❽
train_gen_aux = data_gen_augmented_inceptionnet_v1(
train_gen, random_gamma=True, random_occlude=True ❾
)
valid_gen_aux = data_gen_augmented_inceptionnet_v1(valid_gen) ❿
test_gen_aux = data_gen_augmented_inceptionnet_v1(test_gen) ❿
❶ 定义一个新的函数,引入两种新的增强技术,并修改最终输出的格式。
❷ 检查是否需要伽马校正增强。
❸ 执行伽马校正相关的数据增强。
❹ 检查是否需要随机遮挡数据增强。
❺ 随机定义遮挡的起始 x/y 像素。
❻ 随机为遮挡覆盖添加白色/灰色/黑色。
❼ 对之前关闭的样本居中进行样本级居中。
❽ 确保我们复制目标(y)三次
❾ 训练数据使用随机 gamma 校正和遮挡进行增强。
❿ 验证/测试集不进行增强。
你可以看到data_gen_augmented_inceptionnet_v1返回单个输入(x)和相同输出的三个副本(y)。除了修改输出的格式外,data_gen_augmented_inceptionnet_v1还将使用自定义实现包括两个额外的数据增强步骤(这些步骤不是内置的):
-
Gamma 校正—标准的计算机视觉转换,通过将像素值提高到某个值的幂次方来执行(
mng.bz/lxdz)。在我们的情况下,我们在 0.9 和 1.08 之间随机选择这个值。 -
随机遮挡—我们将在图像上随机遮挡一个随机的补丁(10 × 10),用白色、灰色或黑色像素(随机选择)。
当我们定义 ImageDataGenerator 时,也需要对图像进行居中处理,因为我们将 samplewise_center 参数设置为 False。这通过从每个图像的像素中减去其平均像素值来完成。定义了 data_gen_augmented_inceptionnet_v1 函数后,我们可以为训练/验证/测试数据分别创建修改后的数据生成器 train_gen_aux、valid_gen_aux 和 test_gen_aux。
检查,检查,检查以避免模型性能缺陷
如果你不检查只有训练数据是否被增强,那你可能会陷入麻烦。如果它不能按预期工作,它很容易被忽视。从技术上讲,你的代码是正常工作的,并且没有功能性的 bug。但这会让你在实际情况下不断琢磨为什么模型没有按预期执行。
最后,这个过程中最重要的步骤是验证数据增强是否按照我们的期望进行,而不会以意想不到的方式破坏图像,这会妨碍模型的学习。为此,我们可以绘制由训练数据生成器生成的一些样本以及验证数据生成器生成的样本。我们不仅需要确保数据增强正常工作,还需要确保验证集中不存在数据增强。图 7.3 确保了这一点。
图 7.3 在增强步骤之后训练数据和验证数据之间的差异。该图清楚地显示了应用于训练数据但未应用于验证数据的各种变换,正如我们所预期的那样。
接下来,我们讨论另一种正则化技术称为 dropout。
7.1.2 Dropout:随机关闭网络的部分以提高泛化能力
现在我们将学习一种称为dropout的技术,以进一步减少过拟合。 Dropout 是 Inception net v1 的一部分,但在前一章中,我们避免使用 dropout 以提高清晰度。
Dropout 是一种用于深度网络的正则化技术。正则化技术的作用是控制深度网络,使其在训练过程中摆脱数值错误或者像过拟合这样的麻烦现象。本质上,正则化使深度网络行为良好。
辍学在每次训练迭代期间随机关闭输出神经元。这有助于模型在训练期间学习冗余特征,因为它不总是能够使用先前学到的特征。换句话说,网络在任何给定时间只有部分参数的全网络可学习,并迫使网络学习多个(即冗余的)特征来分类对象。例如,如果网络试图识别猫,那么在第一次迭代中它可能学习关于胡须的知识。然后,如果与胡须知识相关的节点被关闭,它可能学习关于猫耳朵的知识(见图 7.4)。这导致网络学习了冗余/不同的特征,如胡须、两只尖耳朵等,从而在测试时间表现更好。
图 7.4 当学习分类猫图像时,辍学如何改变网络。在第一次迭代中,它可能学习有关胡须的知识。在第二次迭代中,由于包含有关胡须信息的部分被关闭,网络可能学习有关尖耳朵的知识。这使网络在测试时具有关于胡须和耳朵的知识。在这种情况下是好的,因为在测试图像中,你看不到猫的胡须!
在每个要应用辍学的层上应用随机的 1 和 0 掩码关闭节点(见图 7.5)。在训练过程中,您还需要对活动节点进行重要的规范化步骤。假设我们正在训练一个辍学率为 50% 的网络(即在每次迭代中关闭一半的节点)。当你的网络关闭了 50% 时,从概念上讲,你的网络总输出会减少一半,与完整网络相比。因此,您需要将输出乘以一个因子 2,以确保总输出保持不变。辍学的这些计算细节在图 7.5 中突出显示。好消息是,您不必实现任何计算细节,因为 TensorFlow 中提供了辍学作为一个层。
图 7.5 辍学如何运作的计算视角。如果辍学设置为 50%,则每个层中的一半节点(除了最后一层)将被关闭。但在测试时,所有节点都被打开。
Inception 网 v1(见图 7.6)只对全连接层和最后一个平均池化层应用辍学。记住不要在最后一层(即提供最终预测的层)上使用辍学。要执行两个更改:
-
在辅助输出中的中间全连接层应用 70% 的辍学。
-
对最后一个平均池化层的输出应用 40% 的 dropout。
图 7.6 Inception 网络 v1 的抽象架构。Inception 网络以一个称为干线的组件开始,这是一个典型 CNN 中会找到的普通的卷积/池化层序列。然后 Inception 网络引入了一个称为 Inception 块的新组件。最后,Inception 网络还利用了辅助输出层。
在 TensorFlow 中,应用 dropout 就像写一行代码一样简单。一旦你得到了全连接层 dense1 的输出,你就可以使用以下方法应用 dropout:
dense1 = Dropout(0.7)(dense1)
在这里,我们使用了 70% 的 dropout 率(正如原始 Inception 网络 v1 论文中建议的)用于辅助输出。
卷积层上的 dropout
Dropout 主要应用在密集层上,所以人们不禁会想,“为什么我们不在卷积层上应用 dropout 呢?”这仍然是一个争论未决的问题。例如,Nitish Srivastava 等人的原始 dropout 论文(mng.bz/o2Nv)认为,在低卷积层上使用 dropout 可以提高性能。相反,Yarin Gal 等人的论文“具有伯努利近似变分推断的贝叶斯 CNN”(arxiv.org/pdf/1506.02158v6.pdf)认为,在卷积层上应用 dropout 并不会有太大帮助,因为它们的参数数量较低(与密集层相比),已经很好地被正则化了。因此,dropout 可以阻碍卷积层的学习。你需要考虑的一件事是出版时间。dropout 论文是在贝叶斯 CNN 论文之前两年写的。在那段时间内引入的正则化和其他改进可能对改进深度网络产生了重大影响,因此,在卷积层中使用 dropout 的好处可能变得微不足道。你可以在 mng.bz/nNQ4 找到更多非正式的讨论。
辅助输出的最终代码如下列表所示。
列表 7.4 修改了 Inception 网络的辅助输出
def aux_out(inp,name=None):
avgpool1 = AvgPool2D((5,5), strides=(3,3), padding='valid')(inp)
conv1 = Conv2D(128, (1,1), activation='relu', padding='same')(avgpool1)
flat = Flatten()(conv1)
dense1 = Dense(1024, activation='relu')(flat)
dense1 = Dropout(0.7)(dense1) ❶
aux_out = Dense(200, activation='softmax', name=name)(dense1)
return aux_out
❶ 应用了 70% 的 dropout 层
接下来,我们将在最后一个平均池化层的输出上应用 dropout,然后是最后的预测层。在将平均池化层的输出(flat_out)馈送到全连接(即密集)层之前,我们必须将其展平。然后,使用以下方法在 flat_out 上应用 dropout:
flat_out = Dropout(0.4)(flat_out)
对于这一层,我们使用了 40% 的 dropout 率,正如论文中所建议的一样。最终的代码(从平均池化层开始)如下所示:
avgpool1 = AvgPool2D((7,7), strides=(1,1), padding='valid')(inc_5b)
flat_out = Flatten()(avgpool1)
flat_out = Dropout(0.4)(flat_out)
out_main = Dense(200, activation='softmax', name='final')(flat_out)
这就结束了对 dropout 的讨论。要牢记的最后一点是,你不应该简单地设置 dropout 率。应该通过超参数优化技术来选择。非常高的 dropout 率会严重削弱你的网络,而非常低的 dropout 率则不会有助于减少过拟合。
7.1.3 早停:如果网络开始表现不佳,则停止训练过程
我们将要介绍的最后一种技术叫做早停(early stopping)。顾名思义,早停会在验证准确度不再提高时停止模型训练。你可能会想:“什么?我以为训练越多越好。”在达到某一点之前,训练得越多越好,但是之后,训练开始降低模型的泛化能力。图 7.7 展示了在训练模型过程中你会获得的典型训练准确度和验证准确度曲线。你可以看到,在某一点之后,验证准确度停止提高并开始下降。这标志着过拟合的开始。你可以看到,无论验证准确度如何,训练准确度都在持续上升。这是因为现代深度学习模型具有足够多的参数来“记住”数据,而不是学习数据中存在的特征和模式。
图 7.7:过拟合的示意图。在开始时,随着训练迭代次数的增加,训练和验证准确度都会提高。但是在某个时刻之后,验证准确度会趋于平稳并开始下降,而训练准确度则持续上升。这种行为称为过拟合,应该避免。
早停过程非常简单易懂。首先,你定义一个最大的训练轮数。然后模型训练一轮。训练之后,使用评估指标(例如准确度)在验证集上评估模型。如果验证准确度提高了并且还没有达到最大 epoch,则继续训练。否则,停止训练,并完成模型。图 7.8 描述了早停的工作流程。
图 7.8:早停期间的工作流程。首先,模型训练一轮。然后,测量验证准确度。如果验证准确度提高了并且训练还没有达到最大 epoch,则继续训练。否则,停止训练。
实施早停需要对你的代码进行最小的更改。首先,和之前一样,我们将建立一个计算步数的函数:
def get_steps_per_epoch(n_data, batch_size):
""" Given the data size and batch size, gives the number of steps to
➥ travers the full dataset """
if n_data%batch_size==0:
return int(n_data/batch_size)
else:
return int(n_data*1.0/batch_size)+1
接下来,我们将使用 Keras 提供的 EarlyStopping 回调(mng.bz/v6lr)来在训练过程中启用早停。Keras 回调是在每个 epoch 结束时让某些事情发生的简单方法。例如,对于早停,我们只需在每个 epoch 结束时分析验证准确度,如果没有显示任何改善,就终止训练。回调是实现这一目标的理想选择。我们已经使用了 CSVLogger 回调来记录每个 epoch 的指标数量。EarlyStopping 回调有几个参数:
-
monitor—需要监测的指标以终止训练。可以使用 Keras 模型的 model.metric_names 属性获取定义的指标名称列表。在我们的示例中,这将设置为 val_loss(即在验证数据上计算的损失值)。
-
min_delta—被监测指标所需的最小改变,以被视为改进(即任何改进<min_delta 将被视为“没有改进” [默认为零])。
-
patience—如果在这么多个 epochs 之后没有改进,则训练将停止(默认为零)。
-
mode—可以是 auto/min/max。在 min 中,如果指标停止减少(如损失),则训练将停止。在 max 中,如果指标停止增加(如准确度),则训练将停止。该模式将自动从指标名称中推断(默认为 auto)。
-
baseline—指标的基准值。如果指标未超出基准值,则训练将停止(默认为无)。
-
restore_best_weights—在训练开始和终止之间恢复显示选择指标的最佳权重结果(默认为 false)。
首先,如果不存在,我们将创建一个名为 eval 的目录。这将用于存储由 CSVLogger 返回的 CSV 文件:
# Create a directory called eval which stores model performance
if not os.path.exists('eval'):
os.mkdir('eval')
# Logging the performance metrics to a CSV file
csv_logger = CSVLogger(os.path.join('eval','2_eval_data_aug_early_stopping.log'))
然后我们定义 EarlyStopping 回调函数。我们选择 val_loss 作为监测的指标,以及五个 epochs 的耐心。这意味着在五个 epochs 内训练将容忍“没有改进”。我们将保留其他参数为默认值:
# Early stopping callback
es_callback = EarlyStopping(monitor='val_loss', patience=5)
最后使用数据和适当的回调函数调用 model.fit()。在这里,我们使用先前定义的 train_gen_aux 和 valid_gen_aux 作为训练和验证数据(分别)。我们还将 epochs 设置为 50,并使用 get_steps_per_epoch 函数设置训练步数和验证步数。最后,我们提供 EarlyStopping 和 CSVLogger 回调函数,所以在指定条件下没有改进时训练停止:
history = model.fit(
train_gen_aux, validation_data=valid_gen_aux,
steps_per_epoch=get_steps_per_epoch(int(0.9*(500*200)),batch_size),
validation_steps=get_steps_per_epoch(int(0.1*(500*200)),batch_size),
epochs=50, callbacks=[es_callback, csv_logger]
)
下一个清单展示了训练日志的摘要。
列表 7.5 在训练模型期间提供的训练日志
Train for 703 steps, validate for 78 steps
Epoch 1/50
WARNING:tensorflow:Large dropout rate: 0.7 (>0.5). In TensorFlow 2.x,
➥ dropout() uses dropout rate instead of keep_prob. Please ensure that
➥ this is intended. ❶
WARNING:tensorflow:Large dropout rate: 0.7 (>0.5). In TensorFlow 2.x,
➥ dropout() uses dropout rate instead of keep_prob. Please ensure that
➥ this is intended. ❶
WARNING:tensorflow:Large dropout rate: 0.7 (>0.5). In TensorFlow 2.x,
➥ dropout() uses dropout rate instead of keep_prob. Please ensure that
➥ this is intended. ❶
703/703 [==============================] - 196s 279ms/step - loss: 15.4462
➥ - final_loss: 5.1507 - aux1_loss: 5.1369 - aux2_loss: 5.1586 -
➥ final_accuracy: 0.0124 - aux1_accuracy: 0.0140 - aux2_accuracy: 0.0119
➥ - val_loss: 14.8221 - val_final_loss: 4.9696 - val_aux1_loss: 4.8943 -
➥ val_aux2_loss: 4.9582 - val_final_accuracy: 0.0259 - val_aux1_accuracy:
➥ 0.0340 - val_aux2_accuracy: 0.0274
...
Epoch 38/50
703/703 [==============================] - 194s 276ms/step - loss:
➥ 9.4647 - final_loss: 2.8825 - aux1_loss: 3.3037 - aux2_loss: 3.2785 -
➥ final_accuracy: 0.3278 - aux1_accuracy: 0.2530 - aux2_accuracy: 0.2572
➥ - val_loss: 9.7963 - val_final_loss: 3.1555 - val_aux1_loss: 3.3244 -
➥ val_aux2_loss: 3.3164 - val_final_accuracy: 0.2940 - val_aux1_accuracy:
➥ 0.2599 - val_aux2_accuracy: 0.2590
❶ 因为我们对一些层使用了高达 70%的丢失率,TensorFlow 会对此进行警告,因为意外的高丢失率可能会影响模型的性能。
看起来模型没有在 50 个 epochs 中训练到利益。在第 38 个 epoch 之后,它决定终止训练。这在于训练在达到第 50 个 epoch 之前停止(如第 38/50 行所示)。另一个重要的观察结果是,你可以看到训练准确度没有像我们在上一章中看到的那样激增到很高的值。训练准确度一直与验证准确度(~30%)相当接近。尽管我们没有看到很大的性能提升,但我们成功地显著减少了过拟合。因此,我们可以着重提高准确度。
注意 在一台配备 Intel Core i5 处理器和 NVIDIA GeForce RTX 2070 8GB 显卡的机器上,训练大约需要 1 小时 30 分钟才能完成 38 个周期。
接下来,我们将重新审视我们的模型。我们将深入研究一些研究,并实现一个已经被证明在这个特定分类问题上运行良好的模型。
练习 1
你手头有一个模型呈现给你,你发现它严重欠拟合。欠拟合发生在你的模型没有足够近似数据分布时。建议你如何改变 dropout 层以减少欠拟合。你可以选择 20%、50%和 80%作为 dropout 率:
model = tf.keras.models.Sequential([
tf.keras.layers.Dense(100, activation=’relu’, input_shape=(250,)),
tf.keras.layers.Dropout(0.5),
tf.keras.layers.Dense(10, activation=’softmax’)
])
model.compile
(loss=’categorical_crossentropy’, optimizer=’adam’, metrics=[‘accuracy’])
model.fit(X, y, epochs=25)
练习 2
定义一个早停回调函数,如果验证损失值(即 val_loss)在五个周期后没有增加 0.01,则终止训练。为此目的使用 tf.keras.callbacks.EarlyStopping 回调函数。
7.2 朝向极简主义:Minception 而不是 Inception
我们现在有一个几乎不存在过拟合的模型。然而,模型的测试性能仍然没有达到我们想要的水平。你觉得你需要对这个问题有一个新的视角,并咨询团队中的一位高级数据科学家。你解释了你如何在 tiny-imagenet-200 图像分类数据集上训练了一个 Inception net v1 模型,以及模型的性能不佳。他提到他最近读过一篇论文(cs231n.stanford.edu/reports/201…),该论文使用了一个受 Inception-ResNet v2 启发的修改版本的 Inception 网络,在数据集上取得了更好的性能。
他进一步解释了两种新技术,批量标准化和残差连接(它们在修改后的 Inception 网络以及 Inception-ResNet v2 中使用),以及它们在帮助模型训练方面产生的重大影响,特别是在深度模型中。现在,你将实现这个新的修改后的模型,看看它是否会提高性能。
我们看到验证和测试准确率略有提高。但是在性能方面,我们仍然只是触及到了表面。例如,有关这个数据集的测试准确率约为 85%(mng.bz/44ev)。因此,我们需要寻找其他提高模型性能的方法。
你与团队中的高级数据科学家进行的那次会议简直再好不过了。我们将尝试他所读过的新网络。
这个网络主要受到了前一章节简要提及的 Inception-Resnet-v2 网络的启发。这个新网络(我们将其称为 Minception)利用了 Inception-ResNet v2 模型中使用的所有最先进的组件,并对它们进行了修改以适应手头的问题。在本节中,你将深入了解这个新模型。特别是,Minception 网络具有以下元素:
-
一个干扰项
-
Inception-ResNet 块 A
-
Inception-ResNet 块 B
-
减少块(一种新的用于减少输出大小的块)
-
平均池化层
-
最终预测层
与其他 Inception 模型一样,这个模型也有一个干部和 Inception 块。但是,Minception 与 Inception Net v1 不同,因为它没有辅助输出,因为它们有其他稳定训练的技术。另一个值得注意的区别是,Minception 有两种类型的 Inception 块,而 Inception Net v1 在整个网络中重用相同的格式。在讨论 Minception 的不同方面时,我们将更详细地与我们实现的 Inception Net v1 进行比较。在后面的章节中,我们将更详细地讨论 Inception-ResNet v2 模型的架构,并将其与 Minception 进行比较。此代码可在 Ch07-Improving-CNNs-and-Explaining/7.1.Image_Classification_Advance.ipynb 中找到。
7.2.1 实施干部
首先,我们应该关注模型的干部。为了更新我们的知识,干部是一系列卷积和池化层,类似于典型的 CNN。然而,Minception 的布局更加复杂,如图 7.9 所示。
图 7.9 比较 Minception 和 Inception-v1 的干部。请注意 Minception 如何分离卷积层的非线性激活。这是因为批量归一化必须插入到卷积输出和非线性激活之间。
您可以看到它在干部上有并行的卷积层流。Minception 的干部与 Inception Net v1 相比非常不同。另一个关键区别是 Minception 不使用局部响应归一化(LRN),而是使用更强大的批量归一化。
Batch normalization:一种多功能的归一化技术,用于稳定和加速深度网络的训练。
批量归一化(BN)是由 Sergey Ioffe 等人在论文“Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift”中引入的。正如其名称所示,它是一种归一化技术,用于归一化深度网络的中间输出。(proceedings.mlr.press/v37/ioffe15.pdf)
“你可能会问,这很重要吗?” 结果深度网络如果没有得到正确的关注,可能会导致严重的头痛。例如,在训练期间,一批未正确缩放/异常输入或不正确的权重初始化可能导致模型不佳。此外,此类问题可能会随着网络深度或时间的推移而放大,导致每个层接收到的输入分布随时间而改变。输入分布随时间发生变化的现象称为协变量转移。这在流数据问题中非常常见。批量归一化是为了解决这个问题而发明的。让我们了解一下 BN 如何解决这个问题。批量归一化层执行以下操作:
-
使用 x^((k)),网络的第 k^(th) 层的输出进行归一化
-
-
这里,E[x^((k))] 表示输出的平均值,Var[x^((k))] 表示输出的方差。E[x^((k))] 和 Var[x^((k))] 都是向量。对于具有 n 个节点的全连接层,E[x^((k))] 和 Var[x^((k))] 都是长度为 n 的向量(通过对批次维度求平均计算)。对于具有 f 个滤波器/卷积核的卷积层,E[x^((k))] 和 Var[x^((k))] 将是长度为 f 的向量(通过对批次、高度和宽度维度求平均计算)。
-
使用两个可训练的超参数 γ 和 β(分别针对每一层)来缩放和偏移归一化后的输出,如下所示:
-
y^((k)) = γ^((k))x̂^((k)) + β^((k))
-
在这个过程中,计算 E(x) 和 Var(x) 会有些棘手,因为在训练和测试阶段需要对它们进行不同处理。
-
在训练过程中,根据训练的随机性(即一次只查看一个随机数据批次而不是整个数据集),对于每个批次,只使用该批次的数据计算 E(x)(平均值)和 Var(x)(方差)。因此,对于每个批次,你可以计算出 E(x) 和 Var(x)(不必担心除了当前批次以外的任何事情)。
-
然后,利用每个数据批次计算出的 E(x) 和 Var(x),我们估算出了总体的 E(x) 和 Var(x)。这是通过计算 E(x) 和 Var(x) 的运行均值来实现的。我们不会讨论运行均值的工作原理。但你可以想象运行均值是对大数据集的真实均值的高效计算的近似表示。
-
在测试阶段,我们使用之前计算出的基于总体的 E(x) 和 Var(x),并执行之前定义的计算以获得 y^((k))。
由于批归一化涉及的步骤复杂,从头开始实现会需要相当多的工作。幸运的是,你不必这样做。TensorFlow 提供了一个批归一化层(mng.bz/Qv0Q)。如果你有某些密集层的输出(我们称之为 dense1)要应用批归一化,你只需要
dense1_bn = BatchNormalization()(dense1)
然后 TensorFlow 将自动处理批归一化需要在内部发生的所有复杂计算。现在是时候在我们的 Minception 模型中使用这一强大的技术了。在下一个列表中,你可以看到 Minception 网络的基干的实现。我们将编写一个名为 stem 的函数,它允许我们随意开启/关闭批归一化。
列表 7.6 定义 Minception 的基干
def stem(inp, activation='relu', bn=True): ❶
conv1_1 = Conv2D(
32, (3,3), strides=(2,2), activation=None, ❷
kernel_initializer=init, padding='same'
)(inp) #62x62
if bn:
conv1_1 = BatchNormalization()(conv1_1) ❸
conv1_1 = Activation(activation)(conv1_1) ❹
conv1_2 = Conv2D(
32, (3,3), strides=(1,1), activation=None, ❷
kernel_initializer=init, padding='same'
)(conv1_1) # 31x31
if bn:
conv1_2 = BatchNormalization()(conv1_2)
conv1_2 = Activation(activation)(conv1_2)
conv1_3 = Conv2D(
64, (3,3), strides=(1,1), activation=None, ❷
kernel_initializer=init, padding='same'
)(conv1_2) # 31x31
if bn:
conv1_3 = BatchNormalization()(conv1_3)
conv1_3 = Activation(activation)(conv1_3)
maxpool2_1 = MaxPool2D((3,3), strides=(2,2),
➥ padding='same')(conv1_3) ❺
conv2_2 = Conv2D(
96, (3,3), strides=(2,2), activation=None,
kernel_initializer=init, padding='same'
)(conv1_3)
if bn:
conv2_2 = BatchNormalization()(conv2_2)
conv2_2 = Activation(activation)(conv2_2) ❺
out2 = Concatenate(axis=-1)([maxpool2_1, conv2_2]) ❻
conv3_1 = Conv2D(
64, (1,1), strides=(1,1), activation=None,
kernel_initializer=init, padding='same'
)(out2) ❼
if bn:
conv3_1 = BatchNormalization()(conv3_1)
conv3_1 = Activation(activation)(conv3_1)
conv3_2 = Conv2D(
96, (3,3), strides=(1,1), activation=None,
kernel_initializer=init, padding='same'
)(conv3_1) ❼
if bn:
conv3_2 = BatchNormalization()(conv3_2)
conv3_2 = Activation(activation)(conv3_2)
conv4_1 = Conv2D(
64, (1,1), strides=(1,1), activation=None,
kernel_initializer=init, padding='same'
)(out2) ❽
if bn:
conv4_1 = BatchNormalization()(conv4_1)
conv4_1 = Activation(activation)(conv4_1)
conv4_2 = Conv2D(
64, (7,1), strides=(1,1), activation=None,
kernel_initializer=init, padding='same'
)(conv4_1) ❽
if bn:
conv4_2 = BatchNormalization()(conv4_2)
conv4_3 = Conv2D(
64, (1,7), strides=(1,1), activation=None,
kernel_initializer=init, padding='same'
)(conv4_2) ❽
if bn:
conv4_3 = BatchNormalization()(conv4_3)
conv4_3 = Activation(activation)(conv4_3)
conv4_4 = Conv2D(
96, (3,3), strides=(1,1), activation=None,
kernel_initializer=init, padding='same'
)(conv4_3) ❽
if bn:
conv4_4 = BatchNormalization()(conv4_4)
conv4_4 = Activation(activation)(conv4_4)
out34 = Concatenate(axis=-1)([conv3_2, conv4_4]) ❾
maxpool5_1 = MaxPool2D((3,3), strides=(2,2), padding='same')(out34) ❿
conv6_1 = Conv2D(
192, (3,3), strides=(2,2), activation=None,
kernel_initializer=init, padding='same'
)(out34) ❿
if bn:
conv6_1 = BatchNormalization()(conv6_1)
conv6_1 = Activation(activation)(conv6_1)
out56 = Concatenate(axis=-1)([maxpool5_1, conv6_1]) ❿
return out56
❶ 定义函数。请注意我们可以随时开启/关闭批归一化。
❷ 到第一个分支的基干的第一部分
❸ 请注意,在应用非线性激活之前,先应用第一个批归一化。
❹ 非线性激活应用于批归一化步骤后的层。
❺ 第一个分支的两个平行流
❻ 连接第一个分割的两个并行流的输出
❼ 第二个分割的第一个流
❽ 第二个分割的第二个流
❾ 连接第二个分割的两个流的输出
❿ 第三个(最终分割)和输出的连接
一个关键变化需要注意,即每一层的非线性激活与层本身分开。这样做是为了能够在层的输出和非线性激活之间插入批量归一化。这是应用批量归一化的原始方式,正如原始论文中所讨论的那样。但是批量归一化应该在非线性激活之前还是之后是一个持续讨论的问题。您可以在mng.bz/XZpp上找到关于这个主题的非正式讨论。
7.2.2 实现 Inception-ResNet 类型 A 块
在我们已经讨论了网络的干部后,让我们继续看看在 Minception 网络中 Inception 块是什么样子的。让我们快速回顾一下 Inception 块是什么以及为什么会开发它。Inception 块的开发旨在最大化卷积层的表示能力,同时鼓励模型参数的稀疏性,而不会使内存需求激增。它通过具有不同感知域大小(即内核大小)的几个并行卷积层来实现这一点。Minception 网络中的 Inception 块主要使用相同的框架。但是,它引入了一个新概念,称为残差连接。
残差/跳过连接:稳定梯度的捷径
我们已经简要讨论了残差连接,它引入了数学中可以想象的最简单的操作之一:将输入逐元素添加到输出中。换句话说,您取网络的前一个输出(称为 x)并将其添加到当前输出(称为 y)中,因此您得到最终输出 z 为 z = x + y。
如何在卷积层之间添加跳过/残差连接
在实现残差连接时需要注意的一点是确保它们的尺寸匹配,因为这是逐元素的加法。
残差连接的数学观点是什么?
起初可能不太明显,但是跳过连接中的残差之处并不清楚。假设以下情景。您有一个输入 x;接下来您有一些层,F(x) = y,它将输入 x 映射到 y。您实现了以下网络。
残差连接的数学观点
y[k] = F(x)
y[k] [+ 1] = F(y[k])
y[k] [+ 2] = y[k] [+ 1] + x
y[k] [+ 2] = y[k] [+ 1] + G(x); 让我们将残差连接视为一个执行恒等映射的层,并将其称为 G。
y[k] [+ 2] - y[k] [+ 1] = G(x) 或
G(x) = y[k] [+ 2] - y[k] [+ 1]; 实际上,G 代表了最终输出和前一个输出之间的残差。
通过将最终输出视为一个将 x 和 y[k] [+ 1] 作为输入的层 H,我们得到以下方程:
G(x) = H(x, y[k] [+ 1]) - F(y[k])
你可以看到残差是如何发挥作用的。本质上,G(x) 是最终层输出与上一层输出之间的残差
实现残差连接再简单不过了。假设你有以下网络:
from tensorflow.keras.layers import Dense, Input, Add
inp = Input(shape=(10,))
d1 = Dense(20, activation='relu')(inp)
d2 = Dense(20, activation='relu')(d1)
d3 = Dense(20, activation='relu')(d2)
你想要从 d1 到 d3 创建一个残差连接。你所需要做的就是
d4 = d3 + d1
或者,如果你想使用一个 Keras 层(与上一个操作等效),你可以这样做
d4 = Add()([d3, d1])
现在你明白了:d4 是一个残差连接的输出。你可能还记得我说过,为了添加残差连接,输出尺寸必须匹配。我们尝试添加两个不兼容的形状。例如,让我们将 Dense 层的节点数从 20 改为 30:
inp = Input(shape=(10,))
d1 = Dense(20, activation='relu')(inp)
d2 = Dense(20, activation='relu')(d1)
d3 = Dense(30, activation='relu')(d2)
d4 = Add()([d3, d1])
如果你尝试运行这段代码,你将会得到以下错误:
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
...
----> d4 = Add()([d3, d1])
...
ValueError: Operands could not be broadcast together with shapes (30,) (20,)
正如你所看到的,TensorFlow 抱怨它无法广播(在这种情况下,这意味着执行逐元素加法)两个形状分别为 30 和 20 的张量。这是因为 TensorFlow 不知道如何将一个形状为(batch_size,20)的张量与一个形状为(batch_size,30)的张量相加。如果在尝试实现残差连接时出现类似的错误,你应该检查网络输出,并确保它们匹配。要消除此错误,你所需要做的就是按照以下方式更改代码:
inp = Input(shape=(10,))
d1 = Dense(20, activation='relu')(inp)
d2 = Dense(20, activation='relu')(d1)
d3 = Dense(20, activation='relu')(d2)
d4 = Add()([d3, d1])
Minception 有两种类型的 Inception 块(类型 A 和类型 B)。现在让我们将 Inception-ResNet 块(类型 A)写成一个名为 inception_resnet_a 的函数。与之前实现的 Inception 块相比,这个新的 inception 块有以下增加:
-
使用批量归一化
-
使用一个从输入到块的最终输出的残差连接
图 7.10 比较 Minception 的 Inception-ResNet 块类型 A 与 Inception Net v1。一个明显的区别是 Inception Net v1 不利用残差连接的优势。
图 7.10 比较 Inception-ResNet 块 A(Minception)和 Inception 网 v1 的 Inception 块
现在让我们实现 Minception-ResNet 块 A。图 7.11 显示了需要实现的计算类型及其连接性(清单 7.7)。
图 7.11 Minception-ResNet 块 A 的示意图,带有代码清单 7.7 的注释
清单 7.7 Minception-ResNet 块 A 的实现
def inception_resnet_a(inp, n_filters, initializer, activation='relu',
➥ bn=True, res_w=0.1):
out1_1 = Conv2D(
n_filters[0][0], (1,1), strides=(1,1),
➥ activation=None,
kernel_initializer=initializer,
➥ padding='same'
)(inp) ❶
if bn:
out1_1 = BatchNormalization()(out1_1)
out1_1 = Activation(activation)(out1_1) ❶
out2_1 = Conv2D(
n_filters[1][0], (1,1), strides=(1,1),
➥ activation=None,
kernel_initializer=initializer, padding='same'
)(inp) ❷
if bn:
out2_1 = BatchNormalization()(out2_1)
out2_1 = Activation(activation)(out2_1) ❷
out2_2 = Conv2D(
n_filters[1][1], (1,1), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(out2_1) ❷
if bn:
out2_2 = BatchNormalization()(out2_2)
out2_2 = Activation(activation)(out2_2) ❷
out2_3 = Conv2D(
n_filters[1][2], (1,1), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(out2_2) ❷
out3_1 = Conv2D(
n_filters[2][0], (1,1), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(inp) ❸
if bn:
out3_1 = BatchNormalization()(out3_1)
out3_1 = Activation(activation)(out3_1) ❸
out3_2 = Conv2D(
n_filters[2][1], (3,3), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(out3_1) ❸
if bn:
out3_2 = BatchNormalization()(out3_2)
out3_2 = Activation(activation)(out3_2) ❸
out3_3 = Conv2D(
n_filters[2][2], (3,3), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(out3_2) ❸
if bn:
out3_3 = BatchNormalization()(out3_3)
out3_3 = Activation(activation)(out3_3) ❸
out3_4 = Conv2D(
n_filters[2][3], (1,1), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(out3_3) ❸
if bn:
out3_4 = BatchNormalization()(out3_4)
out3_4 = Activation(activation)(out3_4) ❸
out4_1 = Concatenate(axis=-1)([out1_1, out2_2, out3_4]) ❹
out4_2 = Conv2D(
n_filters[3][0], (1,1), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(out4_1)
if bn:
out4_2 = BatchNormalization()(out4_2)
out4_2 += res_w * inp ❺
out4_2 = Activation(activation)(out4_2) ❺
return out4_2
❶ 块中的第一个并行流
❷ 块中的第二个并行流
❸ 块中的第三个并行流
❹ 将三个独立流的输出连接起来。
❺ 合并残差连接(乘以一个因子以改善梯度流动)。
尽管函数看起来很长,但主要是使用卷积层进行乐高堆砌。 图 7.11 为您提供了视觉 Inception 层与代码之间的思维映射。 一个关键观察是批量标准化和非线性激活(ReLU)如何应用于块的顶部部分。 最后的 1×1 卷积使用批量标准化,而不是非线性激活。 非线性激活仅在残余连接之后应用。
现在我们要看看如何实现 Inception-ResNet B 块。
7.2.3 实现 Inception-ResNet 类型 B 块
接下来是 Minception 网络中的 Inception-ResNet 类型 B 块。 我们不会详细讨论这个,因为它与 Inception-ResNet A 块非常相似。 图 7.12 描述了 Inception-ResNet B 块并将其与 Inception-ResNet A 块进行了比较。 块 B 看起来相对简单,只有两个并行流。 代码相关的注释帮助您将 Inception 块的思维模型映射到代码中,如下列表所示。
图 7.12 Minception 的 Inception-ResNet 块 B(左)和 Minception 的 Inception-ResNet 块 A(右)并排放在一起
列表 7.8 Minception-ResNet 块 B 的实现
def inception_resnet_b(inp, n_filters, initializer, activation='relu',
➥ bn=True, res_w=0.1):
out1_1 = Conv2D(
n_filters[0][0], (1,1), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(inp)
if bn:
out1_1 = BatchNormalization()(out1_1)
out1_1 = Activation(activation)(out1_1) ❶
out2_1 = Conv2D(
n_filters[1][0], (1,1), strides=(1,1), activation=activation,
kernel_initializer=initializer, padding='same'
)(inp)
if bn:
out2_1 = BatchNormalization()(out2_1)
out2_1 = Activation(activation)(out2_1) ❷
out2_2 = Conv2D(
n_filters[1][1], (1,7), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(out2_1)
if bn:
out2_2 = BatchNormalization()(out2_2)
out2_2 = Activation(activation)(out2_2) ❷
out2_3 = Conv2D(
n_filters[1][2], (7,1), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(out2_2)
if bn:
out2_3 = BatchNormalization()(out2_3)
out2_3 = Activation(activation)(out2_3) ❷
out3_1 = Concatenate(axis=-1)([out1_1, out2_3]) ❸
out3_2 = Conv2D(
n_filters[2][0], (1,1), strides=(1,1), activation=None,
kernel_initializer=initializer, padding='same'
)(out3_1)
if bn:
out3_2 = BatchNormalization()(out3_2) ❹
out3_2 += res_w * inp ❺
out3_2 = Activation(activation)(out3_2)
return out3_2
❶ 块中的第一个并行流
❷ 块中的第二个并行流
❸ 将来自两个并行流的结果连接起来。
❹ 连接结果顶部的最终卷积层
❺ 应用了加权残差连接
这与函数 inception_resnet_a(...)非常相似,具有两个并行流和残余连接。 需要注意的区别是类型 A 块的卷积层数量比类型 B 块多。 另外,类型 A 块使用 5×5 卷积(分解为两个 3×3 卷积层),而类型 B 使用 7×7 卷积(分解为 1×7 和 7×1 卷积层)。 我将让读者自行详细了解该函数。
7.2.4 实现减少块
受 Inception-ResNet 模型的启发,Minception 也使用减少块。 减少块与 ResNet 块非常相似,唯一的区别是块中没有残余连接(见下一列表)。
列表 7.9 Minception 的减少块的实现
def reduction(inp, n_filters, initializer, activation='relu', bn=True):
# Split to three branches
# Branch 1
out1_1 = Conv2D(
n_filters[0][0], (3,3), strides=(2,2),
kernel_initializer=initializer, padding='same'
)(inp)
if bn:
out1_1 = BatchNormalization()(out1_1)
out1_1 = Activation(activation)(out1_1) ❶
out1_2 = Conv2D(
n_filters[0][1], (3,3), strides=(1,1),
kernel_initializer=initializer, padding='same'
)(out1_1)
if bn:
out1_2 = BatchNormalization()(out1_2)
out1_2 = Activation(activation)(out1_2) ❶
out1_3 = Conv2D(
n_filters[0][2], (3,3), strides=(1,1),
kernel_initializer=initializer, padding='same'
)(out1_2)
if bn:
out1_3 = BatchNormalization()(out1_3)
out1_3 = Activation(activation)(out1_3) ❶
# Branch 2
out2_1 = Conv2D(
n_filters[1][0], (3,3), strides=(2,2),
kernel_initializer=initializer, padding='same'
)(inp)
if bn:
out2_1 = BatchNormalization()(out2_1)
out2_1 = Activation(activation)(out2_1) ❷
# Branch 3
out3_1 = MaxPool2D((3,3), strides=(2,2), padding='same')(inp) ❸
# Concat the results from 3 branches
out = Concatenate(axis=-1)([out1_3, out2_1, out3_1]) ❹
return out
❶ 卷积的第一个并行流
❷ 卷积的第二个并行流
❸ 池化的第三个并行流
❹ 将所有输出连接起来
我将让图 7.13 自己说明列表 7.9。但正如你所看到的,在抽象层面上,它使用了我们讨论过的 Inception 块相同类型的连接和层。
图 7.13 减少块的示意图
现在我们要看看如何通过汇总到目前为止实现的所有不同元素来完成 Minception 的拼图。
7.2.5 将所有内容组合在一起
到目前为止,工作进行得很顺利。随着所有基本块准备就绪,我们的 Minception 模型正在成形。接下来,将事物放在它们应该放置的地方就是问题。最终模型使用以下组件:
-
单个干部
-
1x Inception-ResNet 块 A
-
2x Inception-ResNet 块 B
-
平均池化
-
Dropout
-
具有 200 个节点和 softmax 激活的最终预测层
此外,我们将对模型的输入进行一些更改。根据原始论文,该模型接收的是 56 × 56 × 3 大小的输入,而不是 64 × 64 × 3 大小的输入。通过以下方式实现:
-
训练阶段 — 从原始 64 × 64 × 3 大小的图像中随机裁剪一个 56 × 56 × 3 大小的图像
-
验证/测试阶段 — 从原始图像中心裁剪一个 56 × 56 × 3 大小的图像
此外,我们将在训练期间引入另一个增强步骤,随机对比图像(与论文中使用的相同)。不幸的是,您无法使用 ImageDataGenerator 实现这两个步骤中的任何一个。好消息是,自 TensorFlow 2.2 以来,引入了几个新的图像预处理层(mng.bz/yvzy)。我们可以像模型中的任何其他层一样使用这些层。例如,我们像以前一样从输入开始:
inp = Input(shape=(64,64,3))
然后导入 RandomCrop 和 RandomContrast 层,并按如下方式使用它们:
from tensorflow.keras.layers.experimental.preprocessing import RandomCrop,
➥ RandomContrast
# Cropping the image to a 56x56 sized image
crop_inp = RandomCrop(56, 56, seed=random_seed)(inp)
# Provide a random contrast between 0.7 and 1.3 where 1.0 is the original
➥ contrast
crop_inp = RandomContrast(0.3, seed=random_seed)(crop_inp)
最终模型如下所示。
列表 7.10 最终 Minception 模型
import tensorflow as tf
from tensorflow.keras.layers import Input, Conv2D, MaxPool2D, Dropout,
➥ AvgPool2D, Dense, Concatenate, Flatten, BatchNormalization, Activation
➥ from tensorflow.keras.layers.experimental.preprocessing import RandomCrop,
➥ RandomContrast
from tensorflow.keras.models import Model
from tensorflow.keras.losses import CategoricalCrossentropy
import tensorflow.keras.backend as K
from tensorflow.keras.callbacks import EarlyStopping, CSVLogger
inp = Input(shape=(64,64,3)) ❶
crop_inp = RandomCrop(56, 56, seed=random_seed)(inp) ❷
crop_inp = RandomContrast(0.3, seed=random_seed)(crop_inp) ❸
stem_out = stem(crop_inp) ❹
inc_a = inception_resnet_a(stem_out, [(32,),(32,32), (32, 48, 64,
➥ 384),(384,)], initializer=init) ❺
red = reduction(inc_a, [(256,256,384),(384,)], initializer=init) ❻
inc_b1 = inception_resnet_b(red, [(192,),(128,160,192),(1152,)],
➥ initializer=init) ❼
inc_b2 = inception_resnet_b(inc_b1, [(192,),(128,160,192),(1152,)],
➥ initializer=init) ❼
avgpool1 = AvgPool2D((4,4), strides=(1,1), padding='valid')(inc_b2)
flat_out = Flatten()(avgpool1)
dropout1 = Dropout(0.5)(flat_out)
out_main = Dense(200, activation='softmax', kernel_initializer=init,
➥ name='final')(flat_out) ❽
minception_resnet_v2 = Model(inputs=inp, outputs=out_main) ❾
minception_resnet_v2.compile(loss=’categorical_crossentropy’,
➥ optimizer='adam', metrics=['accuracy']) ❿
❶ 定义 64 × 64 输入层。
❷ 对输入进行随机裁剪(仅在训练期间激活随机性)。
❸ 在输入上执行随机对比度调整(仅在训练期间激活随机性)。
❹ 定义干部的输出。
❺ 定义 Inception-ResNet 块(类型 A)。
❻ 定义减少层。
❼ 定义 2 个 Inception-ResNet 块(类型 B)。
❽ 定义最终预测层。
❾ 定义模型。
❿ 使用分类交叉熵损失和 adam 优化器编译模型。
最后,我们的 Minception 模型已经准备就绪。它接收一个 64 × 64 × 3 大小的输入(与我们实现的其他模型相同)。然后在训练期间随机(在验证/测试期间居中)裁剪图像并应用随机对比度调整(在训练期间)。这些都会自动处理。接下来,处理后的输入进入网络的干部部分,产生输出干部输出,然后进入类型 A 的 Inception-ResNet 块并流入减少块。接下来,我们有两个连续的 Inception-ResNet 类型 B 块。然后是一个平均池化层,一个扁平化层,将除批次维度之外的所有维度压缩为 1。然后在输出上应用 50% 丢失率的 dropout 层。最后,具有 softmax 激活的 200 个节点的密集层生成最终输出。最后,使用分类交叉熵损失和 adam 优化器编译模型。
这结束了我们对 Minception 模型的讨论。您想知道这将如何提升我们模型的性能吗?在下一节中,我们将训练我们定义的 Minception 模型。
7.2.6 训练 Minception
现在我们开始训练模型了。训练过程与您已经为 Inception Net v1 模型所做的非常相似,只有一个区别。我们将使用学习率缩减计划进一步减少过拟合并改善泛化能力。在此示例中,如果模型的性能在预定义的持续时间内没有改善,学习率调度器将减少学习率(请参见下一个清单)。
清单 7.11 训练 Minception 模型
import time
from tensorflow.keras.callbacks import EarlyStopping, CSVLogger
from functools import partial
n_epochs=50
es_callback = EarlyStopping(monitor='val_loss', patience=10) ❶
csv_logger = CSVLogger(os.path.join('eval','3_eval_minception.log')) ❷
lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
monitor='val_loss', factor=0.1, patience=5, verbose=1, mode='auto' ❸
)
history = model.fit( ❹
train_gen_aux, validation_data=valid_gen_aux,
steps_per_epoch=get_steps_per_epoch(int(0.9*(500*200)), batch_size),
validation_steps=get_steps_per_epoch(int(0.1*(500*200)), batch_size),
epochs=n_epochs,
callbacks=[es_callback, csv_logger, lr_callback]
)
❶ 设置了早停回调
❷ 设置了 CSV 记录器以记录指标
❸ 设置学习率控制回调
❹ 训练模型
在训练深度网络时,使用学习率计划而不是固定学习率非常常见。通常,我们通过在模型训练开始时使用较高的学习率,然后随着模型的进展逐渐减小学习率来获得更好的性能。这是因为,在优化过程中,当模型收敛时,您应该使步长变小(即学习率)。否则,较大的步长会使模型表现不稳定。我们可以在观察到指标没有增加时智能地执行此过程,并在固定间隔内减小学习率。在 Keras 中,您可以通过回调 ReduceLROnPlateau (mng.bz/M5Oo) 轻松将此纳入模型训练中:
lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
monitor='val_loss', factor=0.1, patience=5, verbose=1, mode='auto'
)
使用回调时,您需要设置以下关键字参数:
-
monitor——定义观察的指标。在我们的示例中,我们将根据验证损失决定何时降低学习率。
-
factor——减少学习率的乘法因子。如果学习率为 0.01,0.1 的因子,这意味着在减少时学习率将为 0.001。
-
沉着——与早停类似,等待多少个时期在指标没有改善的情况下降低学习率。
-
mode——与早停类似,指标的最小化/最大化是否应被视为改进。
当您训练模型时,您应该得到以下输出:
Train for 703 steps, validate for 78 steps
Epoch 1/50
703/703 [==============================] - 158s 224ms/step - loss: 4.9362 -
➥ accuracy: 0.0544 - val_loss: 13.1802 - val_accuracy: 0.0246
...
Epoch 41/50
702/703 [============================>.] - ETA: 0s - loss: 2.5830 -
➥ accuracy: 0.6828
Epoch 00041: ReduceLROnPlateau reducing learning rate to 0.00010000000474974513.
703/703 [==============================] - 136s 194ms/step - loss: 2.5831 -
➥ accuracy: 0.6827 - val_loss: 3.4446 - val_accuracy: 0.4316
...
Epoch 47/50
702/703 [============================>.] - ETA: 0s - loss: 2.3371 -
➥ accuracy: 0.7859
Epoch 00047: ReduceLROnPlateau reducing learning rate to 1.0000000474974514e-05.
703/703 [==============================] - 139s 197ms/step - loss: 2.3372 -
➥ accuracy: 0.7859 - val_loss: 3.2988 - val_accuracy: 0.4720
...
Epoch 50/50
703/703 [==============================] - 137s 194ms/step - loss: 2.3124 -
➥ accuracy: 0.7959 - val_loss: 3.3133 - val_accuracy: 0.4792
太棒了!通过调整模型架构,我们获得了巨大的准确性提升。我们现在有一个模型,在验证集上的准确率约为 50%(相当于准确识别了 100/200 个类别的对象,或者每个类别的图像有 50% 被准确分类)。您可以在输出中看到 ReduceLROnPlateau 回调所进行的干预。
最后,我们使用以下方式保存模型
if not os.path.exists('models'):
os.mkdir("models")
model.save(os.path.join('models', 'minception_resnet_v2.h5'))
接下来,我们可以在测试集上衡量模型的性能:
# Load the model from disk
model = load_model(os.path.join('models','minception_resnet_v2.h5'))
# Evaluate the model
test_res = model.evaluate(test_gen_aux, steps=get_steps_per_epoch(500*50,
➥ batch_size))
这应该在测试集上达到 51%的准确率。这是非常令人兴奋的消息。通过更多关注模型结构,我们几乎将之前模型的性能提升了一倍。
这是一个很好的教训,教会了我们模型架构在深度学习中的至关重要的作用。有一个误解认为深度学习是解决一切问题的灵丹妙药。不是的。例如,你不应该期望任何随意组合在一起的架构能够像一些公开发表的最新技术结果那样好。获得一个表现良好的深度网络可能是对超参数进行了数天甚至数周的优化和凭经验的选择的结果。
在下一节中,我们将利用迁移学习更快地达到更高程度的准确性。我们将下载一个预训练模型,并在特定数据集上进行微调。
注意 在一台配备 Intel Core i5 和 NVIDIA GeForce RTX 2070 8GB 的机器上,训练大约需要 1 小时 54 分钟来运行 50 个 epoch。
练习 3
你有以下卷积块,用于实现图像分类器:
def my_conv_block(input, activation):
out_1 = tf.keras.layers.Conv2D(n_filters[0][2], (3,3), strides=(1,1),
kernel_initializer=initializer, padding='same')(input)
out_final = tf.keras.layers.BatchNormalization()(out_1)
out_final = tf.keras.layers.Activation(activation)(out_final)
return out_final
你想做以下两个改变:
-
在应用激活后引入批标准化
-
从卷积层的输出到批标准化层的输出创建一个残差连接。
7.3 如果你无法击败它们,就加入它们:使用预训练网络增强性能
到目前为止,你已经开发了一个很好的图像分类模型,它使用各种方法防止过拟合。公司一直很满意,直到你的老板宣布镇上出现了一个表现比你开发的模型更好的新竞争对手的消息。传言是他们有一个大约 70%准确率的模型。所以,你和你的同事又回到了起点。你相信一种特殊的技术,称为迁移学习,可以帮助。具体来说,你打算使用一个在原始 ImageNet 图像分类数据集上已经训练过的 Inception-ResNet v2 的预训练版本;在 tiny-imagenet-200 数据集上对这个模型进行微调将比到目前为止实现的所有模型都提供更高的准确性。
如果你想接近最新技术水平,你必须尽一切可能获得帮助。开始这个探索的一个好方法是使用预训练模型,然后针对你的任务进行微调。预训练模型是已经在类似任务上训练过的模型。这个过程属于迁移学习的概念。例如,你可以很容易找到在 ILSVRC 任务上预训练过的模型。
7.3.1 迁移学习:在深度神经网络中重用现有知识
迁移学习是一个庞大的话题,需要单独的章节(甚至一本书)来讨论。迁移学习有许多变体。要理解迁移学习的不同方面,请参考ruder.io/transfer-learning/。一种方法是使用预训练模型并针对要解决的任务进行微调。该过程如图 7.14 所示。
图 7.14 迁移学习的工作原理。首先,我们从一个在解决与我们感兴趣的任务类似/相关的较大数据集上预训练的模型开始。然后,我们传输模型权重(除了最后一层),并在现有权重之上拟合一个新的预测层。最后,我们在新任务上进行微调。
首先,你在一个你已经拥有大型标记数据集的任务上训练模型(称为预训练任务)。例如,在图像分类中,你有几个大型标记数据集,包括 ImageNet 数据集。一旦你在大型数据集上训练了一个模型,你就会得到网络的权重(除了最后的预测层),并拟合一个匹配新任务的新预测层。这给了网络解决新任务的一个非常好的起点。然后,你可以用较小的数据集解决新任务,因为你已经在类似的较大数据集上训练了模型。
我们如何使用迁移学习来解决我们的问题?这并不难。Keras 为图像分类任务提供了一个巨大的模型库(mng.bz/aJdo)。这些模型主要是在 ImageNet 图像分类任务上进行训练的。让我们驯服 Inception 网络系列中产生的野兽:Inception-ResNet v2。请注意,本节的代码可以在 Ch07-Improving-CNNs-and-Explaining/7.2.Transfer_Learning.ipynb 中找到。
Inception-ResNet v2
我们简要讨论了 Inception-ResNet v2 模型。这是最后一个生产的 Inception 模型。Inception-ResNet v2 具有以下特点,使其与其他 Inception 模型区别开来:
-
重新设计的起始模块,消除了任何表征瓶颈
-
使用残差连接的 Inception 块
-
减少输入高度/宽度维度的减少模块
-
不像早期的 Inception 网络那样使用辅助输出
正如你所看到的,Minception 模型中使用了重新设计的起始模块、Inception-ResNet 块和减少模块。如果你比较一下 Minception 的图表与原始论文中提供的图表,你会看到它们有多么相似。因此,我们不会重复讨论这些组件。如果你仍然想看到不同组件的具体细节和插图,请参考原始论文(arxiv.org/pdf/1602.07261.pdf)。然而,Inception-ResNet v2 的高层架构如下图所示。
Inception-ResNet v2 的整体架构
您可以使用一行代码下载 Inception-ResNet v2 模型:
InceptionResNetV2(include_top=False, pooling='avg')
这里 include_top=False 意味着最终的预测层将被丢弃。这是必要的,因为原始的 inception 网络是为 1000 类设计的。但是,我们只有 200 类。pooling=‘avg’ 确保模型中的最后一个汇合层是平均汇合层。接下来,我们将创建一个新模型,将预训练的 Inception-ResNet v2 模型作为核心,但修改为解决 Tiny ImageNet 分类任务,如下图所示。
列出了基于预训练 Inception-ResNet v2 模型的模型实现
from tensorflow.keras.applications import InceptionResNetV2 ❶
from tensorflow.keras.models import Sequential ❶
from tensorflow.keras.layers import Input, Dense, Dropout ❶
model = Sequential([
Input(shape=(224,224,3)), ❷
InceptionResNetV2(include_top=False, pooling='avg'), ❸
Dropout(0.4), ❹
Dense(200, activation='softmax') ❺
])
adam = tf.keras.optimizers.Adam(learning_rate=0.0001) ❻
model.compile(loss=’categorical_crossentropy’, optimizer=adam,
➥ metrics=['accuracy'])
model.summary()
❶ 一些重要的导入
❷ 为 224 × 224 的图像定义输入层
❸ Inception-ResNet v2 模型的预训练权重
❹ 应用了 40% 的 dropout
❺ 最终的预测层有 200 个类
❻ 由于网络已经在 ImageNet 数据上进行了训练,所以使用较小的学习率(经验选择)
在这里,我们定义了一个顺序模型,
-
首先定义了一个大小为 224 × 224 × 3 的输入层(即,高度 = 224,宽度 = 224,通道 = 3)
-
将 Inception-ResNet v2 模型定义为一个层
-
在最后一个平均汇合层上使用 40% 的 dropout
-
定义了一个使用 Softmax 激活的密集层,具有 200 个节点
我们需要应对的一个关键挑战是,原始的 Inception-ResNet v2 输入大小为 224 × 224 × 3。因此,我们需要找到一种方法来呈现我们的输入(即,64 × 64 × 3)以符合 Inception-ResNet v2 的要求。为了做到这一点,我们将对 ImageDataGenerator 进行一些更改,如下面的列表所示。
列出了生成 224 × 224 图像的修改版 ImageDataGenerator。
def get_train_valid_test_data_generators(batch_size, target_size):
image_gen_aug = ImageDataGenerator(
samplewise_center=False, rotation_range=30, width_shift_range=0.2,
height_shift_range=0.2, brightness_range=(0.5,1.5), shear_range=5,
zoom_range=0.2, horizontal_flip=True, validation_split=0.1
) ❶
image_gen = ImageDataGenerator(samplewise_center=False) ❶
partial_flow_func = partial( ❷
image_gen_aug.flow_from_directory,
directory=os.path.join('data','tiny-imagenet-200', 'train'),
target_size=target_size, ❸
classes=None,
class_mode='categorical',
interpolation='bilinear', ❹
batch_size=batch_size,
shuffle=True,
seed=random_seed)
# Get the training data subset
train_gen = partial_flow_func(subset='training') ❺
# Get the validation data subset
valid_gen = partial_flow_func(subset='validation') ❺
# Defining the test data generator
test_df = get_test_labels_df(os.path.join('data','tiny-imagenet-200',
➥ 'val', 'val_annotations.txt')) ❻
test_gen = image_gen.flow_from_dataframe(
test_df,
directory=os.path.join('data','tiny-imagenet-200', 'val', 'images'),
target_size=target_size, ❼
classes=None,
class_mode='categorical',
interpolation='bilinear', ❼
batch_size=batch_size,
shuffle=False
)
return train_gen, valid_gen, test_gen
batch_size = 32 ❽
target_size = (224,224) ❽
# Getting the train,valid, test data generators
train_gen, valid_gen, test_gen =
➥ get_train_valid_test_data_generators(batch_size, target_size)
train_gen_aux = data_gen_augmented(train_gen, random_gamma=True,
➥ random_occlude=True) ❾
valid_gen_aux = data_gen_augmented(valid_gen) ❾
test_gen_aux = data_gen_augmented(test_gen) ❾
❶ 定义了一个数据增广的图片数据生成器和一个标准的图片数据生成器
❷ 定义了部分函数以避免重复参数
❸ 使用 224 × 224 的目标大小
❹ 使用双线性插值使图像变大
❺ 定义了训练和验证集的数据生成器
❻ 定义了测试数据生成器
❼ 使用双线性插值的 224 × 224 目标大小
❽ 定义了批量大小和目标大小
❾ 使用 data_gen_augmented 函数获得训练集/验证集/测试集的修改过的数据生成器
最终,是时候展示我们最好的模型了:
from tensorflow.keras.callbacks import EarlyStopping, CSVLogger
es_callback = EarlyStopping(monitor='val_loss', patience=10)
csv_logger = CSVLogger(os.path.join('eval','4_eval_resnet_pretrained.log'))
n_epochs=30
lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
monitor='val_loss', factor=0.1, patience=5, verbose=1, mode='auto'
)
history = model.fit(
train_gen_aux, validation_data=valid_gen_aux,
steps_per_epoch=int(0.9*(500*200)/batch_size), validation_steps=int(0.1*(500*200)/batch_size),
epochs=n_epochs, callbacks=[es_callback, csv_logger, lr_callback]
)
训练将与先前训练 Miniception 模型所使用的训练配置相同。我们不会重复细节。我们使用以下内容:
-
记录指标
-
早期停止
-
学习率调整
注意,在一台配有 NVIDIA GeForce RTX 2070 8GB 的 Intel Core i5 机器上,训练 23 epoch 大约需要 9 小时 20 分钟。
您应该得到类似以下的结果:
Epoch 1/50
2813/2813 [==============================] - 1465s 521ms/step - loss:
➥ 2.0031 - accuracy: 0.5557 - val_loss: 1.5206 - val_accuracy: 0.6418
...
Epoch 23/50
2813/2813 [==============================] - ETA: 0s - loss: 0.1268 -
➥ accuracy: 0.9644
Epoch 00023: ReduceLROnPlateau reducing learning rate to
➥ 9.999999974752428e-08.
2813/2813 [==============================] - 1456s 518ms/step - loss:
➥ 0.1268 - accuracy: 0.9644 - val_loss: 1.2681 - val_accuracy: 0.7420
这不是好消息吗?通过结合我们所学的所有知识,我们已经达到了约 74% 的验证准确率。让我们快速看一下模型的测试准确率:
# Evaluate the model
test_res = model.evaluate(test_gen_aux, steps=get_steps_per_epoch(500*50,
➥ batch_size))
这应该显示大约 79% 的准确率。这不是一次轻松的旅程,但显然你已经超过了竞争对手的约 70% 准确率的模型。
在下一节中,我们将看看模型可解释性的重要性。我们将学习一种技术,可以用来解释嵌入模型的知识。
Inception-ResNet v2 对比 Minception
Minception 和 Inception-Resnet-v2 的茎在引入的创新方面是相同的(例如,Inception-ResNet 块,缩减块等)。然而,存在以下低级差异:
-
Inception-ResNet v2 有三种不同的 Inception 块类型;Minception 只有两种。
-
Inception-ResNet v2 有两种不同类型的缩减块;Minception 只有一个。
-
Inception-ResNet v2 有 25 个 Inception 层,但我们实现的 Minception(版本)只有三个。
还有其他一些小的差异,例如 Inception-ResNet v2 在模型的几个层中使用有效填充。如果你想了解详情,请参阅 Inception-ResNet v2 论文。另一个值得注意的观察是,Minception 和 Inception-ResNet v2 都没有使用局部响应归一化(LRN),因为它们使用了更强大的东西:批归一化。
练习 4
你想使用另一个名为 VGGNet(16 层)的不同预训练网络实现一个网络。你可以从 tf.keras.applications.VGG16 获取预训练网络。接下来,你丢弃顶层并在顶部引入一个最大池化层。然后你想在预训练网络的顶部添加两个具有 100(ReLU 激活)和 50(Softmax 激活)个节点的稠密层。实现这个网络。
7.4 Grad-CAM: 让 CNN 供认
公司对你为他们所做的一切感到非常高兴。你成功地建立了一个不仅击败了竞争对手的性能,而且是生产中最好的模型。然而,你的老板希望在发布任何消息之前确认模型是可信的。仅准确性是不够的!你决定演示模型如何进行预测,使用一种名为Grad-CAM的最新模型解释技术。Grad-CAM 使用相对于模型预测而生成的给定输入的梯度的大小来提供模型关注的可视化。图像中某一区域的梯度大小较大意味着图像更关注该区域。通过将梯度大小叠加成热图,你能够产生一个有吸引力的可视化,显示模型在给定输入中关注的内容。
Grad-CAM(梯度类激活映射)是由 Ramprasaath R. Selvaraju 等人在“Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization”中为深度神经网络引入的一种模型解释技术(arxiv.org/pdf/1610.02391.pdf)。深度网络因其不可解释性而臭名昭著,因此被称为黑匣子。因此,我们必须进行一些分析,确保模型正常运行。
下面的代码详细说明了 Grad-CAM 如何发挥其作用,并且实现代码在笔记本 Ch07-Improving-CNNs-and-Explaining/7.3 .Interpreting_CNNs_GradCAM.ipynb 中可用。为了节省本章的篇幅,我们将仅讨论该方法的伪代码,技术细节留给附录 B(参见下一个清单)。
清单 7.14 Grad-CAM 计算的伪代码
Define: model (Trained Inception Resnet V2 model)
Define: probe_ds (A list of image, class(integer) tuples e.g. [(image,
➥ class-int), (image, class-int), ...]) that we will use to interpret the model
Define: last_conv (Last convolution layer of the model - closest to the
➥ prediction layer)
Load the model (inceptionnet_resnet_v2.h5)
For img, cls in probe_ds:
# Computing the gradient map and its associated weights
Compute the model’s final output (out) and last_conv layer’s output
➥ (conv_out)
Compute the gradient d (out[cls]) / d (conv_out) and assign to grad
Compute channel weights by taking the mean of grad over width and
➥ height dimensions (Results in a [batch size(=1), 1, 1, # channels in
➥ last_conv] tensor)
# Creating the final gradient heatmap
grad = grad * weights # Multiply grad with weights
grad = tf.reduce_sum(grad, axis=-1) # Take sum over channels
grad = tf.nn.relu(grad) # Apply ReLU activation to obtain the gradient
➥ heatmap
# Visualizing the gradient heatmap
Resize the gradient heatmap to a size of 224x224
Superimpose the gradient heatmap on the original image (img)
Plot the image and the image with the gradient heatmap superimposed
➥ side by side
Grad-CAM 执行的关键计算是,给定输入图像,计算与图像真实类别相对应的节点对模型的最后卷积输出的梯度。
图像中每个像素的梯度大小代表了该像素对最终结果的贡献。因此,通过将 Grad-CAM 输出表示为热图,调整大小以匹配原始图像,并将其叠加在原始图像上,你可以获得一个非常引人注目和信息丰富的图,显示了模型关注的不同对象的位置。这些图解释性强,显示了模型是否专注于正确的对象以产生期望的预测。在图 7.15 中,我们展示了模型强烈关注的区域(红色/黑色 = 最高关注,蓝色/浅色 = 较少关注)。
图 7.15 展示了几个探测图像的 Grad-CAM 输出的可视化。图像中越红/越暗的区域,模型对该部分的关注越多。你可以看到我们的模型已经学会了理解一些复杂的场景,并将其需要关注的模型分开。
图 7.15(即 Grad-CAM 的可视化)显示了我们的模型确实是一个智能模型。它知道在混乱的环境中(例如,对餐桌进行分类)要关注哪些地方以找到给定的对象。如前所述,区域越红/越暗,模型就越专注于该区域进行预测。现在是时候向你的老板展示结果,建立必要的信心,公开新模型了!
我们将在此结束对图像分类的讨论。我们已经学习了许多可以有效解决问题的不同模型和技术。在下一章中,我们将讨论计算机视觉的另一个方面,即图像分割。
总结
-
图像增强、dropout 和提前停止是在视觉深度网络中防止过拟合的一些常见技术。
-
大多数常见的图像增强步骤可以通过 Keras ImageDataGenerator 实现。
-
对于所选问题,重要的是要注意所选择模型的架构。我们不应随意选择一个架构,而是要研究并确定一个在类似问题上已经奏效的架构。否则,可以通过超参数优化来选择架构。Minception 模型的架构已经被证明在我们本章使用的相同数据上表现良好。
-
迁移学习使我们能够利用已经训练好的模型来解决新任务,从而获得更好的准确性。
-
在 Keras 中,你可以用一行代码获得给定模型,并将其调整到新的任务中。
-
在
mng.bz/M5Oo上有各种预训练网络可供选择。 -
Grad-CAM(梯度类激活映射)是解释你的 CNN 的有效方法。
-
根据模型对预测产生的梯度大小,Grad-CAM 计算出模型关注最多的地方。
练习答案
练习 1
-
如果出现欠拟合,你应该降低 dropout 率,以保持更多节点在训练过程中保持开启状态:
model = tf.keras.models.Sequential([ tf.keras.layers.Dense(100, activation=’relu’, input_shape=(250,)), tf.keras.layers.Dropout(0.2), tf.keras.layers.Dense(10, activation=’softmax’) ]) model.compile(loss=’categorical_crossentropy’, optimizer=’adam’, ➥ metrics=[‘accuracy’]) model.fit(X, y, epochs=25) -
提前停止是通过使用 EarlyStopping 回调引入的:
es_callback = tf.keras.callbacks.EarlyStopping(monitor='val_loss', ➥ patience=5, min_delta=0.1) model.fit(X, y, epochs=25, callbacks=[es_callback])
练习 2
tf.keras.callbacks.EarlyStopping(monitor='val_loss', min_delta=0.01, patience=5)
练习 3
def my_conv_block(input, activation):
out_1 = tf.keras.layers.Conv2D(n_filters[0][2], (3,3), strides=(1,1),
kernel_initializer=initializer, activation=activation,
padding='same')(input)
out_final = tf.keras.layers.BatchNormalization()(out_1)
out = out_final + out_1
return out
练习 4
model = tf.keras.models.Sequential([
tf.keras.layers.Input(shape=(224,224,3)),
tf.keras.applications.VGG16(include_top=False, pooling='max'),
tf.keras.layers.Dense(100, activation=’relu’),
tf.keras.layers.Dense(50, activation='softmax')
])
第八章:区分事物:图像分割
本章内容涵盖
-
了解分割数据并在 Python 中处理它
-
实现一个完整的分割数据管道
-
实现高级分割模型(DeepLab v3)
-
使用自定义构建的图像分割损失函数/度量编译模型
-
对清洁和处理后的图像数据进行图像分割模型训练
-
评估经过训练的分割模型
在上一章中,我们学习了各种先进的计算机视觉模型和技术,以提高图像分类器的性能。我们了解了 Inception net v1 的架构以及它的后继者(例如 Inception net v2、v3 和 v4)。我们的目标是提高模型在一个包含 200 个不同类别的对象的 64×64 大小的 RGB 图像的图像分类数据集上的性能。在尝试在此数据集上训练模型时,我们学到了许多重要的概念:
-
Inception blocks—一种将具有不同尺寸窗口(或核)的卷积层分组在一起的方法,以鼓励学习不同尺度的特征,同时由于更小尺寸的核而使模型参数高效。
-
辅助输出—Inception net 不仅在网络末端使用分类层(即具有 softmax 激活的完全连接层),而且还在网络中间使用。这使得从最终层到第一层的梯度能够强劲地传播。
-
数据增强—使用各种图像转换技术(调整亮度/对比度、旋转、平移等)使用 tf.keras.preprocessing.image.ImageDataGenerator 增加标记数据的数量。
-
Dropout—随机打开和关闭层中的节点。这迫使神经网络学习更健壮的特征,因为网络并不总是激活所有节点。
-
提前停止—使用验证数据集上的性能作为控制训练何时停止的方法。如果在一定数量的 epochs 中验证性能没有提高,则停止训练。
-
迁移学习—下载并使用在更大、类似数据集上训练的预训练模型(例如 Inception-ResNet v2)作为初始化,并对其进行微调以在手头的任务上表现良好。
在本章中,我们将学习计算机视觉中另一个重要任务:图像分割。在图像分类中,我们只关心给定图像中是否存在对象。另一方面,图像分割不仅识别同一图像中的多个对象,还识别它们在图像中的位置。这是计算机视觉的一个非常重要的主题,像自动驾驶汽车这样的应用程序依赖于图像分割模型。自动驾驶汽车需要精确定位其周围的物体,这就是图像分割发挥作用的地方。你可能已经猜到,它们在许多其他应用程序中也有它们的根基:
-
图像检索
-
识别星系 (
mng.bz/gwVx) -
医学图像分析
如果您是从事与图像相关问题的计算机视觉/深度学习工程师/研究人员,您的道路很可能会与图像分割相交。图像分割模型将图像中的每个像素分类为预定义的一组对象类别之一。图像分割与我们之前看到的图像分类任务有关。两者都解决了一个分类任务。此外,预训练的图像分类模型被用作分割模型的骨干,因为它们可以提供不同粒度的关键图像特征,以更好更快地解决分割任务。一个关键区别是图像分类器解决了一个稀疏预测任务,其中每个图像都有一个与之关联的单个类标签,而分割模型解决了一个密集预测任务,其中图像中的每个像素都有一个与之关联的类标签。
任何图像分割算法都可以分类为以下类型之一:
-
语义分割—该算法仅对图像中存在的不同类别的对象感兴趣。例如,如果图像中有多个人,则与所有人对应的像素将被标记为相同的类。
-
实例分割—该算法对单独识别不同对象感兴趣。例如,如果图像中有多个人,属于每个人的像素将被表示为唯一的类。与语义分割相比,实例分割被认为更难。
图 8.1 描述了语义分割任务中找到的数据与实例分割任务中找到的数据之间的区别。在本章中,我们将重点关注语义分割 (mng.bz/5QAZ)。
图 8.1 语义分割与实例分割的比较
在下一节中,我们将更仔细地研究我们正在处理的数据。
8.1 理解数据
您正在尝试一个创业想法。这个想法是为小型遥控(RC)玩具开发一种导航算法。用户可以选择导航需要多安全或者冒险。作为第一步,您计划开发一个图像分割模型。图像分割模型的输出将稍后馈送到另一个模型,该模型将根据用户的请求预测导航路径。
对于这个任务,您觉得 Pascal VOC 2012 数据集会是一个很好的选择,因为它主要包含了城市/家庭环境中的室内和室外图像。它包含图像对:一个包含一些对象的输入图像和一个带有注释的图像。在注释图像中,每个像素都有一个分配的颜色,取决于该像素属于哪个对象。在这里,您计划下载数据集并成功将数据加载到 Python 中。
在深入了解/界定您想解决的问题之后,下一个重点应该是了解和探索数据。分割数据与我们迄今为止见过的图像分类数据集不同。一个主要的区别是输入和目标都是图像。输入图像是一个标准图像,类似于您在图像分类任务中找到的图像。与图像分类不同,目标不是标签,而是图像,其中每个像素都有来自预定义颜色调色板的颜色。换句话说,我们感兴趣的每个对象都被分配了一种颜色。然后,在输入图像中对应于该对象的像素以该颜色着色。可用颜色的数量与您想要识别的不同对象(加上背景)的数量相同(图 8.2)。
图 8.2 图像分类器与图像分割模型的输入和输出
对于这个任务,我们将使用流行的 PASCAL VOC 2012 数据集,该数据集由真实场景组成。数据集为 22 个不同类别提供了标签,如表 8.1 所述。
表 8.1 PASCAL VOC 2012 数据集中的不同类别及其相应的标签
| 类别 | 指定标签 | 类别 | 指定标签 |
|---|---|---|---|
| 背景 | 0 | 餐桌 | 11 |
| 飞机 | 1 | 狗 | 12 |
| 自行车 | 2 | 马 | 13 |
| 鸟 | 3 | 摩托车 | 14 |
| 船 | 4 | 人 | 15 |
| 瓶子 | 5 | 盆栽植物 | 16 |
| 公共汽车 | 6 | 羊 | 17 |
| 汽车 | 7 | 沙发 | 18 |
| 猫 | 8 | 火车 | 19 |
| 椅子 | 9 | 电视/显示器 | 20 |
| 牛 | 10 | 边界/未知对象 | 255 |
白色像素代表对象边界或未知对象。图 8.3 通过显示每个单独的对象类别的样本来说明数据集。
图 8.3 PASCAL VOC 2012 数据集的样本。数据集显示了单个示例图像,以及用于 20 种不同对象类别的注释分割。
在图 8.4 中,深入挖掘一下,你可以近距离看到单个样本数据点(最好是以彩色视图查看)。它有两个对象:一把椅子和一只狗。正如所示,不同的颜色分配给不同的对象类别。虽然最好以彩色查看图像,但您仍然可以通过注意在图像中勾勒对象的白色边框来区分不同的对象。
图 8.4 图像分割中的原始输入图像及其相应的目标标注/分割图像
首先,我们将从mng.bz/6XwZ下载数据集(请参阅下一个清单)。
清单 8.1 下载数据
import os
import requests
import tarfile
# Retrieve the data
if *not* os.path.exists(os.path.join('data','VOCtrainval_11-May-2012.tar')): ❶
url = "http:/ /host.robots.ox.ac.uk/pascal/VOC/voc2012/VOCtrainval_11-
➥ May-2012.tar"
# Get the file from web
r = requests.get(url) ❷
if *not* os.path.exists('data'):
os.mkdir('data')
# Write to a file
with open(os.path.join('data','VOCtrainval_11-May-2012.tar'), 'wb') as f:
f.write(r.content) ❸
else:
print("The tar file already exists.")
if *not* os.path.exists(os.path.join('data', 'VOCtrainval_11-May-2012')): ❹
with tarfile.open(os.path.join('data','VOCtrainval_11-May-2012.tar'), 'r') as tar:
tar.extractall('data')
else:
print("The extracted data already exists")
❶ 检查文件是否已经下载。如果已下载,则不要重新下载。
❷ 从 URL 获取内容。
❸ 将文件保存到磁盘。
❹ 如果文件存在但尚未提取,则提取文件。
数据集的下载与我们以前的经验非常相似。数据作为 tar 文件存在。如果文件不存在,我们会下载文件并解压缩。接下来,我们将讨论如何使用图像库 Pillow 和 NumPy 将图像加载到内存中。在这里,目标图像将需要特殊处理,因为您将看到它们不是使用常规方法存储的。加载输入图像到内存中没有任何意外情况。使用 PIL(即 Pillow)库,可以通过一行代码加载它们:
from PIL import Image
orig_image_path = os.path.join('data', 'VOCtrainval_11-May-2012',
➥ 'VOCdevkit', 'VOC2012', 'JPEGImages', '2007_000661.jpg')
orig_image = Image.open(orig_image_path)
接下来,您可以检查图像的属性:
print("The format of the data {}".format(orig_image.format))
>>> The format of the data JPEG
print("This image is of size: {}".format(orig_image.shape))
>>> This image is of size: (375, 500, 3)
是时候加载相应的注释/分割的目标图像了。如前所述,目标图像需要特殊关注。目标图像不是作为标准图像存储的,而是作为调色板化图像存储的。调色板化是一种在图像中存储具有固定颜色数量的图像时减少内存占用的技术。该方法的关键在于维护一个颜色调色板。调色板被存储为整数序列,其长度为颜色数量或通道数量(例如,对于 RGB 的情况,一个像素由三个值对应于红、绿和蓝,通道数量为三。灰度图像具有单个通道,其中每个像素由单个值组成)。然后,图像本身存储了一个索引数组(大小为高度×宽度),其中每个索引映射到调色板中的一种颜色。最后,通过将图像中的调色板索引映射到调色板颜色,可以计算出原始图像。图 8.5 提供了这个讨论的视觉展示。
图 8.5 显示了 PASCAL VOC 2012 数据集中输入图像和目标图像的数值表示。
下一个清单展示了从调色板图像中重新构造原始图像像素的代码。
代码清单 8.2 从调色板图像中重建原始图像
def rgb_image_from_palette(image):
""" This function restores the RGB values form a palletted PNG image """
palette = image.get_palette() ❶
palette = np.array(pallette).reshape(-1,3) ❷
if isinstance(image, PngImageFile):
h, w = image.height, image.width ❸
# Squash height and width dimensions (makes slicing easier)
image = np.array(image).reshape(-1) ❹
elif isinstance(image, np.ndarray): ❺
h, w = image.shape[0], image.shape[1]
image = image.reshape(-1)
rgb_image = np.zeros(shape=(image.shape[0],3)) ❻
rgb_image[(image != 0),:] = pallette[image[(image != 0)], :] ❻
rgb_image = rgb_image.reshape(h, w, 3) ❼
return rgb_image
❶ 从图像中获取颜色调色板。
❷ 调色板以向量形式存储。我们将其重新整形为一个数组,其中每一行表示一个单独的 RGB 颜色。
❸ 获取图像的高度和宽度。
❹ 将以数组形式存储的调色板图像转换为向量(有助于接下来的步骤)。
❺ 如果图像是以数组而不是 Pillow 图像提供的,将图像作为向量获取。
❻ 首先,我们定义一个与图像长度相同的零向量。然后,对于图像中的所有索引,我们从调色板中获取相应的颜色,并将其分配到 rgb_image 的相同位置。
❼ 恢复原始形状。
在这里,我们首先使用 get_palette()函数获取图像的调色板。这将作为一个一维数组存在(长度为类别数×通道数)。接下来,我们需要将数组重塑为一个(类别数,通道数)大小的数组。在我们的情况下,这将转换为一个(22,3)大小的数组。由于我们将重塑的第一维定义为-1,它将从原始数据的大小和重塑操作的其他维度中自动推断出来。最后,我们定义一个全零数组,它最终将存储图像中找到的索引的实际颜色。为此,我们使用图像(包含索引)索引 rgb_image 向量,并将调色板中匹配的颜色分配给这些索引。
利用我们迄今为止看到的数据,让我们定义一个 TensorFlow 数据管道,将数据转换和转换为模型可接受的格式。
练习 1
你已经提供了一个以 RGB 格式表示的 rgb_image,其中每个像素属于 n 种独特的颜色之一,并且已经给出了一个称为调色板的调色板,它是一个[n,3]大小的数组。你将如何将 rgb_image 转换为调色板图像?
提示 你可以通过使用三个 for 循环来创建一个简单的解决方案:两个循环用于获取 rgb_image 的单个像素,然后最后一个循环用于遍历调色板中的每种颜色。
8.2 认真对待:定义一个 TensorFlow 数据管道
到目前为止,我们已经讨论了将帮助我们为 RC 玩具构建导航算法的数据。在构建模型之前,一个重要的任务是完成从磁盘到模型的可扩展数据摄取方法。提前完成这项工作将节省大量时间,当我们准备扩展或投产时。你认为最好的方法是实现一个 tf.data 管道,从磁盘中检索图像,对它们进行预处理、转换,并使其准备好供模型获取。该管道应该读取图像,将它们重塑为固定大小(对于变尺寸图像),对它们进行数据增强(在训练阶段),分批处理它们,并为所需的 epoch 数重复此过程。最后,我们将定义三个管道:一个训练数据管道,一个验证数据管道和一个测试数据管道。
在数据探索阶段结束时,我们的目标应该是建立一个从磁盘到模型的可靠数据管道。这就是我们将在这里看到的。从高层次来看,我们将建立一个 TensorFlow 数据管道,执行以下任务:
-
获取属于某个子集(例如,训练、验证或测试)的文件名。
-
从磁盘中读取指定的图像。
-
预处理图像(包括对图像进行归一化/调整大小/裁剪)。
-
对图像执行增强以增加数据量。
-
将数据分批处理成小批次。
-
使用几种内置优化技术优化数据检索。
作为第一步,我们将编写一个函数,返回一个生成器,该生成器将生成我们要获取的数据的文件名。我们还将提供指定用户想要获取的子集(例如,训练、验证或测试)的能力。通过生成器返回数据将使编写tf.data流水线更容易(参见下面的代码清单)。
图 8.3 检索给定数据子集的文件名列表
def get_subset_filenames(orig_dir, seg_dir, subset_dir, subset):
""" Get the filenames for a given subset (train/valid/test)"""
if subset.startswith('train'):
ser = pd.read_csv( ❶
os.path.join(subset_dir, "train.txt"),
index_col=None, header=None, squeeze=True
).tolist()
elif subset.startswith('val') or subset.startswith('test'):
random.seed(random_seed) ❷
ser = pd.read_csv( ❸
os.path.join(subset_dir, "val.txt"),
index_col=None, header=None, squeeze=True
).tolist()
random.shuffle(ser) ❹
if subset.startswith('val'):
ser = ser[:len(ser)//2] ❺
else:
ser = ser[len(ser)//2:] ❻
else:
raise NotImplementedError("Subset={} is not recognized".format(subset))
orig_filenames = [os.path.join(orig_dir,f+'.jpg') for f in ser] ❼
seg_filenames = [os.path.join(seg_dir, f+'.png') for f in ser] ❽
for o, s in zip(orig_filenames, seg_filenames):
yield o, s ❾
❶ 读取包含训练实例文件名的 CSV 文件。
❷ 对验证/测试子集执行一次洗牌,以确保我们使用固定的种子得到良好的混合。
❸ 读取包含验证/测试文件名的 CSV 文件。
❹ 修复种子后对数据进行洗牌。
❺ 将第一半部分作为验证集。
❻ 将第二半部分作为测试集。
❼ 形成我们捕获的输入图像文件的绝对路径(取决于子集参数)。
❽ 将文件名对(输入和注释)作为生成器返回。
❾ 形成分段图像文件的绝对路径。
您可以看到,在读取 CSV 文件时我们传递了一些参数。这些参数描述了我们正在读取的文件。这些文件非常简单,每行只包含一个图像文件名。index_col=None表示文件没有索引列,header=None表示文件没有标题,squeeze=True表示输出将被呈现为 pandas Series,而不是 pandas Dataframe。有了这些,我们可以定义一个 TensorFlow 数据集(tf.data.Dataset),如下所示:
filename_ds = tf.data.Dataset.from_generator(
subset_filename_gen_func, output_types=(tf.string, tf.string)
)
TensorFlow 有几个不同的函数,用于使用不同的来源生成数据集。由于我们已经定义了函数get_subset_filenames()来返回一个生成器,我们将使用tf.data.Dataset.from_generator()函数。注意,我们需要提供返回数据的格式和数据类型,通过生成器使用output_types参数。函数subset_filename_gen_func返回两个字符串;因此,我们将输出类型定义为两个tf.string元素的元组。
另一个重要方面是我们根据子集从不同的 txt 文件中读取的情况。在相对路径中有三个不同的文件:data\VOCtrainval_11-May-2012\VOCdevkit\VOC2012\ImageSets\Segmentation 文件夹;train.txt、val.txt 和 trainval.txt。在这里,train.txt 包含训练图像的文件名,而 val.txt 包含验证/测试图像的文件名。我们将使用这些文件创建不同的流水线,产生不同的数据。
tf.data 是从哪里来的?
TensorFlow 的tf.data流水线可以从各种来源消耗数据。以下是一些常用的检索数据的方法:
tf.data.Dataset.from_generator(gen_fn) ——你已经在实际操作中见过这个函数。如果你有一个生成器(即 gen_fn)产生数据,你希望它通过一个 tf.data 流水线进行处理。这是使用的最简单的方法。
tf.data.Dataset.from_tensor_slices(t)——如果你已经将数据加载为一个大矩阵,这是一个非常有用的函数。t 可以是一个 N 维矩阵,这个函数将在第一个维度上逐个元素地提取。例如,假设你已经将一个大小为 3 × 4 的张量 t 加载到内存中:
t = [ [1,2,3,4],
[2,3,4,5],
[6,7,8,9] ]
然后,你可以轻松地设置一个 tf.data 管道,如下所示。tf.data.Dataset.from_tensor_slices(t) 将返回 [1,2,3,4],然后 [2,3,4,5],最后 [6,7,8,9] 当你迭代这个数据管道时。换句话说,你每次看到一行(即从批处理维度中切片,因此称为 from_tensor_slices)。
现在是时候读取我们在上一步获取的文件路径中找到的图像了。TensorFlow 提供了支持,可以轻松加载图像,其中文件名路径为 img_filename,使用函数 tf.io.read_file 和 tf.image.decode_image。在这里,img_filename 是一个 tf.string(即 TensorFlow 中的字符串):
tf.image.decode_jpeg(tf.io.read_file(image_filename))
我们将使用这种模式来加载输入图像。然而,我们需要实现一个自定义图像加载函数来加载目标图像。如果你使用前面的方法,它将自动将图像转换为具有像素值的数组(而不是调色板索引)。但如果我们不执行该转换,我们将得到一个精确符合我们需要的格式的目标数组,因为目标图像中的调色板索引是输入图像中每个对应像素的实际类标签。我们将在 TensorFlow 数据管道中使用 PIL.Image 来加载图像作为调色板图像,并避免将其转换为 RGB:
from PIL import Image
def load_image_func(image):
""" Load the image given a filename """
img = np.array(Image.open(image))
return img
然而,你还不能将自定义函数作为 tf.data 管道的一部分使用。它们需要通过将其包装为 TensorFlow 操作来与数据管道的数据流图相协调。这可以通过使用 tf.numpy_function 操作轻松实现,它允许你将返回 NumPy 数组的自定义函数包装为 TensorFlow 操作。如果我们用 y 表示目标图像的文件路径,你可以使用以下代码将图像加载到 TensorFlow 中并使用自定义图像加载函数:
tf.numpy_function(load_image_func, inp=[y], Tout=[tf.uint8])
tf.numpy_function 的黑暗面
NumPy 对各种科学计算有比 TensorFlow 更广泛的覆盖,所以你可能会认为 tf.numpy_function 让事情变得非常方便。但事实并非如此,因为你可能会在 TensorFlow 代码中引入可怕的性能下降。当 TensorFlow 执行 NumPy 代码时,它可能会创建非常低效的数据流图并引入开销。因此,尽量坚持使用 TensorFlow 操作,并且只在必要时使用自定义的 NumPy 代码。在我们的情况下,由于没有其他方法可以加载调色板图像而不将调色板值映射到实际的 RGB,我们使用了一个自定义函数。
请注意,我们将输入(即,inp=[y])和其数据类型(即,Tout=[tf.uint8])都传递给此函数。它们都需要以 Python 列表的形式存在。最后,让我们把我们讨论的所有内容都整理到一个地方:
def load_image_func(image):
""" Load the image given a filename """
img = np.array(Image.open(image))
return img
# Load the images from the filenames returned by the above step
image_ds = filename_ds.map(lambda x,y: (
tf.image.decode_jpeg(tf.io.read_file(x)),
tf.numpy_function(load_image_func, [y], [tf.uint8])
))
tf.data.Dataset.map() 函数将在本讨论中大量使用。您可以在侧边栏中找到 map() 函数的详细解释。
刷新器:tf.data.Dataset.map() 函数
此 tf.data 管道将大量使用 tf.data.Dataset.map() 函数。因此,我们提醒自己此函数实现了什么功能是非常有帮助的。
td.data.Dataset.map() 函数将给定的函数或多个函数应用于数据集中的所有记录。换句话说,它使用指定的转换来转换数据集中的数据点。例如,假设 tf.data.Dataset
dataset = tf.data.Dataset.from_tensor_slices([1, 2, 3, 4])
要获取每个元素的平方,可以使用 map 函数如下
dataset = dataset.map(lambda x: x**2)
如果在单个记录中有多个元素,则可以利用 map()的灵活性来分别转换它们:
dataset = tf.data.Dataset.from_tensor_slices([[1,3], [2,4], [3,5], [4,6]])
dataset = dataset.map(lambda x, y: (x**2, y+x))
which will return,
[[1, 4], [4, 6], [9, 8], [16, 10]]
作为规范化步骤,我们将通过使用将像素值带到 [0,1] 范围的方法
image_ds = image_ds.map(lambda x, y: (tf.cast(x, 'float32')/255.0, y))
请注意,我们保留了目标图像(y)。在我们的管道中继续进行更多步骤之前,我想引起您的注意。这是一个相当常见的警告,因此值得注意。在我们刚刚完成的步骤之后,您可能会觉得,如果您愿意,您可以将数据进行批处理并将其馈送到模型中。例如
image_ds = image_ds.batch(10)
如果您对此数据集进行此操作,将会收到以下错误:
InvalidArgumentError: Cannot batch tensors with different shapes in
➥ component 0\. First element had shape [375,500,3] and element 1 had
➥ shape [333,500,3]. [Op:IteratorGetNext]
这是因为您忽略了数据集的一个关键特征和一个健全性检查。除非您使用的是经过筛选的数据集,否则您不太可能找到具有相同尺寸的图像。如果您查看数据集中的图像,您会注意到它们的尺寸不同;它们具有不同的高度和宽度。在 TensorFlow 中,除非您使用像 tf.RaggedTensor 这样的特殊数据结构,否则无法将大小不同的图像一起进行批处理。这正是 TensorFlow 在错误中抱怨的内容。
为了缓解问题,我们需要将所有图像调整为标准大小(请参见列表 8.4)。为此,我们将定义以下函数。它将
-
将图像调整为较大的尺寸(resize_to_before_crop),然后将图像裁剪为所需大小(input_size),或者
-
将图像调整为所需大小(input_size)
列表 8.4 使用随机裁剪或调整大小将图像调整为固定大小
def randomly_crop_or_resize(x,y):
""" Randomly crops or resizes the images """
def rand_crop(x, y): ❶
""" Randomly crop images after enlarging them """
x = tf.image.resize(x, resize_to_before_crop, method='bilinear') ❷
y = tf.cast( ❸
tf.image.resize(
tf.transpose(y,[1,2,0]), ❹
resize_to_before_crop, method='nearest'
),
'float32'
)
offset_h = tf.random.uniform(
[], 0, x.shape[0]-input_size[0], dtype='int32'
) ❺
offset_w = tf.random.uniform(
[], 0, x.shape[1]-input_size[1], dtype='int32'
) ❻
x = tf.image.crop_to_bounding_box(
image=x,
offset_height=offset_h, offset_width=offset_w,
target_height=input_size[0], target_width=input_size[1] ❼
)
y = tf.image.crop_to_bounding_box(
image=y,
offset_height=offset_h, offset_width=offset_w,
target_height=input_size[0], target_width=input_size[1] ❼
)
return x, y
def resize(x, y):
""" Resize images to a desired size """
x = tf.image.resize(x, input_size, method='bilinear') ❽
y = tf.cast(
tf.image.resize(
tf.transpose(y,[1,2,0]),
input_size, method='nearest' ❽
),
'float32'
)
return x, y
rand = tf.random.uniform([], 0.0,1.0) ❾
if augmentation and \ ❿
(input_size[0] < resize_to_before_crop[0] or \
input_size[1] < resize_to_before_crop[1]):
x, y = tf.cond(
rand < 0.5, ⓫
lambda: rand_crop(x, y),
lambda: resize(x, y)
)
else:
x, y = resize(x, y) ⓬
return x, y
❶ 定义一个函数,在调整大小后随机裁剪图像。
❷ 使用双线性插值将输入图像调整为较大的尺寸。
❸ 使用最近邻插值将目标图像调整为较大的尺寸。
❹ 要调整大小,我们首先交换 y 轴的轴,因为它的形状为 [1, height, width]。我们使用 tf.transpose() 函数将其转换回 [height, width, 1](即,单通道图像)。
❺ 定义一个随机变量,在裁剪期间偏移图像的高度。
❻ 定义一个随机变量,在裁剪期间在宽度上对图像进行偏移。
❼ 使用相同的裁剪参数裁剪输入图像和目标图像。
❽ 将输入图像和目标图像都调整为所需大小(不裁剪)。
❾ 定义一个随机变量(用于执行增强)。
❿ 如果启用增强并且调整大小后的图像大于我们请求的输入大小,则执行增强。
⓫ 在增强期间,随机执行 rand_crop 或 resize 函数。
⓬ 如果禁用增强,则只调整大小。
这里,我们定义了一个名为 randomly_crop_or_resize 的函数,其中包含两个嵌套函数 rand_crop 和 resize。rand_crop 首先将图像调整为 resize_to_before_crop 中指定的大小,并创建一个随机裁剪。务必检查是否对输入和目标应用了完全相同的裁剪。例如,应使用相同的裁剪参数对输入和目标进行裁剪。为了裁剪图像,我们使用
x = tf.image.crop_to_bounding_box(
image=x,
offset_height=offset_h, offset_width=offset_w,
target_height=input_size[0], target_width=input_size[1]
)
y = tf.image.crop_to_bounding_box(
image=y,
offset_height=offset_h, offset_width=offset_w,
target_height=input_size[0], target_width=input_size[1]
)
参数的含义不言而喻:image 接受要裁剪的图像,offset_height 和 offset_width 决定裁剪的起点,target_height 和 target_width 指定裁剪后的最终大小。resize 函数将使用 tf.image.resize 操作简单地将输入和目标调整为指定大小。
在调整大小时,我们对输入图像使用双线性插值,对目标使用最近邻插值。双线性插值通过计算结果像素的邻近像素的平均值来调整图像大小,而最近邻插值通过从邻居中选择最近的常见像素来计算输出像素。双线性插值在调整大小后会导致更平滑的结果。然而,必须对目标图像使用最近邻插值,因为双线性插值会导致分数输出,破坏基于整数的注释。图 8.6 可视化了所描述的插值技术。
图 8.6 最近邻插值和双线性插值用于上采样和下采样任务
接下来,我们将在使用这两个嵌套函数的方式上引入一个额外的步骤。如果启用了增强,我们希望裁剪或调整大小在管道中随机地发生。我们将定义一个随机变量(从介于 0 和 1 之间的均匀分布中抽取)并根据随机变量的值在给定时间内执行裁剪或调整大小。可以使用 tf.cond 函数实现这种条件,该函数接受三个参数,并根据这些参数返回输出:
-
Condition——这是一个计算结果为布尔值的计算(即随机变量 rand 是否大于 0.5)。
-
true_fn——如果条件为真,则执行此函数(即对 x 和 y 执行 rand_crop)
-
false_fn——如果条件为假,则执行此函数(即对 x 和 y 执行调整大小)
如果禁用了增强(即通过将augmentation变量设置为False),则仅执行调整大小操作。详细信息澄清后,我们可以在我们的数据管道中使用randomly_crop_or_resize函数如下:
image_ds = image_ds.map(lambda x,y: randomly_crop_or_resize(x,y))
此时,我们的管道中出现了一个全局固定大小的图像。接下来我们要处理的事情非常重要。诸如图像大小可变和用于加载图像的自定义 NumPy 函数等因素使得 TensorFlow 在几个步骤之后无法推断其最终张量的形状(尽管它是一个固定大小的张量)。如果您检查此时产生的张量的形状,您可能会将它们视为
(None, None, None)
这意味着 TensorFlow 无法推断张量的形状。为了避免任何歧义或问题,我们将设置管道中输出的形状。对于张量t,如果形状不明确但您知道形状,您可以使用手动设置形状
t.set_shape([<shape of the tensor>])
在我们的数据管道中,我们可以设置形状为
def fix_shape(x, y, size):
""" Set the shape of the input/target tensors """
x.set_shape((size[0], size[1], 3))
y.set_shape((size[0], size[1], 1))
return x, y
image_ds = image_ds.map(lambda x,y: fix_shape(x,y, target_size=input_size))
我们知道跟随调整大小或裁剪的输出将会是
-
输入图像 —— 一个具有
input_size高度和宽度的 RGB 图像 -
目标图像 —— 一个具有
input_size高度和宽度的单通道图像
我们将使用tf.data.Dataset.map()函数相应地设置形状。不能低估数据增强的威力,因此我们将向我们的数据管道引入几个数据增强步骤(见下一篇列表)。
列表 8.5 用于图像随机增强的函数
def randomly_flip_horizontal(x, y):
""" Randomly flip images horizontally. """
rand = tf.random.uniform([], 0.0,1.0) ❶
def flip(x, y):
return tf.image.flip_left_right(x), tf.image.flip_left_right(y) ❷
x, y = tf.cond(rand < 0.5, lambda: flip(x, y), lambda: (x, y)) ❸
return x, y
if augmentation:
image_ds = image_ds.map(lambda x, y: randomly_flip_horizontal(x,y)) ❹
image_ds = image_ds.map(lambda x, y: (tf.image.random_hue(x, 0.1), y)) ❺
image_ds = image_ds.map(lambda x, y: (tf.image.random_brightness(x, 0.1), y)) ❻
image_ds = image_ds.map(lambda x, y: (tf.image.random_contrast(x, 0.8, 1.2), y))❼
❶ 定义一个随机变量。
❷ 定义一个函数来确定性地翻转图像。
❸ 使用与之前相同的模式,我们使用tf.cond随机执行水平翻转。
❹ 在数据集中随机翻转图像。
❺ 随机调整输入图像的色调(即颜色)(目标保持不变)。
❻ 随机调整输入图像的亮度(目标保持不变)。
❼ 随机调整输入图像的对比度(目标保持不变)。
在列表 8.5 中,我们执行以下翻译:
-
随机水平翻转图像
-
随机改变图像的色调(最多 10%)
-
随机改变图像的亮度(最多 10%)
-
随机改变图像的对比度(最多 20%)
通过使用tf.data.Dataset.map()函数,我们可以在管道中轻松执行指定的随机增强步骤,如果用户在管道中启用了增强(即通过将augmentation变量设置为True)。请注意,我们仅对输入图像执行一些增强(例如,随机色调、亮度和对比度调整)。我们还将给用户提供具有不同尺寸的输入和目标(即输出)的选项。这通过将输出调整为由output_size参数定义的所需大小来实现。我们用于此任务的模型具有不同尺寸的输入和输出维度:
if output_size:
image_ds = image_ds.map(
lambda x, y: (
x,
tf.image.resize(y, output_size, method='nearest')
)
)
再次,这里我们使用最近邻插值来调整目标的大小。接下来,我们将对数据进行洗牌(如果用户将shuffle参数设置为True):
if shuffle:
image_ds = image_ds.shuffle(buffer_size=batch_size*5)
混洗函数有一个重要参数称为buffer_size,它确定了加载到内存中以随机选择样本的样本数量。buffer_size越高,引入的随机性就越多。另一方面,较高的buffer_size意味着更高的内存消耗。现在是批处理数据的时候了,所以在迭代时不是单个数据点,而是当我们迭代时获得一批数据:
image_ds = image_ds.batch(batch_size).repeat(epochs)
这是使用tf.data.Dataset.batch()函数完成的,将所需的批次大小作为参数传递。在使用tf.data管道时,如果要运行多个周期,还需要使用tf.data.Dataset.repeat()函数重复管道给定次数的周期。
我们为什么需要tf.data.Dataset.repeat()?
tf.data.Dataset是一个生成器。生成器的一个独特特点是您只能迭代一次。当生成器到达正在迭代的序列的末尾时,它将通过抛出异常退出。因此,如果您需要多次迭代生成器,您需要根据需要重新定义生成器。通过添加tf.data.Dataset.repeat(epochs),生成器将根据需要重新定义(在此示例中为 epochs 次)。
在我们的tf.data管道完成之前,还需要一步。如果查看目标(y)输出的形状,您将看到它具有通道维度为 1。但是,对于我们将要使用的损失函数,我们需要摆脱该维度:
image_ds = image_ds.map(lambda x, y: (x, tf.squeeze(y)))
对此,我们将使用tf.squeeze()操作,该操作会删除尺寸为 1 的任何维度并返回一个张量。例如,如果您压缩一个尺寸为[1,3,2,1,5]的张量,您将得到一个尺寸为[3,2,5]的张量。最终的代码在清单 8.6 中提供。您可能会注意到两个突出显示的步骤。这是两个流行的优化步骤:缓存和预提取。
清单 8.6 最终的tf.data管道
def get_subset_tf_dataset(
subset_filename_gen_func, batch_size, epochs,
input_size=(256, 256), output_size=None, resize_to_before_crop=None,
augmentation=False, shuffle=False
):
if augmentation and not resize_to_before_crop:
raise RuntimeError( ❶
"You must define resize_to_before_crop when augmentation is enabled."
)
filename_ds = tf.data.Dataset.from_generator(
subset_filename_gen_func, output_types=(tf.string, tf.string) ❷
)
image_ds = filename_ds.map(lambda x,y: (
tf.image.decode_jpeg(tf.io.read_file(x)), ❸
tf.numpy_function(load_image_func, [y], [tf.uint8])
)).cache()
image_ds = image_ds.map(lambda x, y: (tf.cast(x, 'float32')/255.0, y)) ❹
def randomly_crop_or_resize(x,y): ❺
""" Randomly crops or resizes the images """
...
def rand_crop(x, y):
""" Randomly crop images after enlarging them """
...
def resize(x, y):
""" Resize images to a desired size """
...
image_ds = image_ds.map(lambda x,y: randomly_crop_or_resize(x,y)) ❻
image_ds = image_ds.map(lambda x,y: fix_shape(x,y, target_size=input_size)) ❼
if augmentation:
image_ds = image_ds.map(lambda x, y: randomly_flip_horizontal(x,y)) ❽
image_ds = image_ds.map(lambda x, y: (tf.image.random_hue(x, 0.1), y)) ❽
image_ds = image_ds.map(lambda x, y: (tf.image.random_brightness(x, 0.1), y))❽
image_ds = image_ds.map(
lambda x, y: (tf.image.random_contrast(x, 0.8, 1.2), y) ❽
)
if output_size:
image_ds = image_ds.map(
lambda x, y: (x, tf.image.resize(y, output_size, method='nearest')) ❾
)
if shuffle:
image_ds = image_ds.shuffle(buffer_size=batch_size*5) ❿
image_ds = image_ds.batch(batch_size).repeat(epochs) ⓫
image_ds = image_ds.prefetch(tf.data.experimental.AUTOTUNE) ⓬
image_ds = image_ds.map(lambda x, y: (x, tf.squeeze(y))) ⓭
return image_ds ⓮
❶ 如果启用了增强,则需要定义resize_to_before_crop。
❷ 根据所请求的数据子集返回文件名列表。
❸ 将图像加载到内存中。cache()是一个优化步骤,将在文本中讨论。
❹ 规范化输入图像。
❺ 随机裁剪或调整图像大小的函数
❻ 在图像上执行随机裁剪或调整大小。
❼ 设置结果图像的形状。
❽ 在数据上随机执行各种增强。
❾ 根据需要调整输出图像的大小。
❿ 使用缓冲区对数据进行随机混洗。
⓫ 批处理数据并为所需的周期重复该过程。
⓬ 这是文本中详细讨论的优化步骤。
⓭ 从目标图像中删除不必要的维度。
⓮ 获取最终的tf.data管道。
这不是一次轻松的旅程,但却是一次有益的旅程。我们学到了一些定义数据管道的重要技能:
-
定义一个生成器,返回要获取的数据的文件名
-
在
tf.data管道中加载图像 -
操作图像(调整大小、裁剪、亮度调整等)
-
数据批处理和重复
-
为不同数据集定义多个流水线,这些数据集具有不同的要求
接下来,我们将查看一些优化技术,将我们平庸的数据流水线转变为令人印象深刻的数据高速公路。
8.2.1 优化 tf.data 流水线
TensorFlow 是一个用于消耗大型数据集的框架,高效地消耗数据是一个关键优先事项。我们对 tf.data 流水线的讨论中仍然缺少一件事,即 tf.data 流水线可用的优化步骤。在列表 8.6 中,缓存和预取两个步骤被加粗设置。如果您对其他优化技术感兴趣,可以在 www.tensorflow.org/guide/data_performance 上阅读更多。
缓存将在数据通过流水线时将其存储在内存中。这意味着当缓存时,该步骤(例如,从磁盘加载数据)仅在第一个时期发生。随后的时期将从内存中保存的缓存数据中读取。在这里,您可以看到我们将图像加载到内存后进行缓存。这样,TensorFlow 仅在第一个时期加载图像:
image_ds = filename_ds.map(lambda x,y: (
tf.image.decode_jpeg(tf.io.read_file(x)),
tf.numpy_function(load_image_func, [y], [tf.uint8])
)).cache()
Prefetching 是你可以使用的另一个强大武器,它允许你利用设备的多进程能力:
image_ds = image_ds.prefetch(tf.data.experimental.AUTOTUNE)
提供给函数的参数决定了预取多少数据。通过将其设置为 AUTOTUNE,TensorFlow 将根据可用资源决定要获取的最佳数据量。假设一个简单的数据流水线从磁盘加载图像并训练模型。然后,数据读取和模型训练将交替进行。这导致了显着的空闲时间,因为模型在数据加载时空闲,反之亦然。
然而,多亏了预取,情况就不一样了。预取利用后台线程和内部缓冲区,在模型训练时提前加载数据。当下一次迭代到来时,模型可以无缝地继续训练,因为数据已经被提前加载到内存中。图 8.7 显示了顺序执行和预取之间的差异。
图 8.7 模型训练中的顺序执行与基于预取的执行的区别
接下来,我们将查看图像分割问题的完整 tf.data 流水线。
8.2.2 最终的 tf.data 流水线
最后,您可以使用我们迄今为止定义的函数来定义数据流水线(们)。在这里,我们为三个不同的目的定义了三种不同的数据流水线:训练、验证和测试(见下面的列表)。
列表 8.7 创建训练/验证/测试数据流水线实例
orig_dir = os.path.join(
'data', 'VOCtrainval_11-May-2012', 'VOCdevkit', 'VOC2012', 'JPEGImages' ❶
)
seg_dir = os.path.join(
'data', 'VOCtrainval_11-May-2012', 'VOCdevkit', 'VOC2012', 'SegmentationClass' ❷
)
subset_dir = os.path.join(
'data', 'VOCtrainval_11-May-2012', 'VOCdevkit', 'VOC2012', 'ImageSets', ❸
'Segmentation'
)
partial_subset_fn = partial(
get_subset_filenames, orig_dir=orig_dir, seg_dir=seg_dir, subset_dir=subset_dir ❹
)
train_subset_fn = partial(partial_subset_fn, subset='train') ❺
val_subset_fn = partial(partial_subset_fn, subset='val') ❺
test_subset_fn = partial(partial_subset_fn, subset='test') ❺
input_size = (384, 384) ❻
tr_image_ds = get_subset_tf_dataset( ❼
train_subset_fn, batch_size, epochs,
input_size=input_size, resize_to_before_crop=(444,444),
augmentation=True, shuffle=True
)
val_image_ds = get_subset_tf_dataset( ❽
val_subset_fn, batch_size, epochs,
input_size=input_size,
shuffle=False
)
test_image_ds = get_subset_tf_dataset( ❾
test_subset_fn, batch_size, 1,
input_size=input_size,
shuffle=False
)
❶ 包含输入图像的目录
❷ 包含注释图像(目标)的目录
❸ 包含训练/验证/测试文件名的文本文件所在的目录
❹ 从 get_subset_filenames 定义一个可重用的部分函数。
❺ 为训练/验证/测试数据定义三个生成器。
❻ 定义输入图像尺寸。
❼ 定义一个使用数据增强和洗牌的训练数据流水线。
❽ 定义一个不使用数据增强或洗牌的验证数据流水线。
❾ 定义一个测试数据流水线。
首先,我们定义了几个重要的路径:
-
orig_dir—包含输入图像的目录
-
seg_dir—包含目标图像的目录
-
subset_dir—包含列出训练和验证实例的文本文件(train.txt、val.txt)的目录
然后我们将从我们之前定义的 get_subset_filenames() 函数定义一个偏函数,以便我们可以通过设置函数的 subset 参数来获取一个生成器。利用这种技术,我们将定义三个生成器:train_subset_fn、val_subset_fn 和 test_subset_fn。最后,我们将使用 get_subset_tf_dataset() 函数定义三个 tf.data.Datasets。我们的流水线将具有以下特征:
-
训练流水线—在每个 epoch 上执行数据增强和数据洗牌
-
验证流水线和测试流水线—无增强或洗牌
我们将定义的模型期望一个 384 × 384 大小的输入和一个输出。在训练数据流水线中,我们将图像调整大小为 444 × 444,然后随机裁剪一个 384 × 384 大小的图像。接下来,我们将看一下解决方案的核心部分:定义图像分割模型。
练习 2
您已经获得了一个包含两个张量的小数据集:张量 a 包含 100 个大小为 64 × 64 × 3 的图像(即,100 × 64 × 64 × 3 的形状),张量 b 包含 100 个大小为 32 × 32 × 1 的分割蒙版(即,100 × 32 × 32 × 1 的形状)。您被要求使用讨论过的函数定义一个 tf.data.Dataset,它将
-
将分割蒙版调整大小以匹配输入图像大小(使用最近的插值)
-
使用转换(x - 128)/255 对输入图像进行标准化,其中单个图像是 x
-
将数据批处理为大小为 32 的批次,并重复五个 epochs
-
使用自动调优功能预取数据
8.3 DeepLabv3:使用预训练网络对图像进行分割
现在是创建流水线的核心部分的时候了:深度学习模型。根据一位在类似问题上工作的自动驾驶汽车公司同事的反馈,您将实现一个 DeepLab v3 模型。这是一个建立在预训练的 ResNet 50 模型(在图像分类上训练)的基础上的模型,但最后几层被改为执行 空洞卷积 而不是标准卷积。它使用金字塔聚合模块,在不同尺度上使用空洞卷积来生成不同尺度上的图像特征,以产生最终输出。最后,它使用双线性插值层将最终输出调整大小为所需大小。您相信 DeepLab v3 能够提供良好的初始结果。
基于深度神经网络的分割模型可以广泛分为两类:
-
编码器解码器模型(例如,U-Net 模型)
-
完全卷积网络(FCN)后跟金字塔聚合模块(例如,DeepLab v3 模型)
编码器-解码器模型的一个著名例子是 U-Net 模型。换句话说,U-Net 具有逐渐创建输入的较小、更粗略表示的编码器。然后,解码器接收编码器生成的表示,并逐渐上采样(即增加输出的大小)直到达到输入图像的大小为止。上采样是通过一种称为转置卷积的操作实现的。最后,你以端到端的方式训练整个结构,其中输入是输入图像,目标是相应图像的分割掩码。我们不会在本章讨论这种类型的模型。然而,我在附录 B 中包含了一个详细的步骤说明(以及模型的实现)。
另一种分割模型引入了一个特殊的模块来替换解码器。我们称之为金字塔聚合模块。它的目的是在不同尺度上收集空间信息(例如来自各种中间卷积层的不同大小的输出),以提供关于图像中存在的对象的细粒度上下文信息。DeepLab v3 是这种方法的一个典型示例。我们将对 DeepLab v3 模型进行详细分析,并借此在分割任务上取得卓越成果。
研究人员和工程师更倾向于使用金字塔聚合模块的方法。可能有很多原因。一个有利可图的原因是,使用金字塔聚合的网络参数较少,比采用基于编码器-解码器的对应网络更少。另一个原因可能是,通常引入新模块(与编码器-解码器相比)提供更多灵活性,可以在多个尺度上设计高效准确的特征提取方法。
金字塔聚合模块有多重要?为了了解这一点,我们首先必须了解完全卷积网络的结构是什么样的。图 8.8 说明了这种分割模型的通用结构。
图 8.8 全卷积网络使用金字塔聚合模块的一般结构和组织方式
了解金字塔聚合模块的重要性的最佳方式是看看如果没有它会发生什么。如果是这种情况,那么最后一个卷积层将承担建立最终分割掩码(通常是最后一层输出的 16-32 倍大)的巨大且不切实际的责任。毫不奇怪,在最终卷积层和最终分割掩码之间存在巨大的表征瓶颈,从而导致性能不佳。在卷积神经网络中通常强制执行的金字塔结构导致最后一层的输出宽度和高度非常小。
金字塔聚合模块弥合了这一差距。 它通过组合几个不同的中间输出来做到这一点。 这样,网络就有了充足的细粒度(来自较早层)和粗糙的(来自更深层)细节,以构建所需的分割掩模。 细粒度的表示提供了关于图像的空间/上下文信息,而粗糙的表示提供了关于图像的高级信息(例如,存在哪些对象)。 通过融合这两种类型的表示,生成最终输出的任务变得更加可行。
为什么不是金字塔而是摩天大楼呢?
你可能会想,如果随着时间的推移使输出变小会导致信息的丢失,“为什么不保持相同的大小呢?”(因此有了摩天大楼这个术语)。 这是一个不切实际的解决方案,主要有两个原因。
首先,通过池化或步幅减小输出大小是一种重要的正则化方法,它迫使网络学习平移不变特征(正如我们在第六章讨论的那样)。 如果去掉这个过程,我们就会阻碍网络的泛化能力。
其次,不减小输出大小将显著增加模型的内存占用。 这反过来会极大地限制网络的深度,使得创建更深层次的网络更加困难。
DeepLab v3 是一系列模型的金童,这些模型起源于并且是由来自谷歌的几位研究人员在论文“重新思考用于语义图像分割的空洞卷积”(arxiv.org/pdf/1706.05587.pdf)中提出的。
大多数分割模型都面临着由常见且有益的设计原则引起的不利副作用。 视觉模型将步幅/池化结合起来,使网络平移不变。 但这个设计思想的一个不受欢迎的结果是输出大小的不断减小。 这通常导致最终输出比输入小 16-32 倍。 作为密集预测任务,图像分割任务严重受到这种设计思想的影响。 因此,大多数涌现出来的具有突破性的网络都是为了解决这个问题。 DeepLab 模型就是为了解决这个问题而诞生的。 现在让我们看看 DeepLab v3 是如何解决这个问题的。
DeepLab v3 使用在 ImageNet 图像分类数据集上预训练的 ResNet-50(arxiv.org/pdf/1512.03385.pdf)作为提取图像特征的主干。 几年前,它是计算机视觉社区中引起轰动的开创性残差网络之一。 DeepLab v3 对模型进行了几个架构上的改变,以缓解这个问题。 此外,DeepLab v3 引入了一个全新的组件,称为空洞空间金字塔池化(ASPP)。 我们将在接下来的章节中更详细地讨论每个组件。
8.3.1 ResNet-50 模型的快速概述
ResNet-50 模型由多个卷积块组成,后跟一个全局平均池化层和一个具有 softmax 激活的完全连接的最终预测层。卷积块是模型的创新部分。原始模型有 16 个卷积块,组织成五个组。一个单独的块由三个卷积层组成(1 × 1 卷积层,步长为 2,3 × 3 卷积层,1 × 1 卷积层),批量归一化和残差连接。我们在第七章深入讨论了残差连接。接下来,我们将讨论模型中始终使用的核心计算,称为孔卷积。
8.3.2 孔卷积:用孔扩大卷积层的感受野
与标准 ResNet-50 相比,DeepLab v3 骄傲地使用孔卷积的主要变化。孔(法语意为“孔”)卷积,也称为扩张卷积,是标准卷积的变体。孔卷积通过在卷积参数之间插入“孔”来工作。感受野的增加由一个称为 扩张率 的参数控制。更高的扩张率意味着卷积中实际参数之间有更多的孔。孔卷积的一个主要好处是能够增加感受野的大小,而不会损害卷积层的参数效率。
图 8.9 孔卷积与标准卷积的比较。标准卷积是孔卷积的特例,其中速率为 1。随着扩张率的增加,层的感受野也会增加。
图 8.9 显示了较大的扩张率导致更大的感受野。阴影灰色框的数量表示参数数量,而虚线,轻微阴影的框表示感受野的大小。正如你所见,参数数量保持不变,而感受野增加。从计算上讲,将标准卷积扩展到孔卷积非常简单。你所需要做的就是在孔卷积操作中插入零。
等等!孔卷积如何帮助分割模型?
正如我们讨论的那样,CNN 的金字塔结构提出的主要问题是输出逐渐变小。最简单的解决方案,不改变学习的参数,是减小层的步幅。尽管技术上会增加输出大小,但在概念上存在问题。
要理解这一点,假设 CNN 的第 i 层的步长为 2,并且获得了 h × w 大小的输入。然后,第 i+1 层获得了 h/2 × w/2 大小的输入。通过移除第 i 层的步长,它获得了 h × w 大小的输出。然而,第 i+1 层的核已经被训练成看到一个更小的输出,所以通过增加输入的大小,我们破坏了(或减少了)层的感受野。通过引入空洞卷积,我们补偿了该感受野的减小。
现在让我们看看 ResNet-50 如何被重新用于图像分割。首先,我们从tf.keras.applications模块下载它。ResNet-50 模型的架构如下所示。首先,它有一个步幅为 2 的卷积层和一个步幅为 2 的池化层。之后,它有一系列卷积块,最后是一个平均池化层和完全连接的输出层。这些卷积块具有卷积层的分层组织。每个卷积块由几个子块组成,每个子块由三个卷积层组成(即 1 × 1 卷积、3 × 3 卷积和 1 × 1 卷积),以及批量归一化。
使用 Keras 函数 API 实现 DeepLab v3
从输入开始直到conv4块的网络保持不变。根据原始 ResNet 论文的符号,这些块被标识为conv2、conv3和conv4块组。我们的第一个任务是创建一个包含输入层到原始 ResNet-50 模型的conv4块的模型。之后,我们将专注于根据 DeepLab v3 论文重新创建最终卷积块(即conv5):
# Pretrained model and the input
inp = layers.Input(shape=target_size+(3,))
resnet50 = tf.keras.applications.ResNet50(
include_top=False, input_tensor=inp,pooling=None
)
for layer *in* resnet50.layers:
if layer.name == "conv5_block1_1_conv":
break
out = layer.output
resnet50_upto_conv4 = models.Model(resnet50.input, out)
如图所示,我们找到了 ResNet-50 模型中位于“conv5_block1_1_conv”之前的最后一层,这将是conv4块组的最后一层。有了这个,我们可以定义一个临时模型,该模型包含从输入到conv4块组的最终输出的层。后来,我们将专注于通过引入论文中的修改和新组件来增强这个模型。我们将使用扩张卷积重新定义conv5块。为此,我们需要了解 ResNet 块的构成(图 8.10)。我们可以假设它有三个不同的级别。
图 8.10 ResNet-50 中卷积块的解剖。对于这个示例,我们展示了 ResNet-50 的第一个卷积块。卷积块组的组织包括三个不同的级别。
现在让我们实现一个函数来表示使用扩张卷积时的每个级别。为了将标准卷积层转换为扩张卷积,我们只需将所需的速率传递给tf.keras.layers.Conv2D层的dilation_rate参数即可。首先,我们将实现一个表示级别 3 块的函数,如下清单所示。
清单 8.8 ResNet-50 中的级别 3 卷积块
def block_level3(
inp, filters, kernel_size, rate, block_id, convlayer_id, activation=True ❶
):
""" A single convolution layer with atrous convolution and batch normalization
inp: 4-D tensor having shape [batch_size, height, width, channels]
filters: number of output filters
kernel_size: The size of the convolution kernel
rate: dilation rate for atrous convolution
block_id, convlayer_id - IDs to distinguish different convolution blocks and layers
activation: If true ReLU is applied, if False no activation is applied
"""
conv5_block_conv_out = layers.Conv2D(
filters, kernel_size, dilation_rate=rate, padding='same', ❷
name='conv5_block{}_{}_conv'.format(block_id, convlayer_id)
)(inp)
conv5_block_bn_out = layers.BatchNormalization(
name='conv5_block{}_{}_bn'.format(block_id, convlayer_id) ❸
)(conv5_block_conv_out)
if activation:
conv5_block_relu_out = layers.Activation(
'relu', name='conv5_block{}_{}_relu'.format(block_id, convlayer_id) ❹
)(conv5_block_bn_out)
return conv5_block_relu_out
else:
return conv5_block_bn_out ❺
❶ 在这里,inp 接受具有形状 [批量大小,高度,宽度,通道] 的 4D 输入。
❷ 对输入执行二维卷积,使用给定数量的滤波器、内核大小和扩张率。
❸ 对卷积层的输出执行批量归一化。
❹ 如果激活设置为 True,则应用 ReLU 激活。
❺ 如果激活设置为 False,则返回输出而不进行激活。
级别 3 块具有一个单独的卷积层,具有所需的扩张率和批量归一化层,随后是非线性 ReLU 激活层。接下来,我们将为级别 2 块编写一个函数(见下一个清单)。
清单 8.9 在 ResNet-50 中的 2 级卷积块
def block_level2(inp, rate, block_id):
""" A level 2 resnet block that consists of three level 3 blocks """
block_1_out = block_level3(inp, 512, (1,1), rate, block_id, 1)
block_2_out = block_level3(block_1_out, 512, (3,3), rate, block_id, 2)
block_3_out = block_level3(
block_2_out, 2048, (1,1), rate, block_id, 3, activation=False
)
return block_3_out
一个 2 级块由具有给定扩张率的三个级别 3 块组成,这些块具有以下规格的卷积层:
-
1 × 1 卷积层,具有 512 个滤波器和所需的扩张率
-
3 × 3 卷积层,具有 512 个滤波器和所需的扩张率
-
1 × 1 卷积层,具有 2048 个滤波器和所需的扩张率
除了使用空洞卷积外,这与 ResNet-50 模型中原始 conv5 块的 2 级块完全相同。所有构建块准备就绪后,我们可以使用空洞卷积实现完整的 conv5 块(见下一个清单)。
清单 8.10 实现最终的 ResNet-50 卷积块组(级别 1)
def resnet_block(inp, rate):
""" Redefining a resnet block with atrous convolution """
block0_out = block_level3(
inp, 2048, (1,1), 1, block_id=1, convlayer_id=0, activation=False ❶
)
block1_out = block_level2(inp, 2, block_id=1) ❷
block1_add = layers.Add(
name='conv5_block{}_add'.format(1))([block0_out, block1_out] ❸
)
block1_relu = layers.Activation(
'relu', name='conv5_block{}_relu'.format(1) ❹
)(block1_add)
block2_out = block_level2 (block1_relu, 2, block_id=2) # no relu ❺
block2_add = layers.Add(
name='conv5_block{}_add'.format(2) ❻
)([block1_add, block2_out])
block2_relu = layers.Activation(
'relu', name='conv5_block{}_relu'.format(2) ❼
)(block2_add)
block3_out = block_level2 (block2_relu, 2, block_id=3) ❽
block3_add = layers.Add(
name='conv5_block{}_add'.format(3) ❽
)([block2_add, block3_out])
block3_relu = layers.Activation(
'relu', name='conv5_block{}_relu'.format(3) ❽
)(block3_add)
return block3_relu
❶ 创建一个级别 3 块(block0),为第一个块创建残差连接。
❷ 定义第一个具有扩张率为 2 的 2 级块(block1)。
❸ 从 block0 到 block1 创建一个残差连接。
❹ 对结果应用 ReLU 激活。
❺ 具有扩张率为 2 的第二级 2 块(block2)
❻ 从 block1 到 block2 创建一个残差连接。
❼ 应用 ReLU 激活。
❽ 对 block1 和 block2 应用类似的过程以创建 block3。
这里没有黑魔法。函数 resnet_block 将我们已经讨论的函数的输出放置在一起以组装最终的卷积块。特别地,它具有三个级别 2 块,其残差连接从前一个块到下一个块。最后,我们可以通过使用我们定义的中间模型的输出(resnet50_ upto_conv4)作为输入并使用扩张率为 2 调用 resnet_block 函数来获得 conv5 块的最终输出:
resnet_block4_out = resnet_block(resnet50_upto_conv4.output, 2)
8.3.4 实现空洞空间金字塔池化模块
在这里,我们将讨论 DeepLab v3 模型最令人兴奋的创新。空洞空间金字塔池化(ASPP)模块有两个目的:
-
聚合通过使用不同扩张率产生的输出获得的图像的多尺度信息
-
结合通过全局平均池化获得的高度摘要的信息
ASPP 模块通过在最后一个 ResNet-50 输出上执行不同的卷积来收集多尺度信息。具体来说,ASPP 模块执行 1 × 1 卷积、3 × 3 卷积(r = 6)、3 × 3 卷积(r = 12)和 3 × 3 卷积(r = 18),其中 r 是 dilation 率。所有这些卷积都有 256 个输出通道,并实现为级别 3 的块(由函数 block_level3() 提供)。
ASRP 通过执行全局平均池化来捕获高级信息,然后进行 1 × 1 卷积,输出通道为 256,以匹配多尺度输出的输出大小,最后是一个双线性上采样层,用于上采样全局平均池化所缩小的高度和宽度维度。记住,双线性插值通过计算相邻像素的平均值来上采样图像。图 8.11 说明了 ASPP 模块。
图 8.11 DeepLab v3 模型中使用的 ASPP 模块
ASPP 模块的任务可以概括为一个简明的函数。我们从之前完成的工作中已经拥有了实现此函数所需的所有工具(请参阅下面的清单)。
清单 8.11 实现 ASPP
def atrous_spatial_pyramid_pooling(inp):
""" Defining the ASPP (Atrous spatial pyramid pooling) module """
# Part A: 1x1 and atrous convolutions
outa_1_conv = block_level3(
inp, 256, (1,1), 1, '_aspp_a', 1, activation='relu'
) ❶
outa_2_conv = block_level3(
inp, 256, (3,3), 6, '_aspp_a', 2, activation='relu'
) ❷
outa_3_conv = block_level3(
inp, 256, (3,3), 12, '_aspp_a', 3, activation='relu'
) ❸
outa_4_conv = block_level3(
inp, 256, (3,3), 18, '_aspp_a', 4, activation='relu'
) ❹
# Part B: global pooling
outb_1_avg = layers.Lambda(
lambda x: K.mean(x, axis=[1,2], keepdims=True)
)(inp) ❺
outb_1_conv = block_level3(
outb_1_avg, 256, (1,1), 1, '_aspp_b', 1, activation='relu' ❻
)
outb_1_up = layers.UpSampling2D((24,24), interpolation='bilinear')(outb_1_avg) ❼
out_aspp = layers.Concatenate()(
[outa_1_conv, outa_2_conv, outa_3_conv, outa_4_conv, outb_1_up] ❽
)
return out_aspp
out_aspp = atrous_spatial_pyramid_pooling(resnet_block4_out) ❾
❶ 定义一个 1 × 1 卷积。
❷ 定义一个带有 256 个滤波器和 dilation 率为 6 的 3 × 3 卷积。
❸ 定义一个带有 256 个滤波器和 dilation 率为 12 的 3 × 3 卷积。
❹ 定义一个带有 256 个滤波器和 dilation 率为 18 的 3 × 3 卷积。
❺ 定义一个全局平均池化层。
❻ 定义一个带有 256 个滤波器的 1 × 1 卷积。
❼ 使用双线性插值上采样输出。
❽ 连接所有的输出。
❾ 创建一个 ASPP 的实例。
ASPP 模块由四个级别 3 的块组成,如代码所示。第一个块包括一个 1 × 1 卷积,带有 256 个无 dilation 的滤波器(这产生了 outa_1_conv)。后三个块包括 3 × 3 卷积,带有 256 个滤波器,但具有不同的 dilation 率(即 6、12、18;它们分别产生 outa_2_conv、outa_3_conv 和 outa_4_conv)。这涵盖了从图像中聚合多个尺度的特征。然而,我们还需要保留关于图像的全局信息,类似于全局平均池化层(outb_1_avg)。这通过一个 lambda 层实现,该层将输入在高度和宽度维度上进行平均:
outb_1_avg = layers.Lambda(lambda x: K.mean(x, axis=[1,2], keepdims=True))(inp)
平均值的输出接着是一个带有 256 个滤波器的 1 × 1 卷积滤波器。然后,为了将输出大小与以前的输出相同,使用双线性插值的上采样层(这产生 outb_1_up):
outb_1_up = layers.UpSampling2D((24,24), interpolation='bilinear')(outb_1_avg)
最后,所有这些输出都通过 Concatenate 层连接到单个输出中,以产生最终输出 out_aspp。
8.3.5 将所有内容放在一起
现在是时候整合所有不同的组件,创建一个宏伟的分割模型了。接下来的清单概述了构建最终模型所需的步骤。
清单 8.12 最终的 DeepLab v3 模型
inp = layers.Input(shape=target_size+(3,)) ❶
resnet50= tf.keras.applications.ResNet50(
include_top=False, input_tensor=inp,pooling=None ❷
)
for layer *in* resnet50.layers:
if layer.name == "conv5_block1_1_conv":
break
out = layer.output ❸
resnet50_upto_conv4 = models.Model(resnet50.input, out) ❹
resnet_block4_out = resnet_block(resnet50_upto_conv4.output, 2) ❺
out_aspp = atrous_spatial_pyramid_pooling(resnet_block4_out) ❻
out = layers.Conv2D(21, (1,1), padding='same')(out_aspp) ❼
final_out = layers.UpSampling2D((16,16), interpolation='bilinear')(out) ❼
deeplabv3 = models.Model(resnet50_upto_conv4.input, final_out) ❽
❶ 定义 RGB 输入层。
❷ 下载并定义 ResNet50。
❸ 获取我们感兴趣的最后一层的输出。
❹ 从输入定义一个中间模型,到 conv4 块的最后一层。
❺ 定义删除的 conv5 ResNet 块。
❻ 定义 ASPP 模块。
❼ 定义最终输出。
❽ 定义最终模型。
注意观察模型中的线性层,它没有任何激活函数(例如 sigmoid 或 softmax)。这是因为我们计划使用一种特殊的损失函数,该函数使用 logits(在应用 softmax 之前从最后一层获得的未归一化分数)而不是归一化的概率分数。因此,我们将保持最后一层为线性输出,没有激活函数。
我们还需要执行最后一步操作:将原始 conv5 块的权重复制到我们的模型中新创建的 conv5 块。为此,首先需要将原始模型的权重存储如下:
w_dict = {}
for l *in* ["conv5_block1_0_conv", "conv5_block1_0_bn",
"conv5_block1_1_conv", "conv5_block1_1_bn",
"conv5_block1_2_conv", "conv5_block1_2_bn",
"conv5_block1_3_conv", "conv5_block1_3_bn"]:
w_dict[l] = resnet50.get_layer(l).get_weights()
在编译模型之前,我们无法将权重复制到新模型中,因为在编译模型之前权重不会被初始化。在这之前,我们需要学习在分割任务中使用的损失函数和评估指标。为此,我们需要实现自定义损失函数和指标,并使用它们编译模型。这将在下一节中讨论。
练习 3
您想要创建一个新的金字塔聚合模块称为 aug-ASPP。这个想法与我们之前实现的 ASPP 模块类似,但有一些区别。假设您已经从模型中获得了两个中间输出:out_1 和 out_2(大小相同)。您必须编写一个函数,aug_aspp,它将获取这两个输出并执行以下操作:
-
对 out_1 进行 atrous 卷积,r=16,128 个过滤器,3×3 卷积,步幅为 1,并应用 ReLU 激活函数(输出将被称为 atrous_out_1)
-
对 out_1 和 out_2 进行 atrous 卷积,r=8,128 个过滤器,3×3 卷积,步幅为 1,并对两者应用 ReLU 激活函数(输出将被称为 atrous_out_2_1 和 atrous_out_2_2)
-
拼接 atrous_out_2_1 和 atrous_out_2_2(输出将被称为 atrous_out_2)
-
对 atrous_out_1 和 atrous_out_2 进行 1×1 卷积,使用 64 个过滤器并进行拼接(输出将被称为 conv_out)
-
使用双线性上采样将 conv_out 的大小加倍(在高度和宽度尺寸上),并应用 sigmoid 激活函数
8.4 编译模型:图像分割中的损失函数和评估指标
为了完成 DeepLab v3 模型的最终构建(主要采用 ResNet-50 结构和 ASPP 模块),我们必须定义适当的损失函数和度量来衡量模型的性能。图像分割与图像分类任务有很大的不同,因此损失函数和度量不一定适用于分割问题。一个关键的区别是,在分割数据中通常存在较大的类别不平衡,因为与其他与对象相关的像素相比,“背景”类通常占据了图像的主导地位。为了开始,您阅读了几篇博客文章和研究论文,并将加权分类交叉熵损失和 Dice 损失视为很好的候选项。您专注于三个不同的度量:像素精度,平均(类别加权)精度和平均 IoU。
图像分割模型中使用的损失函数和评估指标与图像分类器中使用的不同。首先,图像分类器接受单个图像的单个类标签,而分割模型预测图像中每个单个像素的类别。这凸显了不仅需要重新构想现有的损失函数和评估指标,而且需要发明适用于分割模型产生的输出的新的损失和评估指标。我们首先讨论损失函数,然后是指标。
8.4.1 损失函数
损失函数是用于优化其目的是找到最小化定义的损失的参数的模型的。深度网络中使用的损失函数必须是可微分的,因为损失的最小化是通过梯度进行的。我们将使用的损失函数包含两个损失函数:
-
交叉熵损失
-
Dice 损失
交叉熵损失
交叉熵损失是分割任务中最常用的损失之一,可以在 Keras 中仅用一行代码实现。我们已经使用了交叉熵损失很多次,但没有详细分析它。然而,回顾支配交叉熵损失的基础机制是值得的。
对于交叉熵损失函数,需要输入预测目标和真实目标。这两个张量都具有[batch size, height, width, object classes]的形状。对象类维度是给定像素属于哪个对象类别的一种独热编码表示。然后,对每个像素独立地计算交叉熵损失。
8_11a
其中CE(i, j)表示图像位置(i, j)处像素的交叉熵损失,c是类别数,y[k]和ŷ[k]分别表示该像素的独热编码向量中元素和预测概率分布的元素。然后在所有像素上求和以获得最终损失。
在这种方法的简单背后,隐藏着一个关键问题。在图像分割问题中,类别不平衡几乎肯定会出现。你几乎找不到每个对象在图像中占据相等面积的真实世界图像。好消息是,处理这个问题并不是很困难。这可以通过为图像中的每个像素分配一个权重来缓解,这个权重取决于它所代表的类别的显 dominance。属于大对象的像素将具有较小的权重,而属于较小对象的像素将具有较大的权重,尽管在最终损失中大小相等。接下来的列表显示了如何在 TensorFlow 中执行此操作。
列表 8.13 计算给定数据批次的标签权重
def get_label_weights(y_true, y_pred):
weights = tf.reduce_sum(tf.one_hot(y_true, num_classes), axis=[1,2]) ❶
tot = tf.reduce_sum(weights, axis=-1, keepdims=True) ❷
weights = (tot - weights) / tot # [b, classes] ❸
y_true = tf.reshape(y_true, [-1, y_pred.shape[1]*y_pred.shape[2]]) ❹
y_weights = tf.gather(params=weights, indices=y_true, batch_dims=1) ❺
y_weights = tf.reshape(y_weights, [-1]) ❻
return y_weights
❶ 获取 y_true 中每个类别的总像素数。
❷ 获取 y_true 中的总像素数。
❸ 计算每个类别的权重。更稀有的类别获得更多的权重。
❹ 将 y_true 重塑为 [batch size, height*width] 大小的张量。
❺ 通过收集与 y_true 中索引对应的权重来创建权重向量。
❻ 使 y_weights 成为一个向量。
在这里,对于给定的批次,我们将权重计算为一个序列/向量,其元素数量等于 y_true。首先,我们通过计算 one-hot 编码的 y_true(即具有批次、高度、宽度和类别维度的尺寸)的宽度和高度上的总和来获取每个类别的像素总数。在这里,值大于 num_classes 的类将被忽略。接下来,我们通过对类维度求和来计算每个样本的像素总数,得到 tot(一个 [batch size, 1] 大小的张量)。现在可以计算每个样本和每个类别的权重。
其中 n 是像素的总数,n^i 是属于第 i 个类的像素的总数。之后,我们将 y_true 重塑为形状 [batch size, -1],为权重计算的重要步骤做准备。作为最终输出,我们希望从 y_weights 中创建一个张量,其中我们从 y_true 中提取对应于 y_weights 中元素的元素。换句话说,我们从 y_weights 中获取值,其中给定索引由 y_true 中的值给出。最后,结果将与 y_true 的形状和大小相同。这就是我们需要加权样本的全部内容:对每个像素的损失值逐元素乘以权重。为了实现这一点,我们将使用函数 tf.gather(),该函数从给定的张量(params)中收集元素,同时获取表示索引的张量(indices),并返回与索引相同形状的张量:
y_weights = tf.gather(params=weights, indices=y_true, batch_dims=1)
在这里,在执行 gather 时忽略批次维度,我们传递了参数 batch_dims,指示我们有多少批次维度。有了这个,我们将定义一个函数,给出一批预测和真实目标,输出加权交叉熵损失。
现在权重准备好了,我们可以实现第一个分割损失函数。我们将实现加权交叉熵损失。一眼看去,该函数会屏蔽不相关的像素(例如属于未知对象的像素),并展开预测标签和真实标签以消除高度和宽度维度。最后,我们可以使用 TensorFlow 中的内置函数计算交叉熵损失(请参见下一个清单)。
清单 8.14 实现加权交叉熵损失
def ce_weighted_from_logits(num_classes):
def loss_fn(y_true, y_pred):
""" Defining cross entropy weighted loss """
valid_mask = tf.cast(
tf.reshape((y_true <= num_classes - 1), [-1,1]), 'int32'
) ❶
y_true = tf.cast(y_true, 'int32') ❷
y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]]) ❷
y_weights = get_label_weights(y_true, y_pred) ❸
y_pred_unwrap = tf.reshape(y_pred, [-1, num_classes]) ❹
y_true_unwrap = tf.reshape(y_true, [-1]) ❹
return tf.reduce_mean(
y_weights * tf.nn.sparse_softmax_cross_entropy_with_logits( ❺
y_true_unwrap * tf.squeeze(valid_mask),
y_pred_unwrap * tf.cast(valid_mask, 'float32'))
)
return loss_fn ❻
❶ 定义有效掩码,用于屏蔽不必要的像素。
❷ 对 y_true 进行了一些初步设置,将其转换为 int 并设置形状。
❸ 获取标签权重。
❹ 展开 y_pred 和 y_true,以消除批处理、高度和宽度维度。
❺ 使用 y_true、y_pred 和掩码计算交叉熵损失。
❻ 返回计算损失的函数。
你可能会想,“为什么将损失定义为嵌套函数?”如果我们需要向损失函数中包含额外的参数(例如 num_classes),则必须遵循此标准模式。我们正在将损失函数的计算捕获在 loss_fn 函数中,然后创建一个外部函数 ce_weighted_from_logits(),该函数将返回封装损失计算(即 loss_fn)的函数。
具体地,创建有效掩码以指示 y_true 中的标签是否小于类数。任何值大于类数的标签都会被忽略(例如未知对象)。接下来,我们获取权重向量,并使用 get_label_weights() 函数为每个像素指定权重。我们将 y_pred 展开成 [-1, num_classes] 大小的张量,因为 y_pred 包含数据集中所有类别的 logits(即模型输出的未归一化概率分数)。y_true 将展开为一个向量(也就是单维),因为 y_true 只包含类别标签。最后,我们使用 tf.nn.sparse_softmax_cross_entropy_with_logits() 来计算掩码预测和真实目标的损失。该函数有两个参数,标签和 logits,很容易理解。我们可以得出两个重要的观察结果:
-
我们正在计算稀疏交叉熵损失(而不是标准交叉熵损失)。
-
我们从 logits 中计算交叉熵损失。
当使用稀疏交叉熵时,我们不需要对标签进行独热编码,因此可以跳过此步骤,这会导致数据管道更具内存效率。这是因为独热编码在模型内部处理。通过使用稀疏损失,我们需要担心的东西就更少了。
从 logits(即未归一化分数)而不是从归一化概率计算损失会导致更好、更稳定的渐变。因此,尽可能地使用 logits 而不是归一化概率。
Dice 损失
我们将讨论的第二种损失函数称为 Dice 损失,其计算方式如下:
在这里,可以通过逐元素乘法计算预测和目标张量之间的交集,而可以通过逐元素加法计算预测和目标张量之间的并集。你可能会觉得使用逐元素操作来计算交集和并集是一种奇怪的方式。为了理解背后的原因,我想引用之前提到过的一句话:深度网络中使用的损失函数必须是可微分的。
这意味着我们不能使用我们通常用来计算交集和并集的标准方法。相反,我们需要采用可微分计算的方法,得到两个张量之间的交集和并集。交集可以通过预测值和真实目标之间的逐元素乘法来计算。并集可以通过预测值和真实目标之间的逐元素加法来计算。图 8.12 阐明了这些操作如何导致两个张量之间的交集和并集。
图 8.12 显示了 dice 损失中涉及的计算。交集可以通过逐元素乘法计算为一个可微分函数,而并集可以通过逐元素求和计算。
这个损失函数主要关注最大化预测值和真实目标之间的交集。乘数 2 的使用是为了平衡来自交集和并集之间重叠的值的重复,其出现在分母中(参见下面的列表)。
列表 8.15 实现 dice 损失
def dice_loss_from_logits(num_classes):
""" Defining the dice loss 1 - [(2* i + 1)/(u + i)]"""
def loss_fn(y_true, y_pred):
smooth = 1.
# Convert y_true to int and set shape
y_true = tf.cast(y_true, 'int32') ❶
y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]]) ❶
# Get pixel weights
y_weights = tf.reshape(get_label_weights(y_true, y_pred), [-1, 1]) ❷
# Apply softmax to logits
y_pred = tf.nn.softmax(y_pred) ❸
y_true_unwrap = tf.reshape(y_true, [-1]) ❹
y_true_unwrap = tf.cast(
tf.one_hot(tf.cast(y_true_unwrap, 'int32'), num_classes),
➥ 'float32'
) ❹
y_pred_unwrap = tf.reshape(y_pred, [-1, num_classes]) ❹
intersection = tf.reduce_sum(y_true_unwrap * y_pred_unwrap * y_weights)❺
union = tf.reduce_sum((y_true_unwrap + y_pred_unwrap) * y_weights) ❻
score = (2\. * intersection + smooth) / ( union + smooth) ❼
loss = 1 - score ❽
return loss
return loss_fn
❶ y_true 的初始设置
❷ 获取标签权重并将其重塑为 [-1, 1] 的形状。
❸ 对 y_pred 应用 softmax 函数以得到归一化概率。
❹ 将 y_pred 和 one-hot 编码的 y_true 展开为 [-1, num_classes] 的形状。
❺ 使用逐元素乘法计算交集。
❻ 使用逐元素加法计算并集。
❼ 计算 dice 系数。
❽ 计算 dice 损失。
在这里,smooth 是一个平滑参数,我们将用它来避免可能导致除以零而产生 NaN 值的情况。然后我们进行以下操作:
-
获取每个 y_true 标签的权重
-
对 y_pred 应用 softmax 激活函数
-
将 y_pred 展开为 [-1, num_classes] 的张量,将 y_true 展开为大小为 [-1] 的向量
然后计算 y_pred 和 y_true 的交集和并集。具体来说,交集是通过 y_pred 和 y_true 的逐元素乘法计算出来的,而并集是通过 y_pred 和 y_true 的逐元素加法计算出来的。
焦点损失
焦点损失是一种相对较新的损失,它在论文“用于密集目标预测的焦点损失”中介绍(arxiv.org/pdf/1708.02002.pdf)。焦点损失是为了应对分割任务中发现的严重类别不平衡而引入的。具体地,它解决了许多简单示例(例如,来自具有较小损失的常见类的样本)过多的问题,而不是强大的少数困难示例(例如,来自具有较大损失的稀有类的样本)。焦点损失通过引入调制因子来解决这个问题,该调制因子将减小简单示例的权重,因此,损失函数自然更多地关注学习困难示例。
我们将用于优化分割模型的损失函数将是由稀疏交叉熵损失和 Dice 损失相加而得到的损失(见下一列表)。
列表 8.16 最终组合损失函数
def ce_dice_loss_from_logits(num_classes):
def loss_fn(y_true, y_pred):
# Sum of cross entropy and dice losses
loss = ce_weighted_from_logits(num_classes)(
tf.cast(y_true, 'int32'), y_pred
) + dice_loss_from_logits(num_classes)(
y_true, y_pred
)
return loss
return loss_fn
接下来,我们将讨论评估指标。
8.4.2 评估指标
评估指标在模型训练中扮演着重要角色,作为模型的健康检查。这意味着可以通过确保评估指标的行为合理快速识别性能低下或问题。在这里,我们将讨论三种不同的指标:
-
像素
-
平均准确率
-
均交并比
我们将通过利用 TensorFlow 中的一些现有指标来实现这些自定义指标,在这些指标中,你必须从 tf.keras.metrics.Metric 类或其中一个现有指标的基类派生子类。这意味着你需要创建一个新的 Python 类,它继承自其中一个现有具体指标类的基类 tf.keras.metrics.Metric:
class MyMetric(tf.keras.metrics.Metric):
def __init__(self, name='binary_true_positives', **kwargs):
super(MyMetric, self).__init__(name=name, **kwargs)
# Create state related variables
def update_state(self, y_true, y_pred, sample_weight=None):
# update state in this function
def result(self):
# We return the result computed from the state
def reset_states():
# Do what’s required to reset the maintained states
# This function is called between epochs
关于指标,你需要了解的第一件事是它是一个有状态的对象,这意味着它维护着一个状态。例如,一个单独的周期有多个迭代,假设你对计算准确率感兴趣。指标需要累积计算准确率所需的值,以便在结束时,它可以计算该周期的平均准确率。在定义指标时,你需要注意三个函数:init、update_state 和 result,以及 reset_states。
让我们具体点,假设我们正在实现一个准确率指标(即 y_pred 中匹配 y_true 元素的百分比)。它需要维护一个总数:我们传递的所有准确率值的总和和计数(我们传递的准确率值的数量)。有了这两个状态元素,我们可以随时计算平均准确率。当实现准确率指标时,你需要实现这些函数:
-
init—定义了两个状态;总数和计数
-
update_state—基于 y_true 和 y_pred 更新总数和计数
-
result—计算平均准确率为总数/计数
-
reset_states—重置总数和计数(这需要在周期开始时发生)
让我们看看这些知识如何转化为我们感兴趣的评估指标。
像素和平均准确率
像素准确度是你可以想到的最简单的指标。它衡量了预测和真实目标之间的像素精度(见下一个清单)。
清单 8.17 实现像素准确度指标
class PixelAccuracyMetric(tf.keras.metrics.Accuracy):
def __init__(self, num_classes, name='pixel_accuracy', **kwargs):
super(PixelAccuracyMetric, self).__init__(name=name, **kwargs)
def update_state(self, y_true, y_pred, sample_weight=None):
y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]]) ❶
y_true = tf.reshape(y_true, [-1]) ❷
y_pred = tf.reshape(tf.argmax(y_pred, axis=-1),[-1]) ❸
valid_mask = tf.reshape((y_true <= num_classes - 1), [-1]) ❹
y_true = tf.boolean_mask(y_true, valid_mask) ❺
y_pred = tf.boolean_mask(y_pred, valid_mask) ❺
super(PixelAccuracyMetric, self).update_state(y_true, y_pred) ❻
❶ 设置 y_true 的形状(以防未定义)。
❷ 将 y_true 重新调整为向量。
❸ 将 y_pred 取 argmax 后重新调整形状为向量。
❹ 定义一个有效的掩码(屏蔽不必要的像素)。
❺ 收集满足 valid_mask 条件的像素/标签。
❻ 使用处理过的 y_true 和 y_pred,使用 update_state()函数计算准确度。
像素准确度计算预测像素和真实像素之间的一一匹配。为了计算这个指标,我们从 tf.keras.metrics.Accuracy 进行子类化,因为它具有我们需要的所有计算。为此,我们重写 update_state 函数如下所示。我们需要注意以下几点:
-
我们需要作为预防措施设置 y_true 的形状。这是因为在处理 tf.data.Dataset 时,有时会丢失形状。
-
将 y_true 重新调整为向量。
-
通过执行 tf.argmax()获取 y_pred 的类别标签,并将其重新调整为向量。
-
定义一个有效的掩码,忽略不需要的类别(例如,未知对象)。
-
获取仅满足 valid_mask 过滤器的像素。
一旦完成这些任务,我们只需调用父对象(即,tf.keras.metrics.Accuracy)的 update_state 方法,并传递相应的参数。我们不需要重写 result()和 reset_states()函数,因为它们已经包含了正确的计算。
我们说图像分割问题中普遍存在类别不平衡。通常,背景像素将在图像的大区域中散布,可能导致错误的结论。因此,稍微更好的方法可能是分别计算每个类别的准确度,然后取平均值。这就是平均准确度的作用,它防止了像素准确度的不良特性(见下一个清单)。
清单 8.18 实现平均准确度指标
class MeanAccuracyMetric(tf.keras.metrics.Mean):
def __init__(self, num_classes, name='mean_accuracy', **kwargs):
super(MeanAccuracyMetric, self).__init__(name=name, **kwargs)
def update_state(self, y_true, y_pred, sample_weight=None):
smooth = 1
y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]]) ❶
y_true = tf.reshape(y_true, [-1]) ❶
y_pred = tf.reshape(tf.argmax(y_pred, axis=-1),[-1]) ❶
valid_mask = tf.reshape((y_true <= num_classes - 1), [-1]) ❶
y_true = tf.boolean_mask(y_true, valid_mask) ❶
y_pred = tf.boolean_mask(y_pred, valid_mask) ❶
conf_matrix = tf.cast(
tf.math.confusion_matrix(y_true, y_pred, num_classes=num_classes),
➥ 'float32' ❷
)
true_pos = tf.linalg.diag_part(conf_matrix) ❸
mean_accuracy = tf.reduce_mean(
(true_pos + smooth)/(tf.reduce_sum(conf_matrix, axis=1) + smooth) ❹
)
super(MeanAccuracyMetric, self).update_state(mean_accuracy) ❺
❶ 初始设置
❷ 使用 y_true 和 y_pred 计算混淆矩阵。
❸ 获取真正的正例(对角线上的元素)。
❹ 使用每个类别的真正正例和真正类别计数计算平均准确度。
❺ 使用 update_state()函数计算 mean_accuracy 的平均值。
MeanAccuracyMetric 将从 tf.keras.metrics.Mean 分支出来,它计算给定值序列的平均值。计划是在 update_state()函数中计算 mean_accuracy,然后将该值传递给父类的 update_state()函数,以便得到平均准确度的平均值。首先,我们执行我们之前讨论的 y_true 和 y_pred 的初始设置和清理。
图 8.13 五类分类问题的混淆矩阵示意图。阴影区域表示真正的正例。
然后,从预测和真实目标计算混淆矩阵(图 8.13)。对于一种n种分类问题(即,具有n个可能类别的分类问题),混淆矩阵被定义为一个n×n矩阵。这里,位置(i,j)处的元素表示预测为属于第i个类别的实例实际上属于第j个类别。图 8.13 描绘了这种类型的混淆矩阵。我们可以通过提取对角线(即,所有 1 <= i <= n的矩阵中的(i,i)元素)来获取真阳性。现在我们可以通过两个步骤计算平均准确度:
-
对于所有类别,对真阳性计数进行逐元素除法。这将产生一个向量,其元素表示每个类别的准确性。
-
计算步骤 1 产生的向量平均值。
最后,我们将平均准确度传递给其父对象的 update_state()函数。
平均交并比
平均交并比(mean intersection over union)是用于分割任务的流行评估度量,与我们之前讨论的 Dice loss 密切相关,因为它们都使用交和并的概念来计算最终结果(见下一个列表)。
列表 8.19 实现平均交并比度量
class MeanIoUMetric(tf.keras.metrics.MeanIoU):
def __init__(self, num_classes, name='mean_iou', **kwargs):
super(MeanIoUMetric, self).__init__(num_classes=num_classes, name=name, **kwargs)
def update_state(self, y_true, y_pred, sample_weight=None):
y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]])
y_true = tf.reshape(y_true, [-1])
y_pred = tf.reshape(tf.argmax(y_pred, axis=-1),[-1])
valid_mask = tf.reshape((y_true <= num_classes - 1), [-1])
# Get pixels corresponding to valid mask
y_true = tf.boolean_mask(y_true, valid_mask)
y_pred = tf.boolean_mask(y_pred, valid_mask)
super(MeanIoUMetric, self).update_state(y_true, y_pred) ❶
❶ 在 y_true 和 y_pred 的初始设置之后,我们只需调用父对象的 update_state()函数即可。
平均交并比的计算已经在 tf.keras.metrics.MeanIoU 中找到。因此,我们将使用它作为我们的父类。我们所需做的就是为 y_true 和 y_pred 进行前述设置,然后调用父对象的 update_state()函数。平均交并比计算为
在该计算中使用的各种元素如图 8.14 所示。
图 8.14 混淆矩阵,以及如何用它来计算假阳性,假阴性和真阳性
我们现在理解了可用于我们的损失函数和评估指标,并已经实现了它们。我们可以将这些损失编译到模型中:
deeplabv3.compile(
loss=ce_dice_loss_from_logits(num_classes),
optimizer=optimizer,
metrics=[
MeanIoUMetric(num_classes),
MeanAccuracyMetric(num_classes),
PixelAccuracyMetric(num_classes)
])
记住,我们从前面删除的卷积块中存储了权重。现在我们已经编译了模型,我们可以使用以下语法将权重复制到新模型中:
# Setting weights for newly added layers
for k, w *in* w_dict.items():
deeplabv3.get_layer(k).set_weights(w)
我们现在开始使用数据管道和我们定义的模型来训练模型。
练习 4
您正在设计一个新的损失函数,用于计算 y_true 和 y_pred 之间的不相交并集。两个集合 A 和 B 之间的不相交并集是在 A 或 B 中但不在交集中的元素的集合。您知道可以通过 y_true 和 y_pred 的元素乘法计算交并通过 y_true 和 y_pred 的元素加法计算。编写函数的等式,以根据 y_true 和 y_pred 计算不相交并集。
8.5 训练模型
您已经进入产品第一次迭代的最后阶段。现在是时候充分利用您获得的数据和知识了(即,训练模型)。我们将训练模型进行 25 个周期,并监视像素精度、平均精度和平均 IoU 指标。在训练过程中,我们将衡量验证数据集的性能。
训练模型是最容易的部分,因为我们已经完成了导致训练的艰苦工作。现在只需要在我们刚刚定义的 DeepLab v3 上使用正确的参数调用 fit(),如下面的列表所示。
图 8.20 训练模型
if *not* os.path.exists('eval'):
os.mkdir('eval')
csv_logger = tf.keras.callbacks.CSVLogger(
os.path.join('eval','1_pretrained_deeplabv3.log') ❶
monitor_metric = 'val_loss'
mode = 'min' if 'loss' *in* monitor_metric else 'max' ❷
print("Using metric={} and mode={} for EarlyStopping".format(monitor_metric, mode))
lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
monitor=monitor_metric, factor=0.1, patience=3, mode=mode, min_lr=1e-8 ❸
)
es_callback = tf.keras.callbacks.EarlyStopping(
monitor=monitor_metric, patience=6, mode=mode ❹
)
# Train the model
deeplabv3.fit( ❺
x=tr_image_ds, steps_per_epoch=n_train,
validation_data=val_image_ds, validation_steps=n_valid,
epochs=epochs, callbacks=[lr_callback, csv_logger, es_callback])
❶ 训练记录器
❷ 自动根据指标名称设置后续回调函数的模式。
❸ 学习率调度器
❹ 提前停止回调
❺ 在使用验证集进行学习率调整和提前停止的同时训练模型。
首先,如果不存在,我们将定义一个名为 eval 的目录。训练日志将保存在这个目录中。接下来,我们定义三个不同的回调函数在训练过程中使用:
-
csv_logger—记录训练损失/指标和验证损失/指标
-
lr_callback—如果验证损失在三个周期内没有减少,则将学习率减小 10 倍
-
es_callback—如果验证损失在六个周期内没有减少,则执行提前停止
注意 在一台配备英特尔 Core i5 处理器和 NVIDIA GeForce RTX 2070 8GB 显卡的机器上,训练大约需要 45 分钟来运行 25 个周期。
有了这个,我们使用以下参数调用 deeplabv3.fit():
-
x—生成训练实例的 tf.data 流水线(设置为 tr_image_ds)。
-
steps_per_epoch—每个周期的步数。这是通过计算训练实例的数量并将其除以批次大小获得的(设置为 n_train)。
-
validation_data—生成验证实例的 tf.data 流水线。这是通过计算验证实例的数量并将其除以批次大小来获得的(设置为 val_image_ds)。
-
epochs—周期数(设置为 epochs)。
-
callbacks—我们之前设置的回调函数(设置为 [lr_callback, csv_logger, es_callback])。
在模型训练完成后,我们将在测试集上进行评估。我们还将可视化模型生成的分割结果。
练习 5
您有一个包含 10,000 个样本的数据集,并将其分成 90% 的训练数据和 10% 的验证数据。您使用的训练批次大小为 10,验证批次大小为 20。单个周期中将有多少个训练和验证步骤?
8.6 评估模型
让我们花点时间回顾一下我们到目前为止所做的事情。我们定义了一个数据管道来读取图像并将它们准备为模型的输入和目标。然后我们定义了一个称为 DeepLab v3 的模型,它使用预训练的 ResNet-50 作为其主干网络,并使用称为空洞空间金字塔池的特殊模块来预测最终的分割掩模。然后我们定义了任务特定的损失和指标,以确保我们可以使用各种指标评估模型。之后,我们对模型进行了训练。现在是最终揭晓的时候了。我们将在一个未见过的测试数据集上评估性能,看看模型的表现如何。我们还将可视化模型输出,并将其与真实目标进行比较,将它们并排绘制出来。
我们可以在未见过的测试图像上运行模型,并评估其性能。为此,我们执行以下操作:
deeplabv3.evaluate(test_image_ds, steps=n_valid)
测试集的大小与验证集相同,因为我们将在 val.txt 中列出的图像分成两个相等的验证集和测试集。这将返回大约
-
62% 的平均 IoU
-
87% 的平均准确率
-
91% 的像素准确率
考虑到我们的情况,这些得分非常可观。我们的训练数据集包含不到 1500 张分割图像。使用这些数据,我们能够训练出一个在大小约为 725 的测试数据集上达到约 62% 平均 IoU 的模型。
什么是技术发展的最新成果?
Pascal VOC 2012 的最新性能报告显示约 90% 的平均 IoU (mng.bz/o2m2)。然而,这些模型比我们在这里使用的模型要大得多且复杂得多。此外,它们通常使用一个称为语义边界数据集(SBD)的辅助数据集进行训练(该数据集在论文 mng.bz/nNve 中介绍)。这将使训练数据点数量增加到 10000 多个(几乎是我们当前训练集大小的七倍)。
您可以通过直观检查模块生成的一些结果来进一步研究模型。毕竟,我们正在开发一种视觉模型。因此,我们不应仅仅依靠数字来做出决定和结论。在确定结论之前,我们还应该对结果进行视觉分析。
基于 U-Net 的网络的结果会是什么样的呢?
在为 DeepLab v3 模型提供类似条件的情况下,使用预训练的 ResNet-50 模型作为编码器构建的 U-Net 模型仅能达到约 32.5% 的平均 IoU、78.5% 的平均准确率和 81.5% 的像素准确率。实现细节在 ch08 文件夹的 Jupyter 笔记本中提供。
在一台搭载 Intel Core i5 处理器和 NVIDIA GeForce RTX 2070 8GB 显卡的机器上,训练 25 个周期大约需要 55 分钟。
U-Net 模型的详细解释见附录 B。
为了完成这个调查,我们将从测试集中随机选择一些样本,并要求模型对每个图像进行分割地图的预测。然后我们将将结果并排绘制在一起,以确保我们的模型工作得很好(图 8.15)。
图 8.15 对比真实标注目标和模型预测结果。可以看出,该模型在将不同背景的物体分离方面表现很好。
我们可以看到,除非是一张非常困难的图像(例如左上角的图像,其中有一个被栅栏遮挡的汽车),我们的模型表现得非常好。它可以以高准确率识别我们分析的样本中几乎所有的图像。可在笔记本中找到显示图像的代码。
这就是我们关于图像分割的讨论。在接下来的几章中,我们将讨论几个自然语言处理问题。
练习 6
您被给予的是
-
一个模型(称为 model)
-
名为 batch_image 的图像批次(已预处理并准备好馈送给模型)
-
相应的目标批次,batch_targets(以独热编码格式表示的真实分割掩码)
编写一个名为 get_top_bad_examples(model, batch_images, batch_targets, n) 的函数,它将返回批次图像中损失最高(最难的)图像的前 n 个索引。对于给定的预测掩码和目标掩码,可以将元素逐个相乘的和作为给定图像的损失。
您可以使用 model.predict() 函数对 batch_images 进行预测,并返回与 batch_targets 相同大小的预测掩码。一旦计算出批次的损失(batch_loss),您可以使用 tf.math.top_k(batch_loss, n) 函数获取具有最高值的元素的索引。 tf.math .top_k() 返回一个元组,包含给定向量的前 n 个值和索引,按顺序排列。
总结
-
分割模型分为两大类别:语义分割和实例分割。
-
tf.data API 提供了多种功能来实现复杂的数据流水线,例如使用自定义的 NumPy 函数,使用 tf.data.Dataset.map() 执行快速转换以及使用 prefetch 和 cache 等 I/O 优化技术。
-
DeepLab v3 是一种常用的分割模型,它使用了预训练的 ResNet-50 模型作为骨干网络,并通过在卷积操作的权重之间插入孔(即零)来增加感受野。
-
DeepLab v3 模型使用称为空洞空间金字塔池化(atrous spatial pyramid pooling)的模块来在多个尺度上聚合信息,以生成精细分割的输出。
-
在分割任务中,交叉熵损失和 dice 损失是两种常用的损失函数,而像素准确率、平均准确率和平均 IoU 是常用的评估指标。
-
在 TensorFlow 中,损失函数可以实现为无状态函数。但是度量指标必须通过从 tf.keras.metrics.Metric 基类或适当的类中继承来实现为具有状态的对象。
-
DeepLab v3 模型在 Pascal VOC 2010 数据集上达到了 62%的平均 IoU 精度。
练习答案
练习 1
palettized_image = np.zeros(shape=rgb_image.shape[:-1])
for i in range(rgb_image.shape[0]):
for j in range(rgb_image.shape[1]):
for k in range(palette.shape[0]):
if (palette[k] == rgb_image[i,j]).all():
palettized_image[i,j] = k
break
练习 2
dataset_a = tf.data.Dataset.from_tensor_slices(a)
dataset_b = tf.data.Dataset.from_tensor_slices(b)
image_ds = tf.data.Dataset.zip((dataset_a, dataset_b))
image_ds = image_ds.map(
lambda x, y: (x, tf.image.resize(y, (64,64), method='nearest'))
)
image_ds = image_ds.map(
lambda x, y: ((x-128.0)/255.0, tf.image.resize(y, (64,64), method='nearest'))
)
image_ds = image_ds.batch(32).repeat(5).prefetch(tf.data.experimental.AUTOTUNE)
练习 3
import tensorflow.keras.layers as layers
def aug_aspp(out_1, out_2):
atrous_out_1 = layers.Conv2D(128, (3,3), dilation_rate=16,
➥ padding='same', activation='relu')(out_1)
atrous_out_2_1 = layers.Conv2D(128, (3,3), dilation_rate=8,
➥ padding='same', activation='relu')(out_1)
atrous_out_2_2 = layers.Conv2D(128, (3,3), dilation_rate=8,
➥ padding='same', activation='relu')(out_2)
atrous_out_2 = layers.Concatenate()([atrous_out_2_1, atrous_out_2_2])
tmp1 = layers.Conv2D(64, (1,1), padding='same', activation='relu')(atrous_out_1)
tmp2 = layers.Conv2D(64, (1,1), padding='same', activation='relu')(atrous_out_2)
conv_out = layers.Concatenate()([tmp1,tmp2])
out = layers.UpSampling2D((2,2), interpolation='bilinear')(conv_out)
out = layers.Activation('sigmoid')(out)
return out
练习 4
out = (y_pred - (y_pred * y_true)) + (y_true - (y_pred * y_true))
练习 5
练习 6
def get_top_n_bad_examples(model, batch_images, batch_targets, n):
batch_pred = model.predict(batch_images)
batch_loss = tf.reduce_sum(batch_pred*batch_targets, axis=[1,2,3])
_, hard_inds = tf.math.top_k(batch_loss, n)
return hard_inds