TensorFlow 2.0 神经网络实用指南(三)
原文:
annas-archive.org/md5/87c674ffae26bfd552462cfe4dde7475译者:飞龙
第八章:物体检测简介
在图像中检测和分类物体是一个具有挑战性的问题。到目前为止,我们在简单层面上处理了图像分类的问题;但在现实场景中,我们不太可能只拥有包含一个物体的图像。在工业环境中,可以设置相机和机械支撑来捕捉单个物体的图像。然而,即使在像工业这样的受限环境中,也不总是能够拥有如此严格的设置。智能手机应用、自动化引导车辆,以及更一般的,任何在非受控环境中捕捉图像的现实应用,都需要在输入图像中同时进行多个物体的定位和分类。物体检测是通过预测包含物体的边界框的坐标来定位图像中的物体,同时正确分类它的过程。
解决物体检测问题的最先进方法基于卷积神经网络,正如我们在本章中将看到的,它不仅可以用于提取有意义的分类特征,还可以回归边界框的坐标。由于这是一个具有挑战性的问题,因此最好从基础开始。检测和分类多个物体比仅解决单一物体问题需要更复杂的卷积架构设计和训练。回归单个物体的边界框坐标并对内容进行分类的任务被称为定位和分类。解决此任务是开发更复杂架构以解决物体检测任务的起点。
在本章中,我们将研究这两个问题;我们从基础开始,完全开发一个回归网络,然后将其扩展为同时执行回归和分类。章节最后将介绍基于锚点的检测器,因为完整实现物体检测网络超出了本书的范围。
本章使用的数据集是 PASCAL Visual Object Classes Challenge 2007。
本章将涵盖以下主题:
-
获取数据
-
物体定位
-
分类与定位
获取数据
物体检测是一个监督学习问题,需要大量的数据才能达到良好的性能。通过在物体周围绘制边界框并为其分配正确标签,仔细注释图像的过程是一个费时的过程,需要几个小时的重复工作。
幸运的是,已经有几个现成可用的物体检测数据集。最著名的是 ImageNet 数据集,紧随其后的是 PASCAL VOC 2007 数据集。要能够使用 ImageNet,需要专门的硬件,因为它的大小和每张图片中标注的物体数量使得物体检测任务难以完成。
相比之下,PASCAL VOC 2007 只包含 9,963 张图像,每张图像中标注的物体数量不同,且属于 20 个选定的物体类别。20 个物体类别如下:
-
Person: 人物
-
Animal: 鸟、猫、牛、狗、马、羊
-
Vehicle: 飞机、自行车、船、公共汽车、汽车、摩托车、火车
-
Indoor: 瓶子、椅子、餐桌、盆栽植物、沙发、电视/显示器
如官方数据集页面所述(host.robots.ox.ac.uk/pascal/VOC/voc2007/),该数据集已经分为三个部分(训练、验证和测试)可供使用。数据已经被划分为 50%的训练/验证集和 50%的测试集。各类图像和物体的分布在训练/验证集和测试集之间大致相等。总共有 9,963 张图像,包含 24,640 个标注的物体。
TensorFlow 数据集允许我们通过一行代码下载整个数据集(约 869 MiB),并获取每个分割的tf.data.Dataset对象:
(tf2)
import tensorflow as tf
import tensorflow_datasets as tfds
# Train, test, and validation are datasets for object detection: multiple objects per image.
(train, test, validation), info = tfds.load(
"voc2007", split=["train", "test", "validation"], with_info=True
)
和往常一样,TensorFlow 数据集提供了很多关于数据集格式的有用信息。以下输出是print(info)的结果:
tfds.core.DatasetInfo(
name='voc2007',
version=1.0.0,
description='This dataset contains the data from the PASCAL Visual Object Classes Challenge
2007, a.k.a. VOC2007, corresponding to the Classification and Detection
competitions.
A total of 9,963 images are included in this dataset, where each image contains
a set of objects, out of 20 different classes, making a total of 24,640
annotated objects.
In the Classification competition, the goal is to predict the set of labels
contained in the image, while in the Detection competition the goal is to
predict the bounding box and label of each individual object.
',
urls=['http://host.robots.ox.ac.uk/pascal/VOC/voc2007/'],
features=FeaturesDict({
'image': Image(shape=(None, None, 3), dtype=tf.uint8),
'image/filename': Text(shape=(), dtype=tf.string, encoder=None),
'labels': Sequence(shape=(None,), dtype=tf.int64, feature=ClassLabel(shape=(), dtype=tf.int64, num_classes=20)),
'labels_no_difficult': Sequence(shape=(None,), dtype=tf.int64, feature=ClassLabel(shape=(), dtype=tf.int64, num_classes=20)),
'objects': SequenceDict({'label': ClassLabel(shape=(), dtype=tf.int64, num_classes=20), 'bbox': BBoxFeature(shape=(4,), dtype=tf.float32), 'pose': ClassLabel(shape=(), dtype=tf.int64, num_classes=5), 'is_truncated': Tensor(shape=(), dtype=tf.bool), 'is_difficult'
: Tensor(shape=(), dtype=tf.bool)})
},
total_num_examples=9963,
splits={
'test': <tfds.core.SplitInfo num_examples=4952>,
'train': <tfds.core.SplitInfo num_examples=2501>,
'validation': <tfds.core.SplitInfo num_examples=2510>
},
supervised_keys=None,
citation='"""
@misc{pascal-voc-2007,
author = "Everingham, M. and Van~Gool, L. and Williams, C. K. I. and Winn, J. and Zisserman, A.",
title = "The {PASCAL} {V}isual {O}bject {C}lasses {C}hallenge 2007 {(VOC2007)} {R}esults",
howpublished = "http://www.pascal-network.org/challenges/VOC/voc2007/workshop/index.html"}
"""',
redistribution_info=,
)
对于每张图像,都有一个SequenceDict对象,其中包含每个标注物体的信息。在处理任何数据相关项目时,查看数据非常方便。在这个案例中,特别是因为我们正在解决一个计算机视觉问题,查看图像和边界框可以帮助我们更好地理解网络在训练过程中应该面对的难题。
为了可视化标注图像,我们使用matplotlib.pyplot结合使用tf.image包;前者用于显示图像,后者用于绘制边界框并将其转换为tf.float32(从而将值缩放到[0,1]的范围内)。此外,演示了如何使用tfds.ClassLabel.int2str方法;这个方法非常方便,因为它允许我们从标签的数值表示中获取文本表示:
(tf2)
import matplotlib.pyplot as plt
从训练集获取五张图像,绘制边界框,然后打印类别:
with tf.device("/CPU:0"):
for row in train.take(5):
obj = row["objects"]
image = tf.image.convert_image_dtype(row["image"], tf.float32)
for idx in tf.range(tf.shape(obj["label"])[0]):
image = tf.squeeze(
tf.image.draw_bounding_boxes(
images=tf.expand_dims(image, axis=[0]),
boxes=tf.reshape(obj["bbox"][idx], (1, 1, 4)),
colors=tf.reshape(tf.constant((1.0, 1.0, 0, 0)), (1, 4)),
),
axis=[0],
)
print(
"label: ", info.features["objects"]["label"].int2str(obj["label"][idx])
)
然后,使用以下代码绘制图像:
plt.imshow(image)
plt.show()
以下图像是由代码片段生成的五张图像的拼贴画:
请注意,由于 TensorFlow 数据集在创建 TFRecords 时会对数据进行打乱,因此在不同机器上执行相同的操作时,不太可能产生相同的图像顺序。
还值得注意的是,部分物体被标注为完整物体;例如,左下角图像中的人类手被标记为一个人,图片右下角的摩托车后轮被标记为摩托车。
物体检测任务本质上具有挑战性,但通过查看数据,我们可以看到数据本身很难使用。事实上,打印到标准输出的右下角图像的标签是:
-
人物
-
鸟类
因此,数据集包含了完整的物体标注和标签(鸟类),以及部分物体标注并被标记为完整物体(例如,人类的手被标记为一个人)。这个简单的例子展示了物体检测的困难:网络应该能够根据属性(如手)或完整形状(如人)进行分类和定位,同时解决遮挡问题。
查看数据让我们更清楚问题的挑战性。然而,在面对物体检测的挑战之前,最好从基础开始,先解决定位和分类的问题。因此,我们必须过滤数据集中的物体,仅提取包含单个标注物体的图像。为此,可以定义并使用一个简单的函数,该函数接受tf.data.Dataset对象作为输入并对其进行过滤。通过过滤元素创建数据集的子集:我们感兴趣的是创建一个用于物体检测和分类的数据集,即一个包含单个标注物体的图像数据集:
(tf2)
def filter(dataset):
return dataset.filter(lambda row: tf.equal(tf.shape(row["objects"]["label"])[0], 1))
train, test, validation = filter(train), filter(test), filter(validation)
使用之前的代码片段,我们可以可视化一些图像,以检查是否一切如我们所预期:
我们可以看到从训练集抽取的、只包含单个物体的图像,使用之前的代码片段应用filter函数后绘制出来。filter函数返回一个新的数据集,该数据集仅包含输入数据集中包含单个边界框的元素,因此它们是训练单个网络进行分类和定位的完美候选。
物体定位
卷积神经网络(CNN)是极其灵活的对象——到目前为止,我们已经使用它们解决分类问题,让它们学习提取特定任务的特征。如在第六章《使用 TensorFlow Hub 进行图像分类》中所示,设计用于分类图像的 CNN 标准架构由两部分组成——特征提取器,它生成特征向量,以及一组全连接层,用于将特征向量分类到(希望是)正确的类别:
放置在特征向量顶部的分类器也可以看作是网络的头部
到目前为止,卷积神经网络(CNN)仅被用来解决分类问题,这一点不应误导我们。这些类型的网络非常强大,特别是在多层设置下,它们可以用来解决多种不同类型的问题,从视觉输入中提取信息。
因此,解决定位和分类问题的关键只是向网络中添加一个新的头,即定位头。
输入数据是一张包含单一物体以及边界框四个坐标的图像。因此,目标是利用这些信息通过将定位问题视为回归问题,来同时解决分类和定位问题。
将定位视为回归问题
暂时忽略分类问题,专注于定位部分,我们可以将定位问题视为回归输入图像中包含物体的边界框的四个坐标的问题。
实际上,训练 CNN 来解决分类任务或回归任务并没有太大区别:特征提取器的架构保持不变,而分类头则变成回归头。最终,这只是意味着将输出神经元的数量从类别数更改为 4,每个坐标一个神经元。
其理念是,当某些输入特征存在时,回归头应该学习输出正确的坐标。
使用 AlexNet 架构作为特征提取器,并将分类头替换为一个具有四个输出神经元的回归头
为了使网络学习回归物体边界框的坐标,我们必须使用损失函数来表达神经元和标签之间的输入/输出关系(即数据集中存在的边界框四个坐标)。
L2 距离可以有效地用作损失函数:目标是正确回归四个坐标,从而最小化预测值与真实值之间的距离,使其趋近于零:
第一个元组 ![] 是回归头输出,第二个元组 ![] 表示真实的边界框坐标。
在 TensorFlow 2.0 中实现回归网络是直接的。如 第六章 《使用 TensorFlow Hub 进行图像分类》所示,可以通过使用 TensorFlow Hub 下载并嵌入预训练的特征提取器来加速训练阶段。
值得指出的一个细节是 TensorFlow 用于表示边界框坐标(以及一般坐标)的方法—使用的格式是[ymin, xmin, ymax, xmax],并且坐标在[0,1]范围内进行归一化,以避免依赖于原始图像分辨率。
使用 TensorFlow 2.0 和 TensorFlow Hub,我们可以通过几行代码在 PASCAL VOC 2007 数据集上定义并训练坐标回归网络。
使用来自 TensorFlow Hub 的 Inception v3 网络作为坐标回归网络的骨干,定义回归模型是直接的。尽管该网络具有顺序结构,我们通过函数式 API 定义它,因为这将使我们能够轻松扩展模型,而无需重写:
(tf2)
import tensorflow_hub as hub
inputs = tf.keras.layers.Input(shape=(299,299,3))
net = hub.KerasLayer(
"https://tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2",
output_shape=[2048],
trainable=False,
)(inputs)
net = tf.keras.layers.Dense(512)(net)
net = tf.keras.layers.ReLU()(net)
coordinates = tf.keras.layers.Dense(4, use_bias=False)(net)
regressor = tf.keras.Model(inputs=inputs, outputs=coordinates)
此外,由于我们决定使用需要 299 x 299 输入图像分辨率且值在[0,1]范围内的 Inception 网络,我们需要在输入管道中增加额外的步骤来准备数据:
(tf2)
def prepare(dataset):
def _fn(row):
row["image"] = tf.image.convert_image_dtype(row["image"], tf.float32)
row["image"] = tf.image.resize(row["image"], (299, 299))
return row
return dataset.map(_fn)
train, test, validation = prepare(train), prepare(test), prepare(validation)
如前所述,使用的损失函数是标准的 L2 损失,TensorFlow 已经将其作为 Keras 损失实现,可以在tf.losses包中找到。然而,值得注意的是,我们自己定义损失函数,而不是使用tf.losses.MeanSquaredError,因为有一个细节需要强调。
如果我们决定使用已实现的均方误差(MSE)函数,我们必须考虑到,在底层使用了tf.subtract操作。该操作仅仅计算左侧操作数与右侧操作数的差值。这种行为是我们所期望的,但 TensorFlow 中的减法操作遵循 NumPy 的广播语义(几乎所有数学操作都遵循此语义)。这种语义将左侧张量的值广播到右侧张量,如果右侧张量的某个维度为 1,则会将左侧张量的值复制到该位置。
由于我们选择的图像中只有一个物体,因此在"bbox"属性中只有一个边界框。因此,如果我们选择批处理大小为 32,则包含边界框的张量将具有形状(32, 1, 4)。第二个位置的 1 可能会在损失计算中引起问题,并阻止模型收敛。
因此,我们有两个选择:
-
使用 Keras 定义损失函数,通过使用
tf.squeeze去除一维维度 -
手动定义损失函数
实际上,手动定义损失函数使我们能够在函数体内放置tf.print语句,这可以用于原始调试过程,且更重要的是,以标准方式定义训练循环,使得损失函数本身能够处理在需要时去除一维维度。
(tf2)
# First option -> this requires to call the loss l2, taking care of squeezing the input
# l2 = tf.losses.MeanSquaredError()
# Second option, it is the loss function iself that squeezes the input
def l2(y_true, y_pred):
return tf.reduce_mean(
tf.square(y_pred - tf.squeeze(y_true, axis=[1]))
)
训练循环很简单,可以通过两种不同的方式来实现:
-
编写自定义训练循环(因此使用
tf.GradientTape对象) -
使用 Keras 模型的
compile和fit方法,因为这是 Keras 为我们构建的标准训练循环。
然而,由于我们有兴趣在接下来的章节中扩展此解决方案,最好开始使用自定义训练循环,因为它提供了更多的自定义自由度。此外,我们有兴趣通过在 TensorBoard 上记录它们来可视化真实值和预测的边界框。
因此,在定义训练循环之前,值得定义一个draw函数,该函数接受数据集、模型和当前步骤,并利用它们来绘制真实框和预测框:
(tf2)
def draw(dataset, regressor, step):
with tf.device("/CPU:0"):
row = next(iter(dataset.take(3).batch(3)))
images = row["image"]
obj = row["objects"]
boxes = regressor(images)
tf.print(boxes)
images = tf.image.draw_bounding_boxes(
images=images, boxes=tf.reshape(boxes, (-1, 1, 4))
)
images = tf.image.draw_bounding_boxes(
images=images, boxes=tf.reshape(obj["bbox"], (-1, 1, 4))
)
tf.summary.image("images", images, step=step)
我们的坐标回归器的训练循环(它也可以被视为一个区域提议,因为它现在已经知道它正在图像中检测的物体的标签),同时在 TensorBoard 上记录训练损失值和来自训练集和验证集的三个样本图像的预测(使用draw函数),可以很容易地定义:
- 定义
global_step变量,用于跟踪训练迭代,然后定义文件写入器,用于记录训练和验证摘要:
optimizer = tf.optimizers.Adam()
epochs = 500
batch_size = 32
global_step = tf.Variable(0, trainable=False, dtype=tf.int64)
train_writer, validation_writer = (
tf.summary.create_file_writer("log/train"),
tf.summary.create_file_writer("log/validation"),
)
with validation_writer.as_default():
draw(validation, regressor, global_step)
- 根据 TensorFlow 2.0 的最佳实践,我们可以将训练步骤定义为一个函数,并使用
tf.function将其转换为图形表示:
@tf.function
def train_step(image, coordinates):
with tf.GradientTape() as tape:
loss = l2(coordinates, regressor(image))
gradients = tape.gradient(loss, regressor.trainable_variables)
optimizer.apply_gradients(zip(gradients, regressor.trainable_variables))
return loss
- 在每个批次上定义训练循环,并在每次迭代中调用
train_step函数:
train_batches = train.cache().batch(batch_size).prefetch(1)
with train_writer.as_default():
for _ in tf.range(epochs):
for batch in train_batches:
obj = batch["objects"]
coordinates = obj["bbox"]
loss = train_step(batch["image"], coordinates)
tf.summary.scalar("loss", loss, step=global_step)
global_step.assign_add(1)
if tf.equal(tf.mod(global_step, 10), 0):
tf.print("step ", global_step, " loss: ", loss)
with validation_writer.as_default():
draw(validation, regressor, global_step)
with train_writer.as_default():
draw(train, regressor, global_step)
尽管使用了 Inception 网络作为固定的特征提取器,但训练过程在 CPU 上可能需要几个小时,而在 GPU 上则几乎需要半个小时。
以下截图显示了训练过程中损失函数的可见趋势:
我们可以看到,从早期的训练步骤开始,损失值接近零,尽管在整个训练过程中会出现波动。
在训练过程中,在 TensorBoard 的图像标签中,我们可以可视化带有回归框和真实边界框的图像。由于我们创建了两个不同的日志记录器(一个用于训练日志,另一个用于验证日志),TensorFlow 为我们可视化了两个不同数据集的图像:
上述图像是来自训练集(第一行)和验证集(第二行)的样本,包含真实框和回归边界框。训练集中的回归边界框接近真实框,而验证集中的回归框则有所不同。
之前定义的训练循环存在各种问题:
-
唯一被测量的指标是 L2 损失。
-
验证集从未用于衡量任何数值分数。
-
没有进行过拟合检查。
-
完全缺乏一个衡量回归边界框质量的指标,既没有在训练集上,也没有在验证集上进行衡量。
因此,训练循环可以通过测量目标检测指标来改进;测量该指标还可以减少训练时间,因为我们可以提前停止训练。此外,从结果的可视化中可以明显看出,模型正在过拟合训练集,可以添加正则化层(如 dropout)来解决这个问题。回归边界框的问题可以视为一个二分类问题。事实上,只有两种可能的结果:真实边界框匹配或不匹配。
当然,达到完美匹配并非易事;因此,需要一个衡量检测到的边界框与真实值之间好坏的数值评分函数。最常用的用于衡量定位好坏的函数是交并比(IoU),我们将在下一节中详细探讨。
交并比(IoU)
交并比(IoU)定义为重叠区域与并集区域的比率。以下图像是 IoU 的图示:
版权归属:Jonathan Hui (medium.com/@jonathan_hui/map-mean-average-precision-for-object-detection-45c121a31173)
在实践中,IoU 衡量的是预测的边界框与真实边界框的重叠程度。由于 IoU 是一个使用物体区域的指标,因此可以很容易地将真实值和检测区域视为集合来表示。设 A 为提议物体像素的集合,B 为真实物体像素的集合;则 IoU 定义为:
IoU 值在[0,1]范围内,其中 0 表示无匹配(没有重叠),1 表示完美匹配。IoU 值用作重叠标准;通常,IoU 值大于 0.5 被认为是正匹配(真正例),而其他值被视为假匹配(假正例)。没有真正的负例。
在 TensorFlow 中实现 IoU 公式非常简单。唯一需要注意的细节是,需要对坐标进行反归一化,因为面积应该以像素为单位来计算。像素坐标的转换以及更友好的坐标交换表示是在_swap闭包中实现的:
(tf2)
def iou(pred_box, gt_box, h, w):
"""
Compute IoU between detect box and gt boxes
Args:
pred_box: shape (4,): y_min, x_min, y_max, x_max - predicted box
gt_boxes: shape (4,): y_min, x_min, y_max, x_max - ground truth
h: image height
w: image width
"""
将y_min、x_min、y_max和x_max的绝对坐标转换为x_min、y_min、x_max和y_max的像素坐标:
def _swap(box):
return tf.stack([box[1] * w, box[0] * h, box[3] * w, box[2] * h])
pred_box = _swap(pred_box)
gt_box = _swap(gt_box)
box_area = (pred_box[2] - pred_box[0]) * (pred_box[3] - pred_box[1])
area = (gt_box[2] - gt_box[0]) * (gt_box[3] - gt_box[1])
xx1 = tf.maximum(pred_box[0], gt_box[0])
yy1 = tf.maximum(pred_box[1], gt_box[1])
xx2 = tf.minimum(pred_box[2], gt_box[2])
yy2 = tf.minimum(pred_box[3], gt_box[3])
然后,计算边界框的宽度和高度:
w = tf.maximum(0, xx2 - xx1)
h = tf.maximum(0, yy2 - yy1)
inter = w * h
return inter / (box_area + area - inter)
平均精度
如果 IoU 值大于指定阈值(通常为 0.5),则可以将回归的边界框视为匹配。
在单类预测的情况下,计算真实正例(TP)和假正例(FP)的数量,能够使我们计算出平均精度,如下所示:
在目标检测挑战中,平均精度(AP)通常会在不同的 IoU 值下进行测量。最小要求是对 IoU 值为 0.5 时测量 AP,但在大多数实际场景中,单纯达到 0.5 的重叠并不足够。通常情况下,实际上,边界框预测需要至少匹配 IoU 值为 0.75 或 0.85 才能有用。
到目前为止,我们处理的是单类情况下的 AP,但值得讨论更一般的多类目标检测场景。
平均精度均值
在多类检测的情况下,每个回归的边界框可以包含可用类之一,评估目标检测器性能的标准指标是平均精度均值(mAP)。
计算它非常简单——mAP 是数据集中每个类别的平均精度:
了解用于目标检测的指标后,我们可以通过在每个训练周期结束时,在验证集上添加此测量,并每十步在一批训练数据上进行测量,从而改进训练脚本。由于目前定义的模型仅是一个没有类别的坐标回归器,因此测量的指标将是 AP。
在 TensorFlow 中实现 mAP 非常简单,因为tf.metrics包中已经有现成的实现可用。update_state方法的第一个参数是真实标签;第二个参数是预测标签。例如,对于二分类问题,一个可能的场景如下:
(tf2)
m = tf.metrics.Precision()
m.update_state([0, 1, 1, 1], [1, 0, 1, 1])
print('Final result: ', m.result().numpy()) # Final result: 0.66
还应注意,平均精度和 IoU 并不是目标检测专有的指标,但它们可以在执行任何定位任务时使用(IoU)并测量检测精度(mAP)。
在第八章中,语义分割与自定义数据集构建器专门讨论语义分割任务,使用相同的指标来衡量分割模型的性能。唯一的区别是,IoU 是以像素级别来衡量的,而不是使用边界框。训练循环可以改进;在下一节中,将展示一个改进后的训练脚本草案,但真正的改进将留作练习。
改进训练脚本
测量平均精度(针对单一类别)需要你为 IoU 测量设置阈值,并定义tf.metrics.Precision对象,该对象计算批次上的平均精度。
为了不改变整个代码结构,draw函数不仅用于绘制地面真值和回归框,还用于测量 IoU 并记录平均精度的总结:
(tf2)
# IoU threshold
threshold = 0.75
# Metric object
precision_metric = tf.metrics.Precision()
def draw(dataset, regressor, step):
with tf.device("/CPU:0"):
row = next(iter(dataset.take(3).batch(3)))
images = row["image"]
obj = row["objects"]
boxes = regressor(images)
images = tf.image.draw_bounding_boxes(
images=images, boxes=tf.reshape(boxes, (-1, 1, 4))
)
images = tf.image.draw_bounding_boxes(
images=images, boxes=tf.reshape(obj["bbox"], (-1, 1, 4))
)
tf.summary.image("images", images, step=step)
true_labels, predicted_labels = [], []
for idx, predicted_box in enumerate(boxes):
iou_value = iou(predicted_box, tf.squeeze(obj["bbox"][idx]), 299, 299)
true_labels.append(1)
predicted_labels.append(1 if iou_value >= threshold else 0)
precision_metric.update_state(true_labels, predicted_labels)
tf.summary.scalar("precision", precision_metric.result(), step=step)
作为一个练习(参见练习部分),你可以使用这段代码作为基线并重新组织结构,以便改善代码的组织方式。改善代码组织后,建议重新训练模型并分析精度图。
仅仅进行物体定位,而没有关于物体类别的信息,实用性有限,但在实践中,这是任何物体检测算法的基础。
分类与定位
目前定义的这种架构,没有关于它正在定位的物体类别的信息,称为区域提议。
使用单一神经网络进行物体检测和定位是可行的。事实上,完全可以在特征提取器的顶部添加第二个头,并训练它对图像进行分类,同时训练回归头来回归边界框坐标。
同时解决多个任务是多任务学习的目标。
多任务学习
Rich Caruna 在他的论文多任务学习(1997 年)中定义了多任务学习:
“多任务学习是一种归纳迁移方法,它通过利用相关任务训练信号中的领域信息作为归纳偏差来改善泛化能力。它通过并行学习任务,同时使用共享的表示;每个任务学到的内容可以帮助其他任务更好地学习。”
在实践中,多任务学习是机器学习的一个子领域,明确的目标是解决多个不同的任务,利用任务之间的共性和差异。经实验证明,使用相同的网络来解决多个任务,通常比使用同一网络分别训练解决每个任务的效果更好,能够提高学习效率和预测准确性。
多任务学习有助于解决过拟合问题,因为神经网络不太可能将其参数适应于解决一个特定任务,因此它必须学习如何提取对解决不同任务有用的有意义特征。
双头网络
在过去的几年里,已经开发了几种用于物体检测和分类的架构,采用两步过程。第一步是使用区域提议获取可能包含物体的输入图像区域。第二步是对提议的区域使用简单的分类器进行分类。
使用双头神经网络可以使推理时间更快,因为只需要进行一次单模型的前向传播,就可以实现更好的整体性能。
从架构的角度看,假设为了简单起见我们的特征提取器是 AlexNet(而实际上是更复杂的网络 Inception V3),向网络添加新头会改变模型架构,如下截图所示:
上面的截图展示了分类和定位网络的样子。特征提取部分应该能够提取足够通用的特征,以使两个头能够使用相同的共享特征解决这两种不同的任务。
从代码的角度来看,由于我们使用了 Keras 函数式模型定义,向模型添加额外输出非常简单。实际上,这仅仅是添加组成新头的所需层数,并将最终层添加到 Keras 模型定义的输出列表中。正如本书到目前为止所展示的,这第二个头必须以等于模型将训练的类别数量的神经元结束。在我们的例子中,PASCAL VOC 2007 数据集包含 20 个不同的类别。因此,我们只需要按如下方式定义模型:
(tf2)
- 首先,从输入层定义开始:
inputs = tf.keras.layers.Input(shape=(299, 299, 3))
- 然后,使用 TensorFlow Hub,我们定义固定的(不可训练的)特征提取器:
net = hub.KerasLayer(
"https://tfhub.dev/google/tf2-preview/inception_v3/feature_vector/2",
output_shape=[2048],
trainable=False,
)(inputs)
- 然后,我们定义回归头,它只是一个由全连接层堆叠组成的部分,最后以四个线性神经元结束(每个边界框坐标一个):
regression_head = tf.keras.layers.Dense(512)(net)
regression_head = tf.keras.layers.ReLU()(regression_head)
coordinates = tf.keras.layers.Dense(4, use_bias=False)(regression_head)
- 接下来,我们定义分类头,它只是一个由全连接层堆叠组成的部分,经过训练用于分类由固定(不可训练)特征提取器提取的特征:
classification_head = tf.keras.layers.Dense(1024)(net)
classification_head = tf.keras.layers.ReLU()(classificatio_head)
classification_head = tf.keras.layers.Dense(128)(net)
classification_head = tf.keras.layers.ReLU()(classificatio_head)
num_classes = 20
classification_head = tf.keras.layers.Dense(num_classes, use_bias=False)(
classification_head
)
- 最后,我们可以定义将执行分类和定位的 Keras 模型。请注意,该模型有一个输入和两个输出:
model = tf.keras.Model(inputs=inputs, outputs=[coordinates, classification_head])
使用 TensorFlow 数据集,我们拥有执行分类和定位所需的所有信息,因为每一行都是一个字典,包含图像中每个边界框的标签。此外,由于我们已过滤数据集,确保其中仅包含单个对象的图像,因此我们可以像训练分类模型一样训练分类头,如第六章所示,使用 TensorFlow Hub 进行图像分类。
训练脚本的实现留作练习(见练习部分)。训练过程的唯一特点是要使用的损失函数。为了有效地训练网络同时执行不同任务,损失函数应包含每个任务的不同项。
通常,使用不同项的加权和作为损失函数。在我们的例子中,一项是分类损失,它通常是稀疏类别交叉熵损失,另一项是回归损失(之前定义的 L2 损失):
乘法因子 ![] 是超参数,用来赋予不同任务不同的重要性(梯度更新的强度)。
使用单一物体的图像进行分类并回归唯一存在的边界框坐标,只能在有限的实际场景中应用。相反,通常情况下,给定输入图像,要求同时定位和分类多个物体(即实际的物体检测问题)。
多年来,已经提出了多种物体检测模型,最近超越所有其他模型的都是基于锚框概念的模型。我们将在下一节探讨基于锚框的检测器。
基于锚框的检测器
基于锚框的检测器依赖锚框的概念,通过单一架构一次性检测图像中的物体。
基于锚框的检测器的直观思路是将输入图像划分为多个兴趣区域(锚框),并对每个区域应用定位和回归网络。其核心思想是让网络不仅学习回归边界框的坐标并分类其内容,还要使用同一网络在一次前向传播中查看图像的不同区域。
为了训练这些模型,不仅需要一个包含注释真实框的数据集,还需要为每张输入图像添加一组新的框,这些框与真实框有一定程度的重叠(具有所需的 IoU)。
锚框
锚框是输入图像在不同区域的离散化,也称为锚点或边界框先验。锚框概念背后的思路是,输入图像可以在不同的区域中离散化,每个区域有不同的外观。一个输入图像可能包含大物体和小物体,因此,离散化应该在不同的尺度下进行,以便同时检测不同分辨率下的物体。
在将输入离散化为锚框时,重要的参数如下:
-
网格大小:输入图像如何被均匀划分
-
框的尺度级别:给定父框,如何调整当前框的大小
-
长宽比级别:对于每个框,宽度与高度之间的比率
输入图像可以被划分为一个均匀尺寸的网格,例如 4 x 4 网格。这个网格的每个单元格可以用不同的尺度(0.5、1、2 等)进行调整,并且每个单元格具有不同的长宽比级别(0.5、1、2 等)。例如,以下图片展示了如何用锚框“覆盖”一张图像:
锚点框的生成影响着网络的性能——较小框的尺寸代表着网络能够检测到的小物体的尺寸。同样的推理也适用于较大框。
在过去几年中,基于锚点的检测器已经证明它们能够达到惊人的检测性能,不仅准确,而且速度更快。
最著名的基于锚点的检测器是You Only Look Once(YOLO),其次是Single Shot MultiBox Detector(SSD)。以下 YOLO 图像检测了图像中的多个物体,且在不同的尺度下,仅通过一次前向传播:
基于锚点的检测器的实现超出了本书的范围,因为理解这些概念需要相应的理论知识,并且这些模型也非常复杂。因此,仅介绍了使用这些模型时发生的直观想法。
总结
本章介绍了目标检测的问题,并提出了一些基本的解决方案。我们首先关注了所需的数据,并使用 TensorFlow 数据集获取了可以在几行代码中直接使用的 PASCAL VOC 2007 数据集。然后,讨论了如何使用神经网络回归边界框的坐标,展示了如何轻松地利用卷积神经网络从图像表示中生成边界框的四个坐标。通过这种方式,我们构建了区域建议(Region Proposal),即一个能够建议在输入图像中检测单个物体的位置的网络,而不产生其他关于检测物体的信息。
之后,介绍了多任务学习的概念,并展示了如何使用 Keras 函数式 API 将分类头与回归头结合。接着,我们简要介绍了基于锚点的检测器。这些检测器通过将输入划分为成千上万个区域(锚点)来解决目标检测的问题(即在单一图像中检测和分类多个物体)。
我们将 TensorFlow 2.0 和 TensorFlow Hub 结合使用,使我们能够通过将 Inception v3 模型作为固定特征提取器来加速训练过程。此外,得益于快速执行,结合纯 Python 和 TensorFlow 代码简化了整个训练过程的定义。
在下一章,我们将学习语义分割和数据集构建器。
练习
你可以回答所有理论问题,也许更重要的是,努力解决每个练习中包含的所有代码挑战:
-
在获取数据部分,我们对 PASCAL VOC 2007 数据集应用了过滤函数,仅选择了包含单一物体的图像。然而,过滤过程没有考虑类别平衡问题。
创建一个函数,给定三个过滤后的数据集,首先合并它们,然后创建三个平衡的拆分(如果不能完全平衡,可以接受适度的类别不平衡)。
-
使用前面提到的拆分方式,重新训练定位和分类网络。性能变化的原因是什么?
-
交并比(IoU)度量的是啥?
-
IoU 值为 0.4 代表什么?是好的匹配还是差的匹配?
-
什么是平均精度均值(mAP)?请解释这个概念并写出公式。
-
什么是多任务学习?
-
多任务学习是如何影响单任务模型的性能的?是提高了还是降低了?
-
在目标检测领域,什么是锚点?
-
描述一个基于锚点的检测器在训练和推理过程中如何查看输入图像。
-
mAP 和 IoU 仅是目标检测的度量标准吗?
-
为了改进目标检测和定位网络的代码,添加支持在每个训练轮次结束时将模型保存到检查点,并恢复模型(以及全局步骤变量)的状态以继续训练过程。
-
定位和回归网络的代码显式使用了一个
draw函数,它不仅绘制边界框,还测量 mAP。通过为每个不同的功能创建不同的函数来改进代码质量。 -
测量网络性能的代码仅使用了三个样本。这是错误的,你能解释原因吗?请修改代码,在训练过程中使用单个训练批次,并在每个训练轮次结束时使用完整的验证集。
-
为“多头网络和多任务学习”中定义的模型编写训练脚本:同时训练回归和分类头,并在每个训练轮次结束时测量训练和验证准确率。
-
筛选 PASCAL VOC 训练、验证和测试数据集,仅保留至少包含一个人(图片中可以有其他标注的物体)的图像。
-
替换训练过的定位和分类网络中的回归和分类头,使用两个新的头部。分类头现在应该只有一个神经元,表示图像包含人的概率。回归头应回归标注为人的物体的坐标。
-
应用迁移学习来训练之前定义的网络。当“人”类别的 mAP 停止增长(容忍范围为 +/- 0.2)并持续 50 步时,停止训练过程。
-
创建一个 Python 脚本,用于生成不同分辨率和尺度的锚框。
第九章:语义分割与自定义数据集构建器
本章中,我们将分析语义分割及其面临的挑战。语义分割是一个具有挑战性的问题,目标是为图像中的每个像素分配正确的语义标签。本章的第一部分介绍了这个问题本身,它为什么重要以及可能的应用。第一部分结束时,我们将讨论著名的 U-Net 语义分割架构,并将其作为一个 Keras 模型在纯 TensorFlow 2.0 风格中实现。模型实现之前,我们将介绍为成功实现语义分割网络所需的反卷积操作。
本章的第二部分从数据集创建开始——由于在撰写时没有tfds构建器支持语义分割,我们利用这一点来介绍 TensorFlow 数据集架构,并展示如何实现一个自定义的 DatasetBuilder。在获取数据后,我们将一步步执行 U-Net 的训练过程,展示使用 Keras 和 Keras 回调函数训练此模型是多么简便。本章以通常的练习部分结束,或许是整章中最关键的部分。理解一个概念的唯一途径就是亲自动手实践。
在本章中,我们将涵盖以下主题:
-
语义分割
-
创建一个 TensorFlow DatasetBuilder
-
模型训练与评估
语义分割
与目标检测不同,目标检测的目标是检测矩形区域中的物体,图像分类的目的是为整张图像分配一个标签,而语义分割是一个具有挑战性的计算机视觉任务,目标是为输入图像的每个像素分配正确的标签:
来自 CityScapes 数据集的语义标注图像示例。每个输入图像的像素都有相应的像素标签。(来源:www.cityscapes-dataset.com/examples/)
语义分割的应用有无数个,但也许最重要的应用领域是自动驾驶和医学影像。
自动引导车和自动驾驶汽车可以利用语义分割的结果,全面理解由安装在车辆上的摄像头捕捉到的整个场景。例如,拥有道路的像素级信息可以帮助驾驶软件更好地控制汽车的位置。通过边界框来定位道路的精度远不如拥有像素级分类,从而能够独立于视角定位道路像素。
在医学影像领域,由目标检测器预测的边界框有时是有用的,有时则没有。事实上,如果任务是检测特定类型的细胞,边界框可以提供足够的信息。但是如果任务是定位血管,单纯使用边界框是不够的。正如可以想象的那样,精细分类并不是一项容易的任务,理论和实践上都面临着许多挑战。
挑战
一个棘手的挑战是获取正确的数据。由于通过图像的主要内容对图像进行分类的过程相对较快,因此有几个庞大的标注图像数据集。一个专业的标注团队每天可以轻松标注数千张图片,因为这项任务仅仅是查看图片并选择一个标签。
也有很多物体检测数据集,其中多个物体已经被定位和分类。与单纯的分类相比,这个过程需要更多的标注时间,但由于它不要求极高的精度,因此是一个相对较快的过程。
语义分割数据集则需要专门的软件和非常耐心的标注员,他们在工作中非常精确。事实上,像素级精度标注的过程可能是所有标注类型中最耗时的。因此,语义分割数据集的数量较少,图像的数量也有限。正如我们将在下一部分中看到的,专门用于数据集创建的 PASCAL VOC 2007 数据集,包含 24,640 个用于图像分类和定位任务的标注物体,但只有大约 600 张标注图像。
语义分割带来的另一个挑战是技术性的。对图像的每一个像素进行分类需要以不同于目前所见的卷积架构方式来设计卷积架构。到目前为止,所有描述的架构都遵循了相同的结构:
-
一个输入层,用于定义网络期望的输入分辨率。
-
特征提取部分是由多个卷积操作堆叠而成,这些卷积操作具有不同的步幅,或者中间夹杂池化操作,逐层减少特征图的空间范围,直到它被压缩成一个向量。
-
分类部分,给定由特征提取器生成的特征向量,训练该部分将此低维表示分类为固定数量的类别。
-
可选地,回归头部,使用相同的特征生成一组四个坐标。
然而,语义分割任务不能遵循这种结构,因为如果特征提取器仅仅是逐层减少输入的分辨率,网络又如何为输入图像的每个像素生成分类呢?
提出的一个解决方案是反卷积操作。
反卷积 – 转置卷积
我们从这一节开始时,先说明“反卷积”这一术语具有误导性。实际上,在数学和工程学中,确实存在反卷积操作,但与深度学习从业者所指的反卷积并没有太多相似之处。
在这个领域中,反卷积操作是转置卷积操作,或甚至是图像调整大小,之后再执行标准卷积操作。是的,两个不同的实现使用了相同的名称。
深度学习中的反卷积操作只保证,如果特征图是输入图和具有特定大小与步幅的卷积核之间卷积的结果,则反卷积操作将生成具有与输入相同空间扩展的特征图,前提是应用相同的卷积核大小和步幅。
为了实现这一点,首先对预处理过的输入进行标准卷积操作,在边界处不仅添加零填充,还在特征图单元内进行填充。以下图示有助于澄清这一过程:
图像及说明来源:《深度学习卷积算术指南》——Vincent Dumoulin 和 Francesco Visin
TensorFlow 通过tf.keras.layers包,提供了一个现成可用的反卷积操作:tf.keras.layers.Conv2DTranspose。
执行反卷积的另一种可能方式是将输入调整为所需的分辨率,并通过在调整后的图像上添加标准的 2D 卷积(保持相同的填充)来使该操作可学习。
简而言之,在深度学习的背景下,真正重要的是创建一个可学习的层,能够重建原始空间分辨率并执行卷积操作。这并不是卷积操作的数学逆过程,但实践表明,这样做足以取得良好的结果。
使用反卷积操作并在医学图像分割任务中取得显著成果的语义分割架构之一是 U-Net 架构。
U-Net 架构
U-Net 是一种用于语义分割的卷积架构,由 Olaf Ronnerberg 等人在《用于生物医学图像分割的卷积网络》一文中提出,明确目标是分割生物医学图像。
该架构被证明足够通用,可以应用于所有语义分割任务,因为它在设计时并未对数据类型施加任何限制。
U-Net 架构遵循典型的编码器-解码器架构模式,并具有跳跃连接。采用这种设计架构的方式,在目标是生成与输入具有相同空间分辨率的输出时,已经证明非常有效,因为它允许梯度在输出层和输入层之间更好地传播:
U-Net 架构。蓝色框表示由模块产生的特征图,并标明了它们的形状。白色框表示复制并裁剪后的特征图。不同的箭头表示不同的操作。来源:Convolutional Networks for Biomedical Image Segmentation—Olaf Ronnerberg 等。
U-Net 架构的左侧是一个编码器,它逐层将输入大小从 572 x 572 缩小到最低分辨率下的 32 x 32。右侧包含架构的解码部分,它将从编码部分提取的信息与通过上卷积(反卷积)操作学到的信息进行混合。
原始的 U-Net 架构并不输出与输入相同分辨率的结果,但它设计为输出稍微低一些分辨率的结果。最终的 1 x 1 卷积被用作最后一层,将每个特征向量(深度为 64)映射到所需的类别数量。要全面评估原始架构,请仔细阅读 Olaf Ronnerberg 等人撰写的 Convolutional Networks for Biomedical Image Segmentation 中的原始 U-Net 论文。
我们将展示如何实现一个略微修改过的 U-Net,它输出的分辨率与输入相同,并且遵循相同的原始模块组织方式,而不是实现原始的 U-Net 架构。
从架构的截图中可以看到,主要有两个模块:
-
编码模块:有三个卷积操作,接着是一个下采样操作。
-
解码模块:这是一种反卷积操作,接着将其输出与对应的输入特征进行连接,并进行两次卷积操作。
使用 Keras 函数式 API 定义这个模型并连接这些逻辑模块是可能的,而且非常简单。我们将要实现的架构与原始架构略有不同,因为这是一个自定义的 U-Net 变体,它展示了 Keras 如何允许将模型作为层(或构建模块)来使用。
upsample 和 downsample 函数作为 Sequential 模型实现,该模型实际上是一个卷积或反卷积操作,步幅为 2,并随后使用一个激活函数:
(tf2)
import tensorflow as tf
import math
def downsample(depth):
return tf.keras.Sequential(
[
tf.keras.layers.Conv2D(
depth, 3, strides=2, padding="same", kernel_initializer="he_normal"
),
tf.keras.layers.LeakyReLU(),
]
)
def upsample(depth):
return tf.keras.Sequential(
[
tf.keras.layers.Conv2DTranspose(
depth, 3, strides=2, padding="same", kernel_initializer="he_normal"
),
tf.keras.layers.ReLU(),
]
)
模型定义函数假设最小输入分辨率为 256 x 256,并实现了架构中的编码、解码和连接(跳跃连接)模块:
(tf2)
def get_unet(input_size=(256, 256, 3), num_classes=21):
# Downsample from 256x256 to 4x4, while adding depth
# using powers of 2, startin from 2**5\. Cap to 512.
encoders = []
for i in range(2, int(math.log2(256))):
depth = 2 ** (i + 5)
if depth > 512:
depth = 512
encoders.append(downsample(depth=depth))
# Upsample from 4x4 to 256x256, reducing the depth
decoders = []
for i in reversed(range(2, int(math.log2(256)))):
depth = 2 ** (i + 5)
if depth < 32:
depth = 32
if depth > 512:
depth = 512
decoders.append(upsample(depth=depth))
# Build the model by invoking the encoder layers with the correct input
inputs = tf.keras.layers.Input(input_size)
concat = tf.keras.layers.Concatenate()
x = inputs
# Encoder: downsample loop
skips = []
for conv in encoders:
x = conv(x)
skips.append(x)
skips = reversed(skips[:-1])
# Decoder: input + skip connection
for deconv, skip in zip(decoders, skips):
x = deconv(x)
x = tf.keras.layers.Concatenate()([x, skip])
# Add the last layer on top and define the model
last = tf.keras.layers.Conv2DTranspose(
num_classes, 3, strides=2, padding="same", kernel_initializer="he_normal")
outputs = last(x)
return tf.keras.Model(inputs=inputs, outputs=outputs)
使用 Keras,不仅可以可视化模型的表格摘要(通过 Keras 模型的 summary() 方法),还可以获得所创建模型的图形表示,这在设计复杂架构时常常是一个福音:
(tf2)
from tensorflow.keras.utils import plot_model
model = get_unet()
plot_model(model, to_file="unet.png")
这三行代码生成了这个出色的图形表示:
定义的 U-Net 类似结构的图形表示。Keras 允许进行这种可视化,以帮助架构设计过程。
生成的图像看起来像是 U-net 架构的水平翻转版本,这也是我们将在本章中用来解决语义分割问题的架构。
现在我们已经理解了问题,并定义了深度架构,可以继续前进并收集所需的数据。
创建一个 TensorFlow DatasetBuilder
与任何其他机器学习问题一样,第一步是获取数据。由于语义分割是一个监督学习任务,我们需要一个图像及其相应标签的分类数据集。特殊之处在于,标签本身也是一张图像。
在撰写本文时,TensorFlow Datasets 中没有现成可用的语义数据集。因此,我们不仅在本节中创建需要的 tf.data.Dataset,还要了解开发 tfds DatasetBuilder 所需的过程。
由于在上一节的目标检测部分中,我们使用了 PASCAL VOC 2007 数据集,因此我们将重新使用下载的文件来创建 PASCAL VOC 2007 数据集的语义分割版本。以下截图展示了数据集的提供方式。每张图片都有一个对应的标签,其中像素颜色代表不同的类别:
从数据集中采样的一对(图像,标签)。上方的图像是原始图像,而下方的图像包含了已知物体的语义分割类别。每个未知类别都标记为背景(黑色),而物体则使用白色标出。
之前下载的数据集不仅包含了标注的边界框,还包括了许多图像的语义分割注释。TensorFlow Datasets 将原始数据下载到默认目录(~/tensorflow_datasets/downloads/),并将提取的归档文件放在 extracted 子文件夹中。因此,我们可以重新使用下载的数据来创建一个新的语义分割数据集。
在进行之前,值得先了解 TensorFlow 数据集的组织结构,以便明白我们需要做什么才能实现目标。
层次化组织
整个 TensorFlow Datasets API 设计时考虑了尽可能的扩展性。为了实现这一点,TensorFlow Datasets 的架构被组织成多个抽象层,将原始数据集数据转化为 tf.data.Dataset 对象。以下图表来自 TensorFlow Dataset 的 GitHub 页面(github.com/tensorflow/datasets/),展示了该项目的逻辑组织结构:
TensorFlow Datasets 项目的逻辑组织结构。原始数据经过多个抽象层的处理,这些层应用了转换和标准化操作,目的是定义 TFRecord 结构,并最终获取一个 tf.data.Dataset 对象。
通常,FeatureConnector和FileFormatAdapter类是现成的,而DatasetBuilder类必须正确实现,因为它是数据管道中与数据特定相关的部分。
每个数据集创建管道都从一个DatasetBuilder对象的子类开始,该子类必须实现以下方法:
-
_info用于构建描述数据集的DatasetInfo对象(并生成对人类友好的表示,这对全面理解数据非常有用)。 -
_download_and_prepare用于从远程位置下载数据(如果有的话)并进行一些基本预处理(如提取压缩档案)。此外,它还会创建序列化的(TFRecord)表示。 -
_as_dataset:这是最后一步,用于从序列化数据生成一个tf.data.Dataset对象。
直接子类化时,通常不需要DatasetBuilder类,因为GeneratorBasedBuilder是一个现成的DatasetBuilder子类,简化了数据集定义。通过子类化它需要实现的方法如下:
-
_info是DatasetBuilder的相同方法(参见上一条列表中的_info方法描述)。 -
_split_generators用于下载原始数据并进行一些基本预处理,但无需担心 TFRecord 的创建。 -
_generate_examples用于创建一个 Python 迭代器。该方法从原始数据中生成数据集中的示例,每个示例都会被自动序列化为 TFRecord 中的一行。
因此,通过子类化GeneratorBasedBuilder,只需要实现三个简单的方法,我们就可以开始实现它们了。
数据集类和 DatasetInfo
子类化一个模型并实现所需的方法是直接的。第一步是定义我们类的框架,然后按复杂度顺序开始实现方法。此外,既然我们的目标是创建一个用于语义分割的数据集,且使用的是相同的 PASCAL VOC 2007 数据集的下载文件,我们可以重写tfds.image.Voc2007的DatasetBuilder方法,以重用父类中已存在的所有信息:
(tf2)
import tensorflow as tf
import tensorflow_datasets as tfds
import os
class Voc2007Semantic(tfds.image.Voc2007):
"""Pasval VOC 2007 - semantic segmentation."""
VERSION = tfds.core.Version("0.1.0")
def _info(self):
# Specifies the tfds.core.DatasetInfo object
pass # TODO
def _split_generators(self, dl_manager):
# Downloads the data and defines the splits
# dl_manager is a tfds.download.DownloadManager that can be used to
# download and extract URLs
pass # TODO
def _generate_examples(self):
# Yields examples from the dataset
pass # TODO
最直接,但可能也是最重要的方法是实现_info,它包含了所有的数据集信息以及单个示例结构的定义。
由于我们正在扩展tfds.image.Voc2007数据集,因此可以重用某些公共信息。唯一需要注意的是,语义分割需要一个标签,这是一个单通道图像(而不是我们习惯看到的彩色图像)。
实现_info方法因此是直接的:
(tf2)
def _info(self):
parent_info = tfds.image.Voc2007().info
return tfds.core.DatasetInfo(
builder=self,
description=parent_info.description,
features=tfds.features.FeaturesDict(
{
"image": tfds.features.Image(shape=(None, None, 3)),
"image/filename": tfds.features.Text(),
"label": tfds.features.Image(shape=(None, None, 1)),
}
),
urls=parent_info.urls,
citation=parent_info.citation,
)
值得注意的是,TensorFlow Datasets 已经自带了一个预定义的特征连接器集合,这些连接器用于定义 FeatureDict。例如,定义具有固定深度(4 或 1)且高度和宽度未知的图像特征的正确方法是使用 tfds.features.Image(shape=(None, None, depth))。
description、urls 和 citation 字段已从父类继承,尽管这并不完全正确,因为父类的描述和引用字段涉及的是物体检测和分类挑战。
第二个需要实现的方法是 _split_generators。
创建数据集拆分
_split_generators 方法用于下载原始数据并进行一些基本的预处理,而无需担心 TFRecord 的创建。
由于我们是从 tfds.image.Voc2007 继承的,因此无需重新实现它,但需要查看父类的源代码:
(tf2)
def _split_generators(self, dl_manager):
trainval_path = dl_manager.download_and_extract(
os.path.join(_VOC2007_DATA_URL, "VOCtrainval_06-Nov-2007.tar"))
test_path = dl_manager.download_and_extract(
os.path.join(_VOC2007_DATA_URL, "VOCtest_06-Nov-2007.tar"))
return [
tfds.core.SplitGenerator(
name=tfds.Split.TEST,
num_shards=1,
gen_kwargs=dict(data_path=test_path, set_name="test")),
tfds.core.SplitGenerator(
name=tfds.Split.TRAIN,
num_shards=1,
gen_kwargs=dict(data_path=trainval_path, set_name="train")),
tfds.core.SplitGenerator(
name=tfds.Split.VALIDATION,
num_shards=1,
gen_kwargs=dict(data_path=trainval_path, set_name="val")),
]
源代码来自 github.com/tensorflow/datasets/blob/master/tensorflow_datasets/image/voc.py,并遵循 Apache License 2.0 授权协议。
如可以很容易看出,方法使用一个 dl_manager 对象来下载(并缓存)并从某个远程位置解压归档文件。数据集的拆分定义在 "train"、"test" 和 "val" 中的返回行执行。
每次调用 tfds.core.SplitGeneratro 最重要的部分是 gen_kwargs 参数。事实上,在这一行,我们正在指示如何调用 _generate_exaples 函数。
简而言之,这个函数通过调用 _generate_examples 函数,传入 data_path 参数设置为当前数据集路径(test_path 或 trainval_path),并将 set_name 设置为正确的数据集名称,从而创建三个拆分。
set_name 参数的值来自 PASCAL VOC 2007 目录和文件组织。正如我们将在下一节看到的那样,在 _generate_example 方法的实现中,了解数据集的结构和内容对于正确创建拆分是必要的。
生成示例
_generate_example 方法可以定义为任何签名。该方法仅由 _split_generators 方法调用,因此,由这个方法来正确地用正确的参数调用 _generate_example。
由于我们没有重写父类的 _split_generators 方法,因此我们必须使用父类要求的相同签名。因此,我们需要使用 data_path 和 set_name 参数,除此之外,还可以使用 PASCAL VOC 2007 文档中提供的其他所有信息。
_generate_examples 的目标是每次调用时返回一个示例(表现得像一个标准的 Python 迭代器)。
从数据集结构中,我们知道,在 VOCdevkit/VOC2007/ImageSets/Segmentation/ 目录下,有三个文本文件——每个拆分一个:"train","test" 和 "val"。每个文件都包含每个拆分中标记图像的名称。
因此,使用这些文件中包含的信息来创建三份数据集是直接的。我们只需逐行打开文件并读取,就可以知道要读取哪些图像。
TensorFlow Datasets 限制我们使用 Python 文件操作,但明确要求使用 tf.io.gfile 包。这个限制是必要的,因为有些数据集太大,无法在单台机器上处理,而 tf.io.gfile 可以方便地被 TensorFlow Datasets 用来读取和处理远程以及分布式的数据集。
从 PASCAL VOC 2007 文档中,我们还可以提取一个 查找表 (LUT),用来创建 RGB 值与标量标签之间的映射:
(tf2)
LUT = {
(0, 0, 0): 0, # background
(128, 0, 0): 1, # aeroplane
(0, 128, 0): 2, # bicycle
(128, 128, 0): 3, # bird
(0, 0, 128): 4, # boat
(128, 0, 128): 5, # bottle
(0, 128, 128): 6, # bus
(128, 128, 128): 7, # car
(64, 0, 0): 8, # cat
(192, 0, 0): 9, # chair
(64, 128, 0): 10, # cow
(192, 128, 0): 11, # diningtable
(64, 0, 128): 12, # dog
(192, 0, 128): 13, # horse
(64, 128, 128): 14, # motorbike
(192, 128, 128): 15, # person
(0, 64, 0): 16, # pottedplant
(128, 64, 0): 17, # sheep
(0, 192, 0): 18, # sofa
(128, 192, 0): 19, # train
(0, 64, 128): 20, # tvmonitor
(255, 255, 255): 21, # undefined / don't care
}
创建这个查找表后,我们可以仅使用 TensorFlow 操作来读取图像,检查其是否存在(因为无法保证原始数据是完美的,我们必须防止在数据集创建过程中出现故障),并创建包含与 RGB 颜色相关的数值的单通道图像。
仔细阅读源代码,因为第一次阅读时可能很难理解。特别是,查找 RGB 颜色与可用颜色之间对应关系的查找表循环,初看可能不容易理解。以下代码不仅使用 tf.Variable 创建与 RGB 颜色相关的数值的单通道图像,还检查 RGB 值是否正确:
(tf2)
def _generate_examples(self, data_path, set_name):
set_filepath = os.path.join(
data_path,
"VOCdevkit/VOC2007/ImageSets/Segmentation/{}.txt".format(set_name),
)
with tf.io.gfile.GFile(set_filepath, "r") as f:
for line in f:
image_id = line.strip()
image_filepath = os.path.join(
data_path, "VOCdevkit", "VOC2007", "JPEGImages", f"{image_id}.jpg"
)
label_filepath = os.path.join(
data_path,
"VOCdevkit",
"VOC2007",
"SegmentationClass",
f"{image_id}.png",
)
if not tf.io.gfile.exists(label_filepath):
continue
label_rgb = tf.image.decode_image(
tf.io.read_file(label_filepath), channels=3
)
label = tf.Variable(
tf.expand_dims(
tf.zeros(shape=tf.shape(label_rgb)[:-1], dtype=tf.uint8), -1
)
)
for color, label_id in LUT.items():
match = tf.reduce_all(tf.equal(label_rgb, color), axis=[2])
labeled = tf.expand_dims(tf.cast(match, tf.uint8), axis=-1)
label.assign_add(labeled * label_id)
colored = tf.not_equal(tf.reduce_sum(label), tf.constant(0, tf.uint8))
# Certain labels have wrong RGB values
if not colored.numpy():
tf.print("error parsing: ", label_filepath)
continue
yield image_id, {
# Declaring in _info "image" as a tfds.feature.Image
# we can use both an image or a string. If a string is detected
# it is supposed to be the image path and tfds take care of the
# reading process.
"image": image_filepath,
"image/filename": f"{image_id}.jpg",
"label": label.numpy(),
}
_generate_examples 方法不仅返回单个示例,它还必须返回一个元组,(id, example),其中 id —— 在本例中是 image_id —— 应该唯一标识该记录;此字段用于全局打乱数据集,并避免生成的数据集中出现重复元素。
实现了这个方法之后,一切都已经正确设置,我们可以使用全新的 Voc2007Semantic 加载器。
使用构建器
TensorFlow Datasets 可以自动检测当前作用域中是否存在 DatasetBuilder 对象。因此,通过继承现有的 DatasetBuilder 类实现的 "voc2007_semantic" 构建器已经可以直接使用:
dataset, info = tfds.load("voc2007_semantic", with_info=True)
在第一次执行时,会创建拆分,并且 _generate_examples 方法会被调用三次以创建示例的 TFRecord 表示。
通过检查 info 变量,我们可以看到一些数据集统计信息:
[...]
features=FeaturesDict({
'image': Image(shape=(None, None, 3), dtype=tf.uint8),
'image/filename': Text(shape=(), dtype=tf.string, encoder=None),
'label': Image(shape=(None, None, 1), dtype=tf.uint8)
},
total_num_examples=625,
splits={
'test': <tfds.core.SplitInfo num_examples=207>,
'train': <tfds.core.SplitInfo num_examples=207>,
'validation': <tfds.core.SplitInfo num_examples=211>
}
特征通过实现 _info 方法来描述,数据集的规模相对较小,每个训练集和测试集包含 207 张图像,验证集包含 211 张图像。
实现DatasetBuilder是一个相对直接的操作,每当你开始处理一个新的数据集时,都应该进行这项操作——这样,在训练和评估过程中可以使用高效的管道。
模型训练与评估
尽管网络架构并非图像分类器,并且标签不是标量,但语义分割仍然可以视为一个传统的分类问题,因此训练和评估过程是相同的。
出于这个原因,我们可以使用compile和fit Keras 模型来构建训练循环,并分别执行它,而不是编写自定义的训练循环。
数据准备
要使用 Keras 的fit模型,tf.data.Dataset对象应生成 (feature, label) 格式的元组,其中feature是输入图像,label是图像标签。
因此,值得定义一些可以应用于tf.data.Dataset生成的元素的函数,这些函数可以将数据从字典转换为元组,并且在此过程中,我们还可以为训练过程应用一些有用的预处理:
(tf2)
def resize_and_scale(row):
# Resize and convert to float, [0,1] range
row["image"] = tf.image.convert_image_dtype(
tf.image.resize(
row["image"],
(256,256),
method=tf.image.ResizeMethod.NEAREST_NEIGHBOR),
tf.float32)
# Resize, cast to int64 since it is a supported label type
row["label"] = tf.cast(
tf.image.resize(
row["label"],
(256,256),
method=tf.image.ResizeMethod.NEAREST_NEIGHBOR),
tf.int64)
return row
def to_pair(row):
return row["image"], row["label"]
现在很容易从通过tfds.load调用获得的dataset对象中获取验证集和训练集,并对其应用所需的转换:
(tf2)
batch_size= 32
train_set = dataset["train"].map(resize_and_scale).map(to_pair)
train_set = train_set.batch(batch_size).prefetch(1)
validation_set = dataset["validation"].map(resize_and_scale)
validation_set = validation_set.map(to_pair).batch(batch_size)
数据集已准备好用于fit方法,并且由于我们正在开发一个纯 Keras 解决方案,因此可以使用 Keras 回调函数配置隐藏的训练循环。
训练循环与 Keras 回调函数
compile方法用于配置训练循环。我们可以指定优化器、损失函数、评估指标以及一些有用的回调函数。
回调函数是在每个训练周期结束时执行的函数。Keras 提供了一个预定义回调函数的长列表,用户可以直接使用。在下一个代码片段中,将使用两个最常见的回调函数,ModelCheckpoint和TensorBoard回调函数。如其名称所示,前者在每个周期结束时保存检查点,而后者使用tf.summary记录指标。
由于语义分割可以视为一个分类问题,使用的损失函数是SparseCategoricalCrossentropy,并配置为在计算损失值时对网络的输出层应用 sigmoid(在深度维度上),如from_logits=True参数所示。这个配置是必须的,因为我们没有在自定义 U-Net 的最后一层添加激活函数:
(tf2)
# Define the model
model = get_unet()
# Choose the optimizer
optimizer = tf.optimizers.Adam()
# Configure and create the checkpoint callback
checkpoint_path = "ckpt/pb.ckpt"
cp_callback = tf.keras.callbacks.ModelCheckpoint(checkpoint_path,
save_weights_only=True,
verbose=1)
# Enable TensorBoard loggging
TensorBoard = tf.keras.callbacks.TensorBoard(write_images=True)
# Cofigure the training loop and log the accuracy
model.compile(optimizer=optimizer,
loss=tf.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=['accuracy'])
数据集和回调函数被传递到fit方法,该方法执行所需轮数的有效训练循环:
(tf2)
num_epochs = 50
model.fit(train_set, validation_data=validation_set, epochs=num_epochs,
callbacks=[cp_callback, TensorBoard])
训练循环将训练模型 50 个周期,在训练过程中测量损失和准确度,并在每个周期结束时,测量验证集上的准确度和损失值。此外,经过两个回调后,我们在ckpt目录中有一个包含模型参数的检查点,并且不仅在标准输出(即 Keras 默认设置)上记录了度量,还在 TensorBoard 上进行了记录。
评估与推理
在训练过程中,我们可以打开 TensorBoard 并查看损失和度量的图表。在第 50 个周期结束时,我们会得到如下截图所示的图表:
训练集(橙色)和验证集(蓝色)上的准确度和损失值。Keras 将摘要的使用和配置隐藏给用户。
此外,由于我们已经在model变量中拥有了模型的所有参数,我们可以尝试向模型输入一张从互联网上下载的图像,看看分割是否按预期工作。
假设我们从互联网上下载了以下图像,并将其保存为"author.jpg":
问候!
我们期望模型生成这张图像中唯一已知类别的分割,即"person",同时在其他地方生成"background"标签。
一旦我们下载了图像,就将其转换为模型预期的相同格式(一个在*【0,1】范围内的浮动值),并将其大小调整为512*。由于模型处理的是一批图像,因此需要为sample变量添加一个单一的维度。现在,运行推理就像model(sample)一样简单。之后,我们在最后一个通道上使用tf.argmax函数提取每个像素位置的预测标签:
(tf2)
sample = tf.image.decode_jpeg(tf.io.read_file("author.jpg"))
sample = tf.expand_dims(tf.image.convert_image_dtype(sample, tf.float32), axis=[0])
sample = tf.image.resize(sample, (512,512))
pred_image = tf.squeeze(tf.argmax(model(sample), axis=-1), axis=[0])
在pred_image张量中,我们有稠密的预测,这些预测对于可视化几乎没有用处。实际上,这个张量的值在*【0, 21】*范围内,并且这些值一旦可视化后几乎无法区分(它们看起来都很黑)。
因此,我们可以使用为数据集创建的 LUT 应用从标签到颜色的逆映射。最后,我们可以使用 TensorFlow 的io包将图像转换为 JPEG 格式并将其存储在磁盘上,方便可视化:
(tf2)
REV_LUT = {value: key for key, value in LUT.items()}
color_image = tf.Variable(tf.zeros((512,512,3), dtype=tf.uint8))
pixels_per_label = []
for label, color in REV_LUT.items():
match = tf.equal(pred_image, label)
labeled = tf.expand_dims(tf.cast(match, tf.uint8), axis=-1)
pixels_per_label.append((label, tf.math.count_nonzero(labeled)))
labeled = tf.tile(labeled, [1,1,3])
color_image.assign_add(labeled * color)
# Save
tf.io.write_file("seg.jpg", tf.io.encode_jpeg(color_image))
这是在小数据集上仅训练 50 个周期后,简单模型的分割结果:
映射预测标签到相应颜色后的分割结果。
尽管结果较为粗糙,因为架构尚未优化,模型选择未进行,数据集规模较小,但分割结果已经看起来很有前景!
通过计算每个标签的匹配次数,可以检查预测的标签。在pixels_per_label列表中,我们保存了对(label,match_count)的配对,打印出来后,我们可以验证预测的类别是否为预期的"person"(id 15):
(tf2)
for label, count in pixels_per_label:
print(label, ": ", count.numpy())
这将产生以下结果:
0: 218871
1: 0
3: 383
[...]
15: 42285
[...]
这正是预期的。当然,仍然有改进的空间,这留给读者作为练习。
总结
本章介绍了语义分割问题并实现了 U-Net:一种用于解决此问题的深度编码器-解码器架构。简要介绍了该问题的可能应用场景和挑战,接着直观地介绍了用于构建架构解码器部分的反卷积(转置卷积)操作。由于在编写时,TensorFlow Datasets 中还没有准备好的语义分割数据集,我们利用这一点展示了 TensorFlow Datasets 的架构,并展示了如何实现自定义的 DatasetBuilder。实现它是直接的,推荐每个 TensorFlow 用户这样做,因为它是创建高效数据输入管道(tf.data.Dataset)的便捷方式。此外,通过实现 _generate_examples 方法,用户被迫“查看”数据,这是进行机器学习和数据科学时强烈推荐的做法。
之后,我们通过将此问题视为分类问题,学习了语义分割网络训练循环的实现。本章展示了如何使用 Keras 的 compile 和 fit 方法,并介绍了如何通过 Keras 回调函数自定义训练循环。本章以一个快速示例结束,演示了如何使用训练好的模型进行推理,并如何仅使用 TensorFlow 方法保存生成的图像。
在下一章,第九章,生成对抗网络,介绍了生成对抗网络(GANs)及其对抗性训练过程,显然,我们也解释了如何使用 TensorFlow 2.0 实现它们。
练习
以下练习具有基础性的重要性,邀请您回答每个理论问题并解决所有给定的代码挑战:
-
什么是语义分割?
-
为什么语义分割是一个困难的问题?
-
什么是反卷积?深度学习中的反卷积操作是一个真正的反卷积操作吗?
-
是否可以将 Keras 模型作为层使用?
-
是否可以使用单个 Keras
Sequential模型来实现具有跳跃连接的模型架构? -
描述原始 U-Net 架构:本章中展示的自定义实现与原始实现有什么不同?
-
使用 Keras 实现原始的 U-Net 架构。
-
什么是 DatasetBuilder?
-
描述 TensorFlow 数据集的层次结构。
-
_info方法包含数据集中每个示例的描述。这个描述与FeatureConnector对象有什么关系? -
描述
_generate_splits和_generate_examples方法。解释这两个方法是如何连接的,以及tfds.core.SplitGenerator的gen_kwargs参数的作用。 -
什么是 LUT?为什么它是创建语义分割数据集时有用的数据结构?
-
为什么在开发自定义 DatasetBuilder 时需要使用
tf.io.gfile? -
(加分项):为 TensorFlow Datasets 项目添加一个缺失的语义分割数据集!提交一个 Pull Request 到
github.com/tensorflow/datasets,并在消息中分享此练习部分和本书内容。 -
训练本章中展示的修改版 U-Net 架构。
-
更改损失函数并添加重建损失项,最小化过程的目标是同时最小化交叉熵,并使预测标签尽可能接近真实标签。
-
使用 Keras 回调函数测量平均交并比(Mean IOU)。Mean IOU 已在
tf.metrics包中实现。 -
尝试通过在编码器中添加 dropout 层来提高模型在验证集上的表现。
-
在训练过程中,开始时以 0.5 的概率丢弃神经元,并在每个 epoch 后将此值增加 0.1。当验证集的平均 IOU 停止增加时,停止训练。
-
使用训练好的模型对从互联网上下载的随机图像进行推断。对结果的分割进行后处理,以便检测出不同类别的不同元素的边界框。使用 TensorFlow 在输入图像上绘制边界框。
第十章:生成对抗网络
本章将介绍生成对抗网络(GANs)及对抗训练过程。在第一部分,我们将概述 GAN 框架的理论内容,同时强调对抗训练过程的优势以及使用神经网络作为创建 GAN 的模型所带来的灵活性。理论部分将为你提供一个直观的了解,帮助你理解在对抗训练过程中 GAN 的哪些部分被优化,并展示为什么应使用非饱和值函数,而不是原始的值函数。
接下来,我们将通过一步步实现 GAN 模型及其训练,并用视觉方式解释这个过程中发生的事情。通过观察模型的学习过程,你将熟悉目标分布和学习分布的概念。
本章第二部分将介绍 GAN 框架向条件版本的自然扩展,并展示如何创建条件图像生成器。本章与之前的章节一样,最后将会有一个练习部分,鼓励大家不要跳过。
本章将涵盖以下主题:
-
理解 GAN 及其应用
-
无条件 GAN
-
条件 GAN
理解 GAN 及其应用
由Ian Goodfellow 等人在 2014 年提出的论文《生成对抗网络》(Generative Adversarial Networks)中,GANs 彻底改变了生成模型的领域,为令人惊叹的应用铺平了道路。
GANs 是通过对抗过程估计生成模型的框架,其中两个模型,生成器和判别器,进行同时训练。
生成模型(生成器,Generator)的目标是捕捉训练集中包含的数据分布,而判别模型则充当二分类器。它的目标是估计一个样本来自训练数据的概率,而不是来自生成器。在下面的图示中,展示了对抗训练的总体架构:
对抗训练过程的图形化表示。生成器的目标是通过学习生成越来越像训练集的样本来欺骗判别器。(图像来源:www.freecodecamp.org/news/an-intuitive-introduction-to-generative-adversarial-networks-gans-7a2264a81394/—Thalles Silva)
这个想法是训练一个生成模型,而无需明确定义损失函数。相反,我们使用来自另一个网络的信号作为反馈。生成器的目标是愚弄判别器,而判别器的目标是正确分类输入样本是真实的还是虚假的。对抗训练的强大之处在于,生成器和判别器都可以是非线性、参数化的模型,例如神经网络。因此,可以使用梯度下降法来训练它们。
为了学习生成器在数据上的分布,生成器从先验噪声分布,,到数据空间的映射,映射。
判别器,,是一个函数(神经网络),其输出一个标量,表示
来自真实数据分布的概率,而不是来自
。
原始的 GAN 框架通过使用博弈论方法来表达问题,并将其作为一个最小-最大博弈,其中两个玩家——生成器和判别器——相互竞争。
价值函数
价值函数是以期望回报的形式表示玩家目标的数学方法。GAN 博弈通过以下价值函数来表示:
这个价值函数表示了两个玩家所进行的博弈,以及他们各自的长期目标。
判别器的目标是正确分类真实和虚假样本,这一目标通过最大化和
两项来表示。前者代表正确分类来自真实数据分布的样本(因此,目标是得到
),而后者代表正确分类虚假样本(在这种情况下,目标是得到
)。
另一方面,生成器的目标是愚弄判别器,它的目标是最小化。你可以通过生成与真实样本越来越相似的样本来最小化这一项,从而试图愚弄判别器。
值得注意的一点是,最小-最大博弈仅在价值函数的第二项中进行,因为在第一项中,只有判别器在参与。它通过学习正确分类来自真实数据分布的数据来实现这一点。
尽管这种公式清晰且相当容易理解,但它有一个实际的缺点。在训练的早期阶段,判别器可以通过最大化轻松学会如何正确分类假数据,因为生成的样本与真实样本差异太大。由于从生成样本的质量学习较差,判别器可以以较高的置信度拒绝这些样本,因为它们与训练数据明显不同。这种拒绝表现为将生成样本的正确分类标为假(
),使得项
饱和。因此,之前的公式可能无法为G提供足够的梯度以良好地进行学习。解决这一实际问题的方法是定义一个不饱和的新价值函数。
非饱和价值函数
提出的解决方案是训练G以最大化,而不是最小化
。直观地看,我们可以将提出的解决方案视为以不同方式进行相同的最小-最大游戏。
判别器的目标是最大化正确分类真实样本和假样本的概率,与之前的公式没有变化。另一方面,生成器的目标是最小化判别器正确分类生成样本为假的概率,但要通过使判别器将假样本分类为真实的方式显式地欺骗判别器。
同一游戏的价值函数,由两名玩家以不同方式进行游戏,可以表示如下:
如前所述,敌对训练框架的力量来自于G和D都可以是神经网络,并且它们都可以通过梯度下降进行训练。
模型定义和训练阶段
将生成器和判别器定义为神经网络,使我们能够利用多年来开发的所有神经网络架构来解决问题,每种架构都专门用于处理某种数据类型。
模型的定义没有约束;事实上,可以以完全任意的方式定义其架构。唯一的约束是由我们所处理的数据的结构决定的;架构取决于数据类型,所有类型如下:
-
图像:卷积神经网络
-
序列、文本:递归神经网络
-
数值、类别值:全连接网络
一旦我们根据数据类型定义了模型的架构,就可以使用它们来进行最小-最大游戏。
对抗训练包括交替执行训练步骤。每个训练步骤都是一个玩家动作,生成器和判别器交替进行对抗。游戏遵循以下规则:
- 判别器:判别器首先进行操作,可以将以下三个步骤重复执行 1 到k次,其中k是超参数(通常k等于 1):
-
-
从噪声分布中采样一个* m *噪声样本的迷你批量,
,来自噪声先验的
-
从真实数据分布中采样一个* m *样本的迷你批量,
,来自真实数据分布的
-
通过随机梯度上升训练判别器:
-
这里,是判别器的参数
- 生成器:生成器始终在判别器操作之后进行,并且每次仅执行一次:
-
-
从噪声分布中采样一个* m *噪声样本的迷你批量,
,来自噪声先验的
-
通过随机梯度上升训练生成器(这是一个最大化问题,因为游戏的目标是非饱和值函数):
-
这里,是生成器的参数
就像任何通过梯度下降训练的神经网络一样,更新可以使用任何标准优化算法(Adam,SGD,带动量的 SGD 等)。游戏应该持续,直到判别器不再完全被生成器欺骗,也就是说,当判别器对每个输入样本的预测概率总是为 0.5 时。0.5 的值可能听起来很奇怪,但直观地说,这意味着生成器现在能够生成与真实样本相似的样本,而判别器只能做随机猜测。
GANs 的应用
乍一看,生成模型的效用有限。拥有一个生成与我们已有的(真实样本数据集)相似的模型有什么意义?
在实践中,从数据分布中学习在异常检测领域非常有用,并且在“仅限人类”的领域(如艺术、绘画和音乐生成)中也有重要应用。此外,GANs 在其条件形式中的应用令人惊讶,被广泛用于创造具有巨大市场价值的应用程序(更多信息请参阅本章的条件 GANs部分)。
使用 GANs,可以让机器从随机噪声开始生成极其逼真的面孔。以下图像展示了将 GAN 应用于面部生成问题。这些结果来源于论文《Progressive Growing of GANs for Improved Quality, Stability, and Variation》(T. Karras 等,2017,NVIDIA):
这些人并不存在。每一张图片,尽管超逼真,都是通过生成对抗网络(GAN)生成的。你可以亲自尝试,通过访问thispersondoesnotexist.com/来体验一下。(图片来源,论文标题为Progressive Growing of GANs for Improved Quality, Stability, and Variation)。
在 GAN 出现之前,另一个几乎不可能实现的惊人应用是领域转换,指的是你使用 GAN 从一个领域转换到另一个领域,例如,从素描转换到真实图像,或从鸟瞰图转换为地图。
下图来自论文Image-to-Image Translation with Conditional Adversarial Networks(Isola 等,2017),展示了条件 GAN 如何解决几年前被认为不可能完成的任务:
GAN 使得解决领域转换问题成为可能。现在,给黑白图像上色或仅通过素描生成照片变得可行。图片来源:Image-to-Image Translation with Conditional Adversarial Networks(Isola 等,2017)。
GAN 的应用令人惊叹,其实际应用也在不断被发现。从下一部分开始,我们将学习如何在纯 TensorFlow 2.0 中实现其中的一些应用。
无条件 GAN
看到 GAN 被提到为无条件的并不常见,因为这是默认的原始配置。然而,在本书中,我们决定强调原始 GAN 公式的这一特性,以便让你意识到 GAN 的两大主要分类:
-
无条件 GAN
-
条件 GAN
我们在上一部分描述的生成模型属于无条件 GAN 类别。该生成模型训练的目标是捕捉训练数据分布,并生成从捕捉的分布中随机抽样的样本。条件配置是该框架的略微修改版本,并将在下一部分介绍。
由于 TensorFlow 2.0 的默认即时执行风格,实施对抗训练变得非常简单。实际上,为了实现 Goodfellow 等人论文中描述的对抗训练循环(Generative Adversarial Networks),需要逐行按原样实现。当然,创建一个自定义训练循环,特别是需要交替训练两个不同模型的步骤时,最好的方法不是使用 Keras,而是手动实现。
就像任何其他机器学习问题一样,我们必须从数据开始。在这一部分,我们将定义一个生成模型,目的是学习关于以 10 为中心、标准差较小的随机正态数据分布。
准备数据
由于本节的目标是学习数据分布,我们将从基础开始,以便建立对对抗训练过程的直觉。最简单且最容易的方式是通过查看随机正态分布来可视化数据分布。因此,我们可以选择一个均值为 10,标准差为 0.1 的高斯(或正态)分布作为我们的目标数据分布:
由于即时执行过程,我们可以使用 TensorFlow 2.0 本身从目标分布中采样一个值。我们通过使用 tf.random.normal 函数来做到这一点。以下代码片段展示了一个函数,该函数从目标分布中采样(2000)个数据点:
(tf2)
import tensorflow as tf
def sample_dataset():
dataset_shape = (2000, 1)
return tf.random.normal(mean=10., shape=dataset_shape, stddev=0.1, dtype=tf.float32)
为了更好地理解 GAN 能学到什么,以及在对抗训练过程中发生了什么,我们使用 matplotlib 来将数据可视化成直方图:
(tf2)
import matplotlib.pyplot as plt
counts, bin, ignored = plt.hist(sample_dataset().numpy(), 100)
axes = plt.gca()
axes.set_xlim([-1,11])
axes.set_ylim([0, 60])
plt.show()
这显示了目标分布,如下图所示。如预期的那样,如果标准差较小,直方图将在均值处达到峰值:
目标分布的直方图——从一个均值为 10,标准差为 0.1 的高斯分布中采样的 5000 个数据点
现在我们已经定义了目标数据分布,并且有了一个从中采样的函数(sample_dataset),我们准备好定义生成器和判别器网络了。
正如我们在本章开头所述,对抗训练过程的力量在于生成器和判别器都可以是神经网络,并且模型可以使用梯度下降法进行训练。
定义生成器
生成器的目标是表现得像目标分布。因此,我们必须将其定义为一个具有单个神经元的网络。我们可以从目标分布中每次采样一个数字,生成器也应该能够做到这一点。
模型架构定义没有明确的指导原则或约束条件。唯一的限制来自于问题的性质,这些限制体现在输入和输出的维度。输出维度,如前所述,取决于目标分布,而输入维度是噪声先验的任意维度,通常设置为 100。
为了解决这个问题,我们将定义一个简单的三层神经网络,包含两个隐藏层,每个层有 64 个神经元:
(tf2)
def generator(input_shape):
"""Defines the generator keras.Model.
Args:
input_shape: the desired input shape (e.g.: (latent_space_size))
Returns:
G: The generator model
"""
inputs = tf.keras.layers.Input(input_shape)
net = tf.keras.layers.Dense(units=64, activation=tf.nn.elu, name="fc1")(inputs)
net = tf.keras.layers.Dense(units=64, activation=tf.nn.elu, name="fc2")(net)
net = tf.keras.layers.Dense(units=1, name="G")(net)
G = tf.keras.Model(inputs=inputs, outputs=net)
return G
generator 函数返回一个 Keras 模型。虽然只用一个 Sequential 模型也足够,但我们使用了 Keras 函数式 API 来定义该模型。
定义判别器
就像生成器一样,判别器的架构依赖于目标分布。目标是将样本分类为两类。因此,输入层依赖于从目标分布中采样的样本的大小;在我们的案例中,它是 1。输出层是一个单独的线性神经元,用于将样本分类为两类。
激活函数是线性的,因为 Keras 的损失函数应用了 sigmoid:
(tf2)
def disciminator(input_shape):
"""Defines the Discriminator keras.Model.
Args:
input_shape: the desired input shape (e.g.: (the generator output shape))
Returns:
D: the Discriminator model
"""
inputs = tf.keras.layers.Input(input_shape)
net = tf.keras.layers.Dense(units=32, activation=tf.nn.elu, name="fc1")(inputs)
net = tf.keras.layers.Dense(units=1, name="D")(net)
D = tf.keras.Model(inputs=inputs, outputs=net)
return D
定义生成器和判别器架构之后,我们只需通过指定正确的输入形状来实例化 Keras 模型:
(tf2)
# Define the real input shape
input_shape = (1,)
# Define the Discriminator model
D = disciminator(input_shape)
# Arbitrary set the shape of the noise prior
latent_space_shape = (100,)
# Define the input noise shape and define the generator
G = generator(latent_space_shape)
模型和目标数据分布已经定义;唯一缺少的就是表达它们之间的关系,这通过定义损失函数来完成。
定义损失函数
如前所述,判别器的输出是线性的,因为我们将要使用的 loss 函数为我们应用了非线性。为了按照原始公式实现对抗训练过程,使用的 loss 函数是二进制交叉熵:
(tf2)
bce = tf.keras.losses.BinaryCrossentropy(from_logits=True)
bce 对象用于计算两个分布之间的二进制交叉熵:
-
学到的分布,由判别器的输出表示,通过应用 sigmoid
函数将其压缩到 [0,1] 范围内,因为
from_logits参数被设置为True。如果判别器将输入分类为来自真实数据分布,则该值会接近 1。 -
条件经验分布在类别标签上,即一个离散的概率分布,其中真实样本的概率被标记为 1,其他情况下为 0。
数学上,条件经验分布与生成器输出(压缩到 [0,1])之间的二进制交叉熵表示如下:
我们希望训练判别器正确分类真实和伪造数据:正确分类真实数据可以看作是最大化 ,而正确分类伪造数据是最大化
。
通过将期望值替换为批次中 m 个样本的经验均值,可以将正确分类一个样本的对数概率的最大化表示为两个 BCE 的和:
第一项是标签 在给定真实样本作为输入时的判别器输出之间的 BCE,而第二项是标签
在给定假样本作为输入时的判别器输出之间的 BCE。
在 TensorFlow 中实现这个损失函数非常简单:
(tf2)
def d_loss(d_real, d_fake):
"""The disciminator loss function."""
return bce(tf.ones_like(d_real), d_real) + bce(tf.zeros_like(d_fake), d_fake)
我们之前创建的同一 bce 对象在 d_loss 函数中使用,因为它是一个无状态对象,仅计算其输入之间的二元交叉熵。
请注意,在最大化它们的 bce 调用中不需要添加减号;二元交叉熵的数学公式已经包含减号。
生成器损失函数遵循这一理论。仅实施非饱和值函数包括 TensorFlow 实现以下公式:
该公式是生成图像的对数概率与真实图像的分布(标记为 1)之间的二元交叉熵。在实践中,我们希望最大化生成样本的对数概率,更新生成器参数以使判别器将其分类为真实(标签 1)。
TensorFlow 实现非常简单:
(tf2)
def g_loss(generated_output):
"""The Generator loss function."""
return bce(tf.ones_like(generated_output), generated_output)
一切都准备好实施对抗训练过程。
无条件 GAN 中的对抗训练过程
如我们在本章开头解释的那样,对抗训练过程是我们交替执行判别器和生成器的训练步骤的过程。生成器需要通过判别器计算的值来执行其参数更新,而判别器需要生成的样本(也称为假输入)和真实样本。
TensorFlow 允许我们轻松定义自定义训练循环。特别是 tf.GradientTape 对象非常有用,用于计算特定模型的梯度,即使存在两个相互作用的模型。实际上,由于每个 Keras 模型的 trainable_variables 属性,可以计算某个函数的梯度,但只针对这些变量。
训练过程与 GAN 论文描述的过程完全相同(生成对抗网络 - Ian Goodfellow 等人),由于急切模式。此外,由于这个训练过程可能计算密集(特别是在我们希望捕获的数据分布复杂的大型数据集上),值得使用 @tf.function 装饰训练步骤函数,以便通过将其转换为图形加快计算速度:
(tf2)
def train():
# Define the optimizers and the train operations
optimizer = tf.keras.optimizers.Adam(1e-5)
@tf.function
def train_step():
with tf.GradientTape(persistent=True) as tape:
real_data = sample_dataset()
noise_vector = tf.random.normal(
mean=0, stddev=1,
shape=(real_data.shape[0], latent_space_shape[0]))
# Sample from the Generator
fake_data = G(noise_vector)
# Compute the D loss
d_fake_data = D(fake_data)
d_real_data = D(real_data)
d_loss_value = d_loss(d_real_data, d_fake_data)
# Compute the G loss
g_loss_value = g_loss(d_fake_data)
# Now that we comptuted the losses we can compute the gradient
# and optimize the networks
d_gradients = tape.gradient(d_loss_value, D.trainable_variables)
g_gradients = tape.gradient(g_loss_value, G.trainable_variables)
# Deletng the tape, since we defined it as persistent
# (because we used it twice)
del tape
optimizer.apply_gradients(zip(d_gradients, D.trainable_variables))
optimizer.apply_gradients(zip(g_gradients, G.trainable_variables))
return real_data, fake_data, g_loss_value, d_loss_value
为了可视化生成器在训练过程中学习到的内容,我们绘制了从目标分布中采样的相同图形值(橙色),以及从生成器中采样的值(蓝色):
(tf2)
fig, ax = plt.subplots()
for step in range(40000):
real_data, fake_data,g_loss_value, d_loss_value = train_step()
if step % 200 == 0:
print("G loss: ", g_loss_value.numpy(), " D loss: ", d_loss_value.numpy(), " step: ", step)
# Sample 5000 values from the Generator and draw the histogram
ax.hist(fake_data.numpy(), 100)
ax.hist(real_data.numpy(), 100)
# these are matplotlib.patch.Patch properties
props = dict(boxstyle='round', facecolor='wheat', alpha=0.5)
# place a text box in upper left in axes coords
textstr = f"step={step}"
ax.text(0.05, 0.95, textstr, transform=ax.transAxes, fontsize=14,
verticalalignment='top', bbox=props)
axes = plt.gca()
axes.set_xlim([-1,11])
axes.set_ylim([0, 60])
display.display(pl.gcf())
display.clear_output(wait=True)
plt.gca().clear()
现在我们已经将整个训练循环定义为一个函数,可以通过调用 train() 来执行它。
train_step 函数是整个代码片段中最重要的部分,因为它包含了对抗训练的实现。值得强调的一个特点是,通过使用 trainable_variables,我们能够计算损失函数相对于我们感兴趣的模型参数的梯度,同时将其他所有因素保持不变。
第二个特点是使用了持久化的梯度带对象。使用持久化的带对象使我们能够在内存中分配一个单独的对象(即带对象),并且将其使用两次。如果带对象是非持久化创建的,我们就无法重用它,因为它会在第一次 .gradient 调用后自动销毁。
我们没有使用 TensorBoard 来可视化数据(这个留给你做练习),而是遵循了到目前为止使用的 matplotlib 方法,并且每 200 步训练从目标分布和学习到的分布中分别采样 5,000 个数据点,然后通过绘制相应的直方图进行可视化。
在训练的初期阶段,学习到的分布与目标分布不同,如下图所示:
在第 2,600 步训练时的数据可视化。目标分布是均值为 10,标准差为 0.1 的随机正态分布。从学习到的分布中采样的值正在慢慢向目标分布移动。
在训练阶段,我们可以看到生成器如何学习逼近目标分布:
在第 27,800 步训练时的数据可视化。学习到的分布正在接近均值 10,并且正在减少其方差。
在训练的后期阶段,两个分布几乎完全重合,训练过程可以停止:
在第 39,000 步训练时的数据可视化。目标分布和学习到的分布重叠。
多亏了 Keras 模型的表达能力和 TensorFlow eager 模式的易用性(加上通过 tf.function 进行图转换),定义两个模型并手动实现对抗训练过程几乎变得微不足道。
尽管看似微不足道,这实际上是我们在处理不同数据类型时使用的相同训练循环。事实上,同样的训练循环可以用于训练图像、文本甚至音频生成器,唯一的区别是我们在这些情况下使用不同的生成器和判别器架构。
稍微修改过的 GAN 框架允许你收集条件生成的样本;例如,当给定条件时,生成器被训练生成特定的样本。
条件生成对抗网络(Conditional GANs)
Mirza 等人在他们的论文条件生成对抗网络中,提出了一种条件版本的 GAN 框架。这个修改非常容易理解,并且是今天广泛应用的惊人 GAN 应用的基础。
一些最令人惊叹的 GAN 应用,例如通过语义标签生成街景,或者给定灰度输入对图像进行上色,作为条件 GAN 思想的专门版本,经过图像超分辨率处理。
条件生成对抗网络基于这样的思想:如果生成器(G)和判别器(D)都根据某些附加信息进行条件化,那么 GAN 就可以扩展为条件模型,y。这些附加信息可以是任何形式的附加数据,从类别标签到语义图,或者来自其他模态的数据。通过将附加信息作为额外的输入层同时馈入生成器和判别器,可以实现这种条件化。下面的图示来自条件生成对抗网络论文,清楚地展示了生成器和判别器模型如何扩展以支持条件化:
条件生成对抗网络。生成器和判别器有一个附加的输入,y,表示条件化模型的辅助信息(图片来源:条件生成对抗网络,Mirza 等,2014)。
生成器架构被扩展为将噪声的联合隐层表示与条件结合。没有关于如何将条件输入生成器网络的限制。你可以简单地将条件与噪声向量连接起来。或者,如果条件比较复杂,可以使用神经网络对其进行编码,并将其输出连接到生成器的某一层。判别器同样也可以采用相同的逻辑。
对模型进行条件化改变了值函数,因为我们从中采样的数据分布现在是条件化的:
在对抗训练过程中没有其他变化,关于非饱和值函数的考虑仍然适用。
在本节中,我们将实现一个条件的 Fashion-MNIST 生成器。
获取条件生成对抗网络(GAN)数据
通过使用 TensorFlow 数据集,获取数据非常直接。由于目标是创建一个 Fashion-MNIST 生成器,我们将使用类别标签作为条件。从tfds.load调用返回的数据是字典格式。因此,我们需要定义一个函数,将字典映射到一个只包含图像和对应标签的元组。在这个阶段,我们还可以准备整个数据输入管道:
(tf2)
import tensorflow as tf
import tensorflow_datasets as tfds
import matplotlib.pyplot as plt
dataset = tfds.load("fashion_mnist", split="train")
def convert(row):
image = tf.image.convert_image_dtype(row["image"], tf.float32)
label = tf.cast(row["label"], tf.float32)
return image, label
batch_size = 32
dataset = dataset.map(convert).batch(batch_size).prefetch(1)
定义条件生成对抗网络中的生成器
由于我们处理的是图像,天然的选择是使用卷积神经网络。特别是,使用我们在第八章中介绍的反卷积操作,语义分割和自定义数据集构建器,可以轻松定义一个类似解码器的网络,从潜在表示和条件开始生成图像:
(tf2)
def get_generator(latent_dimension):
# Condition subnetwork: encode the condition in a hidden representation
condition = tf.keras.layers.Input((1,))
net = tf.keras.layers.Dense(32, activation=tf.nn.elu)(condition)
net = tf.keras.layers.Dense(64, activation=tf.nn.elu)(net)
# Concatenate the hidden condition representation to noise and upsample
noise = tf.keras.layers.Input(latent_dimension)
inputs = tf.keras.layers.Concatenate()([noise, net])
# Convert inputs from (batch_size, latent_dimension + 1)
# To a 4-D tensor, that can be used with convolutions
inputs = tf.keras.layers.Reshape((1,1, inputs.shape[-1]))(inputs)
depth = 128
kernel_size= 5
net = tf.keras.layers.Conv2DTranspose(
depth, kernel_size,
padding="valid",
strides=1,
activation=tf.nn.relu)(inputs) # 5x5
net = tf.keras.layers.Conv2DTranspose(
depth//2, kernel_size,
padding="valid",
strides=2,
activation=tf.nn.relu)(net) #13x13
net = tf.keras.layers.Conv2DTranspose(
depth//4, kernel_size,
padding="valid",
strides=2,
activation=tf.nn.relu,
use_bias=False)(net) # 29x29
# Standard convolution with a 2x2 kernel to obtain a 28x28x1 out
# The output is a sigmoid, since the images are in the [0,1] range
net = tf.keras.layers.Conv2D(
1, 2,
padding="valid",
strides=1,
activation=tf.nn.sigmoid,
use_bias=False)(net)
model = tf.keras.Model(inputs=[noise, condition], outputs=net)
return model
在条件 GAN 中定义判别器
判别器架构很简单。条件化判别器的标准方法是将图像的编码表示与条件的编码表示连接在一起,并将条件放置在一个独特的向量中。这样做需要定义两个子网络——第一个子网络将图像编码为特征向量,第二个子网络将条件编码为另一个向量。以下代码阐明了这个概念:
(tf2)
def get_Discriminator():
# Encoder subnetwork: feature extactor to get a feature vector
image = tf.keras.layers.Input((28,28,1))
depth = 32
kernel_size=3
net = tf.keras.layers.Conv2D(
depth, kernel_size,
padding="same",
strides=2,
activation=tf.nn.relu)(image) #14x14x32
net = tf.keras.layers.Conv2D(
depth*2, kernel_size,
padding="same",
strides=2,
activation=tf.nn.relu)(net) #7x7x64
net = tf.keras.layers.Conv2D(
depth*3, kernel_size,
padding="same",
strides=2,
activation=tf.nn.relu)(net) #4x4x96
feature_vector = tf.keras.layers.Flatten()(net) # 4*4*96
在定义了将图像编码为特征向量的编码子网络后,我们准备创建条件的隐藏表示并将其与特征向量连接起来。这样做后,我们可以创建 Keras 模型并返回它:
(tf2)
# Create a hidden representation of the condition
condition = tf.keras.layers.Input((1,))
hidden = tf.keras.layers.Dense(32, activation=tf.nn.elu)(condition)
hidden = tf.keras.layers.Dense(64, activation=tf.nn.elu)(hidden)
# Concatenate the feature vector and the hidden label representation
out = tf.keras.layers.Concatenate()([feature_vector, hidden])
# Add the final classification layers with a single linear neuron
out = tf.keras.layers.Dense(128, activation=tf.nn.relu)(out)
out = tf.keras.layers.Dense(1)(out)
model = tf.keras.Model(inputs=[image, condition], outputs=out)
return model
对抗训练过程
对抗训练过程与我们为无条件 GAN 展示的过程相同。loss 函数完全相同:
(tf2)
bce = tf.keras.losses.BinaryCrossentropy(from_logits=True)
def d_loss(d_real, d_fake):
"""The disciminator loss function."""
return bce(tf.ones_like(d_real), d_real) + bce(tf.zeros_like(d_fake), d_fake)
def g_loss(generated_output):
"""The Generator loss function."""
return bce(tf.ones_like(generated_output), generated_output)
唯一的区别是我们的模型现在接受两个输入参数。
在决定噪声的先验维度并实例化 G 和 D 模型之后,定义训练函数需要对之前的训练循环做一些微小的修改。至于无条件 GAN 的训练循环定义,matplotlib 被用来记录图像。改进这个脚本的工作留给你去完成:
(tf2)
latent_dimension = 100
G = get_generator(latent_dimension)
D = get_Discriminator()
def train():
# Define the optimizers and the train operations
optimizer = tf.keras.optimizers.Adam(1e-5)
@tf.function
def train_step(image, label):
with tf.GradientTape(persistent=True) as tape:
noise_vector = tf.random.normal(
mean=0, stddev=1,
shape=(image.shape[0], latent_dimension))
# Sample from the Generator
fake_data = G([noise_vector, label])
# Compute the D loss
d_fake_data = D([fake_data, label])
d_real_data = D([image, label])
d_loss_value = d_loss(d_real_data, d_fake_data)
# Compute the G loss
g_loss_value = g_loss(d_fake_data)
# Now that we comptuted the losses we can compute the gradient
# and optimize the networks
d_gradients = tape.gradient(d_loss_value, D.trainable_variables)
g_gradients = tape.gradient(g_loss_value, G.trainable_variables)
# Deletng the tape, since we defined it as persistent
del tape
optimizer.apply_gradients(zip(d_gradients, D.trainable_variables))
optimizer.apply_gradients(zip(g_gradients, G.trainable_variables))
return g_loss_value, d_loss_value, fake_data[0], label[0]
epochs = 10
epochs = 10
for epoch in range(epochs):
for image, label in dataset:
g_loss_value, d_loss_value, generated, condition = train_step(image, label)
print("epoch ", epoch, "complete")
print("loss:", g_loss_value, "d_loss: ", d_loss_value)
print("condition ", info.features['label'].int2str(
tf.squeeze(tf.cast(condition, tf.int32)).numpy()))
plt.imshow(tf.squeeze(generated).numpy(), cmap='gray')
plt.show()
训练循环遍历训练集 10 个周期,并显示一个生成的 Fashion-MNIST 元素图像及其标签。在几个周期后,生成的图像变得越来越逼真,并开始与标签匹配,如下图所示:
生成的样本将输入随机噪声和条件 T 恤/上衣馈送给生成器。
总结
在本章中,我们研究了 GAN 和对抗训练过程。在第一部分中,介绍了对抗训练过程的理论解释,重点讨论了值函数,它用于将问题表述为一个最小-最大博弈。我们还展示了如何通过非饱和值函数,在实践中解决生成器如何解决饱和问题的学习。
我们接着看了如何在纯 TensorFlow 2.0 中实现用于创建无条件 GAN 的生成器和判别器模型。在这一部分,展示了 TensorFlow 2.0 的表达能力以及自定义训练循环的定义。事实上,展示了如何通过遵循 GAN 论文(生成对抗网络——Ian Goodfellow 等)中描述的步骤,轻松创建 Keras 模型并编写实现对抗训练过程的自定义训练循环。
Keras 函数式 API 也被广泛使用,其中实现了一个条件生成器,用于生成类似 Fashion-MNIST 的图像。该实现向我们展示了通过使用 Keras 函数式 API,如何将第二个输入(条件)传递给生成器和判别器,并轻松定义灵活的神经网络架构。
GAN 的宇宙在复杂的架构和巧妙的应用创意方面十分丰富。本章旨在解释 GAN 框架,并不声称涵盖所有内容;关于 GAN 的材料足够多,我甚至可以写一本完整的书。
本章以习题部分结束,其中包含一个挑战(问题 16 和 17):你能创建一个生成真实图像的条件 GAN 吗?从一个语义标签开始?
到目前为止,我们专注于如何训练各种模型,从简单的分类器到生成模型,而不考虑部署阶段。
在下一章,第十章,将模型投入生产,将展示每个实际机器学习应用的最后一步——学习模型的部署。
习题
尝试回答并解决以下习题,以扩展你从本章中获得的知识:
-
什么是对抗训练过程?
-
编写判别器和生成器所进行的极小极大博弈的价值函数。
-
解释为什么在训练的早期阶段,极小极大价值函数的公式可能会饱和。
-
编写并解释非饱和价值函数。
-
编写对抗训练过程的规则。
-
有没有关于如何向 GAN 传递条件的建议?
-
创建条件 GAN 意味着什么?
-
是否只能使用全连接神经网络来创建 GAN?
-
哪种神经网络架构更适合图像生成问题?
-
更新无条件 GAN 的代码:在 TensorBoard 上记录生成器和判别器的损失值,同时记录 matplotlib 图表。
-
无条件 GAN:在每个周期结束时保存模型参数到检查点。添加对模型恢复的支持,可以从最新的检查点重新开始。
-
通过将无条件 GAN 的代码扩展为条件 GAN,进行修改。给定条件 0 时,生成器必须表现得像正态分布,均值为 10,标准差为 0.1。给定条件 1 时,生成器必须产生一个从均值为 100、标准差为 1 的高斯分布中采样得到的值。
-
在 TensorBoard 中记录计算得出的梯度幅值,用于更新判别器和生成器。如果梯度幅值的绝对值大于 1,应用梯度裁剪。
-
对条件 GAN 重复执行第 1 和第 2 个练习。
-
条件 GAN:不要使用 matplotlib 绘制图像;使用
tf.summary.image和 TensorBoard。 -
使用我们在上一章中创建的数据集,第八章,语义分割和自定义数据集构建器,创建一个条件 GAN,执行领域转换,从语义标签到图像。
-
使用 TensorFlow Hub 下载一个预训练的特征提取器,并将其作为构建块,用于创建条件 GAN 的判别器,该 GAN 根据语义标签生成逼真的场景。
第十一章:将模型部署到生产环境
在本章中,将介绍任何现实机器学习应用的最终目标——训练模型的部署与推理。正如我们在前几章所看到的,TensorFlow 允许我们训练模型并将其参数保存在检查点文件中,这使得恢复模型状态并继续训练变得可能,同时也能够从 Python 执行推理。
然而,检查点文件在目标是使用经过训练的机器学习模型进行低延迟和低内存占用时并不是合适的文件格式。事实上,检查点文件只包含模型的参数值,而没有计算的描述;这迫使程序先定义模型结构,然后再恢复模型参数。此外,检查点文件包含的变量值仅在训练过程中有用,但在推理时(例如,优化器创建的所有变量)完全浪费资源。正确的表示方式是使用 SavedModel 序列化格式,接下来会进行详细介绍。在分析了 SavedModel 序列化格式,并查看如何将一个 tf.function 装饰的函数进行图转换和序列化后,我们将深入探讨 TensorFlow 部署生态系统,了解 TensorFlow 2.0 如何加速图在多个平台上的部署,并且如何为大规模服务而设计。
在本章中,我们将涵盖以下主题:
-
SavedModel 序列化格式
-
Python 部署
-
支持的部署平台
SavedModel 序列化格式
正如我们在第三章中解释的,TensorFlow 图计算架构,通过数据流图(DataFlow graphs)表示计算具有多个优势,特别是在模型可移植性方面,因为图是一种与语言无关的计算表示。
SavedModel 是 TensorFlow 模型的通用序列化格式,它通过创建一个语言无关的计算表示,扩展了 TensorFlow 标准图表示,使得该表示不仅可恢复且是封闭的。这种表示的设计不仅用于承载图的描述和值(像标准图一样),还提供了额外的特性,这些特性旨在简化在异构生产环境中使用训练过的模型。
TensorFlow 2.0 在设计时考虑了简洁性。这种设计选择在以下图示中可以明显看到,在这个图示中,可以看到 SavedModel 格式是研究和开发阶段(左侧)与部署阶段(右侧)之间的唯一桥梁:
TensorFlow 2.0 的训练和部署生态系统。图片来源:medium.com/tensorflow/whats-coming-in-tensorflow-2-0-d3663832e9b8——TensorFlow 团队
作为模型训练和部署之间的桥梁,SavedModel 格式必须提供广泛的特性,以满足可用的各种部署平台,从而为不同的软件和硬件平台提供出色的支持。
特性
SavedModel 包含一个完整的计算图,包括模型参数以及在创建过程中指定的所有其他内容。使用 TensorFlow 1.x API 创建的 SavedModel 对象仅包含计算的平面图表示;在 TensorFlow 2.0 中,SavedModel 包含一个序列化的tf.function对象表示。
创建 SavedModel 在使用 TensorFlow Python API 时非常简单(如下一节所示),但其配置要求你理解其主要特性,具体如下:
-
Graph tagging:在生产环境中,你经常需要将模型投入生产,同时在获得新数据后继续开发同一模型。另一个可能的场景是并行训练两个或多个相同的模型,这些模型使用不同的技术或不同的数据进行训练,并希望将它们全部投入生产,以测试哪个性能更好。
SavedModel 格式允许你在同一个文件中拥有多个图,这些图共享相同的变量和资产集。每个图都与一个或多个标签(用户定义的字符串)相关联,便于我们在加载操作时识别它。
-
SignatureDefs:在定义计算图时,我们需要了解模型的输入和输出;这被称为模型签名。SavedModel 序列化格式使用
SignatureDefs来允许对可能需要保存在graph.SignatureDefs中的签名提供通用支持。SignatureDefs仅仅是定义了哪些节点可以调用模型以及哪个是输出节点的命名模型签名集合,给定一个特定的输入。 -
Assets:为了允许模型依赖外部文件进行初始化,SavedModel 支持资产的概念。资产在 SavedModel 创建过程中被复制到 SavedModel 位置,并且可以被模型初始化过程安全地读取。
-
Device cleanup:我们在第三章中看到的计算图,TensorFlow 图架构,包含了计算必须执行的设备名称。为了生成可以在任何硬件平台上运行的通用图,SavedModel 支持在生成之前清理设备。
这些特性使您能够创建独立且自包含的硬件对象,指定如何调用模型、给定特定输入时的输出节点,以及在可用模型中使用哪一个特定模型(通过标签)。
从 Keras 模型创建 SavedModel
在 TensorFlow 1.x 中,创建 SavedModel 需要知道输入节点是什么,输出节点是什么,并且我们必须成功加载要保存的模型的图形表示到 tf.Session 函数中。
TensorFlow 2.0 大大简化了创建 SavedModel 的过程。由于 Keras 是唯一定义模型的方式,而且不再有会话,创建 SavedModel 的过程只需要一行代码:
(tf2)
具体结构如下:
# model is a tf.keras.Model model
path = "/tmp/model/1"
tf.saved_model.save(model, path)
path 变量遵循一种良好的实践,即在导出路径中直接添加模型的版本号 (/1)。与模型关联的唯一标签是默认的 tag: "serve"。
tf.saved_model.save 调用会在指定的 path 变量中创建以下目录结构。
assets/
variables/
variables.data-?????-of-?????
variables.index
saved_model.pb
目录包含以下内容:
-
assets包含辅助文件。这些文件在前一节中有描述。 -
variables包含模型变量。这些变量与检查点文件中的变量一样,是通过 TensorFlow Saver 对象创建的。 -
saved_model.pb是已编译的 Protobuf 文件。这是 Keras 模型描述的计算的二进制表示。
Keras 模型已经指定了模型的输入和输出;因此,无需担心哪个是输入哪个是输出。从 Keras 模型导出的 SignatureDef(值得提醒的是,它们只是描述如何调用模型的命名函数)是调用 Keras 模型的 call 方法(即前向传递),并且它在 serving_default 签名键下导出。
从 Keras 模型创建 SavedModel 非常简单,因为前向传递的描述包含在其 call 方法中。然后,TensorFlow 会使用 AutoGraph 自动将该函数转换为其图形等效表示。call 方法的输入参数成为图形的输入签名,而 Keras 模型的输出则成为输出。
然而,我们可能不想导出 Keras 模型。如果我们只想部署并提供一个通用的计算图怎么办?
从通用函数转换 SavedModel
在 TensorFlow 1.x 中,导出通用图和模型没有区别:选择输入和输出节点,创建会话,定义签名,然后保存。
在 TensorFlow 2.0 中,由于图形被隐藏,将通用的 TensorFlow 计算转换为 SavedModel(图形)需要一些额外的注意。
tf.saved_model.save(obj, export_dir, signatures=None) 函数的第一个参数描述清楚,obj 必须是一个可追踪的 对象。
可跟踪对象是从TrackableBase类派生的对象(私有,意味着它在tensorflow包中不可见)——几乎 TensorFlow 2.0 中的所有对象都派生自这个类。这些对象是可以存储在检查点文件中的对象,其中包括 Keras 模型、优化器等。
因此,像下面这样的函数无法导出,除非创建一个继承自TrackableBase对象的对象:
(tf2)
def pow(x, y):
return tf.math.pow(x, y)
TensorFlow API 中最通用的类是tf.Module类,一旦实例化,它会创建一个可跟踪对象。模块是一个命名容器,用于存放tf.Variable对象、其他模块和适用于用户输入的函数。继承tf.Module是创建可跟踪对象的直接方法,并满足tf.saved_model.save函数的要求:
(tf2)
class Wrapper(tf.Module):
def pow(self, x, y):
return tf.math.pow(x, y)
由于不是 Keras 模型,tf.saved_model.save不知道Wrapper类中的哪个方法适用于图形转换。我们可以通过两种不同的方式来指示save函数仅转换我们感兴趣的方法。它们如下:
-
指定签名:
save函数的第三个参数可以选择接受一个字典。字典必须包含要导出的函数名称和输入描述。通过使用tf.TensorSpec对象来实现。 -
使用
tf.function:当省略signature参数时,save模式会在obj中查找被@tf.function装饰的方法。如果找到了恰好一个方法,那么该方法将作为 SavedModel 的默认签名使用。并且在这种情况下,我们必须通过手动传递tf.TensorSpec对象来描述输入类型和形状,传递给tf.function的input_signature参数。
第二种方法是最方便的,它的优点还在于能够定义并将当前的 Python 程序转换为图形。使用时,这可以加速计算。
(tf2)
class Wrapper(tf.Module):
@tf.function(
input_signature=[
tf.TensorSpec(shape=None, dtype=tf.float32),
tf.TensorSpec(shape=None, dtype=tf.float32),
]
)
def pow(self, x, y):
return tf.math.pow(x, y)
obj = Wrapper()
tf.saved_model.save(obj, "/tmp/pow/1")
因此,将通用函数导出为其 SavedModel 表示的方式是将函数封装到一个可跟踪对象中,使用tf.function装饰器装饰方法,并指定转换过程中使用的输入签名。
这就是我们导出通用函数的全部步骤,也就是导出一个通用计算图或 Keras 模型到其自包含且与语言无关的表示形式,以便它可以在任何编程语言中使用。
使用 SavedModel 对象的最简单方法是使用 TensorFlow Python API,因为它是 TensorFlow 更完整的高级 API,并提供了方便的方法来加载和使用 SavedModel。
Python 部署
使用 Python,加载保存在 SavedModel 中的计算图并将其用作本地 Python 函数是非常简单的。这一切都要归功于 TensorFlow 的 Python API。tf.saved_model.load(path)方法会将位于path的 SavedModel 反序列化,并返回一个可跟踪的对象,该对象具有signatures属性,包含从签名键到已准备好使用的 Python 函数的映射。
load方法能够反序列化以下内容:
-
通用计算图,例如我们在上一节中创建的那些
-
Keras 模型
-
使用 TensorFlow 1.x 或 Estimator API 创建的 SavedModel
通用计算图
假设我们有兴趣加载在上一节中创建的pow函数的计算图,并在 Python 程序中使用它。在 TensorFlow 2.0 中,这非常简单。按照以下步骤进行操作:
- 导入模型:
(tf2)
path = "/tmp/pow/1"
imported = tf.saved_model.load(path)
imported对象具有一个signatures属性,我们可以检查它来查看可用的函数。在这种情况下,由于我们在导出模型时没有指定签名,因此我们预计只会找到默认签名"serving_default":
(tf2)
assert "serving_default" == list(imported.signatures)[0]
assert len(imported.signatures) == 1
可以通过访问imported.signatures["serving_default"]来获取幂函数的计算图。然后,它就可以准备使用了。
使用导入的计算图要求你对 TensorFlow 图结构有良好的理解,正如在第三章《TensorFlow 图架构》中解释的那样。事实上,imported.signatures["serving_default"]函数是一个静态图,因此,它需要一些额外的关注才能使用。
- 调用计算图并传入错误的输入类型会导致引发异常,因为静态图是严格静态类型的。此外,
tf.saved_model.load函数返回的对象强制要求只使用命名参数,而不能使用位置参数(这与pow函数的原始定义不同,后者只使用位置参数)。因此,一旦定义了正确形状和输入类型的输入,就可以轻松调用该函数:
(tf2)
pow = imported.signatures["serving_default"]
result = pow(x=tf.constant(2.0), y=tf.constant(5.0))
与你预期的不同,result变量并不包含一个值为32.0的tf.Tensor对象;它是一个字典。使用字典来返回计算结果是一个好的设计选择。事实上,这强制调用者(使用导入的计算图的 Python 程序)显式访问一个键,以指示所需的返回值。
- 在
pow函数的情况下,返回值是tf.Tensor而不是 Python 字典,返回的字典具有遵循命名约定的键——键名始终是"output_"字符串,后跟返回参数的位置(从零开始)。以下代码片段阐明了这个概念:
(tf2)
assert result["output_0"].numpy() == 32
如果 pow 函数更新如下,字典键将变为 "output_0", "output_1":
(tf2)
def pow(self, x, y):
return tf.math.pow(x, y), tf.math.pow(y, x)
当然,依赖默认的命名约定并不是一个好的或可维护的解决方案(output_0 代表什么?)。因此,在设计将要导出的函数时,最好使函数返回一个字典,这样导出的 SavedModel 在调用时将使用相同的字典作为返回值。因此,pow 函数的更好设计可能如下:
(tf2)
class Wrapper(tf.Module):
class Wrapper(tf.Module):
@tf.function(
input_signature=[
tf.TensorSpec(shape=None, dtype=tf.float32),
tf.TensorSpec(shape=None, dtype=tf.float32),
]
)
def pow(self, x, y):
return {"pow_x_y":tf.math.pow(x, y), "pow_y_x": tf.math.pow(y, x)}
obj = Wrapper()
tf.saved_model.save(obj, "/tmp/pow/1")
一旦导入并执行,以下代码将生成一个包含有意义名称的字典:
(tf2)
path = "/tmp/pow/1"
imported = tf.saved_model.load(path)
print(imported.signatures"serving_default",y=tf.constant(5.0)))
结果输出是以下字典:
{
'pow_x_y': <tf.Tensor: id=468, shape=(), dtype=float32, numpy=32.0>,
'pow_y_x': <tf.Tensor: id=469, shape=(), dtype=float32, numpy=25.0>
}
TensorFlow Python API 简化了不仅仅是通用计算图的加载,也简化了训练后的 Keras 模型的使用。
Keras 模型
作为官方 TensorFlow 2.0 定义机器学习模型的方式,Keras 模型在序列化时不仅包含序列化的 call 方法。由 load 函数返回的对象类似于恢复通用计算图时返回的对象,但具有更多的属性和特点:
-
.variables属性:附加在原始 Keras 模型上的不可训练变量已被序列化并存储在 SavedModel 中。 -
.trainable_variables属性:与.variables属性类似,模型的可训练变量也被序列化并存储在 SavedModel 中。 -
__call__方法:返回的对象暴露了一个__call__方法,接受的输入方式与原始 Keras 模型相同,而不是暴露一个具有单一键"serving_default"的signatures属性。
所有这些功能不仅允许将 SavedModel 作为独立的计算图使用,如下面的代码片段所示,还允许你完全恢复 Keras 模型并继续训练:
(tf2)
imported = tf.saved_model.load(path)
# inputs is a input compatible with the serialized model
outputs = imported(inputs)
正如我们之前提到的,所有这些附加特性(可训练和不可训练的变量,以及计算的序列化表示)允许从 SavedModel 完全恢复 Keras 模型对象,从而使它们可以作为检查点文件使用。Python API 提供了 tf.keras.models.load_model 函数来完成这一任务,并且在 TensorFlow 2.0 中,它非常方便:
(tf2)
model = tf.keras.models.load_model(path)
# models is now a tf.keras.Model object!
这里,path 是 SavedModel 或 h5py 文件的路径。由于 h5py 序列化格式是 Keras 的表示形式,并且与 SavedModel 序列化格式相比没有额外的优势,因此本书不考虑 h5py 格式。
Python API 还向后兼容 TensorFlow 1.x 的 SavedModel 格式,因此你可以恢复平坦图,而不是tf.function对象。
平坦图
由tf.estimator API 或使用 SavedModel 1.x API 创建的 SavedModel 对象包含计算的更原始表示,这种表示称为平坦图。
在这种表示形式中,平坦图不会继承来自tf.function对象的任何签名,以简化恢复过程。它只需直接获取计算图,以及其节点名称和变量(详情请见第三章,TensorFlow 图架构)。
这些 SavedModel 具有与其签名对应的函数(在序列化过程之前手动定义)存储在.signatures属性中,但更重要的是,恢复的 SavedModel 使用新的 TensorFlow 2.0 API 具有.prune方法,允许你仅通过知道输入和输出节点名称就能从任意子图中提取函数。
使用.prune方法相当于在默认图中恢复 SavedModel 并将其放入 TensorFlow 1.x 的 Session 中;然后,可以通过使用tf.Graph.get_tensor_by_name方法访问输入和输出节点。
通过.prune方法,TensorFlow 2.0 简化了这一过程,使其变得像下面的代码片段一样简单:
(tf2)
imported = tf.saved_model.load(v1savedmodel_path)
pruned = imported.prune("input_:0", "cnn/out/identity:0")
# inputs is an input compatible with the flat graph
out = pruned(inputs)
在这里,input_是任何可能输入节点的占位符,而"cnn/out/identity:0"是输出节点。
在 Python 程序中加载 SavedModel 后,可以将训练好的模型(或通用计算图)用作任何标准 Python 应用程序的构建块。例如,一旦你训练了一个人脸检测模型,就可以轻松使用 OpenCV(最著名的开源计算机视觉库)打开网络摄像头流,并将其输入到人脸检测模型中。训练模型的应用无数,你可以开发自己的 Python 应用程序,将训练好的机器学习模型作为构建块。
尽管 Python 是数据科学的主要语言,但它并不是在不同平台上部署机器学习模型的完美选择。有些编程语言是某些任务或环境的事实标准;例如,Javascript 用于客户端 Web 开发,C++和 Go 用于数据中心和云服务,等等。
作为一种语言无关的表示形式,理论上可以使用任何编程语言加载和执行(部署)SavedModel;这具有巨大的优势,因为有些情况下 Python 不可用,或者不是最佳选择。
TensorFlow 支持许多不同的部署平台:它提供了许多不同语言的工具和框架,以满足广泛的使用场景。
支持的部署平台
如本章开头的图示所示,SavedModel 是一个庞大部署平台生态系统的输入,每个平台的创建目标是满足不同的使用场景需求:
-
TensorFlow Serving:这是谷歌官方提供的机器学习模型服务解决方案。它支持模型版本控制,多个模型可以并行部署,并且通过完全支持硬件加速器(GPU 和 TPU),确保并发模型在低延迟下实现高吞吐量。TensorFlow Serving 不仅仅是一个部署平台,而是围绕 TensorFlow 构建的一个完整生态系统,且使用高效的 C++代码编写。目前,这是谷歌自己用来在 Google Cloud ML 平台上每秒处理数千万次推理的解决方案。
-
TensorFlow Lite:这是在移动设备和嵌入式设备上运行机器学习模型的首选部署平台。TensorFlow Lite 是一个全新的生态系统,拥有自己的训练和部署工具。它的设计目标是优化训练后的模型大小,从而生成一个针对快速推理和低功耗消耗优化的、原始模型的小型二进制表示。此外,TensorFlow Lite 框架还提供了构建新模型和重新训练现有模型的工具(因此它允许进行迁移学习/微调),这些操作可以直接在嵌入式设备或智能手机上完成。
TensorFlow Lite 附带一个 Python 工具链,用于将 SavedModel 转换为其优化表示,即
.tflite文件。 -
TensorFlow.js:这是一个类似于 TensorFlow Lite 的框架,但设计目的是在浏览器和 Node.js 中训练和部署 TensorFlow 模型。与 TensorFlow Lite 类似,该框架提供了一个 Python 工具链,可用于将 SavedModel 转换为 TensorFlow JavaScript 库可读的 JSON 格式。TensorFlow.js 可以用于微调或从零开始训练模型,利用来自浏览器或任何其他客户端的数据传感器。
-
其他语言绑定:TensorFlow 核心是用 C++编写的,且为许多不同的编程语言提供绑定,其中大多数是自动生成的。绑定的结构通常非常低级,类似于 TensorFlow 1.x Python API 和 TensorFlow C++ API 内部使用的 TensorFlow 图结构。
TensorFlow 支持许多不同的部署平台,准备在广泛的平台和设备上进行部署。在接下来的章节中,您将学习如何使用 TensorFlow.js 在浏览器中部署训练好的模型,并如何使用 Go 编程语言进行推理。
TensorFlow.js
TensorFlow.js (www.tensorflow.org/js/) 是一个用于开发和训练机器学习模型的 JavaScript 库,支持在浏览器或 Node.js 中部署这些模型。
要在 TensorFlow.js 中使用,训练好的模型必须转换为 TensorFlow.js 可加载的格式。目标格式是一个包含model.json文件和一组包含模型参数的二进制文件的目录。model.json文件包含图形描述和关于二进制文件的信息,以确保能够成功恢复训练好的模型。
尽管它与 TensorFlow 2.0 完全兼容,但为了最佳实践,建议为 TensorFlow.js 创建一个独立的环境,正如在第三章的环境设置部分中所解释的那样,TensorFlow 图架构。TensorFlow.js 专用环境从现在开始会使用(tfjs)符号,在代码片段之前显示。
开发 TensorFlow.js 应用程序的第一步是将 TensorFlow.js 安装到独立环境中。你需要这样做,以便可以通过 Python 使用所有提供的命令行工具和库本身:
(tfjs)
pip install tensorflowjs
TensorFlow.js 与 TensorFlow 2.0 紧密集成。实际上,使用 Python 直接将 Keras 模型转换为 TensorFlow.js 表示是可能的。此外,它还提供了一个命令行界面,用于将任何计算图的通用 SavedModel 转换为其支持的表示。
将 SavedModel 转换为 model.json 格式
由于无法直接在 TensorFlow.js 中使用 SavedModel,我们需要将其转换为兼容版本,然后在 TensorFlow.js 运行时加载。tensorflowjs_converter命令行工具使得转换过程变得简单明了。此工具不仅执行 SavedModel 和 TensorFlow.js 表示之间的转换,还会自动量化模型,在必要时减少其维度。
假设我们有兴趣将前一节中导出的计算图的 SavedModel 转换为 TensorFlow 格式,方法是通过序列化的pow函数。使用tensorflowjs_converter,我们只需指定输入和输出文件格式(在这种情况下,输入是 SavedModel,输出是 TensorFlow.js 图模型)及其位置,之后我们就可以开始操作了:
(tfjs)
tensorflowjs_converter \
--input_format "tf_saved_model" \
--output_format "tfjs_graph_model" \
/tmp/pow/1 \
exported_js
上述命令读取位于/tmp/pow/1的 SavedModel,并将转换结果放入当前目录exported_js中(如果该目录不存在则创建它)。由于 SavedModel 没有参数,在exported_js文件夹中,我们只会看到包含计算描述的model.json文件。
我们现在可以开始了——我们可以定义一个简单的网页或一个简单的 Node.js 应用程序,导入 TensorFlow.js 运行时,然后成功导入并使用已转换的 SavedModel。以下代码创建了一个包含表单的一页应用程序;通过使用 pow 按钮的点击事件,可以加载已导出的图并执行计算:
<html>
<head>
<title>Power</title>
<!-- Include the latest TensorFlow.js runtime -->
<script src="img/tfjs@latest"></script>
</head>
<body>
x: <input type="number" step="0.01" id="x"><br>
y: <input type="number" step="0.01" id="y"><br>
<button id="pow" name="pow">pow</button><br>
<div>
x<sup>y</sup>: <span id="x_to_y"></span>
</div>
<div>
y<sup>x</sup>: <span id="y_to_x"></span>
</div>
<script>
document.getElementById("pow").addEventListener("click", async function() {
// Load the model
const model = await tf.loadGraphModel("exported_js/model.json")
// Input Tensors
let x = tf.tensor1d([document.getElementById("x").value], dtype='float32')
let y = tf.tensor1d([document.getElementById("y").value], dtype='float32')
let results = model.execute({"x": x, "y": y})
let x_to_y = results[0].dataSync()
let y_to_x = results[1].dataSync()
document.getElementById("x_to_y").innerHTML = x_to_y
document.getElementById("y_to_x").innerHTML = y_to_x
});
</script>
</body>
</html>
TensorFlow.js 在如何使用加载的 SavedModel 方面遵循了不同的约定。如前面的代码片段所示,SavedModel 中定义的签名得以保留,并且通过传递命名参数 "x" 和 "y" 来调用该函数。相反,返回值的格式已被更改:pow_x_y 和 pow_y_x 键已被丢弃,返回值现在是位置参数;在第一个位置(results[0]),我们找到了 pow_x_y 键的值,而在第二个位置找到了 pow_y_x 键的值。
此外,由于 JavaScript 是一种对异步操作有强大支持的语言,TensorFlow.js API 在使用时也大量采用了异步操作——模型加载是异步的,并且定义在一个 async 函数内部。即使是从模型中获取结果,默认也是异步的。但在这种情况下,我们通过使用 dataSync 方法强制调用为同步。
使用 Python,我们现在可以启动一个简单的 HTTP 服务器,并在浏览器中查看该应用程序:
(tfjs)
python -m http.server
通过在浏览器中访问 http://localhost:8000/ 地址并打开包含先前编写代码的 HTML 页面,我们可以直接在浏览器中查看和使用已部署的图:
虽然 TensorFlow.js API 与 Python 版本相似,但它有所不同,并遵循不同的规则;对 TensorFlow.js 进行全面分析超出了本书的范围,因此你应该查看官方文档,以便更好地理解 TensorFlow.js API。
与前述过程相比,后者涉及使用 tensorflowjs_converter,Keras 模型的部署更加简化,并且可以直接将 Keras 模型转换为 model.json 文件的过程嵌入到用于训练模型的 TensorFlow 2.0 Python 脚本中。
将 Keras 模型转换为 model.json 格式
如本章开始时所示,Keras 模型可以导出为 SavedModel,因此,前面提到的将 SavedModel 转换为 model.json 文件的过程仍然可以使用。然而,由于 Keras 模型是 TensorFlow 2.0 框架中的特定对象,因此可以直接将部署过程嵌入到 TensorFlow.js 中,这一操作发生在训练流程的最后:
(tfjs)
import tensorflowjs as tfjs
from tensorflow import keras
model = keras.models.Sequential() # for example
# create the model by adding layers
# Standard Keras way of defining and executing the training loop
# (this can be replaced by a custom training loop)
model.compile(...)
model.fit(...)
# Convert the model to the model.json in the exported_js dir
tfjs_target_dir = "exported_js"
tfjs.converters.save_keras_model(model, tfjs_target_dir)
转换过程很简单,因为它只包含一行代码,tfjs.converters.save_keras_model(model, tfjs_target_dir)。因此,实际应用部分留给你作为练习(有关更多信息,请参见练习部分)。
在可用的部署平台中,有一长串编程语言支持 TensorFlow,这些语言的支持通常通过自动生成的绑定来实现。
支持不同编程语言是一个很大的优势,因为它允许开发者将使用 Python 开发和训练的机器学习模型嵌入到他们的应用程序中。例如,如果我们是 Go 开发者,想要在我们的应用程序中嵌入一个机器学习模型,我们可以使用 TensorFlow Go 绑定或其基础上构建的简化接口 tfgo。
Go 绑定和 tfgo
TensorFlow 对 Go 编程语言的绑定几乎完全是从 C++ API 自动生成的,因此它们只实现了基本操作。没有 Keras 模型、没有即时执行,也没有任何其他 TensorFlow 2.0 新特性;事实上,几乎没有对 Python API 进行任何更改。此外,Go API 不包含在 TensorFlow API 稳定性保证范围内,这意味着在小版本发布之间,所有内容都可能发生变化。不过,这个 API 对于加载使用 Python 创建的模型并在 Go 应用程序中运行它们特别有用。
设置
设置环境比 Python 更加复杂,因为需要下载并安装 TensorFlow C 库,并克隆整个 TensorFlow 仓库来创建正确版本的 Go TensorFlow 包。
以下 bash 脚本展示了如何下载、配置并安装没有 GPU 的 TensorFlow Go API,版本为 1.13:
#!/usr/bin/env bash
# variables
TF_VERSION_MAJOR=1
TF_VERSION_MINOR=13
TF_VERSION_PATCH=1
curl -L "https://storage.googleapis.com/tensorflow/libtensorflow/libtensorflow-cpu-linux-x86_64-""$TF_VERSION_MAJOR"."$TF_VERSION_MINOR"."$TF_VERSION_PATCH"".tar.gz" | sudo tar -C /usr/local -xz
sudo ldconfig
git clone https://github.com/tensorflow/tensorflow $GOPATH/src/github.com/tensorflow/tensorflow/
pushd $GOPATH/src/github.com/tensorflow/tensorflow/tensorflow/go
git checkout r"$TF_VERSION_MAJOR"."$TF_VERSION_MINOR"
go build
一旦安装完成,就可以构建并运行一个仅使用 Go 绑定的示例程序。
Go 绑定
请参考本节提供的示例程序,www.tensorflow.org/install/lang_go。
正如你从代码中看到的,Go 中使用 TensorFlow 与 Python 或甚至 JavaScript 的使用方式非常不同。特别是,提供的操作非常低级,而且仍然需要遵循图定义和会话执行模式。TensorFlow Go API 的详细解释超出了本书的范围;不过,你可以阅读 理解使用 Go 的 TensorFlow 文章(pgaleone.eu/tensorflow/go/2017/05/29/understanding-tensorflow-using-go/),该文解释了 Go API 的基础知识。
一个简化使用 Go 绑定的 Go 包是 tfgo。在接下来的部分,我们将使用它来恢复并执行先前导出的 SavedModel 中的 pow 操作的计算图。
使用 tfgo
安装 tfgo 非常简单;只需在安装 TensorFlow Go 包之后使用以下代码:
go get -u github.com/galeone/tfgo
由于目标是使用 Go 部署之前定义的 pow 函数的 SavedModel,我们将使用 tfgo 的 LoadModel 函数,该函数用于根据路径和所需标签加载 SavedModel。
TensorFlow 2.0 配备了 saved_model_cli 工具,可以用来检查 SavedModel 文件。该工具对于正确使用 Go 绑定或 tfgo 使用 SavedModel 至关重要。事实上,与 Python 或 TensorFlow.js 相反,Go API 需要输入和输出操作的名称,而不是在 SavedModel 创建时给出的高级名称。
通过使用saved_model_cli show,可以获得关于已检查 SavedModel 的所有信息,从而能够在 Go 中使用它们:
saved_model_cli show --all --dir /tmp/pow/1
这将生成以下信息列表:
MetaGraphDef with tag-set: 'serve' contains the following SignatureDefs:
signature_def['__saved_model_init_op']:
The given SavedModel SignatureDef contains the following input(s):
The given SavedModel SignatureDef contains the following output(s):
outputs['__saved_model_init_op'] tensor_info:
dtype: DT_INVALID
shape: unknown_rank
name: NoOp
Method name is:
signature_def['serving_default']:
The given SavedModel SignatureDef contains the following input(s):
inputs['x'] tensor_info:
dtype: DT_FLOAT
shape: unknown_rank
name: serving_default_x:0
inputs['y'] tensor_info:
dtype: DT_FLOAT
shape: unknown_rank
name: serving_default_y:0
The given SavedModel SignatureDef contains the following output(s):
outputs['pow_x_y'] tensor_info:
dtype: DT_FLOAT
shape: unknown_rank
name: PartitionedCall:0
outputs['pow_y_x'] tensor_info:
dtype: DT_FLOAT
shape: unknown_rank
name: PartitionedCall:1
Method name is: tensorflow/serving/predict
最重要的部分如下:
-
标签名称:
serve是该 SavedModel 对象中唯一的标签。 -
SignatureDefs:该 SavedModel 中有两个不同的 SignatureDefs:
__saved_model_init_op,在这种情况下不执行任何操作;以及serving_default,它包含有关导出计算图的输入和输出节点的所有必要信息。 -
输入和输出:每个 SignatureDef 部分都包含输入和输出的列表。如我们所见,对于每个节点,输出张量的 dtype、形状和生成该输出张量的操作名称都是可用的。
由于 Go 绑定支持扁平化的图结构,我们必须使用操作名称,而不是在 SavedModel 创建过程中所使用的名称,来访问输入/输出节点。
现在我们拥有了所有这些信息,使用tfgo加载和执行模型变得很简单。以下代码包含了有关如何加载模型及其使用的信息,以便它只执行计算输出节点的操作![]:
(go)
package main
import (
"fmt"
tg "github.com/galeone/tfgo"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
)
在以下代码片段中,你将从 SavedModel 标签 "serve" 中恢复模型。定义输入张量,即 x=2,y=5。然后,计算结果。输出是第一个节点,"PartitionedCall:0",它对应于 x_to_y。输入名称是 "serving_default_{x,y}",对应于 x 和 y。预测结果需要转换回正确的类型,这里是 float32:
func main() {
model := tg.LoadModel("/tmp/pow/1", []string{"serve"}, nil)
x, _ := tf.NewTensor(float32(2.0))
y, _ := tf.NewTensor(float32(5.0))
results := model.Exec([]tf.Output{
model.Op("PartitionedCall", 0),
}, map[tf.Output]*tf.Tensor{
model.Op("serving_default_x", 0): x,
model.Op("serving_default_y", 0): y,
})
predictions := results[0].Value().(float32)
fmt.Println(predictions)
}
如预期的那样,程序输出 32。
使用saved_model_cli检查 SavedModel 并在 Go 程序或任何其他支持的部署平台中使用它的过程始终是一样的,无论 SavedModel 的内容如何。这是使用标准化 SavedModel 序列化格式作为训练/图定义与部署之间唯一连接点的最大优势之一。
概要
在这一章中,我们研究了 SavedModel 序列化格式。这个标准化的序列化格式旨在简化在多个不同平台上部署机器学习模型的过程。
SavedModel 是一种与语言无关、自包含的计算表示,整个 TensorFlow 生态系统都支持它。由于基于 SavedModel 格式的转换工具或 TensorFlow 绑定提供的其他语言的本地支持,可以在嵌入式设备、智能手机、浏览器上部署训练过的机器学习模型,或者使用许多不同的语言。
部署模型的最简单方式是使用 Python,因为 TensorFlow 2.0 API 完全支持创建、恢复和操作 SavedModel 对象。此外,Python API 还提供了额外的功能和 Keras 模型与 SavedModel 对象之间的集成,使得可以将它们用作检查点。
我们看到,TensorFlow 生态系统支持的所有其他部署平台都基于 SavedModel 文件格式或其某些转换。我们使用 TensorFlow.js 在浏览器和 Node.js 中部署模型。我们了解到,我们需要额外的转换步骤,但由于 Python TensorFlow.js 包和 Keras 模型的本地支持,这一步骤很简单。自动生成的语言绑定接近于 C++ API,因此更低级且难以使用。我们还了解了 Go 绑定和 tfgo,这是 TensorFlow Go API 的简化接口。结合用于分析 SavedModel 对象的命令行工具,您已经了解到如何读取 SavedModel 中包含的信息,并将其用于在 Go 中部署 SavedModel。
我们已经完成了本书的阅读。通过回顾前几章,我们可以看到我们所取得的所有进展。你在神经网络世界的旅程不应该在这里结束;事实上,这应该是一个起点,让您可以在 TensorFlow 2.0 中创建自己的神经网络应用程序。在这段旅程中,我们学习了机器学习和深度学习的基础知识,同时强调了计算的图形表示。特别是,我们了解了以下内容:
-
机器学习基础知识,从数据集的重要性到最常见的机器学习算法家族(监督学习、无监督学习和半监督学习)。
-
最常见的神经网络架构,如何训练机器学习模型以及如何通过正则化解决过拟合问题。
-
TensorFlow 图形架构在 TensorFlow 1.x 中明确使用,并且仍然存在于 TensorFlow 2.0 中。在本章中,我们开始编写 TensorFlow 1.x 代码,在处理
tf.function时发现它非常有用。 -
TensorFlow 2.0 架构及其新的编程方式,TensorFlow 2.0 Keras 实现,急切执行以及许多其他新功能,这些内容在前几章节中也有详细解释。
-
如何创建高效的数据输入管道,并且如何使用新的TensorFlow 数据集(tfds)项目快速获取常见的基准数据集。此外,还介绍了 Estimator API,尽管它仍然使用旧的图表示。
-
如何使用 TensorFlow Hub 和 Keras 对预训练模型进行微调或进行迁移学习。通过这样做,我们学会了如何快速构建一个分类网络,从而通过重用技术巨头的工作来加速训练时间。
-
如何定义一个简单的分类和回归网络,目的是引入目标检测主题并展示如何利用 TensorFlow 即时执行轻松训练一个多头网络。
-
在目标检测之后,我们关注了更难的任务(但更易实现)——对图像进行语义分割,并开发了我们自己的 U-Net 版本来解决它。由于 TensorFlow 数据集(tfds)中没有语义分割数据集,我们还学习了如何添加自定义 DatasetBuilder 来添加新的数据集。
-
生成对抗网络(GANs)的理论以及如何使用 TensorFlow 2.0 实现对抗训练循环。此外,通过使用 fashion-MNIST 数据集,我们还学习了如何定义和训练条件 GAN。
-
最后,在这一章中,我们学会了如何通过利用 SavedModel 序列化格式和 TensorFlow 2.0 Serving 生态系统,将训练好的模型(或通用计算图)带入生产环境。
尽管这是最后一章,但仍然有练习需要做,像往常一样,你不应该跳过它们!
练习
以下练习是编程挑战,结合了 TensorFlow Python API 的表达能力和其他编程语言带来的优势:
-
什么是检查点文件?
-
什么是 SavedModel 文件?
-
检查点(checkpoint)和 SavedModel 有什么区别?
-
什么是 SignatureDef?
-
检查点可以有 SignatureDef 吗?
-
SavedModel 可以有多个 SignatureDef 吗?
-
导出一个计算批量矩阵乘法的计算图作为 SavedModel;返回的字典必须有一个有意义的键值。
-
将上一个练习中定义的 SavedModel 转换为 TensorFlow.js 表示。
-
使用我们在上一个练习中创建的
model.json文件,开发一个简单的网页,允许用户选择矩阵并计算其乘积。 -
恢复在第八章中定义的语义分割模型,语义分割与自定义数据集构建器,从其最新检查点恢复,并使用
tfjs.converters.save_keras_model将其转换为model.json文件。 -
使用我们在上一练习中导出的语义分割模型,开发一个简单的网页,给定一张图像,执行语义分割。使用
tf.fromPixels方法获取输入模型。TensorFlow.js API 的完整参考可以在js.tensorflow.org/api/latest/找到。 -
编写一个使用 TensorFlow Go 绑定的 Go 应用程序,计算一张图像与一个 3x3 卷积核之间的卷积。
-
使用 tfgo 重写你在上一练习中编写的 Go 应用程序。使用“image”包。有关更多信息,请阅读
github.com/galeone/tfgo上的文档。 -
恢复我们在第八章中定义的语义分割模型,语义分割与自定义数据集构建器,将其恢复到最新的检查点,并将其导出为 SavedModel 对象。
-
使用
tg.LoadModel将语义分割模型加载到 Go 程序中,并利用该模型为输入图像生成分割图,该图像的路径作为命令行参数传入。