PyTorch-现代计算机视觉第二版-一-

PyTorch 现代计算机视觉第二版(一)

原文:zh.annas-archive.org/md5/355d709877e6e04dc1540c8ccd0b447d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

人工智能 (AI) 已经到来,并成为推动日常应用的强大力量。 就像火的发现/发明,轮子,石油,电力和电子技术一样,AI 正在以我们难以想象的方式重塑我们的世界。 AI 历来是一个计算机科学的小众学科,只有少数实验室提供。 但由于出色理论的爆炸,计算能力的增加和数据的可用性,这一领域在 21 世纪初开始呈指数增长,并显示出不会很快放缓的迹象。

AI 一再证明,只要给定合适的算法和足够的数据量,它可以几乎不需要人为干预地学习任务,并产生可以与人类判断相匹敌甚至超越的结果。 无论您是刚开始学习或是驾驭大型组织的资深人士,都有充分的理由了解 AI 的工作原理。 神经网络 (NNs) 是适应广泛应用的最灵活的 AI 算法之一,包括结构化数据,文本和视觉领域。

本书从 NN 的基础知识开始,涵盖了超过 40 个使用PyTorch的计算机视觉应用。 通过掌握这些应用,您将能够为各种领域(如汽车,安全,金融后勤,医疗保健等)的各种用例构建 NN,具备必要的技能不仅能实施最先进的解决方案,还能创新并开发解决更多现实世界挑战的新应用。

最终,本书旨在成为学术学习和实际应用之间的桥梁,使您能够自信前行,并在职业生涯中做出重要贡献。

本书适合的读者

本书适合于刚接触 PyTorch 和中级机器学习实践者,他们希望通过深度学习和 PyTorch 熟悉 CV 技术。 刚开始接触 NN 的人士也会发现本书有用。 您只需具备 Python 编程语言和机器学习的基础知识即可开始本书。

本书内容涵盖了什么

第一章人工神经网络基础,为您详细介绍了 NN 的工作原理。 您将首先学习与 NN 相关的关键术语。 接下来,您将了解构建块的工作细节,并在玩具数据集上从头构建 NN。 在本章结束时,您将对 NN 的工作原理感到自信。

第二章PyTorch 基础,介绍了如何使用 PyTorch。 您将了解创建和操作张量对象的方法,然后学习使用 PyTorch 构建神经网络模型的不同方法。 您将继续使用玩具数据集,以便了解与 PyTorch 的具体工作方式。

第三章使用 PyTorch 构建深度神经网络,结合前几章的内容,深入理解各种神经网络超参数对模型准确性的影响。通过本章的学习,你将自信地在真实数据集上操作神经网络。

第四章引入卷积神经网络,详细介绍了使用传统神经网络的挑战,以及卷积神经网络(CNNs)如何克服传统神经网络的各种局限性的原因。你将深入了解 CNN 的工作细节和其中的各个组件。接下来,你将学习在处理图像时的最佳实践。在本章中,你将开始处理真实世界的图像,并学习 CNN 如何帮助进行图像分类的复杂性。

第五章图像分类的迁移学习,让你在实际中解决图像分类问题。你将学习多种迁移学习架构,了解它们如何显著提高图像分类的准确性。接下来,你将利用迁移学习实现面部关键点检测和年龄性别估计的使用案例。

第六章图像分类的实际方面,深入探讨了在构建和部署图像分类模型时需要注意的实际方面。你将实际看到如何利用数据增强和批归一化在真实数据上带来优势。此外,你将学习类激活图如何帮助解释 CNN 模型为什么会预测某个结果的原因。通过本章的学习,你将能够自信地解决大多数图像分类问题,并在自定义数据集上利用前三章讨论的模型。

第七章物体检测基础,为物体检测奠定了基础,你将了解用于构建物体检测模型的各种技术。接下来,通过一个使用案例,你将学习基于区域提议的物体检测技术,实现一个定位图像中卡车和公共汽车的模型。

第八章高级物体检测,让你了解了基于区域提议的架构的局限性。接下来,你将学习更先进的架构,如 YOLO 和 SSD,它们解决了基于区域提议的架构的问题。你将在同一数据集(卡车与公共汽车检测)上实现所有这些架构,以便对比每种架构的工作原理。

第九章图像分割,在前几章的基础上建立,并将帮助你构建能准确定位各种类别对象及其实例在图像中位置的模型。你将在道路图像和普通家居图像上实现使用案例。本章结束时,你将能够自信地解决任何图像分类和对象检测/分割问题,并通过使用 PyTorch 构建模型来解决这些问题。

第十章目标检测和分割的应用,总结了我们在所有前几章中学到的内容,并开始在几行代码中实现目标检测和分割,并实现模型来执行人群计数和图像着色。接下来,你将学习在真实数据集上进行 3D 目标检测。最后,你将学习如何在视频上执行动作识别。

第十一章自编码器和图像处理,为修改图像打下基础。你将从学习各种自编码器开始,这些自编码器有助于压缩图像并生成新颖图像。接下来,你将学习对抗攻击,这些攻击能欺骗模型,在实施神经风格转换之前。最后,你将实现一个自编码器来生成深度伪造图像。

第十二章使用 GAN 生成图像,首先深入介绍了 GAN 的工作原理。接下来,你将实现虚假面部图像生成,并使用 GAN 生成感兴趣的图像。

第十三章高级 GAN 用于图像操作,将图像操作推向了新的高度。你将使用 GAN 来将对象从一类转换到另一类,从草图生成图像,并操作自定义图像,以便按特定风格生成图像。本章结束时,你将能够自信地使用自编码器和 GAN 的组合进行图像操作。

第十四章结合计算机视觉和强化学习,首先介绍了强化学习(RL)的术语和为状态分配价值的方式。当你学习深度 Q 学习时,你将了解到如何将 RL 和神经网络结合起来。利用这些知识,你将实现一个代理来玩乒乓球游戏,以及一个实现自动驾驶汽车的代理。

第十五章结合计算机视觉和 NLP 技术,详细介绍了变压器的工作原理,你将利用它实现诸如图像分类、手写识别、护照图像中的键-值提取以及最后的图像视觉问答等应用。在这个过程中,你将学习多种自定义/利用变压器架构的方法。

Chapter 16, Foundation Models in Computer Vision, starts by strengthening your understanding of combining image and text using CLIP model. Next, you will discuss the Segment Anything Model (SAM), which helps with a variety of tasks – segmentation, recognition, and tracking without any training. Finally, you will understand the working details of diffusion models before you learn the importance of prompt engineering and the impact of bigger pre-trained models like SDXL.

Chapter 17, Applications of Stable Diffusion, extends what you learned in the previous chapters by walking you through how a variety of Stable Diffusion applications (image in-painting, ControlNet, DepthNet, SDXL Turbo, and text-to-video) are trained and then walking you through leveraging different models to perform different tasks.

Chapter 18, Moving a Model to Production, describes the best practices for moving a model to production. You will first learn about deploying a model on a local server before moving it to the AWS public cloud. Next, you will learn about the impact of half-precision on latency, and finally, you will learn about leveraging vector stores (for instance, FAISS) and identifying data drift once a model is moved to production.

随着领域的发展,我们将定期向 GitHub 存储库添加有价值的补充内容。请检查每个章节目录中的supplementary_sections文件夹以获取新的有用内容。

要充分利用本书

Software/hardware covered in the book OS requirements
Minimum 128 GB storageMinimum 8 GB RAMIntel i5 processor or betterNVIDIA 8+ GB graphics card – GTX1070 or betterMinimum 50 Mbps internet speed Windows, Linux, and macOS
Python 3.6 and above Windows, Linux, and macOS
PyTorch 2.1 Windows, Linux, and macOS
Google Colab (can run in any browser) Windows, Linux, and macOS

请注意,本书中的几乎所有代码都可以通过点击 GitHub 上每个笔记本章节的Open Colab按钮在 Google Colab 中运行。

如果您使用本书的数字版本,我们建议您自行输入代码或通过 GitHub 存储库访问代码(链接在下一部分提供)。这样做可以帮助您避免与复制粘贴代码相关的潜在错误。

下载示例代码文件

本书的代码包托管在 GitHub 上,链接为:github.com/PacktPublishing/Modern-Computer-Vision-with-PyTorch-2E。我们还提供了来自丰富书籍和视频目录中的其他代码包。请查看!

下载彩色图像

我们还提供了一份包含本书中使用的屏幕截图/图表的彩色图像的 PDF 文件。您可以在此处下载:packt.link/gbp/9781803231334

使用约定

本书采用了许多文本约定。

CodeInText:表示文本中的代码词语、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是一个例子:“我们正在创建FMNISTDataset类的val对象,除了我们之前看到的train对象。”

代码块设置如下:

# Crop image
img = img[50:250,40:240]
# Convert image to grayscale
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Show image
plt.imshow(img_gray, cmap='gray') 

当我们希望引起您对代码块特定部分的注意时,相关行或项将以粗体显示:

**def****accuracy****(****x, y, model****):**
    **model.****eval****()** # <- let's wait till we get to dropout section
    # get the prediction matrix for a tensor of `x` images
    **prediction = model(x)**
    # compute if the location of maximum in each row coincides
    # with ground truth
    **max_values, argmaxes = prediction.****max****(-****1****)**
    **is_correct = argmaxes == y**
    **return** **is_correct.cpu().numpy().tolist()** 

任何命令行输入或输出均如下所示:

$ python3 -m venv fastapi-venv
$ source fastapi-env/bin/activate 

粗体:表示一个新术语,一个重要词或您在屏幕上看到的单词。例如,菜单或对话框中的词语会出现在文本中,如此处所示。以下是一个例子:“我们将使用梯度下降(在前馈传递后)逐个批次进行,直到我们在一个训练轮次内用尽所有数据点。”

警告或重要说明如下所示。

提示和技巧如下所示。

与我们联系

我们始终欢迎读者的反馈。

一般反馈:电子邮件 feedback@packtpub.com,并在您的消息主题中提及书名。如果您对本书的任何方面有疑问,请发送电子邮件至 questions@packtpub.com 联系我们。

勘误:尽管我们尽了最大努力确保内容的准确性,但错误难免会发生。如果您在本书中发现错误,我们将不胜感激您向我们报告。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,请向我们提供位置地址或网站名称将不胜感激。请通过 copyright@packtpub.com 提供链接至该材料的链接。

如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有兴趣撰写或为书籍做贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读完Modern Computer Vision with PyTorch, Second Edition,我们很想听听您的想法!请点击此处直接访问亚马逊评论页面,与我们分享您的反馈。

您的评论对我们和技术社区都非常重要,将帮助我们确保提供优质内容。

下载本书的免费 PDF 副本

感谢购买本书!

您喜欢随时随地阅读,但无法随身携带印刷书籍吗?

您购买的电子书与您选择的设备不兼容吗?

别担心,现在每本 Packt 书籍都可以免费获取不受 DRM 限制的 PDF 版本。

无论何时何地,任何设备上都能阅读。直接从您喜爱的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

福利不止于此,您还可以在每天的电子邮箱中获取独家折扣、新闻简报和精彩的免费内容。

按照以下简单步骤获取这些福利:

  1. 扫描 QR 码或访问以下链接:

packt.link/free-ebook/9781803231334

  1. 提交您的购买证明。

  2. 这就是!我们将免费的 PDF 和其他福利直接发送到您的电子邮箱。

第一部分:计算机视觉深度学习基础

在本节中,我们将学习神经网络的基本构建模块,以及每个模块在成功训练神经网络中的作用。在这部分内容中,我们将首先简要介绍神经网络理论,然后转向使用 PyTorch 库构建和训练神经网络。

本节包括以下章节:

  • 第一章, 人工神经网络基础

  • 第二章, PyTorch 基础

  • 第三章, 使用 PyTorch 构建深度神经网络

第一章:人工神经网络基础知识

人工神经网络ANN)是一种监督学习算法,松散地受人类大脑功能的启发。类似于人类大脑中神经元的连接和激活方式,神经网络接受输入并通过函数传递,导致某些后续神经元被激活,并因此产生输出。

有几种标准的 ANN 架构。通用逼近定理表明,我们总是能找到足够大的神经网络架构和合适的权重集合,可以精确预测任何给定输入的输出。这意味着对于给定的数据集/任务,我们可以创建一个架构,并不断调整其权重,直到 ANN 预测我们想要的结果。调整权重直到 ANN 学习到给定任务称为训练神经网络。在解决各种相关任务中,ANN 如何通过在大型数据集上训练和定制架构来获得重要性。

计算机视觉中的一个突出任务是识别图像中存在的对象类别。ImageNet(www.image-net.org/challenges/LSVRC/index.php)是一个比赛,旨在识别图像中存在的对象类别。多年来分类错误率的减少如下所示:

图 1.1:ImageNet 比赛中的分类错误率(来源:www.researchgate.net/publication/331789962_Basics_of_Supervised_Deep_Learning

2012 年,神经网络(AlexNet)赢得了 ImageNet 比赛。正如您从前面的图表中可以看到的那样,从 2011 年到 2012 年,错误率显著减少,通过利用神经网络。从那时起,随着更深层和复杂的神经网络,分类错误率继续减少,并超过了人类水平的表现。

神经网络不仅在图像分类(以及对象检测和分割等相关任务)中达到了人类水平的性能,而且还启用了一整套全新的用例。生成 AIGenAI)利用神经网络以多种方式生成内容:

  • 从输入文本生成图像

  • 从输入图像和文本生成新的定制图像

  • 利用多个输入模态(图像、文本和音频)生成新内容

  • 从文本/图像输入生成视频

这为我们学习和实现神经网络在我们的定制任务中的应用提供了坚实的动机。

在本章中,我们将在一个简单的数据集上创建一个非常简单的架构,并主要关注人工神经网络的各种构建模块(前馈、反向传播和学习率)如何调整权重,以使网络能够从给定的输入中学习预测期望的输出。我们首先将从数学上学习神经网络是什么,然后从头开始构建一个,以建立坚实的基础。然后,我们将详细了解每个负责训练神经网络的组件,并编写相应的代码。总体而言,我们将涵盖以下主题:

  • 比较人工智能和传统机器学习

  • 学习关于人工神经网络构建模块

  • 实现前向传播

  • 实现反向传播

  • 将前向传播和反向传播结合起来实现

  • 理解学习率的影响

  • 总结神经网络的训练过程

所有本章节中的代码片段都可以在 Github 仓库的 Chapter01 文件夹中找到,链接为 bit.ly/mcvp-2e

我们强烈建议您通过每个笔记本中的在 Colab 中打开按钮执行代码。

比较人工智能和传统机器学习

传统上,系统通过程序员编写的复杂算法来实现智能化。例如,假设您有兴趣识别一张照片是否包含狗。在传统的机器学习ML)设置中,机器学习从业者或专业主题专家首先确定需要从图像中提取的特征。然后,他们提取这些特征,并通过一个精心编写的算法将给定的特征解析出来,告诉我们图像是否是一只狗。下图说明了这个概念:

使用低置信度自动生成的描述的图表

图 1.2:传统机器学习用于分类的工作流程

看下面的样本:

图 1.3:生成规则的示例图像

从前述图像中,一个简单的规则可能是,如果一幅图像中有三个黑色圆圈呈三角形排列,那么它可以被分类为狗。然而,这个规则在这张欺骗性的松饼特写图像面前就会失败:

图 1.4:简单规则可能失败的图像

当然,当显示一个除了狗脸特写之外的图像时,这个规则也会失败。因此,为了准确分类图像,我们需要创建的手动规则数量可能是指数级的,特别是随着图像变得更加复杂。因此,传统方法在非常受限制的环境中效果良好(比如拍护照照片,所有尺寸都在毫米内限制),在无约束环境中效果不佳,因为每幅图像都有很大变化。

我们可以将相同的思路扩展到任何领域,比如文本或结构化数据。过去,如果有人对编程以解决现实任务感兴趣,那么了解输入数据的一切并尽可能多地编写规则以涵盖每种情况就变得必要了。这是一件繁琐的事情,并且不能保证所有新情况都会遵循这些规则。

然而,通过利用 ANN,我们可以在一步中完成这一操作。

神经网络提供了独特的好处,即在单一步骤中结合特征提取(手动调整)并将这些特征用于分类/回归,只需少量手动特征工程。这两个子任务只需标记数据(例如哪些图片是狗,哪些不是狗)和一个神经网络架构。它不需要人类提出规则来分类图像,这减少了传统技术对程序员的大部分负担。

注意,主要要求是我们为需要解决方案的任务提供大量示例。例如,在前面的情况下,我们需要为模型提供多个非狗图片,以便它学习特征。神经网络在分类任务中的高层视图如下所示:

自动生成的图表描述

图 1.5:基于神经网络的分类方法

现在我们已经对神经网络比传统计算机视觉方法表现更好的基本原因有了非常高层次的概述,让我们在本章的各个部分深入了解神经网络如何工作。

了解 ANN 构建模块

人工神经网络(ANN)是一组张量(权重)和数学操作,以松散复制人脑功能的方式排列。它可以被视为接受一个或多个张量作为输入并预测一个或多个张量作为输出的数学函数。连接这些输入到输出的操作排列被称为神经网络的架构 - 我们可以根据手头任务定制它,即基于问题是否包含结构化(表格)或非结构化(图像、文本和音频)数据(这是输入和输出张量列表)。

人工神经网络由以下组成:

  • 输入层:这些层将独立变量作为输入。

  • 隐藏(中间)层:这些层连接输入和输出层,同时在输入数据之上执行变换。此外,隐藏层包含节点(在下图中表示为单元/圆圈),用于将它们的输入值修改为更高维/低维的值。通过使用各种激活函数修改中间层节点的值来实现更复杂表示的功能。

  • 输出层:这生成了输入变量通过网络传递时预期的值。

考虑到这一点,神经网络的典型结构如下:

Diagram  Description automatically generated

图 1.6:神经网络结构

输出层中的节点(在上图中表示为圆圈)的数量取决于手头的任务以及我们是否试图预测连续变量或分类变量。如果输出是连续变量,则输出层有一个节点。如果输出是具有m个可能类别的分类变量,则输出层有m个节点。让我们放大一个节点/神经元并看看发生了什么。一个神经元的输入转换如下:

图 1.7:神经元的输入转换

在上述图表中,x[1],x[2], ..., x[n]是输入变量,w[0]是偏置项(类似于线性/逻辑回归中的偏差)。

注意,w[1],w[2], ..., w[n]是分配给每个输入变量的权重,w[0]是偏置项。输出值a的计算如下:

正如您所看到的,它是权重和输入对的乘积之和,后跟额外的函数f(偏置项 + 乘积之和)。函数f是激活函数,用于在这些乘积之和之上应用非线性。在接下来的部分中,将详细介绍激活函数。此外,通过具有多个隐藏层,可以实现更多的非线性。

在高层次上,神经网络是一组节点,每个节点具有称为权重的可调浮点值,并且节点作为图形互连以按照网络结构返回输出。网络由三个主要部分组成:输入层、隐藏层(们)和输出层。请注意,可以有更多的隐藏层数 (n),术语深度学习指的是更多的隐藏层数。通常,在神经网络需要理解像图像识别这样复杂的事物时,需要更多的隐藏层。

考虑到神经网络的架构,让我们了解一下前馈传播,它有助于估计网络架构存在的误差(损失)的量。

实现前馈传播

为了建立对前馈传播如何工作的坚实基础理解,我们将通过训练神经网络的玩具示例来进行介绍,其中神经网络的输入为(1, 1),对应的(期望的)输出为 0。在此,我们将根据这对单一输入输出找到神经网络的最优权重。

在实际项目中,将有数千个数据点用于训练人工神经网络。

本例中的神经网络架构包含一个包含三个节点的隐藏层,如下所示:

图 1.8:带有 1 个隐藏层的示例神经网络架构

前述图表中的每个箭头都包含一个浮点值(权重),可调整。有 9 个浮点数(6 个对应于输入节点与隐藏层节点之间的连接的权重,3 个对应于隐藏层与输出层之间的连接的权重),我们需要找到这些值,使得当输入为(1,1)时,输出尽可能接近(0)。这就是我们所说的训练神经网络。出于简化目的,我们尚未引入偏置值,但基本逻辑保持不变。

在接下来的部分,我们将了解前述网络的以下内容:

  • 计算隐藏层的值

  • 执行非线性激活

  • 估计输出层的值

  • 计算对应于期望值的损失值

计算隐藏层单元值

现在,我们将为所有连接分配权重。在第一步中,我们随机分配所有连接的权重。一般来说,在训练开始之前,神经网络会用随机权重进行初始化。再次强调,为了简化起见,在介绍前向传播和反向传播的过程中,我们包括偏置值。但是在后续章节中实现从头开始的前向传播和反向传播时会有。

让我们从在 0 到 1 之间随机初始化的初始权重开始。

重要提示

神经网络训练过程中的最终权重不需要处于特定数值范围内。

网络中提供的权重和值的正式表示如下图所示(左半部分),网络中提供的随机初始化权重如右半部分所示。

图 1.9:(左)神经网络的正式表示(右)神经网络的随机权重初始化

在下一步中,我们执行输入与权重的乘法,以计算隐藏层中隐藏单元的值。在激活之前,隐藏层单元的值如下获得:

此处计算的隐藏层单元值(在激活之前)也显示在下图中:

图 1.10:激活前的隐藏层单元值

现在,我们将通过非线性激活函数传递隐藏层的值。

重要提示

如果我们在隐藏层中不应用非线性激活函数,无论存在多少隐藏层,神经网络都会变成从输入到输出的巨大线性连接。

应用激活函数

激活函数有助于建模输入和输出之间的复杂关系。一些经常使用的激活函数如下计算(其中 x 是输入):

对于各个先前激活的可视化,用于各种输入值的图形如下:

自动生成的图形用户界面、图表、线图说明

图 1.11:不同输入值的不同激活函数的输出

以我们的例子为例,让我们对三个隐藏层的总和应用 sigmoid(逻辑)激活函数 S(x)。通过这样做,我们在 sigmoid 激活后得到以下数值:

现在我们已经获得了激活后的隐藏层数值,在接下来的部分中,我们将获得输出层数值。

计算输出层的数值

到目前为止,我们已经计算出了应用 sigmoid 激活后的最终隐藏层数值。使用激活后的隐藏层数值和权重值(在第一次迭代中随机初始化),我们将计算网络的输出值:

图 1.12:在隐藏单元值上应用 Sigmoid 激活

我们执行隐藏层值和权重值的乘积之和以计算输出值。另一个提醒:我们排除了需要在每个单元(节点)添加的偏差项,仅简化我们对前向传播和反向传播工作细节的理解,将在编写前向传播和反向传播时包含它:

因为我们从一组随机权重开始,输出节点的值与目标非常不同。在这种情况下,差异为 1.235(请记住,目标是 0)。接下来,让我们计算与当前网络状态相关的损失值。

计算损失值

损失值(也称为成本函数)是我们在神经网络中优化的值。为了理解如何计算损失值,让我们看看两种情况:

  • 连续变量预测

  • 分类变量预测

计算连续变量预测期间的损失

通常,在变量连续时,损失值计算为实际值和预测之间差的平方的平均值 —— 也就是说,我们通过改变与神经网络相关的权重值来最小化均方误差。均方误差值计算如下:

在上述方程中, 是实际输出。 是由神经网络计算的预测 (其权重存储在形式为 的形式中),其输入为 m 是数据集中的行数。

关键要点是对于每组唯一的权重,神经网络将预测不同的损失,我们需要找到使损失为零(或在现实场景中尽可能接近零)的黄金权重组合。

在我们的例子中,假设我们正在预测的结果是连续的。在这种情况下,损失函数值是均方误差,计算方法如下:

现在我们已经计算出连续变量的损失值,接下来学习如何计算分类变量的损失值。

在预测分类变量时计算损失

当要预测的变量是离散的(即变量中仅有少数类别时),通常使用分类交叉熵损失函数。当要预测的变量具有其中两个不同值时,损失函数为二元交叉熵。

二元交叉熵的计算方法如下,其中y是输出的实际值,p是输出的预测值,m是数据点的总数:

分类交叉熵的计算方法如下,其中y是输出的实际值,p是输出的预测值,m是数据点的总数,C是类别的总数:

一种简单的可视化交叉熵损失的方法是查看预测矩阵本身。假设您在图像识别问题中预测五类——狗、猫、老鼠、牛和母鸡。神经网络的最后一层必须有五个神经元,采用 softmax 激活(关于 softmax 的更多信息请见下一节)。这样,它将被迫为每个数据点的每个类别预测一个概率。假设有五幅图像,并且预测的概率如下所示(每行中突出显示的单元格对应于目标类):

Table  Description automatically generated

图 1.13:交叉熵损失计算

注意每一行的总和为 1。在第一行中,当目标为Dog且预测概率为0.88时,相应的损失为0.128(这是0.88的负对数)。类似地,计算其他损失。正如您所见,当正确类别的概率高时,损失值较低。正如您所知,概率在 0 到 1 之间变化。因此,最小可能的损失可以是 0(当概率为 1 时),而最大损失可以是无穷大(当概率为 0 时)。

在数据集中,最终的损失是所有行中所有个体损失的平均值。

现在我们已经对计算均方误差损失和交叉熵损失有了扎实的理解,让我们回到我们的示例中。假设我们的输出是一个连续变量,我们将在后面的章节中学习如何使用反向传播来最小化损失值。我们将更新之前随机初始化的权重值 来最小化损失 ()。但在此之前,让我们先用 Python 中的 NumPy 数组编写前向传播代码,以加深对其工作原理的理解。

编写代码中的前向传播

编写前向传播代码的高级策略如下:

  1. 在每个神经元执行求和乘积。

  2. 计算激活。

  3. 在每个神经元重复前两个步骤直到输出层。

  4. 通过将预测值与实际输出进行比较来计算损失。

前向传播函数接受输入数据、当前神经网络权重和输出数据作为输入,并返回当前网络状态的损失作为输出。

用于计算所有数据点上均方误差损失值的前向传播函数在 GitHub 代码库的 Chapter01 文件夹中的 Feed_forward_propagation.ipynb 文件中,链接地址为 bit.ly/mcvp-2e

我们强烈建议您点击每个笔记本中的 在 Colab 中打开 按钮来执行代码笔记本。示例如下:

电脑截图的描述,自动生成

图 1.14: GitHub 笔记本中的“在 Colab 中打开”按钮

一旦点击 在 Colab 中打开,您将能够轻松执行所有代码,并应能够复制本书中显示的结果。

为了使这个练习更加现实,我们将使每个节点都有关联的偏置。因此,权重数组将包含连接不同节点的权重以及隐藏/输出层节点的偏置。现在可以执行代码了,让我们继续编写前向传播代码:

  1. 将输入变量值 (inputs)、权重 (weights,如果这是第一次迭代则为随机初始化) 和提供的数据集中的实际输出 (outputs) 作为 feed_forward 函数的参数:

    import numpy as np
    def feed_forward(inputs, outputs, weights): 
    
  2. 通过执行输入和连接输入层到隐藏层的权重值 (weights[0]) 的矩阵乘法 (np.dot) 并添加与隐藏层节点相关的偏置项 (weights[1]) 来计算隐藏层的值:

     pre_hidden = np.dot(inputs,weights[0])+ weights[1] 
    
  3. 在前一步骤中获得的隐藏层值 pre_hidden 上应用 Sigmoid 激活函数:

     hidden = 1/(1+np.exp(-pre_hidden)) 
    
  4. 通过执行隐藏层激活值 (hidden) 和连接隐藏层到输出层的权重 (weights[2]) 的矩阵乘法 (np.dot),并将输出与与输出层节点相关的偏置 weights[3] 相加来计算输出层的值:

     pred_out = np.dot(hidden, weights[2]) + weights[3] 
    
  5. 计算数据集上的均方误差值,并返回均方误差的均值:

     mean_squared_error = np.mean(np.square(pred_out - outputs))
        return mean_squared_error 
    

现在,通过网络进行前向传播,我们能够得到均方误差值。

在我们学习反向传播之前,让我们学习一些前馈网络的组成部分——激活函数和损失值计算——通过在 NumPy 中实现它们,以便我们深入了解它们的工作原理。

代码中的激活函数

虽然我们在前面的代码中对隐藏层值应用了 sigmoid 激活,让我们看看其他常用的激活函数:

  • Tanh:值(隐藏层单元值)的 tanh 激活计算如下:

    def tanh(x):
        return (np.exp(x)-np.exp(-x))/(np.exp(x)+np.exp(-x)) 
    
  • ReLU:值(隐藏层单元值)的修正线性单元ReLU)计算如下:

    def relu(x):      
        return np.where(x>0,x,0) 
    
  • 线性:值的线性激活是值本身。这也称为“恒等激活”或“无激活”,很少使用。表示如下:

    def linear(x):      
        return x 
    
  • Softmax:与其他激活函数不同,softmax 是在一组值之上执行的。这通常用于确定在给定场景中输入属于m个可能输出类别中的一个的概率。假设我们试图将一幅数字的图像分类为可能的 10 个类别(从 0 到 9)中的一个。

  1. 在这种情况下,有 10 个输出值,其中每个输出值应代表输入图像属于 10 个类别之一的概率。

  2. Softmax 激活用于为输出中的每个类别提供概率值,计算如下:

  3. def softmax(x):      
        return np.exp(x)/np.sum(np.exp(x)) 
    

注意,对输入x的两个操作——np.exp将使所有值变为正数,而通过np.sum(np.exp(x))对所有这些指数进行的除法将强制所有值位于 0 和 1 之间。这个范围与事件概率重合。这就是我们所说的返回概率向量的含义。

现在我们已经学习了各种激活函数,接下来我们将学习不同的损失函数。

代码中的损失函数

在神经网络训练过程中,通过更新权重值来最小化损失值(在代码中)。定义适当的损失函数是构建工作和可靠的神经网络模型的关键。通常在构建神经网络时使用的损失函数如下:

  • 均方误差:均方误差是输出的实际值和预测值之间的平方差。我们对误差取平方,因为误差可以是正或负(当预测值大于实际值或反之时)。平方确保正负误差不会互相抵消。我们计算平均值的平方误差,以便在数据集大小不同时比较两个不同数据集上的误差。

预测输出值数组 (p) 和实际输出值数组 (y) 之间的均方误差计算如下:

def mse(p, y):  
    return np.mean(np.square(p - y)) 

当试图预测具有连续性质的值时,通常使用均方误差。

  • 均绝对误差: 均绝对误差的工作方式与均方误差非常相似。均绝对误差通过计算所有数据点的实际值和预测值之间的绝对差的平均值,确保正负误差不会互相抵消。

预测输出值数组 (p) 和实际输出值数组 (y) 之间的均绝对误差实现如下:

def mae(p, y):      
    return np.mean(np.abs(p-y)) 

类似于均方误差,均绝对误差通常用于连续变量。

  • 二元交叉熵:交叉熵是衡量两个不同分布之间差异的指标:实际分布和预测分布。二元交叉熵适用于二进制输出数据,与我们讨论过的前两种损失函数(适用于连续变量预测)不同。

预测值数组 (p) 和实际值数组 (y) 之间的二元交叉熵实现如下:

def binary_cross_entropy(p, y):     
    return -np.mean((y*np.log(p)+(1-y)*np.log(1-p))) 

注意,当预测值远离实际值时,二元交叉熵损失值很高,而当预测值和实际值接近时,损失值很低。

  • 分类交叉熵:预测值数组 (p) 和实际值数组 (y) 之间的分类交叉熵实现如下:
def categorical_cross_entropy(p, y):        
    return -np.mean(np.log(p[np.arange(len(y)),y])) 

到目前为止,我们已经学习了前向传播及其组成部分,如权重初始化、与节点相关的偏差以及激活和损失函数。在接下来的部分中,我们将学习反向传播,这是一种调整权重的技术,使得它们导致的损失尽可能小。

实施反向传播

在前向传播中,我们将输入层连接到隐藏层,然后连接到输出层。在第一次迭代中,我们随机初始化权重,然后计算由这些权重值导致的损失。在反向传播中,我们采取相反的方法。我们从前向传播中获得的损失值开始,并以这样的方式更新网络的权重,以使损失值尽可能小。

通过执行以下步骤减少损失值:

  1. 通过逐个地改变神经网络中的每个权重来更新权重。

  2. 当改变权重值时 (),测量损失的变化 ()。

  3. 更新权重为 ,其中 k 是一个正值,是称为学习率的超参数。

    注意,对特定权重的更新与通过改变它稍微减少的损失量成比例。直观地说,如果改变一个权重减少了大量损失值,那么我们可以大幅度更新该权重。然而,如果通过改变权重减少的损失较小,那么我们只更新它一小部分。

如果在整个数据集上(我们已经进行了前向传播和反向传播)执行了前面的步骤n次,这实质上就是训练nepochs

由于典型神经网络包含成千上万的权重,改变每个权重的值并检查损失是否增加或减少并不是最优的做法。前述列表中的核心步骤是测量在改变权重时损失的变化。正如你在微积分中学习的那样,衡量这一点与计算损失关于权重的梯度相同。在下一节中,关于反向传播的链式法则将更多地利用微积分中的偏导数来计算损失关于权重的梯度。但在这一节之前,我们将通过稍微改变一次更新一个权重的方式来从头实现梯度下降。然而,在实施反向传播之前,让我们了解神经网络的一个额外细节:学习率

直观地说,学习率有助于建立对算法的信任。例如,在决定权重更新的大小时,我们可能不会一次性改变权重值很大的量,而是更慢地更新它。

这导致我们的模型获得稳定性;我们将看看学习率如何在理解学习率影响部分中帮助稳定性。

我们更新权重以减少错误的整个过程被称为梯度下降随机梯度下降是如何在前述场景中最小化错误的。如前所述,梯度表示差异(即当权重值稍微改变时损失值的差异),下降意味着减少。另一种解释是,梯度表示斜率(损失下降的方向),下降意味着向更低的损失移动。随机代表基于随机样本的选择,根据这些样本作出决策。

除了随机梯度下降外,还有许多类似的优化器帮助最小化损失值;不同的优化器将在下一章讨论。

在接下来的两节中,我们将学习如何在 Python 中从头开始编写反向传播,并简要讨论使用链式法则进行反向传播的工作原理。

代码中的梯度下降

梯度下降在 Python 中的实现如下:

以下代码在本书的 GitHub 存储库的Chapter01文件夹中作为Gradient_descent.ipynb提供 - bit.ly/mcvp-2e

  1. 定义前馈网络并计算均方误差损失值,就像我们在代码中的前向传播部分所做的那样:

    from copy import deepcopy
    import numpy as np
    def feed_forward(inputs, outputs, weights):
        pre_hidden = np.dot(inputs,weights[0])+ weights[1]
        hidden = 1/(1+np.exp(-pre_hidden))
        pred_out = np.dot(hidden, weights[2]) + weights[3]
        mean_squared_error = np.mean(np.square(pred_out - outputs))
        retur mean_squared_error 
    
  2. 逐个将每个权重和偏置值增加一个非常小的量(0.0001),并计算每个权重和偏置更新的总平方误差损失值。

    1. 在以下代码中,我们正在创建一个名为update_weights的函数,该函数执行梯度下降过程以更新权重。函数的输入是网络的输入变量inputs、期望输出outputs、权重(在训练模型开始时随机初始化)以及模型的学习率lr(关于学习率的更多信息将在后面的部分讨论):
    def update_weights(inputs, outputs, weights, lr): 
    
    1. 确保深度复制权重列表。由于权重将在后续步骤中被操作,deepcopy确保我们可以使用多个副本的权重而不影响原始权重值。我们将创建三个原始权重集的副本作为函数的输入 - original_weightstemp_weightsupdated_weights
     original_weights = deepcopy(weights)
        temp_weights = deepcopy(weights)
        updated_weights = deepcopy(weights) 
    
    1. 通过将输入、输出和原始权重通过前馈函数传递,计算损失值(original_loss):
     original_loss = feed_forward(inputs, outputs, original_weights) 
    
    1. 我们将遍历网络的所有层:
     for i, layer in enumerate(original_weights): 
    
    1. 在我们的神经网络中有四个参数列表 - 两个用于连接输入到隐藏层的权重和偏置参数列表,另外两个用于连接隐藏层到输出层的权重和偏置参数列表。现在,我们遍历所有的单个参数,并且由于每个列表具有不同的形状,我们利用np.ndenumerate来遍历给定列表中的每个参数:
     for index, weight in np.ndenumerate(layer): 
    
    1. 现在我们将原始权重集存储在temp_weights中。我们选择第 i 层中存在的权重的索引,并增加一个小的值。最后,我们使用神经网络的新权重集计算新的损失:
     temp_weights = deepcopy(weights)
                temp_weights[i][index] += 0.0001
                _loss_plus = feed_forward(inputs, outputs, temp_weights) 
    
    1. 在前述代码的第一行中,我们将temp_weights重置为原始权重集,因为在每次迭代中,当参数在给定 epoch 内以微小量更新时,我们会更新不同的参数以计算损失。

    2. 我们计算由于权重改变而产生的梯度(损失值的变化):

     grad = (_loss_plus - original_loss)/(0.0001) 
    

这个通过微小量更新参数然后计算梯度的过程相当于微分的过程。

  1. 最后,我们更新对应第 i 层和index的参数。更新后的权重值将按照梯度值的比例减少。此外,我们不完全通过减去梯度值来快速减少权重值,而是通过使用学习率lr逐步建立信任(有关学习率的更多信息,请参阅理解学习率影响部分):

  2.  updated_weights[i][index] -= grad*lr 
    
  3. 一旦跨所有层和层内索引的参数值更新完成,我们返回更新后的权重值 - updated_weights

  4.  return updated_weights, original_loss 
    

神经网络中的另一个参数是用于计算损失值的批量大小

在前面的场景中,我们考虑了所有数据点来计算损失(均方误差)值。然而,在实际操作中,当我们有成千上万(或在某些情况下,百万级)的数据点时,计算损失值时更多数据点的增量贡献会遵循边际收益递减法则,因此我们会使用远小于总数据点数的批量大小。我们将应用梯度下降(在前馈传播之后)每次使用一个批次,直到我们在一轮训练的一个时期内耗尽所有数据点。在构建模型时考虑的典型批次大小介于 32 和 1,024 之间。通常是 2 的幂,并且对于非常大的模型,根据情况,批量大小可以小于 32。

使用链式法则实现反向传播

到目前为止,我们通过更新权重值一小部分来计算与权重相关的损失的梯度,然后计算在原始情景(权重未更改时)和在更新权重后的前向传播损失之间的差异。以这种方式更新权重值的一个缺点是,当网络很大(有更多权重需要更新)时,需要大量计算来计算损失值(实际上,需要两次计算 - 一次是权重值未更改时,再次是权重值稍微更新后)。这导致更多的计算,因此需要更多的资源和时间。在本节中,我们将学习如何利用链式法则,它不需要我们手动计算损失值即可得出损失对权重值的梯度。

在第一次迭代中(我们随机初始化权重时),输出的预测值为 1.235。为了得到理论公式,让我们将权重和隐藏层值及隐藏层激活表示为wha,如下所示:

Diagram  Description automatically generated

图 1.15:泛化权重初始化过程

请注意,在前面的图表中,我们已经将左图的每个组件值概括在右图中。

为了简化理解,在本节中,我们将了解如何使用链规则计算损失值相对于仅 w[11] 的梯度。这种学习方法可以扩展到神经网络的所有权重和偏差。我们鼓励您练习并应用链规则计算到其余的权重和偏差值。此外,为了我们学习的目的简化,我们将仅处理一个数据点,其中输入为 {1,1},预期输出为 {0}。

本书的 GitHub 存储库中Chapter01文件夹中的chain_rule.ipynb笔记本包含使用链规则计算网络中所有参数的权重和偏差变化的梯度的方法,网址为bit.ly/mcvp-2e

鉴于我们正在计算损失值相对于 w[11] 的梯度,请让我们通过以下图表理解所有需要包含的中间组件(在以下图表中,未连接输出到 w[11] 的组件已被标为灰色):

Diagram  Description automatically generated

图 1.16:突出显示需要计算损失梯度的值(h[11], a[11], ŷ)

从上述图表中,我们可以看到 w[11]通过突出显示的路径对损失值有贡献 – , , 和 。让我们详细说明 , , 和 是如何分别获得的。

网络的损失值表示如下:

预测输出值 计算如下:

隐藏层激活值(sigmoid 激活)计算如下:

隐藏层值计算如下:

现在我们已经制定了所有方程,请计算损失值 (C) 相对于权重 的变化的影响。

这就是所谓的链规则。本质上,我们正在执行一系列不同的微分以获取我们感兴趣的微分值。

请注意,在上述方程中,我们已经建立了一系列偏微分方程,使我们能够逐个计算这四个组件的偏导数,并最终计算出损失值相对于权重值 的导数。

上述方程中的各个偏导数计算如下:

  1. 损失值对预测输出值 的偏导数如下所示:

  1. 预测输出值 对隐藏层激活值 的偏导数如下所示:

  1. 隐藏层激活值 对隐藏层激活前数值 的偏导数如下所示:

请注意,上述方程来自于 Sigmoid 函数的导数 如下所示:

  1. 隐藏层激活前数值对权重值 的偏导数如下所示:

在各个偏导数计算完毕后,损失值相对于 的梯度通过用前面步骤中计算的相应值替换每个偏导数项来计算,如下所示:

从上述公式可以看出,我们现在能够计算在权重值略微变化时对损失值的影响(即损失相对于权重的梯度),而无需通过重新计算前向传播来进行蛮力计算。

接下来,我们将按以下方式更新权重值:

两种方法的工作版本:1)使用链式法则识别梯度,然后更新权重;2)学习权重微小变化对损失值影响的更新权重值,在本书的 GitHub 仓库的 Chapter01 文件夹的笔记本 Chain_rule.ipynb 中提供相同的更新权重值,链接为 bit.ly/mcvp-2e

在梯度下降中,我们按顺序执行权重更新过程(逐个权重)。通过利用链式法则,我们学到了另一种计算权重值微小变化对损失值影响的替代方法,同时有机会并行计算。

因为我们在所有层上更新参数,整个参数更新过程可以并行化。此外,考虑到在实际场景中可能存在数百万个层参数,将每个参数在 GPU 的不同核心上进行计算,更新权重的时间比逐个权重循环的速度更快。

现在我们对反向传播有了坚实的理解,不仅从直觉上,还通过利用链式法则,让我们学习一下如何使前向传播和反向传播共同工作,以获得最佳的权重值。

将前向传播和反向传播结合起来

在本节中,我们将在与代码中的前向传播部分中使用的玩具数据集相同的简单神经网络上建立一个隐藏层,并利用我们在前一节中定义的update_weights函数执行反向传播以获得最佳的权重和偏差值。

请注意,我们没有利用链式法则,只是为了让您对前向和反向传播的基础有坚实的理解。从下一章开始,您将不会以这种方式执行神经网络训练。

我们按以下方式定义模型:

  1. 输入连接到具有三个单元/节点的隐藏层。

  2. 隐藏层连接到具有一个输出层中的单元。

以下代码在本书的 GitHub 存储库Chapter01文件夹中作为Back_propagation.ipynb提供 – bit.ly/mcvp-2e

我们将按以下方式创建网络:

  1. 导入相关包并定义数据集:

    from copy import deepcopy
    import numpy as np
    x = np.array([[1,1]])
    y = np.array([[0]]) 
    
  2. 随机初始化权重和偏差值。

    隐藏层中有三个单元,并且每个输入节点连接到隐藏层单元中的每一个。因此,总共有六个权重值和三个偏差值 – 每个隐藏单元对应一个偏差和两个权重(两个权重来自两个输入节点)。此外,最终层有一个单元连接到隐藏层的三个单元。因此,总共有三个权重和一个偏差决定输出层的值。随机初始化的权重如下:

    W = [
        np.array([[-0.0053, 0.3793],
                  [-0.5820, -0.5204],
                  [-0.2723, 0.1896]], dtype=np.float32).T,
        np.array([-0.0140, 0.5607, -0.0628], dtype=np.float32),
        np.array([[ 0.1528,-0.1745,-0.1135]],dtype=np.float32).T,
        np.array([-0.5516], dtype=np.float32)
    ] 
    

    在前面的代码中,第一个参数数组对应于连接输入层到隐藏层的 2 x 3 权重矩阵。第二个参数数组代表与隐藏层每个节点相关联的偏差值。第三个参数数组对应于连接隐藏层到输出层的 3 x 1 权重矩阵,最后一个参数数组表示与输出层相关联的偏差。

  3. 将神经网络通过 100 次前向传播和反向传播的时期 – 函数已经学习并在前面的章节中定义为feed_forwardupdate_weights函数:

    1. 定义feed_forward函数:
    def feed_forward(inputs, outputs, weights):
        pre_hidden = np.dot(inputs,weights[0])+ weights[1]
        hidden = 1/(1+np.exp(-pre_hidden))
        pred_out = np.dot(hidden, weights[2]) + weights[3]
        mean_squared_error = np.mean(np.square(pred_out - outputs))
        return mean_squared_error 
    
    1. 定义update_weights函数(我们将在下一节详细了解学习率lr):
    def update_weights(inputs, outputs, weights, lr):
        original_weights = deepcopy(weights)
        temp_weights = deepcopy(weights)
        updated_weights = deepcopy(weights)
        original_loss = feed_forward(inputs, outputs, original_weights)
        for i, layer in enumerate(original_weights):
            for index, weight in np.ndenumerate(layer):
                temp_weights = deepcopy(weights)
                temp_weights[i][index] += 0.0001
                _loss_plus = feed_forward(inputs, outputs, temp_weights)
                grad = (_loss_plus - original_loss)/(0.0001)
                updated_weights[i][index] -= grad*lr
        return updated_weights, original_loss 
    
    1. 在 100 个时期内更新权重并获取损失值和更新后的权重值:
    losses = []
    for epoch in range(100):
        W, loss = update_weights(x,y,W,0.01)
        losses.append(loss) 
    
  4. 绘制损失值图表:

    import matplotlib.pyplot as plt
    %matplotlib inline
    plt.plot(losses)
    plt.title('Loss over increasing number of epochs')
    plt.xlabel('Epochs')
    plt.ylabel('Loss value') 
    

    上述代码生成了以下图表:

    图表描述自动生成

    图 1.17:随着 epochs 增加,损失值的变化情况

    正如你所看到的,损失从约 0.33 开始稳定下降到约 0.0001。这表明权重根据输入输出数据进行调整,当给定输入时,我们可以预期它预测与损失函数中进行比较的输出。输出权重如下:

    [array([[ 0.01424004, -0.5907864 , -0.27549535],
            [ 0.39883757, -0.52918637, 0.18640439]], dtype=float32),
     array([ 0.00554004, 0.5519136 , -0.06599568], dtype=float32),
     array([[ 0.3475135 ],
            [-0.05529078],
            [ 0.03760847]], dtype=float32),
     array([-0.22443289], dtype=float32)] 
    

    在理解核心 PyTorch 概念之后,可以在 GitHub 仓库的 Chapter02 文件夹中的 Auto_gradient_of_tensors.ipynb 文件中演示相同权重的 PyTorch 版本的相同代码。重新访问本节,验证无论网络是用 NumPy 还是 PyTorch 编写,输入和输出都是相同的。

    在 NumPy 数组中从头开始构建网络,虽然不是最佳选择,但在本章中是为了让您对神经网络的工作细节有坚实的基础。

  5. 一旦我们有了更新的权重,通过网络传递输入来进行预测,并计算输出值:

    pre_hidden = np.dot(x,W[0]) + W[1]
    hidden = 1/(1+np.exp(-pre_hidden))
    pred_out = np.dot(hidden, W[2]) + W[3]
    # -0.017 
    

上述代码的输出是 -0.017 的值,这个值非常接近预期的输出值 0. 随着 epochs 的增加,pred_out 的值甚至更接近 0。

到目前为止,我们已经学习了前向传播和反向传播。在这里定义的 update_weights 函数的关键部分是学习率,我们将在下一节中学习它。

理解学习率的影响

为了理解学习率如何影响模型的训练,让我们考虑一个非常简单的情况,我们尝试拟合以下方程(请注意,以下方程不同于我们到目前为止正在处理的玩具数据集):

注意,y 是输出,x 是输入。通过一组输入和预期输出值,我们将尝试使用不同的学习率拟合方程,以了解学习率的影响:

以下代码可在本书 GitHub 仓库的 Chapter01 文件夹中的 Learning_rate.ipynb 中找到 – bit.ly/mcvp-2e

  1. 指定输入和输出数据集如下:

    x = [[1],[2],[3],[4]]
    y = [[3],[6],[9],[12]] 
    
  2. 定义 feed_forward 函数。此外,在这个例子中,我们将修改网络,使其没有隐藏层,并且架构如下:

    注意,在上述函数中,我们正在估计参数 wb

    from copy import deepcopy
    import numpy as np
    def feed_forward(inputs, outputs, weights):
        pred_out = np.dot(inputs,weights[0])+ weights[1]
        mean_squared_error = np.mean(np.square(pred_out - outputs))
        return mean_squared_error 
    
  3. 定义 update_weights 函数,就像我们在 梯度下降的代码 部分中定义的那样:

    def update_weights(inputs, outputs, weights, lr):
        original_weights = deepcopy(weights)
        org_loss = feed_forward(inputs, outputs,original_weights)
        updated_weights = deepcopy(weights)
        for i, layer in enumerate(original_weights):
            for index, weight in np.ndenumerate(layer):
                temp_weights = deepcopy(weights)
                temp_weights[i][index] += 0.0001
                _loss_plus = feed_forward(inputs, outputs, temp_weights)
                grad = (_loss_plus - org_loss)/(0.0001)
                updated_weights[i][index] -= grad*lr
        return updated_weights 
    
  4. 初始化权重和偏置值为随机值:

    W = [np.array([[0]], dtype=np.float32),
         np.array([[0]], dtype=np.float32)] 
    

注意,权重和偏置值随机初始化为 0 值。此外,输入权重值的形状为 1 x 1,因为输入中每个数据点的形状为 1 x 1,而偏置值的形状为 1 x 1(因为输出中只有一个节点,每个输出只有一个值)。

  1. 让我们利用学习率为 0.01 的update_weights函数,循环执行 1,000 次迭代,并检查权重值(W)随 epoch 增加的变化情况:

    weight_value = []
    for epx in range(1000):
        W = update_weights(x,y,W,0.01)
        weight_value.append(W[0][0][0]) 
    

请注意,在上述代码中,我们使用学习率为 0.01,并重复update_weights函数以在每个 epoch 结束时获取修改后的权重。此外,在每个 epoch 中,我们将最近更新的权重作为输入,以获取下一个 epoch 中的更新后的权重。

  1. 绘制每个 epoch 结束时的权重参数值:

    import matplotlib.pyplot as plt
    %matplotlib inline
    epochs = range(1, 1001)
    plt.plot(epochs,weight_value)
    plt.title('Weight value over increasing \
    epochs when learning rate is 0.01')
    plt.xlabel('Epochs')
    plt.ylabel('Weight value') 
    

上述代码导致权重值随 epoch 增加而变化如下:

自动生成的图表说明

图 1.18:学习率为 0.01 时随着 epoch 增加的权重值

请注意,在上述输出中,权重值逐渐向右方向增加,然后饱和在约为 3 的最佳值。

要了解学习率对到达最佳权重值的影响,让我们了解在学习率为 0.1 和学习率为 1 时,当增加 epoch 时权重值如何变化。

当我们修改相应的学习率值在步骤 5中并执行步骤 6时,获得以下图表:

自动生成的图表说明

图 1.19:(左)学习率为 0.1 时随着 epoch 增加的权重值(右)学习率为 1 时随着 epoch 增加的权重值

注意,当学习率非常小时(0.01),权重值缓慢移动(在较多的 epoch 中)向最佳值靠近。然而,稍高的学习率(0.1)时,权重值起初振荡,然后迅速饱和(在较少的 epoch 中)到达最佳值。最后,当学习率很高(1)时,权重值急剧上升到非常高的值,并且无法达到最佳值。

当学习率较低时,权重值没有大幅波动的原因在于我们通过*梯度 学习率限制了权重更新的幅度,从本质上讲,当学习率较小时,权重更新量较小。然而,当学习率较高时,权重更新量较大,在权重更新量较小时,损失的变化很小,以至于权重无法达到最佳值。

为了更深入地理解梯度值、学习速率和权重值之间的相互作用,让我们仅运行update_weights函数 10 个周期。此外,我们将打印以下数值以了解它们随着周期增加的变化:

  • 每个周期开始时的权重值

  • 权重更新前的损失

  • 当权重稍作调整时的损失

  • 梯度值

我们修改update_weights函数,打印如下前述数值:

def update_weights(inputs, outputs, weights, lr):
    original_weights = deepcopy(weights)
    org_loss = feed_forward(inputs, outputs, original_weights)
    updated_weights = deepcopy(weights)
    for i, layer in enumerate(original_weights):
        for index, weight in np.ndenumerate(layer):
            temp_weights = deepcopy(weights)
            temp_weights[i][index] += 0.0001
            _loss_plus = feed_forward(inputs, outputs, temp_weights)
            grad = (_loss_plus - org_loss)/(0.0001)
            updated_weights[i][index] -= grad*lr
            **if****(i %** **2** **==** **0****):**
**print****(****'weight value:'****, \**
 **np.****round****(original_weights[i][index],****2****), \**
**'original loss:'****, np.****round****(org_loss,****2****), \**
**'loss_plus:'****, np.****round****(_loss_plus,****2****), \**
**'gradient:'****, np.****round****(grad,****2****), \**
**'updated_weights:'****, \**
 **np.****round****(updated_weights[i][index],****2****))**
    return updated_weights 

在前述代码中加粗显示的行是我们从前一节中修改update_weights函数的地方,首先我们通过检查 (i % 2 == 0) 来确认当前是否在处理权重参数,因为另一个参数对应于偏差值,然后我们打印原始权重值 (original_weights[i][index])、损失值 (org_loss)、更新后的损失值 (_loss_plus)、梯度 (grad) 和更新后的权重值 (updated_weights)。

现在让我们了解在我们考虑的三种不同学习速率下,随着周期增加,前述数值如何变化。

学习速率为 0.01 的情景

我们将使用以下代码检查数值:

W = [np.array([[0]], dtype=np.float32),
     np.array([[0]], dtype=np.float32)]
weight_value = []
for epx in range(10):
    W = update_weights(x,y,W,0.01)
    weight_value.append(W[0][0][0])
import matplotlib.pyplot as plt
%matplotlib inline
plt.figure(figsize=(15,5))
plt.subplot(121)
epochs = np.arange(1,11)
plt.plot(epochs, weight_value)
plt.title('Weight value over increasing epochs \n when learning rate is 0.01')
plt.xlabel('Epochs')
plt.ylabel('Weight value')
plt.subplot(122)
plt.plot(epochs, loss_value)
plt.title('Loss value over increasing epochs \n when learning rate is 0.01')
plt.xlabel('Epochs')
plt.ylabel('Loss value') 

前述代码的输出如下:

包含线条、图表、图示、斜率的图片 自动生成的描述

图 1.20:当学习速率为 0.01 时,随着周期增加权重和损失值的变化

注意,当学习速率为 0.01 时,损失值下降缓慢,权重值向最优值更新速度也慢。现在让我们了解学习速率为 0.1 时的前述变化。

学习速率为 0.1

代码与学习速率为 0.01 的情景保持一致;然而,在这种情况下,学习速率参数将为 0.1。更改学习速率参数值后运行相同代码的输出如下:

包含线条、图表、图示、斜率的图片 自动生成的描述

图 1.21:当学习速率为 0.1 时,随着周期增加权重和损失值的变化

让我们对比学习速率为 0.01 和 0.1 的情景——两者之间的主要区别如下:

当学习速率为 0.01 时,与学习速率为 0.1 相比,权重更新速度较慢(在第一个周期中,学习速率为 0.01 时从 0 更新到 0.45,而学习速率为 0.1 时更新到 4.5)。更新速度较慢的原因是学习速率较低,因为权重是通过梯度乘以学习速率进行更新。

除了权重更新幅度,我们还应注意权重更新的方向。当权重值小于最优值时,梯度为负;当权重值大于最优值时,梯度为正。这种现象有助于权重值向正确方向更新。

最后,我们将以学习速率为 1 进行对比。

学习速率为 1

代码与学习率为 0.01 的情况下保持不变;但在这种情况下,学习率参数为 1。更改学习率参数后运行相同代码的输出如下:

包含线条、图形、图表、文本的图片 自动生成的描述

图 1.22:在学习率为 1 时,随着周期的增加,权重和损失值的变化

从上图中我们可以看到,权重已偏向一个非常高的值(例如在第一个周期结束时,权重值为 45,在后续周期中进一步偏向一个非常大的值)。此外,权重值已移动到一个非常大的数值,因此权重值的微小变化几乎不会导致梯度变化,从而使得权重困在这个高值上。

注意

一般来说,采用较低的学习率效果更好。这样一来,模型学习得更慢,但会朝着最优值调整权重。典型的学习率参数值范围在 0.0001 到 0.01 之间。

现在我们已经了解了神经网络的构建模块——前向传播、反向传播和学习率——在接下来的部分,我们将总结如何将这三者结合起来训练神经网络的高级概述。

总结神经网络的训练过程

训练神经网络是通过重复两个关键步骤——前向传播和反向传播——来找到神经网络架构的最优权重的过程,给定一个学习率。

在前向传播中,我们将一组权重应用于输入数据,通过定义的隐藏层传递,对隐藏层输出执行定义的非线性激活,然后通过将隐藏层节点值与另一组权重相乘连接到输出层,以估算输出值。最后,我们计算与给定权重集合对应的整体损失。对于第一次前向传播,权重值是随机初始化的。

在反向传播中,我们通过调整权重以减少总体损失的方向来减少损失值(误差)。此外,权重更新的幅度是梯度乘以学习率。

反向传播和前向传播的过程重复进行,直到尽可能地减少损失。这意味着,在训练结束时,神经网络已经调整了其权重 以预测我们希望其预测的输出。在上述示例中,训练后,更新的网络在输入 {1,1} 时将预测输出值为 0,因为它被训练成这样。

总结

在这一章中,我们了解到需要一个单一网络来同时执行特征提取和分类,然后学习了人工神经网络的架构和各个组件。接下来,我们学习了如何连接网络的各个层,并实现了前馈传播来计算网络当前权重对应的损失值。然后,我们实现了反向传播来优化权重以最小化损失值,并学习了学习率在实现网络的最优权重中的作用。此外,我们从头开始使用 NumPy 实现了网络的所有组件——前馈传播、激活函数、损失函数、链式法则和梯度下降来更新权重,以便在接下来的章节中建立坚实的基础。

现在我们了解了神经网络的工作原理,接下来将在下一章中使用 PyTorch 实现一个神经网络,并深入探讨可以在神经网络中调整的各种其他组件(超参数)。

问题

  1. 神经网络中的各种层有哪些?

  2. 前馈传播的输出是什么?

  3. 连续依赖变量的损失函数与二元依赖变量或分类依赖变量的损失函数有何不同?

  4. 什么是随机梯度下降?

  5. 反向传播练习的作用是什么?

  6. 在反向传播期间如何更新跨层所有权重?

  7. 在训练神经网络的每个 epoch 中使用了哪些函数?

  8. 为什么在 GPU 上训练网络比在 CPU 上训练更快?

  9. 在训练神经网络时学习率的影响是什么?

  10. 学习率参数的典型值是多少?

在 Discord 上了解更多信息

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第二章:PyTorch 基础知识

在上一章中,我们学习了神经网络的基本构建模块,并且在 Python 中从头开始实现了前向传播和反向传播。

在本章中,我们将深入探讨使用 PyTorch 构建神经网络的基础知识,在后续章节中,当我们学习图像分析的各种用例时,将多次利用这些知识。我们将首先学习 PyTorch 工作的核心数据类型——张量对象。然后,我们将深入探讨可以在张量对象上执行的各种操作,以及在构建神经网络模型时如何利用它们,这是在一个玩具数据集上(在我们逐步查看更现实的数据集之前,从下一章开始)加强我们的理解。这将使我们能够理解如何使用 PyTorch 构建神经网络模型以映射输入和输出值。最后,我们将学习如何实现自定义损失函数,以便根据我们解决的用例定制它们。

具体而言,本章将涵盖以下主题:

  • 安装 PyTorch

  • PyTorch 张量

  • 使用 PyTorch 构建神经网络

  • 使用顺序方法构建神经网络

  • 保存和加载 PyTorch 模型

本书的 GitHub 仓库的 Chapter02 文件夹中提供了本章中的所有代码的参考:bit.ly/mcvp-2e

安装 PyTorch

PyTorch 提供了多个功能,有助于构建神经网络——使用高级方法抽象各种组件,并且还提供了利用 GPU 加速训练神经网络的张量对象。

在安装 PyTorch 之前,我们首先需要确保已安装 Python。

接下来,我们将安装 PyTorch,这相当简单:

  1. 访问 pytorch.org/ 网站上的 QUICK START LOCALLY 部分,并选择您的操作系统 (Your OS),Conda 作为 PackagePython 作为 Language,以及 CPU 作为 Compute Platform。如果您有 CUDA 库,可以选择适当的版本:

计算机截图的描述

图 2.1:安装 PyTorch

这将提示您在终端中运行命令,例如 conda install pytorch torchvision torchaudio pytorch-cuda=12.1 -c pytorch -c nvidia

  1. 在命令提示符/终端中运行命令,让 Anaconda 安装 PyTorch 和必要的依赖项。

如果您拥有 NVIDIA 显卡作为硬件组件,强烈建议安装 CUDA 驱动程序,这将大幅加速深度学习训练。有关如何安装 CUDA 驱动程序的说明,请参阅本书的 GitHub 仓库中的 附录。安装完毕后,您可以选择 12.1 作为 CUDA 版本,并使用该命令安装 PyTorch。

  1. 您可以在命令提示符/终端中执行python,然后输入以下内容以验证 PyTorch 确实已安装:

    >>> import torch
    >>> print(torch.__version__) 
    

    本书中的所有代码都可以在 Google Colab 中执行:colab.research.google.com/。Python 和 PyTorch 在 Google Colab 中默认可用。我们强烈建议您在 Colab 中执行所有代码 – 这还包括免费访问 GPU!感谢 Google 提供了这样一个出色的资源!

因此,我们已经成功安装了 PyTorch。现在我们将在 Python 中执行一些基本的张量操作,帮助您掌握相关内容。

PyTorch 张量

张量是 PyTorch 的基本数据类型。张量是类似于 NumPy 的 ndarray 的多维矩阵:

  1. 标量可以表示为零维张量。

  2. 向量可以表示为一维张量。

  3. 二维矩阵可以表示为二维张量。

  4. 多维矩阵可以表示为多维张量。

在图表中,张量如下所示:

自动生成的图表描述图 2.2:张量表示

例如,我们可以将彩色图像视为像素值的三维张量,因为彩色图像由height x width x 3个像素组成 – 其中三个通道对应 RGB 通道。类似地,灰度图像可以被视为二维张量,因为它由height x width个像素组成。

通过本节末尾,我们将学习张量为何有用,如何初始化它们,以及如何在张量之上执行各种操作。这将为我们后面在本章中利用张量构建神经网络模型打下基础。

初始化张量

张量在多种情况下都很有用。除了将它们作为图像的基本数据结构使用外,它们还在连接神经网络不同层的权重时发挥了更显著的作用。在本节中,我们将练习初始化张量对象的不同方法:

本书的Chapter02文件夹中的Initializing_a_tensor.ipynb文件中可以找到以下代码:bit.ly/mcvp-2e

  1. 导入 PyTorch 并通过在列表上调用torch.tensor来初始化一个张量:

    import torch
    x = torch.tensor([[1,2]])
    y = torch.tensor([[1],[2]]) 
    
  2. 接下来,访问张量对象的形状和数据类型:

    print(x.shape)
    # torch.Size([1,2]) # one entity of two items
    print(y.shape)
    # torch.Size([2,1]) # two entities of one item each
    print(x.dtype)
    # torch.int64 
    

    张量中所有元素的数据类型都是相同的。这意味着如果一个张量包含不同数据类型的数据(例如布尔值、整数和浮点数),整个张量将被强制转换为最通用的数据类型:

    x = torch.tensor([False, 1, 2.0])
    print(x)
    # tensor([0., 1., 2.]) 
    

    正如您在前面代码的输出中看到的那样,布尔值False和整数1被转换为浮点数。

    或者,与 NumPy 类似,我们可以使用内置函数初始化张量对象。请注意,我们现在描绘的张量和神经网络权重之间的类比现在变得明显 - 我们正在初始化张量以表示神经网络的权重初始化。

  3. 生成一个由三行四列填充为 0 的张量对象:

    torch.zeros((3, 4)) 
    
  4. 生成一个由三行四列填充为 1 的张量对象:

    torch.ones((3, 4)) 
    
  5. 生成三行四列的数值,其取值范围在 0 到 10 之间(包括低值但不包括高值):

    torch.randint(low=0, high=10, size=(3,4)) 
    
  6. 生成三行四列的随机数,取值范围在 0 到 1 之间:

    torch.rand(3, 4) 
    
  7. 生成遵循正态分布的数值,有三行四列:

    torch.randn((3,4)) 
    
  8. 最后,我们可以直接使用torch.tensor(<numpy-array>)将 NumPy 数组转换为 Torch 张量:

    x = np.array([[10,20,30],[2,3,4]])
    y = torch.tensor(x)
    print(type(x), type(y))
    # <class 'numpy.ndarray'> <class 'torch.Tensor'> 
    

现在我们已经了解了如何初始化张量对象,接下来我们将学习如何在其上执行各种矩阵操作。

张量的操作

与 NumPy 类似,您可以对张量对象执行各种基本操作。神经网络操作的类比包括将输入与权重进行矩阵乘法、加上偏差项以及在需要时重新塑造输入或权重值。每个操作以及其他操作如下执行:

可以在本书的 GitHub 代码库的Chapter02文件夹中的Operations_on_tensors.ipynb文件中找到以下代码:bit.ly/mcvp-2e

  • x中所有元素乘以10可以通过以下代码执行:

    import torch
    x = torch.tensor([[1,2,3,4], [5,6,7,8]]) 
    print(x * 10)
    # tensor([[10, 20, 30, 40],
    #        [50, 60, 70, 80]]) 
    
  • x中的元素加10并将结果张量存储在y中的操作可以通过以下代码实现:

    x = torch.tensor([[1,2,3,4], [5,6,7,8]]) 
    y = x.add(10)
    print(y)
    # tensor([[11, 12, 13, 14],
    #         [15, 16, 17, 18]]) 
    
  • 可以通过以下代码执行张量的重新塑形:

    y = torch.tensor([2, 3, 1, 0]) 
    # y.shape == (4)
    y = y.view(4,1)                
    # y.shape == (4, 1) 
    
  • 另一种重新塑形张量的方法是使用squeeze方法,我们提供要移除的轴索引。请注意,仅当要移除的轴在该维度中只有一个项目时才适用:

    x = torch.randn(10,1,10)
    z1 = torch.squeeze(x, 1) # similar to np.squeeze()
    # The same operation can be directly performed on
    # x by calling squeeze and the dimension to squeeze out
    z2 = x.squeeze(1)
    assert torch.all(z1 == z2) 
    # all the elements in both tensors are equal
    print('Squeeze:\n', x.shape, z1.shape)
    # Squeeze: torch.Size([10, 1, 10]) torch.Size([10, 10]) 
    
  • squeeze的相反操作是unsqueeze,意味着我们向矩阵中添加一个维度,可以通过以下代码执行:

    x = torch.randn(10,10)
    print(x.shape)
    # torch.size(10,10)
    z1 = x.unsqueeze(0)
    print(z1.shape)
    # torch.size(1,10,10)
    # The same can be achieved using [None] indexing
    # Adding None will auto create a fake dim 
    # at the specified axis
    x = torch.randn(10,10)
    z2, z3, z4 = x[None], x[:,None], x[:,:,None]
    print(z2.shape, z3.shape, z4.shape)
    # torch.Size([1, 10, 10]) 
    # torch.Size([10, 1, 10]) 
    # torch.Size([10, 10, 1]) 
    

在索引中使用None是一种添加维度的巧妙方式,如所示,将经常在本书中用于创建假通道/批量维度。

  • 可以通过以下代码执行两个不同张量的矩阵乘法:

    x = torch.tensor([[1,2,3,4], [5,6,7,8]])
    print(torch.matmul(x, y))
    # tensor([[11],
    #         [35]]) 
    
  • 或者,也可以使用@运算符执行矩阵乘法:

    print(x@y)
    # tensor([[11],
    #  [35]]) 
    
  • 与 NumPy 中的concatenate类似,我们可以使用cat方法对张量进行连接:

    import torch
    x = torch.randn(10,10,10)
    z = torch.cat([x,x], axis=0) # np.concatenate()
    print('Cat axis 0:', x.shape, z.shape)
    # Cat axis 0:  torch.Size([10, 10, 10]) 
    # torch.Size([20, 10, 10])
    z = torch.cat([x,x], axis=1) # np.concatenate()
    print('Cat axis 1:', x.shape, z.shape)
    # Cat axis 1: torch.Size([10, 10, 10]) 
    # torch.Size([10, 20, 10]) 
    
  • 通过以下代码可以提取张量中的最大值:

    x = torch.arange(25).reshape(5,5)
    print('Max:', x.shape, x.max()) 
    # Max:  torch.Size([5, 5]) tensor(24) 
    
  • 我们可以提取最大值以及最大值所在的行索引:

    x.max(dim=0)
    # torch.return_types.max(values=tensor([20, 21, 22, 23, 24]), 
    # indices=tensor([4, 4, 4, 4, 4])) 
    

    请注意,在上述输出中,我们获取维度0(即张量的行)上的最大值。因此,所有行的最大值是第 4 个索引处的值,因此indices输出也全为四。此外,.max返回最大值及其位置(argmax)。

    类似地,当获取跨列的最大值时,输出如下:

    m, argm = x.max(dim=1) 
    print('Max in axis 1:\n', m, argm) 
    # Max in axis 1: tensor([ 4, 9, 14, 19, 24]) 
    # tensor([4, 4, 4, 4, 4]) 
    

    min操作与max完全相同,但在适用时返回最小值和最小位置。

  • 重新排列张量对象的维度:

    x = torch.randn(10,20,30)
    z = x.permute(2,0,1) # np.permute()
    print('Permute dimensions:', x.shape, z.shape)
    # Permute dimensions:  torch.Size([10, 20, 30]) 
    # torch.Size([30, 10, 20]) 
    

请注意,当我们在原始张量上执行置换时,张量的形状会发生变化。

永远不要重新形状(即在tensor.view上使用),以交换维度。即使 Torch 不会报错,这也是错误的,并且会在训练期间产生意想不到的结果。如果需要交换维度,请始终使用 permute。

由于在本书中涵盖所有可用操作很困难,重要的是要知道您几乎可以用与 NumPy 几乎相同的语法在 PyTorch 中执行几乎所有 NumPy 操作。标准的数学运算,如absaddargsortceilfloorsincostancumsumcumproddiageigexploglog2log10meanmedianmoderesizeroundsigmoidsoftmaxsquaresqrtsvdtranspose等等,可以直接在具有或不具有适用轴的任何张量上调用。您可以随时运行dir(torch.Tensor)来查看 Torch 张量的所有可能方法,并运行help(torch.Tensor.<method>)以查看该方法的官方帮助和文档。

接下来,我们将学习如何利用张量在数据上执行梯度计算,这是神经网络中反向传播的关键方面。

张量对象的自动梯度

正如我们在前一章中看到的,微分和计算梯度在更新神经网络的权重中起着至关重要的作用。 PyTorch 的张量对象具有内置功能来计算梯度。

您可以在本书 GitHub 存储库的Chapter02文件夹中的Auto_gradient_of_tensors.ipynb文件中找到以下代码:bit.ly/mcvp-2e

在本节中,我们将了解如何使用 PyTorch 计算张量对象的梯度:

  1. 定义一个张量对象,并指定需要计算梯度:

    import torch
    x = torch.tensor([[2., -1.], [1., 1.]], requires_grad=True)
    print(x) 
    

在上述代码中,requires_grad参数指定要为张量对象计算梯度。

  1. 接下来,定义计算输出的方式,这在本例中是所有输入平方和的总和:

    这在代码中用以下行表示:

    out = x.pow(2).sum() 
    

    我们知道前述函数的梯度是2x*。让我们使用 PyTorch 提供的内置函数来验证这一点。

  2. 可通过调用 backward() 方法计算变量的梯度。在我们的情况下,我们计算梯度 - 输出 out 对输入 x 的微小变化的影响 - 如下所示:

    out.backward() 
    
  3. 现在,我们可以获取相对于 xout 的梯度如下:

    x.grad 
    

    这将产生以下输出:

    # tensor([[4., -2.],
    #         [2., 2.]]) 
    

注意,先前获得的梯度与直观梯度值(即 x 的两倍)匹配。

作为练习,在 第一章Chain rule.ipynb 中尝试重新创建此场景,并使用 PyTorch 计算梯度。在进行前向传播后进行单次更新后,验证更新的权重是否与我们在笔记本中计算的相匹配。

到目前为止,我们已经学习了在张量对象的顶部初始化、操作和计算梯度,这些构成神经网络的基本构建模块。除了计算自动梯度外,还可以使用 NumPy 数组进行初始化和操作数据。这要求我们理解为什么在构建神经网络时应使用张量对象而不是 NumPy 数组的原因,我们将在下一节中详细介绍。

PyTorch 张量相对于 NumPy 的 ndarray 的优势

在前一章中,我们看到在计算最优权重值时,我们通过微小变化每个权重,并理解其对减少总体损失值的影响。请注意,基于一个权重更新的损失计算不会影响同一迭代中其他权重更新的损失计算。因此,如果每个权重更新由不同的核并行进行而不是顺序更新权重,则可以优化此过程。在这种情况下,GPU 非常有用,因为它在核心数量上比 CPU 多得多(一般情况下,CPU 可能有 <=64 核)。

与 NumPy 相比,Torch 张量对象被优化以在 GPU 上工作。为了进一步理解这一点,让我们进行一个小实验,在这个实验中,我们在一个场景中使用 NumPy 数组进行矩阵乘法操作,而在另一个场景中使用张量对象,并比较执行矩阵乘法所需的时间:

可在本书 GitHub 存储库的 Chapter02 文件夹中的 Numpy_Vs_Torch_object_computation_speed_comparison.ipynb 文件中找到以下代码:bit.ly/mcvp-2e

  1. 生成两个不同的 torch 对象:

    import torch
    x = torch.rand(1, 6400)
    y = torch.rand(6400, 5000) 
    
  2. 定义我们将在 步骤 1 中创建的张量对象存储到的设备:

    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    

请注意,如果您没有 GPU 设备,设备将是 cpu(此外,当使用 CPU 时,您不会注意到执行所需的时间差异很大)。

  1. 注册在 步骤 1 中创建的张量对象到设备上(注册张量对象意味着将信息存储在设备中):

    x, y = x.to(device), y.to(device) 
    
  2. 在 Torch 对象上执行矩阵乘法,并计时以便我们可以将其与在 NumPy 数组上执行矩阵乘法的速度进行比较:

    %timeit z=(x@y)
    # It takes 0.515 milli seconds on an average to 
    # perform matrix multiplication 
    
  3. cpu上执行相同张量的矩阵乘法:

    x, y = x.cpu(), y.cpu()
    %timeit z=(x@y)
    # It takes 9 milli seconds on an average to 
    # perform matrix multiplication 
    
  4. 执行相同的矩阵乘法,这次在 NumPy 数组上进行:

    import numpy as np
    x = np.random.random((1, 6400))
    y = np.random.random((6400, 5000))
    %timeit z = np.matmul(x,y)
    # It takes 19 milli seconds on an average to 
    # perform matrix multiplication 
    

您将注意到,在 GPU 上执行的 Torch 对象的矩阵乘法比在 CPU 上快约 18 倍,并且比在 NumPy 数组上执行的矩阵乘法快约 40 倍。通常情况下,使用 Torch 张量在 CPU 上进行的matmul仍然比 NumPy 快。请注意,只有当您拥有 GPU 设备时,才能注意到这种速度增加。如果您在 CPU 设备上工作,您将不会注意到这种显著的速度增加。这就是为什么如果您没有 GPU,我们建议使用 Google Colab 笔记本,因为该服务提供免费的 GPU。

现在我们已经学会了如何在神经网络的各个组件/操作中利用张量对象,并且了解了如何使用 GPU 加速计算,接下来我们将学习将所有这些放在一起使用 PyTorch 构建神经网络。

使用 PyTorch 构建神经网络

在前一章中,我们学习了如何从头开始构建神经网络,其中神经网络的组件如下:

  • 隐藏层的数量

  • 隐藏层中的单元数

  • 各层执行的激活函数

  • 我们试图优化的损失函数

  • 与神经网络相关联的学习率

  • 用于构建神经网络的数据批量大小

  • 正向传播和反向传播的时期数量

然而,所有这些都是使用 Python 中的 NumPy 数组从头构建的。在本节中,我们将学习如何在玩具数据集上使用 PyTorch 来实现所有这些。请注意,我们将利用迄今为止关于初始化张量对象、在其上执行各种操作以及计算梯度值以在构建神经网络时更新权重的学习。

为了学会直观地执行各种操作,我们将在本章节的玩具数据集上构建一个神经网络。但从下一章开始,我们将处理更现实的问题和数据集。

我们将解决一个简单的添加两个数字的玩具问题,以便理解使用 PyTorch 实现神经网络的方法,其中我们初始化数据集如下:

在本书的 GitHub 存储库的Chapter02文件夹中的Building_a_neural_network_using_PyTorch_on_a_toy_dataset.ipynb文件中可以找到以下代码,网址为bit.ly/mcvp-2e

  1. 定义输入(x)和输出(y)的值:

    import torch
    x = [[1,2],[3,4],[5,6],[7,8]]
    y = [[3],[7],[11],[15]] 
    

请注意,在前面的输入和输出变量初始化中,输入和输出都是一个列表的列表,其中输入列表中的值的和是输出列表中的值。

  1. 将输入列表转换为张量对象:

    X = torch.tensor(x).float()
    Y = torch.tensor(y).float() 
    

    正如您所见,我们已将张量对象转换为浮点对象。将张量对象作为浮点数或长整数是一个好习惯,因为它们最终将乘以小数值(权重)。

    此外,我们将输入(X)和输出(Y)数据点注册到设备 - 如果您有 GPU,则为cuda,如果您没有 GPU,则为cpu

    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    X = X.to(device)
    Y = Y.to(device) 
    
  2. 定义神经网络架构:

    1. torch.nn模块包含有助于构建神经网络模型的函数:
    import torch.nn as nn 
    
    1. 我们将创建一个类(MyNeuralNet),该类可以组合我们的神经网络架构。在创建模型架构时,强制性继承nn.Module作为所有神经网络模块的基类:
    class MyNeuralNet(nn.Module): 
    
    1. 在类内部,我们使用__init__方法初始化了神经网络的所有组件。我们应该调用super().__init__()以确保该类继承自nn.Module
     def __init__(self):
            super().__init__() 
    
    1. 在上述代码中,通过指定super().__init__(),我们现在能够利用为nn.Module编写的所有预构建功能。将在init方法中初始化的组件将在MyNeuralNet类的不同方法中使用。

    2. 定义神经网络中的层:

     self.input_to_hidden_layer = nn.Linear(2,8)
            self.hidden_layer_activation = nn.ReLU()
            self.hidden_to_output_layer = nn.Linear(8,1) 
    
    1. 在前面的代码行中,我们指定了神经网络的所有层 - 一个线性层(self.input_to_hidden_layer),然后是 ReLU 激活(self.hidden_layer_activation),最后是线性层(self.hidden_to_output_layer)。目前,层数和激活的选择是任意的。在下一章中,我们将更详细地学习层中单位数量和层激活的影响。

    2. 接下来,让我们通过打印nn.Linear方法的输出来理解上述代码中的函数在做什么:

    **# NOTE - This line of code is not a part of model building,** 
    **# this is used only for illustration of Linear method**
    print(nn.Linear(2, 7))
    Linear(in_features=2, out_features=7, bias=True) 
    
    1. 在前面的代码中,线性方法接受两个值作为输入并输出七个值,并且还有一个与之相关的偏差参数。此外,nn.ReLU()调用了 ReLU 激活函数,然后可以在其他方法中使用。

    2. 以下是一些其他常用的激活函数:

    • Sigmoid

    • Softmax

    • Tanh

    1. 现在我们已经定义了神经网络的组件,让我们在定义网络的前向传播时将这些组件连接在一起:
     def forward(self, x):
            x = self.input_to_hidden_layer(x)
            x = self.hidden_layer_activation(x)
            x = self.hidden_to_output_layer(x)
            return x 
    

使用forward作为函数名称是强制性的,因为 PyTorch 已将此函数保留为执行前向传播的方法。使用其他任何名称将引发错误。

  1. 到目前为止,我们已经建立了模型架构;让我们在下一步中检查随机初始化的权重值。

  2. 您可以通过执行以下步骤访问每个组件的初始权重:

    1. 创建MyNeuralNet类对象的实例,并将其注册到device
    mynet = MyNeuralNet().to(device) 
    
    1. 可以通过指定以下内容访问每层的权重和偏差:
    # NOTE - This line of code is not a part of model building, 
    # this is used only for illustration of 
    # how to obtain parameters of a given layer
    mynet.input_to_hidden_layer.weight 
    
    1. 上述代码的输出如下所示:

      文本描述自动生成图 2.3:输入层与隐藏层之间连接的权重值

由于神经网络每次以随机值初始化,因此输出的值将与前值不同。如果希望在多次迭代执行代码时保持相同值,需要在创建类对象实例之前使用 Torch 的manual_seed方法指定种子,如torch.manual_seed(0)

  1. 所有神经网络的参数可以通过以下代码获取:

  2. # NOTE - This line of code is not a part of model building, 
    # this is used only for illustration of 
    # how to obtain parameters of all layers in a model
    mynet.parameters() 
    
  3. 上述代码返回一个生成器对象。

  4. 最后,通过以下方式循环生成器以获取参数:

  5. # NOTE - This line of code is not a part of model building, 
    # this is used only for illustration of how to 
    # obtain parameters of all layers in a model 
    # by looping through the generator object
    for par in mynet.parameters():
        print(par) 
    
  6. 上述代码将导致以下输出:

图 2.4:权重和偏置值

当在__init__方法中定义任何nn层时,模型已将这些张量注册为特殊对象,必要时用于跟踪前向和反向传播。它将自动创建相应的张量并同时注册它们。您还可以使用nn.Parameter(<tensor>)函数手动注册这些参数。因此,以下代码等效于我们之前定义的神经网络类。

  1. 使用nn.Parameter函数定义模型的另一种方式如下:

  2. # for illustration only
    class MyNeuralNet(nn.Module):
         def __init__(self):
            super().__init__()
            self.input_to_hidden_layer = nn.Parameter(torch.rand(2,8))
            self.hidden_layer_activation = nn.ReLU()
            self.hidden_to_output_layer = nn.Parameter(torch.rand(8,1))
         def forward(self, x):
            x = x @ self.input_to_hidden_layer
            x = self.hidden_layer_activation(x)
            x = x @ self.hidden_to_output_layer
            return x 
    
  3. 定义我们优化的损失函数。考虑到我们在预测连续输出,我们将优化均方误差:

    loss_func = nn.MSELoss() 
    

其他显著的损失函数如下:

  • CrossEntropyLoss(用于多项分类)

  • BCELoss(用于二元分类的二元交叉熵损失)

  • 通过将输入值传递给neuralnet对象,然后计算给定输入的MSELoss,可以计算神经网络的损失值:

  1. _Y = mynet(X)
    loss_value = loss_func(_Y,Y)
    print(loss_value)
    # tensor(91.5550, grad_fn=<MseLossBackward>)
    # Note that loss value can differ in your instance 
    # due to a different random weight initialization 
    

在上述代码中,mynet(X)计算输入通过神经网络时的输出值。此外,loss_func函数计算神经网络预测(_Y)和实际值(Y)之间的MSELoss值。

作为惯例,在本书中,我们将使用_<variable>来关联与真实值<variable>对应的预测。在此<variable>之上是Y

还要注意,在计算损失时,我们始终先发送预测值,然后是真实值。这是 PyTorch 的惯例。

现在我们已定义了损失函数,接下来我们将定义优化器,以尝试减少损失值。优化器的输入将是与神经网络相对应的参数(权重和偏置)以及在更新权重时的学习率。

对于这个示例,我们将考虑随机梯度下降(在下一章中将会更多地讨论不同的优化器和学习率的影响)。

  1. torch.optim 模块中导入 SGD 方法,然后将神经网络对象(mynet)和学习率(lr)作为参数传递给 SGD 方法:

    from torch.optim import SGD
    opt = SGD(mynet.parameters(), lr = 0.001) 
    
  2. 将一个 epoch 中要完成的所有步骤一起执行:

    1. 计算给定输入和输出对应的损失值。

    2. 计算每个参数对应的梯度。

    3. 根据学习率和每个参数的梯度更新参数值。

    4. 更新权重后,请确保在计算下一个 epoch 中的梯度之前刷新在上一步中计算的梯度:

    **# NOTE - This line of code is not a part of model building,** 
    **# this is used only for illustration of how we perform** 
    opt.zero_grad() # flush the previous epoch's gradients
    loss_value = loss_func(mynet(X),Y) # compute loss
    loss_value.backward() # perform backpropagation
    opt.step() # update the weights according to the #gradients computed 
    
    1. 使用 for 循环重复前述步骤的次数等于 epoch 的数量。在以下示例中,我们执行了总共 50 个 epoch 的权重更新过程。此外,我们将每个 epoch 中的损失值存储在列表 loss_history 中:
    loss_history = []
    for _ in range(50):
        opt.zero_grad()
        loss_value = loss_func(mynet(X),Y)
        loss_value.backward()
        opt.step()
        loss_history.append(loss_value.item()) 
    
    1. 让我们绘制损失随着 epoch 增加的变化(正如我们在前一章中看到的,我们以使整体损失值随 epoch 增加而减少的方式更新权重):
    import matplotlib.pyplot as plt
    %matplotlib inline
    plt.plot(loss_history)
    plt.title('Loss variation over increasing epochs')
    plt.xlabel('epochs')
    plt.ylabel('loss value') 
    

    上述代码导致以下图表:

图表,直方图 自动生成的描述图 2.5:随着 epoch 增加的损失变化

请注意,正如预期的那样,损失值随着 epoch 的增加而减少。

到目前为止,在本节中,我们通过基于输入数据集中提供的所有数据点计算损失来更新神经网络的权重。在接下来的部分,我们将了解每次权重更新时仅使用部分输入数据点的优势。

数据集、数据加载器和批大小

神经网络中一个尚未考虑的超参数是批大小(batch size)。批大小指的是用于计算损失值或更新权重的数据点数量。

这个特别的超参数在有数百万数据点的情况下特别有用,在这种情况下,使用所有数据点来更新权重的一个实例并不是最佳选择,因为内存无法容纳如此多的信息。此外,样本可能足够代表数据。批大小有助于确保我们获取足够代表性的多个数据样本,但不一定是总数据的 100%代表性。

在本节中,我们将提出一种方法来指定在计算权重梯度和更新权重时要考虑的批大小,这些权重进而用于计算更新后的损失值:

您可以在本书 GitHub 存储库的 Chapter02 文件夹中的 Specifying_batch_size_while_training_a_model.ipynb 文件中找到以下代码:bit.ly/mcvp-2e

  1. 导入帮助加载数据和处理数据集的方法:

    from torch.utils.data import Dataset, DataLoader
    import torch
    import torch.nn as nn 
    
  2. 导入数据,将其转换为浮点数,并将其注册到设备上:

    1. 提供要处理的数据点:
    x = [[1,2],[3,4],[5,6],[7,8]]
    y = [[3],[7],[11],[15]] 
    
    1. 将数据转换为浮点数:
    X = torch.tensor(x).float()
    Y = torch.tensor(y).float() 
    
    1. 将数据注册到设备上 - 鉴于我们使用的是 GPU,我们指定设备为'cuda'。如果您使用 CPU,请将设备指定为'cpu'
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    X = X.to(device)
    Y = Y.to(device) 
    
  3. 实例化数据集的类 - MyDataset

    class MyDataset(Dataset): 
    

MyDataset类内部,我们存储信息以逐个获取数据点,以便将一批数据点捆绑在一起(使用DataLoader),然后通过一次前向传播和一次反向传播来更新权重:

  1. 定义一个__init__方法,该方法接受输入和输出对,并将它们转换为 Torch 浮点对象:

     def __init__(self,x,y):
            self.x = torch.tensor(x).float()
            self.y = torch.tensor(y).float() 
    
  2. 指定输入数据集的长度(__len__),以便类知道输入数据集中存在的数据点数量:

     def __len__(self):
            return len(self.x) 
    
  3. 最后,使用__getitem__方法获取特定的行:

     def __getitem__(self, ix):
            return self.x[ix], self.y[ix] 
    

在前面的代码中,ix指的是要从数据集中获取的行的索引,该索引将是 0 到数据集长度之间的整数。

  1. 创建已定义类的实例:

    ds = MyDataset(X, Y) 
    
  2. 将之前定义的数据集实例通过DataLoader传递,以获取原始输入和输出张量对象中的batch_size数量的数据点:

    dl = DataLoader(ds, batch_size=2, shuffle=True) 
    

    此外,在前面的代码中,我们还指定从原始输入数据集(ds)中获取两个数据点的随机样本(通过设置shuffle=True)以及指定批次大小为2

    要从dl中获取批次数据,我们需要遍历它:

    # NOTE - This line of code is not a part of model building, 
    # this is used only for illustration of 
    # how to print the input and output batches of data
    for x,y in dl:
        print(x,y) 
    

    这将导致以下输出:

    tensor([[1., 2.],
            [3., 4.]]) tensor([[3.], [7.]])
    tensor([[5., 6.],
            [7., 8.]]) tensor([[1.], [15.]]) 
    

    在前面的代码中,由于原始数据集中有四个数据点,而指定的批次大小为2,因此导致了两组输入输出对。

  3. 现在,我们定义在前一节中定义的神经网络类:

    class MyNeuralNet(nn.Module):
        def __init__(self):
            super().__init__()
            self.input_to_hidden_layer = nn.Linear(2,8)
            self.hidden_layer_activation = nn.ReLU()
            self.hidden_to_output_layer = nn.Linear(8,1)
        def forward(self, x):
            x = self.input_to_hidden_layer(x)
            x = self.hidden_layer_activation(x)
            x = self.hidden_to_output_layer(x)
            return x 
    
  4. 接下来,我们定义模型对象(mynet),损失函数(loss_func)和优化器(opt),如前一节所定义的:

    mynet = MyNeuralNet().to(device)
    loss_func = nn.MSELoss()
    from torch.optim import SGD
    opt = SGD(mynet.parameters(), lr = 0.001) 
    
  5. 最后,遍历数据点批次以最小化损失值,就像我们在前一节中的步骤 6中所做的一样:

    import time
    loss_history = []
    start = time.time()
    for _ in range(50):
        for data in dl:
            x, y = data
            opt.zero_grad()
            loss_value = loss_func(mynet(x),y)
            loss_value.backward()
            opt.step()
            loss_history.append(loss_value.item())
    end = time.time()
    print(end - start) 
    

尽管前面的代码看起来与我们在前一节中讨论过的代码非常相似,但是在每个 epoch 中,我们的权重更新次数是前一节中权重更新次数的两倍。本节中的批次大小为2,而前一节中的批次大小为4(总数据点数)。

对新数据点进行预测:

在前一节中,我们学习了如何在已知数据点上拟合模型。在本节中,我们将学习如何利用前一节中训练的mynet模型中定义的前向方法来预测未见数据点。我们将继续使用前一节中构建的代码:

  1. 创建我们想要测试模型的数据点:

    val_x = [[10,11]] 
    

注意,新数据集(val_x)也将是一个列表的列表,因为输入数据集是一个列表的列表。

  1. 将新数据点转换为浮点张量对象并注册到设备上:

    val_x = torch.tensor(val_x).float().to(device) 
    
  2. 将张量对象通过已训练的神经网络mynet传递,就像它是一个 Python 函数一样。这与通过建立的模型进行前向传播是相同的:

    mynet(val_x)
    # 20.99 
    

上述代码返回与输入数据点相关联的预测输出值。

到目前为止,我们已经能够训练我们的神经网络将输入映射到输出,通过执行反向传播来更新权重值以最小化损失值(使用预定义的损失函数计算)。

在接下来的部分,我们将学习如何构建我们自己的自定义损失函数,而不是使用预定义的损失函数。

实现自定义损失函数

在某些情况下,我们可能需要实现一个自定义损失函数,该函数根据我们解决的问题进行自定义,特别是涉及目标检测/生成对抗网络(GANs)的复杂用例。PyTorch 为我们提供了编写自定义函数来构建自定义损失函数的功能。

在本节中,我们将实现一个自定义损失函数,其功能与nn.Module中预先构建的MSELoss函数相同:

以下代码可以在本书的 GitHub 存储库的Chapter02文件夹中的Implementing_custom_loss_function.ipynb文件中找到,网址为bit.ly/mcvp-2e

  1. 导入数据,构建数据集和DataLoader,并定义一个神经网络,与上一节相同:

    x = [[1,2],[3,4],[5,6],[7,8]]
    y = [[3],[7],[11],[15]]
    import torch
    X = torch.tensor(x).float()
    Y = torch.tensor(y).float()
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    X = X.to(device)
    Y = Y.to(device) 
    import torch.nn as nn
    from torch.utils.data import Dataset, DataLoader
    class MyDataset(Dataset):
        def __init__(self,x,y):
            self.x = torch.tensor(x).float()
            self.y = torch.tensor(y).float()
        def __len__(self):
            return len(self.x)
        def __getitem__(self, ix):
            return self.x[ix], self.y[ix]
    ds = MyDataset(X, Y)
    dl = DataLoader(ds, batch_size=2, shuffle=True)
    class MyNeuralNet(nn.Module):
        def __init__(self):
            super().__init__()
            self.input_to_hidden_layer = nn.Linear(2,8)
            self.hidden_layer_activation = nn.ReLU()
            self.hidden_to_output_layer = nn.Linear(8,1)
        def forward(self, x):
            x = self.input_to_hidden_layer(x)
            x = self.hidden_layer_activation(x)
            x = self.hidden_to_output_layer(x)
            return x
    mynet = MyNeuralNet().to(device) 
    
  2. 通过取两个张量对象作为输入,获取它们的差异,对它们进行平方处理,然后返回两者之间平方差的均值来定义自定义损失函数:

    def my_mean_squared_error(_y, y):
        loss = (_y-y)**2
        loss = loss.mean()
        return loss 
    
  3. 对于在上一节中相同的输入和输出组合,使用nn.MSELoss来获取均方误差损失,如下所示:

    loss_func = nn.MSELoss()
    loss_value = loss_func(mynet(X),Y)
    print(loss_value)
    # 92.7534 
    
  4. 类似地,当我们在步骤 2中定义的函数中使用时,损失值的输出如下:

    my_mean_squared_error(mynet(X),Y)
    # 92.7534 
    

注意结果匹配。我们使用了内置的MSELoss函数,并将其结果与我们构建的自定义函数进行了比较。我们可以根据我们解决的问题定义我们选择的自定义函数。

到目前为止,我们已经学会了计算最后一层的输出。迄今为止,中间层的值一直是一个黑盒子。在接下来的部分,我们将学习如何获取神经网络的中间层值。

获取中间层的值

在某些情况下,获取神经网络的中间层值非常有帮助(在我们讨论风格转移和迁移学习用例时将会详细介绍第四章第五章)。

PyTorch 提供了两种方式来获取神经网络的中间值:

以下代码可以在本书的 GitHub 存储库的Chapter02文件夹中的Fetching_values_of_intermediate_layers.ipynb文件中找到,网址为bit.ly/mcvp-2e

  1. 一种方法是直接调用层,就像它们是函数一样。可以按如下方式完成:

    input_to_hidden = mynet.input_to_hidden_layer(X)
    hidden_activation = mynet.hidden_layer_activation(input_to_hidden)
    print(hidden_activation) 
    

    请注意,在调用hidden_layer_activation层之前,我们必须先调用input_to_hidden_layer激活,因为input_to_hidden_layer的输出是hidden_layer_activation层的输入。

  2. 另一种方法是通过指定我们在forward方法中想要查看的层来实现。

    让我们查看本章中我们一直在使用的模型的激活后的隐藏层值。

    虽然以下代码与我们在上一节中看到的代码相同,但我们确保forward方法不仅返回输出,还返回激活后的隐藏层值(hidden2):

    class NeuralNet(nn.Module):
        def __init__(self):
            super().__init__()
            self.input_to_hidden_layer = nn.Linear(2,8)
            self.hidden_layer_activation = nn.ReLU()
            self.hidden_to_output_layer = nn.Linear(8,1)
        def forward(self, x):
            hidden1 = self.input_to_hidden_layer(x)
            hidden2 = self.hidden_layer_activation(hidden1)
            output = self.hidden_to_output_layer(hidden2)
            return output, hidden2 
    

    现在,我们可以通过指定以下内容来访问隐藏层值:

    mynet = NeuralNet().to(device)
    mynet(X)[1] 
    

请注意,mynet的第 0 个索引输出是我们定义的网络前向传播的最终输出,而第一个索引输出是激活后的隐藏层值。

使用序列方法构建神经网络

到目前为止,我们已经学习了如何使用神经网络类来实现神经网络,其中我们手动构建了每一层。然而,除非我们构建的是一个复杂的网络,否则构建神经网络架构的步骤是直接的,我们只需指定层和层堆叠的顺序。让我们继续学习一种简化定义神经网络架构的方法,使用Sequential类。

我们将执行与前几节相同的步骤,不同之处在于用于手动定义神经网络架构的类将替换为Sequential类,以创建神经网络架构。让我们为在本章中已经处理过的相同玩具数据编写网络代码:

以下代码在本书 GitHub 存储库的Chapter02文件夹中的Sequential_method_to_build_a_neural_network.ipynb中提供:bit.ly/mcvp-2e

  1. 定义玩具数据集:

    x = [[1,2],[3,4],[5,6],[7,8]]
    y = [[3],[7],[11],[15]] 
    
  2. 导入相关包并定义我们将在其上工作的设备:

    import torch
    import torch.nn as nn
    import numpy as np
    from torch.utils.data import Dataset, DataLoader
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  3. 现在,我们定义数据集类(MyDataset):

    class MyDataset(Dataset):
        def __init__(self, x, y):
            self.x = torch.tensor(x).float().to(device)
            self.y = torch.tensor(y).float().to(device)
        def __getitem__(self, ix):
            return self.x[ix], self.y[ix]
        def __len__(self): 
            return len(self.x) 
    
  4. 定义数据集(ds)和数据加载器(dl)对象:

    ds = MyDataset(x, y)
    dl = DataLoader(ds, batch_size=2, shuffle=True) 
    
  5. 使用nn包中可用的Sequential方法定义模型架构:

    model = nn.Sequential(
                nn.Linear(2, 8),
                nn.ReLU(),
                nn.Linear(8, 1)
            ).to(device) 
    

请注意,在前述代码中,我们定义了与前几节中相同的网络架构,但我们的定义方式有所不同。nn.Linear接受二维输入,并为每个数据点输出八维输出。此外,nn.ReLU在八维输出上执行 ReLU 激活,最后,八维输入通过最终的nn.Linear层得到一维输出(在我们的情况下,这是两个输入相加的输出)。

  1. 打印我们在步骤 5中定义的模型摘要:

    1. 安装并导入使我们能够打印模型摘要的包:
    !pip install torch_summary
    from torchsummary import summary 
    
    1. 打印模型的摘要,该摘要期望模型的名称和模型的输入大小:
    summary(model, torch.zeros(1,2)) 
    

    上述代码给出了以下输出:

    表自动生成的描述图 2.6:模型架构总结

第一层的输出形状是(-1, 8),其中 -1 表示可以有与批处理大小一样多的数据点,8 表示每个数据点我们有一个八维输出,从而导致形状为(批处理大小 x 8)的输出。对于接下来的两层,解释是类似的。

  1. 接下来,我们定义损失函数(loss_func)和优化器(opt),并训练模型,就像我们在上一节中所做的那样。在这种情况下,我们不需要定义一个模型对象;在这种情况下,网络没有在类内定义:

    loss_func = nn.MSELoss()
    from torch.optim import SGD
    opt = SGD(model.parameters(), lr = 0.001)
    import time
    loss_history = []
    start = time.time()
    for _ in range(50):
        for ix, iy in dl:
            opt.zero_grad()
            loss_value = loss_func(model(ix),iy)
            loss_value.backward()
            opt.step()
            loss_history.append(loss_value.item())
    end = time.time()
    print(end - start) 
    
  2. 现在我们已经训练好了模型,我们可以在我们现在定义的验证数据集上预测值:

    1. 定义验证数据集:
    val = [[8,9],[10,11],[1.5,2.5]] 
    
    1. 预测通过模型传递验证列表的输出(请注意,预期值是列表中每个列表的两个输入的总和)。根据数据集类的定义,我们首先将列表的列表转换为浮点数,然后将其转换为张量对象并注册到设备:
    model(torch.tensor(val).float().to(device))
    # tensor([[16.9051], [20.8352], [ 4.0773]], 
    # device='cuda:0', grad_fn=<AddmmBackward>) 
    

上述代码的输出(如上面的注释所述)接近预期的输出(即输入值的总和)。

现在我们已经学会了利用顺序方法来定义和训练模型,让我们学习一下如何保存和加载模型以进行推理。

保存和加载 PyTorch 模型

在处理神经网络模型时的一个重要方面是在训练后保存并重新加载模型。想象一下,您必须从已经训练好的模型进行推理的场景。您将加载训练好的模型,而不是再次训练它。

下面的代码可以在本书 GitHub 仓库的 Chapter02 文件夹中的 save_and_load_pytorch_model.ipynb 文件中找到,链接为 bit.ly/mcvp-2e

在讨论相关命令来完成这一点之前,以我们的案例为例,让我们了解完全定义神经网络的重要组件。我们需要以下内容:

  • 每个张量(参数)都有一个唯一的名称(键)

  • 将逻辑连接到网络中的每个张量中的一个或另一个

  • 每个张量的值(权重/偏差值)

在定义期间的 __init__ 阶段处理第一个点,而在 forward 方法定义期间处理第二个点。在 __init__ 阶段,默认情况下,在张量中随机初始化值。但是我们想要的是加载训练模型时学到的一组特定权重(或值),并将每个值与特定名称关联起来。这是通过调用以下特殊方法获得的。

使用 state_dict

model.state_dict()命令是理解如何保存和加载 PyTorch 模型工作原理的根本。model.state_dict()中的字典对应于参数名称(键)和模型对应的值(权重和偏置值)。state指的是模型的当前快照(快照是每个张量的值集)。

它返回一个键和值的字典(OrderedDict):

自动生成的文本描述图 2.7:带有权重和偏置值的状态字典

键是模型层的名称,值对应于这些层的权重。

保存

运行torch.save(model.state_dict(), 'mymodel.pth')会将该模型以 Python 序列化格式保存在名为mymodel.pth的磁盘上。一个好的做法是在调用torch.save之前将模型转移到 CPU,因为这样会将张量保存为 CPU 张量而不是 CUDA 张量。这将有助于在加载模型到任何计算机上时,无论其是否具有 CUDA 功能。

我们使用以下代码保存模型:

torch.save(model.to('cpu').state_dict(), 'mymodel.pth') 

加载

加载模型需要我们首先使用随机权重初始化模型,然后从state_dict中加载权重:

  1. 使用与训练时使用的相同命令创建一个空模型:

    model = nn.Sequential(
                nn.Linear(2, 8),
                nn.ReLU(),
                nn.Linear(8, 1)
            ).to(device) 
    
  2. 从磁盘加载模型并反序列化以创建一个orderedDict值:

    state_dict = torch.load('mymodel.pth') 
    
  3. state_dict加载到model中,注册到device,并进行预测:

    model.load_state_dict(state_dict)
    # <All keys matched successfully>
    model.to(device)
    model(torch.tensor(val).float().to(device)) 
    

如果所有权重名称都存在于模型中,则会收到所有键匹配的消息。这意味着我们可以在世界上的任何计算机上从磁盘加载我们的模型,以便所有目的。接下来,我们可以将模型注册到设备,并对新数据点进行推断。

或者,我们可以使用torch.save(model, '<path>')保存模型,并使用torch.load('<path>')加载模型。尽管这看起来步骤更少更方便,但不建议,且在神经网络版本/Python 版本更改时更不灵活,容易出错。虽然torch.save(model.state_dict())仅保存权重(即张量字典),torch.save(model)将同时保存 Python 类。这在加载时可能会导致 PyTorch/Python 版本不一致的问题。

总结

在本章中,我们学习了 PyTorch 的基础构建模块——张量对象——以及在其上执行各种操作。我们进一步构建了一个玩具数据集上的神经网络,首先构建了一个初始化前馈架构的类,通过指定批处理大小从数据集中获取数据点,并定义了损失函数和优化器,通过多个时期进行循环。最后,我们还学习了定义自定义损失函数以优化选择的指标,并利用顺序方法简化网络架构定义的过程。所有这些步骤构成了构建神经网络的基础,将在后续章节中多次应用于各种用例中。

通过掌握使用 PyTorch 构建神经网络的各种组件的知识,我们将继续进入下一章节,在该章节中,我们将学习处理图像数据集上神经网络超参数的各种实际方面。

问题

  1. 在训练期间为什么要将整数输入转换为浮点数值?

  2. 用于重新塑形张量对象的方法有哪些?

  3. 使用张量对象比使用 NumPy 数组计算更快的原因是什么?

  4. 在神经网络类中,初始化魔术函数包括哪些内容?

  5. 在执行反向传播之前为什么要执行零梯度操作?

  6. 哪些魔术函数构成数据集类?

  7. 如何在新数据点上进行预测?

  8. 如何获取神经网络中间层的值?

  9. Sequential方法如何帮助简化神经网络架构的定义?

  10. 在更新loss_history时,我们附加loss.item()而不是loss。这样做有什么作用,为什么附加loss.item()而不只是loss很有用?

  11. 使用torch.save(model.state_dict())的优势是什么?

在 Discord 上了解更多

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第三章:使用 PyTorch 构建深度神经网络

在前一章中,我们学习了如何使用 PyTorch 编写神经网络。我们还了解了神经网络中存在的各种超参数,如批量大小、学习率和损失优化器。在本章中,我们将转变方向,学习如何使用神经网络进行图像分类。基本上,我们将学习如何表示图像并调整神经网络的超参数以理解它们的影响。

为了不引入过多复杂性和混乱,我们仅在上一章中涵盖了神经网络的基本方面。但是,在训练网络时,我们调整的输入还有很多。通常,这些输入称为超参数。与神经网络中的参数(在训练过程中学习的)相反,超参数是由构建网络的人提供的。更改每个超参数的不同方面可能会影响训练神经网络的准确性或速度。此外,一些额外的技术,如缩放、批量归一化和正则化,有助于提高神经网络的性能。我们将在本章中学习这些概念。

但在此之前,我们将学习图像的表示方法:只有这样,我们才能深入探讨超参数的细节。在学习超参数影响时,我们将限制自己使用一个数据集:Fashion MNIST(有关数据集的详细信息可以在 github.com/zalandoresearch/fashion-mnist 找到),以便我们可以比较不同超参数变化对准确性的影响。通过这个数据集,我们还将介绍训练和验证数据的概念,以及为什么有两个单独的数据集是重要的。最后,我们将学习神经网络过拟合的概念,然后了解某些超参数如何帮助我们避免过拟合。

总之,在本章中,我们将涵盖以下主题:

  • 表示图像

  • 为何利用神经网络进行图像分析?

  • 为图像分类准备数据

  • 训练神经网络

  • 缩放数据集以提高模型准确性

  • 理解批量大小变化的影响

  • 理解损失优化器变化的影响

  • 理解学习率变化的影响

  • 构建更深层次的神经网络

  • 理解批量归一化的影响

  • 过拟合的概念

让我们开始吧!

本章中的所有代码可以在本书 GitHub 仓库的 Chapter03 文件夹中查阅,链接为 bit.ly/mcvp-2e

我们在 GitHub 仓库的相关代码中已经覆盖了学习率变化的影响。

表示图像

数字图像文件(通常与扩展名“JPEG”或“PNG”相关联)由像素数组组成。 像素是图像的最小构成元素。 在灰度图像中,每个像素是介于0255之间的标量(单一)值:0 代表黑色,255 代表白色,介于两者之间的是灰色(像素值越小,像素越暗)。 另一方面,彩色图像中的像素是三维向量,对应于其红、绿和蓝通道中的标量值。

一个图像有 height x width x c 个像素,其中 height 是像素的行数width 是像素的列数c通道数。 对于彩色图像,c3(分别对应图像的红色、绿色和蓝色强度的一个通道),而对于灰度图像,c1。 这里展示了一个包含 3 x 3 像素及其对应标量值的灰度图像示例:

包含文本、障子、填字游戏的图片

图 3.1: 图像表示

再次强调,像素值为 0 意味着它是纯黑色,而 255 表示纯亮度(即灰度图像的纯白色和彩色图像中相应通道的纯红/绿/蓝色)。

将图像转换为结构化数组和标量

Python 可以将图像转换为结构化数组和标量,具体如下:

可在 GitHub 的 Chapter03 文件夹中找到 Inspecting_grayscale_images.ipynb 文件中的以下代码:bit.ly/mcvp-2e

  1. 下载一个样本图像或上传您自己的自定义图像:

    !wget https://www.dropbox.com/s/l98lee/Hemanvi.jpeg 
    
  2. 导入 cv2(从磁盘读取图像)和 matplotlib(绘制加载的图像)库,并将下载的图像读入 Python 环境:

    %matplotlib inline
    import cv2, matplotlib.pyplot as plt
    img = cv2.imread('Hemanvi.jpeg') 
    

在前述代码行中,我们利用 cv2.imread 方法读取图像。 这将图像转换为像素值数组。

  1. 我们将裁剪图像,从第 50 行到第 250 行,以及从第 40 列到第 240 列。 最后,我们将使用以下代码将图像转换为灰度并绘制它:

    # Crop image
    img = img[50:250,40:240]
    # Convert image to grayscale
    img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    # Show image
    plt.imshow(img_gray, cmap='gray') 
    

上述步骤的输出如下所示:

包含日历的图片

图 3.2: 裁剪后的图像

您可能已经注意到,前述图像被表示为一个 200 x 200 的像素数组。 现在,让我们减少用于表示图像的像素数量,以便我们可以在图像上叠加像素值(与在 200 x 200 数组上可视化像素值相比,在 25 x 25 数组上更难实现此目标)。

  1. 将图像转换为一个 25 x 25 的数组并绘制它:

    img_gray_small = cv2.resize(img_gray,(25,25))
    plt.imshow(img_gray_small, cmap='gray') 
    

这将产生以下输出:

图表

图 3.3: 调整大小后的图像

自然地,使用较少像素来表示相同图像会产生模糊的输出。

  1. 让我们检查像素值。请注意,由于空间限制,以下输出只粘贴了前四行像素值:

    print(img_gray_small) 
    

这导致以下输出:

计算机屏幕截图的说明(低置信度自动生成)

图 3.4:输入图像的像素值

将相同的像素值集合复制并粘贴到 MS Excel 中,并按像素值进行颜色编码,效果如下:

文本的说明(自动生成)

图 3.5:图像对应的像素值

正如我们之前提到的,像素的标量值接近 255 的显示更浅,接近 0 的则显示更暗。

为彩色图像创建一个结构化数组

前面的步骤同样适用于彩色图像,其表示为三维向量。最亮的红色像素表示为(255,0,0)。同样,三维向量图像中的纯白色像素表示为(255,255,255)。有了这些基础知识,让我们为彩色图像创建一个结构化的像素值数组:

可以在 GitHub 上的 Chapter03 文件夹中的 Inspecting_color_images.ipynb 文件中找到以下代码:bit.ly/mcvp-2e

  1. 下载一幅彩色图像:

    !wget https://www.dropbox.com/s/l98lee/Hemanvi.jpeg 
    
  2. 导入相关包并加载图像:

    import cv2, matplotlib.pyplot as plt
    %matplotlib inline
    img = cv2.imread('Hemanvi.jpeg') 
    
  3. 裁剪图像:

    img = img[50:250,40:240,:]
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) 
    

请注意,在上述代码中,我们使用了 cv2.cvtcolor 方法重新排序了通道。我们这样做是因为当使用 cv2 导入图像时,通道的顺序是蓝色首先,然后是绿色,最后是红色;通常,我们习惯于查看 RGB 通道的图像,其中顺序是红色、绿色,最后是蓝色。

  1. 绘制获得的图像:

    plt.imshow(img)
    print(img.shape)
    # (200,200,3) 
    

这导致以下输出(请注意,如果您正在阅读印刷版的书籍,并且尚未下载彩色图像包,则以下图像将以灰度显示):

图表的说明(自动生成)

图 3.6:RGB 格式的图像

  1. 可以按以下步骤获取右下角的 3 x 3 像素阵列:

    crop = img[-3:,-3:] 
    
  2. 打印并绘制像素值:

    print(crop)
    plt.imshow(crop) 
    

前述代码的输出如下:

图表的说明(自动生成)

图 3.7:图像一个区域的 RGB 值

现在我们已经学会如何将图像(即计算机上的文件)表示为张量,我们现在可以学习各种数学运算和技术,利用这些张量执行任务,如图像分类、目标检测、图像分割等,本书中的许多任务。

但首先,让我们了解为何人工神经网络ANNs)在图像分析中很有用。

为何要利用神经网络进行图像分析?

在传统的计算机视觉中,我们会在使用图像作为输入之前为每个图像创建一些特征。让我们看一些基于以下示例图像的这些特征,以便体会通过训练神经网络节省的努力:

包含文本的图片的描述 自动生成

图 3.8:从图像中生成的一部分特征

注意,我们不会详细介绍如何获取这些特征,因为这里的意图是帮助您意识到手动创建特征是一种次优的练习。但是,您可以在docs.opencv.org/4.x/d7/da8/tutorial_table_of_content_imgproc.html了解各种特征提取方法:

  • 直方图特征:对于某些任务,如自动亮度或夜视,理解图片中的照明情况是很重要的:即明亮或黑暗像素的比例。

  • 边缘和角落特征:对于诸如图像分割的任务,重要的是找到与每个人对应的像素集合,因此首先提取边缘是有意义的,因为人的边界只是边缘的集合。在其他任务中,如图像配准,检测关键地标是至关重要的。这些地标将是图像中所有角落的子集。

  • 颜色分离特征:在自动驾驶汽车的任务中,如交通灯检测,系统理解交通灯显示的颜色是非常重要的。

  • 图像梯度特征:进一步探讨颜色分离特征,理解像素级别的颜色变化可能很重要。不同的纹理可以给我们不同的梯度,这意味着它们可以用作纹理检测器。事实上,找到梯度是边缘检测的先决条件。

这些只是少数这类特征中的一部分。还有许多其他特征,涵盖它们是困难的。创建这些特征的主要缺点是,您需要成为图像和信号分析的专家,并且应该充分理解哪些特征最适合解决问题。即使两个条件都满足,也不能保证这样的专家能够找到正确的输入组合,即使找到了,也不能保证这样的组合在新的未见场景中能够有效工作。

由于这些缺点,社区主要转向基于神经网络的模型。这些模型不仅可以自动找到正确的特征,还可以学习如何最优地组合它们来完成工作。正如我们在第一章中已经看到的,神经网络既是特征提取器,也是分类器。

现在我们已经看过一些历史特征提取技术及其缺点的示例,让我们学习如何在图像上训练神经网络。

准备我们的数据用于图像分类

鉴于本章涵盖多种场景,为了看到一个场景比另一个场景的优势,我们将在本章中专注于一个数据集:Fashion MNIST 数据集,其中包含 10 种不同类别的服装图片(衬衫、裤子、鞋子等)。让我们准备这个数据集:

下面的代码可以在 GitHub 上的 Chapter03 文件夹中的 Preparing_our_data.ipynb 文件中找到,网址为 bit.ly/mcvp-2e

  1. 开始下载数据集并导入相关包。torchvision 包含各种数据集,其中之一是 FashionMNIST 数据集,在本章中我们将对其进行处理:

    from torchvision import datasets
    import torch
    data_folder = '~/data/FMNIST' # This can be any directory
    # you want to download FMNIST to
    fmnist = datasets.FashionMNIST(data_folder, download=True, train=True) 
    

在上述代码中,我们指定了要存储下载数据集的文件夹(data_folder)。然后,我们从 datasets.FashionMNIST 获取 fmnist 数据并将其存储在 data_folder 中。此外,我们指定只下载训练图像,通过指定 train = True

  1. 接下来,我们必须将 fmnist.data 中可用的图像存储为 tr_images,将 fmnist.targets 中可用的标签(目标)存储为 tr_targets

    tr_images = fmnist.data
    tr_targets = fmnist.targets 
    
  2. 检查我们正在处理的张量:

    unique_values = tr_targets.unique()
    print(f'tr_images & tr_targets:\n\tX -{tr_images.shape}\n\tY \
    -{tr_targets.shape}\n\tY-Unique Values : {unique_values}')
    print(f'TASK:\n\t{len(unique_values)} class Classification')
    print(f'UNIQUE CLASSES:\n\t{fmnist.classes}') 
    

上述代码的输出如下:

Chart  Description automatically generated with low confidence

图 3.9:输入和输出形状以及唯一类别

在这里,我们可以看到有 60,000 张图片,每张大小为 28 x 28,并且在所有图片中有 10 种可能的类别。请注意,tr_targets 包含每个类别的数值,而 fmnist.classes 给出与 tr_targets 中每个数值对应的名称。

  1. 为所有 10 种可能的类别绘制 10 张随机样本的图片:

    1. 导入相关包以绘制图像网格,以便也可以处理数组:
    import matplotlib.pyplot as plt
    %matplotlib inline
    import numpy as np 
    
    1. 创建一个图表,我们可以展示一个 10 x 10 的网格,其中每行网格对应一个类别,每列呈现属于该行类别的示例图片。循环遍历唯一的类别号码(label_class),并获取对应给定类别号码的行索引(label_x_rows):
    R, C = len(tr_targets.unique()), 10
    fig, ax = plt.subplots(R, C, figsize=(10,10))
    for label_class, plot_row in enumerate(ax):
        label_x_rows = np.where(tr_targets == label_class)[0] 
    

    请注意,在上述代码中,我们将 np.where 条件的第 0 个索引作为输出提取出来,因为其长度为 1。它包含所有目标值(tr_targets)等于 label_class 的索引数组。

    1. 循环 10 次以填充给定行的列。此外,我们需要从之前获取的与给定类别对应的索引中选择一个随机值(ix)并绘制它们:
     for plot_cell in plot_row:
            plot_cell.grid(False); plot_cell.axis('off')
            ix = np.random.choice(label_x_rows)
            x, y = tr_images[ix], tr_targets[ix]
            plot_cell.imshow(x, cmap='gray')
    plt.tight_layout() 
    

这导致以下输出:

图 3.10:Fashion MNIST 样本图片

请注意,在上述图片中,每一行代表同一类别的 10 张不同图片的样本。

训练神经网络

要训练神经网络,我们必须执行以下步骤:

  1. 导入相关包

  2. 建立一个可以逐个数据点获取数据的数据集

  3. 从数据集中包装 DataLoader

  4. 建立一个模型,然后定义损失函数和优化器

  5. 分别定义两个函数来训练和验证一批数据

  6. 定义一个计算数据准确率的函数

  7. 根据每个数据批次进行权重更新,逐渐增加 epochs

在接下来的代码行中,我们将执行以下每个步骤:

以上代码可以在 GitHub 的Chapter03文件夹中的Steps_to_build_a_neural_network_on_FashionMNIST.ipynb文件中找到,位于bit.ly/mcvp-2e

  1. 导入相关的包和fmnist数据集:

    from torch.utils.data import Dataset, DataLoader
    import torch
    import torch.nn as nn
    import numpy as np
    import matplotlib.pyplot as plt
    %matplotlib inline
    device = "cuda" if torch.cuda.is_available() else "cpu"
    from torchvision import datasets
    data_folder = '~/data/FMNIST' # This can be any directory you
    # want to download FMNIST to
    fmnist = datasets.FashionMNIST(data_folder, download=True, train=True)
    tr_images = fmnist.data
    tr_targets = fmnist.targets 
    
  2. 建立一个获取数据集的类。请记住,它是从Dataset类派生的,并需要定义三个魔法函数__init____getitem____len__

    class FMNISTDataset(Dataset):
        def __init__(self, x, y):
            x = x.float()
            x = x.view(-1,28*28)
            self.x, self.y = x, y
        def __getitem__(self, ix):
            x, y = self.x[ix], self.y[ix]
            return x.to(device), y.to(device)
        def __len__(self):
            return len(self.x) 
    

注意,在__init__方法中,我们将输入转换为浮点数,并将每个图像展平为 28*28 = 784 个数值(其中每个数值对应一个像素值)。在__len__方法中,我们还指定数据点的数量;这里是x的长度。__getitem__方法包含了当我们请求第ix个数据点时应该返回什么的逻辑(ix将是一个介于0__len__之间的整数)。

  1. 创建一个函数,从名为FMNISTDataset的数据集生成一个训练 DataLoader,称为trn_dl。这将随机抽样 32 个数据点作为批量大小:

    **def****get_data****():**
        train = FMNISTDataset(tr_images, tr_targets)
        trn_dl = DataLoader(train, batch_size=32, shuffle=True)
        return trn_dl 
    
  2. 定义一个模型,以及损失函数和优化器:

    from torch.optim import SGD
    **def****get_model****():**
        model = nn.Sequential(
                    nn.Linear(28 * 28, 1000),
                    nn.ReLU(),
                    nn.Linear(1000, 10)
                ).to(device)
        loss_fn = nn.CrossEntropyLoss()
        optimizer = SGD(model.parameters(), lr=1e-2)
        return model, loss_fn, optimizer 
    

该模型是一个含有 1,000 个神经元的隐藏层网络。输出是一个 10 个神经元的层,因为有 10 个可能的类。此外,我们调用CrossEntropyLoss函数,因为输出可以属于每个图像的 10 个类中的任意一个。最后,本练习中需要注意的关键方面是,我们已将学习率lr初始化为0.01,而不是默认值0.001,以查看模型在此练习中的学习情况。

注意,我们在神经网络中根本不使用“softmax”。输出的范围是不受限制的,即值可以具有无限的范围,而交叉熵损失通常期望输出为概率(每行应该总和为1)。在这种情况下,输出中的无约束值仍然可以工作,因为nn.CrossEntropyLoss实际上期望我们发送原始 logits(即无约束值)。它在内部执行 softmax。

  1. 定义一个函数,将数据集训练到一批图像上:

    **def****train_batch****(****x, y, model, opt, loss_fn****):**
        **model.train()** # <- let's hold on to this until we reach
        # dropout section
        # call your model like any python function on your batch
        # of inputs
        **prediction = model(x)**
        # compute loss
        **batch_loss = loss_fn(prediction, y)**
        # based on the forward pass in `model(x)` compute all the
        # gradients of 'model.parameters()'
        **batch_loss.backward()**
        # apply new-weights = f(old-weights, old-weight-gradients)
        # where "f" is the optimizer
        **optimizer.step()**
        # Flush gradients memory for next batch of calculations
     **optimizer.zero_grad()**
    **return** **batch_loss.item()** 
    

前面的代码在前向传播中将图像批量传递给模型。它还计算批次上的损失,然后通过反向传播传递权重并更新它们。最后,它清除梯度的内存,以免影响下一次传播中的梯度计算方式。

现在我们完成了这些步骤,我们可以通过获取batch_loss.item()上的batch_loss来提取损失值作为标量。

  1. 构建一个计算给定数据集准确率的函数:

    # since there's no need for updating weights,
    # we might as well not compute the gradients.
    # Using this '@' decorator on top of functions
    # will disable gradient computation in the entire function
    **@torch.no_grad()**
    **def****accuracy****(****x, y, model****):**
        **model.****eval****()** # <- let's wait till we get to dropout
        # section
        # get the prediction matrix for a tensor of `x` images
        **prediction = model(x)**
        # compute if the location of maximum in each row
        # coincides with ground truth
        **max_values, argmaxes = prediction.****max****(-****1****)**
        **is_correct = argmaxes == y**
        **return** **is_correct.cpu().numpy().tolist()** 
    

在前述代码中,我们明确提到通过使用@torch.no_grad()并计算prediction值,通过模型进行前向传播,我们不需要计算梯度。接下来,我们调用prediction.max(-1)来识别每行对应的argmax索引。然后,我们通过argmaxes == y来将我们的argmaxes与基准真实值进行比较,以便检查每行是否被正确预测。最后,我们在将其转移到 CPU 并转换为 NumPy 数组后返回is_correct对象列表。

  1. 使用以下代码行训练神经网络:

    1. 初始化模型、损失、优化器和 DataLoaders:
    trn_dl = get_data()
    model, loss_fn, optimizer = get_model() 
    
    1. 初始化将在每个 epoch 结束时包含准确率和损失值的列表:
    losses, accuracies = [], [] 
    
    1. 定义 epoch 的数量:
    for epoch in range(5):
        print(epoch) 
    
    1. 初始化将包含每个 epoch 内每个批次的准确率和损失值的列表:
     epoch_losses, epoch_accuracies = [], [] 
    
    1. 通过遍历 DataLoader 来创建训练数据的批次:
     for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch 
    
    1. 使用train_batch函数训练批次,并将训练结束时的损失值存储在batch_loss的顶部。此外,将跨批次的损失值存储在epoch_losses列表中:
     batch_loss = train_batch(x, y, model,optimizer, loss_fn)
            epoch_losses.append(batch_loss) 
    
    1. 我们存储每个 epoch 内所有批次的平均损失值:
     epoch_loss = np.array(epoch_losses).mean() 
    
    1. 接下来,在所有批次训练结束时计算预测的准确率:
     for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            is_correct = accuracy(x, y, model)
            epoch_accuracies.extend(is_correct)
        epoch_accuracy = np.mean(epoch_accuracies) 
    
    1. 在列表中存储每个 epoch 结束时的损失和准确率值:
     losses.append(epoch_loss)
        accuracies.append(epoch_accuracy) 
    
    1. 可以使用以下代码显示随着 epoch 增加的训练损失和准确率的变化。
    epochs = np.arange(5)+1
    plt.figure(figsize=(20,5))
    plt.subplot(121)
    plt.title('Loss value over increasing epochs')
    plt.plot(epochs, losses, label='Training Loss')
    plt.legend()
    plt.subplot(122)
    plt.title('Accuracy value over increasing epochs')
    plt.plot(epochs, accuracies, label='Training Accuracy')
    plt.gca().set_yticklabels(['{:.0f}%'.format(x*100) \
                              for x in \ plt.gca().get_yticks()])
    plt.legend() 
    

    前述代码的输出如下:

    计算机截图的描述

    图 3.11:随着 epoch 增加的训练损失和准确率值

我们的训练准确率在五个 epoch 结束时为 12%。请注意,随着 epoch 数量的增加,损失值并没有显著减少。换句话说,无论我们等待多长时间,模型都不太可能提供高准确率(比如超过 80%)。这要求我们了解所使用的各种超参数如何影响神经网络的准确性。

请注意,由于我们在代码开始时没有指定torch.random_seed(0),因此当您执行所提供的代码时,结果可能会有所不同。但是,您得到的结果应该让您得出类似的结论。

现在,您已经全面了解了如何训练神经网络,让我们研究一些应遵循的良好实践以实现良好的模型性能以及使用它们的原因。可以通过微调各种超参数来实现这一目标,其中一些将在接下来的部分中进行介绍。

缩放数据集以提高模型准确性

缩放数据集是确保变量限制在有限范围内的过程。在本节中,我们将通过将每个输入值除以数据集中可能的最大值 255 来将独立变量的值限制在 0 到 1 之间。这对应于白色像素的值255

为简洁起见,我们仅在接下来的代码中提供了修改后的代码(来自前一节)。完整代码可在 GitHub 上的Chapter03文件夹中的Scaling_the_dataset.ipynb文件中找到:bit.ly/mcvp-2e

  1. 获取数据集以及训练图像和目标,就像我们在前一节中所做的那样。

  2. 修改FMNISTDataset,该数据集获取数据,使得输入图像除以 255(像素的最大强度/值):

    class FMNISTDataset(Dataset):
        def __init__(self, x, y):
            **x = x.****float****()/****255**
            x = x.view(-1,28*28)
            self.x, self.y = x, y
        def __getitem__(self, ix):
            x, y = self.x[ix], self.y[ix]
            return x.to(device), y.to(device)
        def __len__(self):
            return len(self.x) 
    

请注意,与上一节相比,我们唯一更改的是将输入数据除以最大可能像素值:255

鉴于像素值范围在0255之间,将它们除以 255 将导致值始终在01之间。

  1. 训练一个模型,就像我们在前一节的步骤 4、5、6 和 7 中所做的那样。训练损失和准确率值的变化如下:

计算机屏幕截图 自动生成的描述

图 3.12:在经过扩展的数据集上随着周期的增加的训练损失和准确率数值

如图所示,训练损失持续降低,训练准确率持续增加,达到约 85%的准确率。与未对输入数据进行缩放的情况相比,训练损失未能持续降低,而五个周期结束时训练数据集的准确率仅为 12%。

让我们深入探讨为什么在这里缩放有帮助的可能原因。我们将以 Sigmoid 值计算的示例为例:

在下表中,我们根据前述公式计算了Sigmoid列。

图 3.13:不同输入和权重值的 Sigmoid 值

在左表中,我们可以看到当权重值大于 0.1 时,Sigmoid 值不随权重值的增加(变化)而变化。此外,当权重非常小时,Sigmoid 值只有少量变化;改变 Sigmoid 值的唯一方法是通过非常小的数值改变权重。

然而,当输入值很小时,右表中的 Sigmoid 值变化很大。

这是因为大的负值的指数(由于将权重值乘以一个大数得出)非常接近于 0,而指数值在权重乘以扩展输入时变化,正如右表所示。

现在我们了解到,除非权重值非常小,否则 sigmoid 值不会有很大变化,我们将学习如何将权重值影响向最优值。

缩放输入数据集,使其包含一个范围更小的值通常有助于实现更好的模型准确性。

接下来,我们将学习神经网络的另一个重要超参数之一的影响:批大小

理解不同批大小的影响

在之前的章节中,训练数据集中每批有 32 个数据点。这导致每个 epoch 有更多的权重更新次数,因为每个 epoch 有 1,875 次权重更新(60,000/32 接近于 1,875,其中 60,000 是训练图像的数量)。

此外,我们没有考虑模型在未见数据集(验证数据集)上的性能。我们将在本节中探讨这一点。

在本节中,我们将比较以下内容:

  • 当训练批大小为 32 时,训练和验证数据的损失值和准确率。

  • 当训练批大小为 10,000 时,训练和验证数据的损失值和准确率。

现在我们已经介绍了验证数据,让我们重新运行构建神经网络部分中提供的代码,增加额外的代码以生成验证数据,并计算验证数据集的损失值和准确率。

为了简洁起见,我们只提供了修改后的代码(来自上一节)在接下来的部分。完整的代码可以在 GitHub 存储库中的Chapter03文件夹中的Varying_batch_size.ipynb文件中找到,链接为bit.ly/mcvp-2e

批大小为 32

鉴于我们已经在前一节中使用批大小为 32 构建了一个模型,现在我们将详细说明用于处理验证数据集的附加代码。我们将跳过训练模型的详细过程,因为这已经在构建神经网络部分中涵盖了。让我们开始吧:

  1. 下载并导入训练图像和目标。

  2. 与训练图像类似,我们必须通过在调用数据集中的FashionMNIST方法时指定train = False来下载和导入验证数据集:

    val_fmnist =datasets.FashionMNIST(data_folder,download=True, train=False)
    val_images = val_fmnist.data
    val_targets = val_fmnist.targets 
    
  3. 导入相关包并定义device

  4. 定义数据集类(FashionMNIST)和将用于批处理数据的函数(train_batch)、计算准确率的函数(accuracy),然后定义模型架构、损失函数和优化器(get_model)。

  5. 定义一个函数,将获取数据,即get_data。该函数将返回批大小为32的训练数据和与验证数据长度相同的验证数据集(我们将不使用验证数据来训练模型;我们只使用它来了解模型在未见数据上的准确性):

    def get_data():
        train = FMNISTDataset(tr_images, tr_targets)
        trn_dl = DataLoader(train, **batch_size=****32**, shuffle=True)
        val = FMNISTDataset(val_images, val_targets)
        val_dl = DataLoader(val, batch_size=len(val_images),
                                                shuffle=False)
        return trn_dl, val_dl 
    

在上述代码中,我们创建了 FMNISTDataset 类的 val 对象,除了之前看到的 train 对象。此外,验证数据的 DataLoader (val_dl) 使用了 val_images 的长度作为批大小,而 trn_dl 的批大小为 32。这是因为训练数据用于训练模型,而我们获取验证数据的准确率和损失指标。在本节和下节中,我们尝试理解基于模型训练时间和准确性变化 batch_size 的影响。

  1. 定义一个函数来计算验证数据的损失值:即 val_loss。请注意,我们单独计算这个,因为在训练模型时计算了训练数据的损失值:

    @torch.no_grad()
    def val_loss(x, y, model):
        model.eval()
        prediction = model(x)
        val_loss = loss_fn(prediction, y)
        return val_loss.item() 
    

如您所见,我们应用 torch.no_grad 因为我们不训练模型,只获取预测。此外,我们通过损失函数 (loss_fn) 传递我们的 prediction 并返回损失值 (val_loss.item())。

  1. 获取训练和验证 DataLoader。同时,初始化模型、损失函数和优化器:

    trn_dl, val_dl = get_data()
    model, loss_fn, optimizer = get_model() 
    
  2. 训练模型如下:

    1. 初始化包含训练和验证准确率以及损失值的列表,随着 epoch 增加逐步记录它们的变化:
    train_losses, train_accuracies = [], []
    val_losses, val_accuracies = [], [] 
    
    1. 在五个 epoch 中循环,并初始化包含给定 epoch 内训练数据批次的准确率和损失的列表:
    for epoch in range(5):
        print(epoch)
        train_epoch_losses, train_epoch_accuracies = [], [] 
    
    1. 在一个 epoch 内,循环遍历训练数据的批次,并计算准确率 (train_epoch_accuracy) 和损失值 (train_epoch_loss):
     for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            batch_loss = train_batch(x, y, model,optimizer, loss_fn)
            train_epoch_losses.append(batch_loss)
        train_epoch_loss = np.array(train_epoch_losses).mean()
        for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            is_correct = accuracy(x, y, model)
            train_epoch_accuracies.extend(is_correct)
        train_epoch_accuracy = np.mean(train_epoch_accuracies) 
    
    1. 计算验证数据一批次内的损失值和准确率(因为验证数据的批大小等于验证数据的长度):
     for ix, batch in enumerate(iter(val_dl)):
            x, y = batch
            val_is_correct = accuracy(x, y, model)
            validation_loss = val_loss(x, y, model)
        val_epoch_accuracy = np.mean(val_is_correct) 
    
    1. 请注意,在上述代码中,验证数据的损失值是使用 val_loss 函数计算的,并存储在 validation_loss 变量中。此外,所有验证数据点的准确率存储在 val_is_correct 列表中,而其平均值存储在 val_epoch_accuracy 变量中。

    2. 最后,我们将训练和验证数据集的准确率和损失值附加到包含 epoch 级别聚合验证和准确率值的列表中。我们这样做是为了在下一步中查看 epoch 级别的改进:

     train_losses.append(train_epoch_loss)
        train_accuracies.append(train_epoch_accuracy)
        val_losses.append(validation_loss)
        val_accuracies.append(val_epoch_accuracy) 
    
  3. 可视化训练和验证数据集在增加的 epoch 中准确率和损失值的改善:

图表,线图 自动生成的描述

图 3.14: 随着批大小为 32 增加的情况下的训练和验证损失及准确率

如您所见,当批大小为 32 时,训练和验证准确率在五个 epoch 结束时达到约 85%。接下来,我们将在 get_data 函数中训练 DataLoader 时变化 batch_size 参数,以查看其对五个 epoch 结束时准确率的影响。

批大小为 10,000

在本节中,我们将每批使用 10,000 个数据点,以便了解变化 batch_size 的影响。

请注意,“批量大小为 32”的部分提供的代码在此处保持完全相同,除了“步骤 5”中的代码,我们将指定批量大小为 10,000。在执行代码时,请参考此书的 GitHub 仓库中提供的相应笔记本。

通过在“步骤 5”中仅进行这一必要更改,并在执行所有步骤直到“步骤 9”后,当批量大小为 10,000 时,随着增加时期的训练和验证准确率和损失的变化如下:

图表,折线图 描述自动生成

图 3.15:使用批量大小为 10,000 的增加时期的训练和验证损失和准确率

在这里,我们可以看到准确率和损失值未达到前一个场景的相同水平,其中批量大小为 32,因为每个时期更新权重的次数较少(6 次),而批量大小为 32 为 1,875 次。

当你只有少量时期时,较低的批量大小通常有助于实现最佳精度,但不应过低以至于影响训练时间。

到目前为止,我们已经学习了如何缩放数据集,以及在模型训练时间内通过改变批量大小来达到特定精度的影响。在接下来的部分中,我们将学习如何在相同数据集上改变损失优化器的影响。

理解不同损失优化器的影响

到目前为止,我们已经基于 Adam 优化器优化了损失。损失优化器有助于确定最优权重值以最小化总体损失。有多种损失优化器(不同的更新权重值以最小化损失值的方式)会影响模型的整体损失和准确率。在本节中,我们将执行以下操作:

  1. 修改优化器,使其成为随机梯度下降SGD)优化器

  2. 在 DataLoader 中获取数据时恢复为批量大小为 32

  3. 将时期数增加到 10(这样我们可以比较 SGD 和 Adam 在更长时期内的表现)

这些更改意味着只有“批量大小为 32”的部分中的一个步骤会改变(因为该部分的批量大小已经为 32);也就是说,我们将修改优化器,使其成为 SGD 优化器。

让我们修改“批量大小为 32”的“步骤 4”中的get_model函数,以修改优化器,使其使用 SGD 优化器,如下所示:

完整的代码可以在 GitHub 上的Chapter03文件夹中的Varying_loss_optimizer.ipynb文件中找到,链接为bit.ly/mcvp-2e。为了简洁起见,我们不会详细说明“批量大小为 32”的每一个步骤;相反,在接下来的代码中,我们将仅讨论引入更改的步骤。在执行代码时,建议您参考 GitHub 上的相应笔记本。

  1. 修改优化器,使其在get_model函数中使用 SGD 优化器,同时确保其他所有内容保持不变:

    **from** **torch.optim** **import** **SGD, Adam**
    def get_model():
        model = nn.Sequential(
                    nn.Linear(28 * 28, 1000),
                    nn.ReLU(),
                    nn.Linear(1000, 10)
                ).to(device)
        loss_fn = nn.CrossEntropyLoss()
        optimizer = **SGD**(model.parameters(), lr=1e-2)
        return model, loss_fn, optimizer 
    

现在,在步骤 8 中增加 epoch 数量,同时保持除步骤 4步骤 8 外的所有其他步骤与批大小为 32节相同。

  1. 增加我们将用于训练模型的 epoch 数量。

在进行这些更改后,一旦按顺序执行批大小为 32节中的所有其余步骤,使用 SGD 和 Adam 优化器单独训练时,随着 epoch 增加,训练和验证数据集的准确性和损失值变化如下:

图表,折线图  自动生成的描述

图 3.16:使用 SGD 优化器随着 epoch 增加的训练和验证损失及准确率

图 3.17:使用 Adam 优化器随着 epoch 增加的训练和验证损失及准确率

正如您所看到的,当我们使用 Adam 优化器时,准确率仍非常接近 85%。

注意

某些优化器比其他优化器更快地达到最佳准确率。其他显著的优化器包括 Adagrad、Adadelta、AdamW、LBFGS 和 RMSprop。

到目前为止,在训练我们的模型时,我们使用了学习率为 0.01,并在训练过程中的所有 epoch 中保持不变。在第一章人工神经网络基础中,我们学到学习率在达到最佳权重值时发挥关键作用。在这里,当学习率较小时,权重值逐渐向最优值移动,而当学习率较大时,权重值在非最优值处振荡。

然而,最初权重迅速更新到接近最优状态是直观的。从那时起,它们应该被非常缓慢地更新,因为最初减少的损失量很高,而后续 epoch 中减少的损失量则很低。

这要求初始时具有较高的学习率,并随后逐渐降低,直到模型达到接近最优准确率。这要求我们理解何时必须降低学习率(随时间的学习率退火)。请参阅 GitHub 上Chapter03文件夹中的Learning_rate_annealing.ipynb文件,以了解学习率退火的影响。

要了解学习率变化的影响,以下几种情况将有所帮助:

  • 在经过缩放的数据集上使用较高的学习率(0.1)

  • 在经过缩放的数据集上使用较低的学习率(0.00001)

  • 在未经缩放的数据集上使用较低的学习率(0.00001)

这三种情况不会在本章中讨论;但是,您可以在 GitHub 存储库的Chapter03文件夹中的Varying_learning_rate_on_scaled_data.ipynb文件和Varying_learning_rate_on_non_scaled_data.ipynb文件中获取它们的全部代码,网址为bit.ly/mcvp-2e

在下一节中,我们将学习神经网络中层数如何影响其准确性。

构建更深的神经网络

到目前为止,我们的神经网络架构仅有一个隐藏层。在本节中,我们将对比存在两个隐藏层和没有隐藏层(没有隐藏层时为逻辑回归)的模型性能。

可以构建如下的网络内两个层的模型(请注意,我们将第二个隐藏层的单元数设置为 1,000)。修改后的get_model函数(来自批量大小为 32部分中的代码),其中包含两个隐藏层,如下所示:

完整的代码可以在 GitHub 上的Chapter03文件夹中的Impact_of_building_a_deeper_neural_network.ipynb文件中找到,网址为bit.ly/mcvp-2e。为了简洁起见,我们不会详细描述批量大小为 32部分的每一步。在执行代码时,请参考 GitHub 上的笔记本。

def get_model():
    model = nn.Sequential(
                nn.Linear(28 * 28, 1000),
                nn.ReLU(),
 **nn.Linear(****1000****,** **1000****),**
 **nn.ReLU(),**
                nn.Linear(1000, 10)
            ).to(device)
    loss_fn = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters(), lr=1e-3)
    return model, loss_fn, optimizer 

同样地,没有隐藏层的get_model函数如下所示:

def get_model():
    model = nn.Sequential(
                **nn.Linear(****28** ***** **28****,** **10****)**
            ).to(device)
    loss_fn = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters(), lr=1e-3)
    return model, loss_fn, optimizer 

在上述函数中,请注意,我们直接将输入连接到输出层。

当我们像在批量大小为 32部分那样训练模型时,训练和验证数据集上的准确率和损失如下:

自动生成的带有数字说明的数字表格

图 3.18:随着不同隐藏层数量的增加,训练和验证损失以及准确率的变化

在这里,请注意以下内容:

  • 当没有隐藏层时,模型无法学习得很好。

  • 与仅有一个隐藏层的模型相比,当存在两个隐藏层时,模型的过拟合程度更大(验证损失比仅有一个隐藏层的模型更高)。

到目前为止,在不同的部分中,我们发现当输入数据未经过缩放时(未缩小到一个小范围),模型的训练效果并不好。非缩放的数据(具有较大范围的数据)也可能出现在隐藏层中(特别是在具有多个隐藏层的深度神经网络中),这是因为涉及到节点值获取的矩阵乘法。让我们在下一节中了解批量归一化如何帮助处理非缩放数据。

理解批量归一化的影响

我们之前学到,当输入值较大时,当权重值发生显著变化时,Sigmoid 输出的变化不会有太大的差异。

现在,让我们考虑相反的情况,即输入值非常小的情况:

自动生成的带有数字说明的数字表格

图 3.19:不同输入和权重值的 Sigmoid 值

当输入值非常小时,Sigmoid 输出会稍微变化,需要对权重值进行大的改变才能达到最优结果。

另外,在缩放输入数据部分中,我们看到大输入值对训练精度有负面影响。这表明,我们既不能有非常小的输入值,也不能有非常大的输入值。

除了输入中的非常小或非常大的值之外,我们还可能遇到一个情况,即隐藏层中某个节点的值可能会导致一个非常小的数字或一个非常大的数字,这会导致与连接隐藏层到下一层的权重之前看到的问题相同。在这种情况下,批次归一化就派上用场了,因为它像我们缩放输入值时那样归一化每个节点的值。通常,批次中的所有输入值都按以下方式缩放:

通过从批次均值减去每个数据点,然后将其除以批次方差,我们将节点上批次的所有数据点归一化到一个固定范围。虽然这被称为硬归一化,但通过引入γ和β参数,我们让网络确定最佳归一化参数。

为了理解批次归一化过程如何帮助,让我们看看在训练和验证数据集上的损失和精度值,以及以下场景中隐藏层值的分布:

  1. 没有批次归一化的非常小的输入值

  2. 带有批次归一化的非常小的输入值

让我们开始吧!

没有批次归一化的非常小的输入值

到目前为止,当我们必须缩放输入数据时,我们将其缩放为介于 0 和 1 之间的值。在本节中,我们将进一步将其缩放为介于 0 和 0.0001 之间的值,以便我们能够理解缩放数据的影响。正如我们在本节开头所看到的,即使权重值变化很大,小输入值也不能改变 sigmoid 值。

为了缩放输入数据集,使其具有非常低的值,我们将通过以下方式更改我们通常在FMNISTDataset类中执行的缩放,即通过将输入值的范围从0减少到0.0001

完整的代码可以在 GitHub 上的Chapter03文件夹中的Batch_normalization.ipynb文件中找到。为简洁起见,我们不会详细说明批次大小为 32部分的每一个步骤。在执行代码时,请参考 GitHub 上的笔记本。

class FMNISTDataset(Dataset):
    def __init__(self, x, y):
        **x = x.****float****()/(****255*********10000****)** **# Done only for us to**
**# understand the impact of Batch normalization**
        x = x.view(-1,28*28)
        self.x, self.y = x, y
    def __getitem__(self, ix):
        x, y = self.x[ix], self.y[ix]
        return x.to(device), y.to(device)
    def __len__(self):
        return len(self.x) 

请注意,在我们突出显示的代码行中(x = x.float()/(255*10000)),我们通过将其除以 10000 来减少输入像素值的范围。

接下来,我们必须重新定义get_model函数,以便我们可以获取模型的预测以及隐藏层的值。我们可以通过指定一个神经网络类来做到这一点,如下所示:

def get_model():
    class neuralnet(nn.Module):
        def __init__(self):
            super().__init__()
            self.input_to_hidden_layer = nn.Linear(784,1000)
            self.hidden_layer_activation = nn.ReLU()
            self.hidden_to_output_layer = nn.Linear(1000,10)
        def forward(self, x):
            x = self.input_to_hidden_layer(x)
            x1 = self.hidden_layer_activation(x)
            x2= self.hidden_to_output_layer(x1)
            return x2, x1
    model = neuralnet().to(device)
    loss_fn = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters(), lr=1e-3)
    return model, loss_fn, optimizer 

在上述代码中,我们定义了neuralnet类,该类返回输出层值(x2)和隐藏层的激活值(x1)。请注意,网络的架构没有改变。

给定 get_model 函数现在返回两个输出,我们需要修改 train_batchval_loss 函数,这些函数进行预测时,通过模型传递输入。在这里,我们只会获取输出层的数值,而不是隐藏层的数值。考虑到从模型返回的内容中输出层的数值位于第 0^(th) 索引中,我们将修改这些函数,使它们只获取预测结果的第 0^(th) 索引,如下所示:

def train_batch(x, y, model, opt, loss_fn):
    model.train()
    **prediction = model(x)[****0****]**
    batch_loss = loss_fn(prediction, y)
    batch_loss.backward()
    optimizer.step()
    optimizer.zero_grad()
    return batch_loss.item()
def accuracy(x, y, model):
    model.eval()
    with torch.no_grad():
        **prediction = model(x)[****0****]**
    max_values, argmaxes = prediction.max(-1)
    is_correct = argmaxes == y
    return is_correct.cpu().numpy().tolist() 

请注意,上述代码的突出部分是我们确保只获取模型输出的第 0^(th) 索引处的位置(因为第 0^(th) 索引包含输出层的数值)。

现在,当我们运行 Scaling the data 部分提供的其余代码时,我们将看到随着 epochs 增加,训练和验证数据集中准确率和损失值的变化如下:

图表  自动生成的描述

图 3.20:当网络使用非常小的输入值训练时的训练和验证损失与准确率

注意,在上述情景中,即使经过 100 个 epochs,模型训练效果仍然不佳(在之前的章节中,模型在验证数据集上的准确率在 10 个 epochs 内就能达到约 90%,而当前模型的验证准确率仅为约 85%)。

让我们通过探索隐藏值的分布以及参数分布,来理解当输入值具有非常小的范围时,为何模型训练效果不佳:

一个带有数字的蓝色矩形对象  自动生成的描述

图 3.21:当网络使用非常小的输入值训练时的权重和隐藏层节点数值的分布

请注意,第一个分布指示了隐藏层数值的分布(我们可以看到这些数值具有非常小的范围)。此外,考虑到输入和隐藏层数值都非常小,权重必须经过大量变化(包括连接输入到隐藏层的权重和连接隐藏层到输出层的权重)。

现在我们知道,当输入值具有非常小的范围时,网络训练效果不佳,让我们来了解批标准化如何帮助增加隐藏层中数值的范围。

使用批标准化的非常小的输入值

在本节中,我们将只对前一小节的代码进行一处更改;也就是在定义模型架构时添加批标准化。

修改后的 get_model 函数如下所示:

def get_model():
    class neuralnet(nn.Module):
        def __init__(self):
            super().__init__()
            self.input_to_hidden_layer = nn.Linear(784,1000)
            **self.batch_norm = nn.BatchNorm1d(****1000****)**
            self.hidden_layer_activation = nn.ReLU()
            self.hidden_to_output_layer = nn.Linear(1000,10)
        def forward(self, x):
            x = self.input_to_hidden_layer(x)
            **x0 = self.batch_norm(x)**
 **x1 = self.hidden_layer_activation(x0)**
            x2= self.hidden_to_output_layer(x1)
            return x2, x1
    model = neuralnet().to(device)
    loss_fn = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters(), lr=1e-3)
    return model, loss_fn, optimizer 

在前面的代码中,我们声明了一个变量(batch_norm),它执行批量归一化(nn.BatchNorm1d)。我们执行nn.BatchNorm1d(1000)的原因是因为每个图像的输出维度为 1,000(即隐藏层的一维输出)。此外,在forward方法中,我们将隐藏层的输出值通过批量归一化传递,然后通过 ReLU 激活。

随着训练和验证数据集在增加的 epochs 中准确性和损失值的变化如下:

图 3.22:当网络使用非常小的输入值和批量归一化进行训练时的训练和验证损失

在这里,我们可以看到该模型的训练方式与输入值范围不太小时的训练方式非常相似。现在,让我们了解隐藏层值的分布和权重分布,正如在前一节中看到的:

图表,直方图 描述自动生成

图 3.23:当网络使用非常小的输入值和批量归一化进行训练时,权重和隐藏层节点值的分布

我们可以看到,当我们使用批量归一化时,隐藏层的值具有更广泛的分布,连接隐藏层与输出层的权重具有更小的分布。这导致模型学习效果与前面的部分一样有效。

在训练深度神经网络时,批量归一化大大有助于我们。它帮助我们避免梯度变得太小,以至于权重几乎不会更新。

到目前为止,我们已经看到了训练损失和准确率比验证准确率和损失要好得多的情况:这表明模型在训练数据上过拟合,但在验证数据集上泛化能力不强。接下来我们将看看如何解决过拟合问题。

过拟合的概念

到目前为止,我们看到训练数据集的准确率通常超过 95%,而验证数据集的准确率约为 89%。基本上,这表明模型在未见数据上的泛化能力不强,因为它可以从训练数据集中学习。这还表明,模型学习了训练数据集的所有可能边界情况,这些情况不能应用于验证数据集。

在训练数据集上有很高的准确率,而在验证数据集上有相对较低的准确率,指的是过拟合的情况。

一些常见的策略用于减少过拟合的影响,包括dropout和正则化。接下来我们将看看它们对训练和验证损失的影响。

添加 dropout 的影响

我们已经学到,每当计算 loss.backward() 时,权重更新就会发生。通常情况下,我们会在网络内有数十万个参数,并且有数千个数据点用于训练我们的模型。这给我们可能性,即使大多数参数有助于合理地训练模型,某些参数可能会被微调以训练图像,导致它们的值仅由训练数据集中的少数图像决定。这反过来导致训练数据在高准确性上表现良好,但在验证数据集上并非必然如此。

Dropout 是一种机制,它随机选择指定百分比的节点激活并将其减少为 0。在下一次迭代中,另一组随机的隐藏单元将被关闭。这样,神经网络不会针对边缘情况进行优化,因为网络在每次迭代中都没有那么多机会来调整权重以记住边缘情况(鉴于权重不是每次迭代都会更新)。

请记住,在预测过程中,不需要应用 dropout,因为这种机制只能在训练模型时应用。

通常,在训练和验证过程中,层的行为可能会有所不同,就像在 dropout 的情况下所见。因此,您必须在模型前端使用以下两种方法之一指定模式:model.train() 用于让模型知道它处于训练模式,以及 model.eval() 用于让模型知道它处于评估模式。如果我们不这样做,可能会得到意想不到的结果。例如,在下图中,请注意模型(其中包含 dropout)在训练模式下对相同输入给出不同预测的情况。

然而,当同一模型处于 eval 模式时,它将抑制 dropout 层并返回相同输出:

图形用户界面,文本,应用 自动生成的描述

图 3.24:model.eval()对输出值的影响

在定义架构时,在 get_model 函数中如下指定 Dropout

完整代码可以在 GitHub 上的 Impact_of_dropout.ipynb 文件中的 Chapter03 文件夹中找到,网址为 bit.ly/mcvp-2e。为简洁起见,我们在执行代码时不会详细介绍每个步骤。在执行代码时,请参考 GitHub 上的笔记本。

def get_model():
    model = nn.Sequential(
                **nn.Dropout(****0.25****),**
                nn.Linear(28 * 28, 1000),
                nn.ReLU(),
                **nn.Dropout(****0.25****),**
                nn.Linear(1000, 10)
            ).to(device)
    loss_fn = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters(), lr=1e-3)
    return model, loss_fn, optimizer 

请注意,在前述代码中,Dropout 在线性激活之前指定。这指定了线性激活层中一定比例的权重不会被更新。

一旦模型训练完成,就像 Batch size of 32 部分一样,训练集和验证集的损失和准确性值如下:

图表,折线图 自动生成的描述

图 3.25:使用 dropout 的训练和验证损失及准确度

在这种情况下,训练集和验证集的准确性之间的差异不像前一情景中那么大,因此导致了一个具有较少过拟合的情况。

正则化的影响

除了训练准确性远高于验证准确性之外,过拟合的另一个特征是某些权重值远高于其他权重值。大权重值可以是模型在训练数据上学习得非常好的迹象(基本上是根据所见的内容进行死记硬背学习)。

Dropout 是一种机制,用于使权重值的更新频率降低,而正则化是另一种我们可以用于此目的的机制。

正则化是一种技术,通过惩罚模型具有大权重值来实现。因此,它是一种目标函数,旨在最小化训练数据的损失以及权重值。在本节中,我们将学习两种类型的正则化:L1 正则化和 L2 正则化。

完整的代码可以在 GitHub 的Chapter03文件夹中的Impact_of_regularization.ipynb文件中找到,链接为bit.ly/mcvp-2e。为了简洁起见,我们不会详细说明来自批量大小为 32部分的每一步。在执行代码时,请参考 GitHub 上的笔记本。

让我们开始吧!

L1 正则化

L1 正则化计算如下:

前面公式的第一部分是我们到目前为止用于优化的分类交叉熵损失,而第二部分是指模型权重值的绝对和。请注意,L1 正则化通过将权重的绝对值的和纳入损失值计算中来确保对高绝对权重值进行惩罚。

指的是我们与正则化(权重最小化)损失相关联的权重。

在训练模型时实施 L1 正则化,如下所示:

def train_batch(x, y, model, opt, loss_fn):
    model.train()
    prediction = model(x)
    **l1_regularization =** **0**
    for param in model.parameters():
        **l1_regularization += torch.norm(param,****1****)**
    **batch_loss = loss_fn(prediction, y)+****0.0001*****l1_regularization**
    batch_loss.backward()
    optimizer.step()
    optimizer.zero_grad()
    return batch_loss.item() 

你可以看到,我们通过初始化l1_regularization在所有层上强制实施了权重和偏差的正则化。

torch.norm(param,1)提供了跨层权重和偏差值的绝对值。此外,我们还有一个与层间参数的绝对值的和相关联的非常小的权重(0.0001)。

一旦我们执行剩余代码,如批量大小为 32部分所示,随着增加的 epochs,训练和验证数据集的损失和准确率值将如下:

图表 自动生成的描述

图 3.26:使用 L1 正则化的训练和验证损失以及准确率

如您所见,训练集和验证集的准确性之间的差异与没有 L1 正则化时相比并不高。

L2 正则化

L2 正则化计算如下:

前述公式的第一部分指的是获取的分类交叉熵损失,而第二部分指的是模型权重值的平方和。与 L1 正则化类似,通过将权重的平方和纳入损失值计算中,我们惩罚了较大的权重值。

指的是我们与正则化(权重最小化)损失相关联的权重值。

在训练模型时实施 L2 正则化,具体如下:

def train_batch(x, y, model, opt, loss_fn):
    model.train()
    prediction = model(x)
    **l2_regularization =** **0**
    for param in model.parameters():
        **l2_regularization += torch.norm(param,****2****)**
    **batch_loss = loss_fn(prediction, y) +** **0.01*****l2_regularization**
    batch_loss.backward()
    optimizer.step()
    optimizer.zero_grad()
    return batch_loss.item() 

在前述代码中,正则化参数, (0.01),略高于 L1 正则化,因为权重通常在 -1 和 1 之间,平方后会变得更小。将它们乘以更小的数字,正如我们在 L1 正则化中所做的那样,会导致我们在整体损失计算中对正则化的权重非常小。执行剩余代码后,就像 批大小为 32 部分一样,随着 epoch 的增加,训练和验证数据集的损失和准确率值如下:

训练和验证的图形化描述

图 3.27:使用 L2 正则化的训练和验证损失以及准确率

我们可以看到,L2 正则化还导致了验证和训练数据集的准确率和损失值更加接近。

总结

我们从学习图像表示开始了解本章内容。然后,我们学习了如何通过缩放、学习率的值、优化器的选择以及批大小来提高训练的准确性和速度。接着,我们了解了批归一化如何提高训练速度并解决隐藏层中的非常小或非常大的值问题。然后,我们学习了如何通过调整学习率来进一步提高准确性。最后,我们深入了解了过拟合的概念,并学习了如何通过 dropout 以及 L1 和 L2 正则化来避免过拟合。

现在我们已经学习了使用深度神经网络进行图像分类以及有助于训练模型的各种超参数,在下一章中,我们将学习本章所学内容的失败情况以及如何使用卷积神经网络解决这些问题。

问题

  1. 如果输入数据集中的输入值未经过缩放,会发生什么?

  2. 当训练神经网络时,如果背景是白色像素而内容是黑色像素,可能会发生什么?

  3. 批大小对模型训练时间和内存的影响是什么?

  4. 输入值范围对训练结束时权重分布的影响是什么?

  5. 批归一化如何帮助提高准确率?

  6. 为什么权重在 dropout 层的训练和评估过程中表现不同?

  7. 我们如何知道模型是否在训练数据上过拟合了?

  8. 正则化如何帮助避免过拟合?

  9. L1 和 L2 正则化有何不同?

  10. 丢弃法如何帮助减少过拟合?

了解更多关于 Discord

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第二部分:目标分类和检测

在掌握了神经网络 (NN) 基础的理解之后,在本节中,我们将探索建立在这些基础之上的更复杂的神经网络模块,以解决更复杂的与视觉相关的问题,包括目标检测、图像分类和分割以及许多其他问题。

本节包括以下章节:

  • 第四章, 卷积神经网络简介

  • 第五章, 图像分类的迁移学习

  • 第六章, 图像分类的实际方面

  • 第七章, 目标检测基础

  • 第八章, 高级目标检测

  • 第九章, 图像分割

  • 第十章, 目标检测和分割的应用

第四章:引入卷积神经网络

到目前为止,我们学习了如何构建深度神经网络以及调整它们的各种超参数所产生的影响。在本章中,我们将了解传统深度神经网络无法解决的问题。然后,我们将通过一个玩具例子了解卷积神经网络CNNs)的内部工作原理,然后理解它们的一些主要超参数,包括步幅、池化和滤波器。接下来,我们将利用 CNN 以及各种数据增强技术来解决传统深度神经网络精度不高的问题。然后,我们将了解 CNN 中特征学习过程的结果是什么样子。最后,我们将把学到的东西结合起来解决一个用例:我们将通过声明图像中包含狗还是猫来分类图像。通过这样做,我们将能够理解预测准确性如何随着用于训练的数据量的变化而变化。

到本章末尾,您将对 CNN 有深入的理解,CNN 是多个模型架构的基础,用于各种任务。

本章将涵盖以下主题:

  • 传统深度神经网络的问题

  • CNN 的构建模块

  • 实现 CNN

  • 使用深度 CNN 分类图像

  • 实现数据增强

  • 可视化特征学习的结果

  • 为了对真实世界图像进行分类构建 CNN

让我们开始吧!

本章中的所有代码都可在此书的 GitHub 存储库的Chapter04文件夹中找到:bit.ly/mcvp-2e

传统深度神经网络的问题

在深入研究 CNN 之前,让我们看看在使用传统深度神经网络时面临的主要问题。

让我们重新考虑我们在第三章中在 Fashion-MNIST 数据集上构建的模型。我们将获取一个随机图像并预测与该图像对应的类别,如下所示:

以下代码可以在 GitHub 上Chapter04文件夹中的Issues_with_image_translation.ipynb文件中找到:bit.ly/mcvp-2e。这里仅讨论与图像翻译问题对应的附加代码,以保持简洁。

  1. 获取可用训练图像中的随机图像:

    # Note that you should run the code in
    # Batch size of 32 section in Chapter 3
    # before running the following code
    import matplotlib.pyplot as plt
    %matplotlib inline
    # ix = np.random.randint(len(tr_images))
    ix = 24300
    plt.imshow(tr_images[ix], cmap='gray')
    plt.title(fmnist.classes[tr_targets[ix]]) 
    

前面的代码将产生以下输出:

图 4.1:与索引 24300 相对应的图像

  1. 将图像通过训练好的模型传递(继续使用我们在第三章批量大小为 32节中训练的模型):

    1. 预处理图像,使其经过与构建模型时执行的相同预处理步骤:
    img = tr_images[ix]/255.
    img = img.view(28*28)
    img = img.to(device) 
    
    1. 提取与各种类别相关联的概率:
    np_output = model(img).cpu().detach().numpy()
    np.exp(np_output)/np.sum(np.exp(np_output)) 
    
  2. 前面的代码将产生以下输出,我们可以看到最高概率是第一个索引,即裤子类:

    图 4.2:不同类别的概率

  3. 将图像多次(每次移动一个像素)从向左 5 像素到向右 5 像素进行翻译,并将预测结果存储在列表中:

    1. 创建一个存储预测的列表:
    preds = [] 
    
    1. 创建一个循环,将图像从原始位置的-5 像素(向左 5 像素)滚动到+5 像素(向右 5 像素):
    for px in range(-5,6): 
    
    1. 在前述代码中,我们指定 6 作为上限,尽管我们只关注将图像翻译到+5 像素,因为范围的输出将为-5 到+5,而(-5,6)是指定范围的输出。

    2. 预处理图像,就像我们在步骤 2中所做的那样:

     img = tr_images[ix]/255.
        img = img.view(28, 28) 
    
    1. for循环中以px的值滚动图像:
     img2 = np.roll(img, px, axis=1) 
    
    1. 在这里,我们指定axis=1,因为我们希望图像像素在水平方向移动而不是垂直方向。

    2. 将滚动后的图像存储为张量对象并注册到device

     img3 = torch.Tensor(img2).view(28*28).to(device) 
    
    1. img3通过训练模型以预测滚动图像的类,并将其附加到存储各种翻译预测的列表中:
     np_output = model(img3).cpu().detach().numpy()
        preds.append(np.exp(np_output)/np.sum(np.exp(np_output))) 
    
  4. 可视化模型对所有翻译(-5 像素到+5 像素)的预测:

    import seaborn as sns
    fig, ax = plt.subplots(1,1, figsize=(12,10))
    plt.title('Probability of each class \
    for various translations')
    sns.heatmap(np.array(preds), annot=True, ax=ax, fmt='.2f',
    xticklabels=fmnist.classes,yticklabels=[str(i)+ \
    str(' pixels') for i in range(-5,6)], cmap='gray') 
    

前面的代码导致以下输出:

图 4.3:不同翻译每个类的概率

由于我们只将图像从左移动 5 像素到右移动 5 像素,因此图像内容没有变化。但是,当翻译超过 2 像素时,图像的预测类别会发生变化。这是因为在模型训练时,所有训练和测试图像的内容都在中心。这与前一情景不同,我们测试的是偏离中心的翻译图像(偏移 5 像素),导致预测类别不正确。

现在我们已经了解了传统神经网络失败的场景,接下来我们将学习 CNN 如何帮助解决这个问题。但在此之前,让我们首先看一下 CNN 的基本组成部分。

CNN 的基本构建块

CNN 在处理图像时是最突出的架构之一。它们解决了深度神经网络的主要局限性,就像我们在前一节中看到的那样。除了图像分类外,它们还有助于目标检测、图像分割、生成对抗网络等各种任务 - 实质上是我们使用图像的任何地方。此外,有多种构建 CNN 的方式,也有多个预训练模型利用 CNN 执行各种任务。从本章开始,我们将广泛使用 CNN。

在接下来的小节中,我们将理解 CNN 的基本构建块,它们如下:

  • 卷积

  • 过滤器

  • 步幅和填充

  • 池化

让我们开始吧!

卷积

卷积基本上是两个矩阵之间的乘法。正如您在前一章节中看到的,矩阵乘法是训练神经网络的关键组成部分。(我们在计算隐藏层值时执行矩阵乘法——这是输入值和连接输入到隐藏层的权重值之间的矩阵乘法。类似地,我们执行矩阵乘法来计算输出层值。)

为了确保我们对卷积过程有牢固的理解,让我们通过一个例子来进行说明。假设我们有两个矩阵用于执行卷积。

这里是矩阵 A:

图 4.4:矩阵 A

这里是矩阵 B:

图 4.5:矩阵 B

在执行卷积操作时,我们将矩阵 B(较小的矩阵)滑动到矩阵 A(较大的矩阵)上。此外,我们在矩阵 A 和矩阵 B 之间执行元素对元素的乘法,如下所示:

  1. 乘以较大矩阵的{1,2,5,6}部分与较小矩阵的{1,2,3,4}部分:

11 + 22 + 53 + 64 = 44

  1. 乘以较大矩阵的{2,3,6,7}部分与较小矩阵的{1,2,3,4}部分:

21 + 32 + 63 + 74 = 54

  1. 乘以较大矩阵的{3,4,7,8}部分与较小矩阵的{1,2,3,4}部分:

31 + 42 + 73 + 84 = 64

  1. 乘以较大矩阵的{5,6,9,10}部分与较小矩阵的{1,2,3,4}部分:

51 + 62 + 93 + 104 = 84

  1. 乘以较大矩阵的{6,7,10,11}部分与较小矩阵的{1,2,3,4}部分:

61 + 72 + 103 + 114 = 94

  1. 乘以较大矩阵的{7,8,11,12}部分与较小矩阵的{1,2,3,4}部分:

71 + 82 + 113 + 124 = 104

  1. 乘以较大矩阵的{9,10,13,14}部分与较小矩阵的{1,2,3,4}部分:

91 + 102 + 133 + 144 = 124

  1. 乘以较大矩阵的{10,11,14,15}部分与较小矩阵的{1,2,3,4}部分:

101 + 112 + 143 + 154 = 134

  1. 乘以较大矩阵的{11,12,15,16}部分与较小矩阵的{1,2,3,4}部分:

111 + 122 + 153 + 164 = 144

执行上述操作的结果如下所示:

图 4.6:卷积操作的输出

较小的矩阵通常被称为滤波器或内核,而较大的矩阵是原始图像。

滤波器

一个滤波器是一组权重的矩阵,初始时以随机方式初始化。模型随着时间的推移学习到滤波器的最优权重值。滤波器的概念带来了两个不同的方面:

  • 滤波器学习的内容

  • 如何表示滤波器

通常情况下,CNN 中的滤波器越多,模型能够学习图像的特征也就越多。在本章的可视化特征学习结果部分中,我们将学习各种滤波器学习的内容。目前,我们先掌握这样一个中间理解,即滤波器学习图像中不同特征的情况。例如,某个滤波器可能学习猫的耳朵,并在其卷积的图像部分包含猫耳朵时提供高激活(矩阵乘法值)。

在前一节中,当我们将大小为 2 x 2 的滤波器与大小为 4 x 4 的矩阵进行卷积时,我们得到的输出尺寸为 3 x 3。然而,如果有 10 个不同的滤波器与更大的矩阵(原始图像)相乘,则结果将是 10 组 3 x 3 的输出矩阵。

在上述情况下,一个 4 x 4 的图像与大小为 2 x 2 的 10 个滤波器进行卷积,得到 3 x 3 x 10 的输出值。基本上,当图像被多个滤波器卷积时,输出的通道数等于图像被卷积的滤波器数目。

此外,在处理彩色图像的情况下,图像有三个通道,与原始图像进行卷积的滤波器也将有三个通道,导致每次卷积得到单个标量输出。另外,如果滤波器与中间输出(例如形状为 64 x 112 x 112)进行卷积,滤波器将具有 64 个通道以获取标量输出。此外,如果有 512 个滤波器与中间层获得的输出进行卷积,使用 512 个滤波器的卷积输出将具有形状为 512 x 112 x 112。

为了进一步巩固我们对滤波器输出的理解,让我们看看以下的图示:

图 4.7: 多个滤波器进行卷积操作的输出

在前述图中,我们可以看到输入图像被与其深度相同的滤波器乘以,并且卷积输出的通道数与卷积的滤波器数目相同。

步幅和填充

在前一节中,每个滤波器跨越图像时,一次跨越一列和一行(在图像末尾穷尽所有可能的列之后)。这也导致输出尺寸在高度和宽度上比输入图像尺寸少 1 个像素。这会导致信息的部分丢失,并且如果卷积的输出和原始图像不具有相同的形状,则可能限制我们将卷积操作的输出添加到原始图像中的可能性。这被称为残差添加,并将在下一章节中详细讨论。目前,让我们学习步幅和填充如何影响卷积操作的输出形状。

步幅

让我们通过利用“滤波器”部分中看到的同一示例来理解步幅的影响。我们将矩阵 B 以步幅 2 移动到矩阵 A 上。因此,步幅为 2 的卷积输出如下所示:

  1. 大矩阵的{1,2,5,6}与小矩阵的{1,2,3,4}相乘:

11 + 22 + 53 + 64 = 44

  1. 大矩阵的{3,4,7,8}与小矩阵的{1,2,3,4}相乘:

31 + 42 + 73 + 84 = 64

  1. 大矩阵的{9,10,13,14}与小矩阵的{1,2,3,4}相乘:

91 + 102 + 133 + 144 = 124

  1. 大矩阵的{11,12,15,16}与小矩阵的{1,2,3,4}相乘:

111 + 122 + 153 + 164 = 144

执行上述操作的结果如下:

图 4.8:步幅卷积输出

由于我们现在步幅为 2,请注意前述输出与步幅为 1 时相比具有较低的维度(其中输出形状为 3 x 3)。

填充

在前述情况下,我们不能将过滤器的最左侧元素与图像的最右侧元素相乘。如果我们执行这样的矩阵乘法,我们将在图像上加入零填充。这将确保我们可以对图像中所有元素与滤波器中的元素进行逐元素乘法。

让我们通过使用“卷积”部分中使用的同一示例来理解填充。一旦在矩阵 A 上添加填充,修订后的矩阵 A 将如下所示:

图 4.9:矩阵 A 上的填充

正如你所见,我们已经用零填充了矩阵 A,并且与矩阵 B 的卷积不会导致输出维度小于输入维度。在我们处理残差网络时,这一点非常有用,因为我们必须将卷积的输出添加到原始图像中。

一旦完成这一步,我们可以对卷积操作的输出执行激活。对于这一步,我们可以使用第三章中看到的任何激活函数。

池化

池化在一个小区块中聚合信息。想象一种情况,卷积激活的输出如下所示:

图 4.10:卷积操作的输出

此区域的最大池化为 4,因为这是区块中数值的最大值。让我们理解更大矩阵的最大池化:

图 4.11:卷积操作的输出

在前述情况下,如果池化步幅长度为 2,则最大池化操作计算如下,我们将输入图像通过步幅 2 进行分割(即,我们将图像分割成 2 x 2 的分区):

图 4.12:突出显示的步幅卷积输出

对于矩阵的四个子部分,元素池中的最大值如下:

图 4.13:最大池化值

在实践中,并非总是需要步幅为 2;这里仅用于举例说明。池化的其他变体包括求和池化和平均池化。然而,在实践中,最大池化更常用。

请注意,通过执行卷积和池化操作后,原始矩阵的大小从 4 x 4 减小为 2 x 2。在现实情况下,如果原始图像的形状为 200 x 200 并且滤波器的形状为 3 x 3,则卷积操作的输出将是 198 x 198。随后,步长为 2 的池化操作的输出将是 99 x 99。这样,通过利用池化,我们保留了更重要的特征同时减少了输入的维度。

将它们整合在一起

到目前为止,我们已经了解了卷积、滤波器、步幅、填充和池化,以及它们在减少图像维度中的作用。现在,我们将了解 CNN 的另一个关键组成部分——平坦化层(全连接层)——然后将我们学到的三个部分整合在一起。

要理解平坦化过程,我们将使用前一节中池化层的输出,并对其进行平坦化处理。平坦化池化层的输出是 {6, 8, 14, 16}。

通过这样做,我们会看到平坦化层可以等同于输入层(在第三章中我们将输入图像压平为 784 维输入)。一旦获取了平坦化层(全连接层)的值,我们可以将其传递到隐藏层然后获取用于预测图像类别的输出。

CNN 的整体流程如下:

图 4.14:CNN 工作流程

我们可以看到 CNN 模型的整体流程,我们通过多个滤波器将图像通过卷积传递,然后通过池化(在前述情况下,重复两次卷积和池化过程),最后将最终池化层的输出进行平坦化处理。这形成了前述图像的特征学习部分,其中我们将图像转换为较低维度(平坦化输出),同时恢复所需的信息。

卷积和池化操作构成了特征学习部分,滤波器帮助从图像中提取相关特征,池化则有助于聚合信息,从而减少在平坦化层的节点数量。

如果直接将输入图像(例如尺寸为 300 x 300 像素)进行平坦化处理,我们处理的是 90K 个输入值。如果输入有 90K 个像素值并且隐藏层有 100K 个节点,我们需要处理约 9 亿个参数,这在计算上是巨大的。

卷积和池化有助于获取一个比原始图像更小的平坦化层表示。

最后,分类的最后部分类似于我们在第三章中对图像进行分类的方式,那里有一个隐藏层然后获得输出层。

卷积和池化在图像翻译中的帮助

当我们执行池化操作时,可以将操作的输出视为一个区域(一个小补丁)的抽象。特别是在图像被翻译时,这种现象非常方便。

想象一种情景,图像向左平移了 1 个像素。一旦我们对其执行卷积、激活和池化,我们将减少图像的维度(由于池化),这意味着较少数量的像素存储了来自原始图像的大部分信息。此外,由于池化存储了区域(补丁)的信息,即使原始图像平移了 1 个单位,池化图像中一个像素的信息也不会变化。这是因为该区域的最大值很可能已被捕获在池化图像中。

卷积和池化还可以帮助我们实现感受野。要理解感受野,让我们想象一个场景,我们在一个形状为 100 x 100 的图像上进行两次卷积 + 池化操作。如果卷积操作进行了填充,那么在两次卷积池化操作结束时的输出形状将为 25 x 25。25 x 25 输出中的每个单元格现在对应于原始图像中一个较大的 4 x 4 部分。因此,由于卷积和池化操作,结果图像中的每个单元格都包含原始图像的一个补丁内的关键信息。

现在我们已经了解了 CNN 的核心组件,让我们通过一个玩具示例将它们应用起来,以理解它们如何一起工作。

实施 CNN

CNN 是计算机视觉技术的基础组成部分之一,对于您深入理解它们的工作原理非常重要。虽然我们已经知道 CNN 包括卷积、池化、展平,然后是最终的分类层,但在本节中,我们将通过代码了解 CNN 前向传播期间发生的各种操作。

要对此有一个坚实的理解,首先,我们将使用 PyTorch 在一个玩具示例上构建一个 CNN 架构,然后通过 Python 从头开始构建前向传播以匹配输出。CNN 架构将与我们在上一章中构建的神经网络架构有所不同,因为 CNN 除了典型的香草深度神经网络外,还包括以下内容:

  • 卷积操作

  • 池化操作

  • 展平层

在以下代码中,我们将在玩具数据集上构建一个 CNN 模型,如下所示:

可在 GitHub 的Chapter04文件夹中找到CNN_working_details.ipynb文件中的以下代码:bit.ly/mcvp-2e

  1. 首先,我们需要导入相关的库:

    import torch
    from torch import nn
    from torch.utils.data import TensorDataset, Dataset, DataLoader
    from torch.optim import SGD, Adam
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    from torchvision import datasets
    import numpy as np
    import matplotlib.pyplot as plt
    %matplotlib inline 
    
  2. 接下来,我们需要按照以下步骤创建数据集:

    X_train = torch.tensor([[[[1,2,3,4],[2,3,4,5],
                              [5,6,7,8],[1,3,4,5]]],
                            [[[-1,2,3,-4],[2,-3,4,5],
                [-5,6,-7,8],[-1,-3,-4,-5]]]]).to(device).float()
    X_train /= 8
    y_train = torch.tensor([0,1]).to(device).float() 
    

注意,PyTorch 期望输入的形状为N x C x H x W,其中N是图像的数量(批量大小),C是通道数,H是高度,W是图像的宽度。

在这里,我们将输入数据集进行缩放,使其范围在-1 到+1 之间,通过将输入数据除以最大输入值即 8。输入数据集的形状为(2,1,4,4),因为有两个数据点,每个数据点的形状为 4 x 4,并且有 1 个通道。

  1. 定义模型架构:

    def get_model():
        model = nn.Sequential(
                    nn.Conv2d(1, 1, kernel_size=3),
                    nn.MaxPool2d(2),
                    nn.ReLU(),
                    nn.Flatten(),
                    nn.Linear(1, 1),
                    nn.Sigmoid(),
                ).to(device)
        loss_fn = nn.BCELoss()
        optimizer = Adam(model.parameters(), lr=1e-3)
        return model, loss_fn, optimizer 
    

注意,在上述模型中,我们指定输入中有 1 个通道,并且我们使用nn.Conv2d方法在卷积后提取 1 个通道的输出(即,我们有 1 个大小为 3 x 3 的滤波器)。

然后,我们使用nn.MaxPool2d进行最大池化和 ReLU 激活(使用nn.Relu()),然后扁平化并连接到最终层,每个数据点有一个输出。

此外,请注意,损失函数是二元交叉熵损失(nn.BCELoss()),因为输出来自二元类别。我们还指定优化将使用学习率为 0.001 的 Adam 优化器进行。

  1. 使用torch_summary包中的summary方法对我们的model、损失函数(loss_fn)和optimizer进行获取后,总结模型的架构:

    !pip install torch_summary
    from torchsummary import summary
    model, loss_fn, optimizer = get_model()
    summary(model, X_train); 
    

上述代码产生以下输出:

图 4.15:模型架构摘要

让我们理解每一层包含多少参数的原因。Conv2d类的参数如下:

图 4.16:Conv2d 中的参数说明

在前面的例子中,我们指定卷积核的大小(kernel_size)为 3,并且out_channels的数量为 1(本质上,滤波器的数量为 1),其中初始(输入)通道的数量为 1。因此,对于每个输入图像,我们在一个形状为 1 x 4 x 4 的图像上卷积一个形状为 3 x 3 的滤波器,得到一个形状为 1 x 2 x 2 的输出。有 10 个参数,因为我们正在学习九个权重参数(3 x 3)和卷积核的一个偏置。对于MaxPool2d、ReLU 和 flatten 层,没有参数,因为这些是在卷积层输出之上执行的操作;不涉及权重或偏置。

线性层有两个参数 - 一个权重和一个偏置 - 这意味着总共有 12 个参数(来自卷积操作的 10 个和线性层的两个)。

  1. 使用我们在第三章中使用的相同模型训练代码来训练模型,定义将对数据批次进行训练的函数(train_batch)。然后,获取DataLoader并在 2,000 个 epochs 中对数据批次进行训练(我们只使用 2,000 个是因为这是一个小型玩具数据集),如下所示:

    1. 定义将对数据批次进行训练的函数(train_batch):
    def train_batch(x, y, model, opt, loss_fn):
        model.train()
        prediction = model(x)
        batch_loss = loss_fn(prediction.squeeze(0), y)
        batch_loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        return batch_loss.item() 
    
    1. 使用 TensorDataset 方法指定数据集来定义训练 DataLoader,然后使用 DataLoader 加载它:
    trn_dl = DataLoader(TensorDataset(X_train, y_train)) 
    
    1. 鉴于我们没有对输入数据进行大量修改,我们不会单独构建一个类,而是直接利用 TensorDataset 方法,该方法提供了与输入数据对应的对象。

    2. 在 2,000 个 epochs 上训练模型:

    for epoch in range(2000):
        for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            batch_loss = train_batch(x, y, model,optimizer, loss_fn) 
    
  2. 使用上述代码,我们已经在我们的玩具数据集上训练了 CNN 模型。

  3. 对第一个数据点执行前向传播:

    model(X_train[:1]) 
    

上述代码的输出是 0.1625

请注意,由于执行上述代码时随机权重初始化可能不同,因此您可能会有不同的输出值。

使用 GitHub 仓库中的 CNN from scratch in Python.pdf 文件,我们可以学习如何从头开始构建 CNN 中的前向传播,并在第一个数据点上复制输出 0.1625。

在接下来的部分中,我们将把这个应用到 Fashion-MNIST 数据集,并看看它在翻译后的图像上表现如何。

使用深度 CNN 对图像进行分类

到目前为止,我们已经看到传统神经网络对翻译图像预测不正确。这需要解决,因为在实际情况中,会需要应用各种增强技术,如翻译和旋转,这些在训练阶段没有看到。在本节中,我们将了解 CNN 如何解决在 Fashion-MNIST 数据集的图像发生翻译时预测不正确的问题。

Fashion-MNIST 数据集的预处理部分与上一章节相同,除了当我们对输入数据进行重塑(.view)时,我们不再将输入展平为 28 x 28 = 784 维度,而是将每个图像重塑为形状为 (1,28,28)(请记住,首先需要指定通道,然后是它们的高度和宽度,在 PyTorch 中):

可在 GitHub 的 Chapter04 文件夹中的 CNN_on_FashionMNIST.ipynb 文件中找到以下代码:bit.ly/mcvp-2e.

  1. 导入必要的包:

    from torchvision import datasets
    from torch.utils.data import Dataset, DataLoader
    import torch
    import torch.nn as nn
    device = "cuda" if torch.cuda.is_available() else "cpu"
    import numpy as np
    import matplotlib.pyplot as plt
    %matplotlib inline
    data_folder = '~/data/FMNIST' # This can be any directory you
    # want to download FMNIST to
    fmnist = datasets.FashionMNIST(data_folder,download=True, train=True)
    tr_images = fmnist.data
    tr_targets = fmnist.targets 
    
  2. Fashion-MNIST 数据集类定义如下。请记住,Dataset 对象将总是需要我们定义的 __init____getitem____len__ 方法:

    class FMNISTDataset(Dataset):
        def __init__(self, x, y):
            x = x.float()/255
            **x = x.view(-****1****,****1****,****28****,****28****)**
            self.x, self.y = x, y
        def __getitem__(self, ix):
            x, y = self.x[ix], self.y[ix]
            return x.to(device), y.to(device)
        def __len__(self):
            return len(self.x) 
    

粗体的代码行是我们重塑每个输入图像的地方(与前一章节所做的不同),因为我们正在为期望每个输入具有批大小 x 通道 x 高度 x 宽度形状的 CNN 提供数据。

  1. CNN 模型架构定义如下:

    from torch.optim import SGD, Adam
    def get_model():
        model = nn.Sequential(
                    nn.Conv2d(1, 64, kernel_size=3),
                    nn.MaxPool2d(2),
                    nn.ReLU(),
                    nn.Conv2d(64, 128, kernel_size=3),
                    nn.MaxPool2d(2),
                    nn.ReLU(),
                    nn.Flatten(),
                    nn.Linear(3200, 256),
                    nn.ReLU(),
                    nn.Linear(256, 10)
                ).to(device)
        loss_fn = nn.CrossEntropyLoss()
        optimizer = Adam(model.parameters(), lr=1e-3)
        return model, loss_fn, optimizer 
    
  2. 可以使用以下代码创建模型总结:

    from torchsummary import summary
    model, loss_fn, optimizer = get_model()
    summary(model, torch.zeros(1,1,28,28)); 
    

这导致以下输出:

图 4.17:模型架构总结

为了加深我们对 CNN 的理解,让我们了解在前面的输出中参数数量被设定为什么样的原因:

  • 第一层:考虑到有 64 个大小为 3 的滤波器,我们有 64 x 3 x 3 个权重和 64 个偏置,总共 640 个参数。

  • 第四层:考虑到有 128 个大小为 3 的滤波器,我们有 128 x 64 x 3 x 3 个权重和 128 个偏置,总共 73,856 个参数。

  • 第八层:考虑到一个具有 3,200 个节点的层连接到另一个具有 256 个节点的层,我们有 3,200 x 256 个权重和 256 个偏置,总共 819,456 个参数。

  • 第十层:考虑到一个具有 256 个节点的层连接到一个具有 10 个节点的层,我们有 256 x 10 个权重和 10 个偏置,总共 2570 个参数。

现在,我们像在前一章中训练模型一样训练模型。

完整的代码可以在本书的 GitHub 存储库中找到:https://bit.ly/mcvp-2e

一旦模型训练完成,您会注意到在训练集和测试集上的准确性和损失的变化如下:

图 4.18:随着时代的推移训练和验证损失及准确率

在上述场景中,请注意,验证数据集在前五个时期内的准确率约为 92%,这已经优于我们在上一章节中通过各种技术所见到的准确率,即使没有额外的正则化。

让我们翻译这张图片并预测翻译后图片的类别:

  1. 将图像向左或向右平移 5 个像素并预测其类别:

    preds = []
    ix = 24300
    for px in range(-5,6):
        img = tr_images[ix]/255.
        img = img.view(28, 28)
        img2 = np.roll(img, px, axis=1)
        plt.imshow(img2)
        plt.show()
        img3 = torch.Tensor(img2).view(-1,1,28,28).to(device)
        np_output = model(img3).cpu().detach().numpy()
        preds.append(np.exp(np_output)/np.sum(np.exp(np_output))) 
    

在上述代码中,我们对图像(img3)进行了 reshape,使其形状为(-1,1,28,28),这样我们可以将图像传递给 CNN 模型。

  1. 绘制各种翻译下类别的概率:

    import seaborn as sns
    fig, ax = plt.subplots(1,1, figsize=(12,10))
    plt.title('Probability of each class for \
    various translations')
    sns.heatmap(np.array(preds).reshape(11,10), annot=True,
                ax=ax,fmt='.2f', xticklabels=fmnist.classes,
                yticklabels=[str(i)+str(' pixels') \
                for i in range(-5,6)], cmap='gray') 
    

上述代码的输出如下:

图 4.19:各种翻译下各类别的概率

在这种情况下,请注意,即使将图像向右或向左平移了 4 个像素,预测仍然是正确的,而在没有使用 CNN 的情况下,图像向右或向左平移 4 个像素时预测是错误的。此外,当图像向右或向左平移 5 个像素时,Trouser类别的概率显著下降。

正如我们所看到的,虽然 CNN 有助于解决图像平移的挑战,但并不能完全解决问题。我们将学习如何通过结合数据增强和 CNN 来解决这样的场景。

鉴于可以利用不同的数据增强技术,我们在 GitHub 存储库的Chapter04文件夹中的implementing data augmentation.pdf文件中提供了详尽的数据增强信息。

可视化特征学习的结果

到目前为止,我们已经了解到 CNN 如何帮助我们分类图像,即使图像中的对象已被转换。我们还了解到滤波器在学习图像特征方面起着关键作用,这反过来有助于将图像正确分类。但是,我们尚未提到使滤波器强大的是什么。在本节中,我们将学习有关滤波器学习的内容,这使得 CNN 能够正确分类图像,并通过对包含 X 和 O 图像的数据集进行分类来理解完全连接层(展平层)的激活状态。

让我们看看滤波器学到了什么:

下面的代码可以在 GitHub 上的 Chapter04 文件夹中的 Visualizing_the_features'_learning.ipynb 文件中找到,链接为 bit.ly/mcvp-2e

  1. 下载数据集:

    !wget https://www.dropbox.com/s/5jh4hpuk2gcxaaq/all.zip
    !unzip all.zip 
    

注意文件夹中的图像命名如下:

图 4.20:图像的命名约定

图像的类别可以从图像名称中获取,其中图像名称的第一个字符指定图像所属的类别。

  1. 导入所需模块:

    import torch
    from torch import nn
    from torch.utils.data import TensorDataset,Dataset,DataLoader
    from torch.optim import SGD, Adam
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    from torchvision import datasets
    import numpy as np, cv2
    import matplotlib.pyplot as plt
    %matplotlib inline
    from glob import glob
    from imgaug import augmenters as iaa 
    
  2. 定义一个获取数据的类。还要确保图像已调整为 28 x 28 的形状,批次已用三个通道形状,并且因变量已作为数值值获取。我们将在以下代码中逐步完成这些操作:

    1. 定义图像增强方法,将图像调整为 28 x 28 的形状:
    tfm = iaa.Sequential(iaa.Resize(28)) 
    
    1. 定义一个类,它接受文件夹路径作为输入,并在 __init__ 方法中循环处理该路径中的文件:
    class XO(Dataset):
        def __init__(self, folder):
            self.files = glob(folder) 
    
    1. 定义 __len__ 方法,它返回要考虑的文件的长度:
     def __len__(self): return len(self.files) 
    
    1. 定义 __getitem__ 方法,我们用它来获取返回该索引处的文件,读取文件,然后对图像进行增强。我们在这里没有使用 collate_fn,因为这是一个小数据集,不会显著影响训练时间:
     def __getitem__(self, ix):
            f = self.files[ix]
            im = tfm.augment_image(cv2.imread(f)[:,:,0]) 
    
    1. 鉴于每个图像的形状为 28 x 28,我们现在将在形状的开头创建一个虚拟通道维度,即在图像的高度和宽度之前:
     im = im[None] 
    
    1. 现在,我们可以根据文件名中 '/' 后和 '@' 前的字符确定每个图像的类别:
     cl = f.split('/')[-1].split('@')[0] == 'x' 
    
    1. 最后,我们返回图像及其对应的类别:
     return torch.tensor(1 - im/255).to(device).float(),
                          torch.tensor([cl]).float().to(device) 
    
  3. 检查您获取的图像样本。在以下代码中,我们通过从之前定义的类中获取数据来提取图像及其对应的类别:

    data = XO('/content/all/*') 
    

现在,我们可以绘制我们获取的数据集的图像样本:

  1. R, C = 7,7
    fig, ax = plt.subplots(R, C, figsize=(5,5))
    for label_class, plot_row in enumerate(ax):
        for plot_cell in plot_row:
            plot_cell.grid(False); plot_cell.axis('off')
            ix = np.random.choice(1000)
            im, label = data[ix]
            print()
            plot_cell.imshow(im[0].cpu(), cmap='gray')
    plt.tight_layout() 
    

上述代码的结果如下输出:

图 4.21:样本图像

  1. 定义模型架构、损失函数和优化器:

    from torch.optim import SGD, Adam
    def get_model():
        model = nn.Sequential(
                    nn.Conv2d(1, 64, kernel_size=3),
                    nn.MaxPool2d(2),
                    nn.ReLU(),
                    nn.Conv2d(64, 128, kernel_size=3),
                    nn.MaxPool2d(2),
                    nn.ReLU(),
                    nn.Flatten(),
                    nn.Linear(3200, 256),
                    nn.ReLU(),
                    nn.Linear(256, 1),
                    nn.Sigmoid()
                ).to(device)
        loss_fn = nn.BCELoss()
        optimizer = Adam(model.parameters(), lr=1e-3)
        return model, loss_fn, optimizer 
    

    注意,损失函数是二元交叉熵损失 (nn.BCELoss()),因为提供的输出来自二进制类。可以通过以下方式获取上述模型的摘要:

    !pip install torch_summary
    from torchsummary import summary
    model, loss_fn, optimizer = get_model()
    summary(model, torch.zeros(1,1,28,28)); 
    

这导致以下输出:

图 4.22:模型架构摘要

  1. 定义一个用于批量训练的函数,该函数接受图像和它们的类别作为输入,并在对给定数据批次执行反向传播后返回它们的损失值和准确度:

    def train_batch(x, y, model, opt, loss_fn):
        model.train()
        prediction = model(x)
        is_correct = (prediction > 0.5) == y
        batch_loss = loss_fn(prediction, y)
        batch_loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        return batch_loss.item(), is_correct[0] 
    
  2. 定义一个 DataLoader,其中输入是 Dataset 类:

    trn_dl = DataLoader(XO('/content/all/*'),batch_size=32, drop_last=True) 
    
  3. 初始化模型:

    model, loss_fn, optimizer = get_model() 
    
  4. 5 个 epoch 上训练模型:

    for epoch in range(5):
        for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            batch_loss = train_batch(x, y, model,optimizer, loss_fn) 
    
  5. 获取图像以查看滤波器对图像的学习效果:

    im, c = trn_dl.dataset[2]
    plt.imshow(im[0].cpu())
    plt.show() 
    

这导致以下输出:

图 4.23:示例图像

  1. 通过训练好的模型传递图像并获取第一层的输出。然后,将其存储在 intermediate_output 变量中:

    first_layer = nn.Sequential(*list(model.children())[:1])
    intermediate_output = first_layer(im[None])[0].detach() 
    
  2. 绘制 64 个滤波器的输出。intermediate_output 中的每个通道是每个滤波器的卷积输出:

    fig, ax = plt.subplots(8, 8, figsize=(10,10))
    for ix, axis in enumerate(ax.flat):
        axis.set_title('Filter: '+str(ix))
        axis.imshow(intermediate_output[ix].cpu())
    plt.tight_layout()
    plt.show() 
    

这导致以下输出:

图 4.24:64 个滤波器的激活

注意,某些滤波器(如 0、4、6 和 7 号滤波器)学习了网络中存在的边缘,而另一些滤波器(如第 54 号滤波器)学习了反转图像的技巧。

  1. 传递多个 O 图像并检查第四个滤波器在这些图像上的输出(我们仅用第四个滤波器作为示例用途;如果您希望,可以选择不同的滤波器)。

    1. 从数据中获取多个 O 图像:
    x, y = next(iter(trn_dl))
    x2 = x[y==0] 
    
    1. 重塑 x2,使其具有适合 CNN 模型的正确输入形状,即批处理大小 x 通道数 x 高度 x 宽度:
    x2 = x2.view(-1,1,28,28) 
    
    1. 定义一个变量,该变量存储了第一层的模型:
    first_layer = nn.Sequential(*list(model.children())[:1]) 
    
    1. 提取通过模型直到第一层(first_layer)的 O 图像的输出,如前所述:
    first_layer_output = first_layer(x2).detach() 
    
  2. 绘制将多个图像通过 first_layer 模型的输出:

    n = 4
    fig, ax = plt.subplots(n, n, figsize=(10,10))
    for ix, axis in enumerate(ax.flat):
        axis.imshow(first_layer_output[ix,4,:,:].cpu())
        axis.set_title(str(ix))
    plt.tight_layout()
    plt.show() 
    

上述代码的结果如下所示:

图 4.25:当多个 O 图像通过时第四个滤波器的激活

注意,在给定滤波器的行为(在本例中是第一层的第四个滤波器)在图像间保持一致。

  1. 现在,让我们创建另一个模型,该模型提取直到第二个卷积层(即前述模型中定义的四个层),然后提取通过原始 O 图像的输出。然后,我们将绘制第二层滤波器与输入 O 图像卷积的输出:

    second_layer = nn.Sequential(*list(model.children())[:4])
    second_intermediate_output=second_layer(im[None])[0].detach() 
    

    绘制将滤波器与相应图像卷积的输出:

    fig, ax = plt.subplots(11, 11, figsize=(10,10))
    for ix, axis in enumerate(ax.flat):
        axis.imshow(second_intermediate_output[ix].cpu())
        axis.set_title(str(ix))
    plt.tight_layout()
    plt.show() 
    

    上述代码的结果如下所示:

    图 4.26:第二个卷积层中 128 个滤波器的激活

    现在,让我们以前述图像中第 34 个滤波器的输出为例。当我们通过第 34 个滤波器传递多个 O 图像时,我们应该看到图像间的激活类似。让我们测试一下,如下所示:

    second_layer = nn.Sequential(*list(model.children())[:4])
    second_intermediate_output = second_layer(x2).detach()
    fig, ax = plt.subplots(4, 4, figsize=(10,10))
    for ix, axis in enumerate(ax.flat):
        axis.imshow(second_intermediate_output[ix,34,:,:].cpu())
        axis.set_title(str(ix))
    plt.tight_layout()
    plt.show() 
    

上述代码的结果如下所示:

图 4.27:当多个 O 图像通过时第 34 个滤波器的激活

注意,即使在这里,不同图像上第 34 个滤波器的激活也是相似的,即 O 的左半部分在激活滤波器时是相同的。

  1. 绘制全连接层的激活如下:

    1. 首先,获取更大的图像样本:
    custom_dl= DataLoader(XO('/content/all/*'),batch_size=2498, drop_last=True) 
    
    1. 接下来,从数据集中选择仅包含O图像,并将它们重塑,以便可以作为输入传递给我们的 CNN 模型:
    x, y = next(iter(custom_dl))
    x2 = x[y==0]
    x2 = x2.view(len(x2),1,28,28) 
    
    1. 获取扁平(全连接)层,并将之前的图像通过模型传递到扁平层:
    flatten_layer = nn.Sequential(*list(model.children())[:7])
    flatten_layer_output = flatten_layer(x2).detach() 
    
    1. 绘制扁平层:
    plt.figure(figsize=(100,10))
    plt.imshow(flatten_layer_output.cpu()) 
    

上述代码的输出如下所示:

图 4.28:全连接层的激活

注意输出的形状为 1,245 x 3,200,因为我们的数据集中有 1,245 个O图像,并且每个图像在扁平化层中有 3,200 个维度。

还有趣的是,当输入为O时,完全连接层的某些值会被突出显示(在这里,我们可以看到白色线条,每个点代表大于零的激活值)。

注意,尽管输入图像风格差异很大,但模型已经学会将一些结构带入完全连接的层。

现在我们已经学会了 CNN 的工作原理以及滤波器如何在这个过程中起作用,我们将应用这些知识来分类包含猫和狗图像的数据集。

构建用于分类真实世界图像的 CNN

到目前为止,我们已经学习了如何在 Fashion-MNIST 数据集上执行图像分类。在本节中,我们将为更真实的场景执行相同的操作,任务是对包含猫或狗的图像进行分类。我们还将学习当我们改变用于训练的图像数量时,数据集的准确性如何变化。

我们将在 Kaggle 上的数据集上工作,网址为www.kaggle.com/tongpython/cat-and-dog

您可以在 GitHub 上的Cats_Vs_Dogs.ipynb文件中找到以下代码,位于Chapter04文件夹中,网址为bit.ly/mcvp-2e。请务必从 GitHub 笔记本中复制 URL,以避免在重现结果时出现任何问题。

  1. 导入必要的包:

    import torchvision
    import torch.nn as nn
    import torch
    import torch.nn.functional as F
    from torchvision import transforms,models,datasets
    from PIL import Image
    from torch import optim
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    import cv2, glob, numpy as np, pandas as pd
    import matplotlib.pyplot as plt
    %matplotlib inline
    from glob import glob
    !pip install torch_summary 
    
  2. 下载数据集如下:

    1. 我们必须下载在colab环境中可用的数据集。但首先,我们必须上传我们的 Kaggle 认证文件:
    !pip install -q aggle
    from google.colab import files
    files.upload() 
    

为了进行这一步骤,您将需要上传您的kaggle.json文件,可以从您的 Kaggle 帐户中获取。有关如何获取kaggle.json文件的详细信息,请参见 GitHub 上关联笔记本中提供的相关信息,网址为bit.ly/mcvp-2e

  1. 接下来,指定我们要移动到 Kaggle 文件夹,并将kaggle.json文件复制到其中:

  2. !mkdir -p ~/.kaggle
    !cp kaggle.json ~/.kaggle/
    !ls ~/.kaggle
    !chmod 600 /root/.kaggle/kaggle.json 
    
  3. 最后,下载猫和狗的数据集并解压缩它:

  4. !kaggle datasets download -d tongpython/cat-and-dog
    !unzip cat-and-dog.zip 
    
  5. 提供训练和测试数据集文件夹:

    train_data_dir = '/content/training_set/training_set'
    test_data_dir = '/content/test_set/test_set' 
    
  6. 构建一个从前述文件夹获取数据的类。然后,根据图像对应的目录,为狗图像提供标签 1,猫图像提供标签 0。此外,确保获取的图像已被归一化为介于 0 和 1 之间的比例,并重新排列它以便先提供通道(因为 PyTorch 模型期望在提供图像的高度和宽度之前首先指定通道) - 如下所示进行:

    1. 定义 __init__ 方法,该方法接受一个文件夹作为输入,并将与 catsdogs 文件夹中的图像对应的文件路径(图像路径)存储在单独的对象中,在连接文件路径成为单个列表后:
    from torch.utils.data import DataLoader, Dataset
    class cats_dogs(Dataset):
        def __init__(self, folder):
            cats = glob(folder+'/cats/*.jpg')
            dogs = glob(folder+'/dogs/*.jpg')
            self.fpaths = cats + dogs 
    
    1. 接下来,随机化文件路径并根据这些文件路径对应的文件夹创建目标变量:
     from random import shuffle, seed; seed(10);
            shuffle(self.fpaths)
            self.targets=[fpath.split('/')[-1].startswith('dog') \
                          for fpath in self.fpaths] # dog=1 
    
    1. 定义 __len__ 方法,该方法对应于 self 类:
     def __len__(self): return len(self.fpaths) 
    
    1. 定义 __getitem__ 方法,我们用该方法从文件路径列表中指定一个随机文件路径,读取图像,并调整所有图像的大小为 224 x 224。考虑到我们的 CNN 需要从通道中获取每个图像的输入,我们将重新排列调整大小后的图像,以便在返回缩放后的图像和相应的 target 值之前,首先提供通道:
     def __getitem__(self, ix):
            f = self.fpaths[ix]
            target = self.targets[ix]
            im = (cv2.imread(f)[:,:,::-1])
            im = cv2.resize(im, (224,224))
            return torch.tensor(im/255).permute(2,0,1).to(device).float(),\
                   torch.tensor([target]).float().to(device) 
    
  7. 检查一个随机图像:

    data = cats_dogs(train_data_dir)
    im, label = data[200] 
    

我们需要将获取的图像重新排列以使通道排列在最后。这是因为 matplotlib 期望图像的通道在提供图像的高度和宽度之后指定:

  1. plt.imshow(im.permute(1,2,0).cpu())
    print(label) 
    

这将导致以下输出:

图 4.29:示例狗图像

  1. 定义模型、损失函数和优化器,如下所示:

    1. 首先,我们必须定义 conv_layer 函数,在其中按顺序执行卷积、ReLU 激活、批归一化和最大池化。此方法将在我们定义的最终模型中重复使用:
    def conv_layer(ni,no,kernel_size,stride=1):
        return nn.Sequential(
            nn.Conv2d(ni, no, kernel_size, stride),
            nn.ReLU(),
            nn.BatchNorm2d(no),
            nn.MaxPool2d(2)
        ) 
    
    1. 在上述代码中,我们将输入通道数 (ni)、输出通道数 (no)、kernel_sizestride 作为 conv_layer 函数的输入。

    2. 定义 get_model 函数,该函数执行多次卷积和池化操作(通过调用 conv_layer 方法),将输出展平,并在连接到输出层之前连接一个隐藏层:

    def get_model():
        model = nn.Sequential(
                  conv_layer(3, 64, 3),
                  conv_layer(64, 512, 3),
                  conv_layer(512, 512, 3),
                  conv_layer(512, 512, 3),
                  conv_layer(512, 512, 3),
                  conv_layer(512, 512, 3),
                  nn.Flatten(),
                  nn.Linear(512, 1),
                  nn.Sigmoid(),
                ).to(device)
        loss_fn = nn.BCELoss()
        optimizer=torch.optim.Adam(model.parameters(), lr= 1e-3)
        return model, loss_fn, optimizer 
    

您可以在 nn.Sequential 内部链式调用 nn.Sequential,深度可以随意。在上述代码中,我们使用 conv_layer 就像它是任何其他 nn.Module 层一样。

  1. 现在,我们必须调用 get_model 函数来获取模型、损失函数 (loss_fn) 和优化器,并使用我们从 torchsummary 包中导入的 summary 方法对模型进行总结:

  2. from torchsummary import summary
    model, loss_fn, optimizer = get_model()
    summary(model, torch.zeros(1,3, 224, 224)); 
    

上述代码将产生以下输出:

图 4.30:模型架构摘要

  1. 创建 get_data 函数,该函数创建 cats_dogs 类的对象,并为训练和验证文件夹分别创建 batch_size 为 32 的 DataLoader

    def get_data():
        train = cats_dogs(train_data_dir)
        trn_dl = DataLoader(train, batch_size=32, shuffle=True,
                                              drop_last = True)
        val = cats_dogs(test_data_dir)
        val_dl = DataLoader(val,batch_size=32, shuffle=True, drop_last = True)
        return trn_dl, val_dl 
    

在前述代码中,我们通过指定drop_last = True来忽略最后一个数据批次。我们这样做是因为最后一个批次的大小可能与其他批次不同。

  1. 定义将在数据批次上训练模型的函数,就像我们在前面的章节中所做的一样:

    def train_batch(x, y, model, opt, loss_fn):
        model.train()
        prediction = model(x)
        batch_loss = loss_fn(prediction, y)
        batch_loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        return batch_loss.item() 
    
  2. 定义用于计算准确性和验证损失的函数,就像我们在前面的章节中所做的一样:

    1. 定义accuracy函数:
    @torch.no_grad()
    def accuracy(x, y, model):
        prediction = model(x)
        is_correct = (prediction > 0.5) == y
        return is_correct.cpu().numpy().tolist() 
    
    1. 请注意,用于精度计算的前述代码与 Fashion-MNIST 分类中的代码不同,因为当前模型(猫与狗分类)是为二元分类构建的,而 Fashion-MNIST 模型是为多类分类构建的。

    2. 定义验证损失计算函数:

    @torch.no_grad()
    def val_loss(x, y, model):
        prediction = model(x)
        val_loss = loss_fn(prediction, y)
        return val_loss.item() 
    
  3. 在 5 个 epoch 上训练模型,并在每个 epoch 结束时检查测试数据的准确性,就像我们在前面的章节中所做的一样:

    1. 定义模型并获取所需的 DataLoaders:
    trn_dl, val_dl = get_data()
    model, loss_fn, optimizer = get_model() 
    
    1. 在增加的 epoch 上训练模型:
    train_losses, train_accuracies = [], []
    val_losses, val_accuracies = [], []
    for epoch in range(5):
        train_epoch_losses, train_epoch_accuracies = [], []
        val_epoch_accuracies = []
        for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            batch_loss = train_batch(x, y, model,optimizer, loss_fn)
            train_epoch_losses.append(batch_loss)
        train_epoch_loss = np.array(train_epoch_losses).mean()
        for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            is_correct = accuracy(x, y, model)
            train_epoch_accuracies.extend(is_correct)
        train_epoch_accuracy = np.mean(train_epoch_accuracies)
        for ix, batch in enumerate(iter(val_dl)):
            x, y = batch
            val_is_correct = accuracy(x, y, model)
            val_epoch_accuracies.extend(val_is_correct)
        val_epoch_accuracy = np.mean(val_epoch_accuracies)
        train_losses.append(train_epoch_loss)
        train_accuracies.append(train_epoch_accuracy)
        val_accuracies.append(val_epoch_accuracy) 
    
  4. 绘制随着 epoch 增加,训练和验证准确性的变化:

    epochs = np.arange(5)+1
    import matplotlib.ticker as mtick
    import matplotlib.pyplot as plt
    import matplotlib.ticker as mticker
    %matplotlib inline
    plt.plot(epochs, train_accuracies, 'bo',
             label='Training accuracy')
    plt.plot(epochs, val_accuracies, 'r',
             label='Validation accuracy')
    plt.gca().xaxis.set_major_locator(mticker.MultipleLocator(1))
    plt.title('Training and validation accuracy \
    with 4K data points used for training')
    plt.xlabel('Epochs')
    plt.ylabel('Accuracy')
    plt.gca().set_yticklabels(['{:.0f}%'.format(x*100) \
                               for x in plt.gca().get_yticks()])
    plt.legend()
    plt.grid('off')
    plt.show() 
    

前述代码的输出如下:

图 4.31:随着 epoch 增加,训练和验证准确性的变化

请注意,在 5 个 epoch 结束时的分类准确性约为 86%。

正如我们在前一章中讨论的,批量归一化对提高分类准确性有很大影响——通过训练模型而不使用批量归一化来自行验证这一点。此外,如果您使用较少的参数,模型也可以在没有批量归一化的情况下进行训练。您可以通过减少层数、增加步幅、增加池化或将图像调整为低于 224 x 224 的数字来实现这一点。

到目前为止,我们的训练基于约 8K 个示例,其中 4K 个示例来自cat类,其余来自dog类。在接下来的章节中,我们将了解在分类测试数据集的分类准确性中,训练示例数量减少对每个类别的影响。

对训练图像数量的影响

我们知道,通常使用的训练示例越多,我们的分类准确性就越好。在本节中,我们将通过人为减少可用于训练的图像数量,然后在分类测试数据集时测试模型的准确性,来了解使用不同数量的可用图像对训练准确性的影响。

可在 GitHub 上的Chapter04文件夹中的Cats_Vs_Dogs.ipynb文件中找到以下代码,网址为bit.ly/mcvp-2e。鉴于这里提供的大部分代码与我们在前一节中看到的类似,我们仅为简洁起见提供了修改后的代码。相应的 GitHub 笔记本将包含完整的代码。

在这里,我们只希望在训练数据集中有每类 500 个数据点。我们可以通过在 __init__ 方法中限制文件数到每个文件夹中的前 500 个图像路径,并确保其余保持与上一节相同的方式来实现这一点:

 def __init__(self, folder):
        cats = glob(folder+'/cats/*.jpg')
        dogs = glob(folder+'/dogs/*.jpg')
        self.fpaths = cats[:500] + dogs[:500]
        from random import shuffle, seed; seed(10);
            shuffle(self.fpaths)
        self.targets = [fpath.split('/')[-1].startswith('dog') \
                        for fpath in self.fpaths] 

在上述代码中,与我们在前一节中执行的初始化唯一区别在于 self.paths,我们现在将考虑的文件路径数量限制为每个文件夹中的前 500 个。

现在,一旦我们执行其余代码,就像在前一节中所做的那样,构建在 1,000 张图像(每类 500 张)上的模型在测试数据集上的准确率如下:

图 4.32:使用 1K 数据点的训练和验证准确率

可以看到,由于训练中图像样本较少,在测试数据集上模型的准确率显著降低,即下降至约 66%。

现在,让我们看看训练数据点数量如何影响测试数据集的准确性,通过改变用于训练模型的可用训练示例的数量(我们为每种情况构建一个模型)。

我们将使用与 1K(每类 500 个)数据点训练示例相同的代码,但会改变可用图像的数量(分别为 2K、4K 和 8K 总数据点)。为简洁起见,我们只关注在不同训练图像数量下运行模型的输出。结果如下:

图 4.33:使用不同数据点数量的训练和验证准确率

正如您所见,可用的训练数据越多,模型在测试数据上的准确率就越高。然而,在我们遇到的每种情况下,我们可能没有足够大量的训练数据。下一章将涵盖迁移学习,通过引导您了解各种技术,即使在少量训练数据的情况下也能获得高准确率。

总结

当将与先前看到的已被翻译的类似的新图像作为模型的输入时,传统神经网络会失败。CNN 在解决此缺陷中发挥了关键作用。这是通过 CNN 中存在的各种机制实现的,包括滤波器、步幅和池化。最初,我们建立了一个玩具示例来学习 CNN 的工作原理。然后,我们学习了数据增强如何通过在原始图像上创建翻译增强来增加模型的准确性。之后,我们了解了不同滤波器在特征学习过程中学到的内容,以便我们能够实现一个用于图像分类的 CNN。

最后,我们看到不同数量的训练数据对测试数据准确性的影响。在这里,我们看到可用的训练数据越多,测试数据的准确性就越高。在下一章中,我们将学习如何利用各种迁移学习技术来提高测试数据集的准确性,即使我们只有少量的训练数据。

问题

  1. 在使用传统神经网络时,为什么在第一章节中对翻译图像的预测结果较低?

  2. 卷积是如何进行的?

  3. 如何确定滤波器中的最优权重值?

  4. 卷积和池化的组合如何帮助解决图像翻译的问题?

  5. 靠近输入层的卷积滤波器学习什么?

  6. 池化在构建模型时有哪些功能性?

  7. 为什么我们不能像在 Fashion-MNIST 数据集上那样,对输入图像进行展平,然后为真实世界的图像训练模型?

  8. 数据增强如何帮助改进图像翻译?

  9. 在什么情况下我们利用collate_fn来处理数据加载器?

  10. 改变训练数据点的数量对验证数据集的分类准确率有什么影响?

了解更多关于 Discord 的内容

加入我们社区的 Discord 空间,与作者和其他读者讨论:

packt.link/modcv

第五章:图像分类的迁移学习

在前一章中,我们了解到随着训练数据集中可用图像数量的增加,模型的分类准确率也在不断提高,以至于一个包含 8000 张图像的训练数据集在验证数据集上的准确率比一个包含 1000 张图像的训练数据集要高。然而,我们并不总是有数百或数千张图像以及它们对应类别的实际数据集选项,来训练模型。这就是迁移学习能发挥作用的地方。

迁移学习是一种技术,其中我们将模型在通用数据集上的学习迁移到感兴趣的特定数据集上。通常,用于进行迁移学习的预训练模型是在数百万张图像(这些图像是通用的,不是我们感兴趣的数据集)上训练的,然后这些预训练模型现在被微调到我们感兴趣的数据集上。

在本章中,我们将学习关于两种不同的迁移学习架构家族 - 视觉几何组 (VGG) 架构的变体和 残差网络 (ResNet) 架构的变体。

除了理解架构之外,我们还将了解它们在两个不同用例中的应用,即年龄和性别分类,在这里我们将学习如何同时优化交叉熵和平均绝对误差损失,以估计一个人的年龄并预测其性别(给定一个人的图像),以及面部关键点检测(检测眼睛、眉毛和下巴轮廓等关键点,给定一个面部图像作为输入),在这里我们将学习如何利用神经网络在单次预测中生成多个(136 个而不是 1 个)连续输出。最后,我们将学习一个新的库,可以大大减少跨其余章节的代码复杂性。

总结一下,本章涵盖了以下主题:

  • 引入迁移学习

  • 理解 VGG16 和 ResNet 架构

  • 实现面部关键点检测

  • 多任务学习:实现年龄估计和性别分类

  • 引入 torch_snippets

本章所有的代码都可以在本书 GitHub 仓库的 Chapter05 文件夹中找到 - bit.ly/mcvp-2e

引入迁移学习

迁移学习是一种技术,利用从一个任务中获得的知识来解决另一个类似的任务。

想象一个模型,它在数百万张涵盖数千种物体类别(不仅仅是猫和狗)的图像上进行了训练。该模型的各种滤波器(核)会对图像中各种形状、颜色和纹理进行广泛激活。这些滤波器然后可以被重复使用,用于在新的图像上学习特征。在学习特征后,它们可以连接到隐藏层,然后连接到最终分类层,以便在新数据上进行自定义。

ImageNet (www.image-net.org/) 是一个比赛,旨在将大约 1400 万张图像分类为 1000 个不同的类别。数据集中包含各种类别,包括印度大象、狮子鱼、硬盘、发胶和吉普车。

本章中要介绍的深度神经网络架构已经在 ImageNet 数据集上进行了训练。此外,考虑到在 ImageNet 中需要分类的对象的种类和数量,模型非常深,以尽可能多地捕捉信息。

让我们通过一个假设的场景来理解迁移学习的重要性。

考虑以下情况,我们正在处理道路的图像,并试图根据它们所包含的对象进行分类。从头开始建立模型可能会导致结果不佳,因为图像数量可能不足以学习数据集内的各种变化(正如我们在先前的用例中看到的,训练 8000 张图像比训练 1000 张图像在验证数据集上获得更高的准确性)。在这种情况下,预训练模型在 ImageNet 上训练就非常方便。在训练过程中,它已经学习了与交通相关的各种类别,如汽车、道路、树木和人类。因此,利用已训练好的模型将会导致更快速和更准确的训练,因为模型已经了解了通用形状,现在只需将其适配到特定图像上。

有了直觉之后,让我们现在来理解迁移学习的高级流程,如下所示:

  1. 标准化输入图像,使用在预训练模型训练期间使用的相同平均值标准差进行标准化。

  2. 获取预训练模型的架构。获取这种架构的权重,这些权重是在大数据集上训练得到的结果。

  3. 丢弃预训练模型的最后几层,以便我们可以微调这个特定数据集的最后几层。

  4. 将截断的预训练模型连接到一个刚初始化的层(或多层),其中权重是随机初始化的。确保最后一层的输出具有与我们想要预测的类别/输出一样多的神经元。

  5. 确保预训练模型的权重不可训练(换句话说,在反向传播期间被冻结/不更新),但新初始化层的权重及其与输出层连接的权重是可训练的。

我们不会训练预训练模型的权重,因为我们假设这些权重已经很好地适应了任务,因此利用大模型的学习。总结一下,我们只会学习我们小数据集的新初始化层。

  1. 逐步增加时期更新可训练参数,以适应模型。

现在我们已经了解了如何实现迁移学习,让我们在接下来的章节中了解各种架构,它们是如何构建的,以及在将迁移学习应用于猫对狗的用例时的结果。首先,我们将详细介绍一些出自 VGG 的各种架构。

理解 VGG16 的架构

VGG代表Visual Geometry Group,它位于牛津大学。 16代表模型中的层数。VGG16 模型经过训练,用于在 ImageNet 竞赛中分类对象,并在 2014 年排名第二。我们研究这种架构而不是获胜架构(GoogleNet)的原因是其简单性及其被视觉社区广泛用于多种其他任务。

让我们了解 VGG16 的架构以及如何在 PyTorch 中访问和表示预训练的 VGG16 模型。

以下代码可以在 GitHub 上的Chapter05文件夹中的VGG_architecture.ipynb文件中找到,网址为bit.ly/mcvp-2e

要开始使用 PyTorch 中的预训练 VGG16 模型,请按照以下步骤进行:

  1. 安装所需的包:

    import torchvision
    import torch.nn as nn
    import torch
    import torch.nn.functional as F
    from torchvision import transforms,models,datasets
    !pip install torch_summary
    from torchsummary import summary
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    

torchvision包中的models模块托管了 PyTorch 中可用的各种预训练模型。

  1. 加载 VGG16 模型并在设备上注册模型:

    model = models.vgg16(pretrained=True).to(device) 
    

在上述代码中,我们调用了models类中的vgg16方法。通过指定pretrained=True,我们指定加载用于在 ImageNet 竞赛中分类图像的权重,然后将模型注册到设备上。

  1. 获取模型的总结:

    summary(model, torch.zeros(1,3,224,224)); 
    

上述代码的输出可以在 GitHub 上的相关笔记本中找到。

在上述总结中,我们提到的 16 层被分组如下:

{1,2},{3,4,5},{6,7},{8,9,10},{11,12},{13,14},{15,16,17},{18,19},{20,21},{22,23,24},{25,26},{27,28},{29,30,31,32},{33,34,35},{36,37,38],{39} 

同样的总结也可以通过可视化呈现:

Diagram, schematic  Description automatically generated

图 5.1:VGG16 架构

请注意,该网络中有大约 138 million 个参数(其中大约 122 million 个是网络末端的线性层– 102 + 16 + 4 million 个参数),包括 13 层卷积和/或池化层,以及 3 个线性层。

另一种理解 VGG16 模型组件的方式是通过如下方式打印它:

model 

上述代码的输出可以在 GitHub 上找到。

请注意,模型中有三个主要子模块– featuresavgpoolclassifier。通常,我们会冻结featuresavgpool模块。删除classifier模块(或仅删除底部的几层)并创建一个新模块,以预测与我们的数据集对应的所需类别数(而不是现有的 1,000 个类)。

实现 VGG16

现在让我们了解如何在实践中使用 VGG16 模型,使用猫狗数据集(仅考虑每个类别的 500 张图像进行训练)在以下代码中:

可在 GitHub 上的Chapter05文件夹中的Implementing_VGG16_for_image_classification.ipynb文件中找到以下代码,位于bit.ly/mcvp-2e。请务必从 GitHub 的笔记本复制 URL,以避免在复制结果时出现任何问题。

  1. 安装所需的软件包:

    import torch
    import torchvision
    import torch.nn as nn
    import torch.nn.functional as F
    from torchvision import transforms,models,datasets
    import matplotlib.pyplot as plt
    from PIL import Image
    from torch import optim
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    import cv2, glob, numpy as np, pandas as pd
    from glob import glob
    import torchvision.transforms as transforms
    from torch.utils.data import DataLoader, Dataset 
    
  2. 下载数据集并指定训练和测试目录:

    1. 假设我们正在使用 Google Colab,我们执行以下步骤,提供认证密钥并将其放置在 Kaggle 可以使用该密钥进行身份验证并下载数据集的位置:
    !pip install -q kaggle
    from google.colab import files
    files.upload()
    !mkdir -p ~/.kaggle
    !cp kaggle.json ~/.kaggle/
    !ls ~/.kaggle
    !chmod 600 /root/.kaggle/kaggle.json 
    
    1. 下载数据集并解压缩:
    !kaggle datasets download -d tongpython/cat-and-dog
    !unzip cat-and-dog.zip 
    
    1. 指定训练和测试图像文件夹:
    train_data_dir = 'training_set/training_set'
    test_data_dir = 'test_set/test_set' 
    
  3. 提供返回猫和狗数据集的输入输出对的类,就像我们在前一章中所做的那样。请注意,在这种情况下,我们仅从每个文件夹中获取前 500 张图像:

    class CatsDogs(Dataset):
        def __init__(self, folder):
            cats = glob(folder+'/cats/*.jpg')
            dogs = glob(folder+'/dogs/*.jpg')
            self.fpaths = cats**[:****500****]****[:****500****]** + dogs
     **self.normalize = transforms.Normalize(mean=[****0.485****,** 
    **0.456****,** **0.406****],std=[****0.229****,** **0.224****,** **0.225****])**
            from random import shuffle, seed; seed(10); 
            shuffle(self.fpaths)
            self.targets = [fpath.split('/')[-1].startswith('dog') for fpath \
                                                               in self.fpaths]
        def __len__(self): return len(self.fpaths)
        def __getitem__(self, ix):
            f = self.fpaths[ix]
            target = self.targets[ix]
            im = (cv2.imread(f)[:,:,::-1])
            im = cv2.resize(im, (224,224))
            im = torch.tensor(im/255)
            im = im.permute(2,0,1)
            **im = self.normalize(im)** 
            return (
                im.float().to(device), 
                torch.tensor([target]).float().to(device)
            ) 
    

本节中cats_dogs类与第四章中的主要区别在于我们正在使用transforms模块中的Normalize函数应用normalize函数。

当利用预训练模型时,必须对图像进行调整、排列,然后进行归一化(适用于该预训练模型),首先将图像缩放到 0 到 1 之间的值,跨越 3 个通道,然后对 RGB 通道进行均值归一化为[0.485, 0.456, 0.406],标准差归一化为[0.229, 0.224, 0.225]

  1. 获取图像及其标签:

    data = CatsDogs(train_data_dir) 
    
  2. 现在让我们检查一个样本图像及其对应的类别:

    im, label = data[200]
    plt.imshow(im.permute(1,2,0).cpu())
    print(label) 
    
  3. 上述代码会产生以下输出:

    # tensor([0.], device='cuda:0') 
    

一只穿着衣物的猫,自动生成的描述,置信度较低

图 5.2:输入样本图像

  1. 下载预训练的 VGG16 权重,然后冻结features模块,并使用avgpoolclassifier模块进行训练:

    1. 首先,从models类中下载预训练的 VGG16 模型。
    def get_model():
        model = models.vgg16(pretrained=True) 
    
    1. 指定我们要冻结先前下载的模型中的所有参数:
     for param in model.parameters():
            param.requires_grad = False 
    
    1. 替换avgpool模块以返回大小为 1 x 1 的特征映射,而不是 7 x 7;换句话说,输出现在将是batch_size x 512 x 1 x 1
     model.avgpool = nn.AdaptiveAvgPool2d(output_size=(1,1)) 
    

我们已经见过 nn.MaxPool2d,在这里我们从特征图的每个部分选取最大值。这个层的对应物称为 nn.AvgPool2d,它返回一个部分的平均值而不是最大值。在这两个层中,我们固定了核大小。上面的层 nn.AdaptiveAvgPool2d 是带有变化的另一种池化层。我们指定输出特征图的大小。该层自动计算核大小,以便返回指定的特征图大小。例如,如果输入特征图大小的维度为 batch_size x 512 x k x k,那么池化核大小将是 k x k。该层的主要优势是,无论输入大小如何,该层的输出始终是固定的,因此神经网络可以接受任何高度和宽度的图像。

  1. 定义模型的 classifier 模块,在这里我们首先展平 avgpool 模块的输出,将 512 单元连接到 128 单元,并在连接到输出层之前执行激活:

  2.  model.classifier = nn.Sequential(nn.Flatten(),
                                        nn.Linear(512, 128),
                                        nn.ReLU(),
                                        nn.Dropout(0.2),
                                        nn.Linear(128, 1),
                                        nn.Sigmoid()) 
    
  3. 定义损失函数(loss_fn)和 optimizer,并将它们与定义的模型一起返回:

  4.  loss_fn = nn.BCELoss()
        optimizer = torch.optim.Adam(model.parmeters(),lr= 1e-3)
        return model.to(device), loss_fn, optimizer 
    

请注意,在前面的代码中,我们首先冻结了预训练模型的所有参数,然后重写了 avgpoolclassifier 模块。现在,剩下的代码将看起来与我们在前一章中看到的类似。

模型的摘要如下:

  1. !pip install torch_summary
    from torchsummary import summary
    model, criterion, optimizer = get_model()
    summary(model, torch.zeros(1,3,224,224)) 
    

前面代码的输出可以在 GitHub 上关联的笔记本中找到。

请注意,可训练参数的数量仅为 65,793,总共为 14.7 百万,因为我们已冻结了 features 模块并已重写了 avgpoolclassifier 模块。现在,只有 classifier 模块将具有将被学习的权重。

  1. 定义一个函数来训练一个批次数据,计算准确率,并获取数据,就像我们在上一章中做的那样:

    1. 在一批数据上进行训练:
    def train_batch(x, y, model, opt, loss_fn):
        model.train()
        prediction = model(x)
        batch_loss = loss_fn(prediction, y)
        batch_loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        return batch_loss.item() 
    
    1. 定义一个函数来计算一批数据的准确率:
    @torch.no_grad()
    def accuracy(x, y, model):
        model.eval()
        prediction = model(x)
        is_correct = (prediction > 0.5) == y
        return is_correct.cpu().numpy().tolist() 
    
    1. 定义一个函数来获取数据加载器:
    def get_data():
        train = CatsDogs(train_data_dir)
        trn_dl = DataLoader(train, batch_size=32, huffle=True, \
                                              drop_last = True)
        val = CatsDogs(test_data_dir)
        val_dl = DataLoader(val, batch_size=32, huffle=True, drop_last = True)
        return trn_dl, val_dl 
    
    1. 初始化 get_dataget_model 函数:
    trn_dl, val_dl = get_data()
    model, loss_fn, optimizer = get_model() 
    
  2. 训练模型在不断增加的 epoch 上,就像我们在前一章中所做的那样:

    train_losses, train_accuracies = [], []
    val_accuracies = []
    for epoch in range(5):
        print(f" epoch {epoch + 1}/5")
        train_epoch_losses, train_epoch_accuracies = [], []
        val_epoch_accuracies = []
        for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            batch_loss = train_batch(x, y, modl, optimizer, loss_fn)
            train_epoch_losses.append(batch_loss) 
        train_epoch_loss = np.array(train_epoch_losses).mean()
        for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            is_correct = accuracy(x, y, model)
            train_epoch_accuracies.extend(is_correct)
        train_epoch_accuracy = np.mean(train_epoch_accuracies)
        for ix, batch in enumerate(iter(val_dl)):
            x, y = batch
            val_is_correct = accuracy(x, y, model)
            val_epoch_accuracies.extend(val_is_correct)
        val_epoch_accuracy = np.mean(val_epoch_accuracies)
        train_losses.append(train_epoch_loss)
        train_accuracies.append(train_epoch_accuracy)
        val_accuracies.append(val_epoch_accuracy) 
    
  3. 绘制随着 epoch 增加的训练和测试准确率值的图表:

    epochs = np.arange(5)+1
    import matplotlib.ticker as mtick
    import matplotlib.pyplot as plt
    import matplotlib.ticker as mticker
    %matplotlib inline
    plt.plot(epochs, train_accuracies, 'bo', 
             label='Training accuracy')
    plt.plot(epochs, val_accuracies, 'r', 
             label='Validation accuracy')
    plt.gca().xaxis.set_major_locator(mticker.MultipleLocator(1))
    plt.title('Training and validation accuracy \
    with VGG16 \nand 1K training data points')
    plt.xlabel('Epochs')
    plt.ylabel('Accuracy')
    plt.ylim(0.95,1)
    plt.gca().set_yticklabels(['{:.0f}%'.format(x*100) \
                               for x in plt.gca().get_yticks()]) 
    plt.legend()
    plt.grid('off')
    plt.show() 
    

结果如下输出:

图表,折线图 自动生成的描述

图 5.3:VGG16 使用 1K 训练数据点的训练和验证准确率

请注意,即使在 1,000 张图像(每类 500 张图像)的小数据集上,我们在第一个 epoch 内也能获得 98% 的准确率。

除了 VGG16 外,还有 VGG11 和 VGG19 预训练架构,它们的工作原理与 VGG16 类似,但层数不同。VGG19 的参数比 VGG16 多,因为它有更多的层。

当我们在 VGG16 的位置上使用 VGG11 和 VGG19 预训练模型时,训练和验证准确率如下:

Chart, line chart  Description automatically generated

图 5.4:(左)使用 1K 训练数据点的 VGG19 准确率;(右)使用 1K 训练数据点的 VGG11 准确率

请注意,尽管基于 VGG19 的模型在验证数据上的准确率略高于基于 VGG16 的模型,达到了 98%,基于 VGG11 的模型准确率稍低,只有 97%。

从 VGG16 到 VGG19,我们增加了层次的数量,通常来说,神经网络层数越深,准确率就会越好。

然而,如果仅仅增加层数就是诀窍的话,那么我们可以继续向模型中添加更多层次(同时要注意避免过拟合),以在 ImageNet 上获得更精确的结果,然后对感兴趣的数据集进行微调。不幸的是,这并不是真的。

有多种原因使得这并不那么容易。当我们在架构上变得更深时,可能会发生以下任何一种情况:

  • 我们必须学习更多的特征。

  • 梯度消失问题会出现。

  • 在更深的层次上有太多的信息修改。

ResNet 在此特定场景中应运而生,以解决识别不需要学习的情况,我们将在下一节讨论。

理解 ResNet 架构

当构建过深的网络时,会出现两个问题。在前向传播中,网络的最后几层几乎无法获取原始图像的信息。在反向传播中,靠近输入的前几层由于梯度消失几乎不会获得梯度更新(换句话说,它们几乎为零)。为了解决这两个问题,ResNet 使用类似高速公路的连接,将前几层的原始信息传递给后面的层次。理论上,甚至最后一层也将由于这种高速公路网络而获得原始图像的所有信息。由于跳跃连接的存在,反向梯度将自由流动到初始层,并几乎没有修改。

在残差网络中,“残差”一词指的是模型预期从前一层学习到的额外信息,需要传递给下一层。

典型的残差块如下所示:

Diagram  Description automatically generated

图 5.5:残差块

正如您所见,到目前为止,我们一直对提取F(x)值感兴趣,其中x是来自前一层的值,在残差网络的情况下,我们不仅仅是提取通过权重层传递后的值,即 F(x),还将 F(x)与原始值x相加。

到目前为止,我们一直在使用执行线性或卷积变换 F(x) 的标准层以及一些非线性激活。这两种操作在某种意义上都会破坏输入信息。这是第一次,我们看到一个层不仅转换输入,还通过将输入直接添加到转换中来保留输入,即 F(x) + x。因此,在某些情况下,该层几乎不需要记住输入是什么,并且可以专注于学习任务的正确转换。

让我们通过代码更详细地查看残差层,构建一个残差块:

此部分的完整代码可以在 GitHub 上的 Implementing_ResNet18_for_image_classification.ipynb 文件中找到,位于 Chapter05 文件夹中,网址为 bit.ly/mcvp-2e

  1. __init__ 方法中定义一个具有卷积操作(前面图表中的权重层)的类:

    class ResLayer(nn.Module):
         def __init__(self,ni,no,kernel_size,stride=1):
            super(ResLayer, self).__init__()
            padding = kernel_size - 2
            self.conv = nn.Sequential( \
                            nn.Conv2d(ni, no, kernel_size, stride,
                                      padding=padding),
                            nn.ReLU()
            ) 
    

请注意,在上述代码中,我们将 padding 定义为通过卷积传递时的输出维度,如果我们要对两者求和,则输入的维度应保持不变。

  1. 定义 forward 方法:

     def forward(self, x):
             x = self.conv(x) + x
             return x 
    

在上述代码中,我们得到一个输出,它是输入经过卷积操作和原始输入的和。

现在我们已经学习了残差块的工作原理,让我们了解一下如何在预训练的基于残差块的网络 ResNet18 中连接残差块:

Diagram  Description automatically generated

图 5.6:ResNet18 架构

正如您所见,该架构中有 18 层,因此被称为 ResNet18 架构。此外,请注意如何跨网络进行跳跃连接。它们不是在每个卷积层之后进行连接,而是在每两个层之后进行连接。

实现 ResNet18

通过理解 ResNet 架构的组成,让我们基于 ResNet18 架构构建一个模型,用于对狗和猫进行分类,就像我们在之前的部分中使用 VGG16 一样。

要构建一个分类器,实现 VGG16 部分到 步骤 3 的代码保持不变,因为它涉及导入包、获取数据和检查它们。因此,我们将从理解预训练的 ResNet18 模型的组成开始:

此部分的完整代码可以在 GitHub 上的 Resnet_block_architecture.ipynb 文件中找到,位于 Chapter05 文件夹中,网址为 bit.ly/mcvp-2e。考虑到大部分代码与 实现 VGG16 部分的代码相似,我们只提供了额外的代码以简洁为目的。如需完整代码,请参考 GitHub 上的笔记本。

  1. 加载预训练的 ResNet18 模型并检查加载模型中的模块:

    model = models.resnet18(pretrained=True).to(device)
    model 
    

ResNet18 模型的结构包含以下组件:

  • 卷积

  • 批归一化

  • ReLU

  • MaxPooling

  • 四层 ResNet 块

  • 平均池化(avgpool

  • 一个全连接层(fc

就像我们在 VGG16 中所做的那样,我们将冻结所有不同的模块,但在接下来的步骤中更新avgpoolfc模块的参数。

  1. 定义模型架构、损失函数和优化器:

    def get_model():
        model = models.resnet18(pretrained=True)
        for param in model.parameters():
            param.requires_grad = False
        model.avgpool = nn.AdaptiveAvgPool2d(output_size=(1,1))
        model.fc = nn.Sequential(nn.Flatten(),
        nn.Linear(512, 128),
        nn.ReLU(),
        nn.Dropout(0.2),
        nn.Linear(128, 1),
        nn.Sigmoid())
        loss_fn = nn.BCELoss()
        optimizer = torch.optim.Adam(model.parameter(),lr= 1e-3)
        return model.to(device), loss_fn, optimizer 
    

在前述模型中,fc模块的输入形状是 512,因为avgpool的输出形状为批次大小 x 512 x 1 x 1。

现在我们已经定义了模型,让我们执行实施 VGG部分的步骤 56。在对模型(其中模型为 ResNet18、ResNet34、ResNet50、ResNet101 和 ResNet152,每个图表的训练和验证精度)进行增加的 epoch 训练后,训练和验证精度的变化如下:

图表,线图  自动生成描述

图 5.7:使用不同数量的 ResNet 层进行训练和验证的准确率

我们看到模型在仅训练了 1,000 张图像后的准确性在 97%到 98%之间变化,其中随着 ResNet 层数的增加,准确性也随之增加。

除了 VGG 和 ResNet 之外,其他一些著名的预训练模型还包括 Inception、MobileNet、DenseNet 和 SqueezeNet。

现在我们已经了解了如何利用预训练模型来预测一个二元类别,接下来,在接下来的几节中,我们将学习如何利用预训练模型来解决涉及以下内容的真实用例:

  • 多重回归:针对给定图像进行多个值的预测 – 面部关键点检测

  • 多任务学习:在单次预测中预测多个项目 – 年龄估计和性别分类

实现面部关键点检测

到目前为止,我们已经学习了如何预测二元类别(猫与狗)或多标签(时尚-MNIST)。现在让我们学习一个回归问题,通过这样做,我们可以预测不止一个而是多个连续输出(因此是多重回归学习)。

想象一种情境,您被要求预测图像上面部的关键点;例如,眼睛、鼻子和下巴的位置。在这种情况下,我们需要采用一种新策略来构建一个检测关键点的模型。

在我们进一步深入之前,让我们通过下面的图像了解我们试图实现什么:

图形用户界面  自动生成描述(中等置信度)

图 5.8:(左)输入图像;(右)覆盖了面部关键点的输入图像

正如您可以在前面的图像中观察到的那样,面部关键点表示了图像中包含面部的各种关键点的标记。

要解决这个问题,我们首先必须解决其他几个问题:

  • 图像可以有不同的形状。这要求在调整图像以将它们全部调整为标准图像尺寸时,调整关键点的位置。

  • 面部关键点类似于散点图上的点,但这次基于某种模式分散。这意味着这些值在图像被调整为 224 x 224 x 3 的形状时,可以在 0 到 224 之间的任何地方。

  • 根据图像的大小规范化因变量(面部关键点的位置)。如果考虑到它们相对于图像尺寸的位置,关键点值始终在 0 和 1 之间。

  • 鉴于因变量值始终在 0 和 1 之间,我们可以在最后使用一个 sigmoid 层来获取值,这些值将在 0 和 1 之间。

现在让我们为解决面部关键点检测用例制定流水线:

下面的代码可以在位于 GitHub 的Chapter05文件夹中的2D_and_3D facial_keypoints_detection.ipynb文件中找到,链接为bit.ly/mcvp-2e。确保从 GitHub 的笔记本中复制代码,以避免在重现结果时出现问题。

  1. 导入相关包和数据集:

    import torchvision
    import torch.nn as nn
    import torch
    import torch.nn.functional as F
    from torchvision import transforms, models, datasets
    from torchsummary import summary
    import numpy as np, pandas as pd, os, glob, cv2
    from torch.utils.data import TensorDataset,DataLoader,Dataset
    from copy import deepcopy
    from mpl_toolkits.mplot3d import Axes3D
    import matplotlib.pyplot as plt
    %matplotlib inline
    from sklearn import cluster
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 下载并导入相关数据。您可以下载包含图像及其对应的面部关键点的相关数据:

    !git clone https://github.com/udacity/P1_Facial_Keypoints.git
    !cd P1_Facial_Keypoints
    root_dir = 'P1_Facial_Keypoints/data/training/'
    all_img_paths = glob.glob(os.path.join(root_dir, '*.jpg'))
    data = pd.rea_csv('P1_Facial_Keypoints/data/training_frames_keypoints.csv') 
    

导入数据集的样本如下所示:

Table  Description automatically generated with low confidence

图 5.9:输入数据集

在上述输出中,第 1 列表示图像的名称,偶数列表示与脸部 68 个关键点中的每一个对应的x轴值,其余的奇数列(除了第一列)表示与脸部 68 个关键点中的每一个对应的y轴值。

  1. 定义FacesData类,为数据加载器提供输入和输出数据点:

    class FacesData(Dataset): 
    
    1. 现在让我们定义__init__方法,该方法以文件的 DataFrame (df) 作为输入:
     def __init__(self, df):
            super(FacesData).__init__()
            self.df = df 
    
    1. 定义用于预处理图像以便预训练 VGG16 模型能够使用的均值和标准差:
     self.normalize = transforms.Normalize(
                                    mean=[0.485, 0.456, 0.406], 
                                    std=[0.229, 0.224, 0.225]) 
    
    1. 现在,定义__len__方法:
     def __len__(self): return len(self.df) 
    
    1. 定义__getitem__方法,并获取与给定索引(ix)对应的图像路径:
     def __getitem__(self, ix):
            img_path = 'P1_Facial_Keypoints/data/training/' + self.df.iloc[ix,0] 
    
    1. 缩放图像:
     img = cv2.imread(img_path)/255. 
    
    1. 将预期的输出值(关键点)规范化为原始图像大小的比例:
     kp = deepcopy(self.df.iloc[ix,1:].tolist())
            kp_x = (np.array(kp[0::2])/img.shape[1]).tolist()
            kp_y = (np.array(kp[1::2])/img.shape[0]).tolist() 
    
    1. 在上述代码中,我们确保关键点被提供为原始图像尺寸的比例。这样做是为了在调整原始图像大小时,关键点的位置不会改变,因为关键点是按照原始图像的比例提供的。此外,通过这样做,我们有了预期的输出值,这些值在 0 到 1 之间。

    2. 在预处理图像后返回关键点(kp2)和图像(img):

     kp2 = kp_x + kp_y
            kp2 = torch.tensor(kp2) 
            img = self.preprocess_input(img)
            return img, kp2 
    
    1. 定义预处理图像的函数(preprocess_input):
     def preprocess_input(self, img):
            img = cv2.resize(img, (224,224))
            img = torch.tensor(img).permute(2,0,1)
            img = self.normalize(img).float()
            return img.to(device) 
    
    1. 定义一个加载图像的函数,当我们希望可视化测试图像和测试图像的预测关键点时,这将非常有用:
     def load_img(self, ix):
            img_path = 'P1_Facial_Keypoints/data/training/' + self.df.iloc[ix,0]
            img = cv2.imread(img_path)
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)/255.
            img = cv2.resize(img, (224,224))
            return img 
    
  2. 现在让我们创建训练和测试数据集,并建立训练和测试数据加载器:

    from sklearn.model_selection import train_test_split
    train, test = train_test_split(data, tst_size=0.2, random_state=101)
    train_dataset = FacesData(train.reset_index(drop=True))
    test_dataset = FacesData(test.reset_index(drop=True))
    train_loader = DataLoader(train_dataset, batch_size=32)
    test_loader = DataLoader(test_dataset, batch_size=32) 
    

在上述代码中,我们已经按输入数据帧中的人名拆分了训练和测试数据集,并获取了它们对应的对象。

  1. 现在让我们定义一个模型,我们将利用它来识别图像中的关键点:

    1. 加载预训练的 VGG16 模型:
    def get_model():
        model = models.vgg16(pretrained=True) 
    
    1. 确保先冻结预训练模型的参数:
     for param in model.parameters():
            param.requires_grad = False 
    
    1. 覆盖并解冻模型的最后两层参数:
     model.avgpool = nn.Sequential( nn.Conv2d(512,512,3),
                                          nn.MaxPool2d(2),
                                          nn.Flatten())
        model.classifier = nn.Sequential(
                                          nn.Linear(2048, 512),
                                          nn.ReLU(),
                                          nn.Dropout(0.5),
                                          nn.Linear(512, 136),
                                          nn.Sigmoid()
                                        ) 
    
    1. 请注意,在 classifier 模块中模型的最后一层是一个返回值在 0 到 1 之间的 sigmoid 函数,并且预期的输出将始终在 0 到 1 之间,因为关键点位置是原始图像尺寸的一部分。

    2. 定义损失函数和优化器,并将它们与模型一起返回:

     criterion = nn.L1Loss()
        optimizer = torch.optim.Adam(mode.parameters(), lr=1e-4)
        return model.to(device), criterion, optimizer 
    
  2. 请注意,损失函数为 L1Loss;换句话说,我们正在对面部关键点位置的预测进行均值绝对误差减小(这将被预测为图像宽度和高度的百分比)。

  3. 获取模型、损失函数和相应的优化器:

    model, criterion, optimizer = get_model() 
    
  4. 定义函数以在一批数据点上进行训练,并在测试数据集上进行验证:

    1. 训练一个批次,与之前所做的一样,涉及获取通过模型传递输入的输出、计算损失值并进行反向传播以更新权重:
    def train_batch(img, kps, model, optimizer, criterion):
        model.train()
        optimizer.zero_grad()
        _kps = model(img.to(device))
        loss = criterion(_kps, kps.to(device))
        loss.backward()
        optimizer.step()
        return loss 
    
    1. 构建一个函数,返回测试数据上的损失和预测的关键点:
    def validate_batch(img, kps, model, criterion):
        model.eval()
        _kps = model(img.to(device))
        loss = criterion(_kps, kps.to(device))
        return _kps, loss 
    
  5. 根据训练数据加载器训练模型,并在测试数据上进行测试,就像我们在之前的章节中所做的那样:

    train_loss, test_loss = [], []
    n_epochs = 50
    for epoch in range(n_epochs):
        print(f" epoch {epoch+ 1} : 50")
        epoch_train_loss, epoch_test_loss = 0, 0
        for ix, (img,kps) in enumerate(train_loader):
            loss = train_batch(img, kps, mdel, optimizer, criterion)
            epoch_train_loss += loss.item() 
        epoch_train_loss /= (ix+1)
        for ix,(img,kps) in enumerate(test_loadr):
           ps,  loss = validate_batch(img, kps, model, criterion)
            epoch_test_loss += loss.item() 
        epoch_test_loss /= (ix+1)
        train_loss.append(epoch_train_loss)
        test_loss.append(epoch_test_loss) 
    
  6. 绘制随着 epoch 增加的训练和测试损失:

    epochs = np.arange(50)+1
    import matplotlib.ticker as mtick
    import matplotlib.pyplot as plt
    import matplotlib.ticker as mticker
    %matplotlib inline
    plt.plot(epochs, train_loss, 'bo', label='Training loss')
    plt.plot(epochs, test_loss, 'r', label='Test loss')
    plt.title('Training and Test loss over increasing epochs')
    plt.xlabel('Epochs')
    plt.ylabel('Loss')
    plt.legend()
    plt.grid('off')
    plt.show() 
    

上述代码的输出如下所示:

图表,线图 通过自动生成的描述

图 5.10:随着 epoch 增加的训练和测试损失

  1. 测试我们的模型在一个随机测试图像的索引上,假设为 0。请注意,在以下代码中,我们正在利用早前创建的 FacesData 类中的 load_img 方法:

    ix = 0
    plt.figure(figsize=(10,10))
    plt.subplot(221)
    plt.title('Original image')
    im = test_dataset.load_img(ix)
    plt.imshow(im)
    plt.grid(False)
    plt.subplot(222)
    plt.title('Image with facial keypoints')
    x, _ = test_dataset[ix]
    plt.imshow(im)
    kp = model(x[None]).flatten().detach().cpu()
    plt.scatter(kp[:68]*224, kp[68:]*224, c='r')
    plt.grid(False)
    plt.show() 
    

上述代码的输出如下所示:

图形用户界面 通过低置信度自动生成的描述

图 5.11:(左)原始图像;(右)与预测的面部关键点叠加的原始图像

从上述图像中,我们看到模型能够相当精确地识别面部关键点。

在本节中,我们从头开始构建了面部关键点检测器模型。然而,有些预训练模型既适用于 2D 又适用于 3D 点检测。考虑到有多个数据集/来源可获取面部图像,如果我们在包含比我们之前使用的数据集中更多面部图像的大型数据集上构建模型,会怎么样?

2D 和 3D 面部关键点检测

在本节中,我们将利用一个预训练模型,该模型可以在几行代码中检测人脸中存在的二维和三维关键点。

可以在 GitHub 上的Chapter05文件夹中的2D_and_3D facial_keypoints.ipynb文件中找到以下代码。请确保从 GitHub 的笔记本中复制代码,以避免再现结果时出现问题。

为了处理这个问题,我们将利用face-alignment库:

  1. 安装所需的包:

    !pip install -qU face-alignment
    import face_alignment, cv2 
    
  2. 导入图片:

    !wget https://www.dropbox.com/s/2s7x/Hema.JPG 
    
  3. 定义面部对齐方法,我们可以指定是否要获取 2D 或 3D 中的关键点地标:

    fa = face_alignment.FaceAlignment(face_alignment.LandmarksType.TWO_D, 
                        flip_input=False, device='cpu') 
    
  4. 读取输入图像并将其提供给get_landmarks方法:

    input = cv2.imread('Hema.JPG')
    preds = fa.get_landmarks(input)[0]
    print(preds.shape)
    # (68,2) 
    

在前面的代码行中,我们利用fa类中的get_landmarks方法来获取对应于面部关键点的 68 个xy坐标。

  1. 绘制带有检测到的关键点的图像:

    import matplotlib.pyplot as plt
    %matplotlib inline
    fig,ax = plt.subplots(figsize=(5,5))
    plt.imshow(cv2.cvtColor(cv2.imread('Hema.JPG'), cv2.COLOR_BGR2RGB))
    ax.scatter(preds[:,0], preds[:,1], marker='+', c='r')
    plt.show() 
    

上述代码的结果如下。请注意在 68 个可能的面部关键点周围的+符号的散点图:

图形用户界面,应用程序  自动以中等置信度生成的描述

图 5.12:叠加预测关键点的输入图像

类似地,通过以下方式获得面部关键点的 3D 投影:

  1. fa = face_alignment.FaceAlignment(face_alignment.**LandmarksType.THREE_D**, 
                              flip_input=False, device='cpu')
    input = cv2.imread('Hema.JPG')
    preds = fa.get_landmarks(input)[0]
    import pandas as pd
    df = pd.DataFrame(preds)
    df.columns = ['x','y','z']
    import plotly.express as px
    fig = px.scatter_3d(df, x = 'x', y = 'y', z = 'z')
    fig.show() 
    

请注意,与 2D 关键点场景中使用的代码唯一的更改是我们在LandmarksType中指定为 3D,而不是 2D。上述代码的结果如下:

图表  自动生成的描述

图 5.13:预测的 3D 面部关键点

利用face_alignment库的代码,我们可以利用预训练的面部关键点检测模型,在新图像上有高准确率的预测能力。

到目前为止,在不同的使用案例中,我们学到了以下内容:

  • 猫与狗:进行二元分类预测

  • FashionMNIST:在 10 个可能的类别中预测标签

  • 面部关键点:预测给定图像的多个值,介于 0 和 1 之间

在下一节中,我们将学习如何使用单个网络同时预测二进制类和回归值。

实施年龄估计和性别分类

多任务学习是研究的一个分支,其中使用单个/少量输入来预测几个不同但最终相关的输出。例如,在自动驾驶汽车中,模型需要识别障碍物、规划路线以及正确控制油门/刹车和转向等。它需要通过考虑相同的输入(来自多个传感器)在瞬间完成所有这些任务。此外,多任务学习有助于学习可以跨多个任务交叉利用的领域特定特征,可能在同一领域内。

根据我们迄今解决的各种用例,我们现在能够训练神经网络并在给定图像时估计一个人的年龄或预测一个人的性别,分别一次处理一个任务。但是,我们尚未查看过能够从图像中一次预测年龄和性别的情况。在单次预测中预测两种不同的属性非常重要,因为同一图像用于两种预测(在第七章中进行对象检测时将进一步体现)。

在本节中,我们将学习如何在单个前向传递中预测连续和分类预测两个属性。

可在 GitHub 的Chapter05文件夹中的Age_and_gender_prediction.ipynb文件中找到本节的完整代码,位于bit.ly/mcvp-2e。请确保从 GitHub 笔记本中复制代码以避免重现结果时出现任何问题。

要开始在单个前向传递中预测连续和分类属性,请按照以下步骤进行操作:

  1. 导入相关包:

    import torch
    import numpy as np, cv2, pandas as pd, glob, time
    import matplotlib.pyplot as plt
    %matplotlib inline
    import torch.nn as nn
    from torch import optim
    import torch.nn.functional as F
    from torch.utils.data import Dataset, DataLoader
    import torchvision
    from torchvision import transforms, models, datasets
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 获取数据集:

    from pydrive.auth import GoogleAuth
    from pydrive.drive import GoogleDrive
    from google.colab import auth
    from oauth2client.client import GoogleCredentials
    auth.authenticate_user()
    gauth = GoogleAuth()
    gauth.credentials=GoogleCredentials.get_application_default()
    drive = GoogleDrive(gauth)
    def getFile_from_drive( file_id, name ):
        downloaded = drive.CreateFile({'id': file_id})
        downloaded.GetContentFile(name)
    getFile_from_drive('1Z1RqRo0_JiavaZw2yzZG6WETdZQ8qX86', 
                       'fairface-img-margin025-trainval.zip')
    getFile_from_drive('1k5vvyREmHDW5TSM9QgB04Bvc8C8_7dl-', 
                                'fairface-label-train.csv')
    getFile_from_drive('1_rtz1M1zhvS0d5vVoXUamnohB6cJ02iJ', 
                                  'fairface-label-val.csv')
    !unzip -qq fairface-img-margin025-trainval.zip 
    
  3. 我们下载的数据集可以加载,并且结构如下所示:

    trn_df = pd.read_csv('fairface-label-train.csv')
    val_df = pd.read_csv('fairface-label-val.csv')
    trn_df.head() 
    

前面的代码会产生如下输出:

表 描述自动生成

图 5.14:输入数据集

在上图中,请注意数据集包含文件的路径、年龄、性别和与图像相关的种族。

  1. 构建GenderAgeClass类,该类以文件名作为输入,并返回相应的图像、性别和缩放后的年龄。我们对age进行缩放,因为它是一个连续的数值,并且正如我们在第三章中看到的,缩放数据可以避免梯度消失,并且在后处理期间重新缩放它:

    1. __init__方法中提供图像的文件路径(fpaths):
    IMAGE_SIZE = 224
    class GenderAgeClass(Dataset):
        def __init__(self, df, tfms=None):
            self.df = df
            self.normalize = transforms.Normalize(
                                    mean=[0.485, 0.456, 0.406], 
                                    std=[0.229, 0.224, 0.225]) 
    
    1. 定义__len__方法,该方法返回输入中图像的数量:
     def __len__(self): return len(self.df) 
    
    1. 定义__getitem__方法,用于获取给定位置ix的图像信息:
     def __getitem__(self, ix):
            f = self.df.iloc[ix].squeeze()
            file = f.file
            gen = f.gender == 'Female'
            age = f.age
            im = cv2.imread(file)
            im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
            return im, age, gen 
    
    1. 编写一个预处理图像的函数,包括调整图像大小、置换通道和对缩放图像进行归一化:
     def preprocess_image(self, im):
            im = cv2.resize(im, (IMAGE_SIZE, IMAGE_SIZE))
            im = torch.tensor(im).permute(2,0,1)
            im = self.normalize(im/255.)
            return im[None] 
    
    1. 创建collate_fn方法,获取一个批次的数据,其中数据点按以下方式预处理:

      • 使用process_image方法处理每个图像。

      • 将年龄按 80(数据集中的最大age值)进行缩放,以使所有值都在 0 到 1 之间。

      • gender转换为浮点值。

      • imageagegender分别转换为torch对象并返回:

     def collate_fn(self, batch):
            'preprocess images, ages and genders'
            ims, ages, genders = [], [], []
            for im, age, gender in batch:
                im = self.preprocess_image(im)
                ims.append(im)
                ages.append(float(int(age)/80))
                genders.append(float(gender))
            ages, genders = [torch.tensor(x).to(device).float() \
                                for x in [ages, genders]]
            ims = torch.cat(ims).to(device)
            return ims, ages, genders 
    
  2. 现在我们定义训练和验证数据集以及数据加载器:

    1. 创建数据集:
    trn = GenderAgeClass(trn_df)
    val = GenderAgeClass(val_df) 
    
    1. 指定数据加载器:
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    train_loader = DataLoader(trn, batch_size=32, shuffle=True, 
                              drop_last=True,collate_fn=trn.collate_fn)
    test_loader = DataLoader(val, batch_size=32, 
                             collate_fn=val.collate_fn)
    a,b,c, = next(iter(train_loader))
    print(a.shape, b.shape, c.shape) 
    
  3. 定义模型、损失函数和优化器:

    1. 首先,在函数中加载预训练的 VGG16 模型:
    def get_model():
        model = models.vgg16(pretrained = True) 
    
    1. 接下来,冻结加载的模型(通过指定param.requires_grad = False):
     for param in model.parameters():
            param.requires_grad = False 
    
    1. 用我们自己的层覆盖avgpool层:
     model.avgpool = nn.Sequential(
                            nn.Conv2d(512,512, kernel_size=3),
                            nn.MaxPool2d(2),
                            nn.ReLU(),
                            nn.Flatten()
                           ) 
    

现在到了关键部分。我们偏离了迄今为止学到的内容,通过创建两个输出分支来执行:

  1. __init__ 方法中构建名为 ageGenderClassifier 的神经网络类:

  2.  class ageGenderClassifier(nn.Module):
            def __init__(self):
                super(ageGenderClassifier, self).__init__() 
    
  3. 定义 intermediate 层的计算:

  4.  self.intermediate = nn.Sequential(
                                        nn.Linear(2048,512),
                                        nn.ReLU(),
                                        nn.Dropout(0.4),
                                        nn.Linear(512,128),
                                        nn.ReLU(),
                                        nn.Dropout(0.4),
                                        nn.Linear(128,64),
                                        nn.ReLU(),
                                      ) 
    
  5. 定义 age_classifiergender_classifier

  6.  self.age_classifier = nn.Sequential(
                                            nn.Linear(64, 1),
                                            nn.Sigmoid()
                                        )
                self.gender_classifier = nn.Sequential(
                                            nn.Linear(64, 1),
                                            nn.Sigmoid()
                                           ) 
    

    注意,在上述代码中,最后的层具有 sigmoid 激活,因为 age 输出将是一个在 0 到 1 之间的值(因为它被 80 缩放),而 gender 的输出是 sigmoid,因为输出要么是 0 要么是 1

  7. 定义前向传递方法,首先堆叠 intermediate 层,然后是 age_classifier,然后是 gender_classifier

  8.  def forward(self, x):
                x = self.intermediate(x)
                age = self.age_classifier(x)
                gender = self.gender_classifier(x)
                return gender, age 
    
  9. 使用我们之前定义的类覆盖 classifier 模块:

  10.  model.classifier = ageGenderClassifier() 
    
  11. 定义性别(二元交叉熵损失)和年龄(L1 损失)预测的损失函数。定义优化器并返回模型、损失函数和优化器,如下所示:

  12.  gender_criterion = nn.BCELoss()
        age_criterion = nn.L1Loss()
        loss_functions = gender_criterion, age_criterion
        optimizer = torch.optim.Adam(modelparameters(),lr= 1e-4)
        return model.to(device), loss_functions, optimizer 
    
  13. 调用 get_model 函数以初始化变量中的值:

  14. model, criterion, optimizer = get_model() 
    
  15. 定义训练批次数据和验证数据集批次的函数。train_batch 方法接受图像、实际的性别、年龄值、模型、优化器和损失函数作为输入,以计算损失,如下所示:

    1. 定义带有输入参数的 train_batch 方法:
    def train_batch(data, model, optimizer, criteria): 
    
    1. 指定我们正在训练模型,将优化器重置为 zero_grad,并计算预测的 agegender 的值:
     model.train()
        ims, age, gender = data
        optimizer.zero_grad()
        pred_gender, pred_age = model(ims) 
    
    1. 在计算年龄估计和性别分类的损失之前获取年龄和性别的损失函数:
     gender_criterion, age_criterion = criteria
        gender_loss = gender_criterion(predgender.squeeze(), gender)
        age_loss = age_criterion(pred_age.squeeze(), age) 
    
    1. 通过将 gender_lossage_loss 相加来计算总体损失,并通过优化模型的可训练权重执行反向传播以减少总体损失,并返回总体损失:
     total_loss = gender_loss + age_loss
        total_loss.backward()
        optimizer.step()
        return total_loss 
    
  16. validate_batch 方法接受图像、模型和损失函数,以及实际的年龄和性别值作为输入,计算年龄和性别的预测值以及损失值,如下所示:

    1. 使用适当的输入参数定义 validate_batch 函数:
    def validate_batch(data, model, criteria): 
    
    1. 指定我们要评估模型,因此在通过模型传递图像预测年龄和性别值之前不需要进行梯度计算:
     model.eval()
        with torch.no_grad():
            pred_gender, pred_age = model(img) 
    
    1. 计算对应于年龄和性别预测的损失值 (gender_lossage_loss)。我们将预测值(其形状为 (批次大小,1))压缩,以便将其重塑为与原始值相同形状的形式(其形状为批次大小):
     gender_criterion, age_criterion = criteria
        gender_loss = gender_criterion(prd_gender.squeeze(), gender)
        age_loss = age_criterion(pred_age.squeeze(), age) 
    
    1. 计算总体损失和最终预测的性别类别 (pred_gender),并返回预测的性别、年龄和总损失:
     total_loss = gender_loss + age_loss
        pred_gender = (pred_gender > 0.5).squeeze()
        gender_acc = (pred_gender == gender).float().sum()
        age_mae = torch.abs(age - pred_age).float().sum()
        return total_loss, gender_acc, age_mae 
    
  17. 在五个周期内训练模型:

    1. 定义占位符以存储训练和测试损失值,并指定周期数:
    import time
    model, criteria, optimizer = get_model()
    val_gender_accuracies = []
    val_age_maes = []
    train_losses = []
    val_losses = []
    n_epochs = 5
    best_test_loss = 1000
    start = time.time() 
    
    1. 循环遍历不同的周期,并在每个周期开始时重新初始化训练和测试损失值:
    for epoch in range(n_epochs):
        epoch_train_loss, epoch_test_loss = 0, 0
        val_age_mae, val_gender_acc, ctr = 0, 0, 0
        _n = len(train_loader) 
    
    1. 遍历训练数据加载器(train_loader)并训练模型:
     for ix, data in enumerate(train_loader):
            loss = train_batch(data, model, optimizer, criteria)
            epoch_train_loss += loss.item() 
    
    1. 遍历测试数据加载器并计算性别准确率以及年龄的 mae
     for ix, data in enumerate(test_loader):
            loss, gender_acc, age_mae  validate_batch(data, model, criteria)
            epoch_test_loss += loss.item()
            val_age_mae += age_mae
            val_gender_acc += gender_acc
            ctr += len(data[0]) 
    
    1. 计算年龄预测和性别分类的总体准确率:
     val_age_mae /= ctr
        val_gender_acc /= ctr
        epoch_train_loss /= len(train_loader)
        epoch_test_loss /= len(test_loader) 
    
    1. 记录每个 epoch 的指标:
     elapsed = time.time()-start
        best_test_loss = min(best_test_loss, epoch_test_loss)
        print('{}/{} ({:.2f}s - {:.2f}s remaining)'.format(\
                        epoch+1, n_epchs, time.time()-start, \
                        (n_epochs-epoch)*(elapsed/(epoch+1))))
        info = f'''Epoch: {epoch+1:03d}
                    \tTrain Loss: {epoch_train_loss:.3f}
                    \tTest:\{epoch_test_loss:.3f}
                    \tBest Test Loss: {best_test_loss:.4f}'''
        info += f'\nGender Accuracy: 
                    {val_gender_acc*100:.2f}%\tAge MAE: \
                                        {val_age_mae:.2f}\n'
        print(info) 
    
    1. 在每个 epoch 中存储测试数据集的年龄和性别准确率:
     val_gender_accuracies.append(val_gender_acc)
        val_age_maes.append(val_age_mae) 
    
  18. 绘制随着 epoch 增加的年龄估计和性别预测的准确率:

    epochs = np.arange(1,(n_epochs+1))
    fig,ax = plt.subplots(1,2,figsize=(10,5))
    ax = ax.flat
    ax[0].plot(epochs, val_gender_accuracies, 'bo')
    ax[1].plot(epochs, val_age_maes, 'r')
    ax[0].set_xlabel('Epochs')  ; ax[1].set_xlabel('Epochs')
    ax[0].set_ylabel('Accuracy'); ax[1].set_ylabel('MAE')
    ax[0].set_title('Validation Gender Accuracy')
    ax[0].set_title('Validation Age Mean-Absolute-Error')
    plt.show() 
    

上述代码的结果如下输出:

图表,线图  自动生成描述

图 5.15:(左)性别预测准确率;(右)预测年龄的平均绝对误差

在年龄预测方面我们偏差 6 年,性别预测准确率约为 84%。

  1. 对随机测试图像进行年龄和性别预测:

    1. 获取图像。随意选择您自己的图像:
    !wget https://www.dropbox.com/s/6kzr8/Sindhura.JPG 
    
    1. 加载图像并将其通过我们之前创建的 trn 对象中的 preprocess_image 方法处理:
    im = cv2.imread('/content/Sindhura.JPG')
    im = trn.preprocess_image(im).to(device) 
    
    1. 将图像传递到训练好的模型中:
    gender, age = model(im)
    pred_gender = gender.to('cpu').detach().numpy()
    pred_age = age.to('cpu').detach().numpy() 
    
    1. 绘制图像并打印原始值和预测值:
    im = cv2.imread('/content/Sindhura.JPG')
    im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
    plt.imshow(im)
    print('predicted gender:',np.here(pred_gender[0][0]<0.5, 'Male','Female'),
          '; Predicted age', int(pred_age[0][0]*80)) 
    

上述代码的结果如下输出:

图形用户界面,应用程序  自动生成描述

图 5.16:样本图像的预测

在上述用例中,我们可以看到我们能够一次性进行年龄和性别的预测。但是,需要注意的是这是非常不稳定的,不同图像方向和光照条件下年龄值会有很大变化。数据增强在这种情况下非常有用。此外,作为练习,可以训练自己的模型,仅提取面部区域来计算人的年龄和性别,这样就不考虑背景信息了。

到目前为止,我们已经学习了迁移学习、预训练架构以及如何在两种不同的用例中利用它们。您可能还注意到,代码稍微有些冗长,我们手动导入大量包,创建空列表以记录指标,并不断读取/显示图像以进行调试。在下一节中,我们将学习关于避免这种冗长代码的作者建立的一个库。

介绍 torch_snippets

正如您可能注意到的,我们几乎在所有部分中都使用相同的函数。反复编写相同的函数代码是浪费时间的。为了方便起见,我们这本书的作者编写了一个名为 torch_snippets 的 Python 库,使我们的代码看起来简短而干净。

诸如读取图像、显示图像和整个训练循环等工具是非常重复的。我们希望通过将它们封装在尽可能是单个函数调用的代码中来避免一遍又一遍地编写相同的函数。例如,要读取彩色图像,我们无需每次写cv2.imread(...)然后跟着cv2.cvtColor(...)。相反,我们只需简单地调用read(...)即可。同样地,对于plt.imshow(...),存在许多麻烦,包括图像的大小应该是最佳的,通道维度应该是最后一个(记住,PyTorch 将它们放在第一位)。

所有这些都将由单个函数show处理。类似于readshow,我们将在整本书中使用超过 20 个便捷函数和类。从现在开始,我们将使用torch_snippets,以便更多地专注于实际的深度学习工作而不受干扰。让我们稍微深入一下,通过这个库训练agegender,以便学习如何使用这些函数并获得最大的收益。

本节的完整代码可以在 GitHub 上的age_gender_torch_snippets.ipynb文件中找到,位于Chapter05文件夹中,网址为bit.ly/mcvp-2e。为简洁起见,我们仅在此提供附加代码。如需完整代码,请参阅 GitHub 上的笔记本。

为了更好地理解和利用用于训练同时预测年龄和性别的模型的函数,我们将从安装和加载必要的库开始。按照以下步骤开始:

  1. 安装并加载该库:

    !pip install torch_snippets
    from torch_snippets import * 
    

一开始,该库允许我们加载所有重要的torch模块和工具,例如 NumPy、pandas、Matplotlib、Glob、Os 等等。

  1. 下载数据并按照前面部分创建数据集。创建一个名为GenderAgeClass的数据集类,并做出一些改变,以下面的代码为例:

    class GenderAgeClass(Dataset):
        ...
        def __getitem__(self, ix):
            ...
            age = f.age
            **im = read(file,** **1****)**
            return im, age, gen
        def preprocess_image(self, im):
            **im = resize(im, IMAGE_SIZE)**
            im = torch.tensor(im).permute(2,0,1)
            ... 
    

在上述代码块中,行im = read(file, 1)cv2.imreadcv2.COLOR_BGR2RGB封装为单个函数调用。数字“1”代表“读取为彩色图像”,如果未指定,将默认加载黑白图像。还有一个resize函数封装了cv2.resize。接下来,让我们看看show函数。

  1. 指定训练和验证数据集,并查看样本图像:

    trn = GenderAgeClass(trn_df)
    val = GenderAgeClass(val_df)
    train_loader = DataLoader(trn, batch_size=32, shuffle=rue, \
                              drop_last=True, collate_fn=trn.collate_fn)
    test_loader = DataLoader(val, batch_siz=32, collate_fn=val.collate_fn)
    im, gen, age = trn[0]
    **show(im, title=****f'Gender:** **{gen}****\nAge:** **{age}****'****, sz=****5****)** 
    

由于本书涉及到图像处理,将import matplotlib.pyplot as pltplt.imshow封装成一个函数是有意义的。调用show(<2D/3D-Tensor>)将实现这一功能。与 Matplotlib 不同,它可以绘制在 GPU 上存在的 torch 数组,无论图像是否包含作为第一维还是最后一维的通道。

关键词title将在图像上绘制标题,关键词sz(缩写大小)将根据传递的整数值绘制较大/较小的图像(如果未传递,则sz将根据图像分辨率选择合理的默认值)。在目标检测章节(第 7第 8章)中,我们将使用同一函数显示边界框。查看help(show)获取更多参数。让我们在这里创建一些数据集,并检查第一批图像及其目标。

  1. 创建数据加载器并检查张量。检查张量的数据类型、最小值、均值、最大值和形状是如此常见的活动,它被封装为一个函数。它可以接受任意数量的张量输入:

    train_loader = DataLoader(trn, batch_size=32, shuffleTrue, \
                              drop_last=True, collate_fn=trn.collate_fn)
    test_loader = DataLoader(val, batch_sie=32, \
                             collate_fn=val.collate_fn)
    ims, gens, ages = next(iter(train_loader))
    **inspect(ims, gens, ages)** 
    

inspect输出将如下所示:

============================================================
Tensor Shape: torch.Size([32, 3, 224, 224]) Min: -2.118 Max: 2.640 Mean: 0.133 dtype: torch.float32
============================================================
Tensor Shape: torch.Size([32]) Min: 0.000 Max: 1.000 Mean: 0.594 dtype: torch.float32 
============================================================
Tensor Shape: torch.Size([32]) Min: 0.087 Max: 0.925 Mean: 0.400 dtype: torch.float32 
============================================================ 
  1. 像往常一样创建modeloptimizerloss_functionstrain_batchvalidate_batch。由于每个深度学习实验都是独特的,因此此步骤没有任何包装函数。

在本节中,我们将利用在前一节中定义的get_modeltrain_batchvalidate_batch函数。为简洁起见,本节中不提供代码。然而,所有相关代码均可在 GitHub 上对应的笔记本中找到。

  1. 最后,我们需要加载所有组件并开始训练。记录随着增加的 epochs 的指标。

这是一个高度重复的循环,只需进行最小的更改。我们将始终在固定数量的 epochs 上进行循环,首先是在训练数据加载器上,然后是在验证数据加载器上。每个批次都是使用train_batchvalidate_batch调用的。每次您必须创建指标的空列表,并在训练/验证后跟踪它们的平均值。在 epoch 结束时,您必须打印所有这些指标的平均值并重复此任务。还有一个有用的方法,您要知道每个 epoch/批次将训练多长时间(以秒为单位)。最后,在训练结束时,通常会使用matplotlib绘制相同的指标。所有这些都包装在一个名为Report的单一实用程序中。这是一个 Python 类,具有不同的方法来理解。以下代码中的粗体部分突出了Report的功能性:

model, criterion, optimizer = get_model()
n_epochs = 5
**log = Report(n_epochs)**
for epoch in range(n_epochs):
    N = len(train_loader)
    for ix, data in enumerate(train_loader):
        total_loss,gender_loss,age_loss = train_batch(data, 
                                  model, optimizer, criterion)
        **log.record(epoch+(ix+****1****)/N, trn_loss=total_loss, end=****'\r'****)**
    N = len(test_loader)
    for ix, data in enumerate(test_loader):
        total_loss,gender_acc,age_mae = validate_batc(data, \
                                           model, criterion)
        gender_acc /= len(data[0])
        age_mae /= len(data[0])
        **log.record(epoch+(ix+****1****)/N, val_loss=total_loss,** 
 **val_gender_acc=gender_acc,** 
 **val_age_mae=age_mae, end=****'\r'****)**
    **log.report_avgs(epoch+****1****)**
**log.plot_epochs()** 

在开始训练之前,使用要训练的 epoch 数实例化Report类,并在训练开始之前实例化。

在每个训练/验证步骤中,我们可以调用Report.record方法,传递一个位置参数(在批次号中的位置),通常是(epoch_number + (1+batch number)/(total_N_batches)。在位置参数之后,我们传递一堆关键字参数,可以自由选择。如果需要捕获训练损失,关键字参数可以是trn_loss。在前面的例子中,我们记录了四个指标,trn_lossval_lossval_gender_accval_age_mae,而不创建单个空列表。

它不仅记录,还会将相同的损失打印在输出中。使用'\r'作为结束参数的方式特殊地表示:在记录新的损失集合时替换此行。此外,Report将自动计算训练和验证的剩余时间并打印出来。当调用Report.report_avgs函数时,Report将记住指标的记录时间并在该 epoch 时打印所有平均指标。这将是一个永久性的打印。

最后,相同的平均指标作为线图显示在函数调用Report.plot_epochs中,无需格式化(您也可以使用Report.plot来绘制整个训练的每个批次指标,但这可能看起来很混乱)。同样的函数可以在需要时选择性地绘制指标。举个例子,在前述情况下,如果您只想绘制trn_lossval_loss指标,可以通过调用log.plot_epochs(['trn_loss', 'val_loss'])或者简单地log.plot_epochs('_loss')来实现。这将搜索所有指标并确定我们要请求的指标。

一旦训练完成,前面代码段的输出应如下所示:

图表,线图  自动生成描述

图 5.17:随着 epoch 增加的训练和验证损失

请注意,输出具有对应的年龄和性别值的训练和验证数据集损失和准确性值,即使我们没有在训练和验证数据集中初始化任何空列表来记录这些指标(这在前几节中我们已经做过)。

  1. 加载样本图像并进行预测:

    !wget -q https://www.dropbox.com/s/6kzr8/5_9.JPG
    IM = read('/content/5_9.JPG', 1)
    im = trn.preprocess_image(IM).to(device)
    gender, age = model(im)
    pred_gender = gender.to('cpu').detach().numpy()
    pred_age = age.to('cpu').detach().numpy()
    info = f'predicted gender: {np.where(pred_gender[0[0]<0.5, \
    "Male","Female")}\n Predicted age {int(pred_age[0][0]*80)}'
    show(IM, title=info, sz=10) 
    

通过上述步骤,我们能够用更少的代码完成任务。总结一下,在接下来的书中,我们将在需要的任何地方使用以下重要函数(以及它们包装的函数):

  • from torch_snippets import *

  • 全局(glob.glob

  • 选择(np.random.choice

  • 读取(cv2.imread

  • 显示(plt.imshow

  • 子图(plt.subplots – 显示图像列表)

  • 检查(tensor.mintensor.meantensor.maxtensor.shapetensor.dtype – 多个张量的统计信息)

  • 报告(在训练过程中跟踪所有指标并在训练后绘制它们)

您可以通过运行torch_snippets.print(dir(torch_snippets))查看所有函数的完整列表。对于每个函数,您可以使用help(function)或者在 Jupyter 笔记本中简单地使用??function来打印其帮助信息。通过充分利用torch_snippets,您应该能够大大简化代码。从下一章开始,您将看到这一点的实际应用。

概要

在本章中,我们已经学习了如何通过迁移学习在数据点较少的情况下实现高准确度。我们还了解了流行的预训练模型 VGG 和 ResNet。此外,我们理解了在试图预测不同场景时如何构建模型,例如在面部关键点的位置和在训练模型同时预测年龄和性别时如何组合损失值,其中年龄是某种数据类型,而性别是另一种数据类型。

通过迁移学习的图像分类基础,我们将在下一章中学习关于训练图像分类模型的一些实际方面。我们将学习如何解释模型,训练模型以实现高准确度的技巧和窍门,以及实施训练模型时从业者需要避免的陷阱。

问题

  1. VGG 和 ResNet 的预训练架构是在什么上训练的?

  2. 为什么 VGG11 的准确性不如 VGG16?

  3. VGG11 中的数字 11 代表什么?

  4. “残差网络”中的术语残差指的是什么?

  5. 什么是残差网络的优势?

  6. 书中讨论的各种流行的预训练模型及其各自的特点是什么?

  7. 在迁移学习期间,为什么应该使用与训练预训练模型时相同的均值和标准差对图像进行标准化?

  8. 何时以及为什么我们应该冻结模型中的某些参数?

  9. 我们如何知道预训练模型中存在的各种模块?

  10. 我们如何训练一个可以同时预测分类和数值的模型?

  11. 如果我们执行与我们在实现年龄估计和性别分类部分编写的相同代码,则为什么年龄和性别预测代码不总是适用于您自己的图像?

  12. 我们如何进一步提高我们在实现面部关键点检测部分讨论的面部关键点识别模型的准确性?

在 Discord 上了解更多

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第六章:图像分类的实际方面

在前几章中,我们学习了如何利用卷积神经网络CNNs)和预训练模型进行图像分类。本章将进一步巩固我们对 CNN 的理解,并在实际应用中考虑使用它们时需要考虑的各种实际方面。我们将首先通过使用类激活映射CAMs)来理解 CNN 为何预测特定类别。接下来,我们将学习各种可以改善模型准确性的数据增强方法。最后,我们将了解在真实世界中模型可能出现问题的各种情况,并强调在这些情况下应该注意的方面,以避免陷阱。

以下主题将在本章中涵盖:

  • 生成 CAMs

  • 理解批量归一化和数据增强的影响

  • 在模型实施期间需要注意的实际方面

此外,通过实现以下模型,你将了解到前述主题:

  • 预测细胞图像是否指示疟疾

  • 分类道路信号

生成 CAMs

想象一种情境,你建立了一个能够进行良好预测的模型。然而,你向模型的利益相关者展示时,他们希望理解模型预测的原因。在这种情况下,CAM 非常有用。

例如,CAM 如下所示,左侧为输入图像,右侧突出显示用于生成类预测的像素:

图 6.1:图像(左)和相应的 CAM(右)

这样,如果想要调试或理解模型预测,可以利用 CAM 来了解影响输出预测最多的像素。

让我们了解一下在模型训练后如何生成 CAM。特征图是卷积操作后产生的中间激活。通常,这些激活图的形状是n 通道 x 高度 x 宽度。如果我们取所有这些激活的均值,它们会显示图像中所有类别的热点位置。但是,如果我们只关心对于特定类别(比如)而言真正重要的位置,我们需要找出在n 通道中只负责该类别的那些特征图。对于生成这些特征图的卷积层,我们可以计算其相对于类别的梯度。

请注意,只有那些负责预测的通道才会有很高的梯度。这意味着我们可以利用梯度信息来赋予n 通道中的每一个权重,并获得一个专门用于类的激活图。

现在我们了解了生成 CAM 的高级策略,让我们一步步实践:

  1. 决定要计算 CAM 的类别以及要在神经网络中哪个卷积层计算 CAM。

  2. 计算任意卷积层的激活:假设随机卷积层的特征形状为 512 x 7 x 7。

图 6.2: 计算某层的激活

  1. 获取与感兴趣类别相关的梯度值。输出梯度形状为 256 x 512 x 3 x 3(即卷积张量的形状:即输入通道 x 输出通道 x 卷积核大小 x 卷积核大小)。

图 6.3: 获取梯度值

  1. 计算每个输出通道内梯度的均值。输出形状为 512。在下图中,我们计算的均值使我们从输入形状为 256 x 512 x 3 x 3 中得到了形状为 512 的输出。

图 6.4: 计算梯度均值

  1. 计算加权激活图,即将 512 个梯度均值乘以 512 个激活通道。输出形状为 512 x 7 x 7。

图 6.5: 计算加权激活图

  1. 计算加权激活图的均值(跨 512 个通道),以获取形状为 7 x 7 的输出。

图 6.6: 计算加权激活图的均值

  1. 调整(放大)加权激活图输出,以获取与输入大小相同的图像。这样做是为了得到一个类似原始图像的激活图。

图 6.7: 放大加权激活图

  1. 将加权激活图叠加到输入图像上。

下图来自论文Grad-CAM: 基于梯度定位的深度网络视觉解释 (arxiv.org/abs/1610.02391),生动地描述了前述步骤:

图 6.8: 计算 CAMs 概述

整个过程的关键在于步骤 5。我们考虑步骤的两个方面:

  • 如果某个像素很重要,则 CNN 在这些像素上的激活会很大。

  • 如果某个卷积通道对所需类别很重要,那么该通道的梯度会非常大。

通过这两者的乘积,我们确实得到一个跨所有像素重要性的地图。

前述策略在代码中实现,以理解 CNN 模型预测图像显示疟疾事件可能性的原因如下:

本书的 GitHub 仓库中Chapter06文件夹中提供了 Class_activation_maps.ipynb 代码 (bit.ly/mcvp-2e)。代码包含从中下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 上执行该笔记本,以重现结果,同时理解文本中执行和各种代码组件的解释步骤。

  1. 下载数据集并导入相关包(确保提供您的 Kaggle 用户名和密钥):

    %%writefile kaggle.json
    {"username":"XX","key":"XX"}
    !pip install -q kaggle
    !mkdir -p ~/.kaggle
    !cp kaggle.json ~/.kaggle/
    !ls ~/.kaggle
    !chmod 600 /root/.kaggle/kaggle.json
    !kaggle datasets download -d iarunava/cell-images-for-detecting-malaria
    %pip install -U -q torch_snippets
    !unzip -qq cell_images.zip
    import os
    from torch_snippets import * 
    

输入图像的样本如下所示:

一个宝宝的特写图  自动生成的描述图 6.9:寄生图像(左)和未感染图像(右)

  1. 指定与输出类相对应的索引:

    id2int = {'Parasitized': 0, 'Uninfected': 1} 
    
  2. 执行在图像顶部要执行的转换:

    from torchvision import transforms as T
    trn_tfms = T.Compose([
                    T.ToPILImage(),
                    T.Resize(128),
                    T.CenterCrop(128),
                    T.ColorJitter(brightness=(0.95,1.05), 
                                  contrast=(0.95,1.05), 
                                  saturation=(0.95,1.05), 
                                  hue=0.05),
                    T.RandomAffine(5, translate=(0.01,0.1)),
                    T.ToTensor(),
                    T.Normalize(mean=[0.5, 0.5, 0.5], 
                                std=[0.5, 0.5, 0.5]),
                ]) 
    

在前述代码中,我们对输入图像进行了一系列转换,即对图像进行了调整大小的流水线(以确保其中一个维度的最小尺寸为 128,在本例中),然后从中心裁剪。此外,我们进行了随机颜色抖动和仿射变换。接下来,我们使用 .ToTensor 方法对图像进行缩放,使其值介于 01 之间,最后对图像进行了归一化。正如在 第四章 中讨论的那样,我们也可以使用 imgaug 库。

  1. 指定在验证图像上要执行的转换:

    val_tfms = T.Compose([
                    T.ToPILImage(),
                    T.Resize(128),
                    T.CenterCrop(128),
                    T.ToTensor(),
                    T.Normalize(mean=[0.5, 0.5, 0.5], 
                                std=[0.5, 0.5, 0.5]),
                ]) 
    
  2. 定义 MalariaImages 数据集类:

    class MalariaImages(Dataset):
        def __init__(self, files, transform=None):
            self.files = files
            self.transform = transform
            logger.info(len(self))
        def __len__(self):
            return len(self.files)
        def __getitem__(self, ix):
            fpath = self.files[ix]
            clss = fname(parent(fpath))
            img = read(fpath, 1)
            return img, clss
        def choose(self):
            return self[randint(len(self))]
        def collate_fn(self, batch):
            _imgs, classes = list(zip(*batch))
            if self.transform:
                imgs = [self.transform(img)[None] for img in _imgs]
            classes = [torch.tensor([id2int[clss]]) for class in classes]
            imgs, classes = [torch.cat(i).to(device) for i in [imgs, classes]]
            return imgs, classes, _imgs 
    
  3. 获取训练和验证数据集以及数据加载器:

    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    all_files = Glob('cell_images/*/*.png')
    np.random.seed(10)
    np.random.shuffle(all_files)
    from sklearn.model_selection import train_test_split
    trn_files, val_files = train_test_split(all_files, random_state=1)
    trn_ds = MalariaImages(trn_files, transform=trn_tfms)
    val_ds = MalariaImages(val_files, transform=val_tfms)
    trn_dl = DataLoader(trn_ds, 32, shuffle=True, 
                        collate_fn=trn_ds.collate_fn)
    val_dl = DataLoader(val_ds, 32, shuffle=False, 
                        collate_fn=val_ds.collate_fn) 
    
  4. 定义 MalariaClassifier 模型:

    def convBlock(ni, no):
        return nn.Sequential(
            nn.Dropout(0.2),
            nn.Conv2d(ni, no, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.BatchNorm2d(no),
            nn.MaxPool2d(2),
        )
    
    class MalariaClassifier(nn.Module):
        def __init__(self):
            super().__init__()
            self.model = nn.Sequential(
                convBlock(3, 64),
                convBlock(64, 64),
                convBlock(64, 128),
                convBlock(128, 256),
                convBlock(256, 512),
                convBlock(512, 64),
                nn.Flatten(),
                nn.Linear(256, 256),
                nn.Dropout(0.2),
                nn.ReLU(inplace=True),
                nn.Linear(256, len(id2int))
            )
            self.loss_fn = nn.CrossEntropyLoss()
        def forward(self, x):
            return self.model(x)
        def compute_metrics(self, preds, targets):
            loss = self.loss_fn(preds, targets)
            acc =(torch.max(preds, 1)[1]==targets).float().mean()
            return loss, acc 
    
  5. 定义用于训练和验证数据批次的函数:

    def train_batch(model, data, optimizer, criterion):
        model.train()
        ims, labels, _ = data
        _preds = model(ims)
        optimizer.zero_grad()
        loss, acc = criterion(_preds, labels)
        loss.backward()
        optimizer.step()
        return loss.item(), acc.item()
    @torch.no_grad()
    def validate_batch(model, data, criterion):
        model.eval()
        ims, labels, _ = data
        _preds = model(ims)
        loss, acc = criterion(_preds, labels)
        return loss.item(), acc.item() 
    
  6. 在增加的 epochs 上训练模型:

    model = MalariaClassifier().to(device)
    criterion = model.compute_metrics
    optimizer = optim.Adam(model.parameters(), lr=1e-3)
    n_epochs = 2
    log = Report(n_epochs)
    for ex in range(n_epochs):
        N = len(trn_dl)
        for bx, data in enumerate(trn_dl):
            loss, acc = train_batch(model, data, optimizer, criterion)
            log.record(ex+(bx+1)/N,trn_loss=loss,trn_acc=acc, end='\r')
        N = len(val_dl)
        for bx, data in enumerate(val_dl):
            loss, acc = validate_batch(model, data, criterion)
            log.record(ex+(bx+1)/N,val_loss=loss,val_acc=acc, end='\r')
    
        log.report_avgs(ex+1) 
    
  7. 在模型中的第五个 convBlock 中提取卷积层:

    im2fmap = nn.Sequential(*(list(model.model[:5].children())+ \
                            list(model.model[5][:2].children()))) 
    

在前面的代码行中,我们正在获取模型的第四层以及 convBlock 中的前两层,这恰好是 Conv2D 层。

  1. 定义 im2gradCAM 函数,该函数接受输入图像并获取与图像激活对应的热图:

    def im2gradCAM(x):
        model.eval()
        logits = model(x)
        heatmaps = []
        activations = im2fmap(x)
        print(activations.shape)
        pred = logits.max(-1)[-1]
        # get the model's prediction
        model.zero_grad()
        # compute gradients with respect to 
        # model's most confident logit
        logits[0,pred].backward(retain_graph=True)
        # get the gradients at the required featuremap location
        # and take the avg gradient for every featuremap
        pooled_grads = model.model[-6][1].weight.grad.data.mean((1,2,3))
        # multiply each activation map with 
        # corresponding gradient average
        for i in range(activations.shape[1]):
            activations[:,i,:,:] *= pooled_grads[i]
        # take the mean of all weighted activation maps
        # (that has been weighted by avg. grad at each fmap)
        heatmap =torch.mean(activations, dim=1)[0].cpu().detach()
        return heatmap, 'Uninfected' if pred.item() else 'Parasitized' 
    
  2. 定义 upsampleHeatmap 函数以将热图上采样到与图像形状对应的形状:

    SZ = 128
    def upsampleHeatmap(map, img):
        m,M = map.min(), map.max()
        map = 255 * ((map-m) / (M-m))
        map = np.uint8(map)
        map = cv2.resize(map, (SZ,SZ))
        map = cv2.applyColorMap(255-map, cv2.COLORMAP_JET)
        map = np.uint8(map)
        map = np.uint8(map*0.7 + img*0.3)
        return map 
    

在前面的代码行中,我们正在对图像进行反归一化,并将热图叠加在图像顶部。

  1. 在一组图像上运行上述函数:

    N = 20
    _val_dl = DataLoader(val_ds, batch_size=N, shuffle=True, \
                         collate_fn=val_ds.collate_fn)
    x,y,z = next(iter(_val_dl))
    for i in range(N):
        image = resize(z[i], SZ)
        heatmap, pred = im2gradCAM(x[i:i+1])
        if(pred=='Uninfected'):
            continue
        heatmap = upsampleHeatmap(heatmap, image)
        subplots([image, heatmap], nc=2, figsize=(5,3), suptitle=pred) 
    

前述代码的输出如下:

一个包含色彩的图片  自动生成的描述

图 6.10:原始寄生图像及其对应的 CAM

从中可以看出,预测之所以如此,是因为突出显示的内容具有最高的 CAM 值。

现在我们已经了解了如何使用训练模型为图像生成类激活热图,我们能够解释某个分类之所以如此的原因。在接下来的部分,让我们学习一些关于数据增强的额外技巧,这些技巧在构建模型时能够提供帮助。

理解数据增强和批量归一化的影响

通过利用数据增强的聪明方式来提高模型的准确性。正如在第四章中已经提到的那样,我们在 GitHub 仓库中提供了关于数据增强的大量额外细节。在现实世界中,你会遇到具有不同属性的图像:例如,一些图像可能更亮,一些可能在边缘附近包含感兴趣的对象,而一些图像可能比其他图像更加抖动。在本节中,我们将了解如何利用数据增强来帮助提高模型的准确性。此外,我们将了解数据增强如何在实践中成为模型的伪正则化器。

为了理解数据增强和批标准化的影响,我们将通过一个识别交通标志的数据集进行评估。我们将评估三种情况:

  • 没有批标准化/数据增强

  • 仅批标准化,但没有数据增强

  • 既有批标准化又有数据增强

注意,由于数据集和处理在三种情况下保持不变,只有数据增强和模型(批标准化层的添加)有所不同,因此我们只提供第一个情景的以下代码,但其他两种情况在 GitHub 笔记本中可用。

编写道路标志检测代码

让我们编写道路标志检测的代码,没有数据增强和批标准化,如下所示:

请注意,我们在这里不解释代码,因为它与我们在先前章节中讨论过的代码非常一致;只有粗体字体的行在三种情况下有所不同。以下代码在本书的 GitHub 库的Chapter06文件夹中的road_sign_detection.ipynb文件中以bit.ly/mcvp-2e链接提供。

  1. 下载数据集并导入相关包:

    import os
    if not os.path.exists('GTSRB'):
        %pip install -U -q torch_snippets
        !wget -qq https://sid.erda.dk/public/archives/
            daaeac0d7ce1152aea9b61d9f1e19370/
            GTSRB_Final_Training_Images.zip
        !wget -qq https://sid.erda.dk/public/archives/
            daaeac0d7ce1152aea9b61d9f1e19370/
            GTSRB_Final_Test_Images.zip
        !unzip -qq GTSRB_Final_Training_Images.zip
        !unzip -qq GTSRB_Final_Test_Images.zip
        !wget https://raw.githubusercontent.com/georgesung/
         traffic_sign_classification_german/master/signnames.csv
        !rm GTSRB_Final_Training_Images.zip 
           GTSRB_Final_Test_Images.zip
    from torch_snippets import * 
    
  2. 为可能的输出类分配类 ID:

    classIds = pd.read_csv('signnames.csv')
    classIds.set_index('ClassId', inplace=True)
    classIds = classIds.to_dict()['SignName']
    classIds = {f'{k:05d}':v for k,v in classIds.items()}
    id2int = {v:ix for ix,(k,v) in enumerate(classIds.items())} 
    
  3. 定义在没有任何增强的图像上的转换流水线:

    from torchvision import transforms as T
    trn_tfms = T.Compose([
                    T.ToPILImage(),
                    T.Resize(32),
                    T.CenterCrop(32),
                    # T.ColorJitter(brightness=(0.8,1.2), 
                    # contrast=(0.8,1.2), 
                    # saturation=(0.8,1.2), 
                    # hue=0.25),
                    # T.RandomAffine(5,  #translate=(0.01,0.1)),
                    **T.ToTensor(),**
     **T.Normalize(mean=[****0.485****,** **0.456****,** **0.406****],** 
     **std=[****0.229****,** **0.224****,** **0.225****]),**
                ])
    val_tfms = T.Compose([
                    T.ToPILImage(),
                    T.Resize(32),
                    T.CenterCrop(32),
                    T.ToTensor(),
                    T.Normalize(mean=[0.485, 0.456, 0.406], 
                                std=[0.229, 0.224, 0.225]),
                ]) 
    

在上述代码中,我们指定将每个图像转换为 PIL 图像,并从中心调整和裁剪图像。此外,我们使用.ToTensor方法将图像缩放到像素值在01之间;正如我们在第三章中学到的那样,这对于在缩放数据集上训练模型更好。最后,我们对输入图像进行归一化,以便利用预训练模型。

前述代码的注释部分是你应该取消注释并重新运行的内容,以理解执行数据增强的情况。此外,我们不对val_tfms上的图像执行增强,因为这些图像在模型训练期间不使用。然而,val_tfms图像应该通过剩余的转换流水线作为trn_tfms

  1. 定义GTSRB数据集类:

    class GTSRB(Dataset):
        def __init__(self, files, transform=None):
            self.files = files
            self.transform = transform
            logger.info(len(self))
        def __len__(self):
            return len(self.files)
        def __getitem__(self, ix):
            fpath = self.files[ix]
            clss = fname(parent(fpath))
            img = read(fpath, 1)
            return img, classIds[clss]
        def choose(self):
            return self[randint(len(self))]
        def collate_fn(self, batch):
            imgs, classes = list(zip(*batch))
            if self.transform:
                imgs =[self.transform(img)[None] for img in imgs]
            classes = [torch.tensor([id2int[clss]]) for clss in classes]
            imgs, classes = [torch.cat(i).to(device) for i in [imgs, classes]]
            return imgs, classes 
    
  2. 创建训练和验证数据集以及数据加载器:

    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    all_files = Glob('GTSRB/Final_Training/Images/*/*.ppm')
    np.random.seed(10)
    np.random.shuffle(all_files)
    from sklearn.model_selection import train_test_split
    trn_files, val_files = train_test_split(all_files, random_state=1)
    trn_ds = GTSRB(trn_files, transform=trn_tfms)
    val_ds = GTSRB(val_files, transform=val_tfms)
    trn_dl = DataLoader(trn_ds, 32, shuffle=True, \
                        collate_fn=trn_ds.collate_fn)
    val_dl = DataLoader(val_ds, 32, shuffle=False, \
                        collate_fn=val_ds.collate_fn)
    Define the SignClassifier model:
    import torchvision.models as models
    def convBlock(ni, no):
        return nn.Sequential(
                    nn.Dropout(0.2),
                    nn.Conv2d(ni, no, kernel_size=3, padding=1),
                    nn.ReLU(inplace=True),
                    #nn.BatchNorm2d(no),
                    nn.MaxPool2d(2),
                )
    
    class SignClassifier(nn.Module):
        def __init__(self):
            super().__init__()
            self.model = nn.Sequential(
                            convBlock(3, 64),
                            convBlock(64, 64),
                            convBlock(64, 128),
                            convBlock(128, 64),
                            nn.Flatten(),
                            nn.Linear(256, 256),
                            nn.Dropout(0.2),
                            nn.ReLU(inplace=True),
                            nn.Linear(256, len(id2int))
                        )
            self.loss_fn = nn.CrossEntropyLoss()
        def forward(self, x):
            return self.model(x)
        def compute_metrics(self, preds, targets):
            ce_loss = self.loss_fn(preds, targets)
            acc =(torch.max(preds, 1)[1]==targets).float().mean()
            return ce_loss, acc 
    

当您测试具有BatchNormalization情景的模型时,请确保取消convBlock定义行的注释。

  1. 分别定义在数据批次上训练和验证的函数:

    def train_batch(model, data, optimizer, criterion):
        model.train()
        ims, labels = data
        _preds = model(ims)
        optimizer.zero_grad()
        loss, acc = criterion(_preds, labels)
        loss.backward()
        optimizer.step()
        return loss.item(), acc.item()
    @torch.no_grad()
    def validate_batch(model, data, criterion):
        model.eval()
        ims, labels = data
        _preds = model(ims)
        loss, acc = criterion(_preds, labels)
        return loss.item(), acc.item() 
    
  2. 定义模型并在逐步增加的时代训练它:

    model = SignClassifier().to(device)
    criterion = model.compute_metrics
    optimizer = optim.Adam(model.parameters(), lr=1e-3)
    n_epochs = 50
    log = Report(n_epochs)
    for ex in range(n_epochs):
        N = len(trn_dl)
        for bx, data in enumerate(trn_dl):
            loss, acc = train_batch(model, data, optimizer, criterion)
            log.record(ex+(bx+1)/N,trn_loss=loss, trn_acc=acc, end='\r')
        N = len(val_dl)
        for bx, data in enumerate(val_dl):
            loss, acc = validate_batch(model, data, criterion)
            log.record(ex+(bx+1)/N, val_loss=loss, val_acc=acc, end='\r')
    
        log.report_avgs(ex+1)
        if ex == 10: optimizer = optim.Adam(model.parameters(), lr=1e-4) 
    

步骤 3(数据增强)和步骤 5(批量归一化)中被注释掉的代码行是在三种情况下需要更改的代码。在训练和验证精度方面,三种情况的结果如下:

表 6.1:模型在有/无图像增强和批量归一化的消融研究

注意,在前述三种情况中,我们看到以下情况:

  • 当没有批量归一化时,模型的准确性不如此高。

  • 当我们只有批量归一化而没有数据增强时,模型的准确性显著提高,但模型也在训练数据上过拟合。

  • 既有批量归一化又有数据增强的模型在准确性和过拟合方面表现出色(因为训练和验证损失值非常相似)。

在批量归一化和数据增强的重要性存在的情况下,在下一节中,我们将学习在训练/实施我们的图像分类模型时需要注意的一些关键方面。

在模型实施过程中需要注意的实际因素

到目前为止,我们已经看到了在构建图像分类模型时的各种方法。在本节中,我们将学习一些在实际应用中构建模型时需要注意的实际考虑因素。本节将讨论以下内容:

  • 不平衡数据

  • 在分类时执行图像中对象的大小

  • 训练和验证图像之间的差异

  • 网络中卷积和池化层的数量

  • 在 GPU 上训练的图像尺寸

  • OpenCV 实用程序

不平衡数据

想象一种情况,您试图预测一个在我们的数据集中非常罕见的对象:比如说在总图像中只占 1%。例如,这可以是预测 X 光图像是否显示罕见肺部感染的任务。

如何衡量训练用于预测罕见肺部感染的模型的准确性?如果我们简单地预测所有图像的无感染类别,分类的准确率为 99%,但仍然毫无用处。在这种情况下,展示罕见对象类别发生次数和模型正确预测罕见对象类别次数的混淆矩阵在这种情况下非常有用。因此,在这种情况下关注的正确指标集是与混淆矩阵相关的指标。

典型混淆矩阵如下所示:

图 6.11:典型混淆矩阵

在前面的混淆矩阵中,0表示无感染,1表示感染。通常,我们会填充该矩阵以了解我们的模型有多准确。

接下来是确保模型进行训练的问题。通常情况下,损失函数(二元或分类交叉熵)会确保在误分类量高时损失值也很高。然而,除了损失函数外,我们还可以给稀有类别分配更高的权重,从而明确告诉模型我们希望正确分类稀有类别的图像。

除了分配类别权重之外,我们已经看到图像增强和/或迁移学习在显著提高模型准确率方面帮助很大。此外,在增强图像时,我们可以过采样稀有类别的图像,以增加它们在整体人口中的比例。

图像中对象的大小

想象一种情景,即大图像中的小块区域决定了图像的类别:例如,肺部感染识别,其中某些小结节的存在表示疾病的发生。在这种情况下,图像分类可能会导致不准确的结果,因为对象只占整个图像的一小部分。在这种情况下,目标检测非常有用(我们将在下一章节中学习)。

解决这些问题的一个高级直觉方法是首先将输入图像分成更小的网格单元(比如一个 10 x 10 的网格),然后识别网格单元是否包含感兴趣的对象。

除此之外,您可能还想考虑这样一种情况:模型在高分辨率图像上进行训练(也进行推断)。这确保了前面例子中的小结节由足够数量的像素表示,以便进行模型训练。

训练数据与验证数据之间的差异

想象这样一种情景:您建立了一个模型来预测眼睛图像是否表明患者可能患有糖尿病视网膜病变。为了建立模型,您收集了数据,筛选了数据,进行了裁剪和归一化,最终构建了在验证图像上具有非常高准确率的模型。然而,假设在实际环境中(例如由医生/护士使用)使用模型时,模型无法进行良好的预测。让我们了解一些可能的原因:

  • 医生办公室中的图像是否与用于训练模型的图像相似?

  • 在训练时使用的图像和预测时使用的图像(现实世界中的图像)可能会有很大的不同,特别是如果您在经过所有预处理的筛选数据集上构建了模型,而在医生端使用的图像则没有经过筛选。

  • 如果医生办公室用于捕捉图像的设备与用于收集训练图像的设备的图像捕获分辨率不同,图像可能会有所不同。

  • 图像可能会因为两个地方拍摄时的不同光照条件而不同。

  • 图像主体是否足够代表整体人口?

  • 如果在男性人群的图像上进行训练,但在女性人群上进行测试,或者如果在一般情况下,训练和现实世界的图像对应不同的人口统计学特征,则图像是具有代表性的。

  • 训练和验证的拆分是否系统地完成了?

  • 想象一种情况,有 10,000 张图像,前 5,000 张属于一类,后 5,000 张属于另一类。在构建模型时,如果我们不随机分割数据集,而是按顺序分割为训练和验证集(没有随机索引),我们可能会在训练时看到一类的高比例,在验证时看到另一类的高比例。

一般而言,在最终用户利用系统之前,我们需要确保训练、验证和现实世界的图像具有相似的数据分布。我们将在 第十八章 中学习数据漂移的概念,这是一种识别验证/测试数据是否与训练数据不同的技术。

flatten 层中的节点数

考虑一种情况,您正在处理尺寸为 300 x 300 的图像。从技术上讲,我们可以执行超过五次卷积池化操作,以获得具有尽可能多特征的最终层。此外,在 CNN 中,我们在这种情况下可以拥有任意多的通道。然而,在实际中,一般会设计一个网络,使其在 flatten 层中具有 500 到 5,000 个节点。

正如我们在 第四章 中看到的,如果在 flatten 层中有更多节点,那么在连接到最终分类层之前,当 flatten 层连接到后续的密集层时,参数数量将非常高。

通常情况下,最好有一个预训练模型,以获取 flatten 层,从而在适当时激活相关的过滤器。此外,在利用预训练模型时,请确保冻结预训练模型的参数。

一般来说,在较简单的分类任务中,CNN 中可训练参数的数量可以在 1 百万到 1 千万之间。

图像大小

假设我们正在处理非常高维度的图像,例如形状为 2,000 x 1,000。在处理这样大的图像时,我们需要考虑以下可能性:

  • 图像可以调整到较低的尺寸吗?物体的图像在调整大小后可能不会丢失信息;然而,文档图像如果调整到较小的尺寸可能会丢失相当多的信息。

  • 我们可以减小批量大小,以便批量适合 GPU 内存吗?通常,如果我们处理大图像,给定批量大小的情况下,GPU 内存可能不足以对图像批量执行计算。

  • 图像的某些部分是否包含大部分信息,因此是否可以裁剪图像的其余部分?

OpenCV 实用工具

OpenCV 是一个开源包,拥有广泛的模块,可以从图像中提取信息(关于 OpenCV 实用工具的更多详细信息可以在 GitHub 仓库中找到)。在深度学习革命之前,它是计算机视觉领域使用最广泛的库之一。传统上,它是基于多个手工特征构建的,截至本书撰写时,OpenCV 具有几个集成深度学习模型输出的包。

想象一种情景,你必须将模型部署到生产环境中;在这种情况下,通常情况下简化问题会更可取:有时甚至可以以牺牲准确性为代价。如果任何 OpenCV 模块可以解决你已经尝试解决的问题,一般来说,它应该优先于构建模型(除非从头开始构建模型比利用现成模块带来显著的准确性提升更合适)。

总结

在本章中,我们学习了构建 CNN 模型时需要考虑的多个实际因素:批量归一化、数据增强、使用 CAMs 解释结果以及在将模型部署到生产环境时需要注意的一些场景。

在下一章中,我们将转向学习目标检测的基础知识:不仅识别图像中对象对应的类别,还将在对象位置周围绘制边界框。

问题

  1. 如何获取类激活映射?

  2. 在训练模型时,批量归一化和数据增强如何帮助?

  3. CNN 模型过拟合的常见原因是什么?

  4. CNN 模型在训练和验证数据集上有效,但在实际世界中失败的各种场景是什么?

  5. 我们何时利用 OpenCV 包,以及何时优先使用 OpenCV 而不是深度学习的优势场景是什么?

在 Discord 上了解更多

加入我们社区的 Discord 空间,与作者和其他读者讨论:

packt.link/modcv

第七章:物体检测基础

在前几章中,我们学习了如何执行图像分类。想象一下利用计算机视觉进行自动驾驶汽车的场景。不仅需要检测图像中是否包含车辆、人行道和行人等物体,还需要准确识别这些物体的位置。在这种场景下,我们将在本章和下一章中学习的各种物体检测技术将非常有用。

在本章和下一章中,我们将学习一些执行物体检测的技术。我们将从学习基础知识开始 - 使用名为ybat的工具对图像中对象的地面实况边界框进行标记,使用selectivesearch方法提取区域提议,并通过交并比IoU)和均值平均精度度量来定义边界框预测的准确性。之后,我们将学习两个基于区域提议的网络 - R-CNN 和 Fast R-CNN - 首先了解它们的工作细节,然后在包含卡车和公共汽车图像的数据集上实施它们。

本章将涵盖以下主题:

  • 引入物体检测

  • 创建用于训练的边界框地面实况

  • 理解区域提议

  • 理解 IoU、非极大值抑制和均值平均精度

  • 训练基于 R-CNN 的自定义物体检测器

  • 训练基于 Fast R-CNN 的自定义物体检测器

    本章中的所有代码片段都可以在 GitHub 存储库的Chapter07文件夹中找到,链接为bit.ly/mcvp-2e

引入物体检测

随着自动驾驶汽车、面部检测、智能视频监控和人数统计解决方案的兴起,快速准确的物体检测系统需求量大增。这些系统不仅包括从图像中对物体进行分类,还包括在每个物体周围绘制适当边界框以定位它们。这(绘制边界框和分类)使得物体检测比其传统的计算机视觉前辈图像分类更为复杂。

在探索物体检测的广泛用例之前,让我们了解它如何增强我们在上一章中介绍的物体分类任务。想象一下图像中有多个物体的情况。我让你预测图像中存在的物体类别。例如,假设图像中既有猫又有狗。你会如何对这样的图像进行分类?物体检测在这种场景中非常有用,它不仅预测物体的位置(边界框),还预测各个边界框内存在的物体类别。

要理解物体检测的输出是什么样的,请查看以下图表:

图 7.1:对象分类与检测之间的区别

在前述图中,我们可以看到,典型的对象分类仅仅提到图像中存在的对象类别,而对象定位则在图像中的对象周围绘制边界框。另一方面,对象检测涉及绘制边界框以及识别图像中多个对象的边界框中对象的类别。

利用对象检测的一些不同用例包括以下内容:

  • 安全性:这对识别入侵者很有用。

  • 自动驾驶汽车:这对识别道路图像中各种对象很有帮助。

  • 图像搜索:这有助于识别包含感兴趣对象(或人员)的图像。

  • 汽车: 这可以帮助识别汽车图像中的车牌号码。

在所有前述情况下,对象检测被利用来在图像中的各种对象周围绘制边界框。

在本章中,我们将学习预测对象的类别,并在图像中围绕对象创建一个紧密的边界框,这是定位任务。我们还将学习检测图像中多个对象对应的类别,以及围绕每个对象的边界框,这是对象检测任务。

训练典型对象检测模型包括以下步骤:

  1. 创建包含图像中各种对象的边界框标签和类别的真值数据

  2. 提出扫描图像以识别可能包含对象的区域(区域建议)的机制

在本章中,我们将学习利用名为SelectiveSearch的方法生成的区域建议。在下一章中,我们将学习如何利用锚框来识别包含对象的区域。

  1. 通过使用 IoU 指标创建目标类别变量

  2. 创建目标边界框偏移变量,以纠正区域建议在步骤 2中的位置

  3. 构建一个模型,可以预测对象的类别,同时还能预测与区域建议对应的目标边界框偏移量

  4. 使用均值平均精度mAP)来衡量对象检测的准确性

现在我们已经对训练对象检测模型要做的事情有了高层次的概述,我们将在下一节学习为边界框创建数据集(这是构建对象检测模型的第一步)。

创建用于训练的边界框真值

我们已经了解到目标检测以边界框的形式给出感兴趣对象的输出图像。为了构建能够检测这些边界框的算法,我们需要创建输入输出组合,其中输入是图像,输出是边界框和物体类别。

请注意,当我们检测到边界框时,我们实际上是检测到围绕图像的边界框的四个角的像素位置。

要训练一个提供边界框的模型,我们需要图像及其图像中所有物体的对应边界框坐标。在本节中,我们将学习创建训练数据集的一种方法,其中图像是输入,而对应的边界框和物体类别存储在 XML 文件中作为输出。

在这里,我们将安装并使用ybat来创建(标注)图像中物体周围的边界框。我们还将检查包含注释类和边界框信息的 XML 文件。

请注意,还有像 CVAT 和 Label Studio 这样的替代图像标注工具。

让我们从 GitHub 下载ybat-master.zipgithub.com/drainingsun/ybat),然后解压缩它。然后,使用您选择的浏览器打开ybat.html

在我们开始创建与图像对应的真实标签之前,让我们指定我们想要跨图像标记的所有可能类,并将其存储在classes.txt文件中,如下所示:

自动生成的图形用户界面、文本、应用程序描述

图 7.2:提供类名

现在,让我们准备与图像对应的真实标签。这涉及到在物体周围绘制边界框(如以下步骤中所见的人物),并为图像中存在的对象分配标签/类别:

  1. 上传您想要标注的所有图像。

  2. 上传classes.txt文件。

  3. 通过首先选择文件名,然后在要标记的每个对象周围绘制十字线来为每个图像进行标记。在绘制十字线之前,请确保在以下图像中步骤 2下正确选择classes区域中的类别(classes窗格可以看到以下图像中步骤 2之下)。

  4. 将数据转储保存为所需格式。每种格式都是由不同的研究团队独立开发的,它们都同样有效。基于它们的流行度和便利性,每个实现都更喜欢不同的格式。

正如你所看到的,在下图中表示了前述步骤:

图 7.3:标注步骤

例如,当我们下载 PascalVOC 格式时,它会下载一个 XML 文件的压缩包。在 GitHub 上,可以看到绘制矩形边界框后 XML 文件的快照,文件名为sample_xml_file. xml。在那里,您将观察到bndbox字段包含感兴趣图像中对象的xy坐标的最小和最大值。我们还应该能够使用name字段提取图像中对象对应的类别。

现在我们了解了如何创建图像中存在对象的真实对象(类别标签和边界框),让我们深入了解识别图像中对象的基本构建块。首先,我们将介绍有助于突出显示最可能包含对象部分的区域建议。

理解区域建议

想象一个假设情景,感兴趣图像中包含一个人和背景的天空。假设背景(天空)的像素强度变化很小,而前景(人物)的像素强度变化很大。

仅从上述描述本身,我们可以得出这里有两个主要区域 - 人物和天空。此外,在人物图像的区域内,对应头发的像素与对应脸部的像素强度不同,建立了区域内可能存在多个子区域的事实。

区域建议 是一种技术,有助于识别区域岛,其中像素彼此相似。生成区域建议对于目标检测非常有用,其中我们必须识别图像中存在的对象的位置。此外,由于区域建议生成了一个区域的建议,它有助于目标定位,其中的任务是识别一个完全适合对象周围的边界框。我们将在稍后的部分,基于训练 R-CNN 的自定义对象检测器中学习区域建议如何协助对象的定位和检测,但首先让我们了解如何从图像中生成区域建议。

利用 SelectiveSearch 生成区域建议

SelectiveSearch 是用于目标定位的区域建议算法,它根据像素强度生成可能被一起分组的区域建议。SelectiveSearch 根据类似像素的层次分组像素,进而利用图像中内容的颜色、纹理、大小和形状的兼容性进行分组。

首先,SelectiveSearch 通过根据前述属性分组像素来过度分割图像。然后,它遍历这些过度分割的群组,并根据相似性将它们分组。在每次迭代中,它将较小的区域组合成较大的区域。

让我们通过以下示例了解selectivesearch的过程:

在本书的 GitHub 仓库Chapter07文件夹中的Understanding_selectivesearch.ipynb中找到此练习的完整代码,网址为bit.ly/mcvp-2e

  1. 安装所需的包:

    %pip install selectivesearch
    %pip install torch_snippets
    from torch_snippets import *
    import selectivesearch
    from skimage.segmentation import felzenszwalb 
    
  2. 获取并加载所需的图像:

    !wget https://www.dropbox.com/s/l98leemr7/Hemanvi.jpeg
    img = read('Hemanvi.jpeg', 1) 
    
  3. 从图像中提取基于颜色、纹理、大小和形状兼容性的felzenszwalb分段:

    segments_fz = felzenszwalb(img, scale=200) 
    

请注意,在felzenszwalb方法中,scale表示可以在图像段内形成的簇的数量。scale值越高,保留的原始图像细节就越多。

  1. 绘制原始图像和带有分割的图像:

    subplots([img, segments_fz],
             titles=['Original Image',
                     'Image post\nfelzenszwalb segmentation'], sz=10, nc=2) 
    

图 7.4:原始图像及其相应的分割

从前述输出中,请注意属于同一组的像素具有相似的像素值。

具有相似值的像素形成区域提议。这有助于目标检测,因为我们现在将每个区域提议传递给网络,并要求它预测区域提议是背景还是对象。此外,如果它是对象,它还帮助我们识别获取与对象对应的紧凑边界框的偏移量,以及区域提议内内容对应的类别。

现在我们了解了 SelectiveSearch 的作用,让我们实现selectivesearch函数来获取给定图像的区域提议。

实施 SelectiveSearch 来生成区域提议

在本节中,我们将定义extract_candidates函数,使用selectivesearch来在后续关于训练基于 R-CNN 和 Fast R-CNN 的自定义目标检测器的部分中使用它:

  1. 导入相关包并获取图像:

    %pip install selectivesearch
    %pip install torch_snippets
    from torch_snippets import *
    import selectivesearch
    !wget https://www.dropbox.com/s/l98leemr7/Hemanvi.jpeg
    img = read('Hemanvi.jpeg', 1) 
    
  2. 定义extract_candidates函数,从图像中获取区域提议:

    1. 定义以图像作为输入参数的函数:
    def extract_candidates(img): 
    
    1. 使用selective_search方法在图像内获取候选区域:
     img_lbl, regions = selectivesearch.selective_search(img, 
                                       scale=200,  min_size=100) 
    
    1. 计算图像面积并初始化一个候选列表,用于存储通过定义的阈值的候选者:
     img_area = np.prod(img.shape[:2])
        candidates = [] 
    
    1. 仅获取那些超过总图像面积的 5%并且小于或等于图像面积的 100%的候选者(区域),然后返回它们:
     for r in regions:
            if r['rect'] in candidates: continue
            if r['size'] < (0.05*img_area): continue
            if r['size'] > (1*img_area): continue
            x, y, w, h = r['rect']
            candidates.append(list(r['rect']))
        return candidates 
    
  3. 提取候选区域并在图像顶部绘制它们:

    candidates = extract_candidates(img)
    show(img, bbs=candidates) 
    

A dog in a cage  Description automatically generated with medium confidence

图 7.5:图像中的区域提议

前图中的网格代表来自selective_search方法的候选区域(区域提议)。

现在我们了解了区域提议生成,还有一个问题没有解答。我们如何利用区域提议进行目标检测和定位?

在感兴趣图像中,与对象位置(地面真实位置)具有高交集的区域提议被标记为包含对象的区域,而交集较低的区域提议被标记为背景。在接下来的部分,我们将学习如何计算区域提议候选与地面真实边界框的交集,在我们理解构建物体检测模型背后的各种技术的旅程中。

理解 IoU

想象一种情况,我们为对象提出了一个边界框的预测。我们如何衡量我们预测的准确性?在这种情况下,IoU 的概念非常有用。

在术语“交并比”中,“交集”一词指的是预测边界框与实际边界框重叠的程度,而“并集”则指的是用于重叠的总体空间。 IoU 是两个边界框之间重叠区域与两个边界框组合区域的比率。

这可以用下图表示:

自动生成形状说明

图 7.6:可视化 IoU

在上述两个边界框(矩形)的图示中,让我们将左边界框视为地面真实位置,右边界框视为对象的预测位置。作为度量标准的 IoU 是两个边界框之间重叠区域与组合区域的比率。在下图中,您可以观察 IoU 度量标准随着边界框重叠变化的情况:

自动生成形状、多边形说明

图 7.7:不同情况下 IoU 值的变化

我们可以看到随着重叠减少,IoU 也会减少,在最后一个图中,当没有重叠时,IoU 度量为 0。

现在我们了解了如何测量 IoU,让我们在代码中实现它,并创建一个计算 IoU 的函数,因为我们将在训练 R-CNN 和训练 Fast R-CNN 的部分中利用它。

在 GitHub 的Chapter07文件夹中的Calculating_Intersection_Over_Union.ipynb文件中查找以下代码:bit.ly/mcvp-2e

让我们定义一个函数,该函数以两个边界框作为输入,并返回 IoU 作为输出:

  1. 指定get_iou函数,该函数以boxAboxB作为输入,其中boxAboxB是两个不同的边界框(可以将boxA视为地面真实边界框,boxB视为区域提议):

    def get_iou(boxA, boxB, epsilon=1e-5): 
    

我们定义了epsilon参数来处理罕见的情况,即两个框之间的并集为 0,导致除零错误。请注意,在每个边界框中,将有四个值对应于边界框的四个角。

  1. 计算交集框的坐标:

     x1 = max(boxA[0], boxB[0])
        y1 = max(boxA[1], boxB[1])
        x2 = min(boxA[2], boxB[2])
        y2 = min(boxA[3], boxB[3]) 
    

请注意,x1存储两个边界框之间最左侧 x 值的最大值。类似地,y1存储最上面的 y 值,x2y2分别存储交集部分的最右 x 值和最下 y 值。

  1. 计算交集区域的宽度和高度(widthheight):

     width = (x2 - x1)
        height = (y2 - y1) 
    
  2. 计算重叠区域的面积(area_overlap):

     if (width<0) or (height <0):
            return 0.0
        area_overlap = width * height 
    

请注意,在前面的代码中,如果与重叠区域对应的宽度或高度小于 0,则交集的面积为 0。否则,我们计算重叠(交集)的面积类似于计算矩形面积的方式 - 宽度乘以高度。

  1. 计算对应于两个边界框的组合面积:

     area_a = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1])
        area_b = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1])
        area_combined = area_a + area_b - area_overlap 
    

在前面的代码中,我们计算两个边界框的组合面积 - area_aarea_b - 然后在计算area_combined时减去重叠的区域,因为在计算area_aarea_b时,area_overlap被计算了两次。

  1. 计算 IoU 值并返回它:

     iou = area_overlap / (area_combined+epsilon)
        return iou 
    

在前面的代码中,我们计算iou作为重叠区域的面积(area_overlap)与两个边界框组合区域的面积(area_combined)之比,并返回结果。

到目前为止,我们已经学习了如何创建地面实况并计算 IoU,这有助于准备训练数据。

在后续部分,我们将推迟构建模型,因为训练模型涉及更多的步骤,并且在训练模型之前,我们还需要学习更多组件。在下一节中,我们将学习非极大值抑制,它有助于在推断时缩小围绕对象的不同可能预测边界框。

非极大值抑制

想象一种情景,其中生成了多个区域建议,并且彼此显著重叠。基本上,所有预测的边界框坐标(对于区域建议的偏移量)彼此显著重叠。例如,让我们考虑以下图像,在图像中为人生成了多个区域建议:

图 7.8:图像和可能的边界框

我们如何确定在许多区域建议中,我们将考虑作为包含对象的边界框以及我们将放弃的边界框?在这种情况下,非极大值抑制非常有用。让我们解释一下这个术语。

非极大值指的是那些概率最高但不包含对象的边界框,抑制指的是我们放弃这些边界框。在非极大值抑制中,我们确定具有最高概率包含对象的边界框,并且丢弃所有 IoU 低于某个阈值的其他边界框,这些边界框显示具有最高概率包含对象。

在 PyTorch 中,使用 torchvision.ops 模块中的 nms 函数执行非极大值抑制。nms 函数接受边界框坐标、边界框中对象的置信度以及跨边界框的 IoU 阈值,从而确定要保留的边界框。在预测新图像中对象类别和边界框时,您将利用 nms 函数,同时涵盖训练基于 R-CNN 的自定义对象检测器训练基于 Fast R-CNN 的自定义对象检测器部分。

平均精确度

到目前为止,我们已经看到得到的输出包括图像中每个对象周围的边界框和边界框内对象对应的类别。接下来的问题是:如何量化模型预测的准确性?mAP 在这种情况下派上了用场。

在我们尝试理解 mAP 之前,让我们首先理解精确度,然后是平均精确度,最后是 mAP:

  • 精确度: 通常,我们计算精确度如下:

真正的正样本是指预测正确对象类别的边界框,并且其与真实值之间的 IoU 大于某个阈值。错误的正样本是指预测类别错误或与真实值之间的重叠小于定义的阈值的边界框。此外,如果为同一真实边界框识别出多个边界框,则只能有一个边界框是真正的正样本,其他都是错误的正样本。

  • 平均精确度: 平均精确度是在各种 IoU 阈值上计算得出的精确度值的平均值。

  • mAP: mAP 是在数据集中所有对象类别上,通过各种 IoU 阈值计算得出的精确度值的平均值。

到目前为止,我们已经看到为模型准备训练数据集,对模型预测执行非极大值抑制,并计算其准确性。在接下来的几节中,我们将学习如何训练一个(基于 R-CNN 和 Fast R-CNN 的)模型来检测新图像中的对象。

训练基于 R-CNN 的自定义对象检测器

R-CNN 代表基于区域的卷积神经网络。在 R-CNN 中,基于区域指的是用于识别图像中对象的区域提议。请注意,R-CNN 协助识别图像中存在的对象及其位置。

在接下来的几节中,我们将学习关于 R-CNN 的工作细节,然后在我们的自定义数据集上对其进行训练。

R-CNN 的工作细节

让我们通过下面的图表来对基于 R-CNN 的对象检测有一个高层次的理解:

Diagram  Description automatically generated

图 7.9:R-CNN 步骤序列(图片来源:https://arxiv.org/pdf/1311.2524.pdf

在利用 R-CNN 技术进行对象检测时,我们执行以下步骤:

  1. 从图像中提取区域提案。我们需要确保提取出大量的提案,以免错过图像中的任何潜在对象。

  2. 调整(变形)所有提取的区域以获得相同大小的区域。

  3. 将调整大小后的区域提案通过网络传递。通常情况下,我们会通过预训练模型(如 VGG16 或 ResNet50)传递调整大小后的区域提案,并在全连接层中提取特征。

  4. 创建用于模型训练的数据,其中输入是通过预训练模型传递区域提案提取的特征。输出是每个区域提案对应的类别以及与图像对应的地面实况边界框的区域提案偏移量。

如果一个区域提案与对象的 IoU 大于特定阈值,则创建训练数据。在这种情况下,该区域任务是预测其重叠对象的类别以及与包含感兴趣对象的地面实况边界框相关的区域提案的偏移量。以下展示了样本图像、区域提案和地面实况边界框:

图 7.10:带有区域提案和地面实况边界框的样本图像

在前述图像中,o(红色)表示区域提案的中心(虚线边界框),x表示与cat类别对应的地面实况边界框的中心(实线边界框)。我们计算区域提案边界框与地面实况边界框之间的偏移量,作为两个边界框中心坐标之间的差异(dxdy)及边界框高度和宽度之间的差异(dwdh)。

  1. 连接两个输出头,一个对应图像类别,另一个对应区域提案与地面实况边界框的偏移量,以提取对象的精细边界框。

这个练习类似于预测性别(一个类别变量,类似于本案例研究中的对象类别)和年龄(一个连续变量,类似于对区域提案进行的偏移量),基于第五章中一个人脸图像的用例。

  1. 编写自定义损失函数来训练模型,该函数将最小化对象分类误差和边界框偏移误差。

注意,我们要最小化的损失函数与原始论文中优化的损失函数不同(arxiv.org/pdf/1311.2524.pdf)。我们这样做是为了减少从头开始构建 R-CNN 和 Fast R-CNN 所带来的复杂性。一旦您熟悉了模型的工作原理,并能够使用接下来两节中的代码构建模型,我们强烈建议您从头开始实现原始论文中的模型。

在接下来的部分,我们将学习获取数据集和为训练创建数据。在此之后的部分中,我们将学习设计模型并对其进行训练,然后在新图像中预测存在的对象类别及其边界框。

在自定义数据集上实现物体检测的 R-CNN。

到目前为止,我们已经理解了 R-CNN 的工作原理。现在我们将学习如何为训练创建数据。该过程涉及以下步骤:

  1. 下载数据集。

  2. 准备数据集。

  3. 定义区域提议提取和 IoU 计算函数。

  4. 创建训练数据。

  5. 为模型创建输入数据。

  6. 调整区域提议的大小。

  7. 通过预训练模型将它们传递以获取完全连接层的值。

  8. 为模型创建输出数据。

  9. 为每个区域提议标记类别或背景标签。

  10. 定义区域提议相对于地面真值的偏移量(如果区域提议对应于对象而不是背景)。

  11. 定义并训练模型。

  12. 在新图像上进行预测。

让我们开始编码以下各节。

下载数据集。

对于物体检测的情况,我们将从 Google Open Images v6 数据集中下载数据(网址为storage.googleapis.com/openimages/v5/test-annotations-bbox.csv)。然而,在代码中,我们将仅处理公交车或卡车的图像,以确保我们可以训练图像(正如您很快会注意到使用selectivesearch时的内存问题)。在第十章目标检测和分割的应用中,我们将扩展我们将在其中训练的类别数量(除了公交车和卡车之外还有更多类别):

在 GitHub 的Chapter07文件夹中的Training_RCNN.ipynb文件中找到以下代码,网址为bit.ly/mcvp-2e。该代码包含用于下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 上执行笔记本,以帮助您重现结果并理解文本中的步骤和各种代码组件。

导入相关包以下载包含图像及其地面真实值的文件:

%pip install -q --upgrade selectivesearch torch_snippets
from torch_snippets import *
import selectivesearch
from google.colab import files
files.upload() # upload kaggle.json file
!mkdir -p ~/.kaggle
!mv kaggle.json ~/.kaggle/
!ls ~/.kaggle
!chmod 600 /root/.kaggle/kaggle.json
!kaggle datasets download -d sixhky/open-images-bus-trucks/
!unzip -qq open-images-bus-trucks.zip
from torchvision import transforms, models, datasets
from torch_snippets import Report
from torchvision.ops import nms
device = 'cuda' if torch.cuda.is_available() else 'cpu' 

一旦执行上述代码,我们应该将图像及其相应的地面真实值存储在可用的 CSV 文件中。

准备数据集。

现在我们已经下载了数据集,我们将准备数据集。该过程涉及以下步骤:

  1. 获取每个图像及其相应的类别和边界框值。

  2. 获取每个图像中的区域提议,它们对应的 IoU 值,以及区域提议相对于地面真值将被校正的增量。

  3. 为每个类别分配数字标签(除了公交车和卡车类别之外,我们还有一个额外的背景类别,其中 IoU 与地面真实边界框低于阈值)。

  4. 将每个区域建议调整为一个公共大小,以便将它们传递给网络

在此练习结束时,我们将已经调整了区域建议的裁剪大小,为每个区域建议分配了真实的类别,并计算了区域建议相对于真实边界框的偏移量。我们将从上一节结束的地方继续编码:

  1. 指定图像的位置,并读取我们下载的 CSV 文件中存在的真实值:

    IMAGE_ROOT = 'images/images'
    DF_RAW = pd.read_csv('df.csv')
    print(DF_RAW.head()) 
    

自动生成的表说明

图 7.11:样本数据

注意,XMinXMaxYMinYMax 对应于图像边界框的真实值。此外,LabelName 提供了图像的类别。

  1. 定义一个类,返回图像及其对应的类和真实值,以及图像的文件路径:

    1. 将数据框(df)和包含图像的文件夹路径(image_folder)作为输入传递给 __init__ 方法,并获取数据框中存在的唯一 ImageID 值(self.unique_images)。我们这样做是因为一张图像可能包含多个对象,因此多行可以对应相同的 ImageID 值:
    class OpenImages(Dataset):
        def __init__(self, df, image_folder=IMAGE_ROOT):
            self.root = image_folder
            self.df = df
            self.unique_images = df['ImageID'].unique()
        def __len__(self): return len(self.unique_images) 
    
    1. 定义 __getitem__ 方法,在此方法中,我们获取索引(ix)对应的图像(image_id),获取其边界框坐标(boxes)和类别,并返回图像、边界框、类别和图像路径:
     def __getitem__(self, ix):
            image_id = self.unique_images[ix]
            image_path = f'{self.root}/{image_id}.jpg'
            # Convert BGR to RGB
            image = cv2.imread(image_path, 1)[...,::-1]
            h, w, _ = image.shape
            df = self.df.copy()
            df = df[df['ImageID'] == image_id]
            boxes = df['XMin,YMin,XMax,YMax'.split(',')].values
            boxes = (boxes*np.array([w,h,w,h])).astype(np.uint16).tolist()
            classes = df['LabelName'].values.tolist()
            return image, boxes, classes, image_path 
    
  2. 检查一个样本图像及其对应的类和边界框真实值:

    ds = OpenImages(df=DF_RAW)
    im, bbs, clss, _ = ds[9]
    show(im, bbs=bbs, texts=clss, sz=10) 
    

图 7.12:带有真实边界框和对象类别的样本图像

  1. 定义 extract_iouextract_candidates 函数:

    def extract_candidates(img):
        img_lbl,regions = selectivesearch.selective_search(img,
                                       scale=200, min_size=100)
        img_area = np.prod(img.shape[:2])
        candidates = []
        for r in regions:
            if r['rect'] in candidates: continue
            if r['size'] < (0.05*img_area): continue
            if r['size'] > (1*img_area): continue
            x, y, w, h = r['rect']
            candidates.append(list(r['rect']))
        return candidates
    def extract_iou(boxA, boxB, epsilon=1e-5):
        x1 = max(boxA[0], boxB[0])
        y1 = max(boxA[1], boxB[1])
        x2 = min(boxA[2], boxB[2])
        y2 = min(boxA[3], boxB[3])
        width = (x2 - x1)
        height = (y2 - y1)
        if (width<0) or (height <0):
            return 0.0
        area_overlap = width * height
        area_a = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1])
        area_b = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1])
        area_combined = area_a + area_b - area_overlap
        iou = area_overlap / (area_combined+epsilon)
        return iou 
    

现在,我们已经定义了准备数据和初始化数据加载器所需的所有函数。接下来,我们将获取区域建议(模型的输入区域)及其与边界框偏移量的真实值,以及对象的类别(预期输出)。

获取区域建议及其偏移量的真实值

在本节中,我们将学习如何创建与我们模型对应的输入和输出值。输入由使用 selectivesearch 方法提取的候选区域组成,输出包括与候选区域相对应的类别以及如果候选区域包含对象时其相对于最大重叠边界框的偏移量。

我们将从上一节结束的地方继续编码:

  1. 初始化空列表来存储文件路径(FPATHS),真实边界框(GTBBS),对象类别(CLSS),边界框与区域建议的偏移量(DELTAS),区域建议位置(ROIS)以及区域建议与真实值的 IoU(IOUS):

    FPATHS, GTBBS, CLSS, DELTAS, ROIS, IOUS = [],[],[],[],[],[] 
    
  2. 遍历数据集并填充在步骤 1中初始化的列表:

    1. 对于这个练习,我们可以使用所有数据点进行训练,或者只使用前 500 个数据点进行演示。您可以在两者之间选择,这将决定训练时间和训练准确度(数据点越多,训练时间和准确度越高):
    N = 500
    for ix, (im, bbs, labels, fpath) in enumerate(ds):
        if(ix==N):
            break 
    

    在上述代码中,我们指定将处理 500 张图像:

    1. 使用extract_candidates函数从每个图像(im)中提取候选项的绝对像素值(注意,XMinXmaxYMinYMax是作为已下载数据帧中图像形状的比例提供的),并将提取的区域坐标从(x,y,w,h)系统转换为(x,y,x+w,y+h)系统:
     H, W, _ = im.shape
        candidates = extract_candidates(im)
        candidates = np.array([(x,y,x+w,y+h) for x,y,w,h in candidates]) 
    
    1. 初始化iousroisdeltasclss,作为存储每个图像中每个候选项的 IoU、区域提议位置、边界框偏移和对应类别的列表。我们将浏览所有选择性搜索中的提议,并将具有高 IoU 的提议存储为公共汽车/卡车提议(标签中的任意一种类别),其余提议存储为背景提议:
     ious, rois, clss, deltas = [], [], [], [] 
    
    1. 将所有候选项相对于图像中的所有地面真实边界框的 IoU 存储起来,其中bbs是图像中不同对象的地面真实边界框,而candidates包括在前一步骤中获取的区域提议候选项:
     ious = np.array([[extract_iou(candidate, _bb_) for \
                    candidate in candidates] for _bb_ in bbs]).T 
    
    1. 循环遍历每个候选项并存储候选项的 XMin(cx)、YMin(cy)、XMax(cX)和 YMax(cY)值:
     for jx, candidate in enumerate(candidates):
            cx,cy,cX,cY = candidate 
    
    1. 提取与获取了 IoU 列表的所有地面真实边界框相关的候选项对应的 IoU 时:
     candidate_ious = ious[jx] 
    
    1. 找到具有最高 IoU 的候选项的索引(best_iou_at)及其相应的地面真实边界框(best_bb):
     best_iou_at = np.argmax(candidate_ious)
            best_iou = candidate_ious[best_iou_at]
            best_bb = _x,_y,_X,_Y = bbs[best_iou_at] 
    
    1. 如果 IoU(best_iou)大于阈值(0.3),我们将为与候选项对应的类分配标签,否则分配背景:
     if best_iou > 0.3: clss.append(labels[best_iou_at])
            else : clss.append('background') 
    
    1. 提取所需的偏移量(delta),将当前提议转换为最佳区域提议(即地面真实边界框)best_bb所需的偏移量,换句话说,调整当前提议的左、右、上和下边缘多少,使其与来自地面真实边界框的best_bb完全对齐:
     delta = np.array([_x-cx, _y-cy, _X-cX, _Y-cY]) / np.array([W,H,W,H])
            deltas.append(delta)
            rois.append(candidate / np.array([W,H,W,H])) 
    
    1. 将文件路径、IoU、RoI、类偏移和地面真实边界框追加到列表中:
     FPATHS.append(fpath)
        IOUS.append(ious)
        ROIS.append(rois)
        CLSS.append(clss)
        DELTAS.append(deltas)
        GTBBS.append(bbs) 
    
    1. 获取图像路径名并将获取的所有信息FPATHSIOUSROISCLSSDELTASGTBBS存储在一个列表的列表中:
    FPATHS = [f'{IMAGE_ROOT}/{stem(f)}.jpg' for f in FPATHS]
    FPATHS, GTBBS, CLSS, DELTAS, ROIS = [item for item in \
                                         [FPATHS, GTBBS, \
                                          CLSS, DELTAS, ROIS]] 
    

请注意,到目前为止,类别作为类别名称可用。现在,我们将把它们转换为相应的索引,使得背景类别的类索引为 0,公共汽车类的类索引为 1,卡车类的类索引为 2。

  1. 为每个类别分配索引:

    targets = pd.DataFrame(flatten(CLSS), columns=['label'])
    label2target = {l:t for t,l in enumerate(targets['label'].unique())}
    target2label = {t:l for l,t in label2target.items()}
    background_class = label2target['background'] 
    

现在我们已经为每个区域提议分配了一个类别,并创建了边界框偏移量的其他地面真值。接下来,我们将获取与获取的信息(FPATHSIOUSROISCLSSDELTASGTBBS)对应的数据集和数据加载器。

创建训练数据

到目前为止,我们已经获取了数据,在所有图像中获取了区域提议,准备了每个区域提议中对象的类别的地面真值,并获取了与相应图像中对象具有高重叠(IoU)的每个区域提议相对应的偏移量。

在本节中,我们将基于前一节结尾处获得的区域提议的地面真值准备一个数据集类,从中创建数据加载器。然后,我们将通过将每个区域提议调整为相同形状并缩放它们来标准化每个区域提议。我们将继续从前一节结束的地方编码:

  1. 定义一个函数来在通过类似 VGG16 的预训练模型之前对图像进行标准化处理。对于 VGG16 的标准化实践如下:

    normalize= transforms.Normalize(mean=[0.485, 0.456, 0.406],
                                    std=[0.229, 0.224, 0.225]) 
    
  2. 定义一个函数(preprocess_image)来预处理图像(img),其中我们会切换通道、标准化图像,并将其注册到设备上:

    def preprocess_image(img):
        img = torch.tensor(img).permute(2,0,1)
        img = normalize(img)
        return img.to(device).float() 
    
  3. 定义解码预测的函数decode

  4. def decode(_y):
        _, preds = _y.max(-1)
        return preds 
    
  5. 使用前面步骤中获取的预处理区域提议和地面真值(前一节的第 2 步)来定义数据集(RCNNDataset):

    class RCNNDataset(Dataset):
        def __init__(self, fpaths, rois, labels, deltas, gtbbs):
            self.fpaths = fpaths
            self.gtbbs = gtbbs
            self.rois = rois
            self.labels = labels
            self.deltas = deltas
        def __len__(self): return len(self.fpaths) 
    
    1. 根据区域提议获取裁剪图像以及与类别和边界框偏移量相关的其他地面真值:
     def __getitem__(self, ix):
            fpath = str(self.fpaths[ix])
            image = cv2.imread(fpath, 1)[...,::-1]
            H, W, _ = image.shape
            sh = np.array([W,H,W,H])
            gtbbs = self.gtbbs[ix]
            rois = self.rois[ix]
            bbs = (np.array(rois)*sh).astype(np.uint16)
            labels = self.labels[ix]
            deltas = self.deltas[ix]
            crops = [image[y:Y,x:X] for (x,y,X,Y) in bbs]
            return image,crops,bbs,labels,deltas,gtbbs,fpath 
    
    1. 定义collate_fn函数,该函数执行对裁剪图像进行大小调整和标准化(preprocess_image):
     def collate_fn(self, batch):
            input, rois, rixs, labels, deltas =[],[],[],[],[]
            for ix in range(len(batch)):
                image, crops, image_bbs, image_labels, \
                    image_deltas, image_gt_bbs, \
                    image_fpath = batch[ix]
                crops = [cv2.resize(crop, (224,224)) for crop in crops]
                crops = [preprocess_image(crop/255.)[None] for crop in crops]
                input.extend(crops)
                labels.extend([label2target[c] for c in image_labels])
                deltas.extend(image_deltas)
            input = torch.cat(input).to(device)
            labels = torch.Tensor(labels).long().to(device)
            deltas = torch.Tensor(deltas).float().to(device)
            return input, labels, deltas 
    
  6. 创建训练和验证数据集以及数据加载器:

    n_train = 9*len(FPATHS)//10
    train_ds = RCNNDataset(FPATHS[:n_train],ROIS[:n_train],
                           CLSS[:n_train], DELTAS[:n_train],
                           GTBBS[:n_train])
    test_ds = RCNNDataset(FPATHS[n_train:], ROIS[n_train:],
                          CLSS[n_train:], DELTAS[n_train:],
                          GTBBS[n_train:])
    from torch.utils.data import TensorDataset, DataLoader
    train_loader = DataLoader(train_ds, batch_size=2,
                              collate_fn=train_ds.collate_fn,
                              drop_last=True)
    test_loader = DataLoader(test_ds, batch_size=2,
                             collate_fn=test_ds.collate_fn,
                             drop_last=True) 
    

到目前为止,我们已经了解了为训练准备数据的相关信息。接下来,我们将学习如何定义和训练一个模型,该模型能够预测区域提议的类别和偏移量,以便在图像中绘制紧密的边界框。

R-CNN 网络架构

现在我们已经准备好数据,在本节中,我们将学习如何构建一个模型,该模型能够预测区域提议的类别和相应的偏移量,以便在图像中绘制紧密的边界框。我们采用的策略如下:

  1. 定义一个 VGG 主干。

  2. 通过预训练模型后获取标准化裁剪后的特征。

  3. 将带有 sigmoid 激活的线性层附加到 VGG 主干上,以预测区域提议对应的类别。

  4. 附加一个额外的线性层来预测四个边界框偏移量。

  5. 定义用于两个输出(一个用于预测类别,另一个用于预测四个边界框偏移量)的损失计算方法。

  6. 训练模型,该模型预测区域提议的类别和四个边界框偏移量。

执行以下代码。我们将继续从前一节结束的地方编码:

  1. 定义 VGG 骨干网:

    vgg_backbone = models.vgg16(pretrained=True)
    vgg_backbone.classifier = nn.Sequential()
    for param in vgg_backbone.parameters():
        param.requires_grad = False
    vgg_backbone.eval().to(device) 
    
  2. 定义RCNN网络模块:

    1. 定义类别:
    class RCNN(nn.Module):
        def __init__(self):
            super().__init__() 
    
    1. 定义骨干网 (self.backbone) 和如何计算类别分数 (self.cls_score) 和边界框偏移值 (self.bbox):
     feature_dim = 25088
            self.backbone = vgg_backbone
            self.cls_score = nn.Linear(feature_dim, len(label2target))
            self.bbox = nn.Sequential(
                              nn.Linear(feature_dim, 512),
                              nn.ReLU(),
                              nn.Linear(512, 4),
                              nn.Tanh(),
                            ) 
    
    1. 定义损失函数,对应类别预测 (self.cel) 和边界框偏移回归 (self.sl1):
     self.cel = nn.CrossEntropyLoss()
            self.sl1 = nn.L1Loss() 
    
    1. 定义前向传播方法,通过 VGG 骨干网 (self.backbone) 传递图像以获取特征 (feat),进一步通过分类和边界框回归方法获取跨类别的概率 (cls_score) 和边界框偏移量 (bbox):
     def forward(self, input):
            feat = self.backbone(input)
            cls_score = self.cls_score(feat)
            bbox = self.bbox(feat)
            return cls_score, bbox 
    
    1. 定义计算损失的函数 (calc_loss),其中我们返回检测和回归损失的总和。请注意,如果实际类别是背景类别,则不计算与偏移相对应的回归损失:
     def calc_loss(self, probs, _deltas, labels, deltas):
            detection_loss = self.cel(probs, labels)
            ixs, = torch.where(labels != 0)
            _deltas = _deltas[ixs]
            deltas = deltas[ixs]
            self.lmb = 10.0
            if len(ixs) > 0:
                regression_loss = self.sl1(_deltas, deltas)
                return detection_loss + self.lmb *\
                    regression_loss, detection_loss.detach(),
                    regression_loss.detach()
            else:
                regression_loss = 0
                return detection_loss + self.lmb regression_loss, \
                       detection_loss.detach(), regression_loss 
    
  3. 有了模型类别后,我们现在定义批量数据训练和验证数据预测的功能:

    1. 定义train_batch函数:
    def train_batch(inputs, model, optimizer, criterion):
        input, clss, deltas = inputs
        model.train()
        optimizer.zero_grad()
        _clss, _deltas = model(input)
        loss, loc_loss, regr_loss = criterion(_clss, _deltas, clss, deltas)
        accs = clss == decode(_clss)
        loss.backward()
        optimizer.step()
        return loss.detach(), loc_loss, regr_loss, accs.cpu().numpy() 
    
    1. 定义validate_batch函数:
    @torch.no_grad()
    def validate_batch(inputs, model, criterion):
        input, clss, deltas = inputs
        with torch.no_grad():
            model.eval()
            _clss,_deltas = model(input)
            loss,loc_loss,regr_loss = criterion(_clss, _deltas, clss, deltas)
            _, _clss = _clss.max(-1)
            accs = clss == _clss
        return _clss,_deltas,loss.detach(), \
                      loc_loss, regr_loss, accs.cpu().numpy() 
    
  4. 现在,让我们创建一个模型对象,获取损失标准,然后定义优化器和时期数:

    rcnn = RCNN().to(device)
    criterion = rcnn.calc_loss
    optimizer = optim.SGD(rcnn.parameters(), lr=1e-3)
    n_epochs = 5
    log = Report(n_epochs) 
    

现在我们在增加的时期内训练模型:

  1. for epoch in range(n_epochs):
        _n = len(train_loader)
        for ix, inputs in enumerate(train_loader):
            loss, loc_loss,regr_loss,accs = train_batch(inputs, rcnn,
                                            optimizer, criterion)
            pos = (epoch + (ix+1)/_n)
            log.record(pos, trn_loss=loss.item(),
                       trn_loc_loss=loc_loss,
                       trn_regr_loss=regr_loss,
                       trn_acc=accs.mean(), end='\r')
    
        _n = len(test_loader)
        for ix,inputs in enumerate(test_loader):
            _clss, _deltas, loss, loc_loss, regr_loss, \
            accs = validate_batch(inputs, rcnn, criterion)
            pos = (epoch + (ix+1)/_n)
            log.record(pos, val_loss=loss.item(),
                    val_loc_loss=loc_loss,
                    val_regr_loss=regr_loss,
                    val_acc=accs.mean(), end='\r')
    # Plotting training and validation metrics
    log.plot_epochs('trn_loss,val_loss'.split(',')) 
    

训练和验证数据的总体损失图如下:

图表,线图  自动生成描述

图 7.13:增加时期内的训练和验证损失

现在我们已经训练了一个模型,我们将在下一节中使用它来预测新图像。

预测新图像

让我们利用到目前为止训练的模型来预测并在新图像上绘制对象周围的边界框及其对应的对象类别,在预测的边界框内。

  1. 从新图像中提取区域建议。

  2. 调整和规范化每个裁剪。

  3. 将处理后的裁剪图像前向传递以预测类别和偏移量。

  4. 执行非最大抑制以仅获取置信度最高的包含对象的框。

我们通过一个函数执行上述策略,该函数以图像作为输入,并提供地面实况边界框(仅用于比较地面实况和预测的边界框)。

我们将继续从前一节结束的地方进行编码:

  1. 定义test_predictions函数以预测新图像:

    1. 该函数以filename为输入,读取图像,并提取候选对象:
    def test_predictions(filename, show_output=True):
        img = np.array(cv2.imread(filename, 1)[...,::-1])
        candidates = extract_candidates(img)
        candidates = [(x,y,x+w,y+h) for x,y,w,h in candidates] 
    
    1. 遍历候选者以调整大小和预处理图像:
     input = []
        for candidate in candidates:
            x,y,X,Y = candidate
            crop = cv2.resize(img[y:Y,x:X], (224,224))
            input.append(preprocess_image(crop/255.)[None])
        input = torch.cat(input).to(device) 
    
    1. 预测类别和偏移量:
     with torch.no_grad():
            rcnn.eval()
            probs, deltas = rcnn(input)
            probs = torch.nn.functional.softmax(probs, -1)
            confs, clss = torch.max(probs, -1) 
    
    1. 提取不属于背景类别的候选对象,并将候选对象的边界框与预测的边界框偏移值相加:
     candidates = np.array(candidates)
        confs,clss,probs,deltas=[tensor.detach().cpu().numpy() \
                                  for tensor in [confs, clss, probs, deltas]]
        ixs = clss!=background_class
        confs,clss,probs,deltas,candidates = [tensor[ixs] for \
               tensor in [confs,clss, probs, deltas,candidates]]
        bbs = (candidates + deltas).astype(np.uint16) 
    
    1. 使用非最大抑制(nms)消除近似重复的边界框(在此情况下,具有 IoU 大于 0.05 的两个框被视为重复)。在重复的框中,我们选择置信度最高的框并丢弃其余的框:
     ixs = nms(torch.tensor(bbs.astype(np.float32)),
                                     torch.tensor(confs), 0.05)
        confs,clss,probs,deltas,candidates,bbs=[tensor[ixs] \
                   for tensor in [confs, clss, probs, deltas, candidates, bbs]]
        if len(ixs) == 1:
            confs, clss, probs, deltas, candidates, bbs = \
                    [tensor[None] for tensor in [confs, clss,
                             probs, deltas, candidates, bbs]] 
    
    1. 获取置信度最高的边界框:
     if len(confs) == 0 and not show_output:
            return (0,0,224,224), 'background', 0
        if len(confs) > 0:
            best_pred = np.argmax(confs)
            best_conf = np.max(confs)
            best_bb = bbs[best_pred]
            x,y,X,Y = best_bb 
    
    1. 绘制图像以及预测的边界框:
     _, ax = plt.subplots(1, 2, figsize=(20,10))
        show(img, ax=ax[0])
        ax[0].grid(False)
        ax[0].set_title('Original image')
        if len(confs) == 0:
            ax[1].imshow(img)
            ax[1].set_title('No objects')
            plt.show()
            return
        ax[1].set_title(target2label[clss[best_pred]])
        show(img, bbs=bbs.tolist(),
            texts=[target2label[c] for c in clss.tolist()],
            ax=ax[1], title='predicted bounding box and class')
        plt.show()
        return (x,y,X,Y),target2label[clss[best_pred]],best_conf 
    
  2. 在新图像上执行上述函数:

    image, crops, bbs, labels, deltas, gtbbs, fpath = test_ds[7]
    test_predictions(fpath) 
    

上述代码生成以下图像:

img/B18457_07_14.png

图 7.14:原始图像和预测的边界框与类别

根据上图,我们可以看到图像类别的预测准确,边界框的预测也还不错。请注意,生成上述图像的预测大约需要~1.5 秒。

所有这些时间都花在生成区域建议、调整每个区域建议、通过 VGG 骨干网络传递它们以及使用定义的模型生成预测上。其中大部分时间都花在通过 VGG 骨干网络传递每个建议上。在接下来的部分,我们将学习如何通过使用基于 Fast R-CNN 架构的模型来解决将每个建议传递给 VGG的问题。

训练基于 Fast R-CNN 的自定义对象检测器

R-CNN 的一个主要缺点是生成预测需要相当长的时间。这是因为为每个图像生成区域建议,调整区域的裁剪,以及提取与每个裁剪(区域建议)对应的特征会造成瓶颈。

Fast R-CNN 通过将整个图像通过预训练模型以提取特征,然后获取与原始图像中来自selectivesearch的区域建议相对应的特征区域,解决了这个问题。在接下来的部分,我们将学习在自定义数据集上训练之前了解 Fast R-CNN 的工作细节。

Fast R-CNN 的工作细节

让我们通过以下图表理解 Fast R-CNN:

img/B18457_07_15.png

图 7.15:Fast R-CNN 的工作细节

让我们通过以下步骤理解上述图表:

  1. 将图像通过预训练模型传递以在扁平化层之前提取特征;我们称它们为输出特征图。

  2. 提取与图像对应的区域建议。

  3. 提取与区域建议对应的特征图区域(请注意,当图像通过 VGG16 架构传递时,输出时图像会缩小 32 倍,因为进行了五次池化操作。因此,如果原始图像中存在边界框(32,64,160,128),则对应于边界框(1,2,5,4)的特征图将对应于完全相同的区域)。

  4. 逐个将对应于区域提议的特征图通过感兴趣区域(RoI)池化层,使所有区域提议的特征图具有相似的形状。这是对 R-CNN 技术中执行的扭曲的替代。

  5. 将 RoI 池化层的输出值通过全连接层传递。

  6. 训练模型以预测每个区域提议对应的类别和偏移量。

注意,R-CNN 和快速 R-CNN 之间的主要区别在于,在 R-CNN 中,我们逐个通过预训练模型传递裁剪图像(调整大小的区域提议),而在快速 R-CNN 中,我们裁剪对应于每个区域提议的特征图(通过通过整个图像传递预训练模型获得),从而避免需要通过预训练模型传递每个调整大小的区域提议的需求。

现在了解了快速 R-CNN 的工作原理,下一节我们将使用在 R-CNN 部分中利用的相同数据集构建一个模型。

使用自定义数据集实现快速 R-CNN 进行目标检测

在本节中,我们将致力于使用快速 R-CNN 训练我们的自定义对象检测器。为了保持简洁,本节仅提供此部分中的附加或更改代码(应在关于 R-CNN 的先前章节中的“创建训练数据”小节中运行所有代码直至步骤 2):

在这里,我们仅提供用于训练快速 R-CNN 的附加代码。在 GitHub 的 Chapter07 文件夹中的 Training_Fast_R_CNN.ipynb 文件中找到完整的代码:bit.ly/mcvp-2e

  1. 创建一个FRCNNDataset类,返回图像、标签、地面实况、区域提议及每个区域提议对应的增量:

    class FRCNNDataset(Dataset):
        def __init__(self, fpaths, rois, labels, deltas, gtbbs):
            self.fpaths = fpaths
            self.gtbbs = gtbbs
            self.rois = rois
            self.labels = labels
            self.deltas = deltas
        def __len__(self): return len(self.fpaths)
        def __getitem__(self, ix):
            fpath = str(self.fpaths[ix])
            image = cv2.imread(fpath, 1)[...,::-1]
            gtbbs = self.gtbbs[ix]
            rois = self.rois[ix]
            labels = self.labels[ix]
            deltas = self.deltas[ix]
            assert len(rois) == len(labels) == len(deltas), \
                f'{len(rois)}, {len(labels)}, {len(deltas)}'
            return image, rois, labels, deltas, gtbbs, fpath
        def collate_fn(self, batch):
            input, rois, rixs, labels, deltas = [],[],[],[],[]
            for ix in range(len(batch)):
                image, image_rois, image_labels, image_deltas, \
                    image_gt_bbs, image_fpath = batch[ix]
                image = cv2.resize(image, (224,224))
                input.append(preprocess_image(image/255.)[None])
                rois.extend(image_rois)
                rixs.extend([ix]*len(image_rois))
                labels.extend([label2target[c] for c in image_labels])
                deltas.extend(image_deltas)
            input = torch.cat(input).to(device)
            rois = torch.Tensor(rois).float().to(device)
            rixs = torch.Tensor(rixs).float().to(device)
            labels = torch.Tensor(labels).long().to(device)
            deltas = torch.Tensor(deltas).float().to(device)
            return input, rois, rixs, labels, deltas 
    

注意,上述代码与我们在 R-CNN 部分学到的非常相似,唯一的变化是我们返回了更多信息(roisrixs)。

rois 矩阵包含关于批处理中的哪个 RoI 属于哪个图像的信息。注意,input 包含多个图像,而 rois 是一个单独的框列表。我们不会知道第一张图像有多少个 RoI 属于它,第二张图像有多少个 RoI 属于它,依此类推。这就是 rixs 起作用的地方。它是一个索引列表。列表中的每个整数将相应的边界框与适当的图像关联起来;例如,如果 rixs[0,0,0,1,1,2,3,3,3],则我们知道前三个边界框属于批处理中的第一个图像,接下来的两个属于第二个图像,依此类推。

  1. 创建训练和测试数据集:

    n_train = 9*len(FPATHS)//10
    train_ds = FRCNNDataset(FPATHS[:n_train],ROIS[:n_train],
                            CLSS[:n_train], DELTAS[:n_train],
                            GTBBS[:n_train])
    test_ds = FRCNNDataset(FPATHS[n_train:], ROIS[n_train:],
                           CLSS[n_train:], DELTAS[n_train:],
                           GTBBS[n_train:])
    from torch.utils.data import TensorDataset, DataLoader
    train_loader = DataLoader(train_ds, batch_size=2,
                              collate_fn=train_ds.collate_fn,
                              drop_last=True)
    test_loader = DataLoader(test_ds, batch_size=2,
                             collate_fn=test_ds.collate_fn,
                             drop_last=True) 
    
  2. 定义一个用于训练数据集的模型:

    1. 首先,导入 torchvision.ops 类中存在的 RoIPool 方法:
    from torchvision.ops import RoIPool 
    
    1. 定义 FRCNN 网络模块:
    class FRCNN(nn.Module):
        def __init__(self):
            super().__init__() 
    
    1. 载入预训练模型并冻结参数:
     rawnet= torchvision.models.vgg16_bn(pretrained=True)
            for param in rawnet.features.parameters():
                param.requires_grad = False 
    
    1. 提取直至最后一层的特征:
     self.seq = nn.Sequential(*list(\rawnet.features.children())[:-1]) 
    
    1. 指定 RoIPool 要提取 7 x 7 的输出。这里,spatial_scale 是建议(来自原始图像)需要缩小的因子,以便在通过展平层之前,每个输出都具有相同的形状。图像大小为 224 x 224,而特征图大小为 14 x 14:
     self.roipool = RoIPool(7, spatial_scale=14/224) 
    
    1. 定义输出头部 – cls_scorebbox
     feature_dim = 512*7*7
            self.cls_score = nn.Linear(feature_dim, len(label2target))
            self.bbox = nn.Sequential(
                              nn.Linear(feature_dim, 512),
                              nn.ReLU(),
                              nn.Linear(512, 4),
                              nn.Tanh(),
                            ) 
    
    1. 定义损失函数:
     self.cel = nn.CrossEntropyLoss()
            self.sl1 = nn.L1Loss() 
    
    1. 定义 forward 方法,该方法将图像、区域建议和区域建议的索引作为网络的输入:
     def forward(self, input, rois, ridx): 
    
    1. input 图像通过预训练模型传递:
     res = input
            res = self.seq(res) 
    
    1. 创建 rois 的矩阵作为 self.roipool 的输入,首先通过将 ridx 连接为第一列,接下来四列为区域建议边界框的绝对值来实现:
     rois = torch.cat([ridx.unsqueeze(-1), rois*224], dim=-1)
            res = self.roipool(res, rois)
            feat = res.view(len(res), -1)
            cls_score = self.cls_score(feat)
            bbox=self.bbox(feat)#.view(-1,len(label2target),4)
            return cls_score, bbox 
    
    1. 定义损失值计算 (calc_loss),就像我们在 R-CNN 部分所做的那样:
     def calc_loss(self, probs, _deltas, labels, deltas):
            detection_loss = self.cel(probs, labels)
            ixs, = torch.where(labels != background_class)
            _deltas = _deltas[ixs]
            deltas = deltas[ixs]
            self.lmb = 10.0
            if len(ixs) > 0:
                regression_loss = self.sl1(_deltas, deltas)
                return detection_loss + self.lmb * regression_loss, \
                       detection_loss.detach(), regression_loss.detach()
            else:
                regression_loss = 0
                return detection_loss + self.lmb * regression_loss, \
                       detection_loss.detach(), regression_loss 
    
  3. 定义在批处理中训练和验证的函数,就像我们在 训练 基于自定义目标检测器的 R-CNN 部分所做的那样:

    def train_batch(inputs, model, optimizer, criterion):
        input, rois, rixs, clss, deltas = inputs
        model.train()
        optimizer.zero_grad()
        _clss, _deltas = model(input, rois, rixs)
        loss, loc_loss, regr_loss =criterion(_clss,_deltas, clss, deltas)
        accs = clss == decode(_clss)
        loss.backward()
        optimizer.step()
        return loss.detach(), loc_loss, regr_loss, accs.cpu().numpy()
    def validate_batch(inputs, model, criterion):
        input, rois, rixs, clss, deltas = inputs
        with torch.no_grad():
            model.eval()
            _clss,_deltas = model(input, rois, rixs)
            loss, loc_loss,regr_loss = criterion(_clss, _deltas, clss, deltas)
            _clss = decode(_clss)
            accs = clss == _clss
        return _clss,_deltas,loss.detach(),loc_loss,regr_loss, accs.cpu().numpy() 
    
  4. 定义并训练模型以增加 epochs:

    frcnn = FRCNN().to(device)
    criterion = frcnn.calc_loss
    optimizer = optim.SGD(frcnn.parameters(), lr=1e-3)
    n_epochs = 5
    log = Report(n_epochs)
    for epoch in range(n_epochs):
        _n = len(train_loader)
        for ix, inputs in enumerate(train_loader):
            loss, loc_loss,regr_loss, accs = train_batch(inputs,
                                    frcnn, optimizer, criterion)
            pos = (epoch + (ix+1)/_n)
            log.record(pos, trn_loss=loss.item(),
                       trn_loc_loss=loc_loss,
                       trn_regr_loss=regr_loss,
                       trn_acc=accs.mean(), end='\r')
    
        _n = len(test_loader)
        for ix,inputs in enumerate(test_loader):
            _clss, _deltas, loss, \
            loc_loss, regr_loss, accs = validate_batch(inputs,
                                               frcnn, criterion)
            pos = (epoch + (ix+1)/_n)
            log.record(pos, val_loss=loss.item(),
                    val_loc_loss=loc_loss,
                    val_regr_loss=regr_loss,
                    val_acc=accs.mean(), end='\r')
    # Plotting training and validation metrics
    log.plot_epochs('trn_loss,val_loss'.split(',')) 
    

总损失的变化如下:

图表,线图 自动产生描述

图 7.16: 增加 epochs 时的训练和验证损失

  1. 定义一个函数来对测试图像进行预测:

    1. 定义一个函数,该函数以文件名作为输入,然后读取文件并将其调整大小为 224 x 224:
    import matplotlib.pyplot as plt
    %matplotlib inline
    import matplotlib.patches as mpatches
    from torchvision.ops import nms
    from PIL import Image
    def test_predictions(filename):
        img = cv2.resize(np.array(Image.open(filename)), (224,224)) 
    
    1. 获取区域建议,将它们转换为 (x1, y1, x2, y2) 格式(左上角像素和右下角像素的坐标),然后将这些值转换为它们所在的宽度和高度的比例:
     candidates = extract_candidates(img)
        candidates = [(x,y,x+w,y+h) for x,y,w,h in candidates] 
    
    1. 预处理图像并缩放 RoIs (rois):
     input = preprocess_image(img/255.)[None]
        rois = [[x/224,y/224,X/224,Y/224] for x,y,X,Y in candidates] 
    
    1. 由于所有建议都属于同一图像,rixs 将是一个零列表(与建议数量相同):
     rixs = np.array([0]*len(rois)) 
    
    1. 将输入、RoIs 通过训练模型前向传播,并为每个建议获取置信度和类别分数:
     rois,rixs = [torch.Tensor(item).to(device) for item in [rois, rixs]]
        with torch.no_grad():
            frcnn.eval()
            probs, deltas = frcnn(input, rois, rixs)
            confs, clss = torch.max(probs, -1) 
    
    1. 过滤掉背景类:
     candidates = np.array(candidates)
        confs,clss,probs,deltas=[tensor.detach().cpu().numpy() \
                 for tensor in [confs, clss, probs, deltas]]
    
        ixs = clss!=background_class
        confs, clss, probs,deltas,candidates= [tensor[ixs] for \
              tensor in [confs, clss, probs, deltas,candidates]]
        bbs = candidates + deltas 
    
    1. 使用 nms 移除近似重复的边界框,并获取高置信度模型为对象的那些建议的索引:
     ixs = nms(torch.tensor(bbs.astype(np.float32)),
                    torch.tensor(confs), 0.05)
        confs, clss, probs,deltas,candidates,bbs= [tensor[ixs] \ 
                      for tensor in [confs,clss,probs, deltas, candidates, bbs]]
        if len(ixs) == 1:
            confs, clss, probs, deltas, candidates, bbs = \
                        [tensor[None] for tensor in [confs,clss,
                                probs, deltas, candidates, bbs]]
    
        bbs = bbs.astype(np.uint16) 
    
    1. 绘制获得的边界框:
     _, ax = plt.subplots(1, 2, figsize=(20,10))
        show(img, ax=ax[0])
        ax[0].grid(False)
        ax[0].set_title(filename.split('/')[-1])
        if len(confs) == 0:
            ax[1].imshow(img)
            ax[1].set_title('No objects')
            plt.show()
            return
        else:
            show(img,bbs=bbs.tolist(),
        texts=[target2label[c] for c in clss.tolist()],ax=ax[1])
            plt.show() 
    
  2. 对测试图像进行预测:

    test_predictions(test_ds[29][-1]) 
    

上述代码导致如下结果:

图表,折线图 自动生成描述

图 7.17: 原始图像及其预测的边界框和类别

上述代码在 1.5 秒内执行。这主要是因为我们仍在使用两个不同的模型,一个用于生成区域建议,另一个用于进行类别预测和修正。在下一章中,我们将学习如何使用单个模型进行预测,以便在实时场景中进行快速推断。

摘要

在本章中,我们首先学习了为目标定位和检测过程创建训练数据集。然后,我们了解了 SelectiveSearch,一种基于相邻像素相似性推荐区域的区域建议技术。我们还学习了计算 IoU 指标以理解围绕图像中对象的预测边界框的好坏程度。

此外,我们研究了执行非极大值抑制以在图像中获取每个对象的一个边界框,然后学习了如何从头构建 R-CNN 和 Fast R-CNN 模型。我们还探讨了为什么 R-CNN 速度慢,以及 Fast R-CNN 如何利用 RoI 池化从特征图获取区域建议以加快推理速度。最后,我们了解到,来自单独模型的区域建议导致在新图像上预测需要更多时间。

在下一章中,我们将学习一些现代目标检测技术,这些技术用于更实时地进行推断。

问题

  1. 区域建议技术如何生成建议?

  2. 如果图像中存在多个对象,如何计算 IoU?

  3. 为什么 Fast R-CNN 比 R-CNN 更快?

  4. RoI 池化是如何工作的?

  5. 在预测边界框修正时,如果没有获取特征图后的多个层,会有什么影响?

  6. 为什么在计算总体损失时必须给回归损失分配更高的权重?

  7. 非极大值抑制是如何工作的?

加入 Discord 以获取更多信息

加入我们的 Discord 社区空间,与作者和其他读者进行讨论:

packt.link/modcv

第八章:高级目标检测

在前一章节中,我们学习了 R-CNN 和 Fast R-CNN 技术,它们利用区域建议生成图像中对象位置的预测以及图像中对象对应的类别。此外,我们还了解到推断速度的瓶颈在于具有用于区域建议生成和对象检测的两种不同模型。在本章中,我们将学习不同的现代技术,如 Faster R-CNN、YOLO 和单次检测器SSD),它们通过使用单一模型来为对象的类别和边界框进行预测,从而克服了慢推断时间的问题。我们将首先学习关于锚框的内容,然后继续学习每种技术的工作原理以及如何实现它们来检测图像中的对象。

我们将在本章节中讨论以下主题:

  • 现代目标检测算法的组成部分

  • 在自定义数据集上训练 Faster R-CNN

  • YOLO 的工作细节

  • 在自定义数据集上训练 YOLO

  • SSD 的工作细节

  • 在自定义数据集上训练 SSD

除了上述内容外,作为额外的奖励,我们在 GitHub 仓库中还涵盖了以下内容:

  • 训练 YOLOv8

  • 训练 EfficientDet 架构

    本章中的所有代码片段均可在 GitHub 仓库的 Chapter08 文件夹中找到:bit.ly/mcvp-2e

    随着领域的发展,我们将定期在 GitHub 仓库中添加有价值的补充内容。请查看每章节目录中的 supplementary_sections 文件夹获取新的和有用的内容。

现代目标检测算法的组成部分

R-CNN 和 Fast R-CNN 技术的缺点在于它们有两个不相交的网络——一个用于识别可能包含对象的区域,另一个用于在识别到对象的地方对边界框进行修正。此外,这两个模型都需要与区域建议一样多的前向传播。现代目标检测算法主要集中在训练单个神经网络上,并且具备在一次前向传递中检测所有对象的能力。典型现代目标检测算法的各个组成部分包括:

  • 锚框

  • 区域建议网络(RPN)

  • 区域兴趣(RoI)池化

让我们在以下小节中讨论这些(我们将专注于锚框和 RPN,因为我们在前一章节中已经讨论了 RoI 池化)。

锚框

到目前为止,我们使用了来自 selectivesearch 方法的区域建议。锚框作为 selectivesearch 的便捷替代品将在本节中学习它们如何替代基于 selectivesearch 的区域建议。

通常,大多数对象具有类似的形状 - 例如,在大多数情况下,与人的图像对应的边界框将具有比宽度大的更大的高度,而与卡车图像对应的边界框将具有比高度大的更大的宽度。因此,我们在训练模型之前(通过检查与各种类别对象对应的边界框的真实值)将会对图像中存在的对象的高度和宽度有一个合理的了解。

此外,在某些图像中,感兴趣的对象可能会缩放 - 导致高度和宽度比平均值更小或更大 - 同时保持纵横比(即高度/宽度)。

一旦我们对图像中存在的对象的纵横比、高度和宽度有一个合理的了解(可以从数据集中的真实值获得),我们就会定义具有高度和宽度的锚框,这些锚框表示数据集中大多数对象的边界框。通常,这是通过在图像中存在的对象的地面真实边界框上使用 K 均值聚类来获得的。

现在我们了解了如何获取锚框的高度和宽度,我们将学习如何在流程中利用它们:

  1. 将每个锚框从图像的左上角滑动到右下角。

  2. 具有与对象高度重叠联合(IoU)的高的锚框将标记为包含对象,并且其他将标记为0

我们可以通过提到 IoU 的阈值来修改 IoU 的阈值,如果 IoU 大于某个阈值,则对象类别为1;如果小于另一个阈值,则对象类别为0,否则未知。

一旦我们按照这里定义的真实值获得了地面真实值,我们可以构建一个模型,该模型可以预测对象的位置,并预测与锚框相匹配的偏移量以与地面真实值匹配。现在让我们了解如何在以下图像中表示锚框:

图 8.1:示例锚框

在上一张图片中,我们有两种锚框,一种高度大于宽度,另一种宽度大于高度,以对应图像中的对象(类别) - 一个人和一辆车。

我们将这两个锚框滑动到图像上,并注意 IoU 与真实标签的位置最高的地方,并指出该特定位置包含对象,而其余位置则不包含对象。

除了上述两种锚框外,我们还会创建具有不同尺度的锚框,以便适应图像中对象可能呈现的不同尺度。以下是不同尺度锚框的示例。请注意,所有锚框都具有相同的中心点,但具有不同的长宽比或尺度:

图 8.2:具有不同尺度和长宽比的锚框

现在我们了解了锚框,接下来的部分中,我们将学习关于 RPN 的知识,RPN 利用锚框来预测可能包含对象的区域。

区域提议网络

想象一个场景,我们有一个 224 x 224 x 3 的图像。进一步假设这个例子中锚框的形状是 8 x 8。如果我们有一个步幅为 8 像素,那么我们每一行可以从图像中获取 224/8 = 28 个裁剪图像 —— 实际上从一张图像中总共可以获取 28*28 = 576 个裁剪图像。然后,我们将每一个裁剪图像通过 RPN 传递,该网络指示裁剪图像中是否包含对象。基本上,RPN 提供了裁剪图像包含对象的可能性。

让我们比较selectivesearch的输出和 RPN 的输出。

selectivesearch 根据像素值进行一组计算,为我们提供了一个区域候选。然而,RPN 根据锚框和锚框滑过图像的步幅生成区域候选。我们使用这两种方法之一获取区域候选后,会识别最有可能包含对象的候选者。

虽然基于selectivesearch的区域提议生成是在神经网络之外完成的,但我们可以构建一个作为对象检测网络一部分的 RPN。通过使用 RPN,我们现在无需在网络之外执行不必要的计算来计算区域提议。这样,我们只需要一个单一模型来识别区域、图像中对象的类别以及它们对应的边界框位置。

接下来,我们将学习 RPN 如何确定一个区域候选(在滑动锚框后获得的裁剪图像)是否包含对象。在我们的训练数据中,我们会有与对象对应的真实边界框。现在,我们将每个区域候选与图像中对象的真实边界框进行比较,以确定区域候选与真实边界框之间的 IoU 是否大于某个阈值。如果 IoU 大于某个阈值(比如0.5),则区域候选包含对象;如果 IoU 小于某个阈值(比如0.1),则区域候选不包含对象,并且在训练过程中将忽略所有 IoU 介于两个阈值之间(0.10.5)的候选者。

一旦我们训练一个模型来预测区域候选是否包含对象,我们接着执行非极大值抑制,因为多个重叠的区域可能包含对象。

总结一下,RPN 通过以下步骤训练模型,使其能够识别高概率包含对象的区域提议:

  1. 滑动不同长宽比和尺寸的锚框穿过图像,获取图像的裁剪图像。

  2. 计算图像中对象的真实边界框与前一步中获得的裁剪图像之间的 IoU。

  3. 准备训练数据集,使得 IoU 大于阈值的裁剪区域包含对象,而 IoU 小于阈值的裁剪区域不包含对象。

  4. 训练模型以识别包含对象的区域。

  5. 执行非最大抑制以识别概率最高的包含对象的区域候选项,并消除与其高度重叠的其他区域候选项。

现在我们通过一个 RoI 池化层将区域候选项传递,以获得形状的区域。

分类和回归

到目前为止,我们已经学习了以下步骤,以便识别对象并执行边界框的偏移量:

  1. 识别包含对象的区域。

  2. 使用 RoI 池化确保所有区域的特征图,无论区域的形状如何,都完全相同(我们在前一章学习过这一点)。

这些步骤存在两个问题如下:

  • 区域提议与物体的重叠不密切(在 RPN 中我们设定了IoU>0.5的阈值)。

  • 我们已经确定了区域是否包含对象,但没有确定区域中对象的类别。

我们在本节中解决了这两个问题,我们将先前获得的均匀形状的特征图传递到网络中。我们期望网络能够预测区域内包含的对象的类别,并且预测区域的偏移量,以确保边界框尽可能紧密地围绕图像中的对象。

让我们通过以下图表来理解这一点:

图 8.3:预测对象类别和预测边界框要进行的偏移量

在前面的图表中,我们将 RoI 池化的输出作为输入(形状为 7 x 7 x 512),将其展平,并将其连接到一个密集层,然后预测两个不同的方面:

  • 区域中的对象类别

  • 预测边界框的偏移量的数量,以最大化与地面实况的 IoU

因此,如果数据中有 20 个类别,则神经网络的输出总共包含 25 个输出 – 21 个类别(包括背景类别)和用于边界框高度、宽度以及两个中心坐标的 4 个偏移量。

现在我们已经了解了目标检测流水线的不同组成部分,让我们通过以下图表进行总结:

图 8.4:Faster R-CNN 工作流程

更多关于 Faster R-CNN 的细节可以在这篇论文中找到 – arxiv.org/pdf/1506.01497.pdf

在 Faster R-CNN 的每个组件的工作细节都已经就位后,在下一节中,我们将编写使用 Faster R-CNN 算法进行目标检测的代码。

在自定义数据集上训练 Faster R-CNN

在以下代码中,我们将训练 Faster R-CNN 算法来检测图像中物体周围的边界框。为此,我们将继续上一章节中的相同卡车与公共汽车检测练习:

在 GitHub 的 Chapter08 文件夹中的 Training_Faster_RCNN.ipynb 文件中找到以下代码:bit.ly/mcvp-2e

  1. 下载数据集:

    !pip install -qU torch_snippets
    import os
    %%writefile kaggle.json
     {"username":"XXX", "key":"XXX"}
     !mkdir -p ~/.kaggle
     !cp kaggle.json ~/.kaggle/
     !chmod 600 /root/.kaggle/kaggle.json
     !kaggle datasets download -d sixhky/open-images-bus-trucks/
     !unzip -qq open-images-bus-trucks.zip
     !rm open-images-bus-trucks.zip 
    
  2. 读取包含图像及其边界框和类信息的元数据的 DataFrame:

    from torch_snippets import *
    from PIL import Image
    IMAGE_ROOT = 'images/images'
    DF_RAW = df = pd.read_csv('df.csv') 
    
  3. 定义与标签和目标对应的索引:

    label2target = {l:t+1 for t,l in enumerate(DF_RAW['LabelName'].unique())}
    label2target['background'] = 0
    target2label = {t:l for l,t in label2target.items()}
    background_class = label2target['background']
    num_classes = len(label2target) 
    
  4. 定义用于预处理图像的函数 – preprocess_image

    def preprocess_image(img):
        img = torch.tensor(img).permute(2,0,1)
        return img.to(device).float() 
    
  5. 定义数据集类 – OpenDataset

    1. 定义一个 __init__ 方法,该方法接受包含图像的文件夹和包含图像元数据的 DataFrame 作为输入:
    class OpenDataset(torch.utils.data.Dataset):
        w, h = 224, 224
        def __init__(self, df, image_dir=IMAGE_ROOT):
            self.image_dir = image_dir
            self.files = glob.glob(self.image_dir+'/*')
            self.df = df
            self.image_infos = df.ImageID.unique() 
    
    1. 定义 __getitem__ 方法,在此方法中我们返回预处理的图像和目标值:
     def __getitem__(self, ix):
            # load images and masks
            image_id = self.image_infos[ix]
            img_path = find(image_id, self.files)
            img = Image.open(img_path).convert("RGB")
            img = np.array(img.resize((self.w, self.h),
                                  resample=Image.BILINEAR))/255.
            data = df[df['ImageID'] == image_id]
            labels = data['LabelName'].values.tolist()
            data = data[['XMin','YMin','XMax','YMax']].values
            # Convert to absolute coordinates
            data[:,[0,2]] *= self.w
            data[:,[1,3]] *= self.h
            boxes = data.astype(np.uint32).tolist()
            # torch FRCNN expects ground truths as
            # a dictionary of tensors
            target = {}
            target["boxes"] = torch.Tensor(boxes).float()
            target["labels"]= torch.Tensor([label2target[i] \
                                    for i in labels]).long()
            img = preprocess_image(img)
            return img, target 
    

注意,这是我们第一次将输出作为张量字典而不是张量列表返回。这是因为 FRCNN 类的官方 PyTorch 实现期望目标包含边界框的绝对坐标和标签信息。

  1. 定义 collate_fn 方法(默认情况下,collate_fn 仅适用于张量作为输入,但在这里,我们处理的是字典列表)和 __len__ 方法:

  2.  def collate_fn(self, batch):
            return tuple(zip(*batch))
        def __len__(self):
            return len(self.image_infos) 
    
  3. 创建训练和验证数据加载器及数据集:

    from sklearn.model_selection import train_test_split
    trn_ids, val_ids = train_test_split(df.ImageID.unique(),
                        test_size=0.1, random_state=99)
    trn_df, val_df = df[df['ImageID'].isin(trn_ids)], \
                        df[df['ImageID'].isin(val_ids)]
    train_ds = OpenDataset(trn_df)
    test_ds = OpenDataset(val_df)
    train_loader = DataLoader(train_ds, batch_size=4,
                              collate_fn=train_ds.collate_fn,
                                              drop_last=True)
    test_loader = DataLoader(test_ds, batch_size=4,
                               collate_fn=test_ds.collate_fn,
                                              drop_last=True) 
    
  4. 定义模型:

    import torchvision
    from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    def get_model():
        model = torchvision.models.detection\
                    .fasterrcnn_resnet50_fpn(pretrained=True)
        in_features = model.roi_heads.box_predictor.cls_score.in_features
        model.roi_heads.box_predictor = \
                     FastRCNNPredictor(in_features, num_classes)
        return model 
    

模型包含以下关键子模块:

图 8.5:Faster R-CNN 架构

在上述输出中,我们注意到以下元素:

  • GeneralizedRCNNTransform 是一个简单的调整大小后跟随标准化变换:

图 8.6:输入上的转换

  • BackboneWithFPN 是将输入转换为特征映射的神经网络。

  • RegionProposalNetwork 生成前述特征映射的锚框,并预测分类和回归任务的单独特征映射:

图 8.7:RPN 架构

  • RoIHeads 使用前述的映射,通过 ROI 池化对齐它们,处理它们,并为每个提议返回分类概率和相应的偏移量:

图 8.8:roi_heads 架构

  1. 定义在数据批次上训练并计算验证数据上的损失值的函数:

    # Defining training and validation functions
    def train_batch(inputs, model, optimizer):
        model.train()
        input, targets = inputs
        input = list(image.to(device) for image in input)
        targets = [{k: v.to(device) for k, v in t.items()} for t in targets]
        optimizer.zero_grad()
        losses = model(input, targets)
        loss = sum(loss for loss in losses.values())
        loss.backward()
        optimizer.step()
        return loss, losses
    @torch.no_grad()
    def validate_batch(inputs, model):
        model.train()
    #to obtain losses, model needs to be in train mode only
    #Note that here we aren't defining the model's forward #method
    #hence need to work per the way the model class is defined
        input, targets = inputs
        input = list(image.to(device) for image in input)
        targets = [{k: v.to(device) for k, v \
                    in t.items()} for t in targets]
        optimizer.zero_grad()
        losses = model(input, targets)
        loss = sum(loss for loss in losses.values())
        return loss, losses 
    
  2. 在增加的时期训练模型:

    1. 定义模型:
    model = get_model().to(device)
    optimizer = torch.optim.SGD(model.parameters(), lr=0.005,
                               momentum=0.9,weight_decay=0.0005)
    n_epochs = 5
    log = Report(n_epochs) 
    
    1. 训练模型并计算训练和测试数据集上的损失值:
    for epoch in range(n_epochs):
        _n = len(train_loader)
        for ix, inputs in enumerate(train_loader):
            loss, losses = train_batch(inputs, model, optimizer)
            loc_loss, regr_loss, loss_objectness, \
                loss_rpn_box_reg = \
                    [losses[k] for k in ['loss_classifier', \
                    'loss_box_reg', 'loss_objectness', \
                    'loss_rpn_box_reg']]
            pos = (epoch + (ix+1)/_n)
            log.record(pos, trn_loss=loss.item(),
                     trn_loc_loss=loc_loss.item(),
                     trn_regr_loss=regr_loss.item(),
                     trn_objectness_loss=loss_objectness.item(),
                   trn_rpn_box_reg_loss=loss_rpn_box_reg.item(),
                     end='\r')
        _n = len(test_loader)
        for ix,inputs in enumerate(test_loader):
            loss, losses = validate_batch(inputs, model)
            loc_loss, regr_loss, loss_objectness, \
                loss_rpn_box_reg = \
                    [losses[k] for k in ['loss_classifier', \
                    'loss_box_reg', 'loss_objectness', \
                    'loss_rpn_box_reg']]
            pos = (epoch + (ix+1)/_n)
            log.record(pos, val_loss=loss.item(),
                     val_loc_loss=loc_loss.item(),
                     val_regr_loss=regr_loss.item(),
                     val_objectness_loss=loss_objectness.item(),
                     val_rpn_box_reg_loss=loss_rpn_box_reg.item(), end='\r')
        if (epoch+1)%(n_epochs//5)==0: log.report_avgs(epoch+1) 
    
  3. 绘制各种损失值随时间增加的变化:

    log.plot_epochs(['trn_loss','val_loss']) 
    

这导致以下输出:

图 8.9:随着时期增加,训练和验证损失值

  1. 在新图像上进行预测:

    1. 训练模型的输出包含与类别对应的盒子、标签和分数。在下面的代码中,我们定义了一个decode_output函数,它接受模型的输出并在非极大值抑制后提供盒子、分数和类别的列表:
    from torchvision.ops import nms
    def decode_output(output):
        'convert tensors to numpy arrays'
        bbs = output['boxes'].cpu().detach().numpy().astype(np.uint16)
        labels = np.array([target2label[i] for i in \
                    output['labels'].cpu().detach().numpy()])
        confs = output['scores'].cpu().detach().numpy()
        ixs = nms(torch.tensor(bbs.astype(np.float32)),
                                torch.tensor(confs), 0.05)
        bbs, confs, labels = [tensor[ixs] for tensor in [bbs, confs, labels]]
        if len(ixs) == 1:
            bbs,confs,labels = [np.array([tensor]) for tensor \
                                    in [bbs, confs, labels]]
        return bbs.tolist(), confs.tolist(), labels.tolist() 
    
    1. 获取测试图像上的盒子和类别的预测结果:
    model.eval()
    for ix, (images, targets) in enumerate(test_loader):
        if ix==3: break
        images = [im for im in images]
        outputs = model(images)
        for ix, output in enumerate(outputs):
            bbs, confs, labels = decode_output(output)
            info = [f'{l}@{c:.2f}' for l,c in zip(labels,confs)]
            show(images[ix].cpu().permute(1,2,0), bbs=bbs,
                                             texts=labels, sz=5) 
    

前述代码提供了以下输出:

图 8.10:预测的边界框和类别

注意,现在生成一张图像的预测需要约 400 毫秒,而 Fast R-CNN 需要 1.5 秒。

在本节中,我们使用 PyTorch models包中提供的fasterrcnn_resnet50_fpn模型类训练了一个 Faster R-CNN 模型。在接下来的部分中,我们将了解 YOLO,这是一种现代目标检测算法,它可以在一个步骤中执行目标类别检测和区域校正,无需单独的 RPN。

YOLO 的工作细节

You Only Look Once (YOLO)及其变体是显著的目标检测算法之一。在本节中,我们将高层次地了解 YOLO 的工作原理及其在克服基于 R-CNN 的目标检测框架的潜在限制方面的作用。

首先,让我们了解基于 R-CNN 的检测算法可能存在的限制。在 Faster R-CNN 中,我们使用锚框在图像上滑动并识别可能包含对象的区域,然后进行边界框修正。然而,在全连接层中,仅传递检测到的区域的 RoI 池化输出作为输入,在区域建议的边界框不能完全包含对象的情况下(对象超出了区域建议的边界框的边界),网络必须猜测对象的真实边界,因为它仅看到了部分图像(而不是整个图像)。在这种情况下,YOLO 非常有用,因为它在预测图像对应的边界框时会查看整个图像。此外,Faster R-CNN 仍然较慢,因为我们有两个网络:RPN 和最终预测围绕对象的类别和边界框的网络。

让我们了解 YOLO 如何在整个图像一次性地检测以及使用单一网络进行预测的同时,克服 Faster R-CNN 的限制。我们将通过以下示例了解为 YOLO 准备数据。

首先,我们为给定的图像创建一个地面真实值来训练模型:

  1. 让我们考虑一张具有红色边界框的给定地面真实值图像:

图 8.11:具有地面真实边界框的输入图像

  1. 将图像划分为N x N的网格单元格 – 暂时假定N=3:

图 8.12:将输入图像分成一个 3 x 3 的网格

  1. 识别包含至少一个地面真实边界框中心的网格单元格。在我们的 3 x 3 网格图像中,它们是单元格b1b3

  2. 包围框中心点落在的单元格(或单元格)负责预测对象的边界框。让我们为每个单元格创建相应的真实值。

  3. 每个单元格的输出真实值如下所示:

图 8.13:真实值表示

在这里,pc(对象存在分数)是网格单元格包含对象的概率。

  1. 让我们了解如何计算bxbybwbh。首先,我们将网格单元格(假设我们考虑b1网格单元格)作为我们的宇宙,并将其归一化到 0 到 1 的比例,如下所示:

    图 8.14:计算每个真实值的 bx、by、bw 和 bh 的步骤 1

bxby是边界框中心相对于图像(网格单元格)的位置,如前所述定义。在我们的案例中,bx = 0.5,因为边界框的中心点距离原点 0.5 个单位。同样地,by = 0.5:

图 8.15:计算 bx 和 by

到目前为止,我们已经计算了从图像中对象的网格单元格中心到真实中心的偏移量。现在让我们了解如何计算bwbh

  • bw是边界框相对于网格单元格宽度的比率。

  • bh是边界框相对于网格单元格高度的比率。

  1. 接下来,我们将预测与网格单元格对应的类别。如果我们有三个类别(c1truckc2carc3bus),我们将预测网格单元格包含任何类别的对象的概率。请注意,这里我们不需要背景类别,因为pc对应于网格单元格是否包含对象。

  2. 现在我们了解了如何表示每个单元格的输出层之后,让我们了解如何构建我们的 3 x 3 网格单元的输出:

    1. 让我们考虑网格单元格a3的输出:

    图 8.16:计算与单元格 a3 对应的真实值

    单元格a3的输出如前所示的截图。由于网格单元格不包含对象,第一个输出(pc – 对象存在分数)为0,其余值由于单元格不包含任何对象的中心而无关紧要。

    1. 让我们考虑与网格单元格b1对应的输出:

    图 8.17:与单元格 b1 对应的真实值

    之前的输出是因为网格单元格中包含有对象的bxbybwbh值,这些值的获取方式与之前所述相同,最终类别为car,导致 c2 为1,而 c1 和 c3 为0

请注意,对于每个单元格,我们能够获取 8 个输出。因此,对于 3 x 3 网格单元,我们获取 3 x 3 x 8 个输出。

让我们看看接下来的步骤:

  1. 定义一个模型,其输入是图像,输出为 3 x 3 x 8,并且根据前一步骤定义的真实值:

图 8.18:示例模型架构

  1. 通过考虑锚框来定义真实值。

到目前为止,我们一直在为预期只有一个物体存在于网格单元格内的情景进行构建。然而,在现实中,可能存在一个网格单元格内有多个物体的情况。这会导致创建不正确的真实值。让我们通过以下示例图像来理解这一现象:

图 8.19:同一个网格单元格中可能存在多个物体的情景

在上述示例中,汽车和人的真实边界框的中点都落在同一个单元格中 —— 单元格 b1

避免这种情况的一种方法是使用具有更多行和列的网格,例如一个 19 x 19 的网格。然而,仍然可能存在增加网格单元格数量并不起作用的情况。在这种情况下,锚框就显得特别有用。假设我们有两个锚框 —— 一个高度大于宽度(对应于人),另一个宽度大于高度(对应于汽车):

图 8.20:利用锚框

通常,锚框会以网格单元格中心作为它们的中心。在存在两个锚框的情况下,每个单元格的输出表示为两个锚框期望输出的串联:

图 8.21:当存在两个锚框时的真实值表示

这里,bxbybwbh 表示与锚框的偏移(在这种场景中,锚框是宇宙,如图像所示,而不是网格单元格)。

从前面的截图中,我们看到输出为 3 x 3 x 16,因为有两个锚框。期望输出的形状为 N x N x num_classes x num_anchor_boxes,其中 N x N 是网格中单元格的数量,num_classes 是数据集中的类别数,num_anchor_boxes 是锚框的数量。

  1. 现在我们定义损失函数来训练模型。

当计算与模型相关的损失时,我们需要确保在物体性分数低于某个阈值时不计算回归损失和分类损失(这对应于不包含物体的单元格)。

接下来,如果单元格包含一个物体,我们需要确保跨不同类别的分类尽可能准确。

最后,如果单元格包含对象,则边界框偏移应尽可能接近预期。然而,由于宽度和高度的偏移可以比中心的偏移要大得多(因为中心的偏移范围在 0 到 1 之间,而宽度和高度的偏移则不需要),因此我们通过获取平方根值来给予宽度和高度偏移更低的权重。

计算定位和分类损失如下:

在此,我们观察到以下内容:

  • 是与回归损失相关联的权重。

  • 表示单元格是否包含对象

  • 对应于预测类别概率

  • 表示物体性得分

总损失是分类损失和回归损失值的总和。

现在,我们已经能够训练一个模型来预测物体周围的边界框。然而,为了更深入地了解 YOLO 及其变体,我们建议您阅读原始论文,网址为 arxiv.org/pdf/1506.02640

现在我们了解了 YOLO 如何在单次预测中预测物体的边界框和类别之后,我们将在下一节中编写代码。

在自定义数据集上训练 YOLO

在深度学习中,建立在他人工作的基础上是成为成功从业者的重要途径。对于这一实现,我们将使用官方 YOLOv4 实现来识别图像中公共汽车和卡车的位置。我们将克隆 YOLO 作者自己的存储库实现,并根据需要进行定制,如下所示。

要训练最新的 YOLO 模型,我们强烈建议您查阅以下存储库 – github.com/ultralytics/ultralyticsgithub.com/WongKinYiu/yolov7

我们已经在 GitHub 的Chapter08文件夹中提供了 YOLOv8 的工作实现,文件名为Training_YOLOv8.ipynb,网址为 bit.ly/mcvp-2e

安装 Darknet

首先,从 GitHub 拉取darknet存储库并在环境中编译它。该模型是用一种称为 Darknet 的独立语言编写的,与 PyTorch 不同。我们将使用以下代码进行操作:

可在 GitHub 上的Chapter08文件夹中的Training_YOLO.ipynb文件中找到以下代码,网址为 bit.ly/mcvp-2e

  1. 拉取 Git 存储库:

    !git clone https://github.com/AlexeyAB/darknet
    %cd darknet 
    
  2. 重新配置Makefile文件:

    !sed -i 's/OPENCV=0/OPENCV=1/' Makefile
    # In case you dont have a GPU, make sure to comment out the
    # below 3 lines
    !sed -i 's/GPU=0/GPU=1/' Makefile
    !sed -i 's/CUDNN=0/CUDNN=1/' Makefile
    !sed -i 's/CUDNN_HALF=0/CUDNN_HALF=1/' Makefile 
    

Makefile 是在环境中安装 darknet 所需的配置文件(可以将此过程视为在 Windows 上安装软件时所做的选择)。我们强制 darknet 使用以下标志进行安装:OPENCVGPUCUDNNCUDNN_HALF。这些都是重要的优化措施,可加快训练速度。此外,在前面的代码中,有一个称为 sed 的奇特函数,它代表 流编辑器。它是一个强大的 Linux 命令,可以直接从命令提示符中修改文本文件中的信息。具体而言,在这里我们使用它的搜索和替换功能,将 OPENCV=0 替换为 OPENCV=1,以此类推。要理解的语法是 sed 's/<search-string>/<replace-with>/'path/to/text/file

  1. 编译 darknet 源代码:

    !make 
    
  2. 安装 torch_snippets 包:

    !pip install -q torch_snippets 
    
  3. 下载并解压数据集,并删除 ZIP 文件以节省空间:

    !wget --quiet \
     https://www.dropbox.com/s/agmzwk95v96ihic/open-images-bus-trucks.tar.xz
    !tar -xf open-images-bus-trucks.tar.xz
    !rm open-images-bus-trucks.tar.xz 
    
  4. 获取预训练权重以进行样本预测:

    !wget --quiet\ https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v3_optimal/yolov4.weights 
    
  5. 通过运行以下命令测试安装是否成功:

    !./darknet detector test cfg/coco.data cfg/yolov4.cfg\ yolov4.weights
     data/person.jpg 
    

这将使用从 cfg/yolov4.cfg 构建的网络和预训练权重 yolov4.weightsdata/person.jpg 进行预测。此外,它还从 cfg/coco.data 获取类别,这是预训练权重训练的内容。

前述代码将对样本图像 (data/person.jpg) 进行预测,如下所示:

图 8.22:对样本图像的预测

现在我们已经了解了如何安装 darknet,在下一节中,我们将学习如何为自定义数据集创建真实数据,以利用 darknet

设置数据集格式

YOLO 使用固定格式进行训练。一旦我们按要求格式存储图像和标签,就可以用单个命令对数据集进行训练。因此,让我们了解一下 YOLO 训练所需的文件和文件夹结构。

有三个重要的步骤:

  1. 创建一个文本文件 data/obj.names,其中包含一行一个类别名称,通过运行以下命令行来实现(%%writefile 是一个魔术命令,用于在笔记本单元格中创建一个包含内容的文本文件 data/obj.names):

    %%writefile data/obj.names
    bus
    truck 
    
  2. data/obj.data 创建一个文本文件,描述数据集中的参数以及包含训练和测试图像路径的文本文件的位置,还有包含对象名称的文件位置和保存训练模型的文件夹位置:

    %%writefile data/obj.data
    classes = 2
    train = data/train.txt
    valid = data/val.txt
    names = data/obj.names
    backup = backup/ 
    

前述文本文件的扩展名不是 .txt。YOLO 使用硬编码的名称和文件夹来识别数据的位置。此外,魔术命令 %%writefile 在 Jupyter 中创建一个带有单元格中指定内容的文件,如前所示。将每个 %%writefile ... 视为 Jupyter 中的一个单独单元格。

  1. 将所有图像和真实文本文件移动到 data/obj 文件夹中。我们将从 bus-trucks 数据集中将图像复制到此文件夹,并包含标签:

    !mkdir -p data/obj
    !cp -r open-images-bus-trucks/images/* data/obj/
    !cp -r open-images-bus-trucks/yolo_labels/all/{train,val}.txt data/
    !cp -r open-images-bus-trucks/yolo_labels/all/labels/*.txt data/obj/ 
    

请注意,所有的训练和验证图像都位于同一个data/obj文件夹中。我们还将一堆文本文件移动到同一个文件夹中。每个包含图像真实标签的文件与图像具有相同的名称。例如,文件夹可能包含1001.jpg1001.txt,意味着文本文件包含该图像的标签和边界框。如果data/train.txt包含1001.jpg作为其行之一,则它是一个训练图像。如果它存在于val.txt中,则是一个验证图像。

文本文件本身应包含如下信息:clsxcycwh,其中cls是边界框中物体的类索引,位于(xcyc)处,表示矩形的中心点,宽度为w,高度为h。每个xcycwh都是图像宽度和高度的一部分。将每个对象存储在单独的行上。

例如,如果宽度(800)和高度(600)的图像包含一个卡车和一个公共汽车,中心分别为(500,300)和(100,400),宽度和高度分别为(200,100)和(300,50),则文本文件如下所示:

1 0.62 0.50 0.25 0.12
0 0.12 0.67 0.38 0.08 

现在我们已经创建了数据,让我们在下一节中配置网络架构。

配置架构

YOLO 提供了一长串的架构。有些大,有些小,适用于大或小的数据集。配置可以具有不同的主干。标准数据集有预训练的配置。每个配置都是 GitHub 仓库cfgs文件夹中的.cfg文件。

每个文件都包含网络架构的文本文件(与我们使用nn.Module类建立的方式不同),以及一些超参数,如批处理大小和学习率。我们将选择最小的可用架构,并为我们的数据集进行配置:

# create a copy of existing configuration and modify it in place
!cp cfg/yolov4-tiny-custom.cfg cfg/yolov4-tiny-bus-trucks.cfg
# max_batches to 4000 (since the dataset is small enough)
!sed -i 's/max_batches = 500200/max_batches=4000/' cfg/yolov4-tiny-bus-trucks.cfg
# number of sub-batches per batch
!sed -i 's/subdivisions=1/subdivisions=16/' cfg/yolov4-tiny-bus-trucks.cfg
# number of batches after which learning rate is decayed
!sed -i 's/steps=400000,450000/steps=3200,3600/' cfg/yolov4-tiny-bus-trucks.cfg
# number of classes is 2 as opposed to 80
# (which is the number of COCO classes)
!sed -i 's/classes=80/classes=2/g' cfg/yolov4-tiny-bus-trucks.cfg
# in the classification and regression heads,
# change number of output convolution filters
# from 255 -> 21 and 57 -> 33, since we have fewer classes
# we don't need as many filters
!sed -i 's/filters=255/filters=21/g' cfg/yolov4-tiny-bus-trucks.cfg
!sed -i 's/filters=57/filters=33/g' cfg/yolov4-tiny-bus-trucks.cfg 

这样一来,我们已经重新配置了yolov4-tiny,使其可以在我们的数据集上进行训练。唯一剩下的步骤是加载预训练的权重并训练模型,这将在下一节中进行。

训练和测试模型

我们将从以下 GitHub 位置获取权重,并将它们存储在build/darknet/x64中:

!wget --quiet https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v4_pre/yolov4-tiny.conv.29
!cp yolov4-tiny.conv.29 build/darknet/x64/ 

最后,我们将使用以下命令训练模型:

!./darknet detector train data/obj.data \
cfg/yolov4-tiny-bus-trucks.cfg yolov4-tiny.conv.29 -dont_show -mapLastAt 

-dont_show标志跳过显示中间预测图像,而-mapLastAt会定期打印验证数据上的平均精度。整个训练可能需要 1 到 2 小时与 GPU。权重会定期存储在备份文件夹中,并可以在训练后用于预测,例如以下代码,该代码在新图像上进行预测:

!pip install torch_snippets
from torch_snippets import Glob, stem, show, read
# upload your own images to a folder
image_paths = Glob('images-of-trucks-and-busses')
for f in image_paths:
    !./darknet detector test \
    data/obj.data cfg/yolov4-tiny-bus-trucks.cfg\
    backup/yolov4-tiny-bus-trucks_4000.weights {f}
    !mv predictions.jpg {stem(f)}_pred.jpg
for i in Glob('*_pred.jpg'):
    show(read(i, 1), sz=20) 

前面的代码导致了以下输出:

图 8.23:输入图像上的预测边界框和类别

现在我们已经了解了如何利用 YOLO 在自定义数据集上执行目标检测,接下来,我们将学习另一种目标检测技术——单阶段检测器(SSD)

SSD 的工作细节

到目前为止,我们已经看到了一个场景,在这个场景中,我们在逐渐对前一层输出进行卷积和池化之后进行预测。然而,我们知道不同的层对原始图像有不同的感知域。例如,初始层的感知域比最终层的感知域要小。在这里,我们将学习 SSD 如何利用这一现象为图像的边界框进行预测。

SSD 如何帮助解决检测不同尺度对象的问题的工作原理如下:

  1. 我们利用预训练的 VGG 网络,并在其上添加一些额外的层,直到获得一个 1 x 1 的块。

  2. 不仅仅利用最终层进行边界框和类别预测,我们将利用所有最后几层来进行类别和边界框的预测。

  3. 我们将使用具有特定比例和长宽比的默认框代替锚框。

  4. 每个默认框都应该预测对象和边界框偏移量,就像锚框在 YOLO 中预测类别和偏移量一样。

现在我们了解了 SSD 与 YOLO 的主要不同之处(即 SSD 中的默认框取代了 YOLO 中的锚框,并且多个层连接到最终层,而不是 YOLO 中的逐步卷积池化),接下来我们将学习以下内容:

  • SSD 的网络架构

  • 如何利用不同层进行边界框和类别预测

  • 如何为不同层的默认框分配比例和长宽比

SSD 的网络架构如下:

图 8.24:SSD 工作流程

正如您在前面的图中所看到的,我们正在获取一个大小为 300 x 300 x 3 的图像,并通过预训练的 VGG-16 网络来获得conv5_3层的输出。此外,我们通过在conv5_3输出上添加几个额外的卷积来扩展网络。

接下来,我们为每个单元格和每个默认框获取边界框偏移量和类别预测(关于默认框的更多信息将在下一节详细介绍;现在,让我们想象这与锚框类似)。从conv5_3输出中得到的预测总数为 38 x 38 x 4,其中 38 x 38 是conv5_3层的输出形状,4 是在conv5_3层上操作的默认框数量。同样,网络中所有检测的总数如下:

每类的检测数
conv5_3 38 x 38 x 4 = 5,776
FC6 19 x 19 x 6 = 2,166
conv8_2 10 x 10 x 6 = 600
conv9_2 5 x 5 x 6 = 150
conv10_2 3 x 3 x 4 = 36
conv11_2 1 x 1 x 4 = 4
总检测数 8,732

表 8.1:每类的检测数

请注意,在原始论文描述的架构中,某些层次的默认盒子数量较多(为 6 而不是 4)。

现在,让我们了解默认盒子的不同尺度和长宽比。我们将从尺度开始,然后继续到长宽比。

让我们想象一种情况,其中对象的最小比例为图像高度和宽度的 20%,对象的最大比例为图像高度和宽度的 90%。在这种情况下,我们随着层次的增加逐渐增加尺度(随着向后层次,图像尺寸显著缩小),如下所示:

图 8.25:随着不同层次对象大小比例变化的盒子尺度

使图像逐步缩放的公式如下:

了解了如何在不同层次计算尺度之后,让我们学习如何生成不同长宽比的盒子。可能的长宽比如下所示:

不同层次盒子的中心如下:

这里,ij 一起表示层 l 中的一个单元格。另一方面,根据不同长宽比计算的宽度和高度如下所示:

请注意,在某些层次我们考虑了四个盒子,而在另一层次我们考虑了六个盒子。如果要保留四个盒子,则移除 {3,1/3} 长宽比,否则考虑所有六个可能的盒子(五个尺度相同的盒子和一个尺度不同的盒子)。让我们看看如何获得第六个盒子:

有了这些,我们已经得到了所有可能的盒子。接下来让我们了解如何准备训练数据集。

具有 IoU 大于阈值(例如 0.5)的默认盒子被视为正匹配,其余为负匹配。在 SSD 的输出中,我们预测盒子属于某一类的概率(其中第 0 类表示背景),还预测了相对于默认盒子的真实值偏移量。

最后,通过优化以下损失值来训练模型:

  • 分类损失:使用以下方程表示:

在前述方程中,pos 表示与真实值有较高重叠的少数默认盒子,而 neg 表示误分类的盒子,这些盒子预测为某一类别,但实际上未包含对象。最后,我们确保 pos:neg 比例最多为 1:3,如果不进行这种采样,背景类盒子会占主导地位。

  • 定位损失: 对于定位,我们仅在物体性得分大于某个阈值时考虑损失值。定位损失计算如下:

在这里,t是预测的偏移量,d是实际偏移量。

欲深入了解 SSD 工作流程,请参阅arxiv.org/abs/1512.02325

现在我们理解了如何训练 SSD,让我们在下一节将其用于我们的公共汽车与卡车目标检测练习中。本节的核心实用函数位于 GitHub 仓库:github.com/sizhky/ssd-utils/。让我们在开始训练过程之前逐个了解它们。

SSD 代码中的组件

GitHub 仓库中有三个文件。让我们稍微深入了解它们,并在训练之前理解它们使用的导入。请注意,此部分不是训练过程的一部分,而是用于理解训练中使用的导入。

我们从 GitHub 仓库的model.py文件中导入SSD300MultiBoxLoss类。让我们分别了解它们。

SSD300

当你查看SSD300函数定义时,可以明显看到该模型由三个子模块组成:

class SSD300(nn.Module):
    ...
    def __init__(self, n_classes, device):
        ...
        self.base = VGGBase()
        self.aux_convs = AuxiliaryConvolutions()
        self.pred_convs = PredictionConvolutions(n_classes)
        ... 

首先我们将输入发送到VGGBase,它返回两个尺寸为(N, 512, 38, 38)(N, 1024, 19, 19)的特征向量。第二个输出将成为AuxiliaryConvolutions的输入,它返回更多的特征映射,尺寸为(N, 512, 10, 10)(N, 256, 5, 5)(N, 256, 3, 3)(N, 256, 1, 1)。最后,来自VGGBase的第一个输出和这四个特征映射被发送到PredictionConvolutions,它返回 8,732 个锚框,正如我们之前讨论的那样。

SSD300类的另一个关键方面是create_prior_boxes方法。对于每个特征图,与之关联的有三个项目:网格的大小、用于缩小网格单元的比例(这是该特征图的基础锚框),以及单元格中所有锚框的长宽比。使用这三个配置,代码使用三重for循环并为所有 8,732 个锚框创建(cx, cy, w, h)列表。

最后,detect_objects方法接受模型预测的锚框分类和回归值张量,并将它们转换为实际边界框坐标。

MultiBoxLoss

作为人类,我们只关心少数边界框。但是对于 SSD 的工作方式,我们需要比较来自多个特征映射的 8,732 个边界框,并预测一个锚框是否包含有价值的信息。我们将此损失计算任务分配给MultiBoxLoss

forward 方法的输入是模型的锚框预测和地面真实边界框。

首先,我们将地面真值框转换为 8,732 个锚框的列表,方法是将模型中的每个锚点与边界框进行比较。如果 IoU(交并比)足够高,则特定的锚框将具有非零回归坐标,并将一个对象关联为分类的地面真值。自然地,大多数计算出的锚框将具有其关联类别为background,因为它们与实际边界框的 IoU 很小,或者在很多情况下为0

一旦地面真实值转换为这些 8,732 个锚框回归和分类张量,就可以轻松地将它们与模型的预测进行比较,因为它们的形状现在是相同的。我们在回归张量上执行MSE-Loss,在定位张量上执行CrossEntropy-Loss,并将它们相加作为最终损失返回。

在自定义数据集上训练 SSD

在下面的代码中,我们将训练 SSD 算法以检测图像中物体周围的边界框。我们将使用我们一直在进行的卡车与公共汽车物体检测任务:

在 GitHub 的Chapter08文件夹中的Training_SSD.ipynb文件中找到以下代码,网址为bit.ly/mcvp-2e。代码包含下载数据的 URL,并且相对较长。我们强烈建议在 GitHub 上执行笔记本以重现结果,同时按照文本中各种代码组件的步骤和解释进行操作。

  1. 下载图像数据集并克隆托管代码模型和其他处理数据工具的 Git 仓库:

    import os
    if not os.path.exists('open-images-bus-trucks'):
        !pip install -q torch_snippets
        !wget --quiet https://www.dropbox.com/s/agmzwk95v96ihic/\
        open-images-bus-trucks.tar.xz
        !tar -xf open-images-bus-trucks.tar.xz
        !rm open-images-bus-trucks.tar.xz
        !git clone https://github.com/sizhky/ssd-utils/
    %cd ssd-utils 
    
  2. 预处理数据,就像我们在在自定义数据集上训练 Faster R-CNN部分中所做的一样:

    from torch_snippets import *
    DATA_ROOT = '../open-images-bus-trucks/'
    IMAGE_ROOT = f'{DATA_ROOT}/images'
    DF_RAW = pd.read_csv(f'{DATA_ROOT}/df.csv')
    df = DF_RAW.copy()
    df = df[df['ImageID'].isin(df['ImageID'].unique().tolist())]
    label2target = {l:t+1 for t,l in enumerate(DF_RAW['LabelName'].unique())}
    label2target['background'] = 0
    target2label = {t:l for l,t in label2target.items()}
    background_class = label2target['background']
    num_classes = len(label2target)
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  3. 准备数据集类,就像我们在在自定义数据集上训练 Faster R-CNN部分中所做的一样:

    import collections, os, torch
    from PIL import Image
    from torchvision import transforms
    normalize = transforms.Normalize(
                    mean=[0.485, 0.456, 0.406],
                    std=[0.229, 0.224, 0.225]
                )
    denormalize = transforms.Normalize( 
                    mean=[-0.485/0.229,-0.456/0.224,-0.406/0.255],
                    std=[1/0.229, 1/0.224, 1/0.255]
                )
    def preprocess_image(img):
        img = torch.tensor(img).permute(2,0,1)
        img = normalize(img)
        return img.to(device).float()
    class OpenDataset(torch.utils.data.Dataset):
        w, h = 300, 300
        def __init__(self, df, image_dir=IMAGE_ROOT):
            self.image_dir = image_dir
            self.files = glob.glob(self.image_dir+'/*')
            self.df = df
            self.image_infos = df.ImageID.unique()
            logger.info(f'{len(self)} items loaded')
    
        def __getitem__(self, ix):
            # load images and masks
            image_id = self.image_infos[ix]
            img_path = find(image_id, self.files)
            img = Image.open(img_path).convert("RGB")
            img = np.array(img.resize((self.w, self.h),
                           resample=Image.BILINEAR))/255.
            data = df[df['ImageID'] == image_id]
            labels = data['LabelName'].values.tolist()
            data = data[['XMin','YMin','XMax','YMax']].values
            data[:,[0,2]] *= self.w
            data[:,[1,3]] *= self.h
            boxes = data.astype(np.uint32).tolist() # convert to
            # absolute coordinates
            return img, boxes, labels
        def collate_fn(self, batch):
            images, boxes, labels = [], [], []
            for item in batch:
                img, image_boxes, image_labels = item
                img = preprocess_image(img)[None]
                images.append(img)
                boxes.append(torch.tensor( \                       
                           image_boxes).float().to(device)/300.)
                labels.append(torch.tensor([label2target[c] \
                      for c in image_labels]).long().to(device))
            images = torch.cat(images).to(device)
            return images, boxes, labels
        def __len__(self):
            return len(self.image_infos) 
    
  4. 准备训练和测试数据集以及数据加载器:

    from sklearn.model_selection import train_test_split
    trn_ids, val_ids = train_test_split(df.ImageID.unique(),
                                 test_size=0.1, random_state=99)
    trn_df, val_df = df[df['ImageID'].isin(trn_ids)], \
                    df[df['ImageID'].isin(val_ids)]
    train_ds = OpenDataset(trn_df)
    test_ds = OpenDataset(val_df)
    train_loader = DataLoader(train_ds, batch_size=4,
                              collate_fn=train_ds.collate_fn,
                              drop_last=True)
    test_loader = DataLoader(test_ds, batch_size=4,
                             collate_fn=test_ds.collate_fn,
                             drop_last=True) 
    
  5. 定义函数以在批量数据上进行训练,并计算验证数据的准确性和损失值:

    def train_batch(inputs, model, criterion, optimizer):
        model.train()
        N = len(train_loader)
        images, boxes, labels = inputs
        _regr, _clss = model(images)
        loss = criterion(_regr, _clss, boxes, labels)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        return loss
    @torch.no_grad()
    def validate_batch(inputs, model, criterion):
        model.eval()
        images, boxes, labels = inputs
        _regr, _clss = model(images)
        loss = criterion(_regr, _clss, boxes, labels)
        return loss 
    
  6. 导入模型:

    from model import SSD300, MultiBoxLoss
    from detect import * 
    
  7. 初始化模型、优化器和损失函数:

    n_epochs = 5
    model = SSD300(num_classes, device)
    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5)
    criterion = MultiBoxLoss(priors_cxcy=model.priors_cxcy, device=device)
    log = Report(n_epochs=n_epochs)
    logs_to_print = 5 
    
  8. 在增加的 epochs 上训练模型:

    for epoch in range(n_epochs):
        _n = len(train_loader)
        for ix, inputs in enumerate(train_loader):
            loss = train_batch(inputs, model, criterion, optimizer)
            pos = (epoch + (ix+1)/_n)
            log.record(pos, trn_loss=loss.item(), end='\r')
        _n = len(test_loader)
        for ix,inputs in enumerate(test_loader):
            loss = validate_batch(inputs, model, criterion)
            pos = (epoch + (ix+1)/_n)
            log.record(pos, val_loss=loss.item(), end='\r') 
    

随着 epoch 的增加,训练和测试损失值的变化如下:

图 8.26:随着 epoch 增加的训练和验证损失

  1. 获取新图像的预测(获取随机图像):

    image_paths = Glob(f'{DATA_ROOT}/images/*')
    image_id = choose(test_ds.image_infos)
    img_path = find(image_id, test_ds.files)
    original_image = Image.open(img_path, mode='r')
    original_image = original_image.convert('RGB') 
    
  2. 获取与图像中存在的对象对应的边界框、标签和分数:

    bbs, labels, scores = detect(original_image, model,
                                 min_score=0.9, max_overlap=0.5,
                                 top_k=200, device=device) 
    
  3. 将获取的输出覆盖在图像上:

    labels = [target2label[c.item()] for c in labels]
    label_with_conf = [f'{l} @ {s:.2f}' for l,s in zip(labels,scores)]
    print(bbs, label_with_conf)
    show(original_image, bbs=bbs,
         texts=label_with_conf, text_sz=10) 
    

上述代码获取执行迭代中每个输出的样本如下(每次执行都会有一个图像):

图 8.27:输入图像上的预测边界框和类别

从中我们可以看出,我们可以相当准确地检测图像中的物体。

摘要

在本章中,我们了解了现代目标检测算法 Faster R-CNN、YOLO 和 SSD 的工作细节。我们学习了它们如何克服两个独立模型的限制 - 一个用于获取区域提议,另一个用于在区域提议上获取类别和边界框偏移量。此外,我们使用 PyTorch 实现了 Faster R-CNN,使用 darknet 实现了 YOLO,并从头开始实现了 SSD。

在接下来的章节中,我们将学习关于图像分割的内容,这一步进一步超越了仅仅识别对象位置的功能,它还能识别对应对象的像素。此外,在 第十章目标检测和分割的应用 中,我们将学习 Detectron2 框架,它不仅有助于检测对象,还能在单次操作中对它们进行分割。

问题

  1. 为什么 Faster R-CNN 相对于 Fast R-CNN 更快?

  2. 在与 Faster R-CNN 相比时,YOLO 和 SSD 为何更快?

  3. YOLO 和 SSD 单次检测器算法有何特点?

  4. 目标性分数和类别分数之间有何区别?

在 Discord 上了解更多信息

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第九章:图像分割

在上一章中,我们学习了如何检测图像中存在的对象,以及与检测到的对象相对应的类别。在本章中,我们将更进一步,不仅在对象周围绘制边界框,还将确定包含对象的确切像素。此外,在本章结束时,我们将能够单独识别属于同一类别的实例/对象。

通过查看 U-Net 和 Mask R-CNN 架构,我们还将学习语义分割和实例分割。具体来说,我们将涵盖以下主题:

  • 探索 U-Net 架构

  • 使用 U-Net 实现语义分割以在道路上分割对象

  • 探索 Mask R-CNN 架构

  • 使用 Mask R-CNN 实现实例分割以识别给定类别的多个实例

通过图像分割,我们试图实现的简明示例如下所示:

图形用户界面 Description automatically generated with low confidence

图 9.1: 在示例图像上对象定位和分割任务的差异(来源:arxiv.org/pdf/1405.0312.pdf

上图显示了:(a) 从图像中识别的对象类别,(b) 从图像中识别的对象位置,(c) 从图像中识别的对象类别掩码,以及 (d) 从图像中分离出的对象实例。现在你知道可以期待什么了,让我们开始吧!

本章中的所有代码可以在 GitHub 仓库的 Chapter09 文件夹中找到,链接为 bit.ly/mcvp-2e

探索 U-Net 架构

想象一种情景,你被要求预测图像中哪些像素对应于什么对象。到目前为止,当我们预测对象类别和边界框时,我们通过网络传递图像,然后通过骨干架构(如 VGG 或 ResNet)传递图像,将输出在某一层展平,并在进行类别和边界框偏移预测之前连接额外的密集层。然而,在图像分割的情况下,输出形状与输入图像的形状相同,展平卷积的输出然后重构图像可能会导致信息丢失。此外,在图像分割的情况下,原始图像中存在的轮廓和形状在输出图像中不会发生变化,因此我们到目前为止处理过的网络(展平最后一层并连接额外的密集层)在执行分割时并不是最优的。

在本节中,我们将学习如何执行图像分割。在执行分割时,我们需要牢记以下两个方面:

  • 原始图像中的对象的形状和结构在分割输出中保持不变。

  • 利用完全卷积架构(而不是我们将某一层展平的结构)可以帮助我们,因为我们使用一个图像作为输入和另一个作为输出。

U-Net 架构帮助我们实现了这一点。U-Net 的典型表示如下(输入图像形状为 3 x 96 x 128,图像中类别数为 21;这意味着输出包含 21 个通道):

自动生成的图表描述

图 9.2:一个样例 U-Net 架构

前述架构被称为U-Net 架构,因为它具有类似U字形的形状。

在前述图的左半部分,我们可以看到图像通过卷积层,就像我们在前几章中所见到的那样,并且图像的大小在减小,而通道数在增加。然而,在右半部分,我们可以看到我们将缩小的图像进行了上采样,回到原始的高度和宽度,但通道数与类别数相同(在这种情况下是 21)。

此外,在上采样过程中,我们还利用左半部分相应层的信息使用跳跃连接(类似于我们在第五章中学到的 ResNet 中的那些),以便保留原始图像中的结构/对象。这样,U-Net 架构学习保留原始图像的结构(和对象的形状),同时利用卷积特征来预测每个像素对应的类别。

一般来说,输出的通道数与我们想要预测的类别数相同。

现在我们了解了 U-Net 架构的高层次,让我们在下一节中了解我们介绍的新概念,上采样。

执行上采样

在 U-Net 架构中,使用 nn.ConvTranspose2d 方法进行上采样,该方法接受输入通道数、输出通道数、核心大小和步幅作为输入参数。ConvTranspose2d 的一个示例计算如下:

一组黑白方块的描述

图 9.3:上采样操作

在前述示例中,我们采取了一个形状为 3 x 3 的输入数组(输入数组),应用了步长为 2 的步幅,在这里我们分布输入值以适应步幅(调整为步幅的输入数组),用零填充数组(调整为步幅和填充的输入数组),并使用滤波器(过滤器/核心)与填充的输入进行卷积以获取输出数组。

通过利用填充和步幅的组合,我们已经将形状为 3 x 3 的输入上采样到形状为 6 x 6 的数组。虽然前述示例仅用于说明目的,但优化的滤波器值学习(因为在模型训练过程中优化了滤波器权重和偏差)尽可能地重建原始图像。

nn.ConvTranspose2d中的超参数如下:

文本,信函说明自动生成

图 9.4: ConvTranspose2d的参数

为了理解nn.ConvTranspose2d如何帮助放大数组,让我们来看下面的代码:

  1. 导入相关软件包:

    import torch
    import torch.nn as nn 
    
  2. 使用nn.ConvTranspose2d方法初始化网络m

    m = nn.ConvTranspose2d(1, 1, kernel_size=(2,2), stride=2, padding = 0) 
    

在前述代码中,我们指定输入通道值为1,输出通道值为1,核的大小为(2,2),步长为2,填充为0

内部填充的计算方式为扩展 * (核大小 - 1) - 填充。因此,它是 1*(2-1)-0 = 1,在输入数组的两个维度上都添加零填充为 1。

  1. 初始化输入数组并通过模型传递:

    input = torch.ones(1, 1, 3, 3)
    output = m(input)
    output.shape 
    

前述代码生成的形状为1x1x6x6,如前文提供的示例图像所示。

现在我们理解了 U-Net 架构的工作原理以及nn.ConvTranspose2d如何帮助放大图像,让我们来实现它,以便可以预测道路场景图像中存在的不同物体。

使用 U-Net 实现语义分割

在本节中,我们将利用 U-Net 架构来预测图像中所有像素对应的类别。如下所示是这种输入-输出组合的示例:

图 9.5: (左) 输入图像;(右) 输出图像,显示图像中存在的各种对象对应的类别

请注意,在前述图片中,属于同一类的对象(左图中的输入图像)具有相同的像素值(右图中的输出图像),这就是我们将语义相似的像素进行分割的原因。这也称为语义分割。让我们学习如何编写语义分割代码:

在 GitHub 的Chapter09文件夹中的Semantic_Segmentation_with_U_Net.ipynb文件中找到以下代码,位于bit.ly/mcvp-2e,代码包含用于下载数据的 URL,并且长度适中。

  1. 让我们从下载必要的数据集、安装必要的软件包并导入它们开始。完成后,我们可以定义设备:

    import os
    if not os.path.exists('dataset1'):
        !wget -q https://www.dropbox.com/s/0pigmmmynbf9xwq/dataset1.zip
        !unzip -q dataset1.zip
        !rm dataset1.zip
        !pip install -q torch_snippets pytorch_model_summary
    from torch_snippets import *
    from torchvision import transforms
    from sklearn.model_selection import train_test_split
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 定义用于转换图像(tfms)的函数:

    tfms = transforms.Compose([
                transforms.ToTensor(),
                transforms.Normalize([0.485, 0.456, 0.406],
                                     [0.229, 0.224, 0.225])
            ]) 
    
  3. 定义数据集类(SegData)以获取训练所需的输入和输出图像:

    1. 指定包含图像的文件夹在__init__方法中:
    class SegData(Dataset):
        def __init__(self, split):
            self.items=stems(f'dataset1/images_prepped_{split}')
            self.split = split 
    
    1. 定义__len__方法:
     def __len__(self):
            return len(self.items) 
    
    1. 定义__getitem__方法:
     def __getitem__(self, ix):
            image =read(f'dataset1/images_prepped_{self.split}/\
                                   {self.items[ix]}.png', 1)
            image = cv2.resize(image, (224,224))
            mask=read(f'dataset1/annotations_prepped_{self.split}\
                                /{self.items[ix]}.png')[:,:,0]
            mask = cv2.resize(mask, (224,224))
            return image, mask 
    

    __getitem__方法中,我们将输入(image)和输出(mask)图像都调整大小,使它们具有相同的形状。请注意,掩码图像包含的整数值在[0,11]范围内。这表明有 12 种不同的类别。

    1. 定义一个函数(choose)来选择一个随机图像索引(主要用于调试目的):
     def choose(self): return self[randint(len(self))] 
    
    1. 定义collate_fn方法来对图像批次进行预处理:
     def collate_fn(self, batch):
            ims, masks = list(zip(*batch))
            ims = torch.cat([tfms(im.copy()/255.)[None] \
                         for im in ims]).float().to(device)
            ce_masks = torch.cat([torch.Tensor(mask[None]) for\
                         mask in masks]).long().to(device)
            return ims, ce_masks 
    

    在前述代码中,我们对所有输入图像进行预处理,以便它们具有一个通道(这样每个图像稍后可以通过 CNN 传递),一旦我们转换了缩放后的图像。注意,ce_masks 是一个长整数张量,类似于交叉熵目标。

  4. 定义训练和验证数据集,以及数据加载器:

    trn_ds = SegData('train')
    val_ds = SegData('test')
    trn_dl = DataLoader(trn_ds, batch_size=4, shuffle=True,
                        collate_fn=trn_ds.collate_fn)
    val_dl = DataLoader(val_ds, batch_size=1, shuffle=True,
                        collate_fn=val_ds.collate_fn) 
    
  5. 定义神经网络模型架构以进行分割训练。请注意,U-Net 包含传统的卷积以及上采样卷积:

    1. 定义卷积块(conv):
    def conv(in_channels, out_channels):
        return nn.Sequential(
            nn.Conv2d(in_channels,out_channels,kernel_size=3,
                        stride=1, padding=1),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace=True)
        ) 
    
    1. conv 的定义中,我们依次执行 Conv2d 操作、BatchNorm2d 操作和 ReLU 操作。

    2. 定义 up_conv 块:

    def up_conv(in_channels, out_channels):
        return nn.Sequential(
            nn.ConvTranspose2d(in_channels, out_channels,
                               kernel_size=2, stride=2),
            nn.ReLU(inplace=True)
        ) 
    
    1. ConvTranspose2d 确保我们放大图像。这与 Conv2d 操作不同,后者减少图像的维度。它接受具有 in_channels 通道数的图像作为输入通道,并生成具有 out_channels 输出通道数的图像。

    2. 定义网络类(UNet):

    from torchvision.models import vgg16_bn
    class UNet(nn.Module):
        def __init__(self, pretrained=True, out_channels=12):
            super().__init__()
            self.encoder= vgg16_bn(pretrained=pretrained).features
            self.block1 = nn.Sequential(*self.encoder[:6])
            self.block2 = nn.Sequential(*self.encoder[6:13])
            self.block3 = nn.Sequential(*self.encoder[13:20])
            self.block4 = nn.Sequential(*self.encoder[20:27])
            self.block5 = nn.Sequential(*self.encoder[27:34])
            self.bottleneck = nn.Sequential(*self.encoder[34:])
            self.conv_bottleneck = conv(512, 1024)
            self.up_conv6 = up_conv(1024, 512)
            self.conv6 = conv(512 + 512, 512)
            self.up_conv7 = up_conv(512, 256)
            self.conv7 = conv(256 + 512, 256)
            self.up_conv8 = up_conv(256, 128)
            self.conv8 = conv(128 + 256, 128)
            self.up_conv9 = up_conv(128, 64)
            self.conv9 = conv(64 + 128, 64)
            self.up_conv10 = up_conv(64, 32)
            self.conv10 = conv(32 + 64, 32)
            self.conv11 = nn.Conv2d(32, out_channels, kernel_size=1) 
    
    1. 在前述的 __init__ 方法中,我们定义了在 forward 方法中使用的所有层。

    2. 定义 forward 方法:

     def forward(self, x):
            block1 = self.block1(x)
            block2 = self.block2(block1)
            block3 = self.block3(block2)
            block4 = self.block4(block3)
            block5 = self.block5(block4)
            bottleneck = self.bottleneck(block5)
            x = self.conv_bottleneck(bottleneck)
            x = self.up_conv6(x)
            x = torch.cat([x, block5], dim=1)
            x = self.conv6(x)
            x = self.up_conv7(x)
            x = torch.cat([x, block4], dim=1)
            x = self.conv7(x)
            x = self.up_conv8(x)
            x = torch.cat([x, block3], dim=1)
            x = self.conv8(x)
            x = self.up_conv9(x)
            x = torch.cat([x, block2], dim=1)
            x = self.conv9(x)
            x = self.up_conv10(x)
            x = torch.cat([x, block1], dim=1)
            x = self.conv10(x)
            x = self.conv11(x)
            return x 
    
    1. 在前述代码中,我们通过在适当的张量对上使用 torch.cat 来制作 U 型连接,连接了降采样和上采样卷积特征。

    2. 定义一个函数(UNetLoss),用于计算我们的损失和准确性值:

    ce = nn.CrossEntropyLoss()
    def UnetLoss(preds, targets):
        ce_loss = ce(preds, targets)
        acc = (torch.max(preds,1)[1] == targets).float().mean()
        return ce_loss, acc 
    
    1. 定义一个函数,该函数将对批次进行训练(train_batch),并计算验证数据集上的指标(validate_batch):
    def train_batch(model, data, optimizer, criterion):
        model.train()
        ims, ce_masks = data
        _masks = model(ims)
        optimizer.zero_grad()
        loss, acc = criterion(_masks, ce_masks)
        loss.backward()
        optimizer.step()
        return loss.item(), acc.item()
    @torch.no_grad()
    def validate_batch(model, data, criterion):
        model.eval()
        ims, masks = data
        _masks = model(ims)
        loss, acc = criterion(_masks, masks)
        return loss.item(), acc.item() 
    
    1. 定义模型、优化器、损失函数以及训练的 epochs 数量:
    model = UNet().to(device)
    criterion = UnetLoss
    optimizer = optim.Adam(model.parameters(), lr=1e-3)
    n_epochs = 20 
    
  6. 在增加的 epochs 上训练模型:

    log = Report(n_epochs)
    for ex in range(n_epochs):
        N = len(trn_dl)
        for bx, data in enumerate(trn_dl):
            loss, acc = train_batch(model, data, optimizer, criterion)
            log.record(ex+(bx+1)/N,trn_loss=loss,trn_acc=acc, end='\r')
        N = len(val_dl)
        for bx, data in enumerate(val_dl):
            loss, acc = validate_batch(model, data, criterion)
            log.record(ex+(bx+1)/N,val_loss=loss,val_acc=acc, end='\r')
    
        log.report_avgs(ex+1) 
    
  7. 绘制随着 epochs 增加的训练、验证损失和准确性值:

    log.plot_epochs(['trn_loss','val_loss']) 
    

前述代码生成以下输出:

图表,折线图 自动生成描述

图 9.6:随着 epochs 增加的训练和验证损失

  1. 计算在新图像上的预测输出,以观察模型在未见图像上的性能:

    1. 在新图像上获取模型预测:
    im, mask = next(iter(val_dl))
    _mask = model(im) 
    
    1. 获取具有最高概率的通道:
    _, _mask = torch.max(_mask, dim=1) 
    
    1. 展示原始和预测图像:
    subplots([im[0].permute(1,2,0).detach().cpu()[:,:,0],
              mask.permute(1,2,0).detach().cpu()[:,:,0],
              _mask.permute(1,2,0).detach().cpu()[:,:,0]],nc=3,
              titles=['Original image','Original mask',
              'Predicted mask'])
    

    前述代码生成以下输出:

图 9.7:(左)原始图像;(中)原始掩模;(右)预测掩模

从前面的图片中,我们看到使用 U-Net 架构可以成功生成分割掩模。然而,同一类的所有实例将具有相同的预测像素值。如果我们想要分开图像中Person类的实例会怎样?

在接下来的部分,我们将学习关于 Mask R-CNN 架构,它帮助生成实例级别的掩模,以便我们可以区分实例(即使是同一类的实例)。

探索 Mask R-CNN 架构

Mask R-CNN 架构有助于识别/突出显示图像中给定类别的对象实例。当图像中存在多个相同类型的对象时,这尤为方便。此外,术语 Mask 表示由 Mask R-CNN 在像素级别执行的分割。

Mask R-CNN 架构是 Faster R-CNN 网络的扩展,我们在上一章中学习过。然而,Mask R-CNN 架构进行了一些修改,如下所示:

  • RoI Pooling 层已被 RoI Align 层取代。

  • 在最终层中,除了预测对象的类别和边界框修正外,还包括了一个掩码头来预测对象的掩码。

  • 全卷积网络FCN)用于掩码预测。

在我们理解各个组件如何工作之前,让我们快速看一下 Mask R-CNN 中发生的事件(图片来源:arxiv.org/pdf/1703.06870.pdf):

自动生成的图示

图 9.8:Mask R-CNN 工作流程

在上图中,请注意我们从一个图层获取类别和边界框信息,从另一个图层获取掩码信息。

Mask R-CNN 架构的工作细节如下:

自动生成的图示

图 9.9:Mask R-CNN 的工作细节

在我们实现 Mask R-CNN 架构之前,我们需要理解其组成部分。我们将从 RoI Align 开始。

RoI Align

在 Faster R-CNN 中,我们了解到了 RoI 池化。RoI 池化的一个缺点是,在执行 RoI 池化操作时,我们可能会丢失某些信息。这是因为在池化之前,图像的各个区域可能具有不均匀的内容表示。

让我们通过前一章节提供的示例来看一下:

自动生成的表格描述

图 9.10:RoI 池化计算

在上图中,区域提议的形状为 5 x 7,并且我们必须将其转换为 2 x 2 的形状。在转换为 2 x 2 的过程中,区域的某些部分的表示较少,相比其他部分更有权重。这导致信息丢失,RoI Align 应运而生以解决这种情况。

让我们通过一个简单的示例来理解 RoI Align 的工作原理。在这里,我们试图将以下区域(用虚线表示)转换为 2 x 2 的形状:

自动生成的图片描述

图 9.11:用虚线表示的区域

请注意,区域(用虚线表示)在特征图的所有单元格中并不均匀分布。

我们必须执行以下步骤以获取 2 x 2 形状中区域的合理表示:

  1. 首先,将区域分成相等的 2 x 2 形状:

包含表格的图片说明自动生成

图 9.12: 计算区域的四个角

  1. 定义在每个 2 x 2 单元格内均匀间隔的四个点:

中等置信度自动生成的图表说明

图 9.13: 计算四个选定点

注意,在上述图表中,两个连续点之间的距离为 0.75(水平或垂直)。

  1. 根据每个点到最近已知值的距离计算加权平均值:

自动生成的图表说明

图 9.14: 扩展到每个点对应值

  1. 对单元格中所有四个点重复前述插值步骤:

自动生成的表格说明

图 9.15: 四个角的数值

  1. 在单元格内所有四个点上执行平均池化:

包含表格的图片说明自动生成

图 9.16: 所有四个单元格的池化输出

通过实施上述步骤,在执行 RoI Align 时,我们不会丢失信息,也就是说,当我们将所有区域放置在相同形状内时。

掩码头

使用 RoI Align,我们可以获得从区域建议网络获取的标准形状 RoI Align 输出更准确的表示。现在,我们希望获取分割(掩码)输出,对于每个区域建议给定标准形状的 RoI Align 输出。

通常情况下,在目标检测中,我们会通过平坦化层传递 RoI Align 以预测物体的类别和边界框偏移量。然而,在图像分割的情况下,我们预测包含对象的边界框内的像素。因此,现在我们有了第三个输出(除了类别和边界框偏移量),即感兴趣区域内的预测掩码。

在这里,我们预测掩码,它是叠加在原始图像上的图像。考虑到我们预测的是一个图像,而不是展平 RoI Align 的输出,我们将其连接到另一个卷积层以获得另一个类似图像的结构(宽度 x 高度的维度)。让我们通过查看以下图表来理解这一现象:

自动生成的图表说明

图 9.17: 获取掩码的工作流程

在上述图表中,我们使用特征金字塔网络(FPN)获得了形状为 7 x 7 x 2048 的输出,现在具有两个分支:

  • 第一个分支在展平 FPN 输出后返回对象的类别和边界框。

  • 第二个分支在 FPN 的输出上执行卷积以获取掩码。

对于 14 x 14 输出的地面真值是区域提议的调整大小图像。如果数据集中有 80 个唯一类别,则区域提议的地面真值形状为 80 x 14 x 14。每个 80 x 14 x 14 像素是 1 或 0,表示像素是否包含对象。因此,在预测像素类别时,我们执行二进制交叉熵损失最小化。

模型训练后,我们可以检测区域,获取类别,获取边界框偏移量,并获取与每个区域对应的掩码。进行推断时,我们首先检测图像中存在的对象并进行边界框校正。然后,我们将区域偏移量传递给掩码头部,以预测与区域中不同像素对应的掩码。

现在我们了解了 Mask R-CNN 架构的工作原理,让我们编写代码,以便在图像中检测人物的实例。

使用 Mask R-CNN 实现实例分割

为了帮助我们理解如何为实例分割编写 Mask R-CNN 代码,我们将利用一个数据集,该数据集遮盖了图像中存在的人物。我们将使用从 ADE20K 数据集的子集创建的数据集,该数据集包含输入图像及其相应的掩码,可在 groups.csail.mit.edu/vision/datasets/ADE20K/ 找到。我们将仅使用那些具有人物掩码的图像。

我们将采用以下策略:

  1. 获取数据集,然后从中创建数据集和数据加载器。

  2. 创建 PyTorch 官方实现 Mask R-CNN 所需的地面真值格式。

  3. 下载预训练的 Faster R-CNN 模型,并将 Mask R-CNN 头部连接到模型上。

  4. 使用已标准化的 PyTorch 代码片段训练 Mask R-CNN 模型。

  5. 首先执行非极大值抑制来推断图像,并识别图像中的人物的边界框和掩码。

让我们编写上述策略的代码:

在 GitHub 的 Chapter09 文件夹中的 Instance_Segmentation.ipynb 文件中找到以下代码:bit.ly/mcvp-2e

  1. 从 GitHub 导入相关数据集和训练工具:

    !wget --quiet \
     http://sceneparsing.csail.mit.edu/data/ChallengeData2017/images.tar
    !wget --quiet http://sceneparsing.csail.mit.edu/data/ChallengeData2017/annotations_instance.tar
    !tar -xf images.tar
    !tar -xf annotations_instance.tar
    !rm images.tar annotations_instance.tar
    !pip install -qU torch_snippets
    !wget --quiet https://raw.githubusercontent.com/pytorch/vision/master/references/detection/engine.py
    !wget --quiet https://raw.githubusercontent.com/pytorch/vision/master/references/detection/utils.py
    !wget --quiet https://raw.githubusercontent.com/pytorch/vision/master/references/detection/transforms.py
    !wget --quiet https://raw.githubusercontent.com/pytorch/vision/master/references/detection/coco_eval.py
    !wget --quiet https://raw.githubusercontent.com/pytorch/vision/master/references/detection/coco_utils.py
    !pip install -q -U \
    'git+https://github.com/cocodataset/cocoapi.git#subdirectory=PythonAPI' 
    
  2. 导入所有必要的包并定义device

    from torch_snippets import *
    import torchvision
    from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
    from torchvision.models.detection.mask_rcnn import MaskRCNNPredictor
    from engine import train_one_epoch, evaluate
    import utils
    import transforms as T
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  3. 获取包含人物掩码的图像,如下所示:

    1. 循环遍历imagesannotations_instance文件夹以获取文件名:
    all_images = Glob('images/training')
    all_annots = Glob('annotations_instance/training') 
    
    1. 检查原始图像和人物实例掩码的表示:
    f = 'ADE_train_00014301'
    im = read(find(f, all_images), 1)
    an = read(find(f, all_annots), 1).transpose(2,0,1)
    r,g,b = an
    nzs = np.nonzero(r==4) # 4 stands for person
    instances = np.unique(g[nzs])
    masks = np.zeros((len(instances), *r.shape))
    for ix,_id in enumerate(instances):
        masks[ix] = g==_id
    subplots([im, *masks], sz=20) 
    

    上述代码生成以下输出。我们可以看到为每个人生成了单独的掩码。这里有四个Person类的实例:

    包含文本的图片  自动生成的描述

    图 9.18:每个个体的单独掩码生成

    在这个特定的数据集中,实例的地面实况注释是以这样一种方式提供的,即 RGB 中的红色通道对应于对象的类别,而绿色通道对应于实例编号(如果图像中有多个相同类别的对象,如我们此处的示例)。此外,Person 类以值 4 编码。

  4. 遍历注释并存储至少包含一个人的文件:

  5. annots = []
    for ann in Tqdm(all_annots):
        _ann = read(ann, 1).transpose(2,0,1)
        r,g,b = _ann
        if 4 not in np.unique(r): continue
        annots.append(ann) 
    
  6. 将文件分为训练文件和验证文件:

  7. from sklearn.model_selection import train_test_split
    _annots = stems(annots)
    trn_items,val_items=train_test_split(_annots,random_state=2) 
    
  8. 定义转换方法:

    def get_transform(train):
        transforms = []
        transforms.append(T.PILToTensor())
        if train:
            transforms.append(T.RandomHorizontalFlip(0.5))
        return T.Compose(transforms) 
    
  9. 创建数据集类(MasksDataset),如下所示:

    1. 定义 __init__ 方法,该方法接受图像名称(items)、转换方法(transforms)和要考虑的文件数(N)作为输入:
    class MasksDataset(Dataset):
        def __init__(self, items, transforms, N):
            self.items = items
            self.transforms = transforms
            self.N = N 
    
    1. 定义一个方法(get_mask),将获取等于图像中存在的实例数的面具数量:
     def get_mask(self, path):
            an = read(path, 1).transpose(2,0,1)
            r,g,b = an
            nzs = np.nonzero(r==4)
            instances = np.unique(g[nzs])
            masks = np.zeros((len(instances), *r.shape))
            for ix,_id in enumerate(instances):
                masks[ix] = g==_id
            return masks 
    
    1. 获取图像及其对应的目标值以返回。每个人(实例)被视为不同的对象类别;即每个实例都是不同的类别。请注意,类似于训练 Faster R-CNN 模型,目标作为张量字典返回。让我们定义 __getitem__ 方法:
     def __getitem__(self, ix):
            _id = self.items[ix]
            img_path = f'images/training/{_id}.jpg'
            mask_path=f'annotations_instance/training/{_id}.png'
            masks = self.get_mask(mask_path)
            obj_ids = np.arange(1, len(masks)+1)
            img = Image.open(img_path).convert("RGB")
            num_objs = len(obj_ids) 
    
    1. 除了面具本身外,Mask R-CNN 还需要边界框信息。然而,这很容易准备,如下所示的代码:
     boxes = []
            for i in range(num_objs):
                obj_pixels = np.where(masks[i])
                xmin = np.min(obj_pixels[1])
                xmax = np.max(obj_pixels[1])
                ymin = np.min(obj_pixels[0])
                ymax = np.max(obj_pixels[0])
                if (((xmax-xmin)<=10) | (ymax-ymin)<=10):
                    xmax = xmin+10
                    ymax = ymin+10
                boxes.append([xmin, ymin, xmax, ymax]) 
    
    1. 在上述代码中,通过在边界框的 xy 坐标的最小值上添加 10 个像素,来调整存在可疑地面实况的情况(即 Person 类的高度或宽度小于 10 像素)。

    2. 将所有目标值转换为张量对象:

     boxes = torch.as_tensor(boxes, dtype=torch.float32)
            labels = torch.ones((num_objs,), dtype=torch.int64)
            masks = torch.as_tensor(masks, dtype=torch.uint8)
            area = (boxes[:, 3] - boxes[:, 1]) *(boxes[:, 2] - boxes[:, 0])
            iscrowd = torch.zeros((num_objs,),dtype=torch.int64)
            image_id = torch.tensor([ix]) 
    
    1. 将目标值存储在字典中:
     target = {}
            target["boxes"] = boxes
            target["labels"] = labels
            target["masks"] = masks
            target["image_id"] = image_id
            target["area"] = area
            target["iscrowd"] = iscrowd 
    
    1. 指定转换方法并在缩放图像后返回图像:
     if self.transforms is not None:
                img, target = self.transforms(img, target)
            if (img.dtype == torch.float32) or (img.dtype == torch.uint8) :
                 img = img/255.
            return img, target 
    
    1. 指定 __len__ 方法:
     def __len__(self):
            return self.N 
    
    1. 定义将选择随机图像的函数:
     def choose(self):
            return self[randint(len(self))] 
    
    1. 检查输入输出组合:
    x = MasksDataset(trn_items, get_transform(train=True),N=100)
    im,targ = x[0]
    inspect(im,targ)
    subplots([im, *targ['masks']], sz=10) 
    

以下是运行上述代码时生成的一些示例输出。我们可以看到面具的形状为 2 x 512 x 683,表明图像中有两个人物:

图形用户界面 自动生成的描述

图 9.19:(左)输入图像;(中间和右侧)预测的人物面具

请注意,在 __getitem__ 方法中,图像中有多少个对象(实例),就有多少个面具和边界框。此外,因为我们只有两个类别(Background 类和 Person 类),我们将 Person 类指定为 1

到此步骤结束时,输出字典中有相当多的信息,即对象类别、边界框、面具、面具区域以及面具是否对应于人群。所有这些信息都在 target 字典中可用。对于我们将要使用的训练函数,数据以 torchvision.models.detection.maskrcnn_resnet50_fpn 类所要求的格式标准化非常重要。

  1. 接下来,我们需要定义实例分割模型(get_model_instance_segmentation)。我们将使用一个预训练模型,只重新初始化头部以预测两类(背景和人)。首先,我们需要初始化一个预训练模型,并替换box_predictormask_predictor头部,以便从头学习:

    def get_model_instance_segmentation(num_classes):
        # load an instance segmentation model pre-trained on
        # COCO
        model = torchvision.models.detection\
                         .maskrcnn_resnet50_fpn(pretrained=True)
        # get number of input features for the classifier
        in_features = model.roi_heads.box_predictor.cls_score.in_features
        # replace the pre-trained head with a new one
        model.roi_heads.box_predictor = FastRCNNPredictor(\
                                        in_features,num_classes)
        in_features_mask = model.roi_heads\
                          .mask_predictor.conv5_mask.in_channels
        hidden_layer = 256
        # and replace the mask predictor with a new one
        model.roi_heads.mask_predictor = MaskRCNNPredictor(\
                                          in_features_mask,
                                          hidden_layer, 
                                          num_classes)
        return model 
    

FastRCNNPredictor期望两个输入:in_features(输入通道数)和num_classes(类别数)。基于要预测的类别数,计算出边界框预测的数量,即类别数的四倍。

MaskRCNNPredictor期望三个输入:in_features_mask(输入通道数)、hidden_layer(输出通道数)和num_classes(要预测的类别数)。

可通过指定以下内容获取所定义模型的详细信息:

  1. model = get_model_instance_segmentation(2).to(device)
    model 
    

模型的底半部(即不包含骨干网络的部分)如下所示:

文本  自动生成描述

图 9.20:Mask R-CNN 模型架构

请注意,Faster R-CNN(我们在前一章节中训练的)与 Mask R-CNN 模型之间的主要区别在于roi_heads模块,它本身包含多个子模块。让我们看看它们执行的任务:

  • roi_heads:对从 FPN 网络获取的输入进行对齐,并创建两个张量。

  • box_predictor:使用我们获得的输出来预测每个 RoI 的类别和边界框偏移量。

  • mask_roi_pool:该层然后对来自 FPN 的输出进行对齐。

  • mask_head:将先前获得的对齐输出转换为可用于预测掩码的特征图。

  • mask_predictor:接受来自mask_head的输出并预测最终的掩码。

  1. 获取对应于训练和验证图像的数据集和数据加载器:

    dataset = MasksDataset(trn_items, get_transform(train=True), N=3000)
    dataset_test = MasksDataset(val_items, get_transform(train=False), N=800)
    # define training and validation data loaders
    data_loader=torch.utils.data.DataLoader(dataset,
                batch_size=2, shuffle=True, num_workers=0, 
                              collate_fn=utils.collate_fn)
    data_loader_test = torch.utils.data.DataLoader(dataset_test, 
                      batch_size=1, shuffle=False, 
                      num_workers=0,collate_fn=utils.collate_fn) 
    
  2. 定义模型、参数和优化标准:

    num_classes = 2
    model = get_model_instance_segmentation(num_classes).to(device)
    params = [p for p in model.parameters() if p.requires_grad]
    optimizer = torch.optim.SGD(params, lr=0.005,                             
                               momentum=0.9,weight_decay=0.0005)
    # and a learning rate scheduler
    lr_scheduler = torch.optim.lr_scheduler.StepLR(optimizer, 
                                     step_size=3, gamma=0.1) 
    

所定义的预训练模型架构将图像和targets字典作为输入来减少损失。通过运行以下命令可以查看模型接收到的输出样本:

  1. # The following code is for illustration purpose only
    model.eval()
    pred = model(dataset[0][0][None].to(device))
    inspect(pred[0]) 
    

前述代码的输出如下所示:

表格  自动生成描述

图 9.21:样本预测

在这里,我们可以看到一个包含边界框(BOXES)、对应于边界框的类别(LABELS)、对应于类别预测的置信度分数(SCORES)以及我们掩码实例的位置(MASKS)的字典。正如您所见,该模型硬编码为返回 100 个预测,这是合理的,因为我们不应该期望一张典型图像中有超过 100 个对象。

要获取已检测到的实例数量,我们将使用以下代码:

  1. # The following code is for illustration purpose only
    pred[0]['masks'].shape
    # torch.Size([100, 1, 536, 559]) 
    

前述代码获取图像的最多 100 个掩模实例(其中实例对应于非背景类),以及与图像相对应的尺寸。对于这 100 个实例,还会返回相应的类别标签、边界框和该类别的 100 个相应置信度值。

  1. 在增加纪元的训练模型:

    num_epochs = 5
    trn_history = []
    for epoch in range(num_epochs):
        # train for one epoch, printing every 10 iterations
        res = train_one_epoch(model, optimizer, data_loader,
                              device, epoch, print_freq=10)
        trn_history.append(res)
        # update the learning rate
        lr_scheduler.step()
        # evaluate on the test dataset
        res = evaluate(model, data_loader_test, device=device) 
    

通过这样做,我们现在可以在图像中的人物上覆盖我们的掩模。我们可以记录我们在增加纪元期间的训练损失变化如下:

  1. import matplotlib.pyplot as plt
    plt.title('Training Loss')
    losses=[np.mean(list(trn_history[i].meters['loss'].deque)) \
                for i in range(len(trn_history))]
    plt.plot(losses) 
    

前述代码的结果如下:

图表,线图  自动生成描述

图 9.22: 随着增加纪元的训练损失

  1. 对测试图像进行预测:

    model.eval()
    im = dataset_test[0][0]
    show(im)
    with torch.no_grad():
        prediction = model([im.to(device)])
        for i in range(len(prediction[0]['masks'])):
            plt.imshow(Image.fromarray(prediction[0]['masks']\
                          [i, 0].mul(255).byte().cpu().numpy()))
            plt.title('Class: '+str(prediction[0]['labels']\
                       [i].cpu().numpy())+' Score:'+str(\
                      prediction[0]['scores'][i].cpu().numpy()))
            plt.show() 
    

前述代码的结果如下。我们可以看到我们成功地识别了图像中的四个人物。此外,模型还预测了图像中的多个其他部分(我们在前述输出中未显示),尽管置信度较低:

图形用户界面  描述自动生成,置信度中等

图 9.23: (左)输入图像;(其余)预测的掩模

现在模型能够很好地检测实例,让我们对一个不在提供数据集中的自定义图像进行预测。

  1. 对自己的新图像进行预测:

    !wget https://www.dropbox.com/s/e92sui3a4kt/Hema18.JPG
    img = Image.open('Hema18.JPG').convert("RGB")
    from torchvision import transforms
    pil_to_tensor = transforms.ToTensor()(img).unsqueeze_(0)
    Image.fromarray(pil_to_tensor[0].mul(255)\
                            .permute(1, 2, 0).byte().numpy()) 
    

输入图像如下:

一个人抱着一个孩子  描述自动生成,置信度低

图 9.24: 样本输入图像在验证数据之外

  1. 获取输入图像的预测:

    model.eval()
    with torch.no_grad():
        prediction = model([pil_to_tensor[0].to(device)])
        for i in range(len(prediction[0]['masks'])):
            plt.imshow(Image.fromarray(prediction[0]['masks']\
                        [i, 0].mul(255).byte().cpu().numpy()))
            plt.title('Class: '+str(prediction[0]\
                     ['labels'][i].cpu().numpy())+ \       
         'Score:'+str(prediction[0]['scores'][i].cpu().numpy()))
            plt.show() 
    

前述代码的结果如下:

图形用户界面  描述自动生成

图 9.25: (左)输入图像;(其余)预测的掩模

注意,在前述图像中,经过训练的模型表现不如在测试图像上的表现好。这可能是由于以下原因:

  • 人们在训练期间可能没有如此接近并重叠。

  • 模型可能没有在大多数占据图像的感兴趣类别的图像上进行训练。

  • 我们在训练模型的数据集中的图像与预测图像具有不同的数据分布。

然而,尽管检测到了重复的掩模,但在这些区域中具有较低的类别得分(从第三个掩模开始)表明可能存在重复的预测。

到目前为止,我们已经学习了如何分割多个Person类的实例。在下一节中,我们将了解在本节构建的代码中需要调整的内容,以便在图像中分割多个类别的多个实例。

预测多个类别的多个实例

在前一节中,我们学习了如何分割 Person 类。在本节中,我们将学习如何同时为人物和桌子实例进行分割,使用的是我们在前一节构建的同一模型。让我们开始吧:

鉴于大部分代码与前一节保持一致,我们只会解释本节中的附加代码。在执行代码时,我们建议您查看 GitHub 上的 Chapter09 文件夹中的 predicting_multiple_instances_of_multiple_classes.ipynb 笔记本,网址为 bit.ly/mcvp-2e

  1. 获取包含感兴趣类别(Person(类别 ID 4)和 Table(类别 ID 6))的图像:

    classes_list = [4,6]
    annots = []
    for ann in Tqdm(all_annots):
        _ann = read(ann, 1).transpose(2,0,1)
        r,g,b = _ann
        if np.array([num in np.unique(r) for num in \
                    classes_list]).sum()==0: continue
        annots.append(ann)
    from sklearn.model_selection import train_test_split
    _annots = stems(annots)
    trn_items, val_items = train_test_split(_annots, random_state=2) 
    

在上述代码中,我们获取包含至少一个感兴趣类别(classes_list)的图像。

  1. 修改 get_mask 方法,使其返回每个掩码对应的类别的掩码和类别在 MasksDataset 类中的定义:

     def get_mask(self,path):
            an = read(path, 1).transpose(2,0,1)
            r,g,b = an
            cls = list(set(np.unique(r)).intersection({4,6}))
            masks = []
            labels = []
            for _cls in cls:
                nzs = np.nonzero(r==_cls)
                instances = np.unique(g[nzs])
                for ix,_id in enumerate(instances):
                    masks.append(g==_id)
                    labels.append(classes_list.index(_cls)+1)
            return np.array(masks), np.array(labels) 
    

在上述代码中,我们获取存在于图像中的感兴趣类别(cls),并将它们存储在 labels 中。接下来,我们遍历每个识别的类别(cls),并将红色通道值对应于类别(cls)的位置存储在 nzs 中。然后,我们获取这些位置上的实例 ID(instances)。此外,在返回 maskslabels 的 NumPy 数组之前,我们将 instances 追加到 masks 中,并将对应于实例的类别追加到 labels 中。

  1. __getitem__ 方法中修改 labels 对象,使其包含从 get_mask 方法获取的标签,而不是用 torch.ones 填充。下面代码的粗体部分是在前一节的 __getitem__ 方法中实现这些更改的地方:

     def __getitem__(self, ix):
            _id = self.items[ix]
            img_path = f'images/training/{_id}.jpg'
            mask_path = \ f'annotations_instance/training/{_id}.png'
            **masks, labels = self.get_mask(mask_path)**
            #print(labels)
            obj_ids = np.arange(1, len(masks)+1)
            img = Image.open(img_path).convert("RGB")
            num_objs = len(obj_ids)
            boxes = []
            for i in range(num_objs):
                obj_pixels = np.where(masks[i])
                xmin = np.min(obj_pixels[1])
                xmax = np.max(obj_pixels[1])
                ymin = np.min(obj_pixels[0])
                ymax = np.max(obj_pixels[0])
                if (((xmax-xmin)<=10) | (ymax-ymin)<=10):
                    xmax = xmin+10
                    ymax = ymin+10
                boxes.append([xmin, ymin, xmax, ymax])
            boxes = torch.as_tensor(boxes, dtype=torch.float32)
            **labels = torch.as_tensor(labels, dtype=torch.int64)**
            masks = torch.as_tensor(masks, dtype=torch.uint8)
            area = (boxes[:, 3] - boxes[:, 1]) * (boxes[:, 2] - boxes[:, 0])
            iscrowd = torch.zeros((num_objs,), dtype=torch.int64)
            image_id = torch.tensor([ix])
            target = {}
            target["boxes"] = boxes
            target["labels"] = labels
            target["masks"] = masks
            target["image_id"] = image_id
            target["area"] = area
            target["iscrowd"] = iscrowd
            if self.transforms is not None:
                img, target = self.transforms(img, target)
             if (img.dtype == torch.float32) or (img.dtype == torch.uint8) :
                 img = img/255.
            return img, target
        def __len__(self):
            return self.N
        def choose(self):
            return self[randint(len(self))] 
    
  2. 指定在定义 model 时有三个类别而不是两个,因为现在我们需要预测人物、桌子和背景类别:

    num_classes = 3
    model=get_model_instance_segmentation(num_classes).to(device) 
    

在训练模型之后,与前一节相比,随着迭代次数增加,训练损失的变化如下:

图,折线图 说明自动生成

图 9.26:随着迭代次数增加的训练损失

此外,对于包含人物和桌子的样本图像,预测的段如下所示:

图 说明自动生成

图 9.27:(左)输入图像;(其余)预测的掩码和相应的类

从上述图中,我们可以看到使用同一模型能够预测两个类别。作为练习,我们鼓励您增加类别数量和迭代次数,并查看您的结果。

摘要

在本章中,我们学习了如何利用 U-Net 和 Mask R-CNN 对图像进行分割。我们了解到 U-Net 架构如何通过卷积进行图像的降采样和升采样,以保留图像的结构,同时能够预测图像中物体周围的掩码。接着,我们通过道路场景检测练习巩固了这一理解,将图像分割成多个类别。然后,我们了解了 RoI Align,它有助于解决 RoI 池化在图像量化方面的问题。随后,我们学习了 Mask R-CNN 的工作原理,以便训练模型来预测图像中的人物实例,以及图像中的人物和表格实例。

现在我们对各种目标检测技术和图像分割技术有了很好的理解,在下一章中,我们将学习如何利用我们迄今所学的技术来扩展我们将预测的类别数量的应用。此外,我们还将学习 Detectron2 框架,该框架在构建 Faster R-CNN 和 Mask R-CNN 模型时减少了代码复杂性。

问题

  1. 在 U-Net 架构中,升采样如何帮助?

  2. 在 U-Net 中为什么需要一个完全卷积网络?

  3. RoI Align 如何改进 Mask R-CNN 中的 RoI 池化?

  4. U-Net 和 Mask R-CNN 在分割中的主要区别是什么?

  5. 什么是实例分割?

在 Discord 上了解更多信息

加入我们社区的 Discord 空间,与作者和其他读者讨论:

packt.link/modcv

第十章:物体检测与分割的应用

在前几章中,我们学习了各种物体检测技术,如 R-CNN 系列算法、YOLO、SSD 以及 U-Net 和 Mask R-CNN 图像分割算法。在本章中,我们将进一步学习更适合解决检测和分割问题的框架/架构,处理更现实的场景。

我们将从利用 Detectron2 框架开始,训练和检测图像中存在的自定义对象。我们还将使用预训练模型预测图像中人类的姿势。此外,我们将学习如何统计图像中人群的人数,然后学习如何利用分割技术进行图像着色。接下来,我们将学习一个修改版的 YOLO,通过使用来自 LIDAR 传感器的点云来预测物体周围的 3D 边界框。最后,我们将学习如何从视频中识别动作。

在本章结束时,您将学习以下内容:

  • 多对象实例分割

  • 人体姿势检测

  • 人群计数

  • 图像着色

  • 使用点云进行 3D 物体检测

  • 从视频中识别动作

    本章所有代码片段均可在 GitHub 存储库的Chapter10文件夹中找到,链接为bit.ly/mcvp-2e

    随着领域的发展,我们将定期向 GitHub 存储库添加有价值的补充内容。请查看每个章节目录下的supplementary_sections文件夹获取新的有用内容。

多对象实例分割

在前几章中,我们学习了各种物体检测算法。在本节中,我们将学习 Detectron2 平台(链接:ai.facebook.com/blog/-detectron2-a-pytorch-based-modular-object-detection-library-/),然后实现使用 Google 开放图像数据集进行多对象实例分割。Detectron2 是由 Facebook 团队开发的平台。Detectron2 包含了最先进的物体检测算法的高质量实现,包括 Mask R-CNN 模型系列的 DensePose。原始 Detectron 框架使用 Caffe2 编写,而 Detectron2 框架则使用 PyTorch 编写。

Detectron2 支持与物体检测相关的一系列任务。与原始 Detectron 类似,它支持具有框和实例分割掩码的物体检测,以及人体姿势预测。除此之外,Detectron2 还增加了对语义分割和全景分割(将语义分割和实例分割结合在一起的任务)的支持。通过利用 Detectron2,我们能够在几行代码中构建物体检测、分割和姿势估计。

在本节中,我们将:

  • 获取开放图像数据集

  • 将数据集转换为 COCO 格式

  • 使用 Detectron2 训练模型

  • 在训练模型上推断新图像

让我们逐一进行这些步骤。

获取和准备数据

我们将处理由 Google 提供的 Open Images 数据集中可用的图像(其中包含数百万张图像及其注释),网址为storage.googleapis.com/openimages/web/index.html

在此代码部分,我们将了解仅获取所需图像而不是整个数据集的方法。请注意,此步骤是必需的,因为数据集大小限制了可能没有广泛资源的典型用户构建模型的能力。

您可以在 GitHub 的Chapter10文件夹中找到名为Multi_object_segmentation.ipynb的文件,网址为bit.ly/mcvp-2e

  1. 安装所需的包:

    !pip install -qU openimages torch_snippets 
    
  2. 下载所需的注释文件:

    from torch_snippets import *
    !wget -O train-annotations-object-segmentation.csv -q https://storage.googleapis.com/openimages/v5/train-annotations-object-segmentation.csv
    !wget -O classes.csv -q \
    https://raw.githubusercontent.com/openimages/dataset/master/dict.csv 
    
  3. 指定我们希望模型预测的类别(您可以访问 Open Images 网站查看所有类别的列表):

    required_classes = 'person,dog,bird,car,elephant,football,\
    jug,laptop,mushroom,pizza,rocket,shirt,traffic sign,\
    Watermelon,Zebra'
    required_classes = [c.lower() for c in \
                            required_classes.lower().split(',')]
    classes = pd.read_csv('classes.csv', header=None)
    classes.columns = ['class','class_name']
    classes = classes[classes['class_name'].map(lambda x: x \
                               in required_classes)] 
    
  4. 获取与required_classes对应的图像 ID 和掩码:

    from torch_snippets import *
    df = pd.read_csv('train-annotations-object-segmentation.csv')
    data = pd.merge(df, classes, left_on='LabelName', 
                    right_on='class')
    subset_data = data.groupby('class_name').agg( \
                            {'ImageID': lambda x: list(x)[:500]})
    subset_data = flatten(subset_data.ImageID.tolist())
    subset_data = data[data['ImageID'].map(lambda x: x in subset_data)]
    subset_masks = subset_data['MaskPath'].tolist() 
    

鉴于数据量巨大,我们只获取subset_data中每类 500 张图像。您可以选择是否获取更小或更大的文件集合以及唯一类别的列表(required_classes)。

到目前为止,我们只有与图像对应的ImageIdMaskPath值。在接下来的步骤中,我们将继续从open-images下载实际的图像和掩码。

  1. 现在我们已经准备好下载子集掩码数据,让我们开始下载。Open Images 有 16 个用于训练掩码的 ZIP 文件。每个 ZIP 文件仅包含来自subset_masks的少量掩码,因此我们将在将所需掩码移动到单独文件夹后删除其余部分。这一下载 -> 移动 -> 删除操作将保持内存占用相对较小。我们将对这 16 个文件每一个运行此步骤一次:

    !mkdir -p masks
    for c in Tqdm('0123456789abcdef'):
        !wget -q \
         https://storage.googleapis.com/openimages/v5/train-masks/train-masks-{c}.zip
        !unzip -q train-masks-{c}.zip -d tmp_masks
        !rm train-masks-{c}.zip
        tmp_masks = Glob('tmp_masks', silent=True)
        items = [(m,fname(m)) for m in tmp_masks]
        items = [(i,j) for (i,j) in items if j in subset_masks]
        for i,j in items:
            os.rename(i, f'masks/{j}')
        !rm -rf tmp_masks 
    
  2. 下载与ImageId对应的图像:

    masks = Glob('masks')
    masks = [fname(mask) for mask in masks]
    subset_data=subset_data[subset_data['MaskPath'].map(lambda \
                                   x: x in masks)]
    subset_imageIds = subset_data['ImageID'].tolist()
    from openimages.download import _download_images_by_id
    !mkdir images
    _download_images_by_id(subset_imageIds, 'train', './images/') 
    
  3. 将所有图像、掩码和地面真相打包并保存它们——以防会话崩溃时,保存和检索文件以便后续训练是有帮助的。创建 ZIP 文件后,请确保将文件保存在您的驱动器上或下载它。文件大小最终约为 2.5 GB:

    import zipfile
    files = Glob('images') + Glob('masks') + \
    ['train-annotations-object-segmentation.csv', 'classes.csv']
    with zipfile.ZipFile('data.zip','w') as zipme:
        for file in Tqdm(files):
            zipme.write(file,compress_type=zipfile.ZIP_DEFLATED) 
    

最后,将数据移动到单个目录中:

  1. !mkdir -p train/
    !mv images train/myData2020
    !mv masks train/annotations 
    

鉴于目标检测代码中有如此多的移动组件,作为标准化方法,Detectron 接受严格的训练数据格式。虽然可以编写数据集定义并将其提供给 Detectron,但将整个训练数据保存为 COCO 格式更简单(也更有利)。这样,您可以利用其他训练算法,例如detectron transformersDETR),而无需更改数据。

  1. 首先,我们将定义类别:

    1. 以 COCO 格式定义所需的类别:
    !pip install git+git://github.com/sizhky/pycococreator.git@0.2.0
    import datetime
    INFO = {
        "description": "MyData2020",
        "url": "None",
        "version": "1.0",
        "year": 2020,
        "contributor": "sizhky",
        "date_created": datetime.datetime.utcnow().isoformat(' ')
    }
    LICENSES = [
        {
            "id": 1,
            "name": "MIT"
        }
    ]
    CATEGORIES = [{'id': id+1, 'name': name.replace('/',''), \
                   'supercategory': 'none'} \
                  for id,(_,(name, clss_name)) in \
                  enumerate(classes.iterrows())] 
    
    1. 在上述代码中,在 CATEGORIES 的定义中,我们正在创建一个名为 supercategory 的新键。要理解 supercategory,让我们通过一个例子来看:ManWoman 类别属于 Person 超类。在我们的情况下,由于我们不关心超类,我们将其指定为 none

    2. 导入相关包并创建一个带有保存 COCO JSON 文件所需键的空字典:

    !pip install pycocotools
    from pycococreatortools import pycococreatortools
    from os import listdir
    from os.path import isfile, join
    from PIL import Image
    coco_output = {
        "info": INFO,
        "licenses": LICENSES,
        "categories": CATEGORIES,
        "images": [],
        "annotations": []
    } 
    
    1. 设置几个包含图像位置信息和注释文件位置信息的变量:
    ROOT_DIR = "train"
    IMAGE_DIR, ANNOTATION_DIR = 'train/myData2020/', 'train/annotations/'
    image_files = [f for f in listdir(IMAGE_DIR) if \
                   isfile(join(IMAGE_DIR, f))]
    annotation_files = [f for f in listdir(ANNOTATION_DIR) if \
                        isfile(join(ANNOTATION_DIR, f))] 
    
    1. 循环遍历每个图像文件名并填充 coco_output 字典中的 images 键:
    image_id = 1
    # go through each image
    for image_filename in Tqdm(image_files):
        image = Image.open(IMAGE_DIR + '/' + image_filename)
        image_info = pycococreatortools.create_image_info(image_id, \
                    os.path.basename(image_filename), image.size)
        coco_output["images"].append(image_info)
        image_id = image_id + 1 
    
  2. 循环遍历每个分割注释并填充 coco_output 字典中的 annotations 键:

    segmentation_id = 1
    for annotation_filename in Tqdm(annotation_files):
        image_id = [f for f in coco_output['images'] if \
                    stem(f['file_name']) == \
                    annotation_filename.split('_')[0]][0]['id']
        class_id = [x['id'] for x in CATEGORIES \
                    if x['name'] in annotation_filename][0]
        category_info = {'id': class_id, \
                        'is_crowd': 'crowd' in image_filename}
        binary_mask = np.asarray(Image.open(f'{ANNOTATION_DIR}/\
    {annotation_filename}').convert('1')).astype(np.uint8)
    
        annotation_info = pycococreatortools\
                        .create_annotation_info( \
                        segmentation_id, image_id, 
                        category_info, 
                        binary_mask, image.size, tolerance=2)
        if annotation_info is not None:
            coco_output["annotations"].append(annotation_info)
            segmentation_id = segmentation_id + 1 
    
  3. coco_output 保存在 JSON 文件中:

    coco_output['categories'] = [{'id': id+1,'name':clss_name, \
                                  'supercategory': 'none'} for \
                                 id,(_,(name, clss_name)) in \
                                 enumerate(classes.iterrows())]
    import json
    with open('images.json', 'w') as output_json_file:
        json.dump(coco_output, output_json_file) 
    

现在,我们的文件已经按照 COCO 格式准备好了。现在,我们可以使用 Detectron2 框架来训练我们的模型。

训练模型进行实例分割

要训练我们的模型,我们需要下载所需的包,修改配置文件以反映数据集路径,然后训练模型。

让我们逐步进行:

  1. 安装所需的 Detectron2 包:

    %cd /content/
    # install detectron2:
    !git clone https://github.com/facebookresearch/detectron2
    %cd /content/detectron2
    %pip install -r requirements.txt
    !python setup.py install
    %pip install git+https://github.com/facebookresearch/fvcore.git
    %cd /content/ 
    

注意:在进行下一步之前,您需要重新启动 Colab。

  1. 导入相关的 detectron2 包:

    from detectron2 import model_zoo
    from detectron2.engine import DefaultPredictor
    from detectron2.config import get_cfg
    from detectron2.utils.visualizer import Visualizer
    from detectron2.data import MetadataCatalog, DatasetCatalog
    from detectron2.engine import DefaultTrainer 
    
  2. 鉴于我们已重新启动 Colab,请重新获取所需的类:

    from torch_snippets import *
    required_classes= 'person,dog,bird,car,elephant,football,jug,\
    laptop,Mushroom,Pizza,Rocket,Shirt,Traffic sign,\
    Watermelon,Zebra'
    required_classes = [c.lower() for c in \
                        required_classes.lower().split(',')]
    classes = pd.read_csv('classes.csv', header=None)
    classes.columns = ['class','class_name']
    classes = classes[classes['class_name'].map(lambda \
                                    x: x in required_classes)] 
    
  3. 使用 register_coco_instances 注册创建的数据集:

    from detectron2.data.datasets import register_coco_instances
    register_coco_instances("dataset_train", {}, \
                            "images.json", "train/myData2020") 
    
  4. cfg 配置文件中定义所有参数。配置 (cfg) 是一个特殊的 Detectron 对象,保存了训练模型所需的所有相关信息:

    cfg = get_cfg()
    cfg.merge_from_file(model_zoo.get_config_file("COCO-\ InstanceSegmentation/mask_rcnn_R_50_FPN_3x.yaml"))
    cfg.DATASETS.TRAIN = ("dataset_train",)
    cfg.DATASETS.TEST = ()
    cfg.DATALOADER.NUM_WORKERS = 2
    cfg.MODEL.WEIGHTS = model_zoo.get_checkpoint_url("COCO-\ InstanceSegmentation/mask_rcnn_R_50_FPN_3x.yaml") # pretrained 
    # weights
    cfg.SOLVER.IMS_PER_BATCH = 2
    cfg.SOLVER.BASE_LR = 0.00025 # pick a good LR
    cfg.SOLVER.MAX_ITER = 5000 # instead of epochs, we train on 
    # 5000 batches
    cfg.MODEL.ROI_HEADS.BATCH_SIZE_PER_IMAGE = 512
    cfg.MODEL.ROI_HEADS.NUM_CLASSES = len(classes) 
    

正如您在前面的代码中所看到的,您可以设置所有用于训练模型所需的主要超参数。merge_from_file 从用于使用 FPN 作为骨干预训练 mask_rcnn 的预先存在的配置文件中导入所有核心参数。这还将包含有关预训练实验的其他信息,例如优化器和损失函数。在我们的目的中,已在 cfg 中设置的超参数是不言自明的。

  1. 训练模型:

    os.makedirs(cfg.OUTPUT_DIR, exist_ok=True)
    trainer = DefaultTrainer(cfg) 
    trainer.resume_or_load(resume=False)
    trainer.train() 
    

使用上述代码行,我们可以训练一个模型来预测属于我们自定义数据集中定义类的对象的类、边界框和分割。

  1. 将模型保存在一个文件夹中:

    !cp output/model_final.pth output/trained_model.pth 
    

到目前为止,我们已经训练好了我们的模型。在下一节中,我们将对新图像进行推断,以便使用我们预训练的模型识别给定图像中的对象。

对新图像进行推断

要对新图像执行推断,加载路径,设置概率阈值,并通过 DefaultPredictor 方法传递,如下所示:

  1. 使用训练好的模型权重加载权重。使用相同的 cfg 并按照以下代码加载模型权重:

    cfg.MODEL.WEIGHTS = os.path.join(cfg.OUTPUT_DIR, "trained_model.pth") 
    
  2. 设置对象属于某一类的概率阈值:

    cfg.MODEL.ROI_HEADS.SCORE_THRESH_TEST = 0.25 
    
  3. 定义 predictor 方法:

    predictor = DefaultPredictor(cfg) 
    
  4. 在感兴趣的图像上执行分割并进行可视化。在以下代码中,我们随机绘制了 30 张训练图像(注意我们尚未创建验证数据;这是你的练习),但你也可以加载自己的图像路径来代替 choose(files)

    from detectron2.utils.visualizer import ColorMode
    files = Glob('train/myData2020')
    for _ in range(30):
        im = cv2.imread(choose(files))
        outputs = predictor(im)
        v = Visualizer(im[:, :, ::-1], scale=0.5, 
                        metadata=MetadataCatalog.get("dataset_train"), 
                        instance_mode=ColorMode.IMAGE_BW 
    # remove the colors of unsegmented pixels. 
    # This option is only available for segmentation models
        )
        out = v.draw_instance_predictions(outputs["instances"].to("cpu"))
        show(out.get_image()) 
    

Visualizer 是 Detectron2 绘制对象实例的方式。鉴于预测(存在于 outputs 变量中)仅是张量字典,Visualizer 将其转换为像素信息并将其绘制在图像上。让我们看看每个输入的含义:

  1. im:我们要可视化的图像。

  2. scale:绘制时图像的大小。在这里,我们要求将图像缩小到 50%。

  3. metadata:我们需要数据集的类级信息,主要是索引到类的映射,以便当我们将原始张量作为输入发送时,类将解码为实际可读的类别。

  4. instance_mode:我们要求模型仅突出显示分段像素。

  5. 最后,一旦类被创建(在我们的例子中是 v),我们可以要求它从模型中获取实例预测并显示图像。

前面的代码给出了以下输出。请注意,我们能够准确识别与大象相对应的像素:

包含图解的图片 自动生成的描述

图 10.1:实例分割预测

现在我们已经了解了如何利用 Detectron2 来识别图像中类别对应的像素,接下来我们将学习如何利用 Detectron2 执行图像中人体姿势的检测。

人体姿势检测

在前面的部分中,我们了解了检测多个对象并对它们进行分割。现在我们将学习在图像中检测多个人物,以及使用 Detectron2 检测图像中各个人体部位的关键点。检测关键点在多种用例中非常有用,例如体育分析和安全领域。对于此练习,我们将利用配置文件中提供的预训练关键点模型:

可在 GitHub 上的 Chapter10 文件夹中的 Human_pose_detection.ipynb 文件中找到以下代码,网址为 bit.ly/mcvp-2e

  1. 安装所有前面部分中显示的要求。

    %cd /content/
    # install detectron2:
    !git clone https://github.com/facebookresearch/detectron2
    %cd /content/detectron2
    %pip install -r requirements.txt
    !python setup.py install
    %pip install git+https://github.com/facebookresearch/fvcore.git
    %cd /content/
    %pip install torch_snippets
    %pip install pyyaml==5.1 pycocotools>=2.0.1
    from torch_snippets import *
    import detectron2
    from detectron2.utils.logger import setup_logger
    setup_logger()
    from detectron2 import model_zoo
    from detectron2.engine import DefaultPredictor
    from detectron2.config import get_cfg
    from detectron2.utils.visualizer import Visualizer
    from detectron2.data import MetadataCatalog, DatasetCatalog 
    
  2. 获取配置文件并加载在 Detectron2 中存在的预训练关键点检测模型:

    cfg = get_cfg() # get a fresh new config
    cfg.merge_from_file(model_zoo.get_config_file("COCO-Keypoints/keypoint_rcnn_R_50_FPN_3x.yaml")) 
    
  3. 指定配置参数:

    cfg.MODEL.ROI_HEADS.SCORE_THRESH_TEST = 0.5 # set threshold 
    # for this model
    cfg.MODEL.WEIGHTS = model_zoo.get_checkpoint_url("COCO-Keypoints/keypoint_rcnn_R_50_FPN_3x.yaml")
    predictor = DefaultPredictor(cfg) 
    
  4. 加载我们想要预测的图像:

    from torch_snippets import read, resize
    !wget -q https://i.imgur.com/ldzGSHk.jpg -O image.png
    im = read('image.png',1)
    im = resize(im, 0.5) # resize image to half its dimensions 
    
  5. 对图像进行预测并绘制关键点:

    outputs = predictor(im)
    v = Visualizer(im[:,:,::-1], 
                   MetadataCatalog.get(cfg.DATASETS.TRAIN[0]), 
                   scale=1.2)
    out = v.draw_instance_predictions(outputs["instances"].to("cpu"))
    import matplotlib.pyplot as plt
    %matplotlib inline
    plt.imshow(out.get_image()) 
    

前面的代码给出了以下输出。我们可以看到模型能够准确识别图像中人物的各个身体姿势关键点:

一群人坐在公共汽车上 自动以低置信度生成的描述

图 10.2:(左)输入图像(右)预测的关键点叠加在原始图像上

在本节中,我们学习了如何使用 Detectron2 平台执行关键点检测。在下一节中,我们将学习如何从头开始实现修改后的 VGG 架构来估计图像中的人数。

人群计数

想象一种场景,你被给予一张人群的图片,并被要求估计图片中的人数。在这种情况下,人群计数模型非常有用。在我们继续构建用于执行人群计数的模型之前,让我们先了解一下可用的数据和模型架构。

为了训练一个能够预测图像中人数的模型,我们首先需要加载图像。图像应包含图像中所有人的头部中心位置。以下是输入图像及其各自人员头部中心位置的样本:

一群人奔跑 描述自动生成,置信度中等

图 10.3:(左)原始图像(右)图像中人头中心的绘图

来源:上海科技数据集,根据 BSD 2-Clause “简化”许可证许可 (https://github.com/desenzhou/ShanghaiTechDataset)

在前面的例子中,代表真实数据的图像(右边 - 图像中存在的人头中心)非常稀疏。确切地说,有 N 个白色像素,其中 N 是图像中的人数。让我们放大到图像的左上角,并再次看看同样的地图:

一群人手持枪 描述自动生成,置信度低

图 10.4:图 10.3 的放大版本

在下一步中,我们将将地面实况稀疏图像转换为表示该图像区域中人数的密度图:

图 10.5:(左)图像中头部中心的绘图(右)密度图

同一裁剪的最终输入输出对将如下所示:

一群人 描述自动生成,置信度低

图 10.6:(左)放大后的输入图像(右)输出图像

整个图像的最终输入输出对将如下所示:

一群人赛跑 描述自动生成,置信度低

图 10.7:(左)原始输入图像(右)密度图

需要注意的是,在上述图像中,当两个人靠近时,像素强度较高。然而,当一个人远离其他人时,与该人对应的像素密度更均匀分布,导致对该人的像素强度较低。本质上,热图是以这样一种方式生成的,即像素值的总和等于图像中出现的人数。

现在,我们已经准备好接受输入图像以及图像中人员头部中心位置的位置(这些位置已经被处理以获取地面真实输出热图),我们将利用文章CSRNet: Dilated Convolutional Neural Networks for Understanding the Highly Congested Scenesarxiv.org/pdf/1802.10062.pdf)中详细描述的架构来预测图像中出现的人数。模型架构如下:

表格描述 自动生成

图 10.8:CSRNet 架构

注意在模型架构的上述结构中,我们首先将图像通过标准的 VGG-16 骨干网络传递,然后通过四个额外的卷积层。这个输出通过四种配置之一最终通过一个 1 x 1 x 1 卷积层。我们将使用A配置,因为它是最小的。

接下来,我们对输出图像执行均方误差MSE)损失最小化,以获得最佳的权重值,同时使用 MAE 跟踪实际的人群计数。架构的一个额外细节是作者使用了扩张卷积而不是普通卷积。典型的扩张卷积如下所示:

包含表格描述的图片 自动生成

图 10.9:扩张卷积核示例

来源:arXiv:1802.10062 [cs.CV]

左侧的前图表示到目前为止我们一直在工作的典型核心。中间和右侧的图表示的是扩张卷积核,这些核心之间存在像素间隙。这样,卷积核具有更大的感受野,这在我们需要理解靠近给定人员的人数以估算与人员对应的像素密度时非常方便。我们使用扩张卷积核(具有九个参数),而不是正常卷积核(要达到与三个卷积核的膨胀率等效的 49 个参数),以使用更少的参数捕获更多信息。

现在我们已经理解了如何设计模型的架构,让我们继续编写模型来进行人群计数。

对于那些希望了解工作详细信息的人,我们建议阅读此处的论文:arxiv.org/pdf/1802.10062.pdf。我们将在接下来的部分中训练的模型受到此论文的启发。

实施人群计数

我们将采用的策略执行人群计数如下:

  1. 导入相关的包和数据集。

  2. 我们将要处理的数据集 – 上海科技数据集 – 已经将人脸中心转换为基于高斯滤波器密度的分布,因此我们无需再次执行。使用网络映射输入图像和输出高斯密度图。

  3. 定义执行扩张卷积的函数。

  4. 定义网络模型并在数据批次上进行训练以最小化 MSE。

让我们继续按照以下策略进行编码:

下面的代码可以在 GitHub 上的 Chapter10 文件夹中的 crowd_counting.ipynb 文件中找到,链接为 https://bit.ly/mcvp-2e

  1. 导入包并下载数据集:

    %%writefile kaggle.json
    {"username":"xx","key":"xx"}
    !mkdir -p ~/.kaggle
    !mv kaggle.json ~/.kaggle/
    !ls ~/.kaggle
    !chmod 600 /root/.kaggle/kaggle.json
    %%time
    %cd /content
    import os
    if not os.path.exists('shanghaitech-with-people-density-map'):
        print('downloading data...')
        !kaggle datasets download -d tthien/shanghaitech-with-people-density-map/
        print('unzipping data...')
        !unzip -qq shanghaitech-with-people-density-map.zip
    if not os.path.exists('CSRNet-pytorch/'):
        %pip install -U scipy torch_snippets torch_summary
        !git clone https://github.com/sizhky/CSRNet-pytorch.git
    %cd CSRNet-pytorch
    !ln -s ../shanghaitech_with_people_density_map
    from torch_snippets import *
    import h5py
    from scipy import io 
    
  2. 提供图像的位置(image_folder)、地面实况(gt_folder)和热图文件夹(heatmap_folder):

    part_A = Glob('shanghaitech_with_people_density_map/\
    ShanghaiTech/part_A/train_data/');
    image_folder = 'shanghaitech_with_people_density_map/\
    ShanghaiTech/part_A/train_data/images/'
    heatmap_folder = 'shanghaitech_with_people_density_map/\
    ShanghaiTech/part_A/train_data/ground-truth-h5/'
    gt_folder = 'shanghaitech_with_people_density_map/\
    ShanghaiTech/part_A/train_data/ground-truth/' 
    
  3. 定义训练和验证数据集以及数据加载器:

    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    tfm = T.Compose([
        T.ToTensor()
    ])
    class Crowds(Dataset):
        def __init__(self, stems):
            self.stems = stems
        def __len__(self):
            return len(self.stems)
        def __getitem__(self, ix):
            _stem = self.stems[ix]
            image_path = f'{image_folder}/{_stem}.jpg'
            heatmap_path = f'{heatmap_folder}/{_stem}.h5'
            gt_path = f'{gt_folder}/GT_{_stem}.mat'
            pts = io.loadmat(gt_path)
            pts = len(pts['image_info'][0,0][0,0][0])
            image = read(image_path, 1)
            with h5py.File(heatmap_path, 'r') as hf:
                gt = hf['density'][:]
            **gt = resize(gt,** **1****/****8****)*****64**
            return image.copy(), gt.copy(), pts
        def collate_fn(self, batch):
            ims, gts, pts = list(zip(*batch))
            ims = torch.cat([tfm(im)[None] for im in ims]).to(device)
            gts = torch.cat([tfm(gt)[None] for gt in gts]).to(device)
            return ims, gts, torch.tensor(pts).to(device)
        def choose(self):
            return self[randint(len(self))]
    from sklearn.model_selection import train_test_split
    trn_stems, val_stems = train_test_split(stems(Glob(image_folder)),
                                                     random_state=10)
    trn_ds = Crowds(trn_stems)
    val_ds = Crowds(val_stems)
    trn_dl = DataLoader(trn_ds, batch_size=1, shuffle=True, 
                        collate_fn=trn_ds.collate_fn)
    val_dl = DataLoader(val_ds, batch_size=1, shuffle=True, 
                        collate_fn=val_ds.collate_fn) 
    

注意,到目前为止,我们已编写的典型数据集 class 的唯一补充是前述代码中粗体的代码行。我们正在调整地面实况,因为我们网络的输出将缩小到原始大小的 1/8,并且我们将地图乘以 64,以便图像像素的总和将缩放回原始人群计数。

  1. 通过实现以下步骤来定义网络架构:

    1. 定义使扩张卷积(make_layers)成为可能的函数:
    import torch.nn as nn
    import torch
    from torchvision import models
    from utils import save_net,load_net
    def make_layers(cfg, in_channels = 3, batch_norm=False, dilation = False):
        if dilation:
            d_rate = 2
        else:
            d_rate = 1
        layers = []
        for v in cfg:
            if v == 'M': # M is for maxpooling
                layers += [nn.MaxPool2d(kernel_size=2,stride=2)]
            else:
                conv2d = nn.Conv2d(in_channels,v,kernel_size=3,
                                   padding=d_rate, dilation=d_rate)
                if batch_norm:
                    layers += [conv2d, nn.BatchNorm2d(v), 
                               nn.ReLU(inplace=True)]
                else:
                    layers += [conv2d, nn.ReLU(inplace=True)]
                in_channels = v
        return nn.Sequential(*layers) 
    
    1. 定义网络架构 – CSRNet
    class CSRNet(nn.Module):
        def __init__(self, load_weights=False):
            super(CSRNet, self).__init__()
            self.seen = 0
            self.frontend_feat = [64, 64, 'M', 128, 128, 
                                  'M',256, 256, 256, 'M', 512, 512, 512]
            self.backend_feat = [512, 512, 512, 256, 128, 64]
            self.frontend = make_layers(self.frontend_feat)
            self.backend = make_layers(self.backend_feat, in_channels = 512,
                                       dilation = True)
            self.output_layer = nn.Conv2d(64, 1, kernel_size=1)
            if not load_weights:
                mod = models.vgg16(pretrained = True)
                self._initialize_weights()
                items = list(self.frontend.state_dict().items())
                _items = list(mod.state_dict().items())
                for i in range(len(self.frontend.state_dict().items())):
                    items[i][1].data[:] = _items[i][1].data[:]
        def forward(self,x):
            x = self.frontend(x)
            x = self.backend(x)
            x = self.output_layer(x)
            return x
        def _initialize_weights(self):
            for m in self.modules():
                if isinstance(m, nn.Conv2d):
                    nn.init.normal_(m.weight, std=0.01)
                    if m.bias is not None:
                        nn.init.constant_(m.bias, 0)
                elif isinstance(m, nn.BatchNorm2d):
                    nn.init.constant_(m.weight, 1)
                    nn.init.constant_(m.bias, 0) 
    
  2. 定义用于训练和验证数据批次的函数:

    def train_batch(model, data, optimizer, criterion):
        model.train()
        optimizer.zero_grad()
        ims, gts, pts = data
        _gts = model(ims)
        loss = criterion(_gts, gts)
        loss.backward()
        optimizer.step()
        pts_loss = nn.L1Loss()(_gts.sum(), gts.sum())
        return loss.item(), pts_loss.item()
    @torch.no_grad()
    def validate_batch(model, data, criterion):
        model.eval()
        ims, gts, pts = data
        _gts = model(ims)
        loss = criterion(_gts, gts)
        pts_loss = nn.L1Loss()(_gts.sum(), gts.sum())
        return loss.item(), pts_loss.item() 
    
  3. 在增加周期的训练模型:

    model = CSRNet().to(device)
    criterion = nn.MSELoss()
    optimizer = optim.Adam(model.parameters(), lr=1e-6)
    n_epochs = 20
    log = Report(n_epochs)
    for ex in range(n_epochs):
        N = len(trn_dl)
        for bx, data in enumerate(trn_dl):
            loss,pts_loss=train_batch(model, data, optimizer, criterion)
            log.record(ex+(bx+1)/N, trn_loss=loss, 
                               trn_pts_loss=pts_loss, end='\r')
        N = len(val_dl)
        for bx, data in enumerate(val_dl):
            loss, pts_loss = validate_batch(model, data, criterion)
            log.record(ex+(bx+1)/N, val_loss=loss, 
                        val_pts_loss=pts_loss, end='\r')
        log.report_avgs(ex+1)
        if ex == 10: optimizer = optim.Adam(model.parameters(), 
                                            lr=1e-7) 
    

上述代码导致训练和验证损失的变化(这里损失是人群计数的 MAE),如下:

图表,线图 描述自动生成

图 10.10: 随着周期增加的训练和验证损失

从上述图表可以看出,我们的预测与实际相差约 150 人。我们可以通过以下两种方式改进模型:

  1. 通过使用数据增强并在原始图像的裁剪上进行训练

  2. 通过使用更大的网络(我们使用了配置 A,而 BCD 更大)

  3. 通过获取测试图像并对其进行归一化来对新图像进行推断:

    from matplotlib import cm as c
    from torchvision import datasets, transforms
    from PIL import Image
    transform=transforms.Compose([
                     transforms.ToTensor(),transforms.Normalize(\
                              mean=[0.485, 0.456, 0.406],
                              std=[0.229, 0.224, 0.225]),
                      ])
    test_folder = 'shanghaitech_with_people_density_map/\
    ShanghaiTech/part_A/test_data/'
    imgs = Glob(f'{test_folder}/images')
    f = choose(imgs)
    print(f)
    img = transform(Image.open(f).convert('RGB')).to(device) 
    

然后通过训练模型传递图像:

  1. output = model(img[None])
    print("Predicted Count : ", int(output.detach().cpu().sum().numpy()))
    temp = np.asarray(output.detach().cpu()\
                        .reshape(output.detach().cpu()\
                        .shape[2],output.detach()\
                        .cpu().shape[3]))
    plt.imshow(temp,cmap = c.jet)
    plt.show() 
    

上述代码导致输入图像的热图(右图),我们可以看到模型预测的热图相当准确,并且人数预测值接近实际值:

计算机截图 描述自动生成,置信度低

图 10.11: (左) 输入图像 (右) 预测的密度图和人数计数

在下一节中,我们将继续探讨其他应用,并利用 U-Net 架构对图像进行着色。

图像着色

想象一下这样的场景:您被要求将一堆黑白图像变成彩色图像。您会如何解决这个问题?一种解决方法是使用伪监督流水线,其中我们将原始图像转换为黑白图像,并将其视为输入-输出对。

我们将利用 CIFAR-10 数据集演示如何对图像进行着色。我们在编写图像着色网络的代码时将采取的策略如下:

  1. 从训练数据集中获取原始彩色图像,并将其转换为灰度图像以获取输入(灰度)和输出(原始彩色图像)组合。

  2. 规范化输入和输出。

  3. 构建 U-Net 架构。

  4. 在增加的 epochs 上训练模型。

有了上述策略,让我们继续编写模型如下:

您可以在 GitHub 的Chapter10文件夹中的Image colorization.ipynb文件中找到以下代码,网址为bit.ly/mcvp-2e

  1. 安装所需的包并导入它们:

    !pip install torch_snippets
    from torch_snippets import *
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 下载数据集并定义训练和验证数据集以及数据加载器:

    1. 下载数据集:
    from torchvision import datasets
    import torch
    data_folder = '~/cifar10/cifar/' 
    datasets.CIFAR10(data_folder, download=True) 
    
    1. 定义训练和验证数据集以及数据加载器:
    class Colorize(torchvision.datasets.CIFAR10):
        def __init__(self, root, train):
            super().__init__(root, train)
    
        def __getitem__(self, ix):
            im, _ = super().__getitem__(ix)
            bw = im.convert('L').convert('RGB')
            bw, im = np.array(bw)/255., np.array(im)/255.
            bw, im = [torch.tensor(i).permute(2,0,1)\
                      .to(device).float() for i in [bw,im]]
            return bw, im
    trn_ds = Colorize('~/cifar10/cifar/', train=True)
    val_ds = Colorize('~/cifar10/cifar/', train=False)
    trn_dl = DataLoader(trn_ds, batch_size=256, shuffle=True)
    val_dl = DataLoader(val_ds, batch_size=256, shuffle=False) 
    
    1. 输入和输出图像的示例如下所示:
    a,b = trn_ds[0]
    subplots([a,b], nc=2) 
    

    上述代码产生以下输出:

包含文本、模糊、猫、砖块的图片 自动生成描述

图 10.12: (左) 输入图像 (右) 着色后的图像

注意 CIFAR-10 的图像形状为 32 x 32。

  1. 定义网络架构:

    class Identity(nn.Module):
        def __init__(self):
            super().__init__()
        def forward(self, x):
            return x
    class DownConv(nn.Module):
        def __init__(self, ni, no, maxpool=True):
            super().__init__()
            self.model = nn.Sequential(
                nn.MaxPool2d(2) if maxpool else Identity(),
                nn.Conv2d(ni, no, 3, padding=1),
                nn.BatchNorm2d(no),
                nn.LeakyReLU(0.2, inplace=True),
                nn.Conv2d(no, no, 3, padding=1),
                nn.BatchNorm2d(no),
                nn.LeakyReLU(0.2, inplace=True),
            )
        def forward(self, x):
            return self.model(x)
    class UpConv(nn.Module):
        def __init__(self, ni, no, maxpool=True):
            super().__init__()
            self.convtranspose = nn.ConvTranspose2d(ni, no, 2, stride=2)
            self.convlayers = nn.Sequential(
                nn.Conv2d(no+no, no, 3, padding=1),
                nn.BatchNorm2d(no),
                nn.LeakyReLU(0.2, inplace=True),
                nn.Conv2d(no, no, 3, padding=1),
                nn.BatchNorm2d(no),
                nn.LeakyReLU(0.2, inplace=True),
            )
    
        def forward(self, x, y):
            x = self.convtranspose(x)
            x = torch.cat([x,y], axis=1)
            x = self.convlayers(x)
            return x
    class UNet(nn.Module):
        def __init__(self):
            super().__init__()
            self.d1 = DownConv( 3, 64, maxpool=False)
            self.d2 = DownConv( 64, 128)
            self.d3 = DownConv( 128, 256)
            self.d4 = DownConv( 256, 512)
            self.d5 = DownConv( 512, 1024)
            self.u5 = UpConv (1024, 512)
            self.u4 = UpConv ( 512, 256)
            self.u3 = UpConv ( 256, 128)
            self.u2 = UpConv ( 128, 64)
            self.u1 = nn.Conv2d(64, 3, kernel_size=1, stride=1)
        def forward(self, x):
            x0 = self.d1( x) # 32
            x1 = self.d2(x0) # 16
            x2 = self.d3(x1) # 8
            x3 = self.d4(x2) # 4
            x4 = self.d5(x3) # 2
            X4 = self.u5(x4, x3)# 4
            X3 = self.u4(X4, x2)# 8
            X2 = self.u3(X3, x1)# 16
            X1 = self.u2(X2, x0)# 32
            X0 = self.u1(X1) # 3
            return X0 
    
  2. 定义模型、优化器和损失函数:

    def get_model():
        model = UNet().to(device)
        optimizer = optim.Adam(model.parameters(), lr=1e-3)
        loss_fn = nn.MSELoss()
        return model, optimizer, loss_fn 
    
  3. 定义训练和验证数据集以及数据加载器:

    def train_batch(model, data, optimizer, criterion):
        model.train()
        x, y = data
        _y = model(x)
        optimizer.zero_grad()
        loss = criterion(_y, y)
        loss.backward()
        optimizer.step()
        return loss.item()
    @torch.no_grad()
    def validate_batch(model, data, criterion):
        model.eval()
        x, y = data
        _y = model(x)
        loss = criterion(_y, y)
        return loss.item() 
    
  4. 在增加的 epochs 上训练模型:

    model, optimizer, criterion = get_model()
    exp_lr_scheduler = optim.lr_scheduler.StepLR(optimizer, 
                                        step_size=10, gamma=0.1)
    _val_dl = DataLoader(val_ds, batch_size=1, shuffle=True)
    n_epochs = 100
    log = Report(n_epochs)
    for ex in range(n_epochs):
        N = len(trn_dl)
        for bx, data in enumerate(trn_dl):
            loss = train_batch(model, data, optimizer,criterion)
            log.record(ex+(bx+1)/N, trn_loss=loss, end='\r')
            if (bx+1)%50 == 0:
                for _ in range(5):
                    a,b = next(iter(_val_dl))
                    _b = model(a)
                    subplots([a[0], b[0], _b[0]], nc=3, figsize=(5,5))
        N = len(val_dl)
        for bx, data in enumerate(val_dl):
            loss = validate_batch(model, data, criterion)
            log.record(ex+(bx+1)/N, val_loss=loss, end='\r')
    
        exp_lr_scheduler.step()
        if (ex+1) % 5 == 0: log.report_avgs(ex+1)
        for _ in range(5):
            a,b = next(iter(_val_dl))
            _b = model(a)
            subplots([a[0], b[0], _b[0]], nc=3, figsize=(5,5))
    log.plot_epochs() 
    

上述代码生成了以下输出。我们可以看到模型能够相当好地对灰度图像进行着色:

包含文本 自动生成描述

图 10.13: (左) 输入图像 (中) 原始图像 (右) 预测图像

到目前为止,我们已经了解了如何利用 Detectron2 进行分割和关键点检测,如何在人群计数中使用扩张卷积,以及在图像着色中使用 U-Net。在下一节中,我们将学习如何利用 YOLO 进行 3D 物体检测。

使用点云进行 3D 物体检测

现在我们知道如何使用具有锚框核心概念的算法来预测 2D 图像上的边界矩形。让我们学习如何将相同的概念扩展到预测围绕物体的 3D 边界框。

在自动驾驶汽车中,如行人/障碍物检测和路径规划等任务在不了解环境的情况下无法完成。预测 3D 物体位置及其方向是一项重要任务。不仅 2D 障碍物周围的边界框重要,还需要知道距离、高度、宽度和障碍物的方向,这对安全导航至关重要。

在本节中,我们将学习如何使用 YOLO 来预测实际数据集中汽车和行人的 3D 方向和位置。

关于数据下载、训练和测试集的说明均包含在此 GitHub 仓库中:github.com/sizhky/Complex-YOLOv4-Pytorch/blob/master/README.md#training-instructions。考虑到仅有少数公开可用的 3D 数据集,我们选择了本练习中最常用的数据集,您仍需要注册才能下载。我们还在前述链接中提供了注册说明。

理论

收集实时 3D 数据的众所周知传感器之一是光探测与测距仪LIDAR)。它是安装在旋转装置上的激光器,每秒发射数百次激光束。另一个传感器接收来自周围物体的激光反射,并计算激光在遇到障碍物前走过的距离。在汽车的所有方向上执行此操作将产生反映环境本身的距离 3D 点云。在我们即将了解的数据集中,我们从 Velodyne 开发的特定硬件获得了 3D 点云。让我们了解 3D 物体检测的输入和输出是如何编码的。

输入编码

我们的原始输入将是以.bin文件形式呈现给我们的 3D 点云。可以使用np.fromfile(<filepath>)将每个文件加载为 NumPy 数组,以下是一个样本文件的数据查看:

files = Glob('training/velodyne')
F = choose(files)
pts = np.fromfile(F, dtype=np.float32).reshape(-1, 4)
pts 

下载并按照 GitHub 仓库的说明移动原始文件后,这些文件位于dataset/.../training/velodyne目录中。

前述代码得到以下输出:

文本 描述自动生成

图 10.14:输入数组

可视化如下所示:

# take the points and remove faraway points
x,y,z = np.clip(pts[:,0], 0, 50), 
        np.clip(pts[:,1], -25, 25), 
        np.clip(pts[:,2],-3, 1.27)
fig = go.Figure(data=[go.Scatter3d(\
        x=x, y=y, z=z, mode='markers',
        marker=dict(
            size=2,
            color=z, # set color to a list of desired values
            colorscale='Viridis', # choose a colorscale
            opacity=0.8
        )
    )])
fig.update_layout(margin=dict(l=0, r=0, b=0, t=0))
fig.show() 

前述代码的结果如下:

一个城市地图 描述自动生成(置信度低)

图 10.15:输入数组的可视化

我们可以通过以下步骤将这些信息转换为鸟瞰视图图像:

  1. 把 3D 点云投影到XY平面(地面)并分割成 8 平方厘米分辨率的网格单元。

  2. 对于每个单元格,计算以下内容并将其与指定的通道关联:

    1. 红色通道:网格中最高点的高度

    2. 绿色通道:网格中最高点的强度

    3. 蓝色通道:网格中点数除以 64(这是一个标准化因子)

例如,重建的云顶视图可能如下所示:

第 10.16 图:图像输入的鸟瞰视图

您可以清楚地看到图像中的“阴影”,表明存在障碍物。这是我们从 LIDAR 点云数据创建图像的方式。

我们已经将 3D 点云作为原始输入,并获得了鸟瞰图像作为输出。这是创建将作为 YOLO 模型输入的图像所必需的预处理步骤。

输出编码

现在我们已经得到了鸟瞰图像(3D 点云)作为模型的输入,模型需要预测以下真实世界的特征:

  • 图像中目标物体的类别

  • 目标物体距车辆东西轴(x 轴)的距离(以米为单位)

  • 目标物体距车辆南北轴(y 轴)的距离(以米为单位)

  • 目标物体的方向(偏航角

  • 目标物体的大小(以米为单位的长度宽度

可以在鸟瞰图像的像素坐标系中预测边界框,但这些预测仍然在像素空间中,没有真实世界的意义。在这种情况下,我们需要将这些鸟瞰图像的像素坐标边界框预测转换为以米为单位的真实世界坐标。为了避免后处理过程中的额外步骤,我们直接预测真实世界的值。

此外,在现实场景中,目标物体可以朝任意方向定位。如果我们仅计算长度和宽度,则无法足以描述紧凑边界框。一个这样的场景示例如下:

图,工程图 描述自动生成

第 10.17 图:边界框表示

要获得目标物体的紧凑边界框,我们还需要了解障碍物面向的方向信息,因此还需要额外的偏航参数。严格来说,它是物体相对南北轴的方向。

首先,YOLO 模型使用一个 32 x 64 的锚点网格(宽度大于高度),考虑到汽车的仪表板摄像头(因此也包括 LIDAR)视野比高度更宽。该模型为此任务使用了两种损失。第一种是正常的 YOLO 损失(负责预测 xylw 和类别),我们在第八章的 高级目标检测 中学到的,还有一种称为欧拉损失,专门预测偏航角。正式来说,从模型输出预测最终边界框的方程组如下:

在这里,b[x]、b[y]、b[w]、b[l]和b[φ]分别是障碍物的xy坐标值、宽度、长度和偏航角。t[x]、t[y]、t[w]、t[l]、t[Im]和t[Re]是从中预测的六个回归值。请注意,虽然只有 5 个值需要预测,但角度φ使用两个辅助值t[Im]和t[Re]进行预测,分别代表虚部和实部目标。这些只是官方实现中使用的名称,实质上是在尝试使用前述的 arctan 公式计算b[φ]。c[x]和c[y]是 32 x 64 矩阵内网格单元中心的位置,p**wp**l是预定义的先验,通过取汽车和行人的平均宽度和长度确定。此外,实现中还有五个先验(锚定框)。

假设相同类别的每个物体的高度为固定数值。

参考此处给出的插图,以图像方式展示:

自动生成的图表描述

图 10.18:边界框回归 源自:arXiv:1803.06199v2 [cs.CV]

总损失计算如下:

您已经从上一章了解到Loss[YOLO](使用t[x]、t[y]、t[w]和t[l]作为目标)。同时,请注意以下内容:

现在我们已经理解了 3D 物体检测的基础与 2D 物体检测相同(但要预测更多参数),以及此任务的输入输出对,让我们利用现有的 GitHub 仓库来训练我们的模型。

想了解更多关于 3D 物体检测的细节,请参考论文Complex-YOLO,链接地址为arxiv.org/pdf/1803.06199.pdf

训练 YOLO 模型进行 3D 物体检测

标准化的代码大大减少了用户的编码工作量。与 Detectron2 类似,通过确保数据位于正确位置并处于正确格式,我们可以用最少的代码行训练和测试算法。一旦确保了这些,我们就可以进行训练和测试。

首先需要克隆Complex-YOLOv4-Pytorch代码库:

$ git clone https://github.com/sizhky/Complex-YOLOv4-Pytorch 

按照README.md文件中的说明下载并移动数据集到正确的位置。

此 GitHub 仓库中提供了关于下载数据、训练和测试集的所有说明:github.com/sizhky/Complex-YOLOv4-Pytorch/blob/master/README.md#training-instructions。考虑到目前仅有少量开放的 3D 数据集,我们选择了这个练习中最常用的数据集,但您仍需注册才能下载。我们还在前述链接提供了注册说明。

数据格式

我们可以使用任何具有地面真实值的 3D 点云数据来进行此练习。有关如何下载和移动数据的更多说明,请参阅 GitHub 存储库中的README文件。

数据需要以以下格式存储在根目录中:

文本说明自动生成

图 10.19:数据存储格式

对我们而言,三个新文件夹是velodynecaliblabel_2

  • velodyne包含编码为.bin文件的 3D 点云信息列表,这些信息对应于image_2文件夹中的图像。

  • calib包含每个点云对应的校准文件。来自 LIDAR 点云坐标系的三维坐标可以通过位于calib文件夹中每个文件中的 3 x 4 投影矩阵投影到相机坐标系(即图像)上。基本上,LIDAR 传感器捕捉的点略微偏离相机捕捉的点。这种偏移是因为传感器彼此之间相距几英寸。知道正确的偏移量将帮助我们正确地从相机将边界框和三维点投影到图像上。

  • label_2包含每个图像的地面真实值(每行一个地面真实值),格式为 15 个值,这些值在下表中有解释:

表格说明自动生成

图 10.20:样本地面真实值

请注意,我们的目标列是类型(类)、wlxzy(偏航),如此处所示。对于此任务,我们将忽略其余的值。

数据检查

我们可以通过运行以下内容来验证数据是否已正确下载:

$ cd Complex-YOLOv4-Pytorch/src/data_process
$ python kitti_dataloader.py --output-width 600 

上述代码显示了多个图像,每次显示一张图像。以下是这样一个示例(图像来源:arxiv.org/pdf/1803.06199.pdf):

图 10.21:输入图像及其对应的地面真实值

来源:arXiv:1803.06199v2 [cs.CV]

现在我们已经能够下载并查看一些图像,在下一节中,我们将学习如何训练模型以预测 3D 边界框。

训练

训练代码包装在一个单独的 Python 文件中,并可按以下方式调用:

$ cd Complex-YOLOv4-Pytorch/src
$ python train.py --gpu_idx 0 --batch_size 2 --num_workers 4 \
                  --num_epochs 5 

默认的训练周期数为 300,但从第五个周期开始结果就相当合理了。每个周期在 GTX 1070 GPU 上需要 30 到 45 分钟的时间。如果无法一次性完成训练,可以使用--resume_path来恢复训练。代码每五个周期保存一个新的检查点。

测试

就像前面的数据检查部分一样,训练好的模型可以使用以下代码进行测试:

$ cd Complex-YOLOv4-Pytorch/src
$ python test.py --gpu_idx 0 --pretrained_path ../checkpoints/complexer_yolo/Model_complexer_yolo_epoch_5.pth --cfgfile ./config/cfg/complex_yolov4.cfg --show_image 

代码的主要输入是检查点路径和模型配置路径。在指定它们并运行代码后,会出现以下输出(图像来源:arxiv.org/pdf/1803.06199.pdf):

图 10.22:输入图像及相应的预测标签和边界框

来源:arXiv:1803.06199v2 [cs.CV]

由于模型的简单性,我们可以在普通 GPU 上实时应用它,每秒获得约 15-20 个预测结果。

到目前为止,我们已经学习了关于采用图像/帧作为输入并预测类别/对象/边界框的情景。如果我们想要从视频(或帧序列)中识别一个事件,该怎么办?让我们在下一节中重点关注这一点。

从视频中进行动作识别

现在让我们学习如何使用 MMAction 工具箱(github.com/open-mmlab/mmaction)来进行动作识别,这是来自 open-mmlab 项目的工具。MMAction 的主要特点包括:

  • 在修剪后的视频上进行动作识别(视频部分含有动作)

  • 在未修剪的视频中进行时间动作检测(动作定位)

  • 在未修剪的视频中进行空间(表明动作部分的帧)和时间(帧间动作变化)动作检测

  • 支持各种动作数据集

  • 支持多种动作理解框架

首先,让我们了解动作识别的工作原理。视频是随时间间隔(帧)的图像集合。我们有两种模型输入选择 - 2D 和 3D。2D 模型输入的维度为 FxCHW,其中 F 是帧数,C、H、W 分别是通道数、高度和宽度。3D 模型输入的维度为 CFHW。

对于 2D 模型输入情况,我们通过我们在第五章学习过的骨干网络(VGG16,ResNet50)传递视频(帧集)以获取中间层。接下来,我们通过时间卷积将中间输出聚合,以获取每帧发生情况的信息。对于 3D 模型输入情况,我们通过 3D 模型的骨干网络(如 ResNet3D),可以固有地处理时间维度以及空间维度,获取中间层。

接下来,我们通过池化层传递输出(跨所有帧),以获取倒数第二层,然后用它来预测不同的类别。

A picture containing text, screenshot, font, diagram  Description automatically generated

图 10.23:动作识别工作流程

提供了关于执行动作识别的不同方法的调查:arxiv.org/pdf/2010.11757.pdf

现在我们已经了解了如何在视频上进行动作分类,让我们执行以下操作:

  1. 可以直接使用 MMAction 进行给定视频中的动作识别

  2. 在自定义数据集上训练 MMAction 以识别动作

让我们开始使用 MMAction。

在给定视频中识别一个动作

要使用 MMAction 在给定视频中识别动作,请执行以下步骤:

以下代码可以在 GitHub 上的 Chapter10 文件夹中的 action_recognition.ipynb 文件中找到,网址为 bit.ly/mcvp-2e

  1. 安装依赖项。我们将安装 pytorch(版本 2.2.1+u121)并安装 MIM 0.3.9。MIM 提供了一个统一的界面,用于启动和安装 OpenMMLab 项目及其扩展,并管理 OpenMMLab 模型库。首先安装 openmimmmenginemmcv。然后我们将安装 mmaction 及其依赖项:

    %pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
    %pip install -U "openmim==0.3.9"
    !mim install -U "mmengine==0.10.4"
    !mim install "mmcv==2.2.0"
    !git clone https://github.com/sizhky/mmaction2.git -b main
    %cd mmaction2
    %pip install -e .
    %pip install -r requirements/optional.txt
    %pip install "timm==0.9.16"
    %pip install "torch-snippets==0.528" lovely-tensors 
    
  2. 下载预训练的检查点:

    !mkdir checkpoints
    !wget -c https://download.openmmlab.com/mmaction/recognition/tsn/tsn_r50_1x1x3_100e_kinetics400_rgb/tsn_r50_1x1x3_100e_kinetics400_rgb_20200614-e508be42.pth -O       checkpoints/tsn_r50_1x1x3_100e_kinetics400_rgb_20200614-e508be42.pth 
    
  3. 导入所需的包:

    from mmaction.apis import inference_recognizer, init_recognizer
    from mmengine import Config
    from torch_snippets import *
    from builtins import print 
    
  4. 初始化识别器:

    # Choose to use a config and initialize the recognizer
    config = 'configs/recognition/tsn/tsn_imagenet-pretrained-r50_8xb32-1x1x3-100e_kinetics400-rgb.py'
    config = Config.fromfile(config) 
    

detectron2huggingface 一样,mmaction 是一个使用配置文件创建模型、数据加载器和流水线以及训练器的库。在这一部分和下一部分中,我们将继续利用它。

  1. # Setup a checkpoint file to load
    checkpoint = 'checkpoints/tsn_r50_1x1x3_100e_kinetics400_rgb_20200614-e508be42.pth'
    # Initialize the recognizer
    model = init_recognizer(config, checkpoint, device='cuda:0') 
    

模型基本上有两个组件 - 一个 ResNet 主干,接受形状为 [F x 3 x H x W] 的张量并返回形状为 [F x 2048 x 7 x 7] 的特征向量。头部通过首先对每个 7x7 特征图进行平均来转换这个张量。这将返回形状为 [F x 2048 x 1 x 1] 的张量。在下一步中,帧被平均,计算出的张量将是形状为 [1 x 2048 x 1 x 1]。这被展平为 [1 x 2048],然后通过一个线性层,最终返回形状为 [1 x 400] 的张量,其中 400 是类别数。

  1. 使用识别器进行推理:

    from operator import itemgetter
    video = 'demo/demo.mp4'
    label = 'tools/data/kinetics/label_map_k400.txt'
    results = inference_recognizer(model, video)
    pred_scores = results.pred_score.cpu().numpy().tolist()
    score_tuples = tuple(zip(range(len(pred_scores)), pred_scores))
    score_sorted = sorted(score_tuples, key=itemgetter(1), reverse=True)
    top5_label = score_sorted[:5]
    labels = open(label).readlines()
    labels = [x.strip() for x in labels]
    results = [(labels[k[0]], k[1]) for k in top5_label] 
    

inference_recognizer 函数是对视频预处理和模型前向传播的封装,其中视频被加载为 NumPy 数组,帧被调整大小和重塑,尺寸被设置为模型接受的 [F x 3 x H x W] 张量格式。

  1. 打印预测结果:

    for result in results:
        print(f'{result[0]}: ', result[1]) 
    

包含文本、字体、白色、收据 由算法自动生成的说明

图 10.24:预测的动作

我们使用了一个简单的技巧,将帧视为批处理维度。这样,ResNet 的功能不会改变。一个新的头部用于将帧平均为单个值,然后可以简单地使用张量执行带有交叉熵损失的分类。正如您所看到的,我们使用了一个相对简单的路径,将我们对图像处理的知识扩展到了视频分类。

在自定义数据集上训练识别器

现在我们已经学会了如何利用现有的架构进行视频分类,让我们进一步在我们自己的二分类视频数据集上训练相同的模型。请注意,这可以扩展到任意数量的类别的视频分类。

要训练一个新的识别器,我们需要执行以下步骤:

  1. 让我们下载位于 research.google/pubs/the-kinetics-human-action-video-dataset/ 的数据集的一个小子集:

    # download, decompress the data
    !rm kinetics400_tiny.zip*
    !rm -rf kinetics400_tiny
    !wget https://download.openmmlab.com/mmaction/kinetics400_tiny.zip
    !unzip kinetics400_tiny.zip > /dev/null 
    

上述代码下载了 40 个视频 - 30 个用于训练数据集,10 个用于验证数据集。

任务是二进制视频分类,其中有两个类别 - “攀登绳索”(0)和“吹玻璃”(1)。

  1. 检查注释格式:

    !cat kinetics400_tiny/kinetics_tiny_train_video.txt 
    

包含文本、字体、白色、排版说明自动生成的图片

图 10.25:输入视频路径及其对应的类别

上述每行均指示文件路径及其相应的标签。

  1. 通过实施以下内容修改配置文件以进行训练:

    1. 初始化配置文件:
    cfg = Config.fromfile('./configs/recognition/tsn/tsn_imagenet-pretrained-r50_8xb32-1x1x3-100e_kinetics400-rgb.py') 
    

    如前文所述,我们将使用配置文件创建训练器类(也称为运行器)。

    1. 我们将通过更改默认值来修改配置值以适应我们的用例。这些变量的名称应该是自说明的:
    from mmengine.runner import set_random_seed
    # Modify dataset type and path
    cfg.data_root = 'kinetics400_tiny/train/'
    cfg.data_root_val = 'kinetics400_tiny/val/'
    cfg.ann_file_train = 'kinetics400_tiny/kinetics_tiny_train_video.txt'
    cfg.ann_file_val = 'kinetics400_tiny/kinetics_tiny_val_video.txt'
    cfg.test_dataloader.dataset.ann_file = 'kinetics400_tiny/kinetics_tiny_val_video.txt'
    cfg.test_dataloader.dataset.data_prefix.video = 'kinetics400_tiny/val/'
    cfg.train_dataloader.dataset.ann_file = 'kinetics400_tiny/kinetics_tiny_train_video.txt'
    cfg.train_dataloader.dataset.data_prefix.video = 'kinetics400_tiny/train/'
    cfg.val_dataloader.dataset.ann_file = 'kinetics400_tiny/kinetics_tiny_val_video.txt'
    cfg.val_dataloader.dataset.data_prefix.video  = 'kinetics400_tiny/val/'
    # Modify num classes of the model in cls_head
    cfg.model.cls_head.num_classes = 2
    # We can use the pre-trained TSN model
    cfg.load_from = './checkpoints/tsn_r50_1x1x3_100e_kinetics400_rgb_20200614-e508be42.pth'
    # Set up working dir to save files and logs.
    cfg.work_dir = './output_dir'
    cfg.train_dataloader.batch_size = cfg.train_dataloader.batch_size // 16
    cfg.val_dataloader.batch_size = cfg.val_dataloader.batch_size // 16
    cfg.optim_wrapper.optimizer.lr = cfg.optim_wrapper.optimizer.lr / 8 / 16
    cfg.train_cfg.max_epochs = 10
    cfg.train_dataloader.num_workers = 2
    cfg.val_dataloader.num_workers = 2
    cfg.test_dataloader.num_workers = 2 
    
  2. 最后,我们将创建一个运行器类,并使用它来训练识别器:

    import os.path as osp
    import mmengine
    from mmengine.runner import Runner
    # Create work_dir
    mmengine.mkdir_or_exist(osp.abspath(cfg.work_dir))
    # build the runner from config
    runner = Runner.from_cfg(cfg)
    # start training
    runner.train() 
    

上述代码导致训练准确率达到 100%。

注意

类似于 mmaction 中的 Runnerhuggingfacepytorch lightningpytorch-ignitetensorflowdetectron2 中的 Trainer,以及 fastai 中的 Learner,都是核心训练组件的封装。

  • optimizer.zero_grad()

  • model.train()

  • pred = model(inputs)

  • loss = loss_fn(pred, target)

  • loss.backward()

  • optimizer.step()

每个库的功能差异仅仅在于它们的实现方式。它们的功能几乎相同,探索它们是你理解如何编写优秀深度学习代码的好方法。

  1. 最后,我们测试识别器:

    runner.test() 
    

上述操作导致 Top1 准确率为 90%,Top5 准确率为 100%。

总结

在本章中,我们了解了处理目标定位和分割的各种实用方面。具体来说,我们学习了如何利用 Detectron2 平台进行图像分割和检测以及关键点检测。此外,我们还了解了在处理从 Open Images 数据集获取图像时涉及的某些复杂性。接下来,我们利用 VGG 和 U-Net 架构分别进行了人群计数和图像着色的工作。然后,我们理解了使用点云图像进行 3D 对象检测的理论和实现步骤。最后,我们了解了如何对一系列帧(视频)执行分类练习的方法。正如从所有这些示例中可以看出的那样,底层基础知识与前几章中描述的基本相同,只是网络的输入/输出有所修改以适应手头的任务。

在下一章中,我们将转换方向学习图像编码,这有助于识别相似图像以及生成新图像。

问题

  1. 为何将数据集转换为 Detectron2 特定格式是重要的?

  2. 直接对图像中的人数进行回归是困难的。VGG 架构之所以能够进行人群计数的关键见解是什么?

  3. 解释在图像着色案例中的自监督学习。

  4. 我们是如何将 3D 点云转换成与 YOLO 兼容的图像的?

  5. 如何使用仅适用于图像的架构处理视频的简单方法?

在 Discord 上了解更多信息。

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第三部分:图像处理

在本节中,我们将探讨各种图像处理技术,包括自编码器和各种类型的生成对抗网络GANs)。我们将利用这些技术来提高图像质量,调整风格,并从现有图像中生成新图像。

本节包括以下章节:

  • 第十一章, 自编码器和图像处理

  • 第十二章, 使用 GAN 生成图像

  • 第十三章, 高级 GAN 用于图像处理

第十一章:自编码器与图像操作

在之前的章节中,我们学习了如何对图像进行分类、检测图像中的对象以及分割与图像中的对象对应的像素。在本章中,我们将学习如何使用自编码器将图像表示为较低维度,然后利用图像的较低维度表示程度较高地操作(修改)图像。我们还将学习如何生成基于两幅不同图像的内容和风格生成新图像。然后,我们将探讨如何以不改变图像外观的方式修改图像,但在将图像通过图像分类模型时,将图像对应的类从一个类更改为另一个类。最后,我们将学习如何生成深度伪造:给定人物 A 的源图像,我们生成与人物 A 具有相似面部表情的人物 B 的目标图像。

总体而言,在本章中我们将学习以下主题:

  • 理解和实现自编码器

  • 理解卷积自编码器

  • 理解变分自编码器

  • 对图像执行对抗攻击

  • 执行神经风格迁移

  • 生成深度伪造

本章中所有代码片段均可在 Github 存储库的Chapter11文件夹中找到,链接为bit.ly/mcvp-2e

理解自编码器

到目前为止,在之前的章节中,我们已经学习了通过训练基于输入图像及其对应标签的模型来对图像进行分类。现在让我们想象一种情景,即我们需要根据它们的相似性对图像进行聚类,并且不具有它们对应的标签。自编码器在识别和分组相似图像方面非常有用。

自编码器的工作原理

自编码器将图像作为输入,将其存储在较低维度中,并尝试生成相同的图像作为输出,因此术语(简而言之,意味着能够重现输入)。然而,如果我们只是在输出中复制输入,那么我们不需要一个网络,只需简单地将输入乘以 1 即可。自编码器与我们迄今为止所学的典型神经网络架构的区别在于,它将图像中的信息编码到较低维度,然后再生成图像,因此称为编码器。这样,相似的图像将具有类似的编码。此外,解码器致力于从编码向量中重建原始图像。

为了进一步理解自编码器,让我们看一下以下图表:

图 11.1:典型自编码器架构

假设输入图像是 MNIST 手写数字的扁平化版本,输出图像与输入相同。中间层是称为 瓶颈 层的编码层。在输入和瓶颈层之间发生的操作表示 编码器,在瓶颈层和输出之间的操作表示 解码器

通过瓶颈层,我们可以将图像表示为更低维度。此外,借助瓶颈层,我们可以重构原始图像。我们利用瓶颈层来解决识别相似图像和生成新图像的问题,在接下来的部分中我们将学习如何做到这一点。瓶颈层在以下方面帮助:

  • 具有相似瓶颈层数值(编码表示)的图像可能彼此相似。

  • 通过更改瓶颈层的节点值,我们可以改变输出图像。

在前面的理解基础上,让我们在接下来的部分执行以下操作:

  • 从头开始实现自动编码器

  • 根据瓶颈层数值可视化图像的相似性

在下一节中,我们将了解如何构建自动编码器及瓶颈层中不同单元对解码器输出的影响。

实现基础自动编码器

要了解如何构建自动编码器,让我们在 MNIST 数据集上实现一个:

您可以在本书的 GitHub 仓库的 Chapter11 文件夹中的 simple_auto_encoder_with_different_latent_size.ipynb 文件中找到代码,网址为 bit.ly/mcvp-2e

您可以按照以下步骤操作:

  1. 导入相关包并定义设备:

    !pip install -q torch_snippets
    from torch_snippets import *
    from torchvision.datasets import MNIST
    from torchvision import transforms
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 指定我们希望图像通过的转换:

    img_transform = transforms.Compose([
                        transforms.ToTensor(),
                        transforms.Normalize([0.5], [0.5]),
                        transforms.Lambda(lambda x: x.to(device))
                    ]) 
    

在前面的代码中,我们看到我们正在将图像转换为张量,对其进行归一化,然后将其传递到设备上。

  1. 创建训练和验证数据集:

    trn_ds = MNIST('/content/', transform=img_transform, \
                   train=True, download=True)
    val_ds = MNIST('/content/', transform=img_transform, \
                   train=False, download=True) 
    
  2. 定义数据加载器:

    batch_size = 256
    trn_dl = DataLoader(trn_ds, batch_size=batch_size, shuffle=True)
    val_dl = DataLoader(val_ds, batch_size=batch_size, shuffle=False) 
    
  3. 定义网络架构。我们在 __init__ 方法中定义 AutoEncoder 类,包括编码器和解码器以及瓶颈层 latent_dim 的维度:

    class AutoEncoder(nn.Module):
        def __init__(self, latent_dim):
            super().__init__()
            self.latend_dim = latent_dim
            self.encoder = nn.Sequential(
                                nn.Linear(28 * 28, 128), 
                                nn.ReLU(True),
                                nn.Linear(128, 64), 
                                nn.ReLU(True), 
                                nn.Linear(64, latent_dim))
            self.decoder = nn.Sequential(
                                nn.Linear(latent_dim, 64), 
                                nn.ReLU(True),
                                nn.Linear(64, 128), 
                                nn.ReLU(True), 
                                nn.Linear(128, 28 * 28), 
                                nn.Tanh()) 
    
  4. 定义 forward 方法:

     def forward(self, x):
            x = x.view(len(x), -1)
            x = self.encoder(x)
            x = self.decoder(x)
            x = x.view(len(x), 1, 28, 28)
            return x 
    
  5. 可视化前述模型:

    !pip install torch_summary
    from torchsummary import summary
    model = AutoEncoder(3).to(device)
    summary(model, torch.zeros(2,1,28,28)) 
    

结果如下:

图 11.2:UNet 架构

从前述输出中,我们可以看到 Linear: 2-5 layer 是瓶颈层,每个图像表示为三维向量。此外,解码器层使用瓶颈层中的三个值 (Linear: 2-5 layer) 重建原始图像。

  1. 定义名为 train_batch 的函数,以便在批处理数据上训练模型,就像我们在前几章中所做的那样:

    def train_batch(input, model, criterion, optimizer):
        model.train()
        optimizer.zero_grad()
        output = model(input)
        loss = criterion(output, input)
        loss.backward()
        optimizer.step()
        return loss 
    
  2. 定义 validate_batch 函数以验证批处理数据上的模型:

    @torch.no_grad()
    def validate_batch(input, model, criterion):
        model.eval()
        output = model(input)
        loss = criterion(output, input)
        return loss 
    
  3. 定义模型、损失标准和优化器。确保我们使用 MSELoss 因为我们正在重建像素值。

    model = AutoEncoder(3).to(device)
    criterion = nn.MSELoss()
    optimizer = torch.optim.AdamW(model.parameters(), \
                                  lr=0.001, weight_decay=1e-5) 
    
  4. 随着 epoch 的增加训练模型:

    num_epochs = 5
    log = Report(num_epochs)
    for epoch in range(num_epochs):
        N = len(trn_dl)
        for ix, (data, _) in enumerate(trn_dl):
            loss = train_batch(data, model, criterion, optimizer)
            log.record(pos=(epoch + (ix+1)/N), trn_loss=loss, end='\r')
    
        N = len(val_dl)
        for ix, (data, _) in enumerate(val_dl):
            loss = validate_batch(data, model, criterion)
            log.record(pos=(epoch + (ix+1)/N), val_loss=loss, end='\r')
    
        log.report_avgs(epoch+1) 
    
  5. 可视化随着 epoch 增加的训练和验证损失:

    log.plot_epochs(log=True) 
    
output:

图 11.3:随着 epoch 增加的训练和验证损失

  1. 通过查看val_ds数据集上未在训练期间提供的几个预测/输出来验证模型:

    for _ in range(3):
        ix = np.random.randint(len(val_ds))
        im, _ = val_ds[ix]
        _im = model(im[None])[0]
        fig, ax = plt.subplots(1, 2, figsize=(3,3))
        show(im[0], ax=ax[0], title='input')
        show(_im[0], ax=ax[1], title='prediction')
        plt.tight_layout()
        plt.show() 
    

前述代码的输出如下:

图 11.4:自编码器生成的预测/输出

我们可以看到,尽管瓶颈层仅有三个维度,网络仍能以非常高的准确度重现输入。然而,图像的清晰度并不如预期。这主要是由于瓶颈层中节点数量较少造成的。在接下来的图像中,我们将展示在不同瓶颈层大小(2、3、5、10 和 50)训练网络后的重建图像:

图 11.5:自编码器生成的预测/输出

随着瓶颈层中向量的数量增加,重建图像的清晰度得到了提高。

在接下来的章节中,我们将学习如何使用卷积神经网络CNN)生成更清晰的图像,并了解如何将相似图像分组。

实现卷积自编码器

在前一节中,我们学习了自编码器并在 PyTorch 中实现了它们。尽管我们已经实现了它们,但我们通过数据集的便利之处在于每个图像只有一个通道(每个图像表示为黑白图像),而且图像相对较小(28 x 28 像素)。因此,网络将输入展平,并能够在 784(28*28)个输入值上进行训练,以预测 784 个输出值。然而,在现实中,我们将遇到具有三个通道且远大于 28 x 28 像素的图像。

在本节中,我们将学习如何实现一个能够处理多维输入图像的卷积自编码器。然而,为了与普通自编码器进行比较,我们将继续使用在前一节中使用过的 MNIST 数据集,但是修改网络结构,使其成为一个卷积自编码器而非普通自编码器。

卷积自编码器的表示如下:

图 11.6:卷积自编码器

从上述图像中,我们可以看到输入图像在用于重建图像的瓶颈层中表示为一个块。图像通过多次卷积来获取瓶颈表示(通过编码器获得的瓶颈层),并且通过上采样瓶颈表示来获取原始图像(原始图像通过解码器重建)。请注意,在卷积自编码器中,与输入层相比,瓶颈层中的通道数量可以非常高。

现在我们知道了卷积自编码器的表示方式,让我们来实现它:

下面的代码可以在本书的 GitHub 代码库Chapter11文件夹中的conv_auto_encoder.ipynb中找到,网址为https://bit.ly/mcvp-2e

  1. 步骤 14,与实现普通自编码器部分完全相同,具体如下:

    !pip install -q torch_snippets
    from torch_snippets import *
    from torchvision.datasets import MNIST
    from torchvision import transforms
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    img_transform = transforms.Compose([
                        transforms.ToTensor(),
                        transforms.Normalize([0.5], [0.5]),
                        transforms.Lambda(lambda x: x.to(device))
                                        ])
    trn_ds = MNIST('/content/', transform=img_transform, \
                   train=True, download=True)
    val_ds = MNIST('/content/', transform=img_transform, \
                   train=False, download=True)
    batch_size = 128
    trn_dl = DataLoader(trn_ds, batch_size=batch_size, shuffle=True)
    val_dl = DataLoader(val_ds, batch_size=batch_size, shuffle=False) 
    
  2. 定义神经网络类ConvAutoEncoder如下:

    1. 定义类和__init__方法:
    class ConvAutoEncoder(nn.Module):
        def __init__(self):
            super().__init__() 
    
    1. 定义encoder架构:
     self.encoder = nn.Sequential(
                                nn.Conv2d(1, 32, 3, stride=3, padding=1), 
                                nn.ReLU(True),
                                nn.MaxPool2d(2, stride=2),
                                nn.Conv2d(32, 64, 3, stride=2, padding=1), 
                                nn.ReLU(True),
                                nn.MaxPool2d(2, stride=1)
                            ) 
    

    注意,在前面的代码中,我们从初始通道数1开始,增加到32,然后进一步增加到64,同时通过执行nn.MaxPool2dnn.Conv2d操作减少输出值的大小。

    1. 定义decoder架构:
     self.decoder = nn.Sequential(
                            nn.ConvTranspose2d(64, 32, 3, stride=2), 
                            nn.ReLU(True),
                            nn.ConvTranspose2d(32, 16, 5, stride=3,padding=1),
                            nn.ReLU(True),
                            nn.ConvTranspose2d(16, 1, 2, stride=2,padding=1), 
                            nn.Tanh()
                        ) 
    
    1. 定义forward方法:
     def forward(self, x):
            x = self.encoder(x)
            x = self.decoder(x)
            return x 
    
  3. 使用summary方法获取模型的摘要:

    model = ConvAutoEncoder().to(device)
    !pip install torch_summary
    from torchsummary import summary
    summary(model, torch.zeros(2,1,28,28)); 
    

前面的代码结果如下:

图 11.7:模型架构摘要

从前面的总结可以看出,形状为批大小 x 64 x 2 x 2 的MaxPool2d-6层充当了瓶颈层。

一旦我们训练模型,就像我们在前一节中做的那样(在步骤 6、7、8 和 9中),随着增加的时期训练和验证损失的变化以及输入图像的预测如下:

图 11.8:随着时期的变化和样本预测的损失变化

从前面的图像可以看出,卷积自编码器能够比普通自编码器更清晰地预测图像。作为练习,我们建议您在编码器和解码器中改变通道数量,然后分析结果的变化。

在接下来的部分中,当图像的标签不存在时,我们将讨论基于瓶颈层值分组相似图像的问题。

使用 t-SNE 对相似图像进行分组

在前面的部分中,我们假设每个图像在更低的维度中表示,假设相似的图像将具有相似的嵌入,而不相似的图像将具有不相似的嵌入。然而,我们还没有详细查看图像相似性度量或嵌入表示。

在本节中,我们将在二维空间中绘制嵌入(瓶颈)向量。我们可以通过一种称为t-SNE的技术将卷积自编码器的 64 维向量压缩到二维空间中,该技术有助于以一种使得相似数据点聚集在一起而不相似数据点远离彼此的方式压缩信息。 (关于 t-SNE 的更多信息请参见:www.jmlr.org/papers/v9/vandermaaten08a.html。)

通过这种方式,我们可以验证类似图像将具有相似的嵌入,因为相似的图像应该在二维平面上聚集在一起。我们将在二维平面上表示所有测试图像的嵌入:

以下代码在本书的 GitHub 存储库的Chapter11文件夹中的conv_auto_encoder.ipynb中可用,网址为bit.ly/mcvp-2e

  1. 初始化列表以存储潜在向量(latent_vectors)和图像的对应classes(请注意,我们仅存储每个图像的类别,以验证同一类别的图像是否确实在表示上彼此接近):

    latent_vectors = []
    classes = [] 
    
  2. 循环遍历验证数据加载器中的图像(val_dl)并存储编码器层输出的结果(model.encoder(im).view(len(im),-1))和每个图像(im)对应的类别(clss):

    for im,clss in val_dl:
        latent_vectors.append(model.encoder(im).view(len(im),-1))
        classes.extend(clss) 
    
  3. 连接 NumPy 数组latent_vectors

    latent_vectors = torch.cat(latent_vectors).cpu().detach().numpy() 
    
  4. 导入 t-SNE(TSNE)并指定将每个向量转换为二维向量(TSNE(2)),以便我们可以绘制它:

    from sklearn.manifold import TSNE
    tsne = TSNE(2) 
    
  5. 运行fit_transform方法来拟合 t-SNE 到图像嵌入(latent_vectors):

    clustered = tsne.fit_transform(latent_vectors) 
    
  6. 绘制 t-SNE 拟合后的数据点:

    fig = plt.figure(figsize=(12,10))
    cmap = plt.get_cmap('Spectral', 10)
    plt.scatter(*zip(*clustered), c=classes, cmap=cmap)
    plt.colorbar(drawedges=True) 
    

前面的代码生成了以下输出(你可以参考书的数字版以查看彩色图片):

图 11.9:使用 t-SNE 分组的数据点(图像)

我们可以看到同一类别的图像被聚集在一起,这进一步证实了瓶颈层的值以这样的方式排列,即看起来相似的图像将具有相似的值。

到目前为止,我们已经了解了如何使用自编码器将相似图像分组在一起。在下一节中,我们将学习如何使用自编码器生成新图像。

理解变分自编码器

到目前为止,我们已经看到了一种情况,可以将相似的图像分组到集群中。此外,我们学习到当我们获取落在给定集群中的图像的嵌入时,我们可以重新构建(解码)它们。但是,如果一个嵌入(潜在向量)位于两个集群之间,我们不能保证生成逼真的图像。变分自编码器VAEs)在这种情况下非常有用。

VAE 的必要性

在深入理解和构建 VAE 之前,让我们探讨从嵌入中生成图像的局限性,这些图像不属于任何一个集群(或位于不同集群中心)。首先,我们通过以下步骤从采样向量生成图像(可在conv_auto_encoder.ipynb文件中找到):

  1. 计算在前一节中使用的验证图像的潜在向量(嵌入):

    latent_vectors = []
    classes = []
    for im,clss in val_dl:
        latent_vectors.append(model.encoder(im))
        classes.extend(clss)
    latent_vectors = torch.cat(latent_vectors).cpu()\
                          .detach().numpy().reshape(10000, -1) 
    
  2. 使用列级均值(mu)和标准差(sigma)生成随机向量,并在创建来自均值和标准差的向量之前,给标准差添加轻微噪声(torch.randn(1,100))。最后,将它们保存在列表中(rand_vectors):

    rand_vectors = []
    for col in latent_vectors.transpose(1,0):
        mu, sigma = col.mean(), col.std()
        rand_vectors.append(sigma*torch.randn(1,100) + mu) 
    
  3. 绘制从步骤 2中获取的向量重建的图像和在前一节中训练的卷积自编码器模型:

    rand_vectors=torch.cat(rand_vectors).transpose(1,0).to(device)
    fig,ax = plt.subplots(10,10,figsize=(7,7)); ax = iter(ax.flat)
    for p in rand_vectors:
        img = model.decoder(p.reshape(1,64,2,2)).view(28,28)
        show(img, ax=next(ax)) 
    

上述代码的输出如下:

图 11.10:从潜在向量的均值和标准差生成的图像

从上述输出中我们可以看到,当我们绘制从已知向量的列的均值和加噪标准差生成的图像时,得到的图像不如之前清晰。这是一个现实的情况,因为我们事先不知道会生成逼真图片的嵌入向量的范围。

VAEs通过生成具有均值为 0 和标准差为 1 的向量来帮助我们解决这个问题,从而确保我们生成的图像具有均值为 0 和标准差为 1。

实质上,在 VAE 中,我们指定瓶颈层应遵循某种分布。在接下来的章节中,我们将学习我们在 VAE 中采用的策略,还将学习有助于获取遵循特定分布的瓶颈特征的Kullback-LeiblerKL)散度损失。

VAE 的工作原理

在 VAE 中,我们通过以下方式构建网络,使得从预定义分布生成的随机向量能够生成逼真的图像。简单的自编码器无法做到这一点,因为我们没有在网络中指定生成图像的数据分布。我们通过 VAE 采用以下策略来实现这一点:

  1. 编码器的输出是每个图像的两个向量:

    • 一个向量代表均值。

    • 另一个表示标准差。

  2. 从这两个向量中,我们获取一个修改后的向量,该向量是均值和标准差之和(乘以一个随机小数)。修改后的向量将与每个向量具有相同数量的维度。

  3. 将上一步得到的修改后的向量作为输入传递给解码器以获取图像。

  4. 我们优化的损失值是以下几种组合:

    • KL 散度损失:衡量均值向量和标准差向量的分布与 0 和 1 的偏差,分别为

    • 均方损失:是我们用来重构(解码)图像的优化方法

通过指定均值向量应该具有围绕 0 中心的分布,标准差向量应该围绕 1 中心的分布,我们训练网络的方式是,当我们生成均值为 0、标准差为 1 的随机噪声时,解码器能够生成逼真的图像。

此外,请注意,如果我们仅最小化 KL 散度,编码器将预测均值向量为 0,并且每个输入的标准差为 1。因此,同时最小化 KL 散度损失和均方损失是重要的。

在下一节中,让我们学习一下 KL 散度,以便我们可以将其纳入模型损失值的计算中。

KL 散度

KL 散度有助于解释数据两个分布之间的差异。在我们的具体案例中,我们希望我们的瓶颈特征值遵循均值为 0、标准差为 1 的正态分布。

因此,我们使用 KL 散度损失来理解我们的瓶颈特征值与期望的值分布有多不同,期望的分布具有均值为 0 和标准差为 1。

让我们通过详细说明如何计算 KL 散度损失来看看 KL 散度损失如何帮助:

在前述方程中,σ和μ分别表示每个输入图像的均值和标准差值。

让我们讨论前述方程:

  1. 确保均值向量分布在 0 附近:

最小化均方误差()在前述方程中确保尽可能接近 0。

  1. 确保标准差向量分布在 1 附近:

方程的其余项(除了)确保σ(标准差向量)分布在 1 附近。

当均值(µ)为 0 且标准差为 1 时,前述损失函数被最小化。此外,通过指定我们正在考虑标准差的对数,我们确保σ值不能为负。

现在我们理解了构建 VAE 的高级策略以及要最小化的损失函数,以获得编码器输出的预定义分布后,让我们在下一节中实现 VAE。

构建 VAE

在本节中,我们将编写一个 VAE 来生成手写数字的新图像。

以下代码在本书 GitHub 库的Chapter11文件夹中的VAE.ipynb中可用:bit.ly/mcvp-2e

由于我们具有相同的数据,实现基本自动编码器部分中的所有步骤仍然相同,除了步骤 5 和 6,在其中我们分别定义网络架构和训练模型。相反,我们在以下代码中以不同方式定义它们(在VAE.ipynb文件中可用):

  1. 步骤 1步骤 4,与实现基本自动编码器部分完全相同,具体如下:

    !pip install -q torch_snippets
    from torch_snippets import *
    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    import torch.optim as optim
    from torchvision import datasets, transforms
    from torchvision.utils import make_grid
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    train_dataset = datasets.MNIST(root='MNIST/', train=True, \
                                   transform=transforms.ToTensor(), \
                                   download=True)
    test_dataset = datasets.MNIST(root='MNIST/', train=False, \
                                  transform=transforms.ToTensor(), \
                                  download=True)
    train_loader = torch.utils.data.DataLoader(dataset = train_dataset, \
                                               batch_size=64, shuffle=True)
    test_loader = torch.utils.data.DataLoader(dataset= test_dataset, \
                                              batch_size=64, shuffle=False) 
    
  2. 定义神经网络类VAE如下:

    1. __init__方法中定义将在其他方法中使用的层:
    class VAE(nn.Module):
        def __init__(self, x_dim, h_dim1, h_dim2, z_dim):
            super(VAE, self).__init__()
            self.d1 = nn.Linear(x_dim, h_dim1)
            self.d2 = nn.Linear(h_dim1, h_dim2)
            self.d31 = nn.Linear(h_dim2, z_dim)
            self.d32 = nn.Linear(h_dim2, z_dim)
            self.d4 = nn.Linear(z_dim, h_dim2)
            self.d5 = nn.Linear(h_dim2, h_dim1)
            self.d6 = nn.Linear(h_dim1, x_dim) 
    
    1. 注意 d1d2 层对应编码器部分,而 d5d6 对应解码器部分。d31d32 层分别对应均值向量和标准差向量。然而,为了方便起见,我们假设将使用 d32 层作为对数方差向量的表示。

    2. 定义编码器方法:

     def encoder(self, x):
            h = F.relu(self.d1(x))
            h = F.relu(self.d2(h))
            return self.d31(h), self.d32(h) 
    
    1. 注意编码器返回两个向量:一个用于均值 (self.d31(h)),另一个用于对数方差值 (self.d32(h))

    2. 定义从编码器输出中进行采样的方法 (sampling):

     def sampling(self, mean, log_var):
            std = torch.exp(0.5*log_var)
            eps = torch.randn_like(std)
            return eps.mul(std).add_(mean) 
    
    1. 注意 0.5log_var* (torch.exp(0.5*log_var)) 的指数代表标准差 (std)。此外,我们通过随机正态分布生成的噪声乘以均值和标准差的加法来返回值。通过乘以 eps,我们确保即使在编码器向量轻微变化时,也能生成图像。
  3. 定义解码器方法:

     def decoder(self, z):
            h = F.relu(self.d4(z))
            h = F.relu(self.d5(h))
            return F.sigmoid(self.d6(h)) 
    
  4. 定义前向方法:

     def forward(self, x):
            mean, log_var = self.encoder(x.view(-1, 784))
            z = self.sampling(mean, log_var)
            return self.decoder(z), mean, log_var 
    

在上述方法中,我们确保编码器返回均值和对数方差值。接下来,我们通过均值加上乘以对数方差的 epsilon 进行采样,并通过解码器传递后返回值。

  1. 定义用于对一个批次模型进行训练和另一个批次进行验证的函数:

    def train_batch(data, model, optimizer, loss_function):
        model.train()
        data = data.to(device)
        optimizer.zero_grad()
        recon_batch, mean, log_var = model(data)
        loss, mse, kld = loss_function(recon_batch, data, mean, log_var)
        loss.backward()
        optimizer.step()
        return loss, mse, kld, log_var.mean(), mean.mean()
    @torch.no_grad()
    def validate_batch(data, model, loss_function):
        model.eval()
        data = data.to(device)
        recon, mean, log_var = model(data)
        loss, mse, kld = loss_function(recon, data, mean, log_var)
        return loss, mse, kld, log_var.mean(), mean.mean() 
    
  2. 定义损失函数:

    def loss_function(recon_x, x, mean, log_var):
        RECON = F.mse_loss(recon_x, x.view(-1, 784), reduction='sum')
        KLD = -0.5 * torch.sum(1 + log_var - mean.pow(2) - log_var.exp())
        return RECON + KLD, RECON, KLD 
    

在上述代码中,我们获取原始图像 (x) 和重构图像 (recon_x) 之间的 MSE 损失 (RECON)。接下来,根据我们在前一节定义的公式计算 KL 散度损失 (KLD)。注意对数方差的指数是方差值。

  1. 定义模型对象 (vae) 和优化器函数 (optimizer):

    vae = VAE(x_dim=784, h_dim1=512, h_dim2=256, z_dim=50).to(device)
    optimizer = optim.AdamW(vae.parameters(), lr=1e-3) 
    
  2. 在增加 epoch 的过程中训练模型:

    n_epochs = 10
    log = Report(n_epochs)
    for epoch in range(n_epochs):
        N = len(train_loader)
        for batch_idx, (data, _) in enumerate(train_loader):
            loss, recon, kld, log_var, mean = train_batch(data, \
                                                          vae, optimizer, \
                                                          loss_function)
            pos = epoch + (1+batch_idx)/N
            log.record(pos, train_loss=loss, train_kld=kld, \
                       train_recon=recon,train_log_var=log_var, \
                       train_mean=mean, end='\r')
    
        N = len(test_loader)
        for batch_idx, (data, _) in enumerate(test_loader):
            loss, recon, kld,log_var,mean = validate_batch(data, \
                                               vae, loss_function)
            pos = epoch + (1+batch_idx)/N
            log.record(pos, val_loss=loss, val_kld=kld, \
                       val_recon=recon, val_log_var=log_var, \
                       val_mean=mean, end='\r')
    
        log.report_avgs(epoch+1)
        with torch.no_grad():
            z = torch.randn(64, 50).to(device)
            sample = vae.decoder(z).to(device)
            images = make_grid(sample.view(64, 1, 28, 28)).permute(1,2,0)
            show(images)
    log.plot_epochs(['train_loss','val_loss']) 
    

尽管大多数前述代码是熟悉的,让我们看一下网格图像生成过程。我们首先生成一个随机向量 (z),然后通过解码器 (vae.decoder) 获取图像样本。make_grid 函数会绘制图像(如果需要,会自动进行反归一化)。

损失值变化的输出和生成图像的样本如下:

图 11.11:(左)随 epoch 增加损失的变化。(右)使用 VAE 生成的图像

我们可以看到我们能够生成原始图像中不存在的逼真新图像。

到目前为止,我们已经学习了使用 VAE 生成新图像的方法。但是,如果我们希望修改图像以使模型无法识别正确的类别,我们将在下一节中学习用于实现此目的的技术。

对图像执行对抗攻击:

在前一节中,我们学习了使用 VAE 从随机噪声生成图像。但这是一个无监督的练习。如果我们想以如此微小的方式修改图像,以至于对于人类来说与原始图像几乎无法区分,但神经网络模型仍然会将其识别为属于不同类别的对象,那么对图像进行的对抗攻击就非常有用。

对抗攻击是指我们对输入图像值(像素)进行的更改,以便达到特定目标。这在使我们的模型更加健壮以免受轻微修改影响方面特别有帮助。在本节中,我们将学习如何轻微修改图像,使预训练模型将其预测为不同类别(由用户指定),而不是原始类别。我们将采用的策略如下:

  1. 提供一张大象的图像。

  2. 指定与图像相对应的目标类别。

  3. 导入预训练模型,其中模型参数设置为不更新(gradients = False)。

  4. 指定我们在输入图像像素值上计算梯度,而不是网络权重值。这是因为在训练中欺骗网络时,我们无法控制模型,只能控制发送到模型的图像。

  5. 计算与模型预测和目标类别对应的损失。

  6. 对模型进行反向传播。这一步骤帮助我们理解每个输入像素值相关的梯度。

  7. 根据每个输入像素值对应的梯度更新输入图像像素值。

  8. 在多个 epochs 中重复步骤 5、6 和 7

让我们用代码来做这件事:

本书的 GitHub 存储库中的Chapter11文件夹中提供了adversarial_attack.ipynb代码文件:bit.ly/mcvp-2e。该代码包含用于下载数据的 URL。我们强烈建议您在 GitHub 上执行该笔记本,以重现结果并理解执行步骤以及文本中各种代码组件的解释。

  1. 导入相关包、我们用于此用例的图像以及预训练的 ResNet50 模型。同时,指定我们要冻结参数,因为我们不会更新模型(但会更新图像以使其欺骗模型):

    !pip install torch_snippets
    from torch_snippets import inspect, show, np, torch, nn
    from torchvision.models import resnet50
    model = resnet50(pretrained=True)
    for param in model.parameters():
        param.requires_grad = False
    model = model.eval()
    import requests
    from PIL import Image
    url = 'https://lionsvalley.co.za/wp-content/uploads/2015/11/african-elephant-square.jpg'
    original_image = Image.open(requests.get(url, stream=True)\
                                .raw).convert('RGB')
    original_image = np.array(original_image)
    original_image = torch.Tensor(original_image) 
    
  2. 导入image_net_classes并为每个类分配 ID:

    image_net_classes = 'https://gist.githubusercontent.com/yrevar/942d3a0ac09ec9e5eb3a/raw/238f720ff059c1f82f368259d1ca4ffa5dd8f9f5/imagenet1000_clsidx_to_labels.txt'
    image_net_classes = requests.get(image_net_classes).text
    image_net_ids = eval(image_net_classes)
    image_net_classes = {i:j for j,i in image_net_ids.items()} 
    
  3. 指定一个函数来对图像进行归一化(image2tensor)和反归一化(tensor2image):

    from torchvision import transforms as T
    from torch.nn import functional as F
    normalize = T.Normalize([0.485, 0.456, 0.406], 
                            [0.229, 0.224, 0.225])
    denormalize=T.Normalize( \
                    [-0.485/0.229,-0.456/0.224,-0.406/0.225], 
                    [1/0.229, 1/0.224, 1/0.225])
    def image2tensor(input):
        x = normalize(input.clone().permute(2,0,1)/255.)[None]
        return x
    def tensor2image(input):
        x = (denormalize(input[0].clone()).permute(1,2,0)*255.)\
                                          .type(torch.uint8)
        return x 
    
  4. 定义一个函数来预测给定图像的类别(predict_on_image):

    def predict_on_image(input):
        model.eval()
        show(input)
        input = image2tensor(input)
        pred = model(input)
        pred = F.softmax(pred, dim=-1)[0]
        prob, clss = torch.max(pred, 0)
        clss = image_net_ids[clss.item()]
        print(f'PREDICTION: `{clss}` @ {prob.item()}') 
    

在上述代码中,我们正在将输入图像转换为张量(使用先前定义的image2tensor方法进行归一化)并通过model获取图像中对象的类别(clss)和预测的概率(prob)。

  1. 定义attack函数如下:

    1. attack函数接受imagemodeltarget作为输入:
    from tqdm import trange
    losses = []
    def attack(image, model, target, epsilon=1e-6): 
    
    1. 将图像转换为张量,并指定需要计算梯度的输入:
     input = image2tensor(image)
        input.requires_grad = True 
    
    1. 通过将规范化的输入(input)通过模型计算预测,然后计算相应目标类别的损失值:
     pred = model(input)
        loss = nn.CrossEntropyLoss()(pred, target) 
    
    1. 执行反向传播以减少损失:
     loss.backward()
        losses.append(loss.mean().item()) 
    
    1. 基于梯度方向微调图像:
     output = input - epsilon * input.grad.sign() 
    
    1. 在前述代码中,我们通过一个非常小的量(乘以epsilon)更新输入值。此外,我们仅仅通过梯度的方向(input.grad.sign())进行更新图像,而不是梯度的大小,而且在此之前乘以了一个非常小的值(epsilon)。

    2. 将张量转换为图像(tensor2image)并返回输出,这会使图像反标准化:

     output = tensor2image(output)
        del input
        return output.detach() 
    
  2. 修改图像以属于不同的类别:

    1. 指定我们想要将图像转换为的目标(desired_targets):
    modified_images = []
    desired_targets = ['lemon', 'comic book', 'sax, saxophone'] 
    
    1. 循环遍历目标,并在每次迭代中指定目标类别:
    for target in desired_targets:
        target = torch.tensor([image_net_classes[target]]) 
    
    1. 修改图像以攻击逐步增加的时期,并将它们收集到一个列表中:
     image_to_attack = original_image.clone()
        for _ in trange(10):
            image_to_attack = attack(image_to_attack,model,target)
        modified_images.append(image_to_attack) 
    
    1. 下面的代码会导致修改后的图像和相应的类别:
    for image in [original_image, *modified_images]:
        predict_on_image(image)
        inspect(image) 
    

前述代码生成如下内容:

图 11.12:修改后的图像及其对应的类别

我们可以看到,当我们轻微地修改图像时,预测类别完全不同,但置信度非常高。

现在我们了解了如何修改图像,使其成为我们希望的类别,接下来的部分中,我们将学习如何修改图像(内容图像)以我们选择的风格。

理解神经风格转移

想象一个场景,你想以梵高的风格绘制一幅图像。在这种情况下,神经风格转移非常有用。在神经风格转移中,我们使用一个内容图像和一个风格图像,将这两个图像以一种方式结合起来,使得合成图像保留内容图像的内容,同时保持风格图像的风格。

理解神经风格转移的工作原理

示例风格图像和内容图像如下:

图 11.13:(左)风格图像。 (右)内容图像

我们希望保留右侧图片(内容图像)中的内容,但同时叠加左侧图片(风格图像)中的颜色和纹理。

执行神经风格转移的过程如下(我们将遵循此论文中概述的技术:arxiv.org/abs/1508.06576)。我们试图修改原始图像,使得损失值分解为内容损失风格损失。内容损失指生成图像与内容图像有多不同。风格损失指风格图像与生成图像有多相关

虽然我们提到损失是基于图像差异计算的,但在实践中,我们稍微修改了它,确保使用图像的特征层激活而不是原始图像计算损失。例如,在第二层的内容损失将是通过第二层传递时的内容图像和生成图像的激活之间的平方差。这是因为特征层捕获原始图像的某些属性(例如,高层中对应于原始图像的前景轮廓以及低层中细粒度对象的细节)。

虽然计算内容损失似乎很简单,让我们尝试理解如何计算生成图像与样式图像之间的相似性。一个称为格拉姆矩阵的技术非常方便。格拉姆矩阵计算生成图像和样式图像之间的相似性,计算方法如下:

GM**l 是层l的样式图像S和生成图像G的格拉姆矩阵值。N[l]代表特征图的数量,其中M[l]是特征图的高度乘以宽度。

由于将矩阵乘以其自身的转置得到了格拉姆矩阵。让我们讨论这个操作是如何使用的。假设您正在处理一个具有 32 x 32 x 256 的特征输出层。格拉姆矩阵被计算为通道内每个 32 x 32 值与所有通道中值的相关性。因此,格拉姆矩阵计算结果为 256 x 256 的矩阵形状。现在我们比较样式图像和生成图像的 256 x 256 值,以计算样式损失。

让我们理解为什么格拉姆矩阵对于样式转移如此重要。在成功的情况下,假设我们将毕加索的风格转移到了蒙娜丽莎上。让我们称毕加索的风格为St(代表样式),原始蒙娜丽莎为So(代表源),最终图像为Ta(代表目标)。请注意,在理想情况下,图像Ta中的局部特征与St中的局部特征相同。即使内容可能不同,将样式图像的类似颜色、形状和纹理带入目标图像中是样式转移中的重要部分。

扩展开来,如果我们发送So并从 VGG19 的中间层提取其特征,它们将与通过发送Ta获得的特征不同。然而,在每个特征集内,相应向量将以类似的方式相对于彼此变化。例如,如果两个特征集的第一个通道均值与第二个通道均值的比率将是相似的。这就是我们尝试使用格拉姆损失计算的原因。

通过比较内容图像的特征激活的差异来计算内容损失。通过首先在预定义层中计算格拉姆矩阵,然后比较生成图像和样式图像的格拉姆矩阵来计算样式损失。

现在我们能够计算样式损失和内容损失,最终修改的输入图像是最小化总损失的图像,即样式损失和内容损失的加权平均。

执行神经风格迁移

我们采用的实施神经风格迁移的高层策略如下:

  1. 将输入图像通过预训练模型传递。

  2. 提取预定义层的层值。

  3. 将生成的图像通过模型并在同一预定义层中提取其值。

  4. 计算与内容图像和生成图像相对应的每个层的内容损失。

  5. 将样式图像通过模型的多层并计算样式图像的格拉姆矩阵值。

  6. 将生成的图像通过样式图像传递的相同层,并计算其相应的格拉姆矩阵值。

  7. 提取两个图像的格拉姆矩阵值的平方差。这将是样式损失。

  8. 总损失将是样式损失和内容损失的加权平均。

  9. 使总损失最小化的生成图像将是感兴趣的最终图像。

现在让我们编写前面的策略代码:

本书 GitHub 存储库的Chapter11文件夹中提供了名为neural_style_transfer.ipynb的以下代码:bit.ly/mcvp-2e。代码包含从中下载数据的 URL,并且代码长度适中。我们强烈建议您在 GitHub 上执行笔记本以重现结果,同时理解执行步骤和文本中各种代码组件的解释。

  1. 导入相关包:

    !pip install torch_snippets
    from torch_snippets import *
    from torchvision import transforms as T
    from torch.nn import functional as F
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 定义用于预处理和后处理数据的函数:

    from torchvision.models import vgg19
    preprocess = T.Compose([
                    T.ToTensor(),
                    T.Normalize(mean=[0.485, 0.456, 0.406], 
                                std=[0.229, 0.224, 0.225]),
                    T.Lambda(lambda x: x.mul_(255))
                ])
    postprocess = T.Compose([
                    T.Lambda(lambda x: x.mul_(1./255)),
                    T.Normalize(\
                    mean=[-0.485/0.229,-0.456/0.224,-0.406/0.225], 
                                std=[1/0.229, 1/0.224, 1/0.225]),
                ]) 
    
  3. 定义GramMatrix模块:

    class GramMatrix(nn.Module):
        def forward(self, input):
            b,c,h,w = input.size()
            feat = input.view(b, c, h*w)
            G = feat@feat.transpose(1,2)
            G.div_(h*w)
            return G 
    

在前述代码中,我们正在计算所有可能的特征与自身的内积,这基本上是在询问所有向量如何相互关联。

  1. 定义格拉姆矩阵的相应均方误差损失,GramMSELoss

    class GramMSELoss(nn.Module):
        def forward(self, input, target):
            out = F.mse_loss(GramMatrix()(input), target)
            return(out) 
    

一旦我们对两个特征集都有了格拉姆向量,它们尽可能地匹配,因此mse_loss至关重要。

  1. 定义模型类vgg19_modified

    1. 初始化类:
    class vgg19_modified(nn.Module):
        def __init__(self):
            super().__init__() 
    
    1. 提取特征:
     features = list(vgg19(pretrained = True).features)
            self.features = nn.ModuleList(features).eval() 
    
    1. 定义forward方法,该方法接受层列表并返回与每个层对应的特征:
     def forward(self, x, layers=[]):
            order = np.argsort(layers)
            _results, results = [], []
            for ix,model in enumerate(self.features):
                x = model(x)
                if ix in layers: _results.append(x)
            for o in order: results.append(_results[o])
            return results if layers is not [] else x 
    
    1. 定义模型对象:
    vgg = vgg19_modified().to(device) 
    
  2. 导入内容和样式图像:

    !wget https://www.dropbox.com/s/z1y0fy2r6z6m6py/60.jpg
    !wget https://www.dropbox.com/s/1svdliljyo0a98v/style_image.png 
    
  3. 确保图像调整大小为相同形状,512 x 512 x 3:

    imgs = [Image.open(path).resize((512,512)).convert('RGB') \
            for path in ['style_image.png', '60.jpg']]
    style_image,content_image=[preprocess(img).to(device)[None] \
                                  for img in imgs] 
    
  4. 指定要使用requires_grad = True修改内容图像:

    opt_img = content_image.data.clone()
    opt_img.requires_grad = True 
    
  5. 指定定义内容损失和样式损失的层次,即我们使用的中间 VGG 层,用于比较风格的 Gram 矩阵和内容的原始特征向量(注意,下面选择的层次纯粹是实验性的):

    style_layers = [0, 5, 10, 19, 28] 
    content_layers = [21]
    loss_layers = style_layers + content_layers 
    

定义内容和样式损失值的损失函数:

  1. loss_fns = [GramMSELoss()] * len(style_layers) + \
                [nn.MSELoss()] * len(content_layers)
    loss_fns = [loss_fn.to(device) for loss_fn in loss_fns] 
    
  2. 定义与内容和样式损失相关联的权重:

    style_weights = [1000/n**2 for n in [64,128,256,512,512]] 
    content_weights = [1]
    weights = style_weights + content_weights 
    
  3. 我们需要操作我们的图像,使得目标图像的风格尽可能地与style_image相似。因此,我们通过计算从 VGG 的几个选择层次获得的特征的 Gram 矩阵来计算style_imagestyle_targets值。由于整体内容应该保持不变,我们选择content_layer变量来计算来自 VGG 的原始特征:

    style_targets = [GramMatrix()(A).detach() for A in \
                     vgg(style_image, style_layers)]
    content_targets = [A.detach() for A in \
                       vgg(content_image, content_layers)]
    targets = style_targets + content_targets 
    
  4. 定义optimizer和迭代次数(max_iters)。尽管我们可以使用 Adam 或任何其他优化器,但 LBFGS 是一种观察到在确定性场景中工作最佳的优化器。此外,由于我们处理的是一张图像,没有任何随机性。许多实验表明,在神经传递设置中,LBFGS 收敛更快,损失更低,因此我们将使用这个优化器:

    max_iters = 500
    optimizer = optim.LBFGS([opt_img])
    log = Report(max_iters) 
    
  5. 执行优化。在确定性场景中,我们在同一个张量上进行迭代时,可以将优化器步骤包装为一个零参数函数,并重复调用它,如下所示:

    iters = 0
    while iters < max_iters:
        def closure():
            global iters
            iters += 1
            optimizer.zero_grad()
            out = vgg(opt_img, loss_layers)
            layer_losses = [weights[a]*loss_fnsa \
                            for a,A in enumerate(out)]
            loss = sum(layer_losses)
            loss.backward()
            log.record(pos=iters, loss=loss, end='\r')
            return loss
        optimizer.step(closure) 
    
  6. 绘制损失变化情况:

    log.plot(log=True) 
    

这导致以下输出:

图 11.14

图 11.14:随着 epoch 增加的损失变化

  1. 绘制具有内容和风格图像组合的图像:

    With torch.no_grad()
        out_img = postprocess(opt_img[0]).permute(1,2,0)
    show(out_img) 
    

输出如下:

图 11.15

图 11.15:风格转移后的图像

从前述图片可以看出,图像是内容和风格图像的组合。

通过这种方式,我们已经看到了两种操作图像的方法:对图像进行对抗性攻击以修改图像的类别,以及风格转移来将一个图像的风格与另一个图像的内容结合。在接下来的部分,我们将学习生成 Deepfakes,这将表情从一个脸部转移到另一个脸部。

理解 Deepfakes

到目前为止,我们已经了解了两种不同的图像到图像的任务:使用 UNet 进行语义分割和使用自动编码器进行图像重构。Deepfakery 是一个具有非常相似基础理论的图像到图像任务。

深度伪造的工作原理

想象一种情况,你想创建一个应用程序,它可以拍摄一张脸部图片,并以你期望的方式改变表情。在这种情况下,Deepfakes 非常有用。尽管我们有意选择在本书中不讨论最新的 Deepfakes 技术,但一些技术如少样本对抗学习已经发展出来,用于生成具有感兴趣面部表情的逼真图片。了解 Deepfakes 的工作原理和 GANs(你将在下一章节学习到)将帮助你识别假视频。

在深伪造任务中,我们有几百张个人 A 的图片和几百张个人 B 的图片(或者可能是个人 A 和 B 的视频)。目标是重建具有个人 A 表情的个人 B 的面部,反之亦然。

以下图示解释了深度伪造图像生成过程的工作原理:

图 11.16:自动编码器工作流程,其中有一个编码器和两个类/集合的面部的独立解码器

在上述图片中,我们通过编码器(编码器)传递个人 A 和个人 B 的图像。一旦我们得到与个人 A(潜在面部 A)和个人 B(潜在面部 B)对应的潜在向量,我们通过它们对应的解码器(解码器 A解码器 B)传递潜在向量以获取相应的原始图像(重构面部 A重构面部 B)。到目前为止,编码器和两个解码器的概念与我们在理解自动编码器部分看到的非常相似。然而,在这种情况下,我们只有一个编码器,但有两个解码器(每个解码器对应不同的人)。期望从编码器获取的潜在向量表示图像中存在的面部表情的信息,而解码器获取相应的图像。一旦编码器和两个解码器训练好了,在执行深度伪造图像生成时,我们在我们的架构中切换连接如下:

图 11.17:从潜在表示图像重构一旦解码器被交换

当个人 A 的潜在向量通过解码器 B 传递时,个人 B 的重构面部将具有个人 A 的特征(一个微笑的面孔),反之亦然,当个人 B 通过解码器 A 传递时(一个悲伤的面孔)。

另一个有助于生成逼真图像的技巧是对脸部图像进行扭曲,并以这样的方式将它们馈送到网络中,扭曲的脸部作为输入,期望原始图像作为输出。

现在我们了解了它的工作原理,让我们使用自动编码器实现生成具有另一个人表情的假图像。

生成深度伪造

现在让我们来看一个实际例子:

以下代码可在本书的 GitHub 存储库的 Chapter11 文件夹中的 Generating_Deep_Fakes.ipynb 中找到:bit.ly/mcvp-2e。代码包含用于下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 中执行该笔记本,以重现结果,同时理解文本中执行步骤和各种代码组件的解释。

  1. 让我们下载数据(我们已经合成创建)和源代码如下:

    import os
    if not os.path.exists('Faceswap-Deepfake-Pytorch'):
        !wget -q https://www.dropbox.com/s/5ji7jl7httso9ny/person_images.zip
        !wget -q https://raw.githubusercontent.com/sizhky/deep-fake-util/main/random_warp.py
        !unzip -q person_images.zip
    !pip install -q torch_snippets torch_summary
    from torch_snippets import *
    from random_warp import get_training_data 
    
  2. 从图像中获取面部截图如下:

    1. 定义面部级联,它在图像中绘制一个围绕面部的边界框。在 GitHub 存储库的OpenCV 图像分析实用程序PDF 中有关级联的更多信息。但是,目前仅需说明面部级联在图像中绘制紧密的面部边界框:
    face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \
                            'haarcascade_frontalface_default.xml') 
    
    1. 为从图像中裁剪面部定义一个名为crop_face的函数:
    def crop_face(img):
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        faces = face_cascade.detectMultiScale(gray, 1.3, 5)
        if(len(faces)>0):
            for (x,y,w,h) in faces:
                img2 = img[y:(y+h),x:(x+w),:]
            img2 = cv2.resize(img2,(256,256))
            return img2, True
        else:
            return img, False 
    

    在前述函数中,我们通过面部级联将灰度图像(gray)传递,并裁剪包含面部的矩形。接下来,我们返回一个重新调整大小的图像(img2)。此外,为了考虑到在图像中未检测到面部的情况,我们传递一个标志以显示是否检测到面部。

    1. 裁剪personApersonB的图像,并将它们放在不同的文件夹中:
    !mkdir cropped_faces_personA
    !mkdir cropped_faces_personB
    def crop_images(folder):
        images = Glob(folder+'/*.jpg')
        for i in range(len(images)):
            img = read(images[i],1)
            img2, face_detected = crop_face(img)
            if(face_detected==False):
                continue
            else:
                cv2.imwrite('cropped_faces_'+folder+'/'+str(i)+ \
                    '.jpg',cv2.cvtColor(img2, cv2.COLOR_RGB2BGR))
    crop_images('personA')
    crop_images('personB') 
    
  3. 创建一个数据加载器并检查数据:

    class ImageDataset(Dataset):
        def __init__(self, items_A, items_B):
            self.items_A = np.concatenate([read(f,1)[None] \
                                           for f in items_A])/255.
            self.items_B = np.concatenate([read(f,1)[None] \
                                           for f in items_B])/255.
            self.items_A += self.items_B.mean(axis=(0, 1, 2)) \
                            - self.items_A.mean(axis=(0, 1, 2))
        def __len__(self):
            return min(len(self.items_A), len(self.items_B))
        def __getitem__(self, ix):
            a, b = choose(self.items_A), choose(self.items_B)
            return a, b
        def collate_fn(self, batch):
            imsA, imsB = list(zip(*batch))
            imsA, targetA = get_training_data(imsA, len(imsA))
            imsB, targetB = get_training_data(imsB, len(imsB))
            imsA, imsB, targetA, targetB = [torch.Tensor(i)\
                                            .permute(0,3,1,2)\
                                            .to(device) \
                                            for i in [imsA, imsB,\
                                            targetA, targetB]]
            return imsA, imsB, targetA, targetB
    a = ImageDataset(Glob('cropped_faces_personA'), \
                     Glob('cropped_faces_personB'))
    x = DataLoader(a, batch_size=32, collate_fn=a.collate_fn) 
    

数据加载器返回四个张量,imsAimsBtargetAtargetB。第一个张量(imsA)是第三个张量(targetA)的扭曲(变形)版本,第二个(imsB)是第四个张量(targetB)的扭曲(变形)版本。

此外,正如您在a =ImageDataset(Glob('cropped_faces_personA'), Glob('cropped_faces_personB'))行中看到的那样,我们有两个图像文件夹,每个人一个。这些面部之间没有任何关系,并且在__iteritems__数据集中,我们每次随机获取两张面部。

此步骤中的关键函数是get_training_data,出现在collate_fn中。这是一个用于扭曲(变形)面部的增强函数。我们将扭曲的面部作为自编码器的输入,并尝试预测正常的面部。

扭曲的优势不仅在于它增加了我们的训练数据量,而且还作为网络的正则化器,强制它理解关键面部特征,尽管提供的是扭曲的面部。

  1. 让我们检查一些图像:

    inspect(*next(iter(x)))
    for i in next(iter(x)):
        subplots(i[:8], nc=4, sz=(4,2)) 
    

上述代码的输出如下:

图 11.18:一批图像的输入和输出组合

请注意,输入图像是扭曲的,而输出图像则不是,输入到输出图像现在具有一对一的对应关系。

  1. 构建模型如下:

    1. 定义卷积(_ConvLayer)和上采样(_UpScale)函数以及在构建模型时将利用的Reshape类:

      def _ConvLayer(input_features, output_features):
          return nn.Sequential(
              nn.Conv2d(input_features, output_features, 
                        kernel_size=5, stride=2, padding=2),
              nn.LeakyReLU(0.1, inplace=True)
          )
      def _UpScale(input_features, output_features):
          return nn.Sequential(
              nn.ConvTranspose2d(input_features, output_features, 
                               kernel_size=2, stride=2, padding=0),
              nn.LeakyReLU(0.1, inplace=True)
          )
      class Reshape(nn.Module):
          def forward(self, input):
              output = input.view(-1, 1024, 4, 4) # channel * 4 * 4
              return output 
      
    2. 定义Autoencoder模型类,它有一个单独的encoder和两个解码器(decoder_Adecoder_B):

      class Autoencoder(nn.Module):
          def __init__(self):
              super(Autoencoder, self).__init__()
              self.encoder = nn.Sequential(
                              _ConvLayer(3, 128),
                              _ConvLayer(128, 256),
                              _ConvLayer(256, 512),
                              _ConvLayer(512, 1024),
                              nn.Flatten(),
                              nn.Linear(1024 * 4 * 4, 1024),
                              nn.Linear(1024, 1024 * 4 * 4),
                              Reshape(),
                              _UpScale(1024, 512),
                          )
              self.decoder_A = nn.Sequential(
                              _UpScale(512, 256),
                              _UpScale(256, 128),
                              _UpScale(128, 64),
                              nn.Conv2d(64, 3, kernel_size=3, padding=1),
                              nn.Sigmoid(),
                          )
              self.decoder_B = nn.Sequential(
                              _UpScale(512, 256),
                              _UpScale(256, 128),
                              _UpScale(128, 64),
                              nn.Conv2d(64, 3, kernel_size=3, padding=1),
                              nn.Sigmoid(),
                          )
          def forward(self, x, select='A'):
              if select == 'A':
                  out = self.encoder(x)
                  out = self.decoder_A(out)
              else:
                  out = self.encoder(x)
                  out = self.decoder_B(out)
              return out 
      
  2. 生成模型的摘要:

    from torchsummary import summary
    model = Autoencoder()
    summary(model, torch.zeros(32,3,64,64), 'A'); 
    

上述代码生成以下输出:

图 11.19:模型架构总结

  1. 定义train_batch逻辑:

    def train_batch(model, data, criterion, optimizes):
        optA, optB = optimizers
        optA.zero_grad()
        optB.zero_grad()
        imgA, imgB, targetA, targetB = data
        _imgA, _imgB = model(imgA, 'A'), model(imgB, 'B')
        lossA = criterion(_imgA, targetA)
        lossB = criterion(_imgB, targetB)
    
        lossA.backward()
        lossB.backward()
        optA.step()
        optB.step()
        return lossA.item(), lossB.item() 
    

我们感兴趣的是运行model(imgA, 'B')(它将使用类 A 的输入图像返回类 B 的图像),但我们没有地面真相来进行比较。因此,我们正在预测imgA(其中imgAtargetA的扭曲版本)的_imgA,并使用nn.L1Loss_imgAtargetA进行比较。

我们不需要validate_batch,因为没有验证数据集。在训练期间,我们将预测新图像,并在质量上看到进展。

  1. 创建训练模型所需的所有组件:

    model = Autoencoder().to(device)
    dataset = ImageDataset(Glob('cropped_faces_personA'), \
                           Glob('cropped_faces_personB'))
    dataloader = DataLoader(dataset, 32, collate_fn=dataset.collate_fn)
    optimizers=optim.Adam( \
                    [{'params': model.encoder.parameters()}, \
                     {'params': model.decoder_A.parameters()}], \
                     lr=5e-5, betas=(0.5, 0.999)), \
            optim.Adam([{'params': model.encoder.parameters()}, \
                     {'params': model.decoder_B.parameters()}], \
                            lr=5e-5, betas=(0.5, 0.999))
    
    criterion = nn.L1Loss() 
    
  2. 训练模型:

    n_epochs = 1000
    log = Report(n_epochs)
    !mkdir checkpoint
    for ex in range(n_epochs):
        N = len(dataloader)
        for bx,data in enumerate(dataloader):
            lossA, lossB = train_batch(model, data, 
                                       criterion, optimizers)
            log.record(ex+(1+bx)/N, lossA=lossA, 
                       lossB=lossB, end='\r')
        log.report_avgs(ex+1)
        if (ex+1)%100 == 0:
            state = {
                    'state': model.state_dict(),
                    'epoch': ex
                }
            torch.save(state, './checkpoint/autoencoder.pth')
        if (ex+1)%100 == 0:
            bs = 5
            a,b,A,B = data
            line('A to B')
            _a = model(a[:bs], 'A')
            _b = model(a[:bs], 'B')
            x = torch.cat([A[:bs],_a,_b])
            subplots(x, nc=bs, figsize=(bs*2, 5))
            line('B to A')
            _a = model(b[:bs], 'A')
            _b = model(b[:bs], 'B')
            x = torch.cat([B[:bs],_a,_b])
            subplots(x, nc=bs, figsize=(bs*2, 5))
    log.plot_epochs() 
    

上述代码导致重构的图像如下:

图 11.20:原始和重构图像

损失值的变化如下(您可以参考书籍的数字版获取彩色图像):

图 11.21:随着 epochs 增加,损失的变化

正如您所看到的,我们可以通过调整自编码器以使用两个解码器而不是一个,从而在一个面孔和另一个面孔之间交换表情。此外,随着更多的 epochs,重构的图像变得更加逼真。

摘要

在本章中,我们学习了不同变体的自编码器:香草、卷积和变分。我们还学习了瓶颈层单位数量如何影响重构图像。接下来,我们学习了使用 t-SNE 技术识别与给定图像相似的图像。我们了解到当我们对向量进行采样时,无法获得逼真的图像,通过使用变分自编码器,我们学习了如何通过重构损失和 KL 散度损失生成新图像。接着,我们学习了如何对图像进行对抗攻击,以修改图像的类别而不改变图像的感知内容。然后,我们学习了如何利用内容损失和基于 Gram 矩阵的风格损失的组合来优化图像的内容和风格损失,从而生成两个输入图像的组合图像。最后,我们学习了如何调整自编码器以在没有任何监督的情况下交换两个面孔。

现在我们已经学习了如何从给定的图像集生成新图像,下一章中,我们将进一步讨论这个主题,使用生成对抗网络的变体生成全新的图像。

问题

  1. 自编码器中的“编码器”是什么?

  2. 自编码器优化哪种损失函数?

  3. 自编码器如何帮助分组类似的图像?

  4. 卷积自编码器在何时有用?

  5. 如果我们从香草/卷积自编码器获得的嵌入向量空间随机采样,为什么会得到非直观的图像?

  6. 变分自编码器优化的是哪些损失函数?

  7. 变分自编码器如何克服香草/卷积自编码器生成新图像的限制?

  8. 在对抗攻击期间,为什么我们修改输入图像的像素而不是权重值?

  9. 在神经风格转移中,我们优化哪些损失以获得最佳结果?

  10. 当计算风格和内容损失时,为什么要考虑不同层的激活而不是原始图像?

  11. 当计算风格损失时,为什么要考虑 Gram 矩阵损失而不是图像之间的差异?

  12. 在构建用于生成深度伪造的模型时,为什么我们会扭曲图像?

在 Discord 上了解更多信息

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第十二章:使用 GAN 生成图像

在上一章中,我们学习了如何使用神经风格转移操作图片,并将一个图像中的表情叠加到另一个图像上。但是,如果我们给网络一堆图片,并要求它自行创造出一个全新的图片,会怎样呢?

生成对抗网络(GANs)是实现给定一组图片生成图像的尝试。在本章中,我们将从理解使得 GANs 起作用的原理开始,然后从头开始构建一个。这是一个不断扩展的广阔领域,即使我们在撰写本书时,也在扩展中。本章将通过介绍三种变体来奠定 GANs 的基础;在下一章中,我们将学习更高级的 GANs 及其应用。

在本章中,我们将探讨以下主题:

  • 引入 GAN

  • 使用 GAN 生成手写数字

  • 使用 DCGAN 生成人脸图像

  • 实现条件 GANs

本章中所有代码片段均可在 Github 仓库的 Chapter12 文件夹中找到,链接为 bit.ly/mcvp-2e

引入 GAN

要理解 GAN,我们需要理解两个术语:生成器和判别器。首先,我们应该有一个合理数量的对象图片样本(100-1000 张图片)。生成网络(生成器)从图片样本中学习表示,并生成类似样本图片的图片。判别网络(判别器)则会查看生成器网络生成的图片和原始图片样本,并将图片分类为原始或生成的(伪造的)。

生成器网络试图以一种方式生成图片,使得判别器将这些图片分类为真实的。判别器网络试图将生成的图片分类为假的,并将原始样本中的图片分类为真实的。

本质上,GAN 中的对抗术语表示了两个网络相反性质的对立面——一个生成器网络,生成图像以欺骗判别器网络,和一个判别器网络,通过判断图像是生成的还是原始的来分类每张图片

让我们通过下图了解 GANs 的工作过程:

图形用户界面  自动生成的描述

图 12.1:典型的 GAN 工作流程

在上述图表中,生成器网络从随机噪声生成图像作为输入。鉴别器网络查看生成器生成的图像,并将其与真实数据(提供的图像样本)进行比较,以确定生成的图像是真实还是伪造的。生成器试图生成尽可能多的逼真图像,而鉴别器则试图检测生成器生成的图像中哪些是伪造的。这样一来,生成器通过学习鉴别器的观察对象,学会尽可能多地生成逼真图像。

通常情况下,在每一步训练中,生成器和鉴别器交替训练。这样做类似于警察和小偷的游戏,生成器是试图生成伪造数据的小偷,而鉴别器则是试图辨别现有数据是真实还是伪造的警察。训练 GAN 的步骤如下:

  1. 训练生成器(而不是鉴别器)以生成被鉴别器分类为真实的图像。

  2. 训练鉴别器(而不是生成器)以将生成器生成的图像分类为伪造的。

  3. 重复该过程直到达到平衡。

现在让我们理解如何使用以下图表和步骤计算生成器和鉴别器的损失值,从而同时训练两个网络:

自动生成的图表描述

图 12.2:GAN 训练工作流程(虚线表示训练,实线表示过程)

在上述场景中,当鉴别器能够非常好地检测生成的图像时,与生成器相关的损失要比与鉴别器相关的损失高得多。因此,梯度调整得使得生成器的损失更低。然而,这会使得鉴别器的损失偏高。在下一次迭代中,梯度会调整,以使得鉴别器的损失更低。这样一来,生成器和鉴别器不断训练,直到生成器生成逼真图像,而鉴别器无法区分真实图像和生成图像。

在了解这些之后,让我们在下一节生成与 MNIST 数据集相关的图像。

使用 GAN 生成手写数字。

要生成手写数字图像,我们将利用前一节学习的同一网络。我们将采用以下策略:

  1. 导入 MNIST 数据。

  2. 初始化随机噪声。

  3. 定义生成器模型。

  4. 定义鉴别器模型。

  5. 交替训练两个模型。

  6. 让模型训练,直到生成器和鉴别器的损失大致相同。

让我们在下面的代码中执行上述每个步骤。

本书 GitHub 存储库中的Chapter12文件夹中提供了Handwritten_digit_generation_using_GAN.ipynb代码:bit.ly/mcvp-2e。代码相当冗长。我们强烈建议您在 GitHub 上执行此笔记本,以重现结果,并在理解步骤和文本中各种代码组件的解释时进行操作。

  1. 导入相关包并定义设备:

    !pip install -q torch_snippets
    from torch_snippets import *
    device = "cuda" if torch.cuda.is_available() else "cpu"
    from torchvision.utils import make_grid 
    
  2. 导入MNIST数据,并使用内置数据转换定义数据加载器,以便将输入数据缩放到均值 0.5 和标准差 0.5:

    from torchvision.datasets import MNIST
    from torchvision import transforms
    transform = transforms.Compose([
                    transforms.ToTensor(),
                    transforms.Normalize(mean=(0.5,), std=(0.5,))
            ])
    data_loader = torch.utils.data.DataLoader(MNIST('~/data', \
                           train=True, download=True, transform=transform), \
                           batch_size=128, shuffle=True, drop_last=True) 
    
  3. 定义Discriminator模型类:

    class Discriminator(nn.Module):
        def __init__(self):
            super().__init__()
            self.model = nn.Sequential( 
                                    nn.Linear(784, 1024),
                                    nn.LeakyReLU(0.2),
                                    nn.Dropout(0.3),
                                    nn.Linear(1024, 512),
                                    nn.LeakyReLU(0.2),
                                    nn.Dropout(0.3),
                                    nn.Linear(512, 256),
                                    nn.LeakyReLU(0.2),
                                    nn.Dropout(0.3),
                                    nn.Linear(256, 1),
                                    nn.Sigmoid()
                                )
        def forward(self, x): return self.model(x) 
    

鉴别器网络摘要如下:

在前述代码中,请注意,我们使用了LeakyReLU(关于不同类型 ReLU 激活的实证评估可在此处找到:arxiv.org/pdf/1505.00853)作为激活函数,而不是ReLU

  1. !pip install torch_summary
    from torchsummary import summary
    discriminator = Discriminator().to(device)
    summary(discriminator,torch.zeros(1,784)) 
    

上述代码生成以下输出:

自动生成表格描述

图 12.3:鉴别器架构摘要

  1. 定义Generator模型类:

    class Generator(nn.Module):
        def __init__(self):
            super().__init__()
            self.model = nn.Sequential(
                                    nn.Linear(100, 256),
                                    nn.LeakyReLU(0.2),
                                    nn.Linear(256, 512),
                                    nn.LeakyReLU(0.2),
                                    nn.Linear(512, 1024),
                                    nn.LeakyReLU(0.2),
                                    nn.Linear(1024, 784),
                                    nn.Tanh()
                                )
        def forward(self, x): return self.model(x) 
    

注意,生成器接受 100 维输入(即随机噪声),并从输入生成图像。生成器模型摘要如下:

  1. generator = Generator().to(device)
    summary(generator,torch.zeros(1,100)) 
    

上述代码生成以下输出:

自动生成表格描述

图 12.4:生成器架构摘要

  1. 定义一个生成随机噪声并将其注册到设备的函数:

    def noise(size):
        n = torch.randn(size, 100)
        return n.to(device) 
    
  2. 定义训练鉴别器的函数如下:

    1. 鉴别器训练函数(discriminator_train_step)接受真实数据(real_data)和假数据(fake_data)作为输入:
    def discriminator_train_step(real_data, fake_data): 
    
    1. 重置梯度:
     d_optimizer.zero_grad() 
    
    1. 在进行损失值反向传播之前,在真数据(real_data)上进行预测并计算损失(error_real):
     prediction_real = discriminator(real_data)
            error_real = loss(prediction_real, \
                              torch.ones(len(real_data),1).to(device))
            error_real.backward() 
    

    当我们计算真实数据上的鉴别器损失时,我们期望鉴别器预测输出为1。因此,在鉴别器训练期间,通过使用torch.ones来计算真实数据上的鉴别器损失,预期鉴别器预测输出为1

    1. 在进行损失值反向传播之前,在假数据(fake_data)上进行预测并计算损失(error_fake):
     prediction_fake = discriminator(fake_data)
            error_fake = loss(prediction_fake, \
                        torch.zeros(len(fake_data),1).to(device))
            error_fake.backward() 
    

    当我们计算假数据上的鉴别器损失时,我们期望鉴别器预测输出为0。因此,在鉴别器训练期间,通过使用torch.zeros来计算假数据上的鉴别器损失,预期鉴别器预测输出为0

    1. 更新权重并返回总体损失(将error_realreal_data上的损失值与error_fakefake_data上的损失值相加):
     d_optimizer.step()
            return error_real + error_fake 
    
  3. 以以下方式训练生成器模型:

    1. 定义生成器训练函数(generator_train_step),接受假数据(fake_data)作为输入:
    def generator_train_step(fake_data): 
    
    1. 重置生成器优化器的梯度:
     g_optimizer.zero_grad() 
    
    1. 预测鉴别器在虚假数据(fake_data)上的输出:
     prediction = discriminator(fake_data) 
    
    1. 通过传递prediction和期望值作为torch.ones来计算生成器损失值,因为我们希望在训练生成器时愚弄鉴别器输出值为1
     error = loss(prediction, torch.ones(len(real_data),1).to(device)) 
    
    1. 执行反向传播,更新权重,并返回错误:
     error.backward()
        g_optimizer.step()
        return error 
    
  4. 定义模型对象,每个生成器和鉴别器的优化器,以及优化损失函数:

    discriminator = Discriminator().to(device)
    generator = Generator().to(device)
    d_optimizer= optim.Adam(discriminator.parameters(),lr=0.0002)
    g_optimizer = optim.Adam(generator.parameters(), lr=0.0002)
    loss = nn.BCELoss()
    num_epochs = 200
    log = Report(num_epochs) 
    
  5. 在增加的 epochs 上运行模型:

    1. 循环通过 200 个 epochs(num_epochs)使用第 2 步获得的data_loader函数:
    for epoch in range(num_epochs):
        N = len(data_loader)
        for i, (images, _) in enumerate(data_loader): 
    
    1. 加载真实数据(real_data)和虚假数据,其中虚假数据(fake_data)是通过将noise(批处理大小为real_data中的数据点数:len(real_data))通过generator网络来获得的。注意,重要的是运行fake_data.detach(),否则训练将无法进行。在分离时,我们正在创建张量的新副本,以便在discriminator_train_step中调用error.backward()时,与生成器相关联的张量(用于创建fake_data)不受影响:
     real_data = images.view(len(images), -1).to(device)
            fake_data=generator(noise(len(real_data))).to(device)
            fake_data = fake_data.detach() 
    
    1. 使用在第 6 步中定义的discriminator_train_step函数训练鉴别器:
     d_loss=discriminator_train_step(real_data, fake_data) 
    
    1. 现在我们已经训练了鉴别器,让我们在这一步中训练生成器。从噪声数据生成一组新的虚假图像(fake_data),并使用第 6 步中定义的generator_train_step训练生成器:
     fake_data=generator(noise(len(real_data))).to(device)
            g_loss = generator_train_step(fake_data) 
    
    1. 记录损失:
     log.record(epoch+(1+i)/N, d_loss=d_loss.item(), \
                       g_loss=g_loss.item(), end='\r')
        log.report_avgs(epoch+1)
    log.plot_epochs(['d_loss', 'g_loss']) 
    

随着 epochs 的增加,鉴别器和生成器的损失如下(您可以参考书籍的数字版本获取彩色图像):

图表,线图 自动生成的描述

图 12.5:随着 epochs 增加的鉴别器和生成器损失

  1. 可视化训练后的虚假数据:

    z = torch.randn(64, 100).to(device)
    sample_images = generator(z).data.cpu().view(64, 1, 28, 28)
    grid = make_grid(sample_images, nrow=8, normalize=True)
    show(grid.cpu().detach().permute(1,2,0), sz=5) 
    

上述代码生成以下输出:

形状,箭头 自动生成的描述

图 12.6:生成的数字

从这里我们可以看出,我们可以利用 GAN 生成逼真的图像,但仍然有一些改进的空间。在下一节中,我们将学习如何使用深度卷积 GAN 生成更逼真的图像。

使用 DCGAN 生成面部图像

在前一节中,我们学习了如何使用 GAN 生成图像。但是,我们已经在第四章引入卷积神经网络中看到,卷积神经网络(CNNs)在图像背景下的表现比普通神经网络更好。在本节中,我们将学习如何使用深度卷积生成对抗网络(DCGANs)生成图像,这些网络在模型中使用卷积和池化操作。

首先,让我们了解我们将利用的技术,使用一组 100 个随机数生成图像(我们选择了 100 个随机数,以便网络有合理数量的值来生成图像。我们鼓励读者尝试不同数量的随机数并查看结果)。我们将首先将噪声转换为批量大小 x 100 x 1 x 1的形状。

在 DCGAN 中附加额外的通道信息而不在 GAN 部分中执行的原因是,我们将在本节中利用 CNN,它需要以批量大小 x 通道 x 高度 x 宽度的形式输入。

接下来,我们通过利用

ConvTranspose2d。正如我们在第九章图像分割中学到的那样,ConvTranspose2d 执行的是卷积操作的相反操作,即使用预定义的内核大小、步长和填充来将输入的较小特征映射大小(高度 x 宽度)上采样到较大的大小。通过这种方式,我们逐渐将大小为批量大小 x 100 x 1 x 1 的随机噪声向量转换为批量大小 x 3 x 64 x 64 的图像。有了这个,我们已经将大小为 100 的随机噪声向量转换为一个人脸图像。

在理解了这一点之后,现在让我们构建一个模型来生成人脸图像:

以下代码在本书 GitHub 存储库的 Chapter12 文件夹中的 Face_generation_using_DCGAN.ipynb 中作为可用。代码包含用于下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 上执行笔记本以重现结果,同时理解执行步骤和文本中各种代码组件的解释。

  1. 下载并提取人脸图像(我们通过生成随机人物的面孔来整理的数据集):

    !wget https://www.dropbox.com/s/rbajpdlh7efkdo1/male_female_face_images.zip
    !unzip male_female_face_images.zip 
    

这里展示了一些图像的样本:

包含文本、姿势、人物、不同的图片 自动产生的描述

图 12.7:示例男性和女性图像

  1. 导入相关包:

    !pip install -q --upgrade torch_snippets
    from torch_snippets import *
    import torchvision
    from torchvision import transforms
    import torchvision.utils as vutils
    import cv2, numpy as np, pandas as pd
    device = "cuda" if torch.cuda.is_available() else "cpu" 
    
  2. 定义数据集和数据加载器:

    1. 确保我们裁剪图像时仅保留人脸,并且丢弃图像中的额外细节。首先,我们将下载级联过滤器(有关级联过滤器的更多信息可以在 GitHub 上的《使用 OpenCV 工具进行图像分析》PDF 中找到),这将有助于识别图像中的人脸:
    face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \
                            'haarcascade_frontalface_default.xml') 
    
    1. 创建一个新文件夹,并将所有裁剪的人脸图像倒入到这个新文件夹中:
    !mkdir cropped_faces
    images = Glob('/content/females/*.jpg') + \
                Glob('/content/males/*.jpg')
    for i in range(len(images)):
        img = read(images[i],1)
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        faces = face_cascade.detectMultiScale(gray, 1.3, 5)
        for (x,y,w,h) in faces:
            img2 = img[y:(y+h),x:(x+w),:]
        cv2.imwrite('cropped_faces/'+str(i)+'.jpg', \
                    cv2.cvtColor(img2, cv2.COLOR_RGB2BGR)) 
    

裁剪后的人脸样本如下:

人们面孔的拼贴 图片上显示了中等置信度自动生成的描述

图 12.8:裁剪的男性和女性面孔

请注意,通过裁剪并仅保留面部,我们保留了我们希望生成的信息。这样,我们可以减少 DCGAN 需要学习的复杂性。

  1. 指定在每个图像上执行的转换:

  2. transform=transforms.Compose([
                                   transforms.Resize(64),
                                   transforms.CenterCrop(64),
                                   transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]) 
    
  3. 定义 Faces 数据集类:

  4. class Faces(Dataset):
        def __init__(self, folder):
            super().__init__()
            self.folder = folder
            self.images = sorted(Glob(folder))
        def __len__(self):
            return len(self.images)
        def __getitem__(self, ix):
            image_path = self.images[ix]
            image = Image.open(image_path)
            image = transform(image)
            return image 
    
  5. 创建数据集对象:ds

  6. ds = Faces(folder='cropped_faces/') 
    
  7. 如下所示定义dataloader类:

  8. dataloader = DataLoader(ds, batch_size=64, shuffle=True, num_workers=8) 
    
  9. 定义权重初始化,使得权重的分布更小,正如在对抗训练部分的详细说明中提到的那样,参见arxiv.org/pdf/1511.06434

    def weights_init(m):
        classname = m.__class__.__name__
        if classname.find('Conv') != -1:
            nn.init.normal_(m.weight.data, 0.0, 0.02)
        elif classname.find('BatchNorm') != -1:
            nn.init.normal_(m.weight.data, 1.0, 0.02)
            nn.init.constant_(m.bias.data, 0) 
    
  10. 定义Discriminator模型类,接受形状为批量大小 x 3 x 64 x 64 的图像,并预测其真实性或伪造性:

    class Discriminator(nn.Module):
        def __init__(self):
            super(Discriminator, self).__init__()
            self.model = nn.Sequential(
                nn.Conv2d(3,64,4,2,1,bias=False),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Conv2d(64,64*2,4,2,1,bias=False),
                nn.BatchNorm2d(64*2),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Conv2d(64*2,64*4,4,2,1,bias=False),
                nn.BatchNorm2d(64*4),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Conv2d(64*4,64*8,4,2,1,bias=False),
                nn.BatchNorm2d(64*8),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Conv2d(64*8,1,4,1,0,bias=False),
                nn.Sigmoid()
            )
            self.apply(weights_init)
        def forward(self, input): return self.model(input) 
    

获取定义模型的摘要:

  1. !pip install torch_summary
    from torchsummary import summary
    discriminator = Discriminator().to(device)
    summary(discriminator,torch.zeros(1,3,64,64)); 
    

上述代码生成如下输出:

计算机屏幕截图  自动生成描述

图 12.9: 鉴别器架构摘要

  1. 定义Generator模型类,从形状为批量大小 x 100 x 1 x 1 的输入生成虚假图像:

    class Generator(nn.Module):
        def __init__(self):
            super(Generator,self).__init__()
            self.model = nn.Sequential(
                nn.ConvTranspose2d(100,64*8,4,1,0,bias=False,),
                nn.BatchNorm2d(64*8),
                nn.ReLU(True),
                nn.ConvTranspose2d(64*8,64*4,4,2,1,bias=False),
                nn.BatchNorm2d(64*4),
                nn.ReLU(True),
                nn.ConvTranspose2d( 64*4,64*2,4,2,1,bias=False),
                nn.BatchNorm2d(64*2),
                nn.ReLU(True),
                nn.ConvTranspose2d( 64*2,64,4,2,1,bias=False),
                nn.BatchNorm2d(64),
                nn.ReLU(True),
                nn.ConvTranspose2d( 64,3,4,2,1,bias=False),
                nn.Tanh()
            )
            self.apply(weights_init)
        def forward(self,input): return self.model(input) 
    

获取定义模型的摘要:

  1. generator = Generator().to(device)
    summary(generator,torch.zeros(1,100,1,1)) 
    

上述代码生成如下输出:

计算机屏幕截图  自动生成描述

图 12.10: 生成器架构摘要

请注意,我们利用ConvTranspose2d逐渐上采样数组,使其尽可能地类似于图像。

  1. 定义用于训练生成器(generator_train_step)和鉴别器(discriminator_train_step)的函数:

    def discriminator_train_step(real_data, fake_data):
        d_optimizer.zero_grad()
        prediction_real = discriminator(real_data)
        error_real = loss(prediction_real.squeeze(), \
                          torch.ones(len(real_data)).to(device))
        error_real.backward()
        prediction_fake = discriminator(fake_data)
        error_fake = loss(prediction_fake.squeeze(), \
                          torch.zeros(len(fake_data)).to(device))
        error_fake.backward()
        d_optimizer.step()
        return error_real + error_fake
    def generator_train_step(fake_data):
        g_optimizer.zero_grad()
        prediction = discriminator(fake_data)
        error = loss(prediction.squeeze(), \
                     torch.ones(len(real_data)).to(device))
        error.backward()
        g_optimizer.step()
        return error 
    

在上述代码中,我们在模型输出上执行了.squeeze操作,因为模型的输出形状为批量大小 x 1 x 1 x 1,需要与形状为批量大小 x 1 的张量进行比较。

  1. 创建生成器和鉴别器模型对象、优化器和要优化的鉴别器损失函数:

    discriminator = Discriminator().to(device)
    generator = Generator().to(device)
    loss = nn.BCELoss()
    d_optimizer = optim.Adam(discriminator.parameters(), \
                             lr=0.0002, betas=(0.5, 0.999))
    g_optimizer = optim.Adam(generator.parameters(), \
                             lr=0.0002, betas=(0.5, 0.999)) 
    
  2. 在逐步增加的周期上运行模型,如下所示:

    1. 循环通过步骤 3中定义的dataloader函数 25 个周期:
    log = Report(25)
    for epoch in range(25):
        N = len(dataloader)
        for i, images in enumerate(dataloader): 
    
    1. 通过生成器网络传递真实数据(real_data)加载真实数据,并生成虚假数据(fake_data):
     real_data = images.to(device)
            fake_data = generator(torch.randn(len(real_data), 100, 1, \
                                        1).to(device)).to(device)
            fake_data = fake_data.detach() 
    

    请注意,当生成real_data时,普通 GAN 和 DCGAN 之间的主要区别在于,在 DCGAN 的情况下,我们不必展平real_data,因为我们正在利用 CNN。

    1. 使用步骤 7中定义的discriminator_train_step函数训练鉴别器:
    d_loss=discriminator_train_step(real_data, fake_data) 
    
    1. 从嘈杂数据(torch.randn(len(real_data)))生成一组新图像(fake_data),并使用步骤 7中定义的generator_train_step函数训练生成器:
    fake_data = generator(torch.randn(len(real_data), \
                        100, 1, 1).to(device)).to(device)
    g_loss = generator_train_step(fake_data) 
    
    1. 记录损失:
     log.record(epoch+(1+i)/N, d_loss=d_loss.item(), \
                       g_loss=g_loss.item(), end='\r')
      log.report_avgs(epoch+1)
    log.plot_epochs(['d_loss','g_loss']) 
    

上述代码生成如下输出(您可以参考书籍的数字版本查看彩色图像):

图表,线图  自动生成描述

图 12.11: 随着训练周期增加,鉴别器和生成器的损失

请注意,在这种设置中,生成器和鉴别器损失的变化不遵循我们在手写数字生成情况下看到的模式,原因如下:

  • 我们处理更大的图像(图像形状为 64 x 64 x 3,与前一节中的 28 x 28 x 1 形状的图像相比)。

  • 与人脸图像中存在的特征相比,数字的变化较少。

  • 与图像中的信息相比,手写数字中的信息仅在少数像素中可用。

一旦训练过程完成,使用以下代码生成一组样本图像:

generator.eval()
noise = torch.randn(64, 100, 1, 1, device=device)
sample_images = generator(noise).detach().cpu()
grid = vutils.make_grid(sample_images,nrow=8,normalize=True)
show(grid.cpu().detach().permute(1,2,0), sz=10, \
     title='Generated images') 

前述代码生成了以下一组图像:

一个人脸的拼贴描述自动生成

图 12.12:从训练过的 DCGAN 模型生成的图像

注意,尽管生成器从随机噪声生成了人脸图像,这些图像看起来还算不错,但仍然不够逼真。一个潜在的原因是并非所有输入图像都具有相同的面部对齐。作为练习,我们建议您仅在原始图像中没有倾斜面孔且人物直视摄像头的图像上训练 DCGAN。

此外,我们建议您尝试将生成图像与具有高鉴别器分数和低鉴别器分数的图像进行对比。

在这一部分中,我们已经学习了如何生成人脸的图像。然而,我们无法指定我们感兴趣的图像的生成(例如,一个有胡须的男人)。在接下来的部分中,我们将致力于生成特定类别的图像。

实现条件生成对抗网络

想象一种情景,我们想要生成我们感兴趣的类别的图像;例如,一只猫、一只狗,或者一位戴眼镜的男人的图像。我们如何指定我们想要生成我们感兴趣的图像呢?条件生成对抗网络(Conditional GANs)在这种情况下派上了用场。暂时假设我们只有男性和女性面部图像以及它们对应的标签。在这一部分中,我们将学习如何从随机噪声生成指定类别的图像。

我们采用的策略如下:

  1. 指定我们要生成的图像的标签为独热编码版本。

  2. 将标签通过嵌入层传递,以生成每个类别的多维表示。

  3. 生成随机噪声并与上一步生成的嵌入层串联。

  4. 训练模型的方法与前几节相同,但这次要将噪声向量与我们希望生成的图像类别的嵌入串联起来。

在下面的代码中,我们将编写前述策略:

此书的 GitHub 存储库中的 Chapter12 文件夹中提供了名为 Face_generation_using_Conditional_GAN.ipynb 的以下代码:bit.ly/mcvp-2e。我们强烈建议您在 GitHub 中执行此笔记本,以重现结果,同时理解执行步骤和文本中各种代码组件的解释。

  1. 导入图像和相关包:

    !wget https://www.dropbox.com/s/rbajpdlh7efkdo1/male_female_face_images.zip
    !unzip male_female_face_images.zip
    !pip install -q --upgrade torch_snippets
    from torch_snippets import *
    device = "cuda" if torch.cuda.is_available() else "cpu"
    from torchvision.utils import make_grid
    from torch_snippets import *
    from PIL import Image
    import torchvision
    from torchvision import transforms
    import torchvision.utils as vutils 
    
  2. 创建数据集和数据加载器,如下所示:

    1. 存储男性和女性图像路径:
    female_images = Glob('/content/females/*.jpg')
    male_images = Glob('/content/males/*.jpg') 
    
    1. 确保裁剪图像,仅保留面部并丢弃图像中的其他细节。首先,我们将下载级联滤波器(有关级联滤波器的更多信息可在 GitHub 上的 Using OpenCV Utilities for Image Analysis PDF 中找到,这将帮助识别图像中的面部):
    face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \
                            'haarcascade_frontalface_default.xml') 
    
    1. 创建两个新文件夹(一个对应男性图像,另一个对应女性图像),并将所有裁剪后的面部图像转储到相应的文件夹中:
    !mkdir cropped_faces_females
    !mkdir cropped_faces_males
    def crop_images(folder):
        images = Glob(folder+'/*.jpg')
        for i in range(len(images)):
            img = read(images[i],1)
            gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
            faces = face_cascade.detectMultiScale(gray, 1.3, 5)
            for (x,y,w,h) in faces:
                img2 = img[y:(y+h),x:(x+w),:]
                cv2.imwrite('cropped_faces_'+folder+'/'+ \
                        str(i)+'.jpg',cv2.cvtColor(img2, cv2.COLOR_RGB2BGR))
    crop_images('females')
    crop_images('males') 
    
    1. 指定要在每个图像上执行的转换:
    transform=transforms.Compose([
                                   transforms.Resize(64),
                                   transforms.CenterCrop(64),
                                   transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]) 
    
    1. 创建 Faces 数据集类,返回图像及其中人物的性别:
    class Faces(Dataset):
        def __init__(self, folders):
            super().__init__()
            self.folderfemale = folders[0]
            self.foldermale = folders[1]
            self.images = sorted(Glob(self.folderfemale)) + \
                            sorted(Glob(self.foldermale))
        def __len__(self):
            return len(self.images)
        def __getitem__(self, ix):
            image_path = self.images[ix]
            image = Image.open(image_path)
            image = transform(image)
            gender = np.where('female' in image_path,1,0)
            return image, torch.tensor(gender).long() 
    
    1. 定义 ds 数据集和 dataloader
    ds = Faces(folders=['cropped_faces_females', \
                        'cropped_faces_males'])
    dataloader = DataLoader(ds, batch_size=64, \
                            shuffle=True, num_workers=8) 
    
  3. 定义权重初始化方法(就像我们在 Using DCGANs to generate face images 部分中所做的那样),以便随机初始化的权重值没有普遍变化:

    def weights_init(m):
        classname = m.__class__.__name__
        if classname.find('Conv') != -1:
            nn.init.normal_(m.weight.data, 0.0, 0.02)
        elif classname.find('BatchNorm') != -1:
            nn.init.normal_(m.weight.data, 1.0, 0.02)
            nn.init.constant_(m.bias.data, 0) 
    
  4. 如下定义 Discriminator 模型类:

    1. 定义模型架构:
    class Discriminator(nn.Module):
        def __init__(self, emb_size=32):
            super(Discriminator, self).__init__()
            self.emb_size = 32
            self.label_embeddings = nn.Embedding(2, self.emb_size)
            self.model = nn.Sequential(
                nn.Conv2d(3,64,4,2,1,bias=False),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Conv2d(64,64*2,4,2,1,bias=False),
                nn.BatchNorm2d(64*2),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Conv2d(64*2,64*4,4,2,1,bias=False),
                nn.BatchNorm2d(64*4),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Conv2d(64*4,64*8,4,2,1,bias=False),
                nn.BatchNorm2d(64*8),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Conv2d(64*8,64,4,2,1,bias=False),
                nn.BatchNorm2d(64),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Flatten()
            )
            self.model2 = nn.Sequential(
                nn.Linear(288,100),
                nn.LeakyReLU(0.2,inplace=True),
                nn.Linear(100,1),
                nn.Sigmoid()
            )
            self.apply(weights_init) 
    
    1. 注意,在模型类中,我们有一个额外的参数 emb_size,它存在于条件 GAN 中,而不是 DCGAN 中。emb_size 表示我们将输入类标签(我们要生成的图像类的类别)转换为的嵌入数量,这些嵌入存储为 label_embeddings。我们将输入类标签从一热编码版本转换为更高维度的嵌入的原因是,模型具有更高的自由度来学习和调整以处理不同的类别。

      虽然模型类在很大程度上与我们在 DCGAN 中看到的相同,我们正在初始化另一个模型 (model2),这个模型执行分类任务。在我们讨论完 forward 方法后,将详细介绍第二个模型如何帮助解释。在你阅读下面的 forward 方法和模型摘要后,你也会理解为什么 self.model2 有 288 个输入值。

    2. 定义 forward 方法,接受图像及其标签作为输入:

    def forward(self, input, labels):
        x = self.model(input)
        y = self.label_embeddings(labels)
        input = torch.cat([x, y], 1)
        final_output = self.model2(input)
        return final_output 
    
    1. 在定义的 forward 方法中,我们获取第一个模型的输出 (self.model(input)) 和通过 label_embeddings 传递 labels 的输出,然后连接这些输出。接下来,我们通过早期定义的第二个模型 (self.model2) 传递连接的输出,它获取我们的判别器输出。

    2. 获取定义模型的摘要:

    !pip install torch_summary
    from torchsummary import summary
    discriminator = Discriminator().to(device)
    summary(discriminator,torch.zeros(32,3,64,64).to(device), \
            torch.zeros(32).long().to(device)); 
    

上述代码生成以下输出:

表 自动生成描述

图 12.13:判别器架构摘要

注意,self.model2 接受 288 个值作为输入,因为 self.model 的输出每个数据点有 256 个值,然后与输入类标签的 32 个嵌入值连接,结果是 256 + 32 = 288 个输入值给 self.model2

  1. 定义 Generator 网络类:

    1. 定义 __init__ 方法:
    class Generator(nn.Module):
        def __init__(self, emb_size=32):
            super(Generator,self).__init__()
            self.emb_size = emb_size
            self.label_embeddings = nn.Embedding(2, self.emb_size) 
    
    1. 注意,在前述代码中,我们使用nn.Embedding将 2D 输入(即类别)转换为 32 维向量(self.emb_size)。
     self.model = nn.Sequential(
                nn.ConvTranspose2d(100+self.emb_size,\
                                   64*8,4,1,0,bias=False),
                nn.BatchNorm2d(64*8),
                nn.ReLU(True),
                nn.ConvTranspose2d(64*8,64*4,4,2,1,bias=False),
                nn.BatchNorm2d(64*4),
                nn.ReLU(True),
                nn.ConvTranspose2d(64*4,64*2,4,2,1,bias=False),
                nn.BatchNorm2d(64*2),
                nn.ReLU(True),
                nn.ConvTranspose2d(64*2,64,4,2,1,bias=False),
                nn.BatchNorm2d(64),
                nn.ReLU(True),
                nn.ConvTranspose2d(64,3,4,2,1,bias=False),
                nn.Tanh()
            ) 
    
    1. 注意,在前述代码中,我们利用了nn.ConvTranspose2d来向上缩放以获取图像作为输出。

    2. 应用权重初始化:

     self.apply(weights_init) 
    
    1. 定义forward方法,该方法接受噪声值(input_noise)和输入标签(labels)作为输入,并生成图像的输出:
    def forward(self,input_noise,labels):
        label_embeddings = self.label_embeddings(labels).view(len(labels), \
                                  self.emb_size,1, 1)
        input = torch.cat([input_noise, label_embeddings], 1)
        return self.model(input) 
    
    1. 获取所定义的generator函数的摘要:
    generator = Generator().to(device)
    summary(generator,torch.zeros(32,100,1,1).to(device), \
            torch.zeros(32).long().to(device)); 
    

前述代码生成如下输出:

自动生成的表格说明

图 12.14:生成器架构摘要

  1. 定义一个函数(noise),用于生成具有 100 个值的随机噪声,并将其注册到设备上:

    def noise(size):
        n = torch.randn(size, 100, 1, 1, device=device)
        return n.to(device) 
    
  2. 定义训练鉴别器的函数:discriminator_train_step

    1. 鉴别器接受四个输入——真实图像(real_data)、真实标签(real_labels)、虚假图像(fake_data)和虚假标签(fake_labels):
    def discriminator_train_step(real_data, real_labels, \
                                 fake_data, fake_labels):
        d_optimizer.zero_grad() 
    

    在这里,我们正在重置鉴别器对应的梯度。

    1. 计算在真实数据(prediction_real通过discriminator网络时的损失值。将输出的损失值与期望值(torch.ones(len(real_data),1).to(device))进行比较,以获取error_real,然后执行反向传播:
    prediction_real = discriminator(real_data, real_labels)
    error_real = loss(prediction_real, \
                      torch.ones(len(real_data),1).to(device))
    error_real.backward() 
    
    1. 计算在虚假数据(prediction_fake通过discriminator网络时的损失值。将输出的损失值与期望值(torch.zeros(len(fake_data),1).to(device))进行比较,以获取error_fake,然后执行反向传播:
    prediction_fake = discriminator(fake_data, fake_labels)
    error_fake = loss(prediction_fake, \
                    torch.zeros(len(fake_data),1).to(device))
    error_fake.backward() 
    
    1. 更新权重并返回损失值:
    d_optimizer.step()    
    return error_real + error_fake 
    
  3. 定义生成器的训练步骤,其中我们将虚假图像(fake_data)与虚假标签(fake_labels)作为输入传递:

    def generator_train_step(fake_data, fake_labels):
        g_optimizer.zero_grad()
        prediction = discriminator(fake_data, fake_labels)
        error = loss(prediction, \
                     torch.ones(len(fake_data), 1).to(device))
        error.backward()
        g_optimizer.step()
        return error 
    

注意,generator_train_step函数类似于discriminator_train_step,唯一的区别在于这里期望输出为torch.ones(len(fake_data),1).to(device)),因为我们正在训练生成器。

  1. 定义generatordiscriminator模型对象、损失优化器和loss函数:

    discriminator = Discriminator().to(device)
    generator = Generator().to(device)
    loss = nn.BCELoss()
    d_optimizer = optim.Adam(discriminator.parameters(), \
                             lr=0.0002, betas=(0.5, 0.999))
    g_optimizer = optim.Adam(generator.parameters(), \
                             lr=0.0002, betas=(0.5, 0.999))
    fixed_noise = torch.randn(64, 100, 1, 1, device=device)
    fixed_fake_labels = torch.LongTensor([0]* (len(fixed_noise)//2) \
                        + [1]*(len(fixed_noise)//2)).to(device)
    loss = nn.BCELoss()
    n_epochs = 25
    img_list = [] 
    

在前述代码中,在定义fixed_fake_labels时,我们指定一半图像对应于一类(类 0),其余图像对应于另一类(类 1)。此外,我们定义了fixed_noise,将用于从随机噪声生成图像。

  1. 在增加的 epochs (n_epochs)上训练模型:

    1. 指定dataloader的长度:
    log = Report(n_epochs)
    for epoch in range(n_epochs):
        N = len(dataloader) 
    
    1. 循环遍历图像批次及其标签:
     for bx, (images, labels) in enumerate(dataloader): 
    
    1. 指定real_datareal_labels
     real_data, real_labels = images.to(device), labels.to(device) 
    
    1. 初始化fake_datafake_labels
     fake_labels = torch.LongTensor(np.random.randint(0, \
                            2,len(real_data))).to(device)
            fake_data=generator(noise(len(real_data)),fake_labels)
            fake_data = fake_data.detach() 
    
    1. 使用在步骤 7中定义的discriminator_train_step函数训练鉴别器以计算鉴别器损失(d_loss):
     d_loss = discriminator_train_step(real_data, \
                    real_labels, fake_data, fake_labels) 
    
    1. 重新生成虚假图像 (fake_data) 和虚假标签 (fake_labels),并使用在 步骤 8 中定义的 generator_train_step 函数来计算生成器损失 (g_loss):
     fake_labels = torch.LongTensor(np.random.randint(0, \
                            2,len(real_data))).to(device)
            fake_data = generator(noise(len(real_data)), \
                                            fake_labels).to(device)
            g_loss = generator_train_step(fake_data, fake_labels) 
    
    1. 记录如下损失指标:
     pos = epoch + (1+bx)/N
            log.record(pos, d_loss=d_loss.detach(), \
                   g_loss=g_loss.detach(), end='\r')
        log.report_avgs(epoch+1) 
    
  2. 一旦训练模型,生成男性和女性图像:

     with torch.no_grad():
            fake = generator(fixed_noise, fixed_fake_labels).detach().cpu()
            imgs = vutils.make_grid(fake, padding=2, \
                                normalize=True).permute(1,2,0)
            img_list.append(imgs)
            show(imgs, sz=10) 
    

在上述代码中,我们将噪声 (fixed_noise) 和标签 (fixed_fake_labels) 传递给生成器,以获取在训练模型 25 个周期后的 fake 图像,结果如下:

一个人脸拼贴图  Description automatically generated

图 12.15:生成的男性和女性面孔

从上述图像中,我们可以看到前 32 张图像对应于男性图像,接下来的 32 张图像对应于女性图像,这证实了条件 GAN 的表现符合预期。

总结

在本章中,我们学习了如何利用两个不同的神经网络使用 GAN 生成手写数字的新图像。接下来,我们使用 DCGAN 生成逼真的面孔。最后,我们学习了条件 GAN,这有助于我们生成特定类别的图像。尽管我们使用不同的技术生成图像,但我们仍然发现生成的图像不够逼真。此外,在条件 GAN 中,虽然我们通过指定要生成的图像类别来生成图像,但我们仍无法执行图像翻译,即要求替换图像中的一个对象,并保留其他一切。此外,我们还没有一种图像生成机制,其中要生成的类别(风格)更无监督。

在下一章中,我们将学习使用一些最新变体的 GAN 生成更逼真的图像。此外,我们将学习以更无监督的方式生成不同风格的图像。

问题

  1. 如果生成器和鉴别器模型的学习率很高会发生什么?

  2. 在生成器和鉴别器都经过充分训练的情况下,给定图像是真实的概率是多少?

  3. 为什么在生成图像时要使用 ConvTranspose2d

  4. 为什么在条件 GAN 中的嵌入大小比类别数高?

  5. 如何生成带胡须的男性图像?

  6. 为什么在生成器的最后一层使用 Tanh 激活而不是 ReLU 或 sigmoid?

  7. 即使我们没有对生成的数据进行反归一化,为什么我们仍然得到逼真的图像?

  8. 如果在训练 GAN 前不裁剪与图像对应的面部会发生什么?

  9. 在训练生成器时,为什么鉴别器的权重不会得到更新(因为 generator_train_step 函数涉及鉴别器网络)?

  10. 在训练鉴别器时为什么要获取真实图像和虚假图像的损失,而在训练生成器时只获取虚假图像的损失?

在 Discord 上了解更多信息

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第十三章:高级 GAN 用于图像操作

在前一章中,我们学习了如何利用生成对抗网络GANs)生成逼真图像。在本章中,我们将学习如何利用 GANs 来操作图像。我们将学习使用 GANs 生成图像的两种变体方法——配对方法和非配对方法。在配对方法中,我们将提供输入和输出的组合对,以生成基于输入图像的新图像,我们将在Pix2Pix GAN中学习到。在非配对方法中,我们将指定输入和输出;然而,我们不会提供输入和输出之间的一对一对应关系,而是期望 GAN 学习两类的结构,并将一个类别的图像转换为另一个类别的图像,这是我们讨论CycleGAN时将要学习的内容。

另一种非配对图像操作的类别涉及从随机向量的潜在空间生成图像,并查看随着潜在向量值的变化图像如何改变,这是我们在利用 StyleGAN 在自定义图像上部分将要学习的内容。最后,我们将学习如何利用预训练的 GAN——超分辨率生成对抗网络SRGAN),将低分辨率图像转换为高分辨率图像。

具体来说,我们将学习以下主题:

  • 利用 Pix2Pix GAN 将草图/边缘图片转换为图片

  • 利用 CycleGAN 将苹果转换为橙子,反之亦然

  • 利用 StyleGAN 在自定义图像上改变图像表情

  • 使用 SRGAN 对图像进行超分辨率处理

    本章中的所有代码片段都可以在 GitHub 仓库的Chapter13文件夹中找到,网址是bit.ly/mcvp-2e

利用 Pix2Pix GAN

想象一种情景,我们有一对彼此相关的图像(例如,一个对象边缘的图像作为输入,实际对象图像作为输出)。面临的挑战是,我们希望根据对象边缘的输入图像生成一张图像。在传统设置中,这将是一个简单的输入到输出的映射,因此是一个监督学习问题。然而,假设你正在与一个试图为产品设计新外观的创意团队合作。在这种情况下,监督学习并没有太多帮助,因为它只从历史中学习。这里 GAN 会很有用,因为它确保生成的图像看起来逼真,并为实验留下空间(因为我们希望检查生成的图像是否类似于我们想要生成的图像)。具体来说,Pix2Pix GAN 在需要从另一个仅包含边缘(或轮廓)图像生成图像的情景中非常有用。

在本节中,我们将了解用于从鞋类图像的手绘边缘生成鞋类图像的架构。我们将采用以下策略来从手绘的草图生成逼真图像:

  1. 从大量实际图像中获取,并使用标准的cv2边缘检测技术创建相应的边缘。

  2. 从原始图像的补丁中采样颜色,以使生成器知道生成哪些颜色。

  3. 建立一个 UNet 架构,该架构以带有样本补丁颜色的边缘作为输入,并预测相应的图像 - 这是我们的生成器。

  4. 构建一个鉴别器架构,它接受图像并预测其真实性。

  5. 训练生成器和鉴别器,使之达到生成可以欺骗鉴别器的图像的能力。

让我们编写策略:

以下代码可作为本书 GitHub 存储库中Chapter13文件夹中的Pix2Pix_GAN.ipynb获取:bit.ly/mcvp-2e 该代码包含从中下载数据的 URL,长度适中。我们强烈建议您在 GitHub 上执行笔记本以在按照步骤执行并阅读文本中各种代码部分的解释时重现结果。

  1. 导入数据集(在此处可用:sketchx.eecs.qmul.ac.uk/downloads/),并安装相关的包:

    !wget https://www.dropbox.com/s/g6b6gtvmdu0h77x/ShoeV2_photo.zip
    !pip install torch_snippets
    !pip install torch_summary
    from torch_snippets import *
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    

上述代码下载了鞋类图像。下载的图像样本如下所示:

包含鞋类服装图像的图片  自动生成的描述

图 13.1:样本图像

对于我们的问题,我们希望根据鞋子的边缘和一些样本补丁颜色绘制鞋子。在下一步中,我们将从鞋类图像中获取边缘。这样,我们可以训练一个模型,从鞋子的边缘和样本补丁颜色中重建出鞋子的图像。

  1. 定义一个函数,从下载的图像中获取边缘:

    def detect_edges(img):
        img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
        img_gray = cv2.bilateralFilter(img_gray, 5, 50, 50)
        img_gray_edges = cv2.Canny(img_gray, 45, 100)
        # invert black/white
        img_gray_edges = cv2.bitwise_not(img_gray_edges)         
        img_edges=cv2.cvtColor(img_gray_edges, cv2.COLOR_GRAY2RGB)
        return img_edges 
    

在上述代码中,我们利用 OpenCV 包中提供的各种方法从图像中获取边缘(有关 OpenCV 方法的详细信息,请参见 GitHub 存储库中“第一版额外章节”文件夹中的 OpenCV 章节)。

  1. 定义图像转换流水线,对数据集进行预处理和标准化(preprocessnormalize):

    IMAGE_SIZE = 256
    preprocess = T.Compose([
                 T.Lambda(lambda x: torch.Tensor(x.copy())\
                           .permute(2, 0, 1).to(device))])
    normalize = lambda x: (x - 127.5)/127.5 
    
  2. 定义数据集类(ShoesData)。此数据集类返回原始图像和带有边缘的图像。我们将向网络传递的一个额外细节是一些颜色补丁,这些补丁出现在随机选择的区域中。这样,我们使用户能够从手绘的轮廓图像中取出所需颜色并在图像的不同部分中添加,从而生成新的图像。

    此处显示了一个示例输入(第一幅图像)和输出(第三幅图像)(您可以在本书的数字版中以彩色查看):

    包含鞋类的图片

    图 13.2:(左)原始图像;(中)原始图像的轮廓;(右)带有颜色信息的轮廓图像

    然而,我们拥有的输入图像只是鞋子的图像(最左边的图像),我们将用它来提取鞋子的边缘(中间的图像)。接下来,我们将在下一步中撒上颜色,以获取原始图像的颜色信息(最右边的图像)。当右边的图像通过我们的网络时,应该生成左边的图像。在下面的代码中,我们将构建一个类,该类接受轮廓图像,撒上颜色,并返回撒上颜色的图像对和原始鞋子图像(生成包含轮廓图像的图像):

  3. 定义 ShoesData 类、__init__ 方法和 __len__ 方法:

  4. class ShoesData(Dataset):
        def __init__(self, items):
            self.items = items
        def __len__(self): return len(self.items) 
    
  5. 定义 __getitem__ 方法。在这个方法中,我们将处理输入图像以获取带有边缘的图像,然后在原始图像中撒上颜色。在这里,我们获取给定图像的边缘:

  6.  def __getitem__(self, ix):
            f = self.items[ix]
            try: im = read(f, 1)
            except:
                blank = preprocess(Blank(IMAGE_SIZE, IMAGE_SIZE, 3))
                return blank, blank
            edges = detect_edges(im) 
    
  7. 一旦我们获取了图像中的边缘,我们会调整大小并对图像进行归一化处理:

  8.  im, edges = resize(im, IMAGE_SIZE), resize(edges, IMAGE_SIZE)
            im, edges = normalize(im), normalize(edges) 
    
  9. edges 图像上撒上颜色,并预处理原始图像和 edges 图像:

  10.  self._draw_color_circles_on_src_img(edges, im)
            im, edges = preprocess(im), preprocess(edges)
            return edges, im 
    
  11. 定义撒上颜色的函数:

  12.  def _draw_color_circles_on_src_img(self, img_src,
                                           img_target):
            non_white_coords = self._get_non_white_coordinates(img_target)
            for center_y, center_x in non_white_coords:
                self._draw_color_circle_on_src_img(img_src,
                            img_target, center_y, center_x)
        def _get_non_white_coordinates(self, img):
            non_white_mask = np.sum(img, axis=-1) < 2.75
            non_white_y, non_white_x = np.nonzero(non_white_mask)
            # randomly sample non-white coordinates
            n_non_white = len(non_white_y)
            n_color_points = min(n_non_white, 300)
            idxs = np.random.choice(n_non_white, n_color_points, 
                                                  replace=False)
            non_white_coords = list(zip(non_white_y[idxs],
                                        non_white_x[idxs]))
            return non_white_coords
        def _draw_color_circle_on_src_img(self, img_src,
                                img_target, center_y, center_x):
            assert img_src.shape == img_target.shape
            y0, y1, x0, x1= self._get_color_point_bbox_coords(center_y,                                                          center_x)
            color= np.mean(img_target[y0:y1, x0:x1],axis=(0, 1))
            img_src[y0:y1, x0:x1] = color
        def _get_color_point_bbox_coords(self, center_y,center_x):
            radius = 2
            y0 = max(0, center_y-radius+1)
            y1 = min(IMAGE_SIZE, center_y+radius)
            x0 = max(0, center_x-radius+1)
            x1 = min(IMAGE_SIZE, center_x+radius)
            return y0, y1, x0, x1
        def choose(self): return self[randint(len(self))] 
    
  13. 定义训练和验证数据的对应数据集和数据加载器:

    from sklearn.model_selection import train_test_split
    train_items, val_items = train_test_split(Glob('ShoeV2_photo/*.png'),
                             test_size=0.2, random_state=2)
    trn_ds, val_ds = ShoesData(train_items), ShoesData(val_items)
    trn_dl = DataLoader(trn_ds, batch_size=32, shuffle=True)
    val_dl = DataLoader(val_ds, batch_size=32, shuffle=True) 
    
  14. 定义生成器和鉴别器架构,这些架构使用权重初始化 (weights_init_normal)、UNetDownUNetUp 架构,正如我们在 第九章、“图像分割” 和 第十章、“目标检测和分割的应用” 中定义 GeneratorUNetDiscriminator 架构:

    1. 初始化权重,使其遵循正态分布:
    def weights_init_normal(m):
        classname = m.__class__.__name__
        if classname.find("Conv") != -1:
            torch.nn.init.normal_(m.weight.data, 0.0, 0.02)
        elif classname.find("BatchNorm2d") != -1:
            torch.nn.init.normal_(m.weight.data, 1.0, 0.02)
            torch.nn.init.constant_(m.bias.data, 0.0) 
    
    1. 定义 UnetDownUNetUp 类:
    class UNetDown(nn.Module):
        def __init__(self, in_size, out_size, normalize=True, dropout=0.0):
            super(UNetDown, self).__init__()
            layers = [nn.Conv2d(in_size, out_size, 4, 2, 1, bias=False)]
            if normalize:
                layers.append(nn.InstanceNorm2d(out_size))
            layers.append(nn.LeakyReLU(0.2))
            if dropout:
                layers.append(nn.Dropout(dropout))
            self.model = nn.Sequential(*layers)
        def forward(self, x):
            return self.model(x)
    class UNetUp(nn.Module):
        def __init__(self, in_size, out_size, dropout=0.0):
            super(UNetUp, self).__init__()
            layers = [
                nn.ConvTranspose2d(in_size, out_size, 4, 2, 1, bias=False),
                nn.InstanceNorm2d(out_size),
                nn.ReLU(inplace=True),
            ]
            if dropout:
                layers.append(nn.Dropout(dropout))
            self.model = nn.Sequential(*layers)
        def forward(self, x, skip_input):
            x = self.model(x)
            x = torch.cat((x, skip_input), 1)
            return x 
    
    1. 定义 GeneratorUNet 类:
    class GeneratorUNet(nn.Module):
        def __init__(self, in_channels=3, out_channels=3):
            super(GeneratorUNet, self).__init__()
            self.down1 = UNetDown(in_channels, 64,normalize=False)
            self.down2 = UNetDown(64, 128)
            self.down3 = UNetDown(128, 256)
            self.down4 = UNetDown(256, 512, dropout=0.5)
            self.down5 = UNetDown(512, 512, dropout=0.5)
            self.down6 = UNetDown(512, 512, dropout=0.5)
            self.down7 = UNetDown(512, 512, dropout=0.5)
            self.down8 = UNetDown(512, 512, normalize=False, dropout=0.5)
            self.up1 = UNetUp(512, 512, dropout=0.5)
            self.up2 = UNetUp(1024, 512, dropout=0.5)
            self.up3 = UNetUp(1024, 512, dropout=0.5)
            self.up4 = UNetUp(1024, 512, dropout=0.5)
            self.up5 = UNetUp(1024, 256)
            self.up6 = UNetUp(512, 128)
            self.up7 = UNetUp(256, 64)
            self.final = nn.Sequential(
                nn.Upsample(scale_factor=2),
                nn.ZeroPad2d((1, 0, 1, 0)),
                nn.Conv2d(128, out_channels, 4, padding=1),
                nn.Tanh(),
            )
        def forward(self, x):
            d1 = self.down1(x)
            d2 = self.down2(d1)
            d3 = self.down3(d2)
            d4 = self.down4(d3)
            d5 = self.down5(d4)
            d6 = self.down6(d5)
            d7 = self.down7(d6)
            d8 = self.down8(d7)
            u1 = self.up1(d8, d7)
            u2 = self.up2(u1, d6)
            u3 = self.up3(u2, d5)
            u4 = self.up4(u3, d4)
            u5 = self.up5(u4, d3)
            u6 = self.up6(u5, d2)
            u7 = self.up7(u6, d1)
            return self.final(u7) 
    
    1. 定义 Discriminator 类:
    class Discriminator(nn.Module):
        def __init__(self, in_channels=3):
            super(Discriminator, self).__init__()
            def discriminator_block(in_filters, out_filters, 
                                          normalization=True):
                """Returns downsampling layers of each
                discriminator block"""
                layers = [nn.Conv2d(in_filters, out_filters, 4, 
                                          stride=2, padding=1)]
                if normalization:
                   layers.append(nn.InstanceNorm2d(out_filters))
                layers.append(nn.LeakyReLU(0.2, inplace=True))
                return layers
            self.model = nn.Sequential(
                *discriminator_block(in_channels * 2, 64,
                                     normalization=False),
                *discriminator_block(64, 128),
                *discriminator_block(128, 256),
                *discriminator_block(256, 512),
                nn.ZeroPad2d((1, 0, 1, 0)),
                nn.Conv2d(512, 1, 4, padding=1, bias=False)
                                      )
        def forward(self, img_A, img_B):
            img_input = torch.cat((img_A, img_B), 1)
            return self.model(img_input) 
    
  15. 定义生成器和鉴别器模型对象并获取概要:

    generator = GeneratorUNet().to(device)
    discriminator = Discriminator().to(device)
    from torchsummary import summary
    print(summary(generator, torch.zeros(3, 3, IMAGE_SIZE,
                                IMAGE_SIZE).to(device)))
    print(summary(discriminator, torch.zeros(3, 3, IMAGE_SIZE, IMAGE_SIZE).\to(device), torch.zeros(3, 3, IMAGE_SIZE, IMAGE_SIZE).to(device))) 
    

生成器架构概述如下:

一个文件的特写,由中等置信度自动生成的描述

图 13.3:生成器架构概述

鉴别器架构概述如下:

包含文本、收据、截图的图片

图 13.4:鉴别器架构概述

  1. 定义训练鉴别器的函数 (discriminator_train_step):

    1. 鉴别器函数接受源图像 (real_src)、真实目标 (real_trg) 和伪造目标 (fake_trg) 作为输入:
    def discriminator_train_step(real_src, real_trg, fake_trg):
        d_optimizer.zero_grad() 
    
    1. 通过比较真实目标(real_trg)和目标的预测值(real_src)来计算损失(error_real),期望鉴别器将图像预测为真实的(由 torch.ones 表示),然后进行反向传播:
     prediction_real = discriminator(real_trg, real_src)
        error_real = criterion_GAN(prediction_real,
                                   torch.ones(len(real_src), 
                                          1, 16, 16).to(device))
        error_real.backward() 
    
    1. 计算与假图像(fake_trg)对应的鉴别器损失(error_fake);预期鉴别器将假目标分类为假图像(由torch.zeros指示),然后进行反向传播:
     prediction_fake = discriminator( real_src, fake_trg.detach())
        error_fake = criterion_GAN(prediction_fake,
                                   torch.zeros(len(real_src), 1, 
                                        16, 16).to(device))
        error_fake.backward() 
    
    1. 执行优化器步骤,并返回预测的真实和假目标的总体错误和损失值:
     d_optimizer.step()
        return error_real + error_fake 
    
  2. 定义训练生成器的函数(generator_train_step),该函数接受一个假的目标(fake_trg)并训练它,使其在通过鉴别器时被识别为假的概率很低:

    def generator_train_step(real_src, fake_trg):
        g_optimizer.zero_grad()
        prediction = discriminator(fake_trg, real_src)
        loss_GAN = criterion_GAN(prediction, 
                torch.ones(len(real_src), 1, 16, 16).to(device))
        loss_pixel = criterion_pixelwise(fake_trg, real_trg)
        loss_G = loss_GAN + lambda_pixel * loss_pixel
        loss_G.backward()
        g_optimizer.step()
        return loss_G 
    

请注意,在前述代码中,除了生成器损失外,我们还获取了对应于给定轮廓的生成图像和真实图像之间差异的像素损失(loss_pixel)。

  1. 定义一个函数来获取预测的样本:

    denorm = T.Normalize((-1, -1, -1), (2, 2, 2))
    def sample_prediction():
        """Saves a generated sample from the validation set"""
        data = next(iter(val_dl))
        real_src, real_trg = data
        fake_trg = generator(real_src)
        img_sample = torch.cat([denorm(real_src[0]),
                                denorm(fake_trg[0]),
                                denorm(real_trg[0])], -1)
        img_sample = img_sample.detach().cpu().permute(1,2,0).numpy()
        show(img_sample, title='Source::Generated::GroundTruth',sz=12) 
    
  2. 对生成器和鉴别器模型对象应用权重初始化(weights_init_normal):

    generator.apply(weights_init_normal)
    discriminator.apply(weights_init_normal) 
    
  3. 指定损失标准和优化方法(criterion_GANcriterion_pixelwise):

    criterion_GAN = torch.nn.MSELoss()
    criterion_pixelwise = torch.nn.L1Loss()
    lambda_pixel = 100
    g_optimizer = torch.optim.Adam(generator.parameters(),
                 	             lr=0.0002, betas=(0.5, 0.999))
    d_optimizer= torch.optim.Adam(discriminator.parameters(),
                                    lr=0.0002, betas=(0.5, 0.999)) 
    
  4. 训练模型超过 100 个时期:

    epochs = 100
    log = Report(epochs)
    for epoch in range(epochs):
        N = len(trn_dl)
        for bx, batch in enumerate(trn_dl):
            real_src, real_trg = batch
            fake_trg = generator(real_src)
            errD = discriminator_train_step(real_src,  real_trg, fake_trg)
            errG = generator_train_step(real_src, fake_trg)
            log.record(pos=epoch+(1+bx)/N, errD=errD.item(),
                       errG=errG.item(), end='\r')
        [sample_prediction() for _ in range(2)] 
    
  5. 在样本手绘轮廓上生成输出(图像):

    [sample_prediction() for _ in range(2)] 
    

上述代码生成以下输出:

图 13.5:(左)输入图像;(中)生成的图像;(右)原始图像

请注意,在前述输出中,我们生成了具有与原始图像相似颜色的图像。作为练习,我们鼓励您训练更多时期的模型,并查看生成图像的改进。

在本节中,我们学习了如何使用图像的轮廓来生成图像。然而,这要求我们提供输入和输出作为一对,这可能是一个繁琐的过程。在下一节中,我们将学习无配对图像转换的方法。这是通过网络自动学习图像转换而无需指定输入和输出映射的过程。

利用 CycleGAN

想象一个场景,我们要求您执行从一个类别到另一个类别的图像转换,但是不使用输入和相应的输出图像来训练模型。例如,将电影当前场景中的一个演员更换为另一个演员。然而,我们会给您两个不同文件夹中的两个类别/演员的图像。在这种情况下,CycleGAN 非常有用。

在本节中,我们将学习如何训练 CycleGAN(arxiv.org/abs/1703.10593)将苹果的图像转换为橙子的图像,反之亦然。但首先,让我们了解 CycleGAN 的工作原理。

CycleGAN 的工作原理

CycleGAN 中的“Cycle”指的是我们将图像从一个类别转换为另一个类别,然后再转换回原始类别的过程。在高层次上,此架构中将有三个单独的损失值。关于损失计算的更多细节请参阅下一页:

  • 对抗损失:确保领域生成器使用另一个领域的图像作为输入准确地创建各自领域中的对象。与标准 GAN 的唯一区别在于,生成器接受图像而不是噪声。

  • 循环损失:再循环生成图像到原始图像的损失,以确保周围像素不被改变。

  • 身份损失:当一个输入图像属于一个类别时,通过预期将另一个类别的图像转换为输入图像类别的生成器时的损失。

在这里,我们将讲解构建 CycleGAN 的高级步骤:

  1. 导入并预处理数据集。

  2. 构建生成器和鉴别器网络 UNet 架构。

  3. 定义两个生成器:

    • G_AB:将类 A 图像转换为类 B 图像的生成器

    • G_BA:将类 B 图像转换为类 A 图像的生成器

  4. 定义身份损失:

    • 如果你将一个橙子图像发送给一个橙子生成器,理想情况下,如果生成器已经完全理解了橙子的所有内容,它不应该改变图像,而是应该“生成”完全相同的图像。因此,我们利用这个知识创建一个身份。

    • 当通过 G_BA 传递类 A 的图像(real_A)并与 real_A 进行比较时,身份损失应该是最小的。

    • 当通过 G_AB 传递类 B 的图像(real_B)并与 real_B 进行比较时,身份损失应该是最小的。

  5. 定义GAN 损失:

    • 对于 real_A 和 fake_A(当 real_B 图像通过 G_BA 时获得的 fake_A),鉴别器和生成器损失。

    • 对于 real_B 和 fake_B(当 real_A 图像通过 G_AB 时获得的 fake_B),鉴别器和生成器损失。

  6. 定义再循环损失:

    • 考虑这样一个场景:将一个苹果图像通过橙子生成器转换为生成假橙子,然后通过苹果生成器将假橙子转换回苹果。

    • 当 real_A 通过 G_AB 传递时获得的 fake_B 应该在 fake_B 通过 G_BA 传递时再生出 real_A。

    • 当 real_B 通过 G_BA 传递时获得的 fake_A 应该在 fake_A 通过 G_AB 传递时再生出 real_B。

  7. 优化三个损失的加权损失。

现在我们理解了步骤,让我们编写代码来将苹果转换成橙子,反之亦然。

实施 CycleGAN

要实现我们刚刚讨论的步骤,可以使用以下代码:

该代码作为本书 GitHub 存储库中Chapter13文件夹下的CycleGAN.ipynb可用:bit.ly/mcvp-2e。代码包含从中下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 上执行笔记本以在阅读代码组件的步骤和解释时再现结果。

  1. 下载并提取包含苹果和橙子图像的数据集文件夹:

    !wget https://www.dropbox.com/s/2xltmolfbfharri/apples_oranges.zip
    !unzip apples_oranges.zip 
    

这是我们将要处理的图像的一个示例:

一张红苹果和一个橙色图像  描述自动生成,低置信度

图 13.6:样本图像

注意,苹果和橙子图像之间没有一对一的对应关系(不像我们在Leveraging the Pix2Pix GAN一节中学到的轮廓到鞋子生成的用例)。

  1. 导入所需的包:

    !pip install torch_snippets torch_summary
    import itertools
    from PIL import Image
    from torch_snippets import *
    from torchvision import transforms
    from torchvision.utils import make_grid
    from torchsummary import summary 
    
  2. 定义图像转换流水线(transform):

    IMAGE_SIZE = 256
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    transform = transforms.Compose([
        transforms.Resize(int(IMAGE_SIZE*1.33)),
        transforms.RandomCrop((IMAGE_SIZE,IMAGE_SIZE)),
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]) 
    
  3. 定义数据集类(CycleGANDataset),接受苹果和橙子文件夹(在解压下载的数据集后获取)作为输入,并提供一批苹果和橙子图像:

    class CycleGANDataset(Dataset):
        def __init__(self, apples, oranges):
            self.apples = Glob(apples)
            self.oranges = Glob(oranges)
        def __getitem__(self, ix):
            apple = self.apples[ix % len(self.apples)]
            orange = choose(self.oranges)
            apple = Image.open(apple).convert('RGB')
            orange = Image.open(orange).convert('RGB')
            return apple, orange
        def __len__(self): return max(len(self.apples), len(self.oranges))
        def choose(self): return self[randint(len(self))]
        def collate_fn(self, batch):
            srcs, trgs = list(zip(*batch))
            srcs=torch.cat([transform(img)[None] for img in \
                             srcs], 0).to(device).float()
            trgs=torch.cat([transform(img)[None] for img in \ 
                             trgs], 0).to(device).float()
            return srcs.to(device), trgs.to(device) 
    
  4. 定义训练和验证数据集以及数据加载器:

    trn_ds = CycleGANDataset('apples_train', 'oranges_train')
    val_ds = CycleGANDataset('apples_test', 'oranges_test')
    trn_dl = DataLoader(trn_ds, batch_size=1, shuffle=True,
                        collate_fn=trn_ds.collate_fn)
    val_dl = DataLoader(val_ds, batch_size=5, shuffle=True,
                        collate_fn=val_ds.collate_fn) 
    
  5. 定义网络的权重初始化方法(weights_init_normal),如前几节所定义:

    def weights_init_normal(m):
        classname = m.__class__.__name__
        if classname.find("Conv") != -1:
            torch.nn.init.normal_(m.weight.data, 0.0, 0.02)
            if hasattr(m, "bias") and m.bias is not None:
                torch.nn.init.constant_(m.bias.data, 0.0)
        elif classname.find("BatchNorm2d") != -1:
            torch.nn.init.normal_(m.weight.data, 1.0, 0.02)
            torch.nn.init.constant_(m.bias.data, 0.0) 
    
  6. 定义残差块网络(ResidualBlock),因为在这个例子中,我们将使用 ResNet:

    class ResidualBlock(nn.Module):
        def __init__(self, in_features):
            super(ResidualBlock, self).__init__()
            self.block = nn.Sequential(
                nn.ReflectionPad2d(1),
                nn.Conv2d(in_features, in_features, 3),
                nn.InstanceNorm2d(in_features),
                nn.ReLU(inplace=True),
                nn.ReflectionPad2d(1),
                nn.Conv2d(in_features, in_features, 3),
                nn.InstanceNorm2d(in_features),
            )
        def forward(self, x):
            return x + self.block(x) 
    
  7. 定义生成器网络(GeneratorResNet):

    class GeneratorResNet(nn.Module):
        def __init__(self, num_residual_blocks=9):
            super(GeneratorResNet, self).__init__()
            out_features = 64
            channels = 3
            model = [
                nn.ReflectionPad2d(3),
                nn.Conv2d(channels, out_features, 7),
                nn.InstanceNorm2d(out_features),
                nn.ReLU(inplace=True),
            ]
            in_features = out_features
            # Downsampling
            for _ in range(2):
                out_features *= 2
                model += [
                    nn.Conv2d(in_features, out_features, 3,
                              stride=2, padding=1),
                    nn.InstanceNorm2d(out_features),
                    nn.ReLU(inplace=True),
                         ]
                in_features = out_features
            # Residual blocks
            for _ in range(num_residual_blocks):
                model += [ResidualBlock(out_features)]
            # Upsampling
            for _ in range(2):
                out_features //= 2
                model += [
                    nn.Upsample(scale_factor=2),
                    nn.Conv2d(in_features, out_features, 3,
                              stride=1, padding=1),
                    nn.InstanceNorm2d(out_features),
                    nn.ReLU(inplace=True),
                         ]
                in_features = out_features
            # Output layer
            model += [nn.ReflectionPad2d(channels),
                      nn.Conv2d(out_features, channels, 7),
                      nn.Tanh()]
            self.model = nn.Sequential(*model)
            self.apply(weights_init_normal)
        def forward(self, x):
            return self.model(x) 
    
  8. 定义鉴别器网络(Discriminator):

    class Discriminator(nn.Module):
        def __init__(self):
            super(Discriminator, self).__init__()
            channels, height, width = 3, IMAGE_SIZE, IMAGE_SIZE
            def discriminator_block(in_filters, out_filters,  
                                            normalize=True):
                """Returns downsampling layers of each
                discriminator block"""
                layers = [nn.Conv2d(in_filters, out_filters, 4, 
                                    stride=2, padding=1)]
                if normalize:
                   layers.append(nn.InstanceNorm2d(out_filters))
                layers.append(nn.LeakyReLU(0.2, inplace=True))
                return layers
            self.model = nn.Sequential(
              *discriminator_block(channels,64,normalize=False),
              *discriminator_block(64, 128),
              *discriminator_block(128, 256),
              *discriminator_block(256, 512),
              nn.ZeroPad2d((1, 0, 1, 0)),
              nn.Conv2d(512, 1, 4, padding=1)
            )
            self.apply(weights_init_normal)
        def forward(self, img):
            return self.model(img) 
    
  9. 定义生成图像样本的函数 – generate_sample

    @torch.no_grad()
    def generate_sample():
        data = next(iter(val_dl))
        G_AB.eval()
        G_BA.eval()   
        real_A, real_B = data
        fake_B = G_AB(real_A)
        fake_A = G_BA(real_B)
        # Arange images along x-axis
        real_A = make_grid(real_A, nrow=5, normalize=True)
        real_B = make_grid(real_B, nrow=5, normalize=True)
        fake_A = make_grid(fake_A, nrow=5, normalize=True)
        fake_B = make_grid(fake_B, nrow=5, normalize=True)
        # Arange images along y-axis
        image_grid = torch.cat((real_A,fake_B,real_B,fake_A), 1)
    show(image_grid.detach().cpu().permute(1,2,0).numpy(),sz=12) 
    
  10. 定义训练生成器的函数(generator_train_step):

    1. 函数将两个生成器模型(G_AB 和 G_BA)作为Gsoptimizer,以及两类真实图像real_Areal_B作为输入:
    def generator_train_step(Gs, optimizer, real_A, real_B): 
    
    1. 指定生成器:
     G_AB, G_BA = Gs 
    
    1. 将优化器的梯度设置为零:
     optimizer.zero_grad() 
    
    1. 如果将橙色图像发送到橙色生成器,理想情况下,如果生成器完全理解了橙子的所有内容,它不应对图像做任何更改,并且应该“生成”出完全相同的图像。因此,我们利用这一知识创建了一个身份。对应于criterion_identity的损失函数将在模型训练之前定义。计算类型为 A(苹果)和 B(橙子)的身份损失(loss_identity):
     loss_id_A = criterion_identity(G_BA(real_A), real_A)
        loss_id_B = criterion_identity(G_AB(real_B), real_B)
        loss_identity = (loss_id_A + loss_id_B) / 2 
    
    1. 当图像通过生成器传递,并且生成的图像预期尽可能接近另一类时,计算 GAN 损失(在这种情况下,当训练生成器时,我们对同一类别的伪造图像传递给鉴别器时,使用np.ones):
     fake_B = G_AB(real_A)
        loss_GAN_AB = criterion_GAN(D_B(fake_B),
                        torch.Tensor(np.ones((len(real_A), 1,
                                          16, 16))).to(device))
        fake_A = G_BA(real_B)
        loss_GAN_BA = criterion_GAN(D_A(fake_A),
                        torch.Tensor(np.ones((len(real_A), 1,
                                          16, 16))).to(device))
        loss_GAN = (loss_GAN_AB + loss_GAN_BA) / 2 
    
    1. 计算循环损失。考虑这样一个场景:一个苹果图像经由橙色生成器转换成一个伪橙色图像,然后这个伪橙色图像再经由苹果生成器转换回苹果。如果生成器是完美的,这个过程应该返回原始图像,这意味着以下循环损失应该为零:
     recov_A = G_BA(fake_B)
        loss_cycle_A = criterion_cycle(recov_A, real_A)
        recov_B = G_AB(fake_A)
        loss_cycle_B = criterion_cycle(recov_B, real_B)
        loss_cycle = (loss_cycle_A + loss_cycle_B) / 2 
    
    1. 计算总加权损失(其中lambda_cyclambda_id分别是循环损失和身份损失的权重),在返回计算值之前进行反向传播:
     loss_G = loss_GAN + lambda_cyc * loss_cycle + \
                lambda_id * loss_identity
        loss_G.backward()
        optimizer.step()
        return loss_G, loss_identity, loss_GAN, loss_cycle,
                loss_G, fake_A, fake_B 
    
  11. 定义训练鉴别器的函数(discriminator_train_step):

    def discriminator_train_step(D, real_data, fake_data, optimizer):
        optimizer.zero_grad()
        loss_real = criterion_GAN(D(real_data),
                      torch.Tensor(np.ones((len(real_data), 1,
                                       16, 16))).to(device))
        loss_fake = criterion_GAN(D(fake_data.detach()),
                      torch.Tensor(np.zeros((len(real_data), 1,
                                       16, 16))).to(device))
        loss_D = (loss_real + loss_fake) / 2
        loss_D.backward()
        optimizer.step()
        return loss_D 
    
  12. 定义生成器、鉴别器对象、优化器和损失函数:

    G_AB = GeneratorResNet().to(device)
    G_BA = GeneratorResNet().to(device)
    D_A = Discriminator().to(device)
    D_B = Discriminator().to(device)
    criterion_GAN = torch.nn.MSELoss()
    criterion_cycle = torch.nn.L1Loss()
    criterion_identity = torch.nn.L1Loss()
    optimizer_G = torch.optim.Adam(
        itertools.chain(G_AB.parameters(), G_BA.parameters()),
        lr=0.0002, betas=(0.5, 0.999))
    optimizer_D_A = torch.optim.Adam(D_A.parameters(),
                            lr=0.0002, betas=(0.5, 0.999))
    optimizer_D_B = torch.optim.Adam(D_B.parameters(),
                            lr=0.0002, betas=(0.5, 0.999))
    lambda_cyc, lambda_id = 10.0, 5.0 
    
  13. 在增加的周期内训练网络:

    n_epochs = 10
    log = Report(n_epochs)
    for epoch in range(n_epochs):
        N = len(trn_dl)
        for bx, batch in enumerate(trn_dl):
            real_A, real_B = batch
            loss_G, loss_identity, loss_GAN, loss_cycle, \
            loss_G, fake_A, fake_B = generator_train_step(\
                                      (G_AB,G_BA), optimizer_G, 
                                             real_A, real_B)
            loss_D_A = discriminator_train_step(D_A, real_A, 
                                      fake_A, optimizer_D_A)
            loss_D_B = discriminator_train_step(D_B, real_B, 
                                      fake_B, optimizer_D_B)
            loss_D = (loss_D_A + loss_D_B) / 2
    
            log.record(epoch+(1+bx)/N, loss_D=loss_D.item(),
              loss_G=loss_G.item(), loss_GAN=loss_GAN.item(),
                loss_cycle=loss_cycle.item(),
               loss_identity=loss_identity.item(), end='\r')
            if bx%100==0: generate_sample()
        log.report_avgs(epoch+1) 
    
  14. 在我们训练了模型之后生成图像:

    generate_sample() 
    

前面的代码生成了以下输出:

图 13.7:原始和重建的苹果和橙子以及反之亦然

在前面的图像中,我们可以看到我们成功地将苹果转换成橙子(前两行),以及将橙子转换成苹果(最后两行)。

到目前为止,我们已经学习了通过 Pix2Pix GAN 进行成对图像转换和通过 CycleGAN 进行非成对图像转换。在接下来的章节中,我们将学习如何利用 StyleGAN 将一种风格的图像转换为另一种风格的图像。

利用 StyleGAN 处理自定义图像

在第十一章中,我们学习了神经风格转移。我们通过将一幅图像的风格与另一幅图像的内容混合来生成一幅图像。然而,如果我们想要在图片中创建一个人的年轻版本或者给图像增加眼镜等特定属性,StyleGAN 可以做到这一点。让我们在接下来的几节中学习如何做到这一点。

StyleGAN 的演变

让我们首先看一些在 StyleGAN 发明之前的发展。众所周知,生成假面孔(正如我们在上一章中看到的)涉及到使用 GAN。研究面临的最大问题是,可以生成的图像很小(通常为 64 x 64)。任何尝试生成更大图像的努力都会导致生成器或鉴别器陷入局部最小值,停止训练并生成胡言乱语。在一篇提出 Progressive GAN(ProGAN)的研究论文中出现了生成高质量图像的重大进展,它使用了一个巧妙的技巧。逐步增加生成器和鉴别器的大小:

  1. 首先,您创建一个生成器和鉴别器,以从潜在向量生成 4 x 4 的图像。

  2. 接下来,额外的卷积(和放大)层被添加到训练好的生成器和鉴别器中,这些层负责接受 4 x 4 像素的图像(这些图像是从步骤 1 中的潜在向量生成的),并生成/鉴别 8 x 8 像素的图像。

  3. 然后,在生成器和鉴别器中再次创建新的层,以便它们可以被训练生成更大的图像。逐步增加图像大小的逻辑是,向已经正常运行的网络添加新层比尝试从头学习所有层更容易。

以这种方式,图像被放大到 1,024 x 1,024 像素的分辨率:

图 13.8:图像放大过程(来源:)

尽管它取得了成功,但要控制生成图像的各个方面(如性别和年龄)却相当困难,主要因为网络只接收一个输入(在前述图像中,这是网络顶部的潜在)。StyleGAN 解决了这个问题。它使用类似的训练方案逐步生成图像,但每次网络增长时都添加了一组额外的潜在输入。这意味着网络现在在固定间隔接受多个潜在向量(如图 13.9 中 (a) 区块所示)。在生成的阶段,每个给定的潜在向量决定了在网络的该阶段生成的特征。让我们在这里更详细地讨论 StyleGAN 的工作细节:

Diagram  Description automatically generated

图 13.9:StyleGAN 工作细节

(来源:arxiv.org/pdf/1812.04948

在前述图中,我们可以对比传统生成图像的方式和基于风格的生成器。在传统生成器中,只有一个输入。然而,在基于风格的生成器内部有一种机制。让我们看看细节:

  1. 创建一个大小为 1 x 512 的随机噪声向量 z

  2. 将其提供给称为风格网络(或映射网络)的辅助网络,该网络创建了一个大小为 18 x 512 的张量 w

  3. 生成器(合成)网络包含 18 个卷积层。每一层将接受以下输入:

    • w 的相应行(A)

    • 一个随机噪声向量(B)

    • 前一层的输出

注意,噪声(B)仅用于正则化目的。

前述三者的结合将创建一个管道,接受一个大小为 1 x 512 的向量,并生成一个 1,024 x 1,024 的图像。

现在让我们详细讨论从映射网络生成的大小为 18 x 512 向量中的每个 1 x 512 向量如何对图像的生成贡献:

在合成网络的前几层添加的 1 x 512 向量有助于生成图像的大尺度特征,例如姿势和面部形状(因为它们负责生成 4 x 4、8 x 8 图像等,这些是稍后层次中将进一步增强的第一批图像)。

在中间层添加的向量对应于小尺度特征,如发型和眼睛是否睁开或闭合(因为它们负责生成 16 x 16、32 x 32 和 64 x 64 图像)。

在最后几层添加的向量对应于图像的颜色方案和其他微观结构。当我们达到最后几层时,图像结构被保留,面部特征也被保留,但仅改变像素级别的细节,例如光照条件。

在接下来的部分,我们将利用预训练的 StyleGAN2 模型来自定义我们感兴趣的图像,使其具有不同的风格。对于我们的目标,我们将使用 StyleGAN2 模型执行样式迁移。在高层次上,面部样式迁移的工作方式如下(随着您通过代码结果进行查看,以下内容将更加清晰):

  • 假设 w1 样式向量用于生成 face-1w2 样式向量用于生成 face-2。两个向量的形状均为 18 x 512。

  • w2 的前几个 18 个向量(负责从 4 x 4 到 8 x 8 分辨率生成图像)中,将其与 w1 中对应的向量替换。然后,我们将非常粗略的特征,如从 face-1face-2 的姿势,进行转移。

  • 如果后续样式向量(例如 18 x 512 向量中的第三到第十五个,负责生成 64 x 64 到 256 x 256 分辨率的图像)在 w2 中被来自 w1 的向量替换,则我们转移眼睛、鼻子和其他中级面部特征。

  • 如果替换了最后几个样式向量(负责生成 512 x 512 到 1,024 x 1,024 分辨率图像),则会转移细节特征,如肤色和背景(这些特征对整体面部影响不大)。

理解了样式迁移的实现方式,现在让我们看看如何使用 StyleGAN2 进行样式迁移。

实现 StyleGAN

要使用 StyleGAN2 在自定义图像上实现样式迁移,我们遵循以下广泛步骤:

  1. 采用自定义图像。

  2. 将自定义图像对齐,以便仅存储图像的面部区域。

  3. 获取通过 StyleGAN2 传递时可能生成自定义对齐图像的潜在向量。

  4. 通过将随机噪声/潜在向量(1 x 512)传递给映射网络生成图像。

到这一步,我们有两幅图像 —— 我们的自定义对齐图像和通过 StyleGAN2 网络生成的图像。现在,我们希望将自定义图像的一些特征转移到生成的图像中,反之亦然。让我们编写前述策略的代码(请注意,我们正在利用从 GitHub 存储库获取的预训练网络,因为训练这样的网络需要数天甚至数周时间)。

您需要一个支持 CUDA 的环境来运行以下代码。以下代码可以在本书的 GitHub 仓库的 Chapter13 文件夹中的 Customizing_StyleGAN2.ipynb 中找到 bit.ly/mcvp-2e。该代码包含了从中下载数据的 URL,并且代码长度适中。我们强烈建议您在 GitHub 上执行此笔记本,以便在阅读步骤和文本中的各种代码组件的说明时重现结果。

  1. 克隆存储库,安装要求并获取预训练权重:

    import os
    if not os.path.exists('pytorch_stylegan_encoder'):
        !git clone https://github.com/sizhky/pytorch_stylegan_encoder.git
        %cd pytorch_stylegan_encoder
        !git submodule update --init --recursive
        !wget -q https://github.com/jacobhallberg/pytorch_stylegan_encoder/releases/download/v1.0/trained_models.zip
        !unzip -q trained_models.zip
        !rm trained_models.zip
        !pip install -qU torch_snippets
        !mv trained_models/stylegan_ffhq.pth InterFaceGAN/models/pretrain
    else:
        %cd pytorch_stylegan_encoder
    
    from torch_snippets import * 
    
  2. 加载预训练生成器和合成网络,映射网络的权重:

    from InterFaceGAN.models.stylegan_generator import StyleGANGenerator
    from models.latent_optimizer import PostSynthesisProcessing
    synthesizer=StyleGANGenerator("stylegan_ffhq").model.synthesis
    mapper = StyleGANGenerator("stylegan_ffhq").model.mapping
    trunc = StyleGANGenerator("stylegan_ffhq").model.truncation 
    
  3. 定义一个从随机向量生成图像的函数:

    post_processing = PostSynthesisProcessing()
    post_process = lambda image: post_processing(image)\
                    .detach().cpu().numpy().astype(np.uint8)[0]
    def latent2image(latent):
        img = post_process(synthesizer(latent))
        img = img.transpose(1,2,0)
        return img 
    
  4. 生成一个随机向量:

    rand_latents = torch.randn(1,512).cuda() 
    

在上述代码中,我们将随机的 1 x 512 维向量通过映射和截断网络生成一个 1 x 18 x 512 的向量。这些 18 x 512 向量决定了生成图像的风格。

  1. 从随机向量生成一个图像:

    show(latent2image(trunc(mapper(rand_latents))), sz=5) 
    

前述代码生成以下输出:

一位微笑的人的特写 自动生成描述

图 13.10:来自随机潜在的图像

到目前为止,我们已经生成了一个图像。在接下来的几行代码中,您将学习如何在前面生成的图像与您选择的图像之间执行风格转移。

  1. 获取自定义图像(MyImage.jpg)并对齐它。对齐是生成正确潜在向量的重要步骤,因为 StyleGAN 中生成的所有图像都有面部居中和特征明显可见:

    !wget https://www.dropbox.com/s/lpw10qawsc5ipbn/MyImage.JPG\
     -O MyImage.jpg
    !git clone https://github.com/Puzer/stylegan-encoder.git
    !mkdir -p stylegan-encoder/raw_images
    !mkdir -p stylegan-encoder/aligned_images
    !mv MyImage.jpg stylegan-encoder/raw_images 
    
  2. 对齐自定义图像:

    !python stylegan-encoder/align_images.py \
            stylegan-encoder/raw_images/ \
            stylegan-encoder/aligned_images/
    !mv stylegan-encoder/aligned_images/* ./MyImage.jpg 
    
  3. 使用对齐图像生成完美重现对齐图像的潜在变量。这是识别最小化对齐图像与从潜在向量生成的图像之间差异的潜在向量组合的过程:

    from PIL import Image
    img = Image.open('MyImage.jpg')
    show(np.array(img), sz=4, title='original')
    !python encode_image.py ./MyImage.jpg\
     pred_dlatents_myImage.npy\
     --use_latent_finder true\
     --image_to_latent_path ./trained_models/image_to_latent.pt
    pred_dlatents = np.load('pred_dlatents_myImage.npy')
    pred_dlatent = torch.from_numpy(pred_dlatents).float().cuda()
    pred_image = latent2image(pred_dlatent)
    show(pred_image, sz=4, title='synthesized') 
    

前述代码生成以下输出:

图形用户界面,文本 自动生成描述

图 13.11:原始图像和相应潜在图像的合成图像

Python 脚本encode_image.py在高层次上执行以下操作(为了彻底了解每个步骤,请查看 GitHub 存储库中的脚本):

  1. 在潜在空间中创建一个随机向量。或者,我们可以通过将原始图像通过网络传递初始化为权重image_to_latent.pt和文件models/image_to_latent.py中的架构,以获得需要较少优化的初始潜在(向量)集合。

  2. 用这个向量合成一个图像。

  3. 使用 VGG 的感知损失比较合成图像与原始输入图像。

  4. w随机向量进行反向传播,以在固定迭代次数内减少此损失。

  5. 优化的潜在向量现在将合成一个图像,该图像在 VGG 给出的特征几乎与输入图像相同,因此合成图像将类似于输入图像。我们现在有了对应于感兴趣图像的潜在向量。

  6. 在图像之间执行风格转移。如讨论的那样,风格转移背后的核心逻辑实际上是风格张量的部分转移,即 18 x 512 风格张量的 18 个子集之一。在这里,我们将在一个案例中传输前两行(18 x 512 张量的两行),在一个案例中传输第 3-15 行,以及在一个案例中传输第 15-18 行。由于每组向量负责生成图像的不同方面,每组交换的向量集合交换图像中的不同特征:

    idxs_to_swap = slice(0,3)
    my_latents=torch.Tensor(np.load('pred_dlatents_myImage.npy',
                                             allow_pickle=True))
    A, B = latent2image(my_latents.cuda()),
           latent2image(trunc(mapper(rand_latents)))
    generated_image_latents = trunc(mapper(rand_latents))
    x = my_latents.clone()
    x[:,idxs_to_swap] = generated_image_latents[:,idxs_to_swap]
    a = latent2image(x.float().cuda())
    x = generated_image_latents.clone()
    x[:,idxs_to_swap] = my_latents[:,idxs_to_swap]
    b = latent2image(x.float().cuda())
    subplots([A,a,B,b], figsize=(7,8), nc=2,
             suptitle='Transfer high level features') 
    

前述代码生成以下输出:

一个儿童和儿童的拼贴,自动生成的描述,中等置信度

图 13.12:原始图像(左侧)和对应的风格转移图像(右侧)

图 13.12中,由于我们在流水线的早期阶段进行了交换,因此最高级别的特征,如年龄,被交换了。在交换下一个层级的特征(4 到 15),我们将看到下一个层级的特征,如颜色调色板和背景,被交换了。最后,层次(15,18)似乎根本没有改变图像,因为这些特征非常微妙,影响图片中非常细微的细节,如光照。这里的输出带有idxs_to_swap分别为slice(4,15)slice (15,18)

两个人的拼贴,自动生成的描述,低置信度

图 13.13:不同层级上的层交换风格转移

  1. 接下来,我们推断出一个风格向量,使得新向量只会改变我们自定义图像的表情。为此,您需要计算将潜在向量移动的正确方向。我们可以通过首先创建大量的假图像来实现这一点。然后使用 SVM 分类器来识别图像中的人是否微笑。因此,这个 SVM 创建了一个分隔笑脸和非笑脸的超平面。移动的所需方向将是垂直于这个超平面,这被呈现为stylegan_ffhq_smile_w_boundary.npy。实现细节可以在InterfaceGAN/edit.py代码中找到:

    !python InterFaceGAN/edit.py\
     -m stylegan_ffhq\
     -o results_new_smile\
     -b InterFaceGAN/boundaries/stylegan_ffhq_smile_w_boundary.npy\
     -i pred_dlatents_myImage.npy\
     -s WP\
     --steps 20
    generated_faces = glob.glob('results_new_smile/*.jpg')
    subplots([read(im,1) for im in sorted(generated_faces)], 
                                            figsize=(10,10)) 
    

这里展示了生成的图像的外观:

一个人脸拼贴,自动生成的描述,中等置信度

图 13.14:从皱眉到微笑的情感变化进展

总结一下,我们学习了如何利用 GAN 生成非常高分辨率的人脸图像的研究进展。关键是通过逐步增加生成器和鉴别器的复杂性,以增加分辨率的步骤,使得在每一步中,这两个模型在其任务上都表现得不错。我们学习了如何通过确保每个分辨率上的特征由独立输入(称为风格向量)来操纵生成图像的风格。我们还学习了如何通过从一幅图像中交换风格来操纵不同图像的风格。

VToonify 可用于从输入视频生成高质量的艺术变化。有关论文和相关代码,请访问这里:github.com/williamyang1991/VToonify

现在我们已经学会如何利用预训练的 StyleGAN2 模型执行风格转移,在接下来的部分,我们将利用预训练的 SRGAN 模型生成高分辨率的图像。

引入 SRGAN

在前一节中,我们看到了一个场景,我们在其中使用预训练的 StyleGAN 来生成给定风格的图像。在本节中,我们将进一步学习如何使用预训练模型来执行图像超分辨率。在实施之前,我们将了解 SRGAN 模型的架构。

首先,我们将解释为什么 GAN 是超分辨率任务的一个好解决方案。想象一种情况,你拿到一张图像,并被要求提高其分辨率。直觉上,你会考虑各种插值技术来进行超分辨率。以下是一个低分辨率图像示例,以及各种技术的输出:

图 13.15:不同图像超分辨率技术的性能

(来源:arxiv.org/pdf/1609.04802.pdf

在前述图像中,我们可以看到传统的插值技术,如双三次插值,在从低分辨率重建图像(在本例中为原始图像的 4 倍降低缩放图像)方面并没有太大帮助。

尽管基于超分辨率 ResNet 的 UNet 在这种情况下可能很有用,但是 GAN 可以更有用,因为它们模拟了人类的感知。鉴别器知道典型的超分辨率图像是什么样子,可以检测到生成的图像具有不像高分辨率图像的特性的情况。

通过验证 GAN 在超分辨率中的实用性,让我们来了解并利用预训练模型。

架构

尽管可以从头开始编码和训练 SRGAN,但我们将在可能的情况下使用预训练模型。因此,在本节中,我们将利用 Christian Ledig 及其团队开发并发表在标题为《使用生成对抗网络的逼真单图像超分辨率》的论文中的模型。

SRGAN 的架构如下:

图表,条形图 自动生成的描述

图 13.16:SRGAN 架构

(来源:arxiv.org/pdf/1609.04802.pdf

在前述图像中,我们可以看到鉴别器将高分辨率图像作为输入来训练一个模型,预测一个图像是高分辨率还是低分辨率图像。生成器网络以低分辨率图像作为输入,并生成高分辨率图像。在训练模型时,同时最小化内容损失和对抗损失。如果你希望详细了解模型训练的细节以及各种技术用于生成高分辨率图像的比较结果,我们建议你阅读 Ledig 的论文。

在理解了模型构建的高层次之后,我们现在将编写代码来利用预训练的 SRGAN 模型将低分辨率图像转换为高分辨率图像。

编码 SRGAN

以下是加载预训练的 SRGAN 并进行预测的步骤:

以下代码在本书 GitHub 仓库的 Chapter13 文件夹中可作为 Image super resolution using SRGAN.ipynb 使用:bit.ly/mcvp-2e 代码包含了从中下载数据的 URL。我们强烈建议您在 GitHub 上执行笔记本,以便在您进行步骤执行和文本中各种代码组件的解释时重现结果。

  1. 导入相关包和预训练模型:

    import os
    if not os.path.exists('srgan.pth.tar'):
        !pip install -q torch_snippets
        !wget -q https://raw.githubusercontent.com/sizhky/a-PyTorch-Tutorial-to-Super-Resolution/master/models.py -O models.py
        from pydrive.auth import GoogleAuth
        from pydrive.drive import GoogleDrive
        from google.colab import auth
        from oauth2client.client import GoogleCredentials
        auth.authenticate_user()
        gauth = GoogleAuth()
        gauth.credentials = GoogleCredentials.get_application_default()
        drive = GoogleDrive(gauth)
        downloaded = drive.CreateFile({'id': \
                        '1_PJ1Uimbr0xrPjE8U3Q_bG7XycGgsbVo'})
        downloaded.GetContentFile('srgan.pth.tar')
        from torch_snippets import *
        device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 加载模型:

    model = torch.load('srgan.pth.tar', map_location='cpu')['generator'].to(device)
    model.eval() 
    
  3. 获取要转换为更高分辨率的图像:

    !wget https://www.dropbox.com/s/nmzwu68nrl9j0lf/Hema6.JPG 
    
  4. 定义预处理和后处理图像的函数:

    preprocess = T.Compose([
                    T.ToTensor(),
                    T.Normalize([0.485, 0.456, 0.406],
                                [0.229, 0.224, 0.225]),
                    T.Lambda(lambda x: x.to(device))
                ])
    postprocess = T.Compose([
                    T.Lambda(lambda x: (x.cpu().detach()+1)/2),
                    T.ToPILImage()
                ]) 
    
  5. 加载并预处理图像:

    image = readPIL('Hema6.JPG')
    image.size
    # (260,181)
    image = image.resize((130,90))
    im = preprocess(image) 
    

请注意,在上述代码中,我们对原始图像执行了额外的调整大小,以进一步模糊图像,但这仅用于说明,因为当我们使用缩小的图像时,改进效果更为明显。

  1. 通过加载的 model 对预处理过的图像进行处理,并对模型输出进行后处理:

    sr = model(im[None])[0]
    sr = postprocess(sr) 
    
  2. 绘制原始图像和高分辨率图像:

    subplots([image, sr], nc=2, figsize=(10,10),
             titles=['Original image','High resolution image']) 
    

上述代码的输出如下:

图 13.17:原始图像及其对应的 SRGAN 输出

在上述图像中,我们可以看到高分辨率图像捕捉到了原始图像中模糊的细节。

请注意,如果原始图像模糊或低分辨率,原始图像和高分辨率图像之间的对比度将会很高。然而,如果原始图像不模糊,则对比度不会那么高。我们鼓励您使用不同分辨率的图像进行工作。

总结

在本章中,我们学习了如何使用 Pix2Pix GAN 从图像的轮廓生成图像。此外,我们还学习了 CycleGAN 中的各种损失函数,将一类图像转换为另一类图像。接下来,我们学习了如何使用 StyleGAN 生成逼真的人脸,并根据生成器的训练方式复制图像之间的风格。最后,我们学习了如何使用预训练的 SRGAN 模型生成高分辨率图像。所有这些技术为我们进一步学习在 第 16第 17 章中更现代的图像属性转移方法打下了坚实基础。

在下一章中,我们将改变方向,学习如何将计算机视觉技术与强化学习中的其他突出技术结合使用。

问题

  1. 为什么我们需要 Pix2Pix GAN,如果像 UNet 这样的监督学习算法可以从轮廓生成图像?

  2. 为什么我们需要针对 CycleGAN 中的三种不同损失函数进行优化?

  3. ProgressiveGAN 使用的技巧如何帮助构建 StyleGAN 模型?

  4. 如何识别与给定自定义图像相对应的潜在向量?

在 Discord 上了解更多

加入我们社区的 Discord 空间,与作者和其他读者讨论:

packt.link/modcv

第四部分:结合计算机视觉与其他技术

本书最后一节中,我们将学习如何将计算机视觉技术与其他领域的技术(如自然语言处理NLP)、强化学习和基础模型)相结合,开发出解决传统问题的新方法,即使只有有限或没有训练数据。接下来,我们将学习像 Stable Diffusion 这样的新颖图像生成技术,并实现多个应用。最后,我们将学习将模型移至生产环境的最佳实践。

建议您查阅有关用最少数据点进行训练的补充章节,以熟悉词嵌入,这些章节可以在 GitHub 上的第一版额外章节文件夹中找到。

本节包括以下章节:

  • 第十四章结合计算机视觉和强化学习

  • 第十五章结合计算机视觉和 NLP 技术

  • 第十六章计算机视觉中的基础模型

  • 第十七章Stable Diffusion 的应用

  • 第十八章将模型移至生产环境

第十四章:结合计算机视觉和强化学习

在上一章中,我们学习了如何生成感兴趣的图像。在本章中,我们将学习如何将基于强化学习的技术(主要是深度 Q-learning)与基于计算机视觉的技术相结合。这在学习环境复杂且无法收集所有案例数据的场景中特别有用。在这种情况下,我们希望模型在模拟环境中尽可能接近现实地自学习。这种模型在用于自动驾驶汽车、机器人、游戏中的机器人(真实和数字)、以及自监督学习领域时非常有用。

我们将从了解强化学习的基本知识开始,然后了解与确定如何计算与在给定状态下采取行动相关的价值(Q 值)相关的术语。然后,我们将学习填充Q 表,这有助于确定在给定状态下与各种行动相关联的值。我们还将学习在由于可能状态数过高而无法创建 Q 表的情况下,如何利用深度 Q 网络DQN)来确定各种行动的 Q 值。这是我们将了解如何将神经网络与强化学习相结合。接下来,我们将学习 DQN 模型本身无法工作的情况,通过使用固定目标模型来解决这个问题。在这里,我们将通过利用 CNN 与强化学习来玩一个名为 Pong 的视频游戏。最后,我们将利用我们所学到的知识来构建一个可以在模拟环境中自主驾驶汽车的代理人 - CARLA。

总之,在本章中,我们将涵盖以下主题:

  • 学习强化学习的基础知识

  • 实现 Q-learning

  • 实现深度 Q-learning

  • 实现具有固定目标的深度 Q-learning

  • 实现一个执行自主驾驶的代理

本章中的所有代码片段都在 GitHub 存储库的Chapter14文件夹中可用,网址是bit.ly/mcvp-2e

随着领域的发展,我们将定期向 GitHub 存储库添加有价值的补充内容。请检查每个章节目录下的supplementary_sections文件夹获取新的有用内容。

学习强化学习的基础知识

强化学习RL)是机器学习的一个领域,关注软件代理人如何在给定环境状态下采取行动,最大化累积奖励的概念。

要了解 RL 如何帮助,让我们考虑一个简单的场景。想象一下你正在与计算机下国际象棋。让我们确定涉及的不同组件:

  • 计算机是一个已经学会/正在学习如何下国际象棋的代理人

  • 游戏设置(规则)构成了环境

  • 当我们进行一步棋(采取行动)时,棋盘的状态(棋盘上各个棋子的位置)会发生变化。

  • 游戏结束时,根据结果,代理获得奖励。代理的目标是最大化奖励。

如果机器(agent1)与人类对弈,它能够进行的游戏数量是有限的(取决于人类可以进行的游戏数量)。这可能对代理学习游戏造成瓶颈。但是,如果agent1(正在学习游戏的代理)能够与agent2对弈(agent2可以是另一个正在学习国际象棋的代理,或者是一个已经预先编程以玩游戏的国际象棋软件)呢?从理论上讲,这些代理可以无限对弈,这最大化了学习玩游戏的机会。通过进行多场游戏,学习代理很可能学会如何处理游戏的不同场景/状态。

让我们理解学习代理将要遵循的学习过程:

  1. 最初,代理在给定状态下采取随机动作。

  2. 代理将其在游戏中各个状态下采取的动作存储在内存中。

  3. 然后,代理将在各个状态下动作的结果与奖励关联起来。

  4. 在进行多场游戏之后,代理可以通过重播其经历来关联状态中的动作和潜在奖励。

接下来是量化在给定状态下采取行动所对应的价值的问题。我们将在下一节学习如何计算这个价值。

计算状态价值

要理解如何量化一个状态的价值,让我们使用一个简单的场景来定义环境和目标,如下所示:

包含将自动生成描述的将棋图片

图 14.1:环境

环境是一个具有两行三列的网格。代理从起始单元格开始,并且如果到达右下角的网格单元格,则实现其目标(奖励得分+1)。如果它去到任何其他单元格,代理将不会获得奖励。代理可以通过向右、向左、向下或向上移动来采取行动,具体取决于行动的可行性(例如,代理可以从起始网格单元格向右或向下移动)。到达除右下角单元格以外的任何其他单元格的奖励为 0。

利用这些信息,让我们计算一个单元格的价值(代理在给定快照中所处的状态)。鉴于从一个单元格移动到另一个单元格会消耗一些能量,我们通过一个折扣因子 γ 来打折到达单元格的价值,其中 γ 考虑到从一个单元格移动到另一个单元格所花费的能量。此外,引入 γ 会导致代理更快地学会玩得好。因此,让我们形式化广泛使用的贝尔曼方程,帮助计算单元格的价值:

有了上述方程式,让我们计算所有单元格的值(一旦确定了状态中的最优动作),其中γ的值为 0.9(γ的典型值在 0.9 到 0.99 之间):

从前述计算中,我们可以理解如何计算给定状态(单元格)中的值,当给出该状态中的最优动作时。对于我们达到终端状态的简化情景,这些值如下:

一个包含形状描述的图片自动生成

图 14.2:每个单元格的值

有了这些值,我们期望代理会遵循增值路径。

现在我们了解了如何计算状态值,接下来的部分中,我们将了解如何计算与状态-动作组合相关联的值。

计算状态-动作值

在上一节中,我们提供了一个情景,其中我们已经知道代理正在采取最优动作(这并不现实)。在本节中,我们将看看一个情景,我们可以识别与状态-动作组合对应的值。

在下图中,单元格内的每个子单元格代表在该单元格中采取动作的值。最初,给定状态中各种动作的单元格值如下:

多边形描述自动生成

图 14.3:给定状态中不同动作的初始值

请注意,在前面的图像中,单元格b1(第 1 行和第 2 列)将具有值 1,如果代理从该单元格向右移动(因为它对应终端单元格);其他动作结果为 0。X 表示该动作不可能,因此与之相关联的值为零。

在四次迭代(步骤)后,给定状态中各动作的更新单元格值如下:

图解描述自动生成

图 14.4:四次迭代后更新的单元格值

随后将通过多次迭代提供最大化每个单元格价值的最优动作。

让我们了解如何获取第二个表中的单元格值(在前述图像中称为迭代 2)。我们将其缩小到 0.3,这是通过在第二个表的第 1 行和第 2 列中存在时采取向下动作获得的。当代理采取向下动作时,有 1/3 的机会采取下一个状态的最优动作。因此,采取向下动作的值如下:

类似地,我们可以获取不同单元格中采取不同可能动作的值。

现在我们知道了如何计算给定状态中各种动作的值,在接下来的部分中,我们将学习 Q-learning 以及如何利用它与 Gym 环境,使其能够玩各种游戏。

实施 Q-learning

从技术上讲,现在我们已经计算出了所需的各种状态-动作值,我们可以确定在每个状态下将采取的动作。然而,在更复杂的情景中——例如玩视频游戏时——获取状态信息就变得棘手。OpenAI 的Gym环境在这种情况下非常有用。它包含了我们正在玩的游戏的预定义环境。在这里,它获取下一个状态的信息,给定在当前状态下已经采取的动作。到目前为止,我们考虑了选择最优路径的情况。然而,可能会出现我们陷入局部最小值的情况。

在本节中,我们将学习 Q-learning,它帮助计算状态中与动作相关联的值,以及利用 Gym 环境,使我们能够玩各种游戏。目前,我们将看看一个称为 Frozen Lake 的简单游戏,它在 Gym 环境中可用。我们还将学习探索-利用,这有助于避免陷入局部最小值。然而,在此之前,我们将学习 Q 值。

定义 Q 值

Q-learning 或者 Q 值中的 Q 代表动作的质量(值)。让我们回顾一下如何计算它:

我们已经知道,我们必须持续更新给定状态的状态-动作值,直到饱和为止。因此,我们将修改前述公式如下:

在上述方程中,我们用学习率替换 1,以便我们可以更渐进地更新在状态中采取的动作的值:

在这个 Q 值的正式定义中,接下来的部分中,我们将学习关于 Gym 环境以及它如何帮助我们获取 Q 表(其中存储了在各种状态下执行的各种动作的价值信息),从而在状态中提出最优动作。

理解 Gym 环境

在本节中,我们将探索 Gym 环境以及其中的各种功能,同时玩冰湖游戏:

以下代码作为Understanding_the_Gym_environment.ipynb在本书 GitHub 存储库的Chapter14文件夹中提供,网址为bit.ly/mcvp-2e

  1. 安装并导入相关软件包:

    %pip install -U "gym==0.26.2"
    import numpy as np
    import gym
    import random 
    
  2. 打印 Gym 环境中的各种环境:

    from gym import envs
    print('\n'.join([str(env) for env in envs.registry])) 
    

前述代码打印了一个包含在 Gym 中所有可用游戏的字典。

  1. 为所选游戏创建一个环境:

    env = gym.make('FrozenLake-v1', is_slippery=False, render_mode='rgb_array') 
    
  2. 检查已创建的环境:

    env.render() 
    

前述代码的输出如下:

图 14.5:环境状态

在上述图像中,代理从 左上角 开始。在冰冻湖中间有四个洞。如果代理掉入洞中,将获得 0 的奖励并终止游戏。游戏的目标是使代理通过采取特定的动作(在步骤 6 中提到)达到 目标(右下角)

  1. 打印游戏中观测空间的大小(即状态数):

    env.observation_space.n 
    

上述代码给出了 16 的输出。这代表游戏的 16 个单元格。

  1. 打印可能动作的数量:

    env.action_space.n 
    

上述代码的结果是 4,表示可以执行的四种可能动作。

  1. 在给定状态下随机抽取一个动作:

    env.action_space.sample() 
    

在这里,.sample() 指定我们在给定状态下随机选择四种可能的动作之一。每个动作对应的标量可以与动作的名称相关联。我们可以通过查看 GitHub 上的源代码来做到这一点:github.com/openai/gym/blob/master/gym/envs/toy_text/frozen_lake.py

  1. 将环境重置为其原始状态:

    env.reset() 
    
  2. 执行 (step) 一个动作:

    env.step(env.action_space.sample()) 
    

上述代码获取下一个状态、奖励、游戏是否完成的标志以及其他信息。我们可以使用 .step 执行游戏,因为环境在给定动作的情况下会提供下一个状态。

这些步骤为我们建立一个指导在每个状态下采取最优动作的 Q 表奠定了基础。我们将在下一节中完成这一操作。

构建 Q 表

在前一节中,我们学习了如何手动计算各种状态-动作对的 Q 值。在本节中,我们将利用 Gym 环境及其相关模块填充 Q 表,其中行表示代理的可能状态,列表示代理可以执行的动作。Q 表的值表示在给定状态下执行动作的 Q 值。

我们可以使用以下策略填充 Q 表的数值:

  1. 初始化游戏环境和 Q 表,以零填充。

  2. 执行一个随机动作,并获取下一个状态、奖励、游戏是否完成的标志以及其他信息。

  3. 使用我们之前定义的贝尔曼方程更新 Q 值。

  4. 重复 步骤 23,使每个回合最多有 50 步。

  5. 重复 步骤 234 多个回合。

让我们编写上述策略的代码:

以下代码可在本书的 GitHub 代码库的 Chapter14 文件夹中的 Building_Q_table.ipynb 中找到:bit.ly/mcvp-2e

  1. 安装和初始化游戏环境:

    %pip install torch-snippets "gym==0.26.2"
    import numpy as np
    import gym
    import random
    env = gym.make('FrozenLake-v0', is_slippery=False, 
                                    render_mode='rgb_array') 
    
  2. 使用零初始化 Q 表:

    action_size=env.action_space.n
    state_size=env.observation_space.n
    qtable=np.zeros((state_size,action_size)) 
    

上述代码检查可以用来构建 Q 表的可能动作和状态。Q 表的维度应该是状态数乘以动作数。

  1. 进行多个回合,同时随机选择一个动作:

    1. 在这里,我们首先在每个 episode 结束时重置环境:
    episode_rewards = []
    for i in range(10000):
        state, *_ =env.reset() 
    
    1. 每个 episode 最多执行 50 步:
     total_rewards = 0
        for step in range(50): 
    
    1. 我们考虑每个 episode 最多 50 步,因为代理可能会在两个状态之间永远循环(考虑连续执行左右动作)。因此,我们需要指定代理可以采取的最大步数。

    2. 随机采样一个动作并执行(step)该动作:

     action=env.action_space.sample()
            new_state,reward,done,*_=env.step(action) 
    
    1. 更新对应于状态和动作的 Q 值:
     qtable[state,action]+=0.1*(reward+0.9*np.max(qtable[new_state,:]) \
                                                       -qtable[state,action]) 
    
    1. 在上述代码中,我们指定学习速率为0.1,并更新状态-动作组合的 Q 值,考虑到下一个状态的最大 Q 值(np.max(qtable[new_state,:]))。

    2. 更新state值为之前获得的new_state,并将reward累积到total_rewards中:

     state=new_state
            total_rewards+=reward 
    
    1. 将奖励放入列表(episode_rewards),并打印 Q 表(qtable):
     episode_rewards.append(total_rewards)
    print(qtable) 
    

上述代码获取了各种动作在不同状态下的 Q 值:

Table  Description automatically generated with medium confidence

图 14.6:各种动作在状态间的 Q 值

我们将学习如何在下一节中利用获得的 Q 表。

到目前为止,我们一直在每次随机采取一个动作。然而,在现实场景中,一旦我们学到某些动作不能在某些状态下执行,反之亦然,我们就不需要再随机采取动作了。在这种情况下,探索-利用的概念非常有用。

利用探索-利用

探索-利用的概念可以描述如下:

  • 探索 是一种策略,我们学习在给定状态下需要做什么(采取什么动作)。

  • 利用 是一种策略,利用已经学到的知识 - 即在给定状态下采取哪个动作。

在初始阶段,高量的探索是理想的,因为代理一开始不会知道哪些动作是最优的。随着 episode 的进行,随着代理逐渐学习各种状态-动作组合的 Q 值,我们必须利用利用,执行能够带来高奖励的动作。

有了这个直觉之后,让我们修改在前一节中构建的 Q 值计算方法,以便包含探索和利用:

episode_rewards = []
**epsilon=****1**
**max_epsilon=****1**
**min_epsilon=****0.01**
**decay_rate=****0.005**
for episode in range(1000):
    state, *_=env.reset()
    total_rewards = 0
    for step in range(50):
        **exp_exp_tradeoff=random.uniform(****0****,****1****)**
        **## Exploitation:**
        **if** **exp_exp_tradeoff>epsilon:**
            **action=np.argmax(qtable[state,:])**
        **else****:**
            **## Exploration**
            **action=env.action_space.sample()**
        new_state,reward,done,*_=env.step(action)
        qtable[state,action]+=0.9*(reward+0.9*np.max(\
                                  qtable[new_state,:])\
                                   -qtable[state,action])
        state=new_state
        total_rewards+=reward
    episode_rewards.append(total_rewards)
    epsilon=min_epsilon+(max_epsilon-min_epsilon)\
                            ***np.exp(decay_rate*episode)**
print(qtable) 

在上述代码中的粗体行是在之前显示的代码基础上新增的部分。在这段代码中,我们指定随着更多的 episode 进行,我们执行更多的利用而非探索。

一旦我们获得了 Q 表,我们可以利用它来确定代理需要执行的步骤以达到目的地:

env.reset()
for episode in range(1):
    state, *_=env.reset()
    step=0
    done=False
    print("-----------------------")
    print("Episode",episode)
    for step in range(50):
        env.render()
        action=np.argmax(qtable[state,:])
        print(action)
        new_state,reward,done,*_­­­=env.step(action)
        if done:
            print("Number of Steps",step+1)
            break
        state=new_state
env.close() 

在前面的代码中,我们获取代理所处的当前state,确定在给定状态-动作组合中产生最大值的action,执行该动作(step)以获取代理将会处于的new_state对象,并重复这些步骤直到游戏完成(终止)。

前面的代码的输出结果如下:

图 14.7:代理执行的最优动作

正如您从前面的图中看到的那样,代理能够采取最优动作来达到其目标。请注意,这只是一个简化的例子,因为状态空间是离散的,导致我们构建了一个 Q 表。

但是,如果状态空间是连续的(例如,状态空间是游戏当前状态的快照图像)会怎么样?构建 Q 表变得非常困难(因为可能的状态数量非常大)。在这种情况下,深度 Q 学习非常有用。我们将在下一节学习关于这个的内容。

实施深度 Q 学习

到目前为止,我们已经学习了如何构建 Q 表,通过多次回放游戏(在本例中是 Frozen Lake 游戏)来为给定的状态-动作组合提供对应的值。然而,当状态空间是连续的(比如 Pong 游戏的快照 - 也就是图像),可能的状态空间数量就变得非常庞大。我们将在本节以及接下来的节中解决这个问题,使用深度 Q 学习。在本节中,我们将学习如何通过神经网络估计状态-动作组合的 Q 值,因此称为深度 Q 学习。与 Q 表相比,深度 Q 学习利用神经网络来映射任何给定的状态-动作(其中状态可以是连续的或离散的)组合到 Q 值。

在这个练习中,我们将在 Gym 的 CartPole 环境中进行操作。让我们首先了解这是什么。

理解 CartPole 环境

我们的任务是尽可能长时间地平衡车杆。下面的图像展示了 CartPole 环境的样子:

图表、图示 由描述自动生成

图 14.8:CartPole 环境中的可能动作

注意,在购物车向右移动时,杆向左移动,反之亦然。此环境中的每个状态都使用四个观测定义,它们的名称及其最小和最大值如下:

观察 最小值 最大值
购物车位置 -2.4 2.4
购物车速度 -inf inf
杆角度 -41.8° 41.8°
杆顶端的速度 -inf inf

表 14.1:CartPole 环境中的观察(状态)

请注意,表示状态的所有观察都具有连续的值。

在高层次上,CartPole 平衡游戏的深度 Q 学习工作如下:

  1. 获取输入值(游戏图像/游戏的元数据)。

  2. 将输入值通过一个具有与可能动作数相同输出数量的网络。

  3. 输出层预测与在给定状态下采取行动相对应的动作值。

网络架构的高级概述如下:

Diagram  Description automatically generated

图 14.9:在给定状态时识别正确动作值的网络架构

在上述图像中,网络架构使用状态(四个观察)作为输入,当前状态下采取左右动作的 Q 值作为输出。我们按以下方式训练神经网络:

  1. 在探索阶段,我们执行具有输出层中最高值的随机动作。

  2. 然后,我们将动作、下一个状态、奖励以及标志游戏是否完成存储在内存中。

  3. 在给定状态下,如果游戏尚未完成,则在该状态下采取行动的 Q 值将如下计算:

  1. 当前状态-动作组合的 Q 值保持不变,除了在 步骤 2 中执行的动作。

  2. 多次执行步骤 1 到 4 并存储经验。

  3. 适合一个以状态作为输入和以行动值作为预期输出的模型(从记忆和重播经验中),并最小化均方误差MSE)损失,该损失是最佳动作在下一个状态的目标 Q 值与在给定状态下动作的预测 Q 值之间的差值。

  4. 在多个情节上重复上述步骤,同时减少探索率。

有了上述策略,让我们编写深度 Q 学习,以便进行 CartPole 平衡。

执行 CartPole 平衡

要执行 CartPole 平衡,您可以使用以下代码:

此代码作为 Deep_Q_Learning_Cart_Pole_balancing.ipynb 存在于此书 GitHub 存储库的 Chapter14 文件夹中,网址为 bit.ly/mcvp-2e。该代码包含用于下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 上执行笔记本以重现结果,以便理解所需执行的步骤和各种代码组件。

  1. 安装并导入相关包:

    %pip install "gym==0.26.2"
    import gym
    import numpy as np
    import cv2import torch
    import torch.nn as nn
    import torch.nn.functional as F
    import random
    from collections import namedtuple, deque
    import torch.optim as optim
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 定义环境:

    env = gym.make('CartPole-v1') 
    
  3. 定义网络架构:

    class DQNetwork(nn.Module):
        def __init__(self, state_size, action_size):
            super(DQNetwork, self).__init__()
    
            self.fc1 = nn.Linear(state_size, 24)
            self.fc2 = nn.Linear(24, 24)
            self.fc3 = nn.Linear(24, action_size)
    
        def forward(self, state):     
            x = F.relu(self.fc1(state))
            x = F.relu(self.fc2(x))
            x = self.fc3(x)
            return x 
    

注意,由于仅在 2 个隐藏层中包含 24 个单元,因此体系结构相当简单。输出层包含与可能动作数相同数量的单元。

  1. 定义 Agent 类,如下所示:

    1. 使用各种参数、网络和经验定义 __init__ 方法:
    class Agent():
        def __init__(self, state_size, action_size):
    
            self.state_size = state_size
            self.action_size = action_size
            self.seed = random.seed(0)
            ## hyperparameters
            self.buffer_size = 2000
            self.batch_size = 64
            self.gamma = 0.99
            self.lr = 0.0025
            self.update_every = 4
            # Q-Network
            self.local = DQNetwork(state_size, action_size).to(device)
            self.optimizer=optim.Adam(self.local.parameters(), lr=self.lr)
            # Replay memory
            self.memory = deque(maxlen=self.buffer_size)
            self.experience = namedtuple("Experience", \
                                field_names=["state", "action",
                                "reward", "next_state", "done"])
            self.t_step = 0 
    
    1. 定义 step 函数,从内存中获取数据,并通过调用 learn 函数将其适应模型:
     def step(self, state, action, reward, next_state, done):
            # Save experience in replay memory
            self.memory.append(self.experience(state, action,
                                               reward, next_state, done))
            # Learn once every 'update_every' number of time steps.
            self.t_step = (self.t_step + 1) % self.update_every
            if self.t_step == 0:
            # If enough samples are available in memory,
            # get random subset and learn
                if len(self.memory) > self.batch_size:
                    experiences = self.sample_experiences()
                    self.learn(experiences, self.gamma) 
    
    1. 请注意,我们正在学习随机样本的经验(使用 self.memory),而不是连续经验序列,确保模型仅基于当前输入学习要做什么。如果我们按顺序提供经验,模型可能会学习连续输入中的相关性。

    2. 定义 act 函数,它在给定状态时预测动作:

     def act(self, state, eps=0.):
            # Epsilon-greedy action selection
            if random.random() > eps:
                state = torch.from_numpy(state).float()\
                                               .unsqueeze(0).to(device)
                self.local.eval()
                with torch.no_grad():
                    action_values = self.local(state)
                self.local.train()
                return np.argmax(action_values.cpu().data.numpy())
            else:
                return random.choice(np.arange(self.action_size)) 
    
    1. 注意,在上述代码中,我们在确定要采取的动作时进行探索利用。

    2. 定义 learn 函数,它适配模型以便在给定状态时预测动作值:

     def learn(self, experiences, gamma):
            states,actions,rewards,next_states,dones= experiences
            # Get expected Q values from local model
            Q_expected = self.local(states).gather(1, actions)
            # Get max predicted Q values (for next states)
            # from local model
            Q_targets_next = self.local(next_states).detach()\
                                                    .max(1)[0].unsqueeze(1)
            # Compute Q targets for current states
            Q_targets = rewards+(gamma*Q_targets_next*(1-dones))
    
            # Compute loss
            loss = F.mse_loss(Q_expected, Q_targets)
            # Minimize the loss
            self.optimizer.zero_grad()
            loss.backward()
            self.optimizer.step() 
    
    1. 在上述代码中,我们获取了抽样的经验并预测了我们执行的动作的 Q 值。此外,考虑到我们已经知道下一个状态,我们可以预测下一个状态中动作的最佳 Q 值。这样,我们现在知道了与在给定状态下采取的动作对应的目标值。最后,我们计算了预期值 (Q_targets) 和预测值 (Q_expected) 之间的损失,即当前状态中采取的动作的 Q 值的损失。

    2. 定义 sample_experiences 函数以从记忆中抽取经验:

     def sample_experiences(self):
            experiences = random.sample(self.memory,
                                        k=self.batch_size)
            states = torch.from_numpy(np.vstack([e.state \
                        for e in experiences if e is not \
                                    None])).float().to(device)
            actions = torch.from_numpy(np.vstack([e.action \
                        for e in experiences if e is not \
                                    None])).long().to(device)
            rewards = torch.from_numpy(np.vstack([e.reward \
                        for e in experiences if e is not \
                                    None])).float().to(device)
            next_states=torch.from_numpy(np.vstack([e.next_state \
                        for e in experiences if e is not \
                                      None])).float().to(device)
            dones = torch.from_numpy(np.vstack([e.done \
                        for e in experiences if e is not None])\
                           .astype(np.uint8)).float().to(device)
            return (states, actions, rewards, next_states,dones) 
    
  2. 定义 agent 对象:

    agent = Agent(env.observation_space.shape[0], env.action_space.n) 
    
  3. 执行深度 Q 学习,如下所示:

    1. 初始化将存储得分信息和超参数的列表:
    scores = [] # list containing scores from each episode
    scores_window = deque(maxlen=100) # last 100 scores
    n_episodes=5000
    max_t=5000
    eps_start=1.0
    eps_end=0.001
    eps_decay=0.9995
    eps = eps_start 
    
    1. 在每一回合中重置环境并获取状态的形状(观察数量)。此外,重塑它以便我们可以将其传递给网络:
    for i_episode in range(1, n_episodes+1):
        state, *_ = env.reset()
        state_size = env.observation_space.shape[0]
        state = np.reshape(state, [1, state_size])
        score = 0 
    
    1. max_t 时间步中循环,确定要执行的动作并执行 (step)。然后,重塑状态,以便将重塑后的状态传递给神经网络:
     for i in range(max_t):
            action = agent.act(state, eps)
            next_state, reward, done, *_ = env.step(action)
            next_state = np.reshape(next_state, [1, state_size]) 
    
    1. 通过指定 agent.step 在当前状态的基础上适配模型,并重置状态到下一个状态,以便在下一次迭代中有用:
     reward = reward if not done or score == 499 else -10
            agent.step(state, action, reward, next_state, done)
            state = next_state
            score += reward
            if done:
                break 
    
    1. 存储得分值,定期打印,并在前 10 步得分的均值大于 450 时停止训练(这通常是一个不错的分数,因此被选择):
     scores_window.append(score) # save most recent score
        scores.append(score) # save most recent score
        eps = max(eps_end, eps_decay*eps) # decrease epsilon
        print('\rEpisode {:.2f}\tReward {:.2f} \tAverage Score: {:.2f} \tEpsilon: {:.2f}'.format(i_episode, score, np.mean(scores_window), eps), end="")
        if i_episode % 100 == 0:
            print('\rEpisode {:.2f}\tAverage Score: {:.2f} \tEpsilon: {:.2f}'.format(i_episode, np.mean(scores_window), eps))
        if i_episode>10 and np.mean(scores[-10:])>450:
            break 
    
  4. 绘制随着回合增加得分变化的图表:

    import matplotlib.pyplot as plt
    %matplotlib inline
    plt.plot(scores)
    plt.title('Scores over increasing episodes') 
    

以下是显示得分随回合变化的图表:

图表,直方图 由系统自动生成的描述

图 14.10:随着回合增加的得分

从上图可以看出,在第 2,000 个回合后,模型在平衡 CartPole 时取得了高分。

现在我们已经学会了如何实现深度 Q 学习,在接下来的部分,我们将学习如何处理不同的状态空间 - Pong 中的视频帧,而不是定义 CartPole 环境中的四个状态空间。我们还将学习如何使用固定目标模型实现深度 Q 学习。

使用固定目标模型实现深度 Q 学习

在前一节中,我们学习了如何利用深度 Q 学习解决 Gym 中的 CartPole 环境问题。在本节中,我们将解决一个更复杂的乒乓球游戏,并了解如何利用深度 Q 学习以及固定目标模型来解决游戏。在处理此用例时,您还将学习如何利用基于 CNN 的模型(代替我们在前一节中使用的普通神经网络)来解决问题。前一节的理论基本保持不变,但关键变化是“固定目标模型”。基本上,我们创建了本地模型的副本,并在每 1,000 步与本地模型一起使用本地模型的奖励。这使得本地模型更加稳定,并更平滑地更新其权重。在 1,000 步之后,我们使用本地模型更新目标模型,以更新整体学习。

两个模型有效的原因是,我们减轻了本地模型同时选择动作和生成训练网络目标的负担 - 这种相互依赖会导致训练过程中的显著振荡。

理解用例

该用例的目标是构建一个能够与计算机(预训练的非学习代理)对战并在乒乓球游戏中击败它的代理,期望代理能够获得 21 分。

我们将采用的策略来解决创建成功的乒乓球游戏代理的问题如下:

  1. 剪裁图像的不相关部分以获取当前帧(状态):

自动生成的图表描述

图 14.11:乒乓球游戏中的原始图像和处理后图像(帧)

请注意,在前述图像中,我们已经对原始图像进行了处理,剪裁了处理后图像的顶部和底部像素。

  1. 堆叠四个连续帧 - 代理需要状态序列来判断球是否接近它。

  2. 让代理通过最初采取随机动作来进行游戏,并在内存中收集当前状态、未来状态、采取的动作和奖励。只保留最近 10,000 个动作的信息,并在超过 10,000 个动作时清除历史信息。

  3. 构建一个网络(本地网络),从内存中取样状态并预测可能动作的值。

  4. 定义另一个网络(目标网络),它是本地网络的复制品。

  5. 每当更新本地网络 1,000 次时,更新目标网络。每 1,000 个周期结束时,目标网络的权重与本地网络的权重相同。

  6. 利用目标网络计算下一状态中最佳动作的 Q 值。

  7. 对于本地网络建议的动作,我们期望它预测即时奖励和下一状态中最佳动作的 Q 值之和。

  8. 最小化本地网络的均方误差损失。

  9. 让代理程序继续播放,直到最大化其奖励。

有了上述策略,我们现在可以编写代理程序,使其在玩乒乓球时最大化奖励。

编写代理程序玩乒乓球

编写代理程序以使其自学习如何玩乒乓球的步骤如下:

以下代码在本书的 GitHub 存储库的Chapter14文件夹中的Pong_Deep_Q_Learning_with_Fixed_targets.ipynb文件中提供。代码包含从中下载数据的 URL,并且代码长度适中。我们强烈建议您在 GitHub 上执行笔记本以重现结果,以理解执行步骤和各种代码组件。

  1. 导入相关包并设置游戏环境:

    %pip install -qqU "gym[atari, accept-rom-license]==0.26.2"
    import gym
    import numpy as np
    import cv2
    from collections import deque
    import matplotlib.pyplot as plt
    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    import random
    from collections import namedtuple, deque
    import torch.optim as optim
    import matplotlib.pyplot as plt
    %matplotlib inline
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    env = gym.make('PongDeterministic-v0') 
    
  2. 定义状态大小和动作大小:

    state_size = env.observation_space.shape[0]
    action_size = env.action_space.n 
    
  3. 定义一个函数,该函数将预处理帧以去除不相关的底部和顶部像素:

    def preprocess_frame(frame):
        bkg_color = np.array([144, 72, 17])
        img = np.mean(frame[34:-16:2,::2]-bkg_color,axis=-1)/255.
        resized_image = img
        return resized_image 
    
  4. 定义一个函数,该函数将堆叠四个连续的帧,如下所示:

    1. 该函数接受stacked_frames、当前stateis_new_episode标志作为输入:
    def stack_frames(stacked_frames, state, is_new_episode):
        # Preprocess frame
        frame = preprocess_frame(state)
        stack_size = 4 
    
    1. 如果这是新的场次(游戏重新开始),我们将以初始帧的堆栈开始:
     if is_new_episode:
            # Clear our stacked_frames
            stacked_frames = deque([np.zeros((80,80), dtype=np.uint8) \
                             for i in range(stack_size)], maxlen=4)
            # Because we're in a new episode,
            # copy the same frame 4x
            for i in range(stack_size):
                stacked_frames.append(frame)
            # Stack the frames
            stacked_state = np.stack(stacked_frames, \
                                     axis=2).transpose(2, 0, 1) 
    
    1. 如果该场次不是新的,我们将从stacked_frames中移除最旧的帧并附加最新的帧:
     else:
            # Append frame to deque,
            # automatically removes the #oldest frame
            stacked_frames.append(frame)
            # Build the stacked state
            # (first dimension specifies #different frames)
            stacked_state = np.stack(stacked_frames, \
                                     axis=2).transpose(2, 0, 1)
        return stacked_state, stacked_frames 
    
  5. 定义网络架构——即DQNetwork

    class DQNetwork(nn.Module):
        def __init__(self, states, action_size):
            super(DQNetwork, self).__init__()
    
            self.conv1 = nn.Conv2d(4, 32, (8, 8), stride=4)
            self.conv2 = nn.Conv2d(32, 64, (4, 4), stride=2)
            self.conv3 = nn.Conv2d(64, 64, (3, 3), stride=1)
            self.flatten = nn.Flatten()
            self.fc1 = nn.Linear(2304, 512)
            self.fc2 = nn.Linear(512, action_size)
    
        def forward(self, state):
            x = F.relu(self.conv1(state))
            x = F.relu(self.conv2(x))
            x = F.relu(self.conv3(x))
            x = self.flatten(x)
            x = F.relu(self.fc1(x))
            x = self.fc2(x)
            return x 
    
  6. 如前一节中所做的那样,定义Agent类:

    1. 定义__init__方法:
    class Agent():
        **def****__init__****(self, state_size, action_size):**      
            self.state_size = state_size
            self.action_size = action_size
            self.seed = random.seed(0)
            ## hyperparameters
            self.buffer_size = 1000
            self.batch_size = 32
            self.gamma = 0.99
            self.lr = 0.0001
            self.update_every = 4
            self.update_every_target = 1000
            self.learn_every_target_counter = 0
            # Q-Network
            self.local = DQNetwork(state_size, action_size).to(device)
            self.target = DQNetwork(state_size, action_size).to(device)
            self.optimizer=optim.Adam(self.local.parameters(), lr=self.lr)
            # Replay memory
            self.memory = deque(maxlen=self.buffer_size)
            self.experience = namedtuple("Experience", \
                               field_names=["state", "action", \
                                "reward", "next_state", "done"])
            # Initialize time step (for updating every few steps)
            self.t_step = 0 
    
    1. 请注意,在前述代码的__init__方法中,与前一节提供的代码相比,我们唯一增加的部分是target网络及其更新频率(这些行在前述代码中以粗体显示)。

    2. 定义将更新权重的方法(step),就像我们在前一节中所做的那样:

     def step(self, state, action, reward, next_state, done):
            # Save experience in replay memory
            self.memory.append(self.experience(state[None], \
                                        action, reward, \
                                        next_state[None], done))
    
            # Learn every update_every time steps.
            self.t_step = (self.t_step + 1) % self.update_every
            if self.t_step == 0:
        # If enough samples are available in memory, get  
        # random subset and learn
                if len(self.memory) > self.batch_size:
                    experiences = self.sample_experiences()
                    self.learn(experiences, self.gamma) 
    
    1. 定义act方法,该方法将获取给定状态下要执行的动作:
     def act(self, state, eps=0.):
            # Epsilon-greedy action selection
            if random.random() > eps:
                state = torch.from_numpy(state).float()\
                             .unsqueeze(0).to(device)
                self.local.eval()
                with torch.no_grad():
                    action_values = self.local(state)
                self.local.train()
                return np.argmax(action_values.cpu().data.numpy())
            else:
                return random.choice(np.arange(self.action_size)) 
    
    1. 定义learn函数,该函数将训练本地模型:
     def learn(self, experiences, gamma):
            self.learn_every_target_counter+=1
            states,actions,rewards,next_states,dones = experiences
            # Get expected Q values from local model
            Q_expected = self.local(states).gather(1, actions)
            # Get max predicted Q values (for next states)
            # from target model
            **Q_targets_next =** **self****.target(next_states).detach()\**
                                 **.****max****(****1****)[****0****].unsqueeze(****1****)**
            # Compute Q targets for current state
            Q_targets = rewards+(gamma*Q_targets_next*(1-dones))
    
            # Compute loss
            loss = F.mse_loss(Q_expected, Q_targets)
            # Minimize the loss
            self.optimizer.zero_grad()
            loss.backward()
            self.optimizer.step()
            # ------------ update target network ------------- #
            if self.learn_every_target_counter%1000 ==0:
                self.target_update() 
    
    1. 请注意,在前述代码中,Q_targets_next是使用目标模型而不是在前一节中使用的本地模型预测的(我们在代码中突出显示了这一行)。我们还会在每 1,000 步之后更新目标网络,其中learn_every_target_counter是帮助我们确定是否应更新目标模型的计数器。

    2. 定义一个函数(target_update),用于更新目标模型:

     def target_update(self):
            print('target updating')
            self.target.load_state_dict(self.local.state_dict()) 
    
    1. 定义一个函数,该函数将从内存中采样经验:
     def sample_experiences(self):
            experiences = random.sample(self.memory, k=self.batch_size)
            states = torch.from_numpy(np.vstack([e.state \
                        for e in experiences if e is not \
                               None])).float().to(device)
            actions = torch.from_numpy(np.vstack([e.action \
                        for e in experiences if e is not \
                               None])).long().to(device)
            rewards = torch.from_numpy(np.vstack([e.reward \
                        for e in experiences if e is not \
                               None])).float().to(device)
            next_states=torch.from_numpy(np.vstack([e.next_state \
                         for e in experiences if e is not \
                                      None])).float().to(device)
            dones = torch.from_numpy(np.vstack([e.done \
                        for e in experiences if e is not None])\
                           .astype(np.uint8)).float().to(device)
            return (states, actions, rewards, next_states,dones) 
    
  7. 定义Agent对象:

    agent = Agent(state_size, action_size) 
    
  8. 定义将用于训练代理程序的参数:

    n_episodes=5000
    max_t=5000
    eps_start=1.0
    eps_end=0.02
    eps_decay=0.995
    scores = [] # list containing scores from each episode
    scores_window = deque(maxlen=100) # last 100 scores
    eps = eps_start
    stack_size = 4
    stacked_frames = deque([np.zeros((80,80), dtype=np.int) \
                            for i in range(stack_size)], maxlen=stack_size) 
    
  9. 训练代理程序随着场次的增加,就像我们在前一节中所做的那样:

    for i_episode in range(1, n_episodes+1):
        state, *_ = env.reset()
        state, frames = stack_frames(stacked_frames, state, True)
        score = 0
        for i in range(max_t):
            action = agent.act(state, eps)
            next_state, reward, done, *_ = env.step(action)
            next_state, frames = stack_frames(frames, next_state, False)
            agent.step(state, action, reward, next_state, done)
            state = next_state
            score += reward
            if done:
                break
        scores_window.append(score) # save most recent score
        scores.append(score) # save most recent score
        eps = max(eps_end, eps_decay*eps) # decrease epsilon
        print('\rEpisode {}\tReward {} \tAverage Score: {:.2f} \
        \tEpsilon: {}'.format(i_episode,score,\
                              np.mean(scores_window),eps),end="")
        if i_episode % 100 == 0:
            print('\rEpisode {}\tAverage Score: {:.2f} \
            \tEpsilon: {}'.format(i_episode, \
                                  np.mean(scores_window), eps)) 
    

以下图表显示了随着场次增加得分的变化:

图表 自动生成的描述

图 14.12:随着时代增长的得分

从前面的图像中,我们可以看到代理逐渐学会了玩 Pong 游戏,并且在 800 个周期结束时,它学会了在接受高奖励的同时玩这个游戏。

现在我们已经训练了一个能够很好地玩 Pong 游戏的代理,下一节我们将训练一个代理,使其能够在模拟环境中自动驾驶汽车。

实现一个代理程序执行自动驾驶

现在您已经看到 RL 在逐步挑战的环境中运作,我们将通过演示相同的概念可以应用于自动驾驶汽车来结束本章。由于在实际汽车上看到这种工作是不切实际的,我们将转而使用模拟环境。这种情景包含以下组成部分:

  • 环境是一个充满交通的完整城市,道路上有汽车和其他细节。演员(代理)是一辆汽车。

  • 汽车的输入包括各种感官输入,例如仪表板摄像头、光学雷达LIDAR)传感器和 GPS 坐标。

  • 输出将是汽车移动的快慢程度,以及转向的级别。

此仿真将尝试准确地反映现实世界的物理情况。因此,请注意,无论是汽车仿真还是真实汽车,基本原理都将保持不变。

注意,我们要安装的环境需要图形用户界面GUI)来显示仿真内容。此外,训练将至少需要一天时间,如果不是更长。由于 Google Colab 缺乏可视化设置并且时间使用限制,我们将不再使用 Google Colab 笔记本,正如我们迄今所做的那样。这是本书唯一一个需要活跃的 Linux 操作系统,并且最好有 GPU 以在几天的训练中获得可接受结果的部分。

设置 CARLA 环境

正如我们之前提到的,我们需要一个可以模拟复杂交互的环境,使我们相信我们实际上正在处理一个真实场景。CARLA 就是这样的一个环境。环境作者对 CARLA 表示如下:

CARLA 是从头开始开发的,旨在支持自动驾驶系统的开发、训练和验证。除了开放源代码和协议外,CARLA 还提供了专为此目的创建并可以自由使用的开放数字资产(城市布局、建筑物和车辆)。仿真平台支持传感器套件的灵活规格、环境条件、对所有静态和动态参与者的全面控制、地图生成等等。

我们需要遵循两个步骤来设置环境:

  1. 安装 CARLA 仿真环境的二进制文件。

  2. 安装 Gym 版本,该版本提供了 Python 连接以进行仿真环境。

此部分的步骤已作为视频教程呈现在此处:tinyurl.com/mcvp-self-driving-agent

让我们开始吧!

安装 CARLA 二进制文件

在本节中,我们将学习如何安装必要的 CARLA 二进制文件:

  1. 访问github.com/carla-simulator/carla/releases/tag/0.9.6,并下载编译版本文件CARLA_0.9.6.tar.gz

  2. 将其移动到您希望 CARLA 在系统中居住的位置并解压缩它。在这里,我们将通过下载并解压缩 CARLA 到Documents文件夹来演示这一点:

    $ mv CARLA_0.9.6.tar.gz ~/Documents/
    $ cd ~/Documents/
    $ tar -xf CARLA_0.9.6.tar.gz
    $ cd CARLA_0.9.6/ 
    
  3. 将 CARLA 添加到PYTHONPATH中,以便您的机器上的任何模块都可以导入它:

    $ echo "export PYTHONPATH=$PYTHONPATH:/home/$(whoami)/Documents/CARLA_0.9.6/PythonAPI/carla/dist/carla-0.9.6-py3.5-linux-x86_64.egg" >> ~/.bashrc 
    

在前面的代码中,我们将包含 CARLA 的目录添加到名为PYTHONPATH的全局变量中,这是一个用于访问所有 Python 模块的环境变量。将其添加到~/.bashrc将确保每次打开终端时都可以访问此新文件夹。运行上述代码后,重新启动终端并运行ipython -c "import carla; carla.__spec__"。您应该会得到以下输出:

自动生成的文本说明

图 14.13:CARLA 在您的机器上的位置

  1. 最后,按以下方式提供必要的权限并执行 CARLA:

    $ chmod +x /home/$(whoami)/Documents/CARLA_0.9.6/CarlaUE4.sh
    $ ./home/$(whoami)/Documents/CARLA_0.9.6/CarlaUE4.sh 
    

一两分钟后,您应该会看到一个类似于以下显示 CARLA 作为仿真运行的窗口,准备接受输入:

自动生成的文本 说明

图 14.14:显示 CARLA 运行的窗口

在本节中,我们已验证CARLA是一个仿真环境,其二进制文件按预期工作。让我们继续为其安装 Gym 环境。保持终端运行状态不变,因为我们需要二进制文件在整个练习过程中后台运行。

安装 CARLA 的 Gym 环境

由于没有官方的 Gym 环境,我们将利用用户实施的 GitHub 存储库,并从那里为 CARLA 安装 Gym 环境。按照以下步骤安装 CARLA 的 Gym 环境:

  1. 克隆 Gym 存储库到您选择的位置并安装该库:

    $ cd /location/to/clone/repo/to
    $ git clone https://github.com/cjy1992/gym-carla
    $ cd gym-carla
    $ pip install -r requirements.txt
    $ pip install -e . 
    
  2. 通过运行以下命令来测试您的设置:

    $ python test.py 
    

应该会打开一个类似以下的窗口,显示我们已将一个虚拟汽车添加到环境中。从这里,我们可以监视俯视图,激光雷达传感器点云和我们的车载摄像头:

自动生成的文本,包含文本的图片 说明

图 14.15:当前情节的概述

在这里,我们可以观察到以下内容:

  • 第一个视图包含一个与车辆 GPS 系统显示非常相似的视图 - 即我们的车辆,各种航点和道路车道。然而,我们不会使用此输入进行训练,因为它还显示了视图中的其他车辆,这是不现实的。

  • 第二视图更为有趣。有些人认为它就像是自动驾驶汽车的眼睛。激光雷达每秒多次向周围环境发射脉冲光(在所有方向),捕获反射光以确定该方向上最近的障碍物距离。车载计算机汇总所有最近的障碍物信息,以重建一个三维点云,使其能够三维理解其环境。

  • 在第一和第二视图中,我们可以看到汽车前方有一条带状物。这是一个航向指示,指示汽车应该去的方向。

  • 第三视图是一个简单的仪表盘摄像头。

除了这三个外,CARLA 还提供其他传感器数据,例如以下内容:

  • lateral-distance(车道偏离距离)

  • delta-yaw(相对于前方道路的角度)

  • 速度

  • 车辆前方是否有危险障碍物

我们将使用前面提到的前四个传感器以及 LIDAR 和我们的仪表盘摄像头来训练模型。

现在我们准备理解 CARLA 的组件并为自动驾驶汽车创建 DQN 模型。

训练自动驾驶代理

在笔记本中开始训练过程之前,我们将创建两个文件 —— model.pyactor.py。它们分别包含模型架构和 Agent 类。Agent 类包含我们将用于训练代理的各种方法。

此部分的代码说明位于本书 GitHub 存储库的 Chapter14 文件夹中的 Carla.md 文件中,网址为 bit.ly/mcvp-2e

创建 model.py

这将是一个 PyTorch 模型,它将接受提供给它的图像以及其他传感器输入。预计它将返回最可能的行动:

from torch_snippets import *
class DQNetworkImageSensor(nn.Module):
    def __init__(self):
        super().__init__()
        self.n_outputs = 9
        self.image_branch = nn.Sequential(
                            nn.Conv2d(3, 32, (8, 8), stride=4),
                            nn.ReLU(inplace=True),
                            nn.Conv2d(32, 64, (4, 4), stride=2),
                            nn.ReLU(inplace=True),
                            nn.Conv2d(64,128,(3, 3),stride=1),
                            nn.ReLU(inplace=True),
                            nn.AvgPool2d(8),
                            nn.ReLU(inplace=True),
                            nn.Flatten(),
                            nn.Linear(1152, 512),
                            nn.ReLU(inplace=True),
                            nn.Linear(512, self.n_outputs)
                        )
        self.lidar_branch = nn.Sequential(
                            nn.Conv2d(3, 32, (8, 8), stride=4),
                            nn.ReLU(inplace=True),
                            nn.Conv2d(32,64,(4, 4),stride=2),
                            nn.ReLU(inplace=True),
                            nn.Conv2d(64,128,(3, 3),stride=1),
                            nn.ReLU(inplace=True),
                            nn.AvgPool2d(8),
                            nn.ReLU(inplace=True),
                            nn.Flatten(),
                            nn.Linear(1152, 512),
                            nn.ReLU(inplace=True),
                            nn.Linear(512, self.n_outputs)
                        )
        self.sensor_branch = nn.Sequential(
                                nn.Linear(4, 64),
                                nn.ReLU(inplace=True),
                                nn.Linear(64, self.n_outputs)
                            )
    def forward(self, image, lidar=None, sensor=None):
        x = self.image_branch(image)
        if lidar is None:
            y = 0
        else:
            y = self.lidar_branch(lidar)
        z = self.sensor_branch(sensor)
        return x + y + z 

让我们来解析这段代码。正如您所看到的,与之前只接受图像输入的部分不同,这里的 forward 方法中输入的数据类型更多:

  • self.image_branch 期望来自汽车仪表盘摄像头的图像。

  • self.lidar_branch 接受由 LIDAR 传感器生成的图像。

  • self.sensor_branch 接受四个传感器输入,形式为 NumPy 数组。这四个项目是:

    • 横向距离(车道偏离距离)

    • delta-yaw(相对于前方道路的角度)

    • 速度

    • 车辆前方是否存在任何危险障碍物

gym_carla/envs/carla_env.py 中看到第 544 行(已经进行了 Git 克隆的存储库)得到相同的输出。在神经网络的不同分支上,该模块将提供不同级别的传感器重要性,并将输出汇总为最终输出。注意,共有九个输出;我们稍后将对其进行查看。

创建 actor.py

与前几节类似,我们将使用一些代码来存储回放信息,并在需要时进行播放:

  1. 让我们把导入和超参数放在正确的位置:

    import numpy as np
    import random
    from collections import namedtuple, deque
    import torch
    import torch.nn.functional as F
    import torch.optim as optim
    from model1 import DQNetworkImageSensor
    BUFFER_SIZE = int(1e3) # replay buffer size
    BATCH_SIZE = 256 # minibatch size
    GAMMA = 0.99 # discount factor
    TAU = 1e-2 # for soft update of target parameters
    LR = 5e-4 # learning rate
    UPDATE_EVERY = 50 # how often to update the network
    ACTION_SIZE = 2
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 接下来,我们将初始化目标网络和本地网络。在这里与上一节相比,代码没有做任何更改,只是导入的模块不同:

    class Actor():
        def __init__(self):      
            # Q-Network
            self.qnetwork_local=DQNetworkImageSensor().to(device)
            self.qnetwork_target=DQNetworkImageSensor().to(device)
            self.optimizer = optim.Adam(self.qnetwork_local.parameters(),
                                                                  lr=LR)
            # Replay memory
            self.memory= ReplayBuffer(ACTION_SIZE,BUFFER_SIZE, \
                                       BATCH_SIZE, 10)
            # Initialize time step
            # (for updating every UPDATE_EVERY steps)
            self.t_step = 0
    
        def step(self, state, action, reward, next_state, done):
            # Save experience in replay memory
            self.memory.add(state, action, reward, next_state, done)
    
            # Learn every UPDATE_EVERY time steps.
            self.t_step = (self.t_step + 1) % UPDATE_EVERY
            if self.t_step == 0:
      # If enough samples are available in memory,
      # get random subset and learn
                if len(self.memory) > BATCH_SIZE:
                    experiences = self.memory.sample()
                    self.learn(experiences, GAMMA) 
    
  3. 由于有更多传感器需要处理,我们将它们作为状态字典传输。状态包含了在前一节介绍的'image''lidar''sensor'键。在将它们发送给神经网络之前,我们进行预处理,如下面的代码所示:

     def act(self, state, eps=0.):
            images,lidars sensors=state['image'], \
                                  state['lidar'],state['sensor']
            images = torch.from_numpy(images).float()\
                          .unsqueeze(0).to(device)
            lidars = torch.from_numpy(lidars).float()\
                          .unsqueeze(0).to(device)
            sensors = torch.from_numpy(sensors).float()\
                           .unsqueeze(0).to(device)
            self.qnetwork_local.eval()
            with torch.no_grad():
                action_values = self.qnetwork_local(images, \
                                  lidar=lidars, sensor=sensors)
            self.qnetwork_local.train()
            # Epsilon-greedy action selection
            if random.random() > eps:
                return np.argmax(action_values.cpu().data.numpy())
            else:
                return random.choice(np.arange(self.qnetwork_local.n_outputs)) 
    
  4. 现在,我们需要从重放内存中提取项目。执行以下指令:

    1. 获得一批当前状态和下一个状态。

    2. 计算网络在当前状态下执行操作时的预期奖励,Q_expected

    3. 将其与目标奖励Q_targets进行比较,当下一个状态被送入网络时将获得该奖励。

    4. 定期使用本地网络更新目标网络。

下面是用于实现此目的的代码:

  1.  def learn(self, experiences, gamma):
            states,actions,rewards,next_states,dones= experiences
            images, lidars, sensors = states
            next_images, next_lidars, next_sensors = next_states
            # Get max predicted Q values (for next states)
            # from target model
            Q_targets_next = self.qnetwork_target(next_images,
                                            lidar=next_lidars,
                                         sensor=next_sensors)\
                                .detach().max(1)[0].unsqueeze(1)
            # Compute Q targets for current states
            Q_targets= rewards +(gamma*Q_targets_next*(1-dones))
            # Get expected Q values from local model
            # import pdb; pdb.set_trace()
            Q_expected=self.qnetwork_local(images,lidar=lidars,
                                     sensor=sensors).gather(1,actions.long())
            # Compute loss
            loss = F.mse_loss(Q_expected, Q_targets)
            # Minimize the loss
            self.optimizer.zero_grad()
            loss.backward()
            self.optimizer.step()
            # ------------ update target network ------------- #
            self.soft_update(self.qnetwork_local,
                             self.qnetwork_target, TAU)
        def soft_update(self, local_model, target_model, tau):
            for target_param, local_param in \
                zip(target_model.parameters(), \
                local_model.parameters()):
                target_param.data.copy_(tau*local_param.data + \
                                    (1.0-tau)*target_param.data) 
    
  2. ReplayBuffer类中唯一的主要更改是数据存储方式。由于我们有多个传感器,每个内存状态(当前状态和下一个状态)都存储为数据元组 - 即states = [images, lidars, sensors]

    class ReplayBuffer:
        """Fixed-size buffer to store experience tuples."""
        def __init__(self, action_size, buffer_size, batch_size, seed):
            self.action_size = action_size
            self.memory = deque(maxlen=buffer_size)
            self.batch_size = batch_size
            self.experience = namedtuple("Experience", \
                              field_names=["state", "action", \
                              "reward","next_state", "done"])
            self.seed = random.seed(seed)
    
        def add(self, state, action, reward, next_state, done):
            """Add a new experience to memory."""
            e = self.experience(state, action, reward,
                                next_state, done)
            self.memory.append(e)
    
        def sample(self):
            experiences = random.sample(self.memory,
                                        k=self.batch_size)
            images = torch.from_numpy(np.vstack([e.state['image'][None] \
                     for e in experiences if e is not None]))\
                        .float().to(device)
            lidars = torch.from_numpy(np.vstack([e.state['lidar'][None] \
                     for e in experiences if e is not None]))\
                        .float().to(device)
            sensors = torch.from_numpy(np.vstack([e.state['sensor'] \
                     for e in experiences if e is not None]))\
                        .float().to(device)
            **states = [images, lidars, sensors]**
            actions = torch.from_numpy(np.vstack(\
                        [e.action for e in experiences \
                         if e is not None])).long().to(device)
            rewards = torch.from_numpy(np.vstack(\
                        [e.reward for e in experiences \
                         if e is not None])).float().to(device)
            next_images = torch.from_numpy(np.vstack(\
                        [e.next_state['image'][None] \
                         for e in experiences if e is not
                         None])).float().to(device)
            next_lidars = torch.from_numpy(np.vstack(\
                        [e.next_state['lidar'][None] \
                         for e in experiences if e is not \
                         None])).float().to(device)
            next_sensors = torch.from_numpy(np.vstack(\
                        [e.next_state['sensor'] \
                         for e in experiences if e is not \
                         None])).float().to(device)
            next_states = [next_images, next_lidars, next_sensors]
            dones = torch.from_numpy(np.vstack([e.done \
                         for e in experiences if e is not \
                     None]).astype(np.uint8)).float().to(device)     
            return (states,actions, rewards, next_states, dones)
        def __len__(self):
            """Return the current size of internal memory."""
            return len(self.memory) 
    

注意,粗体代码行获取当前状态、动作、奖励和下一个状态的信息。

现在关键的组件都已就位,让我们将 Gym 环境加载到 Python 笔记本中并开始训练。

使用固定目标训练 DQN

在这里我们没有额外的理论需要学习。基础知识保持不变;我们只会对 Gym 环境、神经网络的架构以及我们的代理需要采取的行动进行更改:

  1. 首先,加载与环境相关的超参数。在以下代码中,每个键值对旁边的每个注释都很重要。因为我们将模拟一个复杂的环境,我们需要选择环境的参数,如城市中的汽车数量、行人数量、模拟哪个城镇、仪表摄像头图像的分辨率和 LIDAR 传感器:

    %pip install -U "gym==0.26.2"
    import gym
    import gym_carla
    import carla
    from model import DQNetworkState
    from actor import Actor
    from torch_snippets import *
    params = {
        'number_of_vehicles': 10,
        'number_of_walkers': 0,
        'display_size': 256, # screen size of bird-eye render
        'max_past_step': 1, # the number of past steps to draw
        'dt': 0.1, # time interval between two frames
        'discrete': True, # whether to use discrete control space
        # discrete value of accelerations
        'discrete_acc': [-1, 0, 1],
        # discrete value of steering angles
        'discrete_steer': [-0.3, 0.0, 0.3],
        # define the vehicle
        'ego_vehicle_filter': 'vehicle.lincoln*',
        'port': 2000, # connection port
        'town': 'Town03', # which town to simulate
        'task_mode': 'random', # mode of the task
        'max_time_episode': 1000, # maximum timesteps per episode
        'max_waypt': 12, # maximum number of waypoints
        'obs_range': 32, # observation range (meter)
        'lidar_bin': 0.125, # bin size of lidar sensor (meter)
        'd_behind': 12, # distance behind the ego vehicle (meter)
        'out_lane_thres': 2.0, # threshold for out of lane
        'desired_speed': 8, # desired speed (m/s)
        'max_ego_spawn_times': 200, # max times to spawn vehicle
        'display_route': True, # whether to render desired route
        'pixor_size': 64, # size of the pixor labels
        'pixor': False, # whether to output PIXOR observation
    }
    # Set gym-carla environment
    env = gym.make('carla-v0', params=params) 
    

在上述params字典中,对于我们模拟中的动作空间,以下内容很重要:

  • 'discrete': True:我们的动作位于一个离散空间中。

  • 'discrete_acc':[-1,0,1]:自驾车在模拟过程中可以进行的所有可能的加速度。

  • 'discrete_steer':[-0.3,0,0.3]:自驾车在模拟过程中可以进行的所有可能的转向幅度。

正如您所看到的,discrete_accdiscrete_steer列表各包含三个项目。这意味着汽车可以采取 9 种唯一的离散动作。因此,model.py文件中的网络有九个离散状态。

一旦您浏览了官方文档,可以随意更改参数。

  1. 有了这些,我们就有了训练模型所需的所有组件。如果存在预训练模型,请加载它。如果我们从头开始,将其保持为None

    load_path = None # 'car-v1.pth'
    # continue training from an existing model
    save_path = 'car-v2.pth'
    actor = Actor()
    if load_path is not None:
        actor.qnetwork_local.load_state_dict(torch.load(load_path))
        actor.qnetwork_target.load_state_dict(torch.load(load_path))
    else:
        pass 
    
  2. 固定 episode 数量,并定义dqn函数来训练代理,如下所示:

    1. 重置状态:
    n_episodes = 100000
    def dqn(n_episodes=n_episodes, max_t=1000, eps_start=1, \
            eps_end=0.01, eps_decay=0.995):
        scores = [] # list containing scores from each episode
        scores_window = deque(maxlen=100) # last 100 scores
        eps = eps_start # Initialize epsilon
        for i_episode in range(1, n_episodes+1):
            state, *_ = env.reset() 
    
    1. 将状态封装到字典中(如在actor.py:Actor类中讨论的),并对其进行操作:
     image, lidar, sensor = state['camera'], \
                                   state['lidar'], \
                                   state['state']
            image, lidar = preprocess(image), preprocess(lidar)
            state_dict = {'image': image, 'lidar': lidar, \
                          'sensor': sensor}
            score = 0
            for t in range(max_t):
                action = actor.act(state_dict, eps) 
    
    1. 存储从环境中获得的下一个状态,然后存储state, next_state对(以及奖励和其他状态信息),以使用 DQN 来训练 actor:
     next_state, reward, done, *_ = env.step(action)
                image, lidar, sensor = next_state['camera'], \
                                       next_state['lidar'], \
                                       next_state['state']
                image,lidar = preprocess(image), preprocess(lidar)
                next_state_dict= {'image':image,'lidar':lidar, \
                                  'sensor': sensor}
                actor.step(state_dict, action, reward, \
                           next_state_dict, done)
                state_dict = next_state_dict
                score += reward
                if done:
                    break
            scores_window.append(score) # save most recent score
            scores.append(score) # save most recent score
            eps = max(eps_end, eps_decay*eps) # decrease epsilon
            if i_episode % 100 == 0:
                log.record(i_episode, mean_score=np.mean(scores_window))
                torch.save(actor.qnetwork_local.state_dict(), save_path) 
    

我们必须重复这个循环,直到收到完成信号,然后重置环境并重新开始存储行动。每隔 100 个 episode,存储模型。

  1. 调用dqn函数来训练模型:

    dqn() 
    

由于这是一个更复杂的环境,训练可能需要几天时间,因此请耐心,并继续使用load_pathsave_path参数每次训练几个小时。通过足够的训练,车辆可以学会如何自主驾驶。这是我们在两天训练后能够达到的训练结果视频:tinyurl.com/mcvp-self-driving-agent-result

概要

在本章中,我们学习了如何计算给定状态下各种行动的价值。然后,我们学习了代理如何更新 Q 表,使用折扣值来执行给定状态下的行动。在此过程中,我们了解了在状态数量较多的情况下,Q 表是不可行的。我们还学习了如何利用深度 Q 网络来解决可能状态数量较高的情况。接着,我们利用基于固定目标的 DQN,结合 CNN-based 神经网络来建立一个学习如何玩乒乓球的代理。最后,我们学习了如何利用具有固定目标的 DQN 来进行自动驾驶,使用 CARLA 模拟器。

正如我们在本章中多次看到的那样,您可以使用深度 Q 学习来学习非常不同的任务,比如 CartPole 平衡、玩乒乓球和自动驾驶导航,几乎使用相同的代码。虽然这不是我们探索 RL 旅程的终点,但在这一点上,我们应该能够理解如何将基于 CNN 和基于强化学习的算法结合起来解决复杂问题并构建学习代理。

到目前为止,我们学习了如何将基于计算机视觉的技术与其他重要研究领域的技术结合起来,包括元学习、自然语言处理和强化学习。除此之外,我们还学习了如何使用 GAN 进行对象分类、检测、分割和图像生成。在下一章中,我们将转向学习如何将深度学习模型投入到生产中。

问题

  1. 代理如何计算给定状态的价值?

  2. 如何填充 Q 表?

  3. 为什么在状态-行动价值计算中需要折扣因子?

  4. 为什么需要探索-利用策略?

  5. 为什么需要使用深度 Q 学习?

  6. 如何使用深度 Q 学习计算给定状态-动作组合的值?

  7. 一旦代理在 CartPole 环境中最大化了奖励,是否有可能后来学习到次优策略?

在 Discord 上了解更多

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第十五章:结合计算机视觉和 NLP 技术

在上一章中,我们了解了结合强化学习和计算机视觉的应用。在本章中,我们将转而学习卷积神经网络CNN)如何与 transformers 家族的算法结合使用,在自然语言处理中广泛使用(截至本书编写时)开发同时利用计算机视觉和 NLP 的解决方案。

为了理解如何结合 CNN 和 transformers,我们将首先学习视觉 transformersViTs)的工作原理以及它们如何帮助执行图像分类。之后,我们将学习如何利用 transformers 执行手写图像的转录,使用Transformer 光学字符识别TrOCR)。接下来,我们将学习如何结合 transformers 和 OCR,使用名为LayoutLM的技术在文档图像上执行问答。最后,我们将学习如何使用名为Bootstrapping Language Image Pre-trainingBLIP2)的 transformer 架构执行视觉问题回答。

在本章结束时,您将学习以下主题:

  • 实现 ViTs 进行图像分类

  • 实现 LayoutLM 进行文档问答

  • 转录手写图像

  • 使用 BLIP2 进行视觉问题回答

    建议您阅读关于使用最少数据点进行训练的补充章节,以熟悉在 GitHub 的第一版额外章节文件夹中提供的词嵌入。

本章的代码位于本书 GitHub 存储库的Chapter15文件夹中,链接为bit.ly/mcvp-2e

随着领域的发展,我们将定期向 GitHub 存储库添加有价值的补充内容。请查看每章节目录中的supplementary_sections文件夹获取新的和有用的内容。

引入 transformers

在我们学习关于 ViTs 之前,让我们从自然语言处理的角度理解 transformers。一个 transformer 有助于生成一个最能描述给定上下文(周围单词)中单词的表示(单词/向量嵌入)。递归神经网络RNNs)和长短期记忆LSTM)架构(有关详细信息,请参阅相关的 GitHub 存储库)的一些主要限制包括:

  • 与单词相关的词嵌入不依赖于单词出现的上下文(无论上下文是关于水果还是公司,单词苹果的嵌入都相同)。

  • 在训练期间进行隐藏状态计算是顺序的(一个单词的隐藏状态依赖于前一个单词的隐藏状态,因此只能在前一个隐藏状态计算之后计算),导致处理文本所需的时间相当长。

Transformers 解决了这两个主要限制,导致:

  • 利用大型数据语料库对变压器进行预训练的能力

  • 将变压器微调到各种下游任务中(包括利用它们进行视觉任务)

  • 在各种架构中利用中间状态/隐藏状态 – 仅编码器、仅解码器或编码器-解码器架构(更多关于编码器和解码器的内容在以下章节中)

  • 相比于 RNN 的顺序训练,训练变压器输出可以并行进行

充分利用变压器的优势,让我们了解它们的工作原理。

变压器的基础

要理解变压器,让我们通过一个机器翻译的场景来了解 – 源语言为英语,目标语言为法语。

变压器架构可以如下所示:

自动生成的图表描述

图 15.1:变压器架构

(来源:arxiv.org/pdf/1706.03762

在上述架构中,左侧块是编码器,右侧块是解码器。让我们先了解编码器块。

编码器块

第一步是获取对应于英文输入句子的标记。在传统语言建模中,我们给罕见单词分配一个“未知”标记,并获取与其余(频繁的)单词对应的嵌入。然而,在变压器架构的标记化过程中,我们执行字节对编码(标记化),以一种使个别单词分解的方式(例如,单词 anand 可能会分解为 ###an###and,而像 computer 这样的频繁单词则保持原样)。这样,我们就不会有任何未知单词。此外,每个标记随后将具有相关联的嵌入。

因此,我们利用标记化来获得输入单词嵌入。

现在让我们学习一下位于变压器核心的自注意力模块。它以三个二维矩阵作为输入,分别称为查询Q)、K)和V)矩阵。这些矩阵可以具有非常大的嵌入大小(因为它们将包含词汇 x 嵌入大小数量的值),因此首先被分成较小的组件(以下图表中的步骤 1),然后再运行经过缩放的点积注意力(以下图表中的步骤 2):

自动生成的图表描述

图 15.2:缩放点积注意力和多头注意力的工作流程

让我们理解自注意力是如何工作的。假设我们有一个输入序列长度为 3 个标记 - 即,我们有三个词/标记嵌入(W1W2,和 W3)。假设每个嵌入的大小为 512 个值。可以执行以下步骤:

  1. 每个嵌入单独转换为三个额外的向量,即对应于每个输入的 QKV 向量。在下面的图像中,512 是 Q、K 和 V 向量的嵌入维数,3 是序列长度(3 个单词/标记):

A picture containing calendar  Description automatically generated

图 15.3:初始化 Q、K 和 V 向量

  1. 我们采用多头方法,将每个向量分成较小的“头部”(本例中为八个),每个键、查询和值张量有八组大小为 64 x 3 的向量。这里,64 是通过将 512(嵌入大小)除以 8(头部数)获得的,3 是序列长度:

A group of black letters  Description automatically generated

图 15.4:每个头部的 Q、K 和 V 值

注意,由于有八个头部,将会有八组键、查询和值的张量集合。此外,每个头部可以学习单词的不同方面。

  1. 在每个头部中,我们首先执行键转置和查询矩阵之间的矩阵乘法。这样,我们得到一个 3 x 3 的矩阵。将得到的矩阵除以向量的维数的平方根(在本例中为 d = 64)。通过 softmax 激活函数。现在,我们有一个显示每个单词与其他每个单词关系重要性的矩阵:

图 15.5:对 Q 和 K 向量的操作

  1. 最后,我们对前述张量输出执行值张量的矩阵乘法,以获得自注意力操作的输出:

图 15.6:自注意力计算

形式上,这种缩放点积注意力计算可以写成:

在上述方程中,d 指向向量的维数(在本例中为 64),k 表示头部的索引。

在相关的 GitHub 仓库中,提供了有关一个样本输入和随机初始化的 Q、K 和 V 权重矩阵的逐步计算,文件名为 self-attention.ipynb,存放在 Chapter15 文件夹中,网址为 bit.ly/mcvp-2e

  1. 然后,我们使用 concat 层(图 15.2 中的 步骤 3)组合这一步骤的八个输出,最终得到一个大小为 512 x 3 的单一张量。由于有八个头部(即八个 Q、K 和 V 矩阵),该层也被称为 多头自注意力(来源:Attention Is All You Needarxiv.org/pdf/1706.03762.pdf)。

例如,在计算机视觉中,当搜索马等对象时,查询将包含信息以搜索大尺寸且通常为棕色、黑色或白色的对象。经缩放的点积注意力的 softmax 输出将反映包含这种颜色(棕色、黑色、白色等)的键矩阵的部分。因此,来自自注意力层的值输出将包含图像中大致符合所需颜色的部分,并存在于值矩阵中。

  1. 然后,我们通过残差块传递多头注意力的输出,在这个残差块中,我们首先将多头注意力的输入和输出相加,然后对最终输出进行归一化。

  2. 然后,我们通过线性网络传递输出,其维度与输入的维度相似。

我们多次使用自注意力块(如图 15.1中的Nx)。

解码器块

虽然解码器块与编码器块非常相似,但架构上有两个附加部分:

  • 掩盖的多头注意力

  • 交叉注意力

虽然多头注意力的工作方式与编码器块类似,但在计算给定时间步的令牌的注意力时,我们掩盖了未来时间步中的令牌。这样,我们构建网络,使其不会窥视未来的令牌。我们通过添加掩码/单位矩阵来掩盖未来的令牌,以确保在注意力计算期间不考虑未来的令牌。

编码器的键和值矩阵用作解码器的交叉头部注意力的键和查询输入,而值输入则由神经网络学习,独立于编码器的输入。我们将其称为交叉注意力,因为键和值矩阵从编码器层获取,而查询从解码器层获取。

最后,尽管这是一系列输入,但没有迹象表明哪个令牌(单词)是第一个,哪个是下一个。位置编码是可学习的嵌入,我们将其添加到每个输入中,作为其在序列中位置的函数。这样做是为了让网络理解序列中哪个单词嵌入是第一个,哪个是第二个,依此类推。

在 PyTorch 中创建 Transformer 网络的方法非常简单。您可以像这样创建一个内置的 transformer 块:

from torch import nn
transformer = nn.Transformer(hidden_dim, nheads, \
                     num_encoder_layers, num_decoder_layers) 

在这里,hidden_dim是嵌入的大小,nheads是多头自注意力中的头数,而num_encoder_layersnum_decoder_layers分别是网络中编码和解码块的数量。

从头实现 transformers 的工作细节可以在 GitHub 存储库的Chapter15文件夹中的Transformers_from_scratch.ipynb文件中找到,具体链接为bit.ly/mcvp-2e

现在我们知道 transformers 如何工作,在下一节中,让我们了解 ViTs 如何工作。

ViTs 的工作原理

通过以下图示轻松理解 ViT:

一个过程图的图表 说明自动生成

图 15.7:ViT 架构(来源:arxiv.org/pdf/2010.11929

让我们看看在前面的图中遵循的工作流程:

  1. 我们正在取出一张图像并从中提取 9(3 x 3)个补丁。例如,假设原始图像的大小为 300 x 300,每个 9 个补丁中的一个将是 100 x 100 的形状。

  2. 接下来,我们取出每个补丁,将它们展开,并通过线性投影传递它们。这个练习类似于提取与单词(标记)对应的词嵌入。

  3. 接下来,我们添加与补丁对应的位置嵌入。我们添加位置嵌入是因为我们需要保留补丁在原始图像中的位置信息。此外,我们还初始化一个类标记,这对于图像最终的分类非常有帮助。

  4. 现在,所有嵌入都通过变换器编码器。在变换器中,这些嵌入经过一系列的归一化、多头注意力和线性头的跳跃连接(MLP 代表多层感知器)。

  5. 现在,我们有了与每个补丁对应的输出嵌入。我们现在附加最终头部,具体取决于我们要解决的问题。在图像分类的情况下,我们只会为分类CLS)标记附加一个线性层。其余补丁的输出可以用作下游任务(如物体识别或图像字幕)的特征提取器。

现在我们了解了 ViTs 的工作原理,我们将继续在数据集上实施变换器。

实施 ViTs

我们将在我们在第 4第五章中使用的猫狗数据集上实施 ViTs:

以下代码可在本书 GitHub 存储库的Chapter15文件夹中的ViT_Image_classification.ipynb文件中找到,链接为https://bit.ly/mcvp-2e

  1. 安装和导入所需的包:

    %pip install -U torch-snippets transformers kaggle
    from torch_snippets import *
    from transformers import ViTModel, ViTConfig
    from torch.optim import Adam
    model_checkpoint = 'google/vit-base-patch16-224-in21k' 
    

注意,我们将利用上述预训练的 ViT 模型(提供的检查点位置)。

  1. 导入数据集:

    %%writefile kaggle.json
    {"username":"xx", "key":"xx"}
    !mkdir -p ~/.kaggle
    !cp kaggle.json ~/.kaggle/
    !chmod 600 /root/.kaggle/kaggle.json
    !kaggle datasets download -d tongpython/cat-and-dog
    !unzip cat-and-dog.zip 
    
  2. 指定训练数据的位置:

    train_data_dir = 'training_set/training_set'
    test_data_dir = 'test_set/test_set' 
    
  3. 指定数据集类,就像我们在第 4第五章中所做的那样:

    class CatsDogs(Dataset):
        def __init__(self, folder):
            cats = glob(folder+'/cats/*.jpg')
            dogs = glob(folder+'/dogs/*.jpg')
            self.fpaths = cats[:500] + dogs[:500]
            self.normalize = transforms.Normalize(mean=[0.485, 0.456, 0.406],
                                                  std=[0.229, 0.224, 0.225])
            from random import shuffle, seed; seed(10); shuffle(self.fpaths)
            self.targets = [fpath.split('/')[-1].startswith('dog') \
                                        for fpath in self.fpaths]
        def __len__(self): return len(self.fpaths)
        def __getitem__(self, ix):
            f = self.fpaths[ix]
            target = self.targets[ix]
            im = (cv2.imread(f)[:,:,::-1])
            im = cv2.resize(im, (224,224))
            im = torch.tensor(im/255)
            im = im.permute(2,0,1)
            im = self.normalize(im)
            return im.float().to(device), \
                      torch.tensor([target]).float().to(device) 
    
  4. 定义 ViT 模型架构:

    class ViT(nn.Module):
      def __init__(self, config=ViTConfig(), num_labels=1,
                   model_checkpoint='google/vit-base-patch16-224-in21k'):
            super(ViT, self).__init__()
            self.vit = ViTModel.from_pretrained(model_checkpoint, \
                                         add_pooling_layer=False)
            self.classifier1 = (nn.Linear(config.hidden_size, 128))
            self.classifier2 = (nn.Linear(128, num_labels))
            self.classifier = nn.Sequential(
                                            self.classifier1,
                                            nn.ReLU(),
                                            self.classifier2)
            for param in self.vit.parameters():
                param.requires_grad = False
      def forward(self, x):
        x = self.vit(x)['last_hidden_state']
        # Use the embedding of [CLS] token
        output = self.classifier(x[:, 0, :])
        output = torch.sigmoid(output)
        return output 
    

在前述架构中,我们获取与每个补丁对应的特征,获取第一个的特征(CLS 标记)并通过 sigmoid 层传递它,因为我们希望将其分类为可能的类之一。

  1. 定义模型、损失函数和优化器:

    model = ViT().to('cuda')
    loss_fn = nn.BCELoss()
    optimizer = torch.optim.Adam(model.parameters(), lr= 1e-3) 
    
  2. 定义执行训练、计算准确度和获取数据的函数:

    def train_batch(x, y, model, opt, loss_fn):
        model.train()
        prediction = model(x)
        batch_loss = loss_fn(prediction, y)
        batch_loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        return batch_loss.item()
    @torch.no_grad()
    def accuracy(x, y, model):
        model.eval()
        prediction = model(x)
        is_correct = (prediction > 0.5) == y
        return is_correct.cpu().numpy().tolist()
    def get_data():
        train = CatsDogs(train_data_dir)
        trn_dl = DataLoader(train, batch_size=32, shuffle=True,
                                               drop_last = True)
        val = CatsDogs(test_data_dir)
        val_dl = DataLoader(val, batch_size=32, shuffle=True,
                                               drop_last = True)
        return trn_dl, val_dl
    trn_dl, val_dl = get_data() 
    
  3. 在增加的 epochs 上训练模型:

    n_epochs = 5
    report = Report(n_epochs)
    for epoch in range(n_epochs):
        train_epoch_losses, train_epoch_accuracies = [], []
        val_epoch_accuracies = []
        n = len(trn_dl)
        for ix, batch in enumerate(iter(trn_dl)):
            x, y = batch
            batch_loss = train_batch(x, y, model, optimizer, loss_fn)
            is_correct = accuracy(x, y, model)
            report.record(epoch+(ix+1)/n, trn_loss=batch_loss,
                                  trn_acc=np.mean(is_correct), end='\r')
        n = len(val_dl)
        for ix, batch in enumerate(iter(val_dl)):
            x, y = batch
            val_is_correct = accuracy(x, y, model)
            report.record(epoch+(ix+1)/n,
                          val_acc=np.mean(val_is_correct), end='\r')
        report.report_avgs(epoch+1) 
    
  4. 训练和验证准确性如下:

    report.plot(['trn_loss'], sz=3, figsize=(5,3))
    report.plot_epochs(['acc','trn_acc'], figsize=(5,3)) 
    

一个带有线图的图表 说明自动生成

图 15.8:随着增加的 epochs 的训练损失和准确性

注意,准确性与我们在第五章中看到的 VGG 和 ResNet 的准确性相似。

在本节中,我们学习了如何利用仅编码器的变压器来执行图像分类。在下一节中,我们将学习如何利用基于编码器-解码器架构的变压器来转录包含手写单词的图像。

转录手写图像

想象一种情况,您必须从扫描文档中提取信息(从身份证或手动填写表格的图片中提取键和值)。您将不得不从图像中提取(转录)文本。由于以下变化,这个问题变得棘手:

  • 手写

  • 扫描/图片质量

  • 光线条件

在本节中,我们将学习有关转录手写图像的技术。

让我们首先了解一下如何将变压器的编码器-解码器架构应用于手写图像的转录。

手写转录工作流程

我们将利用 TrOCR 架构(来源:arxiv.org/abs/2109.10282)来转录手写信息。

下图显示了所遵循的工作流程:

计算机程序截图的图片

图 15.9:TrOCR 工作流程

如前面的图片所示,工作流程如下:

  1. 我们接收输入并将其调整为固定的高度和宽度。

  2. 然后,我们将图像分成一组补丁。

  3. 然后,我们将补丁展平并获取与每个补丁对应的嵌入。

  4. 我们将补丁嵌入与位置嵌入结合,并通过编码器传递它们。

  5. 编码器的键和值向量被送入解码器的交叉注意力层,以在最终层中获取输出。

我们将使用 Microsoft 发布的trocr-base-handwritten模型来训练模型和标记器。让我们继续编写下一节的手写识别代码。

代码中的手写转录

可以遵循以下步骤进行手写转录:

此代码作为TrOCR_fine_tuning.ipynb在本书 GitHub 存储库的Chapter15文件夹中提供,网址为bit.ly/mcvp-2e

  1. 下载并导入图像数据集:

    !wget https://www.dropbox.com/s/l2ul3upj7dkv4ou/synthetic-data.zip
    !unzip -qq synthetic-data.zip 
    

在前面的代码中,我们已经下载了包含图像的数据集;图像的文件名包含了与该图像对应的转录的真实信息。

从下载的图像中选取的样本如下:

包含图表描述的图片

图 15.10:样本图像及其真实信息(作为标题)

  1. 安装所需的包并导入它们:

    !pip install torch_snippets torch_summary editdistance jiwer accelerate
    from torch_snippets import *
    from torchsummary import summary
    import editdistance 
    
  2. 指定图像的位置和从图像中提取真实信息的函数:

    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    fname2label = lambda fname: stem(fname).split('@')[0]
    images = Glob('synthetic-data/*') 
    

注意我们正在创建fname2label函数,因为在文件名中@符号后面的图像的真实信息是可用的。文件名的示例如下:

自动生成的图像描述

图 15.11:样本文件名

  1. 从 25K 图像数据集中获取 5,000 个样本(图像及其对应的标签),以加快训练速度:

    images_list = []
    labels_list = []
    for image in images:
      images_list.append(str(image).split('/')[-1])
      labels_list.append(fname2label(image))
    df = pd.DataFrame([images_list[:5000], labels_list[:5000]]).T
    df.columns = ['file_name', 'text'] 
    
  2. 指定训练和测试的数据框架:

    from sklearn.model_selection import train_test_split
    train_df, test_df = train_test_split(df, test_size=0.1)
    train_df.reset_index(drop=True, inplace=True)
    test_df.reset_index(drop=True, inplace=True) 
    
  3. 定义 Dataset 类:

    class IAMDataset(Dataset):
        def __init__(self, root_dir, df, processor, max_target_length=128):
            self.root_dir = root_dir
            self.df = df
            self.processor = processor
            self.max_target_length = max_target_length
        def __len__(self):
            return len(self.df)
        def __getitem__(self, idx):
            # get file name + text
            file_name = self.df['file_name'][idx]
            text = self.df['text'][idx]
            # prepare image (i.e. resize + normalize)
            image = Image.open(self.root_dir + file_name).convert("RGB")
            pixel_values = self.processor(image,
                               return_tensors="pt").pixel_values
            # add labels (input_ids) by encoding the text
            labels = self.processor.tokenizer(text,
                                           padding="max_length", 
                                 max_length=self.max_target_length).input_ids
            # important: make sure that PAD tokens are ignored by the loss function
            labels = [label if label != self.processor.tokenizer.pad_token_id \
                                               else -100 for label in labels]
            encoding = {"pixel_values": pixel_values.squeeze(), \
                        "labels": torch.tensor(labels)}
            return encoding 
    

在上述代码中,我们正在获取图像,并通过处理器获取像素值。此外,我们正在通过模型的标记生成器传递标签,以获取标签的标记。

  1. 定义 TrOCRProcessor

    from transformers import TrOCRProcessor
    processor = TrOCRProcessor.from_pretrained("microsoft/trocr-base-handwritten") 
    

在上述代码中,我们正在定义处理器,预处理图像并对标签进行标记化。

  1. 定义训练和评估数据集:

    train_dataset = IAMDataset(root_dir='/content/synthetic-data/', \
                                  df=train_df, processor=processor)
    eval_dataset = IAMDataset(root_dir='/content/synthetic-data/', 
                                  df=test_df, processor=processor) 
    
  2. 定义 TrOCR 模型:

    from transformers import VisionEncoderDecoderModel
    model = VisionEncoderDecoderModel.from_pretrained("microsoft/trocr-base-
                                                                    stage1") 
    
  3. 定义模型配置参数和训练参数:

    # set special tokens used for creating the decoder_input_ids from the labels
    model.config.decoder_start_token_id = processor.tokenizer.cls_token_id
    model.config.pad_token_id = processor.tokenizer.pad_token_id
    # make sure vocab size is set correctly
    model.config.vocab_size = model.config.decoder.vocab_size
    # set beam search parameters
    model.config.eos_token_id = processor.tokenizer.sep_token_id
    model.config.max_length = 64
    model.config.early_stopping = True
    model.config.no_repeat_ngram_size = 3
    model.config.length_penalty = 2.0
    model.config.num_beams = 4
    from transformers import Seq2SeqTrainer, Seq2SeqTrainingArguments
    training_args = Seq2SeqTrainingArguments(
                                             predict_with_generate=True,
                                             evaluation_strategy="steps",
                                             per_device_train_batch_size=8,
                                             per_device_eval_batch_size=8,
                                             fp16=True,
                                             output_dir="./",
                                             logging_steps=2,
                                             save_steps=1000,
                                             eval_steps=100,
                                             num_train_epochs = 10
                                             ) 
    
  4. 定义计算字符错误率的函数:

    from datasets import load_metric
    cer_metric = load_metric("cer")
    def compute_metrics(pred):
        labels_ids = pred.label_ids
        pred_ids = pred.predictions
        pred_str = processor.batch_decode(pred_ids,
                                   skip_special_tokens=True)
        labels_ids[labels_ids == -100] = processor.tokenizer.pad_token_id
        label_str = processor.batch_decode(labels_ids, \
                                   skip_special_tokens=True)
        cer = cer_metric.compute(predictions=pred_str, references=label_str)
        return {"cer": cer} 
    
  5. 训练模型:

    from transformers import default_data_collator
    # instantiate trainer
    trainer = Seq2SeqTrainer(
                             model=model,
                             tokenizer=processor.feature_extractor,
                             args=training_args,
                             compute_metrics=compute_metrics,
                             train_dataset=train_dataset,
                             eval_dataset=eval_dataset,
                             data_collator=default_data_collator,
                             )
    trainer.train() 
    

上述代码的结果如下:

自动生成的图像描述

图 15.12:随着增加的 epoch 数,训练和验证损失以及字符错误率

注意,随着步骤的增加,错误率持续降低。

  1. 对我们数据集中的样本图像进行推断:

    # Load and preprocess the image
    image = Image.open("/content/synthetic-data/American@3WPOqS.png").convert("RGB")
    pixel_values = processor(image, return_tensors="pt").pixel_values
    # Perform inference
    model.eval()  # Set the model to evaluation mode
    with torch.no_grad():
        generated_ids = model.generate(pixel_values.to(device))
    # Decode the generated ids to text
    predicted_text = processor.batch_decode(generated_ids, \
                               skip_special_tokens=True)[0]
    show(image)
    print("Predicted Text:", predicted_text) 
    

上述代码的结果显示如下图像:

自动生成的图像描述

图 15.13:样本预测

到目前为止,我们已经了解了如何利用编码器-解码器架构进行手写识别。在接下来的部分,我们将学习如何利用变换器架构在文档中获取键和值。

文档布局分析

想象一种情景,你被要求从护照中提取各种键的值(例如姓名、出生日期、签发日期和到期日期)。在某些护照中,值位于键的下方;在其他护照中,它们位于键的右侧;还有些在键的左侧。我们如何构建一个能够为文档图像中的每个文本分配相应值的单一模型?在这种情况下,LayoutLM 非常有用。

理解 LayoutLM

LayoutLM 是一个在大量文档图像上预训练的模型。LayoutLM 的架构如下:

自动生成的图像描述

图 15.14:LayoutLM 架构(来源:arxiv.org/pdf/1912.13318

如前图所示,相应的工作流程包括以下步骤:

  1. 我们拍摄文档的图像并提取各种单词及其边界框坐标(x0、x1、y0 和 y1) - 这是通过 OCR 工具完成的,它们不仅提供文本,还提供文档中文本所在的边界框。

  2. 我们获取与这些边界框坐标对应的位置嵌入 - 位置嵌入是基于边界框坐标计算的。

  3. 我们将从上述图片中提取的各种文本对应的嵌入(文本嵌入)添加到一起,其中我们通过标记器传递文本,进而通过预训练的双向编码器转换器BERT)模型。

  4. 在预训练的 LayoutLM 的训练阶段中,我们会随机屏蔽某些单词(但不是这些单词的位置嵌入),并根据上下文(周围单词及其对应的位置嵌入)预测屏蔽的单词。

  5. 一旦预训练的 LayoutLM 模型被微调,我们通过将单词的文本嵌入与对应的单词位置嵌入相加来提取每个单词对应的嵌入。

  6. 接下来,我们利用 Faster R-CNN 获取与单词位置对应的图像嵌入。我们利用图像嵌入以获取关于文本样式(例如粗体、斜体或下划线)的关键信息,这是 OCR 无法提供的。

  7. 最后,我们执行下游任务,提取与图像对应的键和值。在文档键值提取的情况下,这对应于命名实体识别的任务,其中每个输出单词被分类为可能的键或与键相关联的值之一。

LayoutLMv3 是 LayoutLM 的改进版本,其架构如下:

计算机程序图表描述自动生成

图 15.15:LayoutLMv3 架构(来源:arxiv.org/pdf/2204.08387

前述架构展示了以下步骤:

  1. 单词是使用典型的 OCR 解析器从图像中获取的。

  2. 然后,使用 RoBERTa 模型将单词转换为嵌入(更多细节请见:arxiv.org/abs/1907.11692)。

  3. 文档被调整为固定形状,然后转换为多个补丁。

  4. 每个补丁都被展平并通过线性层传递,以获得与补丁对应的嵌入。

  5. 1D 位置嵌入对应于单词/补丁的索引,而 2D 位置嵌入对应于边界框/段落。

  6. 一旦嵌入就位,我们执行掩码预训练(MLM Head),方式类似于 LayoutLM,其中我们掩码某些单词并使用上下文预测它们。类似地,在掩码图像建模MIM Head)中,我们掩码某些块并预测块内的标记。

  7. 然后执行单词补丁对齐WPA Head),这指的是预测掩码图像补丁是否具有相应的掩码标记。如果一个标记被掩码,并且相应的图像补丁也被掩码,则它是对齐的;如果其中一个被掩码而另一个没有,则它是不对齐的。

在接下来的章节中,我们将学习如何实现这一点。

实施 LayoutLMv3

让我们使用一个护照数据集编写LayoutLMv3的代码 - 在这里,我们尝试将图像中的每个标记与相应的键或值关联起来:

以下代码位于本书 GitHub 仓库的Chapter15文件夹中的LayoutLMv3_passports.ipynb中,地址为bit.ly/mcvp-2e

  1. 安装所需的包并导入它们:

    %pip install transformers[torch] datasets seqeval torch-snippets torchinfo lovely_tensors
    from torch_snippets import *
    from builtins import print 
    
  2. 导入一个合成护照数据集:

    from datasets import load_dataset
    dataset = load_dataset('sizhkhy/passports') 
    

输入数据集包含单词、框和标签,如下所示:

数字的特写 由模型自动生成的描述

图 15.16:样本期望输出

一个样本图像如下所示:

护照的特写 由模型自动生成的描述

图 15.17:样本合成护照

  1. 指定训练和测试集划分:

    examples_train = dataset['train']
    examples_eval = dataset['valid'] 
    
  2. 指定label2idid2label的映射:

    id2label = {i:v for i, v in set(list(zip(\
                                flatten(examples_train['labels']), \
                                flatten(examples_train['label_string']))))}
    label2id = {v:i for i, v in id2label.items()} 
    
  3. 定义处理器并准备函数来编码输入:

    from transformers import AutoProcessor
    processor = AutoProcessor.from_pretrained("microsoft/layoutlmv3-base", apply_ocr=False)
    def prepare_examples(examples):
      images = examples['image']
      words = examples['words']
      boxes = examples['boxes']
      word_labels = examples['labels']
      encoding = processor(images, words, boxes=boxes, \
                           word_labels=word_labels,
                           truncation=True, padding="max_length")
      return encoding 
    

在上述代码中,我们正在利用预训练的LayoutLMv3模型的处理器,我们正在对我们的数据集进行微调。然后,我们通过处理器传递图像、单词和框来获得相应的编码。

  1. 准备训练和评估数据集:

    train_dataset = examples_train.map(
                                  prepare_examples,
                                  batched=True,
                                  remove_columns=examples_train.column_names,
                                  )
    eval_dataset = examples_eval.map(
                                  prepare_examples,
                                  batched=True,
                                  remove_columns=examples_eval.column_names,
                                  ) 
    
  2. 定义评估指标:

    from datasets import load_metric
    metric = load_metric("seqeval") 
    
  3. 定义计算指标的函数:

    return_entity_level_metrics = False
    def compute_metrics(p):
        predictions, labels = p
        predictions = np.argmax(predictions, axis=2)
        # Remove ignored index (special tokens)
        true_predictions = [[id2label[p] for (p, l) in \
                      zip(prediction, label) if l != -100] \
              for prediction, label in zip(predictions, labels)]
        true_labels = [[id2label[l] for (p, l) in \
                       zip(prediction,label) if\ l != -100] \
               for prediction, label in zip(predictions,labels)]
        results = metric.compute(predictions=true_predictions,
                                         references=true_labels)
        if return_entity_level_metrics:
            # Unpack nested dictionaries
            final_results = {}
            for key, value in results.items():
                if isinstance(value, dict):
                    for n, v in value.items():
                        final_results[f"{key}_{n}"] = v
                else:
                    final_results[key] = value
            return final_results
        else:
            return {
                "precision": results["overall_precision"],
                "recall": results["overall_recall"],
                "f1": results["overall_f1"],
                "accuracy": results["overall_accuracy"]} 
    

在上述代码中,我们正在获取非填充的真实输出并计算精确度、召回率、F1 分数和准确度指标。

  1. 通过从 transformers 库导入相关模块来定义预训练的LayoutLMv3模型:

    from transformers import LayoutLMv3ForTokenClassification
    model = LayoutLMv3ForTokenClassification.from_pretrained(
                         "microsoft/layoutlmv3-base",
                         id2label=id2label,
                         label2id=label2id
                         ) 
    

在上述代码中,我们正在定义我们将用于微调模型的基础模型。

  1. 定义训练参数:

    from transformers import TrainingArguments, Trainer
    training_args = TrainingArguments(output_dir="test",
                                  max_steps=100,                                 
                                  per_device_train_batch_size=2,                                 
                                  per_device_eval_batch_size=2,
                                  learning_rate=1e-5,                                 
                                  evaluation_strategy="steps",
                                  eval_steps=50,                                 
                                  load_best_model_at_end=True,                                
                                  metric_for_best_model="f1") 
    
  2. 初始化训练器并训练模型:

    from transformers.data.data_collator import default_data_collator
    # Initialize our Trainer
    trainer = Trainer(
        model=model,
        args=training_args,
        train_dataset=train_dataset,
        eval_dataset=eval_dataset,
        tokenizer=processor,
        data_collator=default_data_collator,
        compute_metrics=compute_metrics,
    )
    trainer.train() 
    
  3. 接下来,我们进行推断(推断的代码在相关的 GitHub 仓库中提供),以获取输入图像的结果。以下是使用预测的键和值进行的样本推断(这些键和值作为图像内的边界框存在):

图 15.18:预测输出及提取的不同键和值的边界框

在这一部分,我们已经学习了从文档中提取键和值的方法。在下一部分中,我们将学习如何在给定一张通用图像的情况下执行问答任务。

视觉问答

想象一种情景,你拿到一张图像,并被要求根据图像回答某些问题。这是一种视觉问答VQA)的任务。VQA 的高级策略可以利用预训练的图像编码图像信息,使用大型语言模型LLM)编码问题(文本),然后使用图像和文本表示来生成(解码)答案 - 实质上是一个多模态模型,既可以接收文本输入也可以接收图像输入。

通过获取与图像对应的标题并在标题上执行问答,执行视觉问答的一种方式。

要理解为什么我们不能使用这个,让我们看一下下面的图像:

一只戴着墨镜的猫 由系统自动生成的描述

图 15.19:示例图像

一组关于原始图像标题的可能问题:

提取的标题 问题
一只戴着墨镜的猫 图像的主题是什么?
一只戴着墨镜的猫 图像的背景颜色是什么?

表 15.1:给定图像的一些问题

在前面的情景中,虽然我们可以回答第一个问题,但我们无法回答第二个问题,因为与问题相关的信息没有在上下文中提取出来(提取的标题)。

让我们了解一下 BLIP2 - 一个帮助解决这个问题的模型。

引入 BLIP2

在我们刚刚讨论的高级策略中讨论的编码器-解码器模型的一个问题是,当 VQA 模型整合视觉感知和语言理解时,模型可能会导致灾难性遗忘。当这些模型使用新的视觉或语言模式进行更新时,这两个领域之间复杂的相互作用可能会导致先前学习的模式被遗忘。

使用冻结图像编码器和 LLMs(BLIP2)通过独特的架构解决了这个问题:

语言模型图示 由系统自动生成的描述

图 15.20:BLIP2 架构(来源:ar5iv.labs.arxiv.org/html/2301.12597

查询变换器(Q-Former)充当了从图像编码器到 LLM 的桥梁。

Q-Former 从图像中提取与所问问题最相关的信息;因此,在本节开头呈现的情景中,它将从图像中提取与所问问题最相关的视觉信息。现在,我们将上下文(Q-Former 提取的信息)附加到问题并将其作为输入提供给 LLM。

本质上,Q-Former 充当了从图像编码器到 LLM 的桥梁,以修改从图像中提取的特征,使其与所问问题最相关。

Q-Former 训练有两个阶段:

  1. 从冻结编码器中引导视觉-语言表示学习

  2. 从冻结的 LLM 中引导视觉-语言生成学习

让我们详细看看这些阶段。

表示学习

在表示学习阶段,我们将 Q-Former 连接到冻结的图像编码器,并使用图像-文本对进行预训练。我们的目标是训练 Q-Former,使得这些查询(32 个可学习查询向量)能够学习提取与文本最相关的视觉表示。

我们共同优化了三个预训练目标,它们共享相同的输入格式和模型参数。每个目标使用不同的注意屏蔽策略来控制查询和文本之间的交互。

下图说明了这一点:

一个过程的流程图 由中等置信度自动生成的描述

图 15.21:表示学习的详细信息(来源:arxiv.org/pdf/2301.12597.pdf

如前图所示,三个目标如下:

  • 图像-文本匹配: 在这个任务中,在自注意模块内,查询标记可以关注文本标记,而文本标记可以关注查询向量。这个预训练的目标是执行图像和文本对是否匹配的二元分类。

  • 基于图像的文本生成: 在这个任务中,在自注意模块中,查询向量不能访问(被屏蔽)文本标记,而文本标记可以访问查询向量以及先前生成的标记。此训练的目标是生成与图像匹配的文本。

  • 图像-文本对比学习: 在这个任务中,我们有一个共享的自注意模块,用于学习的查询和输入文本之间。学习的查询与图像编码器交互以获取输出向量。输入文本转换为嵌入向量,并与自注意和前馈网络交互以生成对应文本的嵌入。我们预训练 Q-Former,使其对匹配的图像-文本对具有高相似度,并对图像与不同文本的情况具有低相似度(类似于 CLIP 的训练方式)。请注意,虽然自注意层是共享的,但文本标记被屏蔽,以防止两者之间信息泄漏。

通过上述三种预训练目标,我们完成了表示学习练习,从中提取了对文本最具信息性的视觉信息。

生成学习

在生成学习阶段,我们通过 Q-Former 传递学习的查询以获得输出向量,然后通过全连接层传递以获得与文本嵌入维度相同的嵌入。可以如下所示:

计算机流程图示例 由自动生成的描述

图 15.22:生成学习的详细信息(来源:ar5iv.labs.arxiv.org/html/2301.12597

现在我们有两种通过冻结的 LLM 生成文本的方式。基于解码器的 LLM 接收全连接FC)层的输出并生成输出文本,而基于编码器-解码器的模型接收 FC 输出和前缀文本的连接以生成后续文本。

通过上述方法,我们完成了预训练 BLIP2 的两个阶段,并获得了与所提问题最相关的上下文。这是我们如何为问题提供最相关的图像背景并生成答案的方法。让我们继续在下一节实现 BLIP2。

实施 BLIP2

在本节中,我们将利用 BLIP2 执行以下任务:

  • 图像字幕

  • 图像问题回答

您可以使用以下步骤来实现此操作:

此代码在本书 GitHub 存储库的Chapter15文件夹中的Visual_Question_answering.ipynb文件中提供,网址为bit.ly/mcvp-2e

  1. 导入所需的包并加载模型:

    import torch
    from transformers import AutoProcessor, Blip2ForConditionalGeneration
    device = "cuda" if torch.cuda.is_available() else "cpu"
    MODEL_ID = "Salesforce/blip2-opt-2.7b"
    processor = AutoProcessor.from_pretrained(MODEL_ID)
    model = Blip2ForConditionalGeneration.from_pretrained(MODEL_ID,
                                   torch_dtype=torch.float16)
    model.to(device) 
    

请注意,该模型需要高显存,因此我们在 Colab 上使用了 V100 机器。

  1. 加载图像 - 您可以使用任何您选择的图像:

    import requests
    from PIL import Image
    image = Image.open('/content/Tejas.jpeg') 
    

一个戴着黄色皇冠的婴儿  自动生成的描述

图 15.23:示例图像

  1. 将图像通过处理器以生成与图像相对应的标题:

    inputs = processor(image, return_tensors="pt").to(device,
                                                 torch.float16)
    generated_ids = model.generate(**inputs, max_new_tokens=20)
    generated_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
    print(generated_text) 
    

这给我们带来了以下输出:

a baby wearing a party hat sits on a bed 
  1. 对图像执行问题回答:

    prompt = "Question: what is the color of baby's trousers? Answer:"
    inputs = processor(image, text=prompt, return_tensors="pt").to(device, torch.float16)
    generated_ids = model.generate(**inputs, max_new_tokens=10)
    generated_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
    print(generated_text) 
    

上述代码的输出结果为blue

注意,在执行问题回答时,我们应明确提及问题的开始和答案的开始,如提示所提供。

摘要

在本章中,我们了解了变压器的工作原理。此外,我们还学习了如何利用 ViT 进行图像分类。然后,我们学习了如何在学习手写转录时利用 TrOCR 进行文档理解,以及如何从文档中利用 LayoutLM 提取键值对。最后,我们了解了如何使用预训练的 BLIP2 模型进行视觉问题回答。

通过这些内容,您应该可以轻松处理一些最常见的真实世界用例,例如文档上的 OCR,从文档中提取键值对,以及图像上的视觉问题回答(处理多模态数据)。此外,通过对变换器的理解,您现在可以深入了解基础模型的下一章。

问题

  1. 自注意力的输入、计算步骤和输出是什么?

  2. 图像如何在视觉变换器中转换为序列输入?

  3. 在 LayoutLM 模型中,BERT 变换器的输入是什么?

  4. BLIP 的三个目标是什么?

在 Discord 上了解更多信息

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

第十六章:计算机视觉中的基础模型

在前一章中,我们了解了如何使用 NLP 和 CV 技术构建新型应用程序。然而,这需要大量的训练,要么是从头开始,要么是通过微调预训练模型。当利用预训练模型时,该模型通常已经在大型数据语料库上进行了训练 - 例如,像 ImageNet 这样的数据集,其中包含大约 2100 万张图像。然而,在互联网上,我们可以访问数亿张图像及其对应的 alt 文本。如果我们在互联网规模的数据上预训练模型,并将这些模型用于涉及对象检测、分割和文本到图像生成的各种应用程序,而无需进行任何微调,会怎么样?这构成了基础模型的基石。

在本章中,我们将学习以下内容:

  • 利用图像和文本嵌入来识别给定文本的最相关图像,反之亦然。

  • 利用图像和文本编码进行零样本图像物体检测和分割

  • 从头开始构建扩散模型,使用该模型我们将有条件地(通过文本提示)和无条件地生成图像

  • 提示工程生成更好的图像

更具体地说,我们将学习对比语言-图像预训练CLIP),它可以识别与给定文本相关的图像,反之亦然,结合图像编码器和提示(文本)编码器来识别图像内的区域/段落。我们还将学习如何利用基于 CNN 的架构与稳定扩散模型(SAM)进行零样本分割,比基于变压器的零样本分割快约 50 倍。我们还将了解Stable Diffusion模型及其 XL 变体的基础知识。

本章中的所有代码片段都可以在 GitHub 存储库的Chapter16文件夹中找到,网址为bit.ly/mcvp-2e

随着领域的发展,我们将定期向 GitHub 存储库添加有价值的补充内容。请查看每个章节目录中的supplementary_sections文件夹获取新的有用内容。

介绍 CLIP

想象一种情景,在这种情况下,您可以访问一个图像数据集。您并未获得数据集中每个图像对应的标签。然而,您拥有一个详尽的列表,其中包含图像数据集中所有唯一标签的信息。对于给定的图像,您将如何分配可能的标签?

在这种情况下,CLIP 来拯救。CLIP 为每个图像和标签提供一个嵌入(与图像相关的文本 - 通常是图像类别或图像的标题)。通过计算图像嵌入和所有唯一标签的文本嵌入之间的相似性,我们可以将图像与最可能的标签关联,其中与图像嵌入最相似的标签是最可能的标签。

在下一节中,让我们先了解一下 CLIP,然后从头构建一个 CLIP 模型,再使用一个预训练模型。

CLIP 的工作原理

要了解 CLIP 如何工作,让我们拆解首字母缩写CLIP对比语言-图像预训练):

  • 预训练:模型在一个庞大的数据语料库上进行训练。

  • 语言-图像预训练:该模型在包含图片及其对应文本(例如图片的 alt-text)的大量数据语料库上进行训练。

  • 对比语言-图像预训练:语言-图像预训练是这样进行的,即相似图片的图像和文本嵌入将是相似的,而一个对象的图像嵌入与另一个对象对应的文本嵌入将尽可能不相似。

让我们通过以下图表来理解这一点:

一个机器的图解  自动生成的描述

图 16.1:CLIP 概述

在上述图中,我们有一组图片和文字。此外,我们还有一组嵌入(i[1]、i[2]...i[N]),这些嵌入是通过将这些图片传递到图像编码器后获得的,以及一组对应的文本嵌入(t[1]、t[2]...t[N]),这些嵌入是通过将文本传递到文本编码器后获得的。

现在我们需要以一种方式训练模型,使得 i[N]与 t[N]有很高的相似度,并且与对应其他图片的文本嵌入的相似度非常低。

在下一节中,让我们继续从头训练 CLIP 模型。

从头构建一个 CLIP 模型

要从头构建一个 CLIP 模型,我们将利用 FLICKR-8K 数据集,因为它包含了图片和相关的标题:

下面的代码可以在 GitHub 仓库的Chapter16文件夹中的CLIP_from_scratch.ipynb文件中找到,网址为bit.ly/mcvp-2e。确保从笔记本中运行代码,并参考这里提供的解释以理解不同的步骤。你需要一个 Kaggle 账户来下载数据。

  1. 安装所需的包并导入所需的模块:

    %%capture
    import os
    if not os.path.exists("MCVP2e-CLIP"):
      !git clone https://github.com/sizhky/MCVP2e-CLIP.git
      %pip install -r MCVP2e-CLIP/requirements.txt
    %cd MCVP2e-CLIP 
    
  2. 导入所需的包:

    import itertools
    from torch_snippets import *
    from clip.core import download_flickr8k_from_kaggle
    from clip.config import ClipConfig
    from clip.dataset import CLIPDataset
    from clip.models import CLIP 
    
  3. 提供你的 Kaggle 凭证:

    %%writefile kaggle.json
    {"username":"XXXX","key":"XXXX"} 
    
  4. 下载 Flickr 数据集。在运行下一行代码之前,请确保你同意 Kaggle 网页上的条款www.kaggle.com/datasets/adityajn105/flickr8k,否则无法下载。

一个白色矩形带黑色文本  自动生成的描述

  1. kaggle_json_path = P("kaggle.json")
    data_download_path = P("/content/flickr-8k-kaggle/")
    download_flickr8k_from_kaggle(kaggle_json_path, data_download_path)
    df = pd.read_csv(data_download_path / "captions.txt")
    df["id"] = [id_ for id_ in range(len(df) // 5) for _ in range(5)]
    df.to_csv(data_download_path / "captions.csv") 
    
  2. 设置你的训练配置:

    config = ClipConfig()
    config.image_path = data_download_path / "Images"
    config.captions_csv_path = data_download_path / "captions.csv"
    # Make any other experiment changes, if you want to, below
    config.debug = False  # Switch to True, in case you want to reduce the dataset size
    config.epochs = 1
    config.save_eval_and_logging_steps = 50 
    

建议您查看 GitHub 笔记本中的完整ClipConfig类,并了解各个参数 - 图像编码器和文本编码器的学习率、周期数、后端模型名称、图像和文本嵌入、文本的最大序列长度以及投影层的维度(因为图像和文本的嵌入维度不同)。现在,我们只是指向正确的数据集路径并设置周期数。

  1. 创建训练和验证数据集。

数据集的关键组件如下(代码从dataset.py脚本导入)。

  1. class CLIPDataset(Dataset):
        def __init__(self, df, config, mode):
            """
            image_filenames and captions must have the same length; so, if there are
            multiple captions for each image, the image_filenames must have repetitive
            file names
            """
            self.config = config
            self.tokenizer = DistilBertTokenizer.from_pretrained(
                config.distilbert_text_tokenizer
            )
            self.image_filenames = df.image.tolist()
            self.captions = df.caption.tolist()
            with notify_waiting(f"Creating encoded captions for {mode} dataset..."):
                self.encoded_captions = self.tokenizer(
                    self.captions,
                    padding=True,
                    truncation=True,
                    max_length=config.max_length,
                )
            self.transforms = get_transforms(config)
        def __getitem__(self, idx):
            item = {
                key: torch.tensor(values[idx])
                for key, values in self.encoded_captions.items()
            }
            image = \ 
    read(f"{self.config.image_path}/{self.image_filenames[idx]}", 1)
            image = self.transforms(image=image)
            item["image"] = torch.tensor(image).permute(2, 0, 1).float()
            item["caption"] = self.captions[idx]
            return item
        def __len__(self):
            return len(self.captions) 
    

所有文本都使用 DistilBert 分词器进行标记化。所有图像都被转换为固定大小,并使用get_transforms方法进行归一化。我们已经因简洁起见省略了Dataset类中使用的其他方法的解释。但是,请务必检查类并理解dataset.py脚本中定义的不同方法。

使用以下代码片段创建训练和验证数据集:

  1. trn_ds, val_ds = CLIPDataset.train_test_split(config) 
    
  2. 加载模型:

    model = CLIP(config).to(config.device) 
    
  3. 让我们看看模型的关键组件:

    1. ImageEncoder类:使用ImageEncoder类,我们获取图像的嵌入,该嵌入是通过经过预训练的 resnet-50 模型传递图像得到的:
    class ImageEncoder(nn.Module):
        """
        Encode images to a fixed size vector
        """
        def __init__(
            self, model_name=CFG.model_name, 
                  pretrained=CFG.pretrained,  
                  trainable=CFG.trainable):
            super().__init__()
            self.model = timm.create_model(
                model_name, pretrained, num_classes=0, 
                                       global_pool="avg")
            for p in self.model.parameters():
                p.requires_grad = trainable
        def forward(self, x):
            return self.model(x) 
    
    1. TextEncoder通过将文本(标记)通过 BERT 模型来将标记转换为嵌入。
    class TextEncoder(nn.Module):
        def __init__(self, model_name=CFG.text_encoder_model, 
            pretrained=CFG.pretrained, trainable=CFG.trainable):
            super().__init__()
            if pretrained:
                self.model = DistilBertModel.from_pretrained(model_name)
            else:
                self.model = DistilBertModel(config=DistilBertConfig())
    
            for p in self.model.parameters():
                p.requires_grad = trainable
            # we are using CLS token hidden representation as   
            # the sentence's embedding
            self.target_token_idx = 0
        def forward(self, input_ids, attention_mask):
            output = self.model(input_ids=input_ids, 
                                  attention_mask=attention_mask)
            last_hidden_state = output.last_hidden_state
            return last_hidden_state[:,self.target_token_idx, :] 
    
    1. ProjectionHead:由于文本编码器输出为 768 维,而图像编码器为 2048 维,我们需要将它们调整到相同的维度以进行比较。
    class ProjectionHead(nn.Module):
        def __init__(
            self,
            embedding_dim,
            projection_dim=CFG.projection_dim,
            dropout=CFG.dropout
        ):
            super().__init__()
            self.projection = nn.Linear(embedding_dim, projection_dim)
            self.gelu = nn.GELU()
            self.fc = nn.Linear(projection_dim, projection_dim)
            self.dropout = nn.Dropout(dropout)
            self.layer_norm = nn.LayerNorm(projection_dim)
    
        def forward(self, x):
            projected = self.projection(x)
            x = self.gelu(projected)
            x = self.fc(x)
            x = self.dropout(x)
            x = x + projected
            x = self.layer_norm(x)
            return x 
    
  4. 构建CLIPModel。为此,我们获得相同维度的image_embeddingtext_embedding。接下来,我们计算 logits 来理解文本和图像嵌入之间的相似性。然后,我们计算给定图像的嵌入与其余图像的嵌入之间的相似性(文本同理)。最后,根据相似图像具有相似嵌入和不相似图像具有远离的嵌入的直觉,计算总体损失:

    class CLIPModel(nn.Module):
        def __init__(
            self,
            temperature=CFG.temperature,
            image_embedding=CFG.image_embedding,
            text_embedding=CFG.text_embedding,
        ):
            super().__init__()
            self.image_encoder = ImageEncoder()
            self.text_encoder = TextEncoder()
            self.image_projection = \ 
                  ProjectionHead(embedding_dim=image_embedding)
            self.text_projection = \ 
                  ProjectionHead(embedding_dim=text_embedding)
            self.temperature = temperature
        def forward(self, batch):
            # Getting Image and Text Features
            image_features = self.image_encoder(batch["image"])
            text_features = self.text_encoder( \
                                  input_ids=batch["input_ids"],  
                        attention_mask=batch["attention_mask"])
    # Getting Image and Text Embeddings (with same dimension)
            image_embeddings= self.image_projection(image_features)
            text_embeddings = self.text_projection(text_features)
            # Calculating the Loss
            logits = (text_embeddings @ image_embeddings.T)/self.temperature
            images_similarity = image_embeddings @ image_embeddings.T
            texts_similarity = text_embeddings @ text_embeddings.T
            targets = F.softmax( (images_similarity + texts_similarity) / 2 * \
                                               self.temperature, dim=-1)
            texts_loss = cross_entropy(logits, targets, reduction='none')
            images_loss = cross_entropy(logits.T, targets.T, reduction='none')
            loss =  (images_loss + texts_loss) / 2.0 # shape: (batch_size)
            return {"loss": loss.mean()} 
    

    在上述代码中使用的损失函数如下:

    def cross_entropy(preds, targets, reduction='none'):
        log_softmax = nn.LogSoftmax(dim=-1)
        loss = (-targets * log_softmax(preds)).sum(1)
        if reduction == "none":
            return loss
        elif reduction == "mean":
            return loss.mean() 
    

我们将输出作为一个字典返回,其中包含loss键,以保持与 Hugging Face 的兼容性。

  1. 加载优化器并定义图像编码器和文本编码器的学习率。

    params = [
        {"params": model.image_encoder.parameters(), "lr": 
                                       config.image_encoder_lr},
        {"params": model.text_encoder.parameters(), "lr": 
                                       config.text_encoder_lr},
        {
            "params": itertools.chain(
                model.image_projection.parameters(), 
                model.text_projection.parameters()
            ),
            "lr": config.head_lr,
            "weight_decay": config.weight_decay,
        },
    ]
    optimizer = torch.optim.AdamW(params, weight_decay=0.0)
    lr_scheduler=torch.optim.lr_scheduler.ReduceLROnPlateau( \
                                                    optimizer, mode="min",
                                                    patience=config.patience,
                                                    factor=config.factor) 
    
  2. 使用 Hugging Face API 进行训练:

    from transformers import Trainer, TrainingArguments
    # Define TrainingArguments
    training_args = TrainingArguments(
        output_dir="./results",  # Output directory where checkpoints and logs will be saved.
        num_train_epochs=config.epochs,  # Total number of training epochs.
        per_device_train_batch_size=config.batch_size,  # Batch size per GPU.
        per_device_eval_batch_size=config.batch_size,  # Batch size for evaluation
        evaluation_strategy="steps",  # Evaluation strategy (steps, epoch).
        logging_strategy="steps",  # Logging strategy (steps, epoch).
        save_strategy="steps",  # Save strategy (steps, epoch).
        save_total_limit=2,  # Limit the total amount of checkpoints.
        learning_rate=5e-5,  # Learning rate.
        logging_steps=config.save_eval_and_logging_steps,
        save_steps=config.save_eval_and_logging_steps,# Save checkpoints every N #steps.
        eval_steps=config.save_eval_and_logging_steps,  # Evaluate every N steps.
        logging_dir="./logs",  # Directory for storing logs.
        metric_for_best_model="loss",
        label_names=["image", "input_ids"],
    )
    # Create Trainer
    trainer = Trainer(
                      model=model,
                      args=training_args,
                      train_dataset=trn_ds,
                      eval_dataset=val_ds,
                      optimizers=(optimizer, lr_scheduler),
    )
    # Train the model
    trainer.train() 
    

注意

类似于 detectron 训练器、mmaction2 运行器,我们有一个 Hugging Face 训练器,它是围绕opt.zero_grad()loss.backward()等步骤的封装,以使训练代码对最终用户简洁。深入研究实际的训练器类留作你的练习。

  1. 训练完成后,我们可以获取与所有图像对应的嵌入:

    def get_image_embeddings(valid_df, model_path):
        tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer)
        valid_loader = build_loaders(valid_df, tokenizer, mode="valid")
    
        model = CLIPModel().to(CFG.device)
        model.load_state_dict(torch.load(model_path, 
                                  map_location=CFG.device))
        model.eval()
    
        valid_image_embeddings = []
        with torch.no_grad():
            for batch in tqdm(valid_loader):
                image_features = \ 
              model.image_encoder(batch["image"].to(CFG.device))
                image_embeddings = \ 
                model.image_projection(image_features)
                valid_image_embeddings.append(image_embeddings)
        return model, torch.cat(valid_image_embeddings)
    _, valid_df = make_train_valid_dfs()
    model, image_embeddings = get_image_embeddings(valid_df, "best.pt") 
    
  2. 查找与给定查询(用户输入文本)的匹配项:

    def find_matches(model, image_embeddings, query, image_filenames, n=9):
        tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer)
        encoded_query = tokenizer([query])
        batch = {
            key: torch.tensor(values).to(CFG.device)
            for key, values in encoded_query.items()
        }
        with torch.no_grad():
            text_features = model.text_encoder(
                input_ids=batch["input_ids"], 
                attention_mask=batch["attention_mask"]
            )
            text_embeddings = model.text_projection(text_features)
    
        image_embeddings_n = F.normalize(image_embeddings, p=2, dim=-1)
        text_embeddings_n = F.normalize(text_embeddings, p=2, dim=-1)
        dot_similarity = text_embeddings_n@image_embeddings_n.T
    
        values, indices = torch.topk(dot_similarity.squeeze(0), n * 5)
        matches = [image_filenames[idx] for idx in indices[::5]] 
        _, axes = plt.subplots(3, 3, figsize=(10, 10))
        for match, ax in zip(matches, axes.flatten()):
            image = cv2.imread(f"{CFG.image_path}/{match}")
            image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
            ax.imshow(image)
            ax.axis("off") 
        plt.show()
    find_matches(model,
                 image_embeddings,
                 query="dogs on the grass",
                 image_filenames=valid_df['image'].values,
                 n=9) 
    

上述代码的输出如下:

一幅奔跑狗的拼贴图  自动生成的描述

图 16.2:与查询“草地上的狗”匹配的图像

有了这个,我们现在能够为给定查询提供匹配的图像。

现在我们理解了 CLIP 是如何从头开始训练的,我们将学习如何利用 OpenAI API 获取与图像和文本对应的嵌入。

利用 OpenAI CLIP

从头开始训练 CLIP 的一个问题是需要大量资源 - 计算资源以及训练模型所需的数据。OpenAI CLIP 已经在 4 亿图像文本对的数据集上训练。通过以下步骤查询它:

以下代码位于 GitHub 仓库中的 Chapter16 文件夹中的 OpenAI_CLIP.ipynb 文件中,位于 bit.ly/mcvp-2e。确保从笔记本中运行代码并参考以下解释,以理解不同的步骤。

  1. 安装所需的软件包:

    !pip install ftfy regex tqdm 
    
  2. 克隆 GitHub 仓库:

    !git clone https://github.com/openai/CLIP.git
    %cd CLIP 
    
  3. 加载预训练的 CLIP 模型:

    import torch
    import clip
    from PIL import Image
    device = "cuda" if torch.cuda.is_available() else "cpu"
    model, preprocess = clip.load("ViT-B/32", device=device) 
    
  4. 提供图像和文本并预处理它们:

    image = preprocess(Image.open("CLIP.png")).unsqueeze(0).to(device)
    text=clip.tokenize(["a diagram","a dog","a cat"]).to(device) 
    

在上述代码中,我们提供了一个图表的图像和可能对应于图像的标签。

我们提供的图像是:

CLIP

图 16.3:输入图像

  1. 通过前馈网络传递图像和文本嵌入:

    with torch.no_grad():
        image_features = model.encode_image(image)
        text_features = model.encode_text(text)
    
        logits_per_image, logits_per_text = model(image, text)
        probs = logits_per_image.softmax(dim=-1).cpu().numpy()
    print("Label probs:", probs)  
    # prints: [[0.9927937  0.00421068 0.00299572]] 
    

通过上述方法,我们可以在给定图像和可能标签集合的情况下获取相应的标签。

现在我们理解了如何在零-shot 设置中获取标签,条件是提供可能标签集合,在下一节中,我们将学习如何分割图像并获取相关内容(仅与标签对应的掩码),通过指定标签。

介绍 SAM

想象一种场景,你拿到一张图像,并被要求预测与给定文本对应的掩码(比如一张图像中有多个物体,比如狗、猫、人等)。你会如何解决这个问题?

在传统设置中,这是一个对象检测问题,我们需要数据来在给定数据集上进行微调或利用预训练模型。我们无法利用 CLIP,因为它对整体图像进行分类,而不是其中的个别对象。

此外,在这种情况下,我们希望在甚至不训练模型的情况下完成所有这些操作。这就是 Segment Anything ModelSAM)- arxiv.org/pdf/2304.02643 来自 Meta 在解决问题中的帮助。

SAM 的工作原理

SAM 在来自 Meta 开发的数据引擎中的 1 亿张图像(SAM 1B 数据集)上训练,这些图像生成自 1100 万张图像。

  1. 辅助手动 - SAM 协助注释者注释掩码。

  2. 半自动化 - SAM 为图像中存在的对象子集生成掩码,同时注释者标记图像中剩余的对象。

  3. 全自动 - 人类提示 SAM 使用网格点,SAM 自动生成与点对应的掩码。

使用这三个步骤,可以生成更多的蒙版(因为 SAM 提示小物体,人类可能会错过)。

上述步骤导致每个图像生成相当多的蒙版,具体如下:

各种物体图像拼贴的图 自动生成描述

图 16.4:使用 SAM 生成的蒙版

SAM 的架构如下所示:

一个计算机程序图示的图表 自动生成描述

图 16.5:SAM 架构

在上述图像中,正在执行以下步骤:

  1. 一个图像通过图像编码器(即预训练的视觉转换器)传入,以获得相应的图像嵌入。

  2. 一旦我们有了图像嵌入,我们可以通过提供密集蒙版(上图中的蒙版)或稀疏蒙版(点、框和文本)在整体图像中查询特定蒙版。

  3. 提示编码器负责接收点、框和蒙版,并返回密集和稀疏嵌入。点和框嵌入的构造方式类似于我们在 LayoutLM 中计算嵌入的方式。让我们看看不同类型蒙版之间的区别:

    • 在需要对人的头发或树叶等部分进行分割的场景中,密集蒙版非常有用,我们提供所需提取蒙版的粗略轮廓,SAM 负责提取蒙版。

    • 当我们要指定与图像对应的文本/边界框/点时,稀疏蒙版非常有用。

    • 如果我们没有提供任何蒙版、文本或点输入,模型将自动生成 1,024 个点均匀分布在输入图像上给点编码器。

在较高级别上,提示编码器的 forward 方法如下所示:

一个程序代码的计算机屏幕截图 参考

图 16.6:提示编码器的正向传播

示例输入和输出如下所示:

自动生成描述的带数字和符号的计算机屏幕 参考

图 16.7:提示编码器的示例输入和输出

在上面的示例中,我们将 64 个点发送到编码器(64 个 x 和 y 坐标,因此形状为 [64,2]),并获得稀疏和密集嵌入。对于文本提示,它通过 CLIP 嵌入来获得与文本对应的嵌入,当通过提示编码器时。对于点/边界框,它们由与点对应的位置嵌入表示。

  1. 然后,嵌入传递到蒙版解码器,该解码器计算提示编码器和图像编码器之间的注意力,然后输出一组蒙版。

蒙版解码器的架构如下所示:

上传的图像

图 16.8:蒙版解码器架构

下面逐步解释各组件及其功能:

  1. Image embedding: 这个过程始于图像嵌入,它是将输入图像转换为一组特征向量的表示(从视觉变压器输出,其中每个特征向量对应于不同的补丁)。

  2. Output tokens + prompt tokens: 提示(任何点或边界框/文本)表示为嵌入。输出令牌类似于变压器中的 CLS 令牌。它们是可学习的嵌入,包含有效分割练习的信息(其中我们输出三个可能的与提示对应的掩码)。

  3. Attention blocks: 这些块类似于变压器解码器块中的块,其中包括自注意力(在解码器输出内部)和编码器与解码器之间的交叉注意力。

    1. Image to token attention: 这个块帮助模型集中在图像嵌入中与令牌相关的特征。这是一种跨注意力形式,其中图像特征信息影响令牌的处理。

    2. Token to image attention: 这相反,允许令牌影响图像特征的处理。

    3. Self-attention: 这种机制允许令牌相互作用,帮助模型在不同令牌之间集成信息。

  4. Multilayer perceptron (MLP): 这是一个神经网络层,处理注意机制中的特征,以进一步转换和组合信息。

  5. Iterations: 注意块和 MLP 堆叠在层中(如 x2 所示),允许模型在每次迭代中优化其对图像的理解和提示。

  6. 2x convolutional transpose: 这是一种上采样操作,用于增加特征图的空间分辨率。也称为反卷积。此操作用于从较低分辨率的嵌入返回到原始图像的高分辨率空间,这对于创建详细的遮罩是必要的。

  7. Dot product per mask: 这一步涉及计算精炼特征与每个潜在掩码之间的点积。这是一种评分方式,用于评估每个特征与预测掩码的对应程度,有效地将特征向量与预测掩码对齐。

  8. Masks: 每个掩码的点积结果用于生成最终的分割掩码。每个掩码对应于图像中由提示定义的特定区域或对象。

  9. Intersection over union (IoU) scores: 除了生成掩码之外,模型还输出 IoU 分数。IoU 度量预测分割掩码与地面实况掩码之间的重叠。

蒙版解码器主要负责从图像和提示(点、框、文本等)的组合信息中获取信息,并将其解码为一组与图像中的对象或特征相对应的分割蒙版。它使用注意力机制聚焦于相关特征,MLP 处理和组合信息,并使用转置卷积将低分辨率嵌入映射回图像空间。最终输出是一组蒙版,每个蒙版都带有相应的 IoU 分数,指示分割的质量。

实施 SAM

想象一个情景,您委派一个标注员尽可能快地标注图像,只需一个点击(作为对象感兴趣的顶部点提示)作为输入。

在本节中,让我们继续使用以下代码,在样本图像上利用 SAM 并了解 SAM 如何在给定点提示的情况下帮助快速分割:

以下代码可在 GitHub 仓库的 Chapter16 文件夹中的 SAM.ipynb 文件中找到,该仓库位于 bit.ly/mcvp-2e。确保从笔记本中运行代码,并参考以下解释以了解不同步骤。

  1. 克隆 GitHub 仓库并安装所需的包:

    !git clone https://github.com/facebookresearch/segment-anything.git
    %cd segment-anything
    !pip install -e . 
    
  2. 下载预训练的视觉变换器模型:

    !wget https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth 
    
  3. 加载 SAM 模型并创建预测器实例:

    from segment_anything import SamAutomaticMaskGenerator, \
                          sam_model_registry, SamPredictor
    import cv2
    sam = sam_model_registry["vit_h"]\
               (checkpoint="sam_vit_h_4b8939.pth").to('cuda')
    mask_generator = SamAutomaticMaskGenerator(sam)
    predictor = SamPredictor(sam) 
    

预测器将负责对点/蒙版/框进行必要的预处理,然后进行模型预测,最后进行必要的后处理。

  1. 加载我们要进行预测的图像:

    image = cv2.imread('/content/segment-anything/notebooks/images/truck.jpg')
    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    predictor.set_image(image) 
    
  2. 提供我们想要提取相应蒙版的点/点/框/提示:

    import numpy as np
    input_point = np.array([[500, 375]])
    input_label = np.array([1]) 
    
  3. 提取与提供的点对应的蒙版:

    masks, scores, logits = predictor.predict(
                                              point_coords=input_point,
                                              point_labels=input_label,
                                              multimask_output=True,
                                              ) 
    

一辆侧面有绿色星星的白色卡车 自动生成的描述

图 16.9:通过提供点提示获得的蒙版

根据前面的输出,我们可以看到我们能够生成与提供的点对应的蒙版。此过程可以扩展至:

  • 以框作为输入

  • 多个点和框作为输入

  • 作为输入的文本提示

我们已在相关的 GitHub 仓库中提供了所有上述示例。

现在我们已经了解了如何在给定提示的情况下提取蒙版,让我们现在学习如何从图像中提取所有可能的蒙版:

  1. 要提取所有的蒙版,请按照本节前述步骤中步骤 1-3 进行操作。

  2. 加载图像:

    import cv2
    image = cv2.imread('/content/segment-anything/notebooks/images/truck.jpg')
    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) 
    
  3. 生成蒙版:

    masks = mask_generator.generate(image) 
    
  4. 定义可视化覆盖在原始图像上的蒙版的函数:

    import numpy as np
    import torch
    import matplotlib.pyplot as plt
    import cv2
    def show_anns(anns):
        if len(anns) == 0:
            return
        sorted_anns = sorted(anns, key=(lambda x: x['area']), reverse=True)
        ax = plt.gca()
        ax.set_autoscale_on(False)
        img = np.ones((sorted_anns[0]['segmentation'].shape[0], 
                   sorted_anns[0]['segmentation'].shape[1], 4))
        img[:,:,3] = 0
        for ann in sorted_anns:
            m = ann['segmentation']
            color_mask = np.concatenate([np.random.random(3), [0.35]])
            img[m] = color_mask
        ax.imshow(img) 
    
  5. 可视化图像:

    plt.figure(figsize=(20,20))
    plt.imshow(image)
    show_anns(masks)
    plt.axis('off')
    plt.show() 
    

输出如下所示:

一辆多色卡车,带有几扇门 自动生成的描述

图 16.10:图像内所有预测的蒙版

根据前述图像,我们可以看到原始图像中不同粒度区域的蒙版。

到目前为止,我们已经学习了如何利用 SAM 来识别图像中的掩膜。然而,获取一张图像的掩膜需要相当长的时间(5-20 秒,取决于图像分辨率和图像中的物体),这使得在实时分割中使用它非常困难。在接下来的部分中,我们将学习 FastSAM,它有助于实时生成掩膜。

FastSAM 的工作原理

SAM 采用输入提示来计算相应的掩膜。如果提供了输入提示,则将掩膜传递到第二个块,进行提示编码;如果未提供输入提示,则 SAM 将计算所有可能的掩膜。这一切都利用了转换器来编码图像并在解码时进行注意力计算。处理一张输入图像和提示需要相当长的时间(约 10 秒)。我们如何减少生成预测所需的时间?FastSAM 就是帮助实现这一目标的。

FastSAM 将 SAM 分解为两个任务:

  1. 计算实例的掩膜

  2. 将提示与其中一个实例关联

此外,FastSAM 利用了 CNN 主干网络,进一步降低了计算复杂性。FastSAM 仅在 SAM 1B 数据集中存在的 2%数据点上进行训练,其架构如下:

一个牛的图示 由描述自动生成

图 16.11:FastSAM 工作流程(来源:arxiv.org/pdf/2306.12156

FastSAM 包括两个步骤:

  1. 所有实例分割

  2. 提示引导的选择

让我们详细看一下这些步骤。

所有实例分割

图 16.11(工作流程的上半部分),我们将输入图像通过 CNN 主干网络(如 ResNet101 架构)传递。然后我们将输出通过特征金字塔网络,它不仅从给定图像中提取特征,还确保捕获多样尺寸的特征。FPN 的输出具有两个不同的分支 - 检测和分割。

检测分支输出类别和边界框,而分割分支输出 k 个原型(在 FastSAM 中默认为 32 个)以及 k 个掩膜系数(有关原型和掩膜系数的更多细节,请参阅 YOLACT++论文:arxiv.org/pdf/1912.06218.pdf)。分割和检测任务并行计算。分割分支接收高分辨率特征图输入,保留空间细节,并包含语义信息。

提示引导的选择

提示引导的选择变得更加容易,因为在掩膜内部的点会突出显示实例/掩膜(对于点提示),并且具有最高 IoU 的框确定了盒子提示中的实例。

此外,在文本嵌入的情况下,我们利用 CLIP 模型计算文本嵌入,并将其与图像中每个实例的嵌入进行比较,以识别最可能表示文本的掩码。

现在我们了解了 FastSAM 如何在高层次上运行,让我们继续实施它。

实现 FastSAM

要实现 FastSam,请使用以下代码:

此代码位于 GitHub 仓库中Chapter16文件夹中的FastSAM.ipynb文件中,网址为bit.ly/mcvp-2e。务必从笔记本运行代码,并参考以下解释了解不同步骤。

  1. 克隆与 FastSAM 相关的 Git 存储库:

    !git clone https://github.com/CASIA-IVA-Lab/FastSAM.git 
    
  2. 下载预训练模型:

    !wget https://huggingface.co/spaces/An-619/FastSAM/resolve/main/weights/FastSAM.pt 
    
  3. 安装所需的包和 OpenAI clip:

    !pip install -r FastSAM/requirements.txt
    !pip install git+https://github.com/openai/CLIP.git 
    
  4. 切换目录:

    %cd FastSAM 
    
  5. 导入所需的包:

    import matplotlib.pyplot as plt
    import cv2
    from fastsam import FastSAM, FastSAMPrompt 
    
  6. 实例化我们想要处理的模型和图像:

    model = FastSAM('/content/FastSAM.pt')
    IMAGE_PATH = '/content/FastSAM/images/cat.jpg'
    DEVICE = 'cuda' 
    
  7. 将图像通过模型传递:

    everything_results = model(IMAGE_PATH, device=DEVICE, 
                               retina_masks=True, imgsz=1024,  
                               conf=0.4, iou=0.9)
    prompt_process = FastSAMPrompt(IMAGE_PATH, everything_results, \
                                                      device=DEVICE)
    # everything prompt
    ann = prompt_process.everything_prompt() 
    

上述代码生成图像中检测到的所有可能掩码。注意,执行时间为 0.5 秒(比 SAM 快约 20 倍)。

  1. 指定我们希望获取掩码的提示:

    ann = prompt_process.text_prompt(text='a photo of a cat') 
    
  2. 将处理后的图像写入磁盘:

    prompt_process.plot(annotations=ann,output_path='./output/cat.jpg') 
    

上述代码保存了带有猫掩码的相应图片。

到目前为止,我们已经了解了如何利用 SAM 获取与图像对应的掩码,以及如何利用 FastSAM 加速预测生成。然而,在实际场景中,我们可能希望跟踪给定实例/对象随时间在视频中的所有帧中的变化。论文 Segment & Track Anythingarxiv.org/pdf/2305.06558.pdf)详细说明了如何实现这一点。

跟踪任何内容的代码位于相关 GitHub 存储库中的SAMTrack.ipynb文件中。

另外,ImageBind 是另一个基础模型,将多种模态(图像、文本、音频、视频、热图和深度)绑定到一个维度中,因此可以提供跨模态的翻译机会。有关 ImageBind 的详细信息,请查阅相关论文和 GitHub 存储库(github.com/facebookresearch/ImageBind)。

到目前为止,我们已经学习了关于零-shot 识别或零-shot 分割给定图像的内容。在接下来的部分,我们将学习关于扩散模型,这将有助于图像的零-shot 生成

引入扩散模型

在前面的 GAN 章节中,我们学习了如何从噪声生成图像;我们还学习了如何从条件输入(例如应生成的图像类别)生成图像。然而,在那种情况下,我们能够直接从随机噪声得到一个脸部图像。这是一个步骤变化。如果我们可以以更渐进的方式从随机噪声生成图像,例如,最初逐渐生成与图像对应的轮廓,然后逐渐从这些轮廓获取图像的更精细细节呢?此外,如果我们可以从文本输入生成图像呢?在这种情况下,扩散模型非常有用。

扩散模型模仿扩散过程的情景,指的是时间内数量(在本例中为图像中的像素值)的逐渐扩散或分散。

扩散模型的工作原理

想象一种情景,你有一组图像。在正向过程中,我们在增加的时间步骤中添加了一小部分噪声 - 即,在第一次迭代中,噪声量很低,但是在第 100 次迭代中,噪声量很高。

在反向过程中,我们以嘈杂图像(在第 100 个时间步长)作为输入,并预测图像中存在的噪声量。接下来,我们移除预测的噪声,添加较小量的噪声(以保持稳定性;更多细节请参见下一节和相关的 GitHub 存储库),并预测第 99 个时间步长中存在的噪声,并重复上述步骤,直到达到第一个时间步长。

扩散模型是一个典型的 U-Net 模型(配有注意力和 resnet 模块),其工作方式如下:

  1. 输入图像是带有根据时间步长(t)变化的噪声的嘈杂图像

  2. 将图像连同时间嵌入(对应于时间步骤)通过几个卷积和池化步骤传递,以获得编码器向量

  3. 将编码器向量通过上卷积传递,并像在 U-Net 架构的分割练习中那样,从输入添加跳过连接 第九章

  4. 预测输入图像中存在的噪声

  5. 从输入图像中减去预测的噪声

  6. 以略微添加回图像中噪声的方式从步骤 5 中获得的图像重新进行时间步骤 t-1 的重复

在上述过程中,有几个方面需要考虑:

  • 在每个时间步骤中添加的噪音量 - 噪音曲线

  • 在给定时间步骤后减去噪声后要添加回的噪声量

在我们的练习中,我们将遵循此图中显示的模式,其中 x 轴是时间步长,y 轴是对原始(输入)图像保留量:

一个带有数字的蓝线图 说明已自动生成

图 16.12:随时间增加向图像添加的噪声程度

最初,我们不会对输入图像进行很多修改。但随着时间步的增加,我们会向图像添加相当大的噪声。

与线性添加噪声相比,这种噪声添加模式更有帮助,因为这将帮助模型花费更多时间选择图像的轮廓和细节,而线性添加噪声将迫使模型从第一个时间步开始学习。

现在我们了解了扩散模型在高层次上的工作原理,让我们在下一节中了解扩散模型的架构细节。

扩散模型架构

要了解该架构,我们将构建一个扩散模型以生成 MNIST 数字。这需要输入形状为 28x28x1 的输入,并生成相同形状的输出。但首先,让我们详细查看架构。

扩散模型是一个带有以下层/块的 UNet 模型:

  • 卷积

  • 时间步嵌入

  • 注意力块

  • ResNet 块

  • 下采样

在 GitHub 上的unet_components_from_scratch.ipynb笔记本中提供了该架构的快速概述,网址为bit.ly/mcvp-2e

扩散 U-Net 的架构如下:

A diagram of a diagram  Description automatically generated

图 16.13:扩散 U-Net 架构摘要

让我们了解架构中存在的不同模块/块:

  • 卷积模块 – Conv_in – 接受原始图像并增加图像中存在的通道数。

  • time_embedding模块接收当前时间步,并将其转换为嵌入。

  • 一个down_block由以下模块组成:

    • ResNet:一个ResNet块包含以下模块:

      • 组归一化:训练生成高分辨率图像模型的一个限制是,在标准 GPU 上,一个批次中无法容纳许多图像。这导致批次归一化限制在较小的数量上,从而导致训练不太稳定。

      • 在这种情况下,组归一化非常方便,因为它在一个输入的通道上操作,而不是在一批输入上操作。本质上,组归一化对图像内的通道进行归一化(均值为零,方差为一)。这样,我们确保一组通道具有标准的数据分布,从而确保更稳定的训练。

      • 卷积:一旦我们执行了组归一化,我们将输出通过卷积层。

      • 时间嵌入投影:接下来,我们通过将时间嵌入投影到与组归一化输出一致的维度来添加时间嵌入。

      • 非线性:此模块中使用的非线性为Sigmoid Linear Unit (SiLU),计算如下:

    • Attention:然后将输出传递给attention块,执行以下操作:

      • 我们首先执行组归一化,并增加输入中存在的组数。

      • 接下来,我们执行注意力(就像我们在前一章中使用键、查询和值矩阵一样)。

      • 最后,我们通过一个线性层传递输出。

    • 降采样:在downsampling块中,我们接受一个输入并将维度减半(步长为 2)。

upsampling块中发生与上述步骤完全相反的过程。此外,在上采样过程中,还会添加来自输入的跳过连接。我们将在下一节更深入地介绍每一层 - 理解稳定扩散

现在我们理解了扩散模型的架构,让我们在下一节中继续构建一个基于 MNIST 数据集的扩散模型。

从头开始实现扩散模型

要实现扩散模型,我们将利用 MNIST 数据集并执行以下步骤:

下面的代码位于 GitHub 存储库中Chapter16文件夹中的Diffusion_PyTorch.ipynb文件中,网址为bit.ly/mcvp-2e。务必从笔记本中运行代码,并参考以下解释了解不同步骤。

  1. 安装所需库并加载库:

    %pip install -q diffusers torch-snippets
    from torch_snippets import *
    from diffusers import DDPMScheduler, UNet2DModel
    from torch.utils.data import Subset
    import torch
    import torch.nn as nn
    from torch.optim.lr_scheduler import CosineAnnealingLR
    device = 'cuda' # torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f'Using device: {device}') 
    
  2. 载入数据集:

    transform = torchvision.transforms.Compose([
                                           torchvision.transforms.Resize(32),
                                           torchvision.transforms.ToTensor()
                                           ])
    dataset = torchvision.datasets.MNIST(root="mnist/", train=True, \
                                  download=True, transform=transform) 
    
  3. 定义批量大小和train_dataloader

    batch_size = 128
    train_dataloader = DataLoader(dataset, 
                                  batch_size=batch_size, 
                                  shuffle=True) 
    
  4. 定义模型架构,我们将从 diffusers 库中利用UNet2DModel。这包括我们在前一节中提到的所有不同块的架构:

    net = UNet2DModel(
        sample_size=28,  # the target image resolution
        in_channels=1,  # the number of input channels, 3 for RGB images
        out_channels=1,  # the number of output channels
        layers_per_block=1,  # how many ResNet layers to use per UNet block
        block_out_channels=(32, 64, 128, 256),  # Roughly matching our basic unet example
        down_block_types=(
            "DownBlock2D", # a regular ResNet downsampling block
            "AttnDownBlock2D", # a ResNet downsampling block with spatial self-attention
            "AttnDownBlock2D",
            "AttnDownBlock2D",
        ),
        up_block_types=(
            "AttnUpBlock2D",
            "AttnUpBlock2D",
            "AttnUpBlock2D",  # a ResNet upsampling block with spatial self-attention
            "UpBlock2D",   # a regular ResNet upsampling block
          ),
    )
    _ = net.to(device) 
    
  5. 定义噪声调度程序:

    noise_scheduler = DDPMScheduler(num_train_timesteps=1000) 
    

DDPMSchedulerDDPM代表去噪扩散概率模型)是管理噪声添加和反转过程调度的组件。它确定噪声应该以什么速率和方式添加,然后再反转。调度程序通常控制诸如以下方面:

  • 扩散步骤的数量

  • 每一步添加的噪声的方差

  • 方差在反向过程中每步如何变化

  1. 定义一个函数,它接受一个输入图像以及相应的时间步,并破坏图像:

    def corrupt(xb, timesteps=None):
      if timesteps is None:
        timesteps = torch.randint(0, 999, (len(xb),)).long().to(device)
      noise = torch.randn_like(xb)
      noisy_xb = noise_scheduler.add_noise(xb, noise, timesteps)
      return noisy_xb, timesteps 
    
  2. 定义模型训练配置:

    n_epochs = 50
    report = Report(n_epochs)
    loss_fn = nn.MSELoss()
    opt = torch.optim.Adam(net.parameters(), lr=1e-3)
    scheduler = CosineAnnealingLR(opt, T_max=len(train_dataloader), \
                                                       verbose=False) 
    

在前述代码中,CosineAnnealingLR按照余弦退火调度调整学习率。这意味着学习率从初始高值逐渐减小到最小值,然后再次增加。这可能有助于避免局部最小值。

调度程序的参数是:

  • opt:调整学习率的优化器。

  • T_max:学习率将在此迭代数后重置。在您的代码中,它设置为train_dataloader的长度,这意味着学习率将在每个 epoch 后完成余弦周期。

  1. 训练模型:

    for epoch in range(n_epochs):
        n = len(train_dataloader)
        for bx, (x, y) in enumerate(train_dataloader):
            x = x.to(device)  # Data on the GPU
            noisy_x, timesteps = corrupt(x)  # Create our noisy x
            pred = net(noisy_x, timesteps).sample
            loss = loss_fn(pred, x)  # How close is the output to the true 'clean' x?
            opt.zero_grad()
            loss.backward()
            opt.step()
            scheduler.step()
            report.record(epoch + ((bx + 1) / n), loss=loss.item(), end='\r')
        report.report_avgs(epoch + 1) 
    

损失值的绘图如下:

带有蓝线的图形 自动生成描述

图 16.14:随着 epoch 增加的损失值

  1. 让我们绘制在此过程中生成的几个数据点:

    net.cpu()
    noise = torch.randn(5,1,32,32).to(net.device)
    progress = [noise[:,0]]
    for ts in np.logspace(np.log10(999), 0.1, 100):
      ts = torch.Tensor([ts]).long().to(net.device)
      noise = net(noise, ts).sample.detach().cpu()
      noise, _ = corrupt(noise, ts)
      progress.append(noise[:,0])
    print(len(progress))
    _n = 10
    subplots(torch.stack(progress[::_n]).permute(1, 0, 2, 3).reshape(-1, 32, \
                                                       32), nc=11, sz=(10,4)) 
    

上述代码的结果是:

自动生成的描述中带有方块数字的一组数字

图 16.15:随时间步增加生成的图像

从上述内容可以看出,我们能够从随机噪声生成 MNIST 数字。

然而,尽管当前模型能够生成图像,但我们无法指定我们感兴趣的标签。在下一节中,我们将学习如何添加额外的上下文(如文本提示)以有条件地生成图像。

条件图像生成

为了用条件输入训练扩散模型,我们修改以下内容:

  1. 扩展 UNet,以便接受额外的输入通道。这样,提示将附加到输入的原始通道上。

  2. 将标签通过嵌入层传递,以便将其转换为嵌入。

  3. 修改图像损坏函数,将标签的嵌入与输入图像串联起来。

一旦完成上述更改,其余的训练代码保持不变。让我们继续编写条件图像生成的代码:

下面的代码位于 GitHub 存储库中的Chapter16文件夹中的Conditional_Diffuser_training.ipynb文件中,链接为bit.ly/mcvp-2e。确保从笔记本中运行代码,并参考以下解释以理解各个步骤。

  1. 步骤 1-3 与上一节相同。

  2. 接下来,定义嵌入层:

    class EmbeddingLayer(nn.Module):
        def __init__(self, num_embeddings, embedding_dim):
            super().__init__()
            self.embedding = nn.Embedding(num_embeddings, 
                                           embedding_dim)
        def forward(self, labels):
            return self.embedding(labels)
    embedding_layer = EmbeddingLayer(num_embeddings=10, embedding_dim=32).to(device) 
    

在上述代码中,我们创建了一个嵌入层,该层接受 10 个可能的标签之一,并将其转换为维度为 32 的嵌入。

  1. 扩展 UNet 类,使其接受 32 个额外的输入通道:

    class ConditionalUNet2DModel(UNet2DModel):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.in_channels += 32  # Adjust for embedding dimension 
    
  2. 实例化 UNet2D 模型对象:

    net = ConditionalUNet2DModel(
        sample_size=28,
        in_channels=1 + 32,  # 1 for original channel, 32 for embedding
        out_channels=1,
        layers_per_block=1,
        block_out_channels=(32, 64, 128, 256),
        down_block_types=("DownBlock2D", "AttnDownBlock2D", 
                        "AttnDownBlock2D", "AttnDownBlock2D"),
        up_block_types=("AttnUpBlock2D", "AttnUpBlock2D", 
                    "AttnUpBlock2D", "UpBlock2D"),).to(device) 
    
  3. 定义图像损坏函数,其中图像损坏方式类似于我们在“扩散模型如何工作”部分看到的方式:

    def corrupt_with_embedded_labels(xb, labels,timesteps=None):
        if timesteps is None:
            timesteps = torch.randint(0, 999, 
                                  (len(xb),)).long().to(device)
        noise = torch.randn_like(xb)
        noisy_xb = noise_scheduler.add_noise(xb, noise, 
                                                     timesteps)
        labels_embedded=embedding_layer(labels).unsqueeze(1).unsqueeze(-1)
        labels_embedded = labels_embedded.expand(-1, -1, 
                                      xb.shape[2], xb.shape[3])
        return torch.cat([noisy_xb, labels_embedded], dim=1), timesteps 
    

在上述代码中,我们通过embedding_layer传递标签来获取它们对应的嵌入。最后,我们将噪声图像和标签嵌入串联起来。

  1. 像在第 8 步中一样训练模型。

  2. 执行推断。为此,我们将 10 个图像初始化为零像素值,并指定时间戳为 999:

    xb = torch.zeros(10, 1, 32, 32)
    timesteps = torch.randint(999, 1000, (len(xb),)).long().to(device) 
    
  3. 接下来,使用noise_scheduler向初始化图像添加噪声:

    noise = torch.randn_like(xb)
    noisy_xb = noise_scheduler.add_noise(xb, noise, timesteps).to(device) 
    
  4. 定义我们要生成的标签(我们希望生成与每个标签对应的图像):

    labels = torch.Tensor([0,1,2,3,4,5,6,7,8,9]).long().to(device) 
    
  5. 获取与标签对应的嵌入:

    labels_embedded=embedding_layer(labels).unsqueeze(-1).unsqueeze(-1)
    labels_embedded = labels_embedded.expand(-1, -1, 
                            xb.shape[2], xb.shape[3]).to(device) 
    
  6. 将噪声图像和标签嵌入串联起来:

    noisy_x = torch.cat([noisy_xb, labels_embedded], dim=1) 
    
  7. 使用训练模型进行预测:

    pred = net(noisy_x, timesteps).sample.permute(0,2,3,1).reshape(-1, 32, 32) 
    
  8. 可视化生成的图像:

    subplots(pred.detach().cpu().numpy()) 
    

自动生成的描述中带有黑色方块的数字

图 16.16:生成的图像

从上述输出中,我们看到可以通过指定它们的标签有条件地生成图像。

现在我们已经学会了如何从头开始使用扩散模型生成图像,接下来将学习如何利用稳定扩散根据文本提示生成图像。

理解稳定扩散

到目前为止,我们已经了解了扩散模型的工作原理。Stable Diffusion 通过首先利用 VAE 将图像编码到较低维度,然后在缩小的/潜在空间上进行训练来改进 UNet2D 模型。一旦模型训练完成,我们使用 VAE 解码器获得高分辨率图像。这样,训练速度更快,因为模型从潜在空间而不是像素值中学习特征。

Stable Diffusion 的架构如下:

一个图表的描述 由系统自动生成

图 16.17:Stable Diffusion 概述

VAE 编码器是标准的自动编码器,接受形状为 768x768 的输入图像并返回 96x96 图像。VAE 解码器接受 96x96 图像并将其放大为 768x768。

预训练的 Stable Diffusion U-Net 模型架构如下:

一个图表的描述 由系统自动生成

图 16.18:预训练的 Stable Diffusion U-Net 模型架构

在上图中,噪声输入表示从 VAE 编码器获得的输出。文本提示表示文本的 CLIP 嵌入。

Stable Diffusion 模型的构建模块

UNet2D 架构对 Stable Diffusion 的功能至关重要,深入理解它对掌握 Stable Diffusion 领域至关重要。由于其复杂性,特征在多个层之间的连接和数据流动,全面理解每个关键层是至关重要的。为了实现这一目标,我们将通过三个关键组成部分来详细检查每一层:

  1. 层的输入:该层输入的数据或张量是什么?

  2. 输入的转换:该层如何处理或转换这些输入?

  3. 最终输出:转换后的结果是什么?通过深入探讨这些方面,我们旨在为您展示张量如何在模型中流动,从而使您能够全面掌握 Stable Diffusion。

让我们通过下载预训练的 Stable Diffusion 模型并调用其摘要来深入理解 UNet2D 模型的内部机制,如下所示:

from diffusers import StableDiffusionPipeline
from torchinfo import summary
model_id = "stabilityai/stable-diffusion-2"
pipe = StableDiffusionPipeline.from_pretrained(model_id)
summary(pipe.unet, depth=4) 

我们得到以下内容:

一个计算机屏幕的描述 由系统自动生成

图 16.19:UNet2D 模型架构

让我们尝试揭示管道中的每个部分,以深入理解这种成功网络背后的理论。我们有以下高级块:

  • CrossAttnDownBlock2D

  • CrossAttnUpBlock2D

  • UNetMidBlock2DCrossAttn

  • DownBlock2D

  • UpBlock2D

让我们详细查看每个块。

CrossAttnDownBlock2D

运行 pipe.unet.down_blocks 将给我们四个下采样模块(即 UNet 中 图 16.18 的左部分)。前三个是 CrossAttnDownBlock2D,最后一个是 DownBlock2D。检查任意 CrossAttnDownBlock2D 的摘要,我们得到:

Summary(pipe.unet.down_blocks[0], depth=2) 

上述代码的结果如下:

一个计算机屏幕的描述 由系统自动生成

图 16.20:UNet 第一个下采样块的总结

如您在 图 16.20 中所见,模块由 3 种类型的块组成 – Transformer2DModelResnetBlock2DDownsample2D

检查 CrossAttnDownBlock2D 类在 github.com/huggingface/diffusers GitHub 仓库中的 forward 方法,在 diffusers/models/unet_2d_blocks.py 中,我们看到模型使用三个输入:

计算机屏幕上显示着数字和符号 自动生成的描述

图 16.21:CrossAttnDownBlock2D 的输入

如前面的截图所示,这些输入是:

  • hidden_states,即对应于嘈杂输入 x 的嵌入

  • temb,对应于 timestep 的嵌入

  • encoder_hidden_states,对应于输入文本提示的嵌入

在高层次上,CrossAttnDownBlock2D 块内计算的(简化的)顺序在 图 16.22 中提供。

程序的计算机屏幕截图 自动生成的描述

图 16.22:CrossAttnDownBlock2D 的前向方法

图 16.20 的架构图中,请注意 CrossAttnDownBlock2D 在执行 DownBlock2D 之前,将 ResNet 和 Transformer2D 块重复两次,这在 图 16.22 中体现为一个 for 循环。

理解流程的关键点是 resnet 块使用 temb,而 attn 模块使用 encoder_hidden_states(与提示相关)。

最终,CrossAttnDownBlock2D 获取输入的隐藏状态,并按以下顺序通过其内部块处理,同时将必要的 tembencoder_hidden_states 张量作为 resnetattn 的输入:

resnet->attn->resnet->attn->downsampler

最后,返回隐藏状态和中间隐藏状态:

计算机屏幕上显示着数字和符号 自动生成的描述

图 16.23:CrossAttnDownBlock2D 的输出

请注意,我们获得 320 个通道,因为 DownBlock2D 输出 320 个通道。hidden_states 用作下一个块的输入,而输出状态用于跳跃连接。这一步在增加通道数后重复两次,然后被馈送到中间块。

UNetMidBlock2DcrossAttn

这个块是 UNet 的瓶颈,作为尽可能提取重要信息的关键点。

它接收以下输入:

计算机代码屏幕上显示着数字和符号 自动生成的描述

图 16.24:UNetMidBlock2DcrossAttn 的输入

这还使用了以下(简化的)前向方法:

程序代码的计算机屏幕截图 自动生成的描述

图 16.25:UNetMidBlock2DcrossAttn 的前向方法(简化版)

请注意,此模块有一个额外的ResNet。输出如下:

一个带有文本的黑色矩形物体 自动产生的描述

图 16.26:UNetMidBlock2DcrossAttn 的输出

此输出被作为hidden_states传递给下一个CrossAttnUpBlock2D模块。

CrossAttnUpBlock2D

此模块的摘要类似于CrossAttnDownBlock2D的摘要。有人可能认为这里唯一的区别在于名称——只是在任何Down出现的地方换成Up。但需要考虑的重要事情是这些块还接受来自相应级别CrossAttnDown2D的输出状态(如下屏幕截图中的res_hidden_states_tuple):

一个带有数字和符号的计算机屏幕截图 自动产生的描述

图 16.27:CrossAttnUpBlock2D

简化的前向方法如下所示:

一个程序的计算机屏幕截图 自动产生的描述

图 16.28:CrossAttnUpBlock2D 的前向方法(简化)

名为res_hidden_states_tuple的额外输入是三个张量的集合(每个down块有三个output_states)。每个张量与hidden_states串联并传递给resnet

输出是一个隐藏状态的单个张量:

一个带有绿色和紫色文本的黑色矩形 自动产生的描述

图 16.29:CrossAttnUpBlock2D 的输出

请注意,我们获得的输出形状为 96x96,这是输入图像的形状。

DownBlock2D,UpBlock2D

这两个模块仅包含 Resnet2D 块,没有注意力模块。读者被鼓励浏览github.com/huggingface/diffusers并查找DownBlock2DUpBlock2D类定义,以找到这些块的输入、输出和前向方法。

Transformer2DModel

这是一个标准的基于自注意力的 Transformer 编码器模块,在输入阶段有一个额外的责任,即将 2D 输入转换为 1D,并在末尾再次转换为 2D 输出。

这里是输入:

一个带有数字和符号的计算机屏幕截图 自动产生的描述

图 16.30:Transformer2DModel 的输入

以下是模块的(简化)forward方法:

一个程序代码的计算机屏幕截图 自动产生的描述

图 16.31:Transformer2DModel 的前向方法(简化)

首先将输入存储为残差,然后通过一个卷积层(在上述代码中的self.proj中)投影hidden_states张量,结果形状为 2x786x96x96。高度和宽度维度在形状[bs, hxw, channels]中重新排列。一旦通过变换器块传递,输入和输出的形状相同。这一步的输出被重塑和排列为(bs x h x w x channels),然后通过卷积层传递以获得原始维度:

一张带有文字和数字的黑色矩形  自动生成的描述

图 16.32:Transformer2DModel 的输出

ResnetBlock2D

这个块与标准的 resnet 没有什么不同,除了接受一个新的temb变量。以下是输入:

一张带有数字和符号的计算机屏幕  自动生成的描述

图 16.33:ResnetBlock2D 的输入

这是forward方法:

一张程序代码的电脑屏幕截图  自动生成的描述

图 16.34:ResnetBlock2D 的前向方法

在这里,self.time_emb_proj是一个线性层,确保temb的通道维度与hidden_states相同。

输出与hidden_states的形状相同:

一张带有文字和数字的黑色矩形  自动生成的描述

图 16.35:ResnetBlock2D 的输出

现在,我们取得了 320x96x96 维度的输出,并通过 VAE 解码器获取输出图像。

现在我们理解了 Stable Diffusion 模型的工作细节,让我们继续利用它来接受文本提示并将其转换为图像。

实施稳定扩散

为了根据文本提示生成图像,我们将利用 Hugging Face 团队构建的 diffusers 库。我们将使用以下代码来实现:

此代码可在 GitHub 存储库的Chapter16文件夹中的Conditional_Diffuser_training.ipynb文件中找到,链接为bit.ly/mcvp-2e。请确保从笔记本中运行代码,并参考以下说明以理解不同步骤。

  1. 登录到 Hugging Face 并提供 auth 令牌:

    !huggingface-cli login 
    
  2. 安装所需的包:

    !pip install -q accelerate diffusers 
    
  3. 导入所需的库:

    from torch import autocast
    from diffusers import DiffusionPipeline 
    
  4. 定义生成器并设置种子以确保可重复性:

    # Setting a seed would ensure reproducibility of the experiment.
    generator = torch.Generator(device="cuda").manual_seed(42) 
    
  5. 正如我们在之前的章节中所看到的,huggingface(及其扩展的 diffusers)将模型包装成易于用户使用的管道形式。让我们定义一下 Hugging Face 的管道:

    # Define the Stable Diffusion pipeline
    pipeline = DiffusionPipeline.from_pretrained(
                                             "CompVis/stable-diffusion-v1-4",
                                             torch_dtype=torch.float16,
                                             )
    # Set the device for the pipeline
    pipeline = pipeline.to("cuda") 
    
  6. 通过上述定义的管道传递文本提示:

    prompt = "baby in superman dress"
    image = pipeline(prompt, generator=generator) 
    
  7. 可视化图像:

    image.images[0] 
    

这给出了以下输出:

一群穿着超级英雄服装的婴儿  自动生成的描述

图 16.36:由 Stable Diffusion 生成的图像

请注意,上述生成的图像存在多个问题——生成了多个婴儿,腿部似乎不对齐等。我们能做得更好吗?

  1. 稳定扩散有一个 XL 变体,它在更多数据上进行了训练,生成了分辨率更高的图像(1024x1024),因此出错的几率更低。让我们用其 XL 版本替换基础模型,如下所示:

    # Define the Stable Diffusion XL pipeline
    pipeline = DiffusionPipeline.from_pretrained(
                                  "stabilityai/stable-diffusion-xl-base-1.0",
                                  torch_dtype=torch.float16,
                                  )
    # Set the device for the pipeline
    pipeline = pipeline.to("cuda") 
    
  2. 通过刚刚定义的管道传递提示:

    prompt = "baby in superman dress"
    image = pipe(prompt, generator=generator) 
    

输出是:

一名穿着服装的婴儿  自动生成的描述

图 16.37:SDXL 的输出

请注意,使用 XL 管道生成的图像比使用基础管道生成的图像要好得多。但是,我们能否做得更好呢?

  1. 在这种情况下,提示工程可以拯救我们。我们可以按以下方式修改我们的提示:

    prompt = "baby in superman dress, photorealistic, cinematic" 
    

前述提示在经过 XL 管道时,生成的输出如下:

一名穿着服装的婴儿  自动生成的描述

图 16.38:SDXL 的逼真输出

请注意,通过添加“逼真”和“电影般”等词语,我们能够生成比之前生成的普通图像更具戏剧性的图像。

总结

在这一章中,我们学习了 CLIP 如何帮助对齐文本和图像的嵌入。然后,我们了解了如何利用 SAM 对任何图像进行分割。接下来,我们了解了如何使用 FastSAM 加速 SAM。最后,我们学习了如何利用扩散模型生成无条件和有条件给定提示的图像。

我们讨论了向 segment-anything 模型发送不同模态的提示,使用 SAM 跟踪对象,以及在相关的 GitHub 存储库中使用 ImageBind 结合多个模态。

有了这些知识,你可以在非常有限或无需训练数据点的情况下,利用基础模型处理你的数据/任务,比如在第 7 至 9 章学到的分割/目标检测任务中训练/利用模型。

在下一章中,你将学习如何进一步调整扩散模型,以生成你感兴趣的图像。

问题

  1. CLIP 如何将文本和图像表示为同一域中的内容?

  2. 如何在 Segment Anything 架构中计算不同类型的令牌,如点令牌、边界框令牌和文本令牌?

  3. 扩散模型是如何工作的?

  4. 稳定扩散与普通扩散有何不同?

  5. 稳定扩散和 SDXL 模型之间有什么区别?

在 Discord 上了解更多

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

posted @ 2024-07-23 14:53  绝不原创的飞龙  阅读(11)  评论(0编辑  收藏  举报