面向计算机视觉的机器学习实践指南-全-

面向计算机视觉的机器学习实践指南(全)

原文:zh.annas-archive.org/md5/1ae02a400f3c153f70befbc5e6e9f728

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

图像上的机器学习正在改变医疗保健、制造业、零售业和许多其他行业。通过训练机器学习(ML)模型识别图像中的对象,许多以前难以解决的问题现在可以解决。本书的目标是提供对支撑这一快速发展领域的 ML 架构的直观解释,并提供实用代码来应用这些 ML 模型解决涉及分类、测量、检测、分割、表征、生成、计数等问题。

图像分类是深度学习的“hello world”。因此,本书还提供了深度学习的实用端到端介绍。它可以作为进入其他深度学习领域(如自然语言处理)的基础。

您将学习如何设计用于计算机视觉任务的 ML 架构,并使用 TensorFlow 和 Keras 中流行的、经过充分测试的预建模型进行模型训练。您还将学习提高准确性和可解释性的技术。最后,本书将教您如何设计、实施和调整端到端的 ML 管道来理解图像任务。

本书适合谁?

本书的主要读者是希望在图像上进行机器学习的软件开发人员。它适用于将使用 TensorFlow 和 Keras 解决常见计算机视觉用例的开发人员。

本书讨论的方法附带在https://github.com/GoogleCloudPlatform/practical-ml-vision-book的代码示例。本书大部分涉及开源 TensorFlow 和 Keras,并且无论您在本地、Google Cloud 还是其他云上运行代码,它都能正常工作。

希望使用 PyTorch 的开发人员将会发现文本解释有用,但可能需要在其他地方寻找实用代码片段。我们欢迎提供 PyTorch 等效代码示例的贡献,请向我们的 GitHub 存储库提交拉取请求。

如何使用本书

我们建议您按顺序阅读本书。确保阅读、理解并运行书中的附带笔记本在GitHub 存储库中的章节——您可以在 Google Colab 或 Google Cloud 的 Vertex 笔记本中运行它们。我们建议在阅读每个文本部分后尝试运行代码,以确保您充分理解引入的概念和技术。我们强烈建议在转到下一章之前完成每章的笔记本。

Google Colab 是免费的,可以运行本书中大多数的笔记本;Vertex Notebooks 更为强大,因此可以帮助您更快地运行笔记本。在第三章、4 章、11 章和 12 章中更复杂的模型和更大的数据集将受益于使用 Google Cloud TPUs。因为本书中所有代码都使用开源 API 编写,所以代码应该也能在任何其他安装了最新版本 TensorFlow 的 Jupyter 环境中运行,无论是您的笔记本电脑,还是 Amazon Web Services(AWS)Sagemaker,或者 Azure ML。然而,我们尚未在这些环境中进行过测试。如果您发现需要进行任何更改才能使代码在其他环境中工作,请提交一个拉取请求,以帮助其他读者。

本书中的代码在 Apache 开源许可下向您提供。它主要作为教学工具,但也可以作为您生产模型的起点。

书籍组织

本书的其余部分组织如下:

  • 在第二章中,我们介绍了机器学习、如何读取图像以及如何使用 ML 模型进行训练、评估和预测。我们在第二章中介绍的模型是通用的,因此在图像上的表现不是特别好,但本章介绍的概念对本书的其余部分至关重要。

  • 在第三章中,我们介绍了一些在图像上表现良好的机器学习模型。我们从迁移学习和微调开始,然后介绍了各种卷积模型,这些模型随着我们深入章节变得越来越复杂。

  • 在第四章中,我们探讨了利用计算机视觉解决目标检测和图像分割问题的方法。任何在第三章介绍的主干架构都可以在第四章中使用。

  • 在第 5 到第九章,我们深入探讨了创建生产级计算机视觉机器学习模型的细节。我们逐个阶段地进行标准机器学习流程,包括在第五章中的数据集创建,第六章中的预处理,第七章中的训练,第八章中的监控和评估,以及第九章中的部署。这些章节讨论的方法适用于第三章和第四章中讨论的任何模型架构和用例。

  • 在第十章中,我们讨论了三大新兴趋势。我们将第 5 至第九章涵盖的所有步骤连接成端到端的、容器化的机器学习管道,然后尝试了一个无代码图像分类系统,可用于快速原型设计,也可作为更定制模型的基准。最后,我们展示了如何在图像模型预测中加入可解释性。

  • 在第十一章和第十二章中,我们演示了计算机视觉的基本构建模块如何用于解决各种问题,包括图像生成、计数、姿态检测等。这些高级用例都有相应的实现。

本书使用的约定

本书使用以下排版约定:

Italic

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

Constant width

用于程序清单,以及段落内用来指代程序元素如变量名、函数名、数据类型、环境变量、语句和关键字的内容。

**Constant width bold**

用于强调代码片段中的内容,以及显示用户应直接输入的命令或其他文本。

*Constant width italic*

显示应由用户提供的值或由上下文确定的值。

提示

这一元素表示提示或建议。

注意

这一元素表示一般注释。

警告

这一元素表示警告。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/GoogleCloudPlatform/practical-ml-vision-book下载。

如果你有技术问题或者在使用代码示例时遇到问题,请发送邮件至 bookquestions@oreilly.com。

本书旨在帮助您完成工作。一般情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您重现了大量代码,否则无需联系我们以获得许可。例如,编写一个使用本书多个代码块的程序不需要许可。出售或分发包含 O'Reilly 书籍示例的 CD-ROM 需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书中大量示例代码整合到产品文档中需要许可。

我们感谢您的支持,但不要求您署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Practical Machine Learning for Computer Vision,作者 Valliappa Lakshmanan、Martin Görner 和 Ryan Gillard,版权所有 2021 年 Valliappa Lakshmanan、Martin Görner 和 Ryan Gillard,978-1-098-10236-4。”

如果您认为您使用的代码示例超出了合理使用范围或以上述许可授权之外,请随时通过 permissions@oreilly.com 联系我们。

致谢

我们非常感谢我们的超级审阅员 Salem Haykal 和 Filipe Gracio,他们审阅了本书的每一章节——他们对细节的把握无处不在。同时也感谢 O’Reilly 的技术审阅员 Vishwesh Ravi Shrimali 和 Sanyam Singhal 提出的重新排序建议,改进了书籍的组织。此外,我们还要感谢 Rajesh Thallam、Mike Bernico、Elvin Zhu、Yuefeng Zhou、Sara Robinson、Jiri Simsa、Sandeep Gupta 和 Michael Munn,他们审阅了与他们专业领域相关的章节。当然,任何剩余的错误都属于我们自己。

我们要感谢 Google Cloud 用户、我们的团队成员以及 Google Cloud 高级解决方案实验室的许多同事,他们推动我们使解释更加简洁。同时也感谢 TensorFlow、Keras 和 Google Cloud AI 工程团队成为深思熟虑的合作伙伴。

我们的 O'Reilly 团队提供了重要的反馈和建议。Rebecca Novack 建议更新早期的 O'Reilly 关于这一主题的书籍,并且接受了我们关于实际计算机视觉书籍现在涉及机器学习的建议,因此这本书需要完全重写。我们的编辑 Amelia Blevins 在 O'Reilly 保持了我们的进展。我们的副本编辑 Rachel Head 和制作编辑 Katherine Tozer 大大提高了我们写作的清晰度。

最后但也是最重要的是,还要感谢我们各自的家人给予的支持。

Valliappa Lakshmanan,华盛顿州贝尔维尤

Martin Görner,华盛顿州贝尔维尤

Ryan Gillard,加利福尼亚州普莱森顿

第一章:计算机视觉的机器学习

想象一下,你坐在花园里,观察周围的一切。你身体里有两个系统在工作:你的眼睛作为传感器创建场景的表示,而你的认知系统正在理解你的眼睛所看到的东西。因此,你可能看到一只鸟,一只虫子和一些动静,并意识到鸟已经走下小路,正在吃虫子(见 图 1-1)。

图 1-1. 人类视觉涉及我们的感知和认知系统。

计算机视觉试图通过提供图像形成的方法(模拟人类的感知系统)和机器感知(模拟人类的认知系统)来模仿人类视觉能力。模仿人类感知系统侧重于硬件以及设计和摆放诸如摄像机之类的传感器。而现代模仿人类认知系统的方法包括从图像中提取信息的机器学习(ML)方法。本书将涵盖这些方法。

例如,当我们看到一朵雏菊的照片时,我们的人类认知系统能够识别它为雏菊(见 图 1-2)。本书中构建的图像分类机器学习模型通过从雏菊的照片开始,模仿这种人类能力。

图 1-2. 一个图像分类的机器学习模型模仿了人类认知系统。

机器学习

如果你在 2010 年代初读关于计算机视觉的书,从照片中提取信息的方法不会涉及机器学习。相反,你会学习去噪、边缘检测、纹理检测和形态学(基于形状)操作。随着人工智能的进步(更具体地说,是机器学习的进步),这种情况已经改变。

人工智能(AI)探索计算机如何模仿人类的能力。机器学习 是 AI 的一个子领域,通过展示大量数据并指导计算机从中学习来教导计算机如何实现这一点。专家系统 是 AI 的另一个子领域,专家系统通过编程让计算机遵循人类逻辑来模仿人类的能力。在 2010 年代之前,像图像分类这样的计算机视觉任务通常是通过构建定制的图像滤波器来实现专家制定的逻辑。如今,图像分类通过卷积网络实现,这是一种深度学习的形式(见 图 1-3)。

图 1-3. 计算机视觉是人工智能的一个子领域,试图模仿人类视觉系统;尽管过去依赖专家系统方法,但今天已经转向机器学习。

以图 1-2 中雏菊的图像为例。机器学习方法通过向计算机展示大量图像及其标签(或正确答案)来教会计算机识别图像中的花卉类型。因此,我们会向计算机展示大量雏菊的图像,大量郁金香的图像等等。基于这样的带标签的训练数据集,计算机学习如何对它以前没有遇到过的图像进行分类。这个过程在第二章和第三章中有详细讨论。

另一方面,在专家系统的方法中,我们会首先采访人类植物学家,了解他们如何分类花朵。如果植物学家解释说bellis perennis(雏菊的学名)由白色的细长花瓣围绕着黄色中心和绿色圆形叶子组成,我们会尝试设计图像处理滤波器以匹配这些标准。例如,我们会寻找图像中白色、黄色和绿色的普遍性。然后,我们会设计边缘滤波器来识别叶子的边界,并匹配形态学滤波器来查看它们是否符合预期的圆形形状。我们可能会在 HSV(色调、饱和度、亮度)空间中平滑图像,以确定花朵中心的颜色与花瓣颜色的比较情况。基于这些标准,我们可能会为图像评分,评估其为雏菊的可能性。同样地,我们会为玫瑰、郁金香、向日葵等设计并应用不同的规则集。要对新图像进行分类,我们会选择得分最高的类别。

这个描述说明了创造图像分类模型所需的大量定制工作。这就是为什么图像分类曾经的适用性有限。

2012 年,随着AlexNet 论文的发表,一切都改变了。作者——Alex Krizhevsky、Ilya Sutskever 和 Geoffrey E. Hinton——通过将卷积网络(在第三章介绍)应用于 ImageNet 大规模视觉识别挑战(ILSVRC)中使用的基准数据集,大大超越了任何现有的图像分类方法。他们的前五错误率为 15.3%¹,而亚军的错误率超过 26%。像这样的比赛通常的改进幅度大约为 0.1%,所以 AlexNet 展示的进步是大多数人意料之外的一百倍!这是引人注目的表现。

自 20 世纪 70 年代起,神经网络已经存在,而卷积神经网络(CNN)自那时起已经存在了二十多年——Yann LeCun 在1989 年提出了这个想法。那么,AlexNet 有何新意呢?有四点:

图形处理单元(GPU)

卷积神经网络是一个很好的想法,但计算上非常昂贵。AlexNet 的作者们在专用芯片称为 GPU 提供的图形渲染库上实现了一个卷积网络。当时,GPU 主要用于高端可视化和游戏。该论文将卷积分组以适应模型跨两个 GPU。GPU 使得卷积网络的训练变得可行(我们将在第七章讨论在多 GPU 上分布模型训练)。

矫正线性单元(ReLU)激活

AlexNet 的创作者在他们的神经网络中使用了一种非饱和激活函数称为 ReLU。我们稍后会在第二章详细讨论神经网络和激活函数;现在,知道使用分段线性非饱和激活函数使他们的模型收敛速度大大加快就足够了。

正则化

ReLU 存在的问题——以及它们直到 2012 年之前为什么没有被广泛使用的原因——是因为它们不会饱和,神经网络的权重会变得数值不稳定。AlexNet 的作者们使用了一种正则化技术来防止权重变得过大。我们在第二章也会讨论正则化技术。

深度

有了更快的训练能力,他们能够训练一个更复杂的模型,拥有更多的神经网络层。我们称具有更多层次的模型为深层;深度的重要性将在第三章中讨论。

值得注意的是,正是神经网络的增加深度(由前三个想法的组合允许)使得 AlexNet 具有世界领先的性能。证明了可以使用 GPU 加速 CNN 的技术在2006 年已经成为事实。ReLU 激活函数本身并不新鲜,正则化是一种众所周知的统计技术。最终,模型的异常性能归功于作者们的洞察力,他们能够结合所有这些因素训练比以往任何时候都更深的卷积神经网络。

深度对神经网络再次引起兴趣如此重要,以至于整个领域被称为深度学习

深度学习的应用案例

深度学习是机器学习的一个分支,它使用具有多层的神经网络。深度学习在计算机视觉方面超过了先前存在的方法,并且现在已成功应用于许多其他形式的非结构化数据:视频、音频、自然语言文本等。

深度学习使我们能够从图像中提取信息,而无需创建定制的图像处理滤波器或编写人类逻辑代码。在使用深度学习进行图像分类时,我们需要成百上千甚至数百万张图片(越多越好),其中我们知道正确的标签(如“郁金香”或“雏菊”)。这些带标签的图像可用于训练图像分类深度学习模型。

只要能够用数据学习任务,就可以使用计算机视觉机器学习方法来解决问题。例如,考虑光学字符识别(OCR)的问题——从扫描图像中提取文本。最早的 OCR 方法涉及教导计算机进行模式匹配,匹配各个字母的外观。出于各种原因,这被证明是一种具有挑战性的方法。例如:

  • 有很多字体,所以一个字母可以用多种方式写成。

  • 字母有不同的大小,因此模式匹配必须是尺度不变的。

  • 装订的书无法平放,因此扫描的字母会失真。

  • 认识到单个字母是不够的;我们需要提取整段文字。构成单词、行或段落的规则是复杂的(见图 1-4)。

图 1-4. 基于规则的光学字符识别需要识别行,将其分成单词,然后识别每个单词的组成字母。

另一方面,利用深度学习,OCR 可以很容易地被制定为图像分类系统。已经有许多书籍被数字化,可以通过向模型展示来自书籍的扫描图像并使用数字化的文本作为标签来训练模型。

计算机视觉方法为各种实际问题提供了解决方案。除了 OCR,计算机视觉方法还成功应用于医学诊断(使用图像如 X 光和 MRI)、自动化零售运营(如读取 QR 码、识别空货架、检查蔬菜质量等)、监视(从卫星图像监测农作物产量、监控野生动物摄像头、入侵者检测等)、指纹识别和汽车安全(保持安全距离跟随车辆、识别道路标志速限变化、自动停车车辆、自动驾驶车辆等)。

计算机视觉已经在许多行业中找到了用途。在政府部门中,它已被用于监控卫星图像,建设智能城市,以及海关和安全检查。在医疗领域,它被用来识别眼部疾病,并从乳房 X 光中发现癌症的早期迹象。在农业中,它被用来检测故障的灌溉泵,评估作物产量,并识别叶片病害。在制造业中,它在工厂生产线上用于质量控制和视觉检查。在保险业中,它被用于事故后自动评估车辆损伤。

摘要

计算机视觉帮助计算机理解数字图像(如照片)的内容。从 2012 年的一篇开创性论文开始,深度学习方法在计算机视觉领域取得了巨大成功。如今,我们在许多行业中发现了计算机视觉的成功应用。

我们将在第二章开始我们的旅程,创建我们的第一个机器学习模型。

¹ Top-5 accuracy 意味着,如果模型在其前五个结果中返回了正确的图像标签,我们认为该模型是正确的。

第二章:视觉的 ML 模型

在本章中,您将学习如何表示图像并训练基本的机器学习模型来分类图像。您将发现线性和全连接神经网络在图像上的表现很差。然而,在此过程中,您将学习如何使用 Keras API 来实现 ML 基元并训练 ML 模型。

提示

本章的代码位于该书的 GitHub 代码库02_ml_models 文件夹中。在适用的情况下,我们将提供代码示例和笔记本文件名。

用于机器感知的数据集

为了本书的目的,如果我们选择一个实际问题,并构建多种机器学习模型来解决它将会很有帮助。假设我们已经收集并标记了近四千张花卉的照片数据集。5-flowers 数据集中有五种类型的花(见 图 2-1),数据集中的每张图像都已经标记了它所描述的花的类型。

图 2-1。5-Flowers 数据集中的照片包括五种花的照片:雏菊、蒲公英、玫瑰、向日葵和郁金香。

假设我们想要创建一个计算机程序,当提供一张图像时,能告诉我们图像中是什么类型的花。我们要求机器学习模型学会感知图像中的内容,因此您可能会看到这种类型的任务称为机器感知。具体而言,这种感知类型类似于人类视觉,所以这个问题被称为计算机视觉,在这种情况下,我们将通过图像分类来解决它。

5-Flowers 数据集

5-Flowers 数据集由 Google 创建,并在公共领域中以创作共用许可证发布。它作为TensorFlow 数据集发布,并以 JPEG 文件的形式存储在公共 Google Cloud Storage 存储桶 (gs://cloud-ml-data/) 中。这使得数据集既逼真(它由即插即用相机收集的 JPEG 照片组成),又易于访问。因此,在本书中,我们将以此数据集作为持续的示例。

在 图 2-2 中,您可以看到几张郁金香的照片。请注意,这些照片从近距离拍摄的照片到郁金香花田的照片不等。所有这些照片对于人类来说都很容易标记为郁金香,但对我们来说,这是一个使用简单规则难以捕捉的问题——例如,如果我们说郁金香是一个细长的花朵,那么只有第一张和第四张照片符合条件。

图 2-2。这五张郁金香的照片在变焦、郁金香的颜色以及画面内容方面有很大的差异。

读取图像数据

要训练图像模型,我们需要将图像数据读入我们的程序中。在标准格式(如 JPEG 或 PNG)中读取图像并准备好训练机器学习模型有四个步骤(完整代码在02a_machine_perception.ipynb的 GitHub 存储库中可用):

import tensorflow as tf
def read_and_decode(filename, reshape_dims):
    # 1\. Read the file.
    img = tf.io.read_file(filename)
    # 2\. Convert the compressed string to a 3D uint8 tensor.
    img = tf.image.decode_jpeg(img, channels=3)
    # 3\. Convert 3D uint8 to floats in the [0,1] range.
    img = tf.image.convert_image_dtype(img, tf.float32)
    # 4\. Resize the image to the desired size.
    return tf.image.resize(img, reshape_dims)

我们首先从持久存储中读取图像数据到内存中,作为一系列字节的序列:

    img = tf.io.read_file(filename)

此处的变量img是一个张量(见“什么是张量?”),其中包含一个字节数组。我们解析这些字节以将它们转换为像素数据——这也称为解码数据,因为像 JPEG 这样的图像格式需要你从查找表中解码像素值:

    img = tf.image.decode_jpeg(img, channels=3)

在这里,我们指定我们只想从 JPEG 图像中获取三个颜色通道(红色、绿色和蓝色),而不是不透明度,这是第四个通道。你可以根据文件本身拥有的通道来选择使用哪些通道。灰度图像可能只有一个通道。

像素将由类型为uint8的 RGB 值组成,范围在[0,255]之间。因此,在第三步中,我们将它们转换为浮点数并将值缩放到[0,1]的范围内。这是因为机器学习优化器通常表现良好与小数字一起工作:

    img = tf.image.convert_image_dtype(img, tf.float32)

最后,我们将图像调整为所需大小。机器学习模型是建立在已知输入大小的基础上工作的。因此,由于现实世界中的图像可能是任意大小的,你可能需要缩小、裁剪或扩展它们以适应所需的大小。例如,要将图像调整为 256 像素宽和 128 像素高,我们可以指定:

    tf.image.resize(img,[256, 128])

在第六章中,我们会看到这种方法不保留纵横比,并探讨其他调整图像大小的选项。

这些步骤并非一成不变。如果你的输入数据包括来自卫星的遥感图像,这些图像以波段交织格式提供,或者提供的是数字影像和医学通信(DICOM)格式的脑部扫描图像,显然不能使用decode_jpeg()来解码它们。同样地,你可能不总是调整数据大小。在某些情况下,你可能选择将数据裁剪到所需大小或用零填充。在其他情况下,你可能会保持纵横比进行调整大小,然后填充剩余的像素。这些预处理操作在第六章中有详细讨论。

可视化图像数据

始终要想象几幅图像,以确保你正确阅读数据——一个常见的错误是以一种使图像旋转或镜像的方式读取数据。想象这些图像也有助于感受机器感知问题的挑战性。

我们可以使用 Matplotlib 的imshow()函数来可视化图像,但是为了做到这一点,我们必须先将图像(一个 TensorFlow 张量)转换为numpy数组,使用numpy()函数。

def show_image(filename):
    img = read_and_decode(filename, [IMG_HEIGHT, IMG_WIDTH])
    plt.`imshow`(img`.``numpy``(``)`);

在我们的一张雏菊图像上试验,我们得到了图 2-3 中显示的结果。

图 2-3. 确保可视化数据以确保正确读取。

注意来自图 2-3,文件名包含花的类型(雏菊)。这意味着我们可以使用 TensorFlow 的glob()函数进行通配符匹配,例如,获取所有郁金香图像:

tulips = tf.io.gfile.`glob`(
    "gs://cloud-ml-data/img/flower_photos/`tulips`/*.jpg")

运行此代码并可视化五张郁金香照片面板的结果显示在图 2-2 中。

读取数据集文件

现在我们知道如何读取图像了。但是,为了训练机器模型,我们需要读取许多图像。我们还必须获取每个图像的标签。我们可以使用glob()进行通配符匹配来获取所有图像的列表:

tf.io.gfile.glob("gs://cloud-ml-data/img/flower_photos/*/*.jpg")

然后,知道我们数据集中的图像有一个命名约定,我们可以使用字符串操作获取文件名并提取标签。例如,我们可以使用以下操作去除前缀:

basename = tf.strings.regex_replace(
    filename,
    "gs://cloud-ml-data/img/flower_photos/", "")

并获取类别名称使用:

label = tf.strings.split(basename, '/')[0]

如往常一样,请参阅此书的 GitHub 代码库获取完整代码。

然而,出于泛化和可重复性的原因(在第 5 章中进一步解释),最好提前设置保留用于评估的图像。在 5-花卉数据集中已经完成了这一点,用于训练和评估的图像在与图像相同的 Cloud Storage 存储桶中的两个文件中列出:

gs://cloud-ml-data/img/flower_photos/train_set.csv
gs://cloud-ml-data/img/flower_photos/eval_set.csv

这些是逗号分隔值(CSV)文件,每行包含文件名及其标签。

读取 CSV 文件的一种方法是使用TextLineDataset读取文本行,并通过map()函数传递处理每一行的函数:

dataset = (tf.data.`TextLineDataset`(
    "gs://cloud-ml-data/img/flower_photos/train_set.csv").
    map(parse_csvline))
注意

我们正在使用tf.dataAPI,通过仅读取少量数据元素并在读取数据时执行转换,使其能够处理大量数据(即使不全部装入内存)。它通过使用名为tf.data.Dataset的抽象来表示一系列元素。在我们的流水线中,每个元素是一个包含两个张量的训练示例。第一个张量是图像,第二个是标签。许多类型的Dataset对应于许多不同的文件格式。我们正在使用TextLineDataset,它读取文本文件,并假设每行是一个不同的元素。

parse_csvline()是我们提供的函数,用于解析行,提取图像的文件名,读取图像并返回图像及其标签:

def parse_csvline(csv_row):
    record_defaults = ["path", "flower"]
    filename, label = tf.io.decode_csv(csv_row, record_defaults)
    img = read_and_decode(filename, [IMG_HEIGHT, IMG_WIDTH])
    return img, label

传递给 parse_csvline() 函数的 record_defaults 指定了 TensorFlow 需要替换以处理遗漏一个或多个值的行。

为了验证这段代码是否工作,我们可以打印出训练数据集中前三幅图像每个通道的平均像素值:

for img, label in dataset.`take``(``3``)`:
    avg = tf.math.reduce_mean(img, axis=[0, 1])
    print(label, avg)

在这段代码片段中,take() 方法截断数据集到三个项。注意因为 decode_csv() 返回一个元组 (img, label),所以当我们迭代数据集时得到的就是这些值。打印整个图像是一个糟糕的主意,因此我们使用 tf.reduce_mean() 打印图像中像素值的平均值。

结果的第一行是(为了易读性添加换行符):

tf.Tensor(b'daisy', shape=(), `dtype``=``string`)
tf.Tensor([0.3588961  0.36257887 0.26933077],
          `shape``=``(``3``,``)`, dtype=float32)

注意标签是一个字符串张量,平均值是一个长度为 3 的 1D 张量。为什么我们得到一个 1D 张量?因为我们向 reduce_mean() 传递了一个 axis 参数:

avg = tf.math.reduce_mean(img, `axis``=``[``0``,` `1``]`)

如果没有提供轴,那么 TensorFlow 会沿着所有维度计算平均值并返回一个标量值。请记住图像的形状是 [IMG_HEIGHT, IMG_WIDTH, NUM_CHANNELS]。因此,通过提供 axis=[0, 1],我们要求 TensorFlow 计算所有列的平均值(axis=0)和所有行的平均值(axis=1),但不要计算 RGB 值的平均值(见图 2-4)。

提示

类似这样打印图像的统计信息对另一个原因也是有帮助的。如果你的输入数据损坏并且图像中有不可表示的浮点数据(技术上称为NaN),那么平均值本身将是NaN。这是一个方便的方式来确保在读取数据时没有出错。

图 2-4. 我们在图像的行和列轴上计算 reduce_mean()

使用 Keras 的线性模型

如图 2-4 所示,reduce_mean() 函数对图像中的每个像素值都进行了相同的加权。如果我们在图像的每个宽度 * 高度 * 3 像素通道点上应用不同的权重会怎样?

给定一幅新图像,我们可以计算其所有像素值的加权平均值。然后,我们可以使用这个值来在五种花之间进行选择。因此,我们将计算五个这样的加权平均值(实际上是学习宽度 * 高度 * 3 * 5 个权重值;见图 2-5),并根据输出最大的选择花的类型。

图 2-5. 在线性模型中,有五个输出,每个输出值都是输入像素值的加权和。

在实践中,还会添加一个称为偏差的常数项,以便我们可以将每个输出值表示为:

Yj=bj+ΣrowsΣcolumnsΣchannels(wi*xi)

如果没有偏差,当所有像素都是黑色时,我们会强制输出为零。

Keras 模型

而不是使用低级别的 TensorFlow 函数编写前述方程,使用更高级别的抽象会更方便。 TensorFlow 1.1 附带了一个这样的抽象,即 Estimator API,并且 Estimators 仍然支持向后兼容性。 但是,自 TensorFlow 2.0 以来,Keras API 一直是 TensorFlow 的一部分,这是我们建议您使用的。

在 Keras 中,线性模型可以表示如下:

model = `tf``.``keras`.Sequential([
    tf.keras.layers.Flatten(input_shape=(IMG_HEIGHT, IMG_WIDTH, 3)),
    tf.keras.layers.Dense(len(CLASS_NAMES))
])

Sequential 模型连接而成,一个层的输出是下一个层的输入。 层是一个 Keras 组件,接受张量作为输入,对该输入应用一些 TensorFlow 操作,并输出一个张量。

第一层,隐含的输入层,要求一个 3D 图像张量。 第二层(Flatten层)接受一个 3D 图像张量作为输入,并将其重新整形为具有相同数值的 1D 张量。 Flatten层连接到一个Dense层,每个花类别都有一个输出节点。 Dense的名称意味着每个输出都是每个输入的加权和,没有权重共享。 在本章后面,我们将遇到其他常见类型的层。

要使用此处定义的 Keras 模型,我们需要用训练数据集调用 model.fit(),并用要分类的每个图像调用 model.predict()。 要训练模型,我们需要告诉 Keras 如何基于训练数据集优化权重。 方法是编译模型,指定要使用的优化器,要最小化损失,以及要报告的指标。 例如:

model.compile(
    optimizer='adam',
    loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
    metrics=['accuracy'])

Keras 的 predict() 函数将在图像上进行模型计算。 如果我们先看预测代码,那么 compile() 函数的参数将更加有意义,所以我们从那里开始。

预测函数

因为模型在其内部状态中具有经过训练的权重集,所以我们可以通过调用 model.predict() 并传入图像来计算图像的预测值:

pred = model.predict(tf.reshape(img,
        [1, IMG_HEIGHT, IMG_WIDTH, NUM_CHANNELS]))

reshape() 的原因是 predict() 需要一个批量的图片,所以我们将 img 张量重塑为一个包含一张图片的批量。

model.predict() 输出的 pred 张量是什么样子?回想一下,模型的最后一层是一个 Dense 层,有五个输出,因此 pred 的形状是 (5) — 即,它由五个数字组成,分别对应五种花的类型。第一个输出是模型对所讨论图片为雏菊的置信度,第二个输出是模型对图片为蒲公英的置信度,以此类推。预测的置信值称为logits,其范围为负无穷到正无穷。

模型的预测是它最有信心的标签:

pred_label_index = tf.math.`argmax`(pred)
pred_label = CLASS_NAMES[pred_label_index]

我们可以通过应用一个称为softmax函数的函数将 logits 转换为概率。因此,对应于预测标签的概率是:

prob = tf.math.`softmax`(pred)[pred_label_index]

激活函数

简单调用 model.predict() 是不够的,因为 model.predict() 返回一个未经限制的加权和。我们可以将这个加权和视为 logits,并应用 sigmoid 或 softmax 函数(取决于我们是否有二元分类问题或多类分类问题)来获得概率:

pred = model.`predict`(tf.reshape(img,
                     [1, IMG_HEIGHT, IMG_WIDTH, NUM_CHANNELS]))
prob = `tf``.``math``.``softmax`(pred)[pred_label_index]

如果我们在模型的最后一层添加一个激活函数,可以为最终用户提供更方便的操作:

model = `tf``.``keras`.Sequential([
    tf.keras.layers.Flatten(input_shape=(IMG_HEIGHT, IMG_WIDTH, 3)),
    tf.keras.layers.Dense(len(CLASS_NAMES), `activation``=``'``softmax``'`)
])

如果我们这样做,model.predict() 将返回五个概率值(而不是 logits),每个类别一个。客户端代码无需调用 softmax()

Keras 中的任何层都可以对其输出应用激活函数。支持的激活函数包括 linearsigmoidsoftmax。我们将在本章后面探讨其他激活函数。

优化器

Keras 允许我们选择优化器来根据训练数据集调整权重。可用的优化器包括:

随机梯度下降(SGD)

最基本的优化器。

Adagrad(自适应梯度)和 Adam

通过添加允许更快收敛的特性来改进基本优化器。

Ftrl

一个倾向于在具有许多分类特征的极度稀疏数据集上表现良好的优化器。

Adam 是深度学习模型的经过验证的选择。我们建议在计算机视觉问题中使用 Adam 作为优化器,除非你有充分的理由不这样做。

SGD 及其所有变体,包括 Adam,依赖于接收数据的小批量(通常简称为批次)。对于每个数据批次,我们通过模型进行前向传播,计算误差和梯度,即每个权重对误差的贡献;然后优化器使用这些信息更新权重,准备好处理下一个数据批次。因此,当我们读取训练数据集时,我们也需要对其进行分批处理:

train_dataset = (tf.data.TextLineDataset(
    "gs://cloud-ml-data/img/flower_photos/train_set.csv").
    map(decode_csv))`.``batch``(``10``)`

训练损失

优化器试图选择能够最小化训练数据集上模型误差的权重。对于分类问题,选择交叉熵作为要最小化的错误具有强大的数学原因。为了计算交叉熵,我们使用以下公式比较模型对于第 j 类的输出概率 (p[j]) 与该类的真实标签 (L[j]),并将其总和计算所有类别:

ΣjLjlog(pj)

换句话说,我们取预测正确标签的概率的对数。如果模型完全正确,这个概率将为 1;log(1) 等于 0,因此损失为 0。如果模型完全错误,这个概率将为 0;log(0) 是负无穷,因此损失是正无穷,即最差的损失。使用交叉熵作为我们的错误度量允许我们根据正确标签的概率小幅调整权重。

为了计算损失,优化器将需要将 parse_csvline() 函数返回的标签与 model.predict() 的输出进行比较。您使用的具体损失将取决于您如何表示标签以及您的模型的最后一层返回什么。

如果您的标签是独热编码的(例如,如果标签编码为 [1 0 0 0 0] 用于雏菊图像),那么您应该使用categorical cross-entropy作为损失函数。这将在您的 decode_csv() 中显示如下:

def parse_csvline(csv_row):
    record_defaults = ["path", "flower"]
    filename, label_string = tf.io.decode_csv(csv_row, record_defaults)
    img = read_and_decode(filename, [IMG_HEIGHT, IMG_WIDTH])
    `label` `=` `tf``.``math``.``equal``(``CLASS_NAMES``,` `label_string``)`
    return img, label

因为 CLASS_NAMES 是一个字符串数组,与单个标签进行比较将返回一个独热编码数组,其中布尔值在相应位置为 1。您将如下指定损失:

tf.keras.losses.`CategoricalCrossentropy`(from_logits=False)

注意构造函数需要一个参数,指定模型的最后一层是返回对数几率还是已经进行了 softmax。

另一方面,如果您的标签将表示为整数索引(例如,4 表示郁金香),则您的 decode_csv() 将通过正确类的位置来表示标签:

label = tf.argmax(tf.math.equal(CLASS_NAMES, label_string))

并且损失将被指定为:

tf.keras.losses.`SparseCategoricalCrossentropy`(from_logits=False)

再次注意要适当地指定 from_logits 的值。

错误度量

虽然我们可以使用交叉熵损失来最小化训练数据集上的误差,但业务用户通常希望有一个更易理解的错误度量。用于此目的最常见的错误度量是准确度,它简单地是正确分类的实例的比例。

然而,当一个类别非常罕见时,精度度量会失败。假设您试图识别假身份证,您的模型具有以下性能特征:

被识别为假身份证 被识别为真实身份证
实际假身份证 8 (TP) 2 (FN)
实际真实身份证 140(FP) 850(TN)

数据集包含 990 张真实身份证和 10 张假身份证——存在类别不平衡。其中,有 8 张假身份证被正确识别。这些是真正例(TP)。因此,该数据集的准确率为 (850 + 8) / 1,000,即 0.858。可以立即看出,由于假身份证非常罕见,模型在这一类别上的表现对其整体准确率几乎没有影响——即使模型仅正确识别了 10 张假身份证中的 2 张,准确率仍几乎不变:0.852。实际上,模型可以通过将所有卡片识别为有效卡片而达到 0.99 的准确率!在这种情况下,通常会报告另外两个指标:

精度

在已识别正例集合中的真正例的比例:TP / (TP + FP)。这里,模型识别了 8 个真正例和 140 个假正例,因此精度仅为 8/148。该模型非常不精确。

召回

在数据集中所有正例中已识别出的真正例的比例:TP / (TP + FN)。这里,全数据集中有 10 个正例,模型已识别了其中的 8 个,因此召回率为 0.8。

除了精度和召回率外,通常还会报告 F1 分数,即两个数字的调和平均数:

F1=2/[1precision+1recall]

在二元分类问题中,例如我们正在考虑的问题(识别假身份证),准确率、精度和召回率都依赖于我们选择的概率阈值,以确定是否将实例分类为一类或另一类。通过调整概率阈值,我们可以在精度和召回率之间获得不同的权衡。结果曲线称为精度-召回率曲线(见图 2-9)。这条曲线的另一个变体,其中真正例率绘制为假正例率,称为接收者操作特征曲线(ROC 曲线)。ROC 曲线下的面积(通常缩写为AUC)也经常用作性能的综合衡量指标。

图 2-9。通过调整阈值,可以获得不同的精度和召回率度量。

我们通常不希望在训练数据集上报告这些指标,而是希望在独立的评估数据集上报告。这是为了验证模型是否仅仅记住了训练数据集的答案。

训练模型

现在让我们把前面章节涵盖的所有概念综合起来,创建并训练一个 Keras 模型。

创建数据集

要训练一个线性模型,我们需要一个训练数据集。实际上,我们希望有两个数据集——一个训练数据集和一个评估数据集——以验证训练出的模型泛化能力,即是否能在未在训练中见过的数据上运行。

因此,我们首先获取训练和评估数据集:

train_dataset = (tf.data.TextLineDataset(
    "gs://cloud-ml-data/img/flower_photos/train_set.csv").
    map(decode_csv)).batch(10)

eval_dataset = (tf.data.TextLineDataset(
    "gs://cloud-ml-data/img/flower_photos/eval_set.csv").
    map(decode_csv)).batch(10)

其中decode_csv()读取并解码 JPEG 图像:

def decode_csv(csv_row):
    record_defaults = ["path", "flower"]
    filename, label_string = tf.io.decode_csv(csv_row, record_defaults)
    img = read_and_decode(filename, [IMG_HEIGHT, IMG_WIDTH])
    label = tf.argmax(tf.math.equal(CLASS_NAMES, label_string))
    return img, label

此代码返回的label是稀疏表示——即郁金香的编号 4,该类的索引——而不是独热编码。我们对训练数据集进行分批处理,因为优化器类期望批次处理。我们还对评估数据集进行批处理,以避免创建所有方法的两个版本(一个批次操作,另一个需要逐个图像处理)。

创建和查看模型

现在数据集已创建,我们需要创建要使用这些数据集进行训练的 Keras 模型:

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=(IMG_HEIGHT, IMG_WIDTH, 3)),
    tf.keras.layers.Dense(len(CLASS_NAMES), activation='softmax')
])
model.compile(optimizer='adam',
    loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False),
    metrics=['accuracy'])

我们可以使用以下方法查看模型:

tf.keras.utils.plot_model(model, show_shapes=True, show_layer_names=False)

这将产生图 2-10 中的图表。请注意,输入层接受一个批次(这是?)的[224, 224, 3]图像。问号表示此维度的大小在运行时未定义;这样,模型可以动态适应任何批次大小。Flatten层接受此输入并返回一批 224 * 224 * 3 = 150,528 个数字,然后连接到Dense层的五个输出。

图 2-10. 用于分类花卉的 Keras 线性模型。

我们可以验证Flatten操作不需要任何可训练的权重,但Dense层有 150,528 * 5 = 752,645 个需要通过model.summary()训练的权重,如下所示:

Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
flatten_1 (Flatten)          (None, 150528)            0
_________________________________________________________________
dense_1 (Dense)              (None, 5)                 752645
=================================================================
Total params: 752,645
Trainable params: 752,645
Non-trainable params: 0

拟合模型

接下来,我们使用model.fit()训练模型,并传入训练和验证数据集:

history = model.fit(train_dataset,
                    validation_data=eval_dataset, epochs=10)

注意,我们将训练数据集传入以进行训练,并将验证数据集传入以报告准确度指标。我们要求优化器通过训练数据 10 次(一个epoch是对整个数据集的完整遍历)。我们希望 10 个 epochs 足够使损失收敛,但我们应通过绘制损失和错误指标的历史记录来验证这一点。我们可以通过查看历史记录来进行:

history.history.keys()

我们获得以下列表:

['loss', 'accuracy', 'val_loss', 'val_accuracy']

然后,我们可以绘制损失和验证损失:

plt.plot(history.history['val_loss'], ls='dashed');

这产生了图 2-11 左侧面板上显示的图表。请注意,损失值不会平稳下降;相反,它非常波动。这表明我们的批次大小和优化器设置的选择可以改进——不幸的是,这部分 ML 过程是试错的。验证损失下降,然后开始增加。这表明开始发生过拟合:网络已经开始记忆训练数据集中不发生的细节(这些细节称为噪声)。10 个时期要么太长,要么我们需要添加正则化。过拟合和正则化是我们将在下一节详细讨论的主题。

图 2-11. 训练(实线)和验证(虚线)集上的损失和准确率曲线。

还可以使用以下方法绘制训练数据集和验证数据集上的准确率:

training_plot('accuracy', history)

结果图显示在图 2-11 的右侧面板上。请注意,在训练数据集上的准确率随着训练时间的增加而增加,而在验证数据集上的准确率则趋于平稳。

这些曲线也是波动的,为我们提供了与损失曲线相同的见解。然而,我们在评估数据集上获得的准确率(0.4)比随机选择(0.2)要好。这表明模型已经能够学习,并在任务上变得有些熟练。

绘制预测

我们可以通过绘制模型在训练数据集中的几个图像的预测来查看模型学到了什么:

batch_image = tf.reshape(img, [1, IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS])
batch_pred = model.predict(batch_image)
pred = batch_pred[0]

请注意,我们需要将我们有的单个图像变成一个批次,因为这是模型训练和预期的内容。幸运的是,我们不需要精确地传递 10 个图像(我们的批次大小在训练期间是 10),因为模型被设计为接受任何批次大小(回想一下图 2-10 中的第一个维度是一个?)。

从训练和评估数据集中显示的前几个预测如图 2-12 所示。

图 2-12. 训练(顶行)和评估(底行)数据集中的前几个图像——第一个图像实际上是一朵雏菊,但被错误地分类为蒲公英,概率为 0.74。

使用 Keras 的神经网络

在前一节中我们介绍的线性模型中,我们将 Keras 模型写成了:

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=(IMG_HEIGHT, IMG_WIDTH, 3)),
    tf.keras.layers.Dense(len(CLASS_NAMES), activation='softmax')
])

输出是输入像素值的加权平均值的 softmax:

Y=softmax(B+ΣpixelsWiXi)

B 是偏置张量,W 是权重张量,X 是输入张量,Y 是输出张量。通常这被写成矩阵形式(使用 § 代表 softmax):

Y=§(B+WX)

如图 2-10 所示,并且在以下模型摘要中,只有一个可训练层,即 Dense 层。Flatten 操作是一种重塑操作,并不包含任何可训练权重:

________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
flatten_1 (Flatten)          (None, 150528)            0
_________________________________________________________________
dense_1 (Dense)              (None, 5)                 752645
=================================================================

线性模型很好,但它们在建模能力上有所限制。我们如何获得更复杂的模型?

神经网络

获得更复杂模型的一种方式是在输入和输出层之间插入一个或多个 Dense 层。这导致了一种被称为 神经网络 的机器学习模型,我们稍后会解释原因。

隐藏层

假设我们插入一个额外的 Dense 层:

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=(IMG_HEIGHT, IMG_WIDTH, 3)),
    `tf``.``keras``.``layers``.``Dense``(``128``)``,`
    tf.keras.layers.Dense(len(CLASS_NAMES), activation='softmax')
])

现在模型有三层(见 Figure 2-14)。具有可训练权重的层,例如我们添加的那一层,既不是输入也不是输出层,称为 隐藏 层。

图 2-14. 具有一个隐藏层的神经网络。

数学上,输出现在是:

Y=§(B2+W2(B1+W1X))

简单地包装多个层如此是没有意义的,因为我们可以直接将第二层的权重 (W[2]) 乘到方程中——模型仍然是线性模型。然而,如果我们在隐藏层的输出上添加非线性 激活 函数 A(x) 来变换输出:

Y=§(B2+W2A(B1+W1X))

那么输出就能表示比简单线性函数更复杂的关系。

在 Keras 中,我们如下引入激活函数:

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=(IMG_HEIGHT, IMG_WIDTH, 3)),
    tf.keras.layers.Dense(128, `activation``=``'``relu``'`),
    tf.keras.layers.Dense(len(CLASS_NAMES), activation='softmax')
])

整流线性单元(ReLU)是隐藏层中最常用的激活函数(见图 2-15)。其他常用的激活函数包括sigmoidtanhelu

图 2-15. 一些非线性激活函数。

在图 2-15 中展示的所有三个激活函数都与人脑中神经元的工作方式 loosly 相关,如果来自树突的输入共同超过一些最小阈值,则模型具有具有非线性激活函数的隐藏层,称为“神经网络”。

图 2-16. 当输入总和超过一些最小阈值时,人脑中的神经元会发出。 图片来源:艾伦大脑科学研究所,艾伦人类大脑图谱,可从human.brain-map.org获取。

Sigmoid 是一个连续函数,其行为最类似于脑神经元的工作方式——输出在两个极端处饱和。然而,Sigmoid 函数收敛速度较慢,因为每一步的权重更新与梯度成正比,并且在极端值附近的梯度非常小。ReLU 更常用,因为在函数的活跃部分权重更新保持相同大小。在具有 ReLU 激活函数的 Dense 层中,如果输入的加权和大于-b,其中 b 是偏差,激活函数就会“触发”。触发的强度与输入的加权和成正比。ReLU 的问题在于其域的一半为零。这导致了一个称为死亡 ReLU的问题,即从不进行权重更新。elu 激活函数(见图 2-15)通过在零的地方使用一个小的指数负值来解决这个问题。然而,由于指数函数的存在,elu 的计算成本较高。因此,一些机器学习从业者选择使用带有小负斜率的 Leaky ReLU。

训练神经网络

训练神经网络类似于训练线性模型。我们编译模型,传入优化器、损失函数和度量标准。然后调用model.fit(),传入数据集:

model.compile(optimizer='adam',
              loss=tf.keras.losses.SparseCategoricalCrossentropy(
                  from_logits=False),
              metrics=['accuracy'])
history = model.fit(train_dataset,
                    validation_data=eval_dataset,
                    epochs=10)

结果显示在图 2-17 中,我们获得的最佳验证准确率(0.45)与线性模型的结果相似。曲线也不够平滑。

图 2-17. 在训练神经网络时的训练和验证数据集上的损失和准确率。

我们通常期望向模型添加层次将提高模型拟合训练数据的能力,从而降低损失。实际上,这种情况确实如此——线性模型的交叉熵损失约为 10,而神经网络的交叉熵损失约为 2。然而,准确率相似,表明模型的改进很大程度上是通过使概率接近 1.0 来获得,而不是通过纠正线性模型错误分类的项目。

尽管如此,还有一些改进方法可以尝试。例如,我们可以调整学习率和损失函数,并更好地利用验证数据集。我们将在接下来进行探讨。

学习率

梯度下降优化器通过在每个点查看所有方向并选择错误函数下降最快的方向来工作。然后,它沿着该方向迈出一步,并再次尝试。例如,在图 2-18 中,从第一个点(标记为圆圈 1)开始,优化器查看两个方向(实际上是 2^N个方向,其中N是要优化的权重张量的维度),选择方向 2,因为损失函数在该方向上下降最快。然后,优化器通过制定的虚线曲线更新权重值。每个权重值的步长大小与称为“学习率”的模型超参数成比例。

图 2-18. 梯度下降优化器的工作原理。

如您所见,如果学习速率过高,优化器可能会完全跳过最小值。在此步骤之后(在图中标记为圆圈 2),优化器再次朝两个方向查找,然后继续到第三点,因为损失曲线在那个方向下降得更快。在这一步之后,再次评估梯度。现在方向指向后方,优化器成功地在第二和第三点之间找到了局部最小值。然而,原本位于第一和第二步之间的全局最小值被错过了。

为了不跳过最小值,我们应该使用较小的学习率。但如果学习率太小,模型会陷入局部最小值。此外,学习率值越小,模型收敛速度就越慢。因此,在不错过最小值和使模型快速收敛之间存在权衡。

Adam 优化器的默认学习率为 0.001。我们可以通过更改传递给 compile() 函数的优化器来更改它:

model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.0001),
              loss=..., metrics=...)

使用较低的训练率重复训练后,我们在准确性方面获得了相同的最终结果,但曲线明显更为平稳(参见图 2-19)。

图 2-19。当学习率降低到 0.0001 时的损失和准确率曲线。

正则化

值得注意的是,神经网络中可训练参数的数量是线性模型的 128 倍(1900 万对比 75 万)。然而,我们只有大约 3700 张图像。更复杂的模型可能表现更好,但我们可能需要更多的数据——数十万张图像。本书后面,我们将介绍数据增强技术,以充分利用我们所拥有的数据。

考虑到我们使用的模型相对复杂,数据集相对较小,模型可能开始使用单独的可训练权重来“记住”训练数据集中每个图像的分类答案——这就是我们可以在图 2-19 中观察到的过拟合现象(尽管训练准确率仍在降低,验证集上的损失开始增加)。当发生这种情况时,权重值开始高度调整到非常特定的像素值,并获得非常高的值。¹ 因此,我们可以通过改变损失函数应用在权重值本身上的惩罚来减少过拟合的发生。这种在损失函数上应用的惩罚称为正则化

两种常见形式是:

loss=cross-entropy+Σi|wi|

和:

loss=cross-entropy+Σiwi2

第一种惩罚类型称为L1 正则化项,第二种称为L2 正则化项。任何一种惩罚都会使优化器倾向于更小的权重值。L1 正则化驱使许多权重值为零,但对于个别大权重值更具有宽容性,而 L2 正则化则倾向于驱使所有权重值为小但非零值。为什么会出现这种情况的数学原因超出了本书的范围,但了解 L1 用于紧凑模型(因为我们可以修剪零权重),而 L2 用于尽可能限制过拟合很有用。

这是如何在Dense层应用正则化项的方法:

regularizer = tf.keras.regularizers.l1_l2(0, 0.001)
model = tf.keras.Sequential([
    tf.keras.layers.Flatten(input_shape=(
                          IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS)),
    tf.keras.layers.Dense(num_hidden,
                          kernel_regularizer=regularizer,
                          activation=tf.keras.activations.relu),
    tf.keras.layers.Dense(len(CLASS_NAMES),
                          kernel_regularizer=regularizer,
                          activation='softmax')
])

开启 L2 正则化后,从图 2-20 可以看出,损失值较高(因为包含了惩罚项)。然而,明显看出,在第 6 个 epoch 后仍然存在过拟合现象。这表明我们需要增加正则化的量。再次强调,这是一个试验和错误的过程。

Images/pmlc_0220.png

图 2-20. 当添加 L2 正则化时的损失和准确率曲线。

提前停止

仔细观察图 2-20 右侧面板。训练集和验证集的准确率都平稳增加直到第六个 epoch。之后,尽管训练集准确率继续增加,验证集准确率开始下降。这是模型停止泛化到未见数据,开始拟合训练数据中的噪声的典型迹象。

如果我们能在验证准确率停止增加时停止训练,那将是很好的。为了做到这一点,我们将一个回调函数传递给model.fit()函数:

history = model.fit(train_dataset,
    validation_data=eval_dataset,
    epochs=10,
    `callbacks``=``[``tf``.``keras``.``callbacks``.``EarlyStopping``(``patience``=``1``)``]`
)

由于收敛可能会有些波动,patience参数允许我们配置在停止训练之前希望验证准确率不下降的 epoch 数。

注意

只有在调整了学习率和正则化以获得平滑且良好的训练曲线后,才添加EarlyStopping()回调函数。如果你的训练曲线波动较大,可能会错过通过提前停止获得更好性能的机会。

超参数调整

我们为模型选择了一些参数:隐藏节点数、学习率、L2 正则化等等。我们怎么知道这些是最优的呢?我们不知道。我们需要调整这些超参数。

一个使用 Keras Tuner 的方法是实现模型构建函数来使用超参数(完整代码在02_ml_models/02b_neural_network.ipynb on GitHub)

import kerastuner as kt

# parameterize to the values in the previous cell
def build_model(hp):
    `lrate` `=` `hp``.``Float`('lrate', 1e-4, 1e-1, sampling='log')
    l1 = 0
    `l2` `=` `hp``.``Choice`('l2', values=[0.0, 1e-1, 1e-2, 1e-3, 1e-4])
    `num_hidden` `=` `hp``.``Int`('num_hidden', 32, 256, 32)

    regularizer = tf.keras.regularizers.l1_l2(l1, `l2`)

    # NN with one hidden layer
    model = tf.keras.Sequential([
        tf.keras.layers.Flatten(
            input_shape=(IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS)),
        tf.keras.layers.Dense(`num_hidden`,
                              kernel_regularizer=regularizer,
                              activation=tf.keras.activations.relu),
        tf.keras.layers.Dense(len(CLASS_NAMES),
                              kernel_regularizer=regularizer,
                              activation='softmax')
    ])
    model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=`lrate`),
                  loss=tf.keras.losses.SparseCategoricalCrossentropy(
                              from_logits=False),
                  metrics=['accuracy'])
  return model

如您所见,我们定义了超参数的取值空间。学习率(lrate)是一个浮点值,选择对数范围内的数值(而非线性范围)。L2 正则化值是从五个预定义值中选择的一个(0.0, 1e-1, 1e-2, 1e-3, 和 1e-4)。隐藏节点数(num_hidden)是一个整数,从 32 到 256,以 32 为增量选择。然后,这些值像平常一样在模型构建代码中使用。

我们将build_model()函数传递给 Keras Tuner 优化算法。支持多种算法,但贝叶斯优化是一个适用于计算机视觉问题的老牌算法,效果良好:

tuner = kt.BayesianOptimization(
    build_model,
    objective=kt.Objective('val_accuracy', 'max'),
    max_trials=10,
    num_initial_points=2,
    overwrite=False) # True to start afresh

在这里,我们明确指出我们的目标是最大化验证准确率,并且我们希望贝叶斯优化器从 2 个随机选择的种子点开始运行 10 次试验。调参器可以从上次停下的地方继续,我们要求 Keras 这样做,告诉它重复使用在现有试验中学到的信息,而不是从头开始。

创建了调参器后,我们可以运行搜索:

tuner.search(
    train_dataset, validation_data=eval_dataset,
    epochs=5,
    callbacks=[tf.keras.callbacks.EarlyStopping(patience=1)]
)

在运行结束时,我们可以使用以下方法获取前N个试验(以最高验证准确率结束的试验):

topN = 2
for x in range(topN):
    print(tuner.get_best_hyperparameters(topN)[x].values)
    print(tuner.get_best_models(topN)[x].summary())

当我们为 5-花问题进行超参数调优时,我们确定最佳参数集如下:

{'lrate': 0.00017013245197465996, 'l2': 0.0, 'num_hidden': 64}

获得的最佳验证准确率为 0.46。

深度神经网络

线性模型给了我们 0.4 的准确率。具有一个隐藏层的神经网络给了我们 0.46 的准确率。如果我们增加更多隐藏层呢?

一个深度神经网络(DNN)是一个具有超过一个隐藏层的神经网络。每次增加一层,可训练参数的数量就会增加。因此,我们将需要一个更大的数据集。尽管如此,正如你将看到的,有几个技巧(即 dropout 和 batch normalization)可以用来限制过拟合的发生。

构建一个 DNN

我们可以将创建 DNN 的参数化如下:

def train_and_evaluate(batch_size = 32,
                       lrate = 0.0001,
                       l1 = 0,
                       l2 = 0.001,
                       num_hidden = [64, 16]):
    ...

    # NN with multiple hidden layers
    layers = [
              tf.keras.layers.Flatten(
                  input_shape=(IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS),
                  name='input_pixels')
    ]
    `layers` `=` `layers` `+` `[`
              `tf``.``keras``.``layers``.``Dense``(``nodes``,`
                      `kernel_regularizer``=``regularizer``,`
                      `activation``=``tf``.``keras``.``activations``.``relu``,`
                      `name``=``'``hidden_dense_{}``'``.``format``(``hno``)``)`
              `for` `hno``,` `nodes` `in` `enumerate``(``num_hidden``)`
    `]`
    layers = layers + [
              tf.keras.layers.Dense(len(CLASS_NAMES),
                      kernel_regularizer=regularizer,
                      activation='softmax',
                      name='flower_prob')
    ]

    model = tf.keras.Sequential(`layers`, name='flower_classification')

注意,我们为各层提供了可读的名称。这在打印模型摘要时会显示出来,同时也有助于通过名称获取层。例如,这是一个num_hidden为[64, 16]的模型:

Model: "sequential_4"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
input_pixels (Flatten)       (None, 150528)            0
_________________________________________________________________
hidden_dense_0 (Dense)       (None, 64)                9633856
_________________________________________________________________
hidden_dense_1 (Dense)       (None, 16)                1040
_________________________________________________________________
flower_prob (Dense)          (None, 5)                 85
=================================================================
Total params: 9,634,981
Trainable params: 9,634,981
Non-trainable params: 0

一旦创建了模型,就像以前一样进行训练。不幸的是,如图 2-21 所示,得到的验证准确率比线性模型或神经网络获得的要差。

图 2-21. 具有两个隐藏层的深度神经网络的损失和准确率曲线。

对于 5-花问题来说,数据集太小,我们无法利用 DNN 额外层提供的额外建模能力。回顾一下,当我们从神经网络开始时,情况类似。最初,我们并没有比线性模型做得更好,但通过添加正则化并降低学习率,我们能够获得更好的性能。

有没有一些技巧我们可以应用来提高 DNN 的性能?很高兴你问!有两个想法——dropout 层batch normalization——值得一试。

Dropout

Dropout 是深度学习中最古老的正则化技术之一。在每次训练迭代中,dropout 层以概率p(通常为 25%到 50%)随机丢弃网络中的神经元。实际上,被丢弃的神经元的输出被设置为零。其结果是,这些神经元在本次训练迭代中不会参与损失计算,并且它们不会得到权重更新(见图 2-22)。不同的神经元会在每次训练迭代中被丢弃。

批量归一化

图 2-22。训练期间应用的辍学层——这里使用 0.4 的辍学率,在每一步训练中随机丢弃层中的 40%节点。

在测试网络性能时,需要考虑所有神经元(辍学率=0)。Keras 会自动执行此操作,因此您只需添加一个tf.keras.layers.Dropout层。它在训练和评估时会自动具有正确的行为:在训练期间,层会随机丢弃节点;但在评估和预测期间,不会丢弃任何节点。

小贴士

神经网络中辍学的理论是,神经网络在其众多层之间有很大的自由度,因此一个层可能会演变出不良行为,而下一个层则会对其进行补偿。这不是神经元理想的使用方式。通过辍学,有很高的概率在给定的训练轮次中,修复问题的神经元将不存在。因此,问题层的不良行为变得显而易见,权重逐渐朝向更好的行为演变。辍学还有助于在整个网络中传播信息流,使所有权重都能够获得相对均等的训练量,这有助于保持模型的平衡。

批量归一化

我们的输入像素值范围为[0, 1],这与典型激活函数和优化器的动态范围兼容。然而,一旦我们添加了隐藏层,生成的输出值将不再位于激活函数的动态范围内,用于后续层的(见图 2-23)输出值。当这种情况发生时,神经元的输出为零,因为向任何方向移动一小步都不会有差别,梯度为零。网络无法从死区中逃脱。

批量归一化

图 2-23。隐藏层神经元的输出值可能不在激活函数的动态范围内。它们可能(A)偏向左侧过多(经 sigmoid 激活后,该神经元几乎总是输出零),(B)过窄(经 sigmoid 激活后,该神经元从不清楚地输出 0 或 1),或者(C)还不错(经 sigmoid 激活后,该神经元会在小批量中输出 0 到 1 之间的合理范围)。

要解决这个问题,批量归一化通过减去平均值并除以标准差来标准化训练数据批次中的神经元输出。然而,仅仅这样做可能会在某个方向上过于偏斜——通过引入每个神经元的两个额外可学习参数,称为缩放中心,并使用这些值来对神经元的输入数据进行归一化:

normalized=(inputcenter)scale

这样,网络通过机器学习决定在每个神经元上应用多少中心化和重新缩放。在 Keras 中,你可以选择性地使用其中一个。例如:

tf.keras.layers.BatchNormalization(scale=False, center=True)

批归一化的问题在于,在预测时,你没有训练批次来计算神经元输出的统计数据,但你仍然需要这些值。因此,在训练期间,神经元输出的统计数据通过运行指数加权平均值在“足够数量”的批次上计算。这些统计数据随后在推断时使用。

好消息是,在 Keras 中,你可以使用tf.keras.layers.BatchNormalization层,所有这些计算都会自动完成。在使用批归一化时,请记住:

  • 在应用激活函数之前,批归一化在层的输出上执行。因此,不要在Dense层的构造函数中设置activation='relu',而是在那里省略激活函数,然后添加一个单独的Activation层。

  • 如果在批归一化中使用center=True,则你的层不需要偏置。批归一化的偏移量起到了偏置的作用。

  • 如果使用的激活函数是尺度不变的(即,如果你对其进行放大,形状不会改变),那么你可以设置scale=False。ReLU 是尺度不变的,而 Sigmoid 不是。

使用了 dropout 和批归一化后,隐藏层现在变成了:

for hno, nodes in enumerate(num_hidden):
    layers.extend([
        tf.keras.layers.`Dense`(nodes,
                              kernel_regularizer=regularizer,
                              name='hidden_dense_{}'.format(hno)),
        tf.keras.layers.`BatchNormalization`(scale=False, # ReLU
                              center=False, # have bias in Dense
                              name='batchnorm_dense_{}'.format(hno)),
        # move activation to come after batch norm
        tf.keras.layers.`Activation`('relu',
                                   name='relu_dense_{}'.format(hno)),
        tf.keras.layers.`Dropout`(rate=0.4,
                                name='dropout_dense_{}'.format(hno)),

    ])

    layers.append(
        tf.keras.layers.Dense(len(CLASS_NAMES),
                              kernel_regularizer=regularizer,
                              activation='softmax',
                              name='flower_prob')
    )

注意,我们已经将激活函数移出了Dense层,并放入了一个在批归一化之后的单独层中。

_________________________________________________________________
hidden_dense_0 (Dense)       (None, 64)                9633856
_________________________________________________________________
`batchnorm_dense_0` `(``BatchNorm` `(``None``,` `64``)`                `128`
_________________________________________________________________
relu_dense_0 (`Activation`)    (None, 64)                0
_________________________________________________________________
dropout_dense_0 (Dropout)    (None, 64)                0
_________________________________________________________________

结果表明,这些技巧已经提高了模型泛化能力并使其收敛速度更快,如图 2-24 所示。我们现在的准确率为 0.48,而没有批归一化和 dropout 时为 0.40。然而,从根本上讲,这个 DNN 并没有比线性模型更好(0.48 对比 0.46),因为密集网络不是更深入的正确方式。

图 2-24。具有两个隐藏层、使用了 dropout 和批归一化的深度神经网络的损失和准确率曲线。

曲线尚未表现良好(请注意验证曲线的不稳定性)。为了平滑它们,我们将不得不尝试不同的正则化值,然后像以前一样进行超参数调整。总的来说,你将不得不为你选择的任何模型尝试所有这些想法(正则化、早停、dropout、批归一化)。在本书的其余部分,我们将仅展示代码,但在实践中,模型的创建总是紧随一段实验和超参数调整的时期。

摘要

在本章中,我们探讨了如何构建一个简单的数据流水线,读取图像文件并创建 2D 浮点数组。这些数组被用作全连接的机器学习模型的输入。我们从一个线性模型开始,然后添加了更多的Dense层。我们发现正则化对限制过拟合很重要,改变学习率对可学性有影响。

在本章中建立的模型并没有利用图像的特殊结构,即相邻像素高度相关。这是我们将在第三章中做的事情。尽管如此,本章介绍的用于读取图像、可视化、创建机器学习模型和使用机器学习模型进行预测的工具,即使模型本身变得更复杂,仍然适用。你在这里学到的技术——隐藏层、调整学习率、正则化、早停、超参数调整、dropout 和批归一化——在本书讨论的所有模型中都有应用。

本章介绍了许多重要的术语。以下是一个术语简短词汇表供快速参考。

术语表

准确度

一个错误度量,用于衡量分类模型中正确预测的分数:(TP + TN)/(TP + FP + TN + FN),例如,TP 是真正例。

激活函数

应用于神经网络节点输入加权和的函数。这是向神经网络添加非线性的方法。常见的激活函数包括 ReLU 和 sigmoid。

AUC

ROC 曲线下的面积。AUC 是一个与阈值无关的错误度量。

批处理或小批量

训练总是在训练数据和标签的批次上执行。这有助于算法收敛。数据张量的批次维度通常是数据张量的第一维。例如,形状为[100, 192, 192, 3]的张量包含 100 张 192x192 像素的图像,每个像素有三个值(RGB)。

批归一化

添加每个神经元两个额外可学习参数来规范输入数据到神经元在训练期间。

交叉熵损失

一种在分类器中经常使用的特殊损失函数。

Dense 层

一个神经元层,其中每个神经元连接到前一层的所有神经元。

Dropout

在深度学习中的一种正则化技术,每次训练迭代中,从网络中随机丢弃选择的神经元。

提前停止

当验证集错误开始恶化时停止训练运行。

纪元

训练期间对整个训练数据集的完整遍历。

错误度量

将神经网络输出与正确答案进行比较的错误函数。在评估数据集上报告的是错误。常见的错误度量包括精度、召回率、准确度和 AUC。

特征

用于指代神经网络输入的术语。在现代图像模型中,像素值形成了特征。

特征工程

确定将数据集(或其组合)的哪些部分馈送到神经网络以获得良好预测的技艺。在现代图像模型中,不需要进行特征工程。

展平

将多维张量转换为包含所有值的一维张量。

超参数调优

一个“外部”优化循环,在此循环中,使用不同的模型超参数值(如学习率和节点数量)训练多个模型,并选择最佳模型。在我们称之为“内部”优化循环或训练循环中,优化模型的参数(权重和偏差)。

标签

“类别”或监督分类问题中的正确答案的另一个名称。

学习率

在训练循环的每次迭代中,权重和偏差更新的梯度分数。

Logits(逻辑值)

应用激活函数之前神经元层的输出。该术语来自于逻辑函数,又名Sigmoid 函数,它曾是最流行的激活函数。“逻辑函数之前的神经元输出”被简化为“logits”。

损失

将神经网络输出与正确答案进行比较的错误函数。

神经元

神经网络的最基本单元,计算其输入的加权和,加上偏差,并通过激活函数输出结果。在训练过程中,会最小化训练数据集上的损失。

独热编码

将分类值表示为二进制向量的方法。例如,5 个类别中的第 3 类被编码为包含五个元素的向量,其中除了第三个元素为 1 外,其他元素均为 0:[0 0 1 0 0]。

精度

一个衡量真正例在被识别的正例集中所占比例的错误度量:TP / (TP + FP)。

召回率

一个衡量在数据集中所有正例中真正例所占比例的错误度量:TP / (TP + FN)。

正则化

在训练过程中对权重或模型函数施加的惩罚,以限制过拟合的数量。L1 正则化会将许多权重值推向零,但对个别大权重值更宽容,而L2 正则化倾向于将所有权重推向小但非零的值。

ReLU(整流线性单元)

整流线性单元。神经元的流行激活函数之一。

Sigmoid(S 形函数)

一种作用于无界标量并将其转换为位于 [0,1] 区间内值的激活函数。用作二元分类器的最后一步。

Softmax

一种特殊的激活函数,作用于向量上。它增加了最大分量与所有其他分量之间的差异,并且将向量归一化为和为 1,以便可以解释为概率向量。用作多类分类器中的最后一步。

Tensor

张量类似于矩阵,但具有任意数量的维度。1D 张量是向量,2D 张量是矩阵,您可以有三、四、五或更多维的张量。在本书中,我们将使用张量一词来指代支持 GPU 加速 TensorFlow 操作的数值类型。

训练

优化机器学习模型的参数以在训练数据集上实现更低的损失。

¹ 这种现象的良好非数学解释可以在DataCamp.com找到。

第三章:图像视觉

在第二章中,我们研究了将像素视为独立输入的机器学习模型。传统的全连接神经网络层在图像上表现不佳,因为它们未利用相邻像素高度相关的事实(参见图 3-1)。此外,完全连接多层也没有为图像的 2D 分层性质提供任何特殊规定。靠近的像素共同工作以创建形状(如线条和弧线),这些形状本身又共同工作以创建对象的可识别部分(如花朵的茎和花瓣)。

本章中,我们将通过研究利用图像的特殊属性的技术和模型架构来纠正这一点。

提示

本章的代码位于该书的GitHub 代码库03_image_models 文件夹中。我们将在适当的情况下提供代码示例和笔记本文件的文件名。

图 3-1. 将全连接层应用于图像的所有像素,将像素视为独立输入,并忽略了图像中相邻像素共同工作以创建形状的事实。

预训练嵌入

在第二章中我们开发的深度神经网络有两个隐藏层,一个有 64 个节点,另一个有 16 个节点。可以通过以下方式思考这种网络架构,如图 3-2 所示。在某种意义上,输入图像中包含的所有信息都由倒数第二层代表,其输出由 16 个数字组成。这 16 个提供图像表示的数字被称为 嵌入。当然,较早的层也会捕获输入图像的信息,但由于缺少某些层次信息,通常不会将它们用作嵌入。

在本节中,我们将讨论如何创建嵌入(与分类模型不同),以及如何使用嵌入在不同数据集上训练模型,使用两种不同的方法,即迁移学习和微调。

图 3-2. 这 16 个数字形成的嵌入提供了整个图像中所有信息的表示。

预训练模型

通过对输入图像应用一系列数学操作来创建嵌入向量。回顾一下,我们在第二章中多次强调,我们得到的模型精度大约为 0.45,因为我们的数据集不足以支持我们完全连接的深度学习模型中的数百万可训练权重。如果我们能重新利用从训练在更大数据集上的模型中提取的嵌入向量创建部分,会怎样呢?我们不能重新利用整个模型,因为那个模型将没有经过训练来分类花卉。然而,我们可以丢弃那个模型的最后一层或者称为预测头的层,并将其替换为我们自己的层。模型的重新利用部分可以从一个非常大、通用的数据集中预训练,然后将这些知识转移到我们要分类的实际数据集中。回顾图 3-2,我们可以用在“预训练模型”框中的 64 节点层替换成一个在更大数据集上训练过的模型的第一组层。

预训练模型是在大型数据集上训练并提供的模型,用作创建嵌入向量的一种方式。例如,MobileNet 模型是一个具有 1 到 4 百万参数的模型,它是在ImageNet(ILSVRC)数据集上训练的,该数据集包含数百万张从网络上爬取的图像,对应数百个类别。因此,生成的嵌入向量能够高效地压缩各种图像中的信息。只要我们想要分类的图像与 MobileNet 训练时使用的图像性质相似,MobileNet 生成的嵌入向量应该会为我们提供一个很好的预训练嵌入向量,可以作为在我们自己的较小数据集上训练模型的起点。

在 TensorFlow Hub 上可以找到一个预训练的 MobileNet 模型,我们可以通过传入训练模型的 URL 轻松将其加载为一个 Keras 层:

import tensorflow_hub as hub
huburl= "https://tfhub.dev/google/imagenet/\
 mobilenet_v2_100_224/feature_vector/4"
hub.KerasLayer(
    handle=huburl,
    input_shape=(IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS),
    `trainable``=``False``,`
    name='mobilenet_embedding')

在这段代码片段中,我们导入了tensorflow_hub包,并创建了一个hub.KerasLayer,传入了图像的 URL 和输入形状。关键是,我们指定这一层不可训练,并假定它是预训练的。通过这样做,我们确保其权重不会基于花卉数据进行修改;它将是只读的。

迁移学习

模型的其余部分与我们先前创建的 DNN 模型类似。以下是一个使用从 TensorFlow Hub 加载的预训练模型作为其第一层的模型示例(完整代码位于03a_transfer_learning.ipynb):

layers = [
    hub.KerasLayer(..., name='mobilenet_embedding'),
    tf.keras.layers.Dense(units=16,
                          activation='relu',
                          name='dense_hidden'),
    tf.keras.layers.Dense(units=len(CLASS_NAMES),
                          activation='softmax',
                          name='flower_prob')
]
model = tf.keras.Sequential(layers, name='flower_classification')
...

结果模型摘要如下:

Model: "flower_classification"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
mobilenet_embedding (KerasLa (None, 1280)              `2257984`
_________________________________________________________________
dense_hidden (Dense)         (None, 16)                20496
_________________________________________________________________
flower_prob (Dense)          (None, 5)                 85
=================================================================
Total params: 2,278,565
`Trainable` `params``:` `20``,``581`
Non-trainable params: 2,257,984

注意,我们称之为mobilenet_embedding的第一层有 2.26 百万个参数,但这些参数不可训练。只有 20,581 个参数是可训练的:1,280 * 16 个权重 + 16 个偏置 = 来自隐藏密集层的 20,496 个,以及 16 * 5 个权重 + 5 个偏置 = 来自与五个输出节点的密集层的 85 个。因此,尽管 5 种花的数据集不足以训练数百万个参数,但足以训练仅 20K 个参数。

通过用图像嵌入替换其输入层来训练模型的过程称为迁移学习,因为我们已将 MobileNet 创作者从一个更大数据集中学到的知识迁移到了我们的问题上。

由于我们正在用 Hub 层替换模型的输入层,因此确保我们的数据管道提供 Hub 层所期望的格式的数据非常重要。TensorFlow Hub 中的所有图像模型使用共同的图像格式,并期望像素值为浮点数,范围在 0,1)内。我们在[第二章中使用的图像读取代码将 JPEG 图像缩放到这个范围内,所以一切都没问题。

训练此模型与训练上一节中的 DNN 相同(有关详细信息,请参见 GitHub 存储库中的03a_transfer_learning.ipynb)。结果的损失和准确率曲线如图 3-3 所示。

图 3-3。具有两个隐藏层的深度神经网络的损失和准确率曲线。

令人印象深刻的是,使用迁移学习可以获得 0.9 的准确率(请参见图 3-4),而在我们的数据上从头开始训练完全连接的深度神经网络时,我们只能达到 0.48 的准确率。每当您的数据集相对较小时,我们建议使用迁移学习。只有当数据集开始超过每个标签大约五千张图像时,您才应该考虑从头开始训练。在本章的后面部分,我们将看到技术和架构,允许我们在有大型数据集并且可以从头开始训练时获得更高的准确性。

注意

对于第二行第一张图像的雏菊预测所关联的概率可能会让人吃惊。概率为 0.41?不应该大于 0.5 吗?请记住,这不是一个二元预测问题。有五个可能的类别,如果输出概率为[0.41, 0.39, 0.1, 0.1, 0.1],argmax将对应于雏菊,并且概率将为 0.41。

图 3-4。MobileNet 迁移学习模型对评估数据集中一些图像的预测。

微调

在迁移学习期间,我们采用了构成 MobileNet 的所有层,并直接使用它们。我们通过使层变为不可训练来实现。仅最后两个密集层在 5 花数据集上进行了调整。

在许多情况下,如果允许训练循环适应预训练层,我们可能会获得更好的结果。这种技术称为微调。预训练权重用作神经网络权重的初始值(通常情况下,神经网络训练从随机初始化权重开始)。

理论上,从迁移学习切换到微调,只需在加载预训练模型时将trainable标志从False翻转为True,然后在您的数据上进行训练。然而,在实践中,当微调预训练模型时,通常会注意到如 图 3-5 中的训练曲线。

图 3-5. 当使用错误的学习率调度微调时,训练和验证损失曲线。

此处的训练曲线显示,模型在数学上收敛。然而,其在验证数据上的表现较差,并且在开始时变得更糟,然后稍有恢复。如果设置的学习率过高,则预训练权重将以大步骤进行更改,丢失了预训练期间学到的所有信息。找到有效的学习率可能会有些棘手——将学习率设置得太低会导致收敛非常缓慢,设置得太高会导致预训练权重丢失。

解决此问题的两种技术是:学习率调度和逐层学习率。展示这两种技术的代码可在 03b_finetune_MOBILENETV2_flowers5.ipynb 中找到。

学习率调度

训练神经网络时最传统的学习率调度是从高开始然后在整个训练过程中指数衰减。在微调预训练模型时,可以添加热身阶段(参见 图 3-6)。

图 3-6. 左侧是传统的指数衰减学习率调度;右侧是包含热身阶段的学习率调度,这对微调更为合适。

图 3-7 展示了使用这种新学习率调度的损失曲线。

图 3-7. 使用适应的学习率调度进行微调。

注意,验证损失曲线仍然有一个小波动,但与之前相比要好得多(与 图 3-5 对比)。这引导我们选择微调的第二种学习率方式。

差分学习率

另一个很好的折中方案是应用差分学习率,其中我们对预训练层使用较低的学习率,对自定义分类头的层使用正常学习率。

实际上,我们可以在预训练层内部扩展差分学习率的思想——我们可以根据层深度乘以一个因子来逐渐增加每层的学习率,并为分类头部分配完整的学习率。

要在 Keras 中应用类似这样的复杂差分学习率,我们需要编写一个自定义优化器。但幸运的是,存在一个名为AdamW的开源 Python 包,我们可以通过为不同层指定学习率倍增器来使用(有关完整代码,请参见 GitHub 存储库中的03_image_models/03b_finetune_MOBILENETV2_flowers5.ipynb):

mult_by_layer={
    'block1_': 0.1,
    'block2_': 0.15,
    'block3_': 0.2,
    ... # blocks 4 to 11 here
    'block12_': 0.8,
    'block13_': 0.9,
    'block14_': 0.95,
    'flower_prob': 1.0, # for the classification head
}

optimizer = AdamW(lr=LR_MAX, model=model,
                   lr_multipliers=mult_by_layer)
提示

我们如何知道加载的预训练模型中的层名称?我们首先在没有任何名称的情况下运行代码,使用lr_multipliers={}。自定义优化器在运行时打印所有层的名称。然后,我们找到了标识网络中层深度的层名称子字符串。自定义优化器通过其lr_multipliers参数传递的子字符串匹配层名称。

使用每层学习率和学习率逐步增加的组合,我们可以将对tf_flowers(5 种花卉)数据集进行微调的 MobileNetV2 的准确率推高至 0.92,而仅使用逐步增加仅为 0.91,仅进行迁移学习为 0.9(有关代码,请参见03b_finetune_MOBILENETV2_flowers5.ipynb)。

在这里微调的收益很小,因为tf_flowers数据集很小。我们需要一个更具挑战性的基准来探索即将探索的先进架构。在本章的其余部分,我们将使用104 flowers数据集。

GitHub 存储库包含三个笔记本,用于在更大的 104 flowers 数据集上尝试这些微调技术。结果显示在 Table 3-1 中。为了完成这项任务,我们使用了 Xception,这是一个比 MobileNet 更重的模型,因为 104 flowers 数据集更大,可以支持这个更大的模型。如您所见,学习率逐步增加或每层差分学习率并不是绝对必要的,但实际上它使收敛更稳定,更容易找到有效的学习率参数。

Table 3-1. 更大模型(Xception)在更大的 104 flowers 数据集上微调后获得的结果摘要

笔记本名称 学习率逐步增加 差分学习率 五次运行的平均 F1 分数 五次运行的标准偏差 备注
lr_decay_xception 0.932 0.004 良好,相对低方差
lr_ramp_xception 0.934 0.007 非常好,高方差
lr_layers_lr_ramp_xception 0.936 0.003 最佳,低方差

到目前为止,我们已经使用了 MobileNet 和 Xception 进行迁移学习和微调,但对于我们来说,这些模型就像黑盒子一样。我们不知道它们有多少层,或者这些层包含什么内容。在下一节中,我们将讨论一个关键概念,卷积,这有助于这些神经网络有效地提取图像的语义信息内容。

卷积网络

卷积层专门设计用于图像。它们在二维空间中操作,可以捕获形状信息;它们通过在图像的两个方向上滑动一个称为卷积滤波器的小窗口来工作。

卷积滤波器

典型的 4x4 滤波器将具有图像每个通道的独立滤波器权重。对于具有红色、绿色和蓝色通道的彩色图像,滤波器总共将有 4 * 4 * 3 = 48 个可学习权重。该滤波器被应用于图像中的单个位置,方法是将该位置附近的像素值乘以滤波器权重并求和,如图 3-9 所示。这个操作称为张量点积。通过在图像上滑动滤波器计算每个位置的点积称为卷积

图 3-9。使用单个 4x4 卷积滤波器处理图像 - 滤波器在图像上的两个方向上滑动,每个位置产生一个输出值。

单个卷积滤波器可以使用非常少的可学习参数处理整个图像 - 实际上,它不能学习和表示足够的图像复杂性。需要多个这样的滤波器。卷积层通常包含数十甚至数百个类似的滤波器,每个都有自己独立的可学习权重(参见图 3-11)。它们按顺序应用于图像,并且每个产生一个通道的输出值。卷积层的输出是一组多通道的 2D 值。注意,这个输出与输入图像具有相同数量的维度,而输入图像本身已经是一个三通道的 2D 像素值集。

理解卷积层的结构使得计算其可学习权重的数量变得容易,如图 3-12 所示。该图还介绍了用于本章模型的卷积层的示意符号表示法。

图 3-11. 使用由多个卷积滤波器组成的卷积层处理图像。所有滤波器的尺寸都相同(这里为 4x4x3),但具有独立的可学习权重。

图 3-12. 卷积层的权重矩阵 W。

在这种情况下,应用了 5 个滤波器,这个卷积层中可学习的总权重数为 4 * 4 * 3 * 5 = 240。

Keras 中提供了卷积层:

tf.keras.layers.Conv2D(filters,
                       kernel_size,
                       strides=(1, 1),
                       padding='valid',
                       activation=None)

下面是参数的简化描述(详细信息请参见 Keras 的文档):

filters

应用到输入的独立滤波器数量。这也将是输出通道的数量。

kernel_size

每个滤波器的尺寸。可以是一个单独的数字,比如 4 表示 4x4 的滤波器,或者是一个对,比如(4, 2)表示一个 4x2 的矩形滤波器。

strides

滤波器在输入图像上以步长滑动。默认步长为 1 像素。使用较大的步长会跳过输入像素并产生较少的输出值。

padding

'valid'表示没有填充,或者'same'表示在边缘进行零填充。如果滤波器应用于具有'valid'填充的输入,则仅当窗口内的所有像素都有效时才进行卷积,因此边界像素会被忽略。因此,输出在xy方向上会稍微小一些。值'same'允许对输入进行零填充,以确保输出与输入具有相同的宽度和高度。

activation

像任何神经网络层一样,卷积层后面可以跟着一个激活函数(非线性)。

图 3-11 中展示的卷积层,使用了五个 4x4 的滤波器,输入填充,以及在两个方向上的默认步幅为 1,可以实现如下:

tf.keras.layers.Conv2D(filters=5, kernel_size=4, padding='same')

卷积层的输入和输出都期望是 4D 张量。第一个维度是批处理大小,因此完整的形状是 [batch, height, width, channels]。例如,一个批量为 16 的彩色(RGB)图像,每个图像大小为 512x512 像素,将表示为具有维度 [16, 512, 512, 3] 的张量。

堆叠卷积层

如前一节所述,一个通用的卷积层以一个 4D 张量形式的输入 [batch, height, width, channels] 作为输入,并产生另一个 4D 张量作为输出。为简单起见,在我们的图表中忽略批处理维度,并展示一个单独的 3D 形状的图像 [height, width, channels] 的情况。

卷积层将一个数据“立方体”转换为另一个“立方体”,然后可以被另一个卷积层消耗。如图 3-13 所示,卷积层可以堆叠。

图 3-13. 顺序应用的两个卷积层转换的数据。右侧显示可学习的权重。第二个卷积层使用步长为 2,并具有六个输入通道,与前一层的六个输出通道匹配。

图 3-13 展示了数据如何经过两个卷积层的转换。从顶部开始,第一层是一个应用于具有四个数据通道的输入的 3x3 滤波器。滤波器应用于输入六次,每次使用不同的滤波器权重,产生六个输出值通道。然后,这些值被输入到第二个卷积层中,该层使用 2x2 滤波器。注意,第二个卷积层在应用滤波器时使用步长为 2(每隔一个像素),以获得较少的输出值(在水平面上)。

池化层

每个卷积层应用的滤波器数量决定输出中的通道数。但是,我们如何控制每个通道中的数据量呢?神经网络的目标通常是从包含数百万像素的输入图像中提炼信息,以获得少数类别。因此,我们需要能够合并或降采样每个通道中信息的层。

最常用的降采样操作是 2x2 最大池化。通过最大池化,每个通道的每组四个输入值仅保留最大值(参见图 3-14)。平均池化以类似的方式工作,但是对四个值进行平均而不是保留最大值。

图 3-14. 对单通道输入数据应用的 2x2 最大池化操作。每组 2x2 输入值取最大值,每个方向上每两个值重复一次操作(步长为 2)。

注意,最大池化层和平均池化层没有任何可训练的权重。它们纯粹是大小调整层。

有一个有趣的物理解释,解释了为什么最大池化层与卷积层在神经网络中很好地配合。卷积层是一系列可训练的滤波器。训练后,每个滤波器专门用于匹配某些特定的图像特征。卷积神经网络中的第一层对输入图像中的像素组合做出反应,而后续层对前一层的特征组合做出反应。例如,在训练用于识别猫的神经网络中,第一层对基本的图像组件(如水平和垂直线或毛皮的纹理)做出反应。随后的层对线条和毛皮的特定组合做出反应,以识别尖耳朵、胡须或猫眼睛。更后面的层检测到尖耳朵 + 胡须 + 猫眼睛的组合,表示猫的头部。最大池化层仅保留检测到某些特征 X 的最大强度的值。如果目标是减少值的数量但保留最具代表性的值,这是有道理的。

池化层和卷积层对检测到的特征位置有不同的影响。卷积层返回具有其高值的特征映射,这些高值位于其滤波器检测到显著内容的位置。另一方面,池化层降低特征映射的分辨率,并使位置信息不太精确。有时候,位置或相对位置很重要,例如在面部中,眼睛通常位于鼻子上方。卷积确实为网络中的其他层提供了位置信息。然而,有时候定位特征并不是目标,比如在花卉分类器中,您希望训练模型在图像中无论花朵出现在何处都能识别出来。在这种情况下,当训练位置不变性时,池化层有助于在某种程度上模糊位置信息,但不能完全模糊。如果要使网络真正地不受位置影响,就必须对显示花朵在许多不同位置的图像进行训练。可以使用数据增强方法,如图像的随机裁剪,来强制网络学习此位置不变性。数据增强在第六章中有所涉及。

用于降低通道信息的第二个选择是应用卷积,其步长为 2 或 3,而不是 1。然后,卷积滤波器在输入图像上按 2 或 3 个像素的步长滑动。这样机械地产生了一定尺寸的输出值,如图 3-15 所示。

图 3-15. 在单通道数据上应用 3x3 滤波器,两个方向上的步长为 2 且没有填充。滤波器每次跳过 2 个像素。

现在我们可以将这些层组装成我们的第一个卷积神经分类器。

AlexNet

最简单的卷积神经网络架构是卷积层和最大池化层的混合体。它将每个输入图像转换为一个最终的数值矩形棱柱,通常称为特征图,然后将其馈送到若干全连接层,最后是一个 softmax 层,用于计算类别概率。

AlexNet 是由 Alex Krizhevsky 等人在 2012 年的论文中引入的,并在图 3-16 中展示,正是这样的一个架构。它是为ImageNet 竞赛设计的,该竞赛要求参与者基于超过一百万张图像的训练数据集将图像分类为一千个类别(汽车、花朵、狗等)。AlexNet 是神经图像分类中最早的成功案例之一,显著提高了准确性,并证明深度学习能够比现有技术更好地解决计算机视觉问题。

图 3-16. AlexNet 架构:左侧表示神经网络层。右侧表示转换后的特征图。

在这种架构中,卷积层改变数据的深度——即通道数量。最大池化层在高度和宽度方向上对数据进行降采样。第一个卷积层的步长为 4,这也是为什么它会对图像进行降采样。

AlexNet 使用 2x2 步长为 2 的 3x3 最大池化操作。更传统的选择可能是 2x2 步长为 2 的最大池化。AlexNet 的研究声称这种“重叠”最大池化具有一定优势,但似乎并不显著。

每个卷积层由 ReLU 激活函数激活。最后的四层形成 AlexNet 的分类头,接收最后的特征图,将其所有值展平为一个向量,并通过三个全连接层馈送。因为 AlexNet 设计用于千分类,最后一层由具有一千个输出的 softmax 激活,计算千个目标类别的概率。

所有卷积和全连接层都使用加性偏置。当使用 ReLU 激活函数时,习惯上在训练之前将偏置初始化为一个小正值,以确保激活后所有层都具有非零输出和非零梯度(记住 ReLU 曲线对所有负值都是平坦零)。

在图 3-16 中注意到,AlexNet 以一个非常大的 11x11 卷积滤波器开始。在可学习权重方面,这是昂贵的,可能不是现代架构中会采用的做法。然而,11x11 滤波器的一个优势是它们的学习权重可以可视化为 11x11 像素的图像。AlexNet 论文的作者做到了;他们的结果在图 3-17 中展示。

图 3-17。第一个 AlexNet 层的所有 96 个滤波器。它们的大小为 11x11x3,这意味着它们可以被视为彩色图像。这张图片显示了它们在训练后的权重。图片来源于Krizhevsky et al., 2012

如您所见,该网络学会了检测各种方向的垂直线、水平线和倾斜线。两个滤波器展现出棋盘格模式,这可能是对图像中颗粒状纹理的反应。您还可以看到检测单色或相邻颜色对的探测器。所有这些都是基本特征,随后的卷积层将把它们组装成语义更重要的结构。例如,神经网络将把纹理和线条结合成“轮子”、“把手”和“鞍座”的形状,然后将这些形状组合成“自行车”。

我们选择在这里介绍 AlexNet,因为它是最早的卷积架构之一。交替使用卷积和最大池化层仍然是现代网络的特征。然而,该架构中做出的其他选择不再代表目前公认的最佳实践。例如,在第一个卷积层中使用非常大的 11x11 滤波器后来被发现并不是可学习权重的最佳利用(正如我们将在本章后面看到的,3x3 更好)。此外,最后的三个全连接层具有超过 2600 万个可学习权重!这比所有卷积层的权重总和(370 万)还要多一个数量级。网络也非常浅,只有八个神经层。现代神经网络显著增加了这一数量,达到了一百层甚至更多。

这个非常简单的模型的一个优点是,它可以在 Keras 中非常简洁地实现(你可以在 GitHub 上的03c_fromzero_ALEXNET_flowers104.ipynb中查看完整的示例):

model = tf.keras.Sequential([
    tf.keras.Input(shape=[IMG_HEIGHT, IMG_WIDTH, 3]),
    tf.keras.layers.Conv2D(filters=96, kernel_size=11, strides=4,
                              activation='relu'),
    tf.keras.layers.Conv2D(filters=256, kernel_size=5,
                              activation='relu'),
    tf.keras.layers.MaxPool2D(pool_size=2, strides=2),
    tf.keras.layers.Conv2D(filters=384, kernel_size=3,
                              activation='relu'),
    tf.keras.layers.MaxPool2D(pool_size=2, strides=2),
    tf.keras.layers.Conv2D(filters=384, kernel_size=3,
                              activation='relu'),
    tf.keras.layers.Conv2D(filters=256, kernel_size=3,
                              activation='relu'),
    tf.keras.layers.MaxPool2D(pool_size=2, strides=2),
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(4096, activation='relu'),
    tf.keras.layers.Dense(4096, activation='relu'),
    tf.keras.layers.Dense(len(CLASSES), activation='softmax')
])

该模型在 104 种花卉数据集上收敛到 39%的准确率,虽然对于实际花卉识别来说并不实用,但对于如此简单的架构来说,这一结果令人惊讶地好。

在本章的其余部分,我们将提供对不同网络架构及其引入的概念和构建模块的直观解释。虽然我们展示了您如何在 Keras 中实现 AlexNet,但通常您不会自己实现我们讨论的这些架构。相反,这些模型通常直接在 Keras 中作为预训练模型提供,可以立即用于迁移学习或微调。例如,您可以如何实例化一个预训练的 ResNet50 模型(更多信息请参见“Keras 中的预训练模型”):

tf.keras.applications.ResNet50(weights='imagenet')

如果一个模型尚未在keras.applications中可用,通常可以在 TensorFlow Hub 中找到。例如,您可以如何从 TensorFlow Hub 实例化相同的 ResNet50 模型:

hub.KerasLayer(
    "https://tfhub.dev/tensorflow/resnet_50/classification/1")

因此,可以随意浏览本章节的其余部分,了解基本概念,然后阅读最后一节,了解如何为您的问题选择模型架构。您不需要完全理解本章节中网络架构的所有细微差别,因为您很少需要从头开始实现这些架构或设计自己的网络架构。大多数情况下,您将从我们在本章最后一节中建议的架构中选择一种。不过,了解这些架构是如何构建的也是很有趣的。理解这些架构还将帮助您在实例化时选择正确的参数。

深度追求

在 AlexNet 之后,研究人员开始增加卷积网络的深度。他们发现添加更多层次会导致更好的分类准确性。关于此现象,提出了几种解释:

表达能力 论证

单层是一个线性函数,无论其参数数量如何,都无法逼近复杂的非线性函数。但是,每层都会通过非线性激活函数(如 sigmoid 或 ReLU)进行激活。堆叠多层会产生多个连续的非线性,更有可能逼近所需的高度复杂功能,例如区分猫和狗的图像。

泛化论证

给单层添加参数可以增加神经网络的“记忆力”,使其能够学习更复杂的事物。然而,它往往会通过记忆输入示例来学习,这样的泛化能力不强。另一方面,堆叠多层会迫使网络将其输入语义上分解为特征的分层结构。例如,初始层将识别毛发和胡须,而后续层将这些特征组合起来识别猫的头部,然后是整个猫。由此产生的分类器泛化能力更强。

感知领域论证

如果猫的头部覆盖图像的大部分区域——比如一个 128x128 像素的区域——单层卷积网络需要 128x128 个滤波器才能捕捉它,这在可学习权重方面将是极其昂贵的。另一方面,堆叠层可以使用小的 3x3 或 5x5 滤波器,如果它们在卷积堆栈中足够深,仍然能够“看到”任何 128x128 像素的区域。

为了设计更深的卷积网络,而不会无法控制地增加参数计数,研究人员还开始设计更便宜的卷积层。我们来看看如何做到这一点。

滤波器分解

哪一个更好:一个 5x5 卷积滤波器还是两个连续应用的 3x3 滤波器?它们都有一个 5x5 的感受野(见图 3-18)。虽然它们并不执行完全相同的数学操作,但它们的效果可能相似。不同之处在于,连续应用的两个 3x3 滤波器总共有 2 * 3 * 3 = 18 个可学习参数,而单个 5x5 滤波器有 5 * 5 = 25 个可学习权重。因此,两个 3x3 滤波器更便宜。

图 3-18. 两个连续应用的 3x3 滤波器。每个输出值都是从一个 5x5 的感受野计算出来的,这类似于一个 5x5 滤波器的工作方式。

另一个优点是,一对 3x3 卷积层将涉及两次激活函数的应用,因为每个卷积层后面都跟着一个激活函数。而单个 5x5 层只有一个激活。激活函数是神经网络中唯一的非线性部分,可能序列中的非线性组合能够更好地表达输入的复杂非线性表示。

实践中发现,两个 3x3 层比一个 5x5 层效果更好,同时使用更少的可学习权重。这就是为什么现代卷积结构中广泛使用 3x3 卷积层的原因。有时这被称为 滤波器分解,尽管在数学意义上并不完全是分解。

另一个今天流行的滤波器尺寸是 1x1 卷积。让我们看看为什么。

1x1 卷积

在图像上滑动单像素滤波器听起来有点傻。这相当于将图像乘以一个常数。但是,在多通道输入中,对于每个通道使用不同的权重,这其实是有意义的。例如,将 RGB 图像的三个颜色通道分别乘以三个可学习的权重,然后将它们相加,会产生颜色通道的线性组合,这实际上是有用的。一个 1x1 卷积层执行多个这种线性组合,每次使用独立的权重集合,产生多个输出通道(参见图 3-19)。

图 3-19. 一个 1x1 卷积层。每个滤波器有 10 个参数,因为它作用于一个 10 通道输入。应用了 5 个这样的滤波器,每个都有自己的可学习参数(图中未显示),因此输出了 5 个通道的数据。

1x1 卷积层是调整数据通道数量的有用工具。第二个优点是,与 2x2、3x3 或更大的卷积层相比,1x1 卷积层在可学习参数的数量上要便宜。在前面的示例中代表 1x1 卷积层的权重张量如图 3-20 所示。

图 3-20. 来自图 3-19 的 1x1 卷积层的权重矩阵。

可学习权重的数量为 1 * 1 * 10 * 5 = 50。一个具有相同输入和输出通道数量的 3x3 层将需要 3 * 3 * 10 * 5 = 450 个权重,多一个数量级!

接下来,让我们看一下采用了这些技巧的架构。

VGG19

VGG19 是由 Karen Simonyan 和 Andrew Zisserman 在 2014 年的论文中介绍的,是第一批完全采用 3x3 卷积的架构之一。图 3-21 展示了它的 19 层结构。

这个图中的所有神经网络层都使用偏置并且是 ReLU 激活的,除了最后一层使用 softmax 激活。

VGG19 通过更深的网络结构改进了 AlexNet。它有 16 个卷积层,而不是 5 个。它还完全采用 3x3 卷积而不失准确性。然而,它使用与 AlexNet 完全相同的分类头,有三个大的全连接层,超过 1.2 亿个权重,而卷积层只有 2 千万个权重。还有更便宜的替代品。

图 3-21. 具有 19 个可学习层的 VGG19 架构(左)。右侧显示了数据形状(未全部表示)。注意所有卷积层都使用 3x3 的滤波器。

全局平均池化

让我们再次看看分类头的实现。在 AlexNet 和 VGG19 架构中,最后一个卷积层输出的特征图被转换为一个向量(扁平化),然后被馈送到一个或多个全连接层中(参见图 3-22)。其目标是以 softmax 激活的全连接层结束,神经元数量正好等于当前分类问题中的类数,例如 ImageNet 数据集的一千类或上一章中使用的 5-花数据集的五类。这个全连接层具有输入 * 输出权重,这往往是很多的。

图 3-22. 卷积网络末端的传统分类头。从卷积层输出的数据被扁平化并馈送到全连接层。使用 softmax 激活获取类别概率。

如果唯一的目标是获得N个值以馈送给N-路 softmax 函数,有一种简单的方法可以实现:调整卷积堆栈,使其以确切的N通道数的最终特征图结束,并简单地对每个通道中的值进行平均,如图 3-23 所示。这被称为全局平均池化。全局平均池化不涉及可学习权重,因此从这个角度来看它是廉价的。

全局平均池化可以直接接 softmax 激活(如 SqueezeNet 中所示,参见图 3-26),尽管在本书描述的大多数架构中,它将被单个 softmax 激活的全连接层所跟随(例如在 ResNet 中,如图 3-29 所示)。

图 3-23. 全局平均池化。每个通道被平均为一个值。全局平均池化后跟 softmax 函数实现了一个具有零可学习参数的分类头部。
注意

平均化会去除通道中存在的大量位置信息。这可能是好事,也可能不是,这取决于应用场景。卷积滤波器在特定位置上检测它们被训练来检测的事物。例如,如果网络正在分类猫和狗,那么位置数据(例如,在通道中位置x, y处检测到“猫须”)可能对分类头部没有用处。感兴趣的仅仅是“任何地方检测到狗”信号与“任何地方检测到猫”信号。然而,在其他应用中,全局平均池化层可能不是最佳选择。例如,在目标检测或物体计数的用例中,检测到的对象的位置很重要,因此不应使用全局平均池化。

模块化架构

一连串的卷积层和池化层足以构建基本的卷积神经网络。然而,为了进一步提高预测准确性,研究人员设计了更复杂的构建模块,或称为模块,通常被赋予诸如“Inception 模块”,“残差块”或“反向残差瓶颈”等晦涩的名称,然后将它们组装成完整的卷积架构。拥有更高级的构建模块也使得创建自动化算法来搜索更好的架构变得更容易,正如我们将在神经架构搜索部分看到的那样。在本节中,我们将探讨几种这样的模块化架构及其背后的研究。

Inception

Inception 架构以克里斯托弗·诺兰 2010 年的电影盗梦空间命名,由莱昂纳多·迪卡普里奥主演。电影对话中的一句话“我们需要更深入”“We need to go deeper” 成为了一个互联网迷因。当时,研究人员建立更深层次的神经网络是主要动机之一。

Inception V3架构仅使用 3x3 和 1x1 卷积滤波器,这在大多数卷积架构中现已成为惯例。然而,它试图以一种非常独特的方式解决另一个问题。在神经网络中排列卷积和池化层时,设计者有多种选择,而最佳选择并不明显。因此,不如在网络本身中构建多个选项,让它学习哪个是最佳的?这正是 Inception“模块”的动机(见图 3-24)。

图 3-24. Inception 模块的示例。右侧展示了完整的 InceptionV3 架构。

Inception 模块不会事先决定哪种层序列最合适,而是提供了几种网络可以根据数据和训练选择的替代方案。如图 3-24 所示,不同路径的输出连接成最终的特征图。

本书不会详细介绍完整的 InceptionV3 架构,因为它相当复杂,并且已被更新和简化的替代方案所取代。接下来将介绍一个基于“模块”思想的简化变体。

SqueezeNet

模块的概念由SqueezeNet架构简化,保留了为网络提供多条选择路径的基本原则,但将模块本身简化为最简形式(见图 3-25)。SqueezeNet 论文将其称为“fire modules”。

图 3-25. 来自 SqueezeNet 架构的简化和标准化卷积模块。右侧展示的架构通过这些“fire modules”和最大池化层交替排列。

SqueezeNet 架构中使用的模块交替进行收缩阶段和扩展阶段。在收缩阶段中,通过 1x1 卷积减少通道数量,而在扩展阶段中,再次增加通道数量。

为了减少权重计算,SqueezeNet 在最后一层使用全局平均池化。此外,在每个模块中的三个卷积层中,有两个是 1x1 卷积,这样可以节省可学习的权重(见图 3-26)。

图 3-26. SqueezeNet 架构,包含 18 个卷积层。每个“fire module”包含一个“squeeze”层,后跟两个平行的“expand”层。该网络包含 1.2M 可学习参数。

在 图 3-26 中,“maxpool” 是一个标准的 2x2 最大池化操作,步幅为 2。此外,架构中的每个卷积层均使用 ReLU 激活并进行批量归一化。千类分类头部通过首先用 1x1 卷积将通道数扩展到一千,然后对千个通道进行全局平均池化,并最后应用 softmax 激活来实现。

SqueezeNet 架构旨在简单经济(就可学习的权重而言),但仍融合了构建卷积神经网络的大部分最佳实践。其简单性使其成为实现自己的卷积主干的良好选择,无论是出于教育目的还是因为需要根据自身需求进行调整。今天可能不再被视为最佳实践的一个建筑元素是直接受到 AlexNet 启发的大型 7x7 初始卷积层。

要在 Keras 中实现 SqueezeNet 模型,我们必须使用 Keras Functional API 模型。我们不能再使用 Sequential 模型,因为 SqueezeNet 不是一系列直接的层。我们首先创建一个辅助函数,实例化一个 fire 模块(完整代码在 03f_fromzero_SQUEEZENET24_flowers104.ipynb on GitHub 中可用):

def fire(x, squeeze, expand):
    y  = tf.keras.layers.Conv2D(filters=squeeze, kernel_size=1,
                                   activation='relu', padding='same')(x)
    y = tf.keras.layers.BatchNormalization()(y)
    y1 = tf.keras.layers.Conv2D(filters=expand//2, kernel_size=1,
                                   activation='relu', padding='same')(y)
    y1 = tf.keras.layers.BatchNormalization()(y1)
    y3 = tf.keras.layers.Conv2D(filters=expand//2, kernel_size=3,
                                   activation='relu', padding='same')(y)
    y3 = tf.keras.layers.BatchNormalization()(y3)
    return tf.keras.layers.concatenate([y1, y3])

正如你在函数的第一行中所看到的,使用 Keras Functional API,tf.keras.layers.Conv2D() 实例化一个卷积层,然后用输入 x 调用它。我们可以轻微修改 fire() 函数,使其使用相同的语义:

def fire_module(squeeze, expand):
    return lambda x: fire(x, squeeze, expand)

下面是自定义的 24 层 SqueezeNet 的实现。它在 104 朵花数据集上表现尚可,F1 分数为 76%,考虑到它是从头开始训练的,这个结果并不差:

x = tf.keras.layers.Input(shape=[IMG_HEIGHT, IMG_WIDTH, 3])
y = tf.keras.layers.Conv2D(kernel_size=3, filters=32,
                              padding='same', activation='relu')(x)
y = tf.keras.layers.BatchNormalization()(y)
y = fire_module(16, 32)(y)
y = tf.keras.layers.MaxPooling2D(pool_size=2)(y)
y = fire_module(48, 96)(y)
y = tf.keras.layers.MaxPooling2D(pool_size=2)(y)
y = fire_module(64, 128)(y)
y = fire_module(80, 160)(y)
y = fire_module(96, 192)(y)
y = tf.keras.layers.MaxPooling2D(pool_size=2)(y)
y = fire_module(112, 224)(y)
y = fire_module(128, 256)(y)
y = fire_module(160, 320)(y)
y = tf.keras.layers.MaxPooling2D(pool_size=2)(y)
y = fire_module(192, 384)(y)
y = fire_module(224, 448)(y)
y = tf.keras.layers.MaxPooling2D(pool_size=2)(y)
y = fire_module(256, 512)(y)
y = tf.keras.layers.GlobalAveragePooling2D()(y)
y = tf.keras.layers.Dense(len(CLASSES), activation='softmax')(y)

model = tf.keras.Model(x, y)

最后一行,通过传入初始输入层和最终输出创建模型。该模型可以像 Sequential 模型一样使用,因此其余代码保持不变。

ResNet 和跳跃连接

ResNet 架构,由 Kaiming He 等人在 2015 年的 论文 中引入,延续了增加神经网络深度的趋势,但解决了非常深的神经网络普遍存在的问题 —— 由于梯度消失或梯度爆炸导致的收敛困难。训练期间,神经网络会了解它所产生的错误(或损失),并通过调整其内部权重来最小化这些错误。它在这方面是由错误的一阶导数(或梯度)引导的。不幸的是,随着层数增加,梯度往往会在所有层中过分稀释,导致网络收敛缓慢或根本不收敛。

ResNet 试图通过在其卷积层旁边添加 跳跃连接 来补救这一点(图 3-27)。跳跃连接以原样传递信号,然后将其与经过一个或多个卷积层转换的数据重新组合。组合操作是简单的逐元素加法。

图 3-27. ResNet 中的残差块。

正如在 图 3-27 中所示,块的输出 f(x) 是卷积路径 C(x) 和跳跃连接 (x) 输出的和。卷积路径经过训练来计算 C(x) = f(x) – x,即期望输出与输入之间的差异。ResNet 论文的作者认为这种“残差”更容易让网络学习。

显然的限制是,逐元素加法只能在数据的维度保持不变时起作用。跨越跳跃连接的层序列(称为 残差块)必须保持数据的高度、宽度和通道数。

当需要尺寸调整时,使用不同类型的残差块(图 3-28)。通过使用 1x1 卷积而不是恒等映射,可以匹配不同数量的通道。通过在卷积路径和跳跃连接中使用步长 2 来获得高度和宽度的调整(是的,使用步长 2 的 1x1 卷积实现跳跃连接会忽略输入的一半值,但在实践中似乎并不重要)。

图 3-28. 带有高度、宽度和通道数调整的残差块。通过使用 1x1 卷积而不是恒等函数,在跳跃连接中改变通道数。通过在卷积路径和跳跃连接中使用步长 2 的一个卷积层来进行数据高度和宽度的下采样。

ResNet 架构可以通过堆叠更多的残差块来实例化不同深度。流行的尺寸有 ResNet50 和 ResNet101(图 3-29)。

图 3-29. ResNet50 架构。带有步长 2 的残差块通过使用 1x1 卷积实现了跳跃连接(虚线)。ResNet 101 架构类似,使用了 23 次“残差 256, 1,024”块,而非 6 次。

在 图 3-29 中,所有卷积层都是经过 ReLU 激活并使用批归一化。具有这种架构的网络可以非常深 —— 如其名称所示,ResNets 中常见的有 50 层、100 层或更多 —— 但它仍然能够确定哪些层需要根据任何给定输出错误调整其权重。

跳跃连接似乎有助于梯度在优化(反向传播)阶段通过网络流动。关于此已经提出了几种解释。以下是最流行的三种解释。

ResNet 论文的作者们推测,加法操作(参见图 3-27)起着重要作用。在常规神经网络中,内部权重被调整以产生期望的输出,例如将输入分类为一千类。然而,通过跳跃连接,神经网络层的目标是输出输入与期望最终输出之间的增量(或“残差”)。作者认为,这对网络来说是一个“更容易”的任务,但他们没有详细说明什么使得这个任务更容易。

第二个有趣的解释是,残差连接实际上使网络变得更浅。在梯度反向传播阶段,梯度既通过卷积层流动(可能减小幅度),也通过跳跃连接流动(保持不变)。在文章“Residual Networks Behave Like Ensembles of Relatively Shallow Networks”中,Veit 等人测量了 ResNet 架构中梯度的强度。结果(见图 3-30)显示,在一个 50 层 ResNet 神经网络中,信号可以通过卷积层和跳跃连接的各种组合流动。

图 3-30. ResNet50 模型中路径长度的理论分布与在反向传播期间实际梯度采取的路径。来自Veit et al., 2016的图像。

在通过卷积层遍历的路径长度中,最有可能的路径长度位于 0 和 50 之间的中点(左图)。然而,Veit 等人测量到在训练后的 ResNet 中提供实际有用的非零梯度的路径要比这更短,大约穿越 12 层。

根据这一理论,深度为 50 层或 100 层的 ResNet 充当一个集合——即解决分类问题不同部分的较浅网络的集合。它们共同发挥其分类优势,但由于实际上并不是非常深,它们仍然能够有效地收敛。与模型集合相比,ResNet 架构的好处在于它作为单一模型训练,并学会为每个输入选择最佳路径。

第三种解释关注训练期间优化的损失函数的拓扑景观。在“Visualizing the Loss Landscape of Neural Nets”中,Li 等人成功地将损失景观呈现为 3D,而不是其原始的数百万维,并展示了使用跳跃连接时好的极小值要容易得多(见图 3-31)。

图 3-31. 通过 Li 等人的“过滤器归一化方案”可视化的 56 层 ResNet 的损失景观。添加跳跃连接使全局最小值更容易达到。图片来源于 Li et al., 2017

在实践中,ResNet 架构表现非常出色,并已成为领域内最流行的卷积架构之一,也是所有其他进展的基准。

DenseNet

DenseNet 架构通过一种全新的激进想法重新审视了跳跃连接的概念。在他们的 论文 中,Gao Huang 等人建议通过创建必要数量的跳跃连接,将所有前层的输出馈送到卷积层。这一次,数据通过在深度轴(通道)上进行串联而不是相加来组合。显然,导致 ResNet 架构的直觉——通过“残余”信号添加跳跃连接的数据更容易学习——并非根本。串联也行得通。

密集块 是 DenseNet 的基本构建块。在密集块中,卷积成对分组,每对卷积接收前面所有卷积对的输出作为其输入。在图 3-32 中描述的密集块中,数据通过在通道上串联来组合。所有卷积都经过 ReLU 激活并使用批归一化。如果数据的高度和宽度维度相同,则通道级串联才能起作用,因此密集块中的卷积都是步幅为 1,不改变这些维度。池化层将必须在密集块之间插入。

图 3-32. “密集块”是 DenseNet 架构的基本构建块。卷积被成对分组。每对卷积接收前面所有卷积对的输出作为输入。注意通道数随层数线性增长。

直觉上,人们会认为连接所有先前看到的输出会导致通道数和参数数量呈爆炸性增长,但实际情况并非如此。从可学习参数的角度来看,DenseNet 非常经济。原因在于,每个连接块,可能具有相对较多的通道数,总是首先通过一个 1x1 卷积进行处理,将其减少到小数量的通道数,K。 1x1 卷积在其参数数量上是廉价的。然后是一个相同通道数(K)的 3x3 卷积。得到的K个通道然后连接到所有先前生成的输出集合。每个步骤,使用一对 1x1 和 3x3 卷积,正好向数据添加K个通道。因此,通道数量仅随着密集块中卷积步骤的数量线性增长。增长率K在整个网络中是常数,并且已经证明 DenseNet 在较低的K值(原始论文中K在 12 到 40 之间)下表现良好。

密集块和池化层交替排列以创建完整的 DenseNet 网络。图 3-33 展示了一个具有 121 层的 DenseNet121,但这种架构是可配置的,可以轻松扩展到超过 200 层。

浅卷积层(K=32,例如)是 DenseNet 的一个特征。在先前的架构中,拥有超过一千个过滤器的卷积并不罕见。DenseNet 能够使用浅卷积,因为每个卷积层都能看到先前计算的所有特征。在其他架构中,数据在每一层都会进行变换,网络必须积极工作以保持数据通道的原样,如果这是正确的操作的话。它必须使用一些过滤器参数来创建一个恒等函数,这是浪费的。作者认为,DenseNet 旨在允许特征复用,因此每个卷积层所需的过滤器要少得多。

图 3-33. DenseNet121 架构。使用增长率 K=32 时,所有卷积层产生 32 个输出通道,除了用于密集块之间转换的 1x1 卷积,它们被设计为减半通道数。有关密集块的详细信息,请参阅前一图。所有卷积均使用 ReLU 激活并进行批归一化。

深度可分离卷积

传统卷积一次过滤输入的所有通道。然后使用多个过滤器使网络有机会对相同的输入通道做许多不同的事情。让我们以一个应用于 8 个输入通道的 3x3 卷积层,输出通道为 16 为例。它有形状为 3x3x8 的 16 个卷积滤波器(图 3-34)。

Figure 3-34. 一个具有 8 个输入和 16 个输出(16 个过滤器)的 3x3 卷积层的权重。在训练后,许多单独的 3x3 过滤器可能会变得相似(阴影部分);例如,水平线探测器过滤器。

在每个 3x3x8 滤波器中,实际上同时发生两个操作:一个 3x3 滤波器被应用于图像的每个输入通道(空间维度),并且经过滤波的输出以各种方式在通道之间重新组合。简而言之,这两个操作是空间过滤与经过滤波输出的线性重新组合。如果这两个操作独立进行(或可分离),而不影响网络的性能,那么可以使用更少的可学习权重来执行。让我们看看为什么。

如果我们看一个训练过的层的 16 个过滤器,很可能网络不得不在许多过滤器中重新发明相同的 3x3 空间过滤器,只是因为它想以不同的方式组合它们。实际上,这可以通过实验来可视化(图 3-35)。

Figure 3-35. 可视化训练神经网络的第一个卷积层中一些 12x12 滤波器。非常相似的滤波器已被多次重新发明。图像来自Sifre, 2014

看起来传统的卷积层使用参数效率低下。这就是为什么 Laurent Sifre 在他 2014 年论文的第 6.2 节中建议使用一种称为深度可分离卷积可分离卷积的不同类型的卷积。主要思想是通过一组独立的滤波器逐个通道地过滤输入,然后使用 1x1 卷积单独组合输出,如图 3-36 所示。假设跨通道提取很少的“形状”信息,因此加权和就足以组合它们(1x1 卷积是通道的加权和)。另一方面,图像的空间维度中包含大量的“形状”信息,需要使用 3x3 或更大的滤波器来捕捉它。

Figure 3-36. 一个 4x4 深度可分离卷积层。在第 1 阶段,4x4 滤波器独立地应用于每个通道,生成相同数量的输出通道。在第 2 阶段,输出通道然后通过 1x1 卷积重新组合(通道的多个加权和)。

在图 3-36 中,第 1 阶段过滤操作可以使用新的滤波权重重复,以产生两倍或三倍数量的通道。这称为深度乘数,但通常值为 1,因此此参数未在右侧权重计算中表示。

可以轻松计算示例卷积层在图 3-36 中使用的权重数量:

  • 使用可分离的 3x3x8x16 卷积层:3 * 3 * 8 + 8 * 16 = 200 个权重

  • 使用传统的卷积层:3 * 3 * 8 * 16 = 1,152 个权重(用于比较)

由于可分离卷积层无需多次重新发明每个空间滤波器,它们在可学习权重方面显著更为经济。问题是它们是否同样高效。

弗朗索瓦·肖莱在他的论文“Xception: 深度学习中的深度可分离卷积”中主张,可分离卷积实际上是与前文中看到的 Inception 模块非常相似的概念。图 3-37(A)展示了一个简化的 Inception 模块,包含三条并行的卷积路径,每条路径由一个 1x1 卷积接着一个 3x3 卷积构成。这与图 3-37(B)中的表示完全等效,其中单个 1x1 卷积输出的通道数是之前的三倍。每个通道块随后会被 3x3 卷积接收。从那里开始,只需要一个参数调整——即增加 3x3 卷积的数量——就可以得到图 3-37(C),在那里,每个来自 1x1 卷积的通道都会被其自身的 3x3 卷积接收。

图 3-37. Inception 模块与深度可分离卷积之间的结构相似性:(A)一个带有三条并行卷积路径的简化 Inception 模块;(B)一个完全等效的设置,其中有一个单独的 1x1 卷积,但输出的通道数是之前的三倍;(C)一个非常类似的设置,包含更多的 3x3 卷积。这正是一个深度可分离卷积,其中 1x1 和 3x3 操作的顺序被交换。

图 3-37(C)实际上代表了一个深度可分离卷积,其中 1x1(深度)和 3x3(空间)操作的顺序被交换。在堆叠这些层的卷积架构中,这种顺序的变化并不太重要。总之,一个简化的 Inception 模块在功能上与深度可分离卷积非常相似。这个新的构建块将使卷积架构在可学习权重方面变得更简单且更经济。

可分离卷积层在 Keras 中是可用的:

tf.keras.layers.SeparableConv2D(filters,
                                kernel_size,
                                strides=(1, 1),
                                padding='valid',
                                `depth_multiplier``=``1`)

与传统卷积层相比的新参数是depth_multiplier参数。以下是参数的简化描述(详见Keras 文档):

filters

最终 1x1 卷积要产生的输出通道数。

kernel_size

每个空间滤波器的大小。可以是一个数字,例如 3 表示 3x3 滤波器,或一对数字,如 (4, 2) 表示一个 4x2 的矩形滤波器。

strides

空间过滤的卷积步长。

padding

'valid' 表示无填充,'same' 表示零填充。

depth_multiplier

空间过滤操作重复执行的次数。默认为 1。

Xception

Xception 架构(图 3-38)将可分离卷积与 ResNet 风格的跳跃连接结合起来。由于可分离卷积在某种程度上等效于 Inception 风格的分支模块,Xception 在更简单的设计中结合了 ResNet 和 Inception 的架构特征。Xception 的简洁性使其成为在实现自己的卷积主干时的一个不错选择。Xception 的 Keras 实现源代码 可以轻松从文档中获取。

图 3-38。具有 36 个卷积层的 Xception 架构。该架构灵感来自 ResNet,但使用可分离卷积代替传统卷积,除了前两层。

在图 3-38 中,所有卷积层均采用 ReLU 激活并使用批标准化。所有可分离卷积使用深度乘数为 1(无通道扩展)。

Xception 中的残差块与其 ResNet 对应部分有所不同:它们使用 3x3 可分离卷积,而不是 ResNet 中 3x3 和 1x1 传统卷积的混合。这是有道理的,因为 3x3 可分离卷积已经是 3x3 和 1x1 卷积的结合体(见图 3-36)。这进一步简化了设计。

此外,虽然深度可分卷积有一个深度乘数参数,允许将初始的 3x3 卷积应用多次到每个输入通道上,但 Xception 架构使用深度乘数为 1 时可以获得良好的结果。这实际上是最常见的做法。本章描述的所有其他基于深度可分卷积的架构也都在不改变深度乘数的情况下使用它们(保持为 1)。似乎在可分卷积的 1x1 部分添加参数足以允许模型捕获输入图像中的相关信息。

神经架构搜索设计

前几页描述的卷积架构都由不同方式排列的类似元素组成:3x3 和 1x1 卷积,3x3 可分卷积,加法,串联… 难道寻找理想组合的过程不能自动化吗?让我们看看能够做到这一点的架构。

NASNet

精确地自动化寻找操作的最佳组合正是NASNet 论文的作者所做的。然而,通过整套可能的操作进行蛮力搜索将是一个过大的任务。选择和组合层以形成完整神经网络的方式有太多种。此外,每个部分还有许多超参数,如其输出通道数或滤波器大小。

相反,他们以聪明的方式简化了问题。回顾 Inception、ResNet 或 Xception 体系结构(分别在图 3-24、3-29 和 3-38 中),很容易看出它们由两种重复模块构成:一种保持特征的宽度和高度不变的模块(“普通细胞”),另一种将它们减半的模块(“减少细胞”)。NASNet 的作者使用自动化算法设计了这些基本细胞的结构(见图 3-39),然后通过手动堆叠具有合理参数(例如通道深度)的细胞来组装卷积体系结构。然后,他们训练结果网络,以查看哪种模块设计效果最好。

图 3-39. 作为 NASNet 构建块使用的一些个别操作。

搜索算法可以是随机搜索,实际上在研究中表现不错,也可以是基于神经网络的更复杂的算法,称为强化学习。要了解更多关于强化学习的信息,请参阅 Andrej Karpathy 的“Pong from Pixels”文章或 Martin Görner 和 Yu-Han Liu 的“Reinforcement Learning Without a PhD” Google I/O 2018 视频。

图 3-40 展示了算法找到的最佳普通和减少细胞的结构。请注意,搜索空间允许连接不仅来自前一阶段,还包括前一个阶段,以模仿更密集连接的体系结构,如 DenseNet。

图 3-40. 在 NASNet 论文中通过神经架构搜索找到的表现最佳的卷积细胞。它们由可分离卷积以及平均池化和最大池化层组成。

论文指出,可分离卷积总是使用双倍(“sep 3x3”在图 3-40 中实际上表示两个连续的 3x3 可分离卷积),这被实证发现可以提高性能。

图 3-41 展示了细胞如何堆叠以形成完整的神经网络。

可以通过调整 NM 参数来获得不同的 NASNet 规模,例如,N=7 和 M=1,920 用于最广泛使用的变体,具有 22.6M 参数。图中的所有卷积层都是 ReLU 激活的,并使用批归一化。

图 3-41. 正常和减少单元的堆叠,以创建完整的神经网络。正常单元重复 N 次。通道数在每个减少单元中乘以 2,以在最后获得 M 输出通道。

有一些有趣的细节需要注意算法的行为:

  • 它只使用可分离卷积,尽管常规卷积是搜索空间的一部分。这似乎确认了可分离卷积的好处。

  • 在合并分支时,算法选择将结果相加,而不是连接它们。这类似于 ResNet,但不像 Inception 或 DenseNet 使用连接。(注意,每个单元中的最后一个连接是由架构强制的,而不是算法选择的。)

  • 在正常的单元中,算法选择了多个并行分支,而不是较少的分支和更多层次的转换。这更像是 Inception,而不像 ResNet。

  • 该算法使用了具有大型 5x5 或 7x7 过滤器的可分离卷积,而不是全部使用 3x3 的卷积来实现。这与本章前面提出的“过滤器分解”假设相反,表明这一假设可能并不成立。

一些选择似乎有问题,可能是搜索空间设计的产物。例如,在正常单元中,带有 1 步长的 3x3 平均池化层基本上是模糊操作。也许模糊是有用的,但是模糊同一输入两次然后将结果相加显然不是最优的做法。

MobileNet 家族

在接下来的几节中,我们将描述 MobileNetV2/MnasNet/EfficientNet 等架构家族。MobileNetV2 适用于“神经架构搜索”部分,因为它引入了新的构建模块,有助于设计更高效的搜索空间。尽管最初的 MobileNetV2 是手工设计的,后续版本的 MnasNetEfficientNet 利用相同的构建模块进行自动化神经架构搜索,并最终得到了优化但非常相似的架构。然而,在讨论这些架构集之前,我们首先需要介绍两个新的构建模块:深度卷积和反向残差瓶颈。

深度卷积

为了理解 MobileNetV2 架构,我们需要解释的第一个构建模块是深度卷积。MobileNetV2 架构重新审视深度可分离卷积及其与跳跃连接的交互作用。为了实现这种细粒度的分析,我们必须首先将先前描述的深度可分离卷积(图 3-36)分解为其基本组件:

  • 空间过滤部分称为深度卷积(图 3-42)。

  • 1x1 卷积

在 图 3-42 中,过滤操作可以使用新的过滤权重重复进行,以产生两倍或三倍数量的通道。这被称为“深度乘数”,但其通常值为 1,因此未在图中表示。

Keras 中提供了深度卷积层:

tf.keras.layers.DepthwiseConv2D(kernel_size,
                                strides=(1, 1),
                                padding='valid',
                                depth_multiplier=1)

请注意,深度可分离卷积,例如:

tf.keras.layers.SeparableConv2D(filters=128, kernel_size=(3,3))

也可以在 Keras 中表示为两层的序列:

tf.keras.layers.DepthwiseConv2D(kernel_size=(3,3)
tf.keras.layers.Conv2D(filters=128, kernel_size=(1,1))

图 3-42. 深度卷积层。卷积滤波器独立应用于每个输入通道,产生相等数量的输出通道。

反向残差瓶颈

MobileNet 家族中第二个也是最重要的构建模块是反向残差瓶颈。在 ResNet 或 Xception 架构中使用的残差块倾向于保持通过跳跃连接传递的通道数量较多(参见下文的 图 3-43)。在 MobileNetV2 论文 中,作者假设跳跃连接帮助保留的信息本质上是低维的。这在直观上是有道理的。如果卷积块专门用于检测例如“猫须”,则其输出中的信息(“在位置(3,16)检测到须”的位置)可以沿着三个维度表示:类别、xy。与须的像素表示相比,这是低维的。

MobileNetV2 架构引入了一种新的残差块设计,其中在通道数较少的地方放置跳跃连接,并扩展残差块内部的通道数量。图 3-43 将新设计与 ResNet 和 Xception 中使用的典型残差块进行了比较。ResNet 块中的通道数遵循“多 - 少 - 多”的顺序,并在“多通道”阶段之间进行跳跃连接。Xception 则为“多 - 多 - 多”。新的 MobileNetV2 设计遵循“少 - 多 - 少”的顺序。论文将此技术称为 反向残差瓶颈 ——“反向”因为它与 ResNet 方法正好相反,“瓶颈”因为通道数量在残差块之间被压缩,如瓶子的颈部。

图 3-43. MobileNetV2 中的新残差块设计(称为“反向残差瓶颈”),与 ResNet 和 Xception 残差块进行比较。“dw-cnv”代表深度卷积。Xception 使用的可分离卷积由其组件表示:“dw-cnv”后跟“conv 1x1”。

这种新的残差块的目标是在推断时提供与之前设计相同的表达能力,但显著减少权重数量,更重要的是减少延迟。MobileNetV2 确实是设计用于移动电话,在那里计算资源稀缺。在 图 3-43 中代表的典型残差块的权重计数分别为 1.1M、52K 和 1.6M,分别对应 ResNet、MobileNetV2 和 Xception 块。

MobileNetV2 论文的作者认为他们的设计可以用更少的参数达到良好的结果,因为在残差块之间流动的信息是低维的,因此可以在有限数量的通道中表示。然而,一个构造细节很重要:反向残差块中的最后一个 1x1 卷积,即将特征图压缩回“少量”通道的卷积,后面不跟随非线性激活函数。MobileNetV2 论文详细讨论了这个话题,但简短地说,在低维空间中,ReLU 激活会破坏太多信息。

现在我们已经准备好构建一个完整的 MobileNetV2 模型,然后使用神经架构搜索将其优化为经过优化但在其他方面非常相似的 MnasNet 和 EfficientNet 架构。

MobileNetV2

现在我们可以组装 MobileNetV2 卷积堆栈。MobileNetV2 由多个反向残差块构建,如 图 3-44 所示。

图 3-44. 基于重复的反向残差瓶颈的 MobileNetV2 架构。重复次数在中间列显示。“conv”表示常规卷积层,“dw-cnv”表示深度卷积。

在 图 3-44 中,反向残差瓶颈块标记为“i-res-bttl N, M”,并通过其内部 (N) 和外部通道深度 (M) 进行参数化。每个标记为“strides 2, 1”的序列以步幅 2 开始,没有跳过连接的反向残差瓶颈块。序列继续使用常规反向残差瓶颈块。所有卷积层均使用批量归一化。请注意,反向残差瓶颈块中的最后一个卷积层不使用激活函数。

MobileNetV2 中的激活函数是 ReLU6,而不是通常的 ReLU。MobileNetV2 的后续进化重新使用了标准的 ReLU 激活函数。在 MobileNetV2 中使用 ReLU6 不是一个基本的实现细节。

MobileNetV2 简单的反向残差瓶颈结构非常适合自动化神经架构搜索方法。这就是 MnasNet 和 EfficientNet 架构的创建方式。

EfficientNet:综合所有内容

创建 MobileNetV2 的团队后来通过自动化神经架构搜索对架构进行了改进,使用反向残差瓶颈作为他们搜索空间的构建块。MnasNet 论文 总结了他们的初步研究结果。这项研究的最有趣的结果是,自动化算法再次引入了 5x5 卷积。正如我们之前看到的 NASNet,手动构建的所有架构都标准化为 3x3 卷积,这一选择是有滤波器分解假设来支持的。显然,像 5x5 这样的大滤波器确实是有用的。

我们将跳过对 MnasNet 架构的正式描述,转而介绍其下一代版本:EfficientNet。这种架构使用了与 MnasNet 完全相同的搜索空间和网络架构搜索算法,但优化目标调整为预测准确性,而不是移动推断延迟。MobileNetV2 中的反向残差瓶颈再次成为基本构建块。

EfficientNet 实际上是一个不同尺寸的神经网络家族,该家族的网络缩放受到了很多关注。卷积架构有三种主要的缩放方式:

  • 使用更多层。

  • 在每一层中使用更多通道。

  • 使用更高分辨率的输入图像。

EfficientNet 论文指出,这三个缩放轴并不是独立的:“如果输入图像更大,则网络需要更多层来增加感受野,并且需要更多通道来捕获更大图像上的更精细模式。”

EfficientNetB0 至 EfficientNetB7 神经网络家族的新颖之处在于,它们沿着所有三个缩放轴进行缩放,而不仅仅是像之前的架构家族 ResNet50/ResNet101/ResNet152 那样只沿着一个轴。EfficientNet 家族如今是许多应用机器学习团队的主力军,因为它为每个权重计数提供了最佳性能水平。研究进展迅速,但到本书印刷时,可能已经发现了更好的架构。

图 3-45 描述了基准 EfficientNetB0 架构。请注意与 MobileNetV2 的相似性。

图 3-45。EfficientNetB0 架构。请注意与 MobileNetV2 的强烈相似性(图 3-44)。

在 图 3-45 中,反向残差瓶颈序列被标注为 i-res-bttl(KxK) P*Ch, ChN,其中:

  • Ch 是每个块输出的外部通道数。

  • 内部通道数通常是外部通道的倍数 P*Ch

  • KxK 是卷积滤波器的大小,通常为 3x3 或 5x5。

  • N 是这样连续的层块数量。

每个标记为“步幅 2, 1”的序列以步幅为 2 的反向残差瓶颈块开始,没有跳过连接。序列继续使用常规的反向残差瓶颈块。如前所述,“conv”表示常规卷积层,“dw-cnv”表示深度卷积。

EfficientNetB1 到 B7 具有完全相同的一般结构,包括七个反向残差瓶颈序列;只是参数不同。 图 3-46 提供了整个系列的缩放参数。

图 3-46. EfficientNetB0 到 EfficientNetB7 系列,展示了构成 EfficientNet 架构的七个反向残差瓶颈序列的参数。

如 图 3-46 所示,系列中的每个神经网络都有一个理想的输入图像大小。它已经在这种大小的图像上进行了训练,尽管也可以使用其他图像大小。每层中的层数和通道数与输入图像大小一起进行缩放。反向残差瓶颈中外部和内部通道数之间的乘数始终为 6,除了第一行是 1。

那么这些缩放参数真的有效吗?EfficientNet 论文显示它们是有效的。上文中概述的复合缩放比单独缩放网络层数、通道或图像分辨率更有效 (图 3-47)。

图 3-47. 使用来自 EfficientNet 论文的复合缩放方法缩放的 EfficientNet 分类器的准确性,与仅缩放单一因素:宽度(卷积块中通道数)、深度(卷积层数)或图像分辨率。图片来源于 Tan & Le, 2019

EfficientNet 论文的作者还使用了来自 Zhou et al., 2016 的类激活映射技术来可视化训练过的网络所“看到”的内容。同样,复合缩放通过帮助网络专注于图像的重要部分获得了更好的结果 (图 3-48)。

图 3-48. 类激活映射(Zhou 等人,2016)显示了几种 EfficientNet 变体中两个输入图像的模型。通过复合缩放(最后一列)获得的模型聚焦于更相关的区域,具有更多的对象细节。图像来源于Tan 和 Le,2019

EfficientNet 还整合了一些额外的优化。简而言之:

  • 每个倒置瓶颈块都通过“挤压-激励”通道优化进一步优化,如Jie 等人,2017所述。这种技术是一种通道注意机制,可以在每个块的最终 1x1 卷积之前“重新标准化”输出通道(即增强某些通道并减弱其他通道)。与任何“注意力”技术一样,它涉及一个小的额外神经网络,该网络学习产生理想的重新标准化权重。这个额外的网络不在图 3-45 中表示。它对可学习权重的总数的贡献很小。这种技术可以应用于任何卷积块,而不仅仅是倒置残差瓶颈,并且通过大约一个百分点增加了网络的准确性。

  • EfficientNet 系列中的所有成员都使用 Dropout 来帮助应对过拟合问题。家族中较大的网络使用稍大的 Dropout 率(分别为 0.2, 0.2, 0.3, 0.3, 0.4, 0.4, 0.5 和 0.5,对应于 EfficientNetB0 至 B7)。

  • EfficientNet 中使用的激活函数是 SiLU(也称为 Swish-1),如Ramachandran 等人,2017所述。该函数为f(x) = x ⋅ sigmoid(x)。

  • 训练数据集使用 AutoAugment 技术自动扩展,如Cubuk 等人,2018所述。

  • 在训练过程中使用了“随机深度”技术,如Huang 等人,2016所述。我们不确定这部分的效果如何,因为随机深度论文本身报告称,该技术对在 ImageNet 上训练的 ResNet152 没有作用。它可能对更深的网络有所作为。

超越卷积:变压器架构

计算机视觉中讨论的架构都依赖于卷积滤波器。与在第二章讨论的天真密集神经网络相比,卷积滤波器减少了学习如何从图像中提取信息所需的权重数量。然而,随着数据集大小的增加,会有一个点,这种减少权重的效果就不再必要了。

Ashish Vaswani 等人在 2017 年的 论文 中提出了 Transformer 架构,标题为“Attention Is All You Need”。正如标题所示,Transformer 架构的关键创新在于 注意力 的概念——在预测每个单词时,模型专注于输入文本序列的某些部分。例如,考虑一个模型需要将法语短语“ma chemise rouge”翻译成英语(“my red shirt”)。当预测英语翻译的第二个单词“red”时,模型会学习专注于单词 rouge。Transformer 模型通过使用 位置编码 来实现这一点。它不仅简单地用单词表示输入短语,还添加了每个单词的位置作为输入:(ma, 1), (chemise, 2), (rouge, 3)。然后,模型通过训练数据集学习,在预测输出的特定单词时需要专注于输入的哪个单词。

Vision Transformer (ViT) 模型将 Transformer 的思想应用于图像。图像中的等效词汇是方形补丁,因此第一步是将输入图像分成补丁,如 Figure 3-49 所示(完整代码在 GitHub 上的 03m_transformer_flowers104.ipynb 可以找到):

patches = tf.image.extract_patches(
    images=images,
    sizes=[1, self.patch_size, self.patch_size, 1],
    strides=[1, self.patch_size, self.patch_size, 1],
    rates=[1, 1, 1, 1],
    padding="VALID",
)

图 3-49. 输入图像被分成补丁,这些补丁被视为传递给 Transformer 的序列输入。

补丁通过连接补丁像素值和图像内的补丁位置来表示:

encoded = (tf.keras.layers.Dense(...)(patch) +
           tf.keras.layers.Embedding(...)(position))

请注意,补丁位置是补丁的序数(第 5 个,第 6 个等),被视为一种分类变量。采用可学习的嵌入来捕捉具有相关内容的补丁之间的接近关系。

补丁表示通过多个 Transformer 块传递,每个块包括一个注意头(用于学习关注输入的哪些部分):

x1 = tf.keras.layers.LayerNormalization()(encoded)
attention_output = tf.keras.layers.MultiHeadAttention(
    num_heads=num_heads, key_dim=projection_dim, dropout=0.1
)(x1, x1)

注意力输出用于强调补丁表示:

# Skip connection 1.
x2 = tf.keras.layers.Add()([attention_output, encoded])
# Layer normalization 2.
x3 = tf.keras.layers.LayerNormalization()(x2)

并传递到一组密集层中:

# multilayer perceptron (mlp), a set of dense layers.
x3 = mlp(x3, hidden_units=transformer_units,
         dropout_rate=0.1)
# Skip connection 2 forms input to next block
encoded = tf.keras.layers.Add()([x3, x2])

训练循环与本章讨论的任何卷积网络架构相似。请注意,ViT 架构需要比卷积网络模型更多的数据——作者建议在大量数据上预训练 ViT 模型,然后在较小的数据集上进行微调。确实,在 104 flowers 数据集上从头开始训练仅能达到 34% 的准确率。

尽管目前对于我们相对较小的数据集来说并不特别有前景,但将 Transformer 架构应用于图像的想法是有趣的,并且是计算机视觉中新创新的潜在来源。

选择模型

本节将提供一些关于为您的任务选择模型架构的建议。首先,使用无代码服务创建基准,以训练机器学习模型,这样您就可以清楚地了解在您的问题上可以实现什么样的准确率。 如果在 Google Cloud 上进行训练,请考虑使用 Google Cloud AutoML,该服务利用神经架构搜索(NAS)。 如果您使用 Microsoft Azure,请考虑 Custom Vision AIDataRobotH2O.ai 则利用无代码转移学习进行图像分类。 您不太可能获得显著高于这些服务提供的开箱即用准确率,因此您可以将它们用作在投入过多时间处理不可行问题之前快速进行概念验证的一种方式。

性能比较

让我们总结迄今为止所看到的性能数字,首先是针对微调的情况(表 3-11)。请注意,在底部看到的新入围者名为“组合”。我们将在下一节中讨论这一点。

表 3-11. 在 104 种花数据集上微调的八种模型架构

模型 参数(不包括分类头^(a)) ImageNet 准确率 104 种花 F1 分数^(b)(经过微调)
EfficientNetB6 40M 84% 95.5%
EfficientNetB7 64M 84% 95.5%
DenseNet201 18M 77% 95.4%
Xception 21M 79% 94.6%
InceptionV3 22M 78% 94.6%
ResNet50 23M 75% 94.1%
MobileNetV2 2.3M 71% 92%
NASNetLarge 85M 82% 89%
VGG19 20M 71% 88%
组合 79M(DenseNet210 + Xception + EfficientNetB6) - 96.2%
^(a) 在参数计数中排除分类头,以便更容易地比较架构之间的差异。 没有分类头,网络中的参数数量与分辨率无关。 此外,在微调示例中,可能会使用不同的分类头。 ^(b) 对于准确率、精确度、召回率和 F1 分数值,数值越高越好。

现在是从零开始训练(表 3-12)。由于在 104 种花数据集上微调效果更好,因此并非所有模型都是从零开始训练的。

表 3-12. 在 104 种花数据集上从零开始训练的六种模型架构

模型 参数(不包括分类头^(a)) ImageNet 准确率 104 种花 F1 分数^(b)(从零开始训练)
Xception 21M 79% 82.6%
SqueezeNet,24 层 2.7M - 76.2%
DenseNet121 7M 75% 76.1%
ResNet50 23M 75% 73%
EfficientNetB4 18M 83% 69%
AlexNet 3.7M 60% 39%
^(a) 在参数计数中排除分类头部,以便更容易地比较不同架构。没有分类头部,网络中的参数数量与分辨率无关。此外,在微调示例中,可能会使用不同的分类头部。^(b) 对于准确度、精确度、召回率和 F1 分数值,数值越高越好。

Xception 在这里占据了第一位,这有些令人惊讶,因为它并不是最新的架构。Xception 的作者在他的论文中也注意到,当应用于除 ImageNet 和学术界常用的其他标准数据集外的真实世界数据集时,他的模型似乎比其他模型效果更好。第二位由书籍作者快速拼凑的一个类似 SqueezeNet 的模型占据。当您想尝试自己的架构时,SqueezeNet 既非常简单编码又非常高效。这个模型也是选择中最小的一个。它的大小可能非常适合相对较小的 104 花卉数据集(约 20K 张图片)。DenseNet 架构与 SqueezeNet 共享了第二名。在这个选择中,它显然是最不寻常的架构,但在非传统数据集上似乎有很大潜力。

值得一提的是,要查看这些模型的其他变体和版本,以选择最合适且最新的模型。正如提到的,EfficientNet 在我们编写本书时(2021 年 1 月)是当时的最先进模型。您阅读时可能会有更新的内容。您可以查看TensorFlow Hub获取新模型信息。

最后一种选择是同时使用多个模型,一种称为集成的技术。接下来我们将详细讨论这个技术。

集成

当寻求最高精度且模型大小和推断时间不是问题时,可以同时使用多个模型并将它们的预测结果合并。这种集成模型通常能比组成它们的任何单个模型给出更好的预测。它们在实际图像上的预测也更为稳健。在选择要集成的模型时,关键考虑因素是选择尽可能不同的模型。架构非常不同的模型更可能具有不同的弱点。当集成时,不同模型的优势和劣势将彼此补偿,只要它们不属于同一类别。

提供了一个笔记本,03z_ensemble_finetune_flowers104.ipynb,展示了在 104 花卉数据集上微调的三个模型的集成:DenseNet210、Xception 和 EfficientNetB6。如 Tabel 3-13 所示,这个集成模型以可观的优势获胜。

表 3-13. 模型集成与单独模型的比较

模型 参数(不含分类头^(a) ImageNet 准确率 104 种花卉 F1 分数^(b)(微调)
EfficientNetB6 40M 84% 95.5%
DenseNet201 18M 77% 95.4%
Xception 21M 79% 94.6%
Ensemble 79M 96.2%
^(a) 在参数计数中排除分类头,以便更轻松地比较不同架构。没有分类头,网络中的参数数量与分辨率无关。此外,在微调示例中,可能使用不同的分类头。^(b) 对于准确率、精确率、召回率和 F1 分数,数值越高越好。

集成这三个模型的最简单方法是对它们预测的类概率进行平均。另一种可能性,在理论上更好,是对它们的 logits(softmax 激活之前的最后一层输出)进行平均,并对平均值应用 softmax 来计算类概率。示例笔记本展示了这两种选项。在 104 种花卉数据集上,它们的表现相同。

注意

当对 logits 进行平均时要注意的一点是,与概率相反,logits 并不被归一化。它们在不同模型中的值可能非常不同。在这种情况下,计算加权平均而不是简单平均可能有所帮助。应使用训练数据集来计算最佳权重。

推荐策略

这是我们处理计算机视觉问题的推荐策略。

首先,根据数据集的大小选择您的训练方法:

  • 如果您有一个非常小的数据集(每个标签少于一千张图像),请使用迁移学习。

  • 如果您有一个中等大小的数据集(每个标签一千到五千张图像),请使用微调。

  • 如果您有一个大型数据集(每个标签超过五千张图像),请从头开始训练。

这些数字是经验法则,因用例的难度、模型的复杂性和数据质量的不同而变化。您可能需要尝试几种选项。例如,104 种花卉数据集每类的图像数量在一百到三千张之间不等;在此数据集上,微调仍然非常有效。

无论您是进行迁移学习、微调还是从头开始训练,都需要选择一个模型架构。您应该选择哪一个呢?

  • 如果您想自定义层,可以从 SqueezeNet 开始。这是一个简单的模型,表现良好。

  • 对于边缘设备,通常需要优化可以快速下载、在设备上占用空间极少,并且在预测过程中不会产生高延迟的模型。对于在低功耗设备上快速运行的小型模型,可以考虑使用 MobileNetV2。

  • 如果您没有大小/速度限制(例如如果推理将在自动扩展的云系统上进行)并且希望获得最佳/最新的模型,请考虑 EfficientNet。

  • 如果您属于一个希望坚持传统的保守组织,请选择 ResNet50 或其较大的变体之一。

如果培训成本和预测延迟不是问题,或者如果模型准确性的小幅改进会带来外部奖励,请考虑使用三个互补模型的集成。

摘要

本章重点介绍了图像分类技术。首先解释了如何使用预训练模型并将其调整到新数据集上。这是目前最流行的技术之一,如果预训练数据集和目标数据集至少有一些相似性,它将起作用。我们探讨了这种技术的两个变体:迁移学习,其中预训练模型被冻结并用作静态图像编码器;以及微调,其中预训练模型的权重用作新数据集上新训练运行的初始值。然后我们研究了历史上和当前最先进的图像分类架构,从 AlexNet 到 EfficientNets。所有这些架构的构建模块都有详细解释,当然从卷积层开始,以便您完全理解这些模型的工作原理。

在第四章中,我们将研究如何使用这些图像模型架构来解决常见的计算机视觉问题。

第四章:目标检测与图像分割

到目前为止,在本书中,我们已经看过各种机器学习架构,但仅用于解决一种类型的问题——整个图像的分类(或回归)。在本章中,我们讨论三个新的视觉问题:目标检测、实例分割和整场景语义分割(图 4-1)。其他更高级的视觉问题,如图像生成、计数、姿态估计和生成模型,将在第十一章和第十二章中涵盖。

图 4-1. 从左至右:目标检测、实例分割和整场景语义分割。图像来自节肢动物城市街景数据集。
提示

本章的代码位于书籍的04_detect_segment文件夹中的 GitHub 仓库中。我们会在适当的地方提供代码示例和笔记本的文件名。

目标检测

对于大多数人来说,看是如此轻松,以至于当我们从眼角瞥见一只蝴蝶并转过头来欣赏它的美丽时,我们甚至不会考虑到数百万的视觉细胞和神经元在起作用,捕捉光线、解码信号,并将它们处理成越来越高级的抽象。

我们在第三章中看到了机器学习中图像识别的工作原理。然而,在该章中介绍的模型是为了将整个图像分类而构建的,它们不能告诉我们花朵在图像中的具体位置。在本节中,我们将探讨构建能够提供位置信息的机器学习模型的方法。这个任务被称为目标检测(图 4-2)。

图 4-2. 一个目标检测任务。来自节肢动物数据集的图像。

实际上,卷积层确实可以识别和定位它们检测到的事物。来自第三章的卷积主干已经提取了一些位置信息。但在分类问题中,网络不利用这些信息。它们是在一个位置不重要的目标上训练的。蝴蝶的图片会在图片中的任何位置被分类为蝴蝶。相反,在目标检测中,我们将在卷积堆栈中添加元素来提取和精炼位置信息,并训练网络以达到最大精度。

最简单的方法是在卷积主干结构的末端添加一些内容,以预测检测到的对象周围的边界框。这就是 YOLO(You Only Look Once)的方法,我们将从这里开始。然而,在卷积主干结构的中间层也包含了许多重要信息。为了提取这些信息,我们将构建更复杂的架构,称为特征金字塔网络(FPNs),并使用 RetinaNet 进行说明。

在这一节中,我们将使用节肢动物分类目标检测数据集(简称为节肢动物),该数据集在Kaggle.com上免费提供。该数据集包含七个类别——鞘翅目(甲壳虫)、蛛形目(蜘蛛)、半翅目(真虫)、双翅目(飞蝇)、鳞翅目(蝴蝶)、膜翅目(蜜蜂、胡蜂和蚂蚁)和蜻蜓目(蜻蜓)——以及边界框。一些示例显示在图 4-3 中。

图 4-3. 节肢动物数据集中的一些目标检测示例。

除了 YOLO,本章还将讨论 RetinaNet 和 Mask R-CNN 架构。它们的实现可以在TensorFlow Model Garden 官方视觉存储库中找到,我们将使用存储库中“beta”文件夹内的最新实现。

展示如何在诸如节肢动物之类的自定义数据集上应用这些检测模型的示例代码可以在04_detect_segment on GitHub中找到,对应于第四章。

除了 TensorFlow Model Garden,还可以在keras.io网站上找到关于 RetinaNet 的优秀的逐步实现

YOLO

YOLO(你只看一次)是最简单的物体检测架构之一。它并非最精确,但在预测时间方面是最快的之一,因此被广泛应用于诸如安全摄像头之类的实时系统中。该架构可以基于第三章中的任何卷积主干结构进行构建。图像通过卷积堆栈处理,如同图像分类情况一样,但分类头被替换为物体检测和分类头。

YOLO 的更多最新变体架构存在(YOLOv2YOLOv3YOLOv4),但我们这里不会涵盖它们。我们将使用 YOLOv1 作为进入物体检测架构的第一步,因为它是最简单的。

YOLO 网格

YOLOv1(以下简称为“YOLO”)将图像划分为一个 NxM 的网格单元格,例如 7x5(详见 图 4-4)。对于每个单元格,它尝试预测一个包围框,其中心位于该单元格中。预测的边界框可以比其起源的单元格更大;唯一的约束是框的中心必须位于单元格内的某处。

预测边界框意味着什么?让我们来看看。

图 4-4. YOLO 网格。每个网格单元预测一个包围框,其中心位于该单元格的某处。来自 节肢动物数据集 的图像。

目标检测头

预测边界框涉及预测六个数值:边界框的四个坐标(例如,中心的 xy 坐标,以及宽度和高度),一个置信度因子,告诉我们是否检测到了对象,最后是对象的类别(例如,“蝴蝶”)。YOLO 架构直接在它正在使用的卷积主干生成的最后特征图上执行此操作。

在 图 4-5 中,xy 坐标的计算使用双曲正切(tanh)激活函数,以确保坐标落在 [–1, 1] 范围内。这些坐标将是检测框的中心相对于其所属的网格单元中心的位置。

图 4-5. YOLO 检测头为每个网格单元预测一个边界框 (x, y, w, h),此处的置信度 C 表示在该位置检测到对象的可能性,以及对象的类别。

宽度和高度 (w, h) 的计算使用 sigmoid 激活函数,以确保落在 [0, 1] 范围内。它们表示检测框相对于整个图像的大小。这样可以使检测框大于其起源网格单元的尺寸。置信度因子 C 也在 [0, 1] 范围内。最后,使用 softmax 激活函数预测检测到的对象的类别。图中展示了 tanh 和 sigmoid 函数,详见 图 4-6。

图 4-6. 双曲正切和 sigmoid 激活函数。双曲正切输出在 [–1, 1] 范围内,而 sigmoid 函数输出在 [0, 1] 范围内。

一个有趣的实际问题是如何获得完全正确尺寸的特征映射。在 图 4-4 的示例中,它必须包含确切的 7 * 5 * (5 + 7) 个值。其中的 7 * 5 是因为我们选择了一个 7x5 的 YOLO 网格。然后,对于每个网格单元,需要五个值来预测一个框 (x, y, w, h, C),再加上七个额外的值,因为在这个示例中,我们想将节肢动物分类为七类(鞘翅目、蜘蛛目、半翅目、双翅目、鳞翅目、膜翅目、蜻蜓目)。

如果您控制卷积堆栈,可以尝试调整它,确保最终输出正好是 7 * 5 * 12(420)个。但是,还有一种更简单的方法:将卷积主干返回的任何特征映射展平,并通过具有正好这些输出数量的全连接层进行馈送。然后,您可以将这 420 个值重塑为一个 7x5x12 的网格,并像图 4-5 中那样应用适当的激活函数。 YOLO 论文的作者认为,全连接层实际上提高了系统的准确性。

损失函数

在目标检测中,与任何监督学习环境一样,训练数据提供了正确答案:地面真实框及其类别。在训练期间,网络预测检测框,必须考虑框的位置和尺寸错误以及误分类错误,并惩罚未检测到任何对象的情况。然而,第一步是正确地将地面真实框与预测框配对,以便进行比较。在 YOLO 架构中,如果每个网格单元预测一个单一框,这是直接的。只要地面真实框和预测框位于同一个网格单元的中心(参见图 4-4 以便更容易理解)。

然而,在 YOLO 架构中,每个网格单元的检测框数是一个参数。它可以多于一个。如果您回顾图 4-5,您会看到每个网格单元预测 10 或 15 个(xywhC)坐标而不是 5,并生成 2 或 3 个检测框而不是 1。但是,将这些预测与地面真实框配对需要更多注意。这是通过计算网格单元内所有地面真实框与所有预测框之间的交并比(IOU;参见图 4-7)来完成,并选择 IOU 最高的配对。

图 4-7. IOU 指标。

总结一下,地面真实框是通过它们的中心分配给网格单元,并通过 IOU 将预测框分配给这些网格单元内的预测框。有了这些配对关系,我们现在可以计算损失的不同部分:

对象存在损失

每个具有地面真实框的网格单元计算:

Lobj=(1C)2

对象缺失损失

每个不具有地面真实框的网格单元计算:

Lnoobj=(0C)2=C2

对象分类损失

每个有真实框的网格单元格计算:

Lclass=crossentropy(p,p^)

其中是预测类别概率的向量,p是独热编码的目标类别。

边界框损失

每个预测框和真实框的配对都会产生贡献(预测坐标标有帽子,另一个坐标是真实的):

Lbox=(xx</mo></mrow></mover><msup><mrow><mo>)</mo></mrow><mrow><mn>2</mn></mrow></msup></mrow><mrow><mo>+</mo><mrow><mrow><mo>(</mo><mi>y</mi><mo>−</mo><mover><mrow><mi>y</mi></mrow><mrow><mo>)2+(ww</mo></mrow></mover></mrow></msqrt><mo>)</mo></mrow><mrow><mn>2</mn></mrow></msup><mo>+</mo><mrow><mo>(</mo><msqrt><mrow><mi>h</mi></mrow></msqrt><mo>−</mo><msqrt><mrow><mover><mrow><mi>h</mi></mrow><mrow><mo>)2

注意这里,盒子大小的差异是在维度的平方根上计算的。这是为了减轻大盒子的影响,大盒子往往会压倒损失。

最后,所有网格单元格的损失贡献加在一起,并带有加权因子。目标检测损失中常见的问题是,大量没有物体的单元格中的小损失最终会压倒预测有用框的孤立单元格的损失。加权损失的不同部分可以缓解这个问题。论文的作者使用了以下经验权重:

λobj=1λnoobj=0.5λclass=1λbox=5

YOLO 的限制

最大的限制是,YOLO 每个网格单元预测一个单一类别,在同一个单元中存在多种不同类型对象时效果不佳。

第二个限制是网格本身:固定的网格分辨率对模型能够执行的任务施加了强烈的空间约束。对于小物体的集合(例如一群鸟),要使 YOLO 模型表现良好,需要仔细调整网格以适应数据集。

此外,YOLO 倾向于以相对较低的精度定位对象。主要原因在于它工作在卷积堆栈的最后一个特征图上,通常这个特征图具有最低的空间分辨率,并且仅包含粗略的位置信号。

尽管存在这些限制,YOLO 架构非常简单易实现,特别是每个网格单元仅有一个检测框,这使得它成为你想要使用自己的代码进行实验时的一个不错的选择。

注意,并不是每个对象都是通过查看单个网格单元中的信息来检测的。在足够深的卷积神经网络(CNN)中,从中计算检测框的最后一个特征图中的每个值都依赖于原始图像的所有像素。

如果需要更高的准确性,可以升级到更高级别的 RetinaNet。它融合了多项改进基于基本的 YOLO 架构,并且被认为是所谓的单阶段检测器的技术最先进。

RetinaNet

RetinaNet,相较于 YOLOv1,在其架构和损失设计上有多项创新。神经网络设计包括特征金字塔网络,结合了多尺度提取的信息。检测头从锚框开始预测框,改变边界框表示以便于训练。最后,损失创新包括焦点损失,专门为检测问题设计的损失,平滑的 L1 损失用于框回归,以及非极大值抑制。让我们依次看看这些创新。

特征金字塔网络

当图像经过 CNN 处理时,初始卷积层捕捉低级细节,如边缘和纹理。进一步的层将它们组合成具有更多语义价值的特征。同时,网络中的池化层降低特征图的空间分辨率(见 图 4-8)。

图 4-8. CNN 不同阶段的特征图。随着信息在神经网络中传播,其空间分辨率减少,但语义内容从低级细节到高级对象逐渐增加。

YOLO 架构仅使用最后一个特征图进行检测。它能正确识别物体,但其定位精度有限。另一种思路是尝试在每个阶段添加检测头。然而,在这种方法中,从早期特征图开始工作的检测头可能定位物体相当好,但标签化却困难重重。在这个早期阶段,图像只经过了几个卷积层,这还不足以对其进行分类。像“这是一朵玫瑰”的更高级语义信息需要数十个卷积层才能显现出来。

仍然有一种流行的检测架构叫做单次检测器(SSD),它基于这个思想。SSD 论文的作者通过将多个检测头连接到多个特征图使其成功运行,这些特征图都位于卷积堆栈的末端。

如果我们能以一种方式结合所有特征图,使其在所有尺度上都能提供良好的空间信息和语义信息,那会怎样?这可以通过添加几个附加层形成 特征金字塔网络 来实现。图 4-9 提供了 FPN 与 YOLO 和 SSD 方法的示意图对比,而 图 4-10 则呈现了详细设计。

Figure 4-9. Y 图 4-9. YOLO、SSD 和 FPN 架构的比较,以及它们在卷积堆栈中连接检测头的位置。

图 4-10. 特征特征金字塔网络详细信息。特征图从卷积主干的各个阶段提取,并且 1x1 卷积将每个特征图压缩到相同数量的通道。上采样(最近邻)然后使它们的空间尺寸兼容,以便可以相加。最后的 3x3 卷积平滑了上采样的伪影。通常在 FPN 层中不使用激活函数。

在图 4-10 中,FPN 中发生的情况如下:在向下路径(卷积主干)中,卷积层逐渐优化特征图中的语义信息,而池化层缩小特征图的空间尺寸(图像的 xy 尺寸)。在向上路径中,来自底层的包含良好高级语义信息的特征图被上采样(使用简单的最近邻算法),以便它们可以逐元素添加到堆栈中更高的特征图中。1x1 卷积用于侧向连接,将所有特征图带到相同的通道深度,并使添加成为可能。例如,FPN 论文中在所有地方使用 256 个通道。现在生成的特征图包含所有尺度的语义信息,这是最初的目标。它们通过 3x3 卷积进一步处理,主要是为了平滑上采样的效果。

FPN 层通常没有非线性。FPN 论文的作者发现它们影响不大。

现在检测头可以获取每个分辨率的特征图,并生成框检测和分类。检测头本身可以具有多种设计,我们将在接下来的两节中进行介绍。然而,它将在不同尺度的所有特征图之间共享。这就是为什么将所有特征图带到相同的通道深度是重要的。

FPN 设计的好处在于它独立于底层卷积主干。只要您可以从中提取中间特征图,来自第三章的任何卷积堆栈都可以使用,通常在各种尺度上为四到六个。您甚至可以使用预训练的主干。典型选择是 ResNet 或 EfficientNet,它们的预训练版本可以在TensorFlow Hub中找到。

在卷积堆栈中有多个层次,可以从中提取特征并输入到 FPN 中。对于每个期望的尺度,许多层输出相同尺寸的特征图(见前一章的图 3-26)。最佳选择是给定层块的最后一个特征图,在池化层再次减半分辨率之前输出的特征大小相似的层块。这个特征图可能包含最强的语义特征。

也可以通过额外的池化和卷积层扩展现有的预训练骨干网络,唯一目的是用于提供 FPN 的输入。这些额外的特征图通常很小,因此处理速度快。它们对应于最低的空间分辨率(参见图 4-8),因此可以改善大物体的检测。SSD 论文RetinaNet都使用了这个技巧,稍后您将在架构图中看到(参见图 4-15)。

锚点

在 YOLO 架构中,检测框是相对于一组基础框计算的增量(Δx = x - x[0],Δy = y - y[0],Δw = w - w[0],Δh = h - h[0],通常被称为“增量”相对于一些基础框 x[0], y[0], w[0], h[0],因为希腊字母Δ,通常用来表示“差异”)。在这种情况下,基础框是覆盖在图像上的一个简单网格(参见图 4-4)。

较新的架构通过明确定义一组所谓的“锚点框”,具有各种长宽比和尺度(例如图 4-11 中的示例)。预测再次是关于锚点位置和尺寸的小变化。目标是帮助神经网络预测接近零的小值而不是大值。事实上,神经网络能够解决复杂的非线性问题,因为它们在各层之间使用非线性激活函数。然而,大多数激活函数(sigmoid、ReLU)只在接近零时表现出非线性行为。这就是为什么神经网络在预测接近零的小值时表现最佳的原因,也是为什么相对于锚点框预测检测框作为小增量是有帮助的。当然,这仅在有足够多的各种尺寸和长宽比的锚点框时才有效,这些锚点框能够与任何物体检测框(通过最大 IOU)配对得非常接近其位置和尺寸。

图 4-11. 展示了用于预测检测框的各种大小和长宽比的锚框示例。图片来自节肢动物数据集

我们将详细描述 RetinaNet 架构中采用的方法,以此为例。RetinaNet 使用了九种不同类型的锚点:

  • 三种不同的长宽比:2:1, 1:1, 1:2

  • 三种不同尺寸:2⁰, 2^⅓, 2^⅔ (≃ 1, 1.3, 1.6)

它们在图 4-12 中展示。

图 4-12. RetinaNet 使用的九种不同锚点类型。三种长宽比和三种不同尺寸。

锚点连同由 FPN 计算的特征图,是 RetinaNet 中计算检测的输入。操作顺序如下:

  • FPN 将输入图像减少到五个特征图(参见 图 4-10)。

  • 每个特征图用于相对于图像中均匀分布的位置的锚点预测边界框。例如,大小为 4x6 且具有 256 通道的特征图将使用图像中的 24(4 * 6)个锚点位置(参见 图 4-13)。

  • 检测头使用多个卷积层将 256 通道特征图转换为确切的 9 * 4 = 36 个通道,从而每个位置产生 9 个检测框。每个检测框的四个数字表示相对于锚点的中心 (x, y)、宽度和高度的增量。计算从特征图到检测的层序列如 图 4-15 所示。

  • 最后,每个 FPN 的特征图由于对应于图像中的不同尺度,将使用不同尺度的锚框。

图 4-13. RetinaNet 检测头的概念视图。特征图中的每个空间位置对应于图像中相同点处的一系列锚点。为了清晰起见,图示仅显示了三个这样的锚点,但 RetinaNet 每个位置会有九个。

锚点本身在输入图像上均匀分布,并且针对特征金字塔的每个级别进行了适当的尺寸设置。例如,在 RetinaNet 中,使用以下参数:

  • 特征金字塔有五个级别,对应于骨干网中的 P[3]、P[4]、P[5]、P[6] 和 P[7] 尺度。尺度 P[n] 表示特征图比输入图像缩小了 2^n 倍(参见 图 4-15 的完整 RetinaNet 视图)。

  • 锚框基准尺寸分别为 32x32、64x64、128x128、256x256 和 512x512 像素,在每个特征金字塔级别上分别为 (= 4 * 2^n, 若 n 是尺度级别)。

  • 在特征金字塔的每个特征图中的每个空间位置都考虑了锚框,这意味着在每个特征金字塔级别上,锚框在输入图像上每 8、16、32、64 或 128 像素之间均匀分布(= 2^n, 若 n 是尺度级别)。

因此,最小的锚框为 32x32 像素,而最大的为 812x1,624 像素。

注意

锚框设置必须针对每个数据集进行调整,以便与训练数据中实际发现的检测框特征相对应。通常通过调整输入图像的大小而不是改变锚框生成参数来完成这一点。然而,在具有许多小检测或者相反大部分为大对象的特定数据集上,可能需要直接调整锚框生成参数。

最后一步是计算检测损失。为此,必须将预测的检测框与地面真实框配对,以便评估检测错误。

地面真实框分配给锚框基于计算出的每个输入图像中每组框之间的 IOU 度量。所有成对的 IOU 都计算并排列成一个N行和M列的矩阵,其中N是地面真实框的数量,M是锚框的数量。然后按列分析该矩阵(见图 4-14):

  • 如果一个锚框的列中最大 IOU 大于 0.5,则该锚框被分配给其列中具有最大 IOU 的地面真实框。

  • 一个锚框如果其列中没有大于 0.4 的 IOU 值,则被分配为不检测任何东西(即图像的背景)。

  • 此时任何未分配的锚框都标记为在训练期间忽略。这些是具有中间区域 IOU 在 0.4 和 0.5 之间的锚框。

现在每个地面真实框都与一个锚框精确配对,可以计算框预测、分类以及相应的损失。

图 4-14. 计算所有地面真实框和所有锚框之间的成对 IOU 度量,以确定它们的配对关系。没有与地面真实框有意义交集的锚框被称为“背景”,并且被训练以不检测任何东西。

架构

检测和分类头部将 FPN 中的特征图转换为类别预测和边界框变化量。特征图是三维的。它们的两个维度对应于图像的xy维度,称为空间维度;第三个维度是它们的通道数。

在 RetinaNet 中,对于每个特征图中的每个空间位置,预测以下参数(其中K为类别数量,B为锚框类型数量,因此在我们的情况下B=9):

  • 类别预测头部预测B * K个概率,每种锚框类型预测一组概率。实际上,这为每个锚框预测一个类别。

  • 检测头部预测B * 4 = 36 个框变量 Δx, Δy, Δw, Δh。边界框仍然通过它们的中心(x, y)以及宽度和高度(w, h)参数化。

虽然两个头部具有类似的设计,但权重不同,并且这些权重在特征金字塔的所有尺度中都是共享的。

图 4-15 展示了 RetinaNet 架构的完整视图。它使用 ResNet50(或其他)骨干网络。FPN 从骨干网络的 P[3]到 P[7,]级别提取特征,其中 P[n]是特征图相对于原始图像宽度和高度缩小了 2^n倍的级别。FPN 部分在图 4-10 中有详细描述。FPN 的每个特征图都通过分类和框回归头部。

图 4-15. RetinaNet 架构的完整视图。K 是目标类别的数量。B 是每个位置的锚框数量,在 RetinaNet 中为 9 个。

RetinaNet FPN 利用来自骨干网络的最后三个尺度级别。骨干网络通过步幅为 2 增加了 2 个额外的层次,以提供 FPN 的两个额外尺度级别。这种架构选择使 RetinaNet 避免处理非常大的特征图,这将是耗时的。增加最后两个粗糙尺度级别还改善了非常大物体的检测。

分类和框回归头部本身由一系列简单的 3x3 卷积组成。分类头部设计用于预测每个锚点的K个二进制分类,这也是为什么它以 sigmoid 激活结束。看起来我们允许多个标签预测每个锚点,但实际上的目标是允许分类头部输出全零,代表“背景类”,即没有检测到的情况。分类的更典型的激活函数可能是 softmax,但 softmax 函数不能输出全零。

框回归不使用激活函数结束。它计算锚框和检测框的中心坐标(xy)、宽度和高度之间的差异。在特征金字塔的所有级别上,必须小心地允许回归器在[-1, 1]范围内工作。以下公式用于实现这一点:

  • X[pixels] = X × U × W[A] + X[A]

  • Y[pixels] = Y × U × H[A] + Y[A]

  • W[pixels] = W[A] × e^(W × V)

  • H[pixels] = H[A] × e^(H × V)

在这些公式中,X[A]、Y[A]、W[A]和 H[A]是锚框的坐标(中心坐标、宽度、高度),而 X、Y、W 和 H 是相对于锚框的预测坐标(增量)。X[pixels]、Y[pixels]、W[pixels]和 H[pixels]是预测框的实际像素坐标(中心和大小)。U 和 V 是调制因子,对应于增量相对于锚框的预期方差。预测值在[-1, 1]范围内会导致预测框的位置在锚点位置的±10%范围内,大小在其±20%范围内。

焦点损失(用于分类)

对于一个输入图像考虑多少个锚框?回顾图 4-15 中的例子输入图像为 640x960 像素,特征金字塔中的五个不同特征图代表了输入图像中的 12,785 个位置。每个位置有 9 个锚框,总计略超过 100K 个锚框。

这意味着每个输入图像将生成 100K 个预测框。相比之下,典型应用中每个图像通常有 0 到 20 个地面实况框。这在检测模型中造成的问题是,与实际检测的损失相比,分配给背景框(分配为检测无内容的框)的损失可能会压倒总损失。即使背景检测已经训练良好并产生了很小的损失,这个小值乘以 100K 仍可能比实际检测的检测损失大几个数量级。最终的结果是无法训练的模型。

RetinaNet 论文提出了这个问题的一个优雅解决方案:作者们调整了损失函数,使空背景的损失值大幅降低。他们称之为焦点损失。以下是详细信息。

我们已经看到 RetinaNet 使用 sigmoid 激活来生成类别概率。输出是一系列二元分类,每个类别一个。每个类别的概率为 0 意味着“背景”;即,在这里没有什么可以检测的。使用的分类损失是二元交叉熵。对于每个类别,它根据实际的二元类别标签 y(0 或 1)和预测的类别概率 p 使用以下公式计算:

CE(y,p)=ylog(p)(1y)log(1p)

焦点损失(focal loss)是稍作修改后的同一公式:

FL(y,p)=y(1p)γlog(p)(1y)pγlog(1p)

对于 γ=0,这就是二元交叉熵,但对于更高的 γ 值,行为略有不同。为简化起见,让我们只考虑不属于任何类别的背景框(即对于所有类别 y=0 的情况):

FLbkg(p)=pγlog(1p)

让我们来绘制各种pγ值下的聚焦损失的数值(图 4-16)。

正如您在图中所见,当γ=2 时(发现这是一个合适的值),聚焦损失远小于常规的交叉熵损失,特别是对于p接近于 0 的情况。对于背景框,网络会迅速学习在所有类别上产生小的类别概率p。使用交叉熵损失,即使像p=0.1 这样明显分类为“背景”的框,仍会贡献相当数量的损失:CE(0.1) = 0.05。聚焦损失则小 100 倍:FL(0.1) = 0.0005。

使用聚焦损失函数,可以将所有锚框的损失相加,不必担心来自易于分类的背景框的成千上万个小损失会压倒总损失。

图 4-16. 各种γ值下的聚焦损失。当γ=0 时,即为交叉熵损失。对于γ较大的值,聚焦损失会大幅弱化易于分类的背景区域,即对于每个类别p接近于 0 的情况。

平滑 L1 损失(用于框回归)

检测框通过回归计算。对于回归,最常见的损失函数是 L1 和 L2,也称为绝对损失平方损失。它们的公式是(计算目标值a和预测值â之间的):

L1(a,a</mo></mrow></mover><mo>)</mo></mrow><mo>=</mo><mrow><mo>|</mo><mi>a</mi><mo>−</mo><mover><mrow><mi>a</mi></mrow><mrow><mo>|L2(a,a</mo></mrow></mover><mo>)</mo><mo>=</mo><mrow><mo>(</mo><mi>a</mi><mo>−</mo><mover><mrow><mi>a</mi></mrow><mrow><mo>)2

L1 损失的问题在于,其梯度处处相同,这对学习并不理想。因此,对于回归任务,更倾向于使用 L2 损失,但它也存在不同的问题。在 L2 损失中,预测值和目标值之间的差异被平方,这意味着随着预测值和目标值的偏离增大,损失会变得非常大。如果数据中存在异常值,比如一些错误的数据点(例如,目标框大小错误),这将带来问题。结果是网络会试图拟合这些错误的数据点,而忽视其他一切,这也是不理想的。

两者之间的一个良好折中方案是Huber 损失平滑 L1 损失(参见图 4-17)。它在小值处的行为类似于 L2 损失,在大值处的行为类似于 L1 损失。接近零时,其梯度特性良好,差异越大时梯度越大,因此它推动网络在犯最大错误的地方学习更多。对于大值,它变为线性而非二次,并避免被一些错误的目标值所扰乱。其公式如下:

Lδ(aa^)=12(aa</mo></mrow></mover><msup><mrow><mo>)</mo></mrow><mrow><mn>2</mn></mrow></msup></mrow></mrow></mtd><mtd><mtext>对于</mtext><mrow><mo>|</mo><mi>a</mi><mo>−</mo><mover><mrow><mi>a</mi></mrow><mrow><mo>|δLδ=δ(|aa^|12δ)否则

其中 δ 是可调参数。δ 是行为从二次转线性的临界点。还有另一个公式可以避免分段定义:

Lδ(aa^)=δ2(1+(aa^δ)21)

这种备选形式与标准的 Huber 损失并不完全相同,但行为相似:对于小值是二次的,对于大值是线性的。在实践中,RetinaNet 中任一形式都能很好地运作,δ=1。

图 4-17. 用于回归的 L1、L2 和 Huber 损失。对于小值,期望的行为是二次的,对于大值则是线性的。Huber 损失同时具有这两种特性。

非最大抑制

使用大量锚框的检测网络(如 RetinaNet),通常会为每个目标框生成多个候选检测结果。我们需要一种算法为每个检测到的对象选择一个单一的检测框。

非最大抑制(NMS)考虑框重叠(IOU)和类别置信度,选择给定对象的最具代表性的框(图 4-18)。

图 4-18. 左侧:同一对象的多个检测结果。右侧:经过非最大抑制后,剩余的单一框。图像来源于节肢动物数据集

该算法采用简单的“贪婪”方法:对于每一类别,它考虑所有预测框之间的重叠(IOU)。如果两个框的重叠大于给定值A(IOU > A),则保留具有最高类别置信度的框。在类似 Python 的伪代码中,对于一给定类别:

def NMS(boxes, class_confidence):
    result_boxes = []
    for b1 in boxes:
        discard = False
        for b2 in boxes:
            if IOU(b1, b2) > A:
                if class_confidence[b2] > class_confidence[b1]:
                    discard = True
        if not discard:
            result_boxes.append(b1)
    return result_boxes

在实践中,NMS 效果非常好,但可能会有一些意外的副作用。请注意,该算法依赖于单一阈值值(A)。更改此值会改变框过滤,尤其是对于原始图像中相邻或重叠的对象。看一看图 4-19 中的示例。如果将阈值设为A=0.4,则图中检测到的两个框将被视为“重叠”,属于同一类别,且类别置信度较低的那个(左侧的那个)将被丢弃。这显然是错误的。在此图像中有两只蝴蝶需要检测,在进行 NMS 之前,两者都以高置信度被检测到。

图 4-19. 彼此接近的对象对非最大抑制算法构成问题。如果 NMS 阈值为 0.4,左侧检测到的框将被丢弃,这是错误的。图片来自节肢动物数据集

将阈值设置得更高会有所帮助,但如果太高,算法将无法合并对应于同一对象的框。此阈值的通常值为A=0.5,但仍会导致彼此接近的对象被检测为一个。

基本 NMS 算法的轻微变体称为Soft-NMS。它不完全移除非最大重叠框,而是通过因子降低它们的置信度分数:

exp(IOU2σ)

σ是调整 Soft-NMS 算法强度的调整因子。典型值为σ=0.5. 该算法通过考虑给定类别的置信度得分最高的框(max box),并将所有其他框的分数减少该因子来应用。然后将最大框放在一边,并在剩余的框上重复操作,直到没有框剩余。

对于不重叠的框(IOU=0),此因子为 1. 不重叠最大框的置信度因子因此不受影响。随着框与最大框的重叠程度增加,这个因子会逐渐但连续地减小。高度重叠的框(IOU=0.9)其置信度因子会大幅度减少(×0.2),这是预期的行为,因为它们与最大框重叠,我们希望摆脱它们。

由于 Soft-NMS 算法不丢弃任何框,因此基于类别置信度使用第二个阈值来实际修剪检测列表。

Soft-NMS 对来自图 4-19 的例子的影响显示在图 4-20 中。

图 4-20. Soft-NMS 处理的彼此接近的对象。左侧的检测框未被删除,但其置信度因子从 78%降低到 55%。图片来自节肢动物数据集
注意

在 TensorFlow 中,有两种非最大抑制的风格可用。标准 NMS 称为tf.image.non_max_suppression,而 Soft-NMS 称为tf.image.non_max_suppression_with_scores

其他考虑事项

为了减少所需的数据量,通常使用预训练的主干网络。

分类数据集比目标检测数据集更容易组合。这就是为什么现成的分类数据集通常比目标检测数据集大得多。使用来自分类器的预训练骨干允许您将通用的大型分类数据集与特定任务的目标检测数据集结合起来,并获得更好的目标检测器。

预训练是在分类任务上完成的。然后,移除分类头,并添加随机初始化的 FPN 和检测头。实际的目标检测训练是通过训练所有权重来完成的,这意味着骨干将进行微调,而 FPN 和检测头则从头开始训练。

由于检测数据集往往较小,数据增强(我们将在第六章中详细介绍)在训练中起着重要作用。基本的数据增强技术是从训练图像中随机裁剪固定大小的区域,并使用随机的缩放因子(见图 4-21)。通过适当调整目标边界框,这使您可以训练网络,使同一对象在图像中的不同位置、不同尺度和不同背景部分可见。

图 4-21. 目标检测训练的数据增强。从每个训练图像中随机裁剪固定大小的图像,可能具有不同的缩放因子。目标框坐标相对于新边界重新计算。这提供了更多的训练图像和来自相同初始训练数据的更多对象位置。图像来自节肢动物数据集

这种技术的一个实际优势是它还为神经网络提供了固定大小的训练图像。您可以直接在由不同大小和长宽比图像组成的训练数据集上进行训练。数据增强会处理将所有图像调整到相同大小的工作。

最终,推动训练和超参数调整的是指标。目标检测问题已经成为多个大规模竞赛的主题,其中检测指标已经得到了精心标准化;这个主题在“目标检测的指标”中有详细介绍,位于第八章。

现在我们已经看过目标检测,让我们转向另一类问题:图像分割。

分割

目标检测找到物体周围的边界框并对其进行分类。实例分割 对于每个检测到的对象,添加一个像素掩码,显示对象的形状。语义分割 则不检测特定的对象实例,而是将图像的每个像素分类为“道路”、“天空”或“人”等类别。

Mask R-CNN 和实例分割

YOLO 和 RetinaNet 是我们在前一节中介绍的单次检测器的示例。一张图像只需经过它们一次即可生成检测结果。另一种方法是使用第一个神经网络提出对象可能的位置进行检测,然后使用第二个网络对这些提议的位置进行分类和微调。这些架构被称为区域提议网络(RPNs)。

它们往往比单次检测器更复杂,因此速度较慢,但也更准确。基于原始“带有 CNN 特征的区域”理念,有一长串的 RPN 变种:R-CNN, Fast R-CNN, Faster R-CNN,等等。截至撰写本文时,最先进的是Mask R-CNN,这也是我们接下来要深入探讨的架构。

了解 Mask R-CNN 等架构的重要性主要不在于它们略微优越的准确性,而在于它们可以扩展到执行实例分割任务。除了预测围绕检测到的对象的边界框外,它们还可以训练以预测它们的轮廓——即找到每个检测到的对象所属的每个像素(图 4-22)。当然,训练它们仍然是一个监督训练任务,训练数据将必须包含所有对象的地面真实分割掩码。不幸的是,与简单的目标检测数据集相比,生成掩码需要更多时间,因此实例分割数据集更难找到。

图 4-22. 实例分割涉及检测物体并找到属于每个物体的所有像素。图像中的物体被着色以形成像素掩码。图片来自节肢动物数据集

让我们详细看看 RPNs,首先分析它们如何执行经典目标检测,然后如何扩展它们进行实例分割。

区域提议网络

RPN 是一个简化的单次检测网络,只关心两类:对象和背景。在数据集中标记为“对象”的是任何被标记为该类的东西(任何类),而“背景”是不包含对象的框的指定类别。

一个 RPN 可以使用类似于我们之前查看的 RetinaNet 设置的架构:一个卷积主干网络,一个特征金字塔网络,一组锚定框和两个头部。一个头部用于预测框,另一个用于对其进行分类,作为对象或背景(我们尚未预测分割掩码)。

RPN 有其自己的损失函数,从稍微修改的训练数据集中计算:任何地面真实对象的类别都被替换为单一类别“对象”。与 RetinaNet 类似,用于盒子的损失函数是 Huber 损失。对于类别,由于这是二元分类,二元交叉熵是最佳选择。

RPN 预测的框然后经过非极大值抑制。按照它们被视为框提议或感兴趣区域(ROIs)的“对象”概率排序的前 N 个框。N 通常约为一千,但如果快速推断很重要,则可以少至 50。ROIs 也可以通过最小“对象”分数或最小尺寸进行过滤。在 TensorFlow 模型园的实现中,即使默认设置为零,这些阈值也是可用的。坏的 ROIs 仍然可以被分类为“背景”并在下一阶段被拒绝,所以在 RPN 级别放行它们不是一个大问题。

一个重要的实际考虑是,如果需要的话,RPN 可以简单快速(参见图 4-23 的示例)。它可以直接使用主干网络的输出,而不是使用 FPN,并且其分类和检测头可以使用较少的卷积层。其目标仅是计算可能对象周围的近似 ROIs。它们将在下一步中进行细化和分类。

图 4-23. 简单的区域提议网络。从卷积主干网络输出的数据通过一个两类分类头(对象或背景)和一个盒子回归头进行处理。B 是每个位置的锚框数量(通常是三个)。也可以使用 FPN。

例如,在 TensorFlow 模型园的 Mask R-CNN 实现中,在其 RPN 中使用了 FPN,但每个位置只使用了三个锚点,纵横比分别为 0.5、1.0 和 2.0,而不是 RetinaNet 使用的每个位置九个锚点。

R-CNN

现在我们有了一组提议的感兴趣区域。接下来呢?

在概念上,R-CNN 的想法(图 4-24)是沿着 ROIs 裁剪图像并再次通过主干网路运行裁剪后的图像,这次附带一个完整的分类头来对对象进行分类(在我们的例子中,可以是“蝴蝶”,“蜘蛛”等)。

图 4-24. R-CNN 的概念视图。图像通过主干网路(backbone)两次传递:第一次生成感兴趣区域(ROIs),第二次对这些 ROIs 中的内容进行分类。图片来自 节肢动物数据集

在实践中,这种方法太慢了。RPN 可以生成大约 50 到 2,000 个建议的 ROIs,如果再次全部通过骨干网络运行将是一项繁重的工作。与其裁剪图像,更明智的做法是直接裁剪特征图,然后在结果上运行预测头部,如图 4-25 所示。

图 4-25. 更快的 R-CNN 或 Mask R-CNN 设计。如前所述,骨干网络生成一个特征图,RPN 从中预测感兴趣的区域(仅显示结果)。然后将 ROIs 映射回特征图,并从中提取特征发送到预测头部进行分类等。图片来源于 Arthropods 数据集

当使用 FPN 时,这会稍微复杂些。特征提取仍然在给定的特征图上执行,但在 FPN 中有几个特征图可供选择。因此,ROI 必须首先分配给最相关的 FPN 级别。分配通常使用以下公式进行:

n=floor(n0+log2(wh/224))

这里的 wh 分别是 ROI 的宽度和高度,n[0] 是 FPN 级别,典型的锚框大小最接近于 224. 这里,floor 表示向最负数方向取整。例如,以下是典型的 Mask R-CNN 设置:

  • 五个 FPN 级别,P[2]、P[3]、P[4]、P[5] 和 P[6](提醒:级别 P[n] 表示比输入图像宽度和高度小 2^n 倍的特征图)

  • 在它们各自的级别上的锚框大小为 32x32、64x64、128x128、256x256 和 512x512(与 RetinaNet 中相同)

  • n[0] = 4

通过这些设置,我们可以验证,例如 80x160 像素的 ROI 将被分配到 P[3] 级别,而 200x300 的 ROI 将被分配到 P[4] 级别,这是合理的。

ROI 重采样(ROI alignment)

在提取对应于 ROIs 的特征图时需要特别小心。必须正确地提取和重采样特征图。Mask R-CNN 论文的作者们发现,在此过程中的任何舍入误差都会对检测性能产生不利影响。他们称其精确的重采样方法为 ROI alignment

例如,假设一个 200x300 像素的 ROI。它将被分配到 FPN 级别 P[4],其相对于 P[4] 特征图的大小为 (200 / 2⁴, 300 / 2⁴) = (12.5, 18.75)。这些坐标不应该被舍入。其位置也是如此。

P[4] 特征图中的这个 12.5x18.75 区域包含的特征然后必须被采样和聚合(使用最大池化或平均池化)成一个新的特征图,通常大小为 7x7。这是一个众所周知的数学操作,称为双线性插值,我们在这里不详细讨论它。重要的是要记住,在这里马虎会降低性能。

类别和边界框预测

模型的其余部分非常标准。提取的特征通过多个预测头并行处理——在这种情况下:

  • 一个分类头,为由 RPN 提出的每个对象分配一个类别,或将其分类为背景

  • 进一步调整边界框的框修正头

为了计算检测和分类损失,使用与 RetinaNet 中描述的相同目标框分配算法。框损失也相同(Huber 损失)。分类头使用 softmax 激活,并添加一个特殊的“背景”类。在 RetinaNet 中,这是一系列二元分类。两者都有效,这个实现细节并不重要。总训练损失是最终框和分类损失的总和,以及 RPN 的框和分类损失。

类别和检测头的确切设计稍后给出,在图 4-30 中。它们与 RetinaNet 中使用的非常相似:一系列直接的层,在 FPN 的所有级别之间共享。

Mask R-CNN 添加了第三个预测头部,用于对对象的每个像素进行分类。其结果是一个像素掩模,描绘了对象的轮廓(见图 4-19)。如果训练数据集包含相应的目标掩模,则可以使用它。然而,在我们解释它的工作原理之前,我们需要介绍一种新的卷积类型,它能够创建图像而不是仅仅过滤和提炼它们:转置卷积。

转置卷积

转置卷积,有时也称为反卷积,执行可学习的上采样操作。常规的上采样算法如最近邻上采样或双线性插值是固定操作。而转置卷积则涉及可学习的权重。

注意

名称“转置卷积”来源于卷积层的矩阵表示中的事实,我们在本书中未涵盖,即使用与普通卷积相同的卷积矩阵进行转置卷积。

图 4-26 中描绘的转置卷积具有单个输入和单个输出通道。理解它的最佳方法是想象它在输出画布上用刷子绘画。刷子是一个 3x3 的滤波器。输入图像的每个值都通过滤波器投射到输出上。数学上,3x3 滤波器的每个元素都与输入值相乘,结果加到输出画布上已有的内容上。然后在下一个位置重复操作:在输入中移动 1,输出中使用可配置步长(本例中为 2)。大于 1 的步长会导致上采样操作。最常见的设置是步长为 2,使用 2x2 滤波器,或步长为 3,使用 3x3 滤波器。

如果输入是具有多个通道的特征图,则对每个通道独立应用相同的操作,每次使用一个新的滤波器;然后将所有输出元素逐元素相加,生成单个输出通道。

当然,可以在同一特征图上多次重复此操作,每次使用新的滤波器集,从而生成具有多个通道的特征图。

对于多通道输入和多通道输出,转置卷积的权重矩阵形状如图 4-27 所示。顺便说一句,这与常规卷积层的形状相同。

图 4-26. 转置卷积。原始图像的每个像素(顶部)都乘以一个 3x3 的滤波器,并将结果加到输出上。在步长为 2 的转置卷积中,输出窗口对每个输入像素移动 2 步,从而创建一个更大的图像(移动的输出窗口用虚线轮廓描绘)。

图 4-27. 转置卷积层的权重矩阵,有时也称为“反卷积”。底部是本章模型将使用的反卷积层的示意符号。

实例分割

让我们回到 Mask R-CNN 及其第三个预测头,用于对对象的各个像素进行分类。输出是描绘对象轮廓的像素掩码(参见图 4-22)。

Mask R-CNN 和其他 RPN 一次处理一个 ROI,相当有可能该 ROI 确实有趣,因此可以对每个 ROI 执行更多工作并提高精度。实例分割就是这样一个任务。

实例分割头部使用转置卷积层将特征图上采样为一个训练成与检测到的对象轮廓相匹配的黑白图像。

图 4-30 展示了完整的 Mask R-CNN 架构。

图 4-30. Mask R-CNN 架构。N 是由 RPN 提出的 ROIs 数量,K 是类别数;“deconv” 表示转置卷积层,用于上采样特征图以预测目标掩膜。

注意,掩膜头为每个类别生成一个掩膜。这似乎有些冗余,因为还有一个单独的分类头。为什么为一个对象预测 K 个掩膜?事实上,这种设计选择增加了分割精度,因为它允许分割头部学习有关对象的类别特定提示。

另一个实施细节是特征图对 ROIs 的重新采样和对齐实际上执行了两次:一次是用于分类和检测头部的 7x7x256 输出,再次是使用不同设置(重新采样为 14x14x256),专门用于掩膜头以提供更多详细信息。

分割损失是简单的逐像素二元交叉熵损失,应用于预测的掩膜在重新缩放和上采样到与地面实况掩膜相同坐标后。请注意,在损失计算中仅考虑预测类别的预测掩膜。计算其他错误类别的掩膜将被忽略。

现在我们完全理解了 Mask R-CNN 的工作原理。需要注意的一点是,尽管 R-CNN 家族的检测器都进行了改进,但 Mask R-CNN 现在在名义上只是一个“双通道”检测器。输入图像实际上只通过系统一次。该架构仍然比 RetinaNet 慢,但实现了稍微更高的检测精度,并增加了实例分割。

还存在一种扩展自 RetinaNet 的模型,称为 RetinaMask,但其性能不如 Mask R-CNN。有趣的是,论文指出,添加掩膜头和相关损失实际上提高了边界框检测的准确性(另一个头部)。类似的效果可能也解释了 Mask R-CNN 的一些改进精度。

Mask R-CNN 方法的一个局限性是预测的对象掩膜分辨率相对较低:28x28 像素。类似但不完全等效的语义分割问题已经通过高分辨率方法解决。我们将在下一节中探讨这个问题。

U-Net 和语义分割

在语义分割中,目标是将图像中的每个像素分类到全局类别,如“道路”、“天空”、“植被”或“人”(见 图 4-31)。对象的个别实例,如个别人,未分隔。整个图像中所有“人”像素都属于同一“段”。

图 4-31. 在语义图像分割中,图像中的每个像素都被分配一个类别(如“道路”、“天空”、“植被”或“建筑”)。请注意,例如,“人”是整个图像中的一个单一类别。来自 Cityscapes 的图像。

对于语义图像分割,一个简单且通常足够的方法称为 U-Net。U-Net 是一个卷积网络架构,专为生物医学图像分割设计(见 图 4-32),并在 2015 年的细胞跟踪竞赛中获胜。

图 4-32. U-Net 架构旨在分割生物医学图像,例如这些显微镜细胞图像。来自 Ronneberger et al., 2015 的图像。

U-Net 架构在 图 4-33 中表示。U-Net 包括一个编码器,将图像降采样为编码(架构的左侧),以及一个镜像的解码器,将编码上采样回所需的掩模(架构的右侧)。解码器块具有多个跳过连接(在中心显示的水平箭头),直接连接从编码器块复制特征。这些跳过连接将特定分辨率的特征从编码器直接传递到解码器,并按通道将它们串联在一起。这样做可以将编码器各级别的语义信息直接引入解码器。 (注意:由于编码器和解码器相应级别的特征图大小可能略有不对齐,因此可能需要在跳过连接上进行裁剪。实际上,U-Net 使用所有卷积操作时没有填充,这意味着每一层的边界像素都会丢失。不过,这种设计选择并非必须,也可以使用填充。)

图 4-33. U-Net 架构由镜像编码器和解码器块组成,当如此展示时呈现出 U 形。跳过连接沿深度轴(通道)串联特征图。K 是目标类别数量。

图像和标签

为了说明 U-Net 图像分割,我们将使用 Oxford 宠物数据集,其中每个输入图像都包含标签掩模,如 图 4-34 所示。该标签是一个图像,其中像素根据它们是背景、对象轮廓还是对象内部而被分配为三个整数值之一。

图 4-34. Oxford 宠物数据集的训练图像(顶部行)和标签(底部行)。

我们将这三个像素值视为类标签的索引,并训练网络进行多类分类:

model = ...
model.compile(optimizer='adam',
    loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
    metrics=['accuracy'])
model.fit(...)

完整的代码可以在 GitHub 上的04b_unet_segmentation.ipynb中找到。

架构

从头开始训练 U-Net 架构需要大量可训练参数。如“其他考虑”中讨论的那样,对于对象检测和分割等任务,标记数据集很困难。因此,为了有效使用标记数据,最好使用预训练的主干网络,并为编码器块使用迁移学习。正如第三章中所述,我们可以使用预训练的 MobileNetV2 来创建编码器:

 base_model = tf.keras.applications.MobileNetV2(
    input_shape=[128, 128, 3], include_top=False)

解码器侧将包括上采样层,以恢复到所需的掩模形状。解码器还需要来自编码器特定层的特征图(跳跃连接)。我们需要的 MobileNetV2 模型的层可以按名称获取如下:

layer_names = [
    'block_1_expand_relu',   # 64x64
    'block_3_expand_relu',   # 32x32
    'block_6_expand_relu',   # 16x16
    'block_13_expand_relu',  # 8x8
    'block_16_project',      # 4x4
]
base_model_outputs = [base_model.get_layer(name).output for name in layer_names]

U-Net 架构的“下堆栈”或左侧包括图像作为输入,以及这些层作为输出。我们正在进行迁移学习,因此整个左侧不需要调整权重:

down_stack = tf.keras.Model(inputs=base_model.input,
                            outputs=base_model_outputs,
                            name='pretrained_mobilenet')
down_stack.trainable = False

在 Keras 中,可以使用Conv2DTranspose层来实现上采样。我们还在每个上采样步骤中添加批量归一化和非线性:

def upsample(filters, size, name):
  return tf.keras.Sequential([
     tf.keras.layers.Conv2DTranspose(filters, size,
                                     strides=2, padding='same'),
     tf.keras.layers.BatchNormalization(),
     tf.keras.layers.ReLU()
  ], name=name)

up_stack = [
    upsample(512, 3, 'upsample_4x4_to_8x8'),
    upsample(256, 3, 'upsample_8x8_to_16x16'),
    upsample(128, 3, 'upsample_16x16_to_32x32'),
    upsample(64, 3,  'upsample_32x32_to_64x64')
]

解码器的每个上堆栈阶段都与编码器的相应层连接起来:

for up, skip in zip(up_stack, skips):
    x = up(x)
    concat = tf.keras.layers.Concatenate()
    x = concat([x, skip])

训练

我们可以使用 Keras 回调在几个选定的图像上显示预测:

class DisplayCallback(tf.keras.callbacks.Callback):
  def on_epoch_end(self, epoch, logs=None):
      show_predictions(train_dataset, 1)

model.fit(train_dataset, ...,
          callbacks=[DisplayCallback()])

这样做在牛津宠物数据集上的结果显示在图 4-35 中。请注意,模型从垃圾(顶部行)开始,正如人们所预期的那样,但然后学会了哪些像素对应于动物,哪些像素对应于背景。

图 4-35. 在模型训练时,输入图像上的预测掩模逐步改善。

然而,由于模型训练为独立预测每个像素为背景、轮廓或内部,我们看到诸如未闭合区域和不连通像素等伪影。模型没有意识到与猫对应的区域应该是封闭的。这就是为什么这种方法主要用于不需要连续性的图像上,例如图 4-31 中的示例,其中“道路”、“天空”和“植被”段经常有不连续性。

应用示例包括自动驾驶算法,用于检测道路。另一个是卫星图像,其中使用 U-Net 架构解决了区分云与雪的难题;两者都是白色的,但雪覆盖是有用的地面信息,而云的遮挡意味着需要重新拍摄图像。

概要

在本章中,我们探讨了目标检测和图像分割方法。我们从 YOLO 开始,考虑了其局限性,然后讨论了 RetinaNet,它在架构和使用的损失方面对 YOLO 进行了创新。我们还讨论了 Mask R-CNN 用于实例分割和 U-Net 用于语义分割。

在接下来的章节中,我们将更深入地研究计算机视觉流程的不同部分,使用简单的迁移学习图像分类架构作为我们的核心模型,该架构源自第三章。无论是骨干架构还是解决的问题,流程步骤都保持不变。

第五章:创建视觉数据集

要对图像进行机器学习,我们需要图像。在我们在第四章中查看的使用案例中,绝大多数是用于监督式机器学习。对于这样的模型,我们还需要正确的答案,或者标签,以训练 ML 模型。如果您打算训练无监督 ML 模型或类似 GAN 或自编码器的自监督模型,则可以省略标签。在本章中,我们将探讨如何创建由图像和标签组成的机器学习数据集。

提示

本章的代码位于书籍的 05_create_dataset 文件夹中的GitHub 存储库。我们将在适当的情况下提供代码示例和笔记本的文件名。

收集图像

在大多数 ML 项目中,第一阶段是收集数据。数据收集可以通过多种方式进行:在交通路口安装摄像机、连接到数字目录以获取汽车零部件的照片、购买卫星图像档案等。它可以是后勤活动(安装交通摄像机)、技术活动(构建与目录数据库的软件连接器)或商业活动(购买图像档案)。

照片

照片是图像数据的最常见来源之一。这些可以包括来自社交媒体和其他来源的照片,以及由永久安装的摄像机在受控条件下拍摄的照片。

在收集图像时我们需要做出的第一个选择是摄像机的放置位置以及图像的大小和分辨率。显然,图像必须框住我们感兴趣的内容——例如,安装在交通路口拍摄的摄像机需要能够无障碍地看到整个路口。

直觉上,似乎通过在最高分辨率图像上训练可以得到最高准确率的模型,因此我们应该尽力以最高分辨率收集数据。然而,高分辨率图像也伴随着一些缺点:

  • 更大的图像将需要更大的模型——卷积模型每一层中的权重数量与输入图像的尺寸成比例。因此,对 256x256 图像训练模型所需的参数数量是对 128x128 图像的四倍,因此训练时间更长,需要更多的计算能力和额外的内存。

  • 我们训练 ML 模型的机器具有有限的内存(RAM),因此图像尺寸越大,批处理中可以包含的图像数量就越少。通常情况下,更大的批处理大小会导致更平滑的训练曲线。因此,大图像在准确性方面可能适得其反。

  • 更高分辨率的图像,特别是在室外和低光环境下拍摄的图像,可能会有更多噪音。将图像平滑处理到较低分辨率可能会导致更快的训练和更高的准确性。

  • 收集和保存高分辨率图像所需的时间比收集和保存低分辨率图像所需的时间长。因此,为了捕捉高速动作,可能需要使用较低分辨率的图像。

  • 更高分辨率的图像传输时间更长。因此,如果您在边缘收集图像并将其发送到云端进行推断,可以通过使用更小、低分辨率的图像来加快推断速度。

因此建议使用最高分辨率,该分辨率由图像的噪声特性和机器学习基础设施预算决定。不要降低分辨率,以至于无法解析感兴趣的对象。

通常情况下,值得使用预算允许的最高质量相机(例如镜头、灵敏度等),这样可以简化许多计算机视觉问题,例如预测时使用的图像始终保持清晰,白平衡保持一致,图像噪声的影响最小。一些问题可以通过图像预处理来解决(图像预处理技术将在第六章中介绍),但最好的方法是拥有没有这些问题的图像,而不是在数据采集后进行修正。

相机通常可以以压缩(例如 JPEG)或未压缩(例如 RAW)格式保存照片。在保存 JPEG 照片时,通常可以选择质量。较低质量和较低分辨率的 JPEG 文件压缩效果更好,因此存储成本更低。如前所述,低分辨率图像还将减少计算成本。由于存储费用相对于计算来说较低,我们建议选择高质量的 JPEG(95%以上)并以较低分辨率存储它们。

注意

您可以使用的最低分辨率取决于问题的性质。如果您试图分类景观照片以确定其是水域还是陆地,则可能可以使用 12x16 像素的图像。如果您的目标是识别这些景观照片中的树木类型,则可能需要像素足够小,能够清晰地捕捉叶子的形状,因此可能需要 768x1024 像素的图像。

仅在您的图像由人类生成的内容(例如 CAD 图纸,其中 JPEG 压缩的模糊边缘可能导致识别问题)时才使用未压缩图像。

图像

许多仪器(X 射线、MRI、光谱仪、雷达、激光雷达等)创建空间的 2D 或 3D 图像。X 射线是三维物体的投影,可以视为灰度图像(见图 5-1)。而典型的照片包含三个通道(红、绿、蓝),这些图像只有一个通道。

图 5-1. 胸部 X 射线可以视为灰度图像。图像由Google AI Blog提供。

如果仪器测量多个量,则可以将不同波长的反射率、多普勒速度和其他测量量视为图像的独立通道。在层析成像中,投影是薄的三维切片,因此创建多个横截面图像;这些横截面可以视为单一图像的通道。

根据传感器几何特性,影像数据存在一些特殊的考虑因素。

极坐标网格

雷达和超声波在极坐标系统中执行(参见图 5-2)。您可以将极坐标 2D 数据本身作为输入图像,或在将其用作机器学习模型输入之前将其转换为笛卡尔坐标系统。任何方法都有权衡:在极坐标系统中,没有插值或重复像素,但是像素大小在整个图像中变化,而在笛卡尔坐标系统中,像素大小一致,但在重新映射时,很多数据可能会丢失、插值或聚合。例如,在图 5-2 中,左下角的许多像素将丢失,并且必须为 ML 目的分配一些数值。与此同时,右下角的像素将涉及从像素网格中聚合许多值,而顶部的像素将涉及在像素值之间进行插值。笛卡尔图像中的这三种情况的存在极大地复杂化了学习任务。

图 5-2。直接使用极坐标网格与将数据重新映射到笛卡尔网格的比较。

我们建议使用极坐标网格作为 ML 模型的输入图像,并将每个像素到中心的距离(或像素的大小)作为 ML 模型的额外输入。因为每个像素的大小不同,将像素大小视为额外通道的最简单方法。这样,我们可以利用所有图像数据,而不通过坐标变换丢失信息。

卫星频道

在处理卫星图像时,与其将图像重新映射到地球坐标,不如在原始卫星视图或经过视差校正的网格上工作。如果使用投影地图数据,请尝试在数据的原始投影中进行机器学习。处理在大致相同时间但不同波长下拍摄的相同位置图像时,请将其视为通道(参见图 5-3)。请注意,预训练模型通常是在三通道图像(RGB)上进行训练的,因此迁移学习和微调将无法使用,但如果从头开始训练,底层架构可以处理任意数量的通道。

图 5-3。在 2020 年 12 月 21 日大约同一时间由 GOES-16 天气卫星上的仪器收集的图像。将这些彩色图像的原始标量值视为输入到模型的六通道图像。图像由美国国家气象局提供。

地理空间图层

如果您有多个地图图层(例如土地所有权、地形、人口密度;参见图 5-4),这些图层是在不同投影下收集的,则必须将它们重新映射到相同的投影中,对齐像素,并将这些不同的图层视为图像的通道。在这种情况下,将像素的纬度作为模型的附加输入通道可能是有用的,以便可以考虑像素大小的变化。

分类层(如土地覆盖类型)可能需要进行独热编码,以便土地覆盖类型成为五个通道,如果有五种可能的土地覆盖类型。

图 5-4。地理空间图层可以视为图像通道。图像由USGS提供。

概念验证

在许多情况下,您可能没有现成的数据,并且收集概念验证所需的数据会花费太长时间。您可以考虑购买类似的数据来了解项目的可行性,然后再投资于例行数据收集。在购买图像时,请记住要获取与最终可以在实际项目中使用的图像类似质量、分辨率等的图像。

例如,许多用于美国 GOES-16 卫星的机器学习算法必须在卫星发射之前开发。自然而然地,当时还没有可用的数据!为了确定将在 GOES-16 数据上构建的机器学习模型列表,使用了欧洲 SEVIRI 卫星已经收集的类似质量的数据来进行概念验证测试。

进行概念验证的另一种方法是模拟图像。我们将在第十一章中看到一个例子,该例子通过模拟图像展示了数西红柿的能力。在模拟图像时,修改现有图像可能比从头开始创建更有帮助。例如,如果照片中已有绿色藤蔓,然后添加不同大小的红番茄,那么模拟的西红柿藤图像可能更容易生成。

注意

不要在完美数据上训练模型,然后尝试将其应用于不完美的图像。例如,如果您需要一个模型能够从远足者在小径上拍摄的照片中识别花朵,那么不应该在由专业摄影师拍摄并经过修饰的照片上训练模型。

数据类型

到目前为止,我们只处理了照片。如前一节讨论的那样,还有其他类型的图像,如地理空间图层、MRI 扫描或声音的频谱图,可以应用机器学习。从数学上讲,所有这些 ML 技术都需要一个 4D 张量(批次 x 高度 x 宽度 x 通道)作为输入。只要我们的数据可以放入这种形式,就可以应用计算机视觉方法。

当然,你必须牢记那些使某些技术成功的基本概念。例如,你可能无法成功地将卷积滤波器应用于寻找缺陷像素的问题上,因为卷积滤波器仅在相邻像素之间存在空间相关性时才有效。

通道

典型的照片以 24 位 RGB 图像存储,具有三个通道(红、绿和蓝),每个通道由 0–255 范围内的 8 位数表示。一些计算机生成的图像还有第四个α通道,用于捕获像素的透明度。α通道主要用于叠加或合成图像。

缩放

机器学习框架和预训练模型通常期望像素值从[0,255]缩放到[0,1]。ML 模型通常忽略α通道。在 TensorFlow 中,可以通过以下方式实现:

# Read compressed data from file into a string.
img = tf.io.read_file(filename)
# Convert the compressed string to a 3D uint8 tensor.
img = tf.image.decode_jpeg(img, channels=3)
# Convert to floats in the [0,1] range.
img = tf.image.convert_image_dtype(img, tf.float32)

通道顺序

典型图像输入的形状是[高度,宽度,通道],其中通道数通常是 RGB 图像的 3 个和灰度图像的 1 个。这称为channels-last表示,并且是 TensorFlow 的默认设置。早期的 ML 包如 Theano 和 Google 的 Tensor Processing Unit (TPU) v1.0 使用channels-first排序。从计算效率的角度来看,channels-first 顺序更高效,因为它减少了内存中的来回查找。² 然而,大多数图像格式将数据按像素存储,所以 channels-last 是更自然的数据摄入和输出格式。从 channels-first 到 channels-last 的转变是在计算硬件变得更加强大的背景下,将易用性置于效率之上的一个例子。

因为通道顺序可能有所不同,Keras 允许你在全局$HOME/.keras/keras.json配置文件中指定顺序:

{
    "image_data_format": "channels_last",
    "backend": "tensorflow",
    "epsilon": 1e-07,
    "floatx": "float32"
}

默认情况下,使用 TensorFlow 作为 Keras 后端,因此图像格式默认为channels_last。这是本书中我们将要做的。因为这是一个全局设置,会影响系统上运行的每个模型,我们强烈建议你不要调整这个文件。

如果你有一个通道优先的图像,并且需要将其改为通道最后的格式,可以使用tf.einsum()

image = tf.einsum('chw->hwc', channels_first_image)

或者简单地转置,提供适当的轴:

image = tf.transpose(channels_first_image, perm=(1, 2, 0))

灰度

如果你有一张灰度图像,或者一个简单的 2D 数字数组,你可能需要扩展维度,将形状从[高度,宽度]改变为[高度,宽度,1]:

image = tf.expand_dims(arr2d, axis=-1)

通过指定axis=-1,我们要求将通道维度附加到现有形状,并将新的通道维度设置为 1。

地理空间数据

地理空间数据可以通过地图图层生成,也可以通过从无人机、卫星、雷达等进行遥感获得。

光栅数据

从地图中产生的地理空间数据通常具有可视为通道的光栅波段(像素值的二维数组)。例如,您可能有几个覆盖土地区域的光栅波段:人口密度、土地覆盖类型、易受洪水影响程度等。为了将计算机视觉技术应用于这种光栅数据,只需读取各个波段并将它们堆叠在一起形成图像:

image = tf.stack([read_into_2darray(b) for b in raster_bands], axis=-1)

除了光栅数据之外,您可能还有矢量数据,例如道路、河流、州或城市的位置。在这种情况下,您必须在将其用于基于图像的机器学习模型之前将数据栅格化。例如,您可以将道路或河流绘制为一组一像素宽的线段(参见图 5-5 的顶部面板)。如果矢量数据由多边形组成,例如州界限,您将通过填充落入边界内的像素来栅格化数据。如果有 15 个州,则最终将得到 15 个光栅图像,每个图像中包含 1 表示对应州界限内的像素—这相当于图像中的一位有效编码类别值(参见图 5-5 的底部面板)。如果矢量数据由城市边界组成,您需要决定是否将其视为布尔值(如果是农村,则像素值为 0,城市则为 1)或分类变量(在这种情况下,您将为数据集中的N个城市生成N个光栅波段)。

图 5-5. 矢量数据栅格化。在栅格化图像中,1 表示的区域已经高亮显示。地图来源:OpenStreetMap(顶部)和维基百科(底部)。

光栅数据通常采用地理投影。某些投影(如兰伯特等角投影)保留面积,其他投影(如墨卡托投影)保留方向,还有一些投影(如等距圆柱投影)由于简便而被选择。根据我们的经验,任何投影对机器学习都很有效,但您应确保所有光栅波段采用相同的投影。如果像素的大小随纬度变化,将纬度添加为额外输入通道也可能有助于。

遥感

遥感数据由成像仪器收集。如果涉及的仪器是相机(例如大多数无人机图像),则结果将是一个具有三个通道的图像。另一方面,如果卫星上有多个仪器捕捉图像,或者仪器可以在多个频率上运行,结果将是一个具有大量通道的图像。

提示

远程传感器图像通常会进行彩色处理以便于可视化。最好回到获取仪器传感的原始数字,而不是使用这些彩色图像。

确保像处理照片那样读取并标准化图像。例如,将每个图像中找到的值缩放从 0 到 1。有时数据会包含异常值。例如,由于海洋波浪和潮汐,测深图像可能具有异常值。在这种情况下,可能需要在缩放之前将数据剪切到合理范围内。

遥感图像通常会包含缺失数据(例如卫星视野外的图像部分或雷达图像中的杂波区域)。如果可能的话,可以裁剪掉缺失的区域。如果缺失区域很小,则通过插值来填补缺失值。如果缺失值包含大面积区域或占据了大部分像素,则创建一个单独的光栅带,指示像素是否缺少真实值或已被替换为零。

在将遥感和地理空间数据输入 ML 模型之前,这些数据需要进行大量处理。因此,最好有一个脚本化/自动化的数据准备步骤或管道,将原始图像处理为光栅带,堆叠它们,并将其写入诸如 TensorFlow Records 之类的高效格式。

音频和视频

音频是一个 1D 信号,而视频是 3D 信号。最好使用专门为音频和视频设计的 ML 技术,但简单的首选解决方案可能涉及将图像 ML 技术应用于音频和视频数据。在本节中,我们将讨论这种方法。音频和视频 ML 框架超出了本书的范围。

谱图

要在音频上进行机器学习,必须将音频分割成块,然后将 ML 应用于这些时间窗口。时间窗口的大小取决于要检测的内容——识别单词需要几秒钟,而识别乐器则需要几分之一秒。

结果是一个 1D 信号,因此可以使用Conv1D而不是Conv2D层来处理音频数据。从技术上讲,这将是时间空间的信号处理。然而,如果将音频信号表示为频谱图——音频信号频率在时间上的堆叠视图,则结果往往更好。在频谱图中,图像的 x 轴表示时间,y 轴表示频率。像素值表示谱密度,即特定频率处音频信号的响度(见图 5-6)。通常,谱密度以分贝表示,因此最好使用谱图的对数作为图像输入。

要读取并将音频信号转换为谱图的对数,使用scipy包:

from scipy import signal
from scipy.io import wavfile
sample_rate, samples = wavfile.read(filename)
_, _, spectro = signal.spectrogram(samples, sample_rate)
img = np.log(spectro)

图 5-6. 两种乐器的音频信号(左)和频谱图(右)。

逐帧

视频由组成,每帧都是一幅图像。处理视频的明显方法是对单独的帧进行图像处理,然后将结果后处理成对整个视频的分析。我们可以使用 OpenCV(cv2)包来读取一个标准格式的视频文件并获取一帧:

cap = cv2.VideoCapture(filename)
num_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
for i in range(num_frames):
    readok, frame = cap.read()
    if readok:
        img = tf.convert_to_tensor(frame)

例如,我们可能对图像帧进行分类,并将视频分类问题的结果视为所有帧中找到的所有类别的集合。问题在于这种方法忽视了视频中相邻帧高度相关的事实,就像图像中相邻像素高度相关一样。

Conv3D

我们可以计算视频帧的滚动平均值,然后应用计算机视觉算法,而不是逐帧处理视频。当视频存在颗粒状时,这种方法特别有用。与逐帧处理方法不同,滚动平均利用帧相关性来去噪图像。

更复杂的方法是使用 3D 卷积。我们将视频剪辑读入一个形状为[批次,时间,高度,宽度,通道]的 5D 张量中,必要时将电影分成短片:

def read_video(filename):
    cap = cv2.VideoCapture(filename)
    num_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    frames = []
    for i in range(num_frames):
        readok, frame = cap.read()
        if readok:
            frames.append(frame)
    return tf.expand_dims(tf.convert_to_tensor(frames), -1)

然后,在我们的图像处理管道中,我们应用Conv3D而不是Conv2D。这类似于滚动平均,其中每个时间步的权重是从数据中学习的,然后是非线性激活函数。

另一种方法是使用递归神经网络(RNNs)和其他适合时间序列数据的序列方法。然而,由于视频序列的 RNNs 很难训练,3D 卷积方法往往更实用。另一种选择是使用卷积从时间信号中提取特征,然后将卷积滤波器的结果传递给一个较简单的 RNN。

手动标记

在许多机器学习项目中,数据科学团队参与的第一步是对图像数据进行标记。即使标记将被自动化,概念验证的前几幅图像几乎总是手动标记的。表格的形式和组织将根据问题类型(图像分类或物体检测)以及图像是否可以具有多个标签而有所不同。

要手动标记图像,评分员查看图像,确定标签并记录标签。有两种典型的记录方法:使用文件夹结构和元数据表。

在文件夹组织中,评分员根据标签将图像简单地移动到不同的文件夹中。例如,所有雏菊花都存储在名为daisy的文件夹中。评分员可以快速完成此操作,因为大多数操作系统提供图像预览和方便的方式选择并移动图像到文件夹中(参见图 5-8)。

使用文件夹方法的问题在于,如果图像可以具有多个标签,则会导致重复,例如,如果图像同时包含玫瑰和雏菊。

图 5-8. 预览图像并快速将其移动到适当的文件夹。

另一种推荐的方法是将标签记录在元数据表中(例如电子表格或 CSV 文件),该表至少有两列:一列是图像文件的 URL,另一列是适用于该图像的标签列表。

$ `gsutil` `cat` `gs``:``/``/``cloud``-``ml``-``data``/``img``/``flower_photos``/``all_data``.``csv` `|` `head` `-``5`
gs://cloud-ml-data/img/flower_photos/daisy/100080576_f52e8ee070_n.jpg,daisy
gs://cloud-ml-data/img/flower_photos/daisy/10140303196_b88d3d6cec.jpg,daisy
gs://cloud-ml-data/img/flower_photos/daisy/10172379554_b296050f82_n.jpg,daisy
gs://cloud-ml-data/img/flower_photos/daisy/10172567486_2748826a8b.jpg,daisy
gs://cloud-ml-data/img/flower_photos/daisy/10172636503_21bededa75_n.jpg,daisy

将文件夹方法的高效性与元数据表方法的通用性结合的一个好方法是将图像组织到文件夹中,然后使用脚本遍历图像并创建元数据表。

多标签

如果一幅图像可以关联多个标签(例如,如果图像既可以包含雏菊又可以包含向日葵),一种方法是简单地将图像复制到两个文件夹中,并分别创建两行数据:

gs://.../sunflower/100080576_f52e8ee070_n.jpg,sunflower
gs://.../daisy/100080576_f52e8ee070_n.jpg,daisy

然而,像这样具有重复的方法会使得训练真正的多标签多类问题更加困难。更好的方法是使标签列包含所有匹配的类别:

gs://.../multi/100080576_f52e8ee070_n.jpg,sunflower daisy

摄入管道将需要解析标签字符串,以提取使用 tf.strings.split 匹配的类别列表。

物体检测

对于物体检测,元数据文件需要包括图像中物体的边界框。可以通过在预定义顺序中的第三列包含边界框顶点来实现此目的(例如从左上角逆时针开始)。对于分割问题,此列将包含一个多边形而不是边界框(参见图 5-9)。

图 5-9. 物体检测和分割问题中的元数据文件需要包含边界框或多边形。

由于环形对象(中心不是对象的一部分)可以用一对多边形表示,其中内多边形的顶点顺序相反。为了避免这种复杂性,有时分割边界仅表示为一组像素而不是多边形。

大规模标记

手动标记成千上万张图像既繁琐又容易出错。我们如何能够更高效和准确地完成呢?一种方法是使用能够高效手动标记成千上万张图像的工具。另一种方法是使用方法来捕捉和纠正标记错误。

标记用户界面

标记工具应具备显示图像并允许评分者快速选择有效类别并将评分保存到数据库的功能。

为支持对象识别和图像分割用例,该工具应具有注释能力,并能将绘制的边界框或多边形转换为图像像素坐标。计算机视觉标注工具(见图 5-10)是一个免费的基于 Web 的视频和图像注释工具,可在线使用并可在本地安装。它支持各种注释格式。

图 5-10. 一个用于高效标注图像的工具。

多个任务

我们经常需要为多个任务标注图像。例如,我们可能需要按照花的类型(雏菊,郁金香,...)、颜色(黄色,红色,...)、位置(室内,室外,...)、种植风格(盆栽,地栽,...)等分类相同的图像。在这种情况下,使用 Jupyter 笔记本的交互功能进行标注是一种高效的方法(见图 5-11)。

图 5-11. 在 Jupyter 笔记本中为多任务高效标注图像。

这个功能由 Python 包multi-label-pigeon提供:

annotations = multi_label_annotate(
    filenames,
    options={'flower':['daisy','tulip', 'rose'],
             'color':['yellow','red', 'other'],
             'location':['indoors','outdoors']},
    display_fn=lambda filename: display(Image(filename))
)
with open('label.json', 'w') as ofp:
    json.dump(annotations, ofp, indent=2)

详细代码在 GitHub 上的05_label_images.ipynb中。输出是包含所有图像所有任务注释的 JSON 文件:

{
    "flower_images/10172379554_b296050f82_n.jpg": {
        "flower": [
            "daisy"
        ],
        "color": [
            "red"
        ],
        "location": [
            "outdoors"
        ]
    },

投票和众包

手动标注面临两个挑战:人为错误和固有不确定性。评分者可能因疲劳而错误地识别图像。分类可能也存在歧义。例如,考虑 X 光图像:放射科医生可能对于某物是否骨折存在分歧。

在这两种情况下,实施投票系统可能会有所帮助。例如,一个图像可能会展示给两个评分者。如果评分者达成一致,他们的标签将分配给该图像。如果评分者意见不一,我们可以选择采取以下几种措施之一:

  • 如果不希望使用歧义数据进行训练,则丢弃该图像。

  • 将图像视为属于中立类别。

  • 最终标签由第三个标签者决定,实际上是三个标签者的多数投票。当然,可以将投票池增加到任何奇数个。

投票也适用于多标签问题。我们只需要将每个类别的发生视为二元分类问题,然后将评分者大多数同意的标签分配给图像。

即使是对象识别和分割边界也可以通过投票确定。这样的系统示例显示在图 5-12 中——CAPTCHA 系统的主要目的是识别用户是机器人还是人类,但第二个目的是众包图像的标记。通过减少瓦片的大小,可以得到更精细的标记。通过偶尔添加图像或瓦片,并在许多用户中收集结果,可以成功地获得标记图像。

图 5-12。众包对象检测或分割多边形。

标签服务

即使进行了高效的标记,也可能需要数天或数月来标记所有需要训练先进图像模型的图像。这不是数据科学家时间的有效利用。因此,出现了许多标签服务。这些是将标记图像的工作分配给低成本地区的几十名员工的企业。通常,我们必须提供几个示例图像和需要使用的标记技术的描述。

标签服务比众包稍微复杂一些。这些服务不仅适用于知名对象(停止标志、人行横道等),还适用于可以教会普通人快速做出正确决策的任务(例如 X 射线图像中的骨折与划痕)。尽管如此,您可能不会将标签服务用于识别需要显著领域专业知识的病毒分子结构等任务。

标签服务的例子包括AI 平台数据标记服务Clarifai,和Lionbridge。您通常会与您组织的采购部门合作使用此类服务。您还应该验证这些服务如何处理敏感或个人身份信息数据。

自动标记

在许多情况下,可以自动获取标签。即使这些方法不是 100%准确,它们也很有用,因为评分员更有效地纠正自动获取的标签,而不是逐个为图像分配标签。

相关数据的标签

例如,您可以通过查看图像出现的产品目录部分或通过从描述图像的单词中进行实体提取来获得图像的标签。

在某些情况下,仅查看图像少量像素即可获得地面真相。例如,可以通过查看挖掘井和提取核心样本的位置来标记地震图像。雷达图像可以使用安装地面雨量计的地点处的读数来标记。这些点标签可以用于标记原始图像的瓦片。或者,可以对点标签进行空间插值,并将空间插值数据用作分割等任务的标签。

Noisy Student

可以使用Noisy Student模型来延展图像的标注。这种方法的运作方式如下:

  • 手动标记,比如说,10,000 张图像。

  • 使用这些图像来训练一个小型机器学习模型。这个模型就是教师模型

  • 使用训练好的机器学习模型来预测,比如说,一百万张未标记图像的标签。

  • 在标记和伪标记图像的组合上训练一个更大的机器学习模型,称为学生模型。在学生模型的学习过程中,使用 dropout 和随机数据增强(在第六章中介绍),以使该模型比教师模型更好地泛化。

  • 通过将学生模型重新放回教师位置来迭代。

可以通过选择机器学习模型不自信的图像来手动校正伪标签。这可以在将学生重新放回新的教师模型之前并入到 Noisy Student 范例中。

自监督学习

在某些情况下,机器学习方法本身可以提供标签。例如,为了创建图像的嵌入,我们可以训练一个自编码器,正如我们在第十章中所描述的那样。在自编码器中,图像本身作为其标签。

另一种自监督学习的方式是,标签将在一段时间后才能确定。例如,可以基于患者最终的结果来标记医学图像。例如,如果患者随后患有肺气肿,那么可以将此标签应用于在诊断前几个月拍摄的患者肺部图像。这种标注方法适用于许多未来活动的预测:卫星天气图像可以基于地面网络检测到的云到地闪电的后续发生情况来标记,预测用户是否将放弃购物车或取消订阅的数据集可以基于用户的最终行动来标记。因此,即使我们的图像在拍摄时没有立即标签,也可以值得保留它们,直到最终为它们获得标签。

许多数据质量问题可以用自监督的方式来解决。例如,如果任务是在云层遮挡视野时填充地面图像,则模型可以通过人工移除晴空图像的部分并使用实际像素值作为标签进行训练。

偏见

理想的机器学习数据集是一个允许我们训练一个在投入生产时表现完美的模型的数据集。如果某些示例在数据集中被低估或高估,以至于当它们在生产环境中遇到时会导致低精度,则我们会面临问题。此时,数据集被称为有偏

在本节中,我们将讨论数据集偏见的来源,如何以无偏的方式收集训练数据集,并如何检测数据集中的偏见。

偏见的来源

数据集中的偏见是指数据集的特性,在模型投入生产时会导致不希望的行为。

我们发现许多人混淆了两个相关但不同的概念:偏见和不平衡。偏见不同于不平衡——自动野生动物摄像机拍摄的照片中不到 1%可能是美洲虎。预期野生动物图片数据集中美洲虎比例很小是可以预期的:它是不平衡的,但这并不是偏见的证据。我们可以对常见动物进行降采样和罕见动物进行过采样,以帮助机器学习模型更好地识别不同类型的野生动物,但这种过采样并不会使数据集产生偏见。相反,数据集偏见是指数据集的任何方面导致模型产生不希望的行为。

有三种偏见的来源。选择偏见发生在模型训练时倾斜的情况下。测量偏见发生在图像收集方式在训练和生产中的变化。确认偏见发生在现实生活中数值分布导致模型强化不希望的行为。让我们更详细地看看每个偏见为何可能发生;然后我们将快速向您展示如何检测数据集中的偏见。

选择偏见

选择偏见通常发生在数据收集的不完善上——我们错误地限制了数据来源,使得某些类别被排除或采样不足。例如,假设我们正在训练一个模型来识别我们销售的物品。我们可能已经在产品目录中的图像上训练了模型,但这可能导致我们合作伙伴的产品没有被包括进来。因此,模型将无法识别我们销售但不在产品目录中的合作伙伴物品。同样地,如果一个房屋图片模型是基于县记录中的房屋照片进行训练的,那么如果未完成的房屋不受县税的影响,因此不在记录中,那么该模型在施工中的房屋可能表现不佳。

选择偏差的常见原因是某些类型的数据比其他数据更容易收集。例如,收集法国和意大利艺术品的图像可能比收集牙买加或斐济艺术品的图像更容易。艺术品数据集因此可能低估了某些国家或时间段。同样,以前几年的产品目录可能很容易找到,但今年竞争对手的目录可能尚未提供,因此我们的数据集对我们的产品可能是最新的,但对竞争对手的产品却不是。

有时选择偏差发生是因为训练数据集在固定时间段内收集,而生产时间段则变化更大。例如,训练数据集可能是在晴天收集的,但系统预期在昼夜、晴雨中都能工作。

选择偏差也可能由于异常值修剪和数据集清理而发生。如果我们丢弃了船屋、谷仓和移动房屋的图像,模型将无法识别这些建筑物。如果我们正在创建海贝数据集,并且丢弃了任何带有动物残留的贝壳图像,则如果展示一个有生活甲壳动物的图像,模型的性能将较差。

要解决选择偏差,需要从生产系统反向工作。需要识别哪些类型的房屋?数据集中是否有足够的这类房屋的示例?如果没有,解决方案是积极收集这样的图像。

测量偏差

测量偏差是由于在训练和生产过程中收集图像的方式不同而导致的结果差异。这些变化导致系统性差异——也许我们在训练图像中使用了高质量的相机,但我们的生产系统却使用了一个光圈更低、白平衡和/或分辨率更低的现成相机。

测量偏差也可能因为训练和生产数据提供者的差异而发生。例如,我们可能希望建立一个工具来帮助远足者识别野花。如果训练数据集由专业摄影师提供,这些照片将包含像背景虚化这样的复杂效果,而这些效果在普通远足者提供的用于识别的照片中则不会出现。

测量偏差也会发生在由一组人标记图像时。不同的评分者可能有不同的标准,标签的不一致性可能导致较差的机器学习模型。

测量偏差也可能非常微妙。也许所有狐狸的照片都是在雪地中拍摄的,而所有狗的照片都是在草地上拍摄的。机器学习模型可能会学习区分雪和草,并比实际学习狐狸和狗特征的模型实现更高的准确性。因此,我们需要注意我们的图像中还有哪些其他因素(并检查模型的解释),以确保我们的模型学习我们希望它们学习的内容。

确认偏见

还记得我们说过很多人混淆偏见和不平衡吗?两者之间的差异和相互关系在讨论确认偏见时尤为重要。即使数据集准确地代表了不平衡的现实世界分布,数据集可能也存在偏见——这是你在阅读本节时应该牢记的事情。请记住,数据集中的偏见包括导致机器学习模型在该数据集上训练时产生不良行为的任何内容。

唐纳德·拉姆斯菲尔德(Donald Rumsfeld)是 2002 年的美国国防部长,他著名列出了三类知识

我们知道已知的事物;我们知道我们知道的事物。我们也知道有已知的未知;也就是说,我们知道有一些我们不知道的事物。但也有未知的未知——那些我们不知道我们不知道的事物。如果人们回顾我们国家和其他自由国家的历史,往往是后一种情况更为困难。

确认偏见是我们在收集数据时不知道的偏见,但这些偏见可能会对训练在数据集上的模型造成严重影响。人们不愿意审视某些不平衡存在的原因,可能会导致机器学习模型延续现有的偏见。

在“野外”收集数据可能会导致确认偏见。例如,在撰写本文时,消防员往往以男性为主。如果我们随机收集消防员的图像样本,很可能所有的图像都是男性消防员的图像。一个在这样的数据集上训练过的机器学习模型,在看到一位女性消防员的图像时,可能会生成这是一位女性在万圣节派对上穿着的图像的说明。这会相当冒犯,对吧?这是一个虚构的例子,但它说明了当数据集反映现实世界时,社会中现有偏见如何被放大。搜索关于有偏见的人工智能的最新新闻标题,你会发现许多核心都是类似的现实世界灾难,因为它们反映了现实世界的分布。

小镇报纸往往报道发生在镇上的事件,由于这些数据“在野外”,大多数音乐会、市集和户外用餐的照片中会包含大多数社区的图像。另一方面,报纸中出现的少数社区青少年的大多数照片可能是被捕的照片。报纸上也会出现大多数社区青少年的被捕照片,但这些照片在户外环境中的照片将大大超过它们。有了这样的数据集,机器学习模型将会学会将少数社区成员与监狱联系起来,将大多数社区成员与良性活动联系起来。这再次是一个模型确认和延续报纸编辑偏见的例子,因为报纸报道的内容倾向于涵盖这些内容。

确认偏见也可能放大标签方面的现有偏见。如果一家公司训练一个模型来筛选收到的工作申请,并根据最终被聘用的人对其进行分类,那么模型将学习公司当前面试官所具有的任何偏见(无论是支持精英学院还是反对少数族裔候选人)。如果公司倾向于很少聘请黑人候选人或非常青睐常春藤盟校候选人,模型将学习并复制这一点。这种“无偏见”的模型实际上已经变得极度偏见。

要解决确认偏见问题,我们必须意识到我们的这个盲点,并有意识地将未知未知领域移动到另外两个类别中的一个。我们必须意识到我们公司、行业或社会中存在的偏见,并仔细验证我们的数据集是否收集方式不会放大这种偏见。推荐的方法涉及意识(潜在偏见)和积极的数据收集(以减少这种偏见)。

检测偏见

要检测偏见,您可以进行分段评估——基本上是计算您模型的客观函数,但仅针对群体成员。将此与非群体成员的指标值进行比较。然后调查任何群体,其分段指标与整体数据集非常不同。您还可以应用贝叶斯方法,并计算诸如“如果样本来自少数民族,那么视网膜扫描是否会被分类为疾病的机会有多大?”等措施。

Aequitas 公平树方法建议根据机器学习模型是惩罚性还是辅助性地使用,来决定监测哪些指标。

创建数据集

一旦我们收集了一组图像并对其进行了标记,我们就可以使用这些图像训练一个机器学习模型。然而,我们必须将数据集分为三部分:训练集、验证集和测试集。我们还希望利用这个机会以更高效的格式存储图像数据供机器学习使用。让我们来看看这两个步骤以及我们的训练程序如何读取这种格式的文件。

数据分割

图像和标签的数据集将必须分为三个部分,用于训练、验证和测试。实际比例由我们决定,但类似 80:10:10 的比例是常见的。

训练数据集是提供给模型的示例集。优化器利用这些示例调整模型的权重,以减少在训练数据集上的误差或损失。然而,在训练结束时的训练数据集上的损失并不是模型性能的可靠衡量标准。为了估计模型性能,我们必须使用一个在模型训练过程中未曾展示过给模型的示例集,这就是验证数据集的目的。

如果我们只训练一个模型,并且只训练一次,那么我们只需要训练和验证数据集(在这种情况下,通常是 80:20 的划分)。然而,很可能我们会使用不同的超参数重新尝试训练——也许我们会改变学习率,或者减少 dropout,或者向模型添加更多层。我们优化的超参数越多,验证数据集上的模型技能就越多地融入到模型结构本身中。因此,验证数据集不再是模型在给定新数据时表现的可靠估计。

我们最终对训练数据集上模型的适配进行评估,并使用在验证数据集上优化的参数。这一评估是在测试数据集上进行的。

在每次训练运行开始时拆分数据集并不是一个好主意。如果这样做,每个实验将具有不同的训练和验证数据集,这违背了保留真正独立测试数据集的目的。相反,我们应该只拆分一次,然后在所有超参数调整实验中继续使用相同的训练和验证数据集。因此,我们应该保存训练、验证和测试 CSV 文件,并在整个模型生命周期中始终使用它们。

有时,我们可能希望在数据集上进行交叉验证。为此,我们多次使用不同划分的前 90% 数据来训练模型(测试数据集保持相同的 10%)。在这种情况下,我们会生成多个训练和验证文件。在小数据集上,交叉验证很常见,但在像图像模型中使用的大数据集上则不太常见。

TensorFlow Records

前面章节提到的 CSV 文件格式并不推荐用于大规模机器学习,因为它依赖于将图像数据存储为单独的 JPEG 文件,这种方法效率不高。更高效的数据格式是 TensorFlow Records(TFRecords)。我们可以使用 Apache Beam 将 JPEG 图像文件转换为 TFRecords。

首先,我们定义一个方法来创建 TFRecord,给定图像文件名和图像的标签:

def create_tfrecord(filename, label, label_int):
    img = read_and_decode(filename)
    dims = img.shape
    img = tf.reshape(img, [-1])  # flatten to 1D array
    return tf.train.Example(features=tf.train.Features(feature={
        `'``image``'`: _float_feature(img),
        'shape': _int64_feature([dims[0], dims[1], dims[2]]),
        `'``label``'`: _string_feature([label]),
        'label_int': _int64_feature([label_int])
    })).SerializeToString()

TFRecord 是一个字典,有两个主要键:imagelabel。由于不同的图像可能具有不同的大小,我们还要注意存储原始图像的形状。为了在训练过程中节省查找标签索引的时间,我们也将标签存储为整数。

小贴士

除了效率外,TFRecords 还能让我们将图像元数据(如标签、边界框甚至额外的 ML 输入,如图像的位置和时间戳)作为数据的一部分嵌入其中。这样,我们就不需要依赖文件/目录名称或外部文件来编码元数据了。

图像本身是一个浮点数的扁平数组 —— 为了效率,在写入 TFRecords 之前,我们进行了 JPEG 解码和缩放。这样做的好处是,在迭代训练数据集时不需要重新执行这些操作:

def read_and_decode(filename):
    img = tf.io.read_file(filename)
    img = tf.image.decode_jpeg(img, channels=IMG_CHANNELS)
    img = tf.image.convert_image_dtype(img, tf.float32)
    return img

解码图像并将其值缩放到 [0, 1] 范围内,然后写入 TFRecords 不仅更高效,还有两个额外优势。首先,这样做将数据放入 TensorFlow Hub 中图像模型所需的确切格式(参见第三章)。其次,它允许读取代码使用数据,而无需知道文件是 JPEG、PNG 还是其他图像格式。

小贴士

另一种同样有效的方法是将数据存储在 TFRecord 中作为 JPEG 字节,依赖于 TensorFlow 的 decode_image() 函数来读取数据,并在模型的预处理层中将图像值缩放到 [0, 1] 范围内。由于使用了专为图像优化的算法对 JPEG 字节进行了压缩,因此生成的文件可能比由原始像素值组成的 gzip TFRecord 文件更小。如果带宽比解码时间更重要,则使用此方法。此方法的另一个好处是,解码操作通常在 CPU 上进行流水线处理,而模型在 GPU 上训练,因此解码操作可能基本上是免费的。

Apache Beam 管道包括获取训练、验证和测试的 CSV 文件,创建 TFRecords,并使用适当的前缀写入这三个数据集。例如,使用以下方式创建训练 TFRecord 文件:

with beam.Pipeline() as p:
    (p
     | 'input_df' >> beam.Create(`train`.values)
     | 'create_tfr' >> beam.Map(lambda x: create_tfrecord(
             x[0], x[1], LABELS.index(x[1])))
     | 'write' >> beam.io.tfrecordio.WriteToTFRecord(
             'output/`train``'``,` file_name_suffix='.gz')
    )

尽管在将像素值解码并写入 TFRecords 之前解码和缩放有多个优点,但是浮点像素数据往往比原始字节流占用更多空间。在前面的代码中,通过压缩 TFRecord 文件来解决这个缺点。当我们指定文件名后缀应为 .gz 时,TFRecord 写入程序将自动压缩输出文件。

规模运行

上述代码用于转换少量图像是可以的,但是当你有成千上百万的图像时,你需要一个更可扩展、更具韧性的解决方案。解决方案需要具有容错性,能够分布到多台机器上,并能够使用标准的 DevOps 工具进行监控。通常情况下,我们还希望在新图像流入时将输出管道传输到成本效益的 Blob 存储中。理想情况下,我们希望以无服务器方式完成这些操作,这样我们就不必自己管理和调整基础架构的规模。

满足这些生产需求(容错性、监控、流式处理和自动扩展)的一个解决方案是,在 Google Cloud Dataflow 上运行我们的 Apache Beam 代码,而不是在 Jupyter 笔记本中运行:

with beam.Pipeline(`'``DataflowRunner``'`, options=opts) as p:

可以使用标准 Python 结构(如 argparse)从命令行获取选项,通常包括要计费的 Cloud 项目和要运行管道的 Cloud 区域。除了 Cloud Dataflow,Apache Beam 的其他运行程序包括 Apache Spark 和 Apache Flink。

只要我们像这样创建管道,捕获工作流程的所有步骤(包括数据集分割的步骤)就会很有帮助。我们可以按以下方式执行此操作(完整代码位于 jpeg_to_tfrecord.py GitHub 中):

with beam.Pipeline(RUNNER, options=opts) as p:
    splits = (p
              | 'read_csv' >> beam.io.ReadFromText(arguments['all_data'])
              | 'parse_csv' >> beam.Map(lambda line: line.split(','))
              | 'create_tfr' >> beam.Map(lambda x: create_tfrecord(
                      x[0], x[1], LABELS.index(x[1])))
              | 'assign_ds' >> beam.Map(assign_record_to_split)
             )

其中 assign_record_to_split() 函数将每个记录分配给三个 splits 中的一个:

def assign_record_to_split(rec):
    rnd = np.random.rand()
    if rnd < 0.8:
        return ('train', rec)
    if rnd < 0.9:
        return ('valid', rec)
    return ('test', rec)

在这一点上,splits 由如下元组组成:

('valid', 'serialized-tfrecord...')

然后,可以将这些分成三组具有适当前缀的分片文件:

for s in ['train', 'valid', 'test']:
    _ = (splits
         | 'only_{}'.format(s) >> beam.Filter(lambda x: x[0] == s)
         | '{}_records'.format(s) >> beam.Map(lambda x: x[1])
         | 'write_{}'.format(s) >> beam.io.tfrecordio.WriteToTFRecord(
                 os.path.join(OUTPUT_DIR, s), file_name_suffix='.gz')
        )

运行此程序时,作业将提交给 Cloud Dataflow 服务,后者将执行整个管道(参见 图 5-13),并创建对应于所有三个 splits 的 TFRecord 文件,名称类似于 valid-00000-of-00005.gz

图 5-13. 在 Cloud Dataflow 中运行数据集创建管道。

将输入从 CSV 文件更改为 Cloud pub/sub 将此管道从批处理管道转换为流式管道。所有中间步骤保持不变,生成的分片 TFRecords(适合机器学习的格式)可以作为我们的 ML 数据湖

TensorFlow Recorder

在前面的章节中,我们看了如何手动创建 TFRecord 文件,在此过程中执行了一些提取、转换、加载(ETL)操作。如果您已经有 Pandas 或 CSV 文件中的数据,可能更方便使用 TFRecorder Python 包,它将 tensorflow.to_tfr() 方法添加到 Pandas dataframe 中:

import pandas as pd
import tfrecorder
csv_file = './all_data_split.csv'
df = pd.read_csv(csv_file, names=['split', 'image_uri', 'label'])
`df``.``tensorflow``.``to_tfr`(output_dir='gs://BUCKET/data/output/path')

本示例中的 CSV 文件假定具有如下形式的行:

valid,gs://BUCKET/img/abc123.jpg,daisy
train,gs://BUCKET/img/def123.jpg,tulip

TFRecorder 将图像序列化为 TensorFlow Records。

在 Cloud Dataflow 中以规模运行 TFRecorder 涉及向调用添加几个参数:

df.tensorflow.to_tfr(
    output_dir='gs://my/bucket',
    runner='DataflowRunner',
    project='my-project',
    region='us-central1',
    tfrecorder_wheel='/path/to/my/tfrecorder.whl')

有关如何创建和加载用于使用的轮子的详细信息,请查看 TFRecorder 文档

读取 TensorFlow Records

要读取 TensorFlow Records,请使用 tf.data.TFRecordDataset。要将所有训练文件读入 TensorFlow dataset,我们可以进行模式匹配,然后将结果文件传递给 TFRecordDataset()

train_dataset = tf.data.TFRecordDataset(
    tf.data.Dataset.list_files(
        'gs://practical-ml-vision-book/flowers_tfr/train-*')
    )

完整的代码位于 06a_resizing.ipynb GitHub 中的笔记本中,但在 第六章 的文件夹中,因为那时我们实际上需要读取这些文件。

到目前为止,数据集包含 protobufs。我们需要根据写入文件的记录的模式来解析 protobufs。我们将该模式指定如下:

feature_description = {
    'image': tf.io.VarLenFeature(tf.float32),
    'shape': tf.io.VarLenFeature(tf.int64),
    'label': tf.io.FixedLenFeature([], tf.string,
default_value=''),
        'label_int': tf.io.FixedLenFeature([], tf.int64, default_value=0),
}

将此与用于创建 TensorFlow Record 的代码进行比较:

return tf.train.Example(features=tf.train.Features(feature={
    'image': _float_feature(img),
    'shape': _int64_feature([dims[0], dims[1], dims[2]]),
    'label': _string_feature(label),
    'label_int': _int64_feature([label_int])
}))

labellabel_int 具有固定长度(1),但 image 及其 shape 长度可变(因为它们是数组)。

给定 proto 和特征描述(或模式),我们可以使用函数 parse_single_example() 读取数据:

rec = tf.io.parse_single_example(proto, feature_description)

为了存储效率,变长数组被存储为稀疏张量(参见“什么是稀疏张量?”)。我们可以将它们转换为密集张量,并将扁平化的图像数组重塑为 3D 张量,从而得到完整的解析函数:

def parse_tfr(proto):
    feature_description = ...
    rec = tf.io.parse_single_example(proto, feature_description)
    shape = tf.sparse.to_dense(rec['shape'])
    img = tf.reshape(tf.sparse.to_dense(rec['image']), shape)
    return img, rec['label_int']

现在,我们可以将解析函数应用于使用 map() 读取的每个 proto:

train_dataset = tf.data.TFRecordDataset(
    [filename for filename in tf.io.gfile.glob(
        'gs://practical-ml-vision-book/flowers_tfr/train-*')
    ])`.``map``(``parse_tfr``)`

到此为止,训练数据集为我们提供了图像及其标签,我们可以像在第二章中从 CSV 数据集中获得的图像和标签一样使用它们。

总结

在本章中,我们看了如何创建由图像和这些图像相关标签组成的视觉数据集。这些图像可以是照片,也可以是由创建 2D 或 3D 投影的传感器生成的图像。可以通过将各个图像的值视为通道来将多个这样的图像对齐到单个图像中。

图像标注通常需要手动完成,至少在项目初期是这样。我们研究了不同类型问题的标签、如何组织标签、如何高效标注图像以及如何使用投票减少标签错误。标签有时可以从最终结果中自动提取,或从辅助数据集中提取。还可以设置迭代的 Noisy Student 过程来创建伪标签。

我们还讨论了数据集偏差、偏差的原因以及如何降低数据集中偏差的机会。在第八章中,我们将学习如何诊断数据集中的偏差。

最后,我们学习了如何为数据创建训练、验证和测试分割,并将这三个图像数据集高效地存储在数据湖中。在接下来的两章中,您将学习如何在为此目的创建的数据集上训练 ML 模型。在第六章中,我们将探讨如何为机器学习预处理图像,在第七章中,我们将讨论如何在预处理后的图像上训练 ML 模型。

¹ 这在物联网(IoT)应用中很常见;请参阅“雾计算”的维基百科条目。

² 参见“理解内存格式”,关于 oneAPI 深度神经网络库。

第六章:预处理

在第五章中,我们看到了如何为机器学习创建训练数据集。这是标准图像处理流程的第一步(参见图 6-1)。下一个阶段是预处理原始图像,以便将其输入模型进行训练或推理。在本章中,我们将讨论为何需要对图像进行预处理,如何设置预处理以确保在生产环境中的可复现性,以及如何在 Keras/TensorFlow 中实现各种预处理操作。

图 6-1. 在将原始图像输入模型之前,它们必须经过预处理,无论是在训练(顶部)还是预测(底部)期间。
小贴士

本章的代码位于该书的GitHub 仓库06_preprocessing文件夹中。我们将在适当的情况下提供代码样本和笔记本的文件名。

预处理的原因

在原始图像可以输入图像模型之前,它们通常需要经过预处理。此类预处理具有几个重叠的目标:形状转换、数据质量和模型质量。

形状转换

输入图像通常必须转换为一致的大小。例如,考虑一个简单的 DNN 模型:

model = tf.keras.Sequential([
    tf.keras.layers.Flatten(`input_shape``=``(``512``,` `256``,` `3``)`),
    tf.keras.layers.Dense(128,
                          activation=tf.keras.activations.relu),
    tf.keras.layers.Dense(len(CLASS_NAMES), activation='softmax')
])

此模型要求输入的图像必须是 4D 张量,并推断出批处理大小、512 列、256 行和 3 个通道。迄今为止,在本书中考虑的每一层都需要在构建时指定形状。有时可以从前面的层推断出规格,不必显式指定:第一个Dense层接收Flatten层的输出,因此在网络架构中建立为具有 512 * 256 * 3 = 393,216 个输入节点。如果原始图像数据不是这个大小,则无法将每个输入值映射到网络节点。因此,大小不正确的图像必须转换为具有此确切形状的张量。任何此类转换将在预处理阶段执行。

数据质量转换

另一个预处理的原因是确保数据质量。例如,许多卫星图像由于太阳照射或地球曲率的原因有终止线(见图 6-2)。

太阳照明会导致图像不同部分的光照水平不同。由于地球上的终结线在一天中移动,并且其位置可以从时间戳精确确定,因此考虑到地球上对应点接收的太阳照明,规范化每个像素值可能是有帮助的。或者,由于地球的曲率和卫星的视角,可能有些图像部分未被卫星感测到。这些像素可能会被屏蔽或分配一个–inf的值。在预处理步骤中,有必要以某种方式处理这些,因为神经网络期望看到有限的浮点值;一种选项是用图像中的平均值替换这些像素。

图 6-2. 太阳照明的影响(左)和地球的曲率(右)。图像来源:NASA © Living Earth 和 NOAA GOES-16 卫星。

即使您的数据集不包含卫星图像,也要意识到数据质量问题(如卫星数据中描述的问题)在许多情况下都会出现。例如,如果您的一些图像比其他图像暗,您可能希望在图像内部转换像素值以保持一致的白平衡。

改善模型质量

预处理的第三个目标是进行转换,以帮助提高在数据上训练的模型的准确性。例如,机器学习优化器在数据值较小时效果最佳。因此,在预处理阶段,将像素值缩放到[0, 1]或[-1, 1]范围内可能会有所帮助。

一些转换可以通过增加模型训练的数据的有效大小来帮助提高模型的质量。例如,如果您正在训练一个用于识别不同动物类型的模型,一个简单的方法是通过添加图像的翻转版本来加倍您的数据集。此外,向图像添加随机扰动可以增强训练的稳健性,限制模型过拟合的程度。

当然,在应用从左到右的转换时,我们必须小心。如果我们正在训练一个包含大量文本的图像模型(例如路标图像),通过左右翻转图像来增强数据可能会降低模型识别文本的能力。此外,有时候翻转图像可能会破坏我们需要的信息。例如,如果我们试图在服装店中识别产品,将扣子衬衫的图像左右翻转可能会破坏信息。男士的衬衫的扣子在穿着者的右侧,扣眼在左侧,而女士的则相反。随机翻转图像将使模型无法利用扣子的位置来确定服装设计的性别。

尺寸和分辨率

如前文所述,图像预处理的一个关键原因是确保图像张量具有 ML 模型输入层期望的形状。为了做到这一点,通常我们需要改变正在读取的图像的大小和/或分辨率。

考虑在第五章中将花卉图像写入 TensorFlow Records。正如在那一章中解释的那样,我们可以使用以下方式读取这些图像:

train_dataset = tf.data.TFRecordDataset(
    [filename for filename in tf.io.gfile.glob(
        'gs://practical-ml-vision-book/flowers_tfr/train-*')
    ]).map(parse_tfr)

让我们显示其中的五张图像:

for idx, (img, label_int) in enumerate(train_dataset.take(5)):
    print(img.shape)
    ax[idx].imshow((img.numpy()));

如图 6-3 所示,这些图像的尺寸各不相同。例如,第二幅图像(240x160)是竖直模式,而第三幅图像(281x500)是水平拉伸的。

图 6-3. 5-花训练数据集中的五幅图像。请注意,它们的尺寸各不相同(标在图像顶部)。

使用 Keras 预处理层

当输入的图像尺寸各不相同时,我们需要对它们进行预处理,以符合 ML 模型输入层期望的形状。我们在第二章中使用了 TensorFlow 函数来读取图像时指定了所需的高度和宽度:

img = tf.image.resize(img, [IMG_HEIGHT, IMG_WIDTH])

Keras 有一个名为Resizing的预处理层,提供相同的功能。通常我们会有多个预处理操作,因此我们可以创建一个包含所有这些操作的 Sequential 模型:

preproc_layers = tf.keras.Sequential([
    tf.keras.layers.experimental.preprocessing.Resizing(
        height=IMG_HEIGHT, width=IMG_WIDTH,
        input_shape=(None, None, 3))
    ])

要将预处理层应用到我们的图像上,我们可以这样做:

train_dataset.map(lambda img: preproc_layers(img))

然而,这种方法行不通,因为train_dataset提供的是一个元组(img, label),其中图像是一个 3D 张量(高度、宽度、通道),而 Keras Sequential 模型期望的是一个 4D 张量(批次大小、高度、宽度、通道)。

最简单的解决方案是编写一个函数,使用expand_dims()在图像的第一个轴上添加额外的维度,并使用squeeze()从结果中去除批次维度:

def apply_preproc(img, label):
    # add to a batch, call preproc, remove from batch
    x = tf.expand_dims(img, 0)
    x = preproc_layers(x)
    x = tf.squeeze(x, 0)
    return x, label

定义了这个函数后,我们可以使用以下方式将预处理层应用到我们的元组中:

train_dataset.map(apply_preproc)
注意

通常情况下,我们不需要在预处理函数中调用expand_dims()squeeze(),因为我们会在batch()调用之后应用预处理函数。例如,我们通常会这样做:

train_dataset`.``batch``(``32``)`.map(apply_preproc)

然而,在这里,我们不能这样做,因为train_dataset中的图像尺寸都不同。为了解决这个问题,我们可以如上所示添加一个额外的维度,或者使用ragged batches

结果显示在图 6-4 中。请注意,现在所有的图像都是相同的大小,因为我们传入了 224 作为IMG_HEIGHTIMG_WIDTH,这些图像都是正方形的。与图 6-3 进行比较,我们注意到第二幅图像在垂直方向被压扁,而第三幅图像在水平方向被压扁并在垂直方向上被拉伸。

图 6-4。将图像调整为形状为(224, 224, 3)的效果。直觉上,拉伸和压缩花朵会使它们更难识别,因此我们希望保留输入图像的长宽比(高度与宽度的比率)。本章后面,我们将看看其他能够做到这一点的预处理选项。

Keras 的Resizing在进行压缩和拉伸时提供了几种插值选项:bilinearnearestbicubiclanczos3gaussian等等。默认的插值方案(bilinear)保留了局部结构,而gaussian插值方案对噪声更宽容。然而,在实践中,不同插值方法之间的差异非常小。

Keras 预处理层有一个优势,我们将在本章后面深入探讨—因为它们是模型的一部分,在预测时会自动应用。因此,选择在 Keras 或 TensorFlow 中进行预处理往往取决于效率和灵活性之间的权衡;我们将在本章后面扩展讨论这一点。

使用 TensorFlow 图像模块

除了我们在第二章中使用的resize()函数外,TensorFlow 的tf.image模块还提供了大量图像处理函数。我们在第五章中使用了这个模块的decode_jpeg(),但 TensorFlow 还可以解码 PNG、GIF 和 BMP,并在彩色和灰度图像之间转换。有关工作于边界框和调整对比度、亮度等的方法也是如此。

在调整大小的领域,TensorFlow 允许我们在调整大小时保持长宽比,通过裁剪图像到期望的长宽比并拉伸它:

img = tf.image.resize(img, [IMG_HEIGHT, IMG_WIDTH],
                      preserve_aspect_ratio=True)

或者用零填充边缘:

img = tf.image.resize_with_pad(img, [IMG_HEIGHT, IMG_WIDTH])

我们可以将此函数直接应用于数据集中的每个(img,label)元组,如下所示:

def apply_preproc(img, label):
    return (tf.image.resize_with_pad(img, 2*IMG_HEIGHT, 2*IMG_WIDTH),
            label)
train_dataset.map(apply_preproc)

结果显示在图 6-5 中。注意填充效果,以避免拉伸或压缩输入图像同时提供所需的输出大小。

图 6-5。将图像调整为(448, 448),并进行填充。

你们中的鹰眼可能已经注意到,我们将图像调整为比期望的高度和宽度大(实际上是两倍)。这样做是为了迎接下一步的挑战。

虽然我们通过指定填充方式保留了长宽比,但现在我们的图像被黑边填充了。这也是不理想的。如果现在我们进行“中心裁剪”—即在图像中心裁剪这些比我们想要的大的图像呢?

混合使用 Keras 和 TensorFlow

TensorFlow 中有一个中心裁剪功能,但为了增加趣味性,让我们混合使用 TensorFlow 的resize_with_pad()和 Keras 的CenterCrop功能。

为了将任意一组 TensorFlow 函数作为 Keras 模型的一部分调用,我们将函数包装在 Keras 的 Lambda 层中:

tf.keras.layers.Lambda(lambda img:
                       tf.image.resize_with_pad(
                           img, 2*IMG_HEIGHT, 2*IMG_WIDTH))

这里,因为我们希望先进行 resize,然后进行 center crop,我们的预处理层如下所示:

preproc_layers = tf.keras.Sequential([
    tf.keras.layers.Lambda(lambda img:
                           tf.image.resize_with_pad(
                               img, 2*IMG_HEIGHT, 2*IMG_WIDTH),
                           `input_shape``=``(``None``,` `None``,` `3``)`),
    tf.keras.layers.experimental.preprocessing.CenterCrop(
        height=IMG_HEIGHT, width=IMG_WIDTH)
    ])

注意第一层 (Lambda) 带有 input_shape 参数。因为输入图像的大小会不同,我们将高度和宽度指定为 None,这样它们将在运行时确定。不过,我们确保始终有三个通道。

应用此预处理的结果显示在 Figure 6-6 中。请注意花朵的长宽比得到保留,并且所有图像都是 224x224。

图 6-6. 应用两个处理操作的效果:resize with pad 后跟 center crop。

到目前为止,你已经看到三种不同的预处理方法:在 Keras 中,作为预处理层;在 TensorFlow 中,作为 tf.data 流水线的一部分;以及在 Keras 中,作为模型本身的一部分。正如前面提到的,选择其中之一取决于效率和灵活性之间的权衡;我们将在本章后面更详细地探讨这个问题。

模型训练:

如果输入图像都是相同大小,我们可以将预处理层合并到模型本身中。但是,由于输入图像大小不同,它们不容易成批处理。因此,我们将在进食流水线中进行批处理之前应用预处理:

train_dataset = tf.data.TFRecordDataset(
    [filename for filename in tf.io.gfile.glob(
        'gs://practical-ml-vision-book/flowers_tfr/train-*')
    ])`.``map``(``parse_tfr``)``.``map``(``apply_preproc``)``.``batch``(``batch_size``)`

模型本身是相同的 MobileNet 迁移学习模型,我们在 Chapter 3 中使用过(完整代码在 GitHub 上的 06a_resizing.ipynb 中)。

layers = [
    hub.KerasLayer(
        "https://tfhub.dev/.../mobilenet_v2/...",
        input_shape=(IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS),
        trainable=False,
        name='mobilenet_embedding'),
    tf.keras.layers.Dense(num_hidden,
                          activation=tf.keras.activations.relu,
                          name='dense_hidden'),
    tf.keras.layers.Dense(len(CLASS_NAMES),
                          activation='softmax',
                          name='flower_prob')
]
model = tf.keras.Sequential(layers, name='flower_classification')
model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=lrate),
              loss=tf.keras.losses.SparseCategoricalCrossentropy(
                  from_logits=False),
              metrics=['accuracy'])
history = model.fit(train_dataset, validation_data=eval_dataset, epochs=10)

模型训练收敛并且验证准确率在 0.85 时平稳(见 Figure 6-7)。

图 6-7. MobileNet 迁移学习模型的损失和准确率曲线,以预处理层作为输入。

将 Figure 6-7 与 Figure 3-3 进行比较,似乎我们在填充和中心裁剪方面的效果不如在 Chapter 3 中简单的 resize 操作(0.85 与 0.9 的准确率)。尽管验证数据集在两种情况下不同,因此准确性数字不直接可比,但准确性差异足够大,很可能 Chapter 6 模型比 Chapter 3 中的模型更差。机器学习是一门实验性学科,除非尝试,否则我们不会知道这一点。在不同的数据集上,更复杂的预处理操作可能会改善最终结果;你需要尝试多种选择来找出哪种方法最适合你的数据集。

一些预测结果显示在 Figure 6-8 中。请注意,输入图像都具有自然的纵横比,并且中心裁剪。

图 6-8. 将图像作为模型的输入,并对这些图像进行预测。

训练-服务偏差

在推理期间,我们需要对图像执行与训练期间完全相同的操作(参见 Figure 6-1)。回想一下,我们在三个地方进行了预处理:

  1. 创建文件时。当我们在第五章中写出 TensorFlow Records 时,我们解码了 JPEG 文件并将输入值缩放到 [0, 1]。

  2. 在读取文件时。我们对训练数据集应用了函数 parse_tfr()。该函数唯一的预处理是将图像张量重塑为 [height, width, 3],其中 height 和 width 是图像的原始尺寸。

  3. 在 Keras 模型中。然后我们对图像应用了 preproc_layers()。在此方法的最后版本中,我们将图像调整大小并填充至 448x448,然后中心裁剪为 224x224。

在推理流程中,我们必须对客户端提供的图像执行所有这些操作(解码、缩放、重塑、调整大小、中心裁剪)¹。如果在训练和推理之间遗漏或者稍微不同地执行某个操作,可能会导致潜在的错误结果。训练和推理流程分歧的情况(因此在推理期间出现未预料或不正确的行为,而在训练期间未见)被称为训练-服务偏差。为了防止训练-服务偏差,最理想的情况是我们可以在训练和推理中重复使用完全相同的代码。

广义上说,我们可以通过以下三种方式设置所有在训练期间进行的图像预处理也会在推理期间完成:

  • 将预处理步骤放入函数中,这些函数会从训练和推理流程中调用。

  • 将预处理整合到模型本身。

  • 使用 tf.transform 创建和重用工件。

让我们看看每种方法。在每种情况下,我们都希望重构训练流程,以便在推理期间更轻松地重用所有预处理代码。代码在训练和推理之间重用得越容易,微小差异导致的问题就越少,从而避免训练-服务偏差的可能性就越大。

重复使用函数

在我们的情况下,训练管道读取由已解码和缩放的 JPEG 文件组成的 TensorFlow Records,而预测管道需要依赖于单个图像文件的路径。因此,预处理代码不会完全相同,但我们仍然可以将所有预处理收集到可重用的函数中,并将它们放在一个我们称之为_Preprocessor的类中。² 完整代码可以在GitHub 上的 06b_reuse_functions.ipynb找到。

预处理类的方法将从两个函数中调用,一个用于从 TensorFlow Records 创建数据集,另一个用于从 JPEG 文件创建一个单独的图像。创建预处理数据集的函数是:

def create_preproc_dataset(pattern):
    preproc = `_Preprocessor``(``)`
    trainds = tf.data.TFRecordDataset(
        [filename for filename in tf.io.gfile.glob(pattern)]
    ).map(preproc.`read_from_tfr`).map(
        lambda img, label: (preproc.`preprocess`(img), label))
    return trainds

正在调用预处理器的三个函数:构造函数、将 TensorFlow Records 读入图像的方法以及预处理图像的方法。创建单个预处理图像的函数是:

def create_preproc_image(filename):
    preproc = `_Preprocessor``(``)`
    img = preproc.`read_from_jpegfile`(filename)
    return preproc.`preprocess`(img)

在这里,我们正在使用构造函数和预处理方法,但我们正在使用一种不同的方式来读取数据。因此,预处理器将需要四种方法。

Python 中的构造函数由名为__init__()的方法组成:

class _Preprocessor:
    def __init__(self):
        self.preproc_layers = tf.keras.Sequential([
            tf.keras.layers.experimental.preprocessing.CenterCrop(
                height=IMG_HEIGHT, width=IMG_WIDTH),
                input_shape=(2*IMG_HEIGHT, 2*IMG_WIDTH, 3)
        ])

__init__()方法中,我们设置了预处理层。

要从 TFRecord 中读取,我们使用来自第五章的parse_tfr()函数,现在是我们类的方法:

def read_from_tfr(self, proto):
    feature_description = ... # schema
    rec = tf.io.parse_single_example(
        proto, feature_description
    )
    shape = tf.sparse.to_dense(rec['shape'])
    img = tf.reshape(tf.sparse.to_dense(rec['image']), shape)
    label_int = rec['label_int']
    return img, label_int

预处理包括获取图像,将其大小调整一致,放入批处理中,调用预处理层,并取消批处理结果:

def preprocess(self, img):
    x = tf.image.resize_with_pad(img, 2*IMG_HEIGHT, 2*IMG_WIDTH)
    # add to a batch, call preproc, remove from batch
    x = tf.expand_dims(x, 0)
    x = self.preproc_layers(x)
    x = tf.squeeze(x, 0)
    return x

当从 JPEG 文件读取时,我们务必执行所有在 TFRecord 文件写出时执行的步骤:

def read_from_jpegfile(self, filename):
    # same code as in 05_create_dataset/jpeg_to_tfrecord.py
    img = tf.io.read_file(filename)
    img = tf.image.decode_jpeg(img, channels=IMG_CHANNELS)
    img = tf.image.convert_image_dtype(img, tf.float32)
    return img

现在,训练管道可以使用我们定义的create_preproc_dataset()函数创建训练和验证数据集:

train_dataset = create_preproc_dataset(
    'gs://practical-ml-vision-book/flowers_tfr/train-*'
).batch(batch_size)

预测代码(将进入一个服务函数,在第九章中进行覆盖)将利用create_preproc_image()函数来读取单个 JPEG 文件,然后调用model.predict()

模型内的预处理

请注意,我们不必采取任何特殊措施来重新使用模型本身进行预测。例如,我们不必编写不同版本的层:表示 MobileNet 的 Hub 层和密集层在训练和预测之间都是透明可重用的。

我们将任何放入 Keras 模型的预处理代码自动应用于预测中。因此,让我们将中心裁剪功能从_Preprocessor类中取出,并将其移入模型本身(请参阅GitHub 上的 06b_reuse_functions.ipynb获取代码):

class _Preprocessor:
    def __init__(self):
        # nothing to initialize
        pass

    def read_from_tfr(self, proto):
        # same as before

    def read_from_jpegfile(self, filename):
        # same as before

    def preprocess(self, img):
        return tf.image.resize_with_pad(img, 2*IMG_HEIGHT, 2*IMG_WIDTH)

CenterCrop层移入 Keras 模型,现在变成了:

layers = [
    tf.keras.layers.experimental.preprocessing.`CenterCrop`(
        height=IMG_HEIGHT, width=IMG_WIDTH,
        `input_shape``=``(``2``*``IMG_HEIGHT``,` `2``*``IMG_WIDTH``,` `IMG_CHANNELS``)``,`
    ),
    hub.KerasLayer(...),
    tf.keras.layers.Dense(...),
    tf.keras.layers.Dense(...)
]

回想一下 Sequential 模型的第一层是带有 input_shape 参数的层。因此,我们已从 Hub 层中删除了此参数,并将其添加到 CenterCrop 层中。此层的输入是图像的所需大小的两倍,因此我们指定了这个大小。

模型现在包括 CenterCrop 层,其输出形状为 224x224,即我们期望的输出形状:

Model: "flower_classification"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
center_crop (CenterCrop)     (None, 224, 224, 3)       0
_________________________________________________________________
mobilenet_embedding (KerasLa (None, 1280)              2257984
_________________________________________________________________
dense_hidden (Dense)         (None, 16)                20496
_________________________________________________________________
flower_prob (Dense)          (None, 5)                 85

当然,如果训练和预测流水线都读取相同的数据格式,我们完全可以摆脱预处理器。

使用 tf.transform

在上一节中所做的事情——编写 _Preprocessor 类,并期望保持 read_from_tfr()read_from_jpegfile() 在预处理方面的一致性——这很难强制执行。这将成为 ML 流水线中常见的错误源,因为 ML 工程团队倾向于不断调整预处理和数据清理例程。

例如,假设我们已经将已裁剪的图像写入 TFRecords 以提高效率。在推断期间如何确保进行裁剪?为了减少训练-服务偏差,最好将所有预处理操作保存在工件注册表中,并自动将这些操作作为服务流水线的一部分应用。

进行此操作的 TensorFlow 库是 TensorFlow Transform (tf.transform)。要使用 tf.transform,我们需要:

  • 编写 Apache Beam 流水线以分析训练数据,预计算预处理所需的任何统计信息(例如用于归一化的平均值/方差),并应用预处理。

  • 将训练代码更改为读取预处理后的文件。

  • 更改训练代码以保存转换函数及其模型。

  • 更改推断代码以应用保存的转换函数。

让我们简要看一下每一个(完整代码可以在 GitHub 上的 06h_tftransform.ipynb 中找到)。

编写 Beam 流水线

进行预处理的 Beam 流水线类似于我们在 第五章 中用于将 JPEG 文件转换为 TensorFlow 记录的流水线。不同之处在于,我们使用 TensorFlow Extended(TFX)的内置功能来创建 CSV 读取器:

RAW_DATA_SCHEMA = schema_utils.schema_from_feature_spec({
    'filename': tf.io.FixedLenFeature([], tf.string),
    'label': tf.io.FixedLenFeature([], tf.string),
})
csv_tfxio = tfxio.CsvTFXIO(file_pattern='gs://.../all_data.csv'],
                           column_names=['filename', 'label'],
                           schema=RAW_DATA_SCHEMA)
And we use this class to read the CSV file:
img_records = (p
               | 'read_csv' >> csv_tfxio.BeamSource(batch_size=1)
               | 'img_record' >> beam.Map(
                   lambda x: create_input_record(x[0], x[1]))
              )

此时的输入记录包含读取的 JPEG 数据和标签索引,因此我们将其指定为模式(参见 jpeg_to_tfrecord_tft.py),以创建将进行转换的数据集:

IMG_BYTES_METADATA = tft.tf_metadata.dataset_metadata.DatasetMetadata(
    schema_utils.schema_from_feature_spec({
        'img_bytes': tf.io.FixedLenFeature([], tf.string),
        'label': tf.io.FixedLenFeature([], tf.string),
        'label_int': tf.io.FixedLenFeature([], tf.int64)
    })
)

转换数据

要转换数据,我们将原始数据和元数据传递给我们称之为 tft_preprocess() 的函数:

raw_dataset = (img_records, IMG_BYTES_METADATA)
transformed_dataset, transform_fn = (
    `raw_dataset` `|` 'tft_img' >>
    tft_beam.AnalyzeAndTransformDataset(`tft_preprocess`)
)

预处理函数使用 TensorFlow 函数执行调整大小操作:

def tft_preprocess(img_record):
    img = tf.map_fn(decode_image, img_record['img_bytes'],
                    fn_output_signature=tf.uint8)
    img = tf.image.convert_image_dtype(img, tf.float32)
    img = tf.image.resize_with_pad(img, IMG_HEIGHT, IMG_WIDTH)
    return {
        'image': img,
        'label': img_record['label'],
        'label_int': img_record['label_int']
    }

保存转换

生成的转换数据如前所述写出。此外,转换函数也会写出:

transform_fn | 'write_tft' >> tft_beam.WriteTransformFn(
    os.path.join(OUTPUT_DIR, 'tft'))

这样就创建了一个包含在原始数据集上执行的所有预处理操作的 SavedModel。

读取预处理数据

在训练期间,可以如下读取转换后的记录:

def create_dataset(pattern, batch_size):
    return tf.data.experimental.make_batched_features_dataset(
        pattern,
        batch_size=batch_size,
        features = {
            'image': tf.io.FixedLenFeature(
                [IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS], tf.float32),
            'label': tf.io.FixedLenFeature([], tf.string),
            'label_int': tf.io.FixedLenFeature([], tf.int64)
        }
    ).map(
        lambda x: (x['image'], x['label_int'])
    )

这些图像已经被缩放和调整大小,因此可以直接在训练代码中使用。

服务期间的转换

我们需要使转换函数工件(使用WriteTransformFn()保存的工件)可用于预测系统。我们可以通过确保WriteTransformFn()将转换工件写入对服务系统可访问的 Cloud Storage 位置来实现这一点。或者,训练管道可以复制转换工件,以便它们与导出的模型一起可用。

在预测时,所有缩放和预处理操作都将加载并应用于从客户端发送的图像字节:

preproc = tf.keras.models.load_model(
    '.../tft/transform_fn').signatures['transform_signature']
preprocessed = preproc(img_bytes=tf.convert_to_tensor(img_bytes)...)

然后我们对预处理数据调用model.predict()

pred_label_index = tf.math.argmax(model.predict(preprocessed))

在第七章中,我们将看到如何编写一个代表客户端执行这些操作的服务函数。

tf.transform 的好处

请注意,使用tf.transform,我们避免了在tf.data管道中放置预处理代码或将其包含为模型的内在折衷。现在我们可以兼顾两种方法的优势——高效的训练和透明的重复使用以防止训练-服务偏差:

  • 预处理(对输入图像进行缩放和调整大小)只会发生一次。

  • 训练管道读取已经预处理的图像,因此速度很快。

  • 预处理函数被存储为模型工件。

  • 服务函数可以加载模型工件,并在调用模型之前应用预处理(如何实现将在稍后介绍)。

服务函数不需要了解转换的细节,只需知道转换后的工件存储在哪里。一种常见做法是在训练程序的一部分中将这些工件复制到模型输出目录中,以便它们与模型本身一起可用。如果我们更改预处理代码,只需再次运行预处理管道;包含预处理代码的模型工件将得到更新,因此正确的预处理会自动应用。

使用tf.transform除了防止训练-服务偏差之外,还有其他优点。例如,因为tf.transform在训练开始之前甚至可以全局统计数据集(例如平均值),所以可以使用数据集的全局统计信息来缩放值。

数据增强

预处理不仅仅是将图像重新格式化为模型所需的大小和形状。预处理还可以通过数据增强来提高模型质量。

数据增强是解决数据不足问题的一种数据空间解决方案(或缺乏正确类型数据问题),它是一组技术,旨在增强训练数据集的规模和质量,以创建更准确、泛化能力更强的机器学习模型。

深度学习模型具有大量的权重,权重越多,训练模型所需的数据就越多。如果我们的数据集相对于机器学习模型的大小太小,模型可以利用其参数来记忆输入数据,导致过拟合(模型在训练数据上表现良好,但在推断时对未见数据产生较差结果)。

作为一种思维实验,考虑一个具有一百万个权重的机器学习模型。如果我们只有一万张训练图像,那么模型可能会为每张图像分配一百个权重,这些权重可能会聚焦于使每张图像在某种方面独特的某些特征上,例如,这可能是唯一一张图像,在其中有一个围绕特定像素中心的明亮区域。问题在于,这样一个过度拟合的模型在投入生产后性能不会很好。模型需要预测的图像将与训练图像不同,并且它学到的嘈杂信息将毫无帮助。我们需要机器学习模型从训练数据集中泛化。为了实现这一点,我们需要大量的数据,而我们希望的模型越大,我们需要的数据就越多。

数据增强技术涉及对训练数据集中的图像进行变换,以创建新的训练样本。现有的数据增强方法可分为三类:

  • 空间变换,如随机缩放、裁剪、翻转、旋转等

  • 色彩失真以改变亮度、色调等。

  • 信息丢失,例如随机遮罩或擦除图像的不同部分

让我们依次看看每一个。

空间变换

在许多情况下,我们可以翻转或旋转图像而不改变其本质。例如,如果我们试图检测农场设备的类型,水平翻转图像(从左到右,如图 6-9 中顶部行所示)将简单地模拟从另一侧看到的设备。通过使用这种图像变换来增加数据集,我们为模型提供了更多的多样性——意味着以不同大小、空间位置、方向等展示所需图像对象或类的更多示例。这将有助于创建一个能够处理真实数据中这些变化的更健壮模型。

图 6-9. 一幅田野中拖拉机图像的几何变换。照片作者拍摄。

然而,垂直翻转图像(如 Figure 6-9 左侧所示)并不是一个好主意,原因有几点。首先,在生产中不会预期模型正确分类倒置的图像,因此将此类图像添加到训练数据集中没有意义。其次,垂直翻转的拖拉机图像使得 ML 模型更难识别像驾驶室这样的非垂直对称特征。因此,垂直翻转图像不仅添加了模型无需正确分类的图像类型,还使得学习问题更加困难。

小贴士

确保数据增强使训练数据集变大,但不会使问题更加困难。一般来说,只有当增强图像典型于模型预期预测的图像时,才会出现这种情况,而如果增强造成了偏斜的、不自然的图像,则不会。不过,稍后讨论的信息丢弃方法是这一规则的一个例外。

Keras 支持多种数据增强层,包括RandomTranslationRandomRotationRandomZoomRandomCropRandomFlip等等。它们的工作原理都类似。

RandomFlip层将在训练期间随机翻转图像或保持其原始方向。在推理期间,图像将保持不变。Keras 会自动完成这一过程;我们只需将其添加为模型的一层即可:

tf.keras.layers.experimental.preprocessing.RandomFlip(
    mode='horizontal',
    name='random_lr_flip/none'
)

mode参数控制允许的翻转类型,其中horizontal翻转将图像从左到右翻转。其他模式包括verticalhorizontal_and_vertical

在前面的章节中,我们对图像进行了中心裁剪。当我们进行中心裁剪时,会丢失图像的相当部分。为了提高我们的训练性能,我们可以考虑通过从输入图像中随机裁剪出所需大小的随机裁剪数据。在 Keras 中,RandomCrop层会在训练期间进行随机裁剪(使模型在每个 epoch 中看到图像的不同部分,尽管其中一些现在包括填充边缘,甚至可能不包括感兴趣的图像部分),并在推理期间表现得像CenterCrop

此示例的完整代码在06d_augmentation.ipynb on GitHub中。结合这两个操作,我们的模型层现在变为:

layers = [
    tf.keras.layers.experimental.preprocessing.RandomCrop(
        height=IMG_HEIGHT//2, width=IMG_WIDTH//2,
        input_shape=(IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS),
        name='random/center_crop'
    ),
    tf.keras.layers.experimental.preprocessing.RandomFlip(
        mode='horizontal',
        name='random_lr_flip/none'
    ),
    hub.KerasLayer(
        "https://tfhub.dev/.../mobilenet_v2/...",
        trainable=False,
        name='mobilenet_embedding'),
    tf.keras.layers.Dense(
        num_hidden,
        kernel_regularizer=regularizer,
        activation=tf.keras.activations.relu,
        name='dense_hidden'),
    tf.keras.layers.Dense(
        len(CLASS_NAMES),
        kernel_regularizer=regularizer,
        activation='softmax',
        name='flower_prob')
]

而模型本身变成:

Model: "flower_classification"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
random/center_crop (RandomCr (None, 224, 224, 3)       0
_________________________________________________________________
random_lr_flip/none (RandomF (None, 224, 224, 3)       0
_________________________________________________________________
mobilenet_embedding (KerasLa (None, 1280)              2257984
_________________________________________________________________
dense_hidden (Dense)         (None, 16)                20496
_________________________________________________________________
flower_prob (Dense)          (None, 5)                 85

训练这个模型类似于没有进行数据增强的训练。然而,每当我们进行数据增强时,需要更长时间地训练模型——直观上说,我们需要训练两倍的 epoch 以使模型看到图像的两种翻转。结果显示在 Figure 6-10 中。

图 6-10. MobileNet 迁移学习模型的损失和准确率曲线。与图 6-7 进行比较。

将图 6-10 与图 6-7 进行比较,我们可以注意到增加数据增强后模型训练的更具鲁棒性。请注意,训练和验证损失几乎同步,训练和验证精度也如此。精度为 0.86,略高于之前的 0.85;重要的是,由于训练曲线表现更好,我们对这个精度更有信心。

通过添加数据增强,我们显著降低了过拟合的程度。

色彩失真

不要仅限于现有的增强层集合。相反,考虑模型在生产中可能遇到的图像变化类型。例如,提供给 ML 模型的照片(特别是来自业余摄影师的照片)在光照方面可能会有很大的差异。因此,如果我们通过随机改变训练图像的亮度、对比度、饱和度等来增强数据,我们可以增加训练数据集的有效大小,使 ML 模型更具鲁棒性。虽然 Keras 提供了几个内置的数据增强层(如RandomFlip),但目前不支持更改对比度³和亮度。因此,让我们自己来实现这一功能。

我们将从头开始创建一个数据增强层,随机改变图像的对比度和亮度。该类将继承自 Keras 的Layer类,并接受两个参数,即调整对比度和亮度的范围(完整代码在06e_colordistortion.ipynb on GitHub中):

class RandomColorDistortion(tf.keras.layers.Layer):
    def __init__(self, contrast_range=[0.5, 1.5],
                 brightness_delta=[-0.2, 0.2], **kwargs):
        super(RandomColorDistortion, self).__init__(**kwargs)
        self.contrast_range = contrast_range
        self.brightness_delta = brightness_delta

调用此层时,其行为将根据是否处于训练模式而有所不同。如果不处于训练模式,则该层将简单返回原始图像。如果处于训练模式,则将生成两个随机数,一个用于调整图像内的对比度,另一个用于调整亮度。实际的调整操作是使用tf.image模块中可用的方法进行的:

    def call(self, images, training=False):
        if not training:
            return images

        contrast = np.random.uniform(
            self.contrast_range[0], self.contrast_range[1])
        brightness = np.random.uniform(
            self.brightness_delta[0], self.brightness_delta[1])

        images = tf.image.adjust_contrast(images, contrast)
        images = tf.image.adjust_brightness(images, brightness)
        images = tf.clip_by_value(images, 0, 1)
        return images
提示

实施自定义增强层时很重要的一点是使用 TensorFlow 函数,这样这些函数可以在 GPU 上高效实现。参见第七章,了解编写高效数据管道的建议。

这一层对几个训练图像的效果显示在图 6-11 中。请注意,图像具有不同的对比度和亮度级别。通过在每个输入图像上每个周期调用此层多次,我们确保模型能够看到原始训练图像的许多颜色变化。

图 6-11。对三个训练图像进行随机对比度和亮度调整。每行的第一个面板显示原始图像,其他面板显示生成的四幅图像。如果您查看的是灰度图像,请参考 GitHub 上的06e_colordistortion.ipynb来查看颜色失真的效果。

层本身可以在RandomFlip层之后插入模型:

layers = [
    ...
    tf.keras.layers.experimental.preprocessing.`RandomFlip`(
        mode='horizontal',
        name='random_lr_flip/none'
    ),
    `RandomColorDistortion``(``name``=``'``random_contrast_brightness/none``'``)`,
    hub.KerasLayer ...
]

然后整个模型将具有这种结构:

Model: "flower_classification"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
random/center_crop (RandomCr (None, 224, 224, 3)       0
_________________________________________________________________
random_lr_flip/none (RandomF (None, 224, 224, 3)       0
_________________________________________________________________
`random_contrast_brightness``/``n` `(``None``,` `224``,` `224``,` `3``)`       `0`
_________________________________________________________________
mobilenet_embedding (KerasLa (None, 1280)              2257984
_________________________________________________________________
dense_hidden (Dense)         (None, 16)                20496
_________________________________________________________________
flower_prob (Dense)          (None, 5)                 85
=================================================================
Total params: 2,278,565
Trainable params: 20,581
Non-trainable params: 2,257,984

模型的训练保持不变。结果显示在图 6-12 中。我们比仅使用几何增强获得更好的准确性(0.88 而不是 0.86),训练和验证曲线保持完全同步,表明过拟合得到控制。

图 6-12。MobileNet 迁移学习模型的损失和准确率曲线,带有几何和颜色增强。与图 6-7 和 6-10 进行比较。

信息丢弃

最近的研究突出了一些数据增强中的新想法,涉及对图像进行更激烈的更改。这些技术丢弃图像中的信息,以使训练过程更具韧性,并帮助模型关注图像的重要特征。它们包括:

Cutout

在训练过程中随机遮罩输入的方形区域。这有助于模型学习忽略图像中无信息的部分(如天空),并注意区分性的部分(如花瓣)。

Mixup

线性插值一对训练图像,并将相应的插值标签值分配为它们的标签。

CutMix

一个 cutout 和 mixup 的组合。从不同的训练图像中剪切补丁,并且按照补丁区域的面积比例混合地真实标签。

GridMask

在控制删除区域的密度和大小的同时删除均匀分布的方形区域。其基本假设是故意收集的图像——均匀分布的方形区域往往是背景。

Cutout 和 GridMask 是对单个图像进行的预处理操作,可以类似于我们实现的颜色失真。开源代码可以在 GitHub 上找到 cutoutGridMask

然而,Mixup 和 CutMix 使用来自多个训练图像的信息创建合成图像,这些图像可能与现实没有任何相似之处。在本节中,我们将看看如何实现 mixup,因为它更简单。完整的代码在 GitHub 上的 06f_mixup.ipynb 中。

mixup 的背后思想是线性插值一对训练图像及其标签。我们无法在 Keras 自定义层中实现这一点,因为该层只接收图像,而不接收标签。因此,让我们实现一个函数,接收一个图像和标签的批次,并执行 mixup:

def augment_mixup(img, label):
    # parameters
    fracn = np.rint(MIXUP_FRAC * len(img)).astype(np.int32)
    wt = np.random.uniform(0.5, 0.8)

在这段代码中,我们定义了两个参数:fracnwt。我们不会将批次中所有图像混合在一起,而是将其中一部分图像混合在一起(默认情况下为 0.4),并保留其余图像(和标签)。参数 fracn 是我们必须混合的批次中的图像数量。在函数中,我们还会选择一个权重因子 wt,介于 0.1 和 0.4 之间,来插值图像对。

要进行插值,我们需要图像对。第一组图像将是批次中的前 fracn 个图像:

img1, label1 = img[:fracn], label[:fracn]

那么每对中的第二张图像呢?我们将做一些非常简单的事情:我们将选择下一张图像,这样第一张图像就与第二张图像插值,第二张图像与第三张图像插值,依此类推。现在我们有了图像/标签对,插值可以按以下方式完成:

def _interpolate(b1, b2, wt):
    return wt*b1 + (1-wt)*b2
interp_img = _interpolate(img1, img2, wt)
interp_label = _interpolate(label1, label2, wt)

结果显示在 Figure 6-13 中。顶部行是五个图像的原始批次。底部行是 mixup 的结果:5 的 40% 是 2,因此混合的是前两个图像,剩下的三个图像保持不变。第一个混合图像是通过对前两个原始图像进行插值得到的,第一个图像权重为 0.63,第二个图像权重为 0.37。第二个混合图像是通过混合顶部行中的第二个和第三个图像得到的。请注意,标签(每个图像上方的数组)显示了 mixup 的影响。

img2, label2 = img[1:fracn+1], label[1:fracn+1] # offset by one

图 6-13. 五个图像及其标签的 mixup 结果。顶部行是原始批次中的图像。底部行是 mixup 的结果,40% 的批次是第一二个图像被混合。

到目前为止,我们从前 fracn+1 个图像中生成了 fracn 对插值图像(我们需要 fracn+1 个图像来获取 fracn 对,因为第 fracn 个图像是与第 fracn+1 个图像插值的)。然后,我们堆叠插值图像和剩余的未改变图像,以重新获得一个 batch_size 的图像:

img = tf.concat([interp_img, img[fracn:]], axis=0)
label = tf.concat([interp_label, label[fracn:]], axis=0)

augment_mixup() 方法可以传递到用于创建训练数据集的 tf.data 管道中:

train_dataset = create_preproc_dataset(...) \
    .shuffle(8 * batch_size) \
    .batch(batch_size, drop_remainder=True) \
    .map(augment_mixup)

在这段代码中,有几点需要注意。首先,我们添加了一个 shuffle() 步骤,以确保每个 epoch 的批次是不同的(否则,我们在 mixup 中将得不到任何变化)。我们要求 tf.data 丢弃最后一个批次中的任何剩余项目,因为在非常小的批次上计算参数 n 可能会遇到问题。由于洗牌,每次我们都会丢弃不同的项目,所以我们对此并不过于担心。

提示

shuffle() 函数的工作原理是将记录读入缓冲区,对缓冲区中的记录进行洗牌,然后将记录提供给数据管道的下一步。因为我们希望每个 epoch 中的批次中的记录是不同的,所以我们需要的洗牌缓冲区大小要远远大于批次大小——在批次内部洗牌是不够的。因此,我们使用:

.shuffle(`8` `*` `batch_size`)

如果我们保持标签为稀疏整数(例如,郁金香的标签为 4),则无法进行插值标签。相反,我们必须对标签进行独热编码(参见 图 6-13)。因此,我们对训练程序进行了两个更改。首先,我们的 read_from_tfr() 方法执行独热编码,而不是简单地返回 label_int

def read_from_tfr(self, proto):
    ...
    rec = tf.io.parse_single_example(
        proto, feature_description
    )
    shape = tf.sparse.to_dense(rec['shape'])
    img = tf.reshape(tf.sparse.to_dense(rec['image']), shape)
    label_int = rec['label_int']
    return img, `tf``.``one_hot``(``label_int``,` `len``(``CLASS_NAMES``)``)`

其次,我们将损失函数从 SparseCategoricalCrossentropy() 更改为 CategoricalCrossentropy(),因为标签现在是独热编码:

model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=lrate),
              loss=tf.keras.losses.`CategoricalCrossentropy`(
                  from_logits=False),
              metrics=['accuracy'])

在 5-花数据集上,mixup 并未改善模型的性能——使用 mixup 的准确率(0.88,见 图 6-14)与未使用时相同。然而,在其他情况下可能有所帮助。回想一下,信息丢弃帮助模型学会忽略图像中无用的部分,而 mixup 通过线性插值训练图像对工作。因此,在仅有图像的一小部分信息有用且像素强度信息有用的情况下,信息丢弃通过 mixup 将起到良好的作用——例如,遥感图像中我们试图识别的破坏林地。

图 6-14。MobileNet 转移学习模型与 mixup 的损失和准确率曲线。与 图 6-12 进行比较。

有趣的是,验证的准确率和损失现在比训练的更好。当我们意识到训练数据集比验证数据集“更难”时,这是合理的——验证集中没有混合图像。

形成输入图像

到目前为止,我们看过的预处理操作是一对一的,即它们仅修改输入图像,并为每个输入图像提供一个单独的图像给模型。 然而,这并非必须如此。 有时,使用预处理管道将每个输入拆分为多个图像并将其馈送到模型进行训练和推断可能是有帮助的(见图 6-15)。

图 6-15. 将单个输入拆分为用于训练模型的组成图像。 在训练期间用于将输入拆分为其组成图像的操作也必须在推断期间重复执行。

形成输入模型的图像的一种方法是平铺。 在我们需要处理极大图像并且可以对大图像的部分进行预测并进行组合的任何领域中,平铺都是非常有用的。 这通常适用于地理空间图像(识别被砍伐的区域)、医学图像(识别癌组织)和监控(识别工厂地板上的液体泄漏)。

假设我们有一幅地球的遥感图像,并希望识别森林火灾(参见图 6-16)。 为此,机器学习模型需要预测单个像素是否包含森林火灾。 这样的模型的输入将是一个平铺,即原始图像周围即将预测的像素部分。 我们可以对地理空间图像进行预处理,以生成用于训练 ML 模型和获取其预测的相同大小的平铺。

图 6-16. 加利福尼亚野火的遥感图像。 图像由美国国家海洋和大气管理局提供。

对于每个平铺,我们需要一个标签来表示平铺内是否有火灾。 要创建这些标签,我们可以利用火灾观察塔检测到的火灾位置,并将其映射到与遥感图像相同大小的图像中(完整代码在 GitHub 上的06g_tiling.ipynb中):

fire_label = np.zeros((338, 600))
for loc in fire_locations:
    fire_label[loc[0]][loc[1]] = 1.0

要生成平铺,我们将提取所需平铺尺寸的补丁,并通过平铺高度和宽度的一半前进(使平铺重叠):

tiles = tf.image.extract_patches(
    images=images,
    sizes=[1, TILE_HT, TILE_WD, 1],
    strides=[1, TILE_HT//2, TILE_WD//2, 1],
    rates=[1, 1, 1, 1],
    padding='VALID')

在进行几次重塑操作后的结果如图 6-17 所示。 在这个图中,我们还通过其标签注释每个平铺。 图像平铺的标签通过查找相应标签平铺内的最大值来获取(如果平铺包含fire_location点,则该值将为 1.0):

labels = tile_image(labels)
labels = tf.reduce_max(labels, axis=[1, 2, 3])

图 6-17. 加利福尼亚野火遥感图像生成的平铺。 带有火灾的平铺标记为“Fire”。 图像由美国国家海洋和大气管理局提供。

这些瓦片及其标签现在可以用来训练图像分类模型。通过减少生成瓦片的步幅,我们可以增强训练数据集。

总结

本章中,我们探讨了图像预处理的各种必要性。可能是为了将输入数据重新格式化和重塑为模型所需的数据类型和形状,或者通过诸如缩放和裁剪等操作来提高数据质量。进行预处理的另一个原因是执行数据增强,这是一组技术,通过从现有训练数据集生成新的训练样本,以提高模型的准确性和鲁棒性。我们还讨论了如何实现每种类型的预处理,既作为 Keras 层,也通过将 TensorFlow 操作封装为 Keras 层。

在下一章中,我们将深入讨论训练循环本身。

¹ 现实更为复杂。可能存在数据预处理(例如,数据增强,在下一节中讨论)仅在训练期间才希望应用的情况。并非所有数据预处理都需要在训练和推理之间保持一致。

² 理想情况下,该类中所有的函数都应该是私有的,只有create_preproc_dataset()create_preproc_image()这两个函数是公开的。不幸的是,在撰写本文时,tf.data的映射功能无法处理私有方法作为 lambda 函数所需的名称处理。类名中的下划线提醒我们其方法本应是私有的。

³ RandomContrast 在撰写本节时和书稿付印之间进行了添加。

第七章:训练管道

预处理后的阶段是模型训练,在此期间,机器学习模型将读取训练数据,并使用该数据调整其权重(参见 图 7-1)。训练后,将保存或导出模型以便部署。

图 7-1. 在模型训练过程中,ML 模型在预处理数据上进行训练,然后导出用于部署。导出的模型用于进行预测。

在本章中,我们将探讨如何使训练(和验证)数据的摄取过程更加高效。我们将利用我们可以使用的不同计算设备(CPU 和 GPU)之间的时间切片,并研究如何使整个过程更具韧性和可重现性。

小贴士

本章的代码位于书籍的 07_training 文件夹中的 GitHub 代码库 中。适用时,我们将提供代码示例和笔记本的文件名。

高效摄取

训练机器学习模型所花费的时间中,有很大一部分用于摄取数据——读取并将其转换为模型可用的形式。我们可以通过以下方式来简化和加快训练管道的这个阶段,从而提高效率:

高效存储数据

我们应尽可能多地预处理输入图像,并以便于读取的方式存储预处理值。

并行化数据读取

在摄取数据时,存储设备的速度往往是瓶颈。不同文件可以存储在不同的磁盘上,也可以通过不同的网络连接读取,因此通常可以并行读取数据。

与训练并行准备图像

如果我们可以在 GPU 训练的同时在 CPU 上并行预处理图像,我们应该这样做。

最大化 GPU 利用率

尽可能多地在 GPU 上进行矩阵和数学运算,因为它比 CPU 快几个数量级。如果我们的任何预处理操作涉及这些操作,我们应该将它们推送到 GPU 上。

让我们更详细地看看这些想法。

高效存储数据

将图像存储为单独的 JPEG 文件从机器学习的角度来看效率不高。在 第五章 中,我们讨论了如何将 JPEG 图像转换为 TensorFlow Records。在本节中,我们将解释为什么 TFRecords 是一种高效的存储机制,并考虑在将数据写出之前进行的预处理量在灵活性和效率之间的权衡。

TensorFlow Records

为什么将图像存储为 TensorFlow Records?让我们考虑一下文件格式中我们寻找的内容。

我们知道我们将批量读取这些图像,因此最好能够使用单个网络连接读取整个图像批次,而不是为每个文件打开一个连接。一次性读取一个批次也将为我们的机器学习管道提供更大的吞吐量,并最小化 GPU 等待下一批图像的时间。

理想情况下,我们希望文件的大小在 10 到 100 MB 之间。这样可以在多个工作节点(每个 GPU 一个)中平衡读取图像的能力,并确保每个文件打开的时间足够长,以便在许多批次中分摊读取第一个字节的延迟。

此外,我们希望文件格式能够使从文件中读取的字节能够立即映射到内存结构,而无需解析文件或处理不同类型机器(如字节序)之间的存储布局差异。

符合所有这些标准的文件格式是 TensorFlow Records。我们可以将用于训练、验证和测试的图像数据存储到单独的 TFRecord 文件中,并在每个文件约 100 MB 的范围内进行分片。Apache Beam 提供了一个方便的 TFRecord 写入器,我们在第五章中使用过。

存储预处理数据

如果我们在训练循环中不需要进行预处理,可以提升训练管道的性能。我们可以在 JPEG 图像上执行所需的预处理,然后将预处理后的数据而不是原始数据写入。

在实践中,我们必须在创建 TensorFlow Records 的 ETL 管道和模型代码本身之间分割预处理操作。为什么不全部在 ETL 管道中完成或全部在模型代码中完成?原因是应用于 ETL 管道中的预处理操作仅在模型训练的每个周期中执行一次。然而,总会有预处理操作是特定于我们正在训练的模型或者需要在每个周期中有所不同的。这些不能在 ETL 管道中完成,必须在训练代码中完成。

在第五章中,我们解码了 JPEG 文件,将其缩放到 [0, 1] 之间,展开数组,并将展开的数组写入 TensorFlow Records 中:

def create_tfrecord(filename, label, label_int):
    img = tf.io.read_file(filename)
    img = tf.image.decode_jpeg(img, channels=IMG_CHANNELS)
    img = tf.image.convert_image_dtype(img, tf.float32)
    img = tf.reshape(img, [-1]) # flatten to 1D array
    return tf.train.Example(features=tf.train.Features(feature={
        `'``image``'`: _float_feature(img),
        ...
    })).SerializeToString()

在编写 TensorFlow Records 之前选择的操作是明确选择的。

如果愿意的话,我们可以做得更少——我们可以简单地读取 JPEG 文件,将每个文件的内容作为字符串写入 TensorFlow Records 中:

def create_tfrecord(filename, label, label_int):
    img = tf.io.read_file(filename)
    return tf.train.Example(features=tf.train.Features(feature={
        `'``image``'`: `_bytes_feature`(img),
        ...
    })).SerializeToString()

如果我们担心可能存在不同的文件格式(JPEG、PNG 等)或 TensorFlow 不理解的图像格式,我们可以解码每个图像,将像素值转换为公共格式,并将压缩的 JPEG 写成字符串存储起来。

我们也可以做得更多。例如,我们可以创建图像的嵌入并不是写入图像数据,而只是嵌入数据:

embedding_encoder = tf.keras.Sequential([
    hub.KerasLayer(
        "https://tfhub.dev/.../mobilenet_v2/...",
        trainable=False,
        input_shape=(256, 256, IMG_CHANNELS),
        name='mobilenet_embedding'),
])

def create_tfrecord(filename, label, label_int):
    img = tf.io.read_file(filename)
    img = tf.image.decode_jpeg(img, channels=IMG_CHANNELS)
    img = tf.image.convert_image_dtype(img, tf.float32)
    img = tf.resize(img, [256, 256, 3])
    embed = embedding_encoder(filename)
    embed = tf.reshape(embed, [-1]) # flatten to 1D array
    return tf.train.Example(features=tf.train.Features(feature={
        `'``image_embedding``'`: _float_feature(embed),
        ...
    })).SerializeToString()

选择执行哪些操作涉及效率和可重用性之间的权衡。这也受到我们所设想的可重用性类型的影响。请记住,ML 模型训练是一个高度迭代的实验过程。每次训练实验都会多次(由 epochs 数量指定)遍历训练数据集。因此,每个训练数据集中的 TFRecord 都必须被多次处理。我们能在写入 TFRecords 之前执行的处理越多,训练流水线本身就要进行的处理就越少。这将导致更快速、更高效的训练和更高的数据吞吐量。这个优势会倍增,因为通常我们不仅仅会训练一次模型;我们会使用多个超参数运行多次实验。另一方面,我们必须确保我们正在进行的预处理对我们希望使用这个数据集训练的所有 ML 模型都是有益的——我们进行的预处理越多,数据集的可重用性可能就越低。我们还不应该陷入微小优化的领域,这些微小优化可能会微幅提高速度,但会使代码变得不够清晰或可重用。

如果我们将图像嵌入(而不是像素值)写入 TensorFlow Records,训练流水线将会非常高效,因为嵌入计算通常涉及将图像通过一百多个神经网络层。效率提升是相当可观的。然而,这假定我们将进行迁移学习。我们不能使用这个数据集从头开始训练图像模型。当然,存储比计算便宜得多,我们可能还会发现创建两个数据集(一个是嵌入数据,另一个是像素值)是有利的。

因为 TensorFlow Records 可能在预处理方面有所不同,因此在元数据的形式中记录这一点是一个好的做法。解释记录中存在的数据以及数据生成方式。像Google Cloud 数据目录CollibraInformatica等通用工具可以帮助这一点,还有像Feast 特征存储这样的自定义 ML 框架。

并行读取数据

提高将数据导入训练流水线效率的另一种方法是并行读取记录。在第六章中,我们读取已写入的 TFRecords,并使用以下方法进行预处理:

preproc = _Preprocessor()
trainds = tf.data.TFRecordDataset(pattern)
            .map(preproc.read_from_tfr)
            .map(_preproc_img_label)

在这段代码中,我们正在做三件事:

  1. 从模式中创建TFRecordDataset

  2. 将每个文件中的记录传递给read_from_tfr(),该函数返回一个(img, label)元组。

  3. 使用_preproc_img_label()预处理元组

并行化

我们可以对我们的代码进行一些改进,假设我们在一台具有多个虚拟 CPU 的机器上运行(大多数现代机器至少有两个 vCPU,通常更多)。首先,我们可以在创建数据集时要求 TensorFlow 自动交错读取:

tf.data.TFRecordDataset(pattern, num_parallel_reads=AUTO)

其次,两个 map() 操作可以使用并行化:

.map(preproc.read_from_tfr, num_parallel_calls=AUTOTUNE)

测量性能

为了衡量这些变化的性能影响,我们需要遍历数据集并执行一些数学运算。让我们计算所有图像的平均值。为了防止 TensorFlow 优化掉任何计算(见下面的侧边栏),我们只计算每次迭代中高于某个随机阈值的像素的平均值:

def loop_through_dataset(ds, nepochs):
    lowest_mean = tf.constant(1.)
    for epoch in range(nepochs):
        thresh = np.random.uniform(0.3, 0.7)  # random threshold
        ...
        for (img, label) in ds:
            ...
            mean = tf.reduce_mean(tf.where(img > thresh, img, 0))
            ...

表 7-1 显示了在使用不同机制时,测量前述循环性能的结果。显然,虽然额外的并行化增加了整体 CPU 时间,但实际的挂钟时间随着每次并行化减少。通过使映射并行化并交错两个数据集,我们减少了 35%的时间。

表 7-1. 使用不同方式处理小数据集时循环所需的时间

方法 CPU 时间 墙上时间
普通 7.53 s 7.99 s
并行映射 8.30 s 5.94 s
交错 8.60 s 5.47 s
交错 + 并行映射 8.44 s 5.23 s

这种性能提升是否会延续到机器学习模型?为了测试这一点,我们可以尝试训练一个简单的线性分类模型,而不是使用 loop_through_dataset() 函数:

def train_simple_model(ds, nepochs):
    model = tf.keras.Sequential([
        tf.keras.layers.Flatten(
            input_shape=(IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS)),
        tf.keras.layers.Dense(len(CLASS_NAMES), activation='softmax')
    ])
    model.compile(optimizer=tf.keras.optimizers.Adam(),
                  loss=tf.keras.losses.SparseCategoricalCrossentropy(
                      from_logits=False),
                  metrics=['accuracy'])
    model.fit(ds, epochs=nepochs)

结果显示在表 7-2 中,说明性能提升是可持续的——我们在第一行和最后一行之间获得了 25%的加速。随着模型复杂度的增加,I/O 在整体时间中的作用越来越小,因此改进的效果也就相应减少。

表 7-2. 使用不同方式处理小数据集时训练线性 ML 模型所需的时间

方法 CPU 时间 墙上时间
普通 9.91 s 9.39 s
并行映射 10.7 s 8.17 s
交错 10.5 s 7.54 s
交错 + 并行映射 10.3 s 7.17 s

循环遍历数据集比在完整训练数据集上训练实际的 ML 模型更快。用它作为一种轻量级方式来运行您的摄取代码,以调整 I/O 部分的性能。

最大化 GPU 利用率

由于 GPU 在执行机器学习模型操作时更有效率,我们的目标应该是最大化它们的利用率。如果我们按小时租用 GPU(就像在公共云中那样),最大化 GPU 利用率将使我们能够利用它们的增强效率,从而比在 CPU 上训练时获得更低的总体训练成本。

有三个因素会影响我们模型的性能:

  1. 每次在 CPU 和 GPU 之间传输数据时,该传输都需要时间。

  2. GPU 在矩阵数学上非常高效。我们对单个项目执行的操作越多,就越少利用 GPU 提供的性能加速。

  3. GPU 具有有限的内存。

这些因素在优化中起到了作用,可以改善我们训练循环的性能。在本节中,我们将探讨最大化 GPU 利用率的三个核心思想:高效数据处理、向量化和保持在图中。

高效数据处理

当我们在 GPU 上训练模型时,CPU 会处于空闲状态,而 GPU 则在计算梯度和进行权重更新。

为了让 CPU 有事可做,我们可以要求它预取数据,这样下一批数据就准备好传递给 GPU:

ds = create_preproc_dataset(
    'gs://practical-ml-vision-book/flowers_tfr/train' + PATTERN_SUFFIX
)`.``prefetch``(``AUTOTUNE``)`

如果我们有一个小数据集,特别是图像或 TensorFlow 记录必须通过网络读取的数据集,将它们缓存在本地也可能会有所帮助:

ds = create_preproc_dataset(
    'gs://practical-ml-vision-book/flowers_tfr/train' + PATTERN_SUFFIX
).cache()

表格 7-3 展示了预取和缓存对模型训练时间的影响。

表格 7-3. 在小数据集上训练线性 ML 模型时,当输入记录被预取和/或缓存时所花费的时间

方法 CPU 时间 墙时间
交错 + 并行 9.68 s 6.37 s
缓存 6.16 s 4.36 s
预取 + 缓存 5.76 s 4.04 s
注意

根据我们的经验,缓存通常只适用于小(玩具)数据集。对于大数据集,您可能会耗尽本地存储空间。

向量化

因为 GPU 擅长矩阵操作,我们应该尽量给 GPU 提供它可以处理的最大数据量。而不是一次传递一个图像,我们应该传递一个图像批次——这称为向量化

要对记录进行批处理,我们可以这样做:

ds = create_preproc_dataset(
    'gs://practical-ml-vision-book/flowers_tfr/train' + PATTERN_SUFFIX
).prefetch(AUTOTUNE).`batch``(``32``)`

重要的是要意识到整个 Keras 模型是批处理操作。因此,我们添加的RandomFlipRandomColorDistortion预处理层不是逐个图像处理,而是处理图像批次。

批量大小越大,训练循环通过一个时期的速度就越快。但是,增加批量大小会带来递减的回报。此外,还受到 GPU 内存限制的限制。值得对使用更大、更昂贵的机器(具有更多 GPU 内存)进行更短时间训练的成本效益进行分析,与使用较小、更便宜的机器进行更长时间训练进行比较。

提示

在 Google 的 Vertex AI 上训练时,GPU 内存使用情况和利用率会自动报告每个作业。Azure 允许您为 GPU 监控配置容器。Amazon CloudWatch 在 AWS 上提供 GPU 监控。如果您管理自己的基础设施,请使用类似 nvidia-smiAMD 系统监视器 的 GPU 工具。您可以使用这些工具诊断 GPU 的使用效果以及 GPU 内存是否有余地来增加批量大小。

在表 7-4 中,我们展示了改变批量大小对线性模型的影响。更大的批量更快,但有递减回报,并且在某一点之后我们将耗尽板载 GPU 内存。增加批量大小后的更快性能是 TPU 成本效益如此高的原因之一,因为它们具有大容量的板载内存和共享内存的互联核心。

表 7-4. 在不同批量大小下训练线性 ML 模型所需时间

方法 CPU 时间 墙时间
批量大小 1 11.4 s 8.09 s
批量大小 8 9.56 s 6.90 s
批量大小 16 9.90 s 6.70 s
批量大小 32 9.68 s 6.37 s

我们将随机翻转、颜色失真和其他预处理以及数据增强步骤作为 Keras 层在第六章中实现的一个关键原因与批处理有关。我们本可以使用 map() 来进行颜色失真,如下所示:

trainds = tf.data.TFRecordDataset(
        [filename for filename in tf.io.gfile.glob(pattern)]
    ).map(preproc.read_from_tfr).map(_preproc_img_label
    )`.``map``(``color_distort``)`.batch(32)

其中 color_distort() 是:

def color_distort(image, label):
    contrast = np.random.uniform(0.5, 1.5)
    brightness = np.random.uniform(-0.2, 0.2)
    image = tf.image.adjust_contrast(image, contrast)
    image = tf.image.adjust_brightness(image, brightness)
    image = tf.clip_by_value(image, 0, 1)
    return image, label

但这样做效率低下,因为训练流水线将不得不逐个图像进行颜色失真。如果我们在 Keras 层中执行预处理操作,则效率要高得多。这样,预处理就可以在一个步骤中对整个批次进行。另一种选择是通过编写以下代码对颜色失真操作进行向量化:

    ).batch(32).map(color_distort)

这也会导致颜色失真发生在一批数据上。然而,最佳实践是在 Keras 层中编写遵循 batch() 操作的预处理代码。有两个原因支持这样做。首先,如果我们始终将对 batch() 的调用作为硬边界,则摄入代码与模型代码之间的分离更清晰且更易维护。其次,将预处理保持在 Keras 层中(见第六章)使得在推断流水线中复现预处理功能更容易,因为所有模型层都会自动导出。

留在图中

因为在 GPU 上执行数学函数比在 CPU 上高效得多,TensorFlow 使用 CPU 读取数据,将数据传输到 GPU,然后在 GPU 上运行属于 tf.data 管道的所有代码(例如 map() 调用中的代码)。它还在 GPU 上运行 Keras 模型层中的所有代码。由于我们直接将数据从 tf.data 管道发送到 Keras 输入层,因此无需传输数据——数据保留在 TensorFlow 图中。数据和模型权重都保留在 GPU 内存中。

这意味着我们必须非常小心,确保在 CPU 将数据传送到 GPU 后,不要做任何可能涉及移动数据出 TensorFlow 图的操作。数据传输会带来额外的开销,而且在 CPU 上执行的任何代码都会变慢。

迭代

例如,假设我们正在读取加利福尼亚野火的卫星图像,并希望根据光度测定来应用特定的公式将 RGB 像素值转换为单一的“灰度”图像(见图 7-2),完整的代码在 GitHub 上的 07b_gpumax.ipynb 中:

def to_grayscale(img):
    rows, cols, _ = img.shape
    result = np.zeros([rows, cols], dtype=np.float32)
    for row in range(rows):
        for col in range(cols):
            red = img[row][col][0]
            green = img[row][col][1]
            blue = img[row][col][2]
            c_linear = 0.2126 * red + 0.7152 * green + 0.0722 * blue
            if c_linear > 0.0031308:
                result[row][col] = 1.055 * pow(c_linear, 1/2.4) - 0.055
            else:
                result[row][col] = 12.92 * c_linear
    return result

这个函数存在三个问题:

  • 它需要遍历图像像素:

    rows, cols, _ = img.shape
        for row in range(rows):
        for col in range(cols):
    
  • 它需要读取单个像素值:

    green = img[row][col][1]
    
  • 它需要更改输出像素值:

    result[row][col] = 12.92 * c_linear
    

图 7-2. 顶部:具有三个通道的原始图像。底部:只有一个通道的转换后的图像。加利福尼亚野火图像由 NOAA 提供。

这些操作无法在 TensorFlow 图中执行。因此,为了调用该函数,我们需要使用 .numpy() 将其从图中提取出来,进行转换,然后将结果作为张量推回图中(gray 被转换为张量以进行 reduce_mean() 操作)。

切片和条件语句

我们可以通过使用 TensorFlow 的切片功能避免显式迭代和像素级读写:

def to_grayscale(img):
    # TensorFlow slicing functionality
    red = `img``[``:``,` `:``,` `0``]`
    green = img[:, :, 1]
    blue = img[:, :, 2]
    c_linear = 0.2126 * red + 0.7152 * green + 0.0722 * blue

请注意,此代码片段的最后一行实际上是在张量上操作的(red 是一个张量,不是标量),并使用操作符重载(+ 实际上是 tf.add())来调用 TensorFlow 函数。

但是我们如何在原始代码中执行 if 语句?

if c_linear > 0.0031308:
    result[row][col] = 1.055 * pow(c_linear, 1 / 2.4) - 0.055
else:
    result[row][col] = 12.92 * c_linear

if 语句假定 c_linear 是一个单一的浮点值,而现在 c_linear 是一个二维张量。

为了将条件语句推入图中并避免逐个设置像素值,我们可以使用 tf.cond() 和/或 tf.where()

gray = tf.where(c_linear > 0.0031308,
                1.055 * tf.pow(c_linear, 1 / 2.4) - 0.055,
                12.92 * c_linear)

有一点需要注意的是,此示例中 tf.where() 的所有三个参数实际上都是二维张量。还要注意使用 tf.pow() 而不是 pow()。在 tf.cond()tf.where() 之间的选择时,应优先使用 tf.where(),因为它更快。

这将导致超过 10 倍的速度提升。

矩阵运算

可以进一步优化 c_linear 的计算。这是我们的代码:

red = img[:, :, 0]
green = img[:, :, 1]
blue = img[:, :, 2]
c_linear = 0.2126 * red + 0.7152 * green + 0.0722 * blue

如果我们仔细观察这个计算,我们会发现我们不需要切片。相反,如果我们将常数放入一个 3x1 张量中,我们可以将计算写成矩阵乘法:

def to_grayscale(img):
    wt = tf.constant([[0.2126], [0.7152], [0.0722]]) # 3x1 matrix
    `c_linear` `=` `tf``.``matmul``(``img``,` `wt``)`  # (ht,wd,3) x (3x1) -> (ht, wd)
    gray = tf.where(c_linear > 0.0031308,
                    1.055 * tf.pow(c_linear, 1 / 2.4) - 0.055,
                    12.92 * c_linear)
    return gray

通过这种优化,我们额外获得 4 倍的加速。

批处理

一旦我们用矩阵数学写下了c_linear的计算,我们还意识到我们不需要逐个图像地处理数据。我们可以一次处理一批图像。我们可以使用自定义 Keras 层或Lambda层在一批图像上进行计算。

让我们将灰度计算封装到一个自定义 Keras 层的call()语句中:

class Grayscale(tf.keras.layers.Layer):
    def __init__(self, **kwargs):
        super(Grayscale, self).__init__(kwargs)

    def call(self, img):
        wt = tf.constant([[0.2126], [0.7152], [0.0722]]) # 3x1 matrix
        c_linear = tf.matmul(img, wt)  #(N, ht,wd,3)x(3x1)->(N, ht, wd)
        gray = tf.where(c_linear > 0.0031308,
                        1.055 * tf.pow(c_linear, 1 / 2.4) - 0.055,
                        12.92 * c_linear)
        return gray # (N, ht, wd)

一个重要的注意事项是,输入矩阵现在是一个 4D 张量,第一个维度是批量大小。因此,结果是一个 3D 张量。

调用此代码的客户端可以计算每个图像的平均值以获取一个 1D 张量的平均值:

tf.keras.layers.Lambda(lambda gray: tf.reduce_mean(gray, axis=[1, 2]))

我们可以将这两层组合成一个 Keras 模型,或者将它们放在现有模型的前面:

preproc_model = tf.keras.Sequential([
    Grayscale(input_shape=(336, 600, 3)),
    tf.keras.layers.Lambda(lambda gray: tf.reduce_mean(
                              gray, axis=[1, 2]))  # note axis change
])

所有讨论过的方法在本节中的时间显示在表 7-5 中。

表 7-5. 不同方式进行灰度计算时所需的时间

方法 CPU 时间 墙上时间
迭代 39.6 s 41.1 s
Pyfunc 39.7 s 41.1 s
切片 4.44 s 3.07 s
Matmul 1.22 s 2.29 s
批处理 1.11 s 2.13 s

保存模型状态

在本书中,我们一直在训练一个模型,然后立即使用训练好的模型进行一些预测。这是非常不现实的——我们将希望训练我们的模型,并保留训练好的模型以便随时进行预测。我们需要保存模型的状态,以便在需要时快速读取训练好的模型(其结构和最终权重)。

我们不仅要保存模型以便预测,还要恢复训练。想象一下,我们已经在百万张图片上训练了一个模型,并用该模型进行预测。一个月后,我们收到一千张新图片,那么用新图片继续训练原始模型几步将是不错的选择,而不是从头开始训练。这被称为微调(在第三章中讨论过)。

因此,有两个原因可以保存模型状态:

  • 从模型中进行推理

  • 恢复训练

这两种用例需要的是非常不同的。如果我们考虑我们模型中的RandomColorDistortion数据增强层,最容易理解这两种用例之间的区别。为了推断,这一层可以完全移除。然而,为了恢复训练,我们可能需要知道层的完整状态(例如,随着训练时间的增加,我们降低了扭曲的程度)。

为了进行推断而保存模型称为导出模型。为了恢复训练而保存模型称为检查点。检查点的大小比导出大得多,因为它们包含了更多的内部状态。

导出模型

要导出一个训练好的 Keras 模型,请使用save()方法:

os.mkdir('export')
model.save('export/flowers_model')

输出目录将包含一个名为saved_model.pb的 protobuf 文件(这也是为什么这种格式经常被称为 TensorFlow SavedModel 格式)、变量权重以及模型预测所需的任何资源,比如词汇文件。

提示

SavedModel 的一个替代方案是 Open Neural Network Exchange (ONNX),这是一个由 Microsoft 和 Facebook 引入的开源、与框架无关的 ML 模型格式。您可以使用tf2onnx工具将 TensorFlow 模型转换为 ONNX 格式。

调用模型

我们可以使用随 TensorFlow 一起提供的命令行工具saved_model_cli来查询 SavedModel 的内容:

saved_model_cli show --tag_set all --dir export/flowers_model

这显示了预测签名(见下面的侧边栏)是:

inputs['random/center_crop_input'] tensor_info:
    dtype: DT_FLOAT
    shape: (-1, 448, 448, 3)
    name: serving_default_random/center_crop_input:0

给定的 SavedModel SignatureDef包含以下输出:

outputs['flower_prob'] tensor_info:
    dtype: DT_FLOAT
    shape: (-1, 5)
    name: StatefulPartitionedCall:0
Method name is: tensorflow/serving/predict

因此,要调用这个模型,我们可以加载它并调用predict()方法,传入一个形状为[num_examples, 448, 448, 3]的 4D 张量,其中 num_examples 是我们希望一次性进行预测的示例数:

serving_model = tf.keras.models.load_model('export/flowers_model')
img = create_preproc_image('../dandelion/9818247_e2eac18894.jpg')
batch_image = tf.reshape(img, [1, IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS])
batch_pred = serving_model.predict(batch_image)

结果是一个形状为[num_examples, 5]的 2D 张量,表示每种花卉的概率。我们可以查找这些概率中的最大值来获取预测结果:

pred = batch_pred[0]
pred_label_index = tf.math.argmax(pred).numpy()
pred_label = CLASS_NAMES[pred_label_index]
prob = pred[pred_label_index]

然而,所有这些仍然非常不现实。我们真的期望一个需要对图像进行预测的客户端会知道如何执行reshape()argmax()等操作吗?我们需要为我们的模型提供一个更简单的签名。

可用签名

对于我们的模型来说,更易用的签名是那种不暴露训练中所有内部细节的(例如模型训练时使用的图像大小)。

对于客户端使用起来最方便的签名是什么?与其要求他们发送一个包含图像内容的张量,我们可以简单地要求他们发送一个 JPEG 文件。而不是返回一个 logits 张量,我们可以发送从 logits 中提取的易于理解的信息(完整代码在 GitHub 上的07c_export.ipynb中):

@tf.function(input_signature=[tf.TensorSpec([None,], dtype=tf.string)])
def predict_flower_type(filenames):
    ...
    return {
        'probability': top_prob,
        'flower_type_int': pred_label_index,
        'flower_type_str': pred_label
    }

注意,顺便说一下,我们可以使函数更加高效——我们可以获取一批文件名,并一次性对所有图像进行预测。向量化不仅在训练时带来效率增益,在预测时也同样如此!

给定一个文件名列表,我们可以使用以下方式获取输入图像:

input_images = [create_preproc_image(f) for f in filenames]

然而,这涉及遍历文件名列表,并在加速的 TensorFlow 代码与非加速的 Python 代码之间来回传递数据。如果我们有一个文件名的张量,可以通过使用tf.map_fn()在保持所有数据在 TensorFlow 图中的同时实现迭代的效果。有了这个,我们的预测函数变成了:

input_images = tf.map_fn(
    create_preproc_image,
    filenames,
    fn_output_signature=tf.float32
)

接下来,我们调用模型以获取完整的概率矩阵:

batch_pred = model(input_images)

然后我们找到最大概率及其索引:

top_prob = tf.math.reduce_max(batch_pred, axis=1)
pred_label_index = tf.math.argmax(batch_pred, axis=1)

注意,在找到最大概率和 argmax 时我们小心指定了axis为 1(axis=0 是批处理维度)。最后,在 Python 中我们可以简单地执行:

pred_label = CLASS_NAMES[pred_label_index]

TensorFlow 中图形内版本的使用是使用tf.gather()

pred_label = tf.gather(params=tf.convert_to_tensor(CLASS_NAMES),
                       indices=pred_label_index)

此代码将CLASS_NAMES数组转换为张量,然后使用pred_label_index张量对其进行索引。结果值存储在pred_label张量中。

提示

你可以经常用tf.map_fn()代替 Python 迭代,并使用tf.gather()来解引用数组(读取数组的第n个元素),正如我们在这里所做的那样。使用 [:, :, 0] 语法进行切片非常有用。tf.gather()与切片的区别在于,tf.gather()可以接受张量作为索引,而切片是常量。在非常复杂的情况下,tf.dynamic_stitch()会非常有用。

使用签名

定义了签名后,我们可以将我们的新签名指定为服务的默认签名:

model.save('export/flowers_model',
           signatures={
               'serving_default': predict_flower_type
           })

注意,API 允许我们在模型中有多个签名——如果我们想要为签名添加版本控制,或者为不同的客户端支持不同的签名,这将非常有用。我们将在第 9 章进一步探讨这一点。

导出模型后,客户端代码进行预测现在变得非常简单:

serving_fn = tf.keras.models.load_model('export/flowers_model'
                                       ).signatures['serving_default']
filenames = [
    'gs://.../9818247_e2eac18894.jpg',
     ...
    'gs://.../8713397358_0505cc0176_n.jpg'
]
`pred` `=` `serving_fn``(``tf``.``convert_to_tensor``(``filenames``)``)`

结果是一个字典,可以按以下方式使用:

print(`pred``[``'``flower_type_str``'``]`.numpy().decode('utf-8'))

展示了一些输入图像及其预测结果,见图 7-3。要注意的是,这些图像都是不同尺寸的。客户端在调用模型时不需要了解任何模型的内部细节。值得注意的是,在 TensorFlow 中,“string”类型只是一个字节数组。我们必须将这些字节传递到 UTF-8 解码器中,以获取正确的字符串。

图 7-3。在几幅图像上的模型预测。

检查点

到目前为止,我们专注于如何导出模型以进行推理。现在,让我们看看如何保存模型以便恢复训练。检查点通常不仅在训练结束时完成,还会在训练中间完成。这样做有两个原因:

  • 在验证准确度最高的点重新选择模型可能会有所帮助。请记住,随着训练时间的延长,训练损失会持续减少,但在某个时期,由于过拟合,验证损失开始上升。当我们观察到这一点时,我们必须选择前一个时期的检查点,因为它具有最低的验证错误。

  • 在生产数据集上进行机器学习可能需要几个小时到几天的时间。在这么长的时间段内,机器崩溃的可能性相当高。因此,定期备份是个好主意,这样我们就可以从中间点恢复训练,而不是从头开始。

Keras 通过回调实现检查点功能——这些功能在训练循环中被调用,通过将其作为参数传递给model.fit()函数来实现:

model_checkpoint_cb = tf.keras.callbacks.ModelCheckpoint(
    filepath='./chkpts',
    monitor='val_accuracy', mode='max',
    `save_best_only``=``True`)
history = model.fit(train_dataset,
                    validation_data=eval_dataset,
                    epochs=NUM_EPOCHS,
                    `callbacks``=``[``model_checkpoint_cb``]``)`

在这里,我们正在设置回调函数,如果当前验证准确度更高,则覆盖先前的检查点。

在我们进行此操作的同时,我们可以设置早停策略——即使最初我们认为需要训练 20 个时期,一旦验证误差连续 2 个时期没有改进(由patience参数指定),我们就可以停止训练:

early_stopping_cb = tf.keras.callbacks.EarlyStopping(
    monitor='val_accuracy', mode='max',
    patience=2)

现在回调列表如下:

callbacks=[model_checkpoint_cb, early_stopping_cb]

当我们使用这些回调进行训练时,训练在八个时期后停止,如图 7-4 所示。

图 7-4。在早停策略下,模型训练在验证准确度不再提高时停止。

要从输出目录中的最后一个检查点开始,请调用:

model.load_weights(checkpoint_path)

完整的故障恢复由BackupAndRestore回调提供,此时它还处于试验阶段。

分发策略

要将处理分布到多个线程、加速器或机器中,我们需要并行化处理。我们已经看过如何并行化摄入。但是,我们的 Keras 模型并没有并行化;它只在一个处理器上运行。我们如何在多个处理器上运行我们的模型代码?

要分发模型训练,我们需要设置分发策略。有几种可用的策略,但它们的使用方式都相似——您首先使用其构造函数创建一个策略,然后在该策略的范围内创建 Keras 模型(这里我们使用MirroredStrategy):

strategy = tf.distribute.MirroredStrategy()
with strategy.scope():
    layers = [
        ...
    ]
    model = tf.keras.Sequential(layers)
model.compile(...)
history = model.fit(...)

MirroredStrategy是什么?还有哪些其他策略可用,我们该如何在它们之间进行选择?我们将在接下来的章节中回答这些问题。

提示

代码运行在哪个设备上?所有创建可训练变量的 TensorFlow 指令(如 Keras 模型或层)必须在strategy.scope()内创建,除了model.compile()。您可以在任何地方调用compile()方法。尽管这个方法在技术上创建变量(例如优化器槽位),但它已实现为使用与模型相同的策略。此外,您可以在任何地方创建您的摄取(tf.data)管道。它将始终在 CPU 上运行,并始终适当地分发数据给工作节点。

选择策略

图像 ML 模型往往很深,输入数据很密集。对于这样的模型,有三种竞争的分发策略:

MirroredStrategy

在每个可用的 GPU 上创建模型结构的镜像。模型中的每个权重都在所有副本之间进行镜像,并通过在每个批次结束时发生的相同更新保持同步。无论该机器具有一个 GPU 还是多个 GPU,都可以使用MirroredStrategy。这样,当您连接第二个 GPU 时,您的代码将无需更改。

MultiWorkerMirroredStrategy

MirroredStrategy的思想扩展到分布在多台机器上的 GPU。为了使多个工作节点进行通信,您需要正确设置TF_CONFIG 变量,我们建议使用公共云服务(例如 Vertex Training),这样可以为您自动完成设置。

TPUStrategy

在 TPU 上运行训练作业,TPU 是专门为机器学习工作负载定制的专用应用特定集成电路(ASIC),通过自定义矩阵乘法单元、高速板上网络连接高达数千个 TPU 核心和大型共享内存获得加速。它们仅在 Google Cloud Platform 上商业可用。Colab 提供了带有一些限制的免费 TPU,并且 Google Research 通过TensorFlow 研究云计划为学术研究人员提供访问 TPU 的权限。

这三种策略都是数据并行的形式,每个批次都在工作节点之间分割,然后进行全局归约操作。其他可用的分发策略,如CentralStorageParameterServer,设计用于稀疏/大型示例,不适合像个别图像密集小的图像模型。

提示

我们建议在单台机器上最大化 GPU 数量,使用MirroredStrategy,然后再转向使用多个工作节点的MultiWorkerMirroredStrategy(更多详细信息请参见后续章节)。特别是当您转向更大的批处理大小时,TPU 通常比 GPU 更具成本效益。目前 GPU(如 16xA100)的趋势是在单台机器上提供多个强大的 GPU,以便使这种策略适用于更多模型。

创建策略

在本节中,我们将介绍用于分发图像模型训练的三种常用策略的具体细节。

MirroredStrategy

要创建MirroredStrategy实例,我们可以简单地调用其构造函数(完整代码在07d_distribute.ipynb on GitHub中):

def create_strategy():
    return tf.distribute.MirroredStrategy()

要验证我们是否在配置了 GPU 的机器上运行,我们可以使用:

if (tf.test.is_built_with_cuda() and
    len(tf.config.experimental.list_physical_devices("GPU")) > 1)

这不是必须要求;MirroredStrategy 在只有 CPU 的机器上也能工作。

在具有两个 GPU 的机器上启动 Jupyter 笔记本,并使用MirroredStrategy,我们看到了显著的加速。在 CPU 上处理一个时代大约需要 100 秒,而在单个 GPU 上需要 55 秒,而在拥有两个 GPU 时,仅需 29 秒。

在分布式训练中,必须确保增加批次大小。这是因为一个批次被分割在多个 GPU 之间,因此如果单个 GPU 有资源处理批次大小为 32,那么两个 GPU 将能够轻松处理 64。这里,64 是全局批次大小,每个 GPU 的本地批次大小为 32。更大的批次大小通常与更好的训练曲线行为相关联。我们将在“超参数调整”中尝试不同的批次大小。

注意

有时,即使不分发训练代码或使用 GPU,为了一致性和调试目的,拥有一种策略也是有帮助的。在这种情况下,请使用OneDeviceStrategy

tf.distribute.OneDeviceStrategy('/cpu:0')

MultiWorkerMirroredStrategy

要创建MultiWorkerMirroredStrategy实例,我们可以再次简单地调用其构造函数:

def create_strategy():
    return tf.distribute.MultiWorkerMirroredStrategy()

要验证TF_CONFIG环境变量是否正确设置,我们可以使用:

tf_config = json.loads(os.environ["TF_CONFIG"])

并检查结果配置。

如果使用类似 Google 的 Vertex AI 或 Amazon SageMaker 的托管 ML 训练系统,这些基础设施细节将被照顾。

在使用多个工作进程时,有两个细节需要注意:洗牌和虚拟周期。

Shuffling

当所有设备(CPU、GPU)都在同一台机器上时,每个训练样本批次都会分配给不同的设备工作进程,并且产生的梯度更新是同步的 —— 每个设备工作进程返回其梯度,这些梯度在设备工作进程之间进行平均,计算出的权重更新发送回设备工作进程用于下一步。

当设备分布在多台机器上时,让中心循环等待每台机器上的所有工作进程完成一个批次将导致计算资源的显著浪费,因为所有工作进程都必须等待最慢的一个。相反,思路是让工作进程并行处理数据,并且如果可用,平均梯度更新 —— 迟到的梯度更新从计算中简单丢弃。每个工作进程接收到当前时刻的权重更新。

当我们像这样异步地应用梯度更新时,我们不能将一批数据跨越不同的工作器进行分割,因为这样我们的批次将是不完整的,而我们的模型将希望等大小的批次。因此,我们必须让每个工作器读取完整的数据批次,计算梯度,并为每个完整的批次发送梯度更新。如果我们这样做,那么所有工作器读取相同数据就毫无意义了——我们希望每个工作器的批次包含不同的示例。通过对数据集进行洗牌,我们可以确保工作器在任何时候都在处理不同的训练示例。

即使我们不进行分布式训练,随机读取 tf.data 流水线中的数据顺序也是一个好主意。这将有助于减少一批数据中全是雏菊,下一批数据中全是郁金香等情况发生的可能性。这样的糟糕批次可能会对梯度下降优化器造成严重影响。

我们可以在两个地方随机读取数据:

  • 当我们获得与模式匹配的文件时,我们会对这些文件进行洗牌:

    files = [filename for filename
        # shuffle so that workers see different orders
        in `tf``.``random``.``shuffle`(tf.io.gfile.glob(pattern))
    ]
    
  • 在我们预处理数据之后,在批处理之前,我们将记录在比批量大小大的缓冲区内进行洗牌:

    trainds = (trainds
        `.``shuffle``(``8` `*` `batch_size``)`  # Shuffle for distribution ...
        .map(preproc.read_from_tfr, num_parallel_calls=AUTOTUNE)
        .map(_preproc_img_label, num_parallel_calls=AUTOTUNE)
        .prefetch(AUTOTUNE)
    )
    
  • 您的数据集越有序,您的洗牌缓冲区就需要越大。如果您的数据集最初按标签排序,那么只有涵盖整个数据集的缓冲区大小才能工作。在这种情况下,最好是在准备训练数据集时提前洗牌数据。

虚拟时代

我们通常希望对固定数量的训练示例进行训练,而不是对固定数量的时代进行训练。由于一个时代中的训练步骤数量取决于批量大小,因此更容易依据数据集中的总训练示例数计算每个时代的步数应该是多少:

num_steps_per_epoch = None
if (num_training_examples > 0):
    num_steps_per_epoch = (num_training_examples // batch_size)

我们称这个步数的训练周期为 虚拟时代 并且与以前一样训练相同数量的时代。

我们将虚拟时代的步数指定为 model.fit() 的参数:

history = model.fit(train_dataset,
                    validation_data=eval_dataset,
                    epochs=num_epochs,
                    `steps_per_epoch``=``num_steps_per_epoch`)                      )

如果我们对数据集中的训练示例数量估计错误会发生什么?假设我们将数字指定为 4,000,但实际上有 3,500 个示例?我们将遇到问题,因为数据集会在遇到 4,000 个示例之前完成。我们可以通过使训练数据集无限重复来防止这种情况发生:

if (num_training_examples > 0):
    train_dataset = train_dataset`.``repeat``(``)`

当我们低估数据集中的训练示例数量时,这也适用——下一组示例将简单地转移到下一个时代。Keras 知道当数据集是无限的时候,应该使用每个时代的步数来决定下一个时代何时开始。

TPUStrategy

虽然 MirroredStrategy 适用于单台机器上的一个或多个 GPU,而 MultiWorkerMirroredStrategy 适用于多台机器上的 GPU,TPUStrategy 则允许我们分布到名为 TPU 的自定义 ASIC 芯片上,如 图 7-5 所示。

图 7-5. 张量处理单元。

要创建一个TPUStrategy实例,我们可以调用它的构造函数,但必须向该构造函数传递一个参数:

tpu = tf.distribute.cluster_resolver.TPUClusterResolver().connect()
return tf.distribute.TPUStrategy(tpu)

因为 TPU 是多用户机器,初始化将擦除 TPU 上的现有内存,因此在程序中进行任何工作之前,我们必须确保初始化 TPU 系统。

此外,我们在model.compile()中添加了额外的参数:

model.compile(steps_per_execution=32)

此参数指示 Keras 一次向 TPU 发送多个批次。除了降低通信开销外,这还使得编译器有机会在多个批次间优化 TPU 硬件利用率。有了这个选项,不再需要将批次大小推到非常高的值以优化 TPU 性能。

值得注意的是,在 TensorFlow/Keras 中,分发数据的复杂代码会自动由strategy.distribute_dataset()处理,用户无需担心。在撰写本文时,这是在 PyTorch 中需要手动编写的代码。

然而,仅仅编写软件还不够;我们还需要设置硬件。例如,要使用MultiWorkerMirroredStrategy,我们还需要启动一个协调训练 ML 模型任务的机器集群。

要使用TPUStrategy,我们需要启动一台附有 TPU 的机器。我们可以通过以下方式实现:

gcloud compute tpus execution-groups create \
    --accelerator-type v3-32 --no-forward-ports --tf-version 2.4.1 \
    --name somevmname --zone europe-west4-a \
    --metadata proxy-mode=project_editors

如果我们使用管理硬件基础设施的服务,那么实施分发策略会更加容易。我们将把硬件设置延迟到下一节。

无服务器 ML

虽然 Jupyter 笔记本适用于实验和培训,但如果将代码组织成 Python 包,则在生产中维护代码对于 ML 工程师来说更加简单。可以使用类似Papermill的工具直接执行笔记本。但我们建议您将笔记本视为可消耗的,并将生产就绪的代码保留在带有相关单元测试的独立 Python 文件中。

通过将代码组织成 Python 包,我们还可以轻松地将代码提交到完全托管的 ML 服务,例如 Google 的 Vertex AI、Azure ML 或 Amazon SageMaker。在这里,我们将演示 Vertex AI,但其他服务的概念类似。

创建 Python 包

要创建一个 Python 包,我们必须在文件夹结构中组织文件,其中每个级别都由一个init.py文件标记。init.py文件用于运行包需要的任何初始化代码,虽然可以是空的。最简单的结构是:

trainer/
       __init__.py
       07b_distribute.py

可重复使用的模块

如何将笔记本中的代码导入到文件07b_distribute.py中?在 Jupyter 笔记本和 Python 包之间重复使用代码的简单方法是将 Jupyter 笔记本导出为.py文件,然后删除仅用于在笔记本中显示图形和其他输出的代码。另一种可能性是在独立文件中进行所有代码开发,然后根据需要从笔记本单元格中import所需的模块。

我们创建 Python 包的原因是包使我们的代码更易于重复使用。然而,我们不太可能只训练这一个模型。出于可维护性的考虑,建议您有以下这种组织结构(完整代码在 GitHub 上的 serverlessml 中):

flowers/                     Top-level package
      __init__.py            Initialize the flowers package
      classifier/            Subpackage for the classification model
              __init__.py
              model.py       Most of the code in the Jupyter notebook
              train.py       argparse and then launches model training
              ...
      ingest/                Subpackage for reading data
              __init__.py
              tfrecords.py   Code to read from TensorFlow Records
              ...
      utils/                 Subpackage for code reusable across models
              __init__.py
              augment.py     Custom layers for data augmentation
              plots.py       Various plotting functions
              ...

使用 Jupyter notebooks 进行实验,但最终将代码移至一个 Python 包中并维护该包。从那时起,如果需要进行实验,可以从 Jupyter notebook 调用 Python 包。

调用 Python 模块

在前一节中概述的结构中给定文件,我们可以使用以下命令调用培训程序:

python3 -m flowers.classifier.train --job-dir /tmp/flowers

这也是使模块的所有超参数可设置为命令行参数的好时机。例如,我们将想要尝试不同的批处理大小,因此将批处理大小作为命令行参数:

python3 -m flowers.classifier.train --job-dir /tmp/flowers \
        --batch_size 32 --num_hidden 16 --lrate 0.0001 ...

在入口 Python 文件中,我们将使用 Python 的 argparse 库将命令行参数传递给 create_model() 函数。

最好尝试使模型的每个方面都可配置。除了 L1 和 L2 正则化外,使数据增强层也成为可选项是个好主意。

由于代码已分布在多个文件中,您会发现自己需要调用现在位于不同文件中的函数。因此,您将不得不向调用者添加此形式的导入语句:

from flowers.utils.augment import *
from flowers.utils.util import *
from flowers.ingest.tfrecords import *

安装依赖项

虽然我们展示的包结构足以创建和运行一个模块,但很可能您需要训练服务 pip install 您需要的 Python 包。指定的方法是在与包相同的目录中创建一个 setup.py 文件,以便整体结构变为:

serverlessml/                Top-level directory
      `setup``.``py`               File to specify dependencies
      flowers/               Top-level package
            __init__.py

setup.py 文件如下所示:

from setuptools import setup, find_packages
setup(
    name='flowers',
    version='1.0',
    packages=find_packages(),
    author='Practical ML Vision Book',
    author_email='abc@nosuchdomain.com',
    `install_requires``=``[``'``python-package-example``'``]`
)
注意

确保在顶层目录(包含 setup.py 的目录)内执行两件事来验证包装和导入是否正确:

python3 ./setup.py dist
python3 -m flowers.classifier.train \
        --job-dir /tmp/flowers \
        --pattern '-00000-*'--num_epochs 1

还需查看生成的 MANIFEST.txt 文件,确保所有所需文件都在那里。如果需要辅助文件(文本文件、脚本等),可以在 setup.py 中指定它们。

提交培训作业

一旦我们有一个可以本地调用的模块,我们可以将模块源代码放入 Cloud Storage(例如,gs://${BUCKET}/flowers-1.0.tar.gz),然后提交作业给 Vertex Training,让它在我们选择的云硬件上为我们运行代码。

例如,要在单 CPU 的机器上运行,我们将创建一个配置文件(称为 cpu.yaml)指定 CustomJobSpec:

workerPoolSpecs:
  machineSpec:
    machineType: n1-standard-4
  replicaCount: 1
  pythonPackageSpec:
    executorImageUri: us-docker.pkg.dev/vertex-ai/training/tf-cpu.2-4:latest
    packageUris: gs://{BUCKET}/flowers-1.0.tar.gz
    pythonModule: flowers.classifier.train
    args: 
    - --pattern="-*"
    - --num_epochs=20
    - --distribute="cpu"

然后在启动训练程序时提供该配置文件:

gcloud ai custom-jobs create \
  --region=${REGION} \
  --project=${PROJECT} \
  --python-package-uris=gs://${BUCKET}/flowers-1.0.tar.gz \
  --config=cpu.yaml \
  --display-name=${JOB_NAME}

一个关键考虑因素是,如果我们使用 Python 3.7 和 TensorFlow 2.4 开发代码,需要确保 Vertex Training 使用相同版本的 Python 和 TensorFlow 来运行我们的训练作业。我们使用executorImageUri设置来实现这一点。不支持所有运行时和 Python 版本的组合,因为某些 TensorFlow 版本可能存在问题,后来已修复。如果您在 Vertex Notebooks 上开发,将在 Vertex Training 和 Vertex Prediction 上有相应的运行时(或升级路径以达到一致状态)。如果您在异构环境中开发,值得验证您的开发、训练和部署环境是否支持相同的环境,以防止后续出现问题。

在训练代码中,应创建一个OneDeviceStrategy

strategy = tf.distribute.OneDeviceStrategy('/cpu:0')

使用gcloud命令启动训练作业可以轻松将模型训练整合到脚本中,从 Cloud Functions 调用训练作业,或使用 Cloud Scheduler 安排训练作业。

接下来,让我们详细介绍与我们目前涵盖的不同分发方案对应的硬件设置。这里的每个场景对应于一个不同的分发策略。

在多个 GPU 上运行

要在单机上使用一、两、四个或更多 GPU 运行,可以将以下片段添加到 YAML 配置文件中:

workerPoolSpecs:
  machineSpec:
    machineType: n1-standard-4
    `acceleratorType``:` `NVIDIA_TESLA_T4`
    `acceleratorCount``:` `2`
  replicaCount: 1

并像以前一样启动gcloud命令,确保在--config中指定此配置文件。

在训练代码中,应创建一个MirroredStrategy实例。

分配到多个 GPU

要在多个工作节点上运行,每个节点都有几个 GPU,配置 YAML 文件应包含类似以下的行:

workerPoolSpecs:
  - machineSpec:
      machineType: n1-standard-4
      acceleratorType: NVIDIA_TESLA_T4
      acceleratorCount: 1
  - machineSpec:
      machineType: n1-standard-4
      acceleratorType: NVIDIA_TESLA_T4
      acceleratorCount: 1
  replicaCount: 1

请记住,如果使用多个工作机器,应通过声明将作为一个 epoch 的训练示例数来使用虚拟 epoch。还需要进行洗牌。GitHub 上的serverlessml代码示例同时执行这两个操作。

在训练代码中,应创建一个MultiWorkerMirroredStrategy实例。

分配到 TPU

要在 Cloud TPU 上运行,配置文件 YAML 看起来像这样(选择在阅读时最合适的TPU 版本,请参考此链接中最适当的版本):

workerPoolSpecs:
	- machineSpec:
		machineType: n1-standard-4
		acceleratorType:TPU_V2
		acceleratorCount: 8

在训练代码中,应创建一个 TPUStrategy 实例。

您可以利用 Python 的错误处理机制来创建适合硬件配置的分发策略的样板方法:

def create_strategy():
	try:
	    # detect TPUs
	    tpu = tf.distribute.cluster_resolver.TPUClusterResolver().connect()
	    return tf.distribute.experimental.TPUStrategy(tpu)
	except ValueError:
	    # detect GPUs
	    return tf.distribute.MirroredStrategy()

现在我们已经看过如何训练单个模型,让我们考虑如何训练一组模型并选择最佳模型。

超参数调整

在创建我们的 ML 模型的过程中,我们已经做出了许多任意选择:隐藏节点的数量,批量大小,学习率,L1/L2 正则化量等等。总的可能组合数非常庞大,因此最好采取一种优化方法,我们指定一个预算(例如,“尝试 30 个组合”),而后请求超参数优化技术选择最佳设置。

在第二章中,我们看过内置的 Keras 调谐器。然而,仅当您的模型和数据集足够小,整个训练过程可以在调谐器内部进行时,才能正常工作。对于更实际的 ML 数据集,最好使用完全托管的服务。

完全托管的超参数训练服务向训练程序提供参数值的组合,然后训练模型并报告性能指标(准确率、损失等)。因此,超参数调整服务要求我们:

  • 指定要调整的参数集、搜索空间(每个参数可以取值的范围,例如学习率必须在 0.0001 和 0.1 之间)、以及搜索预算。

  • 将给定的参数组合纳入训练程序。

  • 报告模型使用该参数组合时的表现如何。

在本节中,我们将以 Vertex AI 上的超参数调整为例,说明其工作原理。

指定搜索空间

我们在提供给 Vertex AI 的 YAML 配置中指定搜索空间。例如,我们可能有:

displayName: "FlowersHpTuningJob"
maxTrialCount: 50
parallelTrialCount: 2
studySpec:
  metrics:
  - metricId: accuracy
    goal: MAXIMIZE
  parameters:
  - parameterId: l2
    scaleType: UNIT_LINEAR_SCALE
    doubleValueSpec:
      minValue: 0
      maxValue: 0.2
  - parameterId: batch_size
    scaleType: SCALE_TYPE_UNSPECIFIED
    discreteValueSpec:
      values:
      - 16
      - 32
      - 64
  algorithm: ALGORITHM_UNSPECIFIED

在这个 YAML 列表中,我们正在指定(看看您能否找到相应的行):

  • 目标是最大化由训练程序报告的准确性

  • 预算,即一共 50 次试验,每次进行 2 次

  • 如果看起来不太可能比我们已经看到的表现更好,我们希望尽早停止试验

  • 两个参数,l2batch_size

    • 可能的 L2 正则化强度(介于 0 和 0.2 之间)

    • 批量大小,可以是 16、32 或 64 之一

  • 算法类型,如果未指定,则使用贝叶斯优化

使用参数值

Vertex AI 将调用我们的训练器,将具体的l2batch_size值作为命令行参数传递。因此,我们确保在argparse中列出它们:

parser.add_argument(
    '--l2',
    help='L2 regularization', default=0., type=float)
parser.add_argument(
    '--batch_size',
    help='Number of records in a batch', default=32, type=int)

我们必须将这些值纳入训练程序中。例如,我们将使用批量大小作为:

train_dataset = create_preproc_dataset(
    'gs://...' + opts['pattern'],
    IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS
).`batch``(``opts``[``'``batch_size``'``]``)`

此时,仔细思考我们在模型中做出的所有隐含选择是有帮助的。例如,我们的CenterCrop增强层是:

tf.keras.layers.experimental.preprocessing.RandomCrop(
    height=IMG_HEIGHT // 2, width=IMG_WIDTH // 2,
    input_shape=(IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS),
    name='random/center_crop'
)

数字 2 是固定的,但真正固定的是 MobileNet 模型所需的图像大小(224x224x3)。值得尝试的是,我们是否应该将图像居中裁剪到原始大小的 50%,或者使用其他比例。因此,我们将crop_ratio作为超参数之一:

- parameterName: crop_ratio
  type: DOUBLE
  minValue: 0.5
  maxValue: 0.8
  scaleType: UNIT_LINEAR_SCALE

并按以下方式使用它:

IMG_HEIGHT = IMG_WIDTH = round(MODEL_IMG_SIZE / opts['crop_ratio'])
tf.keras.layers.experimental.preprocessing.RandomCrop(
    height=MODEL_IMG_SIZE, width=MODEL_IMG_SIZE,
    input_shape=(IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS),
    name='random/center_crop'
)

报告准确性

在我们使用在命令行上提供给训练器的超参数训练模型之后,我们需要向超参数调优服务报告。我们报告的内容是在 YAML 文件中指定的hyperparameterMetricTag

hpt = hypertune.HyperTune()
accuracy = ...
hpt.report_hyperparameter_tuning_metric(
    hyperparameter_metric_tag=`'``accuracy``'`,
    metric_value=accuracy,
    global_step=nepochs)

结果

在提交作业时,启动超参数调优并进行 50 次试验,每次 2 个。这些试验的超参数是使用贝叶斯优化方法选择的,因为我们指定了两个并行试验,优化器从两个随机初始点开始。每当一个试验完成时,优化器确定需要进一步探索输入空间的哪一部分,并启动新的试验。

作业的成本由用于训练模型的基础设施资源决定,这需要训练模型 50 次。同时运行 50 次试验,每次 2 个,使得作业完成的速度是如果我们一次只运行一个试验的两倍。如果我们一次运行 50 次试验,每次 10 个,作业将完成的速度是原来的 10 倍,但成本相同——然而,前 10 次试验将无法充分利用先前完成的试验的信息,未来的试验平均来看也无法利用已经启动的 9 次试验的信息。我们建议根据预算尽可能多地使用总试验数,并根据耐心允许的程度尽量少地进行并行试验!您还可以恢复已完成的超参数作业(在 YAML 中指定resumePreviousJobId),这样如果您找到更多预算或更多耐心,可以继续搜索。

结果显示在 Web 控制台中(见图 7-6)。

图 7-6. 超参数调优结果。

根据调优,使用以下设置可以获得最高的准确率(0.89):l2=0batch_size=64num_hidden=24with_color_distort=0crop_ratio=0.70706

继续调整

观察这些结果,显然num_hiddenbatch_size的最佳值是我们尝试的最高值。鉴于此,继续进行超参数调优过程并探索更高的值可能是个好主意。同时,我们可以通过将crop_ratio设为一组离散值(0.70706 可能仅需设为 0.7)来减少其搜索空间。

这一次,我们不需要贝叶斯优化。我们只希望超参数服务执行 45 种可能组合的网格搜索(这也是预算):

  - parameterId: batch_size
    scaleType: SCALE_TYPE_UNSPECIFIED
    discreteValueSpec:
      values:
      - 48
      - 64
      - 96
  - parameterId: num_hidden
    scaleType: SCALE_TYPE_UNSPECIFIED
    discreteValueSpec:
      values:
      - 16
      - 24
      - 32
  - parameterId: crop_ratio
    scaleType: SCALE_TYPE_UNSPECIFIED
    discreteValueSpec:
      values:
      - 0.65
      - 0.70
      - 0.75
      - 0.80
      - 0.85

在这次新的训练运行后,我们得到了与之前相同的报告,并可以选择最佳的参数集。当我们这样做时,结果表明batch_size=64num_hidden=24确实是最佳的选择——比选择批量大小为 96 或隐藏节点数为 32 更好——但crop_ratio=0.8

部署模型

现在我们已经有了一个训练好的模型,让我们为在线预测部署它。TensorFlow SavedModel 格式受到名为 TensorFlow Serving 的服务系统支持。您可以在诸如 Google Kubernetes Engine、Google Cloud Run、Amazon Elastic Kubernetes Service、AWS Lambda、Azure Kubernetes Service 或使用 Kubernetes 的本地系统中部署此服务的Docker 容器。TensorFlow Serving 的托管版本在所有主要云中都有提供。在这里,我们将展示如何将 SavedModel 部署到 Google 的 Vertex AI。

Vertex AI 还提供模型管理和版本控制功能。为了使用这些功能,我们将创建一个名为flowers的端点,用于部署多个模型版本:

gcloud ai endpoints create --region=us-central1 --display-name=flowers

例如,假设超参数调优试验#33 是最佳的,并包含我们想要部署的模型。此命令将创建一个名为txf(用于迁移学习)的模型,并将其部署到 flowers 端点:

MODEL_LOCATION="gs://...}/`33`/flowers_model"
gcloud ai models upload ---display-name=txf \
           --container-image-uri=".../tf2-cpu.2-1:latest" -artifact-uri=$MODEL_LOCATION
gcloud ai endpoints deploy-model $ENDPOINT_ID  --model=$MODEL_ID \
            ... --region=us-central1  --traffic-split=

模型部署后,我们可以对模型进行 HTTP POST 请求,以获取预测的 JSON 请求。例如,发布:

{"instances": [
    {"filenames": "gs://cloud-ml-data/.../9853885425_4a82356f1d_m.jpg"},
    {"filenames": "gs://cloud-ml-data/../8713397358_0505cc0176_n.jpg"}
]}

返回:

{
    "predictions": [
        {
            "probability": 0.9999885559082031,
            "flower_type_int": 1,
            "flower_type_str": "dandelion"
        },
        {
            "probability": 0.9505964517593384,
            "flower_type_int": 4,
            "flower_type_str": "tulips"
        }
    ]
}

当然,我们可以从任何能够发送 HTTP POST 请求的程序中发布此请求(参见图 7-7)。

图 7-7. 左侧:在 Google Cloud Platform 控制台中尝试部署的模型。右侧:在 Python 中复制示例代码。

某人如何使用这个模型?他们必须将图像文件上传到云端,并将文件路径发送到模型以获取预测结果。这个过程有点繁琐。模型不能直接接受图像文件的内容吗?我们将看看如何在第九章中改进服务体验。

总结

在本章中,我们涵盖了构建训练流水线的各个方面。我们从考虑在 TFRecords 文件中进行高效存储开始,以及如何通过tf.data流水线高效读取数据。这包括映射函数的并行执行,数据集的交错读取以及向量化。优化思路延伸到模型本身,我们看了如何在多个 GPU、多个工作节点以及 TPUs 上并行执行模型。

接着我们转向运营化考虑。我们不再管理基础设施,而是看了如何通过向 Vertex AI 提交训练作业以无服务器方式进行训练,并如何使用这种范式进行分布式训练。我们还研究了如何使用 Vertex AI 的超参数调优服务来提升模型性能。对于预测,我们需要自动扩展基础设施,因此我们看了如何将 SavedModel 部署到 Vertex AI。在此过程中,您了解了签名的使用方法,如何自定义它们以及如何从部署的模型获取预测结果。

在下一章中,我们将看看如何监控部署的模型。

第八章:模型质量和持续评估

到目前为止,本书涵盖了视觉模型的设计和实现。在本章中,我们将深入讨论监控和评估这一重要主题。除了从高质量模型开始,我们还希望保持该质量。为了确保最佳运行,通过监控获得洞察力、计算指标、理解模型的质量以及持续评估其性能非常重要。

监控

因此,我们可能已经在数百万图像上训练了我们的模型,并且对其质量非常满意。我们已经将其部署到了云端,现在我们可以坐下来放松,让它永远为未来做出优秀的预测…… 对吗?错!正如我们不会让小孩子独自管理自己一样,我们也不希望把我们的模型单独留在野外。重要的是,我们要不断监控其质量(使用准确性等指标)和计算性能(每秒查询、延迟等)。特别是当我们不断使用新数据对模型进行重新训练时,可能会出现分布变化、错误和其他问题,我们需要注意这些问题。

TensorBoard

通常,机器学习从业者在训练模型时并未充分考虑所有细节。他们提交一个训练任务,然后不时检查,直到任务完成。然后,他们使用训练好的模型进行预测,查看其性能如何。如果训练任务只需几分钟,这可能看起来无关紧要。然而,许多计算机视觉项目,特别是包含数百万图像的数据集,其训练任务需要花费数天甚至数周的时间。如果在训练早期出现问题而我们没有注意到,或者直到训练完成或尝试使用模型进行预测时才注意到,那将是非常糟糕的。

有一个名为 TensorBoard 的优秀监控工具,它与 TensorFlow 一起分发,可以用来避免这种情况。TensorBoard 是一个交互式仪表板(参见图 8-1),显示了模型训练和评估期间保存的摘要信息。您可以将其用作实验运行的历史记录,用于比较您模型或代码的不同版本,并用于分析训练任务。

图 8-1. TensorBoard 标量摘要 UI。

TensorBoard 允许我们监控损失曲线,以确保模型训练仍在进行中,并且没有停止改进。我们还可以显示和与模型中的任何其他评估指标交互,例如准确性、精度或 AUC——例如,我们可以跨多个系列执行过滤、平滑和异常值移除,并且能够放大和缩小。

权重直方图

我们还可以在 TensorBoard 中探索直方图,如图 8-2 所示。我们可以用它们来监视权重、梯度和其他数量过多的标量。

图 8-2. TensorBoard 直方图用户界面。模型权重位于水平轴上,训练步骤编号位于垂直轴上。

设备放置

我们可以将 TensorFlow 模型图输出到 TensorBoard 以进行可视化和探索,如图 8-3 所示。

图 8-3. TensorBoard 模型图可视化:结构视图(左)和设备视图(右)。

默认的结构视图显示哪些节点共享相同的结构,设备视图显示哪些节点位于哪些设备上,每个设备用不同的颜色表示。我们还可以看到 TPU 的兼容性等信息。这可以帮助我们确保我们的模型代码得到适当的加速。

数据可视化

TensorBoard 可以显示特定类型的数据示例,例如图像(在图像选项卡上,如图 8-4 所示在左侧)或音频(在音频选项卡上)。这样,我们可以在训练过程中得到反馈;例如,在图像生成方面,我们可以实时查看生成图像的外观。对于分类问题,TensorBoard 还可以显示混淆矩阵,如图 8-4 右侧所示,这样我们可以监控每个类别在整个训练过程中的指标(更多详细信息请参见“分类的度量”)。

图 8-4. TensorBoard 图像选项卡允许您可视化训练图像(左)并查看混淆矩阵(右),以了解分类器大部分错误发生的位置。

训练事件

我们可以使用以下代码为我们的模型添加 TensorBoard 回调:

tensorboard_callback = tf.keras.callbacks.TensorBoard(
    log_dir='logs', histogram_freq=0, write_graph=True,
    write_images=False, update_freq='epoch', profile_batch=2,
    embeddings_freq=0, embeddings_metadata=None, **kwargs
)

我们通过log_dir参数指定 TensorBoard 事件日志将写入磁盘的目录路径。histogram_freqembeddings_freq控制两种摘要类型在多少个 epochs 后写入一次;如果指定为零,则不会计算或显示。请注意,当拟合模型以显示直方图时,需要指定验证数据,或者至少需要拆分。此外,对于嵌入,我们可以通过embeddings_metadata参数传递一个映射层名称到嵌入元数据将保存的文件名的字典。

如果我们想在 TensorBoard 中查看图表,我们可以将write_graph参数设置为True;然而,如果我们的模型很大,事件日志文件可能会非常大。更新频率通过update_freq参数指定。在这里,它被设置为每个 epoch 或批次更新一次,但我们可以将其设置为整数值,以便在那些批次数之后更新。我们可以使用布尔参数write_images在 TensorBoard 中将模型权重可视化为图像。最后,如果我们想分析计算特性的性能,例如对步骤时间的贡献,我们可以将profile_batch设置为整数或整数元组,并将分析应用于那个批次或一系列批次。将该值设置为零将禁用分析。

一旦定义,我们可以将 TensorBoard 回调添加到model.fit()的回调列表中,如下所示:

history = model.fit(
    train_dataset,
    epochs=10,
    batch_size=1,
    validation_data=validation_dataset,
    callbacks=[tensorboard_callback]
)

运行 TensorBoard 的最简单方法是打开终端并运行以下 bash 命令:

tensorboard --logdir=*`<``path_to_your_logs``>`*

您可以提供其他参数,例如更改 TensorBoard 使用的默认端口,但要快速启动它,您只需指定logdir即可。

摘要通常包括损失和评估指标曲线。然而,根据我们的用例,我们可以使用回调来生成其他潜在有用的摘要,比如图像和权重直方图。我们还可以在训练过程中打印和/或记录损失和评估指标,以及定期评估生成的图像或其他模型输出,然后检查改进的递减回报。最后,如果使用model.fit()在本地训练,我们可以检查历史输出,并查看损失和评估指标随时间的变化。

模型质量指标

即使您正在使用验证集,查看验证损失并不能真正清楚地展示模型的表现如何。进入评估指标!这些指标是基于模型对未见数据的预测计算的,它们允许我们评估模型在与用例相关的方面的表现。

分类指标

正如您在前几章学到的那样,图像分类涉及将标签分配给指示它们所属类别的图像。标签可以是相互排斥的,每个图像只适用一个标签,或者可能有多个标签描述一个图像。在单标签和多标签的情况下,我们通常对图像的每个类别预测一个概率。由于我们的预测是概率,而我们的标签通常是二进制的(如果图像不是该类,则为 0,如果是,则为 1),因此我们需要一些方法将预测转换为二进制表示,以便与实际标签进行比较。为此,我们通常设置一个阈值:任何低于阈值的预测概率变为 0,而任何高于阈值的预测概率变为 1。在二元分类中,默认阈值通常为 0.5,使两种选择有平等的机会。

二元分类

在实践中,有许多用于单标签分类的指标,但最佳选择取决于我们的用例。特别是,不同的评估指标适用于二元分类和多类分类。让我们从二元分类开始。

最常见的评估指标是准确率。这是衡量模型正确预测的百分比的指标。为了弄清楚这一点,计算四个其他指标也很有用:真正例、真反例、假正例和假反例。真正例是标签为 1,表示示例属于某个类别,并且预测也为 1。类似地,真反例是标签为 0,表示示例不属于该类别,并且预测也为 0。相反,假正例是标签为 0 但预测为 1,假反例是标签为 1 但预测为 0。总之,这些指标构成了一种称为混淆矩阵的预测集合,这是一个 2x2 的网格,统计了这四个指标的数量,如图 8-5 所示。

图 8-5. 一个二元分类混淆矩阵。

我们可以将这四个指标添加到我们的 Keras 模型中,如下所示:

model.compile(
    optimizer="sgd",
    loss="mse",
    metrics=[
        `tf``.``keras``.``metrics``.``TruePositives``(``)``,`
        `tf``.``keras``.``metrics``.``TrueNegatives``(``)``,`
        `tf``.``keras``.``metrics``.``FalsePositives``(``)``,`
        `tf``.``keras``.``metrics``.``FalseNegatives``(``)``,`
    ]
)

分类准确率是正确预测的百分比,因此通过将模型正确预测的数量除以其总预测数量来计算。使用四个混淆矩阵指标,可以表示为:

accuracy=TP+TNTP+TN+FP+FN

在 TensorFlow 中,我们可以像这样向 Keras 模型添加一个准确率指标:

model.compile(optimizer="sgd", loss="mse",
    metrics=[`tf``.``keras``.``metrics``.``Accuracy``(``)`]
)

这会计算与标签匹配的预测数量,然后除以总预测数量。

如果我们的预测和标签都是 0 或 1,例如在二分类的情况下,我们可以添加以下 TensorFlow 代码:

model.compile(optimizer="sgd", loss="mse",
    metrics=[`tf``.``keras``.``metrics``.``BinaryAccuracy``(``)`]
)

在这种情况下,预测通常是经过阈值化为 0 或 1 的概率,然后与实际标签比较,以确定匹配的百分比。

如果我们的标签是分类的、独热编码的,那么我们可以添加以下 TensorFlow 代码:

model.compile(optimizer="sgd", loss="mse",
    metrics=[`tf``.``keras``.``metrics``.``CategoricalAccuracy``(``)`]
)

这对于多类情况更为常见,通常涉及将每个样本的预测概率向量与每个类别的独热编码标签向量进行比较。

然而,准确率的一个问题是它仅在类别平衡时表现良好。例如,假设我们的用例是预测视网膜图像是否显示眼部疾病。假设我们已经筛选了一千名患者,只有两名患者实际患有眼部疾病。一个有偏向的模型,预测每张图像都显示健康眼睛,将会在 998 次中正确,仅错过两次,因此达到 99.8%的准确率。虽然听起来令人印象深刻,但这个模型对我们来说实际上是无用的,因为它完全无法检测到我们实际寻找的病例。对于这个特定问题,准确率并不是一个有用的评估指标。幸运的是,混淆矩阵值的其他组合对于不平衡数据集(以及平衡数据集)可能更有意义。

相反,如果我们对模型正确预测的正预测百分比感兴趣,那么我们将测量预测精度。换句话说,在所有模型预测为患有眼部疾病的人中,真正患有眼部疾病的有多少?精度计算如下:

precision=TPTP+FP

同样地,如果我们想知道我们的模型能够正确识别的正样本百分比,那么我们将测量预测召回率。换句话说,实际患有眼部疾病的患者中,模型能找到多少人?召回率计算如下:

recall=TPTP+FN

在 TensorFlow 中,我们可以通过以下方式将这两个指标添加到我们的 Keras 模型中:

model.compile(optimizer="sgd", loss="mse",
    metrics=[`tf``.``keras``.``metrics``.``Precision``(``)``,` `tf``.``keras``.``metrics``.``Recall``(``)`]
)

如果我们希望在 0.5 以外的阈值处计算指标,我们还可以添加thresholds参数,可以是范围为[0, 1]的浮点数,或者是浮点数值的列表或元组。

正如你所见,精确率和召回率的分子相同,只有在包括假阳性或假阴性时分母有所不同。因此,通常当一个增加时,另一个减少。那么我们如何找到两者之间的一个良好平衡点呢?我们可以引入另一个度量标准,即 F1 分数:

F1=2precisionrecallprecision+recall

F1 分数简单地是精确率和召回率之间的调和平均数。与准确率、精确率和召回率一样,它的范围在 0 到 1 之间。F1 分数为 1 表示模型具有完美的精确率和召回率,因此准确率也是完美的。F1 分数为 0 意味着精确率或召回率中的一个为 0,这意味着没有真正的正例。这表明我们要么有一个糟糕的模型,要么我们的评估数据集根本没有正例,导致我们的模型无法学习如何有效预测正例。

一个更一般的度量标准称为 F[β] 分数,其中添加一个实值常数 β(在 0 到 1 之间),允许我们在 F-分数方程中缩放精确率或召回率的重要性:

Fβ=(1+β2)precisionrecallβ2*precision+recall

如果我们想要使用比单独的精确率或召回率更综合的度量,但与假阳性和假阴性相关的成本不同,这将允许我们优化我们最关心的一个方面。

到目前为止,我们看过的所有评估指标都要求我们选择一个分类阈值,以确定概率是否足够高,使其成为正类预测还是不是。但是我们如何知道在哪里设置阈值呢?当然,我们可以尝试许多可能的阈值值,然后选择优化我们最关心的指标的那个值。

然而,如果我们使用多个阈值,还有另一种比较模型的方法。首先,通过在阈值网格上构建度量的曲线来进行比较。最流行的两种曲线是接收者操作特征(ROC)曲线和精确率-召回率曲线。ROC 曲线将真阳性率(也称为灵敏度或召回率)放在 y 轴上,将假阳性率(也称为 1-特异性或真阴性率)放在 x 轴上。假阳性率定义为:

FPR=FPFP+TN

精确率-召回率曲线的精确率在 y 轴上,召回率在 x 轴上。

假设我们选择了两百个等间距阈值的网格,并计算了水平和垂直轴的阈值评估度量。当然,绘制这些点将创建一条延伸至所有两百个阈值的线。

生成这样的曲线可以帮助我们进行阈值选择。我们希望选择一个优化感兴趣度量的阈值。它可以是这些统计度量之一,或者更好的是与当前业务或用例相关的度量,比如错过有眼病患者的经济成本与对没有眼病患者进行额外不必要的筛查。

我们可以通过计算曲线下面积(AUC)来总结这些信息。正如我们在图 8-6 左侧看到的,完美的分类器 AUC 为 1,因为会有 100%的真阳性率和 0%的假阳性率。随机分类器的 AUC 为 0.5,因为 ROC 曲线将沿 y = x 轴下降,显示真阳性和假阳性以相等速率增长。如果计算出的 AUC 小于 0.5,则意味着我们的模型表现比随机分类器更差;AUC 为 0 表示模型在每个预测上都完全错误。其他条件相同,较高的 AUC 通常更好,可能的范围在 0 到 1 之间。

精确率-召回率(PR)曲线与我们在图 8-6 右侧看到的类似;然而,并非每个 PR 空间的点都可以获取,因此范围小于[0, 1]。实际范围取决于数据类分布的偏斜程度。

图 8-6. 左侧:ROC 曲线。右侧:精确率-召回率曲线。

那么,在比较分类模型时,我们应该使用哪种曲线?如果类别被很好地抽样和平衡,那么推荐计算 AUC-ROC。否则,如果类别不平衡或倾斜,那么推荐使用 AUC-PR。以下是用于添加 AUC 评估指标的 TensorFlow 代码:

tf.keras.metrics.AUC(
    num_thresholds=200, curve="ROC",
    summation_method="interpolation",
    thresholds=None, multi_label=False
)

我们可以通过num_thresholds参数设置要计算四个混淆度量的阈值数,它将在 0 到 1 之间创建那些数量的均匀间隔的阈值。或者,我们可以提供一个浮点阈值列表,该列表在范围[0, 1]内,tf.keras.metrics.AUC()将使用它来计算 AUC。

我们还可以通过curve参数将曲线类型设置为"ROC""PR",分别使用 ROC 曲线或精确率-召回率曲线。

最后,由于我们正在进行二元分类,我们将multi_label设为False。否则,它会计算每个类别的 AUC,然后求平均值。

多类别,单标签分类

如果我们有一个多类别分类问题,比如三个类别(狗、猫和鸟),那么混淆矩阵将看起来像 Figure 8-7。请注意,现在我们不再有一个 2x2 的矩阵,而是一个 3x3 的矩阵;因此,一般来说,它将是一个nxn的矩阵,其中n是类别数。二元分类问题和多类别分类问题之间的一个关键区别是,我们不再有真负例,因为那些现在是其他类别的“真正例”。

图 8-7. 一个具有三个类别的多类别混淆矩阵。

请记住,对于多类别、单标签分类,即使我们有多个类别,每个实例仍然属于一个且仅一个类别。标签是互斥的。它可以是一张狗的图片、一张猫的图片或一张鸟的图片,不会同时是这些中的多个。

我们如何将我们的二元分类混淆矩阵指标适配到我们的多类别版本?让我们通过一个例子来详细说明。如果我们有一张被标记为狗的图片,并且我们预测它是狗,那么矩阵中狗-狗单元格的计数将增加一。这就是我们在二元分类版本中称之为真正例的情况。但是如果我们的模型预测“猫”呢?显然是一个假的某种东西,但它并不真正适合于假阳性或假阴性的阵营。它只是…错误。

幸运的是,我们不必跨越太远来使我们的多类别混淆矩阵为我们工作。让我们再次看一下填入值的混淆矩阵(Figure 8-8)。

图 8-8. 一个狗、猫、鸟多类别分类混淆矩阵示例。

我们可以看到这是一个平衡的数据集,因为每个类别都有两百个例子。然而,这不是一个完美的模型,因为它不是一个纯对角矩阵;它在许多例子中犯了错误,正如非对角线计数所示。如果我们想要计算精确率、召回率和其他指标,那么我们必须逐个类别地查看。

仅仅看狗类别,我们的混淆矩阵缩小到我们在图 8-9 中看到的情况。我们可以在这个图中看到,我们的真正例子是那些实际上是狗并且我们预测为狗的图像,这种情况发生了 150 次。假正例是我们预测图像是狗但实际上不是的情况(例如猫或鸟)。因此,为了得到这个计数,我们将狗-猫单元格中的 50 个示例和狗-鸟单元格中的 50 个示例相加。为了找到假负例的数量,我们做相反的操作:这些是我们应该预测为狗但没有预测的情况,所以我们将猫-狗单元格中的 30 个示例和鸟-狗单元格中的 20 个示例相加。最后,真负例的计数是其余单元格的总和,这些单元格中我们正确地说这些图像不是狗。请记住,即使模型在某些情况下可能将猫和鸟混淆在一起,因为我们现在只看狗类别,这些值都会被合并到真负例计数中。

图 8-9. 狗类别分类混淆矩阵。

一旦我们对每个类别都完成了这个过程,我们可以计算每个类别的复合指标(精确率、召回率、F1 分数等)。然后,我们可以取这些指标的未加权平均值来获得这些指标的宏版本,例如,对所有类别的精确率进行平均会得到宏精确率。还有一个微观版本,其中我们将每个单独类别混淆矩阵中的所有真正例子相加到一个全局真正例子计数中,并对其他三个混淆指标做同样处理。然而,由于这是全局完成的,微观精确率、微观召回率和微观 F1 分数将都是相同的。最后,与我们在宏版本中所做的未加权平均值不同,我们可以通过每个类别的样本总数加权每个类别的个别指标。这将为我们提供加权精确率、加权召回率等。如果我们有不平衡的类别,这可能是有用的。

由于这些仍然使用阈值将预测的类别概率转换为获胜类别的 1 或 0,我们可以使用这些组合指标来生成不同阈值的 ROC 曲线或精确率-召回率曲线,以找到比较无阈值模型性能的 AUC。

多类别、多标签分类

在二进制(单类别,单标签)分类中,概率是互斥的,每个示例要么是正类要么不是。在多类别单标签分类中,概率再次是互斥的,因此每个示例只能属于一个类别,但没有正面和负面类别。第三种分类问题是多类别多标签分类,其中概率不再是互斥的。一幅图像不一定只能是狗或只能是猫。如果图像中同时存在狗和猫,那么狗和猫的标签可以都是 1,因此一个好的模型应该为每个类别预测一个接近 1 的值,对于其他类别则预测接近 0 的值。

用于多标签情况的评估指标有哪些?我们有几个选项,但首先让我们定义一些符号。我们将定义Y为实际标签集,Z为预测标签集,并定义函数I为指示函数。

一个严格且具有挑战性的最大化指标是精确匹配比(EMR),也称为子集精度

EMR=1nΣi=1nI(Yi=Zi)

这个指标衡量了我们完全正确预测所有标签的例子的百分比。请注意,这不会给予部分分。如果我们应该预测一幅图像中有一百个类别,但我们只预测了其中的 99 个,则这个示例不会被视为精确匹配。模型越好,EMR 应该越高。

一个较少严格的指标是Hamming 分数,它实际上是多标签精度的衡量:

HS=1nΣi=1n|YiZi||YiZi|

在这里,我们正在测量每个示例预测正确标签与总标签数之比,包括预测和实际标签,然后对所有示例进行平均。我们希望最大化这个量。这类似于 Jaccard 指数或交并比(IOU),我们在第四章中已经看过了。

还有一个汉明损失可以使用,其范围为[0, 1]:

HL=1knΣi=1nΣl=1k[I(lZilYi)+I(lZilYi)]

不同于汉明分数,汉明损失度量了一个示例与错误预测的类标签的相关性,然后对该度量进行平均。因此,我们能够捕捉两种类型的错误:在和的第一项中,我们测量了预测错误的标签,而对于第二项,我们测量了未能预测到的相关标签的错误。这类似于排他或(XOR)操作。我们对示例数 n 和类数 k 进行求和,并通过这两个数字对双重求和进行归一化。如果我们只有一个类,这将简化为二元分类中的 1 - 精度。由于这是一种损失函数,数值越小越好。

我们还有精确度、召回率和 F1 分数的多标签形式。对于精确度,我们平均预测正确标签与实际标签总数的比率。

precision=1nΣi=1n|YiZi||Zi|

同样地,对于召回率,我们平均预测正确标签与预测标签总数的比率:

recall=1nΣi=1n|YiZi||Zi|

对于 F1 分数,与以往类似,它是精确率和召回率的调和平均:

F1=1nΣi=1n2|YiZi||Yi|+|Zi|

当然,我们也可以使用宏版本计算 ROC 曲线或精确率-召回率曲线的 AUC,其中我们按类计算 AUC,然后求平均。

回归指标

对于图像回归问题,还有一些评估指标可用于查看我们的模型在训练之外的数据上表现如何。对于以下所有回归指标,我们的目标是尽量将它们最小化。

最著名和标准的度量方法是均方误差(MSE):

MSE=1nΣi=1n(YiY^i)2

均方误差(MSE)顾名思义,是预测值与实际连续标签之间的平方误差的平均值。这是一个均值无偏估计量,由于二次项的存在,具有很高的敏感性,但这种敏感性意味着少数离群值可能会对其产生不适当的影响。

均方根误差(RMSE),它只是均方误差的平方根,也被广泛使用:

RMSE=1nΣi=1n(YiY^i)2

更简单和更易解释的度量方法是平均绝对误差(MAE):

MAE=1nΣi=1n|YiY^i|

MAE 只是连续预测和标签之间的绝对差异。与 MSE/RMSE 使用的平方指数相比,MAE 不太容易因为少数离群值而偏斜。此外,与 MSE 不同,后者是均值无偏估计,其中估计量的样本均值与分布均值相同,而 MAE 是中位数无偏估计,估计量高估和低估的频率相同。

为了使回归更加稳健,我们也可以尝试使用 Huber 损失度量。这比使用平方误差损失更不容易受到离群值的影响:

HLδ(Y,Y^)=1nΣi=1n{12(YiY</mo></mrow></mover></mrow><mrow><mi>i</mi></mrow></msub><msup><mrow><mo>)</mo></mrow><mrow><mn>2</mn></mrow></msup></mrow></mtd><mtd><mi>f</mi><mi>o</mi><mi>r</mi><mrow><mo>|</mo><msub><mrow><mi>Y</mi></mrow><mrow><mi>i</mi></mrow></msub><mo>−</mo><msub><mrow><mover><mrow><mi>Y</mi></mrow><mrow><mo>i|δδ|YiY^i|12δ2otherwise

我们可以看到,这个度量标准集两全其美。我们声明一个常数阈值,δ;如果绝对残差小于该值,我们使用平方项,否则使用线性项。这样,我们既可以从靠近零的值的平方均值无偏估计的敏感性中获益,也可以从远离零的值的线性项的中位数无偏估计的稳健性中获益。

物体检测度量标准

实际上,大多数常见的物体检测评估度量与分类度量相同。但是,我们不是比较整个图像的预测和实际标签,而是使用边界框比较检测到的对象与实际存在的对象,正如我们在第四章中所看到的。

最常见的物体检测度量标准之一是交并比:

IOU=area(B</mo></mrow></mover><mo>∩</mo><mi>B</mi><mo>)</mo></mrow></mrow><mrow><mi>a</mi><mi>r</mi><mi>e</mi><mi>a</mi><mrow><mo>(</mo><mover><mrow><mi>B</mi></mrow><mrow><mo>B)

分子是我们预测的边界框和实际边界框的交集面积。分母是我们预测的边界框和实际边界框的并集面积。我们可以在图 8-10 中以图形方式看到这一点。

图 8-10。交并比是重叠区域与并集区域的比值。

在完美重叠的情况下,两个区域将相等,因此 IOU 将为 1。在没有重叠的情况下,分子中将为 0,因此 IOU 将为 0。因此,IOU 的界限为[0, 1]。

我们还可以使用一种形式的分类混淆指标,例如真正例。与分类一样,计算这些需要一个阈值,但我们不是对预测概率进行阈值处理,而是对 IOU 进行阈值处理。换句话说,如果边界框的 IOU 超过某个值,则声明检测到了该对象。阈值通常为 50%,75%或 95%。

在这种情况下,真正例被视为正确的检测。当预测的边界框和实际边界框的 IOU 大于或等于阈值时发生。另一方面,假正例被视为错误的检测。当预测的边界框和实际边界框的 IOU 小于阈值时发生。假负例被视为漏检,即根本没有检测到实际边界框。

最后,真负例不适用于物体检测。真负例是正确的未检测到。如果我们记得我们的每类多类混淆矩阵,真负例是其他三个混淆指标中未使用的所有其他单元格的总和。在这里,真负例将是我们可以放置在图像上并且未触发其他三个混淆指标之一的所有边界框。即使对于小图像,这种未放置边界框的排列方式的数量也会非常庞大,因此使用这种混淆指标是没有意义的。

在这种情况下,精度等于真正例数除以所有检测数。这衡量了模型识别图像中相关对象的能力:

precision=TPalldetections

在目标检测中,召回率衡量了模型在图像中查找所有相关对象的能力。因此,它等于真正例的数量除以所有实际边界框的数量:

recall=TPallactualboundingboxes

就像分类一样,这些复合度量可以用来使用不同的阈值创建曲线。其中一些最常见的是精确率-召回率曲线(就像我们之前见过的那些)和召回-IOU 曲线,通常绘制 IOU 在范围[0.5, 1.0]之间。

我们还可以使用精确率-召回率和召回-IOU 曲线来计算平均精确率和平均召回率。为了平滑曲线中的任何波动,在执行实际平均精确率计算之前,我们通常在多个召回水平处插值精确率,如在图 8-11 中所示。

图 8-11. 一个插值精确率-召回率曲线。

我们对平均召回率也采取类似的方法。在公式中,选择的召回水平r处的插值精确率是找到的任何大于或等于r的召回水平r'的精确率p的最大值:

pinterpolated=maxrr[p(r)]

传统的插值方法是选择 11 个等间距的召回水平;然而,最近的实践者们开始尝试选择所有唯一的召回水平进行插值。因此,平均精度就是插值精度-召回曲线下的面积:

AP=1nΣi=1n1(ri+1ri)pinterpolated(ri+1)

如果我们只有一个类别,这就是精度的结尾,但通常在目标检测中,我们有许多不同的类别,它们都有不同的检测性能。因此,计算平均平均精度(mAP)可能很有用,这只是每个类别平均精度的平均值:

mAP=1kΣl=1kAPl

如前所述,为了计算平均召回率,我们使用召回-IOU 曲线而不是用于平均精度的精度-召回曲线。它实质上是所有 IOU(特别是至少为 50% 的 IOU)上的平均召回率,因此成为召回-IOU 曲线下的两倍面积:

AR=20.51recall(u)du

如我们对于多类别目标检测情况的平均精度所做的,我们可以通过对所有类别的平均召回率进行平均来找到平均平均召回率(mAR):

mAR=1kΣl=1kARl

例如,分割任务中的指标与检测任务完全相同。IOU 同样适用于框或掩码。

现在我们已经探讨了模型的可用评估指标,让我们看看如何使用它们来理解模型偏差和进行持续评估。

质量评估

在训练期间验证数据集上计算的评估指标是聚合计算的。这样的聚合指标忽略了一些需要真正衡量模型质量的微妙之处。让我们看看切片评估,一种捕捉这些微妙之处的技术,以及如何使用切片评估来识别模型中的偏见。

切片评估

评估指标通常基于类似于训练数据集分布的保留数据集进行计算。这通常给我们一个关于模型健康和质量的总体良好视角。然而,模型在某些数据片段上的表现可能比其他片段要差得多,这些缺陷可能会在整个数据集计算的海洋中被忽视。

因此,通常建议更细粒度地分析模型质量可能是一个好主意。我们可以通过基于类别或其他分离特征的数据切片,并在每个子集上计算常规评估指标来实现这一点。当然,我们仍然应该使用所有数据计算评估指标,以便看到各个子集与超集的差异。您可以在图 8-12 中看到这些切片评估指标的示例。

图 8-12. 两个不同数据段的切片 ROC 曲线与总体 ROC 曲线进行比较。

一些用例对数据的某些段位特别重视,因此这些是应用切片评估指标以密切关注的主要目标。

然而,这不仅仅是一种被动的监控练习!一旦我们知道了切片评估指标,我们可以对我们的数据或模型进行调整,使每个切片的指标符合我们的预期。这可能只需为特定类别增加数据或为了更好地理解这些问题片段而增加模型的复杂性。

接下来,我们将看一个可能需要进行切片评估的特定段的具体示例。

公平性监控

图像 ML 模型在某些人群中表现不佳。例如,2018 年的一项研究显示,商用面部分析程序在识别深肤色女性的性别时错误率显著高于较浅肤色的男性。2020 年,许多 Twitter 用户报告称,Twitter 的照片预览功能似乎更偏向白种人面孔而不是黑种人面孔。与此同时,Zoom 的面部识别在使用虚拟背景时似乎会删除黑种人面孔。而 2015 年,Google Photos 错误地将一对黑人夫妇的自拍标记为猩猩的图像。

考虑到这些由高效工程团队发生的高调和令人不安的错误,很明显,如果我们的计算机视觉问题涉及到人类主体,我们应该尝试通过进行切片评估来防范这些错误,其中各个段落包括属于不同种族和性别的个体。这将帮助我们诊断是否存在问题。

在不同性别和种族主题上模型表现不佳,不能简单通过确保所有种族和性别在训练和评估数据集中都有代表来解决。可能存在更深层次的问题。摄影滤镜和处理技术过去是针对更轻肤色最佳化的,这导致在较深色调的个体上会出现照明效果问题。因此,我们的模型训练流程中可能需要包含预处理和数据增强方法来纠正这种影响。此外,ML 模型训练最初侧重于常见情况,后来才转向更罕见的例子。这意味着技术如早停止、修剪和量化可能会对少数群体产生偏见。换句话说,这不仅仅是一个数据问题。解决公平性问题需要审视整个机器学习流程。

切片评估是诊断我们训练的模型中是否存在这些偏见的宝贵工具。这意味着我们应该针对我们担心可能会被不公平对待的人群段进行这些评估。

连续评估

我们应该多久进行一次切片评估?即使在部署后,不断评估我们的模型也很重要。这可以帮助我们早期发现可能出现问题的情况。例如,我们可能会因为推理输入分布随时间缓慢变化而出现预测漂移。还可能会有突发事件导致数据发生重大变化,从而导致模型行为改变。

连续评估通常包括七个步骤:

  1. 随机采样并保存发送到模型预测的数据。例如,我们可能选择保存所有发送到部署模型的图像中的 1%。

  2. 像往常一样使用模型进行预测并将其发送回客户端,但务必保存每个采样图像的模型预测结果。

  3. 发送样本进行标记。我们可以使用与训练数据相同的标记方法,例如可以使用标记服务,或者根据最终结果几天后标记数据。

  4. 计算采样数据上的评估指标,包括切片评估指标。

  5. 绘制评估指标的移动平均线。例如,我们可以绘制过去七天的平均 Hubert 损失。

  6. 搜索随时间变化的平均评估指标或超过特定阈值的情况。例如,如果任何监测段的准确率低于 95%,或者本周的准确率比上周低 1%以上,我们可能会选择发送警报。

  7. 我们还可以选择在添加采样并随后标记的数据到训练数据集后,定期重新训练或微调模型。

何时重新训练是我们需要做出的决定。一些常见选择包括当评估指标低于某个阈值时重新训练,每隔X天重新训练,或者一旦有X个新的标记样本时重新训练。

是否从头开始训练还是仅进行微调是我们需要做出的另一个决定。如果新样本只是原始训练数据的一小部分,通常的选择是微调模型;一旦采样数据接近原始数据集的大约 10%,则选择从头开始训练。

摘要

在本章中,我们讨论了在训练过程中监控模型的重要性。我们可以使用 TensorBoard 的出色图形用户界面来观察我们的损失和其他指标的变化,并验证模型是否随着时间的推移收敛并变得更好。此外,由于我们不希望过度训练我们的模型,通过创建检查点并启用早期停止,我们可以在最佳时机停止训练。

我们还讨论了许多质量评估指标,可用于在未知数据上评估我们的模型,以更好地衡量它们的表现。对于图像分类、图像回归和目标检测,有不同的指标,尽管其中一些在各种问题类型中以稍有不同的形式重新出现。事实上,图像分类有三种不同的分类指标子系列,这取决于类别数量和每张图像的标签数量。

最后,我们研究了对数据子集进行切片评估,不仅了解我们模型的缺陷,还帮助我们构思修复这些缺陷的方法。这种做法可以帮助我们监控偏见,确保我们尽可能公平,并了解使用我们模型的固有风险。

第九章:模型预测

训练机器学习模型的主要目的是能够使用它们进行预测。在本章中,我们将深入探讨部署训练过的 ML 模型并使用它们进行预测涉及的多个考虑因素和设计选择。

提示

本章的代码位于书的 GitHub 代码库09_deploying 文件夹中。适用时,我们将为代码示例和笔记本提供文件名。

预测

调用 训练过的模型——即使用它进行预测——我们必须从导出到的目录中加载模型,并调用服务签名。在本节中,我们将看看如何实现这一点。我们还将探讨如何改进被调用模型的可维护性和性能。

导出模型

要获得调用的服务签名,我们必须导出我们训练过的模型。让我们快速回顾一下这两个主题——导出和模型签名——在 “保存模型状态” 中有更详细的讨论,该主题在 第七章 中有所涵盖。回想一下,可以使用类似下面这样的代码导出 Keras 模型(请参阅 GitHub 上的笔记本 07c_export.ipynb):

model.save('gs://practical-ml-vision-book/flowers_5_trained')

这将以 TensorFlow SavedModel 格式保存模型。我们讨论了如何使用命令行工具 saved_model_cli 检查预测函数的签名。默认情况下,签名与保存的 Keras 模型的输入层匹配,但可以通过显式指定不同的函数来导出模型(参见 图 9-1):

model.save('export/flowers_model',
           signatures={
               `'``serving_default``'``:` `predict_flower_type`
           })

图 9-1. 导出模型会创建一个 SavedModel,其中包含用于服务预测的默认签名。在这种情况下,左侧的模型是内存中的 Python 对象,而 SavedModel 是持久化到磁盘上的内容。

predict_flower_type() 函数带有 @tf.function 注解,详细信息请参阅 “TensorFlow 函数的签名” 在 第七章 中:

`@tf.function``(`input_signature=[tf.TensorSpec([None,], dtype=tf.`string`)])
def `predict_flower_type``(``filenames``)`:
    ...

假设,在本章第一部分的示例中,我们已经导出了具有 predict_flower_type() 函数作为默认服务函数的模型。

使用内存中的模型

假设我们正在编写一个需要调用此模型并获取其预测的客户端程序。客户端可以是我们希望从中调用模型的 Python 程序。然后,我们将模型加载到我们的程序中,并获取默认的服务函数如下(完整代码见 GitHub 上的 09a_inmemory.ipynb):

serving_fn = tf.keras.models.`load_model`(MODEL_LOCATION
                                       )`.``signatures``[``'``serving_default``'``]`

如果我们向服务函数传递一组文件名,我们将获得相应的预测结果:

filenames = [
    'gs://.../9818247_e2eac18894.jpg',
     ...
    'gs://.../8713397358_0505cc0176_n.jpg'
]
`pred` `=` `serving_fn``(``tf``.``convert_to_tensor``(`filenames`)``)`

结果是一个字典。可以通过查找字典中特定键并调用 .numpy() 来从张量中获取最大似然预测:

pred['flower_type_str'].numpy()

在这种预测情况下,模型是直接在客户端程序中加载和调用的(请参见 Figure 9-2)。模型的输入必须是张量,因此客户端程序必须将文件名字符串创建为张量。因为模型的输出也是张量,所以客户端程序必须使用 .numpy() 获取正常的 Python 对象。

图 9-2. 一个用 Python 编写的客户端程序将 SavedModel 加载到内存中,将包含文件名的张量发送到内存中的模型,并接收包含预测标签的张量。

在 Figure 9-3 中展示了一些输入图像及其预测结果。请注意,由于我们在第五章和第七章中复制了预处理操作以在服务函数中进行操作,客户端可以发送任何大小的图像给我们——服务器将会将图像调整为模型所需的大小。

图 9-3. 一组图像及其对应的预测结果。

然而,这种内存中方法存在两个关键问题:抽象和性能。让我们看看这些问题是什么以及如何解决它们。

改进抽象

通常情况下,开发 ML 模型的机器学习工程师和数据科学家拥有的工具和技能与将 ML 预测集成到用户界面应用程序中的应用程序开发人员不同。您希望 ML 预测 API 能够被那些不了解 TensorFlow 或 React、Swift 或 Kotlin 编程的人使用。这就是为什么抽象是必要的。

我们在某种程度上抽象了模型的细节——客户端不需要知道图像的必需大小(确实,请注意 Figure 9-3 中图像大小都不同)或用于分类的 ML 模型的架构。但这种抽象并不完全。我们确实对客户端程序员有一些要求:

  • 客户端机器需要安装 TensorFlow 库。

  • 在撰写本文时,TensorFlow API 仅能从 Python、CJavaGoJavaScript 中调用。因此,客户端必须使用其中一种语言编写。

  • 因为客户端程序员必须调用像 tf.convert_to_tensor().numpy() 这样的函数,所以他们必须理解张量形状和即时执行等概念。

为了提高抽象性,最好能够使用诸如 HTTPS 之类的协议调用模型,这样可以从多种语言和环境中使用。此外,最好能够以 JSON 等通用格式提供输入,并以相同的格式获取结果。

提高效率

在内存中的方法中,模型直接加载并在客户端程序中调用。因此,客户端需要:

  • 考虑到板载内存,因为图像模型往往非常庞大

  • 加速器如 GPU 或 TPU,否则计算速度将会非常慢

只要确保在具有足够内存和加速器的机器上运行客户端代码,我们就可以了吗?并不完全是。

性能问题通常会在以下四种场景中显现:

在线预测

我们可能有许多并发客户端需要几乎实时的预测结果。这种情况出现在我们构建交互工具时,比如一个允许将产品照片加载到电子商务网站上的工具。由于可能存在成千上万的同时用户,我们需要确保所有这些并发用户的预测结果以低延迟完成。

批量预测

我们可能需要在一个大型图像数据集上进行推理。如果每张图像处理需要 300 毫秒,那么对 10000 张图像的推理将花费将近一个小时。我们可能需要更快的结果。

流式预测

我们可能需要在图像流进入系统时进行推理。如果我们每秒接收大约 10 张图像,并且每张图像处理需要 100 毫秒,我们几乎无法跟上传入流量,因此任何流量突增都会导致系统开始落后。

边缘预测

低连通性客户端可能需要几乎实时的预测结果。例如,我们可能需要在工厂传送带上的零件中识别缺陷,即使它在移动。为了实现这一点,我们需要尽快处理传送带的图像。我们可能没有网络带宽将该图像发送到云中强大的机器并在移动传送带所规定的时间预算内获取结果。在手机应用程序根据手机摄像头所对准的物体做出决策时也是如此。因为工厂或手机位于网络边缘,网络带宽不如云数据中心中两台机器之间的高,这称为边缘预测

在接下来的几节中,我们将深入探讨每种场景,并探讨处理它们的技术。

在线预测

对于在线预测,我们需要一个微服务架构——模型推断需要在配备加速器的强大服务器上进行。客户端将通过发送 HTTP 请求并接收 HTTP 响应来请求模型推断。使用加速器和自动扩展基础设施解决了性能问题,而使用 HTTP 请求和响应解决了抽象问题。

TensorFlow Serving

在线预测的推荐方法是使用 TensorFlow Serving 部署模型作为响应 POST 请求的 Web 微服务。请求和响应将不是张量,而是抽象为诸如 JSON 之类的 Web 原生消息格式。

部署模型

TensorFlow Serving 只是软件,因此我们还需要一些基础设施。用户请求将动态路由到不同的服务器,并且需要自动扩展以处理流量峰值。您可以在像 Google Cloud 的 Vertex AI、Amazon SageMaker 或 Azure ML 这样的托管服务上运行 TensorFlow Serving,这些平台通过 GPU 和 AWS Inferentia、Azure FPGA 等自定义加速器提供加速。尽管您可以将 TensorFlow Serving 模块或 Docker 容器安装到您喜欢的 Web 应用程序框架中,但我们不建议此方法,因为您将无法获得云提供商 ML 平台优化的 ML 服务系统和基础设施管理的好处。

要在 Google Cloud 上将 SavedModel 部署为 Web 服务,我们需要指向模型导出到的 Google Cloud 存储位置,并将生成的模型部署到 Vertex AI 端点。详情请参阅 GitHub 上的代码。

在部署模型时,我们还可以指定机器类型、加速器类型以及最小和最大副本数。

图 9-4. 通过 REST API 提供的在线模型预测。

进行预测

可以从任何能够向部署模型的服务器发出 HTTPS 调用的计算机获取预测结果(参见 图 9-4)。数据来回传递为 JSON 消息,并且 TensorFlow Serving 将 JSON 转换为张量以发送到 SavedModel。

我们可以通过创建一个 JSON 请求来测试部署的模型:

{
    "`instances`": [
        {
            "`filenames`": "gs://.../9818247_e2eac18894.jpg"
        },
        {
            "filenames": "gs://.../9853885425_4a82356f1d_m.jpg"
        },
     ]
}

并使用 gcloud 将其发送到服务器:

gcloud ai endpoints predict ${ENDPOINT_ID} \
    --region=${REGION} \
    --json-request=request.json

一个关键的注意事项是,JSON 请求由一组实例组成,每个实例都是一个字典。字典中的项目对应于模型签名中指定的输入。我们可以通过在 SavedModel 上运行命令行工具 saved_model_cli 来查看模型签名:

saved_model_cli show --tag_set serve \
    --signature_def serving_default --dir ${MODEL_LOCATION}

对于花卉模型,返回如下:

inputs[`'``filenames``'`] tensor_info:
    dtype: `DT_STRING`
    shape: (-1)
    name: serving_default_filenames:0

这就是我们知道 JSON 中每个实例需要一个名为 filenames 的字符串元素的方式。

因为这只是一个 REST API,可以从任何能发送 HTTPS POST 请求的编程语言中调用它。以下是在 Python 中的操作方法:

`api` = ('https://{}-aiplatform.googleapis.com/v1/projects/' +
       '{}/locations/{}/endpoints/{}:predict'.format(
       REGION, PROJECT, REGION, ENDPOINT_ID))

标头包含客户端的身份验证令牌。可以使用以下方法以编程方式检索:

token = (GoogleCredentials.get_application_default()
         .get_access_token().access_token)

我们已经看到如何部署模型并从中获取预测结果,但 API 是与模型导出时的签名一致的。接下来,让我们看看如何更改这一点。

修改服务函数

目前,flowers 模型已经导出,以便它接受文件名作为输入,并返回由最可能的类(例如 daisy)、这个类的索引(例如 2)以及与这个类相关的概率(例如 0.3)组成的字典。假设我们希望更改签名,以便我们还返回与预测相关联的文件名。

这种情况非常常见,因为在导出模型时,我们无法预料到在生产中会需要确切的签名。在这种情况下,我们希望将客户端的输入参数传递到响应中。这种透传参数的需求非常普遍,不同的客户端将想要传递不同的内容。

虽然可以返回,更改训练程序,重新训练模型,并重新导出具有所需签名的模型,但简单地更改导出模型的签名更为便捷。

更改默认签名

要更改签名,首先我们加载导出的模型:

model = tf.keras.models.load_model(MODEL_LOCATION)

然后,我们定义一个带有所需新签名的函数,确保在新函数内部调用模型的旧签名:

@tf.function(input_signature=[tf.TensorSpec([None,], dtype=tf.string)])
def pass_through_input(filenames):
    old_fn = model.signatures['serving_default']
    result = `old_fn``(``filenames``)` # has flower_type_int etc.
    `result``[``'``filename``'``]` = filenames # pass through
    return result

如果客户端希望提供一个序列号,并要求我们在响应中透传此序列号,我们可以按以下方式操作:

@tf.function(input_signature=[tf.TensorSpec([None,], dtype=tf.string),
                              tf.TensorSpec([], dtype=`tf``.``int64`)])
def pass_through_input(filenames, `sequenceNumber`):
    old_fn = model.signatures['serving_default']
    result = old_fn(filenames) # has flower_type_int etc.
    result['filename'] = filenames # pass through
    `result``[``'``sequenceNumber``'``]` `=` `sequenceNumber` # pass through
    return result

最后,我们将带有新函数的模型导出为服务默认:

model.save(NEW_MODEL_LOCATION,
           signatures={
               'serving_default': pass_through_input
           })

我们可以使用saved_model_cli验证生成的签名,并确保文件名包含在输出中:

outputs['filename'] tensor_info:
      dtype: DT_STRING
      shape: (-1)
      name: StatefulPartitionedCall:0

多个签名

如果你有多个客户,并且每个客户都想要不同的签名?TensorFlow Serving 允许在模型中拥有多个签名(尽管其中只有一个将成为服务默认)。

例如,假设我们想支持原始签名和透传版本。在这种情况下,我们可以导出带有两个签名的模型(见图 9-5):

model.save('export/flowers_model2',
           signatures={
               `'``serving_default``'``:` `old_fn``,`
               `'``input_pass_through``'``:` `pass_through_input`
           })

其中old_fn是通过以下方式获取的原始服务签名:

model = tf.keras.models.load_model(MODEL_LOCATION)
old_fn = model.signatures['serving_default']

图 9-5. 导出带有多个签名的模型。

客户希望调用非默认服务签名的,需要在他们的请求中明确包含一个签名名称:

{
    `"``signature_name``"``:` `"``input_pass_through``"``,`
    "instances": [
        {
            "filenames": "gs://.../9818247_e2eac18894.jpg"
        },
        ...
}

其他人将获得对应于默认服务函数的响应。

处理图像字节

到目前为止,我们一直在向服务发送文件名,并请求分类结果。这对已经上传到云中的图像效果很好,但如果情况不是这样,可能会增加摩擦。如果图像尚未在云中,客户端代码最好将 JPEG 字节发送给我们,而不是文件内容。这样,我们可以避免在调用预测模型之前上传图像数据到云的中间步骤。

加载模型

在这种情况下改变模型,我们可以加载导出的模型,并更改输入签名为:

@tf.function(input_signature=[tf.TensorSpec([None,], dtype=tf.string)])
def predict_bytes(img_bytes):

但是这种实现会做什么呢?为了调用现有的模型签名,我们需要用户的文件能够在服务器上可用。因此,我们需要获取传入的图像字节,将其写入临时的云存储位置,然后发送到模型。然后模型将再次读取这个临时文件到内存中。这是相当浪费的 —— 我们如何让模型直接使用我们发送的字节呢?

为此,我们需要解码 JPEG 字节,以与模型训练期间相同的方式预处理它们,然后调用 model.predict()。为此,我们需要加载在模型训练期间保存的最后(或最佳)检查点:

CHECK_POINT_DIR='gs://.../chkpts'
model = tf.keras.models.load_model(CHECK_POINT_DIR)

我们也可以使用相同的 API 加载导出的模型:

EXPORT_DIR='gs://.../export'
model = tf.keras.models.load_model(EXPORT_DIR)

添加预测签名

加载模型后,我们使用此模型来实现预测函数:

@tf.function(input_signature=[tf.TensorSpec([None,], dtype=tf.string)])
def predict_bytes(img_bytes):
    input_images = tf.map_fn(
        `preprocess`, # preprocessing function used in training
        img_bytes,
        fn_output_signature=tf.float32
    )
    batch_pred = `model``(``input_images``)` # same as model.predict()
    top_prob = tf.math.reduce_max(batch_pred, axis=[1])
    pred_label_index = tf.math.argmax(batch_pred, axis=1)
    pred_label = tf.gather(tf.convert_to_tensor(CLASS_NAMES),
                           pred_label_index)
    return {
        'probability': top_prob,
        'flower_type_int': pred_label_index,
        'flower_type_str': pred_label
    }

在那段代码片段中,请注意我们需要获取训练中使用的预处理函数的访问权,可能通过导入一个 Python 模块。预处理函数必须与训练时使用的相同:

def preprocess(img_bytes):
    img = tf.image.decode_jpeg(img_bytes, channels=IMG_CHANNELS)
    img = tf.image.convert_image_dtype(img, tf.float32)
    return tf.image.resize_with_pad(img, IMG_HEIGHT, IMG_WIDTH)

我们可能也要实现另一种方法来根据文件名预测:

@tf.function(input_signature=[tf.TensorSpec([None,], dtype=tf.string)])
def predict_filename(filenames):
    img_bytes = tf.map_fn(
        `tf``.``io``.``read_file``,`
        filenames
    )
    result = `predict_bytes``(``img_bytes``)`
    result['filename'] = filenames
    return result

此函数简单地读取文件(使用 tf.io.read_file()),然后调用另一个预测方法。

导出签名

这两个函数都可以导出,以便客户可以选择提供文件名或字节内容:

model.save('export/flowers_model3',
           signatures={
               'serving_default': predict_filename,
               'from_bytes': predict_bytes
           })

Base64 编码

为了将本地图像文件的内容提供给 Web 服务,我们将文件内容读入内存,并通过网络发送。因为 JPEG 文件很可能包含会混淆服务器端 JSON 解析器的特殊字符,所以在发送之前有必要对文件内容进行 base64 编码(完整代码在 GitHub 上的 09d_bytes.ipynb 可以找到):

def b64encode(filename):
    with open(filename, 'rb') as ifp:
        img_bytes = ifp.read()
        return base64.b64encode(img_bytes)

将 base64 编码的数据包含在以下发送的 JSON 消息中:

data = {
    `"``signature_name``"``:` `"``from_bytes``"``,`
    "instances": [
        {
            "img_bytes": `{``"``b64``"``:` `b64encode``(``'``/tmp/test1.jpg``'``)``}`
        },
        {
            "img_bytes": {"b64": b64encode('/tmp/test2.jpg')}
        },
    ]
}

注意使用特殊的 b64 元素来表示 base64 编码。TensorFlow Serving 理解这一点,并在另一端解码数据。

批处理和流式预测

逐个图像进行批量预测速度太慢了。更好的解决方案是并行进行预测。批量预测是一个尴尬的并行问题 —— 两个图像的预测可以完全并行执行,因为两个预测过程之间没有数据传输。然而,尝试在单机上使用多个 GPU 并行化批量预测代码通常会遇到内存问题,因为每个线程都需要拥有模型的独立副本。使用 Apache Beam、Apache Spark 或任何其他允许我们跨多台机器分布数据处理的大数据处理技术是提高批量预测性能的好方法。

对于流式预测同样需要多台机器(例如响应通过 Apache Kafka、Amazon Kinesis 或 Google Cloud Pub/Sub 的点击流事件),原因与批量预测相同 —— 在图像到达时并行执行推断而不引起内存问题。然而,由于流式工作负载往往是突发的,我们还需要这种基础设施能够自动扩展 —— 在流量高峰时提供更多机器,在流量低谷时缩减到最少机器数量。Apache Beam 在 Cloud Dataflow 上提供了这种能力。因此,我们建议使用 Beam 来提高流式预测的性能。令人高兴的是,Beam 中用于批量预测的相同代码也能够无需修改地用于流式预测。

Apache Beam 管道

批量和流式预测的解决方案都涉及 Apache Beam。我们可以编写一个 Beam 转换来作为管道的一部分执行推断:

| 'pred' >> beam.Map(ModelPredict(MODEL_LOCATION))

我们可以通过加载从导出模型中导入的服务函数来重用内存预测中使用的模型预测代码:

class ModelPredict:
    def __init__(self, model_location):
        self._model_location = model_location

    `def` `__call__``(``self``,` `filename``)``:`
        `serving_fn` `=` `(``tf``.``keras``.``models``.``load_model``(``self``.``_model_location``)`
                    `.``signatures``[``'``serving_default``'``]``)`
        `result` `=` `serving_fn``(``tf``.``convert_to_tensor``(``[``filename``]``)``)`
        return {
            'filenames': filename,
            'probability': result['probability'].numpy()[0],
            'pred_label': result['flower_type_str'].numpy()[0]
        }

然而,这段代码存在两个问题。首先,我们是逐个处理文件。如果能够批量处理文件,TensorFlow 图操作将更快,因此我们希望能够批量处理文件名。其次,我们为每个元素加载模型。理想情况下,我们只需加载模型一次并重复使用。然而,由于 Beam 是一个分布式系统,实际上我们必须在每个工作器上加载模型 一次(参见 图 9-6)。为此,我们必须通过弱引用获取一个共享 句柄(实质上是与服务的共享连接)。这个句柄必须通过弱引用获取,以便在工作器由于低流量而下线后重新激活(由于流量峰值)时,Beam 能够执行正确的操作并重新加载该工作器上的模型。

图 9-6. 批量预测使用分布式工作器并行处理输入数据。这种架构也适用于流式预测。

要使用共享句柄,我们需要修改模型预测代码如下:

class ModelPredict:
    def __init__(self, shared_handle, model_location):
        `self``.``_shared_handle` `=` `shared_handle`
        self._model_location = model_location

    def __call__(self, filenames):
        `def` `initialize_model``(``)``:`
            logging.info('Loading Keras model from ' +
                         self._model_location)
            return (tf.keras.models.load_model(self._model_location)
                    .signatures['serving_default'])

        `serving_fn` `=` `self``.``_shared_handle``.``acquire``(``initialize_model``)`
        result = serving_fn(tf.convert_to_tensor(filenames))
        return {
            'filenames': filenames,
            'probability': result['probability'].numpy(),
            'pred_label': result['flower_type_str'].numpy()
        }

由 Apache Beam 提供的共享句柄确保在工作器内重复使用连接,并在休眠后重新获取。在管道中,我们创建共享句柄,并确保在调用模型预测之前对元素进行批处理(你可以在 GitHub 上的 09a_inmemory.ipynb 中查看完整代码):

with beam.Pipeline() as p:

    `shared_handle` `=` `Shared``(``)`

    (p
     | ...
     | 'batch' >> `beam``.``BatchElements``(`
                     min_batch_size=1, max_batch_size=32)
     | 'addpred' >> beam.Map(
                     ModelPredict(shared_handle, MODEL_LOCATION) )
    )

相同的代码适用于批处理和流式预测。

注意

如果正在对图像进行分组,则这些组已经是图像的批处理,因此无需显式对它们进行批处理:

| 'groupbykey' >> beam.GroupByKey()  # (usr, [files])
| 'addpred' >> beam.Map(`lambda` `x``:`
                ModelPredict(shared_handle,
                             MODEL_LOCATION)`(``x``[``1``]``)`)

我们可以使用 Cloud Dataflow 对 Apache Beam 代码进行大规模运行。

批量预测的托管服务

如果我们已将模型部署为支持在线预测的 Web 服务,那么除了使用 Dataflow 批处理管道中的 Beam 外,还可以使用 Vertex AI 进行批量预测:

gcloud ai custom-jobs create \
    --display_name=flowers_batchpred_$(date -u +%y%m%d_%H%M%S) \
    --region ${REGION} \
    --project=${PROJECT} \

--worker-pool-spec=machine-type='n1-highmem-2',container-image-uri=${IMAGE}

在性能方面,最佳方法取决于在线预测基础设施中可用的加速器与大数据基础设施中可用的加速器。由于在线预测基础设施可以使用自定义 ML 芯片,这种方法往往更好。此外,Vertex AI 批量预测更易于使用,因为我们不必编写处理批处理请求的代码。

调用在线预测

在 Apache Beam 中编写自己的批量预测管道更加灵活,因为我们可以在管道中进行额外的转换。如果我们能够将 Beam 和 REST API 方法结合起来,那将是非常好的事情。

我们可以通过从 Beam 管道中调用部署的 REST 端点而不是调用内存中的模型来实现这一点(完整代码在 GitHub 上的 09b_rest.ipynb 中):

class ModelPredict:
    def __init__(self, project, model_name, model_version):
        self._api = ('https://ml.googleapis.com/...:predict'
            .format(project, model_name, model_version))

    def __call__(self, filenames):
        token = (GoogleCredentials.get_application_default()
                 .get_access_token().access_token)
        data = {
            "instances": []
        }
        for f in filenames:
            data['instances'].append({
                "filenames" : f
            })
        headers = {'Authorization': 'Bearer ' + token }
        response = `requests``.``post``(``self``.``_api``,` `json``=``data``,` `headers``=``headers``)`
        response = json.loads(response.content.decode('utf-8'))
        for (a,b) in zip(filenames, response['predictions']):
            result = b
            result['filename'] = a
            yield result

如果按照这里显示的方法将 Beam 方法与 REST API 方法结合起来,我们将能够支持流式预测(这是托管服务不支持的)。我们还能获得一些性能优势:

  • 部署的在线模型可以根据模型的计算需求进行扩展。同时,Beam 管道可以根据数据速率进行扩展。这种独立扩展两个部分的能力可以节省成本。

  • 部署的在线模型可以更有效地利用 GPU,因为整个模型代码都在 TensorFlow 图上。虽然可以在 GPU 上运行 Dataflow 管道,但 GPU 使用效果较低,因为 Dataflow 管道执行许多其他任务(如读取数据、分组键等),这些任务并不受 GPU 加速的益处。

然而,这两个性能优点必须与增加的网络开销平衡——使用在线模型会从 Beam 管道向部署的模型发起网络调用。通过测量性能来确定内存模型对您的需求是否比 REST 模型更好。实际上,我们观察到,模型越大,批次中的实例越多,使用 Beam 从在线模型调用而不是在内存中托管模型的性能优势就越大。

Edge ML

Edge ML 因为近年来具有计算能力的设备数量急剧增长而变得越来越重要。这些设备包括智能手机、家庭和工厂中的连接设备,以及户外放置的仪器。如果这些边缘设备有摄像头,那么它们就是图像机器学习用例的候选者。

约束与优化

边缘设备往往有一些限制:

  • 它们可能没有连接到互联网,即使有连接,连接也可能不稳定且带宽较低。因此,有必要在设备上执行 ML 模型推理,这样我们就不必等待云端的往返时间。

  • 可能存在隐私约束,并且可能希望图像数据永远不离开设备。

  • 边缘设备往往具有有限的内存、存储和计算能力(至少与典型的台式机或云计算机相比)。因此,模型推理必须以高效的方式完成。

  • 在使用案例中,设备通常需要低成本、小尺寸、极低功耗,并且不会过热。

因此,对于边缘预测,我们需要一种低成本、高效的设备内 ML 加速器。在某些情况下,加速器已经内置。例如,现代手机通常有内置 GPU。在其他情况下,我们将不得不在仪器的设计中集成加速器。我们可以购买边缘加速器,将其附加或集成到正在构建的仪器(如摄像头和 X 射线扫描仪)中。

与选择快速硬件一起,我们还需要确保不过度使用设备。我们可以利用减少图像模型计算要求的方法,以便它们在边缘上高效运行。

TensorFlow Lite

TensorFlow Lite 是一个在边缘设备上执行 TensorFlow 模型推理的软件框架。请注意,TensorFlow Lite 不是 TensorFlow 的一个版本——我们不能使用 TensorFlow Lite 训练模型。相反,我们使用常规 TensorFlow 训练模型,然后将 SavedModel 转换为适用于边缘设备的高效形式(参见图 9-7)。

图 9-7 创建一个可在边缘运行的 ML 模型。

要将一个 SavedModel 文件转换成 TensorFlow Lite 文件,我们需要使用tf.lite转换器工具。我们可以在 Python 中这样做:

converter = tf.lite.TFLiteConverter.from_saved_model(MODEL_LOCATION)
tflite_model = converter.convert()
with open('export/model.tflite', 'wb') as ofp:
    ofp.write(tflite_model)

为了获得高效的边缘预测,我们需要做两件事。首先,我们应该确保使用类似 MobileNet 这样的边缘优化模型。由于在训练期间修剪连接并使用分段线性逼近激活函数等优化措施,MobileNet 的速度大约是 Inception 等模型的 40 倍。

其次,我们应仔细选择如何量化模型权重。量化的适当选择取决于我们部署模型的设备。例如,Coral Edge TPU 在将模型权重量化为整数时效果最佳。我们可以通过在转换器上指定一些选项来进行整数量化:

converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)
converter.`optimizations` = [tf.lite.Optimize.DEFAULT]
converter.`representative_dataset` `=` `training_dataset``.``take``(``100``)`
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.`inference_input_type` `=` `tf``.``int8`  # or tf.uint8
converter.inference_output_type = tf.int8  # or tf.uint8
tflite_model = converter.convert()

在此代码中,我们要求优化器查看我们训练数据集中的一百个代表性图像(或者模型输入是什么),以确定如何最佳地量化权重而不损失模型的预测能力。我们还要求转换过程仅使用 int8 算术,并指定模型的输入和输出类型将是 int8。

将模型权重从 float32 量化为 int8,使 Edge TPU 能够使用四分之一的内存,并通过在整数上执行算术加速运算,比使用浮点数快一个数量级。量化通常会导致精度损失约为 0.2% 到 0.5%,尽管这取决于模型和数据集。

一旦我们拥有了 TensorFlow Lite 模型文件,我们将文件下载到边缘设备上,或者将模型文件打包到安装在设备上的应用程序中。

运行 TensorFlow Lite

要从模型获取预测结果,边缘设备需要运行 TensorFlow Lite 解释器。Android 自带一个用 Java 编写的解释器。要从 Android 程序中进行推断,我们可以:

try (Interpreter tflite = new Interpreter(tf_lite_file)) {
    tflite.run(inputImageBuffer.getBuffer(), output);
}

iOS 上类似的解释器可用 Swift 和 Objective-C 编写。

提示

ML Kit 框架支持许多常见的边缘用途,如文本识别、条形码扫描、人脸检测和物体检测。ML Kit 与 Firebase(一种流行的移动应用程序软件开发工具包(SDK))集成良好。在自己开发 ML 解决方案之前,请检查 ML Kit 中是否已经提供了相应功能。

对于非手机设备,请使用 Coral Edge TPU。在撰写本文时,Coral Edge TPU 有三种形式可用:

  • 一种可以通过 USB3 连接到边缘设备(如 Raspberry Pi)的插件

  • 带有 Linux 和蓝牙的基板

  • 可以焊接到现有板上的小型独立芯片

Edge TPU 较 CPU 提供的加速大约为 30–50 倍。

使用 TensorFlow Lite 解释器在 Coral 上涉及设置和检索解释器状态:

interpreter = make_interpreter(path_to_tflite_model)
interpreter.allocate_tensors()
common.set_input(interpreter, imageBuffer)
interpreter.invoke()
result = classify.get_classes(interpreter)
注意

要在像 Arduino 这样的微控制器上运行模型,请使用TinyML,¹而不是 TensorFlow Lite。微控制器是一种在单一电路板上的小型计算机,不需要任何操作系统。TinyML 提供了一个定制的 TensorFlow 库,旨在在嵌入式设备上运行,这些设备没有操作系统,只有几十千字节的内存。另一方面,TensorFlow Lite 是一组工具,用于优化在具有操作系统的边缘设备上运行的 ML 模型。

处理图像缓冲区

在边缘设备上,我们将直接在摄像头缓冲区中处理图像,因此我们一次只处理一张图片。让我们适当地更改服务签名(完整代码在09e_tflite.ipynb on GitHub):

@tf.function(input_signature=[
       tf.TensorSpec(`[``None``,` `None``,` `3``]`, dtype=tf.float32)])
def predict_flower_type(img):
    img = tf.image.resize_with_pad(img, IMG_HEIGHT, IMG_WIDTH)
    ...
    return {
        'probability': tf.squeeze(top_prob, axis=0),
        'flower_type': tf.squeeze(pred_label, axis=0)
    }

然后我们将其导出:

model.save(MODEL_LOCATION,
          signatures={
              'serving_default': predict_flower_type
          })

并将此模型转换为 TensorFlow Lite 格式。

联邦学习

使用 TensorFlow Lite,我们在云上训练了模型并将云训练的模型转换为适合复制到边缘设备的文件格式。一旦模型位于边缘设备上,它就不再被重新训练。然而,在边缘 ML 模型上,数据漂移和模型漂移会发生,就像在云模型上一样。因此,我们需要计划将至少一部分图像样本保存到设备上的磁盘,并定期将这些图像检索到集中位置。

请记住,进行边缘推理的一个原因是支持涉及隐私的用例。如果我们不希望图像数据离开设备怎么办?

解决这一隐私问题的一种解决方案是联邦学习。在联邦学习中,设备共同学习一个共享的预测模型,而每个设备都将其训练数据保留在设备上。实质上,每个设备计算一个梯度更新,并仅与其邻居共享梯度(而不是原始图像)或联邦化。联邦中的一个或多个成员对多个设备的梯度更新进行平均,并且只有聚合结果发送到云端。设备也可以基于在设备上发生的交互进一步微调共享的预测模型(参见图 9-8)。这允许在每个设备上进行隐私敏感的个性化。

图 9-8。在联邦学习中,设备上的模型(A)基于其自身的互动和来自许多其他设备的数据进行改进,但数据永远不会离开设备。许多用户的更新被聚合(B)以形成对共享模型的共识变更(C),之后该过程被重复。图像由Google AI Blog提供。

即使采用这种方法,模型攻击仍然可能从训练好的模型中提取出一些敏感信息。为了进一步增强隐私保护,可以将联邦学习与差分隐私结合起来。在TensorFlow 代码库中提供了一个开源框架来实现联邦学习。

摘要

在本章中,我们看了如何调用一个训练好的模型。我们改进了预测 API 提供的抽象层,并讨论了如何提高推理性能。对于批量预测,我们建议使用像 Apache Beam 这样的大数据工具,并在多台机器上分发预测任务。

对于大规模并发的实时预测,我们建议将模型部署为一个使用 TensorFlow Serving 的微服务。我们还讨论了如何更改模型的签名以支持多种需求,并接受直接通过网络发送的图像字节数据。我们还演示了如何使用 TensorFlow Lite 使模型更有效地部署到边缘设备。

到目前为止,我们已经涵盖了典型机器学习流水线的所有步骤,从数据集创建到部署用于预测。在下一章中,我们将探讨如何将它们整合成一个流水线的方法。

¹ Pete Warden 和 Daniel Situnayake,《TinyML》(O’Reilly,2019)。

第十章:生产 ML 的趋势

在本书中,我们将计算机视觉视为数据科学家要解决的问题。然而,由于机器学习用于解决实际业务问题,因此有其他角色与数据科学家进行接口,以执行机器学习,例如:

ML 工程师

数据科学家构建的 ML 模型由 ML 工程师投入生产,他们将典型的机器学习工作流程的所有步骤,从数据集创建到部署以进行预测,串联成一个机器学习管道。你经常会听到这被描述为MLOps

终端用户

基于 ML 模型做出决策的人往往不信任黑匣子 AI 方法。这在医学等领域尤为明显,终端用户是高度训练有素的专家。他们通常要求你的 AI 模型是可解释的——可解释性被广泛认为是负责任地进行 AI 的先决条件。

领域专家

领域专家可以使用无代码框架开发 ML 模型。因此,他们经常帮助进行数据收集、验证和问题可行性评估。你可能听到这被描述为通过无代码低代码工具使 ML“民主化”。

在本章中,我们将探讨这些相邻角色的需求和技能如何越来越影响生产设置中的 ML 工作流程。

提示

本章的代码位于该书的GitHub 存储库10_mlops文件夹中。我们将在适用时提供代码示例和笔记本的文件名。

机器学习管道

图 10-1 展示了机器学习管道的高层视图。为了创建一个接受图像文件并识别其中花朵的网络服务,正如我们在本书中所描述的,我们需要执行以下步骤:

  • 通过将我们的 JPEG 图像转换为 TensorFlow Records 来创建我们的数据集,并将数据分为训练、验证和测试数据集。

  • 训练一个 ML 模型来分类花朵(我们进行了超参数调整以选择最佳模型,但假设我们可以预先确定参数)。

  • 部署模型用于服务。

图 10-1。端到端 ML 管道。

正如您在本节中所看到的,为了完成 ML 管道中的这些步骤,我们必须:

  • 在其上设置一个集群来执行管道。

  • 封装我们的代码库,因为管道执行容器。

  • 编写与管道的每个步骤对应的管道组件。

  • 连接管道组件,以便一次性运行管道。

  • 自动化管道以响应诸如新数据到达之类的事件而运行。

首先,让我们讨论为什么我们首先需要 ML 管道。

管道的需求

在我们对原始数据集进行训练模型之后,如果我们获得了更多文件进行训练,会发生什么?我们需要执行相同的操作来处理这些文件,将它们添加到我们的数据集中,并重新训练我们的模型。在一个依赖于有新鲜数据的模型中(例如用于产品识别而不是花卉分类的模型),我们可能需要每天执行这些步骤。

当模型需要对新数据进行预测时,由于数据漂移的存在,模型的性能开始下降是相当常见的——也就是说,新数据可能与其训练的数据不同。也许新图像的分辨率更高,或者来自我们训练数据集中没有的季节或地点。我们还可以预期,一个月后,我们会有几个新想法想要尝试。也许我们的同事会设计出更好的数据增强滤波器,我们希望将其纳入,或者 MobileNet 的新版本(我们正在进行迁移学习的架构)可能已发布。改变模型代码的实验将是相当常见的,并且必须进行计划。

理想情况下,我们希望有一个框架可以帮助我们安排和操作我们的 ML 流水线,并允许进行持续的实验。Kubeflow Pipelines 提供了一个软件框架,可以用其领域特定语言(DSL)表示我们选择的任何 ML 流水线。它在 Kubeflow 上运行,这是一个针对运行 TensorFlow 模型优化的 Kubernetes 框架(见图 10-2)。Google Cloud 上的托管 Kubeflow Pipelines 执行器称为 Vertex Pipelines。流水线本身可以在 Kubernetes 集群上执行步骤(用于本地工作),或者在 Google Cloud 上调用 Vertex Training、Vertex Prediction 和 Cloud Dataflow。有关实验和步骤的元数据可以存储在集群本身中,也可以存储在 Cloud Storage 和 Cloud SQL 中。

图 10-2. Kubeflow Pipelines API 在 TensorFlow 和 Kubernetes 上运行。
小贴士

大多数 ML 流水线遵循一套相当标准的步骤:数据验证、数据转换、模型训练、模型评估、模型部署和模型监控。如果您的流水线遵循这些步骤,您可以利用 TensorFlow Extended(TFX)提供的高级抽象形式的 Python API。这样,您就不需要在 DSL 和容器化步骤的级别上工作。TFX超出了本书的范围。

Kubeflow Pipelines 集群

要执行 Kubeflow 流水线,我们需要一个集群。我们可以通过转到AI Platform Pipelines 控制台并创建一个新实例在 Google Cloud 上设置一个。一旦启动,我们将获得一个链接来打开 Pipelines 仪表板和一个提供主机 URL 的设置图标(见图 10-3)。

图 10-3. AI 平台管道为 Kubeflow 管道提供了托管的执行环境。

我们可以在 Jupyter 笔记本中开发流水线,然后将其部署到集群中。请在GitHub 上的07e_mlpipeline.ipynb中查看完整的代码。

将代码库容器化

一旦我们有了集群,我们流水线的第一步需要将 JPEG 文件转换为 TensorFlow 记录。回想一下,我们在第五章编写了一个名为jpeg_to_tfrecord.py的 Apache Beam 程序来处理这个任务。为了使其可重复执行,我们需要将其制作成一个容器,其中捕获了所有依赖关系。

我们在 Jupyter 笔记本中开发了它,幸运的是,Vertex AI 上的笔记本服务为每种笔记本实例类型发布了相应的容器映像。因此,要构建一个能够执行该程序的容器,我们需要执行以下操作:

  • 获取与笔记本实例对应的容器映像。

  • 安装任何额外的软件依赖项。浏览所有我们的笔记本,我们看到我们需要安装两个额外的 Python 包:apache-beam[gcp]cloudml-hypertune

  • 复制脚本。因为我们可能还需要仓库中的其他代码来完成其他任务,最好将整个仓库复制过来。

这个 Dockerfile(完整代码在GitHub 上的Dockerfile中)执行了这三个步骤:

FROM gcr.io/deeplearning-platform-release/tf2-gpu
RUN python3 -m pip install --upgrade apache-beam[gcp] cloudml-hypertune
RUN mkdir -p /src/practical-ml-vision-book
COPY . /src/practical-ml-vision-book/
注意

熟悉 Dockerfile 的人会注意到这个文件中没有ENTRYPOINT。这是因为我们将在 Kubeflow 组件中设置入口点——我们流水线中的所有组件都将使用相同的 Docker 镜像。

我们可以使用标准的 Docker 功能将 Docker 镜像推送到容器注册表中:

full_image_name=gcr.io/${PROJECT_ID}/practical-ml-vision-book:latest
docker build -t "${full_image_name}" .
docker push "$full_image_name"

编写组件

对于我们需要的每个组件,我们首先会从一个 YAML 文件中加载其定义,然后使用它来创建实际的组件。

我们需要创建的第一个组件是数据集(参见图 10-1)。从第五章我们知道,这一步涉及运行jpeg_to_tfrecord.py。我们在名为create_dataset.yaml的文件中定义了该组件。它指定了这些输入参数:

inputs:
- {name: runner, type: str, default: 'DirectRunner', description: 'DirectRunner…'}
- {name: project_id, type: str, description: 'Project to bill Dataflow job to'}
- {name: region, type: str, description: 'Region to run Dataflow job in'}
- {name: input_csv, type: GCSPath, description: 'Path to CSV file'}
- {name: output_dir, type: GCSPath, description: 'Top-level directory…'}
- {name: labels_dict, type: GCSPath, description: 'Dictionary file…'}

它还指定了实现方式,即调用名为create_dataset.sh的脚本,在GitHub 上的create_dataset.sh中可以找到。脚本的参数是从组件输入构建的:

implementation:
    container:
        image: gcr.io/[PROJECT-ID]/practical-ml-vision-book:latest
        command: [
            "bash",
            "/src/practical-ml-vision-book/.../create_dataset.sh"
        ]
        args: [
            {inputValue: output_dir},
            {outputPath: tfrecords_topdir},
            "--all_data", {inputValue: input_csv},
            "--labels_file", {inputValue: labels_dict},
            "--project_id", {inputValue: project_id},
            "--output_dir", {inputValue: output_dir},
            "--runner", {inputValue: runner},
            "--region", {inputValue: region},
        ]

create_dataset.sh脚本简单地将所有内容转发给 Python 程序:

cd /src/practical-ml-vision-book/05_create_dataset
python3 -m jpeg_to_tfrecord $@

在这里为什么需要额外的间接层?为什么不简单地将python3指定为命令(而不是调用 shell 脚本的 bash 命令)?这是因为除了调用转换程序外,我们还需要执行其他功能,如创建文件夹、向 Kubeflow 流水线的后续步骤传递消息、清理中间文件等。与其更新 Python 代码以添加与 Kubeflow Pipelines 功能无关的功能,我们将在一个 bash 脚本中包装 Python 代码来完成设置、消息传递和清理工作。稍后将详细介绍这些内容。

我们将按以下方式从流水线调用组件:

create_dataset_op = kfp.components.load_component_from_file(
    'components/create_dataset.yaml'
)
create_dataset = create_dataset_op(
    runner='DataflowRunner',
    project_id=project_id,
    region=region,
    input_csv='gs://cloud-ml-data/img/flower_photos/all_data.csv',
    output_dir='gs://{}/data/flower_tfrecords'.format(bucket),
    labels_dict='gs://cloud-ml-data/img/flower_photos/dict.txt'
)
提示

如果我们传入DirectRunner而不是DataflowRunner,Apache Beam 流水线将在 Kubeflow 集群本身上执行(虽然速度慢,并且在单台机器上执行)。这对于在本地执行非常有用。

给定我们刚刚创建的create_dataset_op组件,我们可以创建一个流水线来运行这个组件:

create_dataset_op = kfp.components.load_component_from_file(
    'components/create_dataset.yaml'
)

`@dsl.pipeline``(`
    `name``=``'``Flowers Transfer Learning Pipeline``'``,`
    `description``=``'``End-to-end pipeline``'`
`)`
`def` `flowerstxf_pipeline``(`
    `project_id``=``PROJECT``,`
    `bucket``=``BUCKET``,`
    `region``=``REGION`
`)``:`
    # Step 1: Create dataset
    create_dataset = create_dataset_op(
        runner='DataflowRunner',
        project_id=project_id,
        region=region,
        input_csv='gs://cloud-ml-data/img/flower_photos/all_data.csv',
        output_dir='gs://{}/data/flower_tfrecords'.format(bucket),
        labels_dict='gs://cloud-ml-data/img/flower_photos/dict.txt'
    )

然后,我们将此流水线编译成一个.zip文件:

pipeline_func = flowerstxf_pipeline
pipeline_filename = pipeline_func.__name__ + '.zip'
import kfp.compiler as compiler
compiler.Compiler().compile(pipeline_func, pipeline_filename)

并将该文件提交为一个实验:

import kfp
client = kfp.Client(host=KFPHOST)
experiment = client.create_experiment('from_notebook')
run_name = pipeline_func.__name__ + ' run'
run_result = client.run_pipeline(
    experiment.id,
    run_name,
    pipeline_filename,
    {
        'project_id': PROJECT,
        'bucket': BUCKET,
        'region': REGION
    }
)

我们还可以上传.zip文件,提交流水线,并在 Pipelines 仪表板上进行实验和运行。

连接组件

现在我们有了流水线的第一步。下一步(参见图 10-1)是在第一步创建的 TensorFlow Records 上训练 ML 模型。

create_dataset步骤和train_model步骤之间的依赖关系表达如下:

create_dataset = create_dataset_op(...)
train_model = train_model_op(
    `input_topdir``=``create_dataset``.``outputs``[``'``tfrecords_topdir``'``]``,`
    region=region,
    job_dir='gs://{}/trained_model'.format(bucket)
)

在此代码中,请注意train_model_op()的一个输入依赖于create_dataset的输出。以这种方式连接两个组件使得 Kubeflow Pipelines 在启动train_model步骤之前等待create_dataset步骤完成。

底层实现涉及create_dataset步骤将tfrecords_topdir的值写入本地临时文件,其名称将由 Kubeflow Pipelines 自动生成。因此,我们的create_dataset步骤必须接受此额外输入并填充文件。以下是在create_dataset.sh中如何将输出目录名称写入文件(Kubeflow 提供给此脚本的参数在 YAML 文件中指定)的方法:

#!/bin/bash -x
OUTPUT_DIR=$1
`shift`
COMPONENT_OUT=$1
shift

# run the Dataflow pipeline
cd /src/practical-ml-vision-book/05_create_dataset
python3 -m jpeg_to_tfrecord `$``@`

# for subsequent components
`mkdir` `-``p` `$``(``dirname` `$``COMPONENT_OUT``)`
`echo` `"``$OUTPUT_DIR``"` `>` `$``COMPONENT_OUT`

该脚本将输出目录名称写入组件输出文件,从命令行参数中移除两个参数(这就是 bash 中shift的作用),并将其余的命令行参数传递给jpeg_to_tfrecord

train_model步骤类似于create_dataset步骤,它使用代码库容器并调用一个脚本来训练模型:

name: train_model_caip
...
implementation:
    container:
        image: gcr.io/[PROJECT-ID]/practical-ml-vision-book:latest
        command: [
            "bash",
            "/src/practical-ml-vision-book/.../train_model_caip.sh",
        ]
        args: [
            {inputValue: input_topdir},
            {inputValue: region},
            {inputValue: job_dir},
            {outputPath: trained_model},
        ]
提示

我们可以通过将对 Vertex AI 训练的调用替换为对gcloud ai-platform local的调用来在集群上进行本地训练运行。有关详细信息,请参阅书籍的 GitHub 仓库中的train_model_kfp.sh

脚本将写出存储训练模型的目录:

echo "${JOB_DIR}/flowers_model" > $COMPONENT_OUT

部署步骤不需要任何自定义代码。要部署模型,我们可以使用 Kubeflow Pipelines 提供的部署运算符:

deploy_op = kfp.components.load_component_from_url(
    'https://.../kubeflow/pipelines/.../deploy/component.yaml')
deploy_model = deploy_op(
    model_uri=train_model.outputs['trained_model'],
    model_id='flowers',
    version_id='txf',
    ...)

当管道运行时,日志、步骤和在步骤之间传递的工件会显示在控制台上(参见图 10-4)。

图 10-4. 展示已运行的管道信息在 Vertex Pipelines 控制台中显示。

自动化运行

因为我们有一个 Python API 来提交一个实验的新运行,将这段 Python 代码整合到 Cloud Function 或 Cloud Run 容器中非常简单。然后,该函数将在响应 Cloud Scheduler 触发器或在新文件添加到存储桶时调用。

实验启动代码也可以在响应持续集成(CI)触发器(如 GitHub/GitLab Actions)时调用,以便在提交新代码时进行重新训练。必要的持续集成、持续部署(CD)、权限管理、基础设施授权和认证共同构成MLOps领域。MLOps 超出了本书的范围,但Google Cloud 平台上的 ML 工程Azure 上的 MLOpsAmazon SageMaker MLOps Workshop GitHub 仓库包含了帮助您在各自平台上入门的说明。

我们已经看到管道如何满足机器学习工程师将典型的机器学习工作流程的所有步骤连接到 ML 管道的需求。接下来,让我们看看可解释性如何满足决策者的需求。

可解释性

当我们向模型展示一幅图像时,我们会得到一个预测。但是为什么会得到那个预测?模型用什么来决定一朵花是雏菊还是郁金香?解释 AI 模型如何工作对多个原因都是有用的:

信任

人类用户可能不会信任一个不解释其行为的模型。如果一个图像分类模型说一张 X 光显示有骨折,但没有指出它用于做出决定的确切像素,很少有医生会信任该模型。

故障排除

知道图像的哪些部分对于做出决定是重要的,可以帮助诊断模型为何出错。例如,如果将一只狗误认为狐狸,而最相关的像素恰好是雪的一部分,那么模型可能错误地学习将背景(雪)与狐狸关联起来。为了纠正这个错误,我们需要收集其他季节的狐狸示例或在雪中的狗示例,或通过将狐狸和狗粘贴到彼此的场景中来增加数据集。

偏见消除

如果我们将图像元数据作为模型的输入,那么检查与敏感数据相关联的特征的重要性对于确定偏差来源可能非常重要。例如,如果模型将道路上的坑洞作为重要特征来识别交通违规行为,这可能是因为模型在训练数据集中学习到了偏差(也许在较贫穷或维护不良的地区发出的罚单比富裕地区多)。

有两种解释方法:全局和实例级。这里的术语全局突出显示这些解释是模型训练后的整体属性,而不是推断时的每个单独预测。这些方法按照它们解释预测变异性的程度对模型的输入进行排名。例如,我们可以说feature1解释了 36%的变异性,feature2解释了 23%,依此类推。因为全局特征重要性是基于不同特征对变异性的贡献程度,所以这些方法是在包含许多示例的数据集上计算的,例如训练或验证数据集。然而,在计算机视觉中全局特征重要性方法并不那么有用,因为当图像直接用作模型的输入时,没有明确的、可读的人工特征。因此,我们将不再考虑全局解释。

第二种解释方法是度量实例级特征重要性。这些解释试图解释每个单独的预测,在促进用户信任和故障排除方面非常宝贵。这些方法在图像模型中更为常见,接下来将进行介绍。

技术

有四种常用的方法用于解释或说明图像模型的预测。按复杂程度递增的顺序,它们是:

让我们依次看看每一种。

LIME

LIME 通过首先识别图像中由连续相似像素组成的补丁(参见图 10-5),然后用统一值替换其中的一些补丁来扰动输入图像。它然后要求模型对扰动后的图像进行预测。对于每个扰动后的图像,我们得到一个分类概率。这些概率根据扰动图像与原始图像的相似程度进行空间加权。最后,LIME 将具有最高正权重的补丁呈现为解释。

图 10-5. LIME 工作原理图,改编自Ruberio 等人,2016。在底部面板中,p 代表图像预测为青蛙的概率。

KernelSHAP

KernelSHAP 类似于 LIME,但是它对扰动实例进行了不同的加权。LIME 将与原始图像相似的实例权重设定得非常低,理论上认为它们包含的额外信息非常少。另一方面,KernelSHAP 根据从博弈论导出的分布对实例进行加权。如果在扰动图像中包含更多的补丁,则该实例的权重较低,因为理论上这些补丁中的任何一个都可能是重要的。在实践中,与 LIME 相比,KernelSHAP 的计算成本通常要高得多,但提供的结果略好一些。

集成梯度

IG 使用模型的梯度来识别哪些像素是重要的。深度学习的一个特性是,在训练初始阶段,训练主要集中在最重要的像素上,因为利用它们的信息可以最大程度地减少错误率。因此,高梯度与训练开始时的重要像素相关联。不幸的是,神经网络在训练过程中会收敛,在收敛过程中,网络会保持与重要像素对应的权重不变,并集中于更少见的情况。这意味着在训练结束时,与最重要像素对应的梯度实际上接近于零!因此,IG 需要整个训练过程中的梯度,而不是在训练结束时的梯度。然而,SavedModel 文件中只包含最终的权重。那么,IG 如何利用梯度来识别重要像素呢?

IG 基于这样的直觉:如果给定一个基线图像,该图像由全部为 0、全部为 1 或在范围[0, 1]内的随机值组成,模型将输出先验类概率。通过逐步改变每个像素值从基线值到实际输入,并计算每种改变的梯度,来数值化计算整体梯度变化。然后,在原始图像上显示积分过基线值到实际像素值的最大梯度的像素(见图 10-6)。

图 10-6. 集成梯度应用于熊猫图像(左)和消防艇图像(右)。图片来自IG TensorFlow 教程
小贴士

在使用 IG 时选择适当的基线图像非常关键。解释是相对于基线的,因此如果您的训练数据包含许多传达图像中意义的黑色(或白色)区域,您不应该使用全白色或全黑色的图像作为基线。例如,X 射线中的黑色区域对应组织。在这种情况下,您应该使用随机像素的基线。另一方面,如果您的训练数据包含许多传达图像中意义的高方差补丁,您可能不希望使用随机像素作为基线。尝试不同的基线是值得的,因为这可能会显著影响归因的质量。

IG 对两幅图像的输出显示在图 10-6 中。在第一幅图像中,IG 确定熊猫面部的鼻子和毛皮纹理是决定该图像是熊猫的最重要部分的像素。第二幅图像是消防船,显示了 IG 如何用于故障排除。这里,消防船被正确地识别为消防船,但该方法使用船上喷射水柱作为关键特征。这表明我们可能需要收集没有主动向空中喷水的消防船的图像。

然而,在实际操作中(我们很快会看到),IG 往往会捕捉到图像中的高信息区域,无论该信息是否被模型用于分类特定图像。

xRAI

在 xRAI 中,使用训练有素的神经网络的权重和偏差来训练解释网络。解释网络输出一组在代数表达式家族中选择的选择(例如布尔表达式和低阶多项式),这些表达式是被很好理解的。因此,xRAI 旨在从简单函数家族中找到对原始训练模型的近似,而不是原始模型本身。然后解释这种近似。

xRAI 方法结合了 LIME 和 KernelSHAP 的预处理方法的优点,以及 IG 提供的基于像素级归因的基线图像中的补丁查找(见图 10-7)。像素级归因在形成补丁的所有像素之间进行集成,然后根据具有相似集成梯度水平的区域将这些补丁组合成区域。然后从输入图像中删除这些区域,并调用模型以确定每个区域的重要性,并根据它们对给定预测的重要性对这些区域进行排序。

图 10-7. xRAI 结合了 LIME 和 KernelSHAP 的基于补丁的预处理以及 IG 的像素级归因,并根据其对预测的影响对区域进行排名。 图片来自Google Cloud 文档

IG 提供像素级归因。xRAI 提供基于区域的归因。两者都有其用途。例如,在诊断眼部疾病区域(糖尿病性视网膜病变用例)的模型中,了解导致诊断的具体像素非常有用,因此使用 IG。IG 在低对比度图像(如 X 光片或实验室中拍摄的科学图像)中效果最好。

在自然图像中,例如检测所描绘动物的类型时,优先选择基于区域的归因,因此使用 xRAI。我们不建议在自然图像上使用 IG,例如在自然环境中或房子周围拍摄的照片。

图 10-8. Tracin 通过识别影响选择的训练示例的训练损失的关键支持者和反对者来工作。支持者与损失减少相关联。图像由Google AI Blog提供。

现在让我们看看如何使用这些技术获取我们花卉模型预测的解释。

添加可解释性

因为图像可解释性与个别预测相关联,我们建议您使用能够为其呈现的每个预测执行前述可解释性技术之一或全部的 ML 部署平台。可解释性方法计算开销大,而能够分发和扩展计算的部署平台可以帮助您更有效地进行预测分析。

在本节中,我们将展示如何使用集成梯度和 xRAI 从部署在 Google Cloud Vertex AI 上的模型获取解释。

提示

在撰写本文时,Azure ML 支持 SHAPAmazon SageMaker Clarify 也支持。尽管语法略有不同,但这些服务的概念上的使用方式相似。具体详情请查阅链接的文档。

可解释性签名

所有的可解释性方法都需要用原始图像的扰动版本来调用模型。比如说,我们的花卉模型有以下导出签名:

@tf.function(input_signature=[tf.TensorSpec([None,], dtype=tf.string)])
def predict_filename(filenames):
    ...

它接受一个文件名,并返回该文件中的图像数据的预测结果。

为了使可解释人工智能(XAI)模块能够创建原始图像的扰动版本并对其进行预测,我们需要添加两个签名:

  • 一个预处理签名,用于获取输入到模型的图像。该方法将接受一个或多个文件名作为输入(如原始导出签名),并生成模型所需形状的 4D 张量(完整代码在 GitHub 上的09f_explain.ipynb中):

    @tf.function(input_signature=[
                 tf.TensorSpec([None,], dtype=tf.string)])
    def xai_preprocess(`filenames`):
        input_images = tf.map_fn(
            preprocess, # preprocessing function from Ch 6
            filenames,
            fn_output_signature=tf.float32
        )
        return {
            `'``input_images``'`: input_images
        }
    

    请注意,返回值是一个字典。字典的键值(这里是input_images)必须与接下来描述的第二个签名中的参数名匹配,以便后面可以调用这两种方法,并在我们稍后讨论的第三个模型签名中一起调用。

  • 一个模型签名,用于发送 4D 图像张量(XAI 将发送扰动图像)并获取预测:

    @tf.function(input_signature=[
         tf.TensorSpec(`[``None``,` `IMG_HEIGHT``,` `IMG_WIDTH``,` `IMG_CHANNELS``]`,
                       dtype=tf.float32)])
    def xai_model(`input_images`):
        batch_pred = model(input_images) # same as model.predict()
        top_prob = tf.math.reduce_max(batch_pred, axis=[1])
        pred_label_index = tf.math.argmax(batch_pred, axis=1)
        pred_label = tf.gather(tf.convert_to_tensor(CLASS_NAMES),
                               pred_label_index)
        return {
            'probability': top_prob,
            'flower_type_int': pred_label_index,
            'flower_type_str': pred_label
       }
    

    此代码调用模型,然后提取出得分最高的标签及其概率。

鉴于预处理和模型签名,最初的签名(大多数客户将使用)可以重构为:

@tf.function(input_signature=[tf.TensorSpec([None,], dtype=tf.string)])
def predict_filename(filenames):
    preproc_output = xai_preprocess(filenames)
    return xai_model(preproc_output['input_images'])

现在,我们保存具有所有三个导出签名的模型:

model.save(MODEL_LOCATION,
           signatures={
               'serving_default': predict_filename,
               'xai_preprocess': xai_preprocess, # input to image
               'xai_model': xai_model # image to output
           })

此时,模型已经具备应用 XAI 所需的签名,但是需要一些额外的元数据来计算解释。

解释元数据

除了模型之外,我们还需要提供 XAI 一个基线图像和一些其他元数据。这些以一个 JSON 文件的形式呈现,我们可以使用 Google Cloud 开源的 Explainability SDK 程序化地创建。

我们首先要指定哪个导出签名是接受扰动图像作为输入的签名,并指定需要解释的输出键(probabilityflower_type_intflower_type_str):

from explainable_ai_sdk.metadata.tf.v2 import SavedModelMetadataBuilder
builder = SavedModelMetadataBuilder(
    MODEL_LOCATION,
    signature_name=`'``xai_model``'`,
    outputs_to_explain=[`'``probability``'`])

然后,我们创建基线图像,它将作为梯度起始点。通常选择是全零(np.zeros)、全一(np.ones)或随机噪声。我们选择第三个选项:

random_baseline = np.random.rand(IMG_HEIGHT, IMG_WIDTH, 3)
builder.set_image_metadata(
    'input_images',
    input_baselines=[random_baseline.tolist()])

请注意,我们在xai_model()函数的输入参数名为input_images

最后,我们保存元数据文件:

builder.save_metadata(MODEL_LOCATION)

这将创建一个名为explanation_metadata.json的文件,与 SavedModel 文件一起存在。

部署模型

与之前一样,将 SavedModel 和相关解释元数据部署到 Vertex AI,但需要一些额外的参数来处理可解释性。要部署一个提供 IG 解释的模型版本,我们会这样做:

gcloud beta ai-platform versions create \
    --origin=$MODEL_LOCATION --model=flowers ig ... \
    `-``-``explanation``-``method` `integrated``-``gradients` --num-integral-steps 25

而要获得 xRAI 解释,我们会这样做:

gcloud beta ai-platform versions create \
    --origin=$MODEL_LOCATION --model=flowers xrai ... \
    `-``-``explanation``-``method` `xrai` --num-integral-steps 25

--num-integral-steps参数指定基线图像和输入图像之间的步骤数,用于数值积分的目的。步数越多,梯度计算越准确(但计算量也越大)。典型值为 25。

提示

解释响应包含每个预测的近似误差。检查代表性输入的近似误差,如果误差过高,则增加步数。

对于这个例子,让我们同时使用两种图像可解释性方法——我们将部署一个版本,它提供名为ig的 IG 解释和一个提供名为xrai的 xRAI 解释的版本。

无论部署的版本是哪个,都可以像正常调用一样,请求的负载看起来像这样:

{
    "instances": [
        {
            "filenames": "gs://.../9818247_e2eac18894.jpg"
        },
        {
            "filenames": "gs://.../9853885425_4a82356f1d_m.jpg"
        },
        ...
    ]
}

它返回每个输入图像的标签和相关概率:

FLOWER_TYPE_INT  FLOWER_TYPE_STR  PROBABILITY
1                dandelion        0.398337
1                dandelion        0.999961
0                daisy            0.994719
4                tulips           0.959007
4                tulips           0.941772

XAI 版本可以用于正常服务而无需性能影响。

获取解释

获取解释的三种方式。第一种是通过gcloud,第二种是通过可解释 AI SDK。这两种方式最终会调用第三种方式——一个 REST API,我们也可以直接使用它。

我们将研究gcloud方法,因为它是最简单和最灵活的。我们可以发送 JSON 请求,并使用以下方法获取 JSON 响应:

gcloud beta ai-platform `explain` --region=$REGION \
    --model=flowers `-``-``version``=``ig` \
    `-``-``json``-``request``=``request``.``json` `>` `response``.``json`

要使用 IG 获取解释,我们将使用以下选项部署此版本(ig):

--explanation-method integrated-gradients

JSON 响应以 base64 编码形式包含归因图像。我们可以使用以下方法对其进行解码:

with open('response.json') as ifp:
    explanations = json.load(ifp)['explanations']
    for expln in explanations:
        `b64bytes` = (expln['attributions_by_label'][0]
                    ['attributions']['input_images']['b64_jpeg'])
        img_bytes = base64.`b64decode`(b64bytes)
        img = tf.image.`decode_jpeg`(img_bytes, channels=3)
        `attribution` = tf.image.convert_image_dtype(img, tf.float32)

五张图像的 IG 结果显示在图 10-9 中。GitHub 上的 10b_explain.ipynb 笔记本具有必要的绘图代码。

图 10-9. 花朵模型的集成梯度解释。输入图像在顶行,XAI 程序返回的归因在第二行。

对于第一张图像,模型似乎使用了高大的白色花朵,以及背景中的部分白色像素,来确定图像是雏菊。在第二张图像中,黄色中心和白色花瓣是模型依赖的部分。令人担忧的是,在第四张图像中,猫似乎是决策的重要部分。有趣的是,郁金香的决策似乎更多地受到绿色茎的驱动,而不是鳞茎状的花朵。再次,正如我们很快会看到的那样,这种归因是误导性的,这种误导性归因展示了 IG 方法的局限性。

要获取 xRAI 解释,我们在部署的模型端点上调用gcloud explain,使用名为xrai的版本。同一花卉图像的 xRAI 归因显示在图 10-10 中。

图 10-10. 花朵模型的 xRAI 解释。输入图像在顶行,XAI 程序返回的归因在第二行。底行包含与第二行相同的信息,但归因图像已经重新上色,以便在本书页面上更容易进行可视化。

请记住,xRAI 使用 IG 方法识别显著区域,然后调用模型以确定图像各个区域的重要性。显然,xRAI 在图 10-10 中的归因比在图 10-9 中使用 IG 方法得到的归因更为精确。

对于第一幅花卉图像,模型专注于高大的白色花朵,只有这朵花。很明显,模型已学会忽略背景中较小的花朵。而 IG 看似表明背景很重要,xRAI 结果显示,模型放弃了背景信息,而选择了图像中最显著的花卉。在第二幅图像中,模型依据的是黄色中心和白色花瓣(IG 也正确)。xRAI 方法的精度对于第三幅图像也很明显——模型关注的是花瓣与中心相连的明亮黄色窄带。这是雏菊独有的特征,有助于将其与色彩相似的蒲公英区分开来。在第四幅图像中,我们可以看到郁金香的球茎是模型用于分类的特征,尽管猫会分散它的注意力。最终的郁金香分类似乎受到了花朵众多的影响。IG 方法误导了我们——茎很显眼,但是球茎驱动了预测概率。

IG 在某些情况下很有用。如果我们考虑了像素级别的辐射图像,像素级别的归因(而不是区域)很重要,那么 IG 的表现会更好。然而,在描绘对象的图像中,xRAI 的表现往往更好。

在本节中,我们研究了如何向我们的预测服务添加可解释性,以满足决策者理解机器学习模型依赖的需求。接下来,让我们看看无代码工具如何帮助民主化机器学习。

无代码计算机视觉

我们在本书中迄今考虑过的计算机视觉问题——图像分类、物体检测和图像分割——都受到低代码和无代码机器学习系统的支持。例如,图 10-11 显示了 Google Cloud AutoML Vision 的起始控制台。

图 10-11. Google Cloud AutoML Vision 支持的基本计算机视觉问题,这是一个可以使用的机器学习工具,无需编写任何代码。

其他适用于图像的无代码和低代码工具包括 Create ML(由 Apple 提供)、DataRobotH2O

为什么使用无代码?

在本书中,我们专注于使用代码实现机器学习模型。然而,将无代码工具纳入整体工作流程是值得的。

在进行计算机视觉项目时,无代码工具有几个优点,包括:

问题的可行性

诸如 AutoML 之类的工具充当了您可以期待的准确性的健全性检查。如果达到的准确性远低于上下文中可以接受的水平,这可以避免浪费时间在无效的机器学习项目上。例如,如果在识别伪造身份证时仅达到 98%的精度以达到期望的召回率,您就知道出现了问题——错误拒绝 2%的客户可能是一个不能接受的结果。

数据质量和数量

无代码工具可以检查数据集的质量。在数据收集之后,在许多机器学习项目中,正确的下一步是出去收集更多/更好的数据,而不是训练一个机器学习模型;AutoML 之类的工具提供的准确性可以帮助您做出这样的决定。例如,如果工具生成的混淆矩阵表明模型经常将水中的所有花卉分类为百合花,这可能表明您需要更多水景照片。

基准测试

使用类似 AutoML 的工具起始可以为您提供一个基准,用以比较您构建的模型。

许多机器学习组织向其领域专家提供无代码工具,以便他们可以检查问题的可行性,并在将问题带给数据科学团队之前帮助收集高质量的数据。

在本节的其余部分中,我们将快速介绍如何在 5 花数据集上使用 AutoML,从加载数据开始。

数据加载

第一步是将数据加载到系统中。我们通过指向云存储桶中的all_data.csv文件来完成这一步(参见图 10-12)。

数据加载后,我们发现有 3,667 张图像,其中包括 633 朵雏菊、898 朵蒲公英等(参见图 10-13)。我们可以验证所有图像都已经标记,并在必要时更正标签。如果我们加载的数据集没有标签,我们可以在用户界面中自行标记图像,或者将任务委托给标注服务(标注服务在第五章中有介绍)。

图 10-12. 通过从云存储中导入文件来创建数据集。

图 10-13. 加载数据集后,我们可以查看图像及其标签。这也是在必要时添加或更正标签的机会。

训练

当我们对标签感到满意时,可以点击“训练新模型”按钮来训练一个新模型。这将引导我们通过图 10-14 所示的一系列屏幕(参见图 10-14),选择模型类型、数据集拆分方式和训练预算。在撰写本文时,我们指定的 8 小时训练预算大约需要花费 25 美元。

图 10-14. 启动训练作业的用户界面屏幕。

请注意,在最后一个屏幕中,我们启用了早停功能,因此如果 AutoML 在验证指标上看不到进一步的改善,它可以决定提前停止。选择这个选项后,训练在不到 30 分钟内完成(见图 10-15),这意味着整个机器学习训练过程花费了我们约 3 美元。结果是 96.4% 的准确率,与我们在第三章中经过大量调整和实验得到的最复杂模型的准确率相当。

图 10-15. AutoML 在不到一个小时内完成训练,成本不到 3 美元,并在 5-花数据集上达到了 96.4% 的准确率。

我们应该警告您,并非所有的无代码系统都相同——我们在本节中使用的 Google Cloud AutoML 系统进行数据预处理和增强,采用最先进的模型,并进行超参数调整以构建非常精确的模型。其他无代码系统可能没有那么复杂:有些只训练一个模型(例如 ResNet50),有些训练单一模型但进行超参数调整,还有一些在一系列模型中进行搜索(ResNet18、ResNet50 和 EfficientNet)。查看文档,以了解您将获得什么。

评估

评估结果表明,最多的误分类是将玫瑰错误地识别为郁金香。如果我们继续我们的实验,我们将检查一些错误(见图 10-16)并尝试收集更多图像以减少假阳性和假阴性。

图 10-16. 检查假阳性和假阴性,以确定需要收集更多的哪些类型的示例。这也可以是一个机会,从数据集中移除不具代表性的图像。

一旦我们对模型的性能感到满意,我们可以将其部署到一个端点,从而创建一个 Web 服务,客户可以通过该服务要求模型进行预测。然后,我们可以向模型发送样本请求并从中获得预测结果。

对于基本的计算机视觉问题,无代码系统的易用性、低成本和高准确性非常具有吸引力。我们建议您在计算机视觉项目中作为第一步引入这些工具。

摘要

在这一章中,我们看到了如何使整个机器学习过程操作化。我们使用 Kubeflow Pipelines 来实现这一目的,并快速浏览了 SDK,创建了 Docker 容器和 Kubernetes 组件,并使用数据依赖关系将它们串联成一个流水线。

我们探讨了几种技术,使我们能够理解模型在进行预测时依赖的信号。我们还看了一下无代码计算机视觉框架的能力,使用 Google Cloud 的 AutoML 来说明典型的步骤。

领域专家使用无代码工具来验证问题的可行性,而机器学习工程师在部署中使用机器学习管道,解释性则用于促进决策者对机器学习模型的采纳。因此,这些通常构成许多计算机视觉项目的两端,并且是数据科学家与其他团队接口的重要点。

这标志着本书的主要部分的结束,我们已经从头到尾构建并部署了一个图像分类模型。在本书的其余部分,我们将专注于高级架构和使用案例。

第十一章:高级视觉问题

到目前为止,本书主要关注整个图像分类的问题。在第二章中,我们提到了图像回归,在第四章中讨论了目标检测和图像分割。在本章中,我们将探讨可以使用计算机视觉解决的更高级别的问题:测量、计数、姿态估计和图像搜索。

提示

本章的代码位于书籍的GitHub 仓库11_adv_problems文件夹中。我们将在适当的地方提供代码样本和笔记本的文件名。

对象测量

有时我们想知道图像中对象的尺寸(例如,沙发长 180 厘米)。尽管我们可以简单地使用像素回归来测量像云层覆盖的空中图像中地面降水这样的东西,但是我们需要为对象测量场景做一些更复杂的事情。我们不能简单地数像素的数量并从中推断尺寸,因为同一对象可能由于其在图像中的位置、旋转、长宽比等而用不同数量的像素表示。让我们按照Imaginea Labs建议的方法,步骤步骤地测量对象的照片。

参考对象

假设我们是一家在线鞋店,我们想通过客户上传的脚印照片来帮助他们找到最合适的鞋码。我们要求顾客把脚弄湿,踏在纸上,然后上传像图 11-1 中展示的脚印照片。然后我们可以使用机器学习模型从脚印中获取适当的鞋码(基于长度和宽度)和脚弓类型。

图 11-1. 左:纸上湿脚印的照片。右:与纸稍微靠近几英寸的相同脚印的照片。识别高压区域有助于识别人的脚型。本节中的照片由作者提供。

机器学习模型应该使用不同的纸张类型、不同的光照、旋转、翻转等来训练,以预测脚印图像在推断时可能接收到的所有可能变化。但是,仅仅通过脚印图像本身是不足以创建有效的测量解决方案的,因为(正如您在图 11-1 中所见)图像中的脚的大小将取决于诸如摄像机与纸之间距离等因素。

解决尺度问题的一个简单方法是包含几乎所有客户都会有的参考对象。大多数客户都有标准尺寸的信用卡,因此可以将其用作参考或校准对象,以帮助模型确定图像中脚的相对大小。如图 Figure 11-2 所示,我们只需要求每位客户在拍照前将信用卡放在其脚印旁边。有一个参考对象可以将测量任务简化为与该对象进行比较。

图 11-2. 左:信用卡旁边湿脚印的照片。右:同一物体的照片,相机离纸稍近了几英寸。

建立我们的训练数据集,包含各种背景上的不同脚印,当然可能需要一些清理,如旋转图像使所有脚印都朝向相同方向。否则,对于某些图像,我们将测量投影长度而不是真实长度。至于参考信用卡,在训练之前我们不会进行任何修正,并在预测时对齐生成的脚印和参考面具。

在训练开始时,我们可以执行数据增强,如旋转、模糊、改变亮度、缩放和对比度,如 Figure 11-3 所示。这可以帮助我们增加训练数据集的大小,同时教导模型足够灵活以接收许多不同的真实世界数据变化。

图 11-3. 在训练开始时执行的脚印图像数据增强。

分割

机器学习模型首先需要在图像中分割出脚印和信用卡,并将它们识别为两个正确提取的对象。为此,我们将使用 Mask R-CNN 图像分割模型,如 第四章 中讨论的,并在 Figure 11-4 中描述。

图 11-4. Mask R-CNN 架构。图片取自 He et al., 2017

通过架构的面具分支,我们将预测脚印的面具和信用卡的面具,获得类似于 Figure 11-4 右侧的结果。

请记住我们面具分支的输出有两个通道:一个用于每个对象,脚印和信用卡。因此,我们可以单独查看每个面具,如图 Figure 11-5 所示。

图 11-5. 脚印和信用卡的单独面具。

接下来,我们必须对齐面具,以便获得正确的测量结果。

旋转校正

一旦我们获得了脚印和信用卡的掩模,它们必须根据可能在拍摄照片时以略微不同的方向放置信用卡的用户进行归一化处理。

要纠正旋转,我们可以对每个掩模应用主成分分析(PCA),以获取特征向量 ——例如,物体在最大特征向量方向上的大小是物体的长度(见图 11-6)。从 PCA 获得的特征向量彼此正交,每个后续分量的特征向量对方差的贡献越来越小。

图 11-6. 信用卡可能相对于脚放置在稍有不同的方向上。每个对象中两个最大特征向量的方向由轴标记。

在 PCA 之前,掩模的尺寸位于一个向量空间中,该空间的维度轴是相对于原始图像的,如图 11-6 左侧所示。利用事实,即在 PCA 之后特征向量位于不同的向量空间基础上,现在的轴沿着最大方差的方向(如图 11-6 右侧所示),我们可以利用原始坐标轴与第一个特征向量之间的角度来确定需要进行多少旋转校正。

比率和测量

有了我们校正旋转后的掩模,现在可以计算脚印的测量值。我们首先将我们的掩模投影到二维空间,并沿 x 和 y 轴观察。长度通过测量最小和最大y坐标值之间的像素距离来确定,宽度则类似于x维度。请记住,脚印和信用卡的测量单位均为像素,而不是厘米或英寸。

接下来,通过了解信用卡的精确尺寸,我们可以找到像素尺寸与卡片实际尺寸之间的比率。然后,可以将此比率应用于脚印的像素尺寸,以确定其真实尺寸。

确定拱型类型略微更复杂,但仍需要在找到高压区域后进行像素计数(见苏等人,2015,以及图 11-1)。通过正确的测量值,如图 11-7 所示,我们的商店将能够为每位顾客找到最适合的鞋子。

图 11-7. 我们可以使用参考像素/厘米比率来获得经过 PCA 校正的掩模的最终测量。

计数

计算图像中物体的数量是一个具有广泛应用的问题,从估计人群规模到从无人机图像中识别作物潜在产量。请问图 11-8 中的照片中有多少浆果?

图 11-8. 植物上的浆果。照片作者提供。

基于我们目前所涵盖的技术,您可以选择以下的方法之一:

  1. 训练一个物体检测分类器来检测浆果,并计算边界框的数量。然而,浆果往往会彼此重叠,检测方法可能会错过或结合浆果。

  2. 把这视为分割问题。找出包含浆果的各个分段,然后根据每个群集的特性(例如大小),确定每个浆果的数量。这种方法的问题在于它不具有尺度不变性,如果我们的浆果比典型的更小或更大,则会失败。与前一节讨论的脚尺寸测量场景不同,难以将参考物体整合到这个问题中。

  3. 把这看作一个回归问题,从整个图像估计浆果的数量。这种方法与分割方法一样存在尺度问题,难以找到足够的标记图像,尽管过去已成功用于计数人群野生动物

这些方法还存在其他缺点。例如,前两种方法要求我们正确分类浆果,而回归方法忽略了位置信息,而我们知道位置信息是图像内容的重要信息来源。

更好的方法是在模拟图像上使用密度估计。在本节中,我们将讨论这种技术并逐步介绍该方法。

密度估计

对于像这样物体小且重叠的情况下的计数,有一种替代方法,由 Victor Lempitsky 和 Andrew Zisserman 在 2010 年的论文中介绍,避免了需要进行物体检测或分割,并且不会丢失位置信息。其想法是教会网络估计图像区域(这里是浆果)的密度¹。

为了进行密度估计,我们需要具有指示密度的标签。因此,我们将原始图像分解为较小的非重叠块,并且我们通过浆果中心点的数量标记每个块,如图 11-9 所示。正是这个值,网络将学会估计。为了确保块中的浆果总数等于图像中的浆果数量,我们确保只有浆果中心点在块中才算浆果在块中。因为某些浆果可能只部分在块中,所以模型的网格输入必须比块大。输入由虚线表示。显然,这使得图像的边界问题变得棘手,但是我们可以像图 11-9 右侧所示一样简单地填充图像来处理这个问题。

图 11-9。模型是在原始图像的块上进行训练:左侧面板显示了三个这样的块的输入和标签。标签包含那些中心点位于每个块内部正方形内的浆果数量。输入块需要在所有边上进行“same”填充,而标签块只包含有效像素。

当然,这种方法不仅适用于浆果计数——它在估计人群规模、计算生物图像中的细胞数量以及其他一些应用中通常比替代方法效果更好。这与图像回归类似,只是通过使用块增加了数据集大小,并教会模型关注密度。

提取块

给定一个包含浆果图像和一个标签图像的问题,其中每个浆果的中心点对应于 1s,生成所需的输入和标签块的最简单方法是使用 TensorFlow 函数 tf.image.extract_patches()。这个函数要求我们传入一个图像批次。如果我们只有一张图像,那么我们可以通过使用 tf.expand_dims() 添加一个批次大小为 1 的维度。由于标签图像只有一个通道,因为它是布尔型的,所以我们还必须添加深度维度为 1(完整的代码在 11a_counting.ipynb on GitHub 中):

def get_patches(img, label, verbose=False):
    img = tf.expand_dims(img, axis=0)
    label = tf.expand_dims(tf.expand_dims(label, axis=0), axis=-1)

现在我们可以在输入图像上调用 tf.image.extract_patches()。请注意下面的代码中,我们要求获取大小为虚线框 (INPUT_WIDTH) 的块,但是步长是较小的标签块 (PATCH_WIDTH) 的大小。如果虚线框是 64x64 像素,那么每个框将有 64 * 64 * 3 像素值。这些值将是 4D 的,但我们可以将块值重新整形为平坦数组以便于使用:

num_patches = (FULL_IMG_HEIGHT // PATCH_HEIGHT)**2
patches = tf.image.extract_patches(img,
    `=``[``1``,` `INPUT_WIDTH``,` `INPUT_HEIGHT``,` `1``]``,`
    =[1, PATCH_WIDTH, PATCH_HEIGHT, 1],
    =[1, 1, 1, 1],
    `=``'``SAME``'``,`
    ='get_patches')
patches = tf.reshape(patches, [num_patches, -1])

接下来,我们在标签图像上重复相同的操作:

labels = tf.image.extract_patches(label,
    `=``[``1``,` `PATCH_WIDTH``,` `PATCH_HEIGHT``,` `1``]``,`
    =[1, PATCH_WIDTH, PATCH_HEIGHT, 1],
    =[1, 1, 1, 1],
    `=``'``VALID``'``,`
    ='get_labels')
labels = tf.reshape(labels, [num_patches, -1])

标签补丁的代码与图像补丁的代码有两个关键差异。首先,标签补丁的大小仅为内部框的大小。还要注意填充规格的差异。对于输入图像,我们指定padding=SAME,要求 TensorFlow 用零填充输入图像,然后从中提取所有较大框大小的补丁(见图 11-9)。对于标签图像,我们只要求完全有效的框,因此不会进行填充。这确保我们对于每个有效的标签补丁都得到相应的外框图像。

标签图像现在将 1 对应于我们要计数的所有对象的中心。我们可以通过对标签补丁的像素值求和找到这些对象的总数,我们将其称为密度:

 # the "density" is the number of points in the label patch
 patch_labels = tf.math.reduce_sum(labels, axis=[1], name='calc_density')

模拟输入图像

在他们 2017 年的论文中,Maryam Rahnemoor 和 Clay Sheppard 表明,甚至不需要真实的标记照片就可以训练神经网络进行计数。为了训练他们的神经网络在藤上计数番茄,作者们简单地输入了由红色圆圈组成的模拟图像,背景为棕色和绿色。由于这种方法只需要模拟数据,因此可以快速创建大型数据集。结果训练好的神经网络在实际的番茄植物上表现良好。接下来,我们将展示这种称为深度模拟学习的方法。当然,如果您确实有标记数据,其中每个浆果(或人群中的人,或样本中的抗体)都被标记,那么您可以使用那些数据。

我们将生成一个模糊的绿色背景,模拟 25 到 75 个“浆果”,并将它们添加到图像中(见图 11-10)。

图 11-10. 在绿色背景上模拟用于计数“浆果”的输入图像。第一幅图是背景,第二幅是模拟的浆果,第三幅是实际输入图像。

关键代码部分是随机放置几个浆果:

num_berries = np.random.randint(25, 75)
berry_cx = np.random.randint(0, FULL_IMG_WIDTH, size=num_berries)
berry_cy = np.random.randint(0, FULL_IMG_HEIGHT, size=num_berries)
label = np.zeros([FULL_IMG_WIDTH, FULL_IMG_HEIGHT])
label[berry_cx, berry_cy] = 1

在标签图像的每个浆果位置上,画一个红色圆圈:

berries = np.zeros([FULL_IMG_WIDTH, FULL_IMG_HEIGHT])
for idx in range(len(berry_cx)):
    rr, cc = draw.circle(berry_cx[idx], berry_cy[idx],
                         radius=10,
                         shape=berries.shape)
    berries[rr, cc] = 1

然后将浆果添加到绿色背景中:

img = np.copy(backgr)
img[berries > 0] = [1, 0, 0] # red

一旦我们有了图像,我们可以从中生成图像补丁,并通过将落入标签补丁内的浆果中心相加来获得密度。一些示例补丁及其相应的密度显示在图 11-11 中。

图 11-11. 几个补丁和相应的密度。请注意,标签补丁仅包含输入补丁的中心 50%,并且只有其中心位于标签补丁内的红色圆圈才会被计入密度计算。

回归

一旦我们开始创建补丁,我们就可以在补丁上训练回归模型来预测密度。首先,通过生成模拟图像来设置我们的训练和评估数据集:

def create_dataset(num_full_images):
    def generate_patches():
        for i in range(num_full_images):
            img, label = generate_image()
            patches, patch_labels = get_patches(img, label)
        for patch, patch_label in zip(patches, patch_labels):
            yield patch, patch_label

    return tf.data.Dataset.from_generator(
            generate_patches,
            (tf.float32, tf.float32), # patch, patch_label
            (tf.TensorShape([INPUT_HEIGHT*INPUT_WIDTH*IMG_CHANNELS]),
             tf.TensorShape([]))
    )

我们可以使用我们在第三章讨论过的任何模型。为了说明,让我们使用一个简单的 ConvNet(完整的代码可以在GitHub 上的 11a_counting.ipynb中找到):

Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
reshape (Reshape)            (None, 64, 64, 3)         0
_________________________________________________________________
conv2d (Conv2D)              (None, 62, 62, 32)        896
_________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 31, 31, 32)        0
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 29, 29, 64)        18496
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 14, 14, 64)        0
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 12, 12, 64)        36928
_________________________________________________________________
flatten (Flatten)            (None, 9216)              0
_________________________________________________________________
dense (Dense)                (None, 64)                589888
_________________________________________________________________
dense_1 (Dense)              (None, 1)                 65
=================================================================
Total params: 646,273
Trainable params: 646,273
Non-trainable params: 0

这里展示的架构的关键方面有:

  • 输出是一个单一的数值(密度)。

  • 输出节点是一个线性层(因此密度可以取任何数值)。

  • 损失是均方误差。

这些方面使得该模型成为能够预测密度的回归模型。

预测

记住,模型接受一个补丁,并预测补丁中的浆果密度。给定输入图像,我们必须像在训练期间那样将其分成补丁,并对所有补丁进行模型预测,然后总结预测的密度,如下所示:

def count_berries(model, img):
    num_patches = (FULL_IMG_HEIGHT // PATCH_HEIGHT)**2
    img = tf.expand_dims(img, axis=0)
    patches = tf.image.extract_patches(img,
        sizes=[1, INPUT_WIDTH, INPUT_HEIGHT, 1],
        strides=[1, PATCH_WIDTH, PATCH_HEIGHT, 1],
        rates=[1, 1, 1, 1],
        padding='SAME',
        name='get_patches')
    patches = tf.reshape(patches, [num_patches, -1])
    densities = model.predict(patches)
    return tf.reduce_sum(densities)

一些独立图像的预测结果显示在图 11-12 中。如您所见,预测值与实际数值相差不超过 10%。

图 11-12. 模型预测值与每个图像中实际对象数量的比较。

然而,当我们在实际的浆果图像上尝试时,估计结果相差很大。解决这个问题可能需要模拟不同大小的浆果,而不仅仅是在随机位置放置大小相同的浆果。

姿势估计

有多种情况我们可能希望识别物体的关键部位。非常常见的情况是识别肘部、膝盖、面部等,以便识别人物的姿势。因此,这个问题被称为姿势估计姿势检测。姿势检测可以用于识别被拍摄主体是坐着、站着、跳舞、躺下或者为运动和医疗环境提供姿势建议。

针对像 Figure 11-13 中的照片,我们如何识别图像中的脚、膝盖、肘部和手部?

图 11-13. 识别关键身体部位的相对位置对提供关于改进球员姿态建议是有用的。照片由作者拍摄。

在本节中,我们将讨论该技术,并指向一个已经训练好的实现。几乎不需要从头开始训练姿势估计模型——相反,您将使用已经训练好的姿势估计模型的输出来确定图像中的主体正在做什么。

PersonLab

这种最先进的方法是由 George Papandreou 等人在 2018 年提出的论文中建议的。他们称之为 PersonLab,但现在实施他们方法的模型被称为PoseNet。从概念上讲,PoseNet 包括在 Figure 11-14 中描述的步骤:

  1. 使用对象检测模型来识别骨架中所有感兴趣点的热图。这些通常包括膝盖、肘部、肩部、眼睛、鼻子等。为简单起见,我们将这些称为关节。热图是对象检测模型分类头部输出的分数(即阈值化之前)。

  2. 锚定在每个检测到的关节处,识别附近关节最可能的位置。图中显示了检测到手腕时肘部的偏移位置。

  3. 使用投票机制来检测基于步骤 1 和 2 选择的人体姿势的关节。

实际上,步骤 1 和 2 是通过对象检测模型(可以使用第四章讨论的任何模型)同时进行的,该模型预测一个关节及其位置以及与附近关节的偏移。

图 11-14. 识别关键关节的相对位置有助于识别人体姿势。图片修改自Papandreou et al., 2018

我们需要步骤 2 和 3 是因为仅仅运行对象检测模型来检测各种关节是不够的—模型可能会漏掉一些关节并识别虚假的关节。这就是为什么 PoseNet 模型还预测从检测到的关节到附近关节的偏移。例如,如果模型检测到手腕,手腕检测会带有肘部关节位置的偏移预测。这有助于在某些情况下,例如肘部未被检测到的情况下。如果检测到了肘部,我们现在可能有三个该关节的候选位置—来自热图的肘部位置和手腕和肩部偏移预测的肘部位置。考虑到所有这些候选位置,使用称为霍夫变换的加权投票机制确定关节的最终位置。

PoseNet 模型

PoseNet 实现可用于Android 上的 TensorFlow和 Web 浏览器。TensorFlow JS 实现在 Web 浏览器中运行,并使用 MobileNet 或 ResNet 作为底层架构,但继续称其为 PoseNet。OpenPose提供了另一种实现方式。

TensorFlow JS PoseNet 模型经过训练,可以识别包括面部特征(鼻子、左眼、右眼、左耳、右耳)和关键肢体关节(肩部、肘部、手腕、臀部、膝盖和踝部)在内的 17 个身体部位,分别位于左侧和右侧。

要尝试它,您需要运行一个本地网络服务器—GitHub 仓库中的 11b_posenet.html 提供了详细信息。加载posenet包并要求它估算单个姿势(而不是一张图中的多个人):

posenet.load().then(function(net) {
    const pose = net.estimateSinglePose(imageElement, {
        flipHorizontal: false
    });
    return pose;
})

请注意,我们要求图像不要翻转。但是,如果您处理自拍图像,您可能希望水平翻转它们以匹配用户体验的镜像图像。

我们可以使用以下方法直接显示返回的 JSON 元素:

document.getElementById('output_json').innerHTML =
    "<pre>" + JSON.stringify(pose, null, 2) + "</pre>";

JSON 中标识的关键点及其在图像中的位置:

{
    "score": 0.5220872163772583,
    "part": "leftEar",
    "position": {
        "x": 342.9179292671411,
        "y": 91.27406275411522
    }
},

我们可以像在图 11-15 中展示的那样,直接注释图像。

图 11-15. 一个带注释的图像,注释来自 PoseNet 的输出。每个浅灰色框中包含一个标记(例如,rightWrist),它们由骨架连接。

PoseNet 的准确性由底层分类模型的准确性(例如,相比于 MobileNet,ResNet 往往更大且速度更慢但更准确)和输出步幅的大小决定——步幅越大,补丁越大,因此输出位置的精度受到影响。

当 PoseNet 被加载时,这些因素可以被改变:

posenet.load({
    architecture: 'ResNet50',
    outputStride: 32, # default 257
    inputResolution: { width: 500, height: 900 },
    quantBytes: 2
});

较小的输出步幅会导致更精确的模型,但速度会降低。输入分辨率指定图像在输入 PoseNet 模型之前调整大小和填充的大小。该值越大,准确性越高,但速度越慢。

MobileNet 架构有一个称为multiplier的参数,用于指定卷积操作的深度乘数。乘数越大,模型的准确性就越高,但速度更慢。ResNet 中的quantBytes参数指定了用于权重量化的字节数。使用4会比使用1得到更高的准确性和更大的模型。

识别多个姿势

为了估计单个图像中多人的姿势,我们使用与前一节中概述的相同技术,并添加了一些额外的步骤:

  1. 使用图像分割模型识别在图像中对应于人物的所有像素。

  2. 使用关节的组合,识别特定身体部位(例如鼻子)最可能的位置。

  3. 使用步骤 1 中找到的分割蒙版中的像素以及步骤 2 中确定的可能连接,将人物像素分配给各自的人物。

例如,在图 11-16 中显示了一个示例。同样,可以在第四章讨论的任何图像分割模型中使用。

图 11-16. 在图像中识别多人的姿势。改编自Papandreou 等人,2018

运行 PoseNet 时,您可以要求它使用以下方法估计多个姿势:

net.estimateMultiplePoses(image, {
    flipHorizontal: false,
    maxDetections: 5,
    scoreThreshold: 0.5,
    nmsRadius: 20
});

这里的关键参数包括图像中的最大人数(maxDetections)、人物检测的置信度阈值(scoreThreshold),以及两个检测之间应该抑制的距离(nmsRadius,以像素为单位)。

接下来,让我们看一下支持图像搜索的问题。

图像搜索

eBay 使用图像搜索来改善购物体验(例如,找到特定名人所穿眼镜的款式)和列表体验(例如,这是你正在尝试出售的小工具的所有相关技术规格)。

在这两种情况下的关键问题是找到数据集中与新上传图像最相似的图像。为了提供这种能力,我们可以使用嵌入。其核心思想是两张相似的图像会有接近的嵌入。因此,要搜索类似的图像,我们可以简单地搜索相似的嵌入。

分布式搜索

为了使搜索相似嵌入成为可能,我们将不得不在数据集中创建嵌入的搜索索引。假设我们将此嵌入索引存储在像 Google BigQuery 这样的大规模分布式数据仓库中。

如果我们在数据仓库中有天气图像的嵌入,那么就能够轻松地搜索与当前某些场景类似的“相似”天气情况。这里是可以执行的SQL 查询

WITH ref1 AS (
    SELECT time AS ref1_time, ref1_value, ref1_offset
    FROM `ai-analytics-solutions.advdata.wxembed`,
        UNNEST(ref) AS ref1_value WITH OFFSET AS ref1_offset
    WHERE time = '2019-09-20 05:00:00 UTC'
)
SELECT
    time,
    SUM( (ref1_value - ref[OFFSET(ref1_offset)])
        * (ref1_value - ref[OFFSET(ref1_offset)]) ) AS sqdist
FROM ref1, `ai-analytics-solutions.advdata.wxembed`
GROUP BY 1
ORDER By sqdist ASC
LIMIT 5

我们正在计算在指定时间戳(refl1)处的嵌入与其他每个嵌入之间的欧氏距离,并显示最接近的匹配项。结果如下所示:

<0xa0> time sqdist
0 2019-09-20 05:00:00+00:00 0.000000
1 2019-09-20 06:00:00+00:00 0.519979
2 2019-09-20 04:00:00+00:00 0.546595
3 2019-09-20 07:00:00+00:00 1.001852
4 2019-09-20 03:00:00+00:00 1.387520

这样做非常有道理。从前/后一小时的图像最相似,然后是+/– 2 小时的图像,依此类推。

快速搜索

在前一节的 SQL 示例中,我们搜索了整个数据集,我们之所以能够高效地完成,是因为 BigQuery 是一个大规模扩展的云数据仓库。然而,数据仓库的一个缺点是它们往往具有高延迟。我们将无法获得毫秒级的响应时间。

对于实时服务,我们需要更聪明地搜索相似嵌入。可扩展最近邻居(ScaNN),我们在下一个示例中使用它,可以对搜索空间进行修剪,并提供了一种高效查找相似向量的方法。

让我们构建我们的 5 种花卉数据集的前 100 张图像的搜索索引(当然,通常情况下,我们会构建一个更大的数据集,但这只是一个示例)。我们可以通过创建一个 Keras 模型来创建 MobileNet 嵌入:

layers = [
    hub.KerasLayer(
        "https://.../mobilenet_v2/...",
        input_shape=(IMG_WIDTH, IMG_HEIGHT, IMG_CHANNELS),
        trainable=False,
        name='mobilenet_embedding'),
    tf.keras.layers.Flatten()
]
model = tf.keras.Sequential(layers, name='flowers_embedding')

要创建一个嵌入数据集,我们循环遍历花卉图像数据集,并调用模型的predict()函数(完整代码在 GitHub 上的11c_scann_search.ipynb中):

def create_embeddings_dataset(csvfilename):
    ds = (tf.data.TextLineDataset(csvfilename).
          map(decode_csv).batch(BATCH_SIZE))
    dataset_filenames = []
    `dataset_embeddings` `=` `[``]`
    for filenames, images in ds:
        embeddings = `model``.``predict``(``images``)`
        dataset_filenames.extend(
            [f.numpy().decode('utf-8') for f in filenames])
        `dataset_embeddings``.``extend``(``embeddings``)`
    dataset_embeddings = tf.convert_to_tensor(dataset_embeddings)
    return dataset_filenames, dataset_embeddings

一旦我们有了训练数据集,我们可以初始化ScaNN 搜索器,指定要使用的距离函数为余弦距离(我们也可以使用欧氏距离):

searcher = scann.scann_ops.builder(
    dataset_embeddings,
    NUM_NEIGH, "dot_product").score_ah(2).build()

这将构建一个用于快速搜索的树。

要搜索某些图像的邻居,我们获取它们的嵌入并调用搜索器:

_, query_embeddings = create_embeddings_dataset(
    "gs://cloud-ml-data/img/flower_photos/eval_set.csv"
)
neighbors, distances = searcher.search_batched(query_embeddings)

如果只有一张图片,调用searcher.search()

在图 11-17 中显示了一些结果。我们正在寻找与每行第一张图片相似的图像;其他面板显示了三个最接近的邻居。结果并不太令人印象深刻。如果我们使用更好的方法来创建嵌入,而不是使用用于迁移学习的 MobileNet 嵌入,会怎样?

图 11-17。搜索与每行第一张图片相似的图像。

更好的嵌入

在前面的部分中,我们使用了 MobileNet 的嵌入,这些嵌入是通过训练大型图像分类模型获得的中间瓶颈层得到的。可以使用更定制的嵌入。例如,在搜索面部相似性时,来自训练用于识别和验证面部的模型的嵌入将比通用嵌入表现更好。

为了优化面部搜索的嵌入,一个名为FaceNet的系统使用匹配/非匹配面部块的三元组,这些块基于面部特征对齐。三元组由两个匹配和一个非匹配的面部缩略图组成。使用三元损失函数,旨在通过最大可能距离将正对组与负对组分开。缩略图本身是面部区域的紧凑裁剪图。网络训练时,显示给网络的三元组的难度会增加。

注意

由于围绕面部搜索和验证的道德敏感性,我们没有在我们的存储库中演示面部搜索的实现或进一步讨论此主题。实现FaceNet 技术的代码在线上已经很容易获得。请确保您负责任地使用 AI,并遵守不违反政府、行业或公司政策的方式。

三元损失可以用来创建通过标签聚类在一起的嵌入,使得具有相同标签的两个图像的嵌入彼此靠近,而具有不同标签的两个图像的嵌入彼此远离。

三元损失的正式定义使用三个图像:锚定图像,另一个具有相同标签的图像(使得第二个图像和锚定图像形成正对组),以及具有不同标签的第三个图像(使得第三个图像和锚定图像形成负对组)。给定三个图像,三元组(a, p, n)的损失定义为距离d(a, p)朝零推进,并且距离d(a, n)至少比d(a, p)大一些边距:

Lmax(d(a,p)d(a,n)+margin,0)

根据这个损失,负样本分为三类:

  • 硬负样本,即比锚点更接近正样本的负样本。

  • 简单负样本,即距离锚点非常远的负样本。

  • 半硬负样本,比正样本更远,但在边距距离之内。

在 FaceNet 论文中,Schroff 等人发现专注于半硬负样本可以产生嵌入,其中具有相同标签的图像聚集在一起,并且与具有不同标签的图像不同。

我们可以通过添加线性层来改进我们的花卉图像的嵌入,然后训练模型以最小化这些图像上的三元损失,重点放在半硬负样本上:

layers = [
    hub.KerasLayer(
        "https://tfhub.dev/.../mobilenet_v2/feature_vector/4",
        input_shape=(IMG_HEIGHT, IMG_WIDTH, IMG_CHANNELS),
        trainable=False,
        name='mobilenet_embedding'),
    `tf``.``keras``.``layers``.``Dense``(``5``,` `activation``=``None``,` name='dense_5'),
    tf.keras.layers.Lambda(lambda x: `tf``.``math``.``l2_normalize``(``x``,` `axis``=``1``)``,`
                           name='normalize_embeddings')
]
model = tf.keras.Sequential(layers, name='flowers_embedding')
model.compile(optimizer=tf.keras.optimizers.Adam(0.001),
              `loss``=``tfa``.``losses``.``TripletSemiHardLoss``(``)`)

在上述代码中,架构确保生成的嵌入维度为 5,并且嵌入值已归一化。

注意,损失的定义意味着我们必须确保每个批次至少包含一个正对。洗牌和使用足够大的批次大小通常有效。在我们的 5 花示例中,我们使用了批次大小为 32,但这是您需要进行实验的数字。假设k个类均匀分布,批次大小为B包含至少一个正对的概率为:

1k1Bk

对于 5 个类和批次大小为 32,这相当于 99.9%。然而,0.1%并不为零,因此在摄入管道中,我们必须丢弃不符合此标准的批次。

在训练此模型并在测试数据集上绘制嵌入(完整代码在GitHub 上的11c_scann_search.ipynb中),我们看到生成的嵌入与相似标签聚集(见图 11-18)。

图 11-18。在使用三元损失训练模型时,我们发现具有相同标签的图像在嵌入空间中聚集在一起。

当我们搜索相似图像时,结果表明这一点是显而易见的(参见图 11-19)—距离更小,图像看起来比图 11-17 中的图像更相似。

图 11-19. 在使用三元损失训练嵌入时,距离变小,附近的图像确实更相似。与图 11-17 进行比较。

概要

在本章中,我们探讨了多种基于基础计算机视觉技术的用例。可以使用参考对象、蒙版和一些图像校正来进行物体测量。通过对物体检测的后处理可以进行计数。然而,在某些情况下,密度估算更为合适。姿态估计是通过预测图像内粗粒度块中不同关节的可能性来完成的。通过使用三元损失训练嵌入并使用快速搜索方法(如 ScaNN)可以改进图像搜索。

在下一章中,我们将探讨如何生成图像,而不仅仅是处理它们。

¹ Lempitsky 和 Zisserman 提出了一种称为 MESA 距离的自定义损失函数,但该技术使用传统的均方误差效果也很好,这就是我们展示的内容。

第十二章:图像与文本生成

到目前为止,在本书中,我们专注于处理图像的计算机视觉方法。在本章中,我们将研究能够生成图像的视觉方法。不过,在讨论图像生成之前,我们必须学习如何训练模型以理解图像中发生的事情,以便知道该生成什么。我们还将讨论基于图像内容生成文本(标题)的问题。

小贴士

本章的代码位于书籍的 12_generation 文件夹中的 GitHub 仓库 中。我们将在适当的地方提供代码示例和笔记本文件名。

图像理解

知道图像中有哪些组件是一回事,但实际理解图像中发生了什么并利用该信息进行其他任务是完全不同的。在本节中,我们将快速回顾嵌入,然后看看各种方法(自编码器和变分自编码器)来对图像进行编码并了解其属性。

嵌入

深度学习用例的一个常见问题是缺乏足够的数据或高质量的数据。在第三章中,我们讨论了迁移学习,它提供了一种方法来提取从一个训练在更大数据集上的模型中学到的嵌入,并将这些知识应用到在较小数据集上训练有效模型的过程中。

使用迁移学习时,我们使用的嵌入是通过在相同任务上训练模型创建的,例如图像分类。例如,假设我们有一个在 ImageNet 数据集上训练的 ResNet50 模型,如图 12-1 所示。

图 12-1. 训练 ResNet 模型以分类图像。

要提取学到的图像嵌入,我们会选择模型的一个中间层——通常是最后一个隐藏层——作为输入图像的数值表示(见图 12-2)。

图 12-2. 从训练好的 ResNet 模型中进行特征提取以获取图像嵌入。

使用训练分类模型并使用其倒数第二层创建嵌入的方法存在两个问题:

  • 要创建这些嵌入,我们需要一个大型的、带标签的图像数据集。在这个例子中,我们在 ImageNet 上训练了一个 ResNet50 以获取图像嵌入。然而,这些嵌入只对 ImageNet 中的图像类型(例如互联网上的照片)有效。如果您有不同类型的图像(例如机器部件的图表、扫描书页、建筑图纸或卫星图像),那么从 ImageNet 数据集中学到的嵌入可能效果不佳。

  • 这些嵌入反映了确定图像标签相关信息。因此,许多与特定分类任务无关的输入图像细节可能不会在嵌入中被捕捉到。

如果您希望获得一个对于非照片类图像效果良好的嵌入,并且没有大规模标记的此类图像数据集,并且希望尽可能捕捉图像中的信息内容,该怎么办?

辅助学习任务

另一种创建嵌入的方法是使用辅助学习任务。辅助任务是指除了我们试图解决的实际监督学习问题之外的任务。这个任务应该是有大量数据可用的。例如,在文本分类的情况下,我们可以使用一个无关的问题,比如预测句子中的下一个单词,来创建文本嵌入,因为这种问题已经有大量且易于获取的训练数据。然后可以从辅助模型中提取某些中间层的权重值,并用于表示各种其他无关任务的文本。图 12-3 展示了这种文本或单词嵌入的例子,其中模型被训练用于预测句子中的下一个单词。使用“the cat sat”作为输入词语,这样的模型将被训练来预测“on”这个单词。输入词语首先进行了独热编码,但是如果预测模型的倒数第二层有四个节点,它将学会将输入词语表示为一个四维嵌入。

图 12-3. 通过训练模型预测句子中的下一个单词创建的词嵌入,可以用于无关任务,例如文本分类。图示显示了辅助任务之前(左)和之后(右)的单词编码。

自编码器利用了图像的辅助学习任务,类似于文本中的预测下一个单词模型。我们接下来将介绍这些内容。

自编码器

用于学习图像嵌入的一个很好的辅助学习任务是使用自编码器。通过自编码器,我们将图像数据传入一个网络,将其压缩成一个更小的内部向量,然后再扩展回到原始图像的维度。当我们训练自编码器时,输入图像本身就充当其自身的标签。这样,我们基本上是在学习有损压缩,即通过一个受限制的网络恢复原始图像的能力。希望通过这种方式,我们能够将数据中的噪音挤压出去,并学习到信号的有效映射。

通过受监督任务训练的嵌入,输入中与标签无关或无用的信息通常会随着噪声被剪除。另一方面,对于自编码器而言,由于“标签”是整个输入图像,输入的每个部分都与输出相关,因此希望从输入中保留更多的信息。因为自编码器是自监督的(我们不需要单独的步骤来标记图像),我们可以在更多的数据上训练,并获得大大改进的编码。

通常情况下,编码器和解码器形成一个沙漏形状,因为编码器中的每个渐进层的维度缩小,解码器中的每个渐进层的维度扩展,如图 12-4 所示。随着编码器中维度的缩小和解码器中维度的扩展,某个时刻维度达到最小值,在编码器末端和解码器开头,由图 12-4 中中间的两个像素单通道块表示。这个潜在向量是输入的简明表示,数据被迫通过一个瓶颈。

图 12-4. 自编码器将图像作为输入并生成重建的图像作为输出。

那么,潜在维度应该有多大呢?与其他类型的嵌入一样,存在压缩和表达能力之间的权衡。如果潜在空间的维度太小,将没有足够的表达力来完全表示原始数据,部分信号将会丢失。当这种表示被解压缩回原始大小时,将会丢失太多信息,无法获得所需的输出。相反,如果潜在空间的维度太大,尽管有充足的空间来存储所有所需的信息,但也会有空间来编码一些不需要的信息(即噪声)。因此,潜在维度的理想大小是通过实验调整的。典型的值是 128、256 或 512,当然这取决于编码器和解码器层的大小。

接下来,我们将看一下实现自编码器的过程,从其架构开始。

架构

为了简化我们对自编码器架构的讨论和分析,我们将选择一个名为MNIST的简单手写数字数据集应用自编码器(完整代码在 GitHub 上的12a_autoencoder.ipynb中)。输入图像大小为 28x28,包含单通道灰度。

编码器从这些 28x28 的输入开始,然后通过卷积层逐步将信息压缩到越来越少的维度,最终达到大小为 2 的潜在维度:

encoder = tf.keras.Sequential([
    `keras``.``Input``(``shape``=``(``28``,` `28``,` `1``)`, name="image_input"),
    layers.Conv2D(32, 3, activation="relu", strides=2, padding="same"),
    layers.Conv2D(64, 3, activation="relu", strides=2, padding="same"),
    layers.Flatten(),
    `layers``.``Dense``(``2``)` # latent dim
], name="encoder")

解码器将不得不使用Conv2DTranspose层(也称为反卷积层,在第四章中讨论过)反转编码器具有Conv2D(卷积)层的步骤:

decoder = tf.keras.Sequential([
    keras.Input(shape=(latent_dim,), name="d_input"),
    layers.Dense(7 * 7 * 64, activation="relu"),
    layers.Reshape((7, 7, 64)),
    layers.Conv2DTranspose(32, 3, activation="relu",
                           strides=2, padding="same"),
    layers.Conv2DTranspose(1, 3, activation="sigmoid",
                           strides=2, padding="same")
], name="decoder")

一旦我们有了编码器和解码器模块,我们可以将它们结合在一起形成一个可以训练的模型。

训练

要训练的模型由编码器和解码器模块连接在一起构成:

encoder_inputs = keras.Input(shape=(28, 28, 1))
x = encoder(encoder_inputs)
decoder_output = decoder(x)
autoencoder = keras.Model(encoder_inputs, decoder_output)

模型必须经过训练,以最小化输入和输出图像之间的重建误差。例如,我们可以计算输入和输出图像之间的均方误差,并将其用作损失函数。这种损失函数可以在反向传播中使用,以计算梯度并更新编码器和解码器子网络的权重:

autoencoder.compile(optimizer=keras.optimizers.Adam(), loss='mse')
history = autoencoder.fit(mnist_digits, mnist_digits,
                          epochs=30, batch_size=128)

潜在向量

一旦模型训练完成,我们可以舍弃解码器,使用编码器将图像转换为潜在向量:

z = encoder.predict(img)

如果自编码器成功学会了如何重建图像,相似图像的潜在向量将倾向于聚类,如图 12-5 所示。请注意,1、2 和 0 占据潜在向量空间的不同部分。

图 12-5. 编码器将输入图像压缩为潜在表示。特定数字的潜在表示聚集在一起。我们能够将潜在表示呈现为 2D 图中的点,因为每个潜在表示是两个数字(x,y)。

因为整个输入图像的信息内容必须通过瓶颈层流动,所以训练后的瓶颈层将保留足够的信息,使得解码器能够重建接近输入图像的复制品。因此,我们可以训练自编码器进行降维。其思想,如图 12-5 中所示,是舍弃解码器,使用编码器将图像转换为潜在向量。这些潜在向量随后可以用于分类和聚类等下游任务,并且其结果可能优于像主成分分析(PCA)这样的传统降维技术。如果自编码器使用非线性激活函数,则编码可以捕捉输入特征之间的非线性关系,这与 PCA 不同,后者仅是一种线性方法。

另一种应用可能是使用解码器将用户提供的潜在向量转换成生成的图像,如图 12-6 所示。

图 12-6. 解码器将潜在表示解压缩回图像。

尽管这对于非常简单的 MNIST 数据集效果尚可,但在实践中对更复杂的图像并不适用。实际上,在图 12-7 中,我们可以看到即使在 MNIST 手写数字中也存在一些缺陷——尽管在潜在空间的某些部分数字看起来逼真,但在其他地方却不像我们知道的任何数字。例如,看一下图像左侧中心,2 和 8 已经插入到不存在的东西中。重建的图像完全毫无意义。还要注意,0 和 2 的数量明显较多,但 1 的数量却没有像在图 12-5 中看到的重叠聚类那样多。虽然生成 0 和 2 相对容易,但生成 1 却非常困难——我们必须精确地调整潜在向量才能得到一个看起来不像 2 或 5 的 1!

图 12-7. 潜在空间在[–2,-3]和[3,3]之间的重建图像。

到底是怎么回事呢?潜在空间有广泛的区域(注意图 12-5 中的空白区域)不对应有效的数字。训练任务根本不关心这些空白区域,也没有减少它们的动机。当使用编码器和解码器一起时,经过训练的模型表现出色,这也是我们最初要求自编码器学习的原始任务。我们将图像传入编码器子网络,该子网络将数据压缩成向量(这些图像的学习潜在表示)。然后我们使用解码器子网络解压这些表示以重建原始图像。编码器已经学会使用可能包含数百万参数权重的高度非线性组合从图像到潜在空间的映射。如果我们尝试简单地创建自己的潜在向量,它不太可能符合编码器创建的更加微妙的潜在空间。因此,没有编码器,我们随机选择的潜在向量很可能不能生成解码器用于生成高质量图像的良好编码。

现在我们已经看到,我们可以使用自编码器通过其两个子网络:编码器和解码器来重建图像。此外,通过丢弃解码器仅使用编码器,我们可以将图像非线性地编码成潜在向量,然后在不同的任务中使用它们作为嵌入。然而,我们也看到,简单地尝试相反的操作——丢弃编码器并使用解码器从用户提供的潜在向量生成图像并不奏效。

如果我们真的想要使用自编码器类型结构的解码器来生成图像,那么我们需要开发一种方法来组织或规范潜空间。这可以通过将在潜空间中接近的点映射到在图像空间中接近的点,并填充潜空间地图以使所有点都变得有意义,而不是在未映射的噪声海洋中创建合理输出的小岛。这样我们就可以生成自己的潜向量,解码器将能够使用这些向量生成高质量的图像。这迫使我们离开经典的自编码器,走向下一个进化阶段:变分自编码器。

变分自编码器

如果没有适当组织的潜空间,使用自编码器类型架构进行图像生成通常会出现两个主要问题。首先,如果我们在潜空间中生成两个接近的点,我们期望经过解码器后,这些点对应的输出应该相似。例如,正如图 12-8 所示,如果我们训练我们的自编码器来处理几何形状如圆、正方形和三角形,如果我们创建两个在潜空间中接近的点,我们假设它们应该是圆形、正方形或者它们之间的某种插值的潜在表示。然而,由于潜空间没有明确规范,其中一个潜在点可能生成一个三角形,而另一个潜在点可能生成一个圆形。

图 12-8. 在 3D 潜空间中接近的两个点可能被解码为非常不同的图像。

其次,在训练自编码器长时间并观察到良好重建之后,我们期望编码器已经学会了每个图像原型(例如形状数据集中的圆形或正方形)在潜空间中最佳位置,为每个原型创建了一个n维子域,这些子域可以与其他子域重叠。例如,可能存在一个潜空间区域,其中主要是方形类型的图像,以及另一个潜空间区域,其中类似圆形的图像已经被组织起来。此外,在它们重叠的地方,我们将得到某种在方形-圆形光谱上介于两者之间的形状。

然而,由于自编码器中的潜空间没有明确的组织,潜空间中的随机点经过解码器后可能会返回完全毫无意义、不可识别的图像,如图 12-9 所示。与想象中的广阔重叠影响球相反,形成了小而孤立的岛屿。

图 12-9. 在潜空间中的随机点解码为一个毫无意义的、荒谬的图像。

现在,我们不能责怪自编码器太多。它们正在做它们被设计来做的事情,即通过最小化重构损失函数来重构它们的输入。如果通过拥有小的孤立岛来实现这一目标,那么它们将学会做到这一点。

变分自编码器(VAEs)是响应经典自编码器无法使用其解码器从用户生成的潜在向量生成高质量图像而开发的。

VAEs 是生成模型,可以用于当我们不仅仅想要区分类别时使用,例如在分类任务中我们在潜在空间创建决策边界(可能仅满足于简单地分离类别),我们希望创建n维的气泡,这些气泡包含类似的训练样本。这一区别在图 12-10 中得到了可视化。

图 12-10。判别模型条件概率与生成模型联合概率分布对比。

判别模型,包括用于分类和物体检测等任务的流行图像机器学习模型,学习一个条件概率分布,该分布建模类之间的决策边界。而生成模型则学习一个联合概率分布,明确地建模每个类的分布。条件概率分布并没有丢失——我们仍然可以使用贝叶斯定理进行分类,例如。

幸运的是,变分自编码器的大部分架构与经典自编码器相同:沙漏形状、重构损失等。然而,少数额外的复杂性使得 VAEs 能够做到自编码器无法做到的事情:图像生成。

这在图 12-11 中有所展示,显示出我们的潜在空间正则性问题已经解决。第一个问题是接近的潜在点生成非常不同的解码图像已经被修复,现在我们能够创建通过潜在空间平滑插值的类似图像。第二个问题是在潜在空间中的点生成毫无意义、无意义的图像也已经被解决,现在我们可以生成可信的图像。请记住,这些图像实际上可能并不完全像模型训练的图像,但可能处于一些主要原型之间,因为在学习的有序潜在空间内存在平滑重叠的区域。

图 12-11。这两个问题都已经在一个有组织的、正则化的潜在空间中得到解决。

不再只是在编码器和解码器网络之间有一个潜在向量,这本质上是潜在空间中的一个点,变分自编码器训练编码器产生概率分布的参数,并使解码器随机地使用这些参数进行采样。编码器不再输出描述潜在空间内点的向量,而是概率分布的参数。然后我们可以从该分布中采样,并将这些采样点传递给解码器,以将它们解压缩回图像。

在实践中,概率分布是一个标准正态分布。这是因为接近零的均值有助于防止编码分布之间距离过大,看起来像是孤立的岛屿。此外,接近单位矩阵的协方差有助于防止编码分布太窄。图 12-12 左侧展示了我们试图避免的情况,即被毫无意义的空白所包围的小而孤立的分布。

图 12-12. 我们试图避免的情况(左)和我们试图实现的情况(右)。我们不希望被毫无意义的空白所包围的小而孤立的岛屿分布;我们希望整个空间被 n 维泡泡覆盖,如右侧所示。目标是具有平滑重叠的分布而没有大的间隙。

右侧的图像在图 12-12 中展示了平滑重叠的分布,没有大的间隙,这正是我们在优秀图像生成中所追求的。请注意两个分布相交处的插值。事实上,潜在空间中编码了一个平滑的梯度。例如,我们可以从三角分布的深处直接移向圆形分布。我们将从一个完美的三角形开始,随着每一步朝向圆形分布,我们的三角形会变得越来越圆,直到达到圆形分布的深处,那时我们将拥有一个完美的圆。

要能够从这些分布中进行采样,我们需要均值向量和协方差矩阵。因此,编码器网络将输出分布的均值向量和协方差向量。为了简化问题,我们假设这些是独立的。因此,协方差矩阵是对角线的,我们可以简单地使用它,而不是一个n²长的大部分为零的向量。

现在让我们看看如何实现 VAE,从其架构开始。

架构

在 TensorFlow 中,VAE 编码器的结构与经典自编码器相同,唯一的区别是我们不再只有一个潜在向量,而是一个具有两个分量(均值和方差)的向量(完整的代码在12b_vae.ipynb on GitHub中)。

encoder_inputs = keras.Input(shape=(28, 28, 1))
x = layers.Conv2D(
    32, 3, activation="relu", strides=2, padding="same")(encoder_inputs)
x = layers.Conv2D(
    64, 3, activation="relu", strides=2, padding="same")(x)
x = layers.Flatten(name="e_flatten")(x)
z_mean = layers.Dense(latent_dim, name="z_mean")(x) # same as autoencoder

然而,除了 z_mean 外,我们还需要从编码器中得到两个额外的输出。由于我们的模型现在具有多个输出,我们不能再使用 Keras 顺序 API;而是必须使用功能 API。Keras 功能 API 更像标准的 TensorFlow,其中输入被传递到一层,层的输出被传递到另一层作为其输入。可以使用 Keras 功能 API 制作任意复杂的有向无环图:

z_log_var = layers.Dense(latent_dim, name="z_log_var")(x)
z = Sampling()(z_mean, z_log_var)
encoder = keras.Model(encoder_inputs, [z_mean, z_log_var, z], name="encoder")

采样层需要从由编码器层的输出参数化的正态分布中采样,而不是像非变分自编码器中那样使用来自最终编码器层的向量。TensorFlow 中采样层的代码如下:

class Sampling(tf.keras.layers.Layer):
    """Uses (z_mean, z_log_var) to sample z, the vector encoding a digit.
 """
    def call(self, inputs):
        z_mean, z_log_var = inputs
        batch = tf.shape(input=z_mean)[0]
        dim = tf.shape(input=z_mean)[1]
        epsilon = tf.random.normal(shape=(batch, dim))
        return z_mean + tf.math.exp(x=0.5 * z_log_var) * epsilon

VAE 解码器与非变分自编码器中的解码器完全相同——它接收由编码器产生的潜变量 z,并将其解码为图像(具体来说,是原始图像输入的重构):

z_mean, z_log_var, z = encoder(encoder_inputs) # 3 outputs now
decoder_output = decoder(z)
vae = keras.Model(encoder_inputs, decoder_output, name="vae")

损失

变分自编码器的损失函数包含图像重构项,但我们不能仅仅使用均方误差(MSE)。除了重构误差外,损失函数还包含了称为 Kullback–Leibler 散度的正则化项,它实质上是对编码器的正态分布(由均值 μ 和标准差 σ 参数化)不是完美标准正态分布(均值为 0,标准差为单位矩阵)的一种惩罚:

L=xx^2+KL(N(μx,σx),N(0,I))

因此,我们修改编码器的损失函数如下:

def kl_divergence(z_mean, z_log_var):
    kl_loss = -0.5 * (1 + z_log_var - tf.square(z_mean) -
                      tf.exp(z_log_var))
    return tf.reduce_mean(tf.reduce_sum(kl_loss, axis=1))
encoder.add_loss(kl_divergence(z_mean, z_log_var))

总体重构损失是每像素损失的总和:

def reconstruction_loss(real, reconstruction):
    return tf.reduce_mean(
        tf.reduce_sum(
            keras.losses.binary_crossentropy(real, reconstruction),
            axis=(1, 2)
        )
    )
vae.compile(optimizer=keras.optimizers.Adam(),
            loss=reconstruction_loss, metrics=["mse"])

然后,我们使用 MNIST 图像来训练编码器/解码器组合,这些图像既是输入特征也是标签:

history = vae.fit(mnist_digits, mnist_digits, epochs=30,
                  batch_size=128)

由于变分编码器已经训练包括二元交叉熵在其损失函数中,它考虑了图像中不同类别的可分离性。由此产生的潜变量更易分离,占据整个潜空间,并且更适合生成(见图 12-13)。

图 12-13. 从 MNIST 上训练的 VAE 中的聚类(左)和生成的图像(右)。

变分自编码器能够创建看起来与其输入完全相同的图像。但是,如果我们想要生成全新的图像呢?我们接下来来看一下图像生成。

图像生成

图像生成是一个重要且迅速发展的领域,远远超出了仅仅为了娱乐生成数字和面孔;它对个人和企业都有许多重要的用例,如图像恢复、图像翻译、超分辨率和异常检测。

我们之前看到 VAE 如何重新创建它们的输入;然而,它们在创建与输入数据集中的图像相似但又不同的全新图像方面并不特别成功。如果生成的图像需要在感知上真实,例如,如果我们给定一个工具图像数据集,希望模型生成具有与训练图像中不同特征的新工具图片,这一点尤为重要。在本节中,我们将讨论在这类情况下生成图像的方法(如 GAN、cGAN 等),以及图像生成可应用的一些用途(如翻译、超分辨率等)。

生成对抗网络

用于图像生成的模型类型通常是生成对抗网络(GAN)。GAN 从博弈论中借鉴,通过使两个网络相互对抗直到达到均衡状态。其理念是,一个网络,生成器,将不断尝试创建更好的真实图像复制品,而另一个网络,判别器,将尝试越来越善于检测复制品与真实图像之间的区别。理想情况下,生成器和判别器将建立纳什均衡,以便任何一个网络都不能完全主导另一个。如果其中一个网络开始主导另一个,不仅没有办法让“失败”的网络恢复,而且这种不平等竞争将阻止网络相互改进。

训练在两个网络之间交替进行,每个网络通过与另一个网络的挑战变得更加擅长于其所做的事情。这种过程持续到收敛,当生成器变得非常擅长于创建逼真的假图像时,判别器只是随机猜测哪些图像是真实的(来自数据集),哪些图像是假的(来自生成器)。

生成器和判别器都是神经网络。图 12-14显示了整体训练架构。

图 12-14. 标准 GAN 架构,由生成器和判别器组成。

例如,想象一下,一个犯罪组织想要制造看起来真实的钱来存入银行。在这种情况下,犯罪分子将是生成器,因为他们试图制造逼真的假钞。银行家将是判别器,检查钞票并试图确保银行不接受任何假币。

典型的 GAN 训练将以生成器和鉴别器使用随机权重初始化开始。在我们的场景中,这意味着伪造者不知道如何生成逼真的货币:开始时生成的输出看起来只是随机噪声。同样,银行家开始时也不知道真假币之间的区别,因此他们会对真假进行非常不明智的随机猜测。

鉴别器(银行家组)被呈现第一组合法和生成的帐单,并且必须将它们分类为真实或伪造。因为鉴别器从随机权重开始,最初它不能轻易地“看到”一个帐单是随机噪声,另一个是好帐单。它会根据其表现的好坏来更新,因此在许多迭代中,鉴别器将开始更好地预测哪些帐单是真实的,哪些是生成的。在训练鉴别器权重时,生成器的权重被冻结。然而,在其轮次内,生成器(伪造者组)也在改进,因此它对鉴别任务的难度逐渐增加。根据它的帐单如何成功(或失败),它在每次迭代中进行更新,而在其训练期间,鉴别器的权重被冻结。

经过多次迭代后,生成器开始创造类似真实货币的东西,因为鉴别器在分辨真实和生成帐单方面做得很好。这进一步推动了鉴别器在其训练时更好地分辨现在看起来像样的生成帐单和真实帐单。

最终,在许多次训练迭代后,算法收敛。这是指鉴别器已经失去了分辨生成帐单和真实帐单的能力,基本上是随机猜测。

要查看 GAN 训练算法的一些细节,我们可以参考 Figure 12-15 中的伪代码。

图 12-15. 一个基本 GAN 训练算法。图片来自 Goodfellow et al., 2014

正如我们在伪代码的第一行中所看到的,外层有一个 for 循环,循环次数为交替的鉴别器/生成器训练迭代次数。我们将依次查看每个更新阶段,但首先需要设置我们的生成器和鉴别器。

创建网络

在进行任何训练之前,我们需要为生成器和鉴别器创建网络。在一个基本的 GAN 中,通常这只是由 Dense 层组成的神经网络。

生成器网络以某些潜在维度的随机向量作为输入,并通过一些(可能是几个)Dense层来生成图像。对于这个示例,我们将使用 MNIST 手写数字数据集,因此我们的输入是 28x28 的图像。LeakyReLU激活函数通常非常适合 GAN 训练,因为它们具有非线性特性,不会出现梯度消失问题,同时也不会因为任何负输入而丢失信息,也不会遇到可怕的 ReLU 死亡问题。Alpha 是我们希望通过的负信号量,在这里,值为 0 与 ReLU 激活相同,值为 1 则是线性激活。我们可以在下面的 TensorFlow 代码中看到这一点:

latent_dim = 512
vanilla_generator = tf.keras.Sequential(
    [
        tf.keras.Input(shape=(latent_dim,)),
        tf.keras.layers.Dense(units=256),
        tf.keras.layers.LeakyReLU(alpha=0.2),
        tf.keras.layers.Dense(units=512),
        tf.keras.layers.LeakyReLU(alpha=0.2),
        tf.keras.layers.Dense(units=1024),
        tf.keras.layers.LeakyReLU(alpha=0.2),
        tf.keras.layers.Dense(units=28 * 28 * 1, activation="tanh"),
        tf.keras.layers.Reshape(target_shape=(28, 28, 1))
    ],
    name="vanilla_generator"
)

在普通 GAN 中的鉴别器网络也由Dense层组成,但不是生成图像,而是以图像作为输入,如此处所示。输出是 logits 向量:

vanilla_discriminator = tf.keras.Sequential(
    [
        tf.keras.Input(shape=(28, 28, 1)),
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(units=1024),
        tf.keras.layers.LeakyReLU(alpha=0.2),
        tf.keras.layers.Dense(units=512),
        tf.keras.layers.LeakyReLU(alpha=0.2),
        tf.keras.layers.Dense(units=256),
        tf.keras.layers.LeakyReLU(alpha=0.2),
        tf.keras.layers.Dense(units=1),
    ],
    name="vanilla_discriminator"
)

鉴别器训练

在外循环内是更新鉴别器的内循环。首先,我们抽样一个小批量的噪声,通常是从标准正态分布中随机抽样的样本。随机噪声潜在向量通过生成器生成生成(假)图像,如图 12-16 所示。

图 12-16. 生成器通过从潜在空间抽样创建其第一批生成图像,并将其传递给鉴别器。

在 TensorFlow 中,例如,我们可以使用以下代码来抽样一批随机正态分布的随机数:

# Sample random points in the latent space.
random_latent_vectors = tf.random.normal(shape=(batch_size, self.latent_dim))

我们还从数据集中抽样一小批示例,即我们的情况下的真实图像,如图 12-17 所示。

图 12-17. 我们还从训练数据集中提取了一批真实图像,并将其传递给鉴别器。

生成器生成的图像和数据集中的真实图像分别通过鉴别器,鉴别器进行预测。然后计算真实图像和生成图像的损失项,如图 12-18 所示。损失可以采用许多不同形式:二元交叉熵(BCE)、最终激活映射的平均值、二阶导数项、其他惩罚等。在示例代码中,我们将使用 BCE:真实图像损失越大,鉴别器认为真实图像是假的;生成图像损失越大,鉴别器认为生成图像是真的。

图 12-18. 真实样本和生成样本通过鉴别器以计算损失。

我们在以下的 TensorFlow 代码中执行这个操作(通常情况下,完整代码在 GitHub 上的12c_gan.ipynb)。我们可以将我们生成的和真实的图像连接在一起,并用相应的标签做同样的事情,以便我们可以通过鉴别器进行一次传递:

# Generate images from noise.
generated_images = self.generator(inputs=random_latent_vectors)

# Combine generated images with real images.
combined_images = tf.concat(
    values=[generated_images, real_images], axis=0
)

# Create fake and real labels.
fake_labels = tf.zeros(shape=(batch_size, 1))
real_labels = tf.ones(shape=(batch_size, 1))

# Smooth real labels to help with training.
real_labels *= self.one_sided_label_smoothing

# Combine labels to be inline with combined images.
labels = tf.concat(
    values=[fake_labels, real_labels], axis=0
)

# Calculate discriminator loss.
self.loss_fn = tf.keras.losses.BinaryCrossentropy(from_logits=True)
predictions = self.discriminator(inputs=combined_images)
discriminator_loss = self.loss_fn(y_true=labels, y_pred=predictions)

我们首先将随机的潜在向量通过生成器生成一批生成的图像。这些图像与我们的真实图像批次连接起来,以便在一个张量中同时包含两组图像。

然后我们生成我们的标签。对于生成的图像,我们制作一个由 0 组成的向量,对于真实图像,是由 1 组成的向量。这是因为在我们的二元交叉熵(BCE)损失中,我们实质上只是进行二元图像分类(其中正类是真实图像),因此我们得到的是图像是真实的概率。将真实图像标记为 1,虚假图像标记为 0,鼓励鉴别器模型输出尽可能接近 1 的概率,以及尽可能接近 0 的概率。

有时向我们的真实标签添加单侧标签平滑可能会有所帮助,这涉及将它们乘以一个在范围[0.0, 1.0]内的浮点常数。这有助于鉴别器避免基于图像中仅有的少量特征而过于自信的预测,这可能会被生成器利用(导致它擅长欺骗鉴别器但不擅长生成图像)。

由于这是鉴别器的训练步骤,我们使用这些损失的组合来计算相对于鉴别器权重的梯度,然后根据图 12-19 中所示更新前述的权重。请记住,在鉴别器训练阶段,生成器的权重是冻结的。这样每个网络都有机会独立学习,与其他网络无关。

图 12-19. 相对于损失更新鉴别器权重。

在下面的代码中,我们可以看到这种鉴别器更新的执行过程:

# Train ONLY the discriminator.
with tf.GradientTape() as tape:
    predictions = self.discriminator(inputs=combined_images)
    discriminator_loss = self.loss_fn(
        y_true=labels, y_pred=predictions
    )

grads = tape.gradient(
    target=discriminator_loss,
    sources=self.discriminator.trainable_weights
)

self.discriminator_optimizer.apply_gradients(
    grads_and_vars=zip(grads, self.discriminator.trainable_weights)
)

生成器训练

经过几步应用于鉴别器的梯度更新之后,是时候更新生成器了(这次需要冻结鉴别器的权重)。我们也可以在内部循环中执行这个过程。这是一个简单的过程,我们再次从我们的标准正态分布中抽取一个小批量的随机样本,并将它们通过生成器生成以获得虚假图像。

在 TensorFlow 中,代码看起来像这样:

# Sample random points in the latent space.
random_latent_vectors = tf.random.normal(shape=(batch_size, self.latent_dim))

# Create labels as if they're real images.
labels = tf.ones(shape=(batch_size, 1))

请注意,尽管这些将是生成的图像,我们会将它们标记为真实。请记住,我们想要欺骗鉴别器,让它认为我们生成的图像是真实的。我们可以提供给生成器由鉴别器在它没有被欺骗的图像上产生的梯度。生成器可以利用这些梯度更新其权重,以便下次能更好地欺骗鉴别器。

随机输入像以前一样通过生成器生成生成的图像; 然而,生成器训练不需要真实图像,正如您在图 12-20 中所看到的那样。

图 12-20. 我们仅使用生成的图像进行生成器训练。

然后像以前一样,生成的图像通过鉴别器并计算生成器损失,正如图 12-21 中所示。

图 12-21. 只有生成的样本通过鉴别器以计算损失。

请注意,此阶段不使用数据集中的实际图像。 损失仅用于更新生成器的权重,如图 12-22 所示;尽管鉴别器在生成器的前向传递中被使用,但其权重在此阶段被冻结,因此它不会从此过程中学到任何东西。

图 12-22. 生成器的权重根据损失进行更新。

这是执行生成器更新的代码:

# Train ONLY the generator.
with tf.GradientTape() as tape:
    predictions = self.discriminator(
        inputs=self.generator(inputs=random_latent_vectors)
    )
    generator_loss = self.loss_fn(y_true=labels, y_pred=predictions)

grads = tape.gradient(
    target=generator_loss, sources=self.generator.trainable_weights
)

self.generator_optimizer.apply_gradients(
    grads_and_vars=zip(grads, self.generator.trainable_weights)
)

一旦完成,我们会回到鉴别器的内部循环,依此类推,直到收敛。

我们可以从我们的香草 GAN 生成器 TensorFlow 模型中调用以下代码,以查看一些生成的图像:

gan.generator(
    inputs=tf.random.normal(shape=(num_images, latent_dim))
)

当然,如果模型尚未经过训练,输出的图像将是随机噪声(由输入随机噪声并通过多层随机权重传递而产生)。 图 12-23 展示了我们的 GAN 在完成对 MNIST 手写数字数据集训练后学到的内容。

图 12-23. 香草 GAN 生成器生成的 MNIST 数字。

分布变化

与更传统的机器学习模型相比,GAN 确实具有有趣的训练过程。 从数学上来说,它们如何工作可能显得有些神秘,试图更深入地理解它们的一种方式是观察生成器和鉴别器学习的分布动态,它们各自努力超越对方。 图 12-24 展示了在 GAN 训练过程中生成器和鉴别器学习的分布如何变化。

图 12-24. GAN 训练期间学习分布的演变。虚线是鉴别器分布,实线是生成器分布,点线是数据生成(真实数据)分布。下方的水平线是从中采样潜在空间 z 的域,上方的水平线是图像空间 x 的一部分域。箭头显示了 z 如何通过 x = G(z)映射到 x。生成器分布在 z 到 x 映射的低密度区域收缩并在高密度区域扩展。图片来源于Goodfellow et al., 2014

在图 12-24(a)中,我们可以看到生成器表现并不惊艳,但在生成部分数据分布方面做得相当不错。实线生成器分布与点线真实数据分布(我们试图生成的内容)有所重叠。同样地,鉴别器在分类真实与伪造样本方面表现相当不错:当与点线数据分布重叠及生成器分布峰值左侧时,显示出强信号(虚线)。在生成器分布峰值区域,鉴别信号显著减小。

鉴别器然后在内部鉴别器训练循环中的固定生成器的另一批真实和生成图像上进行训练,持续若干次迭代。在图 12-24(b)中,我们可以看到虚线鉴别器分布变得平滑,在右侧跟随实线生成器分布下的点线数据分布。在左侧,分布较高,并接近数据分布。请注意,由于尚未更新生成器,实线生成器分布在这一步骤中不会改变。

图 12-24(c)展示了生成器训练若干次迭代后的结果。新更新的鉴别器表现有助于引导其调整网络权重,并填补其在数据分布中缺失的一些部分,从而使其在生成伪样本方面变得更加优秀。我们可以看到,实线生成器分布现在与数据分布的点线曲线更加接近。

图 12-24(d) 显示了在多次交替训练鉴别器和生成器之后的结果。如果两个网络具有足够的容量,生成器将会收敛:其分布将与数据分布非常接近,并且生成的样本看起来非常好。鉴别器也已经收敛,因为它已经无法区分真实样本来自数据分布还是生成样本来自生成器分布。因此,鉴别器的分布会平坦到随机猜测的 50/50 几率,并且 GAN 系统的训练完成。

GAN 改进

纸上看起来很棒,GAN 对于图像生成非常强大,但是在实践中,由于对超参数的高度敏感、训练不稳定以及许多失败模式,它们可能非常难以训练。

如果任何一个网络在其工作中进展得太快,那么另一个网络将无法跟上,生成的图像将永远无法看起来非常真实。另一个问题是模式崩溃,生成器在创建图像时失去了大部分多样性,只生成了同样的几个输出。当生成器偶然生成了一个非常擅长困惑鉴别器的图像时,这种情况会持续相当长的时间。在训练过程中,直到鉴别器最终能够检测到这些少数图像是生成的而不是真实的。

在 GAN 中,第一个网络(生成器)从其输入层到其输出层具有扩展的层大小。第二个网络(鉴别器)从其输入层到其输出层具有缩小的层大小。我们的香草 GAN 架构使用了密集层,类似于自动编码器。然而,在涉及图像的任务中,卷积层往往表现更好。

深度卷积 GAN(DCGAN)基本上只是将香草 GAN 中的密集层替换为卷积层。在以下 TensorFlow 代码中,我们定义了一个 DCGAN 生成器:

def create_dcgan_generator(latent_dim):
    dcgan_generator = [
        tf.keras.Input(shape=(latent_dim,)),
        tf.keras.layers.Dense(units=7 * 7 * 256),
        tf.keras.layers.LeakyReLU(alpha=0.2),
        tf.keras.layers.Reshape(target_shape=(7, 7, 256)),
    ] + create_generator_block(
        filters=128, kernel_size=4, strides=2, padding="same", alpha=0.2
    ) + create_generator_block(
        filters=128, kernel_size=4, strides=2, padding="same", alpha=0.2
    ) + [
        tf.keras.layers.Conv2DTranspose(
            filters=1,
            kernel_size=3,
            strides=1,
            padding="same",
            activation="tanh"
        )
    ]

    return tf.keras.Sequential(
        layers=dcgan_generator, name="dcgan_generator"
    )

我们的模板化生成器块如下所示:

def create_generator_block(filters, kernel_size, strides, padding, alpha):
    return [
        tf.keras.layers.Conv2DTranspose(
            filters=filters,
            kernel_size=kernel_size,
            strides=strides,
            padding=padding
        ),
        tf.keras.layers.BatchNormalization(),
        tf.keras.layers.LeakyReLU(alpha=alpha)
    ]

同样地,我们可以像这样定义一个 DCGAN 鉴别器:

def create_dcgan_discriminator(input_shape):
    dcgan_discriminator = [
        tf.keras.Input(shape=input_shape),
        tf.keras.layers.Conv2D(
            filters=64, kernel_size=3, strides=1, padding="same"
        ),
        tf.keras.layers.LeakyReLU(alpha=0.2)
    ] + create_discriminator_block(
        filters=128, kernel_size=3, strides=2, padding="same", alpha=0.2
    ) + create_discriminator_block(
        filters=128, kernel_size=3, strides=2, padding="same", alpha=0.2
    ) + create_discriminator_block(
        filters=256, kernel_size=3, strides=2, padding="same", alpha=0.2
    ) + [
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(units=1)
    ]

    return tf.keras.Sequential(
        layers=dcgan_discriminator, name="dcgan_discriminator"
    )

这是我们的模板化鉴别器块:

def create_discriminator_block(filters, kernel_size, strides, padding, alpha):
    return [
        tf.keras.layers.Conv2D(
            filters=filters,
            kernel_size=kernel_size,
            strides=strides,
            padding=padding
        ),
        tf.keras.layers.BatchNormalization(),
        tf.keras.layers.LeakyReLU(alpha=alpha)
    ]

正如你所看到的,生成器使用Conv2DTranspose层对图像进行上采样,而鉴别器使用Conv2D层对图像进行下采样。

然后,我们可以调用训练好的 DCGAN 生成器来看看它学到了什么:

dcgan.generator(
    inputs=tf.random.normal(shape=(num_images, latent_dim))
)

结果显示在图 12-25 中。

图 12-25. DCGAN 生成器生成的生成的 MNIST 数字。

可以对香草 GAN 进行许多其他改进,例如使用不同的损失项、梯度和惩罚项。由于这是一个活跃的研究领域,这些超出了本书的范围。

有条件的 GAN

我们之前讨论过的基本 GAN 完全无监督地训练在我们想要生成的图像上。例如,随机噪声向量等潜在表示用于探索和采样学习的图像空间。一个简单的增强方法是在我们的输入中添加一个外部标志和一个标签。例如,考虑 MNIST 数据集,它包含从 0 到 9 的手写数字。通常,GAN 只学习数字的分布,当生成器被给定随机噪声向量时,它生成不同的数字,如图 12-26 所示。然而,生成的是哪些数字无法控制。

图 12-26. 无条件 GAN 输出。

在训练过程中,就像 MNIST 一样,我们可能知道每个图像的实际标签或类别。这些额外的信息可以作为我们的 GAN 训练中的特征,然后在推理时使用。通过 条件 GANs(cGANs),图像生成可以依赖于标签,因此我们能够聚焦于特定感兴趣的数字分布。然后,在推理时,我们可以通过传入期望的标签而不是接收随机数字,创建特定数字的图像,如图 12-27 所示。

图 12-27. 有条件的 GAN 输出。

cGAN 生成器

我们需要对之前的香草 GAN 生成器代码进行一些更改,以便我们可以整合标签。基本上,我们将把我们的潜在向量与标签的向量表示连接起来,正如您可以在以下代码中看到的那样:

# Create the generator.
def create_label_vectors(labels, num_classes, embedding_dim, dense_units):
    embedded_labels = tf.keras.layers.Embedding(
        input_dim=num_classes, output_dim=embedding_dim
    )(inputs=labels)
    label_vectors = tf.keras.layers.Dense(
        units=dense_units
    )(inputs=embedded_labels)

    return label_vectors

在这里,我们使用一个Embedding层将我们的整数标签转换为密集表示。稍后,我们将结合标签的嵌入和我们典型的随机噪声向量创建一个新的潜在向量,这是输入潜在空间和类标签的混合。然后,我们使用一个Dense层进一步混合组件。

接下来,我们利用之前的标准香草 GAN 生成器。但是,这次我们使用 Keras 的功能性 API,就像我们之前对变分自编码器所做的那样,因为现在我们的生成器模型有多个输入(潜在向量和标签):

def standard_vanilla_generator(inputs, output_shape):
    x = tf.keras.layers.Dense(units=64)(inputs=inputs)
    x = tf.keras.layers.LeakyReLU(alpha=0.2)(inputs=x)
    x = tf.keras.layers.Dense(units=128)(inputs=x)
    x = tf.keras.layers.LeakyReLU(alpha=0.2)(inputs=x)
    x = tf.keras.layers.Dense(units=256)(inputs=x)
    x = tf.keras.layers.LeakyReLU(alpha=0.2)(inputs=x)
    x = tf.keras.layers.Dense(
        units=output_shape[0] * output_shape[1] * output_shape[2],
        activation="tanh"
    )(inputs=x)

    outputs = tf.keras.layers.Reshape(target_shape=output_shape)(inputs=x)

    return outputs

现在我们有了一种将整数标签嵌入的方法,我们可以将其与我们原来的标准生成器结合起来,创建一个香草 cGAN 生成器:

def create_vanilla_generator(latent_dim, num_classes, output_shape):
    latent_vector = tf.keras.Input(shape=(latent_dim,))

    labels = tf.keras.Input(shape=())
    label_vectors = create_label_vectors(
        labels, num_classes, embedding_dim=50, dense_units=50
    )

    concatenated_inputs = tf.keras.layers.Concatenate(
        axis=-1
    )(inputs=[latent_vector, label_vectors])

    outputs = standard_vanilla_generator(
        inputs=concatenated_inputs, output_shape=output_shape
    )

    return tf.keras.Model(
        inputs=[latent_vector, labels],
        outputs=outputs,
        name="vanilla_generator"
    )

现在我们使用 Keras 的Input层有两组输入。记住,这是我们使用 Keras Functional API 而不是 Sequential API 的主要原因:它允许我们拥有任意数量的输入和输出,并在其间实现任意类型的网络连接。我们的第一个输入是标准的潜在向量,即我们生成的随机正态噪声。我们的第二个输入是整数标签,稍后我们将根据这些标签进行条件控制,因此我们可以通过推理时提供的标签来定位生成图像的特定类别。由于在此示例中,我们使用的是 MNIST 手写数字,标签将是 0 到 9 之间的整数。

一旦我们创建了我们的密集标签向量,我们使用 Keras 的Concatenate层将它们与我们的潜在向量结合起来。现在我们有一个向量的单个张量,每个的形状为latent_dim + dense_units。这是我们的新“潜在向量”,它被发送到标准的香草 GAN 生成器中。这不是我们原始向量空间中原始潜在向量的原始潜在向量,我们从中随机采样了随机点,但是现在由于编码标签向量的串联而成为一个新的更高维度的向量空间。

这个新的潜在向量帮助我们定位特定的类别进行生成。类标签现在嵌入在潜在向量中,因此将映射到图像空间中的不同点,而不是原始潜在向量中的随机点。此外,给定相同的潜在向量,每个标签配对将映射到图像空间中的不同点,因为它使用不同的学习映射,这是由于不同的连接的潜在-标签向量。因此,当 GAN 被训练时,它学会将每个潜在点映射到图像空间中的一个属于 10 个类别之一的图像。

如我们所见,在函数的最后,我们简单地使用我们的两个输入张量和输出张量实例化了一个 Keras Model。查看有条件的 GAN 生成器架构图,显示在 图 12-28 中,应该清楚地说明了我们如何使用两组输入,即潜在向量和标签,来生成图像。

图 12-28. 有条件的 GAN 生成器架构。

现在我们已经看过生成器了,让我们来看看有条件的 GAN 判别器的代码。

cGAN 判别器

对于生成器,我们创建了与潜在向量连接的标签向量。对于具有图像输入的判别器,我们将标签转换为图像,并将从标签创建的图像与输入图像结合起来。这样可以将标签信息嵌入到我们的图像中,帮助判别器区分真实图像和生成图像。标签将帮助扭曲从潜在空间到图像空间的映射,以便每个输入都与其标签的图像空间内的泡泡相关联。例如,在 MNIST 中,如果模型给出数字 2,判别器将在学习的图像空间中生成 2 的泡泡内的某些内容。

为了完成判别器的条件映射,我们再次通过Embedding层和Dense层传递我们的整数标签。然而,批次中的每个示例现在只是一个长度为 num_pixels 的向量。因此,我们使用Reshape层将向量转换为具有一个通道的图像。可以将其视为我们标签的灰度图像表示。在下面的代码中,我们可以看到标签被嵌入到图像中:

def create_label_images(labels, num_classes, embedding_dim, image_shape):
    embedded_labels = `tf``.``keras``.``layers``.``Embedding`(
        input_dim=num_classes, output_dim=embedding_dim)`(``inputs``=``labels``)`
    num_pixels = image_shape[0] * image_shape[1]
    dense_labels = tf.keras.layers.Dense(
        units=num_pixels)(inputs=embedded_labels)
    label_image = tf.keras.layers.Reshape(
        target_shape=(image_shape[0], image_shape[1], 1))(inputs=dense_labels)

    return label_image

就像我们为生成器所做的那样,我们将重用我们在前一节中使用的标准的普通 GAN 判别器,该判别器将图像映射到用于与二元交叉熵损失计算的对数向量中。以下是使用 Keras Functional API 的标准判别器的代码:

def standard_vanilla_discriminator(inputs):
    """Returns output of standard vanilla discriminator layers.

 Args:
 inputs: tensor, rank 4 tensor of shape (batch_size, y, x, channels).

 Returns:
 outputs: tensor, rank 4 tensor of shape
 (batch_size, height, width, depth).
 """
    x = tf.keras.layers.Flatten()(inputs=inputs)
    x = tf.keras.layers.Dense(units=256)(inputs=x)
    x = tf.keras.layers.LeakyReLU(alpha=0.2)(inputs=x)
    x = tf.keras.layers.Dense(units=128)(inputs=x)
    x = tf.keras.layers.LeakyReLU(alpha=0.2)(inputs=x)
    x = tf.keras.layers.Dense(units=64)(inputs=x)
    x = tf.keras.layers.LeakyReLU(alpha=0.2)(inputs=x)

    outputs = tf.keras.layers.Dense(units=1)(inputs=x)

    return outputs

现在我们将创建我们的条件 GAN 判别器。它有两个输入:第一个是标准的图像输入,第二个是图像将被条件化的类标签。就像生成器一样,我们将标签转换为可用于与图像配合使用的表示形式——即转换为灰度图像——并使用Concatenate层将输入图像与标签图像结合起来。我们将这些组合图像发送到我们标准的普通 GAN 判别器中,然后使用我们的两个输入、输出以及判别器Model的名称实例化一个 Keras Model

def create_vanilla_discriminator(image_shape, num_classes):
    """Creates vanilla conditional GAN discriminator model.

 Args:
 image_shape: tuple, the shape of the image without batch dimension.
 num_classes: int, the number of image classes.

 Returns:
 Keras Functional Model.
 """
    images = tf.keras.Input(shape=image_shape)

    labels = tf.keras.Input(shape=())
    label_image = create_label_images(
        labels, num_classes, embedding_dim=50, image_shape=image_shape
    )

    concatenated_inputs = tf.keras.layers.Concatenate(
        axis=-1
    )(inputs=[images, label_image])

    outputs = standard_vanilla_discriminator(inputs=concatenated_inputs)

    return tf.keras.Model(
        inputs=[images, labels],
        outputs=outputs,
        name="vanilla_discriminator"
    )

图 12-29 展示了完整的条件 GAN 判别器架构。

图 12-29. 条件 GAN 判别器架构。

条件 GAN 训练过程的其余部分与非条件 GAN 训练过程基本相同,只是现在我们将数据集中的标签传递给生成器和判别器使用。

使用 latent_dim 为 512 并训练 30 个 epochs 后,我们可以使用我们的生成器生成像图 12-30 中的图像那样的图像。请注意,对于每一行,推理时使用的标签是相同的,因此第一行全为零,第二行全为一,依此类推。这很棒!我们不仅可以生成手写数字,还可以专门生成我们想要的数字。

图 12-30. 在 MNIST 数据集训练后,条件普通 GAN 生成的数字。

如果我们使用之前展示过的 DCGAN 生成器和判别器而不是标准的普通 GAN 生成器和判别器,我们可以得到更干净的结果。图 12-31 展示了在使用 512 的latent_dim和 50 个 epochs 训练条件 DCGAN 模型后生成的一些图片。

图 12-31. 在 MNIST 数据集训练后,条件 DCGAN 生成的数字。

GAN 是生成数据的强大工具。我们在这里关注了图像生成,但 GAN 也可以生成其他类型的数据(如表格、时间序列和音频数据)。然而,GAN 有点挑剔,通常需要我们在这里介绍的技巧和更多技巧来改善它们的质量和稳定性。既然你已经把 GAN 作为工具箱中的另一个工具添加进去了,让我们来看看一些使用它们的高级应用。

图像到图像的翻译

图像生成是 GAN 擅长的较简单的应用之一。我们还可以结合和操纵 GAN 的基本组件,将它们用于其他许多最前沿的用途。

图像到图像的翻译是指将一张图片从一个(源)域翻译到另一个(目标)域。例如,在图 12-32 中,一张马的图片被翻译成看起来像斑马的图片。当然,由于找到配对的图片(例如,同一个场景的冬季和夏季)可能非常困难,我们可以创建一个能够使用不配对图片进行图像到图像翻译的模型架构。这可能不像使用配对图片的模型那样表现良好,但可以非常接近。在本节中,我们将探讨如何在有不配对图片时执行图像翻译,这是更常见的情况,然后是如何在有配对图片时执行图像翻译。

图 12-32. 使用 CycleGAN 将马的图像翻译成斑马的图像的结果。图片来自Zhu et al., 2020

在图 12-32 中用于执行翻译的 CycleGAN 架构进一步发展,具有两个生成器和两个判别器之间的循环,如图 12-33 所示。继续之前的例子,假设马的图像属于图像域 X,斑马的图像属于图像域 Y。请记住,这些是不配对的图像;因此,每张马的图像都没有与之匹配的斑马图像,反之亦然。

图 12-33. CycleGAN 训练图解。图片来自Zhu et al., 2020

在图 12-33(a)中,生成器 G 将图像域 X(马)映射到图像域 Y(斑马),而另一个生成器 F 则反向映射,即 Y(斑马)到 X(马)。这意味着生成器 G 学习权重,将马的图像(在本例中)映射到斑马的图像,反之亦然对生成器 F。鉴别器 D[X]导致生成器 F 在 Y(斑马)到 X(马)的映射上有很好的表现,而鉴别器 D[Y]导致生成器 G 在 X(马)到 Y(斑马)的映射上有很好的表现。然后,我们执行循环,以在学习的映射中增加一些正则化,如下一部分所述。

在图 12-33(b)中,前向循环一致性损失——X(马)到 Y(斑马)到 X(马)——来自于 X(马)域图像与使用生成器 G 将其映射到 Y(斑马),然后使用生成器 F 将其映射回 X(马)的比较,与原始的 X(马)图像。

类似地,在图 12-33(c)中,反向循环一致性损失——Y(斑马)到 X(马)到 Y(斑马)——来自于 Y(斑马)域图像与使用生成器 F 将其映射到 X(马),然后使用生成器 G 将其映射回 Y(斑马)的比较,与原始的 Y(斑马)图像。

前向和后向循环一致性损失都会比较原始图像与该域的循环图像,以便网络能够学习减少它们之间的差异。

通过拥有这些多个网络并确保循环一致性,我们能够获得令人印象深刻的结果,尽管图像未配对,例如在马和斑马之间或夏季图像和冬季图像之间进行转换,如图 12-34 所示。

图 12-34. 使用 CycleGAN 将夏季图像转换为冬季图像的结果。图片来源于Zhu et al., 2020

现在,如果我们确实有配对示例,那么我们可以利用监督学习来获得更令人印象深刻的图像到图像转换结果。例如,如图 12-35 所示,城市的俯视地图可以转换为卫星视图,反之亦然。

图 12-35. 使用 Pix2Pix 将地图视图转换为卫星视图及其反向的结果。图片来源于Isola et al., 2018

Pix2Pix 架构使用配对图像来创建两个域之间的前向和反向映射。我们不再需要循环来执行图像到图像的转换,而是使用具有跳跃连接的 U-Net(在第四章中已经见过)作为生成器,以及 PatchGAN 作为鉴别器,我们接下来会进一步讨论这些。

U-Net 生成器接收源图像并尝试创建目标图像版本,如图 12-36 所示。生成的图像通过 L1 损失或 MAE 与实际配对的目标图像进行比较,然后乘以 lambda 来加权损失项。生成的图像(源到目标域)和输入源图像然后传递给判别器,标签为全部为 1,使用二元/ sigmoid 交叉熵损失。这些损失的加权和用于生成器的梯度计算,以鼓励生成器改进其用于域转换的权重,以欺骗判别器。对于另一个生成器/判别器设置,源和目标域颠倒进行反向转换。

图 12-36. Pix2Pix 生成器训练图表。图像来源于Isola et al., 2018

PatchGAN 判别器使用较小分辨率的补丁对输入图像进行分类。这样每个补丁使用本地信息而不是整个图像,被分类为真实或虚假。判别器接收两组输入对,通过通道连接,如图 12-37 所示。

图 12-37. Pix2Pix 判别器训练图表。图像来源于Isola et al., 2018

第一对由输入源图像和生成的“源到目标”图像组成,判别器应将其标记为假,全部标签为 0。第二对由输入源图像与目标图像连接而非生成图像组成。这是真实分支,因此此对标记为全部为 1。如果回想简单的图像生成,这遵循相同的判别器训练模式,其中生成的图像在假的、全部标签为 0 的分支中,我们想要生成的真实图像在真实的、全部标签为 1 的分支中。因此,与图像生成相比唯一的变化是,我们本质上是使用源域输入图像来调节判别器,类似于我们在条件 GAN 中所做的。

这可能导致惊人的应用案例,例如图 12-38,手绘物体可以被填充成具有摄影质量的真实物体。

图 12-38. 使用 Pix2Pix 将绘图转换为具有摄影质量的图像的结果。图像来源于Isola et al., 2018

就像我们可以在不同语言之间翻译文本和语音一样,我们也可以在不同领域之间翻译图像。我们可以使用 CycleGAN 等架构和(更常见的)无配对图像数据集,或者更专门的架构如 Pix2Pix,可以充分利用配对图像数据集。这仍然是一个非常活跃的研究领域,发现了许多改进方法。

超分辨率

到目前为止,我们看到的大多数用例中,我们都是用原始图像进行训练和预测。然而,实际情况中图像往往存在许多缺陷,如模糊或分辨率过低。幸运的是,我们可以修改一些已学习的技术来修复其中一些图像问题。

超分辨率是将降质或低分辨率图像进行放大处理,转换为修正后的高分辨率图像的过程。超分辨率作为一种普遍的图像处理技术已经存在很长时间,然而直到最近,深度学习模型才能够利用这一技术产生最先进的结果。

最简单且最古老的超分辨率方法使用各种像素插值技术,如最近邻插值或双三次插值。请记住,我们从低分辨率图像开始,放大后的像素数比原始图像更多。这些像素需要通过某种方式填充,并以一种在视觉上正确且不仅仅产生平滑模糊的大图像的方式。

图 12-39 展示了 Christian Ledig 等人在 2017 年的一篇论文的部分结果样本。最右边是原始高分辨率图像。从该图像创建了一个用于训练过程的低分辨率版本。最左边是双三次插值图像——它是从原始较小、低分辨率版本的起始图像重新创建的平滑模糊版本。对于大多数应用程序来说,这种质量不够高。

图 12-39 中从左边数第二幅图是由 SRResNet 创建的图像,它是一个残差卷积块网络。在这里,由于高斯噪声和降采样而导致的原始图像的低分辨率版本经过了 16 个残差卷积块。输出是一个相当接近原始图像的不错的超分辨率图像,然而仍然存在一些错误和伪影。在 SRResNet 中使用的损失函数是超分辨率输出图像中每个像素与原始高分辨率图像之间的均方误差。虽然该模型仅使用 MSE 损失能够获得相当不错的结果,但这还不足以鼓励模型生成真正逼真和在视觉上相似的图像。

图 12-39. 超分辨率图像。图片来自Ledig et al., 2017

在图 12-39 的左侧第三幅图显示了最佳(在感知质量方面)结果,使用了一种称为 SRGAN 的模型。利用 GAN 的想法来自于 SRResNet 图像的缺失:高感知质量,我们可以通过观察图像来快速判断。这是由于 MSE 重建损失,它旨在最小化像素的平均误差,但不试图确保单个像素组合成为感知上令人信服的图像。

正如我们之前看到的那样,GAN 通常既有用于创建图像的生成器,也有用于辨别传递给它的图像是真实还是生成的鉴别器。与尝试缓慢而痛苦地手动调整模型以创建令人信服的图像不同,我们可以使用 GAN 自动进行这种调整。图 12-40 显示了 SRGAN 的生成器和鉴别器网络架构。

图 12-40. SRGAN 生成器和鉴别器架构。图片来自Ledig et al., 2017

在 SRGAN 生成器架构中,我们首先使用高分辨率(HR)图像并对其应用高斯滤波器,然后通过某些因子对图像进行下采样。这创建了图像的低分辨率(LR)版本,然后将其卷积并通过多个残差块传递,就像我们在第四章中使用 ResNet 看到的那样。在路径上对图像进行了上采样,因为我们需要恢复到其原始大小。网络还包括跳跃连接,以便更多来自早期层的细节能够影响后续层,并在向后传播期间实现更好的梯度反向传播。经过几个卷积层后,生成了超分辨率(SR)图像。

鉴别器将图像作为输入,并确定它们是 SR 还是 HR。输入通过几个卷积块传递,最后通过一个密集层将中间图像展平,然后再通过另一个密集层生成 logits。就像普通的 GAN 一样,这些 logits 在二元交叉熵上进行优化,因此结果是图像是 HR 还是 SR 的概率,这是对抗损失项。

使用 SRGAN 时,还有另一个损失项与对抗损失一起加权以训练生成器。这是上下文损失,即原始内容在图像中保留的程度。最小化上下文损失将确保输出图像看起来与原始图像相似。通常这是像素级均方误差(MSE),但由于它是一种平均化形式,可能会创建过于平滑的纹理,看起来不真实。因此,SRGAN 使用其开发人员称为VGG 损失,使用预训练的 19 层 VGG 网络的每个层的激活特征图(在每个激活后,位于相应的最大池化层之前)。他们计算 VGG 损失为原始 HR 图像的特征图与生成的 SR 图像的特征图之间的欧几里得距离的总和,跨所有 VGG 层对这些值进行归一化,并通过图像的高度和宽度。这两个损失项的平衡将创建不仅外观与输入图像相似的图像,而且在感知上看起来像真实图像的图像。

修改图片(修复)

还有其他修复图像的原因,例如照片上的撕裂或缺失或遮挡的部分,如图 12-41(a)所示。这种填补空白区域的操作称为修复,我们希望在空白处确实填充应该存在的像素。通常情况下,艺术家需要花费数小时或数天手工修复这样的问题,如图 12-41(b)所示,这是一个费力的过程,无法扩展。幸运的是,基于 GAN 的深度学习可以实现可扩展地修复这样的图像——图 12-41(c)和(d)展示了一些样本结果。

图 12-41. 上下文编码器修复结果。图片来源于Pathak et al., 2016

这里,与 SRGAN 不同的是,我们并不是为了训练而给高分辨率图像添加噪音或滤镜并降采样,而是提取像素区域并将该区域设置为一边。然后,我们通过一个简单的编码器/解码器网络处理剩余的图像,如图 12-42 所示。这形成了 GAN 的生成器,我们希望它能生成与我们提取的像素区域相似的内容。

图 12-42. 上下文编码器生成器和判别器架构。图片来源于Pathak et al., 2016

然后,判别器将生成的像素区域与我们从原始图像中提取的区域进行比较,并试图确定图像是生成的还是来自真实数据集。

与 SRGAN 类似,出于同样的原因,损失函数包括两项:重建损失和对抗损失。重建损失不是提取图像补丁与生成图像补丁之间的典型 L2 距离,而是归一化的掩码 L2 距离。损失函数对整体图像应用掩码,因此我们只聚合重建的补丁距离,而不是周围的边界像素。最终损失是通过区域中像素数归一化的聚合距离。通常情况下,这样可以粗略地创建图像补丁的轮廓;然而,由于像素平均误差,重建的补丁通常缺乏高频细节,因此变得模糊。

生成器的对抗损失来自鉴别器,这有助于生成的图像补丁看起来来自自然图像的流形,因此看起来真实。这两个损失函数可以结合成加权和的联合损失。

提取的图像补丁不仅仅需要来自中心区域,就像图 12-43(a)中所示的那样——事实上,这种方法可能会因为学习到的低级别图像特征无法推广到没有提取补丁的图像而导致训练效果不佳。相反,像图 12-43(b)中那样随机选取块,或者像图 12-43(c)中那样随机选择像素区域,会产生更通用的特征,并且明显优于使用中心区域掩码的方法。

图 12-43. 不同提取的补丁区域掩码。图片来自Pathak 等人,2016

异常检测

异常检测是另一个可以从 GAN 的使用中受益的应用领域——可以将图像传递给修改后的 GAN 模型,并标记为异常或非异常。这对于任务如检测伪钞或在医学扫描中寻找肿瘤非常有用。

深度学习用例中通常会有更多的未标记数据可用,而标记过程通常非常费时,可能需要深入的学科专业知识。这使得监督方法不可行,因此我们需要一种无监督方法。

要执行异常检测,我们需要学习“正常”是什么样子。如果我们知道正常是什么,那么当图像不符合该分布时,它可能包含异常。因此,在训练异常检测模型时,重要的是仅对正常数据进行训练。否则,如果正常数据中混有异常,那么模型会学习到这些异常是正常的。在推断时,这将导致实际的异常图像无法正确标记,从而产生比可接受范围更多的假阴性。

异常检测的标准方法是首先学习如何重构正常图像,然后学习正常图像重构误差的分布,并最终学习一个距离阈值,其中超过该阈值的任何内容都被标记为异常。我们可以使用许多不同类型的模型来最小化输入图像与其重构之间的重构误差。例如,使用自编码器,我们可以将正常图像通过编码器(将图像压缩为更紧凑的表示),可能通过瓶颈层或两层,然后通过解码器(将图像扩展回其原始表示)生成一个应该是原始图像重构的图像。重构永远不会完美;总会有一些误差。在大量的正常图像集合中,重构误差将形成一个“正常误差”的分布。现在,如果网络给出一个异常图像——在训练期间没有见过类似的图像——它将无法正确地压缩和重构它。重构误差将远远超出正常误差分布。因此,该图像可以被标记为异常图像。

将异常检测用例推进一步,我们可以执行异常定位,在这种情况下,我们标记单个像素为异常。这类似于一个无监督的分割任务,而不是无监督的分类任务。每个像素都有一个误差,这可以形成一个误差分布。在异常图像中,许多像素将表现出较大的误差。从原始版本的某个距离阈值以上的重建像素可以被标记为异常,如图 12-44 所示。

图 12-44. 异常定位标记单个像素作为异常。图片来源于Schlegl et al., 2019

然而,对于许多使用情况和数据集来说,事情并没有就此结束。仅有自编码器和重构损失,模型可能学会如何将任何图像映射到其自身,而不是学习正常的外观。基本上,重构主导了组合损失方程,因此模型学习了压缩任何图像的最佳方式,而不是学习“正常”图像流形。对于异常检测来说,这是非常不利的,因为正常和异常图像的重构损失将是相似的。因此,与超分辨率和修复一样,使用 GAN 可以提供帮助。

这是一个活跃的研究领域,因此有许多竞争的模型架构、损失函数、训练过程等,但它们都有一些共同的组成部分,如图 12-45 所示。通常它们包括一个生成器和鉴别器,有时还包括额外的编码器和解码器网络,这取决于使用情况。

图 12-45. Skip-GANomaly 架构,使用 U-Net 生成器(编码器/解码器)与跳跃连接,鉴别器和多个损失项。图像来自 Akçay et al., 2019

如果生成器的输入和输出是图像,如 图 12-45 的 G,生成器可以是自编码器或 U-Net,或者生成器可以只是一个解码器,其输入是用户提供的随机潜在向量。这种图像自编码器,因为它是 GAN 的一部分,有时也称为对抗性自编码器。

鉴别器,如 图 12-45 的 D,用于对生成器进行对抗训练。这通常是一种编码器类型的网络,将图像压缩成一个逻辑向量,然后用于损失计算。

如前所述,有时会有额外的编码器或多个生成器/鉴别器对。如果生成器是自编码器,则额外的编码器可以用于正则化生成器的中间瓶颈向量。如果生成器只是一个解码器,那么编码器可以将生成器生成的图像编码成特征向量,以重建噪声先验,从本质上来说,它充当了生成器的反函数。

与 SRGAN 和 inpainting 相同,通常存在多个损失项:例如重建损失如 L[con] 和对抗损失如 L[adv],在示例架构中如 图 12-45 中所示。此外,还可能有其他损失项,如 图 12-45 的 L[lat],这是一个潜在损失,它计算了鉴别器中一个中间层的两个特征图之间的欧几里得距离之和。这些损失的加权和旨在促进所需的推断行为。对抗损失确保生成器已经学会了正常图像的流形。

图像重建的三阶段训练过程,计算正常预测误差分布,并应用距离阈值,将根据经过训练的生成器传递的是正常还是异常图像而产生不同的结果。正常图像将与原始图像非常相似,因此它们的重建误差将很低;因此,与学习的参数化误差分布相比,它们将具有低于学习阈值的距离。然而,当生成器传递异常图像时,重建将不再仅仅稍差。因此,应该将异常点标记出来,生成模型认为没有异常时的图像样子。生成器基本上会根据其学习的正常图像流形来幻想应该在那里的内容。显然,与原始异常图像相比,这应该会产生非常大的误差,从而使我们能够正确地标记异常图像或像素,用于异常检测或定位,分别。

深度伪造

最近爆炸性增长到主流的一种流行技术是所谓的深度伪造。深度伪造用不同的对象或人物替换现有图像或视频中的对象或人物。用于创建这些深度伪造图像或视频的典型模型是自编码器,或者性能更好的是 GAN。

创建深度伪造的一种方法是创建一个编码器和两个解码器,A 和 B。假设我们试图用人物 X 的面孔与人物 Y 的面孔交换。首先,我们扭曲人物 X 面孔的图像,通过编码器获取嵌入,然后通过解码器 A 传递。这鼓励这两个网络学习如何从嘈杂版本中重建出人物 X 的面孔。接下来,我们通过同样的编码器传递人物 Y 面孔的扭曲版本,并通过解码器 B 传递。这鼓励这两个网络学习如何从嘈杂版本中重建出人物 Y 的面孔。我们一遍又一遍地重复这个过程,直到解码器 A 能够产生人物 X 的清晰图像,解码器 B 能够产生人物 Y 的清晰图像。这三个网络已经学会了这两张面孔的本质。

推断时,如果我们现在通过编码器将人物 X 的图像传递,然后通过训练在人物 Y 而不是人物 X 上的解码器 B,网络将认为输入是嘈杂的,并将图像“去噪”为人物 Y 的面孔。添加一个对抗损失的鉴别器可以帮助改善图像质量。

在深度伪造的创建中已经有许多其他进展,例如只需要单个源图像(通常通过在蒙娜丽莎等艺术作品上运行深度伪造来演示)。不过,请记住,为了取得良好的结果,需要大量的数据来充分训练网络。

深度假像是我们需要密切关注的东西,因为它们可能被滥用于政治或财务上的利益,例如让政客看起来说了他们从未说过的话。目前有大量积极的研究致力于探索检测深度假像的方法。

图像标题生成

到目前为止,在本章中,我们已经看到如何表示图像(使用编码器)以及如何从这些表示生成图像(使用解码器)。然而,图像并不是唯一值得从图像表示生成的东西,我们可能想基于图像内容生成文本,这是一个被称为图像标题生成的问题。

图像标题生成是一个非对称转换问题。这里的编码器操作于图像,而解码器需要生成文本。一个典型的方法是使用标准模型来完成这两个任务,如图 12-46 所示。例如,我们可以使用 Inception 卷积模型将图像编码为图像嵌入,并使用语言模型(在灰色框内)进行序列生成。

图 12-46。高级图像标题生成架构。

有两个重要的概念是理解语言模型中发生的事情的必要条件:注意力和门控循环单元(GRUs)。

注意力 对于模型学习图像中特定部分与标题中特定词语之间的关系非常重要。通过训练网络,使其学会专注于图像的特定部分以预测输出序列中特定词语的方式来实现这一点(参见图 12-47)。因此,解码器包含一个机制,通过注意图像来预测下一个词语。

图 12-47。模型通过关注输入图像的相关部分来学习预测序列中的下一个词。在预测“飞盘”这个词时,网络的注意力如图所示。图像来自Xu et al., 2016

GRU 单元 是序列模型的基本构建块。与本书中看到的图像模型不同,语言模型需要记住它们已经预测过的词语。为了让语言模型能够将英文输入句子(“I love you”)翻译成法文(“Je t’aime”),仅仅逐词翻译是不够的。相反,模型需要一些记忆能力。这通过 GRU 单元完成,它具有输入、输出、输入状态和输出状态。为了预测下一个词语,状态在步骤之间传递,并且一个步骤的输出成为下一个步骤的输入。

在本节中,我们将构建一个端到端的字幕模型,从创建数据集和预处理字幕开始,然后构建字幕模型,训练它,并使用它进行预测。

数据集

要训练一个模型来预测标题,我们需要一个包含这些图像和标题的训练数据集。COCO 字幕数据集是这样一组带字幕图像的大型语料库。我们将使用 TensorFlow 数据集的 COCO 数据集版本——这个版本包含了来自 COCO 2014 的图像、边界框、标签和字幕,按照 Karpathy 和 Li(2015)定义的子集划分,并解决了原始数据集中的一些数据质量问题(例如,原始数据集中的一些图像没有字幕)。

我们可以使用以下代码创建一个训练数据集(完整代码在 GitHub 的02e_image_captioning.ipynb中查看):

def get_image_label(example):
    captions = example['captions']['text'] # all the captions
    img_id = example['image/id']
    img = example['image']
    img = tf.image.resize(img, (IMG_WIDTH, IMG_HEIGHT))
    img = tf.keras.applications.inception_v3.preprocess_input(img)
    return {
        'image_tensor': img,
        'image_id': img_id,
        'captions': captions
    }

    trainds = load_dataset(...).map(get_image_label)

此代码将get_image_label()函数应用于读取的每个示例。此方法提取标题和图像张量。这些图像大小不同,但我们需要它们的形状为(299,299,3)以便使用预训练的 Inception 模型。因此,我们将每个图像调整为所需的大小。

每个图像都有多个标题。下面显示了一些示例图像和每个图像的第一个标题:图 12-48。

图 12-48。来自 COCO 数据集的几个示例图像和这些图像的第一个标题。

标题分词

给定一个标题,如:

A toilet and sink in a tiled bathroom.

我们需要删除标点符号,转换为小写,拆分为单词,删除不常见的单词,添加特殊的起始和停止标记,并将其填充到一致的长度:

['<start>', 'a', 'toilet', 'and', 'sink', 'in', 'a', 'tiled', 'bathroom', '<end>'
, '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>'
, '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>'
, '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>'
, '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>', '<pad>'
, '<pad>', '<pad>', '<pad>']

首先,我们在每个标题字符串中添加 <start><end> 标记:

train_captions = []
for data in trainds:
    str_captions = ["<start> {} <end>".format(
        t.decode('utf-8')) for t in data['captions'].numpy()]
    train_captions.extend(str_captions)

然后,我们使用 Keras 的标记器创建单词到索引的查找表:

tokenizer = tf.keras.layers.experimental.preprocessing.TextVectorization(
    max_tokens=VOCAB_SIZE, output_sequence_length=MAX_CAPTION_LEN)
tokenizer.adapt(train_captions)

现在标记器可以用来在两个方向上进行查找:

padded = tokenizer(str_captions)
predicted_word = tokenizer.get_vocabulary()[predicted_id]

批处理

COCO 数据集中的每个图像最多可以有五个标题。因此,对于给定的图像,我们实际上可以生成高达五个特征/标签对(图像是特征,标题是标签)。由于这个原因,创建一批训练特征并不像这么容易:

trainds.batch(32)

由于这些 32 个示例将扩展到 32 到 32 * 5 个潜在示例。我们需要批次具有一致的大小,因此我们将使用训练数据集生成必要的示例,然后对它们进行批处理:

def create_batched_ds(trainds, batchsize):
    # generator that does tokenization, padding on the caption strings
    # and yields img, caption
    def generate_image_captions():
        for data in trainds:
            captions = data['captions']
            img_tensor = data['image_tensor']
            str_captions = ["starttoken {} endtoken".format(
                t.decode('utf-8')) for t in captions.numpy()]
            # Pad each vector to the max_length of the captions
            padded = tokenizer(str_captions)
            for caption in padded:
                yield img_tensor, caption # repeat image
        return tf.data.Dataset.from_generator(
            generate_image_captions,
            (tf.float32, tf.int32)).batch(batchsize)

请注意,我们正在阅读字幕字符串,并对这些字符串应用与前一节相同的处理。这是为了创建单词到索引查找表,并计算整个数据集上的最大字幕长度,以便字幕可以填充到相同的长度。在这里,我们简单地应用查找表,并根据在整个数据集上计算的内容填充字幕。

然后,我们可以通过以下方式创建 193 个图像/字幕对的批次:

create_batched_ds(trainds, 193)

字幕模型

该模型由图像编码器和字幕解码器组成(见图 12-46)。字幕解码器包含一个注意机制,专注于输入图像的不同部分。

图像编码器

图像编码器由预训练的 Inception 模型和一个Dense层组成:

class ImageEncoder(tf.keras.Model):
    def __init__(self, embedding_dim):
    inception = tf.keras.applications.InceptionV3(
        include_top=False,
        weights='imagenet'
    )
    self.model = tf.keras.Model(inception.input,
                                inception.layers[-1].output)
    self.fc = tf.keras.layers.Dense(embedding_dim)

调用图像编码器应用 Inception 模型,将 Inception 返回的[batch, 8, 8, 2048]扁平化为[batch, 64, 2048],并通过Dense层传递:

def call(self, x):
    x = self.model(x)
    x = tf.reshape(x, (x.shape[0], -1, x.shape[3]))
    x = self.fc(x)
    x = tf.nn.relu(x)
    return x

注意机制

注意组件很复杂——结合图 12-46 的以下描述以及02e_image_captioning.ipynb在 GitHub 上的完整代码

请回想一下,注意力是模型学习图像中特定部分与字幕中特定单词之间关系的方式。注意机制包括两组权重——W1是用于空间组件(特征,图像中要关注的位置)的密集层,W2是“时间”组件的密集层(指示输入序列中要关注的单词):

attention_hidden_layer = (tf.nn.tanh(self.W1(features) +
                          self.W2(hidden_with_time_axis)))

加权注意机制应用于递归神经网络的隐藏状态,以计算分数:

score = self.V(attention_hidden_layer)
attention_weights = tf.nn.softmax(score, axis=1)

V在这里是一个密集层,具有一个输出节点,通过 softmax 层传递以获得最终的组合权重,该权重在所有单词上加起来等于 1。这些特征通过此值加权,这是解码器的输入:

context_vector = attention_weights * features

这个注意机制是解码器的一部分,我们将在下一部分中讨论。

字幕解码器

请记住,解码器需要记住它过去预测过的内容,因此状态会从一个步骤传递到下一个步骤,一个步骤的输出成为下一个步骤的输入。同时,在训练过程中,字幕单词逐个输入解码器。

解码器逐个获取字幕单词(在下面的列表中为 x),并将每个单词转换为其词嵌入。然后将嵌入与注意机制的上下文输出(指定注意机制当前关注的图像部分)连接起来,并传递到递归神经网络单元(此处使用 GRU 单元):

x = self.embedding(x)
x = tf.concat([tf.expand_dims(context_vector, 1), x], axis=-1)
output, state = self.gru(x)

然后,GRU 单元的输出通过一组密集层传递,以获得解码器的输出。这里的输出通常会是 softmax,因为解码器是一个多标签分类器——我们需要解码器告诉下一个单词是五千个单词中的哪一个。然而,出于在预测部分变得明显的原因,保持输出为 logits 是有帮助的。

把这些片段放在一起,我们有:

encoder = ImageEncoder(EMBED_DIM)
decoder = CaptionDecoder(EMBED_DIM, ATTN_UNITS, VOCAB_SIZE)
optimizer = tf.keras.optimizers.Adam()
loss_object = tf.keras.losses.SparseCategoricalCrossentropy(
    from_logits=True, reduction='none')

字幕模型的损失函数有点棘手。它不仅仅是整个输出的平均交叉熵,因为我们需要忽略填充的单词。因此,在计算平均值之前,我们定义了一个可以屏蔽填充单词(即全零)的损失函数:

def loss_function(real, pred):
    mask = tf.math.logical_not(tf.math.equal(real, 0))
    loss_ = loss_object(real, pred)
    mask = tf.cast(mask, dtype=loss_.dtype)
    loss_ *= mask
    return tf.reduce_mean(loss_)

训练循环

现在我们的模型已经创建,我们可以开始训练它了。您可能已经注意到,我们不只有一个 Keras 模型——我们有一个编码器和一个解码器。这是因为仅仅在整个图像和字幕上调用model.fit()是不够的——我们需要逐个传递字幕单词给解码器,因为解码器需要学习如何预测序列中的下一个单词。

给定一张图像和一个目标字幕,我们初始化损失并重置解码器状态(以防解码器继续使用前一个字幕的单词):

def train_step(img_tensor, target):
    loss = 0
    hidden = decoder.reset_state(batch_size=target.shape[0])

解码器输入以特殊的起始标记开始:

dec_input = ... tokenizer(['starttoken'])...

我们调用解码器,并通过比较解码器输出与字幕中的下一个单词来计算损失:

for i in range(1, target.shape[1]):
    predictions, hidden, _ = decoder(dec_input, features, hidden)
    loss += loss_function(target[:, i], predictions)

我们每次将第 i 个单词添加到解码器输入中,这样模型学习时基于正确的字幕,而不是基于预测的单词是什么:

dec_input = tf.expand_dims(target[:, i], 1)

这被称为教师强制。教师强制将输入中的目标词与上一步预测的词交换。

刚刚描述的整套操作必须被捕捉以计算梯度更新,因此我们在GradientTape中包裹它:

with tf.GradientTape() as tape:
    features = encoder(img_tensor)
    for i in range(1, MAX_CAPTION_LENGTH):
        predictions, hidden, _ = decoder(dec_input, features, hidden)
        loss += loss_function(target[:, i], predictions)
        dec_input = tf.expand_dims(target[:, i], 1)

然后我们可以更新损失并应用梯度:

    total_loss = (loss / MAX_CAPTION_LENGTH)
    trainable_variables = \
        encoder.trainable_variables + decoder.trainable_variables
    gradients = tape.gradient(loss, trainable_variables)
    optimizer.apply_gradients(zip(gradients, trainable_variables))

现在我们已经定义了单个训练步骤中发生的事情,我们可以循环执行所需数量的时期:

batched_ds = create_batched_ds(trainds, BATCH_SIZE)
for epoch in range(EPOCHS):
    total_loss = 0
    num_steps = 0
    for batch, (img_tensor, target) in enumerate(batched_ds):
        batch_loss, t_loss = train_step(img_tensor, target)
        total_loss += t_loss
        num_steps += 1
    # storing the epoch end loss value to plot later
    loss_plot.append(total_loss / num_steps)

预测

为了预测的目的,我们得到一张图像,需要生成一个字幕。我们用标记<start>开始字幕字符串,并将图像和初始标记馈送给解码器。解码器返回一组 logits,每个词汇表中的一个。

现在我们需要使用 logits 来获取下一个单词。我们可以采取几种方法:

  • 贪婪方法是我们选择具有最大对数似然的单词。这本质上意味着我们在 logits 上进行tf.argmax()。这很快,但往往会过度强调像“a”和“the”这样的不太信息丰富的单词。

  • 光束搜索方法会选择前三或五个候选词。然后我们会强制解码器使用这些词中的每一个,并选择序列中的下一个词。这样就创建了一个输出序列的树,从中选择概率最高的序列。由于这种优化是针对序列而不是单个词的概率,因此往往会产生最佳结果,但计算成本相当高,可能导致较高的延迟。

  • 一种概率方法是根据其可能性选择词语——在 TensorFlow 中,可以通过tf.random.categorical()实现。例如,如果“crowd”后面的词“people”的可能性为 70%,“watching”的可能性为 30%,那么模型以 70%的概率选择“people”,30%的概率选择“watching”,以便探索可能性较低的短语。这是一种在新颖性和速度之间进行合理权衡的方法,但代价是不可重现性。

让我们试试第三种方法。

我们首先对图像应用所有预处理步骤,然后将其发送到图像编码器:

def predict_caption(filename):
    attention_plot = np.zeros((max_caption_length, ATTN_FEATURES_SHAPE))
    hidden = decoder.reset_state(batch_size=1)
    img = tf.image.decode_jpeg(tf.io.read_file(filename),
                               channels=IMG_CHANNELS)
    img = tf.image.resize(img, (IMG_WIDTH, IMG_HEIGHT)) # inception size
    img_tensor_val = tf.keras.applications.inception_v3.preprocess_input(img)

    features = encoder(tf.expand_dims(img_tensor_val, axis=0))

然后,我们用标记<start>初始化解码器输入,并重复调用解码器,直到收到<end>标题或达到最大标题长度:

dec_input = tf.expand_dims([tokenizer(['starttoken'])], 0)
result = []
for i in range(max_caption_length):
    predictions, hidden = decoder(dec_input, features, hidden)
    # draws from log distribution given by predictions
    predicted_id = `tf``.``random``.``categorical`(predictions, 1)[0][0].numpy()
    result.append(tokenizer.vocabulary()[predicted_id])
    if tokenizer.vocabulary()[predicted_id] == 'endtoken':
        return result
    dec_input = tf.expand_dims([predicted_id], 0)

return img, result, attention_plot

一个示例图像及其生成的标题显示在图 12-49 中。模型似乎捕捉到这是一群在球场上打棒球的人群。然而,该模型认为中间的白线可能是街道的分隔线,这场比赛可能是在街上进行的。模型不会生成停用词(of, in, and, a 等),因为我们在训练数据集中已将其移除。如果我们有更大的数据集,我们可以尝试保留这些停用词以生成更合适的句子。

图 12-49. 一个示例图像,由作者提供,并模型生成的部分标题。

到此为止,我们现在有了一个端到端的图像字幕模型。图像字幕是理解大量图像的重要方式,开始在许多应用中找到用途,例如为视觉障碍者生成图像描述,满足社交媒体的可访问性要求,生成博物馆中使用的音频导览,以及执行图像的跨语言注释。

摘要

在本章中,我们看了如何生成图像和文本。要生成图像,我们首先使用自编码器(或变分自编码器)创建图像的潜在表示。通过经过训练的解码器传递的潜在向量充当图像生成器。然而,在实践中,生成的图像显然过于假造。为了提高生成图像的逼真度,我们可以使用生成对抗网络(GANs),这种方法使用博弈论方法训练一对神经网络。最后,我们看了如何通过训练图像编码器和文本解码器以及注意机制来实现图像字幕生成。

第十三章:后记

1966 年,麻省理工学院教授西摩·帕帕特(Seymour Papert)为他的学生们启动了一个暑期项目。项目的最终目标是通过将物体与已知物体词汇进行匹配,来命名图像中的对象。他为他们详细分解了任务,并期望小组能在几个月内完成。可以说,帕帕特博士稍微低估了这个问题的复杂性。

我们从探讨像全连接神经网络这样的天真机器学习方法开始这本书,这些方法未能利用图像的特殊特征。在第二章中,尝试这些天真的方法使我们学会了如何读取图像,以及如何使用机器学习模型进行训练、评估和预测。

然后,在第三章中,我们介绍了许多创新概念——卷积滤波器、最大池化层、跳跃连接、模块、挤压激活等,这些概念使得现代机器学习模型能够在从图像中提取信息方面表现出色。在实践中实现这些模型通常涉及使用内置的 Keras 模型或 TensorFlow Hub 层。我们还详细讨论了迁移学习和微调。

在第四章中,我们探讨了如何使用在第三章中涵盖的计算机视觉模型来解决计算机视觉中的两个更基本的问题:物体检测和图像分割。

本书的接下来几章深入探讨了创建生产级计算机视觉机器学习模型涉及的每个阶段:

  • 在第五章中,我们讨论了如何创建一个对机器学习高效的数据集格式。我们还讨论了标签创建的选项,以及为模型评估和超参数调整保留独立数据集的选项。

  • 在第六章中,我们深入研究了预处理和防止训练-服务偏差。预处理可以在tf.data输入流水线中完成,在 Keras 层中完成,在tf.transform中完成,或者使用这些方法的混合。我们涵盖了每种方法的实施细节及其利弊。

  • 在第七章中,我们讨论了模型训练,包括如何在多个 GPU 和工作节点上分布训练。

  • 在第八章中,我们探讨了如何监控和评估模型。我们还研究了如何进行分片评估,以诊断模型中的不公平和偏见。

  • 在第九章中,我们讨论了用于部署模型的各种选项。我们实现了批处理、流式处理和边缘预测。我们能够在本地和通过网络调用我们的模型。

  • 在第十章中,我们向您展示了如何将所有这些步骤结合成一个机器学习流水线。我们还尝试了一个无代码图像分类系统,以利用机器学习的民主化进程。

在第十一章中,我们将视野扩展到图像分类之外。我们看到了计算机视觉的基本构建模块如何用于解决各种问题,包括计数、姿态检测和其他用例。最后,在第十二章中,我们探讨了如何生成图像和字幕。

在整本书中,讨论的概念、模型和流程都伴随着在GitHub 上的实现。我们强烈建议您不仅阅读本书,还要动手编写代码并进行实验。学习机器学习的最佳方式就是亲自实践。

计算机视觉正处于一个激动人心的阶段。底层技术已经足够成熟,以至于今天,在帕珀特博士向他的学生提出这个问题 50 多年后,我们终于达到了图像分类可以完成为期两个月的项目的阶段!我们祝您在将这项技术应用于改善人类生活方面取得巨大成功,并希望您能像我们一样,通过使用计算机视觉来解决真实世界的问题,为此带来无限的乐趣。

posted @ 2025-11-22 09:01  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报