PyTorch-现代计算机视觉-全-

PyTorch 现代计算机视觉(全)

零、前言

人工智能 ( AI )就在这里,已经成为一股强大的力量,正在推动一些日常使用的现代应用。就像火、轮子、石油、电和电子的发现/发明一样,人工智能正在以我们只能幻想的方式重塑我们的世界。人工智能在历史上一直是一个利基计算机科学学科,由少数实验室提供。但由于优秀理论的爆炸、计算能力的提高和数据的可用性,该领域自 2000 年代以来开始呈指数增长,并且没有任何迹象表明会很快放缓。
人工智能一次又一次地证明,只要有正确的算法和足够多的数据,它可以在有限的人工干预下自行学习任务,并产生与人类判断相媲美的结果,有时甚至超过人类判断。无论你是一个正在学习诀窍的菜鸟,还是一个驱动大型组织的老手,都有充分的理由了解人工智能是如何工作的。神经网络是人工智能算法中最灵活的一类,已经适应了包括结构化数据、文本和视觉领域在内的广泛应用。

这本书从神经网络的基础开始,涵盖了计算机视觉的 50 多个应用。首先,您将使用 NumPy、PyTorch 从头开始构建一个神经网络 ( NN ),然后学习调整 NN 的超参数的最佳实践。随着我们的进展,你将学习 CNN,重点是图像分类的迁移学习。您还将了解构建神经网络模型时需要注意的实际问题。

接下来,您将了解多目标检测、分割,并使用 R-CNN 系列、SSD、YOLO、U-Net、Mask-RCNN 架构实现它们。然后,您将学习使用 Detectron2 框架来简化构建用于目标检测和人体姿态估计的神经网络的过程。最后,您将实现三维目标检测。

随后,您将学习自编码器和 GANs,重点是图像处理和生成。在这里,您将实现 VAE,DCGAN,CGAN,Pix2Pix,CycleGan,StyleGAN2,SRGAN,Style-Transfer 来处理各种任务的图像。

然后,您将学习在使用变形器执行 OCR、图像字幕、目标检测时结合 NLP 和 CV 技术。接下来,您将学习结合 RL 和 CV 技术来实现一个自动驾驶汽车代理。最后,您将完成将一个神经网络模型移植到产品中,并使用 OpenCV 库学习传统的 CV 技术。

这本书是给谁的

这本书是为 PyTorch 新手和中级机器学习从业者编写的,他们希望通过使用深度学习和 PyTorch 精通 CV 技术。那些刚刚开始使用 NNs 的人也会发现这本书很有用。Python 编程语言和机器学习的基础知识是你开始阅读这本书所需要的。

这本书涵盖的内容

第一章、人工神经网络基础,给你一个神经网络如何工作的完整细节。您将从学习与神经网络相关的关键术语开始。接下来,您将了解构建模块的工作细节,并在玩具数据集上从头开始构建神经网络。到本章结束时,你将对神经网络的工作方式有信心。

第二章, PyTorch 基础,向您介绍如何使用 PyTorch。在了解使用 PyTorch 构建神经网络模型的不同方法之前,您将了解创建和操作张量对象的方法。您仍将使用玩具数据集,以便理解使用 PyTorch 的细节。

第三章,用 PyTorch 构建深度神经网络,结合了前面章节已经涉及的所有内容,了解各种神经网络超参数对模型精度的影响。在本章结束时,你将对在真实数据集上使用神经网络充满信心。

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

第五章、图像分类的迁移学习,让你接触到解决现实世界中的图像分类问题。您将了解多迁移学习架构,并了解它如何帮助显著提高图像分类的准确性。接下来,您将利用迁移学习来实现面部关键点检测和年龄、性别估计的用例。

第六章、影像分类的实际方面,提供了在构建和部署影像分类模型时需要注意的实际方面的见解。您将实际看到在真实世界数据上利用数据扩充和批量规范化的优势。此外,您将了解到类激活图如何帮助解释 CNN 模型预测某种结果的原因。本章结束时,您可以自信地解决大多数影像分类问题,并在您的自定义数据集上利用前 3 章中讨论的模型。

第七章,目标检测的基础知识为目标检测打下基础,在这里你将学习用于建立目标检测模型的各种技术。接下来,您将通过一个用例了解基于区域提议的目标检测技术,在这个用例中,您将实现一个模型来定位图像中的卡车和公共汽车。

第八章、高级目标检测,向您展示基于区域提议的架构的局限性。然后,您将了解更高级的架构的工作细节,这些架构解决了基于区域提议的架构的问题。您将在同一个数据集上实现所有架构(卡车与公共汽车检测),这样您就可以对比每个架构的工作原理。

第九章、图像分割,建立在前面章节的学习基础上,将帮助您建立模型,精确定位图像中各类物体以及物体实例的位置。您将在道路图像和普通家庭图像上实现用例。在本章结束时,你将自信地处理任何图像分类、目标检测/分割问题,并通过使用 PyTorch 建立模型来解决它。

第十章、目标检测和分割的应用,总结了前面所有章节的学习内容,您将在几行代码中实现目标检测、分割,实现模型以执行人群计数和图像着色。最后,您还将了解如何在真实数据集上进行 3D 目标检测。

第十一章,自编码器和图像处理,,为修改图像奠定基础。首先,您将了解各种有助于压缩图像和生成新颖图像的自编码器。接下来,您将了解在实现神经类型转移之前欺骗模型的对抗性攻击。最后,您将实现一个自编码器来生成深度假图像。

第十二章,使用 GANs 生成图像,首先让你深入了解 GANs 的工作原理。接下来,您将实现假面部图像生成,以及使用 GANs 生成感兴趣的图像。

第十三章、高级 GANs 操控图像,将图像操控提升到一个新的高度。您将实现 GANs 来将对象从一个类转换到另一个类,从草图生成图像,并操作自定义图像,以便我们可以生成特定样式的图像。在本章结束时,你可以自信地使用自编码器和 GANs 的组合来执行图像操作。

第十四章,用最少的数据点进行训练,为你学习利用其他技术与计算机视觉技术相结合奠定基础。您还将了解如何根据最小训练数据点和零训练数据点分类图像。

第十五章,结合计算机视觉和自然语言处理技术,为您提供各种自然语言处理技术的工作细节,如文字嵌入、LSTM、变形器,您将使用这些技术实现图像字幕、OCR 和变形器目标检测等应用。

第十六章,结合计算机视觉和强化学习,首先让你接触到强化学习的术语,以及给一个状态赋值的方法。当你了解深度 Q 学习时,你会明白 RL 和神经网络是如何结合在一起的。通过学习,您将实现一个代理来玩 Pong 游戏,还将实现一个代理来实现一辆自动驾驶汽车。

第十七章,将模型投入生产,描述了将模型投入生产的最佳实践。在将模型转移到 AWS 公共云之前,您将首先了解如何在本地服务器上部署模型。

第十八章,使用 OpenCV 实用程序进行图像分析,详细介绍了创建 5 个有趣应用程序的各种 OpenCV 实用程序。通过本章,您将了解有助于深度学习的实用程序,以及在内存或推理速度受到相当大限制的情况下可以替代深度学习的实用程序。

从这本书中获得最大收益

| 书中涵盖的软件/硬件 | 操作系统要求 |
| 最低 128 GB 存储
最低 8 GB 内存
英特尔 i5 处理器或更好的处理器
英伟达 8+ GB 显卡–gtx 1070 或更好的显卡
最低 50 Mbps 互联网速度 | Windows、Linux 和 macOS |
| Python 3.6 及以上版本 | Windows、Linux 和 macOS |
| PyTorch 1.7 | Windows、Linux 和 macOS |
| Google Colab(可以在任何浏览器中运行) | Windows、Linux 和 macOS |

请注意,书中几乎所有的代码都可以使用 Google Colab 运行,只需在 GitHub 的每个章节笔记本上点击打开 Colab 按钮即可。

如果你使用的是这本书的数字版本,我们建议你自己输入代码或者通过 GitHub 库获取代码(链接见下一节)。这样做将帮助您避免任何与复制和粘贴代码相关的潜在错误。

下载示例代码文件

你可以从 GitHub 的 https://GitHub . com/packt publishing/Modern-Computer-Vision-with-py torch 下载本书的示例代码文件。如果代码有更新,它将在现有的 GitHub 库中更新。

我们在也有丰富的书籍和视频目录中的其他代码包。看看他们!

下载彩色图像

我们还提供了一个 PDF 文件,其中有本书中使用的截图/图表的彩色图像。可以在这里下载:static . packt-cdn . com/downloads/9781839213472 _ color images . pdf

使用的惯例

本书通篇使用了许多文本约定。

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

代码块设置如下:

# 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()

Bold :表示一个新术语、一个重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词出现在文本中,如下所示。下面是一个例子:“我们将在一个
时间使用一个批次应用梯度下降(在前馈通过之后),直到我们在一个训练时期内用尽所有数据点为止。”

警告或重要提示如下所示。

提示和技巧是这样出现的。

取得联系

我们随时欢迎读者的反馈。

总体反馈:如果您对这本书的任何方面有疑问,请在邮件主题中提及书名,并在customercare@packtpub.com发送电子邮件给我们。

勘误表:虽然我们已经尽力确保内容的准确性,但错误还是会发生。如果你在这本书里发现了一个错误,请告诉我们,我们将不胜感激。请访问 www.packtpub.com/support/errata,选择您的图书,点击勘误表提交表格链接,并输入详细信息。

盗版:如果您在互联网上遇到我们作品的任何形式的非法拷贝,如果您能提供我们的地址或网站名称,我们将不胜感激。请通过copyright@packt.com联系我们,并提供材料链接。

如果你有兴趣成为一名作家:如果有你擅长的主题,并且你有兴趣写书或投稿,请访问 authors.packtpub.com。

复习

请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?潜在的读者可以看到并使用您不带偏见的意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们的书的反馈。谢谢大家!

更多关于 Packt 的信息,请访问packt.com

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

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

本节包括以下章节:

  • 第一章,人工神经网络基础
  • 第二章、 PyTorch 基本面
  • 第三章,用 PyTorch 构建深度神经网络

一、人工神经网络基础

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

有几种标准的人工神经网络结构。通用近似定理说,我们总是可以找到一个足够大的神经网络架构,它具有正确的权重集,可以准确地预测任何给定输入的任何输出。这意味着,对于给定的数据集/任务,我们可以创建一个架构,并不断调整其权重,直到人工神经网络预测出我们希望它预测的内容。调整权重直到发生这种情况称为训练神经网络。在大型数据集和定制架构上的成功训练是人工神经网络在解决各种相关任务中获得突出地位的原因。

计算机视觉中的一个突出任务是识别图像中存在的对象的类别。ImageNet 是一项旨在识别图像中存在的对象类别的竞赛。历年来分类错误率的降低情况如下:

2012 年,一个神经网络(AlexNet)被用于竞赛的获胜解决方案。从上图中可以看出,通过利用神经网络,从 2011 年到 2012 年,错误有了相当大的减少。从那时起,随着时间的推移,随着更深更复杂的神经网络的出现,分类错误不断减少,并击败了人类水平的表现。这给了我们一个坚实的动力去学习和实现神经网络来完成我们的定制任务,只要适用。

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

  • 人工智能和传统机器学习的比较
  • 了解人工神经网络构建块
  • 实现前馈传播
  • 实现反向传播
  • 将前馈传播和反向传播放在一起
  • 了解学习速度的影响
  • 总结神经网络的训练过程

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

传统上,系统是通过使用程序员编写的复杂算法来实现智能化的。

例如,假设您对识别照片中是否包含狗感兴趣。在传统的机器学习 ( ML )设置中,ML 从业者或主题专家首先识别需要从图像中提取的特征。然后,他们提取这些特征,并通过一个精心编写的算法来解读给定的特征,以判断图像是否是一只狗。下图说明了同样的想法:

取以下样本:

根据前面的图像,一个简单的规则可能是,如果一个图像包含三个排成三角形的黑色圆圈,它可以被归类为一只狗。然而,这条规则对这个欺骗性的松饼特写无效:

当然,这个规则也不适用于除了狗的脸部特写以外的任何图像。因此,自然地,我们需要为多种类型的精确分类创建的手动规则的数量可能是指数级的,尤其是当图像变得更加复杂时。因此,传统方法在非常受限的环境中工作得很好(比如,拍摄护照照片,其中所有维度都被限制在毫米以内),而在无约束的环境中工作得很差,在无约束的环境中,每个图像变化很大。

我们可以将同样的思路扩展到任何领域,比如文本或结构化数据。在过去,如果有人对编程解决现实世界的任务感兴趣,他们就有必要了解关于输入数据的一切,并编写尽可能多的规则来覆盖每个场景。这是乏味的,并且不能保证所有新的场景都遵循所述规则。

然而,通过利用人工神经网络,我们可以一步完成。

神经网络提供了将特征提取(手动调整)相结合的独特优势,并在单次操作中使用这些特征进行分类/回归,几乎不需要手动特征工程。这两个子任务都只需要标签化的数据(比如哪些图片是狗,哪些图片不是狗)和神经网络架构。它不需要人类想出规则来分类图像,这消除了传统技术强加给程序员的大部分负担。

请注意,主要要求是我们为需要解决方案的任务提供大量的示例。例如,在前面的例子中,我们需要向模型提供很多很多的非狗的图片,以便它学习特征。如何利用神经网络完成分类任务的高级视图如下:

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

了解人工神经网络构建模块

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

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

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

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

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

在上图中,x[1T5, x [2] ,...、 x [n] 为输入变量, w [0] 为偏差项(类似于我们在线性/逻辑回归中有偏差的方式)。]

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

如您所见,它是权重和输入对的乘积之和,后跟一个附加函数 f (偏差项+乘积之和)。函数 f 是激活函数,用于在乘积的总和上应用非线性。关于激活函数的更多细节将在下一节前馈传播中提供。此外,通过具有一个以上的隐藏层,堆叠大量的神经元,可以实现更高的非线性。

在高层次上,神经网络是节点的集合,其中每个节点都具有可调整的浮点值,并且这些节点以图形的形式互连,以网络架构所规定的格式返回输出。网络由三个主要部分组成:输入层、隐藏层和输出层。请注意,您可以拥有更高数量(n)的个隐藏层,术语深度学习指的是更大数量的隐藏层。通常,当神经网络必须理解一些复杂的东西(如图像识别)时,需要更多的隐藏层。

了解了神经网络的架构后,在下一节中,我们将了解前馈传播,它有助于估计网络架构的误差(损失)量。

实现前馈传播

为了建立对前馈传播如何工作的强有力的基础理解,我们将通过一个训练神经网络的玩具示例,其中神经网络的输入是(1,1)并且对应的(预期的)输出是 0。这里,我们将基于这一单个输入-输出对找到神经网络的最佳权重。但是,您应该注意到,在现实中,将会有成千上万的数据点用于训练人工神经网络。

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

上图中的每个箭头恰好包含一个可调整的浮点值( weight )。我们需要找到 9 个(第一个隐层 6 个,第二个隐层 3 个)浮点,这样当输入为(1,1)时,输出尽可能接近(0)。这就是我们所说的训练神经网络。为了简单起见,我们还没有引入偏差值——底层逻辑保持不变。

在随后的章节中,我们将了解前面网络的以下内容:

  • 计算隐藏层值
  • 执行非线性激活
  • 估计输出图层值
  • 计算对应于期望值的损失值

计算隐藏层单元值

我们现在将为所有连接分配权重。第一步,我们在所有连接中随机分配权重。通常,神经网络在训练开始前用随机权重初始化。同样,为了简单起见,在介绍主题时,我们将而不是在学习前馈传播和反向传播时包括偏差值。但是我们将从头开始实现前馈传播和反向传播。

让我们从在 0 和 1 之间随机初始化的初始权重开始,但是注意,神经网络的训练过程之后的最终权重不需要在一组特定的值之间。下图(左半部分)提供了网络中权重和值的正式表示,右半部分提供了网络中随机初始化的权重。

在下一步中,我们将输入与权重相乘,以计算隐藏层中隐藏单元的值。

激活前隐藏层的单位值如下获得:

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

现在,我们将通过非线性激活传递隐藏层值。请注意,如果我们不在隐藏层中应用非线性激活函数,则无论存在多少隐藏层,神经网络都将成为从输入到输出的巨大线性连接。

应用激活功能

激活函数有助于对输入和输出之间的复杂关系进行建模。

一些常用的激活函数计算如下(其中 x 为输入):

各种输入值的每个先前激活的可视化如下:

对于我们的示例,让我们使用 sigmoid(逻辑)函数进行激活。

通过将 sigmoid(逻辑)激活 S(x) 应用于三个隐藏层,我们在 sigmoid 激活后得到以下值:

现在我们已经获得了激活后的隐藏层值,在下一节中,我们将获得输出层值。

计算输出层值

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

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

因为我们从一组随机的权重开始,所以输出节点的值与目标非常不同。这种情况下,差的是 1.235 (记住,目标是 0)。在下一节中,我们将学习如何计算网络当前状态下的损耗值。

计算损失值

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

  • 分类变量预测
  • 连续变量预测

连续变量预测时计算损失

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

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

关键是,对于每一组独特的权重,神经网络会预测不同的损失,我们需要找到损失为零的黄金权重组(或者,在现实情况下,尽可能接近零)。

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

现在我们已经了解了如何计算连续变量的损失值,在下一节中,我们将了解如何计算分类变量的损失值。

在分类变量预测期间计算损失

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

二进制交叉熵的计算如下:

y 是输出的实际值, p 是输出的预测值, m 是数据点的总数。

分类交叉熵的计算如下:

y 为输出的实际值, p 为输出的预测值, m 为数据点总数, C 为总类数。

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

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

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

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

代码中的前馈传播

编码前馈传播的高级策略如下:

  1. 在每个神经元上执行和积。
  2. 计算激活。
  3. 在每个神经元上重复前两步,直到输出层。
  4. 通过比较预测值和实际输出值来计算损耗。

这将是一个函数,它将输入数据、当前神经网络权重和输出数据作为函数的输入,并返回当前网络状态的丢失。

计算所有数据点的均方误差损失值的前馈函数如下:

The following code is available as Feed_forward_propagation.ipynb in the Chapter01 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

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

一旦你点击 Colab 中的打开(在前面的截图中突出显示),你将能够毫不费力地执行所有代码,并且应该能够复制本书中显示的结果。

有了执行代码的方法,让我们继续编写前馈传播代码:

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

为了让这个练习更真实一点,我们将偏差与每个节点关联起来。因此,权重数组将不仅包含连接不同节点的权重,还包含与隐藏/输出层中的节点相关联的偏差。

  1. 通过执行inputs的矩阵乘法(np.dot)和将输入层连接到隐藏层的权重值(weights[0])计算隐藏层值,并添加与隐藏层节点相关的偏差项(weights[1]):
    pre_hidden = np.dot(inputs,weights[0])+ weights[1]
  1. 将 sigmoid 激活函数应用于上一步获得的隐藏层值之上-pre_hidden:
    hidden = 1/(1+np.exp(-pre_hidden))
  1. 通过执行隐藏层激活值(hidden)和将隐藏层连接到输出层的权重(weights[2])的矩阵乘法(np.dot),以及将输出与输出层中的节点相关联的偏差求和来计算输出层值-weights[3]:
    pred_out = np.dot(hidden, weights[2]) + weights[3]
  1. 计算整个数据集的均方误差值,并返回均方误差:
    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)。在这种情况下,有 10 个输出值,其中每个输出值应该表示输入图像属于 10 个类别之一的概率。

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

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

注意,在输入xnp.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))

与均方误差类似,平均绝对误差通常用于连续变量。此外,一般而言,当要预测的输出具有小于 1 的值时,优选地将平均绝对误差作为损失函数,因为当预期输出小于 1 时,均方误差将显著降低损失的幅度(1 和-1 之间的数的平方是更小的数)。

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

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

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

注意,当预测值远离实际值时,二进制交叉熵损失具有高值,当预测值和实际值接近时,具有低值。

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

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

实现反向传播

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

当我们执行以下步骤时,损失值会降低:

  1. 少量改变神经网络中的每个权重——一次一个。
  2. 当重量值发生变化()时,测量损失的变化()。
  3. 通过更新权重(其中 k 为正值,是一个被称为学习率的超参数)。

请注意,对特定权重的更新与通过少量更改而减少的损失量成比例。直观地说,如果改变一个权重可以大幅度减少损失,那么我们可以大幅度更新权重。但是,如果通过改变权重减少的损失很小,那么我们只对其进行少量更新。

如果前面的步骤在整个数据集上执行 n 次(其中我们已经完成了前馈传播和反向传播),它基本上导致了对n时期的训练。

由于典型的神经网络包含数千/数百万(如果不是数十亿)个权重,因此改变每个权重的值,并检查损失是增加还是减少并不是最佳的。前面列表中的核心步骤是当重量改变时“损失变化”的测量。你可能学过微积分,测量这个和计算重量损失的梯度是一样的。在下一节,关于反向传播的链式法则,会有更多关于利用微积分的偏导数来计算重量损失的梯度。

在本节中,我们将从头开始实现梯度下降,一次更新一个权重,每次更新一个小的量,如本节开始时所详述的。然而,在实现反向传播之前,让我们了解神经网络的一个额外的细节:学习速率。

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

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

我们更新权重以减少误差的整个过程被称为梯度下降

随机梯度下降是在前面的场景中误差最小化的方式。如前所述,梯度代表差异(当权重值少量更新时损失值的差异),而下降表示减少。随机代表随机样本的选择,基于此做出决策。

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

在接下来的两节中,我们将学习用 Python 从头开始编写反向传播的直觉代码,还将简要讨论反向传播如何使用链规则工作。

代码中的梯度下降

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

The following code is available as Gradient_descent.ipynb in the Chapter01 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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)) 
    return mean_squared_error
  1. 将每个权重和偏移值增加一个非常小的量(0.0001),并为每个权重和偏移更新一次计算一个总的平方误差损失值。
  • 在下面的代码中,我们创建了一个名为update_weights的函数,它执行梯度下降过程来更新权重。该函数的输入是网络的输入变量—inputs、预期的outputsweights(在训练模型开始时随机初始化),以及模型的学习速率—lr(在后面的部分中详细介绍学习速率):
def update_weights(inputs, outputs, weights, lr):
  • 确保你的重量清单。由于权重将在后面的步骤中被操作,deepcopy确保我们可以在不干扰实际权重的情况下使用权重的多个副本。我们将创建原始权重集的三个副本,它们作为输入传递给函数——original_weightstemp_weightsupdated_weights:
original_weights = deepcopy(weights)
temp_weights = deepcopy(weights)
updated_weights = deepcopy(weights)           
  • 通过feed_forward函数传递inputsoutputsoriginal_weights,用原来的一组权重计算损失值(original_loss):
original_loss = feed_forward(inputs, outputs, \
                                 original_weights)
  • 我们将遍历网络的所有层:
for i, layer in enumerate(original_weights):
  • 我们的神经网络中共有四个参数列表——两个用于将输入连接到隐藏层的权重和偏差参数列表,另外两个用于将隐藏层连接到输出层的权重和偏差参数列表。现在,我们遍历所有单个参数,因为每个列表都有不同的形状,所以我们利用np.ndenumerate遍历给定列表中的每个参数:
for index, weight in np.ndenumerate(layer):
  • 现在我们将原始的一组权重存储在temp_weights中。我们选择存在于第 i ^层中的索引权重,并将其增加一个小值。最后,我们用神经网络的新的一组权重计算新的损失:
temp_weights = deepcopy(weights)
temp_weights[i][index] += 0.0001
_loss_plus = feed_forward(inputs, outputs, \
                            temp_weights)

在上述代码的第一行中,我们将temp_weights重置为原始权重集,因为在每次迭代中,当参数在给定时段内少量更新时,我们会更新不同的参数来计算损失。

  • 我们计算由于重量变化引起的梯度(损失值的变化):
grad = (_loss_plus - original_loss)/(0.0001)

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

  • 最后,我们更新出现在相应的第 i ^层和updated_weightsindex中的参数。更新的权重值将与梯度值成比例地减少。此外,我们引入了一种通过使用学习率来慢慢建立信任的机制,而不是完全减少一个等于梯度值的值—lr(在了解学习率的影响部分中有更多关于学习率的信息):
updated_weights[i][index] -= grad*lr
  • 一旦所有层的参数值和层内的指数被更新,我们返回更新的权重值-updated_weights:
return updated_weights, original_loss

神经网络中的另一个参数是在计算损失值时考虑的批量

在前面的场景中,我们考虑了所有数据点来计算损失(均方误差)值。然而,在实践中,当我们有数千个(在某些情况下,数百万个)数据点时,在计算损失值时,更多数据点的增量贡献将遵循收益递减规律,因此我们将使用与我们拥有的数据点总数相比小得多的批量。我们将一次使用一个批次来应用梯度下降(在前馈传播之后),直到我们在训练的一个时期内用尽中的所有数据点。

构建模型时考虑的典型批量大小在 32 到 1,024 之间。

在本节中,我们学习了当权重值发生少量变化时,根据损失值的变化来更新权重值。在下一节中,我们将了解如何更新权重,而不需要一次计算一个梯度。

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

到目前为止,我们已经通过少量更新权重,然后计算原始场景(当权重不变时)中的前馈损失和更新权重后的前馈损失之间的差异,计算了关于权重的损失梯度。以这种方式更新权重值的一个缺点是,当网络很大时,需要大量的计算来计算损失值(实际上,计算要进行两次——一次是权重值不变,另一次是权重值少量更新)。这导致更多的计算,因此需要更多的资源和时间。在本节中,我们将了解如何利用链式法则,该法则不要求我们手动计算损失值来得出与重量值相关的损失梯度。

在第一次迭代中(我们随机初始化权重),输出的预测值是 1.235。

为了得到理论公式,让我们将权重和隐藏层值以及隐藏层激活分别表示为 wh、a ,如下所示:

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

为了便于理解,在本节中,我们将了解如何使用链式法则来计算仅关于 w [11] 的损失值的梯度。同样的学习可以扩展到神经网络的所有权重和偏差。我们鼓励您练习并将链式法则计算应用于其余的权重和偏差值。

本书 GitHub 资源库的Chapter01文件夹中的chain_rule.ipynb笔记本包含了使用链式法则计算网络中所有参数的权重和偏差变化的梯度的方法。

此外,为了便于学习,我们将只处理一个数据点,其中输入为{1,1},预期输出为{0}。

假设我们正在用 w [11] 计算损耗值的梯度,让我们通过下图了解计算梯度时要包括的所有中间元件(不将输出连接到 w [11] 的元件在下图中显示为灰色):

从上图中,我们可以看到 w [11] 通过突出显示的路径—贡献了损失值。

接下来,我们来阐述一下是如何分别获得的。

网络的损耗值表示如下:

预测输出值计算如下:

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

隐藏层值计算如下:

既然我们已经制定了所有的方程,让我们计算损失值( C )的变化相对于重量的变化的影响如下:

这被称为链式法则。本质上,我们正在执行一个微分链,以获取我们感兴趣的微分。

请注意,在前面的等式中,我们已经建立了一系列偏微分方程,现在我们能够对四个分量中的每一个单独进行偏微分,并最终计算损失值相对于重量值的导数。

上述等式中的各个偏导数计算如下:

  • 损失值相对于预测输出值的偏导数如下:

  • 预测输出值相对于隐藏层激活值的偏导数如下:

  • 隐藏层激活值相对于激活前隐藏层值的偏导数如下:

注意,前面的等式来自于 sigmoid 函数的导数是的事实。

  • 激活前的隐藏层值相对于权重值的偏导数如下:

这样,损失值相对于的梯度通过将每个偏微分项替换为之前步骤中计算的相应值来计算,如下所示:

从前面的公式中,我们可以看到,我们现在能够计算重量值的微小变化(损失相对于重量的梯度)对损失值的影响,而无需通过再次计算前馈传播来强行进行。

接下来,我们将继续更新权重值,如下所示:

这两种方法的工作版本,1)使用链式法则识别梯度,然后更新权重,以及 2)通过了解权重值的微小变化对损失值的影响来更新权重值,从而使更新后的权重值具有相同的值,在本书的 GitHub 资源库-【https://tinyurl.com/mcvp-packt】?? 的Chapter01文件夹中的笔记本Chain_rule.ipynb中提供

在梯度下降中,我们顺序执行权重更新过程(一次一个权重)。通过利用链式法则,我们了解到有一种替代方法可以计算重量的少量变化对损失值的影响,但是有机会进行并行计算。

Because we are updating parameters across all layers, the whole process of updating parameters can be parallelized. Further, given that in a realistic scenario, there can exist millions of parameters across layers, performing the calculation for each parameter on a different core of GPU results in the time taken to update weights is a much faster exercise than looping through each weight, one at a time.

现在,我们已经从直觉的角度和利用链式法则对反向传播有了一个坚实的了解,在下一节中,我们将了解前馈和反向传播如何协同工作以达到最佳权重值。

将前馈传播和反向传播放在一起

在本节中,我们将构建一个简单的神经网络,它具有一个隐藏层,将输入连接到我们在代码部分的前馈传播中处理的相同玩具数据集的输出,并且还利用我们在上一节中定义的update_weights函数来执行反向传播,以获得最佳权重和偏差值。

我们将模型定义如下:

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

The following code is available as Back_propagation.ipynb in the Chapter01 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

我们将创建如下网络:

  1. 导入相关包并定义数据集:
from copy import deepcopy
import numpy as np 
x = np.array([[1,1]])
y = np.array([[0]])
  1. 随机初始化权重和偏差值。

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

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 权重矩阵,最后一个参数数组表示与输出层相关联的偏差。

  1. 运行神经网络通过 100 个前馈传播和反向传播时期——其功能已经在前面章节中学习并定义为feed_forwardupdate_weights功能。
  • 定义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
  • 定义update_weights功能:
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
  • 在 100 个时期内更新权重,并获取损失值和更新后的权重值:
losses = []
for epoch in range(100):
    W, loss = update_weights(x,y,W,0.01)
    losses.append(loss)
  1. 绘制损失值:
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')

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

你可以看到,损失开始在 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)]

GitHub 笔记本(Auto_gradient_of_tensors.ipynb)中演示了相同代码相同权重的 PyTorch 版本。理解下一章 PyTorch 的核心概念后,再来看这一节。自己验证一下,不管网络是用 NumPy 还是 PyTorch 写的,输入和输出确实是一样的。使用 NumPy 数组从零开始构建一个网络,虽然不是最优的,但在这一章中会帮助你对神经网络的工作细节有一个坚实的基础。

  1. 一旦我们有了更新的权重,通过将输入传递到网络来对输入进行预测,并计算输出值:
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。随着我们训练更多的纪元,pred_out值变得更加接近 0。

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

了解学习速度的影响

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

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

The following code is available as Learning_rate.ipynb in the Chapter01 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  1. 我们指定输入和输出数据集如下:
x = [[1],[2],[3],[4]]
y = [[3],[6],[9],[12]]
  1. 定义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
  1. 定义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
  1. 将权重和偏差值初始化为随机值:
W = [np.array([[0]], dtype=np.float32), 
     np.array([[0]], dtype=np.float32)]

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

  1. 让我们利用学习率为 0.01 的update_weights函数,循环 1,000 次迭代,并检查权重值(W)在增加的时期内如何变化:
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函数在每个时期结束时获取修改后的权重。此外,在每个时期,我们给出最近更新的权重作为输入,以在下一个时期获取更新的权重。

  1. 在每个时期结束时绘制权重参数的值:
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')

前面的代码会导致权重值在增加的时期内发生变化,如下所示:

请注意,在前面的输出中,权重值逐渐向右增加,然后在最佳值~3 处饱和。

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

当我们在步骤 5 中修改相应的学习率值并执行步骤 6 时,会获得以下图表(生成以下图表的代码与我们之前学习的代码相同,只是学习率值有所变化,可在 GitHub 中的相关笔记本中找到):

请注意,当学习率非常小(0.01)时,权重值缓慢地(经过更多的时期)向最佳值移动。然而,在稍高的学习率(0.1)下,权重值最初振荡,然后迅速饱和(在更少的时期内)到最优值。最后,当学习率很高(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])
print(W)
import matplotlib.pyplot as plt
%matplotlib inline
epochs = range(1, 11)
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')

上述代码会产生以下输出:

注意,当学习率为 0.01 时,损失值缓慢下降,并且权重值也朝着最佳值缓慢更新。现在让我们来了解一下当学习率为 0.1 时,上述情况是如何变化的。

  • 0.1 的学习率:代码与学习率为 0.01 的场景中的代码保持相同,但是,在该场景中,学习率参数将为 0.1。使用更改的学习率参数值运行相同代码的输出如下:

让我们对比一下 0.01 和 0.1 的学习率场景,两者之间的主要区别如下:

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

除了权重更新幅度之外,我们还应该注意权重更新的方向:

当权重值小于最优值时,梯度为负,当权重值大于最优值时,梯度为正。这种现象有助于在正确的方向上更新权重值。

最后,我们将学习率 1:

  • 1的学习率:代码保持与 0.01 的学习率场景中的代码相同,但是,在该场景中,学习率参数将为 1。使用更改后的学习率参数运行相同代码的输出如下:

从前面的图中,我们可以看到权重已经偏离到非常高的值(如在第一个时期结束时,权重值为 45,在后面的时期中进一步偏离到非常大的值)。除此之外,权重值移动到非常大的量,因此权重值的小变化几乎不会导致梯度的变化,因此权重停留在该高值。

一般来说,学习率低比较好。这样,模型能够缓慢学习,但会将权重调整到最佳值。典型的学习率参数值范围在 0.0001 和 0.01 之间。

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

总结神经网络的训练过程

训练神经网络是通过以给定的学习速率重复前向传播和反向传播这两个关键步骤来得出神经网络架构的最佳权重的过程。

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

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

前馈传播和反向传播的过程重复进行,直到我们实现尽可能小的损失。这意味着,在训练结束时,神经网络已经调整了它的权重,以便它预测我们希望它预测的输出。在前面的玩具示例中,经过训练后,当 {1,1} 被输入时,更新后的网络将预测 0 值作为输出,因为它被训练以实现该值。

摘要

在本章中,在我们了解人工神经网络的架构和各种组件之前,我们了解了对单个网络的需求,该网络可以在单个镜头中执行特征提取和分类。接下来,我们学习了如何在实现前馈传播之前连接网络的各层,以计算与网络当前权重相对应的损耗值。接下来,我们实现了反向传播,以了解优化权重来最小化损失值的方法。此外,我们还了解了学习速率如何在实现网络的最佳权重方面发挥作用。此外,我们实现了网络的所有组件——前馈传播、激活函数、损失函数、链式法则和梯度下降,以从头开始更新 NumPy 中的权重,从而为我们在接下来的章节中构建打下坚实的基础。

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

问题

  1. 神经网络中的各层是什么?
  2. 前馈传播的输出是什么?
  3. 连续因变量的损失函数与二元因变量以及分类因变量的损失函数有何不同?
  4. 什么是随机梯度下降?
  5. 反向传播练习做什么?
  6. 在反向传播期间,跨层的所有权重的权重更新是如何发生的?
  7. 在训练神经网络的每个时期内,神经网络的哪些功能发生?
  8. 为什么在 GPU 上训练网络比在 CPU 上训练更快?
  9. 学习率如何影响神经网络的训练?
  10. 学习率参数的典型值是多少?

二、PyTorch 基础

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

在本章中,我们将深入探讨使用 PyTorch 构建神经网络的基础,在后续章节中,当我们了解图像分析中的各种用例时,我们将多次利用 py torch。我们将从 PyTorch 研究的核心数据类型——张量对象开始。然后,我们将深入研究可以在张量对象上执行的各种操作,以及在玩具数据集上构建神经网络模型时如何利用它们(以便我们在从下一章开始逐步查看更现实的数据集之前加强理解)。这将允许我们直观地了解如何使用 PyTorch 构建神经网络模型来映射输入和输出值。最后,我们将学习实现定制损失函数,这样我们就可以基于我们正在解决的用例进行定制。

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

  • 安装 PyTorch
  • PyTorch tensors
  • 使用 PyTorch 构建神经网络
  • 使用顺序方法建立神经网络
  • 保存和加载 PyTorch 模型

安装 PyTorch

PyTorch 提供了多种功能来帮助构建神经网络——使用高级方法抽象各种组件,并为我们提供张量对象,利用 GPU 更快地训练神经网络。

在安装 PyTorch 之前,我们首先需要安装 Python,如下:

  1. 为了安装 Python,我们将使用anaconda.com/distribution/平台来获取安装程序,该安装程序将为我们自动安装 Python 以及重要的深度学习专用库:

选择最新 Python 版本 3.xx (3.7,截至撰写本书时)的图形安装程序,并让它下载。

  1. 使用下载的安装程序进行安装:

在安装过程中选择 Add Anaconda to my PATH 环境变量选项,因为这将使我们在命令提示符/终端中键入python时调用 Anaconda 版本的 Python 变得容易。

接下来,我们将安装 PyTorch,这同样简单。

  1. 访问pytorch.org/网站上的本地快速入门部分,选择您的操作系统(您的 OS),对于软件包选择 Conda,对于语言选择 Python,对于 CUDA 选择 None。如果你有 CUDA 库,你可以选择合适的版本:

这将提示您在终端中运行一个命令,比如conda install pytorch torchvision cpuonly -c pytorch

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

如果你拥有一个 NVIDIA 显卡作为硬件组件,强烈建议安装 CUDA 驱动,它可以将深度学习训练加速几个数量级。有关如何安装 CUDA 驱动程序的说明,请参考附录。一旦你安装了它们,你可以选择 10.1 作为 CUDA 版本,并使用这个命令来安装 PyTorch。

  1. 您可以在命令提示符/终端中执行python,然后键入以下命令来验证 PyTorch 确实已安装:
>>> import torch
>>> print(torch.__version__)
# '1.7.0'

本书中的所有代码都可以在 Google Colab-colab.research.google.com/中执行。Python 和 PyTorch 在 Google Colab 中默认可用。我们强烈建议您在 Colab 上执行所有代码——包括免费访问 GPU!感谢谷歌提供如此优秀的资源!

所以,我们已经成功安装了 Python 和 PyTorch。我们现在将在 Python 中执行一些基本的张量运算来帮助你掌握它。

PyTorch tensors

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

  • 标量可以表示为零维张量。
  • 向量可以表示为一维张量。
  • 二维矩阵可以表示为二维张量。
  • 多维矩阵可以表示为多维张量。

从图像上看,张量如下:

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

在本节结束时,我们将学习张量为什么有用,如何初始化它们,以及在张量上执行各种操作。这将作为我们在下一节研究利用张量构建神经网络模型时的基础。

初始化张量

张量在很多方面都很有用。除了用作图像的基本数据结构之外,张量的一个更突出的用途是用于初始化连接神经网络不同层的权重。

在本节中,我们将练习初始化张量对象的不同方法:

下面的代码可以在本书的 GitHub 库【https://tinyurl.com/mcvp-packtChapter02文件夹中找到Initializing_a_tensor.ipynb

  1. 导入 PyTorch 并通过调用列表上的torch.tensor初始化张量:
import torch
x = torch.tensor([[1,2]])
y = torch.tensor([[1],[2]])
  1. 接下来,访问张量对象的形状和数据类型:
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,我们可以使用内置函数初始化张量对象。请注意,我们在神经网络的张量和权重之间绘制的相似之处现在暴露出来了——我们正在初始化张量,以便它们代表神经网络的权重初始化。

  1. 生成一个张量对象,该对象有三行四列,用零填充:
torch.zeros((3, 4))
  1. 生成一个张量对象,该对象有三行四列,用 1 填充:
torch.ones((3, 4))
  1. 生成三行四列介于 0 和 10 之间的值(包括低值,但不包括高值):
torch.randint(low=0, high=10, size=(3,4))
  1. 用三行四列生成 0 到 1 之间的随机数:
torch.rand(3, 4)
  1. 生成符合三行四列正态分布的数字:
torch.randn((3,4))

  1. 最后,我们可以使用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 类似,可以对张量对象执行各种基本操作。与神经网络操作类似的是输入与权重的矩阵乘法、偏置项的添加以及在需要时对输入或权重值进行整形。这些操作和附加操作的完成方式如下:

The following code is available as Operations_on_tensors.ipynb in the Chapter02 folder of this book's GitHub repository.

  • 可以使用以下代码将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]])
  • 10添加到x中的元素,并将结果张量存储到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输出也是全 4。此外,.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完全相同,但在适用的情况下返回最小值和 arg-minimum。

  • 置换张量对象的维度:
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 on)来交换维度。尽管 Torch 不会抛出错误,但这是错误的,会在训练过程中产生无法预料的结果。如果需要交换尺寸,请始终使用置换。

因为很难涵盖本书中所有可用的操作,所以知道您可以使用与 NumPy 几乎相同的语法在 PyTorch 中执行几乎所有的 NumPy 操作是很重要的。标准的数学运算,如absaddargsortceilfloorsincostancumsumcumproddiageigexploglog2log10meanmedianmoderesizeroundsigmoidsoftmaxsquaresqrtsvd你可以随时运行dir(torch.Tensor)来查看 Torch 张量的所有可能方法,运行help(torch.Tensor.<method>)来查看该方法的官方帮助和文档。

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

张量对象的自动渐变

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

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

The following code is available as Auto_gradient_of_tensors.ipynb in the Chapter02 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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 提供的内置函数来验证这一点。

  1. 可以通过对值调用backward()方法来计算值的梯度。在我们的例子中,我们计算梯度–对于x(输入)的微小变化out(输出)的变化–如下:
out.backward()
  1. 我们现在可以获得out相对于x的梯度,如下所示:
x.grad

这会产生以下输出:

请注意,之前获得的梯度与直观的梯度值相匹配(是 x 值的两倍)。

As an exercise, try recreating the scenario in Chain rule.ipynb in Chapter 1, Artificial Neural Network Fundamentals, with PyTorch. Compute the gradients after making a forward pass and make a single update. Verify that the updated weights match what we calculated in the notebook.

到目前为止,我们已经了解了如何在张量对象上初始化、操作和计算梯度——它们共同构成了神经网络的基本构件。除了计算自动渐变,初始化和操作数据也可以使用 NumPy 数组。这要求我们理解为什么在构建神经网络时应该使用张量对象而不是 NumPy 数组——这将在下一节中讨论。

PyTorch 的张量优于 NumPy 的 ndarrays

在前一章中,我们看到,在计算最佳权重值时,我们会对每个权重进行少量调整,并了解其对降低整体损失值的影响。注意,基于一个权重的权重更新的损失计算不影响同一迭代中其他权重的权重更新的损失计算。因此,如果每个权重更新由不同的核心并行进行,而不是顺序更新权重,则可以优化该过程。在这种情况下,GPU 很方便,因为与 CPU(一般情况下,CPU 可能不超过 64 个内核)相比,它由数千个内核组成。

与 NumPy 相比,Torch 张量对象经过优化,可与 GPU 配合使用。为了进一步理解这一点,让我们进行一个小实验,其中我们在一个场景中使用 NumPy 数组执行矩阵乘法操作,在另一个场景中使用 tensor 对象执行矩阵乘法操作,并比较在两个场景中执行矩阵乘法所花费的时间:

The following code is available as Numpy_Vs_Torch_object_computation_speed_comparison.ipynb in the Chapter02 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  1. 生成两个不同的torch对象:
import torch
x = torch.rand(1, 6400)
y = torch.rand(6400, 5000)
  1. 定义我们将存储在步骤 1 中创建的张量对象的设备:
device = 'cuda' if torch.cuda.is_available() else 'cpu'

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

  1. 用设备注册在步骤 1 中创建的张量对象。注册张量对象意味着在设备中存储信息:
x, y = x.to(device), y.to(device)
  1. 对 Torch 对象执行矩阵乘法,并计时,以便我们可以比较在 NumPy 数组上执行矩阵乘法的情况下的速度:
%timeit z=(x@y)
# It takes 0.515 milli seconds on an average to 
# perform matrix multiplication
  1. cpu进行相同张量的矩阵乘法:
x, y = x.cpu(), y.cpu()
%timeit z=(x@y)
# It takes 9 milli seconds on an average to 
# perform matrix multiplication
  1. 执行相同的矩阵乘法,这次是在 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 上对 Torch 对象执行的矩阵乘法快大约 18 倍,比在 NumPy 数组上执行的矩阵乘法快大约 40 倍。总的来说,matmul在 CPU 上用 Torch tensors 还是比 NumPy 快。请注意,只有当您有 GPU 设备时,您才会注意到这种加速。如果您正在使用 CPU 设备,您不会注意到速度的显著提高。这就是为什么如果你没有自己的 GPU,我们建议使用谷歌 Colab 笔记本电脑,因为该服务提供免费的 GPU。

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

使用 PyTorch 构建神经网络

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

  • 隐藏层的数量
  • 隐藏层中的单元数
  • 在不同层执行的激活功能
  • 我们试图优化的损失函数
  • 与神经网络相关联的学习速率
  • 用于构建神经网络的批量数据
  • 正向和反向传播的次数

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

请注意,在本章中,为了获得执行各种操作的直觉,我们将在玩具数据集上构建一个神经网络。从下一章开始,我们将处理解决更现实的问题和数据集。

为了理解使用 PyTorch 实现神经网络,我们要解决的玩具问题是两个数的简单相加,其中我们按如下方式初始化数据集:

The following code is available as Building_a_neural_network_using_PyTorch_on_a_toy_dataset.ipynb in the Chapter02 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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)数据点注册到设备——cuda(如果您有 GPU)和cpu(如果您没有 GPU ):

device = 'cuda' if torch.cuda.is_available() else 'cpu'
X = X.to(device)
Y = Y.to(device)
  1. 定义神经网络架构:
  • torch.nn模块包含有助于构建神经网络模型的功能:
import torch.nn as nn
  • 我们将创建一个类(MyNeuralNet),它可以组成我们的神经网络架构。创建模型架构时,必须从nn.Module继承,因为它是所有神经网络模块的基类:
class MyNeuralNet(nn.Module):
  • 在该类中,我们使用__init__方法初始化神经网络的所有组件。我们应该调用super().__init__()来确保该类继承nn.Module:
def __init__(self):
    super().__init__()

使用前面的代码,通过指定super().__init__(),我们现在能够利用为nn.Module编写的所有预建功能。将在init方法中初始化的组件将在MyNeuralNet类的不同方法中使用。

  • 定义神经网络中的层:
    self.input_to_hidden_layer = nn.Linear(2,8)
    self.hidden_layer_activation = nn.ReLU()
    self.hidden_to_output_layer = nn.Linear(8,1)

在前面的代码行中,我们指定了神经网络的所有层——线性层(self.input_to_hidden_layer),然后是 ReLU 激活(self.hidden_layer_activation),最后是线性层(self.hidden_to_output_layer)。注意,现在,层数和激活的选择是任意的。我们将在下一章更详细地了解层中单位数量和层激活的影响。

  • 此外,让我们通过打印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)

在前面的代码中,线性方法将两个值作为输入,输出七个值,并且还有一个与之关联的偏差参数。此外,nn.ReLU()调用 ReLU 激活,然后可以在其他方法中使用。

其他一些常用的激活功能如下:

  • 乙状结肠的
  • Softmax
  • 双曲正切

现在我们已经定义了神经网络的组件,让我们在定义网络的正向传播时将组件连接在一起:

    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. 您可以通过执行以下步骤来访问每个组件的初始重量:
  • 创建我们之前定义的MyNeuralNet类对象的一个实例,并将其注册到device:
mynet = MyNeuralNet().to(device)
  • 可通过指定以下内容来访问各层的权重和偏差:
# 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

上述代码的输出如下:

输出中的值将与前面的不同,因为神经网络每次都用随机值进行初始化。如果您希望它们在执行相同代码的多次迭代中保持不变,那么您需要在创建类对象实例之前使用 Torch 中的manual_seed方法将种子指定为torch.manual_seed(0)

  • 使用以下代码可以获得神经网络的所有参数:
# 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()

前面的代码返回一个生成器对象。

  • 最后,通过遍历生成器获得参数,如下所示:
# 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)

上述代码会产生以下输出:

该模型已将这些张量注册为特殊对象,这些对象是跟踪向前和向后传播所必需的。在__init__方法中定义任意一个nn层时,会自动创建相应的张量并同时注册。您也可以使用nn.Parameter(<tensor>)功能手动注册这些参数。因此,下面的代码相当于我们之前定义的神经网络类。

  • 使用nn.Parameter功能定义模型的另一种方法如下:
# 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
  1. 定义我们优化的损失函数。假设我们预测的是连续输出,我们将针对均方误差进行优化:
loss_func = nn.MSELoss()

其他突出的损失函数如下:

  • CrossEntropyLoss(用于多项分类)

  • BCELoss(二值分类的二值交叉熵损失)

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

_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)
  1. 一起执行一个时期内要完成的所有步骤:
  • 计算对应于给定输入和输出的损耗值。
  • 计算每个参数对应的梯度。
  • 基于每个参数的学习速率和梯度更新权重。
  • 更新权重后,请确保在下一个时段计算梯度之前,刷新上一步中计算的梯度:
# 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 back-propagation
opt.step() # update the weights according to the gradients computed
  • 使用for循环,重复上述步骤,重复次数与历元数相同。在下面的例子中,我们对总共 50 个时期执行权重更新过程。此外,我们将每个时期的损失值存储在列表中—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)
  • 让我们绘制损失随增加的时期的变化(正如我们在上一章中看到的,我们以总损失值随增加的时期减少的方式更新权重):
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')

上述代码会产生以下图形:

请注意,正如预期的那样,损失值随着时期的增加而降低。

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

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

神经网络中我们还没有考虑的一个超参数是批量大小。批量是指计算损失值或更新权重时考虑的数据点数量。

这种超参数在有数百万个数据点的情况下特别有用,将所有这些数据点用于一次权重更新并不是最佳选择,因为内存无法容纳如此多的信息。此外,样本可以充分代表数据。批量大小有助于获取足够有代表性的多个数据样本,但不一定是全部数据的 100%代表。

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

The following code is available as Specifying_batch_size_while_training_a_model.ipynb in the Chapter02 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  1. 导入有助于加载数据和处理数据集的方法:
from torch.utils.data import Dataset, DataLoader
import torch
import torch.nn as nn
  1. 导入数据,将数据转换为浮点数,并将它们注册到设备:
  • 提供要处理的数据点:
x = [[1,2],[3,4],[5,6],[7,8]]
y = [[3],[7],[11],[15]]
  • 将数据转换成浮点数:
X = torch.tensor(x).float()
Y = torch.tensor(y).float()
  • 向设备注册数据——假设我们在 GPU 上工作,我们指定设备为'cuda'。如果您在 CPU 上工作,将设备指定为'cpu':
device = 'cuda' if torch.cuda.is_available() else 'cpu'
X = X.to(device)
Y = Y.to(device)
  1. 实例化数据集的一个类-MyDataset:
class MyDataset(Dataset):

MyDataset类中,我们存储信息以一次获取一个数据点,以便可以将一批数据点捆绑在一起(使用DataLoader)并通过一个前向和一个反向传播发送,以便更新权重:

  • 定义一个__init__方法,该方法接受输入和输出对,并将它们转换成 Torch 浮动对象:
    def __init__(self,x,y):
        self.x = torch.tensor(x).float()
        self.y = torch.tensor(y).float()
  • 指定输入数据集的长度(__len__):
    def __len__(self):
        return len(self.x)
  • 最后,__getitem__方法用于获取特定的行:
    def __getitem__(self, ix):
        return self.x[ix], self.y[ix]

在前面的代码中,ix指的是要从数据集中提取的行的索引。

  1. 创建已定义类的实例:
ds = MyDataset(X, Y)
  1. 通过DataLoader传递先前定义的数据集实例,以从原始输入和输出张量对象中获取batch_size个数据点:
dl = DataLoader(ds, batch_size=2, shuffle=True)

此外,在前面的代码中,我们还指定从原始输入数据集(ds)中获取两个数据点(通过提及batch_size=2)的随机样本(通过提及shuffle=True)。

  • 为了从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)

这会产生以下输出:

注意,前面的代码产生了两组输入输出对,因为原始数据集中总共有四个数据点,而指定的批处理大小是2

  1. 现在,我们按照上一节中的定义来定义神经网络类:
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
  1. 接下来,我们还定义了模型对象(mynet)、损失函数(loss_func)和优化器(opt),如前一节所定义的:
mynet = MyNeuralNet().to(device)
loss_func = nn.MSELoss()
from torch.optim import SGD
opt = SGD(mynet.parameters(), lr = 0.001)
  1. 最后,循环遍历数据点批次,以最小化损失值,就像我们在上一节的步骤 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)
end = time.time()
print(end - start)

请注意,虽然前面的代码似乎与我们在上一节中经历的代码非常相似,但与上一节中更新权重的次数相比,我们在每个时期执行的权重更新次数是 2 倍,因为本节中的批量大小是2,而上一节中的批量大小是4(数据点的总数)。

现在我们已经训练了一个模型,在下一节中,我们将学习对一组新的数据点进行预测。

预测新的数据点

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

  1. 创建我们想要测试模型的数据点:
val_x = [[10,11]]

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

  1. 将新数据点转换为张量浮点对象,并注册到设备:
val_x = torch.tensor(val_x).float().to(device)
  1. 将张量对象通过训练好的神经网络-mynet-就像它是一个 Python 函数一样。这与通过构建的模型执行正向传播是一样的:
mynet(val_x)
# 20.99

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

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

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

实现自定义损失函数

在某些情况下,我们可能必须实现一个针对我们正在解决的问题定制的损失函数——特别是在涉及目标检测/ 生成性广告网络 ( GANs )的复杂用例中。PyTorch 为我们提供了通过编写自己的函数来构建自定义损失函数的功能。

在本节中,我们将实现一个定制的损失函数,它与nn.Module中预构建的MSELoss函数做相同的工作:

The following code is available as Implementing_custom_loss_function.ipynb in the Chapter02 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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()
import torch.nn as nn
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)
  1. 通过将两个张量对象作为输入来定义自定义损失函数,取它们的差,对它们求平方,并返回两者之间的平方差的平均值:
def my_mean_squared_error(_y, y):
    loss = (_y-y)**2
    loss = loss.mean()
    return loss
  1. 对于上一节中的相同输入和输出组合,nn.MSELoss用于获取均方误差损失,如下所示:
loss_func = nn.MSELoss()
loss_value = loss_func(mynet(X),Y)
print(loss_value)
# 92.7534
  1. 同样,当我们使用在步骤 2 中定义的函数时,损失值的输出如下:
my_mean_squared_error(mynet(X),Y)
# 92.7534

请注意结果是匹配的。我们使用了内置的MSELoss函数,并将其结果与我们构建的自定义函数进行了比较。

我们可以根据我们要解决的问题定义一个自定义函数。

到目前为止,我们已经了解了如何计算最后一层的输出。到目前为止,中间层值一直是一个黑箱。在下一节中,我们将学习获取神经网络的中间层值。

获取中间层的值

在某些情况下,获取神经网络的中间层值是有帮助的(当我们在后面的章节中讨论风格迁移和转移学习用例时,会有更多关于这方面的内容)。

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

The following code is available as Fetching_values_of_intermediate_layers.ipynb in the Chapter02 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  • 一种方法是直接调用层,就像它们是函数一样。这可以通过以下方式完成:
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层的输入。

  • 另一种方法是通过在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类所替代之外,我们将执行与前面章节相同的步骤。

让我们为本章中讨论过的相同玩具数据编写网络代码:

The following code is available as Sequential_method_to_build_a_neural_network.ipynb in the Chapter02 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  1. 定义玩具数据集:
x = [[1,2],[3,4],[5,6],[7,8]]
y = [[3],[7],[11],[15]]
  1. 导入相关的包并定义我们将要使用的设备:
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'
  1. 现在,我们定义数据集类(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)
  1. 定义数据集(ds)和数据加载器(dl)对象:
ds = MyDataset(x, y)
dl = DataLoader(ds, batch_size=2, shuffle=True)
  1. 使用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 中定义的模型摘要:
  • 安装并导入使我们能够打印模型摘要的包:
!pip install torch_summary
from torchsummary import summary
  • 打印模型摘要,包括模型名称和模型的输入尺寸:
summary(model, torch.zeros(1,2))

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

请注意,第一层的输出形状是(-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)
end = time.time()
print(end - start)
  1. 现在我们已经训练了模型,我们可以预测我们现在定义的验证数据集的值:
  • 定义验证数据集:
val = [[8,9],[10,11],[1.5,2.5]]
  • 预测通过模型传递验证列表的输出(注意,期望值是列表列表中每个列表的两个输入的总和)。如 dataset 类中所定义的,在将列表转换为张量对象并将它们注册到设备后,我们首先将列表转换为浮点数:
model(torch.tensor(val).float().to(device))
# tensor([[16.9051], [20.8352], [ 4.0773]], 
# device='cuda:0', grad_fn=<AddmmBackward>)

请注意,前面代码的输出(如注释所示)接近预期值(即输入值的总和)。

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

保存和加载 PyTorch 模型

处理神经网络模型的一个重要方面是在训练后保存和加载模型。想象一个场景,你必须从一个已经训练好的模型中做出推论。您将加载已训练的模型,而不是再次训练它。

下面的代码可以在本书的 GitHub 库【https://tinyurl.com/mcvp-packtChapter02文件夹中找到save_and_load_pytorch_model.ipynb

在浏览相关命令之前,以前面的例子为例,让我们了解一下完整定义一个神经网络的所有重要组件。我们需要以下内容:

  • 每个张量(参数)的唯一名称(键)
  • 连接网络中每个张量的逻辑
  • 每个张量的值(权重/偏差值)

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

国家声明

model.state_dict()命令是理解如何保存和加载 PyTorch 模型的基础。model.state_dict()中的字典对应模型对应的参数名(键)和数值(权重和偏差值)。state指模型的当前快照(其中快照是每个张量上的值的集合)。

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

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

节约

运行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)
  1. 从磁盘中加载模型并将其解序列化,以创建一个orderedDict值:
state_dict = torch.load('mymodel.pth')
  1. state_dict加载到model上,注册到device,并进行预测:
model.load_state_dict(state_dict)
# <All keys matched successfully>
model.to(device)
model(torch.tensor(val).float().to(device))

如果所有的权重名称都出现在模型中,那么您将得到一条消息,说明所有的键都匹配。这意味着我们能够在世界上的任何机器上,出于任何目的,从磁盘加载我们的模型。

接下来,我们可以将模型注册到设备,并对新的数据点执行推理,正如我们在上一节中所了解的那样。

摘要

在这一章中,我们学习了 PyTorch 张量对象的构建模块以及在它们之上执行各种操作。我们进一步在玩具数据集上构建神经网络,首先构建一个初始化前馈架构的类,通过指定批量大小从数据集中获取数据点,并定义损失函数和优化器,循环多个时期。最后,我们还了解了如何定义自定义损失函数来优化选择指标,以及如何利用顺序方法来简化定义网络架构的过程。

所有前面的步骤构成了构建神经网络的基础,我们将在后续章节中构建的各种用例中多次利用它。

了解了使用 PyTorch 构建神经网络的各种组件后,我们将进入下一章,在这一章中,我们将了解处理影像数据集上神经网络的超参数的各种实际方面。

问题

  1. 训练时为什么要把整数输入转换成浮点值?
  2. 重塑张量物体的各种方法有哪些?
  3. 为什么张量对象比 NumPy 数组的计算速度更快?
  4. 神经网络类中的 init 神奇函数是由什么构成的?
  5. 为什么我们在执行反向传播之前执行零梯度?
  6. 数据集类由哪些神奇的函数构成?
  7. 我们如何对新的数据点进行预测?
  8. 我们如何获取神经网络的中间层值?
  9. 顺序方法如何有助于简化神经网络架构的定义?

三、使用 PyTorch 构建深度神经网络

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

为了不引入太多的复杂性和混乱,我们在前一章中只讨论了神经网络的基本方面。然而,在训练网络时,我们还需要调整更多的输入。通常,这些输入被称为超参数。与神经网络中的参数(在训练期间学习)相反,这些输入是由构建网络的人提供的超参数。改变每个超参数的不同方面可能会影响训练神经网络的精度或速度。此外,一些额外的技术(如缩放、批处理规范化和正则化)有助于提高神经网络的性能。我们将在本章中学习这些概念。

然而,在此之前,我们将了解图像是如何表示的——只有到那时,我们才会深入研究超参数的细节。在了解超参数的影响时,我们将局限于一个数据集——fashion mnist——以便我们可以比较各种超参数变化的影响。通过该数据集,我们还将了解训练和验证数据,以及拥有两个独立数据集的重要性。最后,我们将了解神经网络过度拟合的概念,然后了解某些超参数如何帮助我们避免过度拟合。

总之,在本章中,我们将讨论以下主题:

  • 代表一幅图像
  • 为什么要利用神经网络进行图像分析?
  • 为影像分类准备数据
  • 训练神经网络
  • 缩放数据集以提高模型准确性
  • 了解改变批量大小的影响
  • 了解改变损失优化器的影响
  • 理解改变学习速度的影响
  • 了解学习率退火的影响
  • 构建更深层次的神经网络
  • 了解批处理规范化的影响
  • 过度拟合的概念

我们开始吧!

代表一幅图像

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

一幅图像有个高度 x 宽度 x c 个像素,其中高度是像素的的数量,宽度是像素的的数量, c 是像素的通道的数量。 c 对于彩色图像是 3(图像的红、强度各一个通道),对于灰度图像是 1。包含 3 x 3 像素及其相应标量值的灰度图像示例如下所示:

同样,像素值为 0 意味着它是一片漆黑,而 255 意味着它是纯亮度的(也就是说,灰度是纯白,彩色图像的各个通道是纯红/绿/蓝)。

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

Python 可以将图像转换成结构化数组和标量,如下所示:

The following code is available as Inspecting_grayscale_images.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  1. 下载示例图像:
!wget https://www.dropbox.com/s/l98leemr7r5stnm/Hemanvi.jpeg

  1. 导入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')

前面一系列步骤的输出如下:

您可能已经注意到,前面的图像表示为 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')

这会产生以下输出:

自然地,用更少的像素来表示相同的图像会导致更模糊的输出。

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

这会产生以下输出:

当复制并粘贴到 Excel 中并按像素值进行颜色编码时,同一组像素值将如下所示:

正如我们之前提到的,标量值接近 255 的像素看起来更亮,而接近 0 的像素看起来更暗。

前面的步骤也适用于彩色图像,彩色图像被表示为三维向量。最亮的红色像素表示为(255,0,0)。类似地,三维矢量图像中的纯白像素表示为(255,255,255)。记住这一点,让我们为彩色图像创建一个结构化的像素值数组:

The following code is available as Inspecting_color_images.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  1. 下载彩色图像:
!wget https://www.dropbox.com/s/l98leemr7r5stnm/Hemanvi.jpeg
  1. 导入相关包并加载映像:
import cv2, matplotlib.pyplot as plt
%matplotlib inline
img = cv2.imread('Hemanvi.jpeg') 

  1. 裁剪图像:
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)

这会产生以下输出:

  1. 右下角的 3 x 3 像素阵列可以如下获得:
crop = img[-3:,-3:]

  1. 打印并绘制像素值:
print(crop)
plt.imshow(crop)

上述代码会产生以下输出:

现在,我们可以将每个图像表示为标量数组(对于灰度图像)或数组数组(对于彩色图像),我们实际上已经将磁盘上的文件转换为结构化的数组格式,现在可以使用多种技术对其进行数学处理。将图像转换为结构化的数字数组(即将图像读入 Python 内存)使我们能够在图像(表示为数字数组)上执行数学运算。我们可以利用这种数据结构来执行各种任务,如分类、检测和分割,所有这些将在后面的章节中详细讨论。

现在我们已经了解了图像是如何表示的,让我们来理解利用神经网络进行图像分类的原因。

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

在传统的计算机视觉中,我们会在使用它们作为输入之前为每个图像创建一些特征。让我们基于下面的样本图像来看一些这样的特征,以便理解我们通过训练神经网络来避免的努力:

请注意,我们不会向您介绍如何获得这些特征,因为这里的目的是帮助您认识到为什么手动创建特征是次优的练习:

  • 直方图特征:对于一些任务,比如自动亮度或夜视,了解画面中的光照很重要;即亮或暗像素的比例。下图显示了示例图像的直方图。它描述了图像被很好地照亮,因为在 255:

  • 边缘和角点特征:对于图像分割等任务来说,找到每个人对应的像素集是很重要的,首先提取边缘是有意义的,因为人的边界只是边缘的集合。在诸如图像配准的其他任务中,检测关键标志是至关重要的。这些标志将是图像中所有角的子集。下图显示了我们的示例图像中可以找到的边缘和拐角:

  • 分色功能:在自动驾驶汽车的交通灯检测等任务中,系统理解交通灯上显示的是什么颜色是很重要的。下图(彩色效果最佳)显示了示例图像中的红色、绿色和蓝色像素:

  • 图像渐变 特征:更进一步,理解颜色在像素级别如何变化可能很重要。不同的纹理可以给我们不同的梯度,这意味着它们可以用作纹理检测器。事实上,找到梯度是边缘检测的先决条件。下图显示了示例图像的一部分的整体渐变以及渐变的 y 和 x 分量:

这些仅仅是这些特性中的一小部分。还有很多,很难全部涵盖。创建这些功能的主要缺点是,您需要成为图像和信号分析方面的专家,并且应该完全了解哪些功能最适合解决某个问题。即使两个约束条件都得到满足,也不能保证这样的专家能够找到正确的输入组合,即使他们找到了,也不能保证这样的组合能够在新的、未知的场景中工作。

由于这些缺点,社区在很大程度上转向了基于神经网络的模型。这些模型不仅能自动找到合适的功能,还能学习如何优化组合它们来完成工作。正如我们在第一章中已经了解的,神经网络同时充当特征提取器和分类器。

既然我们已经看了一些历史特征提取技术的例子和它们的缺点,让我们学习如何在图像上训练神经网络。

为图像分类准备数据

鉴于我们在本章中涉及多个场景,为了让我们看到一个场景相对于另一个场景的优势,我们将在本章中使用一个数据集——时尚 MNIST 数据集。让我们准备这个数据集:

The following code is available as Preparing_our_data.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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来指定我们只想下载训练图像

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

  1. 检查我们正在处理的张量:
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}') 

上述代码的输出如下:

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

  1. 为所有 10 个可能的类别绘制 10 个图像的随机样本:
  • 导入相关的包以绘制图像网格,这样您也可以处理数组:
import matplotlib.pyplot as plt
%matplotlib inline
import numpy as np
  • 创建一个图,其中我们可以显示一个 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]

注意,在前面的代码中,我们获取第 0 个^(?? 索引作为np.where条件的输出,因为它的长度为 1。它包含目标值(tr_targets)等于label_class的所有索引的数组。)

  • 循环 10 次以填充给定行的列。此外,我们需要从先前获得的对应于给定类别的指数(label_x_rows)中选择一个随机值(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()

这会产生以下输出:

请注意,在前面的图像中,每一行都代表属于同一类的 10 个不同图像的样本。

既然我们已经学习了如何导入数据集,在下一节中,我们将学习如何使用 PyTorch 训练神经网络,以便它接收图像并预测该图像的类别。此外,我们还将了解各种超参数对预测准确性的影响。

训练神经网络

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

  1. 导入相关的包。
  2. 构建一个可以一次提取一个数据点的数据的数据集。
  3. 从数据集中包装数据加载器。
  4. 建立一个模型,然后定义损失函数和优化器。
  5. 定义两个函数来分别训练和验证一批数据。
  6. 定义一个计算数据准确性的函数。
  7. 根据每批数据在不断增加的时期内执行重量更新。

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

下面的代码可以在本书的 GitHub 库【https://tinyurl.com/mcvp-packtChapter03文件夹中找到Steps_to_build_a_neural_network_on_FashionMNIST.ipynb

  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
  1. 构建一个获取数据集的类。记住,它是从一个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的数据集生成一个训练数据加载器trn_dl。这将针对批量随机采样 32 个数据点:
def get_data(): 
    train = FMNISTDataset(tr_images, tr_targets) 
    trn_dl = DataLoader(train, batch_size=32, shuffle=True)
    return trn_dl

在前面的代码行中,我们创建了一个名为trainFMNISTDataset类的对象,并调用了 DataLoader,以便它随机获取 32 个数据点来返回训练数据加载器;那就是,trn_dl

  1. 定义模型,以及损失函数和优化器:
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

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

注意,我们在神经网络中根本没有使用“softmax”。输出的范围是不受约束的,因为值可以具有无限的范围,而交叉熵损失通常期望输出为概率(每行总和应为 1)。这在这个设置中仍然有效,因为nn.CrossEntropyLoss实际上希望我们发送原始逻辑(即不受约束的值)。它在内部执行 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上提取batch_loss.item()来提取损失值作为一个标量。

  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与实际情况进行比较,以便我们可以检查每一行是否预测正确。最后,我们在将is_correct对象列表移动到 CPU 并转换成 numpy 数组后,返回该列表。

  1. 使用下列代码行来训练神经网络:
  • 初始化模型、损失、优化器和数据加载器:
trn_dl = get_data()
model, loss_fn, optimizer = get_model()
  • 在每个时期结束时调用包含精度和损失值的列表:
losses, accuracies = [], []
  • 定义纪元的数量:
for epoch in range(5):
    print(epoch)
  • 调用列表,该列表将包含与一个时期内的每个批次相对应的准确度和损失值:
    epoch_losses, epoch_accuracies = [], []
  • 通过迭代数据加载器创建批量训练数据:
    for ix, batch in enumerate(iter(trn_dl)):
        x, y = batch
  • 使用train_batch功能训练批次,并将训练结束时的损失值作为batch_loss存储在批次顶部。此外,将各批次的损失值存储在epoch_losses列表中:
        batch_loss = train_batch(x, y, model, optimizer, \
                                                    loss_fn)
        epoch_losses.append(batch_loss)
  • 我们存储一个时期内所有批次的平均损失值:
    epoch_loss = np.array(epoch_losses).mean()
  • 接下来,我们在所有批次的训练结束时计算预测的准确性:
    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)
  • 将每个时期结束时的损失和精度值存储在列表中:
    losses.append(epoch_loss)
    accuracies.append(epoch_accuracy)

可以使用以下代码来显示训练损失和准确度在增加的时期内的变化:

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()

上述代码的输出如下:

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

注意,由于我们没有保存torch.random_seed(0),当您执行提供的代码时,结果可能会有所不同。然而,你得到的结果也应该让你得到类似的结论。

现在,您已经对如何训练神经网络有了一个完整的了解,让我们来研究一些我们应该遵循的良好实践,以实现良好的模型性能,以及使用它们背后的原因。这可以通过微调各种超参数来实现,其中一些我们将在接下来的章节中讨论。

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

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

The following code is available as Scaling_the_dataset.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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
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
  1. 修改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。

假设像素值的范围在 0 到 255 之间,将它们除以 255 将得到始终在 0 到 1 之间的值。

  1. 训练一个模型,就像我们在前面章节的步骤 4567 中所做的那样:
  • 获取数据:
def get_data(): 
    train = FMNISTDataset(tr_images, tr_targets) 
    trn_dl = DataLoader(train, batch_size=32, shuffle=True)
    return trn_dl
  • 定义模型:
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
  • 定义用于训练和验证一批数据的函数:
def train_batch(x, y, model, opt, loss_fn):
    model.train()
    # 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 memory for next batch of calculations
 optimizer.zero_grad()
 return batch_loss.item() @torch.no_grad()
def accuracy(x, y, model):
 model.eval()   
    # 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()
  • 在不断增加的时期内训练模型:
trn_dl = get_data()
model, loss_fn, optimizer = get_model()
losses, accuracies = [], []
for epoch in range(5):
    print(epoch)
    epoch_losses, epoch_accuracies = [], []
    for ix, batch in enumerate(iter(trn_dl)):
        x, y = batch
        batch_loss = train_batch(x, y, model, optimizer, 
                                        loss_fn)
        epoch_losses.append(batch_loss)
    epoch_loss = np.array(epoch_losses).mean()
    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)
    losses.append(epoch_loss)
    accuracies.append(epoch_accuracy)

训练损失和精度值的变化如下:

如我们所见,训练损失持续减少,训练准确度持续增加,从而将时期准确度增加到约 85%。

将前面的输出与输入数据未缩放的情况进行对比,在这种情况下,训练损失没有持续减少,并且在五个时期结束时训练数据集的准确性仅为 12%。

让我们深入探讨一下缩放在这里有所帮助的可能原因。

让我们以如何计算 sigmoid 值为例:

在下表中,我们根据前面的公式计算了 Sigmoid 列:

在左侧表格中,我们可以看到,当权重值大于 0.1 时,Sigmoid 值不会随着权重值的增加(变化)而变化。此外,当重量极小时,Sigmoid 值仅变化很小;改变 sigmoid 值的唯一方法是将重量改变到非常非常小的量。

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

其原因是大负值的指数(由权重值乘以大数值得到)非常接近于 0,而当权重值乘以比例输入时,指数值会发生变化,如右侧表格所示。

现在我们已经知道,除非权重值非常小,否则 Sigmoid 值不会发生显著变化,我们现在将了解如何影响权重值,使其趋向最佳值。

缩放输入数据集以使其包含更小范围的值通常有助于实现更高的模型精度。

接下来,我们将了解任何神经网络的其他主要超参数之一的影响:批量大小。

了解改变批量大小的影响

在上一节中,训练数据集中每批考虑 32 个数据点。这导致每个时期更大数量的权重更新,因为每个时期有 1,875 个权重更新(60,000/32 几乎等于 1,875,其中 60,000 是训练图像的数量)。

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

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

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

既然我们已经引入了验证数据,那么让我们重新运行在构建神经网络部分中提供的代码,使用额外的代码来生成验证数据,以及计算验证数据集的损失和准确度值。

The code for the Batch size of 32 and Batch size of 10,000 sections is available as Varying_batch_size.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

32 件的批量

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

  1. 下载并导入训练图像和目标:
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)
tr_images = fmnist.data
tr_targets = fmnist.targets
  1. 与训练图像类似,我们必须通过指定train = False来下载和导入验证数据集,同时在我们的数据集中调用FashionMNIST方法:
val_fmnist =datasets.FashionMNIST(data_folder,download=True, \
                                                 train=False)
val_images = val_fmnist.data
val_targets = val_fmnist.targets
  1. 导入相关包并定义device:
import matplotlib.pyplot as plt
%matplotlib inline
import numpy as np
from torch.utils.data import Dataset, DataLoader
import torch
import torch.nn as nn
device = 'cuda' if torch.cuda.is_available() else 'cpu'
  1. 定义数据集类(FashionMNIST)、将用于对一批数据进行训练的函数(train_batch)、计算准确度(accuracy),然后定义模型架构、损失函数和优化器(get_model)。请注意,用于获取数据的函数将是唯一与我们在前面章节中看到的有所不同的函数(因为我们现在正在处理定型和验证数据集),因此我们将在下一步中构建它:
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)

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 = Adam(model.parameters(), lr=1e-2)
    return model, loss_fn, optimizer

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()

def accuracy(x, y, model):
 model.eval()
    # this is the same as @torch.no_grad 
    # at the top of function, only difference
    # being, grad is not computed in the with scope
 with torch.no_grad():
        prediction = model(x)
    max_values, argmaxes = prediction.max(-1)
    is_correct = argmaxes == y
    return is_correct.cpu().numpy().tolist()
  1. 定义一个将获取数据的函数;也就是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

在前面的代码中,除了我们之前看到的train对象之外,我们还创建了一个名为valFMNISTDataset类的对象。此外,用于验证的数据加载器(val_dl)的批次大小为len(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. 获取培训和验证数据加载器。此外,初始化模型、损失函数和优化器:
trn_dl, val_dl = get_data()
model, loss_fn, optimizer = get_model()
  1. 训练模型,如下所示:
  • 初始化包含训练和验证准确性以及递增时期的损失值的列表:
train_losses, train_accuracies = [], []
val_losses, val_accuracies = [], []
  • 遍历五个时期,并初始化包含给定时期内各批训练数据的准确度和损失的列表:
for epoch in range(5):
    print(epoch)
    train_epoch_losses, train_epoch_accuracies = [], []
  • 循环遍历一批训练数据,计算一个历元内的精度(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)
  • 计算一批验证数据内的损失值和准确度(因为验证数据的批量等于验证数据的长度):
    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)

注意,在前面的代码中,验证数据的丢失值是使用val_loss函数计算的,并存储在validation_loss变量中。此外,所有验证数据点的精度存储在val_is_correct列表中,而其平均值存储在val_epoch_accuracy变量中。

  • 最后,我们将训练和验证数据集的准确度和损失值附加到包含历元级聚合验证和准确度值的列表。我们这样做是为了在下一步中查看纪元级别的改进:
    train_losses.append(train_epoch_loss)
    train_accuracies.append(train_epoch_accuracy)
    val_losses.append(validation_loss)
    val_accuracies.append(val_epoch_accuracy)
  1. 直观显示训练和验证数据集中准确性和损失值随时间推移的提高情况:
epochs = np.arange(5)+1
import matplotlib.ticker as mtick
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
%matplotlib inline
plt.subplot(211)
plt.plot(epochs, train_losses, 'bo', label='Training loss')
plt.plot(epochs, val_losses, 'r', label='Validation loss')
plt.gca().xaxis.set_major_locator(mticker.MultipleLocator(1))
plt.title('Training and validation loss \
when batch size is 32')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.grid('off')
plt.show()
plt.subplot(212)
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 \
when batch size is 32')
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()

前面的代码给出了以下输出:

如您所见,当批次大小为 32 时,在五个时期结束时,训练和验证准确度约为 85%。接下来,当在get_data函数中训练数据加载器时,我们将改变batch_size参数,以查看它在五个时期结束时对准确性的影响。

10,000 件的批量

在本节中,我们将使用每批 10,000 个数据点,以便我们可以了解改变批量大小会产生什么影响。

请注意,除了步骤 5 中的代码之外,32 部分的批量中提供的代码在这里保持完全相同。这里,我们将在get_data函数中为训练和验证数据集指定数据加载器。我们鼓励您在执行代码时参考本书的 GitHub 资源库中的相应笔记本。

我们将修改get_data,使其在从训练数据集中获取训练数据加载器时具有 10,000 的批处理大小,如下所示:

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

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

在这里,我们可以看到准确性和损失值没有达到与前一个场景相同的级别,在前一个场景中,批量大小为 32,因为当批量大小为 32 (1875)时,时间权重更新的次数较少。在批量大小为 10,000 的情况下,每个时期有六个权重更新,因为每批有 10,000 个数据点,这意味着总的训练数据大小为 60,000。

到目前为止,我们已经了解了如何缩放数据集,以及改变批量大小对模型训练时间的影响,以实现一定的准确性。在下一节中,我们将了解在同一个数据集上改变丢失优化器的影响。

当您有少量的历元时,较低的批处理大小通常有助于实现最佳的准确性,但是它不应该太低而影响训练时间。

了解改变损失优化器的影响

到目前为止,我们一直在优化基于 Adam 优化器的损失。在本节中,我们将执行以下操作:

  • 修改优化器,使其成为一个随机梯度下降 ( 新币)优化器
  • 在数据加载器中提取数据时,恢复为 32 的批处理大小
  • 将时期数增加到 10(这样我们就可以在更长的时期内比较 SGD 和 Adam 的性能)

做出这些改变意味着 32 段的批量中只有一步会改变(因为在 32 段的批量中批量已经是 32);也就是说,我们将修改优化器,使其成为 SGD 优化器。

让我们修改 32 部分的批量的步骤 4* 中的get_model函数,以便修改 optimzier,这样我们就可以使用 SGD 优化器,如下所示:*

The following code is available as Varying_loss_optimizer.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Note that we are not providing all the steps for brevity and that only the steps where we're making a change from the code we went through in the Batch size of 32 section will be discussed in the following code. We encourage you to refer to the respective notebooks in this book's GitHub repository while executing the code.

  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 中的时期数,同时保持所有其他步骤(除了步骤 4步骤 8 )与它们在 32 部分的批量中相同。

  1. 增加我们将用于训练模型的纪元数量:
train_losses, train_accuracies = [], []
val_losses, val_accuracies = [], []
for epoch in range(10):
    train_epoch_losses, train_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)
        validation_loss = val_loss(x, y, model)
    val_epoch_accuracy = np.mean(val_is_correct)

    train_losses.append(train_epoch_loss)
    train_accuracies.append(train_epoch_accuracy)
    val_losses.append(validation_loss)
    val_accuracies.append(val_epoch_accuracy)

进行这些更改后,一旦我们按顺序执行 32 个部分的批量中的所有剩余步骤,训练和验证数据集的准确度和损失值在增加的时期内的变化将如下:

让我们在优化器为 Adam 的情况下,针对不断增加的时期的训练和验证损失以及准确性变化获取相同的输出。这就要求我们将步骤 4 中的优化器改为 Adam。

一旦进行了这种改变并且执行了代码,训练和验证数据集的准确度和损失值的变化如下:

如您所见,当我们使用 Adam 优化器时,准确率仍然非常接近 85%。但是,请注意,到目前为止,学习率为 0.01。

在下一节中,我们将了解学习率对验证数据集准确性的影响。

某些优化器比其他优化器更快地达到最佳精度。Adam 通常能更快地达到最佳精度。其他一些著名的优化器包括 Adagrad、Adadelta、AdamW、LBFGS 和 RMSprop。

理解改变学习速度的影响

到目前为止,我们在训练模型时一直使用 0.01 的学习率。在第一章人工神经网络基础中,我们了解到学习速率在获得最佳权重值方面起着关键作用。这里,当学习率小时,权重值逐渐向最佳值移动,而当学习率大时,权重值在非最佳值振荡。我们在第一章人工神经网络基础中处理了一个玩具数据集,所以我们将在这一部分处理一个现实场景。

为了理解变化的学习率的影响,我们将经历以下场景:

  • 规模数据集上的学习率更高(0.1)
  • 缩放数据集的学习率较低(0.00001)
  • 未缩放数据集的学习率较低(0.001)
  • 在非缩放数据集上的学习率更高(0.1)

总之,在本节中,我们将了解各种学习率值对缩放数据集和非缩放数据集的影响。

在本节中,我们将了解学习率对未缩放数据的影响,尽管我们已经确定缩放数据集是有帮助的。我们再次这样做是因为我们想让您直观地了解在模型能够适应数据的情况和模型不能适应数据的情况之间,权重的分布是如何变化的。

现在,让我们了解模型如何在缩放数据集上学习。

学习率对规模数据集的影响

在本节中,我们将根据以下内容对比训练和验证数据集的准确性:

  • 高学习率
  • 中等学习速度
  • 学习率低

以下三小节的代码可以在本书的 GitHub 资源库【https://tinyurl.com/mcvp-packtChapter03文件夹中的Varying_learning_rate_on_scaled_data.ipynb中找到。注意,为了简洁起见,我们没有提供所有的步骤;下面的代码将只讨论与我们在32部分中所经历的代码有所不同的步骤。我们鼓励您在执行代码时参考本书的 GitHub 资源库中相应的笔记本。

我们开始吧!

高学习率

在本节中,我们将采用以下策略:

  • 当我们使用 Adam 优化器时,我们需要执行的步骤将与 32 部分的批处理大小完全相同。
  • 当我们定义get_model函数时,唯一的变化是optimizer中的学习率。这里,我们将把学习率(lr)的值改为 0.1。

请注意,除了我们将在本节中对get_model函数进行的修改之外,所有代码都与 32 节中的批量相同。

要修改学习率,我们必须在optimizer的定义中进行更改,这可以在get_model函数中找到,如下所示:

def get_model():
    model = nn.Sequential(
                nn.Linear(28 * 28, 1000),
                nn.ReLU(),
                nn.Linear(1000, 10)
            ).to(device)

    loss_fn = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters(), lr=1e-1)
    return model, loss_fn, optimizer

注意,在前面的代码中,我们修改了优化器,使其学习率为 0.1 ( lr=1e-1)。

一旦我们执行了 GitHub 中提供的所有剩余步骤,对应于训练和验证数据集的准确度和损失值将如下所示:

请注意,验证数据集的准确度约为 25%(与我们在学习率为 0.01 时达到的约 85%的准确度相比)。

在下一节中,我们将了解学习率为中等(0.001)时验证数据集的准确性。

中等学习速度

在本节中,我们将通过修改get_model函数并从头开始重新训练模型,将优化器的学习率降低到 0.001。

get_model功能修改后的代码如下:

def get_model():
    model = nn.Sequential(
                nn.Linear(28 * 28, 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

注意,在前面的代码中,由于我们修改了lr参数值,学习率已经降低到一个很小的值。

一旦我们执行了 GitHub 中提供的所有剩余步骤,对应于训练和验证数据集的准确度和损失值将如下所示:

从前面的输出可以看出,当学习率(or)从 0.1 降低到 0.001 时,模型训练成功

在下一节中,我们将进一步降低学习率。

学习率低

在本节中,我们将通过修改get_model函数并从头开始重新训练模型,将优化器的学习率降低到 0.00001。此外,我们将运行模型更长的时期(100)。

我们将为get_model函数使用的修改后的代码如下:

def get_model():
    model = nn.Sequential(
                nn.Linear(28 * 28, 1000),
                nn.ReLU(),
                nn.Linear(1000, 10)
            ).to(device)

    loss_fn = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters(), lr=1e-5)
    return model, loss_fn, optimizer

注意,在前面的代码中,由于我们修改了lr参数值,学习率已经降低到一个非常小的值。

一旦我们执行了 GitHub 中提供的所有剩余步骤,对应于训练和验证数据集的准确度和损失值将如下所示:

从上图中,我们可以看到,与之前的场景相比,该模型的学习速度要慢得多(中等学习速度)。这里,与学习率为 0.001 时的八个时期相比,需要大约 100 个时期才能达到大约 89%的准确度。

此外,我们还应该注意到,当学习率与之前的场景相比较低时,训练和验证损失之间的差距要小得多(在之前的场景中,在时期 4 的末尾存在类似的差距)。其原因是当学习率低时,权重更新低得多,这意味着训练和验证损失之间的差距不会迅速扩大。

到目前为止,我们已经了解了学习率对训练和验证数据集准确性的影响。在下一节中,我们将了解对于不同的学习率值,权重值的分布如何在各层之间变化。

不同学习速率的跨层参数分布

在前面的章节中,我们了解到在高学习率(0.1)的情况下,模型无法被训练(模型训练不足)。然而,我们可以训练模型,以便当学习率为中等(0.001)或低(0.00001)时,它具有相当高的准确性。在这里,我们看到中等学习率能够快速地过度拟合,而低学习率需要更长的时间才能达到与中等学习率模型相当的精度。

在本节中,我们将了解参数分布如何成为模型过拟合和欠拟合的良好指标。

到目前为止,我们的模型中有四个参数组:

  • 将输入层连接到隐藏层的层中的权重
  • 隐藏层中的偏差
  • 将隐藏层连接到输出层的层中的权重
  • 输出层中的偏置

让我们使用下面的代码来看看这些参数的分布情况(我们将为每个模型执行下面的代码):

for ix, par in enumerate(model.parameters()):
    if(ix==0):
        plt.hist(par.cpu().detach().numpy().flatten())
        plt.title('Distribution of weights conencting \
                    input to hidden layer')
        plt.show()
    elif(ix ==1):
        plt.hist(par.cpu().detach().numpy().flatten())
        plt.title('Distribution of biases of hidden layer')
        plt.show()
    elif(ix==2):
        plt.hist(par.cpu().detach().numpy().flatten())
        plt.title('Distribution of weights conencting \
                    hidden to output layer')
        plt.show()
    elif(ix ==3):
        plt.hist(par.cpu().detach().numpy().flatten())
        plt.title('Distribution of biases of output layer')
        plt.show()

注意model.parameters将因我们绘制分布图的型号而异。上述代码在三种学习速率下的输出如下:

在这里,我们可以看到以下内容:

  • 当学习率高时,与中等和低学习率相比,参数具有大得多的分布。
  • 当参数分布较大时,会出现过度拟合。

到目前为止,我们已经研究了改变学习率对在规模数据集上训练的模型的影响。在下一节中,我们将了解改变学习率对在非缩放数据上训练的模型的影响。

请注意,尽管我们已经确定始终缩放输入值更好,但我们将继续确定在非缩放数据集上训练模型的影响。

改变学习率对未缩放数据集的影响

在这一节中,我们将通过在定义数据集的类中不执行除以 255 来恢复对数据集的操作。可以这样做:

The code for this section is available as Varying_learning_rate_on_non_scaled_data.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Note that we are not providing all the steps for brevity and that only the steps where there is a change from the code we went through in the Batch size of 32 section will be discussed in the following code. We encourage you to refer to the notebooks in this book's GitHub repository while executing the code.

class FMNISTDataset(Dataset):
    def __init__(self, x, y):
        x = x.float() # Note that the data is not scaled in this 
        # scenario
        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,这是我们在缩放数据集时执行的。

通过改变跨时期的准确度和损失值来改变学习率的结果如下:

正如我们所看到的,即使数据集是非缩放的,当学习率为 0.1 时,我们也无法训练出准确的模型。此外,当学习率为 0.001 时,精度不如前一部分中的精度高。

最后,当学习率非常小(0.00001)时,模型能够像在前面的部分中一样学习,但是这次过度拟合了训练数据。让我们通过研究跨层的参数分布来理解为什么会发生这种情况,如下所示:

这里,我们可以看到,当模型精度高时(当学习率为 0.00001 时),与学习率高时相比,权重具有小得多的范围(在这种情况下通常在-0.05 到 0.05 之间)。

因为学习率小,所以权重可以向小值调整。请注意,在非缩放数据集上学习率为 0.00001 的情况等同于在缩放数据集上学习率为 0.001 的情况。这是因为权重现在可以向非常小的值移动(因为梯度*学习率是非常小的值,假设学习率很小)。

既然我们已经确定了高学习率不可能在缩放数据集和非缩放数据集上产生最佳结果,那么在下一节中,我们将了解如何在模型开始过度拟合时自动降低学习率。

通常,0.001 的学习率是有效的。学习率非常低意味着需要很长时间来训练模型,而学习率很高会导致模型变得不稳定。

了解学习率退火的影响

到目前为止,我们已经初始化了一个学习率,在训练模型时,它在所有时期都保持不变。然而,最初,将权重快速更新到接近最优的情况是直观的。从那时起,它们应该非常缓慢地更新,因为最初减少的损失量很高,而在以后的时期减少的损失量很低。

这要求在开始时有一个高的学习率,然后随着模型达到接近最优的精度,逐渐降低学习率。这就需要我们了解什么时候必须降低学习率。

我们可以解决这个问题的一个潜在方法是通过持续监控验证损失,如果验证损失没有减少(比如说,在之前的 x 个时期),那么我们降低学习率。

PyTorch 为我们提供了一些工具,当验证损失在前一个“x”时期没有减少时,我们可以使用这些工具来降低学习率。在这里,我们可以使用lr_scheduler方法:

from torch import optim
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer,
                                    factor=0.5,patience=0,
                                    threshold = 0.001, 
                                    verbose=True, 
                                    min_lr = 1e-5, 
                                    threshold_mode = 'abs')

在前面的代码中,我们指定,如果某个值在接下来的 n 个时期(这里 n 是 0)没有提高threshold(这里是 0.001),我们将把optimizer的学习率参数减少factor0.5。最后,我们指定学习率min_lr(假定它以 0.5 的因子减少)不能低于 1e-5,并且threshold_mode应该是绝对的,以确保越过最小阈值 0.001。

现在我们已经了解了调度程序,让我们在训练模型时应用它。

与前几节相似,所有代码与 32 节的批次大小相同,除了此处显示的粗体代码,该代码是为计算验证损失而添加的:

The code for this section is available as Learning_rate_annealing.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Note that we are not providing all the steps for brevity and that only the steps where there is a change from the code we went through in the Batch size of 32 section will be discussed in the following code. We encourage you to refer to the notebooks in this book's GitHub repository while executing the code.

from torch import optim
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, 
 factor=0.5, patience=0, 
 threshold = 0.001, 
 verbose=True, 
 min_lr = 1e-5, 
 threshold_mode = 'abs')
train_losses, train_accuracies = [], []
val_losses, val_accuracies = [], []
for epoch in range(30):
    #print(epoch)
    train_epoch_losses, train_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)
        validation_loss = val_loss(x, y, model)
        scheduler.step(validation_loss)
    val_epoch_accuracy = np.mean(val_is_correct)

    train_losses.append(train_epoch_loss)
    train_accuracies.append(train_epoch_accuracy)
    val_losses.append(validation_loss)
    val_accuracies.append(val_epoch_accuracy)

在前面的代码中,我们指定只要验证损失在连续的时期内没有减少,就应该激活调度程序。在这些情况下,学习率降低到当前学习率的 0.5 倍。

在我们的模型上执行此操作的输出如下:

让我们了解随着时代的增加,训练和验证数据集的准确性和损失值的变化:

注意,每当验证损失在增加的时期内增加至少 0.001,学习率就减少一半。这发生在 5、8、11、12、13、15 和 16 世纪。

此外,我们没有任何巨大的过度拟合问题,即使我们训练了 100 个纪元的模型。这是因为学习率变得如此之小,以至于权重更新非常小,导致训练和验证精度之间的差距更小(与我们有 100 个时期而没有学习率退火的情况相比,训练精度接近 100%,而验证精度接近 89%)。

到目前为止,我们已经了解了各种超参数对模型准确性的影响。在下一节中,我们将了解神经网络的层数如何影响其准确性。

构建更深层次的神经网络

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

网络中有两层的模型可以按如下方式构建(注意,我们将第二个隐藏层中的单元数设置为 1000)。修改后的get_model函数(来自 32 部分的批量中的代码),有两个隐藏层,如下所示:

The following code is available as Impact_of_building_a_deeper_neural_network.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Note that we are not providing all the steps for brevity and that only the steps where there is a change from the code we went through in the Batch size of 32 section will be discussed in the following code. We encourage you to refer to the notebooks in this book's GitHub repository while executing the code.

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 部分中那样训练模型,训练和验证数据集的准确度和损失将如下:

在此,请注意以下几点:

  • 当没有隐藏层时,模型不能很好地学习。

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

到目前为止,在不同的部分,我们已经看到,当输入数据没有缩放(缩小到一个小范围)时,模型无法很好地训练。由于在获取隐藏层中的节点值时涉及矩阵乘法,非缩放数据(具有较高范围的数据)也可能出现在隐藏层中(特别是当我们有具有多个隐藏层的深度神经网络时)。在下一节中,我们将学习如何在中间层处理这种不可伸缩的数据。

了解批处理规范化的影响

之前,我们了解到当输入值较大时,当权重值显著变化时,Sigmoid 输出的变化不会产生太大影响。

现在,让我们考虑相反的场景,其中输入值非常小:

当输入值很小时,Sigmoid 输出会发生轻微变化,从而对权重值产生较大变化。

此外,在缩放输入数据部分,我们看到大的输入值对训练精度有负面影响。这表明我们的输入既不能有很小的值,也不能有很大的值。

除了很小或很大的输入值之外,我们还可能会遇到这样的情况:隐藏层中某个节点的值可能会导致很小的数字或很大的数字,从而导致我们之前看到的连接隐藏层和下一层的权重的相同问题。

在这种情况下,批处理规范化可以解决问题,因为它可以对每个节点的值进行规范化,就像我们缩放输入值一样。

通常,一个批处理中的所有输入值按如下方式缩放:

通过从批次平均值中减去每个数据点,然后除以批次方差,我们已经将节点处批次的所有数据点标准化到固定范围。

虽然这被称为硬归一化,但通过引入γ和β参数,我们让网络识别最佳归一化参数。

为了理解批处理规范化过程的作用,我们来看一下训练和验证数据集的损失值和精度值,以及隐藏图层值在以下场景中的分布情况:

  • 非常小的输入值,没有批量标准化
  • 批量标准化的非常小的输入值

我们开始吧!

非常小的输入值,没有批量标准化

到目前为止,当我们必须缩放输入数据时,我们将其缩放到 0 到 1 之间的值。在本节中,我们将进一步将其缩放到 0 到 0.0001 之间的值,以便我们可以了解缩放数据的影响。正如我们在本节开始时看到的,即使权重值变化很大,小输入值也不会改变 Sigmoid 值。

为了缩放输入数据集,使其具有非常低的值,我们将通过将输入值的范围从 0 减小到 0.0001 来更改通常在FMNISTDataset类中进行的缩放,如下所示:

The following code is available as Batch_normalization.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Note that we are not providing all the steps for brevity and that only the steps where there is a change from the code we went through in the Batch size of 32 section will be discussed in the following code. We encourage you to refer to the notebooks in this book's GitHub repository while executing the code.

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)),我们通过将输入像素值除以 10,000,缩小了输入像素值的范围。

接下来,我们必须重新定义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 ^个索引中,我们将修改函数,使其仅获取预测的第 0 ^个索引,如下所示:

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 ^个索引(因为第 0 ^个索引包含输出层的值)。

现在,当我们运行缩放 数据部分中提供的其余代码时,我们将看到训练和验证数据集中的准确度和损失值在增加的时期内的变化如下:

请注意,在前面的场景中,即使在 100 个时期之后,模型也没有训练好(在前面的部分中,模型在 10 个时期内在验证数据集上训练到大约 90%的准确度,而当前模型只有大约 85%的验证准确度)。

让我们通过研究隐藏值的分布以及参数分布,来了解当输入值的范围很小时,模型不能很好地训练的原因:

请注意,第一个分布表示隐藏层中的值的分布(我们可以看到这些值的范围非常小)。此外,假设输入层和隐藏层值的范围都很小,则权重必须变化很大(对于将输入连接到隐藏层的权重和将隐藏层连接到输出层的权重)。

既然我们已经了解了当输入值的范围很小时,网络的训练效果并不好,那么我们就来了解批处理规范化是如何帮助增加隐藏层中值的范围的。

批量标准化的非常小的输入值

在这一节中,我们将只对上一小节中的代码做一处修改;也就是说,我们将在定义模型架构的同时添加批处理规范化。

修改后的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

注意,在前面的代码中,我们声明了一个执行批处理规范化(nn.BatchNorm1d)的变量(batch_norm)。我们执行nn.BatchNorm1d(1000)的原因是因为每个图像的输出维度是 1000(即隐藏层的 1 维输出)。

此外,在forward方法中,我们在 ReLU 激活之前,通过批量标准化传递隐藏层值的输出。

训练和验证数据集的准确度和损失值在增加的时期内的变化如下:

在这里,我们可以看到模型的训练方式与输入值的范围不是很小时的训练方式非常相似。

让我们了解隐藏层值的分布和权重分布,如前一部分所示:

在这里,我们可以看到,当我们进行批量归一化时,隐藏层值具有较大的分布,而将隐藏层连接到输出层的权重具有较小的分布。模型学习的结果与前几节一样有效。

在训练深度神经网络时,批处理规范化非常有帮助。它帮助我们避免梯度变得如此之小,以至于权重几乎没有更新。

请注意,在前面的场景中,我们比根本没有批处理规范化时更快地获得了高验证准确性。这可能是标准化中间层的结果,从而减少了权重中发生饱和的机会。然而,过度拟合的问题仍有待解决。我们接下来会看这个。

过度拟合的概念

到目前为止,我们已经看到训练数据集的准确性通常超过 95%,而验证数据集的准确性大约为 89%。

本质上,这表明该模型不会对看不见的数据集进行太多的归纳,因为它可以从训练数据集中学习。这也表明模型正在学习训练数据集的所有可能的边缘情况;这些不能应用于验证数据集。

在训练数据集上具有高精度而在验证数据集上具有相当低的精度是指过拟合的情况。

用来减少过度拟合影响的一些典型策略如下:

  • 拒绝传统社会的人
  • 正规化

我们将在接下来的章节中探讨它们的影响。

增加辍学的影响

我们已经知道,每当计算loss.backward()时,都会发生权重更新。通常,我们在网络中有成千上万的参数和成千上万的数据点来训练我们的模型。这为我们提供了一种可能性,即虽然大多数参数有助于合理地训练模型,但某些参数可以针对训练图像进行微调,导致它们的值仅由训练数据集中的少数图像决定。这反过来导致训练数据具有高精度,但是验证数据集不能概括。

Dropout 是一种随机选择指定激活百分比并将其降至 0 的机制。在下一次迭代中,另一组随机隐藏单元被关闭。这样,神经网络不会针对边缘情况进行优化,因为网络不会有太多的机会来调整权重以记忆边缘情况(假设权重不会在每次迭代中更新)。

请注意,在预测过程中,不需要应用 dropout,因为这种机制只能应用于经过训练的模型。此外,权重将在预测(评估)期间自动缩减,以调整权重的大小(因为所有权重在预测时间期间都存在)。

通常,在训练和验证期间,各层会有不同的表现——就像你在辍学案例中看到的那样。因此,您必须使用两种方法之一提前指定模型的模式——model.train()让模型知道它处于训练模式,而model.eval()让它知道它处于评估模式。如果我们不这样做,我们可能会得到意想不到的结果。例如,在下图中,请注意模型(包含 dropout)如何在训练模式下对相同的输入给出不同的预测。但是,当同一个模型处于 eval 模式时,它将抑制 dropout 层并返回相同的输出:

定义架构时,Dropoutget_model函数中指定如下:

The following code is available as Impact_of_dropout.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Note that we are not providing all the steps for brevity and that only the steps where there is a change from the code we went through in the Batch size of 32 section will be discussed in the following code. We encourage you to refer to the notebooks in this book's GitHub repository while executing the code.

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是在线性激活之前指定的。这指定线性激活层中固定百分比的权重不会被更新。

一旦模型训练完成,如 32 个部分的批量,训练和验证数据集的损失和准确度值将如下:

请注意,在前面的方案中,定型数据集和验证数据集的精确度之间的差异没有我们在前面的方案中看到的那么大,因此导致了过度拟合较少的方案。

正规化的影响

除了训练精度远高于验证精度之外,过拟合的另一个特征是某些权重值将远高于其他权重值。高权重值可能是模型在训练数据上学习得非常好的症状(本质上,是对它所看到的内容的 rot 学习)。

虽然 dropout 是一种用于使权重值不会频繁更新的机制,但正则化是我们可以用于此目的的另一种机制。

正则化是一种技术,其中我们惩罚具有高权重值的模型。因此,这是一个双重目标函数——最小化训练数据和权重值的损失。在本节中,我们将了解两种类型的正则化:

  • L1 正则化
  • L2 正则化

The following code is available as Impact_of_regularization.ipynb in the Chapter03 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Note that we are not providing all the steps for brevity and that only the steps where there is a change from the code we went through in the Batch size of 32 section will be discussed in the following code. We encourage you to refer to the notebooks in this book's GitHub repository while executing the code.

我们开始吧!

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 个的批量部分中,训练和验证数据集在增加的时期内的损失和准确度值将如下:

在这里,我们可以看到,训练和验证数据集的准确性之间的差异没有 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 个的批量部分中,训练和验证数据集在增加的时期内的损失和准确度值将如下:

在这里,我们可以看到 L2 正则化也导致了验证和训练数据集的准确性和损失值彼此接近。

最后,让我们比较没有正则化和有 L1/ L2 正则化的权重值,以便我们可以验证我们的理解,即在记忆边缘情况的值时,某些权重变化很大。我们将通过遍历各层的权重分布来实现这一点,如下图所示:

这里,我们可以看到,与不执行正则化相比,当我们执行 L1/ L2 正则化时,参数的分布非常小。这潜在地减少了针对边缘情况更新权重的机会。

摘要

本章一开始,我们学习了图像是如何表现的。接下来,我们了解了扩展、学习率的值、我们选择的优化器以及批量大小如何帮助提高训练的准确性和速度。然后,我们了解了批处理规范化如何帮助提高训练速度,并解决隐藏层中非常小或非常大的值的问题。接下来,我们学习了如何安排学习速率来进一步提高准确性。然后,我们开始理解过度拟合的概念,并了解辍学和 L1 和 L2 正则化如何帮助我们避免过度拟合。

现在,我们已经了解了使用深度神经网络进行图像分类,以及帮助训练模型的各种超参数,在下一章中,我们将了解我们在本章中学习的内容如何失败,以及如何使用卷积神经网络解决这一问题。

问题

  1. 如果输入数据集中的输入值没有缩放,会发生什么情况?
  2. 在训练神经网络时,如果背景是白色像素颜色,而内容是黑色像素颜色,会发生什么?
  3. 批量大小对模型的训练时间有什么影响,以及它在给定数量的时期内的准确性?
  4. 输入值范围对训练结束时的权重分布有什么影响?
  5. 批处理规范化如何帮助提高准确性?
  6. 我们如何知道一个模型是否过度拟合了训练数据?
  7. 正则化如何帮助避免过度拟合?
  8. L1 和 L2 正规化有何不同?
  9. 辍学如何有助于减少过度拟合?

第二部分:对象分类和检测

有了对神经网络 ( NN )基础知识的理解,在本节中,我们将发现构建在这些基础知识之上的更复杂的神经网络模块,以解决更复杂的视觉相关问题,包括目标检测、图像分类以及其他许多问题。

本节包括以下章节:

  • 第四章,介绍卷积神经网络
  • 第五章,用于图像分类的迁移学习
  • 第六章,图像分类实用方面
  • 第七章、目标检测基础知识
  • 第八章、高级物体探测
  • 第九章、图像分割
  • 第十章, 目标检测与分割的应用

四、卷积神经网络简介

到目前为止,我们已经了解了如何建立深度神经网络以及调整其各种超参数的影响。在这一章中,我们将了解传统的深度神经网络不起作用的地方。然后,我们将通过使用一个玩具示例来了解卷积神经网络(CNN)的内部工作方式,然后了解它们的一些主要超参数,包括步长、池和过滤器。接下来,我们将利用 CNN 以及各种数据增强技术来解决传统深度神经网络准确性不高的问题。接下来,我们将了解 CNN 中特征学习过程的结果是什么样的。最后,我们将把我们的学习放在一起解决一个用例:我们将通过声明图像包含一只狗还是一只猫来分类图像。通过这样做,我们将能够理解预测的准确性如何随着可用于训练的数据量而变化。

本章将涵盖以下主题:

  • 传统深度神经网络的问题是
  • CNN 的构建模块
  • 实现 CNN
  • 使用深度细胞神经网络分类图像
  • 实现数据扩充
  • 可视化特征学习的结果
  • 构建用于分类真实世界图像的 CNN

我们开始吧!

传统深度神经网络的问题是

在我们深入 CNN 之前,让我们看看使用传统深度神经网络时面临的主要问题。

让我们重新考虑一下我们在第三章、用 PyTorch 建立深度神经网络中在时尚-MNIST 数据集上建立的模型。我们将获取一个随机图像,并预测对应于该图像的类,如下所示:

The code for this section is available as Issues_with_image_translation.ipynb in the Chapter04 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Note that the entire code is available in GitHub and that only the additional code corresponding to the issue of image translation will be discussed here for brevity. We strongly encourage you to refer to the notebooks in this book's GitHub repository while executing the code.

  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]])

上述代码会产生以下输出:

  1. 将图像通过训练过的模型(继续使用我们在第三章、批量 32* 部分训练过的模型,用 PyTorch* 构建深度神经网络)。
  • 预处理图像,使其经过我们在构建模型时执行的相同预处理步骤:
img = tr_images[ix]/255.
img = img.view(28*28)
img = img.to(device)
  • 提取与各种类别相关的概率:
np_output = model(img).cpu().detach().numpy()
np.exp(np_output)/np.sum(np.exp(np_output))

上述代码会产生以下输出:

从前面的输出中,我们可以看到第 1 个 ^(st) 索引的概率最高,它属于Trouser类。

  1. 将图像平移(滚动/滑动)多次(一次一个像素),从向左平移 5 个像素到向右平移 5 个像素,并将预测存储在列表中。
  • 创建存储预测的列表:
preds = []
  • 创建一个循环,将图像从原始位置(位于图像中心)的-5 像素(向左 5 像素)平移(滚动)到+5 像素(向右 5 像素):
for px in range(-5,6):

在前面的代码中,我们将 6 指定为上限,尽管我们感兴趣的是平移到+5 像素,因为当(-5,6)是指定的范围时,范围的输出将是从-5 到+5。

  • 预处理图像,正如我们在步骤 2 中所做的:
    img = tr_images[ix]/255.
    img = img.view(28, 28)
  • for循环中将图像滚动一个等于px的值:
    img2 = np.roll(img, px, axis=1)

在前面的代码中,我们指定了axis=1,因为我们希望图像像素水平移动,而不是垂直移动。

  • 将滚动图像存储为张量对象,并注册到device:
    img3 = torch.Tensor(img2).view(28*28).to(device)
  • img3传递给训练好的模型,以预测翻译(滚动)图像的类别,并将其添加到存储各种翻译预测的列表中:
    np_output = model(img3).cpu().detach().numpy()
    preds.append(np.exp(np_output)/np.sum(np.exp(np_output)))
  1. 可视化所有平移的模型预测(-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')

上述代码会产生以下输出:

图像的内容没有变化,因为我们只将图像向左平移了 5 个像素,向右平移了 5 个像素。然而,当平移超过 2 个像素时,图像的预测类别发生变化。这是因为当模型被训练时,所有训练和测试图像中的内容都在中心。这与前面的场景不同,在前面的场景中,我们使用偏离中心的翻译图像进行测试,从而导致错误预测的类。

既然我们已经了解了传统神经网络失败的场景,我们将了解CNN如何帮助解决这个问题。但在此之前,我们将了解 CNN 的组成部分。

CNN 的构建模块

CNN 是处理图像时最常用的架构。CNN 解决了我们在上一节中看到的深度神经网络的主要限制。除了图像分类之外,它们还有助于目标检测、图像分割、GANs 等等——基本上,在我们使用图像的任何地方。此外,有不同的方法来构建卷积神经网络,并且有多个预训练模型来利用 CNN 执行各种任务。从本章开始,我们将广泛使用 CNN。

在接下来的小节中,我们将了解 CNN 的基本构建模块,如下所示:

  • 回旋
  • 过滤
  • 步幅和衬垫
  • 联营

我们开始吧!

盘旋

卷积基本上是两个矩阵之间的乘法。正如你在前一章看到的,矩阵乘法是训练神经网络的一个关键因素。(我们在计算隐藏层值时执行矩阵乘法,这是输入值和将输入连接到隐藏层的权重值的矩阵乘法。同样,我们执行矩阵乘法来计算输出图层值。)

为了确保我们对卷积过程有一个坚实的理解,让我们来看看下面的例子。

让我们假设我们有两个矩阵可以用来执行卷积。

这是矩阵 A:

这是矩阵 B:

在执行卷积运算时,您在矩阵 A(较大的矩阵)上滑动矩阵 B(较小的矩阵)。此外,我们正在矩阵 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

执行上述操作的结果如下:

较小的矩阵通常被称为滤波器或内核,而较大的矩阵是原始图像。

过滤器

过滤器是在开始时随机初始化的权重矩阵。该模型在增加的时期内学习过滤器的最佳权重值。

过滤器的概念带给我们两个不同的方面:

  • 过滤器了解什么
  • 如何表示过滤器

一般来说,CNN 中的过滤器越多,模型可以了解的图像特征就越多。我们将在本章的可视化过滤器学习部分了解各种过滤器学习的内容。现在,我们将确保我们有一个中间的理解,即过滤器了解图像中存在的不同特征。例如,某个过滤器可能会学习猫的耳朵,并在与其卷积的图像部分包含猫的耳朵时提供高激活度(矩阵乘法值)。

在上一节中,我们了解到,当我们将一个大小为 2 x 2 的滤波器与一个大小为 4 x 4 的矩阵进行卷积时,我们得到的输出维度为 3 x 3。

然而,如果 10 个不同的滤波器乘以较大的矩阵(原始图像),结果是 10 组 3×3 输出矩阵。

在前一种情况下,一个 4 x 4 的图像与 10 个大小为 2 x 2 的滤波器进行卷积,得到 3 x 3 x 10 的输出值。本质上,当图像被多个滤波器卷积时,输出的通道数与图像被卷积的滤波器数相同。

此外,在我们处理有三个通道的彩色图像的情况下,与原始图像卷积的滤波器也将有三个通道,导致每个卷积只有一个标量输出。此外,如果滤波器与中间输出进行卷积,比如形状为 64 x 112 x 112,滤波器将有 64 个通道来获取标量输出。此外,如果有 512 个滤波器与在中间层获得的输出进行卷积,那么与 512 个滤波器进行卷积后的输出在形状上将是 512×111×111。

为了进一步巩固我们对滤波器输出的理解,让我们看一下下图:

在上图中,我们可以看到输入图像与深度与输入图像相同的滤波器相乘(滤波器与输入图像进行卷积),卷积输出中的通道数量与滤波器数量相同。

步幅和衬垫

在前面的部分中,每个过滤器都在图像中大步前进——一次一列和一行(在图像结束时用尽所有可能的列之后)。这也导致输出尺寸比输入图像尺寸小 1 个像素——在高度和宽度方面都是如此。这会导致部分信息丢失,并可能影响我们将卷积运算的输出添加到原始图像的可能性(这称为残差加法,将在下一章详细讨论)。

在本节中,我们将了解步长和填充如何影响卷积的输出形状。

大步走

让我们利用在过滤器部分看到的同一个例子来理解 stride 的影响。此外,我们将在矩阵 a 上以步长 2 跨越矩阵 B。步长为 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

执行上述操作的结果如下:

请注意,与跨距为 1 的场景(输出形状为 3 x 3)相比,前面的输出具有更低的维度,因为我们现在的跨距为 2。

填充

在前面的例子中,我们不能将过滤器最左边的元素乘以图像最右边的元素。如果我们要执行这样的矩阵乘法,我们将用零填充图像。这将确保我们可以使用过滤器对图像中的所有元素执行元素到元素的乘法。

让我们通过使用在卷积部分中使用的相同示例来理解填充。

一旦我们在矩阵 A 的顶部添加了填充,矩阵 A 的修订版将如下所示:

从前面的矩阵中,我们可以看到,我们已经用零填充了矩阵 A,并且与矩阵 B 的卷积不会导致输出维度小于输入维度。当我们在残差网络上工作时,这个方面很方便,我们必须将卷积的输出添加到原始图像中。

一旦我们完成了这些,我们就可以在卷积运算的输出之上执行激活。为此,我们可以使用在第三章、用 PyTorch 构建深度神经网络中看到的任何激活函数。

联营

池化将信息聚集在一个小块中。想象一个场景,其中卷积激活的输出如下:

此修补程序的最大池是 4。这里,我们已经考虑了这个元素池中的元素,并在所有存在的元素中取最大值。

同样,让我们了解一下更大矩阵的最大池:

在前面的情况下,如果池化跨度的长度为 2,则最大池化操作的计算如下,其中我们将输入图像除以跨度 2(即,我们将图像分成 2×2 个部分):

对于矩阵的四个子部分,元素池中的最大值如下:

在实践中,不需要总是具有 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}

通过这样做,我们将看到展平层可以被视为等同于输入层(我们在第三章的中使用 PyTorch 将输入图像展平为 784 维输入)。一旦获得展平层(完全连接层)的值,我们可以通过隐藏层传递它,然后获得预测图像类别的输出。

CNN 的总体流程如下:

在前面的图像中,我们可以看到 CNN 模型的整体流程,其中我们通过多个过滤器将图像通过卷积,然后合并(在前面的情况中,重复卷积和合并过程两次),然后平坦化最终合并层的输出。这形成了前面图像的特征学习部分。

卷积和汇集的操作构成了特征学习部分,因为过滤器有助于从图像中提取相关特征,而汇集有助于聚合信息,从而减少展平层的节点数量。(如果我们直接展平输入图像(例如,大小为 300 x 300 像素),我们处理的是 90K 输入值。如果我们在一个隐藏层中有 90K 个输入像素值和 100K 个节点,我们会看到大约 90 亿个参数,这在计算方面是巨大的。)

卷积和池化有助于获取比原始图像更小的展平图层。

最后,分类的最后一部分类似于我们在第三章、在 PyTorch 中构建深度神经网络中分类图像的方式,在这里我们有一个隐藏层,然后获得输出层。

卷积和汇集如何帮助图像翻译

当我们执行池化时,我们可以将操作的输出视为一个区域的抽象(一小块)。这种现象会派上用场,尤其是在翻译图像的时候。

想象一个图像向左平移 1 个像素的场景。一旦我们在其上执行卷积、激活和合并,我们将减少图像的维度(由于合并),这意味着更少数量的像素存储了原始图像的大部分信息。此外,假定汇集存储区域(斑块)的信息,则汇集图像的像素内的信息不会变化,即使原始图像被平移 1 个单位。这是因为该区域的最大值可能会在合并的图像中被捕获。

卷积和汇集 cam 也帮助我们处理感受野。为了理解感受野,让我们想象一个场景,其中我们在形状为 100 x 100 的图像上执行两次卷积池操作。两个卷积池操作结束时的输出是 25 x 25 的形状(如果卷积操作是用填充完成的)。25 x 25 输出中的每个单元现在对应于原始图像的一个更大的 4 x 4 部分。因此,由于卷积和池化操作,结果图像中的每个单元对应于原始图像的一个小块。

现在我们已经了解了 CNN 的核心组件,让我们将它们全部应用到一个玩具示例中,以了解它们是如何协同工作的。

实现 CNN

CNN 是计算机视觉技术的基础之一,对你来说,深入了解它们是如何工作的非常重要。虽然我们已经知道 CNN 由卷积、汇集、展平以及最终的分类层组成,但在本节中,我们将了解在通过代码向前传递 CNN 的过程中发生的各种操作。

为了更好地理解这一点,首先,我们将使用 PyTorch 在一个玩具示例上构建一个 CNN 架构,然后通过用 Python 从头构建前馈传播来匹配输出。

使用 PyTorch 构建基于 CNN 的架构

CNN 架构将不同于我们在上一章中构建的神经网络架构,因为除了典型的普通深度神经网络之外,CNN 还包含以下内容:

  • 卷积运算
  • 联营业务
  • 展平层

在下面的代码中,我们将在玩具数据集上构建一个 CNN 模型,如下所示:

The code for this section is available as CNN_working_details.ipynb in the Chapter04 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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
  1. 然后,我们需要使用以下步骤创建数据集:
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. 定义模型架构:
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()),因为输出来自二进制类。我们还指定优化将使用 Adam 优化器完成,学习率为 0.001。

  1. 使用torch_summary包中可用的summary方法总结模型的架构,通过调用get_model函数获取我们的model、损失函数(loss_fn)和optimizer:
!pip install torch_summary
from torchsummary import summary
model, loss_fn, optimizer = get_model()
summary(model, X_train);

上述代码会产生以下输出:

我们来理解一下每一层包含这么多参数的原因。Conv2d类的参数如下:

在前面的例子中,我们指定卷积核(kernel_size)的大小为 3,out_channels的数量为 1(本质上,滤波器的数量为 1),其中初始(输入)通道的数量为 1。因此,对于每个输入图像,我们在 1 x 4 x 4 的形状上卷积形状为 3 x 3 的滤波器,这导致形状为 1 x 2 x 2 的输出。有 10 个参数,因为我们正在学习 9 个权重参数(3×3)和卷积核的一个偏差。对于 MaxPool2d、ReLU 和 Flatten 层,没有参数,因为这些是在卷积层的输出之上执行的操作。不涉及任何权重或偏见。

  • 线性图层有两个参数-一个权重和一个偏差-这意味着总共有 12 个参数(10 个来自卷积运算,两个来自线性图层)。
  1. 使用我们在第三章、使用 PyTorch 构建深度神经网络中使用的相同模型训练代码来训练模型,其中我们定义了将对批量数据进行训练的函数(train_batch)。然后,获取数据加载器,并对超过 2,000 个历元的批数据进行训练(我们只使用 2,000 个,因为这是一个小的玩具数据集),如下所示:
  • 定义将对批量数据进行训练的函数(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()
  • 通过使用TensorDataset方法指定数据集,然后使用DataLoader加载数据集,定义训练数据加载器:
trn_dl = DataLoader(TensorDataset(X_train, y_train))

注意,假设我们没有大量修改输入数据,我们将不会单独构建一个类,而是直接利用TensorDataset方法,它提供了一个对应于输入数据的对象。

  • 训练模型超过 2000 个时期:
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)

使用前面的代码,我们在玩具数据集上训练了 CNN 模型。

  1. 在第一个数据点的顶部执行向前传递:
model(X_train[:1])

前面代码的输出是0.1625

请注意,在执行前面的代码时,由于不同的随机权重初始化,您可能会有不同的输出值。但是,您应该能够将输出与下一节中得到的内容进行匹配。

在下一节中,我们将了解 CNN 中的前向传播是如何工作的,以便我们可以在第一个数据点上获得 0.1625 的值。

用 Python 向前传播输出

在我们继续之前,请注意本节只是为了帮助您清楚地了解 CNN 是如何工作的。在真实场景中,我们不需要执行以下步骤:

  1. 提取已定义架构的卷积和线性层的权重和偏差,如下所示:
  • 提取模型的各个层:
list(model.children())

这会产生以下输出:

  • 从模型的所有层中提取与weight属性相关联的层:
(cnn_w, cnn_b), (lin_w, lin_b) = [(layer.weight.data, \
                            layer.bias.data) for layer in \
                            list(model.children()) \
                                  if hasattr(layer,'weight')]

在前面的代码中,hasattr(layer,'weight')返回一个布尔值,而不管图层是否包含weight属性。

请注意,卷积(Conv2d)层和最后的Linear层是唯一包含参数的层,这就是为什么我们将它们分别保存为Conv2d层的cnn_wcnn_b以及Linear层的lin_wlin_b

cnn_w的形状是 1×1×3×3,因为我们已经初始化了一个滤波器,它有一个通道,尺寸为 3×3。cnn_b具有 1 的形状,因为它对应于一个过滤器。

  1. 为了对输入值执行cnn_w卷积运算,我们必须为 sumproduct ( sumprod)初始化一个零矩阵,其中高度为输入高度-滤波器高度+ 1 ,宽度为宽度-滤波器宽度+ 1 :
h_im, w_im = X_train.shape[2:]
h_conv, w_conv = cnn_w.shape[2:]
sumprod = torch.zeros((h_im - h_conv + 1, w_im - w_conv + 1))
  1. 现在,让我们通过在第一个输入上卷积滤波器(cnn_w)并在将滤波器形状从 1 x 1 x 3 x 3 形状整形为 3 x 3 形状后对滤波器偏置项(cnn_b)求和来填充sumprod:
for i in range(h_im - h_conv + 1):
    for j in range(w_im - w_conv + 1):
        img_subset = X_train[0, 0, i:(i+3), j:(j+3)]
        model_filter = cnn_w.reshape(3,3)
        val = torch.sum(img_subset*model_filter) + cnn_b
        sumprod[i,j] = val

在前面的代码中,img_subset存储了我们将与过滤器进行卷积的输入部分,因此我们将遍历可能的列,然后是行。

此外,假设输入的形状为 4 x 4,滤波器的形状为 3 x 3,则输出的形状为 2 x 2。

在这个阶段,sumprod的输出如下:

  1. 对输出执行 ReLU 操作,然后获取池的最大值(MaxPooling),如下所示:
  • ReLU 是在 Python 中的sumprod之上执行的,如下所示:
sumprod.clamp_min_(0)

请注意,在前面的代码中,我们将输出箝位到最小值 0(这就是 ReLU 激活的作用):

  • 池层的输出可以这样计算:
pooling_layer_output = torch.max(sumprod)

上述代码会产生以下输出:

  1. 通过线性激活传递前面的输出:
intermediate_output_value = pooling_layer_output*lin_w+lin_b

该操作的输出如下:

  1. 通过sigmoid操作传递输出:
from torch.nn import functional as F # torch library 
# for numpy like functions
print(F.sigmoid(intermediate_output_value))

前面的代码给出了以下输出:

注意,我们执行sigmoid而不是softmax,因为损失函数是二元交叉熵,而不是像时尚-MNIST 数据集中那样的分类交叉熵。

前面的代码给出了我们使用 PyTorch 前馈方法获得的相同输出,从而加强了我们对 CNN 如何工作的理解。

现在我们已经了解了 CNN 是如何工作的,在下一节中,我们将把它应用到时尚 MNIST 数据集,并看看它在翻译图像上的表现。

使用深度细胞神经网络分类图像

到目前为止,我们已经看到传统的神经网络对翻译图像的预测是不正确的。这需要解决,因为在真实世界的场景中,需要应用各种增强,例如平移和旋转,这在训练阶段是看不到的。在本节中,我们将了解当图像转换发生在时尚 MNIST 数据集中的图像上时,CNN 如何解决不正确预测的问题。

时尚-MNIST 数据集的预处理部分与前一章相同,只是当我们对(.view)输入数据进行整形时,我们不是将输入数据展平为 28 x 28 = 784 维,而是将每个图像的输入数据整形为(1,28,28)的形状(记住,首先要指定通道,然后是它们的高度和宽度,单位为 PyTorch):

The code for this section is available as CNN_on_FashionMNIST.ipynb in the Chapter04 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Note that the entirety of the code is available in GitHub and that only the additional code corresponding to defining the model architecture is provided here for brevity. We strongly encourage you to refer to the notebooks in this book's GitHub repository while executing the code.

  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
  1. 时尚-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)

前面用粗体显示的代码行是我们对每个输入图像进行整形的地方(与我们在上一章中所做的不同),因为我们向 CNN 提供数据,该 CNN 期望每个输入具有批量大小 x 通道 x 高度 x 宽度的形状。

  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
  • 可以使用以下代码创建模型的摘要:
!pip install torch_summary
from torchsummary import summary
model, loss_fn, optimizer = get_model()
summary(model, torch.zeros(1,1,28,28));

这会产生以下输出:

为了巩固我们对 CNN 的理解,让我们来理解为什么在前面的输出中参数的数量是这样设置的:

  • 第 1 层:假设有 64 个内核大小为 3 的过滤器,我们有 64×3×3 的权重和 64×1 的偏差,总共有 640 个参数。
  • 第 4 层:假设有 128 个内核大小为 3 的过滤器,我们有 128 x 64 x3 x 3 的权重和 128 x 1 的偏差,总共有 73856 个参数。
  • 第 8 层:假设一个有 3200 个节点的层连接到另一个有 256 个节点的层,我们总共有 3,200 x 256 个权重+ 256 个偏差,总共有 819456 个参数。
  • 第 10 层:假设一个有 256 个节点的层连接到一个有 10 个节点的层,我们总共有 256 x 10 个权重和 10 个偏差,总共有 2570 个参数。

现在,我们训练模型,就像我们在前一章中训练它一样。完整的代码可以在本书的 GitHub 资源库-tinyurl.com/mcvp-packt中找到

训练完模型后,您会注意到训练和测试数据集的精度变化和损失如下:

请注意,在前面的场景中,验证数据集的准确性在前五个时期内约为 92%,这已经优于我们在前一章中看到的各种技术的准确性,即使没有额外的正则化。

现在,让我们翻译图像并预测翻译图像的类别:

  1. 将图像在-5 像素到+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)进行了整形,使其形状为(-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 个像素时,预测也是正确的,而在我们不使用 CNN 的情况下,当图像平移 4 个像素时,预测是不正确的。此外,当图像平移 5 个像素时,“裤子”的概率大大下降。

正如我们所看到的,虽然 CNN 有助于解决图像翻译的挑战,但它们并没有完全解决手头的问题。在下一节中,我们将学习如何通过利用数据增强和 CNN 来解决这种情况。

实现数据扩充

在前面的场景中,我们了解了 CNN 如何帮助预测图像在翻译时的类别。虽然这对于高达 5 个像素的转换很有效,但是超过 5 个像素的转换对于正确的类来说概率很低。在这一节中,我们将学习如何确保我们预测正确的类,即使图像被平移了相当大的量。

为了应对这一挑战,我们将通过将输入图像随机平移 10 个像素(向左和向右)并将它们传递给网络来训练神经网络。这样,相同的图像将在不同的通道中作为不同的图像来处理,因为它在每个通道中具有不同的平移量。

在我们利用增强来提高图像转换时模型的准确性之前,让我们了解一下可以在图像上进行的各种增强。

图像增强

到目前为止,我们已经了解了图像转换对模型预测准确性的影响。但是,在现实世界中,我们可能会遇到各种情况,例如:

  • 图像会轻微旋转
  • 图像被放大/缩小(缩放)
  • 图像中存在一定量的噪声
  • 图像亮度低
  • 图像已经翻转
  • 图像已被剪切(图像的一侧更加扭曲)

不考虑上述情况的神经网络不会提供准确的结果,就像在前面的部分中,我们有一个神经网络,它没有对经过大量翻译的图像进行显式训练。

在我们从给定图像创建更多图像的场景中,图像增强非常有用。每个创建的图像可以在旋转、平移、缩放、噪声和亮度方面有所不同。此外,这些参数中的每一个的变化程度也可以变化(例如,在给定迭代中特定图像的平移可以是+10 像素,而在不同的迭代中,它可以是-5 像素)。

imgaug包中的augmenters类具有执行这些扩充的有用工具。让我们来看看augmenters类中的各种工具,用于从给定图像生成增强图像。一些最著名的增强技术如下:

  • 仿射变换
  • 改变亮度
  • 添加噪声

注意 PyTorch 有一个方便的图像增强管道,形式为torchvision.transforms。然而,我们仍然选择引入一个不同的库,主要是因为imgaug包含了更多种类的选项,同时也是因为向新用户解释增强功能很容易。我们鼓励你将火炬视觉转换作为一个练习来研究,并重新创建所有的功能来加强你的理解。

仿射变换

仿射变换涉及图像的平移、旋转、缩放和剪切。它们可以使用augmenters类中的Affine方法在代码中执行。让我们通过下面的截图来看看Affine方法中的参数。这里,我们已经定义了Affine方法的所有参数:

Affine方法中的一些重要参数如下:

  • scale指定图像的缩放量
  • translate_percent以图像高度和宽度的百分比指定平移量
  • translate_px将平移量指定为绝对像素数
  • rotate指定要在图像上完成的旋转量
  • shear指定要在部分图像上完成的旋转量

在我们考虑其他参数之前,让我们先了解一下缩放、平移和旋转在哪里会派上用场。

The code for this section is available as Image_augmentation.ipynb in the Chapter04 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

fashionMNIST的训练数据集中提取随机图像:

  1. 从时尚 MNIST 数据集下载图片:
from torchvision import datasets
import torch
data_folder = '/content/' # This can be any directory 
# you download FMNIST to
fmnist = datasets.FashionMNIST(data_folder, download=True, \
                               train=True)
  1. 从下载的数据集中获取图像:
tr_images = fmnist.data
tr_targets = fmnist.targets
  1. 让我们绘制第一幅图像:
import matplotlib.pyplot as plt
%matplotlib inline
plt.imshow(tr_images[0])

上述代码的输出如下:

在图像顶部执行缩放:

  1. 定义执行缩放的对象:
from imgaug import augmenters as iaa
aug = iaa.Affine(scale=2)
  1. 指定我们想要使用augment_image方法来放大图像,该方法在aug对象中可用,并绘制它:
plt.imshow(aug.augment_image(tr_images[0]))
plt.title('Scaled image')

上述代码的输出如下:

在前面的输出中,图像被放大了很多。由于图像的输出形状没有改变,这导致一些像素从原始图像中被剪切。

现在,让我们来看一个使用translate_px参数将图像平移了一定数量像素的场景:

aug = iaa.Affine(translate_px=10)
plt.imshow(aug.augment_image(tr_images[0]))
plt.title('Translated image by 10 pixels')

上述代码的输出如下:

在前面的输出中,x 轴和 y 轴都发生了 10 个像素的平移。

如果我们希望在一个轴上执行更多的平移,而在另一个轴上执行更少的平移,我们必须指定我们希望在每个轴上的平移量:

aug = iaa.Affine(translate_px={'x':10,'y':2})
plt.imshow(aug.augment_image(tr_images[0]))
plt.title('Translation of 10 pixels \nacross columns \
and 2 pixels over rows')

这里,我们提供了一个字典,它在translate_px参数中说明了 x 轴和 y 轴的平移量。

上述代码的输出如下:

前面的输出显示,与行相比,更多的转换发生在列之间。这也导致图像的某一部分被裁剪。

现在,让我们考虑旋转和剪切对图像增强的影响:

在前面的大多数输出中,我们可以看到某些像素在转换后的图像中被裁剪掉了。现在,让我们看看Affine方法中的其余参数如何帮助我们不因裁剪后增强而丢失信息。

fit_output是一个参数,可以帮助前面的场景。默认设置为False。然而,让我们看看当我们缩放、平移、旋转和剪切图像时,当我们将fit_output指定为True时,前面的输出是如何变化的:

plt.figure(figsize=(20,20))
plt.subplot(161)
plt.imshow(tr_images[0])
plt.title('Original image')
plt.subplot(162)
aug = iaa.Affine(scale=2, fit_output=True)
plt.imshow(aug.augment_image(tr_images[0]))
plt.title('Scaled image')
plt.subplot(163)
aug = iaa.Affine(translate_px={'x':10,'y':2}, fit_output=True)
plt.imshow(aug.augment_image(tr_images[0]))
plt.title('Translation of 10 pixels across \ncolumns and \
2 pixels over rows')
plt.subplot(164)
aug = iaa.Affine(rotate=30, fit_output=True)
plt.imshow(aug.augment_image(tr_images[0]))
plt.title('Rotation of image \nby 30 degrees')
plt.subplot(165)
aug = iaa.Affine(shear=30, fit_output=True)
plt.imshow(aug.augment_image(tr_images[0]))
plt.title('Shear of image \nby 30 degrees')

上述代码的输出如下:

在这里,我们可以看到原始图像没有被裁剪,并且增强图像的大小增加了,以说明增强图像没有被裁剪(在缩放图像的输出中或当图像旋转 30 度时)。此外,我们还可以看到,fit_output参数的激活否定了我们在 10 像素图像的翻译中所期望的翻译(这是一个已知的行为,如文档中所解释的)。

请注意,当增强图像的大小增加时(例如,当图像旋转时),我们需要弄清楚不属于原始图像的新像素应该如何填充。

cval参数解决了这个问题。它指定了当fit_outputTrue时创建的新像素的像素值。在前面的代码中,cval用默认值 0 填充,这导致黑色像素。让我们来了解一下当图像旋转时,将cval参数值更改为 255 会如何影响输出:

aug = iaa.Affine(rotate=30, fit_output=True, cval=255)
plt.imshow(aug.augment_image(tr_images[0]))
plt.title('Rotation of image by 30 degrees')

上述代码的输出如下:

在前面的图像中,新像素的像素值为 255,对应于白色。

此外,我们可以使用不同的模式来填充新创建的像素值。这些值用于mode参数,如下所示:

  • constant:具有恒定值的焊盘。
  • edge:填充数组的边缘值。
  • symmetric:沿阵列边缘镜像的矢量反射的焊盘。
  • reflect:沿每个轴的向量的第一个和最后一个值上镜像的向量的映射。
  • wrap:沿轴向量环绕的焊盘。

初始值用于填充结尾,而结束值用于填充开头。

cval设置为 0 并且我们改变mode参数时,我们收到的输出如下:

在这里,我们可以看到,对于我们当前基于时尚-MNIST 数据集的场景,使用constant模式进行数据扩充更可取。

到目前为止,我们已经指定了平移需要一定数量的像素。类似地,我们已经指定旋转角度应该是特定的度数。然而,在实践中,很难指定图像需要旋转的确切角度。因此,在下面的代码中,我们提供了图像旋转的范围。可以这样做:

plt.figure(figsize=(20,20))
plt.subplot(151)
aug = iaa.Affine(rotate=(-45,45), fit_output=True, cval=0, \
                 mode='constant')
plt.imshow(aug.augment_image(tr_images[0]), cmap='gray')
plt.subplot(152)
aug = iaa.Affine(rotate=(-45,45), fit_output=True, cval=0, \
                 mode='constant')
plt.imshow(aug.augment_image(tr_images[0]), cmap='gray')
plt.subplot(153)
aug = iaa.Affine(rotate=(-45,45), fit_output=True, cval=0, \
                 mode='constant')
plt.imshow(aug.augment_image(tr_images[0]), cmap='gray')
plt.subplot(154)
aug = iaa.Affine(rotate=(-45,45), fit_output=True, cval=0, \
                 mode='constant')
plt.imshow(aug.augment_image(tr_images[0]), cmap='gray')

上述代码的输出如下:

在前面的输出中,相同的图像在不同的迭代中以不同的方式旋转,因为我们根据旋转的上限和下限指定了可能的旋转角度范围。类似地,当我们翻译或分享图像时,我们可以随机化增强。

到目前为止,我们已经用不同的方式看了不同的图像。但是,图像的强度/亮度保持不变。接下来,我们将学习如何增加图像的亮度。

改变亮度

想象一个场景,背景和前景之间的差异不像我们到目前为止看到的那样明显。这意味着背景没有像素值 0,前景没有像素值 255。当图像中的照明条件不同时,通常会发生这种情况。

如果在模型定型时背景的像素值始终为 0,前景的像素值始终为 255,但我们预测的图像的背景像素值为 20,前景像素值为 220,则预测很可能不正确。

MultiplyLinearcontrast是两种不同的增强技术,可以用来解决这种情况。

Multiply方法将每个像素值乘以我们指定的值。到目前为止,我们考虑的图像的每个像素值乘以 0.5 的输出如下:

aug = iaa.Multiply(0.5)
plt.imshow(aug.augment_image(tr_images[0]), cmap='gray', \
           vmin = 0, vmax = 255)
plt.title('Pixels multiplied by 0.5')

上述代码的输出如下:

Linearcontrast根据以下公式调整每个像素值:

在上式中,当α等于 1 时,像素值保持不变。但是,当α小于 1 时,高像素值减少,低像素值增加。

让我们看看Linearcontrast对该图像输出的影响:

aug = iaa.LinearContrast(0.5)
plt.imshow(aug.augment_image(tr_images[0]), cmap='gray', \
           vmin = 0, vmax = 255)
plt.title('Pixel contrast by 0.5')

上述代码的输出如下:

在这里,我们可以看到背景变得更加明亮,而前景像素的强度降低。

接下来,我们将使用GaussianBlur方法模糊图像以模拟真实场景(图像可能会因运动而模糊):

aug = iaa.GaussianBlur(sigma=1)
plt.imshow(aug.augment_image(tr_images[0]), cmap='gray', \
           vmin = 0, vmax = 255)
plt.title('Gaussian blurring of image')

上述代码的输出如下:

在前面的图像中,我们可以看到图像相当模糊,随着sigma值的增加(默认值为 0 表示无模糊),图像变得更加模糊。

添加噪声

在现实世界中,由于糟糕的摄影条件,我们可能会遇到颗粒状图像。DropoutSaltAndPepper是两种有助于模拟粒状图像条件的突出方法。让我们来看看用这两种方法放大图像的输出:

plt.figure(figsize=(10,10))
plt.subplot(121)
aug = iaa.Dropout(p=0.2)
plt.imshow(aug.augment_image(tr_images[0]), cmap='gray', \
           vmin = 0, vmax = 255)
plt.title('Random 20% pixel dropout')
plt.subplot(122)
aug = iaa.SaltAndPepper(0.2)
plt.imshow(aug.augment_image(tr_images[0]), cmap='gray', \
           vmin = 0, vmax = 255)
plt.title('Random 20% salt and pepper noise')

上述代码的输出如下:

在这里,我们可以看到,Dropout方法随机丢弃了一定数量的像素(也就是说,它将它们转换为像素值为 0),而SaltAndPepper方法向我们的图像中随机添加了一些白色和黑色的像素。

执行一系列增强操作

到目前为止,我们已经看了各种增强,也进行了表演。然而,在现实世界的场景中,我们必须考虑尽可能多的扩充。在本节中,我们将了解执行扩充的顺序方式。

使用Sequential方法,我们可以使用所有必须执行的相关增强来构建增强方法。对于我们的例子,我们将只考虑rotateDropout来增强我们的形象。Sequential对象看起来如下:

seq = iaa.Sequential([
      iaa.Dropout(p=0.2),
      iaa.Affine(rotate=(-30,30))], random_order= True)

在前面的代码中,我们指定我们对两个增强感兴趣,并且还指定我们将使用random_order参数。扩增过程将在两者之间随机进行。

现在,让我们用这些放大图来绘制图像:

plt.imshow(seq.augment_image(tr_images[0]), cmap='gray', \
           vmin = 0, vmax = 255)
plt.title('Image augmented using a \nrandom order \
of the two augmentations')

上述代码的输出如下:

从前面的图像中,我们可以看到这两个放大是在原始图像的顶部执行的(您可以看到图像已经旋转,并且应用了 dropout)。

对一批图像执行数据扩充以及对 collate_fn 的需求

我们已经看到,在同一幅图像的不同迭代中执行不同的增强是更可取的。

如果我们有一个在__init__方法中定义的增强管道,我们将只需要在输入图像集上执行一次增强。这意味着我们在不同的迭代中不会有不同的扩充。

类似地,如果增强是在__getitem__方法中——这是理想的,因为我们想要对每幅图像执行不同的增强集——主要的瓶颈是对每幅图像执行一次增强。如果我们对一批图像进行增强,而不是一次对一幅图像进行增强,速度会快得多。让我们通过查看两个场景来详细理解这一点,在这两个场景中,我们将处理 32 幅图像:

  • 增加 32 幅图像,一次一幅
  • 一次性增加 32 幅图像

为了了解在这两种情况下扩充 32 幅图像所需的时间,让我们利用时尚 MNIST 数据集的训练图像中的前 32 幅图像:

The following code is available as Time_comparison_of_augmentation_scenario.ipynb in the Chapter04 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  1. 获取训练数据集中的前 32 幅图像:
from torchvision import datasets
import torch
data_folder = '/content/' 
fmnist = datasets.FashionMNIST(data_folder, download=True, \
                                                train=True)
tr_images = fmnist.data
tr_targets = fmnist.targets
  1. 指定要在图像上执行的增强:
from imgaug import augmenters as iaa
aug = iaa.Sequential([
              iaa.Affine(translate_px={'x':(-10,10)}, 
                                        mode='constant'),
            ])

接下来,我们需要理解如何在Dataset类中执行增强。有两种可能的方法来扩充数据:

  • 一次增加一批图像
  • 一次放大一批中的所有图像

让我们来了解一下执行前面两个场景所需的时间:

  • 场景 1: 扩充 32 幅图像,一次一幅:

使用augment_image方法计算一次放大一幅图像所需的时间:

%%time
for i in range(32):
    aug.augment_image(tr_images[i])

放大 32 幅图像需要大约 180 毫秒。

  • 场景 2: 一次性批量扩充 32 张图像:

使用augment_images方法计算一次增加 32 张图像所需的时间:

%%time
aug.augment_images(tr_images[:32])

对一批图像进行增强需要大约 8 毫秒。

最佳做法是在一批图像的基础上进行扩充,而不是一次扩充一个图像。另外,augment_images方法的输出是一个numpy数组。

然而,我们一直在做的传统的Dataset类在__getitem__方法中一次提供一个图像的索引。因此,我们需要学习如何使用一个新的功能——collate_fn——使我们能够对一批图像进行操作。

  1. 定义Dataset类,它将输入图像、它们的类和增强对象作为初始化器:
from torch.utils.data import Dataset, DataLoader
class FMNISTDataset(Dataset):
    def __init__(self, x, y, aug=None):
        self.x, self.y = x, y
        self.aug = aug
    def __getitem__(self, ix):
        x, y = self.x[ix], self.y[ix]
        return x, y
    def __len__(self): return len(self.x)
  • 定义collate_fn,将该批数据作为输入:
    def collate_fn(self, batch):
  • 将一批图像及其类别分成两个不同的变量:
        ims, classes = list(zip(*batch))
  • 指定如果提供了增强对象,则必须进行增强。如果我们需要对训练数据而不是验证数据进行扩充,这是很有用的:
        if self.aug: ims=self.aug.augment_images(images=ims)

在前面的代码中,我们利用了augment_images方法,这样我们就可以处理一批图像。

  • 通过将图像形状除以 255,创建图像的张量以及缩放数据:
        ims = torch.tensor(ims)[:,None,:,:].to(device)/255.
        classes = torch.tensor(classes).to(device)
        return ims, classes

一般来说,当我们必须执行繁重的计算时,我们利用collate_fn方法。这是因为一次对一批图像进行这样的计算比一次对一幅图像进行更快。

  1. 从现在开始,为了利用collate_fn方法,我们将在创建数据加载器时使用一个新的参数:
  • 首先,我们创建了train对象:
train = FMNISTDataset(tr_images, tr_targets, aug=aug)
  • 接下来,我们定义数据加载器,以及对象的collate_fn方法,如下所示:
trn_dl = DataLoader(train, batch_size=64, \
                    collate_fn=train.collate_fn,shuffle=True)
  1. 最后,我们训练模型,就像我们到目前为止一直在训练它一样。通过利用collate_fn方法,我们可以更快地训练模型。

现在,我们已经对我们可以使用的一些主要数据增强技术有了坚实的理解,包括像素转换和collate_fn,它允许我们增强一批图像,让我们了解如何将它们应用于一批数据以解决图像转换问题。

用于图像翻译的数据增强

现在,我们可以用增强的数据来训练模型了。让我们创建一些增强数据并训练模型:

The following code is available as Data_augmentation_with_CNN.ipynb in the Chapter04 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  1. 导入相关的包和数据集:
from torchvision import datasets
import torch
from torch.utils.data import Dataset, DataLoader
import torch
import torch.nn as nn
import matplotlib.pyplot as plt
%matplotlib inline
import numpy as np

device = 'cuda' if torch.cuda.is_available() else 'cpu'
data_folder = '/content/' # 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
val_fmnist=datasets.FashionMNIST(data_folder, download=True, \
                                        train=False)
val_images = val_fmnist.data
val_targets = val_fmnist.targets
  1. 创建一个类,该类可以对随机平移到-10 到+10 像素之间的任何位置(向左或向右)的图像执行数据扩充:
  • 定义数据扩充管道:
from imgaug import augmenters as iaa
aug = iaa.Sequential([
              iaa.Affine(translate_px={'x':(-10,10)}, 
                                        mode='constant'),
            ])
  • 定义Dataset类:
class FMNISTDataset(Dataset):
    def __init__(self, x, y, aug=None):
        self.x, self.y = x, y
        self.aug = aug
    def __getitem__(self, ix):
        x, y = self.x[ix], self.y[ix]
        return x, y
    def __len__(self): return len(self.x)
    def collate_fn(self, batch):
        'logic to modify a batch of images'
        ims, classes = list(zip(*batch))
        # transform a batch of images at once
        if self.aug: ims=self.aug.augment_images(images=ims) 
        ims = torch.tensor(ims)[:,None,:,:].to(device)/255.
        classes = torch.tensor(classes).to(device)
        return ims, classes

在前面的代码中,我们利用了collate_fn方法来指定我们想要对一批图像执行增强。

  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, 10)
            ).to(device)

    loss_fn = nn.CrossEntropyLoss()
    optimizer = Adam(model.parameters(), lr=1e-3)
    return model, loss_fn, optimizer
  1. 定义train_batch函数,以便对批量数据进行训练:
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. 定义get_data函数来获取训练和验证数据加载器:
def get_data(): 
    train = FMNISTDataset(tr_images, tr_targets, aug=aug)
    'notice the collate_fn argument'
    trn_dl = DataLoader(train, batch_size=64, \
                collate_fn=train.collate_fn, shuffle=True)
    val = FMNISTDataset(val_images, val_targets) 
    val_dl = DataLoader(val, batch_size=len(val_images), 
                collate_fn=val.collate_fn, shuffle=True)
    return trn_dl, val_dl
  1. 指定训练和验证数据加载器,并获取模型对象、损失函数和优化器:
trn_dl, val_dl = get_data()
model, loss_fn, optimizer = get_model()
  1. 5个时期内训练模型:
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)
  1. 像我们在上一节中所做的那样,在翻译的图像上测试模型:
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)))

现在,让我们绘制不同翻译的预测类的变化:

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')

上述代码会产生以下输出:

现在,当我们预测图像的各种翻译时,我们将看到分类预测没有变化,从而确保通过在增强的翻译图像上训练我们的模型来处理图像翻译。

到目前为止,我们已经看到了用增强图像训练的 CNN 模型如何能够很好地预测翻译后的图像。在下一节中,我们将了解过滤器学习什么,这使得预测翻译的图像成为可能。

可视化特征学习的结果

到目前为止,我们已经了解了 CNN 如何帮助我们分类图像,即使图像中的对象已经被翻译。我们还了解到,过滤器在学习图像特征方面起着关键作用,这反过来有助于将图像分类到正确的类别中。然而,我们还没有提到过滤器学到了什么使它们变得强大。

在这一节中,我们将了解这些过滤器学到了什么,使 CNN 能够通过对包含 X 和 O 的图像的数据集进行分类来正确地分类图像。我们还将检查完全连接的层(展平层),以了解它们的激活看起来像什么。让我们来看看过滤器学到了什么:

The code for this section is available as Visualizing_the_features'_learning.ipynb in the Chapter04 folder of this book's GitHub repository - tinyurl.com/mcvp-packt.

  1. 下载数据集:
!wget https://www.dropbox.com/s/5jh4hpuk2gcxaaq/all.zip
!unzip all.zip

请注意,文件夹中的图像命名如下:

图像的类别可以从图像的名称中获得,其中图像名称的第一个字符指定图像所属的类别。

  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
  1. 定义一个获取数据的类。此外,请确保图像的大小已调整为 28 x 28 的形状,批次已用三个通道成形,并且因变量作为数值提取。我们将在下面的代码中一步一步地实现这一点:
  • 定义图像增强方法,该方法将图像的大小调整为 28 x 28 的形状:
tfm = iaa.Sequential(iaa.Resize(28))
  • 定义一个类,它将文件夹路径作为输入,并在__init__方法中遍历该路径中的文件:
class XO(Dataset):
    def __init__(self, folder):
        self.files = glob(folder)
  • 定义__len__方法,该方法返回要考虑的文件长度:
    def __len__(self): return len(self.files)
  • 定义__getitem__方法,我们用它来获取一个索引,返回该索引处的文件,读取该文件,然后对图像执行增强。这里我们没有使用collate_fn,因为这是一个小数据集,不会显著影响训练时间:
    def __getitem__(self, ix):
        f = self.files[ix]
        im = tfm.augment_image(cv2.imread(f)[:,:,0])
  • 假设每个图像的形状为 28 x 28,我们现在将在形状的开始处创建一个虚拟通道尺寸;也就是说,在图像的高度和宽度之前:
        im = im[None]
  • 现在,我们可以根据文件名中的字符 post '/'和 previous'@'来分配每个图像的类别:
        cl = f.split('/')[-1].split('@')[0] == 'x'
  • 最后,我们返回图像和相应的类:
        return torch.tensor(1 - im/255).to(device).float(), \
                       torch.tensor([cl]).float().to(device)
  1. 检查你得到的图像样本。在下面的代码中,我们通过从之前定义的类中获取数据来提取图像及其对应的类:
data = XO('/content/all/*')
  • 现在,我们可以从获得的数据集中绘制一个图像样本:
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()

上述代码会产生以下输出:

  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));

这会产生以下输出:

  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]
  1. 定义一个DataLoader,其中输入是Dataset类:
trn_dl = DataLoader(XO('/content/all/*'), batch_size=32, \
                    drop_last=True)
  1. 初始化模型:
model, loss_fn, optimizer = get_model()
  1. 5个时期内训练模型:
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)
  1. 获取图像以检查过滤器对图像的了解:
im, c = trn_dl.dataset[2]
plt.imshow(im[0].cpu())
plt.show()

这会产生以下输出:

  1. 将图像传递给经过训练的模型,并获取第一层的输出。然后,将其存储在intermediate_output变量中:
first_layer = nn.Sequential(*list(model.children())[:1])
intermediate_output = first_layer(im[None])[0].detach()
  1. 绘制 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()

这会产生以下输出:

在前面的输出中,请注意,某些滤波器(如滤波器 0、4、6 和 7)学习网络中存在的边,而其他滤波器(如滤波器 54)学习反转图像。

  1. 传递多个图像并检查第四个滤波器在图像上的输出(我们使用第四个滤波器只是为了说明的目的;如果愿意,您可以选择不同的过滤器):
  • 从数据中提取多个图像:
x, y = next(iter(trn_dl))
x2 = x[y==0]
  • 重塑x2的形状,使其具有适合 CNN 模型的输入形状;即批量 x 通道 x 高度 x 宽度:
x2 = x2.view(-1,1,28,28)
  • 定义一个存储模型直到第一层的变量:
first_layer = nn.Sequential(*list(model.children())[:1])
  • 提取通过模型传递 O 图像(x2)直到第一层(first_layer)的输出,如前所述:
first_layer_output = first_layer(x2).detach()
  1. 绘制通过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()

上述代码会产生以下输出:

请注意,给定滤镜(在这种情况下,第一层的第四个滤镜)的行为在图像之间保持一致。

  1. 现在,让我们创建另一个模型,该模型提取层,直到第二个卷积层(即,直到前面模型中定义的四个层),然后提取传递原始 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()

上述代码会产生以下输出:

现在,让我们使用上图中第 34 个过滤器的输出作为例子。当我们让多个 O 图像通过过滤器 34 时,我们应该看到图像之间的类似激活。让我们测试一下,如下所示:

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()

上述代码会产生以下输出:

注意,即使在这里,不同图像上第 34 个^(滤光器的激活是相似的,因为 O 的左半部分激活了滤光器。)

  1. 绘制完全连接层的激活,如下所示:
  • 首先,获取更大的图像样本:
custom_dl= DataLoader(XO('/content/all/*'),batch_size=2498, \
                       drop_last=True)
  • 接下来,仅从数据集中选择 O 图像,然后对它们进行整形,以便它们可以作为输入传递给我们的 CNN 模型:
x, y = next(iter(custom_dl))
x2 = x[y==0]
x2 = x2.view(len(x2),1,28,28)
  • 提取展平(完全连接)层,将前面的图像传递到模型中,直到它们到达展平层:
flatten_layer = nn.Sequential(*list(model.children())[:7])
flatten_layer_output = flatten_layer(x2).detach()
  • 绘制展平层:
plt.figure(figsize=(100,10))
plt.imshow(flatten_layer_output.cpu())

上述代码产生以下输出:

请注意,输出的形状是 1245 x 3200,因为我们的数据集中有 1,245 张 O 图像,展平层中的每张图像有 3,200 个维度。

有趣的是,当输入为 O 时,全连接层中的某些值会突出显示(这里,我们可以看到白线,其中每个点代表大于零的激活值)。

请注意,该模型已经学会为完全连接的层带来一些结构,即使输入图像——虽然都属于同一类——在风格上有很大不同。

既然我们已经了解了 CNN 是如何工作的,以及过滤器是如何帮助这个过程的,我们将应用这一点,以便我们可以对猫和狗的图像进行分类。

构建用于分类真实世界图像的 CNN

到目前为止,我们已经了解了如何在时尚 MNIST 数据集上执行图像分类。在这一节中,我们将在一个更真实的场景中做同样的事情,任务是对包含猫或狗的图像进行分类。我们还将了解当我们改变可用于训练的图像数量时,数据集的准确性如何变化。

我们将在 Kaggle 中使用一个数据集:www.kaggle.com/tongpython/cat-and-dog

The code for this section is available as Cats_Vs_Dogs.ipynb in the Chapter04 folder of this book's GitHub repository - tinyurl.com/mcvp-packt Be sure to copy the URL from the notebook in GitHub to avoid any issue while reproducing the results

  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
  1. 下载数据集,如下所示:
  • 这里,我们必须下载在colab环境中可用的数据集。然而,首先我们必须上传我们的 Kaggle 认证文件:
!pip install -q kaggle
from google.colab import files
files.upload()

这一步你需要上传你的kaggle.json文件,可以从你的 Kaggle 账户获得。GitHub 上的相关笔记本中提供了如何获取kaggle.json文件的详细信息

  • 接下来,指定我们将移动到 Kaggle 文件夹,并将kaggle.json文件复制到其中:
!mkdir -p ~/.kaggle
!cp kaggle.json ~/.kaggle/
!ls ~/.kaggle
!chmod 600 /root/.kaggle/kaggle.json
  • 最后,下载猫狗数据集并解压:
!kaggle datasets download -d tongpython/cat-and-dog
!unzip cat-and-dog.zip
  1. 提供培训和测试数据集文件夹:
train_data_dir = '/content/training_set/training_set'
test_data_dir = '/content/test_set/test_set'
  1. 构建一个从前面的文件夹中获取数据的类。然后,根据图像对应的目录,为“狗”图像提供标签 1,为“猫”图像提供标签 0。此外,确保获取的图像已被规范化为 0 到 1 之间的比例,并对其进行置换,以便首先提供通道(因为 PyTorch 模型希望在图像的高度和宽度之前首先指定通道)。
  • 定义__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
  • 接下来,随机化文件路径,并基于对应于这些文件路径的文件夹创建目标变量:
        from random import shuffle, seed; seed(10); 
        shuffle(self.fpaths)
        self.targets=[fpath.split('/')[-1].startswith('dog') \
                      for fpath in self.fpaths] # dog=1 
  • 定义对应于self类的__len__方法:
    def __len__(self): return len(self.fpaths)
  • 定义__getitem__方法,我们用它从文件路径列表中指定一个随机的文件路径,读取图像,并调整所有图像的大小,使它们的大小为 224 x 224。假设我们的 CNN 期望首先为每个图像指定来自通道的输入,我们将permute调整大小的图像,以便在我们返回缩放的图像和相应的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)
  1. 检查随机图像:
data = cats_dogs(train_data_dir)
im, label = data[200]

我们需要把我们最后获得的图像传送到我们的频道。这是因为 matplotlib 希望在提供图像的高度和宽度后,图像具有指定的通道:

plt.imshow(im.permute(1,2,0).cpu())
print(label)

这会产生以下输出:

  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)
    )

在前面的代码中,我们将输入通道的数量(ni)、输出通道的数量(no)、滤波器的kernel_sizestride作为conv_layer函数的输入。

  • 定义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层一样。

  • 现在,我们必须调用get_model函数来获取模型、损失函数(loss_fn)和optimizer,然后使用从torchsummary包中导入的summary方法对模型进行汇总:
from torchsummary import summary
model, loss_fn, optimizer = get_model()
summary(model, torch.zeros(1,3, 224, 224));

上述代码会产生以下输出:

  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()
  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()

请注意,前面用于精度计算的代码不同于时尚-MNIST 分类中的代码,因为当前模型(猫与狗的分类)是为二元分类构建的,而时尚-MNIST 模型是为多类分类构建的。

  • 定义验证损失计算函数:
@torch.no_grad()
def val_loss(x, y, model):
    prediction = model(x)
    val_loss = loss_fn(prediction, y)
    return val_loss.item()
  1. 针对5时段训练模型,并在每个时段结束时检查测试数据的准确性,正如我们在前面章节中所做的那样:
  • 定义模型并获取所需的数据加载器:
trn_dl, val_dl = get_data()
model, loss_fn, optimizer = get_model()
  • 在不断增加的时期内训练模型:
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)
  1. 绘制训练和验证准确度在增加的时期内的变化:
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()

上述代码会产生以下输出:

注意,在5时期结束时的分类精度约为 86%。

正如我们在上一章中所讨论的,批处理规范化对提高分类准确性有很大的影响——通过在没有批处理规范化的情况下训练模型来验证这一点。此外,如果使用较少的参数,可以在不进行批量标准化的情况下训练模型。您可以通过减少层数、增加步幅、增加池大小或将图像大小调整为低于 224 x 224 的数字来实现这一点。

到目前为止,我们所做的培训是基于大约 8K 个例子,其中 4K 的例子来自cat班,其余的来自dog班。在下一节中,我们将了解在测试数据集的分类准确性方面,减少训练样本数量对每个类有什么影响。

对用于训练的图像数量的影响

我们知道,一般来说,我们使用的训练样本越多,我们的分类准确度就越高。在本节中,我们将通过人为减少可用于训练的图像数量,然后在对测试数据集进行分类时测试模型的准确性,来了解使用不同数量的可用图像对训练准确性有何影响。

The code for this section is available as Cats_Vs_Dogs.ipynb in the Chapter04 folder of this book's GitHub repository - tinyurl.com/mcvp-packt . Given that the majority of the code that will be provided here is similar to what we have seen in the previous section, in text, we have only provided the modified code for brevity. The respective notebook in this book's GitHub repository will contain the full code.

这里,我们只希望训练数据集中每个类有 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 张)构建的模型的准确性将如下所示:

在这里,我们可以看到,因为我们在训练中有更少的图像示例,测试数据集的准确性大大降低;也就是下降到~66%。

现在,让我们通过改变将用于训练模型的可用训练示例的数量,来看看训练数据点的数量如何影响测试数据集的准确性(在这里,我们为每个场景构建一个模型)。

我们将使用用于 500 个数据点训练示例的相同代码,但将改变可用图像的数量(分别为 2K、4K 和 8K 总数据点)。为简洁起见,我们将只查看在不同数量的可用于训练的图像上运行模型的输出。这会产生以下输出:

如您所见,可用的训练数据越多,模型对测试数据的准确性就越高。然而,在我们遇到的每个场景中,我们可能没有足够大的训练数据量。下一章将介绍迁移学习,将通过指导您使用各种技术来解决这个问题,即使是在少量的训练数据上,您也可以使用这些技术来获得高精度。

摘要

当与先前看到的已经被翻译的图像非常相似的新图像被输入到模型中时,传统的神经网络就失效了。卷积神经网络在解决这一缺点方面起着关键作用。这是通过 CNN 中的各种机制实现的,包括过滤器、步长和池。最初,我们构建了一个玩具示例来了解 CNN 是如何工作的。然后,我们了解了数据增强如何通过在原始图像上创建翻译增强来帮助提高模型的准确性。之后,我们了解了不同的过滤器在特征学习过程中学习什么,以便我们可以实现 CNN 来分类图像。

最后,我们看到了不同数量的训练数据对测试数据准确性的影响。在这里,我们看到可用的训练数据越多,测试数据的准确性就越好。在下一章中,我们将了解如何利用各种迁移学习技术来提高测试数据集的准确性,即使我们只有少量的训练数据。

问题

  1. 为什么在使用传统神经网络时,对翻译图像的预测很低?

  2. 卷积是怎么做的?

  3. 如何确定过滤器中的最佳重量值?

  4. 卷积和汇集的结合如何帮助解决图像转换的问题?

  5. 更接近输入层的层中的过滤器学习什么?

  6. 池有哪些功能有助于构建模型?

  7. 为什么我们不能获取一个输入图像,将其展平(就像我们在时尚-MNIST 数据集上所做的那样),然后为真实世界的图像训练一个模型?

  8. 数据增强如何帮助改善图像翻译?

  9. 在什么场景下我们利用collate_fn进行数据加载?

  10. 改变训练数据点的数量对验证数据集的分类准确性有什么影响?

五、用于图像分类的迁移学习

在前一章中,我们了解到,随着训练数据集中可用图像数量的增加,模型的分类精度不断提高,达到了包含 8,000 幅图像的训练数据集在验证数据集上比包含 1,000 幅图像的训练数据集具有更高精度的程度。然而,为了训练一个模型,我们并不总是能够选择成百上千的图像,以及它们相应类别的基本事实。这就是迁移学习的用处。

迁移学习是一种技术,其中我们将通用数据集上的模型学习转移到感兴趣的特定数据集。通常,用于执行迁移学习的预训练模型是在数百万幅图像(这些图像是通用的,不是我们感兴趣的数据集)上训练的,这些预训练模型现在被微调到我们感兴趣的数据集。

在本章中,我们将学习两种不同的迁移学习架构——VGG 架构的变体和 ResNet 架构的变体。

除了了解这些架构,我们还将了解它们在两种不同用例中的应用,年龄和性别分类,我们将了解如何同时优化交叉熵和平均绝对误差损失,以及面部关键点检测,我们将了解如何利用神经网络在单个预测中生成多个(136 个,而不是 1 个预测)连续输出。最后,我们将了解一个新的库,它有助于在剩余的章节中大大降低代码的复杂性。

总之,本章涵盖了以下主题:

  • 介绍迁移学习
  • 了解 VGG16 和 ResNet 架构
  • 实现面部关键点检测
  • 多任务学习:实现年龄估计和性别分类
  • torch_snippets 库简介

介绍迁移学习

迁移学习是一种利用从一项任务中获得的知识来解决另一项类似任务的技术。

想象一下,一个模型在跨越数千种对象(不仅仅是猫和狗)的数百万张图像上进行训练。模型的各种过滤器(内核)将针对图像中的各种形状、颜色和纹理而激活。这些过滤器现在可以重复使用,以学习一组新图像的特征。在学习特征之后,它们可以连接到最终分类层之前的隐藏层,以便对新数据进行定制。

ImageNet(www.image-net.org/)是一项竞赛,旨在将大约 1400 万张图片分为 1000 个不同的类别。它在数据集中有各种类,包括印度象、狮子鱼、硬盘、发胶和吉普。

我们将在本章中讨论的深度神经网络架构已经在 ImageNet 数据集上进行了训练。此外,考虑到要在 ImageNet 中分类的对象的种类和数量,模型非常深入,以便捕捉尽可能多的信息。

让我们通过一个假设的场景来理解迁移学习的重要性:

考虑这样一种情况,我们正在处理道路的图像,试图根据它们包含的对象对它们进行分类。从零开始构建模型可能会导致次优结果,因为图像的数量可能不足以学习数据集中的各种变化(正如我们在之前的使用案例中所看到的,在 8,000 张图像上的训练比在 2,000 张图像上的训练产生了更高的验证数据集准确性)。在这种情况下,在 ImageNet 上训练的预训练模型就派上了用场。在对大型 ImageNet 数据集进行训练的过程中,它可能已经了解了许多与交通相关的类,如汽车、道路、树木和人类。因此,利用已经训练的模型将导致更快和更准确的训练,因为模型已经知道通用形状,现在必须使它们适合特定图像。有了直觉,现在让我们理解迁移学习的高级流程如下:

  1. 标准化输入图像,通过在预训练模型的训练期间使用的相同平均值和标准偏差进行标准化。
  2. 获取预训练模型的架构。获取此架构的权重,这是在大型数据集上训练的结果。
  3. 丢弃预训练模型的最后几层。
  4. 将截断的预训练模型连接到新初始化的层(或多个层),其中权重被随机初始化。确保最后一层的输出具有与我们想要预测的类/输出一样多的神经元
  5. 确保预训练模型的权重是不可训练的(换句话说,在反向传播期间被冻结/不被更新),但是新初始化的层的权重和将其连接到输出层的权重是可训练的:
  • 我们不训练预训练模型的权重,因为我们假设这些权重对于该任务已经很好地学习了,因此利用了从大型模型的学习。总之,我们只学习小数据集的新初始化的层。
  1. 在增加的时期内更新可训练参数以适合模型。

既然我们对如何实现迁移学习有了一个概念,那么让我们来理解各种架构,它们是如何构建的,以及在后续部分中我们将迁移学习应用于猫和狗的用例时的结果。首先,我们将详细介绍来自 VGG 的各种架构。

了解 VGG16 架构

VGG 代表视觉 几何 ,是基于牛津大学的,16 代表模型中的层数。VGG16 模型在 ImageNet 竞赛中被训练用于分类对象,并在 2014 年获得亚军架构。我们之所以研究这种架构,而不是获胜的架构(GoogleNet ),是因为它的简单性,以及通过在其他几个任务中使用它,它在 vision 社区中被更广泛地接受。让我们了解 VGG16 的架构,以及如何在 PyTorch 中访问和表示 VGG16 预训练模型。

The code for this section is available as VGG_architecture.ipynb in the Chapter05 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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));

上述代码的输出如下:

在前面的总结中,我们提到的 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}

同样的总结也可以这样设想:

请注意,该网络中有约 1.38 亿个参数(其中约 1.22 亿个是网络末端的线性层,即 102+16+400 万个参数),该网络包括 13 个卷积和/或池层以及 3 个线性层,滤波器数量不断增加。

理解 VGG16 模型组件的另一种方法是简单地将其打印如下:

model

这会产生以下输出:

注意,模型中有三个主要的子模块— featuresavgpoolclassifier。通常,我们会冻结featuresavgpool模块。删除classifier模块(或仅底部的几层)并在其位置创建一个新模块,该模块将预测对应于我们数据集的所需类数量(而不是现有的 1000 个)。

现在,让我们通过在以下代码中使用猫与狗数据集(考虑每个类中只有 500 张图像用于训练),来了解 VGG16 模型在实践中是如何使用的:

The following code is available as Implementing_VGG16_for_image_classification.ipynb in the Chapter05 folder of this book's GitHub repository - tinyurl.com/mcvp-packt Be sure to copy the URL from the notebook in GitHub to avoid any issue while reproducing the results

  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
  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
  • 下载数据集并解压缩:
!kaggle datasets download -d tongpython/cat-and-dog
!unzip cat-and-dog.zip
  • 指定训练和测试图像文件夹:
train_data_dir = 'training_set/training_set'
test_data_dir = 'test_set/test_set'
  1. 提供为猫狗数据集返回输入-输出对的类,就像我们在第四章、介绍卷积神经网络中所做的一样。请注意,在这种情况下,我们只从每个文件夹中获取前 500 张图像:
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)

本节中的cats_dogs类和第四章中的normalize类的主要区别是我们使用transforms模块中的Normalize函数来应用的normalize函数。

在利用预训练模型时,必须调整图像的大小、置换图像,然后归一化图像(根据预训练模型的情况而定),其中图像首先在 3 个通道上被缩放到 0 和 1 之间的值,然后在 RGB 通道上被归一化到平均值[0.485,0.456,0.406]和标准偏差[0.229,0.224,0.225]。

  1. 获取图像及其标签:
data = CatsDogs(train_data_dir)

现在让我们检查一个样本图像及其对应的类:

im, label = data[200]
plt.imshow(im.permute(1,2,0).cpu())
print(label)

上述代码会产生以下输出:

  1. 定义模型。下载预训练的 VGG16 重量,然后冻结features模块,并使用avgpoolclassifier模块进行训练:
  • 首先,我们从models类下载预训练的 VGG16 模型:
def get_model():
    model = models.vgg16(pretrained=True)
  • 指定我们要冻结先前下载的模型中的所有参数:
    for param in model.parameters():
        param.requires_grad = False

在前面的代码中,我们通过指定param.requires_grad = False来冻结反向传播期间的参数更新。

  • 替换avgpool模块以返回大小为 1 x 1 而不是 7 x 7 的特征图,换句话说,输出现在将是batch_size x 512 x 1 x 1:
    model.avgpool = nn.AdaptiveAvgPool2d(output_size=(1,1))

We have seen nn.MaxPool2d, where we are picking the maximum value from every section of a feature map. There is a counterpart to this layer called nn.AvgPool2d, which returns the average of a section instead of the maximum. In both these layers, we fix the kernel size. The layer above, nn.AdaptiveAvgPool2d, is yet another pooling layer with a twist. We specify the output feature map size instead. The layer automatically computes the kernel size so that the specified feature map size is returned. For example, if the input feature map size dimensions were batch_size x 512 x k x k, then the pooling kernel size is going to be k x k. The major advantage with this layer is that whatever the input size, the output from this layer is always fixed and, hence, the neural network can accept images of any height and width.

  • 定义模型的classifier模块,其中我们首先展平avgpool模块的输出,将 512 个单元连接到 128 个单元,并在连接到输出层之前执行激活:
    model.classifier = nn.Sequential(nn.Flatten(),
                                    nn.Linear(512, 128),
                                    nn.ReLU(),
                                    nn.Dropout(0.2),
                                    nn.Linear(128, 1),
                                    nn.Sigmoid())
  • 定义损失函数(loss_fn),optimizer,并随定义的模型一起返回:
    loss_fn = nn.BCELoss()
    optimizer = torch.optim.Adam(model.parameters(),lr= 1e-3)
    return model.to(device), loss_fn, optimizer

注意,在前面的代码中,我们首先冻结了预训练模型的所有参数,然后覆盖了avgpoolclassifier模块。现在,剩下的代码看起来将和我们在上一章看到的相似。

该模型的摘要如下:

!pip install torch_summary
from torchsummary import summary
model, criterion, optimizer = get_model()
summary(model, torch.zeros(1,3,224,224))

上述代码会产生以下输出:

请注意,可训练参数的数量仅为 1470 万个中的 65793 个,因为我们已经冻结了features模块,并覆盖了avgpoolclassifier模块。现在,只有classifier模块将学习重量。

  1. 定义一个函数来批量训练、计算精度并获取数据,就像我们在Chapter 4介绍卷积神经网络中所做的那样:
  • 对一批数据进行训练:
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
  • 初始化get_dataget_model功能:
trn_dl, val_dl = get_data()
model, loss_fn, optimizer = get_model()
  1. 在不断增加的时期内训练模型,就像我们在第四章、介绍卷积神经网络中所做的那样:
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, 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)
  1. 绘制递增时期的训练和测试精度值:
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()

这会产生以下输出:

请注意,我们能够在第一个时期内获得 98%的准确度,即使是在 1000 幅图像的小数据集上(每类 500 幅图像)。

除了 VGG16,还有 VGG11 和 VGG19 预训练架构,其工作方式与 VGG16 类似,但层数不同。VGG19 比 VGG16 有更多的参数,因为它有更多的层数

当我们使用 VGG11 和 VGG19 代替 VGG16 预训练模型时,训练和验证精度如下:

注意,虽然基于 VGG19 的模型比基于 VGG16 的模型具有稍好的准确性,在验证数据上具有 98%的准确性,但是基于 VGG11 的模型具有稍低的准确性,为 97%。

从 VGG16 到 VGG19,我们增加了层数,一般来说,神经网络越深,精度越好。

然而,如果仅仅增加层数是个窍门,那么我们可以继续向模型添加更多的层(同时注意避免过度拟合),以在 ImageNet 上获得更准确的结果,然后针对感兴趣的数据集对其进行微调。不幸的是,事实证明并非如此。

这并不容易,原因有很多。随着我们深入了解架构,以下任何一种情况都有可能发生:

  • 我们必须学习更多的特征。
  • 消失梯度出现。
  • 在更深的层有太多的信息修改。

ResNet 的出现是为了解决识别何时不学习的特定场景,我们将在下一节中了解这一点。

了解 ResNet 架构

在建立太深的网络的同时,有两个问题。在前向传播中,网络的最后几层几乎没有关于原始图像是什么的信息。在反向传播中,由于渐变消失(换句话说,它们几乎为零),靠近输入的前几个层几乎不会得到任何渐变更新。为了解决这两个问题,残差网络(ResNet)使用类似高速公路的连接,将原始信息从前面的几层传输到后面的几层。理论上,由于这个高速公路网络,即使是最后一层也将具有原始图像的全部信息。并且由于跳跃层,反向梯度将几乎不加修改地自由流向初始层。

残差网络中的术语残差是模型期望从上一层学习到的需要传递到下一层的附加信息。

典型的残差块如下所示:

如您所见,到目前为止,我们一直对提取 F(x)值感兴趣,其中 x 是来自前一层的值,在残差网络的情况下,我们不仅提取通过权重层后的值,即 F(x),还将 F(x)与原始值 x 相加。

到目前为止,我们一直在使用执行线性或卷积变换F(x)以及一些非线性激活的标准层。这两种操作在某种意义上都破坏了输入信息。我们第一次看到一个层,它不仅转换输入,而且通过将输入直接添加到转换中来保存输入。这样,在某些情况下,该层在记住输入是什么方面负担很小,并且可以专注于学习任务的正确转换。

让我们通过代码构建一个残差块来更详细地了解残差层:

The code for this section is available as Implementing_ResNet18_for_image_classification.ipynb in the Chapter05 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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`定义为通过卷积时输出的维数,如果我们将两者相加,输入的维数应该保持不变。

2.  定义`forward`方法:

 def forward(self, x):
     x = self.conv(x) + x
     return x

在前面的代码中,我们得到的输出是通过卷积运算得到的输入和原始输入之和。

现在我们已经了解了残差块的工作原理,让我们来了解一下残差块是如何在预训练的基于残差块的网络 ResNet18 中连接的:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/9198574d-820a-4eda-b5fc-3c856541bf12.png)

如您所见,该架构有 18 层,因此被称为 ResNet18 架构。此外,请注意跳过连接是如何通过网络进行的。它不是在每个卷积层进行,而是在每两层之后进行。

现在我们已经了解了 ResNet 架构的组成,让我们基于 ResNet18 架构构建一个模型来对狗和猫进行分类,就像我们在上一节中使用 VGG16 所做的那样。

为了构建一个分类器,直到 VGG16 部分的*步骤 3* 的代码保持不变,因为它处理导入包、获取数据和检查它们。因此,我们将从了解预训练 ResNet18 模型的组成开始:

The code for this section is available as `Resnet_block_architecture.ipynb` in the `Chapter05` folder of the GitHub repository. Given that a majority of the code is similar to the code in the VGG section, we have only provided the additional code for brevity. For the full code, you are encouraged to check the notebook in GitHub.

1.  加载预训练的 ResNet18 模型,并检查加载模型中的模块:

model = models.resnet18(pretrained=True).to(device)
model


ResNet18 模型的结构包含以下组件:

*   盘旋

*   批量标准化

*   热卢

*   最大池化

*   四层 ResNet 块

*   平均池(avgpool)

*   全连接层(fc)

正如我们在 VGG16 中所做的,我们将冻结所有不同的模块,但在下一步中更新`avgpool`和`fc`模块中的参数。

2.  定义模型架构、损失函数和优化器:

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.parameters(),lr= 1e-3)
return model.to(device), loss_fn, optimizer


在前面的模型中,`fc`模块的输入形状是 512,因为`avgpool`的输出具有批处理大小 x 512 x 1 x 1 的形状。

现在我们已经定义了模型,让我们根据 VGG 部分执行*步骤 5* 和 *6* 。在训练模型(其中,对于以下每个图表,模型是 ResNet18、ResNet34、ResNet50、ResNet101 和 ResNet152)之后,训练和验证精度在增加的时期内的变化如下:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/2648d146-09fc-454f-a6b5-23ead29759fc.png)

我们看到,当只对 1,000 幅图像进行训练时,模型的准确性在 97%和 98%之间变化,其中准确性随着 ResNet 中层数的增加而增加。

除了 VGG 和 ResNet,其他一些著名的预训练模型是 Inception,MobileNet,DenseNet 和 SqueezeNet。

现在,我们已经了解了如何利用预训练模型来预测二进制类,在接下来的部分中,我们将了解如何利用预训练模型来解决涉及以下内容的真实用例:

*   **多元回归**:给定图像作为输入的多个值的预测——面部关键点检测

*   **多任务学习**:预测单次拍摄中的多个项目——年龄估计和性别分类

实现面部关键点检测

到目前为止,我们已经了解了二元(猫对狗)或多标签(fashionMNIST)的预测类。现在让我们来学习一个回归问题,在这个过程中,我们预测的不是一个而是几个连续输出。想象一个场景,要求你预测一张人脸图像上的关键点,例如,眼睛、鼻子和下巴的位置。在这种情况下,我们需要采用一种新的策略来建立一个模型来检测关键点。

在我们深入探讨之前,让我们通过下面的图片来了解我们想要达到的目的:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/c9e53db9-461a-42b9-876b-cd878948935e.png)

正如您在前面的图像中所看到的,面部关键点表示包含面部的图像上各种关键点的标记。

要解决这个问题,我们必须先解决几个问题:

*   图像可以是不同的形状:

*   这保证了在调整图像时调整关键点位置,以使它们都达到标准图像尺寸。

*   面部关键点类似于散点图上的点,但这次是基于某种模式分散的:

*   这意味着,如果将图像的大小调整为 224 x 224 x 3 的形状,这些值可以是 0 到 224 之间的任何值。

*   根据图像的大小归一化因变量(面部关键点的位置):

*   如果我们考虑它们相对于图像尺寸的位置,关键点值总是在 0 和 1 之间。

*   假设因变量的值总是在 0 和 1 之间,我们可以在最后使用一个 sigmoid 层来获取 0 和 1 之间的值。

让我们制定解决这个用例的流程:

1.  导入相关的包。

2.  导入数据。

3.  定义准备数据集的类:

*   确保对输入图像进行适当的预处理,以执行迁移学习。

*   确保关键点的位置以这样的方式被处理,即我们获取它们相对于被处理图像的相对位置。

4.  定义模型、损失函数和优化器:

*   损失函数是平均绝对误差,因为输出是 0 和 1 之间的连续值。

5.  在不断增加的时期内训练模型。

现在让我们实现前面的步骤:

The code for this section is available as `Facial_keypoints_detection.ipynb` in the `Chapter05` folder of this book's GitHub repository - [`tinyurl.com/mcvp-packt`](https://tinyurl.com/mcvp-packt) Be sure to copy the URL from the notebook in GitHub to avoid any issue while reproducing the results

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.read_csv(
'P1_Facial_Keypoints/data/training_frames_keypoints.csv')


导入数据集的示例如下:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/1b701179-7ba7-4cce-81d7-cfaf3314f437.png)

在前面的输出中,第 1 列表示图像的名称,偶数列表示对应于面部 68 个关键点中每一个的*x*-轴值,其余奇数列(除了第一列)表示对应于 68 个关键点中每一个的*y*-轴值。

3.  定义为数据加载器提供输入和输出数据点的`FacesData`类:

class FacesData(Dataset):


*   现在让我们定义`__init__`方法,它将文件的数据帧(`df`)作为输入:

def __init__(self, df):
    super(FacesData).__init__()
    self.df = df

*   定义图像预处理的均值和标准差,以便预训练的 VGG16 模型可以使用这些图像:

    self.normalize = transforms.Normalize(
                            mean=[0.485, 0.456, 0.406], 
                            std=[0.229, 0.224, 0.225])

*   现在,定义`__len__` 方法:

def __len__(self): return len(self.df)

接下来,我们定义`__getitem__`方法,在这里,我们获取对应于给定索引的图像,对其进行缩放,获取对应于给定索引的关键点值,对关键点进行归一化,以便我们将关键点的位置作为图像大小的比例,并对图像进行预处理。

*   定义`__getitem__`方法,获取给定索引对应的图像路径(`ix`):

def __getitem__(self, ix):
    img_path = 'P1_Facial_Keypoints/data/training/' + \ 
                                    self.df.iloc[ix,0]

*   缩放图像:

    img = cv2.imread(img_path)/255.

*   将预期输出值(关键点)归一化为原始图像大小的一部分:

    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()

在前面的代码中,我们确保关键点按原始图像大小的比例提供。这样做是为了当我们调整原始图像的大小时,关键点的位置不会改变,因为关键点是作为原始图像的一部分提供的。此外,通过将关键点作为原始图像的一部分,我们可以得到介于 0 和 1 之间的输出值。

*   对图像进行预处理后,返回关键点(`kp2`)和图像(`img`):

    kp2 = kp_x + kp_y
    kp2 = torch.tensor(kp2) 
    img = self.preprocess_input(img)
    return img, kp2

*   定义预处理图像的功能(`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)

*   定义一个函数来加载图像,当我们想要可视化一个测试图像和测试图像的预测关键点时,这个函数会很有用:

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

4.  现在,让我们创建一个训练和测试数据分割,并建立训练和测试数据集和数据加载器:

from sklearn.model_selection import train_test_split

train, test = train_test_split(data, test_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)


在前面的代码中,我们在输入数据框中按人名分割了训练和测试数据集,并提取了它们对应的对象。

5.  现在让我们定义一个模型,我们将利用它来识别图像中的关键点:

*   加载预训练的 VGG16 模型:

def get_model():
model = models.vgg16(pretrained=True)


*   确保预训练模型的参数首先被冻结:

for param in model.parameters():
    param.requires_grad = False

覆盖并解冻模型最后两层的参数:

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()
                                )

请注意,`classifier`模块中模型的最后一层是一个 sigmoid 函数,它返回一个介于 0 和 1 之间的值,并且预期输出将始终介于 0 和 1 之间,因为关键点位置是原始图像尺寸的一部分:

*   定义损失函数和优化器,并将它们与模型一起返回:

criterion = nn.L1Loss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
return model.to(device), criterion, optimizer

注意,损失函数是`L1Loss`,换句话说,我们正在对面部关键点的位置(将被预测为图像的宽度和高度的百分比)的预测执行平均绝对误差减少。

6.  获取模型、损失函数和相应的优化器:

model, criterion, optimizer = get_model()


7.  定义函数来训练一批数据点,并在测试数据集上进行验证:

*   正如我们前面所做的那样,训练一个批处理需要获取通过模型传递输入的输出,计算损失值,并执行反向传播以更新权重:

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


*   构建一个返回测试数据损失和预测关键点的函数:

def validate_batch(img, kps, model, criterion):
model.eval()
with torch.no_grad():
_kps = model(img.to(device))
loss = criterion(_kps, kps.to(device))
return _kps, loss


8.  在训练数据加载器的基础上训练模型,并在测试数据上测试它,就像我们在前面几节中所做的那样:

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, model, optimizer,
criterion)
epoch_train_loss += loss.item()
epoch_train_loss /= (ix+1)

for ix,(img,kps) in enumerate(test_loader):
    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)

9.绘制递增时期的训练和测试损失图:

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()


上述代码会产生以下输出:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/aee91f6f-f116-42dc-9d1d-591ab157b321.png)

10.  在随机测试图像的索引上测试我们的模型,比如说 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()


上述代码会产生以下输出:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/47715930-2162-4333-b8fa-539beb223aff.png)

从前面的图像中,我们可以看到,在给定图像作为输入的情况下,该模型能够相当准确地识别面部关键点。

在这一部分,我们已经从头开始建立了面部关键点检测器模型。然而,存在为 2D 和 3D 点检测构建的预训练模型。在下一节中,我们将了解如何利用人脸对齐库来获取人脸的 2D 和 3D 关键点。

2D 和三维人脸关键点检测

在本节中,我们将利用一个预训练的模型,它可以在几行代码中检测出人脸中存在的 2D 和 3D 关键点。

以下代码在本书的 GitHub 知识库的`Chapter05`文件夹中以`2D_and_3D facial_keypoints.ipynb`的形式提供-【https://tinyurl.com/mcvp-packt】请务必从 GitHub 中的笔记本上复制 URL,以避免在复制结果时出现任何问题

为了解决这个问题,我们将利用`face-alignment`库:

1.  安装所需的软件包:

!pip install -qU face-alignment
import face_alignment, cv2


2.  导入图像:

!wget https://www.dropbox.com/s/2s7xjto7rb6q7dc/Hema.JPG


3.  定义面对齐方法,其中我们指定是要获取 2D 还是 3D 中的关键点界标:

fa = face_alignment.FaceAlignment(
face_alignment.LandmarksType._2D,
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 个 *x* 和 *y* 坐标。

5.  用检测到的关键点绘制图像:

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()


上述代码会产生以下输出:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/8b1b6213-1234-4b12-9f4f-14682a36c10e.png)

注意 60 个可能的面部关键点周围的+符号散点图。

以类似的方式,如下获得面部关键点的 3D 投影:

fa = face_alignment.FaceAlignment(
face_alignment.LandmarksType._3D,
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 关键点场景中使用的代码相比,唯一的变化是我们将 2D 指定为 3D

上述代码会产生以下输出:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/80a4f168-9589-4b46-b438-b3a352676f08.png)

通过利用`face_alignment`库的代码,我们看到我们能够利用预先训练的面部关键点检测模型在新图像上进行高精度预测。

到目前为止,在不同的使用案例中,我们学到了以下内容:

*   **猫对狗**:预测二元分类

*   时尚达人:在 10 个可能的类别中预测一个标签

*   **面部关键点**:预测给定图像的 0 到 1 之间的多个值

在下一节中,我们将了解如何使用单个网络在单个镜头中预测二进制类和回归值。

多任务学习-实现年龄估计和性别分类

多任务学习是一个研究分支,其中单个/几个输入用于预测几个不同但最终相关的输出。例如,在自动驾驶汽车中,模型需要识别障碍物,规划路线,提供适量的油门/刹车和转向,等等。它需要通过考虑同一组输入(来自几个传感器)在瞬间完成所有这些工作

从我们迄今为止已经解决的各种用例来看,我们可以训练神经网络,估计给定图像的人的年龄,或者预测给定图像的人的性别,每次单独完成一项任务。然而,我们还没有看到这样一个场景,在这个场景中,我们可以从一张照片中预测年龄和性别。在单个镜头中预测两个不同的属性是很重要的,因为相同的图像用于两个预测(这将在我们在第七章、*目标检测基础*中执行目标检测时得到进一步的理解)。

在本节中,我们将了解如何在一次向前传递中预测两种属性,连续预测和分类预测。

我们采取的策略如下:

1.  导入相关的包。

2.  获取包含人物图像、性别和年龄信息的数据集。

3.  通过执行适当的预处理来创建训练和测试数据集。

4.  建立一个适用于以下情况的模型:

*   除了最后一部分,模型的所有层都与我们到目前为止构建的模型相似。

*   在最后一部分,创建从前一层分支出来的两个单独的层,其中一层对应于年龄估计,另一层对应于性别分类。

*   确保对输出的每个分支使用不同的损失函数,因为年龄是连续值(需要 mse 或 mae 损失计算),而性别是分类值(需要交叉熵损失计算)。

*   对年龄估计损失和性别分类损失进行加权求和。

*   通过执行优化权重值的反向传播来最小化总体损失。

5.  在新图像上训练模型和预测。

有了前面的策略,让我们编写用例:

The code for this section is available as `Age_and_gender_prediction.ipynb` in the `Chapter05` folder of this book's GitHub repository - [`tinyurl.com/mcvp-packt`](https://tinyurl.com/mcvp-packt) The code contains URLs to download data from. We strongly recommend you to execute the notebook on 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()


上述代码会产生以下输出:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/997e653e-9899-4e89-ac17-ed17d5188ef0.png)

4.  构建`GenderAgeClass`类,将文件名作为输入,并返回相应的图像、性别和年龄。我们缩放年龄,因为它是一个连续的数字,正如我们在第三章、*使用 PyTorch* 构建深度神经网络中看到的,最好缩放数据以避免消失梯度,然后在后处理期间重新缩放:

*   在`__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])


*   将`__len__`方法定义为返回输入中图像数量的方法:

def __len__(self): return len(self.df)

*   定义在给定位置获取图像信息的`__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

*   编写一个预处理图像的函数,包括调整图像大小、排列通道以及对缩放后的图像执行标准化:

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]

*   创建`collate_fn`方法,该方法获取一批数据,其中数据点经过如下预处理:

*   使用`process_image`方法处理每个图像。

*   将年龄缩放 80(数据集中存在的最大年龄值),以便所有值都在 0 和 1 之间。

*   将性别转换为浮点值。

*   图像、年龄和性别都被转换成 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

5.  我们现在定义训练和验证数据集和数据加载器:

*   创建数据集:

trn = GenderAgeClass(trn_df)
val = GenderAgeClass(val_df)


*   指定数据加载器:

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)


6.  定义模型、损失函数和优化器:

*   首先,在函数中,我们加载预训练的 VGG16 模型:

def get_model():
model = models.vgg16(pretrained = True)


*   接下来,冻结加载的模型(通过指定`param.requires_grad = False`):

for param in model.parameters():
    param.requires_grad = False

*   用我们自己的图层覆盖`avgpool`图层:

model.avgpool = nn.Sequential(
                    nn.Conv2d(512,512, kernel_size=3),
                    nn.MaxPool2d(2),
                    nn.ReLU(),
                    nn.Flatten()
                )

现在关键的部分来了。我们通过创建两个输出分支来偏离我们迄今为止所学的内容。这按如下方式执行:

*   用下面的`__init__`方法建立一个名为`ageGenderClassifier`的神经网络`class`:

class ageGenderClassifier(nn.Module):
    def __init__(self):
        super(ageGenderClassifier, self).__init__()

*   定义`intermediate`层的计算:

        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(),
                            )

*   定义`age_classifier`和`gender_classifier`:

        self.age_classifier = nn.Sequential(
                                    nn.Linear(64, 1),
                                    nn.Sigmoid()
                                )
        self.gender_classifier = nn.Sequential(
                                    nn.Linear(64, 1),
                                    nn.Sigmoid()
                                )

请注意,在前面的代码中,最后的层有一个 sigmoid 激活,因为年龄输出将是一个 0 到 1 之间的值(因为它被缩放了 80),而性别有一个 sigmoid,因为输出是 *0* 或 *1* 。

*   将堆叠层的`forward`路径方法定义为先`intermediate`,后`age_classifier`,再`gender_classifier`:

    def forward(self, x):
        x = self.intermediate(x)
        age = self.age_classifier(x)
        gender = self.gender_classifier(x)
        return gender, age

*   用我们之前定义的类覆盖`classifier`模块:

model.classifier = ageGenderClassifier()

*   定义性别(二元交叉熵损失)和年龄(L1 损失)预测的损失函数。定义优化器并返回模型、损失函数和优化器,如下所示:

gender_criterion = nn.BCELoss()
age_criterion = nn.L1Loss()
loss_functions = gender_criterion, age_criterion
optimizer = torch.optim.Adam(model.parameters(),lr= 1e-4)
return model.to(device), loss_functions, optimizer

*   调用`get_model`函数来初始化变量中的值:

model, criterion, optimizer = get_model()


7.  定义函数对一批数据进行训练,并对一批数据集进行验证。

`train_batch`方法将图像、性别、年龄、型号、优化器和损失函数的实际值作为输入来计算总损失,如下所示:

*   使用适当的输入参数定义`train_batch`方法:

def train_batch(data, model, optimizer, criteria):


*   指定我们正在训练模型,将优化器重置为`zero_grad`,并计算年龄和性别的预测值:

model.train()
ims, age, gender = data
optimizer.zero_grad()
pred_gender, pred_age = model(ims) 

*   在计算对应于年龄估计和性别分类的损失之前,获取年龄和性别的损失函数:

gender_criterion, age_criterion = criteria
gender_loss = gender_criterion(pred_gender.squeeze(), \
                                gender)
age_loss = age_criterion(pred_age.squeeze(), age)

*   通过对`gender_loss`和`age_loss`求和来计算总损失,并通过优化模型的可训练权重来执行反向传播以减少总损失,并返回总损失;

total_loss = gender_loss + age_loss
total_loss.backward()
optimizer.step()
return total_loss

`validate_batch`方法将图像、模型和损失函数以及年龄和性别的实际值作为输入,计算年龄和性别的预测值以及损失值,如下所示:

*   用合适的输入参数定义`vaidate_batch`函数:

def validate_batch(data, model, criteria):


*   指定我们要评估模型,因此在通过模型传递图像来预测年龄和性别值之前,不需要进行梯度计算:

model.eval()
with torch.no_grad():
    pred_gender, pred_age = model(img)

*   计算对应于年龄和性别预测的损失值(`gender_loss`和`age_loss`)。我们压缩预测(其形状为(批处理大小,1),以便将其重新调整为与原始值相同的形状(其形状为批处理大小):

gender_criterion, age_criterion = criteria
gender_loss = gender_criterion(pred_gender.squeeze(), \
                                    gender)
age_loss = age_criterion(pred_age.squeeze(), age)

*   计算总损失、最终预测性别类别(`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

8.  在五个时期内训练模型:

*   定义占位符以存储训练和测试损失值,并指定时期数:

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()


*   循环通过不同的时期,并在每个时期开始时重新初始化训练和测试损失值:

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)


*   循环训练数据加载器(`train_loader`)并训练模型:

for ix, data in enumerate(train_loader):
    loss = train_batch(data, model, optimizer, criteria)
    epoch_train_loss += loss.item()

*   循环通过测试数据加载器,并计算性别准确度和平均年龄:

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])

*   计算年龄预测和性别分类的总体准确度:

val_age_mae /= ctr
val_gender_acc /= ctr
epoch_train_loss /= len(train_loader)
epoch_test_loss /= len(test_loader)

*   记录每个时期的指标:

elapsed = time.time()-start
best_test_loss = min(best_test_loss, epoch_test_loss)
print('{}/{} ({:.2f}s - {:.2f}s remaining)'.format(\
                epoch+1, n_epochs, 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)

*   存储每个历元中测试数据集的年龄和性别准确度:

val_gender_accuracies.append(val_gender_acc)
val_age_maes.append(val_age_mae)

9.  绘制年龄估计和性别预测在不同时期的准确度图:

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()


上述代码会产生以下输出:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/6284b8c1-8be8-457c-bf2f-2a5145c62aba.png)

我们在年龄预测方面有 6 年的误差,在性别预测方面大约有 84%的准确率。

10.  在随机测试图像上预测年龄和性别:

*   获取图像:

!wget https://www.dropbox.com/s/6kzr8l68e9kpjkf/5_9.JPG


*   加载图像并通过我们之前创建的`trn`对象中的`preprocess_image`方法传递它:

im = cv2.imread('/content/5_9.JPG')
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()


*   绘制图像并打印原始值和预测值:

im = cv2.imread('/content/5_9.JPG')
im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
plt.imshow(im)
print('predicted gender:',np.where(pred_gender[0][0]<0.5,
'Male','Female'),
'; Predicted age', int(pred_age[0][0]*80))


上述代码会产生以下输出:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/f07995df-617d-45b7-90f4-1f3799636191.png)

通过以上内容,我们可以看到我们能够一次性预测年龄和性别。然而,我们需要注意的是,这是非常不稳定的,并且年龄值随着图像的不同方向以及光照条件而显著变化。在这种情况下,数据增强就派上了用场。

到目前为止,我们已经了解了迁移学习、预培训架构,以及如何在两种不同的用例中利用它们。您还会注意到,代码稍微长了一点,我们手动导入大量的包,创建空列表来记录指标,并不断读取/显示图像以进行调试。在下一节中,我们将了解作者为避免这种冗长代码而构建的库。

# torch_snippets 库简介

正如您可能已经注意到的,我们在几乎所有的部分都使用相同的函数。一遍又一遍地写同样的函数是浪费我们的时间。为了方便起见,这本书的作者写了一个名为`torch_snippets`的 Python 库,这样我们的代码看起来就简单明了了了。

诸如读取图像、显示图像以及整个训练循环的实用程序是相当重复的。我们希望通过将相同的函数封装在代码中(最好是一个函数调用)来避免反复编写相同的函数。例如,要读取一幅彩色图像,我们不需要每次都先写`cv2.imread(...)`再写`cv2.cvtColor(...)`。相反,我们可以简单地称之为`read(...)`。同样,对于`plt.imshow(...)`,有许多麻烦,包括图像的大小应该是最佳的,通道维度应该是最后的(记住 PyTorch 首先拥有它们)。这些将总是由单个函数`show`来处理。类似于`read`和`show`,我们将在整本书中使用超过 20 个方便的函数和类。从现在开始,我们将使用`torch_snippets`,以便更加专注于实际的深度学习,而不会分心。让我们深入一点,通过用这个库来训练`age-and-gender`来理解突出的函数,这样我们可以学习使用这些函数并获得最大的好处。

这一部分的代码可以在本书的 GitHub 资源库【https://tinyurl.com/mcvp-packt[的`Chapter05`文件夹中找到`age_gender_torch_snipets.ipynb`。为了保持简洁,我们在本节中只提供了额外的代码。关于完整代码,我们鼓励您参考 GitHub 中的笔记本。](https://tinyurl.com/mcvp-packt)

1.  安装并加载库:

!pip install torch_snippets
from torch_snippets import *


从一开始,这个库就允许我们加载所有重要的 torch 模块和实用程序,比如 NumPy、pandas、Matplotlib、Glob、Os 等等。

2.  如前一节所述,下载数据并创建数据集。创建一个数据集类`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.imread`和`cv2.COLOR_BGR2RGB`包装成一个函数调用。“1”代表“读取为彩色图像”,如果没有给出,默认情况下将加载黑白图像。还有一个`resize`函数包装了`cv2.resize`。接下来,我们来看看`show`函数。

3.  指定训练和验证数据集并查看样本图像:

trn = GenderAgeClass(trn_df)
val = GenderAgeClass(val_df)
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)

im, gen, age = trn[0]
show(im, title=f'Gender: {gen}\nAge: {age}', sz=5)


因为我们在整本书中处理图像,所以将`import matplotlib.pyplot as plt`和`plt.imshow`包装成一个函数是有意义的。打电话给`show(<2D/3D-Tensor>)`会做到这一点。与 Matplotlib 不同,它可以绘制 GPU 上存在的 torch 数组,而不管图像是否包含作为第一维或最后一维的通道。关键字`title`将为图像绘制一个标题,关键字`sz`(size 的简称)将根据传递的整数值绘制一个更大/更小的图像(如果没有传递,`sz`将根据图像分辨率选择一个合理的默认值)。在目标检测章节中,我们也将使用相同的函数来显示边界框。查看`help(show)`了解更多参数。让我们在这里创建一些数据集,并检查第一批图像及其目标。

4.  创建数据加载器并检查张量。检查张量的数据类型、最小值、平均值、最大值和形状是如此常见的活动,以至于它被包装成一个函数。它可以接受任意数量的张量输入:

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)

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


5.  照常创建`model`、`optimizer`、`loss_functions`、`train_batch`和`validate_batch`。由于每个深度学习实验都是独特的,因此这一步没有任何包装器函数。

在本节中,我们将利用我们在上一节中定义的`get_model`、`train_batch`和`validate_batch`函数。为了简洁起见,我们在本节中不提供代码。但是,所有相关代码都可以在 GitHub 中的相应笔记本中找到。

6.  最后,我们需要加载所有组件并开始训练。记录递增时期的指标。

这是一个高度重复的循环,只需要很少的改变。我们将总是循环经过固定数量的时期,首先经过训练数据加载器,然后经过验证数据加载器。每次都使用`train_batch`或`validate_batch`来调用每一批,每次都必须创建空的度量列表,并在训练/验证之后跟踪它们。在一个时期结束时,您必须打印所有这些指标的平均值,并重复该任务。这也有助于你知道每个历元/批次将训练多长时间(以秒为单位)。最后,在培训结束时,通常使用`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_batch(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()


`Report`类是用唯一的参数,即要训练的时期数来实例化的,并且是在训练开始之前实例化的。

在每一个训练/验证步骤中,我们可以用一个位置参数调用`Report.record`方法,这个位置参数就是我们正在进行的训练/验证的位置(就批号而言)(通常是`( epoch_number + (1+batch number)/(total_N_batches) )`)。在位置参数之后,我们传递一堆我们可以自由选择的关键字参数。如果需要记录的是培训损失,关键字参数可以是`trn_loss`。在前面,我们记录了四个指标,`trn_loss`、`val_loss`、`val_gender_acc`和`val_age_mae`,而没有创建一个空列表。

它不仅记录,而且还会在输出中打印相同的损失。使用`'\r'`作为结束参数是一种特殊的方式,表示在下一次记录一组新的损失时替换该行。此外,`Report`将自动计算训练和验证的剩余时间,并打印出来。

当调用`Report.report_avgs`函数时,`Report`将记住何时记录指标并打印该时期的所有平均指标。这将是一个永久的印记。

最后,相同的平均指标在函数调用`Report.plot_epochs`中被绘制成折线图,不需要格式化(您也可以使用`Report.plot`来绘制整个训练的每一批指标,但是这可能看起来很混乱)。如果需要,同一个函数可以有选择地绘制度量标准。举个例子,在前面的例子中,如果您只对绘制`trn_loss`和`val_loss`度量感兴趣,这可以通过调用`log.plot_epochs(['trn_loss, 'val_loss'])`甚至简单的`log.plot_epochs('_loss')`来完成。它将搜索与所有指标匹配的字符串,并找出我们需要的指标。

训练完成后,前面代码片段的输出应该如下所示:

![](https://gitee.com/OpenDocCN/vkdoc-cv-zh/raw/master/docs/mod-cv-pytorch/img/a1e005e3-c1a5-44e5-b53d-45a21cf372b3.png)

请注意,对于年龄和性别值,输出具有相应的训练和验证数据集损失和准确性值,即使我们没有初始化任何空列表来在训练和验证数据集中记录这些指标(我们在前面的小节中进行了初始化)

7.  加载样本图像并实现预测:

!wget -q https://www.dropbox.com/s/6kzr8l68e9kpjkf/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.min`、`tensor.mean`、`tensor.max`、`tensor.shape`、`tensor.dtype`——几个张量的统计)

*   报告(在培训时跟踪所有指标,并在培训后绘制它们)

您可以通过运行`torch_snippets; print(dir(torch_snippets))`来查看完整的函数列表。对于每个功能,你可以使用`help(function)`或简单的`??function`在 Jupyter 笔记本上打印它的帮助。随着对利用`torch_snippets`的理解,您应该能够大大简化代码。从下一章开始,你会注意到这一点。

# 摘要

在这一章中,我们已经了解了迁移学习如何帮助实现高精度,即使数据点数量较少。我们还了解了受欢迎的预培训模型,VGG 和雷斯内特。此外,当我们尝试预测不同的场景时,我们知道如何构建模型,例如面部关键点的位置和组合损失值,同时训练模型来预测年龄和性别,其中年龄属于特定数据类型,而性别属于不同的数据类型。

有了通过迁移学习进行图像分类的基础,在下一章中,我们将了解训练图像分类模型的一些实际方面。我们将学习如何解释一个模型,还将学习如何训练一个模型以达到高精度的技巧,最后,学习一个从业者在实现一个训练好的模型时需要避免的陷阱。

# 问题

1.  VGG 和 ResNet 预培训架构的培训内容是什么?

2.  为什么 VGG11 的精度不如 VGG16?

3.  VGG11 中的数字 11 代表什么?

4.  什么是剩余网络中的*剩余*?

5.  残余网络的优势是什么?

6.  有哪些各种流行的预训模型?

7.  在迁移学习过程中,为什么要使用与预训练模型训练过程中使用的相同的均值和标准差来归一化图像?

8.  为什么我们要冻结模型中的某些参数?

9.  我们如何知道预训练模型中存在的各种模块?

10.  我们如何训练一个能同时预测分类值和数值的模型?

11.  如果我们执行与我们在年龄和性别估计一节中所写的代码相同的代码,为什么年龄和性别预测代码可能不总是对您感兴趣的图像起作用?

12.  我们如何进一步提高我们在面部关键点预测部分中提到的面部关键点识别模型的准确性?

六、图像分类的实践层面

在前面的章节中,我们学习了利用卷积神经网络(CNN)以及预训练模型来执行图像分类。本章将进一步巩固我们对 CNN 的理解,以及在现实应用中利用它们时需要考虑的各种实际问题。我们将从理解 CNN 使用班级激活图 ( CAMs )预测他们所做班级的原因开始。接下来,我们将了解可以用来提高模型准确性的各种数据扩充。最后,我们将了解模型在现实世界中可能出错的各种情况,并强调在这种情况下应该注意的方面以避免陷阱。

本章将涵盖以下主题:

  • 生成凸轮
  • 了解批量标准化和数据扩充的影响
  • 模型实现过程中需要注意的实际问题

生成凸轮

想象一个场景,你已经建立了一个模型,能够做出很好的预测。然而,您向其展示模型的利益相关者想要了解模型预测保持原样的原因。在这种情况下,摄像头就派上了用场。CAM 示例如下,其中输入图像在左侧,用于产生分类预测的像素在右侧突出显示:

让我们来了解一下,一旦模型被训练好了,cam 是如何生成的。特征映射是卷积运算之后的中间激活。通常,这些激活图的形状是n-channels x height x width。如果我们取所有这些激活的平均值,它们显示了图像中所有类别的热点。但是如果我们只对某个特定类别(比如说,cat)重要的位置感兴趣,我们只需要在n-channels中找出那些负责那个类别的特征地图。对于生成这些特征图的卷积层,我们可以计算它相对于cat类的梯度。注意,只有那些负责预测cat的通道将具有高梯度。这意味着我们可以使用梯度信息给每个n-channels赋予权重,并获得一个专用于cat的激活图。

现在我们已经了解了如何生成 cam 的高级策略,让我们一步一步地将其付诸实践:

  1. 决定要为哪个类计算 CAM,以及要为神经网络中的哪个卷积层计算 CAM。
  2. 计算任何卷积层产生的激活–假设随机卷积层的特征形状为 512 x 7 x 7。
  3. 根据感兴趣的类别提取该图层的梯度值。输出的渐变形状是 256 x 512 x 3 x 3(这是卷积张量的形状——也就是in-channels x out-channels x kernel-size x kernel-size)。
  4. 计算每个输出通道内梯度的平均值。输出形状是 512。
  5. 计算加权激活图,即 512 个梯度平均值乘以 512 个激活通道。输出形状为 512 x 7 x 7。
  6. 计算加权激活图的平均值(跨越 512 个通道),以获取 7 x 7 形状的输出。
  7. 调整(放大)加权激活图输出的大小,以获取与输入大小相同的图像。这样做是为了让我们有一个类似于原始图像的激活图。
  8. 将加权激活图叠加到输入图像上。

来自论文 Grad-CAM 的下图:梯度加权类激活映射(【https://arxiv.org/abs/1610.02391】??)形象地描述了前面的步骤:

整个过程的关键在于第五步。我们考虑该步骤的两个方面:

  • 如果某个像素是重要的,那么 CNN 将在这些像素上有大量的激活。
  • 如果某个卷积信道对于所需的类别很重要,则该信道的梯度将非常大。

将这两者相乘,我们确实得到了所有像素的重要性图。

前面的策略在代码中实现,以理解 CNN 模型预测图像指示疟疾事件可能性的原因,如下所示:

The following code is available as Class_activation_maps.ipynb in the Chapter06 folder of this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from and is moderately lengthy. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components in text.

  1. 下载数据集并导入相关包:
import os
if not os.path.exists('cell_images'):
    !pip install -U -q torch_snippets
    !wget -q ftp://lhcftp.nlm.nih.gov/Open-Access-Datasets/
     Malaria/cell_images.zip
    !unzip -qq cell_images.zip
    !rm cell_images.zip
from torch_snippets import *
  1. 指定对应于输出类别的索引:
id2int = {'Parasitized': 0, 'Uninfected': 1}
  1. 在图像上执行要完成的变换:
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库。

  • 指定要在验证图像上完成的转换:
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]),
            ])
  1. 定义数据集类-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
  1. 获取训练和验证数据集以及数据加载器:
device = 'cuda' if torch.cuda.is_available() else 'cpu'
all_files = Glob('cell_img/*/*.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)
  1. 定义模型-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
  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()
  1. 在不断增加的时期内训练模型:
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)
  1. 提取模型中第五个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[-7][1]\
                        .weight.grad.data.mean((0,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'
  1. 定义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)

上述代码的输出如下:

由此我们可以看出,预测是因为以红色突出显示的内容(具有最高的 CAM 值)而保持原样。

现在,我们已经了解了如何使用训练好的模型来生成图像的类别激活热图,我们可以解释是什么使某个分类如此。

在下一节中,让我们了解在构建模型时有助于数据扩充的其他技巧。

了解数据扩充和批处理规范化的影响

提高模型准确性的一个聪明方法是利用数据扩充。我们已经在第四章、介绍卷积神经网络中看到了这一点,在那里我们使用数据增强来提高翻译图像的分类精度。在现实世界中,您会遇到具有不同属性的图像,例如,一些图像可能更亮,一些图像可能在边缘附近包含感兴趣的对象,而一些图像可能比其他图像更抖动。在本节中,我们将了解数据扩充的使用如何有助于提高模型的准确性。此外,我们将了解数据扩充如何实际上成为我们模型的伪正则化。

为了了解数据扩充和批量标准化的影响,我们将浏览一个识别交通标志的数据集。我们将评估三种情况:

  • 无批量标准化/数据扩充
  • 只有批量标准化,但没有数据扩充
  • 批量标准化和数据扩充

请注意,假设数据集和处理在三个场景中保持不变,只有数据扩充和模型(添加了批处理规范化层)不同,我们将只为第一个场景提供以下代码,但其他两个场景可在 GitHub 上的笔记本中找到。

对路标检测进行编码

让我们在不进行数据扩充和批量标准化的情况下,对路标检测进行编码,如下所示:

请注意,我们在这里不解释代码,因为它与我们在前面章节中学习过的代码非常一致——只有粗体字的行在三个场景中有所不同。下面的代码可以在本书的 GitHub 库【https://tinyurl.com/mcvp-packtChapter06文件夹中找到road_sign_detection.ipynb

  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 *
  1. 将类 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())}
  1. 在没有任何扩充的情况下,在图像上定义转换管道:
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
  1. 创建培训和验证数据集以及数据加载器:
device = 'cuda' if torch.cuda.is_available() else 'cpu'
all_files = Glob('GTSRB/Final_Training/img/*/*.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)
  1. 定义模型-SignClassifier:
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场景测试模型时,请确保取消前面代码中粗体部分的注释。

  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()
  1. 定义模型,并在不断增加的时期内对其进行训练:
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)

加粗的代码行是您在这三个场景中要更改的代码行。三个场景在训练和验证准确性方面的结果如下:

| 增强 | 批量定额 | 列车精度 | 验证精度 |
| 不 | 不 | Ninety-five point nine | Ninety-four point five |
| 不 | 是 | Ninety-nine point three | Ninety-seven point seven |
| 是 | 是 | Ninety-seven point seven | Ninety-seven point six |

请注意,在前面的三个场景中,我们看到了以下内容:

  • 当没有批次标准化时,模型没有那么高的准确度。
  • 当我们只有批量标准化而没有数据扩充时,模型的准确性显著增加,但是模型过度拟合训练数据。
  • 具有批量标准化和数据扩充的模型具有高精确度和最小的过度拟合(因为训练和验证损失值非常相似)。

由于批量归一化和数据扩充的重要性,在下一节中,我们将了解在训练/实现我们的影像分类模型时需要注意的一些关键方面。

在模型实现过程中需要处理的实际问题

到目前为止,我们已经看到了建立图像分类模型的各种方法。在本节中,我们将了解在构建模型时需要注意的一些实际问题。我们将在本章中讨论的问题如下:

  • 处理不平衡数据
  • 执行分类时图像中对象的大小
  • 训练图像和验证图像之间的差异
  • 网络中卷积层和汇集层的数量
  • 要在 GPU 上训练的图像大小
  • 利用 OpenCV 实用程序

处理不平衡数据

想象一下这样一个场景,你试图预测一个在我们的数据集中很少出现的物体——假设在全部图像的 1%中。例如,这可以是预测 X 射线图像是否暗示罕见的肺部感染的任务。

我们如何衡量被训练来预测罕见肺部感染的模型的准确性?如果我们简单的对所有图像预测一个没有感染的类别,分类的准确率是 99%,而仍然是无用的。在这种情况下,描述稀有对象类出现的次数和模型正确预测稀有对象类的次数的混淆矩阵就派上了用场。因此,在这个场景中要查看的正确的度量集是与混淆矩阵相关的度量。

典型的混淆矩阵如下所示:

在前面的混淆矩阵中,0代表无感染,1代表有感染。通常,我们会填充矩阵,以了解我们的模型有多精确。

接下来是确保模型得到训练的问题。通常,损失函数(二元或分类交叉熵)负责确保当误分类量高时损失值高。然而,除了损失函数之外,我们还可以将更高的权重分配给很少出现的类,从而确保我们明确地向模型提及我们想要正确地分类很少出现的类图像。

除了分配类权重之外,我们已经看到图像增强和/或迁移学习在提高模型的准确性方面有相当大的帮助。此外,当扩充图像时,我们可以对稀有类图像进行过采样,以增加它们在总体中的混合。

图像中对象的大小

想象一个场景,在一个大图像中出现一个小斑块决定了图像的类别,例如,肺部感染识别,其中出现某些微小结节表示疾病事件。在这种情况下,图像分类很可能导致不准确的结果,因为对象占据整个图像的较小部分。在这种情况下,目标检测很方便(我们将在下一章研究)。

解决这些问题的高级直觉是首先将输入图像分成更小的网格单元(比如 10×10 的网格),然后识别网格单元是否包含感兴趣的对象。

处理训练数据和验证数据之间的差异

想象一个场景,其中您建立了一个模型来预测一只眼睛的图像是否表明该人可能患有糖尿病性视网膜病。要构建模型,您需要收集数据、管理数据、裁剪数据、归一化数据,然后最终构建一个对验证图像具有极高准确性的模型。然而,假设,当该模型在真实环境中使用时(比如医生/护士),该模型不能很好地预测。让我们了解几个可能的原因:

  • 在医生办公室拍摄的图像与用于训练模型的图像相似吗?
  • 如果您在一组经过筛选的数据上建立模型,并且完成了所有预处理,而在医生端拍摄的图像是非筛选的,那么训练时使用的图像和真实世界的图像可能会非常不同。
  • 如果用于在医生办公室捕获图像的设备与用于收集用于训练的图像的设备相比,具有不同的捕获图像分辨率,则图像可能不同。
  • 如果在两个地方获取图像的照明条件不同,图像可能会有所不同。
  • 被摄对象(图片)是否足够代表总体人群?
  • 如果图像是在男性群体的图像上训练的,但是是在女性群体上测试的,或者如果通常训练和真实世界的图像对应于不同的人口统计,则图像是有代表性的。
  • 培训和验证是否有条不紊地进行?
  • 想象一个场景,有 10,000 个图像,前 5,000 个图像属于一个类别,后 5,000 个图像属于另一个类别。在构建模型时,如果我们不随机化,而是将数据集分为具有连续索引的训练和验证(没有随机索引),则在训练时可能会看到一个类的较高表示,在验证时可能会看到另一个类的较高表示。

一般来说,我们需要确保在最终用户利用系统之前,训练、验证和真实图像都具有相似的数据分布。

展平层中的节点数

考虑一个场景,您正在处理尺寸为 300 x 300 的图像。从技术上讲,我们可以执行五次以上的卷积池操作,以获得具有尽可能多功能的最终层。此外,在这个场景中,我们可以在 CNN 中拥有任意多的频道。但实际上,一般来说,我们会设计一个网络,使其在扁平化层中有 500–5000 个节点。

正如我们在第四章、介绍卷积神经网络中看到的,如果我们在展平层中有更多的节点,当展平层在连接到最终分类层之前连接到后续密集层时,我们将有非常多的参数。

一般来说,一个好的做法是有一个预训练的模型来获得展平层,以便在适当的时候激活相关的滤镜。此外,当利用预训练模型时,确保冻结预训练模型的参数。

通常,在不太复杂的分类练习中,CNN 中的可训练参数的数量可以在一百万到一千万之间。

图像尺寸

假设我们正在处理非常高维的图像,例如,形状为 2,000 x 1,000。在处理如此大的图像时,我们需要考虑以下可能性:

  • 图像可以调整到更低的尺寸吗?对象的图像在调整大小时可能不会丢失信息;但是,如果将文本文档的图像调整为较小的尺寸,可能会丢失大量信息。
  • 我们可以有一个较低的批量大小,以便批量适合 GPU 内存吗?通常,如果我们正在处理大型图像,对于给定的批处理大小,GPU 内存很可能不足以对该批图像执行计算。
  • 图像的某些部分是否包含大部分信息,因此图像的其余部分是否可以被裁剪掉?

利用 OpenCV 实用程序

OpenCV 是一个开源包,它有大量的模块来帮助从图像中获取信息(更多关于 OpenCV 实用程序的信息,见第十八章、使用 OpenCV 实用程序进行图像分析)。这是计算机视觉深度学习革命之前使用的最著名的库之一。传统上,它是建立在多个手工设计的功能之上的,在写这本书的时候,OpenCV 有几个集成深度学习模型输出的包。

想象一个场景,你必须将一个模型转移到生产中;在这种情况下,复杂性越低越好——有时甚至会以牺牲准确性为代价。如果任何 OpenCV 模块解决了您已经试图解决的问题,一般来说,它应该比构建模型更受欢迎(除非从头构建模型比利用现成的模块在准确性上有相当大的提高)。

摘要

在本章中,我们了解了在构建 CNN 模型时需要考虑的多个实际方面——批量标准化、数据扩充、使用 CAMs 解释结果,以及将模型投入生产时需要注意的一些场景。

在下一章中,我们将切换话题,了解目标检测的基础知识——我们不仅会识别图像中物体对应的类别,还会围绕物体的位置绘制一个边界框。

问题

  1. 凸轮是如何获得的?
  2. 批量规范化和数据扩充在训练模型时有什么帮助?
  3. CNN 模型过度拟合的常见原因是什么?
  4. CNN 模型在数据科学家端处理训练和验证数据,但在现实世界中不处理的各种场景有哪些?
  5. 我们利用 OpenCV 包的各种场景是什么?

七、目标检测基础

到目前为止,在前面的章节中,我们学习了执行图像分类。想象一个场景,我们正在利用计算机视觉来实现自动驾驶汽车。不仅需要检测道路的图像是否包含车辆、人行道和行人的图像,而且识别这些物体所在的位置也很重要。在这种情况下,我们将在本章和下一章学习的各种物体探测技术将会派上用场。

在这一章和下一章,我们将学习一些执行目标检测的技术。我们将从学习基础知识开始——使用名为ybat的工具标记边界框对象的基本事实,使用selectivesearch方法提取区域建议,并通过使用联合交集 ( IoU )度量和平均精度度量来定义边界框预测的准确性。之后,我们将了解两个基于区域提议的网络——R-CNN 和 Fast R-CNN,首先了解它们的工作细节,然后在包含卡车和公共汽车图像的数据集上实现它们。

本章将涵盖以下主题:

  • 目标检测简介
  • 为训练创建边界框地面真实
  • 了解区域提案
  • 了解 IoU、非最大抑制和平均精度
  • 训练基于 R-CNN 的自定义目标检测器
  • 训练快速的基于 R-CNN 的自定义目标检测器

目标检测简介

随着自动驾驶汽车、面部检测、智能视频监控和人数统计解决方案的兴起,快速准确的目标检测系统需求很大。这些系统不仅包括来自图像的对象分类,还包括通过在每个对象周围绘制适当的边界框来定位每个对象。这(绘制边界框和分类)使得目标检测比其传统的计算机视觉前身图像分类更难。

为了了解目标检测的输出是什么样的,我们来看一下下图:

在上图中,我们可以看到,虽然典型的对象分类仅提及图像中存在的对象类别,但对象定位会在图像中存在的对象周围绘制一个边界框。另一方面,目标检测将涉及在图像中的单个对象周围绘制边界框,以及在跨越图像中存在的多个对象的边界框内识别对象的类别。

在我们了解目标检测的广泛使用案例之前,让我们了解它是如何添加到我们在上一章中介绍的对象分类任务中的。

想象一个场景,在一个图像中有多个对象。我要求你预测图像中出现的物体类别。举个例子,假设图像中同时包含了猫和狗。你如何对这些图片进行分类?在这种情况下,目标检测非常方便,它不仅可以预测其中存在的对象(边界框)的位置,还可以预测各个边界框中存在的对象的类别。

利用目标检测的各种使用案例包括:

  • 安全:这有助于识别入侵者。
  • 自主 汽车:这有助于识别道路图像上出现的各种物体。
  • 图像搜索:这有助于识别包含感兴趣的物体(或人)的图像。
  • 汽车:这有助于识别汽车图像中的车牌。

在上述所有情况下,利用目标检测来围绕图像中存在的各种对象绘制边界框。

在这一章中,我们将学习预测物体的类别,并在图像中的物体周围有一个紧密的边界框,这是定位任务。我们还将学习检测图片中多个对象对应的类,以及每个对象周围的边界框,这是目标检测任务。

训练典型的目标检测模型包括以下步骤:

  1. 创建包含与图像中存在的各种对象相对应的边界框和类的标签的基础事实数据。
  2. 提出扫描图像以识别可能包含对象的区域(区域提议)的机制。在本章中,我们将了解如何利用名为选择性搜索的方法生成的区域建议。在下一章,我们将学习如何利用定位框来识别包含对象的区域。在结合计算机视觉和自然语言处理技术的一章中(第十五章),我们将学习如何利用转换器中的位置嵌入来帮助识别包含物体的区域。
  3. 使用 IoU 度量创建目标类变量。
  4. 创建目标边界框偏移变量,以对第二步中出现的区域提议的位置进行校正。
  5. 建立模型,该模型可以预测对象的类别以及对应于区域提议的目标包围盒偏移。
  6. 使用平均精度 ( )测量目标检测的精度。

现在,我们对训练目标检测模型要做的事情有了一个高层次的概述,我们将在下一节中学习如何为边界框创建数据集(这是构建目标检测模型的第一步)。

为训练创建边界框地面真实

我们已经知道,目标检测给出的输出是图像中感兴趣的对象周围的边界框。为了构建检测图像中对象周围的边界框的算法,我们必须创建输入-输出组合,其中输入是图像,输出是给定图像中对象周围的边界框,以及对应于对象的类。

注意,当我们检测边界框时,我们检测的是包围图像的边界框的四个角的像素位置。

为了训练提供边界框的模型,我们需要图像,以及图像中所有对象的相应边界框坐标。在本节中,我们将了解一种创建训练数据集的方法,其中图像是输入,相应的边界框和对象类存储在 XML 文件中作为输出。我们将使用ybat工具来注释边界框和相应的类。

让我们了解一下如何安装和使用ybat来创建(注释)图像中物体周围的边界框。此外,我们还将在下一节中检查包含带注释的类和边界框信息的 XML 文件。

安装图像注释工具

让我们从下面的 GitHub 链接开始下载ybat-master.zipgithub.com/drainingsun/ybat,然后解压。解压后,存储在你选择的文件夹中。使用您选择的浏览器打开ybat.html,您将看到一个空白页面。下面的截图展示了文件夹的样子以及如何打开ybat.html文件:

在我们开始创建对应于一个图像的地面实况之前,让我们指定我们想要跨图像标记并存储在classes.txt文件中的所有可能的类,如下所示:

现在,让我们准备一个图像对应的地面真相。这包括在对象(下图中的人)周围绘制一个边界框,并在以下步骤中为图像中出现的对象分配标签/类别:

  1. 上传所有要添加注释的图像(下图中的步骤 1)。
  2. 上传classes.txt文件(下图中的步骤 2)。
  3. 为每个图像添加标签,首先选择文件名,然后在每个要添加标签的对象周围画一个十字准线(下图中的步骤 3)。在绘制十字准线之前,请确保在“类别”区域(下图中第二个椭圆形下方的“类别”窗格)中选择了正确的类别。
  4. 以所需格式保存数据转储(下图中的步骤 4)。每种格式都是由不同的研究团队独立开发的,所有格式都同样有效。基于它们的受欢迎程度和便利性,每个实现都喜欢不同的格式。

使用下图可以更好地表示所有这些步骤:

例如,当我们下载 PascalVOC 格式时,它会下载一个 XML 文件的 zip 文件。绘制矩形边界框后,XML 文件的快照如下:

从前面的截图中,注意到bndbox字段包含与图像中感兴趣的对象相对应的 xy 坐标的最小值和最大值的坐标。我们还应该能够使用name字段提取对应于图像中对象的类。

既然我们已经了解了如何创建图像中存在的对象(类标签和边界框)的基本事实,在下面的部分中,我们将深入到识别图像中的对象的构建块中。首先,我们将讨论有助于突出显示图像中最有可能包含对象的部分的区域提议。

了解区域提案

想象一个假设场景,其中感兴趣的图像在背景中包含一个人和天空。此外,对于这种情况,让我们假设背景(天空)的像素强度几乎没有变化,而前景(人)的像素强度有相当大的变化。

仅仅从前面的描述本身,我们可以得出结论,这里有两个主要区域——一个是人的,另一个是天空的。此外,在人的图像的区域内,对应于头发的像素将具有与对应于脸的像素不同的强度,从而确定在一个区域内可以有多个子区域。

区域提议是一种帮助识别像素彼此相似的区域岛的技术。

生成区域建议对于我们必须识别图像中存在的对象的位置的目标检测来说很方便。此外,给定一个区域提议生成该区域的提议,这有助于对象定位,其中任务是识别正好适合图像中对象周围的边界框。我们将在后面的培训基于 R-CNN 的自定义目标检测器中了解区域提议如何帮助对象定位和检测,但让我们首先了解如何从图像中生成区域提议。

利用选择性研究生成区域建议书

SelectiveSearch 是一种用于对象定位的区域建议算法,它根据像素强度生成可能组合在一起的区域建议。SelectiveSearch 根据相似像素的层次分组对像素进行分组,这反过来又利用了图像中内容的颜色、纹理、大小和形状兼容性。

最初,SelectiveSearch 通过基于前面的属性对像素进行分组来过度分割图像。接下来,它遍历这些过度分割的组,并根据相似性对它们进行分组。在每次迭代中,它将较小的区域组合成一个较大的区域。

我们通过下面的例子来了解一下selectivesearch的流程:

以下代码在本书的 GitHub 知识库的Chapter07文件夹中以Understanding_selectivesearch.ipynb的形式提供-【https://tinyurl.com/mcvp-packt】请务必从 GitHub 中的笔记本上复制 URL,以避免在复制结果时出现任何问题

  1. 安装所需的软件包:
!pip install selectivesearch
!pip install torch_snippets
from torch_snippets import *
import selectivesearch
from skimage.segmentation import felzenszwalb
  1. 获取并加载所需的图像:
!wget https://www.dropbox.com/s/l98leemr7r5stnm/Hemanvi.jpeg
img = read('Hemanvi.jpeg', 1)
  1. 从图像中提取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)

上述代码会产生以下输出:

从前面的输出可以看出,属于同一组的像素具有相似的像素值。

具有相似值的像素形成一个区域提议。这现在有助于目标检测,因为我们现在将每个区域提议传递给网络,并要求它预测该区域提议是背景还是对象。此外,如果它是一个对象,这将有助于我们确定获取与该对象相对应的紧边界框以及与区域建议内的内容相对应的类的偏移量。

现在我们已经了解了 SelectiveSearch 的工作,让我们实现selectivesearch函数来获取给定图像的区域建议。

实现选择性研究以生成区域建议

在本节中,我们将使用selectivesearch定义extract_candidates函数,以便在后续关于训练基于 R-CNN 和快速 R-CNN 的自定义目标检测器的部分中利用它:

  1. 定义从图像中提取区域建议的extract_candidates函数:
  • 定义将图像作为输入参数的函数:
def extract_candidates(img):
  • 使用selectivesearch包中可用的selective_search方法提取图像中的候选区域:
    img_lbl, regions = selectivesearch.selective_search(img, \
                                     scale=200, min_size=100)
  • 计算图像面积并初始化一个列表(候选项),我们将使用该列表存储通过定义阈值的候选项:
    img_area = np.prod(img.shape[:2])
    candidates = []
  • 仅提取那些超过总图像面积 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
  1. 导入相关包并获取图像:
!pip install selectivesearch
!pip install torch_snippets
from torch_snippets import *
import selectivesearch
!wget https://www.dropbox.com/s/l98leemr7r5stnm/Hemanvi.jpeg
img = read('Hemanvi.jpeg', 1)
  1. 提取候选对象并将其绘制在图像上:
candidates = extract_candidates(img)
show(img, bbs=candidates)

上述代码生成以下输出:

上图中的网格代表来自selective_search方法的候选区域(区域建议)。

现在我们已经了解了区域建议书的生成,还有一个问题没有回答。我们如何利用区域提议进行目标检测和定位?

与感兴趣的图像中的对象的位置(地面实况)具有高交集的区域提议被标记为包含该对象的区域提议,而具有低交集的区域提议被标记为背景。

在下一节中,我们将在理解构成构建目标检测模型的主干的各种技术的过程中,了解如何计算候选区域提议与基本事实边界框的交集。

理解欠条

想象一个场景,我们想出了一个物体的边界框的预测。我们如何衡量我们预测的准确性?在这种情况下,交于 ( 借据)的概念就派上了用场。

交集在术语交集在并集内测量预测和实际边界框的重叠程度,而并集测量可能重叠的整体空间。IoU 是两个边界框之间的重叠区域与两个边界框的组合区域的比率。

这可以用下图表示:

在前面的两个边界框(矩形)的图中,让我们将左边的边界框视为地面真实,将右边的边界框视为对象的预测位置。作为度量的 IoU 是两个边界框之间的重叠区域与组合区域的比率。

在下图中,您可以观察到 IoU 度量随着边界框之间重叠的变化而变化:

从上图中我们可以看出,随着重叠减少,IoU 也会减少,在最后一张图中,当没有重叠时,IoU 度量为 0。

既然我们有了测量 IoU 的直觉,让我们用代码实现它,并创建一个函数来计算 IoU,因为我们将在“训练 R-CNN”和“训练快速 R-CNN”部分利用它。

下面的代码可以在本书的 GitHub 库【https://tinyurl.com/mcvp-packtChapter07文件夹中找到Calculating_Intersection_Over_Union.ipynb

让我们定义一个函数,它将两个边界框作为输入,并将 IoU 作为输出返回:

  1. 指定将boxAboxB作为输入的get_iou函数,其中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)
  1. 计算重叠面积(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_overlap被计算了两次,一次是在计算area_a时,另一次是在计算area_b时。

  1. 计算欠条并归还:
    iou = area_overlap / (area_combined+epsilon)
    return iou

在前面的代码中,我们将iou计算为重叠区域(area_overlap)与组合区域(area_combined)的面积之比,并将其返回。

到目前为止,我们已经学习了创建地面实况和计算 IoU,这有助于准备训练数据。接下来,目标检测模型将在检测图像中的对象时派上用场。最后,我们将计算模型性能并在新图像上进行推断。

我们将推迟构建模型,直到接下来的部分,因为训练模型更复杂,我们还必须在训练它之前学习更多的组件。在下一节中,我们将了解非最大值抑制,这有助于在新图像上使用训练模型进行推断时,从对象周围不同的可能预测边界框中进行筛选。

非最大抑制

设想一个场景,其中生成了多个区域提议,并且这些提议彼此明显重叠。本质上,所有预测的边界框坐标(对区域提议的偏移)彼此明显重叠。例如,让我们考虑下面的图像,其中为图像中的人生成了多个区域建议:

在上图中,我要求您在众多区域建议中找出我们认为包含对象的框,以及我们将丢弃的框。在这种情况下,非最大抑制很方便。让我们解开术语“非最大抑制”

非最大值是指不包含最大概率包含物体的盒子,抑制是指我们丢弃那些不包含最大概率包含物体的盒子。在非最大值抑制中,我们识别具有最高概率的边界框,并且丢弃 IoU 大于某个阈值的所有其他边界框,其中该框包含包含对象的最高概率。

在 PyTorch 中,使用torchvision.ops模块中的nms功能执行非最大抑制。nms函数采用边界框坐标、边界框中对象的置信度以及边界框间 IoU 的阈值来识别要保留的边界框。在分别位于步骤 19步骤 16训练基于 R-CNN 的定制目标检测器训练快速基于 R-CNN 的定制目标检测器部分中,当预测新图像中的对象类别和对象边界框时,您将利用nms功能。

平均精度

到目前为止,我们已经了解了如何获得包含图像中每个对象周围的边界框和对应于边界框中对象的类的输出。现在下一个问题来了:我们如何量化来自我们模型的预测的准确性?

在这种情况下,地图可以提供帮助。在我们尝试理解 mAP 之前,让我们先理解精度,然后是平均精度,最后是 mAP:

  • 精度:通常,我们计算精度为:

真阳性指的是预测正确的对象类别并且具有大于某个阈值的地面真值的 IoU 的边界框。假阳性是指边界框错误地预测了类,或者与地面真实值的重叠小于定义的阈值。此外,如果有多个边界框被识别为同一个基本事实边界框,则只有一个边界框会变成真阳性,而其他所有边界框都会变成假阳性。

  • 平均精度:平均精度是在各种 IoU 阈值下计算的精度值的平均值。
  • mAP: mAP 是在数据集中所有对象类别的各种 IoU 阈值下计算的精度值的平均值。

到目前为止,我们已经了解了如何为模型准备训练数据集,对模型的预测执行非最大值抑制,以及计算其准确性。在下面的部分中,我们将学习训练模型(基于 R-CNN 和基于快速 R-CNN)来检测新图像中的对象。

训练基于 R-CNN 的自定义目标检测器

R-CNN 代表基于区域的卷积神经网络R-CNN 内部的代表地区提案。区域提议用于识别图像内的对象。注意,R-CNN 有助于识别图像中存在的对象和图像中对象的位置。

在接下来的部分中,我们将学习 R-CNN 的工作细节,然后在我们的自定义数据集上训练它。

R-CNN 的工作细节

让我们使用下图在高层次上了解一下基于 R-CNN 的目标检测:

图片来源:https://arxiv.org/pdf/1311.2524.pdf

当利用 R-CNN 技术进行目标检测时,我们执行以下步骤:

  1. 从图像中提取区域建议:
  • 确保我们提取大量的建议,不遗漏图像中的任何潜在对象。
  1. 调整(扭曲)所有提取的区域,以获得相同大小的图像。
  2. 通过网络传递调整大小的区域提议:
  • 通常,我们通过预先训练的模型(如 VGG16 或 ResNet50)传递调整大小的区域建议,并在完全连接的层中提取特征。
  1. 创建用于模型训练的数据,其中输入是通过将区域提议传递通过预训练的模型而提取的特征,输出是对应于每个区域提议的类以及区域提议与对应于图像的地面真实的偏移:
  • 如果区域提议具有大于某个阈值的与对象的 IoU,我们以这样的方式准备训练数据,即该区域负责预测与其重叠的对象的类别,以及区域提议与包含感兴趣对象的地面真实边界框的偏移。

为区域方案创建边界框偏移和基础真值类的示例如下:

在上图中,o(红色)表示区域建议的中心(虚线边界框),x 表示与 cat 类对应的地面真实边界框(实线边界框)的中心。我们将区域提议边界框和地面真实边界框之间的偏移计算为两个边界框的中心坐标之间的差(dx,dy)和边界框的高度和宽度之间的差(dw,dh)。

  1. 将两个输出头,一个对应于图像的类别,另一个对应于区域提议的偏移,与地面真实边界框连接,以提取物体上的精细边界框:
  • 这个练习将类似于我们根据上一章中人的面部图像预测性别(分类变量,类似于本案例研究中的对象类别)和年龄(连续变量,类似于要在区域建议之上完成的偏移)的用例。
  1. 训练模型柱,编写自定义损失函数,使对象分类误差和边界框偏移误差最小化。

请注意,我们将最小化的损失函数不同于原始论文中优化的损失函数。我们这样做是为了降低从头构建 R-CNN 和快速 R-CNN 的复杂性。一旦读者熟悉了模型是如何工作的,并且能够使用下面的代码构建一个模型,我们强烈鼓励他们从头开始实现原始的论文。

在下一节中,我们将学习获取数据集和创建用于训练的数据。在接下来的部分中,我们将学习在预测新图像中存在的对象类别及其边界框之前设计和训练模型。

在自定义数据集上实现用于目标检测的 R-CNN

到目前为止,我们对 R-CNN 的工作原理有了理论上的了解。在本节中,我们将了解如何为培训创建数据。该过程包括以下步骤:

  1. 下载数据集

  2. 准备数据集

  3. 定义区域建议提取和 IoU 计算功能

  4. 创建培训数据

  • 为模型创建输入数据
  • 调整区域大小的建议
  • 将它们通过预训练的模型以获取完全连接的层值
  • 为模型创建输出数据
  • 用类别或背景标签标记每个区域提议
  • 如果所述区域提议对应于对象而不是背景,则定义所述区域提议相对于地面实况的偏移
  1. 定义和训练模型
  2. 对新图像进行预测

让我们从下面几节开始编码。

下载数据集

对于目标检测的场景,我们将从 Google Open Images v6 数据集下载数据(可从storage . Google APIs . com/Open img/V5/test-annotations-bbox . CSV获得)。然而,在代码中,我们将只处理那些公共汽车或卡车的图像,以确保我们可以训练图像(您将很快注意到与使用selectivesearch相关的内存问题)。我们将扩大我们将在第十章、目标检测和分割应用中培训的类别数量(除了公共汽车和卡车之外的更多类别)。

以下代码在本书的 GitHub 知识库的Chapter07文件夹中以Training_RCNN.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行笔记本以再现结果,同时您理解执行的步骤和对文本中各种代码组件的解释。

  1. 导入相关包以下载包含图像及其基本事实的文件:
!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 = 'img/images'
DF_RAW = pd.read_csv('df.csv')
print(DF_RAW.head())

前面数据帧的示例如下:

注意,XMinXMaxYMinYMax对应于图像的边界框的地面真实度。再者,LabelName提供了形象的类。

  1. 定义一个类,该类返回图像及其对应的类和基本事实以及图像的文件路径:
  • 将数据框(df)和包含图像的文件夹路径(image_folder)作为输入传递给__init__方法,并获取数据框(self.unique_images)中的唯一值ImageID。我们这样做是因为一幅图像可以包含多个对象,因此多行可以对应同一个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)
  • 定义__getitem__方法,其中我们获取对应于索引(ix)的图像(image_id),获取其边界框坐标(boxes)、classes,并返回图像、边界框、类和图像路径:
    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
  1. 检查样本图像及其相应的类和边界框基础事实:
ds = OpenImages(df=DF_RAW)
im, bbs, clss, _ = ds[9]
show(im, bbs=bbs, texts=clss, sz=10)

上述代码会产生以下结果:

  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 = [],[],[],[],[],[]
  1. 遍历数据集并填充上面初始化的列表:
  • 在这个练习中,我们可以使用所有的数据点进行训练,或者只使用前 500 个数据点进行说明。您可以在这两者之间进行选择,这决定了训练时间和训练精度(数据点越多,训练时间和精度就越大):
N = 500
for ix, (im, bbs, labels, fpath) in enumerate(ds):
    if(ix==N):
        break

在前面的代码中,我们指定我们将处理 500 个图像。

  • 使用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])
  • iousroisdeltasclss初始化为存储每个候选的iou、区域提议位置、边界框偏移和对应于每个图像的每个候选的类的列表。我们将仔细检查 SelectiveSearch 中的所有提案,并将 IOU 高的提案存储为公共汽车/卡车提案(以标签中的类别为准),其余的作为背景提案:
    ious, rois, clss, deltas = [], [], [], []
  • 存储关于图像的所有基本事实的所有候选的 IoU,其中bbs是图像中存在的不同对象的基本事实边界框,并且candidates是在先前步骤中获得的区域提议候选:
    ious = np.array([[extract_iou(candidate, _bb_) for \
                candidate in candidates] for _bb_ in bbs]).T
  • 遍历每个候选项,并存储候选项的 XMin ( cx)、YMin ( cy)、XMax ( cX)和 YMax ( cY)值:
    for jx, candidate in enumerate(candidates):
        cx,cy,cX,cY = candidate
  • 提取在获取ious的列表列表时已经计算出的所有基本事实包围盒的候选对应的 IoU
        candidate_ious = ious[jx]
  • 找到具有最高 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]
  • 如果 IoU ( best_iou)大于阈值(0.3),我们分配对应于候选的类的标签,否则分配背景:
        if best_iou > 0.3: clss.append(labels[best_iou_at])
        else : clss.append('background')
  • 获取所需的偏移量(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]))
  • 追加文件路径、IoU、roi、等级增量和基本事实边界框:
    FPATHS.append(fpath)
    IOUS.append(ious)
    ROIS.append(rois)
    CLSS.append(clss)
    DELTAS.append(deltas)
    GTBBS.append(bbs)
  • 获取图像路径名,并将获得的所有信息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)的每个区域提议的偏移。

在本节中,我们将根据在第步第 8 结束时获得的区域建议的基本事实准备一个数据集类,并从中创建数据加载器。接下来,我们将通过将每个区域调整到相同的形状并缩放来规范化它们。我们将从上一节停止的地方继续编码:

  1. 定义归一化图像的函数:
normalize= transforms.Normalize(mean=[0.485, 0.456, 0.406], \
                                 std=[0.229, 0.224, 0.225])
  1. 定义一个函数(preprocess_image)来预处理图像(img),其中我们切换通道、标准化图像并将其注册到设备:
def preprocess_image(img):
    img = torch.tensor(img).permute(2,0,1)
    img = normalize(img)
    return img.to(device).float()
  • 定义函数给类decode预测:
def decode(_y):
    _, preds = _y.max(-1)
    return preds
  1. 使用预处理的区域建议以及在先前步骤中获得的基础事实来定义数据集(步骤 7 ):
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)
  • 根据区域建议获取作物,以及与类和边界框偏移相关的其他地面实况:
    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
  • 定义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
  1. 创建训练和验证数据集以及数据加载器:
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)
  1. 定义RCNN网络模块:
  • 定义类别:
class RCNN(nn.Module):
    def __init__(self):
        super().__init__()
  • 定义主干(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(),
                        )
  • 定义对应于类别预测(self.cel)和边界框偏移回归(self.sl1)的损失函数:
        self.cel = nn.CrossEntropyLoss()
        self.sl1 = nn.L1Loss()
  • 定义前馈方法,其中我们通过 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
  • 定义计算损失的函数(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

有了模型类,我们现在定义函数来训练一批数据并预测验证数据。

  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()
  1. 现在,让我们创建一个模型对象,获取损失标准,然后定义优化器和时期数:
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(','))

跨训练和验证数据的总体损失图如下:

现在我们已经训练了一个模型,我们将在下一节中使用它来预测新图像。

预测新的图像

在本节中,我们将利用迄今为止训练的模型来预测和绘制新图像上的对象周围的边界框以及预测的边界框内的相应对象类别。我们采取的策略如下:

  1. 从新图像中提取区域建议。

  2. 调整大小和正常化每个作物。

  3. 前馈已处理的作物以预测类别和偏移量。

  4. 执行非最大抑制,仅提取那些包含对象的可信度最高的框。

我们通过一个将图像作为输入的函数和一个地面真实边界框(仅在我们比较地面真实和预测边界框时使用)来执行前面的策略。我们将从上一节停止的地方继续编码:

  1. 定义test_predictions函数对新图像进行预测:
  • 该函数将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]
  • 遍历候选图像以调整图像大小并对图像进行预处理:
    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)
  • 预测类别和偏移量:
    with torch.no_grad():
        rcnn.eval()
        probs, deltas = rcnn(input)
        probs = torch.nn.functional.softmax(probs, -1)
        confs, clss = torch.max(probs, -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)
  • 使用非最大抑制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]]
  • 获取具有最高可信度的边界框:
    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
  • 沿着预测的边界框绘制图像:
    _, 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
  1. 对新图像执行上述功能:
image, crops, bbs, labels, deltas, gtbbs, fpath = test_ds[7]
test_predictions(fpath)

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

从前面的图中,我们可以看到对图像类别的预测是准确的,并且包围盒预测也很不错。请注意,为前面的图像生成预测大约需要 1.5 秒。

所有这些时间都消耗在生成区域建议、调整每个区域建议的大小、通过 VGG 主干传递它们以及使用定义的模型生成预测上。然而,大部分时间都花在通过 VGG 骨干网传递每一个提案上。在下一节中,我们将学习如何通过使用基于快速 R-CNN 架构的模型来解决“将每个提议传递给 VGG”的问题。

训练快速的基于 R-CNN 的自定义目标检测器

R-CNN 的一个主要缺点是生成预测需要相当长的时间,因为为每个图像生成区域建议、调整区域裁剪的大小以及提取对应于每个裁剪的特征(区域建议)构成了瓶颈。

快速 R-CNN 通过将整个图像通过预训练的模型来提取特征,然后获取与原始图像的区域提议(从selectivesearch获得)相对应的特征区域,从而解决了这个问题。在接下来的部分中,我们将学习快速 R-CNN 的工作细节,然后在我们的自定义数据集上训练它。

快速 R-CNN 的工作细节

我们通过下图来了解一下 Fast R-CNN:

让我们通过以下步骤来理解上图:

  1. 使图像通过预训练的模型,以在展平层之前提取特征;让我们称输出为特征地图。
  2. 提取对应于该图像的区域提议。
  3. 提取对应于区域提议的特征映射区域(注意,当图像通过 VGG16 架构时,由于执行了 5 次汇集操作,图像在输出端被缩小 32 倍。因此,如果在原始图像中存在具有(40,32,200,240)的边界框的区域,则对应于(5,4,25,30)的边界框的特征图将对应于完全相同的区域)。
  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*中找到。

  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(img/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 属于第一幅图像,有多少属于第二幅图像,等等。这就是ridx出现的原因。这是一个索引列表。列表中的每个整数将相应的边界框与适当的图像相关联;例如,如果ridx[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)
  1. 定义要在数据集上训练的模型:
  • 首先,导入torchvision.ops类中的RoIPool方法:
from torchvision.ops import RoIPool
  • 定义FRCNN网络模块:
class FRCNN(nn.Module):
    def __init__(self):
        super().__init__()
  • 加载预训练模型并冻结参数:
        rawnet = torchvision.models.vgg16_bn(pretrained=True)
        for param in rawnet.features.parameters():
            param.requires_grad = True
  • 提取要素直到最后一个图层:
        self.seq = nn.Sequential(*list(\
                            rawnet.features.children())[:-1])
  • 指定RoIPool提取一个 7×7 的输出。这里,spatial_scale是建议(来自原始图像)需要缩小的因子,以便每个输出在通过展平层之前具有相同的形状。图像的大小为 224 x 224,而特征图的大小为 14 x 14:
        self.roipool = RoIPool(7, spatial_scale=14/224)
  • 定义输出头-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(),
                        )
  • 定义损失函数:
        self.cel = nn.CrossEntropyLoss()
        self.sl1 = nn.L1Loss()
  • 定义forward方法,该方法将图像、区域建议和区域建议索引作为之前定义的网络的输入:
    def forward(self, input, rois, ridx):
  • input图像通过预训练的模型:
        res = input
        res = self.seq(res)
  • 创建一个矩阵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
  • 定义损失值计算(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
  1. 像我们在 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()
  1. 在不断增加的时期内定义和训练模型:
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(','))

总损失的变化如下:

  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))
  • 获取区域建议并将其转换为(x1,y1,x2,y2)格式(左上像素和右下像素坐标),然后将这些值转换为它们所呈现的宽度和高度的比率,与图像成比例:
    candidates = extract_candidates(img)
    candidates = [(x,y,x+w,y+h) for x,y,w,h in candidates]
  • 预处理图像并缩放感兴趣区域(rois):
    input = preprocess_image(img/255.)[None]
    rois = [[x/224,y/224,X/224,Y/224] for x,y,X,Y in \
                candidates]
  • 由于所有建议都属于同一个图像,rixs将是一个零列表(与建议的数量一样多):
    rixs = np.array([0]*len(rois))
  • 通过训练的模型向前传播输入和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)
  • 过滤掉背景类:
    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
  • 移除带有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)
  • 绘制获得的边界框:
    _, 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()
  1. 在测试图像上预测:
test_predictions(test_ds[29][-1])

上述代码会产生以下结果:

前面的代码在 0.5 秒内执行,这明显优于 R-CNN。不过实时使用起来还是很慢。这主要是因为我们仍在使用两种不同的模型,一种用于生成区域建议,另一种用于预测等级和修正。在下一章中,我们将学习如何用一个单一的模型来做预测,这样在实时场景中就可以快速地做出推断。

摘要

在这一章中,我们开始学习为目标定位和检测过程创建训练数据集。接下来,我们学习了 SelectiveSearch,这是一种基于邻近像素相似性推荐区域的区域推荐技术。接下来,我们学习了如何计算 IoU 度量,以了解图像中存在的对象周围的预测边界框的优劣。接下来,我们学习了如何执行非最大值抑制,以便在从头开始构建 R-CNN 和快速 R-CNN 模型之前,为图像中的每个对象获取一个边界框。此外,我们还了解了 R-CNN 速度慢的原因,以及 R-CNN 利用 RoI 池和从特征地图获取区域建议的速度有多快,从而加快了推理速度。最后,我们了解到,来自单独模型的区域建议会导致在新图像上进行预测需要更长的时间。

在下一章中,我们将学习一些现代目标检测技术,这些技术被用来在更实时的基础上进行推断。

问题

  1. 区域提议技术如何生成提议?
  2. 如果一个图像中有多个对象,如何计算 IoU?
  3. 为什么 R-CNN 生成预测需要很长时间?
  4. 为什么快速 R-CNN 比 R-CNN 快?
  5. 投资回报池是如何工作的?
  6. 预测边界框校正时,如果没有多个图层来发布所获得的要素地图,会有什么影响?
  7. 为什么在计算总体损失时,我们必须为回归损失分配更高的权重?
  8. 非最大抑制是如何工作的?

八、高级目标检测

在前一章中,我们学习了 R-CNN 和快速 R-CNN 技术,它们利用区域建议来预测图像中对象的位置以及图像中对象对应的类别。此外,我们了解了推理速度的瓶颈,这是因为有两个不同的模型——一个用于区域提议生成,另一个用于目标检测。在这一章中,我们将了解不同的现代技术,如更快的 R-CNN、YOLO 和单镜头检测器 ( SSD ),它们通过采用单个模型在单镜头中预测对象的类别和边界框来克服缓慢的推理时间。我们将从学习锚盒开始,然后继续学习每种技术如何工作,以及如何实现它们来检测图像中的对象。

我们将在本章中讨论以下主题:

  • 现代目标检测算法的组成部分
  • 在自定义数据集上训练更快的 R-CNN
  • YOLO 的工作细节
  • 在自定义数据集上训练 YOLO
  • 固态硬盘的工作细节
  • 在自定义数据集上训练 SSD

现代目标检测算法的组成部分

R-CNN 和快速 R-CNN 技术的缺点是它们有两个脱节的网络——一个用于识别可能包含对象的区域,另一个用于对识别对象的边界框进行校正。此外,两个模型都需要与区域建议一样多的前向传播。现代目标检测算法主要集中在训练单个神经网络,并且具有在一次正向传递中检测所有对象的能力。在随后的章节中,我们将了解典型现代目标检测算法的各种组件:

  • 锚箱
  • 地区提案网 ( RPN )
  • 利息集中区域

锚箱

到目前为止,我们已经收到了来自selectivesearch方法的地区提案。锚定框是选择性搜索的便捷替代品——我们将在本节中了解它们如何取代基于selectivesearch的区域提议。

通常,大多数对象具有相似的形状,例如,在大多数情况下,与人的图像相对应的边界框的高度大于宽度,与卡车的图像相对应的边界框的宽度大于高度。因此,即使在训练模型之前(通过检查与各种类别的对象相对应的边界框的基本事实),我们也将对图像中存在的对象的高度和宽度有相当好的了解。

此外,在某些图像中,感兴趣的对象可能会被缩放,导致比平均值小得多或大得多的高度和宽度,同时仍然保持纵横比(即)。

一旦我们对图像中存在的对象的长宽比以及高度和宽度(可以从数据集中的地面真实值获得)有了一个合适的概念,我们就可以用代表我们的数据集中大多数对象的边界框的高度和宽度来定义锚框。

典型地,这是通过在图像中存在的对象的真实边界框的顶部采用 K-means 聚类来获得的。

现在我们已经了解了锚盒的高度和宽度是如何获得的,我们将了解如何在此过程中利用它们:

  1. 将每个锚定框从左上向右下滑动到图像上。
  2. 与对象在并集 ( IoU )上有高交集的锚定框将有一个标签,表明它包含一个对象,其他的将被标记为 0:
  • 我们可以这样修改 IoU 的阈值:如果 IoU 大于某个阈值,则对象类为 1;如果小于另一个阈值,则对象类为 0,否则未知。

一旦我们获得了这里定义的地面真相,我们就可以建立一个模型,该模型可以预测对象的位置以及对应于锚框的偏移,以将其与地面真相相匹配。现在让我们了解下图中锚定框是如何表示的:

在上图中,我们有两个定位框,一个高度大于宽度,另一个宽度大于高度,以对应图像中的对象(类)——一个人和一辆车。

我们在图像上滑动两个锚框,并注意锚框的 IoU 与地面实况最高的位置,并表示该特定位置包含对象,而其余位置不包含对象。

除了前面的两个锚定框,我们还将创建具有不同比例的锚定框,以便我们适应对象在图像中呈现的不同比例。以下是不同比例的锚盒外观示例:

请注意,所有锚定框都具有相同的中心,但具有不同的纵横比或比例。

现在我们已经了解了锚盒,在下一节中,我们将学习 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.1 - 0.5)之间的所有候选区域。

一旦我们训练一个模型来预测候选区域是否包含一个对象,我们就执行非最大值抑制,因为多个重叠区域可以包含一个对象。

总之,RPN 通过执行以下步骤来训练模型,以使其能够识别具有包含对象的高可能性的区域提议:

  1. 在图像上滑动不同纵横比和大小的锚定框,以获取图像的裁剪。
  2. 计算图像中对象的地面真实边界框和上一步获得的裁剪之间的 IoU。
  3. 准备训练数据集,使 IoU 大于阈值的作物包含对象,IoU 小于阈值的作物不包含对象。
  4. 训练模型以识别包含对象的区域。
  5. 执行非最大抑制以识别包含对象的概率最高的候选区域,并消除与其高度重叠的其他候选区域。

分类和回归

到目前为止,我们已经了解了识别对象和执行边界框偏移的以下步骤:

  1. 识别包含对象的区域。
  2. 使用感兴趣区域 ( 感兴趣区域)池化(我们在前一章中了解过),确保所有区域的特征图完全相同,而不管区域的形状如何。

这些步骤的两个问题如下:

  1. 区域建议在对象上并不紧密对应(IoU>0.5 是我们在 RPN 中的阈值)。
  2. 我们识别该区域是否包含对象,但是不识别位于该区域中的对象的类别。

我们将在本节中解决这两个问题,我们采用之前获得的统一形状的特征地图,并通过网络传递它。我们期望网络预测包含在该区域内的对象的类别以及对应于该区域的偏移,以确保边界框尽可能紧密地围绕图像中的对象。

让我们通过下图来理解这一点:

在上图中,我们将 RoI 池的输出作为输入(7 x 7 x 512 形状),将其展平,并连接到密集层,然后预测两个不同的方面:

  1. 区域中对象的类别
  2. 要在区域的预测边界框上完成的偏移量,以使 IoU 与地面真实值最大化

因此,如果数据中有 20 个类,则神经网络的输出总共包含 25 个输出–21 个类(包括背景类)和应用于边界框的高度、宽度和两个中心坐标的 4 个偏移量。

现在我们已经了解了目标检测管道的不同组件,让我们用下图总结一下:

有了更快的 R-CNN 的每个组件的工作细节,在下一节中,我们将使用更快的 R-CNN 算法编写目标检测代码。

在自定义数据集上训练更快的 R-CNN

在下面的代码中,我们将训练更快的 R-CNN 算法来检测图像中对象周围的边界框。为此,我们将进行与上一章相同的卡车和公交车检测练习:

The following code is available as Training_Faster_RCNN.ipynb in the Chapter08 folder of this book's GitHub repository - tinyurl.com/mcvp-packt.

  1. 下载数据集:
import os
if not os.path.exists('images'):
    !pip install -qU torch_snippets
    from google.colab import files
    files.upload() # upload kaggle.json
    !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
    !rm open-images-bus-trucks.zip
  1. 读取包含关于图像及其边界框和类的信息的元数据的数据帧:
from torch_snippets import *
from PIL import Image
IMAGE_ROOT = 'img/images'
DF_RAW = df = pd.read_csv('df.csv')
  1. 定义与标签和目标相对应的索引:
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)
  1. 定义预处理图像的功能-preprocess_image:
def preprocess_image(img):
    img = torch.tensor(img).permute(2,0,1)
    return img.to(device).float()
  1. 定义数据集类-OpenDataset:
  • 定义一个__init__方法,将包含图像的文件夹和包含图像元数据的数据帧作为输入:
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()
  • 定义__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 实现期望目标包含边界框的绝对坐标和标签信息。

  • 定义collate_fn方法(默认情况下,collate_fn只使用张量作为输入,但是在这里,我们处理的是字典列表)和__len__方法:
    def collate_fn(self, batch):
        return tuple(zip(*batch)) 

    def __len__(self):
        return len(self.image_infos)
  1. 创建培训和验证数据加载器和数据集:
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)
  1. 定义模型:
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

该模型包含以下关键子模块:

我们注意到以下情况:

  • GeneralizedRCNNTransform是一个简单的 resize,后面跟着一个 normalize 变换:

  • BackboneWithFPN是将输入转换成特征图的神经网络。
  • RegionProposalNetwork为前面的特征图生成锚框,并为分类和回归任务预测各个特征图:

  • RoIHeads获取前面的图,使用 RoI 池对齐它们,处理它们,并返回每个建议的分类概率和相应的偏移:

  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 arn'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
  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)
  • 训练模型并计算训练和测试数据集的损失值:
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)
  1. 绘制各种损失值在增加的时期内的变化:
log.plot_epochs(['trn_loss','val_loss'])

这会产生以下输出:

  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()
  • 获取测试图像上的盒子和类的预测:
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)

上述代码提供了以下输出:

在本节中,我们使用 PyTorch models包中提供的fasterrcnn_resnet50_fpn模型类训练了一个更快的 R-CNN 模型。在下一节中,我们将了解 YOLO,这是一种现代的目标检测算法,它可以在单次拍摄中执行对象类别检测和区域校正,而无需单独的 RPN。

YOLO 的工作细节

你只看一次 ( YOLO )及其变体是突出的目标检测算法之一。在这一节中,我们将从较高的层面了解 YOLO 是如何工作的,以及 YOLO 所克服的基于 R-CNN 的目标检测框架的潜在局限性。

首先,让我们了解一下基于 R-CNN 的检测算法可能存在的局限性。在更快的 R-CNN 中,我们使用锚框在图像上滑动,并识别可能包含对象的区域,然后我们进行边界框校正。然而,在完全连接的层中,其中只有检测到的区域的 RoI 汇集输出作为输入被传递,在区域没有完全包围对象的情况下(其中对象在区域提议的边界框的边界之外),网络必须猜测对象的真实边界,因为它没有看到完整的图像(但是只看到了区域提议)。

在这种情况下,YOLO 派上了用场,因为它在预测图像对应的边界框时会查看整个图像。

此外,fast R-CNN 仍然很慢,因为我们有两个网络:RPN 和预测类和对象周围的边界框的最终网络。

在这里,我们将了解 YOLO 如何克服更快的 R-CNN 的限制,既通过一次查看整个图像,又通过单一网络进行预测。我们将通过以下示例了解如何为 YOLO 准备数据:

  1. 为给定图像创建地面实况以训练模型:
  • 让我们考虑一个给定的红色边界框的图像:

  • 将图像分成 N x N 个网格单元——现在,假设 N =3:

  • 识别那些包含至少一个基本事实边界框中心的格网单元。在我们的例子中,它们是 3×3 网格图像的单元格 b1b3
  • 地面真实边界框的中点所在的单元负责预测对象的边界框。让我们创建对应于每个单元格的地面真相。
  • 对应于每个单元的输出接地真值如下:

这里, pc (对象性得分)是单元格包含对象的概率。

先来了解一下如何通过、 bwbh 计算 bx

首先,我们将网格单元(让我们将 b1 网格单元)视为我们的宇宙,并将其归一化为 0 到 1 之间的范围,如下所示:

bxby 是地面真实边界框相对于(网格单元的)图像的中点位置,如前所述。在我们的例子中, bx = 0.5,因为地面真实的中点距离原点 0.5 个单位。同理, = 0.5:

到目前为止,我们已经计算了从网格单元中心到对应于图像中对象的地面真实中心的偏移。现在,我们来了解一下 bwbh 是如何计算的。

bw 是边界框的宽度相对于网格单元宽度的比值。

bh 是边界框的高度相对于网格单元高度的比值。

接下来,我们将预测网格单元对应的类。如果我们有三个类(C1-卡车,C2-汽车,C3-公共汽车),我们将预测单元包含三个类中任何一个对象的概率。注意,这里我们不需要背景类,因为 pc 对应于网格单元是否包含对象。

现在我们已经了解了如何表示每个单元的输出层,让我们了解如何构造 3 x 3 网格单元的输出。

  • 让我们考虑网格单元 a3 的输出:

单元格 a3 的输出如前面的截图所示。由于网格单元不包含对象,第一个输出(PC–objectness score)是 0,并且剩余的值无关紧要,因为单元不包含对象的任何地面真实边界框的中心。

  • 让我们考虑对应于网格单元 b1 的输出:

前面的输出之所以是这样,是因为网格单元格包含一个对象,该对象的 bxbybwbh 值是以与我们之前经历的相同的方式获得的(在上一个项目符号中),最后类为car,导致 c2 为 1,而 c1 和 c3 为 0。

注意,对于每个单元,我们能够获取 8 个输出。因此,对于 3×3 网格的单元,我们获取 3×3×8 输出。

  1. 定义一个模型,其中输入是一个图像,输出是 3 x 3 x 8,地面实况是上一步中定义的:

  1. 通过考虑锚盒来定义地面实况。

到目前为止,我们已经构建了一个场景,期望在一个网格单元中只有一个对象。然而,在现实中,可能会出现在同一个网格单元中有多个对象的情况。这将导致创造不正确的真相。让我们通过下面的示例图像来理解这一现象:

在前面的示例中,汽车和人的基本事实边界框的中点都落在同一个单元格中——单元格 b1

避免这种情况的一种方法是使用包含更多行和列的网格,例如 19 x 19 的网格。但是,仍有可能出现增加网格单元数量于事无补的情况。锚盒在这种情况下就派上了用场。假设我们有两个锚定框——一个高度大于宽度(对应于人),另一个宽度大于高度(对应于车):

通常,定位框会将网格单元中心作为它们的中心。在我们有两个锚盒的场景中,每个单元的输出被表示为两个锚盒的预期输出的串联:

这里, bxbybwbh 表示从锚定框的偏移(这是图像中看到的这个场景中的宇宙,而不是网格单元)。

从前面的截图中,我们看到我们有一个 3 x 3 x 16 的输出,因为我们有两个锚点。预期输出的形式为NxNx(num_classes+1)x(num_anchor_boxes),其中 N x N 是网格中单元的数量,num_classes是数据集中类的数量,num_anchor_boxes是锚框的数量。

  1. 现在我们定义损失函数来训练模型。

当计算与模型相关联的损失时,我们需要确保当对象性分数小于某个阈值(这对应于不包含对象的单元)时,我们不计算回归损失和分类损失。

接下来,如果单元格包含一个对象,我们需要确保不同类之间的分类尽可能准确。

最后,如果单元格包含一个对象,边界框偏移量应该尽可能接近预期值。然而,由于宽度和高度的偏移量与中心的偏移量相比可能高得多(因为中心的偏移量在 0 和 1 之间,而宽度和高度的偏移量不需要),所以我们通过取平方根值来给宽度和高度的偏移量一个较低的权重。

计算本地化和分类的损失如下:

在这里,我们观察到以下情况:

  • 是与回归损失相关的权重。
  • 表示单元格中是否包含对象。
  • 对应于预测的类别概率,表示客观分数。

总损失是分类和回归损失值的总和。

有了这些,我们现在可以训练一个模型来预测物体周围的边界框。然而,为了更好地理解 YOLO 和它的变体,我们鼓励你浏览原始论文。现在我们已经了解了 YOLO 是如何在一次拍摄中预测边界框和对象类别的,我们将在下一节中编写代码。

在自定义数据集上训练 YOLO

建立在他人工作的基础上对于成为深度学习的成功实践者非常重要。对于这个实现,我们将使用官方的 YOLO-v4 实现来识别图像中公共汽车和卡车的位置。我们将克隆作者自己的 YOLO 实现的存储库,并在下面的代码中根据我们的需要定制它。

以下代码在本书的 GitHub 资源库【https://tinyurl.com/mcvp-packtChapter08文件夹中以Training_YOLO.ipynb的形式提供。

安装暗网

首先,从 GitHub 中拉出darknet库,并在环境中编译它。该模型是用一种叫做 Darknet 的独立语言编写的,它不同于 PyTorch。我们将使用以下代码来实现这一点:

  1. 拉动 Git 回购:
!git clone https://github.com/AlexeyAB/darknet
%cd darknet
  1. 重新配置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
  1. 安装torch_snippets包:
!pip install -q torch_snippets
  1. 下载并提取数据集,并删除 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
  1. 获取预先训练的权重以进行样本预测:
!wget --quiet\ https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v3_optimal/yolov4.weights
  1. 通过运行以下命令测试安装是否成功:
!./darknet detector test cfg/coco.data cfg/yolov4.cfg\ yolov4.weights
 data/person.jpg

这将使用从cfg/yolov4.cfg和预先训练的权重yolov4.weights构建的网络对data/person.jpg进行预测。此外,它从cfg/coco.data中获取类,这是预训练权重被训练的内容。

前面的代码导致对样本图像(data/person.jpg)的预测如下:

现在我们已经了解了如何安装darknet,在下一节中,我们将了解如何为我们的自定义数据集创建基本事实来利用darknet

设置数据集格式

YOLO 使用固定的训练模式。一旦我们以所需的格式存储了图像和标签,我们就可以用一个命令在数据集上进行训练。因此,让我们了解一下 YOLO 培训所需的文件和文件夹结构。

有三个重要步骤:

  1. 通过运行下面一行,在data/obj.names创建一个包含类名的文本文件,每行一个类(%%writefile是一个神奇的命令,它在data/obj.names创建一个包含笔记本单元格中存在的任何内容的文本文件):
%%writefile data/obj.names
bus
truck
  1. data/obj.data创建一个文本文件,描述数据集中的参数、包含训练和测试图像路径的文本文件的位置、包含对象名称的文件的位置以及保存训练模型的文件夹:
%%writefile data/obj.data
classes = 2
train = data/train.txt
valid = data/val.txt
names = data/obj.names
backup = backup/

The extensions for the preceding text files are not .txt.
Yolo uses hardcoded names and folders to identify where data is.
Also, the magic %%writefile Jupyter function creates a file with the content mentioned in a cell, as shown previously. Treat each %%writefile ... as a separate cell in Jupyter.

  1. 将所有图像和地面实况文本文件移动到data/obj文件夹。我们将把图像从bus-trucks数据集连同标签一起复制到该文件夹:
!mkdir -p data/obj
!cp -r open-images-bus-trucks/img/* 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中,那么它是一个验证图像。

文本文件本身应该包含这样的信息:cls, xc, yc, w, h,,其中cls是位于(xc, yc)的边界框中对象的类索引,它代表宽度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 有一长串的建筑。一些是大的,一些是小的,以在大或小的数据集上进行训练。配置可以有不同的主干。标准数据集有预先训练好的配置。每个配置都是一个.cfg文件,位于我们克隆的同一个 GitHub repo 的cfgs文件夹中。每一个都包含了文本文件形式的网络架构(与我们用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 个小时。权重定期存储在备份文件夹中,并可在训练后用于预测,如以下代码,该代码对新图像进行预测:

!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)

上述代码会导致以下结果:

现在,我们已经了解了如何利用 YOLO 对我们的自定义数据集执行目标检测,在下一部分,我们将了解如何利用 SSD 执行目标检测。

固态硬盘的工作细节

到目前为止,我们已经看到了这样一个场景,我们在逐渐卷积和汇集来自前一层的输出后进行预测。然而,我们知道不同的层对原始图像有不同的感受域。例如,与具有较大感受野的最终层相比,初始层具有较小的感受野。在这里,我们将了解 SSD 如何利用这一现象来预测图像的边界框。

SSD 如何帮助克服检测不同比例对象的问题,其工作原理如下:

  • 我们利用预训练的 VGG 网络,并用几个附加层来扩展它,直到我们获得 1×1 块。
  • 我们将利用所有最后几层来进行类和边界框预测,而不是只利用最后一层来进行边界框和类预测。
  • 在锚定框的位置,我们将提出具有特定比例和纵横比的默认框。
  • 每个默认框都应该预测对象和边界框的偏移量,就像锚框在 YOLO 预测类和偏移量一样。

现在我们已经了解了 SSD 与 YOLO 的主要不同之处(即 SSD 中的默认盒取代了 YOLO 的锚盒,多个层连接到 SSD 中的最终层,而不是 YOLO 的渐进卷积池),让我们了解以下内容:

  • 固态硬盘的网络架构
  • 如何利用不同的层进行边界框和类预测
  • 如何为不同图层中的默认框分配比例和纵横比

固态硬盘的网络架构如下:

正如您在前面的图表中看到的,我们正在获取一个大小为 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 |
| 总参数 | 8732 |

请注意,与原始论文中描述的架构中的其他层相比,某些层有更多的盒子(6 个而不是 4 个)。

现在,让我们了解一下默认框的不同比例和长宽比。我们将从比例开始,然后讨论长宽比。

让我们设想一个场景,一个物体的最小尺度是一个图像高度的 20%和宽度的 20%,物体的最大尺度是高度的 90%和宽度的 90%。在这种情况下,我们逐渐增加跨层的比例(随着我们向后面的层前进,图像大小会显著缩小),如下所示:

实现图像逐步缩放的公式如下:

现在我们已经了解了如何跨层计算比例,我们现在将学习如何制作不同纵横比的盒子。

可能的纵横比如下:

不同层的方框中心如下:

这里 ij 一起代表 l 层中的一个单元格。

对应于不同纵横比的宽度和高度计算如下:

请注意,我们考虑的是在某些层中有四个盒子,在另一层中有六个盒子。现在,如果我们想要有四个盒子,我们移除{3,1/3}纵横比,否则我们考虑所有六个可能的盒子(五个具有相同比例的盒子和一个具有不同比例的盒子)。那么,让我们学习如何获得第六个盒子:

现在我们已经有了所有可能的框,让我们了解如何准备训练数据集。

IoU 大于阈值(比如 0.5)的默认框被认为是正匹配,其余的是负匹配。

在 SSD 的输出中,我们预测盒子属于一个类的概率(其中第 0 个^(到第)个类代表背景)以及地面真实相对于默认盒子的偏移。

最后,我们通过优化以下损失值来训练模型:

  • 分类损失:使用以下等式表示:

在前面的等式中,pos表示与地面实况高度重叠的几个默认框,而neg表示预测了类但实际上不包含对象的误分类框。最后,我们确保pos : neg的比率最多为 1:3,就好像我们不执行这个采样,我们将拥有背景类盒的优势。

  • 定位损失:对于定位,我们仅在客观分数大于某个阈值时才考虑损失值。本地化损失计算如下:

这里 t 是预测偏移, d 是实际偏移。

现在我们已经了解了如何训练 SSD,让我们在下一节中使用它来进行公交车与卡车目标检测练习。

GitHub repo:github.com/sizhky/ssd-utils/中提供了该部分的核心实用函数。在开始训练过程之前,让我们一个一个地了解它们。

SSD 代码中的组件

GitHub repo 中有三个文件。先稍微挖掘一下,了解一下再训练。请注意,本节不是培训过程的一部分,而是为了理解培训过程中使用的导入。

我们正在从 GitHub 资源库的model.py文件中导入SSD300MultiBoxLoss类。让我们来了解一下他们两个。

固态硬盘 300

当您查看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,它返回 8732 个锚盒,正如我们之前讨论的。

SSD300类的另一个关键方面是create_prior_boxes方法。对于每个特征地图,都有三个与之相关的项目:网格的大小、缩小网格单元的比例(这是该特征地图的基础锚点框),以及单元中所有锚点的纵横比。使用这三种配置,代码使用三重for循环并为所有 8732 个锚盒创建一个(cx, cy, w, h)列表。

最后,detect_objects方法获取分类和回归值的张量(预测锚框的),并将它们转换成实际的边界框坐标。

多盒损耗

作为人类,我们只担心少数边界框。但是对于 SSD 的工作方式,我们需要比较来自几个特征地图的 8732 个边界框,并预测锚框是否包含有价值的信息。我们将这个损失计算任务分配给MultiBoxLoss

正向方法的输入是来自模型和地面真实边界框的锚框预测。

首先,我们通过将模型中的每个锚点与边界框进行比较,将基础真实框转换成 8,732 个锚点框的列表。如果 IoU 足够高,则该特定锚定框将具有非零回归坐标,并且关联一个对象作为分类的基础事实。自然地,大多数被计算的定位框将有它们的相关类作为background,因为它们与实际边界框的 IoU 将很小,或者在相当多的情况下为零。

一旦地面事实被转换成这 8,732 个锚盒回归和分类张量,就很容易将它们与模型的预测进行比较,因为形状现在是相同的。

我们对回归张量执行MSE-Loss,对定位张量执行CrossEntropy-Loss,并将它们相加,作为最终损失返回。

在自定义数据集上训练 SSD

在下面的代码中,我们将训练 SSD 算法来检测图像中存在的对象周围的边界框。我们将使用我们一直在进行的卡车与公共汽车目标检测任务:

The following code is available as Training_SSD.ipynb in the Chapter08 folder of this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from and is moderately lengthy. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components from text.

  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
  1. 预处理数据,就像我们在培训 更快的 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'
  1. 准备一个数据集类,就像我们在在自定义数据集上训练更快的 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)
  1. 准备训练和测试数据集以及数据加载器:
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)
  1. 定义函数以对一批数据进行训练,并计算验证数据的准确度和损失值:
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
  1. 导入模型:
from model import SSD300, MultiBoxLoss
from detect import *
  1. 初始化模型、优化器和损失函数:
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
  1. 在不断增加的时期内训练模型:
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')

各时期的训练和测试损失值的变化如下:

  1. 获取对新图像的预测:
  • 获取随机图像:
image_paths = Glob(f'{DATA_ROOT}/img/*')
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')
  • 获取与图像中存在的对象相对应的边界框、标签和分数:
bbs, labels, scores = detect(original_image, model, \
                             min_score=0.9, max_overlap=0.5,\
                             top_k=200, device=device)
  • 将获得的输出叠加到图像上:
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)

前面的代码获取如下输出示例(每次迭代执行一个图像):

由此可见,我们可以相当准确地检测图像中的对象。

摘要

在这一章中,我们已经了解了现代目标检测算法的工作细节:更快的 R-CNN、YOLO 和 SSD。我们了解了他们如何克服拥有两个独立模型的限制——一个用于获取区域提议,另一个用于获取区域提议上的类和边界框偏移量。此外,我们使用 PyTorch 实现了更快的 R-CNN,使用darknet实现了 YOLO,并从头开始实现 SSD。

在下一章,我们将学习图像分割,它通过识别对应于物体的像素,比物体定位更进了一步。

此外,在第十五章、结合计算机视觉和 NLP 技术中,我们将了解 DETR,一种基于转换器的目标检测算法,在第十章、目标检测和分割的应用中,我们将了解 Detectron2 框架,该框架不仅有助于检测对象,而且有助于在单次拍摄中分割它们。

测试你的理解能力

  1. 为什么快速 R-CNN 比快速 R-CNN 更快?
  2. 与更快的 R-CNN 相比,YOLO 和 SSD 的速度如何?
  3. 是什么让 YOLO 和 SSD 单拍算法?
  4. 客观分和类分有什么区别?
  5. 锚定框和默认框有什么区别?

九、图像分割

在前一章中,我们学习了如何检测图像中出现的对象,以及与检测到的对象相对应的类。在这一章中,我们将更进一步,不仅在对象周围画一个边界框,而且识别包含对象的确切像素。除此之外,到本章结束时,我们将能够挑出属于同一个类的实例/对象。

在本章中,我们将通过研究 U-Net 和 Mask R-CNN 架构来了解语义分段和实例分段。具体来说,我们将涵盖以下主题:

  • 探索 U-Net 架构
  • 用 U-Net 实现语义切分
  • 探索掩模 R-CNN 架构
  • 使用掩模 R-CNN 实现实例分割

我们试图通过图像分割(arxiv.org/pdf/1405.0312.pdf)实现的简洁图像如下:

我们开始吧!

探索 U-Net 架构

想象一下这样一个场景,给你一张图像,要求你预测哪个像素对应哪个对象。到目前为止,当我们预测对象的类别和对应于该对象的边界框时,我们通过网络传递图像,然后通过主干架构(如 VGG 或 ResNet)传递图像,在某一层平坦化输出,并在预测类别和边界框偏移之前连接附加的密集层。但是,在图像分割的情况下,输出形状与输入图像的形状相同,展平卷积的输出然后重建图像可能会导致信息丢失。此外,在图像分割的情况下,原始图像中存在的轮廓和形状在输出图像中不会变化,因此,当我们执行分割时,我们迄今为止处理的网络(展平最后一层并连接附加的密集层)并不是最佳的。

在本节中,我们将了解如何执行图像分割。

在执行分段时,我们需要记住以下两个方面:

  • 原始图像中对象的形状和结构在分段输出中保持不变。
  • 利用完全卷积架构(而不是我们展平某一层的结构)会有所帮助,因为我们使用一个图像作为输入,另一个图像作为输出。

U-Net 架构帮助我们实现了这一目标。U-Net 的典型表示如下(输入图像是 3×96×128 的形状,而图像中存在的类的数量是 21;这意味着输出包含 21 个通道):

前述架构因其“ U ”状的形状而被称为 U 网架构

在上图的左半部分,我们可以看到图像通过卷积层,正如我们在前面章节中看到的那样,图像尺寸不断减小,而通道数量不断增加。然而,在右半部分,我们可以看到我们正在放大缩小的图像,回到原始的高度和宽度,但是通道的数量与类的数量一样多。

此外,在向上扩展的同时,我们还使用跳过连接来利用来自左半部分中相应层的信息,以便我们可以保留原始图像中的结构/对象。

这样,U-Net 架构学习保留原始图像的结构(和对象的形状),同时利用卷积的特征来预测对应于每个像素的类别。

一般来说,输出中的通道数量与我们想要预测的类别数量一样多。

执行升级

在 U-Net 架构中,使用nn.ConvTranspose2d方法执行向上扩展,该方法将输入通道的数量、输出通道的数量、内核大小和步幅作为输入参数。ConvTranspose2d的计算示例如下:

在前面的例子中,我们采用了一个形状为 3 x 3 的输入数组(输入数组),应用了一个步长为 2 的输入数组,其中我们分配输入值以适应步长(输入数组针对步长进行了调整),用零填充数组(输入数组针对步长和填充进行了调整),并用一个过滤器(过滤器/内核)对填充后的输入进行卷积以获取输出数组。

通过利用填充和步幅的组合,我们将形状为 3 x 3 的输入升级为形状为 6 x 6 的数组。虽然前面的示例仅用于说明目的,但是最佳滤波器值学习(因为滤波器权重和偏差在模型训练过程中被优化)以尽可能多地重建原始图像。

nn.ConvTranspose2d中的超参数如下:

为了理解nn.ConvTranspose2d如何帮助提升一个数组,让我们看一下下面的代码:

  1. 导入相关包:
import torch
import torch.nn as nn
  1. nn.ConvTranspose2d方法初始化网络m:
m = nn.ConvTranspose2d(1, 1, kernel_size=(2,2), 
                       stride=2, padding = 0)

在前面的代码中,我们指定输入通道的值是1,输出通道的值是1,内核的大小是(2,2),步幅是2,填充是0

在内部,填充被计算为膨胀* (kernel_size - 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 架构来预测对应于图像中所有像素的类。这种输入-输出组合的一个例子如下:

请注意,在前面的图片中,属于同一类别的对象(在左侧图像-输入图像中)具有相同的像素值(在右侧图像-输出图像中),这就是为什么我们要分割彼此语义相似的像素。这也称为语义分割。

现在,让我们学习如何编码语义分段:

以下代码在本书的 GitHub 知识库的Chapter09文件夹中以Semantic_Segmentation_with_U_Net.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 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'
  1. 定义将用于转换图像的函数(tfms):
tfms = transforms.Compose([
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], 
                                 [0.229, 0.224, 0.225]) 
        ])
  1. 定义数据集类(SegData):
  • __init__方法中指定包含图像的文件夹:
class SegData(Dataset):
    def __init__(self, split):
        self.items=stems(f'dataset1/images_prepped_{split}')
        self.split = split
  • 定义__len__方法:
    def __len__(self):
        return len(self.items)
  • 定义__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')
        mask = cv2.resize(mask, (224,224))
        return image, mask

__getitem__ 方法中,我们调整了输入(image)和输出(mask)图像的大小,使它们具有相同的形状。请注意,屏蔽图像包含范围在[0,11]之间的整数。这表明有 12 个不同的类别。

  • 定义一个用于选择随机图像索引的函数(choose)(主要用于调试目的):
    def choose(self): return self[randint(len(self))]
  • 定义对一批图像进行预处理的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是一个长整数张量,类似于交叉熵目标。

  1. 定义训练和验证数据集,以及数据加载器:
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)
  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)
    )

在前面的conv定义中,我们依次执行Conv2d操作、BatchNorm2d操作和ReLU操作。

  • 定义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)
    )

确保我们放大图像。这不同于Conv2d操作,在这里我们缩小图像的尺寸。它将具有in_channels个通道的图像作为输入通道,并产生具有out_channels个输出通道的图像。

  • 定义网络类(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)

在前面的__init__方法中,我们定义了将在forward方法中使用的所有层。

  • 定义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

在前面的代码中,我们通过在适当的张量对上使用torch.cat,在向下缩放和向上缩放卷积特征之间建立 U 型连接。

  • 定义一个函数(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
  • 定义一个函数,该函数将在批处理(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()
  • 定义模型、优化器、损失函数和历元数:
model = UNet().to(device)
criterion = UnetLoss
optimizer = optim.Adam(model.parameters(), lr=1e-3)
n_epochs = 20
  1. 在不断增加的时期内训练模型:
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)
  1. 绘制递增时期的训练值、验证损失值和准确度值:
log.plot_epochs(['trn_loss','val_loss'])

上述代码生成以下输出:

  1. 计算新图像的预测输出:
  • 获取新图像的模型预测:
im, mask = next(iter(val_dl))
_mask = model(im)
  • 获取概率最高的频道:
_, _mask = torch.max(_mask, dim=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'])

上述代码生成以下输出:

从上图中,我们可以看到,我们可以使用 U-Net 架构成功地生成一个分段掩码。但是,同一类的所有实例将具有相同的预测像素值。如果我们想在图像中分离出Person类的实例呢?在下一节中,我们将学习 Mask R-CNN 架构,它有助于生成实例级掩码,以便我们可以区分实例(甚至是同一类的实例)。

探索掩模 R-CNN 架构

Mask R-CNN 架构有助于识别/突出显示图像中给定类别的对象的实例。当图像中存在多个相同类型的对象时,这尤其方便。此外,术语 Mask 表示 Mask R-CNN 在像素级完成的分割。

Mask R-CNN 架构是更快的 R-CNN 网络的扩展,我们在前一章已经了解过。但是,对 Mask R-CNN 架构进行了一些修改,如下所示:

  • RoI Pooling 层已被 RoI Align 层所取代。
  • 除了已经预测最终层中的对象类别和边界框校正的头部之外,还包括了用于预测对象遮罩的遮罩头部。
  • 一个全卷积网络 ( FCN )被用于掩码预测。

在我们了解每个组件如何工作之前,让我们快速浏览一下 Mask R-CNN 中发生的事件(图片来源:arxiv.org/pdf/1703.06870.pdf):

在上图中,请注意,我们从一个层获取类和边界框信息,从另一个层获取遮罩信息。

Mask R-CNN 架构的工作细节如下:

在我们实现 Mask R-CNN 架构之前,我们需要了解它的组件。我们将从 RoI 对齐开始。

ROI align(ROI 对齐)

通过更快的 R-CNN,我们了解了投资回报率池。RoI 合并的一个缺点是,当我们执行 RoI 合并操作时,我们可能会丢失某些信息。这是因为在汇集之前,我们可能会在图像的所有区域中均匀地呈现内容。

让我们看一下上一章提供的例子:

在上图中,区域建议的形状是 5 x 7,我们必须将其转换为 2 x 2 的形状。当将其转换为 2 x 2 形状时(一种称为量化的现象),该区域的一部分与该区域的其他部分相比具有较少的表示。这导致信息丢失,因为该区域的某些部分比其他部分具有更大的权重。RoI Align 帮助解决了这种情况。

为了理解 RoI Align 的工作原理,我们来看一个简单的例子。这里,我们尝试将以下区域(用虚线表示)转换为 2 x 2 形状:

请注意,该区域(虚线中)并非均匀分布在特征地图中的所有像元上。

我们必须执行以下步骤,以在 2 x 2 形状中获得该区域的合理表示:

  1. 首先,将该区域分成相等的 2 x 2 形状:

  1. 在每个 2 x 2 单元格内定义四个等距点:

请注意,在上图中,两个连续点之间的距离是 0.75。

  1. 根据每个点到最近已知值的距离计算每个点的加权平均值:

  1. 对像元中的所有四个点重复上述插值步骤:

  1. 在一个单元格内的所有四个点上执行平均汇集:

通过实现上述步骤,我们在执行 RoI 对齐时不会丢失信息;也就是说,当我们把所有的区域放在同一个形状里面。

面具头

使用 RoI Align,我们可以更准确地表示从区域提案网络中获得的区域提案。现在,我们想要获得分割(掩模)输出,给定标准形状的 RoI 对准输出,用于每个区域提议。

通常,在目标检测的情况下,我们将通过展平层传递 RoI Align,以便预测对象的类别和边界框偏移。然而,在图像分割的情况下,我们预测包含对象的边界框内的像素。因此,我们现在有了第三个输出(除了类和边界框偏移之外),这是感兴趣区域内的预测掩膜。

这里,我们预测的是蒙版,它是覆盖在原始图像上的图像。假设我们预测的是一幅图像,我们将把它连接到另一个卷积层以获得另一个类似图像的结构(宽度 x 高度),而不是展平 RoI Align 的输出。让我们通过下图来了解这一现象:

在上图中,我们使用特征金字塔网络()获得了 7 x 7 x 2048 形状的输出,该网络现在有两个分支:

*** 第一个分支返回对象的类和边界框,后展平 FPN 输出。

  • 第二个分支在 FPN 输出的顶部执行卷积,以获得掩码。

对应于 14×14 输出的基本事实是区域提议的尺寸调整后的图像。如果数据集中有 80 个唯一类,则区域建议的基本事实是 80 x 14 x 14 的形状。80 x 14 x 14 像素中的每一个都是 1 或 0,这表示该像素是否包含对象。因此,我们在预测像素类别的同时执行二进制交叉熵损失最小化。

在模型训练之后,我们能够检测区域,获得类,获得边界框偏移,并获得对应于每个区域的遮罩。当进行推断时,我们首先检测图像中存在的对象,并进行边界框校正。然后,我们将偏移区域传递给掩模头,以预测该区域中不同像素对应的掩模。

现在我们已经了解了 Mask R-CNN 架构的工作原理,让我们对其进行编码,以便我们可以检测图像中的人物实例。

使用掩模 R-CNN 实现实例分割

为了帮助我们理解如何编码屏蔽 R-CNN 用于实例分割,我们将利用屏蔽图像中存在的人的数据集。我们将使用的数据集是从 ADE20K 数据集的子集创建的,该数据集可在groups.csail.mit.edu/vision/datasets/ADE20K/获得。我们将只使用那些有人类面具的图像。

我们将采取的策略如下:

  1. 获取数据集,然后从中创建数据集和数据加载器。
  2. 以 PyTorch 官方实现 Mask R-CNN 所需的格式创建一个地面真相。
  3. 下载预先训练好的更快的 R-CNN 模型,给它贴上一个口罩 R-CNN 头。
  4. 使用 PyTorch 代码片段训练模型,该代码片段已被标准化用于训练 Mask R-CNN。
  5. 通过首先执行非最大值抑制,然后识别与图像中的人相对应的边界框和遮罩,对图像进行推断。

让我们编写前面的策略:

以下代码在本书的 GitHub 知识库的Chapter09文件夹中以Instance_Segmentation.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  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'
  1. 导入所有必需的包并定义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'
  1. 获取包含人物面具的图像,如下所示:
  • 遍历imagesannotations_instance文件夹以获取文件名:
all_images = Glob('img/training')
all_annots = Glob('annotations_instance/training')
  • 检查原始图像和人物实例的面具表示:
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类的实例。

在这个特定的数据集中,地面实况实例注释以这样一种方式提供,即 RGB 中的红色通道对应于对象的类别,而绿色通道对应于实例编号(如果图像中有多个相同类别的对象,如我们这里的示例)。此外,Person类的编码值为 4。

  • 遍历注释并存储至少包含一个人的文件:
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)
  • 将文件分为培训和验证文件:
from sklearn.model_selection import train_test_split
_annots = stems(annots)
trn_items,val_items=train_test_split(_annots,random_state=2)
  1. 定义转换方法:
def get_transform(train):
    transforms = []
    transforms.append(T.ToTensor())
    if train:
        transforms.append(T.RandomHorizontalFlip(0.5))
    return T.Compose(transforms)
  1. 创建数据集类(MasksDataset),如下所示:
  • 定义__init__方法,该方法将图像名称(items)、转换方法(transforms)和要考虑的文件数量(N)作为输入:
class MasksDataset(Dataset):
    def __init__(self, items, transforms, N):
        self.items = items
        self.transforms = transforms
        self.N = N
  • 定义一个方法(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
  • 获取要返回的图像和相应的目标值。每个人(实例)被视为不同的对象类;也就是说,每个实例是一个不同的类。注意,类似于训练更快的 R-CNN 模型,目标作为张量的字典返回。让我们定义一下__getitem__方法:
    def __getitem__(self, ix):
        _id = self.items[ix]
        img_path = f'img/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)
  • 除了遮罩本身,遮罩 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])

在前面的代码中,我们通过在边界框的 x 和 y 坐标的最小值上增加 10 个像素来调整存在可疑事实的场景(Person类的高度或宽度小于 10 个像素)。

  • 将所有目标值转换为张量对象:
        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])
  • 将目标值存储在字典中:
        target = {}
        target["boxes"] = boxes
        target["labels"] = labels
        target["masks"] = masks
        target["image_id"] = image_id
        target["area"] = area
        target["iscrowd"] = iscrowd
  • 指定转换方法并返回图像;也就是,target:
        if self.transforms is not None:
            img, target = self.transforms(img, target)
        return img, target
  • 指定__len__方法:
    def __len__(self):
        return self.N
  • 定义将选择随机图像的函数:
    def choose(self):
        return self[randint(len(self))]
  • 检查输入输出组合:
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,表明图像中有两个人。

注意,在__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(要预测的类数)。

可以通过指定以下内容来获取已定义模型的详细信息:

model = get_model_instance_segmentation(2).to(device)
model

模型的下半部分(即没有主干)如下所示:

请注意,更快的 R-CNN 网络(我们在上一章中训练过)和屏蔽 R-CNN 模型之间的主要区别在于roi_heads模块,它本身包含多个子模块。让我们看看他们执行什么任务:

  • roi_heads:对齐从 FPN 网络获取的输入,并创建两个张量。

  • box_predictor:使用我们获得的输出来预测每个 RoI 的类别和边界框偏移。

  • mask_roi_pool : RoI 随后整理来自 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)
  1. 定义模型、参数和优化标准:
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字典作为输入,以减少损失。通过运行以下命令,可以看到从模型接收到的输出示例:

# The following code is for illustration purpose only
model.eval()
pred = model(dataset[0][0][None].to(device))
inspect(pred[0])

上述代码会产生以下输出:

在这里,我们可以看到一个具有边界框(BOXES)的字典,对应于边界框的类(LABELS),对应于类预测的置信度得分(SCORES,以及我们的掩码实例的位置(MASKS)。如您所见,该模型被硬编码为返回 100 个预测,这是合理的,因为我们不应该期望在一个典型的图像中有超过 100 个对象。

要获取已检测到的实例数量,我们将使用以下代码:

# 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)

通过这样做,我们现在可以将我们的遮罩覆盖在图像中的人物上。我们可以按如下方式记录我们在不断增加的时期内的训练损失变化:

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)

上述代码会产生以下输出:

  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()

上述代码会产生以下输出:

从前面的图像中,我们可以看到我们可以成功地识别图像中的四个人。此外,该模型预测图像中的多个其他片段(我们在前面的输出中没有显示),尽管这是低置信度的。

既然模型可以很好地检测实例,那么让我们对提供的数据集中不存在的自定义图像运行预测。

  1. 对自己的新形象进行预测:
!wget https://www.dropbox.com/s/e92sui3a4ktvb4j/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())

输入图像如下:

  • 获取输入图像的预测:
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()

上述代码会产生以下输出:

请注意,在前面的图像中,经过训练的模型不如在测试图像中工作得好。这可能是由于以下原因:

  • 人们在训练时可能不会靠得这么近。
  • 该模型可能没有在感兴趣的类别占据图像的大部分的许多图像上被训练。
  • 我们用来训练模型的数据集中的图像与预测的图像具有不同的数据分布。

但是,即使已经检测到重复的掩码,在这些区域(从第三个掩码开始)中具有较低的类分数是预测中可能存在重复的良好指示。

到目前为止,我们已经学习了如何分割Person类的多个实例。在下一节中,我们将了解我们在这一节中构建的代码中需要调整的内容,以分割图像中多类对象的多个实例。

预测多个类的多个实例

在上一节中,我们学习了如何分割Person类。在这一节中,我们将学习如何通过使用我们在上一节中构建的相同模型,一次性地对 person 和 table 实例进行分段。让我们开始吧:

鉴于大部分代码与前一节中的代码相同,我们将只在这一节中解释额外的代码。在执行代码时,我们鼓励你浏览一下predicting_multiple_instances_of_multiple_classes.ipynb笔记本,它可以在本书的 GitHub 库的Chapter09文件夹中找到

  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中。接下来,我们遍历每个已识别的类(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'img/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)
        return img, target
    def __len__(self):
        return self.N
    def choose(self):
        return self[randint(len(self))]
  1. 在定义model时,指定您有三个类而不是两个:
num_classes = 3
model=get_model_instance_segmentation(num_classes).to(device)

在训练模型时,正如我们在上一节中所做的,我们将看到训练损失在增加的时期内的变化如下:

此外,包含人和桌子的样本图像的预测片段如下:

从上图中,我们可以看到,我们能够使用相同的模型预测这两个类别。作为练习,我们鼓励您增加类的数量和纪元的数量,看看您会得到什么结果。

摘要

在本章中,我们学习了如何利用 U-Net 和 Mask R-CNN 在图像上执行分割。我们了解了 U-Net 架构如何使用卷积对图像执行缩小和放大,以保留图像的结构,同时仍然能够预测图像中对象周围的遮罩。然后,我们使用道路场景检测练习巩固了我们对此的理解,在该练习中,我们将图像分割成多个类别。接下来,我们学习了 RoI Align,它有助于确保围绕图像量化的 RoI 汇集问题得到解决。在那之后,我们了解了 Mask R-CNN 是如何工作的,这样我们就可以训练模型来预测图像中的人的实例,以及图像中的人和桌子的实例。

现在,我们已经很好地理解了各种目标检测技术和图像分割技术,在下一章中,我们将学习利用我们到目前为止所学技术的应用程序,以便我们可以扩展我们将要预测的类的数量。此外,我们还将了解 Detectron2 框架,它在我们构建更快的 R-CNN 和 Mask R-CNN 模型时降低了代码复杂性。

问题

  1. 向上扩展对 U-Net 架构有何帮助?
  2. U-Net 中为什么需要全卷积网络?
  3. 在 Mask-RCNN 中,RoI Align 如何改进 RoI pooling?
  4. U-Net 和 Mask-RCNN 在分割方面的主要区别是什么?
  5. 什么是实例分段?**

十、目标检测和分割应用

在之前的章节中,我们学习了各种目标检测技术,例如 R-CNN 系列算法、YOLO、SSD 以及 U-Net 和 Mask R-CNN 图像分割算法。在这一章中,我们将把我们的学习向前推进一步——我们将在更真实的场景中工作,并了解为解决检测和分割问题而更优化的框架/架构。我们将首先利用 Detectron2 框架来训练和检测图像中存在的自定义对象。我们还将使用预先训练的模型来预测图像中出现的人的姿势。此外,我们将学习如何计算图像中人群的人数,然后学习如何利用分割技术来执行图像着色。最后,我们将了解 YOLO 的修改版本,通过使用从激光雷达传感器获得的点云来预测对象周围的 3D 边界框。

本章结束时,您将了解到以下内容:

  • 多对象实例分割
  • 人体姿态检测
  • 人群计数
  • 图像彩色化
  • 基于点云的三维目标检测

多对象实例分割

在前几章中,我们学习了各种目标检测算法。在本节中,我们将了解 Detectron2 平台(ai . Facebook . com/blog/-detectron 2-a-py torch-based-modular-object-detection-library-/),然后使用 Google Open Images 数据集实现它。Detectron2 是由脸书团队构建的平台。Detectron2 包括最先进的目标检测算法的高质量实现,包括 Mask R-CNN 模型系列的 DensePose。最初的 Detectron 框架是用 Caffe2 编写的,而 Detectron2 框架是用 PyTorch 编写的。

Detectron2 支持一系列与目标检测相关的任务。像最初的 Detectron 一样,它支持使用盒子和实例分割遮罩进行目标检测,以及人体姿势预测。除此之外,Detectron2 增加了对语义分段和全景分段的支持(一项结合了语义和实例分段的任务)。通过利用 Detectron2,我们能够在几行代码中构建目标检测、分割和姿态估计。

在本节中,我们将了解以下内容:

  1. open-images存储库中获取数据
  2. 将数据转换成 Detectron2 接受的 COCO 格式
  3. 为实例分割训练模型
  4. 对新图像进行推理

让我们在接下来的几节中逐一讨论这些问题。

获取和准备数据

我们将在谷歌在 https://storage.googleapis.com/openimg/web/index.html 提供的开放图像数据集中(包含数百万张图像及其注释)处理这些图像。

在这部分代码中,我们将学习如何只获取所需的图像,而不是整个数据集。请注意,这一步是必需的,因为数据集大小会阻止可能没有大量资源的典型用户构建模型:

以下代码在本书的 GitHub 知识库的Chapter10文件夹中以Multi_object_segmentation.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  1. 安装所需的软件包:
!pip install -qU openimages torch_snippets
  1. 下载所需的注释文件:
from torch_snippets import *
!wget -O train-annotations-object-segmentation.csv -q https://storage.googleapis.com/openimg/v5/train-annotations-object-segmentation.csv
!wget -O classes.csv -q \
 https://raw.githubusercontent.com/openimg/dataset/master/dict.csv 
  1. 指定我们希望模型预测的类(您可以访问 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)]
  1. 获取对应于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/openimg/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
  1. 下载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', './img/')
  1. 压缩所有图像、遮罩和基本事实并保存它们——以防您的会话崩溃,保存和检索文件对以后的培训很有帮助。创建 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)

最后,将数据移动到一个目录中:

!mkdir -p train/
!mv images train/myData2020
!mv masks train/annotations

鉴于目标检测代码中有如此多的移动组件,作为一种标准化的方式,Detectron 接受一种严格的数据格式进行训练。虽然可以编写数据集定义并将其提供给 Detectron,但以 COCO 格式保存整个训练数据更容易(也更有利)。这样,你可以利用其他的训练算法,比如detectron transformers(DETR),而不需要改变任何数据。首先,我们将从定义类的类别开始。

  1. 在 COCO 格式中定义所需的类别:
!pip install \
 git+git://github.com/waspinator/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())]

在前面的代码中,在CATEGORIES的定义中,我们创建了一个名为supercategory的新键。为了理解supercategory,让我们看一个例子:ManWoman类属于Person超类别。在我们的例子中,假设我们对超级类别不感兴趣,我们将把它指定为none

  • 导入相关的包并创建一个空字典,其中包含保存 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": []
}
  • 在适当的位置设置几个包含图像位置和注释文件位置信息的变量:
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))]
  • 遍历每个图像文件名,并在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
  1. 遍历每个分段注释,并在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
  1. 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 框架来训练我们的模型。

训练模型进行实例分割

使用 Detectron2 进行培训可以通过几个步骤完成:

  1. 安装所需的 Detectron2 软件包。在安装正确的软件包之前,您应该检查您的 CUDA 和 PyTorch 版本。截至撰写本书时,Colab 包含 PyTorch 1.7 和 CUDA 10.1,因此我们将使用相应的文件:
!pip install detectron2 -f https://dl.fbaipublicfiles.com/detectron2/wheels/cu101/torch1.7/index.html
!pip install pyyaml==5.1 pycocotools>=2.0.1

在进行下一步之前,重新启动 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
  • 假设我们已经重启了 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)]
  1. 使用register_coco_instances注册创建的数据集:
from detectron2.data.datasets import register_coco_instances
register_coco_instances("dataset_train", {}, \
                        "images.json", "train/myData2020")
  1. 定义cfg配置文件中的所有参数。

Configuration ( 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_rccnn的预训练。这还将包含关于预训练实验的附加信息,例如优化器和损失函数。为了我们的目的,在cfg中设置的超参数是不言自明的。

  1. 训练模型:
os.makedirs(cfg.OUTPUT_DIR, exist_ok=True)
trainer = DefaultTrainer(cfg) 
trainer.resume_or_load(resume=False)
trainer.train()

使用前面的代码行,我们可以训练一个模型来预测类、边界框以及属于自定义数据集中已定义类的对象的分段。

  • 将模型保存在文件夹中:
!cp output/model_final.pth output/trained_model.pth

至此,我们已经训练好了模型。在下一节中,我们将对一个新图像进行推理。

对新图像进行推断

为了对新图像执行推断,我们加载路径,设置概率阈值,并通过DefaultPredictor方法传递它,如下所示:

  1. 用训练好的模型加载权重。使用相同的cfg并加载模型权重,如以下代码所示:
cfg.MODEL.WEIGHTS = os.path.join(cfg.OUTPUT_DIR, \
                                 "trained_model.pth")
  1. 为对象属于某个类别的概率设置阈值:
cfg.MODEL.ROI_HEADS.SCORE_THRESH_TEST = 0.25
  1. 定义predictor方法:
predictor = DefaultPredictor(cfg)
  1. 对感兴趣的图像执行分割并将其可视化:

在下面的代码中,我们随机绘制了 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将它们转换成像素信息并绘制在图像上。

让我们看看每个输入的含义:

  • 我们想要可视化的图像。
  • scale:打印时图像的大小。在这里,我们要求它将图像缩小到 50%。
  • metadata:我们需要数据集的类级信息,主要是索引到类的映射,这样当我们发送原始张量作为要绘制的输入时,类会将它们解码成实际的人类可读的类。
  • 我们要求模型只突出显示分割的像素。

最后,一旦创建了类(在我们的例子中,它是v),我们可以要求它绘制来自模型的实例预测并显示图像。

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

从前面的输出中,您可以看到我们能够相当准确地识别对应于大象的像素。

现在我们已经了解了如何利用 detector 2 来识别图像中与类别相对应的像素,在下一节中,我们将了解如何利用 detector 2 来执行图像中人物的姿势检测。

人体姿态检测

在上一节中,我们学习了如何检测多个对象并对其进行分割。在本节中,我们将学习如何检测图像中的多人,以及如何使用 Detectron2 检测图像中人物的各个身体部位的关键点。在多种用例中,检测关键点非常方便。例如在体育分析和安全领域。

在本练习中,我们将利用配置文件中提供的预训练关键点模型:

下面的代码在 GitHub 知识库的Chapter10文件夹中以Human_pose_detection.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含了下载数据的 URL。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  1. 安装上一节所示的所有要求:
!pip install detectron2 -f \
  https://dl.fbaipublicfiles.com/detectron2/wheels/cu101/torch1.7/index.html
!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
  1. 获取配置文件并加载 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"))
  1. 指定配置参数:
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)
  1. 加载我们想要预测的图像:
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
  1. 预测图像并绘制关键点:
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())

前面的代码给出如下输出:

从前面的输出中,我们可以看到该模型能够准确地识别与图像中的人相对应的各种关键点。

在本节中,我们学习了如何使用 Detectron2 平台执行关键点检测。在下一节中,我们将从头开始学习实现一个修改的 VGG 架构,以估计图像中存在的人数。

人群计数

想象一个场景,给你一张人群的照片,要求你估计照片中的人数。在这种情况下,人群计数模型就派上了用场。在我们继续构建一个模型来执行人群计数之前,让我们先了解可用的数据和模型架构。

为了训练预测图像中人数的模型,我们必须首先加载图像。图像应该构成图像中出现的所有人的头部中心的位置。输入图像的样本和图像中各个人物的头部中心位置如下(来源:上海科技数据集(github.com/desenzhou/ShanghaiTechDataset)):

在前面的示例中,表示地面实况的图像(右边的图像–图像中出现的人的头部中心)非常稀疏。正好有 N 个白色像素,其中 N 是图像中的人数。让我们放大到图像的左上角,再次看到相同的地图:

在下一步中,我们将地面实况稀疏图像转换为密度图,该密度图表示图像中该区域的人数:

同一作物的最终输入输出对看起来像这样:

整个图像看起来也是如此:

请注意,在前面的图像中,当两个人彼此靠近时,像素强度较高。然而,当一个人远离休息时,对应于该人的像素密度更均匀地散开,导致对应于远离休息的人的像素密度更低。本质上,热图是以这样一种方式生成的,即像素值的总和等于图像中出现的人数。

现在,我们已经能够接受输入图像和图像中人的头部中心的位置(图像被处理以获取地面实况输出热图),我们将利用标题为 CSRNet:用于理解高度拥堵场景的扩展卷积神经网络的论文中详细描述的架构来预测图像中出现的人数。

模型架构(arxiv.org/pdf/1802.10062.pdf)如下:

在模型架构的上述结构中,我们在首先将图像通过标准的 VGG-16 主干网之后,再将它通过四个额外的卷积层。该输出通过四种配置之一,并最终通过 1 x 1 x 1 卷积层。我们将使用 A 配置,因为它是最小的。

接下来,我们对输出图像执行均方误差 ( MSE )损失最小化,以达到最佳权重值,同时使用 MAE 跟踪实际人群计数。

该架构的另一个细节是,作者使用了扩展卷积而不是普通卷积。

典型的扩张卷积如下(图像来源:arxiv.org/pdf/1802.10062.pdf):

在前面的内容中,左边的图表代表了我们目前为止一直在做的一个典型的内核。第二个和第三个图表示扩展的内核,在各个像素之间有一个间隙。这样,内核就有了更大的感受野。一个大的感受野可以派上用场,因为我们需要了解一个给定的人附近的人数,以便估计对应于这个人的像素密度。我们使用一个膨胀的核(有九个参数)而不是普通的核(有 49 个参数,相当于三个核的膨胀率)来用更少的参数获取更多的信息。

了解了如何构建模型之后,让我们继续编写模型代码来执行下一节中的人群计数。(对于那些希望了解工作细节的人,我们建议你浏览这里的文件:arxiv.org/pdf/1802.10062.pdf。我们将在下一节中培训的模型是受本文的启发。)

编码人群计数

我们将采用以下策略进行人群计数:

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

  2. 我们将要处理的数据集 ShanghaiTech 数据集——已经将人脸中心转换为基于高斯过滤器密度的分布,因此我们无需再次执行。使用网络映射输入图像和输出高斯密度图。

  3. 定义一个函数来执行扩张卷积。

  4. 定义网络模型,并对批量数据进行训练,以最小化 MSE。

让我们将我们的策略编码如下:

以下代码在本书的 GitHub 知识库的Chapter 10文件夹中以crowd_counting.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  1. 导入包并下载数据集:
%%time
import os
if not os.path.exists('CSRNet-pytorch/'):
    !pip install -U scipy torch_snippets torch_summary
    !git clone https://github.com/sizhky/CSRNet-pytorch.git
    from google.colab import files
    files.upload() # upload kaggle.json
    !mkdir -p ~/.kaggle
    !mv kaggle.json ~/.kaggle/
    !ls ~/.kaggle
    !chmod 600 /root/.kaggle/kaggle.json
    print('downloading data...')
    !kaggle datasets download -d \
        tthien/shanghaitech-with-people-density-map/
    print('unzipping data...')
    !unzip -qq shanghaitech-with-people-density-map.zip

%cd CSRNet-pytorch
!ln -s ../shanghaitech_with_people_density_map
from torch_snippets import *
import h5py
from scipy import io
  • 提供图像(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/img/'
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/'
  1. 定义训练和验证数据集以及数据加载器:
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)

请注意,到目前为止,我们编写的典型数据集类的唯一附加内容是前面代码中以粗体显示的代码行。我们正在调整地面真实的大小,因为我们网络的输出将被缩小到原始大小的 1/8 ^(th) ,因此我们将地图乘以 64,以便图像像素的总和将被缩放回原始的人群计数。

  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':
            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)
  • 定义网络架构-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)
  1. 定义对一批数据进行训练和验证的函数:
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()
  1. 在不断增加的时期内训练模型:
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),如下所示:

从前面的图中,我们可以看到我们的预测误差了大约 150 人。我们可以从以下两个方面改进该模型:

  • 通过对原始图像的裁剪使用数据扩充和训练
  • 通过使用更大的网络(我们使用 A 配置,而 B、C 和 D 更大)。
  1. 对新图像进行推断:
  • 获取测试图像并使其正常化:
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)
  • 将图像传递给训练好的模型:
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()

上述代码会生成输入图像(左图)的热图(右图):

从前面的输出中,我们可以看到该模型相当准确地预测了热图,并且预测的人数接近实际值。

在下一节中,我们将利用 U-Net 架构来给图像着色。

图像彩色化

想象一个场景,给你一堆黑白图像,要求你把它们变成彩色图像。你会怎么解决这个问题?解决这个问题的一种方法是使用伪监督管道,我们获取原始图像,将其转换为黑白图像,并将它们视为输入输出对。我们将通过利用 CIFAR-10 数据集对图像执行彩色化来演示这一点。

当我们编码图像彩色化网络时,我们将采用的策略如下:

  1. 获取训练数据集中的原始彩色图像,并将其转换为灰度,以获取输入(灰度)和输出(原始彩色图像)组合。
  2. 标准化输入和输出。
  3. 构建一个 U-Net 架构。
  4. 在不断增加的时期内训练模型。

有了前面的策略,让我们继续编写模型代码,如下所示:

下面的代码可以在本书的 GitHub 库【https://tinyurl.com/mcvp-packtChapter 10文件夹中找到Image colorization.ipynb

  1. 安装所需的软件包并导入它们:
!pip install torch_snippets
from torch_snippets import *
device = 'cuda' if torch.cuda.is_available() else 'cpu'
  1. 下载数据集并定义训练和验证数据集以及数据加载器:
  • 下载数据集:
from torchvision import datasets
import torch
data_folder = '~/cifar10/cifar/' 
datasets.CIFAR10(data_folder, download=True)
  • 定义训练和验证数据集以及数据加载器:
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)

输入和输出图像的示例如下:

a,b = trn_ds[0]
subplots([a,b], nc=2)

上述代码会产生以下输出:

请注意,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
  1. 定义模型、优化器和损失函数:
def get_model():
    model = UNet().to(device)
    optimizer = optim.Adam(model.parameters(), lr=1e-3)
    loss_fn = nn.MSELoss()
    return model, optimizer, loss_fn
  1. 定义对一批数据进行训练和验证的函数:
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()
  1. 在不断增加的时期内训练模型:
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()

上述代码生成如下输出:

从前面的输出中,我们可以看到该模型能够很好地为灰度图像着色。

到目前为止,我们已经了解了如何利用 Detectron2 进行分割和关键点检测、人群计数中的膨胀卷积以及图像着色中的 U-Net。在下一部分,我们将了解如何利用 YOLO 进行 3D 目标检测。

基于点云的三维目标检测

到目前为止,我们已经学习了如何使用具有锚定框核心基本概念的算法来预测 2D 图像上的边界矩形。我们现在将学习如何将相同的概念扩展到预测物体周围的 3D 边界框。

在自动驾驶汽车中,如果不了解环境,行人/障碍物检测和路线规划等任务就无法完成。预测 3D 对象的位置及其方向成为一项重要的任务。障碍物周围的 2D 边界框不仅重要,而且知道障碍物与对象的距离、高度、宽度和方向对于在 3D 世界中安全导航也至关重要。

在本节中,我们将了解如何使用 YOLO 来预测现实数据集上汽车和行人的 3D 方向和位置。

下载数据、训练和测试集的说明都在这个 GitHub repo 中给出:GitHub . com/sizhky/Complex-yolov 4-py torch/blob/master/readme . MD # training-instructions。鉴于很少有公开可用的 3D 数据集,我们选择了最常用的数据集来做这个练习,您仍然需要注册下载。我们也在前面的链接中提供了注册说明。

理论

收集实时 3D 数据的一个众所周知的传感器是激光雷达 ( 光探测和测距)。它是一个安装在旋转装置上的激光器,每秒钟发射数百次激光束。另一个传感器接收来自周围物体的激光反射,并计算激光在遇到障碍物之前已经行进了多远。在汽车的所有方向上这样做将产生反映环境本身的距离的 3D 点云。在我们将要学习的数据集中,我们已经从称为velodyne的特定硬件获得了 3D 点云。让我们了解如何为 3D 目标检测编码输入和输出。

输入编码

我们的原始输入将是以.bin文件的形式呈现给我们的 3D 点云。每一个都可以使用np.fromfile(<filepath>)作为 NumPy 数组加载,下面是样本文件的数据外观(按照 GitHub repo 指令下载并移动原始文件后,这些文件位于dataset/.../training/velodyne目录中):

files = Glob('training/velodyne')
F = choose(files)
pts = np.fromfile(F, dtype=np.float32).reshape(-1, 4)
pts

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

这可以想象如下:

# 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()

上述代码会产生以下输出:

通过执行以下步骤,我们可以将这些信息转换成鸟瞰图。

  1. 将 3D 点云投影到 XY 平面(地面)上,分割成一个网格,每个网格单元的分辨率为 8 cm ² 。
  2. 对于每个单元格,计算以下内容,并将它们与指定的通道相关联:
  • 红色通道:网格中最高点的高度
  • 绿色通道:网格中最高点的亮度
  • 蓝色通道:网格中的点数除以 64(这是一个归一化因子)

例如,重建的云的俯视图可能如下所示:

可以清楚地看到图像中的“阴影”,表示有障碍物。

这就是我们如何从激光雷达点云数据创建图像。

我们将 3D 点云作为原始输入,并获得鸟瞰图像作为输出。这是创建图像所必需的预处理步骤,该图像将作为 YOLO 模型的输入。

输出编码

现在我们已经将鸟瞰图像(3D 点云的)作为模型的输入,模型需要预测以下真实世界的特征:

  • 图像中出现的对象()是什么
  • 物体在东西轴( x )上离汽车有多远(以米为单位)
  • 物体在南北轴( y )上离汽车有多远(以米为单位)
  • 物体的方位()是什么****
    ***** 物体有多大(物体的长度宽度,单位为米)****

****可以预测(鸟瞰图像的)像素坐标系中的边界框。但是它没有任何现实意义,因为预测仍然在像素空间中(在鸟瞰图中)。在这种情况下,我们需要将这些像素坐标(鸟瞰图的)边界框预测转换为以米为单位的真实坐标。为了避免后处理过程中的额外步骤,我们直接预测真实值。

此外,在现实场景中,对象可以朝向任何方向。如果我们只计算长度和宽度,将不足以描述紧密包围盒。这种情况的一个例子如下:

为了得到物体的紧密包围盒,我们还需要障碍物朝向哪个方向的信息,因此我们还需要额外的偏航参数。从形式上来说,它是具有南北轴的物体所做的定向。

首先,考虑到汽车的 dashcam(以及激光雷达)视图的宽度大于高度,YOLO 模型使用了 32 x 64 单元的锚定网格(宽度大于高度)。该模型对任务使用了两个损失。第一个是正常的 YOLO 损失(它负责预测我们在第八章、高级物体探测中了解到的 xylw 类),另一个损失称为欧拉损失,它专门预测偏航。形式上,从模型输出预测最终边界框的方程组如下:

b[x]=σ(t[x]??)+c[x
b[y]=σ(
t[y])+c[y] b[w]= p[w]e^(t[w])]

这里,b[x]??、b[y]??、b[w]??、 b [l]b [φ] 分别是障碍物的 xz 坐标值、宽度、长度和偏航。
t [x]t [y]t [w]t [l]t [Im]
t[Re]是预测的六个回归值
c [x]c [y] 是 32×64 矩阵中网格单元的中心位置,而 p [w]p [l] 是通过取汽车和行人的平均宽度和长度选择的预定义先验。此外,在实现中有五个先验(锚盒)。

同一类的每个对象的高度被假定为一个固定的数字。

参考这里给出的插图,它以图片的形式展示了这一点(图片来源:arxiv.org/pdf/1803.06199.pdf):

总损失计算如下:

你已经从上一章知道了损失 [YOLO] (使用t[x]??、 t [y]t [w]t [l] 作为目标)。另外,请注意以下事项:

现在我们已经了解了 3D 目标检测的基本原理如何与 2D 目标检测的基本原理保持相同(但是有更多的参数要预测)以及该任务的输入输出对,让我们利用现有的 GitHub repo 来训练我们的模型。

关于 3D 目标检测的更多细节,请参考在 https://arxiv.org/pdf/1803.06199.pdf的论文复合体-YOLO

训练用于 3D 目标检测的 YOLO 模型

由于标准化的代码,编码工作在很大程度上远离了用户。很像 Detectron2,我们可以通过确保数据在正确的位置以正确的格式来训练和测试算法。一旦确保了这一点,我们就可以用最少的代码行来训练和测试代码。

我们需要首先克隆Complex-YOLOv4-Pytorch存储库:

$ git clone https://github.com/sizhky/Complex-YOLOv4-Pytorch

按照README.md文件中的说明下载数据集并将其移动到正确的位置。

The instructions for downloading the data, training, and testing sets are all given in this GitHub repo: github.com/sizhky/Complex-YOLOv4-Pytorch/blob/master/README.md#training-instructions.

Given that there are very few openly available 3D datasets, we have chosen the most-used dataset for this exercise, which you still need to register for download. We also give the instructions for registration in the preceding link.

数据格式

在这个练习中,我们可以使用任何带有地面实况的 3D 点云数据。有关如何下载和移动数据的更多说明,请参考 GitHub repo 上的README文件。数据需要以下列格式存储在根目录中:

三个对我们来说是新的文件夹是velodynecaliblabel_2:

  • velodyne包含一个.bin文件列表,这些文件对存在于image_2文件夹中的相应图像的 3D 点云信息进行编码。

  • calib包含每个点云对应的校准文件。通过使用calib文件夹中每个文件中的 3 x 4 投影矩阵,可以将激光雷达点云坐标系中的 3D 坐标投影到相机坐标系上,即图像上。实际上,激光雷达传感器捕捉的点与相机捕捉的点略有偏差。这种偏移是由于两个传感器彼此相隔几英寸安装。了解正确的偏移量将有助于我们正确地将边界框和 3D 点投影到来自相机的图像上。

  • label_2包含每幅图像的基本事实(每行一个基本事实),以 15 个值的形式,如下表所述:

请注意,我们的目标列是这里看到的类型(class)、 wlxzry (偏航)。我们将忽略该任务的其余值。

数据检查

我们可以通过运行以下命令来验证数据是否正确下载:

$ cd Complex-YOLOv4-Pytorch/src/data_process
$ python kitti_dataloader.py --output-width 600

前面的代码显示了多个图像,一次一个图像。下面就是这样一个例子(图片来源:arxiv.org/pdf/1803.06199.pdf):

既然我们已经能够下载并查看一些图像,在下一节中,我们将学习如何训练模型来预测 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):

由于模型的简单性,我们可以使用普通 GPU 在实时场景中使用它,每秒钟可以获得大约 15-20 次预测。

摘要

在这一章中,我们学习了处理对象定位和分割的各种实际方面。具体来说,我们了解了如何利用 Detectron2 平台来执行图像分割和检测以及关键点检测。此外,当我们从开放图像数据集中获取图像时,我们还了解了处理大型数据集所涉及的一些复杂性。接下来,我们致力于利用 VGG 和 U-Net 架构分别进行人群计数和图像着色。最后,我们了解了使用点云图像进行三维目标检测的理论和实现步骤。正如您从所有这些示例中看到的,底层基础与前面章节中描述的相同,只是在网络的输入/输出方面进行了修改,以适应手头的任务。

在下一章中,我们将转换话题,学习图像编码,这有助于识别相似的图像以及生成新的图像。******

第三部分:图像操作

在这一节中,我们将探索各种处理图像的技术,包括自编码器和各种类型的 GANs。我们将利用这些技术来提高图像质量,操作风格,并从现有的图像中生成新的图像。

本节包括以下章节:

  • 第十一章、自编码器和图像处理
  • 第十二章,使用 GANs 生成图像
  • 第十三章,高级 GAN 操纵图像

十一、自编码器和图像操作

在前面的章节中,我们已经学习了图像分类、检测图像中的对象以及分割图像中与对象相对应的像素。在这一章中,我们将学习使用自编码器在低维中表示图像,以及通过使用变分自编码器利用图像的低维表示来生成新图像。学习用较低的维度来表示图像,有助于我们在相当大的程度上操纵(修改)图像。我们将学习利用低维表示来生成新图像以及基于两个不同图像的内容和风格的新颖图像。接下来,我们还将学习以这样一种方式修改图像,即图像在视觉上不被改变,然而,对应于图像的类从一个改变到另一个。最后,我们将了解如何生成深度假像:给定一个人 A 的源图像,我们生成一个人 B 的目标图像,其面部表情与人 A 相似。

总的来说,我们将在本章中讨论以下主题:

  • 理解和实现自编码器
  • 了解卷积自编码器
  • 了解可变自编码器
  • 对图像进行对抗性攻击
  • 执行神经类型转移
  • 产生深刻的假象

了解自编码器

到目前为止,在前面的章节中,我们已经学习了通过基于输入图像及其相应标签训练模型来分类图像。现在,让我们想象一个场景,我们需要根据图像的相似性对图像进行聚类,并且没有相应的标签。自编码器可以方便地识别和分组相似的图像。

自编码器将图像作为输入,将其存储在一个较低的维度中,并试图再现相同的图像作为输出,因此出现了术语 auto (代表能够再现输入)。然而,如果我们只是在输出中再现输入,我们将不需要网络,但是输入乘以 1 就可以了。自编码器的与众不同之处在于,它将图像中存在的信息在较低的维度上进行编码,然后再现图像,因此有了术语编码器(代表在较低的维度上表示图像的信息)。这样,相似的图像将具有相似的编码。此外,解码器致力于从编码矢量中重建原始图像。

为了进一步理解自编码器,让我们看一下下图:

假设输入图像是 MNIST 手写数字的扁平版本,输出图像与作为输入提供的图像相同。最中间层是编码层,称为瓶颈层。发生在输入和瓶颈层之间的操作代表编码器,瓶颈层和输出之间的操作代表解码器

通过瓶颈层,我们可以在低得多的维度上表示图像。此外,有了瓶颈层,我们可以重建原始图像。我们利用瓶颈层来解决识别相似图像以及生成新图像的问题,我们将在后续章节中学习如何做。

瓶颈层在以下方面有所帮助:

  • 具有相似瓶颈层值(编码表示)的图像可能彼此相似。
  • 通过改变瓶颈层的节点值,我们可以改变输出图像。

有了前面的理解,让我们做以下事情:

  • 从头开始实现自编码器
  • 基于瓶颈层值可视化图像的相似性

在下一节中,我们将了解自编码器是如何构建的,还将了解瓶颈层中不同单元对解码器输出的影响。

实现普通自编码器

为了理解如何构建自编码器,让我们在包含手写数字图像的 MNIST 数据集上实现一个:

以下代码可在本书的 GitHub 知识库的chapter11文件夹中以simple_auto_encoder_with_different_latent_size.ipynb的名称获得-【https://tinyurl.com/mcvp-packt 代码长度适中。我们强烈建议您在 GitHub 中执行笔记本以重现结果,同时理解执行的步骤和文本中各种代码组件的解释。

  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'
  1. 指定我们希望图像通过的转换:
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)
  1. 定义数据加载器:
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)
  1. 定义网络架构。我们定义在__init__方法中构成编码器和解码器的AutoEncoder类,以及瓶颈层的维度、latent_dimforward方法,并可视化模型摘要:
  • 定义包含编码器、解码器和瓶颈层维度的AutoEncoder类和__init__方法:
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())
  • 定义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
  • 将前面的模型可视化:
!pip install torch_summary
from torchsummary import summary
model = AutoEncoder(3).to(device)
summary(model, torch.zeros(2,1,28,28))

这会产生以下输出:

从前面的输出中,我们可以看到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
  1. 定义要对该批数据进行验证的函数(validate_batch):
@torch.no_grad()
def validate_batch(input, model, criterion):
    model.eval()
    output = model(input)
    loss = criterion(output, input)
    return loss
  1. 定义模型、损失标准和优化器:
model = AutoEncoder(3).to(device)
criterion = nn.MSELoss()
optimizer = torch.optim.AdamW(model.parameters(), \
                              lr=0.001, weight_decay=1e-5)
  1. 在不断增加的时期内训练模型:
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)
  1. 设想在不断增长的时期内培训和验证的损失:
log.plot_epochs(log=True)

上述代码片段返回以下输出:

  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()

上述代码的输出如下:

我们可以看到,即使瓶颈层只有三维大小,网络也能以非常高的精确度再现输入。然而,图像并不像我们预期的那样清晰。这主要是因为瓶颈层中的节点数量很少。在下图中,我们将使用不同的瓶颈层大小(2、3、5、10 和 50)对网络进行训练,然后可视化重建的图像:

很明显,随着瓶颈层中矢量数量的增加,重建图像的清晰度提高。

在下一节中,我们将学习如何使用卷积神经网络 ( CNN )生成更清晰的图像,我们还将学习如何对相似的图像进行分组。

了解卷积自编码器

在上一节中,我们学习了自编码器,并在 PyTorch 中实现了它们。虽然我们已经实现了它们,但我们通过数据集获得的一个便利是,每个图像只有一个通道(每个图像被表示为黑白图像),并且图像相对较小(28 x 28)。因此,网络拉平了输入,并能够对 784 (28*28)个输入值进行训练,以预测 784 个输出值。然而,在现实中,我们会遇到有 3 个通道的图像,并且比 28 x 28 的图像大得多。

在本节中,我们将了解如何实现一个卷积自编码器,它能够处理多维输入图像。然而,为了与普通自编码器进行比较,我们将使用与上一节中相同的 MNIST 数据集,但是修改了网络,现在我们构建的是卷积自编码器,而不是普通自编码器。

卷积自编码器表示如下:

从前面的图像中,我们可以看到输入图像被表示为瓶颈层中用于重建图像的块。图像经过多次卷积以获取瓶颈表示(即通过编码器获得的瓶颈层)并且瓶颈表示被放大以获取原始图像(原始图像通过解码器被重建)。

现在我们知道卷积自编码器是如何表示的,让我们用下面的代码实现它:

Given that the majority of the code is similar to the code in the previous section, we have only provided the additional code for brevity. The following code is available as conv_auto_encoder.ipynb in Chapter11 folder of this book's GitHub repository. We encourage you to go through the notebook in GitHub if you want to see the complete code.

  1. 步骤 1 到 4 与普通自编码器部分完全相同,如下所示:
!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)
  1. 定义神经网络的类别,ConvAutoEncoder:
  • 定义类和__init__方法:
class ConvAutoEncoder(nn.Module):
    def __init__(self):
        super().__init__()
  • 定义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操作来减小输出值的大小。

  • 定义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()
                    )
  • 定义forward方法:
    def forward(self, x):
        x = self.encoder(x)
        x = self.decoder(x)
        return x
  1. 使用summary方法获得模型的概要:
model = ConvAutoEncoder().to(device)
!pip install torch_summary
from torchsummary import summary
summary(model, torch.zeros(2,1,28,28));

上述代码会产生以下输出:

从前面的总结中,我们可以看到形状为批次大小 x 64 x 2 x 2 的MaxPool2d-6层充当瓶颈层。

一旦我们对模型进行了训练,就像我们在上一节中所做的那样(在步骤 6、7、8 和 9 中),训练和验证损失在增加的时期和对输入图像的预测上的变化如下:

从前面的图像中,我们可以看到卷积自编码器能够比普通自编码器更清晰地预测图像。作为练习,我们建议您改变编码器和解码器中的通道数量,然后分析结果的变化。

在下一节中,我们将解决当图像标签不存在时,基于瓶颈层值对相似图像进行分组的问题。

使用 t-SNE 对相似图像进行分组

在前面的部分中,我们假设相似的图像具有相似的嵌入,不相似的图像具有不同的嵌入,在低得多的维度中表示每个图像。然而,我们还没有查看图像相似性度量或详细检查嵌入表示。

在这一节中,我们将在二维空间中绘制嵌入(瓶颈)向量。我们可以通过使用一种称为 t-SNE 的技术,将卷积自编码器的 64 维向量减少到 2 维空间。(更多关于 SNE 霸王龙的信息,请点击这里:【http://www.jmlr.org/papers/v9/vandermaaten08a.html】。)

这样,可以证明我们的理解,即相似的图像将具有相似的嵌入,因为相似的图像应该在二维平面中聚集在一起。在下面的代码中,我们将表示二维平面中所有测试图像的嵌入:

下面的代码是上一节构建的代码的延续,理解卷积自编码器的,在本书的 GitHub 库Chapter 11文件夹中以conv_auto_encoder.ipynb的名称提供-【https://tinyurl.com/mcvp-packt】的

  1. 初始化列表,以便我们存储图像的潜在向量(latent_vectors)和相应的classes(注意,我们存储每个图像的类别只是为了验证预期彼此具有非常高的相似性的同一类别的图像在表示方面是否确实彼此接近):
latent_vectors = []
classes = []
  1. 遍历验证数据加载器(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)
  1. 串联latent_vectors的 NumPy 数组:
latent_vectors = torch.cat(latent_vectors).cpu()\
                      .detach().numpy()
  1. 导入 t-SNE ( TSNE)并指定每个向量都要转换成一个二维向量(TSNE(2)),这样我们就可以绘制它了:
from sklearn.manifold import TSNE
tsne = TSNE(2)
  1. 通过在图像嵌入(latent_vectors)上运行fit_transform方法来拟合 t-SNE:
clustered = tsne.fit_transform(latent_vectors)
  1. 拟合 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)

上述代码提供了以下输出:

我们可以看到同一类的图像聚集在一起,这加强了我们的理解,即瓶颈层以这样一种方式具有值,即看起来相似的图像将具有相似的值。

到目前为止,我们已经了解了如何使用自编码器将相似的图像分组在一起。在下一节中,我们将学习如何使用自编码器来生成新图像。

了解可变自编码器

到目前为止,我们已经看到了一个场景,其中我们可以将相似的图像分组到集群中。此外,我们已经了解到,当我们嵌入属于给定聚类的图像时,我们可以重构(解码)它们。然而,如果一个嵌入(一个潜在向量)落在两个簇之间呢?不能保证我们会生成真实的图像。在这种情况下,可变自编码器就派上了用场。

在我们开始构建一个变分自编码器之前,让我们先来探索一下从不属于一个簇(或者在不同簇中间)的嵌入中生成图像的局限性。首先,我们通过采样向量生成图像:

The following code is a continuation of the code built in the previous section, Understanding convolutional autoencoders, and is available as conv_auto_encoder.ipynb in the chapter11 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

  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)
  1. 生成具有列级平均值(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)
  1. 绘制从步骤 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))

上述代码会产生以下输出:

我们可以从前面的输出中看到,当我们绘制由已知向量列的平均值和添加噪声的标准偏差生成的图像时,我们得到的图像不如以前清晰。这是一个真实的场景,因为我们事先不知道生成真实图片的嵌入向量的范围。

变分自编码器 ( VAE )通过生成均值为 0、标准差为 1 的向量来帮助我们解决这个问题,从而确保我们生成的图像均值为 0、标准差为 1。

本质上,在 VAE,我们指定瓶颈层应该遵循一定的分布。在接下来的部分中,我们将了解我们对 VAE 采取的策略,我们还将了解 KL 散度损失,这有助于我们获取遵循特定分布的瓶颈特征。

VAE 的工作

在 VAE 中,我们构建网络的方式使得从预定义分布生成的随机向量可以生成逼真的图像。这对于简单的自编码器是不可能的,因为我们没有指定在网络中生成图像的数据的分布。我们采用以下策略,通过 VAE 实现这一点:

  1. 编码器的输出是每个图像的两个向量:
    • 一个矢量代表平均值。
    • 另一个代表标准差。
  2. 从这两个向量中,我们获取一个修改后的向量,它是平均值和标准偏差之和(乘以一个随机的小数字)。修改后的向量将具有与每个向量相同的维数。
  3. 在前一步骤中获得的修改向量作为输入被传递到解码器以获取图像。
  4. 我们优化的损耗值是均方误差和 KL 发散损耗的组合:
    • KL 散度损失分别测量平均向量和标准偏差向量的分布与 0 和 1 的偏差。
    • 均方损失是我们用来重建(解码)图像的优化方法。

通过指定均值向量的分布应该以 0 为中心,标准差向量的分布应该以 1 为中心,我们以这样的方式训练网络:当我们生成均值为 0、标准差为 1 的随机噪声时,解码器将能够生成逼真的图像。

此外,注意,如果我们仅最小化 KL 散度,编码器将会预测平均向量的值为 0,并且每个输入的标准偏差为 1。因此,同时最小化 KL 发散损失和均方损失是重要的。

在下一节中,让我们了解 KL 散度,以便我们可以将其纳入模型的损失值计算中。

KL 散度

KL 散度有助于解释两种数据分布之间的差异。在我们的具体例子中,我们希望瓶颈特征值遵循均值为 0、标准差为 1 的正态分布。

因此,我们使用 KL 散度损失来理解我们的瓶颈特征值相对于平均值为 0 且标准偏差为 1 的值的预期分布有多不同。

让我们看看 KL 散度损失是如何计算的:

在上式中,σ和μ代表每个输入图像的平均值和标准偏差值。

让我们理解前面等式背后的直觉:

  • 确保平均向量分布在 0:
    • 最小化前面等式中的均方误差()确保尽可能接近 0。
  • 确保标准偏差向量分布在 1:
    • 等式其余部分的项(除了)确保 sigma(标准差向量)分布在 1 附近。

当均值( )为 0 且标准差为 1 时,前述损失函数最小化。此外,通过指定我们考虑标准偏差的对数,我们确保 sigma 值不能为负。

现在,我们已经了解了构建 VAE 和最小化损失函数的高级策略,以便获得编码器输出的预定义分布,让我们在下一部分实现 VAE。

建设一个 VAE

在这一节中,我们将编码一个 VAE 来生成新的手写数字图像。

The following code is available as VAE.ipynb in the Chapter11 folder of this book's GitHub repository - tinyurl.com/mcvp-packt

因为我们有相同的数据,所以除了步骤 5 和 6 之外,实现普通自编码器部分中的所有步骤都保持不变,在步骤 5 和 6 中,我们分别定义了网络架构和训练模型,我们在下面的代码中定义了这两个模型:

  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)
  1. 定义神经网络类,VAE:
  • __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)

注意,d1d2层将对应于编码器部分,d5d6将对应于解码器部分。d31d32层分别对应于均值和标准差向量。然而,为了方便起见,我们将做的一个假设是我们将使用d32层作为方差向量的对数的表示。

  • 定义encoder方法:
    def encoder(self, x):
        h = F.relu(self.d1(x))
        h = F.relu(self.d2(h))
        return self.d31(h), self.d32(h)

注意,编码器返回两个向量:一个向量表示平均值(self.d31(h)),另一个向量表示方差值的对数(self.d32(h))

  • 定义从编码器输出中取样(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)

注意的指数 0.5log_var* ( torch.exp(0.5*log_var))代表标准差(std)。此外,我们将返回平均值和标准偏差乘以随机正态分布生成的噪声的和。通过乘以eps,我们确保即使编码器向量有微小的变化,我们也能生成图像。

  • 定义decoder方法:
    def decoder(self, z):
        h = F.relu(self.d4(z))
        h = F.relu(self.d5(h))
        return F.sigmoid(self.d6(h))
  • 定义forward方法:
    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

在前面的方法中,我们确保编码器返回方差值的平均值和对数。接下来,我们进行采样,将均值加上ε乘以方差的对数,并在通过解码器后返回值。

  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()
  1. 定义损失函数:
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)
  1. 在不断增加的时期内训练模型:
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功能绘制图像(如果需要,在绘制前自动反规格化图像)。

损失值变化的输出和生成的图像样本如下:

我们可以看到,我们能够生成原始图像中没有的逼真的新图像。

到目前为止,我们已经了解了如何使用 VAEs 生成新图像。然而,如果我们想以一种模型不能识别正确的类的方式来修改图像,该怎么办呢?我们将在下一节中了解解决这一问题的技术。

对图像进行对抗性攻击

在上一节中,我们学习了使用 VAE 从随机噪声中生成图像。然而,这是一次无人监督的练习。如果我们想要以这样一种方式修改图像,使得图像的变化非常小,以至于对于人类来说它与原始图像无法区分,但是神经网络模型仍然认为该对象属于不同的类别,那么该怎么办?在这种情况下,对图像的对抗性攻击就派上了用场。

对抗性攻击指的是我们对输入图像值(像素)进行的更改,以便我们达到某个目标。

在本节中,我们将学习以这样的方式稍微修改图像,即预训练的模型现在将它们预测为不同的类(由用户指定)而不是原始类。我们将采取的策略如下:

  1. 提供一幅大象的图像。
  2. 指定与图像对应的目标类。
  3. 导入预先训练的模型,其中模型的参数被设置为不更新(gradients = False)。
  4. 指定我们计算输入图像像素值的梯度,而不是网络的权重值。这是因为在训练欺骗网络时,我们不能控制模型,而只能控制发送给模型的图像。
  5. 计算与模型预测和目标类别相对应的损失。
  6. 对模型执行反向传播。这一步有助于我们理解与每个输入像素值相关的梯度。
  7. 基于对应于每个输入像素值的梯度的方向来更新输入图像像素值。
  8. 在多个时期内重复步骤 5、6、7

让我们用代码来做这件事:

The following code is available as adversarial_attack.ipynb in the Chapter11 folder of this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components in text.

  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)
  1. 导入 Imagenet 类并为每个类分配 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()}
  1. 指定一个函数来规格化(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
  1. 定义对给定图像进行预测的函数(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功能:
  • attack函数以imagemodeltarget为输入:
from tqdm import trange
losses = []
def attack(image, model, target, epsilon=1e-6):
  • 将图像转换为张量,并指定输入需要计算梯度:
    input = image2tensor(image)
    input.requires_grad = True
  • 通过将标准化输入(input)传递给模型来计算预测,然后计算与指定目标类别相对应的损失值:
    pred = model(input)
    loss = nn.CrossEntropyLoss()(pred, target)
  • 执行反向传播以减少损失:
    loss.backward()
    losses.append(loss.mean().item())
  • 基于梯度方向非常轻微地更新图像:
    output = input - epsilon * input.grad.sign()

在前面的代码中,我们以非常小的量更新输入值(乘以epsilon)。此外,我们不是通过梯度的大小来更新图像,而是在将其乘以一个非常小的值(epsilon)后,只更新梯度的方向(input.grad.sign())。

  • 将张量转换成图像(tensor2image)后返回输出,反规格化图像:
    output = tensor2image(output)
    del input
    return output.detach()
  1. 将图像修改为属于不同的类别:
  • 指定我们要将图像转换到的目标(desired_targets):
modified_images = []
desired_targets = ['lemon', 'comic book', 'sax, saxophone']
  • 循环遍历目标,并在每次迭代中指定目标类:
for target in desired_targets:
    target = torch.tensor([image_net_classes[target]])
  • 修改图像以攻击更多的时期,并将它们收集在一个列表中:
    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)
  • 以下代码会产生修改后的图像和相应的类:
for image in [original_image, *modified_images]:
    predict_on_image(image)
    inspect(image)

上述代码生成以下内容:

我们可以看到,当我们非常轻微地修改图像时,预测类完全不同,但是具有非常高的置信度。

现在我们已经了解了如何修改图像,以便按照我们的意愿对它们进行分类,在下一节中,我们将学习如何以我们选择的样式修改图像(内容图像)。我们必须提供一个内容图像和一个样式图像。

执行神经类型转移

在神经风格迁移中,我们有一个内容图像和一个风格图像,我们以这样一种方式组合这两个图像,即组合的图像保留了内容图像的内容,同时保持了风格图像的风格。

样式图像和内容图像的示例如下:

在前面的图片中,我们希望保留右边图片中的内容(内容图像),但是用左边图片中的颜色和纹理覆盖它(样式图像)。

执行神经类型转移的过程如下。我们尝试以这样的方式修改原始图像,即损失值被分成内容损失风格损失。内容损失是指生成的图像与内容图像的差异有多大。风格损失指的是如何关联风格图像与生成的图像。

虽然我们提到损失是基于图像的差异来计算的,但在实践中,我们通过确保损失是使用图像的特征层激活而不是原始图像来计算的来稍微修改它。例如,当通过第二层时,第二层的内容损失将是内容图像的激活和生成的图像之间的平方差。

损失是在要素图层而不是原始图像上计算的,因为要素图层捕捉原始图像的某些属性(例如,较高层中与原始图像相对应的前景轮廓和较低层中细粒度对象的细节)。

虽然计算内容损失似乎很简单,但是让我们试着理解如何计算生成的图像和样式图像之间的相似性。一种叫做克矩阵的技术派上了用场。Gram matrix 计算生成的图像和风格图像之间的相似度,计算如下:

GM[l] 是风格图像 S 和生成图像 Gl 层的克矩阵值。
格拉姆矩阵由矩阵乘以其自身的转置产生。我们来了解一下这个操作的用途。

假设您正在处理一个要素输出为 32 x 32 x 256 的图层。gram 矩阵计算为一个通道中 32 x 32 个值中的每一个相对于所有通道中的值的相关性。因此,gram 矩阵计算的结果是一个 256 x 256 的矩阵。我们现在比较样式图像和生成的图像的 256 x 256 值来计算样式损失。

让我们来理解为什么语法矩阵对风格转换很重要。

在一个成功的场景中,假设我们把毕加索的风格迁移到了蒙娜丽莎上。姑且称毕加索风格 St (风格),蒙娜丽莎原作 So (来源),最终图像 Ta (目标)。注意,在理想情况下,图像 Ta 中的局部特征与 St 中的局部特征相同。即使内容可能不一样,但在风格转换中,让相似的颜色、形状和纹理作为风格图像进入目标图像是很重要的。

通过扩展,如果我们发送 So 并从 VGG19 的中间层提取其特征,它们将不同于通过发送 Ta 获得的特征。然而,在每个特征集中,相应的向量将以相似的方式彼此相对变化。比方说,例如,在两个特征集中,第一通道的平均值与第二通道的平均值的比值将是相似的。这就是为什么我们试图用克损失来计算。

通过比较内容图像相对于生成图像的特征激活的差异来计算内容损失。通过首先计算预定义层中的 gram 矩阵,然后比较生成的图像和风格图像的 gram 矩阵,来计算风格损失。

既然我们能够计算样式损失和内容损失,最终修改的输入图像是最小化总体损失的图像,即样式和内容损失的加权平均值。
我们采用的实现神经风格迁移的高级策略如下:

  1. 将输入图像通过预先训练的模型。
  2. 提取预定义图层的图层值。
  3. 将生成的图像传递给模型,并在相同的预定义层提取其值。
  4. 计算对应于内容图像和生成图像的每一层的内容损失。
  5. 将样式图像传递到模型的多个层,并计算样式图像的 gram 矩阵值。
  6. 将生成的图像通过与样式图像相同的层,并计算其对应的 gram 矩阵值。
  7. 提取两幅图像的 gram 矩阵值的平方差。这将是风格的损失。
  8. 总体损失将是样式损失和内容损失的加权平均值。
  9. 最小化总损失的输入图像将是最终感兴趣的图像。

现在让我们编写前面的策略:

The following code is available as neural_style_transfer.ipynb in the chapter11 folder of this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from and is moderately lengthy. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components in text.

  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'
  1. 定义预处理和后处理数据的函数:
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]),
            ])
  1. 定义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. 定义 gram 矩阵对应的 MSE 损失,GramMSELoss:
class GramMSELoss(nn.Module):
    def forward(self, input, target):
        out = F.mse_loss(GramMatrix()(input), target)
        return(out)

一旦我们有了两个特征集的 gram 向量,重要的是它们尽可能地匹配,因此有了mse_loss

  1. 定义模型类,vgg19_modified:
  • 初始化该类:
class vgg19_modified(nn.Module):
    def __init__(self):
        super().__init__()
  • 提取特征:
        features = list(vgg19(pretrained = True).features)
        self.features = nn.ModuleList(features).eval() 
  • 定义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
  • 定义模型对象:
vgg = vgg19_modified().to(device)
  1. 导入内容和样式图像:
!wget https://www.dropbox.com/s/z1y0fy2r6z6m6py/60.jpg
!wget https://www.dropbox.com/s/1svdliljyo0a98v/style_image.png
  • 确保图像的大小调整为相同的形状,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]
  1. requires_grad = True指定要修改的内容图像:
opt_img = content_image.data.clone()
opt_img.requires_grad = True
  1. 指定定义内容损失和样式损失的层,即我们使用的中间 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]
  1. 定义与内容和风格损失相关的权重:
style_weights = [1000/n**2 for n in [64,128,256,512,512]] 
content_weights = [1]
weights = style_weights + content_weights
  1. 我们需要操作我们的图像,使得目标图像的风格尽可能地类似于style_image。因此,我们通过计算从 VGG 的几个选定层获得的特征的GramMatrix来计算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
  1. 定义optimizer和迭代次数(max_iters)。尽管我们可以使用 Adam 或任何其他优化器,但据观察,LBFGS 是在确定性场景中工作得最好的优化器。此外,由于我们处理的是一幅图像,因此没有任何随机性。许多实验表明,LBFGS 收敛更快,并且在神经传递设置中的损失更低,因此我们将使用此优化器:
max_iters = 500
optimizer = optim.LBFGS([opt_img])
log = Report(max_iters)
  1. 执行优化。在我们一次又一次迭代同一个张量的确定性场景中,我们可以将优化器步骤包装为一个没有参数的函数,并重复调用它,如下所示:
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)
  1. 绘制损失的变化:
log.plot(log=True)

这会产生以下输出:

  1. 使用内容和样式图像的组合绘制图像:
out_img = postprocess(opt_img[0]).permute(1,2,0)
show(out_img)

输出如下所示:

从上图我们可以看出,图片是内容和风格图片的结合。

至此,我们已经看到了处理图像的两种方式:对抗性攻击来修改图像的类别,以及风格转换来将一个图像的风格与另一个图像的内容相结合。在下一节中,我们将学习如何生成深度假像,这种假像将表情从一张脸转移到另一张脸上。

产生深刻的假象

到目前为止,我们已经学习了两种不同的图像到图像的任务:使用 UNet 的语义分割和使用自编码器的图像重建。深度伪装是一种图像到图像的任务,有着非常相似的基本理论。

想象一下这样一个场景,您想要创建一个应用程序,该应用程序获取一张脸的给定图像,并以您想要的方式改变面部表情。在这种情况下,深度假货就派上了用场。虽然我们不会在本书中讨论深度假动作的最新进展,但像少数镜头对抗学习这样的技术可以用来生成带有感兴趣的面部表情的真实图像。关于深度假动作和 GANs 的知识(你将在接下来的章节中学习)将帮助你识别哪些视频是假视频。

在深度伪装的任务中,我们会有几百张 A 人的照片和几百张 B 人的照片,目的是用 A 人的面部表情重建 B 人的面部,反之亦然。

下图解释了深度假图像生成过程的工作原理:

在上图中,我们通过编码器( Encoder )传递人 A 和人 B 的图像。一旦我们得到了人 A ( 潜脸 A )和人 B ( 潜脸 B )对应的潜向量,我们就将潜向量通过它们对应的解码器(解码器 A解码器 B )来获取对应的原始图像(重构脸 A重构脸 B )。到目前为止,编码器和解码器的概念与我们在自编码器部分学到的非常相似。然而,在这个场景中,我们只有一个编码器,但是有两个解码器(每个解码器对应不同的人)。期望从编码器获得的潜在向量表示关于图像中存在的面部表情的信息,而解码器获取对应于人的图像。一旦编码器和两个解码器被训练,在执行深度伪图像生成时,我们在我们的架构内切换连接如下:

当人 A 的潜在向量通过解码器 B 时,重构的人 B 的脸将具有人 A 的特征(微笑的脸),反之亦然。

有助于生成真实图像的另一个技巧是扭曲人脸图像,并以扭曲的人脸作为输入,原始图像作为输出的方式将它们提供给网络。

现在我们已经了解了它是如何工作的,让我们用下面的代码使用自编码器实现一个人的假图像和另一个人的表情的生成:

以下代码在本书的 GitHub 知识库的Chapter11文件夹中以Generating_Deep_Fakes.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 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
  1. 从图像中获取面部裁剪:
  • 定义面部层叠,它在图像中的面部周围绘制一个边界框。在第十八章,OpenCV 图像分析实用程序中有更多关于级联的内容。然而,现在,可以说面部层叠在图像中存在的面部周围绘制了一个紧密的边界框:
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \
                        'haarcascade_frontalface_default.xml')
  • 定义一个函数(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

在前面的函数中,我们通过 face cascade 传递灰度图像(gray)并裁剪包含人脸的矩形。接下来,我们将返回一个重新调整大小的图像(img2)。此外,考虑到在图像中没有检测到人脸的情况,我们传递一个标志来显示是否检测到人脸。

  • 裁剪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')
  1. 创建数据加载器并检查数据:
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中。这是扭曲面的增强功能。我们将扭曲的人脸作为自编码器的输入,并尝试预测正常的人脸。

扭曲的优势在于,它不仅增加了我们的训练数据量,还充当了网络的正则化器,尽管给定了一张扭曲的脸,但网络仍被迫理解关键的面部特征。

  • 让我们来看看几张图片:
inspect(*next(iter(x)))

for i in next(iter(x)):
    subplots(i[:8], nc=4, sz=(4,2))

上述代码会产生以下输出:

注意,输入图像是扭曲的,而输出图像不是,并且输入到输出图像现在具有一一对应关系。

  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
  • 定义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
  • 生成模型摘要:
from torchsummary import summary
model = Autoencoder()
summary(model, torch.zeros(32,3,64,64), 'A');

上述代码生成以下输出:

  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()
  1. 训练模型:
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()

上述代码会生成重建的图像,如下所示:

损失值的变化如下:

正如你所看到的,我们可以通过调整自编码器将一张脸的表情转换成另一张脸的表情。此外,随着历元数量的增加,重建的图像变得更加真实。

摘要

在这一章中,我们已经了解了自编码器的不同变体:普通的、卷积的和变化的。我们还了解了瓶颈层中的单元数量如何影响重建图像。接下来,我们学习了使用 t-SNE 技术识别与给定图像相似的图像。我们了解到,当我们对向量进行采样时,我们无法获得真实的图像,通过使用变分自编码器,我们了解到通过使用重建损失和 KL 发散损失的组合来生成新的图像。接下来,我们学习了如何对图像进行对抗性攻击,以修改图像的类别,同时不改变图像的感知内容。最后,我们了解了如何利用内容损失和基于 gram 矩阵的样式损失的组合来优化图像的内容和样式损失,从而得到由两个输入图像组合而成的图像。最后,我们学习了在没有任何监督的情况下调整自编码器来交换两张脸。

既然我们已经学习了从一组给定的图像中生成新的图像,在下一章中,我们将在这个主题的基础上使用一个叫做生成对抗网络的网络的变体来生成全新的图像。

问题

  1. 什么是自编码器中的编码器?

  2. 自编码器针对什么损失函数进行优化?

  3. 自编码器如何帮助分组相似的图像?

  4. 卷积自编码器什么时候有用?

  5. 如果我们从普通/卷积自编码器获得的嵌入向量空间中随机采样,为什么会得到不直观的图像?

  6. VAEs 优化的损失函数是什么?

  7. VAEs 如何克服普通/卷积自编码器生成新图像的限制?

  8. 在对抗性攻击中,为什么我们修改输入图像像素而不是权重值?

  9. 在神经类型转移中,我们优化的损失是什么?

  10. 为什么我们在计算样式和内容损失时考虑不同图层的激活而不考虑原始图像?

  11. 为什么我们在计算风格损失时考虑的是克矩阵损失而不是图像之间的差异?

  12. 为什么我们在建立模型的时候会扭曲图像,从而产生深度假像?

十二、使用 GAN 的图像生成

在前一章中,我们学习了使用神经风格迁移来处理图像,并将一幅图像中的表情叠加到另一幅图像上。然而,如果我们给网络一堆图像,让它自己想出一个全新的图像,会怎么样呢?

生成对抗网络 ( )是实现给定一组图像生成一幅图像的壮举的一步。在这一章中,我们将从学习 GANs 的工作原理开始,然后从头开始构建一个。在我们写这本书的时候,gan 是一个正在扩展的广阔领域。本章将通过 GANs 的三种变体来奠定 GANs 的基础,而我们将在下一章学习更高级的 GANs 及其应用。

在本章中,我们将探讨以下主题:

  • GANs 简介
  • 使用 GANs 生成手写数字
  • 使用 DCGANs 生成人脸图像
  • 实现有条件的 GANs

GANs 简介

要理解 GANs,我们需要了解两个术语:发生器鉴别器。首先,我们应该有一个物体图像的合理样本。生成网络(生成器)从图像样本中学习表示,然后生成与图像样本相似的图像。鉴别器网络(discriminator)是一种查看(由生成器网络)生成的图像和图像的原始样本并将图像分类为原始图像或生成的(伪造的)图像的网络。

生成器网络以鉴别器将图像分类为真实图像的方式生成图像。鉴别器网络将生成的图像分类为假的,而将原始样本中的图像分类为真的。

本质上,GAN 中的对抗性术语代表了两个网络的相反性质——生成者网络,它生成图像以欺骗鉴别者网络,鉴别者网络通过说明图像是生成的还是原始的来对每个图像进行分类。

让我们通过下图了解 GANs 采用的流程:

在上图中,生成器网络通过输入随机噪声生成图像。鉴别器网络查看生成器生成的图像,并将它们与真实数据(提供的图像样本)进行比较,以确定生成的图像是真的还是假的。生成器试图生成尽可能多的真实图像,而鉴别器试图检测由生成器生成的哪些图像是假的。这样,生成器通过学习鉴别器查看的内容来识别图像是否是假的,从而学习生成尽可能多的真实图像。

通常,发生器和鉴别器交替训练。这样就变成了警察和小偷的游戏,生成者是试图生成假数据的小偷,鉴别者是试图鉴别可用数据真假的警察。

现在,让我们了解如何计算发生器和鉴别器的损耗值,以便使用下图和步骤一起训练两个网络:

培训 GANs 的步骤如下:

  1. 训练生成器(而不是鉴别器)生成图像,使鉴别器将图像分类为真实图像。
  2. 训练鉴别器(而不是生成器)将生成器生成的图像分类为假的。
  3. 重复这个过程,直到达到平衡。

在前面的场景中,当鉴别器可以很好地检测生成的图像时,与鉴别器对应的损耗相比,发生器对应的损耗要高得多。

因此,梯度以发电机会有损耗的方式调整。然而,它会使鉴频器损耗向更高的一侧倾斜。在下一次迭代中,梯度被调整,使得鉴频器损耗更低。通过这种方式,发生器和鉴别器不断接受训练,直到发生器生成真实图像而鉴别器无法区分真实图像或生成的图像。

有了这样的理解,让我们在下一节中生成与 MNIST 数据集相关的图像。

使用 GANs 生成手写数字

为了生成手写数字的图像,我们将利用在上一节中了解到的同一个网络。我们将采取的策略如下:

  1. 导入 MNIST 数据。
  2. 初始化随机噪声。
  3. 定义发电机型号。
  4. 定义鉴别器模型。
  5. 交替训练两个模型。
  6. 让模型训练,直到发电机和鉴频器损耗基本相同。

让我们在下面的代码中执行前面的每个步骤:

以下代码可在本书的 GitHub 资源库Chapter12文件夹中的Handwritten_digit_generation_using_GAN.ipynb处获得-【https://tinyurl.com/mcvp-packt 代码长度适中。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  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
  1. 导入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)
  1. 定义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作为激活函数。鉴别器网络概述如下:

!pip install torch_summary
from torchsummary import summary
discriminator = Discriminator().to(device)
summary(discriminator,torch.zeros(1,784))

上述代码生成以下输出:

  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 维的输入(随机噪声),并根据该输入生成一个图像。发电机型号总结如下:

generator = Generator().to(device)
summary(generator,torch.zeros(1,100))

上述代码生成以下输出:

  1. 定义一个函数来产生随机噪声,并将其注册到设备:
def noise(size):
    n = torch.randn(size, 100)
    return n.to(device)
  1. 定义一个函数来训练鉴别器:
  • 鉴别器训练函数(discriminator_train_step)将真实数据(real_data)和虚假数据(fake_data)作为输入:
def discriminator_train_step(real_data, fake_data):
  • 重置渐变:
d_optimizer.zero_grad()
  • 在对损失值进行反向传播之前,对真实数据(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()

When we calculate the discriminator loss on real data, we expect the discriminator to predict an output of 1. Hence, the discriminator loss on real data is calculated by expecting the discriminator to predict output as 1 using torch.ones during discriminator training.

  • 在对损失值进行反向传播之前,对假数据(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 来计算鉴别器对假数据的损失。

  • 更新权重并返回总损失(合计real_data上的error_realfake_data上的error_fake的损失值):
d_optimizer.step()
return error_real + error_fake
  1. 训练发电机模型:
  • 定义采用虚假数据(fake_data)的发电机训练函数(generator_train_step):
def generator_train_step(fake_data):
  • 重置生成器优化器的梯度:
g_optimizer.zero_grad()
  • 预测鉴别器对假数据的输出(fake_data):
prediction = discriminator(fake_data)
  • 通过传递prediction和期望值torch.ones来计算发电机损耗值,因为我们想在训练发电机时欺骗鉴别器输出值1:
error = loss(prediction, \
             torch.ones(len(real_data),1).to(device))
  • 执行反向传播,更新权重,并返回错误:
error.backward()
g_optimizer.step()
return error
  1. 定义模型对象、每个生成器和鉴别器的优化器以及要优化的损失函数:
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)
  1. 在不断增加的时期内运行模型:
  • 步骤 2 中获得的data_loader函数上循环 200 个历元(num_epochs):
for epoch in range(num_epochs):
    N = len(data_loader)
    for i, (images, _) in enumerate(data_loader):
  • 加载真实数据(real_data)和虚假数据,其中虚假数据(fake_data)是通过generator网络传递noise(批量大小为real_datalen(real_data)中的数据点数)得到的。注意,运行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()
  • 使用步骤 6 中定义的discriminator_train_step功能训练鉴别器:
        d_loss=discriminator_train_step(real_data, fake_data)
  • 现在我们已经训练了鉴别器,让我们在这一步中训练生成器。从噪声数据中生成一组新的假图像(fake_data),并使用步骤 6 中定义的generator_train_step训练生成器:
        fake_data=generator(noise(len(real_data))).to(device)
        g_loss = generator_train_step(fake_data)
  • 记录损失:
        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'])

鉴频器和发生器在递增时期的损耗如下:

  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)

上述代码生成以下输出:

由此可见,我们可以利用 GANs 生成逼真的图像,但仍有改进的余地。在下一节中,我们将学习使用深度卷积 GANs 来生成更真实的图像。

使用 DCGANs 生成人脸图像

在上一节中,我们学习了使用 GANs 生成图像。然而,我们已经看到卷积神经网络(CNN)与普通神经网络相比,在图像环境中表现更好。在本节中,我们将学习使用深度卷积生成对抗网络 ( DCGANs )生成图像,该网络在模型中使用卷积和池化操作。

首先,让我们了解一下我们将利用 100 个随机数生成图像的技术。我们首先将噪声转换成批量大小为 x 100 x 1 x 1 的形状。在 DCGANs 中添加额外的通道信息而不在 GAN 部分添加的原因是,我们将在本部分利用 CNN,它要求以批量 x 通道 x 高度 x 宽度的形式输入。

接下来,我们通过利用
ConvTranspose2d将生成的噪声转换成图像。

正如我们在Chapter 9中了解到的,图像分割ConvTranspose2d与卷积运算相反,它采用较小的特征图尺寸(高度 x 宽度)的输入,并使用预定义的内核尺寸、步幅和填充将其上采样到较大的尺寸。通过这种方式,我们可以逐渐将一个向量从批量大小为 x 100 x 1 x 1 的形状转换为批量大小为 x 3 x 64 x 64 的形状。这样,我们就有了一个大小为 100 的随机噪声矢量,并将其转换成一张人脸图像。

有了这样的理解,现在让我们建立一个模型来生成人脸图像:

以下代码可在本书的 GitHub 知识库的Chapter12文件夹中以Face_generation_using_DCGAN.ipynb的形式获得-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  1. 下载并提取面部图像:
!wget https://www.dropbox.com/s/rbajpdlh7efkdo1/male_female_face_images.zip
!unzip male_female_face_images.zip

这里显示了一个图像示例:

  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"
  1. 定义数据集和数据加载器:
  • 确保我们裁剪图像,以便我们只保留图像中的面部,而丢弃图像中的其他细节。首先,我们将下载级联过滤器(在第十八章、中关于 OpenCV 中级联过滤器的更多信息,使用 OpenCV 工具进行图像分析),这将有助于识别图像中的人脸:
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \
                        'haarcascade_frontalface_default.xml')
  • 创建一个新文件夹,并将所有裁剪的人脸图像转储到新文件夹中:
!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))

裁剪面的示例如下:

请注意,通过只裁剪和保留面,我们只保留了我们想要生成的信息。

  • 指定要对每个图像执行的转换:
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))])
  • 定义Faces数据集类:
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
  • 创建数据集对象-ds:
ds = Faces(folder='cropped_faces/')
  • 如下定义dataloader类:
dataloader = DataLoader(ds, batch_size=64, shuffle=True, \
                        num_workers=8)
  1. 定义权重初始化,以便权重具有较小的分布:
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)
  1. 定义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)
  • 获取已定义模型的摘要:
!pip install torch_summary
from torchsummary import summary
discriminator = Discriminator().to(device)
summary(discriminator,torch.zeros(1,3,64,64));

上述代码生成以下输出:

  1. 定义从 shape 批处理大小 x 100 x 1 x 1 的输入生成假图像的Generator模型类:
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)
  • 获取已定义模型的摘要:
generator = Generator().to(device)
summary(generator,torch.zeros(1,100,1,1))

上述代码生成以下输出:

请注意,我们已经利用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))
  1. 在不断增加的时期内运行模型:
  • 步骤 3 中定义的dataloader函数上循环 25 个时期:
log = Report(25)
for epoch in range(25):
    N = len(dataloader)
    for i, images in enumerate(dataloader):
  • 加载真实数据(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时,vanilla GANs 和 dcgan 之间的主要区别是,在 dcgan 的情况下,我们不必展平real_data,因为我们利用了 CNN。

  • 使用步骤 7 中定义的discriminator_train_step功能训练鉴别器:
d_loss=discriminator_train_step(real_data, fake_data)
  • 从噪声数据(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)
  • 记录损失:
        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'])

上述代码生成以下输出:

请注意,在这种设置下,发生器和鉴频器损耗的变化并不遵循我们在手写数字生成中看到的模式,原因如下:

  1. 我们正在处理更大的图像(与我们在上一节中看到的 28 x 28 x 1 形状的图像相比,64 x 64 x 3 形状的图像)。
  2. 与人脸图像中的特征相比,手指的变化较少。
  3. 与面部图像中的信息相比,手写数字中的信息仅在少数像素中可用。

训练过程完成后,使用以下代码生成一个图像样本:

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')

上述代码生成以下一组图像:

请注意,虽然生成器从随机噪声中生成了人脸图像,但图像还不错,但仍然不够真实。一个潜在的原因是,并非所有的输入图像都具有相同的面部对齐。作为一个练习,我们建议您只在原始图像中没有倾斜面部并且人直视摄像机的那些图像上训练 DCGAN。此外,我们建议您尝试将鉴别器得分高的图像与鉴别器得分低的图像进行对比。

在这一节中,我们学习了如何生成人脸图像。然而,我们不能指定我们感兴趣的图像的生成。在下一节中,我们将致力于生成特定类的图像。

实现有条件的 GANs

想象一个场景,我们想要生成一个我们感兴趣的类的图像;例如,猫的图像或狗的图像,或戴眼镜的人的图像。我们如何指定我们想要生成我们感兴趣的图像?在这种情况下,有条件的甘来救援。

现在,让我们假设我们只有男性和女性面孔的图像以及它们相应的标签。在本节中,我们将学习从随机噪声中生成特定感兴趣类别的图像。

我们采取的策略如下:

  1. 指定我们要生成为一次性编码版本的图像的标签。
  2. 通过嵌入层传递标签,以生成每个类的多维表示。
  3. 生成随机噪声,并与上一步生成的嵌入层连接。
  4. 训练模型就像我们在前面的部分,但这一次与噪声向量级联与嵌入的图像类,我们希望生成。

在下面的代码中,我们将编写前面的策略:

下面的代码可以在本书的 GitHub 库【https://tinyurl.com/mcvp-packtChapter12文件夹中找到Face_generation_using_Conditional_GAN.ipynb。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  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
  1. 创建数据集和数据加载器:
  • 存储男性和女性图像路径:
female_images = Glob('/content/females/*.jpg')
male_images = Glob('/content/males/*.jpg')
  • 确保我们裁剪图像,以便只保留图像中的人脸,丢弃图像中的其他细节。首先,我们将下载级联过滤器(在第十八章、中关于 OpenCV 中级联过滤器的更多信息,使用 OpenCV 工具进行图像分析),这将有助于识别图像中的人脸:
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + \
                        'haarcascade_frontalface_default.xml')
  • 创建两个新文件夹(一个对应男性图像,另一个对应女性图像),并将所有裁剪的面部图像转储到各自的文件夹中:
!mkdir cropped_faces_females
!mkdir cropped_faces_males

def crop_images(folder):
    images = Glob(folder+'/*.jpg')
    for i in range(len(images)):
        img = read(female_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')
  • 指定要对每个图像执行的转换:
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))
                           ])
  • 创建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()
  • 定义ds数据集和dataloader:
ds = Faces(folders=['cropped_faces_females', \
                    'cropped_faces_males'])
dataloader = DataLoader(ds, batch_size=64, \
                        shuffle=True, num_workers=8)
  1. 定义权重初始化方法(就像我们在 DCGAN 部分所做的那样),这样我们就不会在随机初始化的权重值之间有大范围的变化:
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)
  1. 定义Discriminator模型类:
  • 定义模型架构:
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)

注意,在模型类中,我们有一个额外的参数emb_size,它出现在条件 gan 中,而不在 DCGANs 中。emb_size表示我们将输入类标签(我们想要生成的图像的类)转换成的嵌入数,存储为label_embeddings。我们将输入类标签从一次性编码的版本转换为更高维度的嵌入的原因是,模型具有更高的学习和调整自由度来处理不同的类。

虽然模型类在很大程度上仍然与我们在 DCGANs 中看到的一样,但我们正在初始化另一个模型(model2)来进行分类练习。在我们接下来讨论forward方法之后,将有更多关于第二个模型如何帮助的内容。通过下面的forward方法和模型的总结,你也会明白self.model2有 288 个值作为输入的原因:

  • 定义将图像和图像标签作为输入的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

在定义的forward方法中,我们获取第一个模型的输出(self.model(input))和通过label_embeddings传递labels的输出,然后连接这些输出。接下来,我们通过我们之前定义的第二个模型(self.model2)传递连接的输出,该模型获取我们的鉴别器输出。

  • 获取已定义模型的摘要:
!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));

上述代码生成以下输出:

请注意,self.model2接受 288 个值的输入,因为self.model的输出每个数据点有 256 个值,然后将这些值与输入类标签的 32 个嵌入值连接起来,得到self.model2的 256 + 32 = 288 个输入值。

  1. 定义Generator网络类:
  • 定义__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)      

注意,在前面的代码中,我们使用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()
        )

注意,在前面的代码中,我们利用了nn.ConvTranspose2d来获取图像作为输出。

  • 应用权重初始化:
        self.apply(weights_init)
  • 定义将噪声值(input_noise)和输入标签(labels)作为输入并生成图像输出的forward方法:
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)
  • 获取已定义的generator功能的摘要:
generator = Generator().to(device)
summary(generator,torch.zeros(32,100,1,1).to(device), \
        torch.zeros(32).long().to(device));

上述代码生成以下输出:

  1. 定义一个函数(noise)生成 100 个值的随机噪声,并将其注册到设备:
def noise(size):
    n = torch.randn(size, 100, 1, 1, device=device)
    return n.to(device)
  1. 定义训练鉴别器的功能-discriminator_train_step:
  • 鉴别器接受四个输入——真实图像(real_data)、真实标签(real_labels)、虚假图像(fake_data)和虚假标签(fake_labels):
def discriminator_train_step(real_data, real_labels, \
                             fake_data, fake_labels):
    d_optimizer.zero_grad()

这里,我们正在重置对应于鉴别器的梯度:

  • 计算与实际数据上的预测相对应的损失值(prediction_real)。real_datareal_labels通过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()
  • 计算与假数据上的预测相对应的损失值(prediction_fake)。fake_datafake_labels通过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()
  • 更新权重并返回损失值:
d_optimizer.step()    
return error_real + error_fake
  1. 定义生成器的训练步骤,其中我们传递假图像(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. 在增加的时期(n_epochs)内训练模型:
  • 指定dataloader的长度:
log = Report(n_epochs)
for epoch in range(n_epochs):
    N = len(dataloader)
  • 遍历一批图像及其标签:
for bx, (images, labels) in enumerate(dataloader):
  • 指定real_datareal_labels:
real_data, real_labels = images.to(device), \
                        labels.to(device)
  • 初始化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()
  • 使用步骤 7 中定义的discriminator_train_step函数训练鉴频器,计算鉴频器损耗(d_loss):
d_loss = discriminator_train_step(real_data, \
                real_labels, fake_data, fake_labels)
  • 重新生成假图像(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)
  • 按如下方式记录指标:
    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)

一旦我们训练了模型,生成男性和女性图像:

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)传递给生成器,以获取fake图像,这些图像在 25 个时期的模型训练结束时如下所示:

从前面的图像中,我们可以看到,前 32 个图像对应于男性图像,而接下来的 32 个图像对应于女性图像,这证实了一个事实,即条件甘的表现符合预期。

摘要

在这一章中,我们已经学习了利用两种不同的神经网络来使用 GANs 生成新的手写数字图像。接下来,我们使用 DCGANs 生成真实的人脸。最后,我们学习了条件甘,它帮助我们生成某类图像。使用不同的技术生成图像后,我们仍然可以看到生成的图像不够真实。此外,虽然我们通过指定我们希望在条件 GANs 中生成的图像类来生成图像,但我们仍然无法执行图像转换,即我们要求用另一个对象替换图像中的一个对象,而其他内容保持不变。此外,我们还没有一个图像生成机制,其中要生成的类(样式)的数量更不受监督。

在下一章,我们将学习使用一些最新的 GANs 变体来生成更真实的图像。此外,我们将学习以一种更加无人监督的方式生成不同风格的图像。

问题

  1. 如果生成器和鉴别器模型的学习率很高会怎么样?

  2. 在生成器和鉴别器训练有素的场景中,给定图像是真实的概率是多少?

  3. 为什么我们在生成图像时使用convtranspose2d

  4. 为什么在条件 gan 中,与类的数量相比,嵌入的大小很大?

  5. 我们如何生成有胡子的男人的图像?

  6. 为什么我们在发生器的最后一层激活 Tanh,而不是 ReLU 或 Sigmoid?

  7. 为什么我们得到了真实的图像,即使我们没有对生成的数据进行反规格化?

  8. 如果我们在训练 GAN 之前不裁剪对应于图像的面部,会发生什么?

  9. 为什么训练生成器时鉴频器的权重没有更新(因为generator_train_step函数涉及鉴频器网络)?

  10. 为什么在训练鉴别器时,我们得到真实图像和虚假图像的损失,而在训练生成器时,我们只得到虚假图像的损失?

十三、用于操作图像的高级 GAN

在前一章中,我们学习了如何利用生成对抗网络 ( GANs )来生成逼真的图像。在这一章中,我们将学习如何利用 GANs 来处理图像。我们将学习使用 GANs 生成图像的两种变体——监督和非监督方法。在监督方法中,我们将提供输入和输出对组合,以基于输入图像生成图像,我们将在 Pix2Pix GAN 中了解这一点。在无监督方法中,我们将指定输入和输出,然而,我们不会提供输入和输出之间的一一对应,而是期望 GAN 学习两个类的结构,并将图像从一个类转换为另一个类,这将在 CycleGAN 中学习。

另一类无监督的图像处理涉及从随机向量的潜在空间生成图像,并查看图像如何随着潜在向量值的变化而变化,我们将在在自定义图像上利用 style gan部分了解这一点。最后,我们将了解如何利用预先训练的 GAN–SRGAN,它有助于将低分辨率图像转换为高分辨率图像。

具体来说,我们将了解以下主题:

  • 利用 Pix2Pix GAN
  • 利用循环根
  • 在自定义图像上利用 StyleGAN
  • 超分辨率氮化镓

利用 Pix2Pix GAN

想象一个场景,我们有彼此相关的图像对(例如,一个对象的边缘图像作为输入,一个对象的实际图像作为输出)。给定的挑战是我们想要在给定物体边缘的输入图像的情况下生成图像。在传统的设置中,这将是一个简单的输入到输出的映射,因此是一个监督学习问题。然而,想象一下,你正和一个创意团队一起工作,他们正试图为产品设计出一个全新的外观。在这种情况下,监督学习没有太大帮助——因为它只从历史中学习。GAN 在这里很方便,因为它将确保生成的图像看起来足够真实,并为实验留下空间(因为我们有兴趣检查生成的图像是否像感兴趣的类之一)。

在本节中,我们将学习从手绘的鞋子轮廓生成鞋子图像的架构。我们将采用以下策略从涂鸦中生成逼真的图像:

  1. 获取大量实际图像,并使用标准 cv2 边缘检测技术创建相应的轮廓。
  2. 从原始图像的补丁中采样颜色,以便生成器知道要生成的颜色。
  3. 构建一个 UNet 架构,将带有样本补丁颜色的轮廓作为输入,并预测相应的图像-这是我们的生成器。
  4. 建立一个鉴别器架构,它可以拍摄图像并预测图像是真是假。
  5. 将发生器和鉴别器一起训练到发生器可以生成欺骗鉴别器的图像的程度。

让我们编码策略:

以下代码在本书的 GitHub 知识库的Chapter13文件夹中以Pix2Pix_GAN.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  1. 导入数据集并安装相关包:
try:
    !wget https://bit.ly/3kiuN93
    !mv 3kiuN93 ShoeV2.zip
    !unzip ShoeV2.zip
    !unzip ShoeV2_F/ShoeV2_photo.zip
except:
    !wget https://www.dropbox.com/s/g6b6gtvmdu0h77x/ShoeV2_photo.zip
!pip install torch_snippets
from torch_snippets import *
device = 'cuda' if torch.cuda.is_available() else 'cpu'

前面的代码下载鞋子的图像。下载图像的示例如下:

对于我们的问题,我们想画出鞋子的轮廓(边缘)和鞋子的样本补丁颜色。在下一步中,我们将获取给定鞋子图像的边缘。这样,我们可以训练一个模型,在给定鞋子的轮廓和样本补丁颜色的情况下,重建鞋子的图像。

  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 方法如何工作的更多细节,请参见第十八章、中的使用 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
  1. 定义数据集类(ShoesData)。这个数据集类返回原始图像和带边缘的图像。我们将传递给网络的另一个细节是随机选择的区域中的颜色块。通过这种方式,我们使用户能够获得手绘轮廓图像,在图像的不同部分喷洒所需的颜色,并生成新的图像。此处显示了输入(第三幅图像)和输出(第一幅图像)的示例(彩色效果最佳):

然而,我们在步骤 1 中得到的输入图像只是鞋子的图像(第一幅图像),我们将用它来提取鞋子的边缘(第二幅图像)。此外,我们将在下一步中使用颜色来获取前面图像的输入(第三个图像)-输出(第一个图像)组合。

在下面的代码中,我们将构建一个类,该类获取轮廓图像,散布颜色,并返回一对散布了颜色的图像和原始的鞋子图像(生成轮廓的图像):

  • 定义ShoesData类、__init__方法和__len__方法:
class ShoesData(Dataset):
    def __init__(self, items):
        self.items = items
    def __len__(self): return len(self.items)
  • 定义__getitem__方法。在这种方法中,我们将处理输入图像以获取具有边缘的图像,然后用原始图像中存在的颜色来点缀图像。这里,我们获取给定图像的边缘:
    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)
  • 一旦我们获取了图像的边缘,调整图像的大小并使其正常化:
        im, edges = resize(im, IMAGE_SIZE), \
                    resize(edges, IMAGE_SIZE)
        im, edges = normalize(im), normalize(edges)
  • edges图像和preprocess原始图像和edges图像上色:
        self._draw_color_circles_on_src_img(edges, im)
        im, edges = preprocess(im), preprocess(edges)
        return edges, im
  • 定义喷洒颜色的功能:
    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))]
  1. 定义训练和验证数据对应的数据集和数据加载器:
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)
  1. 定义生成器和鉴别器架构,它们利用权重初始化(weights_init_normal)、UNetDownUNetUp架构,正如我们在第九章、图像分割和第十章、目标检测和分割应用中所做的那样,来定义GeneratorUNetDiscriminator架构。
  • 初始化权重,使其遵循正态分布:
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)
  • 定义UNetwDownUNetUp类:
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
  • 定义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)
  • 定义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)
  1. 定义generatordiscriminator模型对象并提取摘要:
generator = GeneratorUNet().to(device)
discriminator = Discriminator().to(device)
!pip install torch_summary
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)))

发电机架构总结如下:

鉴别器架构概述如下:

  1. 定义训练鉴别器的函数(discriminator_train_step):
  • 鉴别器函数将源图像(real_src)、真实目标(real_trg)和虚假目标(fake_trg)作为输入:
def discriminator_train_step(real_src, real_trg, fake_trg):
    d_optimizer.zero_grad()
  • 通过比较真实目标(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()
  • 计算假图像(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()
  • 执行优化器步骤,并返回预测的真实和虚假目标的总体错误和损失值:
    d_optimizer.step()
    return error_real + error_fake
  1. 定义训练生成器(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):

  • 定义一个函数来获取预测样本:
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)
  1. 将权重初始化(weights_init_normal)应用于发生器和鉴别器模型对象:
generator.apply(weights_init_normal)
discriminator.apply(weights_init_normal)
  1. 指定损失标准和优化方法(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))
  1. 训练模型超过 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)]
  1. 在样本手绘轮廓上生成:
[sample_prediction() for _ in range(2)]

上述代码生成以下输出:

请注意,在前面的输出中,我们生成了与原始图像颜色相似的图像。

在本节中,我们学习了如何利用图像的轮廓来生成图像。然而,这要求我们成对地提供输入和输出,这有时会是一个繁琐的过程。在下一节中,我们将了解不成对的图像转换,在这种情况下,无需我们指定图像的输入和输出映射,网络就能计算出转换。

利用循环根

想象一个场景,我们要求您执行从一个类到另一个类的图像转换,但是没有给出输入和相应的输出图像来训练模型。然而,我们在两个不同的文件夹中给你两个类的图像。在这种情况下,CycleGAN 就派上了用场。

在本节中,我们将学习如何训练 CycleGAN 将苹果的图像转换为橙子的图像,反之亦然。CycleGAN 中的循环指的是我们把一个图像从一个类翻译(转换)到另一个类,再回到原来的类。

概括地说,在此架构中,我们将有三个独立的损耗值(此处提供了更多详细信息):

  • 鉴别器损失:这确保在训练模型时修改对象类(如前一节所示)。
  • 循环损耗:将一幅图像从生成的图像循环到原始图像,以保证周围像素不被改变的损耗。
  • 身份损失:当一个类别的图像通过一个生成器时的损失,该生成器预期将另一个类别的图像转换成输入图像的类别。

在这里,我们将从较高的层面了解构建 CycleGAN 的步骤:

  1. 导入和预处理数据集
  2. 构建发生器和鉴别器网络 UNet 架构
  3. 定义两个生成器:
  • G_AB :将 A 类图像转换为 B 类图像的生成器
  • G_BA :将 B 类图像转换为 A 类图像的生成器
  1. 定义身份丧失:
  • 如果你要发送一个橙色图像给一个橙色生成器,理想情况下,如果生成器已经理解了关于橙色的一切,它不应该改变图像,应该“生成”完全相同的图像。因此,我们利用这些知识创造了一个身份。
  • 当类别 A (real_A)的图像通过 G_BA 并与 real_A 比较时,身份损失应该是最小的。
  • 当类 B (real_B)的图像通过 G_AB 并与 real_B 比较时,身份损失应该是最小的。
  1. 定义 GAN 损耗:
  • real_A 和 fake_A 的鉴频器和发生器损耗(当 real_B 图像通过 G_BA 时获得 fake_A)
  • real_B 和 fake_B 的鉴频器和发生器损耗(当 real_A 图像通过 G_AB 时获得 fake_B)
  1. 定义 re- 周期 损耗:
  • 考虑一个场景,其中一个苹果的图像将被一个橙子生成器转换成一个假橙子,而假橙子将被苹果生成器转换回一个苹果。
  • fake_B,是 real_A 通过 G_AB 时的输出,fake_B 通过 G_BA 时应该会重新生成 real_A。
  • fake_A,是 real_B 通过 G_BA 时的输出,fake_A 通过 G_AB 时应该会重新生成 real_B。
  1. 针对三种损失的加权损失进行优化。

现在我们已经了解了这些步骤,让我们对它们进行编码,以便将苹果转换成橙子,反之亦然,如下所示:

以下代码在本书的 GitHub 知识库的Chapter13文件夹中以CycleGAN.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行笔记本以重现结果,同时理解执行的步骤和文本中各种代码组件的解释。

  1. 导入相关数据集和包:
  • 下载并提取数据集:
!wget https://www.dropbox.com/s/2xltmolfbfharri/apples_oranges.zip
!unzip apples_oranges.zip

我们将要处理的图像样本:

请注意,苹果和橙色图像之间没有一对一的对应关系(不像我们在利用 Pix2Pix GAN 部分了解的轮廓到鞋子生成用例)。

  • 导入所需的包:
!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
  1. 定义图像转换管道(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)),
])
  1. 定义 dataset 类(CycleGANDataset),以appleorange文件夹(解压下载的数据集后得到)为输入,提供一批苹果和橙子的图片:
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)
  1. 定义训练和验证数据集以及数据加载器:
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)
  1. 按照前面章节的定义,定义网络的权重初始化方法(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)
  1. 定义剩余块网络(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)
  1. 定义发电机网络(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)
  1. 定义鉴别器网络(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)
  • 定义生成图像样本的函数-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)
  1. 定义训练发电机的功能(generator_train_step):
  • 该函数以两个发电机模型(G_AB 和 G_BA 为Gs)、optimizer和两个类的实像——real_Areal_B——作为输入:
def generator_train_step(Gs, optimizer, real_A, real_B):
  • 指定生成器:
    G_AB, G_BA = Gs
  • 将优化器的梯度设置为零:
    optimizer.zero_grad()
  • 如果你要发送一个橙色图像到一个橙色生成器,理想情况下,如果生成器已经理解了关于橙色的一切,它不应该对图像做任何改变,应该“生成”精确的图像。因此,我们利用这些知识创造了一个身份。对应于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
  • 当图像通过生成器时,计算 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
  • 计算循环损耗。考虑一个场景,其中一个苹果的图像将被一个橙子生成器转换成一个假橙子,并且这样一个假橙子将被苹果生成器转换回苹果。如果发生器是完美的,这个过程应该返回原始图像,这意味着以下循环损耗应该为零:
    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
  • 计算总损耗,并在返回计算值之前执行反向传播:
    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
  1. 定义训练鉴别器的函数(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
  1. 定义生成器、鉴别器对象、优化器和损失函数:
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
  1. 在越来越多的时期训练网络:
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)
  1. 训练完模型后生成图像:
generate_sample()

上述代码生成以下输出:

从前面的例子中,我们可以看到,我们成功地将苹果转换为橙子(前两行),将橙子转换为苹果(后两行)。

到目前为止,我们已经了解了通过 Pix2Pix GAN 的成对图像到图像转换和通过 CycleGAN 的不成对图像到图像转换。在下一节中,我们将了解如何利用 StyleGAN 将一种样式的图像转换成另一种样式的图像。

在自定义图像上利用 StyleGAN

让我们首先了解在 StyleGAN 发明之前的一些历史发展。正如我们所知,从上一章生成假面涉及到了 GANs 的使用。研究面临的最大问题是可以生成的图像很小(通常为 64 x 64)。任何产生较大尺寸图像的努力都会导致生成器或鉴别器陷入局部极小值,从而停止训练并产生乱码。生成高质量图像的重大飞跃之一涉及一篇名为 ProGAN(Progressive GAN 的缩写)的研究论文,其中涉及一个聪明的技巧。

发生器和鉴别器的尺寸都逐渐增大。在第一步中,创建一个生成器和鉴别器,从潜在向量生成 4 x 4 图像。在此之后,附加卷积(和放大)层被添加到经过训练的生成器和鉴别器,其将负责接受 4×4 图像(在步骤 1 中从潜在向量生成)并生成/鉴别 8×8 图像。一旦这个步骤也完成了,新的层在发生器和鉴别器中再次被创建,被训练以生成更大的图像。图像大小以这种方式一步一步(渐进地)增加。其逻辑是,向已经运行良好的网络添加新层比从头开始学习所有层更容易。通过这种方式,图像被放大到 1024×1024 像素的分辨率(图像来源:arxiv.org/pdf/1710.10196v3.pdf):

尽管它取得了很大的成功,但控制生成图像的各个方面(如性别和年龄)却相当困难,这主要是因为网络只能获得一个输入(在前面的图像中:潜伏在网络的顶部)。StyleGAN 解决了这个问题。

StyleGAN 使用了一种类似的训练方案,图像是逐步生成的,但每次网络增长时都会增加一组潜在输入。这意味着该网络现在接受多个潜在向量在定期间隔的图像大小生成。在生成阶段给出的每一个潜势决定了在那个网络阶段将要生成的特征(风格)。让我们在这里更详细地讨论 StyleGAN 的工作细节:

在上图中,我们可以对比生成图像的传统方式和基于样式的生成器。在传统的发电机中,只有一个输入。但是,在基于样式的生成器中有一种机制。下面我们来了解一下细节:

  1. 创建一个大小为 1 x 512 的随机噪声向量 z

  2. 将此馈送到一个称为样式网络(或映射网络)的辅助网络,该网络创建一个大小为 18 x 512 的张量 w

  3. 发生器(合成)网络包含 18 个卷积层。每层将接受以下内容作为输入:

    • 对应行的w(‘A’)
    • 随机噪声向量(“B”)
    • 前一层的输出

请注意,噪声(‘B’)仅用于正则化目的。

前面的三个组合将创建一个接收 1 x 512 矢量的管道,并创建一个 1024 x 1024 的图像。

现在,让我们了解从映射网络生成的 18×512 向量中的 18 个 1×512 向量中的每一个如何对图像的生成做出贡献。在合成网络的前几层添加的 1 x 512 矢量有助于图像中存在的整体姿势和大比例特征,如姿势、脸型等(因为它们负责生成 4 x 4、8 x 8 图像等,这些是将在后面的层中进一步增强的前几个图像)。中间层中添加的矢量对应于小比例特征,例如发型、眼睛睁开/闭上(因为它们负责生成 16 x 16、32 x 32 和 64 x 64 的图像)。最后几层添加的矢量对应于图像的配色方案和其他微结构。当我们到达最后几层时,图像结构被保留,面部特征被保留但只有图像级细节如光照条件被改变。

在本节中,我们将利用预先训练的 StyleGAN2 模型来定制我们感兴趣的图像,使其具有不同的风格。

对于我们的目标,我们将使用 StyleGAN2 模型执行样式转换。概括地说,下面是 faces 上的样式转换是如何工作的(下面的内容在您浏览代码的结果时会更清楚):

  • 假设 w [1] 样式向量用于生成 face-1,w [2] 样式向量用于生成 face-2。两个都是 18 x 512。

  • w [2] 中 18 个矢量的前几个(负责生成 4×4 到 8×8 分辨率的图像)被替换为 w [1] 中的相应矢量。然后,我们转移非常粗糙的特征,例如从面 1 到面 2 的姿态。

  • 如果在 w [2] 中用 w [1] 中的样式向量替换后面的样式向量(比如 18 x 512 的第三个到第十五个——它们负责生成 64 x 64 到 256 x 256 维的一批图像),那么我们就转移眼睛、鼻子和其他面部中级特征。

  • 如果最后几个风格向量(其负责生成 512 x 512 到 1024 x 1024 维的一批图像)被替换,则精细级别的特征如肤色和背景(其不会以显著的方式影响整个面部)被转移。

了解了如何进行样式转换后,现在让我们了解如何使用 StyleGAN2 在自定义图像上执行样式转换:

  1. 拍摄自定义图像。
  2. 对齐自定义图像,以便仅存储图像的面部区域。
  3. 获取可能生成自定义校准图像的潜在向量。
  4. 通过将随机潜在向量(1 x 512)传递到映射网络来生成图像。

到这一步,我们有两个图像——我们定制的对齐图像和由 StyleGAN2 网络生成的图像。我们现在想把自定义图像的一些特性转移到生成的图像上,反之亦然。

让我们编写前面的策略。

请注意,我们正在利用从 GitHub 存储库中获取的预训练网络,因为训练这样的网络需要几天甚至几周的时间:

您需要一个支持 CUDA 的环境来运行下面的代码。以下代码在本书的 GitHub 知识库的Chapter13文件夹中以Customizing_StyleGAN2.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  1. 克隆存储库,安装需求,并获取预先训练好的权重:
import os
if not os.path.exists('pytorch_stylegan_encoder'):
    !git clone https://github.com/jacobhallberg/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 *
  1. 加载预训练的生成器和合成网络,映射网络的权重:
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
  1. 定义从随机向量生成图像的函数:
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
  1. 生成随机向量:
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)

上述代码生成以下输出:

到目前为止,我们已经生成了一个图像。在接下来的几行代码中,您将了解如何在前面生成的图像和您选择的图像之间执行样式转换。

  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
  1. 对齐自定义图像:
!python stylegan-encoder/align_images.py \
stylegan-encoder/raw_img/ \
stylegan-encoder/aligned_img/
!mv stylegan-encoder/aligned_img/* ./MyImage.jpg
  1. 使用校准图像生成能够完美再现校准图像的潜影。这是识别潜在向量组合的过程,该潜在向量组合使对准图像和从潜在向量生成的图像之间的差异最小化:
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')

上述代码生成以下输出:

Python 脚本encode_image.py在较高层次上执行以下操作:

  1. 空间创建一个随机向量。

  2. 用这个向量合成一个图像。

  3. 使用 VGG 感知损失(与神经风格转换中使用的损失相同)将合成图像与原始输入图像进行比较。

  4. 随机向量进行反向传播,以减少固定迭代次数的损失。

  5. 优化的矢量现在将合成一幅图像,VGG 为该图像给出与输入图像几乎相同的特征,因此合成的图像将看起来与输入图像相似。

现在我们有了对应于感兴趣图像的潜在向量,让我们在下一步执行图像之间的风格转换。

  1. 执行风格转换:

如前所述,风格迁移背后的核心逻辑实际上是部分风格张量的转移,即 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')

前面的代码生成了以下内容:

下面是用idxs_to_swap分别作为slice(4,15)slice (15,18)的输出。

  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))

下面是生成的图像:

总之,我们已经了解了在使用 GANs 生成高分辨率人脸图像方面的研究进展。诀窍是在增加分辨率的步骤中增加发生器和鉴别器的复杂性,以便在每一步中,两个模型都能很好地完成任务。我们了解了如何通过确保每个分辨率下的特征都由一个称为样式向量的独立输入来决定,从而操纵生成图像的样式。我们还学习了如何通过从一个图像到另一个图像交换样式来操作不同图像的样式。

现在,我们已经了解了如何利用预训练的 StyleGAN2 模型来执行风格转换,在下一节中,我们将利用预训练的超分辨率 GAN 模型来生成高分辨率图像。

超分辨率氮化镓

在上一节中,我们看到了一个场景,其中我们利用预先训练好的 StyleGAN 来生成给定样式的图像。在本节中,我们将更进一步,了解如何利用预先训练的模型来执行图像超分辨率。在将超分辨率 GAN 模型应用于图像之前,我们将对其架构有所了解。

首先,我们将理解 GAN 是超分辨率任务的良好解决方案的原因。想象这样一个场景,给你一张图片,要求你提高它的分辨率。直觉上,你会考虑各种插值技术来执行超分辨率。这里有一个低分辨率图像样本以及各种技术的输出(图像来源:arxiv.org/pdf/1609.04802.pdf):

从前面的图像中,我们可以看到,当从低分辨率(原始图像的 4X 缩小图像)重建图像时,双三次插值等传统插值技术没有多大帮助。

虽然基于 ResNet 的超分辨率 UNet 在这种情况下可以派上用场,但 GANs 可能更有用,因为它们模拟人类的感知。假设鉴别器知道典型的超分辨率图像看起来是什么样的,那么它可以检测生成的图像具有不一定看起来像高分辨率图像的属性的情况。

确定了超分辨率对 GANs 的需求后,让我们了解并利用预训练模型。

架构

虽然从头开始编码和训练超分辨率 GAN 是可能的,但我们将尽可能利用预先训练的模型。因此,在本节中,我们将利用 Christian Ledig 及其团队开发的模型,该模型发表在题为使用生成式对抗网络的照片级单图像超分辨率的论文中。

SRGAN 的架构如下(图片来源:arxiv.org/pdf/1609.04802.pdf):

从前面的图像中,我们看到鉴别器将高分辨率图像作为输入来训练预测图像是高分辨率图像还是低分辨率图像的模型。生成器网络将低分辨率图像作为输入,并得出高分辨率图像。在训练模型时,内容损失和敌对损失都被最小化。要详细了解模型训练的细节,并比较用于生成高分辨率图像的各种技术的结果,我们建议您通读本文。

通过对模型构建方式的高级理解,我们将编码利用预训练的 SRGAN 模型将低分辨率图像转换为高分辨率图像的方法。

编码 SRGAN

以下是加载预训练的 SRGAN 并进行预测的步骤:

以下代码可在本书的 GitHub 知识库的Chapter 13文件夹中以Image super resolution using SRGAN.ipynb的名称获得-【https://tinyurl.com/mcvp-packt 代码包含了下载数据的 URL。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  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'
  1. 加载模型:
model = torch.load('srgan.pth.tar', map_location='cpu')['generator'].to(device)
model.eval()
  1. 获取要转换为高分辨率的图像:
!wget https://www.dropbox.com/s/nmzwu68nrl9j0lf/Hema6.JPG
  1. 定义图像的preprocesspostprocess函数:
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()
            ])
  1. 加载图像并对其进行预处理:
image = readPIL('Hema6.JPG')
image.size
# (260,181)
image = image.resize((130,90))
im = preprocess(image)

请注意,在前面的代码中,我们对原始图像执行了额外的调整大小,以进一步模糊图像,但这只是为了说明,因为当我们缩小图像时,改进会更加明显。

  1. 将预处理后的图像通过加载的modelpostprocess模型输出:
sr = model(im[None])[0]
sr = postprocess(sr)
  1. 绘制原始图像和高分辨率图像:
subplots([image, sr], nc=2, figsize=(10,10), \
         titles=['Original image','High resolution image'])

上述代码会产生以下输出:

从前面的图像中,我们可以看到高分辨率图像捕捉到了原始图像中模糊的细节。

请注意,如果原始图像模糊,则原始图像和高分辨率图像之间的对比度会很高。但是,如果原始图像没有模糊,对比度就不会那么高。我们鼓励您使用不同分辨率的图像。

摘要

在本章中,我们学习了如何使用 Pix2Pix GAN 从给定的轮廓生成图像。此外,我们还学习了 CycleGAN 中的各种损失函数,用于将一类图像转换为另一类图像。接下来,我们了解了 StyleGAN 如何帮助生成逼真的人脸,以及如何根据生成器的训练方式将样式从一个图像复制到另一个图像。最后,我们了解了如何利用预训练的 SRGAN 模型来生成高分辨率图像。

在下一章中,我们将转而学习基于非常少(通常少于 20 张)的图像来训练图像分类模型。

问题

  1. 为什么我们需要 Pix2Pix GAN,而 UNet 等监督学习算法可以从轮廓生成图像?
  2. 为什么我们需要在 CycleGAN 中针对三种不同的损失函数进行优化?
  3. ProgressiveGAN 中的技巧如何帮助构建 StyleGAN?
  4. 我们如何识别对应于给定自定义图像的潜在向量?

第四部分:使用其它技术的计算机视觉

在这最后一节中,我们将学习如何将计算机视觉技术与其他领域的技术相结合,如 NLP、强化学习和 OpenCV 等工具,以提出解决传统问题的新方法。

本节包括以下章节:

  • 第十四章,用最少的数据点训练
  • 第十五章,结合计算机视觉和自然语言处理技术
  • 第十六章,结合计算机视觉和强化学习
  • 第十七章,将一个模型转移到生产
  • 第十八章,使用 OpenCV 工具进行图像分析

十四、将最小数据点用于训练

到目前为止,在前面的章节中,我们已经学习了如何分类图像,每个类别都有成百上千的示例图像要训练。在这一章中,我们将学习各种有助于图像分类的技术,即使每堂课只有很少的训练样本。我们将从训练一个模型来预测一个类别开始,即使在训练期间对应于该类别的图像不存在。接下来,我们将转到一个场景,在这个场景中,我们试图预测的类在训练期间只出现几个图像。我们将编码暹罗网络,这属于少数镜头学习的范畴,并了解关系网络和原型网络的工作细节。

我们将在本章中了解以下主题:

  • 实现零样本学习
  • 实现少样本学习

实现零样本学习

想象一个场景,我让你预测图像中的对象类别,而你以前没有见过该对象类别的图像。在这种情况下,你如何做出预测?

直觉上,我们求助于图像中对象的属性,然后尝试识别最匹配属性的对象。

在一个这样的场景中,我们必须自动提出属性(属性不是为了训练而给出的),我们利用词向量。词向量包含词之间的语义相似性。例如,所有的动物都有相似的单词向量,而汽车有非常不同的单词向量表示。虽然单词向量的生成超出了本书的范围,但我们将研究预先训练好的单词向量。在非常高的水平上,具有相似周围单词(上下文)的单词将具有相似的向量。以下是单词向量的 t-SNE 表示法示例:

从前面的示例中,我们可以看到单词 vectors of automobiles 位于图表的左侧,而对应于动物的 vectors 位于右侧。此外,相似的动物也有相似的媒介。

这给了我们直觉,文字,就像图像一样,也有帮助获得相似性的矢量嵌入。

在下一节中,当我们编写零触发学习时,我们将利用这一现象来识别模型在训练期间看不到的类。本质上,我们将学习直接将图像特征映射到单词特征。

编码零触发学习

我们在编码零炮学习时采用的高级策略如下:

  1. 导入数据集——数据集由图像及其相应的类组成。
  2. 从预先训练的词向量模型中取出对应于每个类别的词向量。
  3. 通过预先训练的图像模型(如 VGG16)传递图像。
  4. 我们期望网络预测图像中物体对应的词向量。
  5. 一旦我们训练了模型,我们就可以预测新图像上的单词向量。
  6. 最接近预测单词向量的单词向量的类别是图像的类别。

让我们将前面的策略编码如下:

以下代码在本书的 GitHub 资源库的Chapter14文件夹中以Zero_shot_learning.ipynb的形式提供-【https://tinyurl.com/mcvp-packt 确保从 GitHub 的笔记本中复制 URL,以避免在复制结果时出现任何问题

  1. 克隆包含本练习数据集的 GitHub 存储库,并导入相关的包:
!git clone https://github.com/sizhky/zero-shot-learning/
!pip install -Uq torch_snippets
%cd zero-shot-learning/src
import gzip, _pickle as cPickle
from torch_snippets import *
from sklearn.preprocessing import LabelEncoder, normalize
device = 'cuda' if torch.cuda.is_available() else 'cpu'
  1. 定义特征数据的路径(DATAPATH)以及 word2vec 嵌入(WORD2VECPATH):
WORD2VECPATH = "../data/class_vectors.npy"
DATAPATH = "../data/zeroshot_data.pkl"
  1. 提取可用类的列表:
with open('train_classes.txt', 'r') as infile:
    train_classes = [str.strip(line) for line in infile]
  1. 加载特征向量数据:
with gzip.GzipFile(DATAPATH, 'rb') as infile:
    data = cPickle.load(infile)
  1. 定义训练数据和属于零触发类(训练期间不存在的类)的数据。注意,我们将仅显示属于训练类的类,并隐藏零样本模型类,直到推断时间:
training_data = [instance for instance in data if \
                instance[0] in train_classes]
zero_shot_data = [instance for instance in data if \
                instance[0] not in train_classes]
np.random.shuffle(training_data)
  1. 为每个类提取 300 个训练图像用于训练,并且为验证提取剩余的训练类图像:
train_size = 300 # per class
train_data, valid_data = [], []
for class_label in train_classes:
    ctr = 0
    for instance in training_data:
        if instance[0] == class_label:
            if ctr < train_size:
                train_data.append(instance)
                ctr+=1
            else:
                valid_data.append(instance)
  1. 混洗训练和验证数据,并将对应于类别的向量提取到字典中-vectors:
np.random.shuffle(train_data)
np.random.shuffle(valid_data)
vectors = {i:j for i,j in np.load(WORD2VECPATH, \
                                allow_pickle=True)}
  1. 获取用于训练和验证数据的图像和文字嵌入特征:
train_data=[(feat,vectors[clss]) for clss,feat in train_data]
valid_data=[(feat,vectors[clss]) for clss,feat in valid_data]
  1. 获取培训、验证和零触发类:
train_clss = [clss for clss,feat in train_data]
valid_clss = [clss for clss,feat in valid_data]
zero_shot_clss = [clss for clss,feat in zero_shot_data]
  1. 定义训练数据、验证数据和零炮数据的输入和输出数组:
x_train, y_train = zip(*train_data)
x_train, y_train = np.squeeze(np.asarray(x_train)), \
                    np.squeeze(np.asarray(y_train))
x_train = normalize(x_train, norm='l2')

x_valid, y_valid = zip(*valid_data)
x_valid, y_valid = np.squeeze(np.asarray(x_valid)), \
                    np.squeeze(np.asarray(y_valid))
x_valid = normalize(x_valid, norm='l2')

y_zsl, x_zsl = zip(*zero_shot_data)
x_zsl, y_zsl = np.squeeze(np.asarray(x_zsl)), \
                np.squeeze(np.asarray(y_zsl))
x_zsl = normalize(x_zsl, norm='l2')
  1. 定义训练和验证数据集以及数据加载器:
from torch.utils.data import TensorDataset

trn_ds = TensorDataset(*[torch.Tensor(t).to(device) for t in \
                         [x_train, y_train]])
val_ds = TensorDataset(*[torch.Tensor(t).to(device) for t in \
                         [x_valid, y_valid]])
trn_dl = DataLoader(trn_ds, batch_size=32, shuffle=True)
val_dl = DataLoader(val_ds, batch_size=32, shuffle=False)
  1. 构建一个模型,将 4096 维特征作为输入,预测 300 维向量作为输出:
def build_model(): 
    return nn.Sequential(
        nn.Linear(4096, 1024), nn.ReLU(inplace=True),
        nn.BatchNorm1d(1024), nn.Dropout(0.8),
        nn.Linear(1024, 512), nn.ReLU(inplace=True),
        nn.BatchNorm1d(512), nn.Dropout(0.8),
        nn.Linear(512, 256), nn.ReLU(inplace=True),
        nn.BatchNorm1d(256), nn.Dropout(0.8),
        nn.Linear(256, 300)
    )
  1. 定义对一批数据进行训练和验证的函数:
def train_batch(model, data, optimizer, criterion):
    model.train()
    ims, labels = data
    _preds = model(ims)
    optimizer.zero_grad()
    loss = criterion(_preds, labels)
    loss.backward()
    optimizer.step()
    return loss.item()

@torch.no_grad()
def validate_batch(model, data, criterion):
    model.eval()
    ims, labels = data
    _preds = model(ims)
    loss = criterion(_preds, labels)
    return loss.item()
  1. 在不断增加的时期内训练模型:
model = build_model().to(device)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
n_epochs = 60

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')

    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')        

    if not (ex+1)%10: log.report_avgs(ex+1)

log.plot_epochs(log=True)

上述代码会产生以下输出:

  1. 对包含零拍摄类(模型未见过的类)的图像(x_zsl)进行预测,并获取对应于所有可用类的实际特征(vectors)和classnames:
pred_zsl = model(torch.Tensor(x_zsl).to(device)).cpu()\
                                    .detach().numpy()
class_vectors = sorted(np.load(WORD2VECPATH, \
                allow_pickle=True), key=lambda x: x[0])
classnames, vectors = zip(*class_vectors)
classnames = list(classnames)

vectors = np.array(vectors)
  1. 计算每个预测向量与对应于可用类的向量之间的距离,并测量出现在前五个预测中的零炮类的数量:
dists = (pred_zsl[None] - vectors[:,None])
dists = (dists**2).sum(-1).T

best_classes = []
for item in dists:
    best_classes.append([classnames[j] for j in \
                         np.argsort(item)[:5]])

np.mean([i in J for i,J in zip(zero_shot_clss, best_classes)])

从前面可以看出,在模型的前 5 个预测中,我们可以正确预测大约 73%的图像,这些图像包含的对象的类别在训练期间不存在。请注意,对于前 1、2 和 3 个预测,正确分类图像的百分比分别为 6%、14%和 40%。

现在,我们已经看到了在通过零镜头分类进行训练时不存在某个类别的图像时处理预测的场景,在下一节中,我们将了解如何在训练集中只有几个某个类别的示例时构建模型来预测图像中的对象类别。

实现少样本学习

想象一下这样一个场景,我们只给你一个人的 10 张照片,并要求你辨别一张新照片是否是同一个人。作为人类,我们可以轻松地对这些任务进行分类。然而,到目前为止,我们学习的基于深度学习的算法需要数百/数千个标记的例子才能准确分类。

元学习范式中的多种算法可以方便地解决这种情况。在这一节中,我们将学习致力于解决少图像问题的连体网络、原型网络和关系匹配网络。

这三种算法都旨在学习比较两幅图像,以得出图像相似程度的分数。

下面是一个在少镜头分类过程中会发生什么的示例:

在前面的代表性数据集中,我们在训练时向网络显示了每个类的一些图像,并要求它根据这些图像预测新图像的类。

到目前为止,我们一直在使用预先训练好的模型来解决这类问题。然而,考虑到可用的数据量很少,这样的模型很可能很快就会过度拟合。

您可以利用多种指标、模型和基于优化的架构来解决这种情况。在这一章中,我们将了解基于度量的架构,这些架构提出了一个最佳度量,或者是欧几里德距离,或者是余弦相似度,将相似的图像分组在一起,然后对新图像进行预测。

N-shot k-class 分类是指 k 个类别各有 N 个图像来训练网络。

在接下来的章节中,我们将了解工作细节和代码连体网络,以及原型和关系网络的工作细节。

建立一个暹罗网络

这里,它是我们的两个图像(一个参考图像和查询图像)通过的网络。让我们来了解暹罗网络的工作细节,以及它们如何帮助识别只有几幅图像的同一个人的图像。首先,让我们大致了解一下暹罗网络的工作原理:

我们经历以下步骤:

  1. 通过卷积网络传递图像。
  2. 将另一幅图像通过与步骤 1 相同的神经网络。
  3. 计算两幅图像的编码(特征)。
  4. 计算两个特征向量之间的差。
  5. 通过 sigmoid 激活传递差向量,表示两幅图像是否相似。

在前面的架构中,单词 Siamese 与通过双网络传递两个图像(其中我们复制网络来处理两个图像)以获取两个图像中每一个的图像编码有关。此外,我们正在比较两幅图像的编码,以获取两幅图像的相似性得分。如果相似性得分(或不相似性得分)超过阈值,我们认为图像是同一个人的。

有了这个策略,让我们对暹罗网络进行编码,以预测与图像对应的类别——其中图像类别在训练数据中只出现了几次。

编码连体网络

在这一节中,我们将学习编码暹罗网络来预测一个人的图像是否与我们数据库中的参考图像相匹配。

我们采用的高级策略如下:

  1. 获取数据集。
  2. 以这样一种方式创建数据,即同一个人的两个图像的不相似性将较低,而当两个图像是不同的人时,不相似性较高。
  3. 构建一个卷积神经网络 ( CNN )。
  4. 我们期望 CNN 对损失值求和,这两个损失值对应于图像是同一个人时的分类损失,以及两幅图像之间的距离。我们在这个练习中使用对比损失。
  5. 在不断增加的时期内训练模型。

让我们对前面的策略进行编码:

The following code is available as Siamese_networks.ipynb in the Chapter14 folder in this book's GitHub repository - tinyurl.com/mcvp-packt Be sure to copy the URL from the notebook in GitHub to avoid any issue while reproducing the results

  1. 导入相关的包和数据集:
!pip install torch_snippets
from torch_snippets import *
!wget https://www.dropbox.com/s/ua1rr8btkmpqjxh/face-detection.zip
!unzip face-detection.zip
device = 'cuda' if torch.cuda.is_available() else 'cpu'

训练数据包括 38 个文件夹(每个文件夹对应于不同的人),并且每个文件夹包含该人的 10 个样本图像。测试数据包括 3 个不同人物的 3 个文件夹,每个文件夹有 10 幅图像。

  1. 定义数据集类-SiameseNetworkDataset:
  • __init__方法将包含图像的folder和要执行的变换(transform)作为输入:
class SiameseNetworkDataset(Dataset):
    def __init__(self, folder, transform=None, \
                 should_invert=True):
        self.folder = folder
        self.items = Glob(f'{self.folder}/*/*') 
        self.transform = transform
  • 定义__getitem__方法:
    def __getitem__(self, ix):
        itemA = self.items[ix]
        person = fname(parent(itemA))
        same_person = randint(2)
        if same_person:
            itemB = choose(Glob(f'{self.folder}/{person}/*', \
                                silent=True))
        else:
            while True:
                itemB = choose(self.items)
                if person != fname(parent(itemB)):
                    break
        imgA = read(itemA)
        imgB = read(itemB)
        if self.transform:
            imgA = self.transform(imgA)
            imgB = self.transform(imgB)
        return imgA, imgB, np.array([1-same_person])

在前面的代码中,我们获取了两幅图像— imgAimgB,如果是同一个人,则返回第三个输出 0,如果不是,则返回 1。

  • 定义__len__方法:
    def __len__(self):
        return len(self.items)
  1. 定义要执行的转换,并为培训和验证数据准备数据集和数据加载器:
from torchvision import transforms

trn_tfms = transforms.Compose([
            transforms.ToPILImage(),
            transforms.RandomHorizontalFlip(),
            transforms.RandomAffine(5, (0.01,0.2), \
                                    scale=(0.9,1.1)),
            transforms.Resize((100,100)),
            transforms.ToTensor(),
            transforms.Normalize((0.5), (0.5))
        ])
val_tfms = transforms.Compose([
            transforms.ToPILImage(),
            transforms.Resize((100,100)),
            transforms.ToTensor(),
            transforms.Normalize((0.5), (0.5))
        ])

trn_ds=SiameseNetworkDataset(folder="./data/faces/training/" \
                             , transform=trn_tfms)
val_ds=SiameseNetworkDataset(folder="./data/faces/testing/", \
                               transform=val_tfms)

trn_dl = DataLoader(trn_ds, shuffle=True, batch_size=64)
val_dl = DataLoader(val_ds, shuffle=False, batch_size=64)
  1. 定义神经网络架构:
  • 定义卷积块(convBlock):
def convBlock(ni, no):
    return nn.Sequential(
        nn.Dropout(0.2),
        nn.Conv2d(ni, no, kernel_size=3, padding=1, \
                  padding_mode='reflect'),
        nn.ReLU(inplace=True),
        nn.BatchNorm2d(no),
    )
  • 定义在给定输入的情况下返回五维编码的SiameseNetwork架构:
class SiameseNetwork(nn.Module):
    def __init__(self):
        super(SiameseNetwork, self).__init__()
        self.features = nn.Sequential(
            convBlock(1,4),
            convBlock(4,8),
            convBlock(8,8),
            nn.Flatten(),
            nn.Linear(8*100*100, 500), nn.ReLU(inplace=True),
            nn.Linear(500, 500), nn.ReLU(inplace=True),
            nn.Linear(500, 5)
        )

    def forward(self, input1, input2):
        output1 = self.features(input1)
        output2 = self.features(input2)
        return output1, output2
  1. 定义ContrastiveLoss功能:
class ContrastiveLoss(torch.nn.Module):
    """
    Contrastive loss function.
Based on: http://yann.lecun.com/exdb/publis/pdf/hadsell-chopra-lecun-06.pdf
    """

    def __init__(self, margin=2.0):
        super(ContrastiveLoss, self).__init__()
        self.margin = margin

请注意,这里的边距类似于 SVM 的边距,我们希望属于两个不同类的数据点之间的边距尽可能高。

  • 定义forward方法:
    def forward(self, output1, output2, label):
        euclidean_distance = F.pairwise_distance(output1, \
                                output2, keepdim = True)
        loss_contrastive = torch.mean((1-label) * \
                        torch.pow(euclidean_distance, 2) + \
                        (label) * torch.pow(torch.clamp( \
                        self.margin - euclidean_distance, \
                                            min=0.0), 2))
        acc = ((euclidean_distance>0.6)==label).float().mean()
        return loss_contrastive, acc

在前面的代码中,我们获取两个不同图像的编码—output1output2,并计算它们的eucledian_distance

接下来,我们计算对比损失–loss_contrastive,这对于相同标签的图像之间具有高欧几里德距离以及对于不同标签的图像具有低欧几里德距离和self.margin是不利的。

  1. 定义函数以对一批数据进行训练并验证:
def train_batch(model, data, optimizer, criterion):
    imgsA, imgsB, labels = [t.to(device) for t in data]
    optimizer.zero_grad()
    codesA, codesB = model(imgsA, imgsB)
    loss, acc = criterion(codesA, codesB, labels)
    loss.backward()
    optimizer.step()
    return loss.item(), acc.item()

@torch.no_grad()
def validate_batch(model, data, criterion):
    imgsA, imgsB, labels = [t.to(device) for t in data]
    codesA, codesB = model(imgsA, imgsB)
    loss, acc = criterion(codesA, codesB, labels)
    return loss.item(), acc.item()
  1. 定义模型、损失函数和优化器:
model = SiameseNetwork().to(device)
criterion = ContrastiveLoss()
optimizer = optim.Adam(model.parameters(),lr = 0.001)
  1. 在不断增加的时期内训练模型:
n_epochs = 200
log = Report(n_epochs)
for epoch in range(n_epochs):
    N = len(trn_dl)
    for i, data in enumerate(trn_dl):
        loss, acc = train_batch(model, data, optimizer, \
                                criterion)
        log.record(epoch+(1+i)/N,trn_loss=loss,trn_acc=acc, \
                   end='\r')
    N = len(val_dl)
    for i, data in enumerate(val_dl):
        loss, acc = validate_batch(model, data, \
                                   criterion)
        log.record(epoch+(1+i)/N,val_loss=loss,val_acc=acc, \
                   end='\r')
    if (epoch+1)%20==0: log.report_avgs(epoch+1)
  • 绘制在增加的时期内训练和验证损失准确度的变化记录:
log.plot_epochs(['trn_loss','val_loss'])
log.plot_epochs(['trn_acc','val_acc'])

上述代码会产生以下输出:

  1. 在新图像上测试模型。请注意,该模型从未见过这些新图像。测试时,我们将获取一个随机测试图像,并将其与测试数据中的其他图像进行比较:
model.eval()
val_dl = DataLoader(val_ds,num_workers=6,batch_size=1, \
                    shuffle=True)
dataiter = iter(val_dl)
x0, _, _ = next(dataiter)

for i in range(2):
    _, x1, label2 = next(dataiter)
    concatenated = torch.cat((x0*0.5+0.5, x1*0.5+0.5),0)
    output1,output2 = model(x0.cuda(),x1.cuda())
    euclidean_distance = F.pairwise_distance(output1, output2)
    output = 'Same Face' if euclidean_distance.item() < 0.6 \
                        else 'Different'
    show(torchvision.utils.make_grid(concatenated), \
         title='Dissimilarity: {:.2f}\n{}'. \
         format(euclidean_distance.item(), output))
    plt.show()

上述操作会产生以下输出:

从前面的描述中,我们可以看到,即使我们只有一个类的几个图像,我们也可以识别图像中的人。

在现实场景中(您可能会使用暹罗网络进行出勤跟踪),在我们训练模型或在新图像上进行推断之前,从完整图像中裁剪人脸是一个好主意。

现在我们已经了解了暹罗网络的工作原理,在接下来的章节中,我们将学习其他基于度量的技术——原型网络和关系网络。

原型网络的工作细节

原型是某一类的代表。想象一个场景,我们给你每类 10 张图片,有 5 个这样的类。原型网络通过对属于一个类别的每个图像的嵌入进行平均,得出每个类别的代表性嵌入(原型)。

这里,让我们来理解一个实际的场景:

假设您有 5 个不同的图像类别,每个类别的数据集包含 10 个图像。此外,我们在培训中每班给你 5 张图片,并在另外 5 张图片上测试你的网络的准确性。我们将用每个类中的一个图像和随机选择的测试图像作为查询来构建我们的网络。我们的任务是识别与查询图像(测试图像)具有最高相似性的已知图像(训练图像)的类别。

对于面部识别,原型网络的工作细节如下:

  • 随机选择 N 个不同的人进行训练。
  • 选择与每个人相对应的 k 个样本作为可用于训练的数据点-这是我们的支持集(要比较的图像)。
  • 选择与每个人相对应的 q 个样本作为要测试的数据点–这是我们的查询集(要比较的图像):

现在,我们已经选择了 N 个 [c] 类,N 个 [s] 图像在支持集中,N 个 [q] 图像在查询集中:

  • 当通过 CNN 网络时,获取每个数据点在支持集(训练图像)和查询集(测试图像)内的嵌入,其中我们期望 CNN 网络识别与查询图像具有最高相似性的训练图像的索引。
  • 训练完网络后,计算对应于支持集(训练图像)嵌入的原型:
    • 原型是属于同一类的所有图像的平均嵌入:

在前面的示例插图中,有三个类,每个圆圈代表属于该类的图像的嵌入。每个星形(原型)是图像中所有图像(圆形)的平均嵌入:

  • 计算查询嵌入和原型嵌入之间的欧几里德距离:
  • 如果有 5 个查询图像和 10 个类别,我们将有 50 个欧几里德距离。
  • 在之前获得的欧几里德距离的基础上执行 softmax,以识别对应于不同支持类别的概率。
  • 训练模型以最小化将查询图像分配给正确类别的损失值。此外,在数据集上循环时,在下一次迭代中随机选择一组新的人。

在迭代结束时,模型将学会识别查询图像所属的类别——给定一些支持集图像和查询图像。

关系网络的工作细节

关系网络非常类似于暹罗网络,除了我们优化的度量不是嵌入之间的 L1 距离,而是关系分数。让我们使用下图来了解关系网络的工作细节:

在上图中,左边的图片是五个类的支持集,底部的狗图像是查询图像:

  • 通过嵌入模块传递支持和查询图像,该模块为输入图像提供嵌入。
  • 将支持图像的特征图与查询图像的特征图连接起来。
  • 通过 CNN 模块传递连接的要素以预测关系得分。

具有最高关系分数的类别是查询图像的预测类别。

至此,我们已经理解了少样本学习算法的不同工作方式。我们将给定的查询图像与图像的支持集合进行比较,以得出支持集合中存在的与查询图像具有最高相似性的对象类别。

摘要

在这一章中,我们已经学习了如何利用词向量来提出一种方法来解决我们想要预测的类在训练期间不存在的情况。此外,我们学习了暹罗网络,它学习两幅图像之间的距离函数,以识别相似人的图像。最后,我们学习了原型网络和关系网络,以及它们如何学习执行少镜头图像分类。

在下一章中,我们将学习如何将计算机视觉和基于自然语言处理的技术结合起来,提出解决注释图像、检测图像中的对象和手写转录的方法。

问题

  1. 预训练的词向量是如何获得的?
  2. 零拍学习中我们如何从一个图像特征嵌入映射到一个单词嵌入?
  3. 暹罗网为什么这么叫?
  4. 暹罗网是怎么得出两幅图像的相似度的?

十五、组合计算机视觉和 NLP 技术

在前一章中,我们学习了在数据点数量最少的情况下如何利用新颖的架构。在这一章中,我们将切换话题,了解如何将卷积神经网络 ( CNN )与循环神经网络 ( RNNs )的广泛家族中的算法结合使用,这些算法在自然语言处理 ( NLP )中被大量使用,以开发利用计算机视觉和 NLP 的解决方案。

为了理解 CNN 和 RNNs 的结合,我们将首先了解 RNNs 如何工作及其变体——主要是长短期记忆(LSTM)——以理解它们如何应用于预测给定图像作为输入的注释。在此之后,我们将了解另一个重要的损失函数,称为连接主义者时间分类 ( CTC )损失函数,然后将其与 CNN 和 RNN 一起应用来执行手写图像的转录。最后,我们将了解并利用转换器,使用转换器检测 ( DETR )架构来执行目标检测。

本章结束时,您将了解到以下主题:

  • RNNs 简介
  • 介绍 LSTM 建筑
  • 实现图像字幕
  • 抄写手写图像
  • 使用 DETR 的目标检测

RNNs 简介

一个 RNN 可以有多种架构。设计 RNN 的一些可能方法如下:

在上图中,底部的框是输入层,后面是隐藏层(中间的框),然后顶部的框是输出层。一对一架构是典型的神经网络,在输入层和输出层之间有一个隐藏层。不同架构的示例如下:

  • 一对多:输入是图像,输出是图像的标题。
  • 多对一:输入是电影评论(输入多个单词),输出是与评论相关的情感。
  • 多对多:机器翻译一种语言的句子到另一种语言的句子。

需要 RNN 建筑背后的想法

当我们想要预测给定一系列事件的下一个事件时,rnn 是有用的。一个例子就是预测这个单词后面的单词:这是一个 __。

假设在现实中,句子是这是一个例子

传统的文本挖掘技术将通过以下方式解决这个问题:

  1. 对每个单词进行编码,同时为潜在的新单词提供额外的索引:

这个 : {1,0,0,0}

: {0,1,0,0}

一个 : {0,0,1,0}

  1. 编码短语这是一个:

这是一个 : {1,1,1,0}

  1. 创建训练数据集:

输入- > {1,1,1,0}

输出- > {0,0,0,1}

  1. 使用给定的输入和输出组合构建模型:

该模型的一个主要缺点是,无论输入句子是以这是一个安是这个还是这安是的形式,输入表示都不会改变。

但是,直观上,我们知道前面的每一个句子都是不同的,在数学上不能用相同的结构来表示。这需要不同的架构,如下所示:

在前面的架构中,句子中的每个单词在输入框中输入一个单独的框。这确保了我们保留了输入句子的结构;比如这个进入第一个盒子,进入第二个盒子,进入第三个盒子。顶部的输出框将是输出–即示例

了解了对 RNN 架构的需求后,在下一节中,让我们学习如何解释 rnn 的输出。

探索 RNN 的结构

你可以把 RNN 想象成一种保存记忆的机制——隐藏层包含了记忆。RNN 的展开版本如下:

右边的网络是左边网络的展开版本。右侧的网络在每个时间步长中获取一个输入,并在每个时间步长提取输出。

请注意,在预测第三个时间步长的输出时,我们通过隐藏层合并了前两个时间步长的值,隐藏层连接了跨时间步长的值。

让我们来看看前面的图表:

  • u 权重表示连接输入层和隐藏层的权重。
  • w 权重表示隐藏层到隐藏层的连接。
  • v 权重表示隐藏层到输出层的连接。

给定时间步长中的输出取决于当前时间步长中的输入和前一时间步长中的隐藏层值。通过引入前一时间步的隐藏层作为输入,以及当前时间步的输入,我们从前一时间步获得信息。这样,我们就创建了一个支持内存存储的连接管道。

为什么要存储内存?

需要存储记忆,因为在前面的例子中,或者甚至在一般的文本生成中,下一个单词不仅依赖于前面的单词,而且依赖于要预测的单词前面的单词的上下文。

鉴于我们正在看前面的单词,应该有一种方法将它们保存在内存中,这样我们就可以更准确地预测下一个单词。

我们还应该把记忆整理好;通常,在预测下一个单词时,最近的单词比距离要预测的单词更远的单词更有用。

考虑多个时间步长进行预测的传统 RNN 可以如下所示:

请注意,随着时间步长的增加,出现在更早时间步长(时间步长 1)的输入对更晚时间步长(时间步长 7)的输出的影响会更小。这里可以看到一个这样的例子(暂且忽略偏置项,假设在时间步长 1 输入的隐藏层是0,我们预测时间步长 5 的隐藏层的值——h[5]):

可以看到,随着时间步长的增加,隐藏层的值(h[5]??)高度依赖于X[1]ifU>1;但是如果 U < 1,那么对 X [1] 的依赖性就小很多。**

U 矩阵的依赖还会导致隐藏层( h [5] )的值非常小,因此当 U 的值非常小时会导致渐变消失,当 U 的值非常高时会导致渐变爆炸。

当存在对预测下一个单词的长期依赖性时,前面的现象导致了一个问题。为了解决这个问题,我们将使用 LSTM 架构。

介绍 LSTM 建筑

在上一节中,我们了解了传统的 RNN 如何面临消失或爆炸的梯度问题,导致它无法适应长期记忆。在本节中,我们将了解如何利用 LSTM 来解决这个问题。

为了用一个例子进一步理解这个场景,让我们考虑下面的句子:

我来自英国。我讲 __ 。

在上一句中,直观地说,我们知道大多数英国人说英语。要填充的空白值(英文)是从这个人来自英国这一事实中获得的。虽然在这种情况下,我们的信号词(英格兰)更接近空白值,但在现实情况下,我们可能会发现信号词远离空白(我们试图预测的词)。当信号字和空白值之间的距离很大时,通过传统的 RNNs 的预测可能由于消失或爆炸梯度现象而出错。LSTM 解决了这种情况——我们将在下一节学习。

LSTM 的工作细节

标准的 LSTM 架构如下:

在上图中,您可以看到,虽然输入 X 和输出 h 与我们在探索 RNN 部分的结构中看到的相似,但是在 LSTM,输入和输出之间发生的计算是不同的。让我们来理解输入和输出之间发生的各种激活:

在上图中,我们可以观察到以下情况:

  • Xh 代表时间步 t 的输入和输出。

  • C 代表电池状态。这可能有助于储存长期记忆。

  • C [t-1]

  • h[t-1 代表前一时间步的输出。]

  • f[t]代表帮助遗忘某些信息的激活。

  • i [t] 代表输入结合上一时间步的输出所对应的变换( h [t-1] )。

需要被遗忘的内容f[t]如下获得:

注意W[xf]和W[HF]分别代表与输入和前一个隐藏层相关联的权重。**

通过将来自前一时间步的单元状态C[t-1]乘以有助于遗忘的输入内容f[t]来更新单元状态。**

更新后的单元状态如下:

注意,在前面的步骤中,我们正在执行C[t-1]和 f [t] 之间的元素到元素乘法,以获得修改后的单元格状态, C [t]

为了理解前面的操作有什么帮助,我们来看一下输入句子:我来自英国。我讲 __

一旦我们用英语填补空白,我们就不再需要这个人来自英国的信息,因此应该从记忆中抹去。细胞状态和遗忘门的结合有助于实现这一点。

在下一步中,我们将包括从当前时间步长到单元状态以及输出的附加信息。通过输入激活(基于当前时间步长的输入和先前时间步长的输出)和调制门gt 更新修改后的单元状态(在忘记要忘记的内容之后)。

输入激活的计算如下:

注意 W [xi]W [hi] 分别代表与输入和前一个隐藏层相关联的权重。

修改门的激活计算如下:

注意 W [xg]W [hg] 分别代表与输入和前一个隐藏层相关联的权重。

经修改的门可帮助隔离待更新的单元状态值而非其余的单元状态值,以及识别待完成的更新的量值。

修改后的单元状态C[t]将传递到下一个时间步,现在如下:

最后,我们将激活的更新单元状态( tanh(C [t] ) )乘以激活的输出值 O [t] ,以获得最终输出 h [t] ,在时间步长 t :

这样,我们可以利用 LSTM 中存在的各种门来选择性地记忆过长的时间步长。

在 PyTorch 实现 LSTM

在一个典型的文本相关练习中,每个单词都是 LSTM 的一个输入——每个时间步长一个单词。为了让 LSTM 工作,我们执行以下两个步骤:

  1. 将每个单词转换成嵌入向量。
  2. 将时间步长中对应于相关单词的嵌入向量作为输入传递给 LSTM。

我们先来理解一下为什么要把一个输入词转换成嵌入向量的原因。如果我们的词汇表中有 100K 个唯一的单词,我们必须在将它们传递到网络之前对它们进行一次性编码。然而,为每个单词创建一个 one-hot-encoded 向量会失去该单词的语义含义——例如,单词 likeenjoy 是相似的,应该具有相似的向量。为了解决这种情况,我们利用单词嵌入,这有助于自动学习单词向量表示(因为它们是网络的一部分)。单词嵌入按如下方式提取:

embed = nn.Embedding(vocab_size, embed_size)

在前面的代码中,nn.Embedding方法将vocab_size个维度作为输入,并返回输出的embed_size个维度。这样,如果词汇大小是 100K,嵌入大小是 128,则 100K 个单词中的每一个都被表示为 128 维向量。进行这种练习的一个好处是,一般来说,相似的单词将具有相似的嵌入。

接下来,我们通过 LSTM 传递单词 embeddings。使用nn.LSTM方法在 PyTorch 中实现 LSTM,如下所示:

hidden_state, cell_state = nn.LSTM(embed_size, \
                                   hidden_size, num_layers)

在前面的代码中,embed_size表示每个时间步长对应的嵌入大小,hidden_size对应隐藏层输出的维度,num_layers表示 LSTM 叠加的次数。

此外,nn.LSTM方法返回隐藏状态值和单元格状态值。

现在我们已经了解了 LSTM 和 RNNs 的工作细节,让我们了解在下一节中预测给定图像的字幕时,如何结合 CNN 利用它们。

实现图像字幕

图像字幕是指在给定图像的情况下生成字幕。在这一节中,我们将首先学习构建一个 LSTM 时要做的预处理,该库可以生成给定图像的文本字幕,然后我们将学习如何结合 CNN 和 LSTM 来执行图像字幕。在我们了解如何构建一个生成标题的系统之前,让我们先来了解一个输入和输出的例子:

在前面的例子中,图像是输入,预期的输出是图像的标题–在这个图像中,我可以看到一些蜡烛。背景为黑色

我们将采取以下策略来解决这个问题:

  1. 预处理输出(基本事实注释/标题),以便每个唯一的单词由一个唯一的 ID 表示。
  2. 假设输出句子可以是任意长度,让我们指定一个开始和结束标记,以便模型知道何时停止生成预测。此外,确保所有输入的句子都被填充,以便所有输入都具有相同的长度。
  3. 将输入影像传递给预先训练好的模型,如 VGG16、ResNet-18 等,以在拼合图层之前提取要素。
  4. 使用图像的特征图和上一步中获得的文本(如果它是我们要预测的第一个单词,则是开始标记)来预测一个单词。
  5. 重复前面的步骤,直到我们获得结束令牌。

既然我们已经在较高层次上理解了要做什么,那么让我们在下一节中用代码实现前面的步骤。

代码中的图像字幕

让我们用代码执行上一节中设计的策略:

The following code is available as Image_captioning.ipynb in the Chapter15 folder of this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from and is moderately lengthy. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components from text.

  1. 从打开的图像数据集中获取数据集,该数据集包括训练图像、其注释和验证数据集:
  • 导入相关的包,定义设备,并获取包含要下载的图像信息的 JSON 文件:
!pip install -qU openimages torch_snippets urllib3
!wget -O open_images_train_captions.jsonl -q https://storage.googleapis.com/localized-narratives/annotations/open_images_train_v6_captions.jsonl
from torch_snippets import *
import json
device = 'cuda' if torch.cuda.is_available() else 'cpu'
  • 遍历 JSON 文件的内容,获取前 100,000 张图像的信息:
with open('open_images_train_captions.jsonl', 'r') as \
                                            json_file:
    json_list = json_file.read().split('\n')
np.random.shuffle(json_list)
data = []
N = 100000
for ix, json_str in Tqdm(enumerate(json_list), N):
    if ix == N: break
    try:
        result = json.loads(json_str)
        x = pd.DataFrame.from_dict(result, orient='index').T
        data.append(x)
    except:
        pass

从 JSON 文件获得的信息示例如下:

从前面的示例中,我们可以看到captionimage_id是我们将在后续步骤中使用的关键信息。image_id将用于获取相应的图像,而caption将用于关联与从给定图像 ID 获得的图像相对应的输出。

  • 将数据帧(data)分成训练和验证数据集:
np.random.seed(10)
data = pd.concat(data)
data['train'] = np.random.choice([True,False], \
                                 size=len(data),p=[0.95,0.05])
data.to_csv('data.csv', index=False)
  • 下载与从 JSON 文件中获取的图像 id 相对应的图像:
from openimages.download import _download_images_by_id
!mkdir -p train-images val-images
subset_imageIds = data[data['train']].image_id.tolist()
_download_images_by_id(subset_imageIds, 'train', \
                       './train-img/')

subset_imageIds = data[~data['train']].image_id.tolist()
_download_images_by_id(subset_imageIds, 'train', \
                       './val-img/')
  1. 为数据帧中所有标题中出现的所有独特单词创建一个词汇表:
  • 词汇对象可以将所有标题中的每个单词映射到一个唯一的整数,反之亦然。我们将利用torchtext库的Field.build_vocab功能,该功能贯穿所有单词(注释/标题)并将它们累积到两个计数器stoiitos中,这两个计数器分别是“string to int”(一个字典)和“int to string”(一个列表):
from torchtext.data import Field
from pycocotools.coco import COCO
from collections import defaultdict

captions = Field(sequential=False, init_token='<start>', \
                 eos_token='<end>')
all_captions = data[data['train']]['caption'].tolist()
all_tokens = [[w.lower() for w in c.split()] \
              for c in all_captions]
all_tokens = [w for sublist in all_tokens \
              for w in sublist]
captions.build_vocab(all_tokens)

在前面的代码中,captionsField是用于在 PyTorch 中构建更复杂的 NLP 数据集的专用对象。我们不能像处理图像一样直接处理文本,因为字符串与张量是不兼容的。因此,我们需要跟踪所有唯一出现的单词(也称为标记),这将有助于每个单词与唯一相关整数的一对一映射。例如,如果输入标题是坐在垫子上的猫,基于单词到整数的映射,该序列将被转换成,比如说,[5 23 24 4 29],其中唯一地与整数 5 相关联。这种映射通常被称为词汇表,可能看起来像{'<pad>': 0, '<unk'>: 1, '<start>': 2, '<end>': 3, 'the': 4, 'cat': 5, ...., 'on': 24, 'sat': 23, ... }。前几个标记是为特殊功能保留的,比如填充、未知、句子的开始和句子的结束。

  • 我们只需要captions词汇组件,所以在下面的代码中,我们创建了一个虚拟的vocab对象,它是轻量级的,将有一个额外的<pad>令牌,这是在captions.vocab中所缺少的:
class Vocab: pass
vocab = Vocab()
captions.vocab.itos.insert(0, '<pad>')
vocab.itos = captions.vocab.itos

vocab.stoi = defaultdict(lambda: \
                         captions.vocab.itos.index('<unk>'))
vocab.stoi['<pad>'] = 0
for s,i in captions.vocab.stoi.items():
    vocab.stoi[s] = i+1

注意vocab.stoi被定义为具有默认功能的defaultdict。当一个键不存在时,Python 使用这个特殊的字典返回一个默认值。在我们的例子中,当我们试图调用vocab.stoi[<new-key/word>]时,我们将返回一个'<unk>'令牌。这在验证阶段非常方便,因为在验证阶段可能会有一些标记不在训练数据中。

  1. 定义数据集类-CaptioningDataset:
  • 定义__init__方法,其中我们提供之前获得的数据帧(df)、包含图像的文件夹(root)、vocab,以及图像转换管道(self.transform):
from torchvision import transforms
class CaptioningData(Dataset):
    def __init__(self, root, df, vocab):
        self.df = df.reset_index(drop=True)
        self.root = root
        self.vocab = vocab
        self.transform = transforms.Compose([ 
            transforms.Resize(224),
            transforms.RandomCrop(224),
            transforms.RandomHorizontalFlip(), 
            transforms.ToTensor(), 
            transforms.Normalize((0.485, 0.456, 0.406), 
                                 (0.229, 0.224, 0.225))]
        )
  • 定义__getitem__方法,获取图像及其相应的标题。此外,使用在上一步中构建的vocab将目标转换成相应的单词 id 列表:
    def __getitem__(self, index):
        """Returns one data pair (image and caption)."""
        row = self.df.iloc[index].squeeze()
        id = row.image_id
        image_path = f'{self.root}/{id}.jpg'
        image = Image.open(os.path.join(image_path))\
                                  .convert('RGB')

        caption = row.caption
        tokens = str(caption).lower().split()
        target = []
        target.append(vocab.stoi['<start>'])
        target.extend([vocab.stoi[token] for token in tokens])
        target.append(vocab.stoi['<end>'])
        target = torch.Tensor(target).long()
        return image, target, caption
  • 定义__choose__方法:
    def choose(self):
        return self[np.random.randint(len(self))]
  • 定义__len__方法:
    def __len__(self):
        return len(self.df)
  • 定义collate_fn方法来处理一批数据:
    def collate_fn(self, data):
        data.sort(key=lambda x: len(x[1]), reverse=True)
        images, targets, captions = zip(*data)
        images = torch.stack([self.transform(image) \
                              for image in images], 0)
        lengths = [len(tar) for tar in targets]
        _targets = torch.zeros(len(captions), \
                               max(lengths)).long()
        for i, tar in enumerate(targets):
            end = lengths[i]
            _targets[i, :end] = tar[:end] 
        return images.to(device), _targets.to(device), \
    torch.tensor(lengths).long().to(device)

collate_fn方法中,我们计算一批标题的最大长度(具有最大字数的标题),并填充该批中的其余标题,使其具有相同的长度。

  1. 定义培训和验证数据集以及数据加载器:
trn_ds = CaptioningData('train-images', data[data['train']], \
                        vocab)
val_ds = CaptioningData('val-images', data[~data['train']], \
                        vocab)

image, target, caption = trn_ds.choose()
show(image, title=caption, sz=5); print(target)

样本图像和相应的标题和标记的单词索引如下:

  1. 为数据集创建数据加载器:
trn_dl = DataLoader(trn_ds, 32, collate_fn=trn_ds.collate_fn)
val_dl = DataLoader(val_ds, 32, collate_fn=val_ds.collate_fn)
inspect(*next(iter(trn_dl)), names='images,targets,lengths')

样本批次将包含以下实体:

  1. 定义网络类别:
  • 定义编码器架构-EncoderCNN:
from torch.nn.utils.rnn import pack_padded_sequence
from torchvision import models
class EncoderCNN(nn.Module):
    def __init__(self, embed_size):
        """Load the pretrained ResNet-152 and replace 
        top fc layer."""
        super(EncoderCNN, self).__init__()
        resnet = models.resnet152(pretrained=True)
        # delete the last fc layer.
        modules = list(resnet.children())[:-1] 
        self.resnet = nn.Sequential(*modules)
        self.linear = nn.Linear(resnet.fc.in_features, \
                                embed_size)
        self.bn = nn.BatchNorm1d(embed_size, \
                                 momentum=0.01)

    def forward(self, images):
        """Extract feature vectors from input images."""
        with torch.no_grad():
            features = self.resnet(images)
        features = features.reshape(features.size(0), -1)
        features = self.bn(self.linear(features))
        return features

在前面的代码中,我们获取预训练的 ResNet-152 模型,删除最后的fc层,将其连接到大小为embed_sizeLinear层,然后通过批处理规范化(bn)传递它。

  • 获取encoder类的摘要:
encoder = EncoderCNN(256).to(device)
!pip install torch_summary
from torchsummary import summary
print(summary(encoder,torch.zeros(32,3,224,224).to(device)))

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

  • 定义解码器架构-DecoderRNN:
class DecoderRNN(nn.Module):
    def __init__(self, embed_size, hidden_size, vocab_size, \
                 num_layers, max_seq_length=80):
        """Set the hyper-parameters and build the layers."""
        super(DecoderRNN, self).__init__()
        self.embed = nn.Embedding(vocab_size, embed_size)
        self.lstm = nn.LSTM(embed_size, hidden_size, \
                            num_layers, batch_first=True)
        self.linear = nn.Linear(hidden_size, vocab_size)
        self.max_seq_length = max_seq_length

    def forward(self, features, captions, lengths):
        """Decode image feature vectors and 
        generates captions."""
        embeddings = self.embed(captions)
        embeddings = torch.cat((features.unsqueeze(1), \
                                embeddings), 1)
        packed = pack_padded_sequence(embeddings, \
                            lengths.cpu(), batch_first=True) 
        outputs, _ = self.lstm(packed)
        outputs = self.linear(outputs[0])
        return outputs

在前面的解码器中,让我们了解我们正在初始化什么:

  • self.embed:一个vocab x embed_size矩阵,为每个单词创建并学习一个唯一的嵌入。
  • self.lstmCNNEncoder的输出和前一时间步的单词输出嵌入作为输入,并返回每个时间步的隐藏状态。
  • self.linear将每个隐藏状态转换成一个V维向量,我们将使用 softmax 来获取时间步长的可能单词。

forward方法中,我们看到以下内容:

  1. 使用self.embed将标题(作为整数发送)转换成嵌入内容。
  2. 来自EncoderCNNfeatures被连接到embeddings。如果每个字幕的时间步长数(在下面的例子中为 L )是 80,那么在拼接之后,时间步长数将是 81。请参见以下示例,了解每个时间步中的供给和预测内容:

  1. 使用pack_padded_sequences,级联的嵌入被打包到一个数据结构中,该数据结构通过不在填充出现的时间步长展开而使 RNN 计算更有效。直观的解释见下图:
  • 在下图中,我们有三个句子,它们用相应的单词索引进行编码。字索引0表示填充索引。打包后,批次大小为最后一个索引中的1,因为只有一个句子中的最后一个索引不是填充索引:

  • 打包的填充现在被传递给 LSTM,如下所示:

代码中上一个示例的对应行是outputs, _ = self.lstm(packed)。最后,LSTM 的输出通过线性层发送,因此维数从 512 变为 vocab 大小。

我们还将向 RNN 添加一个predict方法,该方法接受来自EncoderCNN的特性并返回每个特性的预期令牌。我们将在训练后使用它来获取图像的标题:

    def predict(self, features, states=None):
        """Generate captions for given image 
        features using greedy search."""
        sampled_ids = []
        inputs = features.unsqueeze(1)
        for i in range(self.max_seq_length):
            hiddens, states = self.lstm(inputs, states) 
            # hiddens: (batch_size, 1, hidden_size)
            outputs = self.linear(hiddens.squeeze(1)) 
            # outputs: (batch_size, vocab_size)
            _, predicted = outputs.max(1) 
            # predicted: (batch_size)
            sampled_ids.append(predicted)
            inputs = self.embed(predicted) 
            # inputs: (batch_size, embed_size)
            inputs = inputs.unsqueeze(1) 
            # inputs: (batch_size, 1, embed_size)

        sampled_ids = torch.stack(sampled_ids, 1) 
        # sampled_ids: (batch_size, max_seq_length)
        # convert predicted tokens to strings
        sentences = []
        for sampled_id in sampled_ids:
            sampled_id = sampled_id.cpu().numpy()
            sampled_caption = []
            for word_id in sampled_id:
                word = vocab.itos[word_id]
                sampled_caption.append(word)
                if word == '<end>':
                    break
            sentence = ' '.join(sampled_caption)
            sentences.append(sentence)
        return sentences
  1. 定义对一批数据进行训练的函数:
def train_batch(data, encoder, decoder, optimizer, criterion):
    encoder.train()
    decoder.train()
    images, captions, lengths = data
    images = images.to(device)
    captions = captions.to(device)
    targets = pack_padded_sequence(captions, lengths.cpu(), \
                                   batch_first=True)[0]
    features = encoder(images)
    outputs = decoder(features, captions, lengths)
    loss = criterion(outputs, targets)
    decoder.zero_grad()
    encoder.zero_grad()
    loss.backward()
    optimizer.step()
    return loss

请注意,我们由此创建了一个名为targets的张量,它将项目打包到一个向量中。正如您在前面的图表中所知道的,pack_padded_sequence有助于以这样一种方式打包预测,即更容易在输出中调用带有打包的target值的nn.CrossEntropyLoss

  1. 定义要对一批数据进行验证的函数:
@torch.no_grad()
def validate_batch(data, encoder, decoder, criterion):
    encoder.eval()
    decoder.eval()
    images, captions, lengths = data
    images = images.to(device)
    captions = captions.to(device)
    targets = pack_padded_sequence(captions, lengths.cpu(), \
                                   batch_first=True)[0]
    features = encoder(images)
    outputs = decoder(features, captions, lengths)
    loss = criterion(outputs, targets)
    return loss
  1. 定义模型对象和损失函数,以及优化器:
encoder = EncoderCNN(256).to(device)
decoder = DecoderRNN(256, 512, len(vocab.itos), 1).to(device)
criterion = nn.CrossEntropyLoss()
params = list(decoder.parameters()) + \
         list(encoder.linear.parameters()) + \
         list(encoder.bn.parameters())
optimizer = torch.optim.AdamW(params, lr=1e-3)
n_epochs = 10
log = Report(n_epochs)
  1. 在不断增加的时期内训练模型:
for epoch in range(n_epochs):
    if epoch == 5: optimizer = torch.optim.AdamW(params, \
                                                 lr=1e-4)
    N = len(trn_dl)
    for i, data in enumerate(trn_dl):
        trn_loss = train_batch(data, encoder, decoder, \
                               optimizer, criterion)
        pos = epoch + (1+i)/N
        log.record(pos=pos, trn_loss=trn_loss, end='\r')

    N = len(val_dl)
    for i, data in enumerate(val_dl):
        val_loss = validate_batch(data, encoder, decoder, \
                                  criterion)
        pos = epoch + (1+i)/N
        log.record(pos=pos, val_loss=val_loss, end='\r')
    log.report_avgs(epoch+1)

log.plot_epochs(log=True)

前面的代码生成了在增加的时期内训练和验证损失的变化的输出:

  1. 定义一个在给定图像的情况下生成预测的函数:
def load_image(image_path, transform=None):
    image = Image.open(image_path).convert('RGB')
    image = image.resize([224, 224], Image.LANCZOS)
    if transform is not None:
        tfm_image = transform(image)[None]
    return image, tfm_image

def load_image_and_predict(image_path):
    transform = transforms.Compose([
                    transforms.ToTensor(), 
                    transforms.Normalize(\
                        (0.485, 0.456, 0.406), 
                        (0.229, 0.224, 0.225))
                    ])
    org_image, tfm_image = load_image(image_path, transform)
    image_tensor = tfm_image.to(device)
    encoder.eval()
    decoder.eval()
    feature = encoder(image_tensor)
    sentence = decoder.predict(feature)[0]
    show(org_image, title=sentence)
    return sentence

files = Glob('val-images')
load_image_and_predict(choose(files))

前述生成给定图像的预测:

从前面的例子中,我们可以看到,给定一幅图像(在前面的例子中显示为标题),我们可以生成合理的标题。

在本节中,我们学习了如何利用 CNN 和 RNN 一起生成字幕。在下一节中,我们将了解如何使用 CNN、RNNs 和 CTC 损失函数来转录包含手写单词的图像。

抄写手写图像

在上一节中,我们学习了从输入图像中生成单词序列。在这一节中,我们将学习用图像作为输入来生成字符序列。此外,我们将了解 CTC 损失功能,这有助于抄录手写图像。

在我们了解 CTC 损失函数之前,让我们了解一下为什么我们在图像字幕部分看到的架构可能不适用于手写转录。在图像字幕中,图像中的内容和输出单词之间没有直接的关联,而在手写图像中,图像中出现的字符序列和输出序列之间有直接的关联。因此,我们将遵循与上一节中设计的不同的架构。

此外,假设一幅图像被分成 20 个部分(假设一幅图像中每个单词最多 20 个字符),其中每个部分对应一个字符。一个人的笔迹可能确保每个字符完全适合一个框,而另一个人的笔迹可能被混淆,使得每个框包含两个字符,而另一个人的笔迹中两个字符之间的间隔太大,以至于不可能将一个单词适合 20 个时间步长(部分)。这需要一种不同的方法来解决这个问题,即利用 CTC 丢失功能,我们将在下一节了解这一点。

CTC 丢失的工作细节

想象一个场景,我们正在转录一个包含单词 ab 的图像。无论我们选择以下三个图像中的哪一个,图像看起来都像下面的任何一个,并且输出总是 ab :

在下一步中,我们将前面的三个示例分成六个时间步长,如下所示(其中每个方框代表一个时间步长):

现在,我们将预测每个时间步中的输出字符——其中输出是词汇中出现单词的概率的软最大值。假设我们正在执行 softmax,假设通过我们的模型(我们将在后续部分中定义)运行图像后,每个时间步的输出字符如下所示(图像上方提供了每个单元的输出):

请注意,*-*表示在相应的时间步长中不存在任何东西。此外,注意字符 b 在两个不同的时间步长中重复出现。

在最后一步中,我们将压缩输出(一个字符序列),这是通过将我们的图像传递到模型中获得的,其方式是将连续的重复字符压缩成一个字符。

如果存在连续的相同字符预测,则压缩重复字符输出的前一步骤会产生如下最终输出:

-a-b-

在另一种情况下,当输出为 abb 时,预计最终输出压缩后在两个 b 字符之间有一个分隔符,示例如下:

-a-b-b-

现在我们已经了解了输入和输出值的概念,在下一节中,让我们了解如何计算 CTC 损失值。

计算 CTC 损失值

对于我们在上一节中解决的问题,让我们考虑以下场景——在下图的圆圈中提供了角色在给定时间步长中的概率(注意,在从 ???? 的每个时间步长中,概率加起来为 1):

然而,为了使计算简单,为了让我们理解 CTC 损失值是如何计算的,让我们假设图像只包含字符 a 而不包含单词 ab 的场景。此外,为了简化计算,我们假设只有三个时间步长:

如果每个时间步中的 softmax 是以下七种情况中的任何一种,我们可以获得 a 的地面真值:

| 每个时间步的输出 | Prob。t 中的人物[0] | Prob。中的人物 t[1] | Prob。中的人物 t[2] | 组合概率 | 最终概率 |
| [构成动植物的古名或拉丁化的现代名] | Zero point eight | Zero point one | Zero point one | 0.8 x 0.1 x 0.1 | Zero point zero zero eight |
| -aa | Zero point eight | Zero point nine | Zero point one | 0.8 x 0.9 x 0.1 | Zero point zero seven two |
| 美国汽车协会 | Zero point two | Zero point nine | Zero point one | 0.2 x 0.9 x 0.1 | Zero point zero one eight |
| -一个- | Zero point eight | Zero point nine | Zero point eight | 0.8 x 0.9 x 0.8 | Zero point five seven six |
| -aa | Zero point eight | Zero point nine | Zero point one | 0.8 x 0.9 x 0.1 | Zero point zero seven two |
| 表示“不” | Zero point two | Zero point one | Zero point eight | 0.2 x 0.1 x 0.8 | Zero point zero one six |
| aa- | Zero point two | Zero point nine | Zero point eight | 0.2 x 0.9 x 0.8 | Zero point one four four |
| | | | | 总概率 | Zero point nine zero six |

从前面的结果可以看出,获得地面真值的总体概率为 0.906。

0.094 的其余部分对应于结果未获得地面真相的概率。

让我们来计算所有可能的地真理之和对应的二元交叉熵损失。

CTC 损失是导致地面实况= -log(0.906) = 0.1 的组合的总体概率总和的负对数。

既然我们已经了解了 CTC 损失是如何计算的,那么让我们在下一节中实现这一知识,同时构建一个从图像进行手写转录的模型。

代码中的手写转录

我们将采用以下策略来编码一个可以转录手写单词图像内容的网络:

  1. 导入图像数据集及其相应的转录。
  2. 给每个字符一个索引。
  3. 通过卷积网络传递图像以获取对应于图像的特征图。
  4. 通过 RNN 传递特征地图。
  5. 获取每个时间步中的概率。
  6. 利用 CTC 损失功能压缩输出,并提供转录和相应的损失。
  7. 通过最小化 CTC 损失函数来优化网络的权重。

让我们用代码执行前面的策略:

The following code is available as Handwriting_transcription.ipynb in the Chapter15 folder of this book's GitHub repository - tinyurl.com/mcvp-packt.

  1. 下载并导入图像数据集:
!wget https://www.dropbox.com/s/l2ul3upj7dkv4ou/synthetic-data.zip
!unzip -qq synthetic-data.zip

在前面的代码中,我们已经下载了提供图像的数据集,并且图像的文件名包含对应于该图像的转录的基本事实。

下载的图像示例如下:

  1. 安装所需的软件包并导入它们:
!pip install torch_snippets torch_summary editdistance
  • 导入包:
from torch_snippets import *
from torchsummary import summary
import editdistance
  1. 指定图像的位置和从图像中提取地面实况的功能:
device = 'cuda' if torch.cuda.is_available() else 'cpu'
fname2label = lambda fname: stem(fname).split('@')[0]
images = Glob('synthetic-data')

请注意,我们正在创建fname2label函数,因为在文件名中的@符号之后可以获得图像的基本事实。文件名示例如下:

  1. 定义字符的词汇量(vocab)、批量大小(B)、RNN 的时间步长(T)、词汇量的长度(V)、图像的高度(H)和宽度(W):
vocab='QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm'
B,T,V = 64, 32, len(vocab) 
H,W = 32, 128 
  1. 定义OCRDataset数据集类:
  • 定义__init__方法,通过循环vocab指定字符到字符 ID 的映射(charList)和反过来的映射(invCharList),以及时间步数(timesteps)和要提取的图像的文件路径(items)。我们在这里使用charListinvCharList而不是torchtextbuild vocab,因为词汇表更容易处理(包含更少数量的不同字符):
class OCRDataset(Dataset):
    def __init__(self, items, vocab=vocab, \
                 preprocess_shape=(H,W), timesteps=T):
        super().__init__()
        self.items = items
        self.charList = {ix+1:ch for ix,ch \
                         in enumerate(vocab)}
        self.charList.update({0: '`'})
        self.invCharList = {v:k for k,v in \
                            self.charList.items()}
        self.ts = timesteps
  • 定义__len____getitem__ 的方法:
    def __len__(self):
        return len(self.items)
    def sample(self):
        return self[randint(len(self))]
    def __getitem__(self, ix):
        item = self.items[ix]
        image = cv2.imread(item, 0)
        label = fname2label(item)
        return image, label

注意,在 __getitem__ 方法中,我们使用前面定义的fname2label读取图像并创建标签。
此外,我们正在定义一个sample方法,帮助我们从数据集中随机抽取图像。

  • 定义collate_fn方法,该方法获取一批图像,并将它们和它们的标签添加到不同的列表中。此外,它将对应于图像的地面实况的字符转换为其矢量格式(将每个字符转换为其对应的 ID ),最后,存储每个图像的标签长度和输入长度(总是时间步长的数量)。CTC 损耗函数在计算损耗值时利用标签长度和输入长度:
    def collate_fn(self, batch):
        images, labels, label_lengths = [], [], []
        label_vectors, input_lengths = [], []
        for image, label in batch:
            images.append(torch.Tensor(self.\
                                preprocess(image))[None,None])
            label_lengths.append(len(label))
            labels.append(label)
            label_vectors.append(self.str2vec(label))
            input_lengths.append(self.ts)
  • 将前面的每个列表转换成一个 Torch 张量对象,并返回imageslabelslabel_lengthslabel_vectorsinput_lengths:
        images = torch.cat(images).float().to(device)
        label_lengths = torch.Tensor(label_lengths)\
                             .long().to(device)
        label_vectors = torch.Tensor(label_vectors)\
                             .long().to(device)
        input_lengths = torch.Tensor(input_lengths)\
                             .long().to(device)
        return images, label_vectors, label_lengths, \
                input_lengths, labels
  • 定义str2vec函数,它将字符 id 的输入转换成一个字符串:
    def str2vec(self, string, pad=True):
        string = ''.join([s for s in string if \
                          s in self.invCharList])
        val = list(map(lambda x: self.invCharList[x], \
                       string)) 
        if pad:
            while len(val) < self.ts:
                val.append(0)
        return val

str2vec函数中,如果标签的长度(len(val))小于时间步长的数量(self.ts),我们从一串字符 id 中提取字符,并向向量添加填充索引0

  • 定义preprocess函数,该函数将一幅图像(imgshape作为输入,将其处理成一致的 32 x 128 的形状。注意,除了调整图像大小之外,还要进行额外的预处理,因为要在保持纵横比的同时调整图像大小。

定义preprocess函数和图像的目标形状,该图像目前被初始化为空白图像(白色图像–target):

    def preprocess(self, img, shape=(32,128)):
        target = np.ones(shape)*255

获取图像的形状和预期形状:

        try:
            H, W = shape
            h, w = img.shape

计算如何调整图像大小以保持纵横比:

            fx = H/h
            fy = W/w
            f = min(fx, fy)
            _h = int(h*f)
            _w = int(w*f)

调整图像大小,并将其存储在前面定义的目标变量中:

            _img = cv2.resize(img, (_w,_h))
            target[:_h,:_w] = _img

返回标准化的图像(我们首先将图像转换为黑色背景,然后将像素缩放到 0 到 1 之间的值):

        except:
            ...
        return (255-target)/255
  • 定义decoder_chars函数将预测解码成单词:
    def decoder_chars(self, pred):
        decoded = ""
        last = ""
        pred = pred.cpu().detach().numpy()
        for i in range(len(pred)):
            k = np.argmax(pred[i])
            if k > 0 and self.charList[k] != last:
                last = self.charList[k]
                decoded = decoded + last
            elif k > 0 and self.charList[k] == last:
                continue
            else:
                last = ""
        return decoded.replace(" "," ")

在前面的代码中,我们一次循环一个时间步长的预测(pred),获取具有最高置信度的字符(k),将其与前一个时间步长中具有最高置信度的字符(last)进行比较,如果前一个时间步长中具有最高置信度的字符与当前时间步长中具有最高置信度的字符不同(相当于挤压,我们在“CTC 损失函数”一节中对此进行了讨论),则将该字符追加到目前为止的decoded字符。

  • 定义计算字符和单词准确性的方法:
    def wer(self, preds, labels):
        c = 0
        for p, l in zip(preds, labels):
            c += p.lower().strip() != l.lower().strip()
        return round(c/len(preds), 4)
    def cer(self, preds, labels):
        c, d = [], []
        for p, l in zip(preds, labels):
            c.append(editdistance.eval(p, l) / len(l))
        return round(np.mean(c), 4)
  • 定义一种在一组图像上评估模型并返回单词和字符错误率的方法:
    def evaluate(self, model, ims, labels, lower=False):
        model.eval()
        preds = model(ims).permute(1,0,2) # B, T, V+1
        preds = [self.decoder_chars(pred) for pred in preds]
        return {'char-error-rate': self.cer(preds, labels), \
                'word-error-rate': self.wer(preds, labels), \
                'char-accuracy': 1-self.cer(preds, labels), \
                'word-accuracy' : 1-self.wer(preds, labels)}

在前面的代码中,我们对输入图像的通道进行了置换,以便按照模型的预期对数据进行预处理,使用decoder_chars函数对预测进行解码,然后返回字符错误率、单词错误率及其相应的准确性。

  1. 指定训练和验证数据集以及数据加载器:
from sklearn.model_selection import train_test_split
trn_items,val_items=train_test_split(Glob('synthetic-data'), \
                              test_size=0.2, random_state=22)
trn_ds = OCRDataset(trn_items)
val_ds = OCRDataset(val_items)

trn_dl = DataLoader(trn_ds, batch_size=B, \
                    collate_fn=trn_ds.collate_fn, \
                    drop_last=True, shuffle=True)
val_dl = DataLoader(val_ds, batch_size=B, \
                collate_fn=val_ds.collate_fn, drop_last=True)
  1. 构建网络架构:
  • 构建 CNN 的基本模块:
from torch_snippets import Reshape, Permute
class BasicBlock(nn.Module):
    def __init__(self, ni, no, ks=3, st=1, \
                 padding=1, pool=2, drop=0.2):
        super().__init__()
        self.ks = ks
        self.block = nn.Sequential(
            nn.Conv2d(ni, no, kernel_size=ks, \
                      stride=st, padding=padding),
            nn.BatchNorm2d(no, momentum=0.3),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(pool),
            nn.Dropout2d(drop)
        )
    def forward(self, x):
        return self.block(x)
  • 构建神经网络类 OCR,其具有分别在self.modelself.rnn中的__init__方法中定义的 CNN 块和 RNN 块。接下来,我们定义self.classification层,它获取 RNN 的输出,并在通过密集层处理 RNN 输出后,将其传递给 softmax 激活:
class Ocr(nn.Module):
    def __init__(self, vocab):
        super().__init__()
        self.model = nn.Sequential(
                    BasicBlock( 1, 128),
                    BasicBlock(128, 128),
                    BasicBlock(128, 256, pool=(4,2)),
                    Reshape(-1, 256, 32),
                    Permute(2, 0, 1) # T, B, D
                )
        self.rnn = nn.Sequential(
            nn.LSTM(256, 256, num_layers=2, \
                    dropout=0.2, bidirectional=True),
        )
        self.classification = nn.Sequential(
            nn.Linear(512, vocab+1),
            nn.LogSoftmax(-1),
        )
  • 定义forward方法:
    def forward(self, x):
        x = self.model(x)
        x, lstm_states = self.rnn(x)
        y = self.classification(x)
        return y

在前面的代码中,我们在第一步中获取 CNN 输出,然后通过 RNN 获取lstm_states和 RNN 输出x,最后通过分类层(self.classification)传递输出并返回。

  • 定义 CTC 损失函数:
def ctc(log_probs, target, input_lengths, \
        target_lengths, blank=0):
    loss = nn.CTCLoss(blank=blank, zero_infinity=True)
    ctc_loss = loss(log_probs, target, \
                    input_lengths, target_lengths)
    return ctc_loss

在前面的代码中,我们利用nn.CTCLoss方法来最小化ctc_loss,它将置信度矩阵、log_probs(每个时间步中的预测)、target(基本事实)、input_lengthstarget_lengths作为输入来返回ctc_loss值。

  • 获取已定义模型的概要:
model = Ocr(len(vocab)).to(device)
summary(model, torch.zeros((1,1,32,128)).to(device))

上述代码会产生以下输出:

注意,输出有 53 个概率与批中的每个图像相关联,因为有 53 个字符的词汇表(26 x 2 = 52 个字母和分隔符)。

  1. 定义对一批数据进行训练的函数:
def train_batch(data, model, optimizer, criterion):
    model.train()
    imgs, targets, label_lens, input_lens, labels = data
    optimizer.zero_grad()
    preds = model(imgs)
    loss = criterion(preds, targets, input_lens, label_lens)
    loss.backward()
    optimizer.step()
    results = trn_ds.evaluate(model, imgs.to(device),labels)
    return loss, results
  1. 定义要对一批数据进行验证的函数:
@torch.no_grad()
def validate_batch(data, model):
    model.eval()
    imgs, targets, label_lens, input_lens, labels = data
    preds = model(imgs)
    loss = criterion(preds, targets, input_lens, label_lens)
    return loss, val_ds.evaluate(model, imgs.to(device), \
                                 labels)
  1. 定义模型对象、优化器、损失函数和时期数:
model = Ocr(len(vocab)).to(device)
criterion = ctc

optimizer = optim.AdamW(model.parameters(), lr=3e-3)

n_epochs = 50
log = Report(n_epochs)
  1. 在不断增加的时期内运行模型:
for ep in range( n_epochs):
    N = len(trn_dl)
    for ix, data in enumerate(trn_dl):
        pos = ep + (ix+1)/N
        loss, results = train_batch(data, model, optimizer, \
                                    criterion)
        ca, wa = results['char-accuracy'], \
                 results['word-accuracy']
        log.record(pos=pos, trn_loss=loss, trn_char_acc=ca, \
                   trn_word_acc=wa, end='\r')
    val_results = []
    N = len(val_dl)
    for ix, data in enumerate(val_dl):
        pos = ep + (ix+1)/N
        loss, results = validate_batch(data, model)
        ca, wa = results['char-accuracy'], \
                 results['word-accuracy']
        log.record(pos=pos, val_loss=loss, val_char_acc=ca, \
                   val_word_acc=wa, end='\r')

    log.report_avgs(ep+1)
    print()
    for jx in range(5):
        img, label = val_ds.sample()
        _img=torch.Tensor(val_ds.preprocess(img)[None,None])\
                                  .to(device)
        pred = model(_img)[:,0,:]
        pred = trn_ds.decoder_chars(pred)
        print(f'Pred: `{pred}` :: Truth: `{label}`')
    print()

上述代码会产生以下输出:

从图表中,我们可以看到,该模型在验证数据集上的单词准确率约为 80%。

此外,训练结束时的预测如下:

到目前为止,我们已经学习了如何结合使用 CNN 和 rnn。在下一节中,我们将了解如何利用 transformer 架构对我们在前面章节中处理过的卡车和公交车数据集执行目标检测。

使用 DETR 的目标检测

在前面关于目标检测的章节中,我们学习了利用锚盒/区域提议来执行对象分类和检测。然而,它涉及一系列步骤来实现目标检测。DETR 是一种利用转换器来提供端到端管道的技术,可以大大简化目标检测网络架构。转换器是在 NLP 中执行各种任务的最流行和最新的技术之一。在这一节中,我们将学习转换器 DETR 的工作细节,并编写代码来执行我们检测卡车和公共汽车的任务。

转换器的工作细节

转换器已被证明是解决序列间问题的出色架构。截至撰写本书时,几乎所有的 NLP 任务都有来自 transformers 的最先进的实现。这类网络仅使用线性层和 softmax 来创建自我关注(这将在下一小节中详细解释)。自我注意有助于识别输入文本中单词之间的相互依赖性。输入序列通常不超过 2,048 项,因为这对文本应用程序来说已经足够大了。然而,如果图像要与 transformers 一起使用,它们必须被展平,这将产生数千/数百万像素量级的序列(因为 300 x 300 x 3 图像将包含 270K 像素),这是不可行的。脸书研究公司想出了一种绕过这种限制的新方法,将特征图(比输入图像小)作为输入输入到转换器。这一节先了解转换器的基础知识,后面再了解相关的代码块。

转换器基础

转换器的核心是自我关注模块。它以三个二维矩阵(称为查询 ( Q )、 ( K )、 ( V )矩阵)作为输入。矩阵可以具有非常大的嵌入大小(因为它们将包含文本大小 x 嵌入大小的值的数量),所以在遍历 scaled-dot-product-attention(下图中的步骤 2)之前,首先将它们拆分成较小的组件(下图中的步骤 1)。

让我们来理解自我关注是如何工作的。在序列长度为 3 的假设场景中,我们有三个字嵌入( W [1]W [2]W [3] )作为输入。假设每个嵌入的大小为 512。这些嵌入中的每一个都被单独转换成三个附加向量,它们是对应于每个输入的查询、键和值向量:

因为每个向量的大小是 512,所以在它们之间进行矩阵乘法在计算上是昂贵的。因此,我们将这些向量中的每一个分成八个部分,对于键、查询和值张量中的每一个有八组(64×3)向量,其中 64 是从 512(嵌入大小)/ 8(多头)获得的,3 是序列长度:

注意会有八组等张量,因为有八个多头。

在每一部分中,我们首先执行键矩阵和查询矩阵之间的矩阵乘法。这样,我们最终得到一个 3 x 3 的矩阵。让它通过 softmax 激活。现在,我们有一个矩阵来显示每个单词相对于其他单词的重要性:

最后,我们执行前面的张量输出与值张量的矩阵乘法,以获得我们的自我注意操作的输出:

然后,我们组合这一步的八个输出,使用 concat layer 返回(下图中的步骤 3),最终得到一个大小为 512 x 3 的张量。由于 Q、K、V 矩阵的分裂,该层也被称为多头自关注(来源:【https://arxiv.org/pdf/1706.03762.pdf】??):

这样一个看起来复杂的网络背后的想法如下:

  • ( )是在键和查询矩阵的上下文中,对于给定的输入需要学习的处理过的嵌入。
  • 查询 ( Qs )和 ( Ks )的作用方式是,它们的组合将创建正确的掩码,以便仅将值矩阵的重要部分提供给下一层。

对于我们在计算机视觉中的例子,当搜索一个对象(比如一匹马)时,查询应该包含搜索一个大尺寸并且通常是棕色、黑色或白色的对象的信息。比例点积注意力的 softmax 输出将反映图像中包含该颜色(棕色、黑色、白色等)的按键矩阵部分。因此,从自我关注层输出的值将具有图像中大致具有期望颜色的那些部分,并且存在于值矩阵中。

我们在网络中多次使用自我关注模块,如下图所示。转换器网络包含一个编码网络(图的左边部分),其输入是源序列。编码部分的输出用作解码部分的密钥和查询输入,而值输入将由神经网络独立于编码部分进行学习(来源:arxiv.org/pdf/1706.03762.pdf):

最后,尽管这是一个输入序列,但没有迹象表明哪个标记(单词)是第一个,哪个是下一个(因为线性图层没有位置指示)。位置编码是可学习的嵌入(有时是硬编码的向量),我们根据它在序列中的位置将其添加到每个输入中。这样做是为了使网络了解哪个单词嵌入是序列中的第一个,哪个是第二个,等等。

在 PyTorch 中创建转换器网络的方法非常简单。您可以创建一个内置的转换器块,如下所示:

from torch import nn
transformer = nn.Transformer(hidden_dim, nheads, \
                        num_encoder_layers, num_decoder_layers)

这里,hidden_dim是嵌入的大小,nheads是多头自关注中的头数,num_encoder_layersnum_decoder_layers分别是网络中的编码和解码块数。

DETR 的工作细节

普通的转换器网络和 DETR 没有什么关键区别。首先,我们的输入是图像,而不是序列。因此,DETR 通过 ResNet 主干传递图像,以获得大小为 256 的向量,然后可以将该向量视为一个序列。在我们的例子中,解码器的输入是对象查询嵌入,它是在训练过程中自动学习的。这些充当所有解码器层的查询矩阵。类似地,对于每一层,键和查询矩阵将成为编码器块的最终输出矩阵,复制两次。转换器的最终输出将是一个Batch_Size x 100 x Embedding_Size张量,其中模型已经用100作为序列长度进行了训练;也就是说,它学习了 100 个对象查询嵌入,并为每个图像返回 100 个向量,指示是否存在对象。这些 100 x Embedding_Size矩阵被分别馈送到对象分类模块和对象回归模块,它们分别独立地预测是否有对象(以及它是什么)和边界框坐标是什么。这两个模块都是简单的nn.Linear层。

在高层次上,DETR 的架构如下(来源:arxiv.org/pdf/2005.12872.pdf):

DETR 的一个较小变体的定义如下:

  • 创建 DETR 模型类:
from collections import OrderedDict
class DETR(nn.Module):
    def __init__(self,num_classes,hidden_dim=256,nheads=8, \
                 num_encoder_layers=6, num_decoder_layers=6):
        super().__init__()
        self.backbone = resnet50()
  • 我们将只从 ResNet 中提取几层,并丢弃其余的层。这几层包含以下列表中给出的名称:
        layers = OrderedDict()
        for name,module in self.backbone.named_modules():
            if name in ['conv1','bn1','relu','maxpool', \
                    'layer1','layer2','layer3','layer4']:
                layers[name] = module
        self.backbone = nn.Sequential(layers)
        self.conv = nn.Conv2d(2048, hidden_dim, 1)
        self.transformer = nn.Transformer(\
                            hidden_dim, nheads, \
                            num_encoder_layers, \
                            num_decoder_layers)
        self.linear_class = nn.Linear(hidden_dim, \
                                      num_classes + 1)
        self.linear_bbox = nn.Linear(hidden_dim, 4)

在前面的代码中,我们指定了以下内容:

  • 感兴趣的层按顺序排列(self.backbone)

  • 卷积运算(self.conv)

  • 转换器座(self.transformer)

  • 最终连接获得的类数(self.linear_class)

  • 边界框(self.linear_box)

  • 定义编码器和解码器层的位置嵌入:

        self.query_pos = nn.Parameter(torch.rand(100, \
                                            hidden_dim))
        self.row_embed = nn.Parameter(torch.rand(50, \
                                            hidden_dim // 2))
        self.col_embed = nn.Parameter(torch.rand(50, \
                                            hidden_dim // 2))

self.query_pos是解码器层的位置嵌入输入,而self.row_embedself.col_embed形成编码器层的二维位置嵌入。

  • 定义forward方法:
    def forward(self, inputs):
        x = self.backbone(inputs)
        h = self.conv(x)
        H, W = h.shape[-2:]
 '''Below operation is rearranging the positional 
 embedding vectors for encoding layer'''
        pos = torch.cat([\
            self.col_embed[:W].unsqueeze(0).repeat(H, 1, 1),\
            self.row_embed[:H].unsqueeze(1).repeat(1, W, 1),\
            ], dim=-1).flatten(0, 1).unsqueeze(1)
 '''Finally, predict on the feature map obtained 
 from resnet using the transformer network'''
        h = self.transformer(pos+0.1*h.flatten(2)\
                             .permute(2, 0, 1), \
                      self.query_pos.unsqueeze(1))\
                             .transpose(0, 1)
 '''post process the output `h` to obtain class 
 probability and bounding boxes'''
        return {'pred_logits': self.linear_class(h), \
                'pred_boxes': self.linear_bbox(h).sigmoid()}

您可以加载在 COCO 数据集上训练的预训练模型,并将其用于预测一般类。预测逻辑将在下一节中解释,您也可以在这个模型上使用相同的函数(当然,对于 COCO 类):

detr = DETR(num_classes=91)
state_dict = torch.hub.load_state_dict_from_url(url=\ 'https://dl.fbaipublicfiles.com/detr/detr_demo-da2a99e9.pth'\
,map_location='cpu', check_hash=True)
detr.load_state_dict(state_dict)
detr.eval();

请注意,与我们在第七章、物体探测基础知识和第八章、高级物体探测中学习的其他物体探测技术相比,DETR 可以在单次拍摄中获取预测。

更详细的 DETR 建筑版本如下(来源:arxiv.org/pdf/2005.12872.pdf):

主干段中,我们获取图像特征,然后通过编码器传递,编码器将图像特征与位置嵌入连接起来。

本质上,在__init__方法中,位置嵌入(表示为self.row_embed, self.col_embed)有助于对图像中各种对象的位置信息进行编码。编码器采用位置嵌入和图像特征的连接来获得隐藏状态向量h(在正向方法中),该向量作为输入被传递给解码器。该变换器的输出被进一步馈送到两个线性网络,一个用于对象识别,一个用于边界框回归。转换器的所有复杂性都隐藏在网络的self.transformer模块中。

训练使用一种新颖的匈牙利损失,它负责将对象识别为一个集合,并惩罚冗余预测。这完全消除了对非最大抑制的需要。匈牙利损失的细节超出了本书的范围,我们鼓励你仔细阅读原始论文中的工作细节。

解码器采用编码器隐藏状态向量和对象查询的组合。对象查询的工作方式类似于位置嵌入/锚定框的工作方式,产生五个预测-一个针对对象的类别,另外四个针对与对象对应的边界框。

凭着对 DETR 工作细节的直觉和高度理解,让我们在下面的部分中对它进行编码。

使用代码中的转换器进行检测

在下面的代码中,我们将对 DETR 进行编码,以预测我们感兴趣的对象——公共汽车与卡车:

The following code is available as Object_detection_with_DETR.ipynb in the Chapter15 folder of this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from and is moderately lengthy. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components from text.

  1. 导入数据集并创建一个名为detr的文件夹:
import os
if not os.path.exists('open-images-bus-trucks'):
    !pip install -q torch_snippets torchsummary
    !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/detr/
%cd detr
  • 将注释图像移动到detr文件夹:
%cd ../open-images-bus-trucks/annotations
!cp mini_open_images_train_coco_format.json\
 instances_train2017.json
!cp mini_open_images_val_coco_format.json\
 instances_val2017.json
%cd ..
!ln -s img/ train2017
!ln -s img/ val2017
%cd ../detr
  • 定义感兴趣的类别:
CLASSES = ['', 'BUS','TRUCK']
  1. 导入预训练的 DETR 模型:
from torch_snippets import *
if not os.path.exists('detr-r50-e632da11.pth'):
    !wget https://dl.fbaipublicfiles.com/detr/detr-r50-e632da11.pth
    checkpoint = torch.load("detr-r50-e632da11.pth", \
                            map_location='cpu')
    del checkpoint["model"]["class_embed.weight"]
    del checkpoint["model"]["class_embed.bias"]
    torch.save(checkpoint,"detr-r50_no-class-head.pth")
  1. open-images-bus-trucks文件夹中的图像和注释训练模型:
!python main.py --coco_path ../open-images-bus-trucks/\
  --epochs 10 --lr=1e-4 --batch_size=2 --num_workers=4\
  --output_dir="outputs" --resume="detr-r50_no-class-head.pth"
  1. 一旦我们定型了模型,就从文件夹中加载它:
from main import get_args_parser, argparse, build_model
parser=argparse.ArgumentParser('DETR training and \
            evaluation script', parents=[get_args_parser()])
args, _ = parser.parse_known_args()

model, _, _ = build_model(args)
model.load_state_dict(torch.load("outputs/checkpoint.pth")\
                      ['model']);
  1. 后处理预测以获取图像和对象周围的边界框:
from PIL import Image, ImageDraw, ImageFont

# standard PyTorch mean-std input image normalization
# colors for visualization
COLORS = [[0.000, 0.447, 0.741], [0.850, 0.325, 0.098], 
          [0.929, 0.694, 0.125], [0.494, 0.184, 0.556], 
          [0.466, 0.674, 0.188], [0.301, 0.745, 0.933]]

transform = T.Compose([
    T.Resize(800),
    T.ToTensor(),
    T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

# for output bounding box post-processing
def box_cxcywh_to_xyxy(x):
    x_c, y_c, w, h = x.unbind(1)
    b = [(x_c - 0.5 * w), (y_c - 0.5 * h), \
         (x_c + 0.5 * w), (y_c + 0.5 * h)]
    return torch.stack(b, dim=1)

def rescale_bboxes(out_bbox, size):
    img_w, img_h = size
    b = box_cxcywh_to_xyxy(out_bbox)
    b = b * torch.tensor([img_w, img_h, img_w, img_h], \
                         dtype=torch.float32)
    return b

def detect(im, model, transform):
    img = transform(im).unsqueeze(0)
    '''demo model only supports images up to 1600 pixels 
     on each side'''
    assert img.shape[-2] <= 1600 and \
    img.shape[-1] <= 1600
    outputs = model(img)
    # keep only predictions with 0.7+ confidence
    probas=outputs['pred_logits'].softmax(-1)[0,:,:-1]
    keep = probas.max(-1).values > 0.7
    # convert boxes from [0; 1] to image scales
    bboxes_scaled = rescale_bboxes(outputs['pred_boxes']\
                                   [0, keep], im.size)
    return probas[keep], bboxes_scaled

def plot_results(pil_img, prob, boxes):
    plt.figure(figsize=(16,10))
    plt.imshow(pil_img)
    ax = plt.gca()
    for p, (xmin, ymin, xmax, ymax), c in zip(prob, \
                            boxes.tolist(), COLORS * 100):
        ax.add_patch(plt.Rectangle((xmin, ymin), \
                        xmax - xmin, ymax - ymin,\
                        fill=False, color=c, linewidth=3))
        cl = p.argmax()
        text = f'{CLASSES[cl]}: {p[cl]:0.2f}'
        ax.text(xmin, ymin, text, fontsize=15,\
                bbox=dict(facecolor='yellow', alpha=0.5))
    plt.axis('off')
    plt.show()
  1. 预测新图像:
for _ in range(2):
    image = Image.open(choose(Glob(\
                '../open-images-bus-trucks/img/*')))\
                .resize((800,800)).convert('RGB')
    scores, boxes = detect(image, model, transform)
    plot_results(image, scores, boxes)

上述代码生成以下输出:

从前面,我们可以看到,我们现在可以训练模型,能够预测图像中的对象。

请注意,我们已经在小数据集上训练了模型,因此在这种特定情况下,检测的准确性可能不是很高。然而,同样的方法可以扩展到大型数据集。作为一个练习,我们建议你应用与我们在第十章、目标检测应用和分割中所做的相同的技术来检测多个物体。

摘要

在这一章中,我们学习了 RNNs 是如何工作的,特别是 LSTM 的变体。此外,在我们的图像字幕用例中,当我们将图像通过预先训练的模型来提取特征,并将特征作为时间步长传递给 RNN 来一次提取一个单词时,我们学习了如何一起利用 CNN 和 RNNs。然后,我们将 CNN 和 RNNs 的结合更进一步,我们利用 CTC 损失函数来转录手写图像。CTC 损失函数有助于确保我们将来自后续时间步骤的相同字符压缩成单个字符,并确保考虑所有可能的输出组合,然后我们基于产生地面真实的组合来评估损失。最后,我们学习了如何利用转换器来执行使用 DETR 的目标检测,在此期间,我们还了解了转换器如何工作,以及如何在目标检测的环境中利用它们。

在下一章中,我们将了解如何结合 CNN 和强化学习技术来开发自动驾驶汽车原型,这是一个能够在学习贝尔曼方程后在没有监督的情况下玩 Atari Space Invaders 游戏的代理,它能够为给定的状态赋值。

问题

  1. 为什么 CNN 和 RNNs 在图像字幕中组合使用?
  2. 为什么图像字幕中提供了开始和结束标记,而手写转录中没有?
  3. 为什么在手写转录中利用 CTC 丢失功能?
  4. 转换器如何帮助目标检测?

十六、组合计算机视觉和强化学习

在前一章中,我们学习了如何将自然语言处理技术(LSTM 和转换器)与基于计算机视觉的技术相结合。在本章中,我们将学习如何将基于强化学习的技术(主要是深度 Q 学习)与基于计算机视觉的技术相结合。

我们将从学习强化学习的基础开始,然后学习与识别如何计算在给定状态下采取行动的相关值(Q 值)相关的术语。接下来,我们将了解如何填写 Q 表,这有助于确定给定状态下与各种行为相关的值。此外,我们将了解在由于大量的可能状态而无法提出 Q 表的情况下,如何确定各种行动的 Q 值;我们将使用深度 Q 网络来实现这一点。这是我们将理解如何利用神经网络与强化学习相结合的地方。接下来,我们将了解深度 Q 网络模型不起作用的情况,并通过使用深度 Q 网络和固定目标模型来解决这个问题。在这里,我们将通过利用 CNN 结合强化学习来玩一个名为 Pong 的视频游戏。最后,我们将利用我们所学的知识来构建一个可以在模拟环境卡拉中自动驾驶汽车的智能体。

总之,在本章中,我们将讨论以下主题:

  • 学习强化学习的基础知识
  • 实现探究式学习
  • 实现深度 Q 学习
  • 实现具有固定目标的深度 Q 学习
  • 实现一个代理来执行自动驾驶

学习强化学习的基础知识

强化学习 ( RL )是机器学习的一个领域,涉及软件代理环境的给定状态下应该如何采取动作,以最大化累积奖励的概念。

为了理解 RL 如何帮助,让我们考虑一个简单的场景。想象你正在和一台电脑下棋(在我们的例子中,电脑是一个已经学会/正在学习如何下棋的代理)。游戏的设置(规则)构成了环境。此外,当我们走一步棋(采取动作)时,棋盘的状态(棋盘上各种棋子的位置)会发生变化。游戏结束,根据结果,代理人获得奖励。代理人的目标是最大化报酬。

如果机器(agent1)与人类对战,它可以玩的游戏数量是有限的(取决于人类可以玩的游戏数量)。这可能会给代理学习造成瓶颈。然而,如果 agent1(正在学习游戏的 agent)可以与 agent2 (agent2 可能是另一个正在学习象棋的 agent,也可能是一个已经被预编程来很好地玩游戏的象棋软件)对弈会怎么样呢?理论上,代理人可以相互玩无限的游戏,这使得学习玩好游戏的机会最大化。这样,通过相互玩多个游戏,学习代理很可能学会如何很好地处理游戏的不同场景/状态。

让我们来了解一下学习代理要学好所要遵循的流程:

  1. 最初,代理在给定的状态下采取随机动作。
  2. 代理将它在游戏中不同状态下采取的行动存储在存储器中。
  3. 然后,代理将各种状态下的动作结果与奖励相关联。
  4. 在玩了多个游戏之后,代理可以通过重放其经历来将一个状态中的动作与潜在奖励相关联。

接下来是量化对应于在给定状态下采取行动的的问题。我们将在下一节学习如何计算这一点。

计算状态值

为了理解如何量化状态的值,让我们使用一个简单的场景,其中我们将如下定义环境和目标:

环境是一个两行三列的网格。代理从起始单元格开始,如果代理到达右下角的网格单元格,它就实现了它的目标(奖励+1 分)。如果它去了任何其他细胞,代理人不会得到奖励。代理可以通过向右、向左、向下或向上执行操作,这取决于操作的可行性(例如,代理可以在起始网格单元中向右或向下)。到达除右下角单元格之外的任何剩余单元格的奖励为 0。

通过使用这些信息,让我们计算一个单元的(在给定的快照中,代理所处的状态)。假设从一个像元移动到另一个像元消耗了一些能量,我们将到达像元的值减去因子γ,其中γ考虑了从一个像元移动到另一个像元所消耗的能量。此外,γ的引入导致代理更快地学习玩得好。有了这个,让我们正式贝尔曼方程,这有助于计算一个细胞的价值:

有了前面的等式,让我们计算所有单元格的值()一旦确定了一个状态中的最优动作),γ的值为 0.9(γ的典型值在 0.9 和 0.99 之间):

从前面的计算中,我们可以理解如何计算给定状态(单元)中的值,当给定该状态中的最佳动作时。下面是我们到达终端状态的简单场景:

有了这些价值,我们期望代理遵循一条价值递增的道路。

现在我们已经了解了如何计算状态值,在下一节中,我们将了解如何计算与状态-动作组合相关的值。

计算状态-动作值

在上一节中,我们提供了一个场景,其中我们已经知道代理正在采取最优的行动(这是不现实的)。在这一节中,我们将看一个场景,在这个场景中,我们可以确定对应于状态-动作组合的值。

在下图中,单元格中的每个子单元格都表示在单元格中执行某项操作的值。最初,各种操作的单元格值如下:

注意,在前面的图像中,如果代理从单元格向右移动(因为它对应于终端单元格),单元格 b1 (2 ^(nd) 行和 2 ^(nd) 列)的值将为 1;其他操作产生的值为 0。x 表示该操作是不可能的,因此没有与之相关联的值。

经过四次迭代(步骤),给定状态下动作的更新单元值如下:

然后,这将经历多次迭代,以提供使每个单元的价值最大化的最佳动作。

让我们了解如何获取第二个表中的单元格值(上图中的迭代 2 )。让我们将它缩小到 0.3,当它出现在第二个表的第 1 ^第行和第 2 ^第列时,通过采取向下的动作获得。当代理人采取向下行动时,有 1/3 的机会在下一个状态采取最优行动。因此,采取向下行动的价值如下:

以类似的方式,我们可以获得在不同的单元格中采取不同的可能动作的值。

现在我们知道了如何计算给定状态下各种动作的值,在下一节中,我们将了解 Q-learning 以及如何利用它,以及健身房环境,以便它可以玩各种游戏。

实现探究式学习

在上一节中,我们手工计算了所有组合的状态-动作值。从技术上来说,现在我们已经计算了我们需要的各种状态-动作值,我们现在可以确定在每个状态下将要采取的动作。然而,在更复杂的情况下——例如,当玩视频游戏时——获取状态信息变得很棘手。OpenAI 的健身房环境在这种情况下派上了用场。它包含了我们正在玩的游戏的预定义环境。在这里,它获取下一个状态信息,给出在当前状态下已经采取的动作。到目前为止,我们已经考虑了选择最佳路径的场景。然而,可能会出现我们陷入局部最小值的情况。

在本节中,我们将学习 Q-learning,它有助于计算与状态中的动作相关联的值,以及如何利用健身房环境,以便我们可以玩各种游戏。现在,我们来看看一个简单的游戏,叫做冰封湖。我们还将看看探索-开发,这有助于我们避免陷入局部最小值。然而,在此之前,我们将了解 Q 值。

q 值

Q 学习或 Q 值中的 Q 代表一个动作的质量。让我们来学习如何计算它:

我们已经知道,我们必须保持更新给定状态的状态动作值,直到它饱和。因此,我们将修改前面的公式,如下所示:

在前面的等式中,我们用学习率代替 1,这样我们就可以更加渐进地更新在某个状态下采取的动作的值:

有了 Q 值的正式定义,在下一节中,我们将了解健身房环境,以及它如何帮助我们获取 Q 表(它存储了在不同状态下采取的各种行动的值的信息),从而得出一种状态下的最佳行动。

了解健身房环境

在本节中,我们将在玩健身房环境中的冰湖游戏时探索健身房环境和其中的各种功能:

下面的代码可以在本书的 GitHub 库【https://tinyurl.com/mcvp-packtChapter16文件夹中找到Understanding_the_Gym_environment.ipynb

  1. 导入相关包:
import numpy as np
import gym
import random
  1. 打印体育馆环境中的各种环境:
from gym import envs
print(envs.registry.all())

前面的代码打印了一个字典,其中包含了 Gym 中所有可用的游戏。

  1. 为所选游戏创建环境:
env = gym.make('FrozenLake-v0', is_slippery=False)
  1. 检查创建的环境:
env.render()

上述代码会产生以下输出:

在上图中,代理从开始。这里, F 指定单元格被冻结,而 H 指定单元格中有一个洞。如果代理到达单元格 H 并且游戏终止,代理将获得 0 的奖励。游戏的目标是让代理达到 G

  1. 打印游戏中观察空间的大小(状态数):
env.observation_space.n

前面的代码给出了 16 的输出。这代表了游戏中的 16 个单元格。

  1. 打印可能操作的数量:
env.action_space.n

前面的代码产生的值为 4,表示可以采取的四种可能的操作。

  1. 对给定状态下的随机动作进行采样:
env.action_space.sample()

指定我们获取给定状态下四个可能动作中的一个。对应于每个动作的标量可以与动作的名称相关联。我们可以通过检查 GitHub 中的代码来做到:GitHub . com/open ai/gym/blob/master/gym/envs/toy _ text/frozen _ lake . py

  1. 将环境重置为其原始状态:
env.reset()
  1. 采取行动:
env.step(env.action_space.sample())

前面的代码获取下一个状态、奖励、表示游戏是否完成的标志以及其他信息。我们可以用.step来执行这个游戏,因为当环境给出一个动作的步骤时,它很容易提供下一个状态。

这些步骤构成了我们构建 Q 表的基础,该表规定了在每个状态下要采取的最佳行动。我们将在下一节中完成这项工作。

构建 Q 表

在上一节中,我们学习了如何手动计算各种状态-动作对的 Q 值。在本节中,我们将利用健身房环境和与之相关的各种模块来填充 Q 表,其中行代表代理可以处于的状态,列代表代理可以采取的行动。Q 表的值表示在给定状态下采取行动的 Q 值。

我们可以使用以下策略填充 Q 表的值:

  1. 用零初始化游戏环境和 Q 表。
  2. 采取随机行动,获取下一个状态、奖励、表明游戏是否完成的标志以及其他信息。
  3. 使用我们之前定义的贝尔曼方程更新 Q 值。
  4. 重复第 2 步第 3 步,这样一集最多有 50 步。
  5. 多次重复步骤 23、4

让我们编写前面的策略:

The following code is available as Building_Q_table.ipynb in the Chapter16 folder in this book's GitHub repository - tinyurl.com/mcvp-packt

  1. 初始化游戏环境:
import numpy as np
import gym
import random
env = gym.make('FrozenLake-v0', is_slippery=False)
  • 用零初始化 Q 表:
action_size=env.action_space.n
state_size=env.observation_space.n
qtable=np.zeros((state_size,action_size))

前面的代码检查可用于构建 Q 表的可能动作和状态。Q 表的维数应该是状态数乘以动作数。

  1. 采取随机行动的同时播放多集。在这里,我们在每集的结尾重置环境:
episode_rewards = []
for i in range(10000):
    state=env.reset()
  • 每集最多走 50 步:
    total_rewards = 0
    for step in range(50):

我们考虑每集最多 50 步,因为代理有可能永远在两种状态之间振荡(想象一下左右动作永远连续执行)。因此,我们需要指定代理可以执行的最大步骤数。

  • 对一个随机动作进行采样,并执行它:
        action=env.action_space.sample()
        new_state,reward,done,info=env.step(action)
  • 更新对应于状态和动作的 Q 值:
qtable[state,action]+=0.1*(reward+0.9*np.max(\
                            qtable[new_state,:]) \
                           -qtable[state,action])

在前面的代码中,我们指定学习率为 0.1,并通过考虑下一个状态(np.max(qtable[new_state,:]))的最大 Q 值来更新状态-动作组合的 Q 值。

  • state值更新为我们之前获得的new_state,并将reward累加到total_rewards:
        state=new_state
        total_rewards+=reward
  • 将奖励放入列表(episode_rewards)并打印 Q 表(qtable):
    episode_rewards.append(total_rewards)
print(qtable)

前面的代码获取某个状态下各种动作的 Q 值:

我们将在下一节了解如何利用获得的 Q 表。

到目前为止,我们每次都采取随机行动。然而,在现实场景中,一旦我们知道在某些状态下不能采取某些动作,反之亦然,我们就不再需要采取随机动作了。在这种情况下,探索开发的概念就派上了用场。

利用勘探开发

在上一节中,我们探讨了在给定空间中我们可以采取的可能行动。在本节中,我们将了解探索-开发的概念,具体如下:

  • 探索是一种策略,在这种策略中,我们了解在给定的状态下需要做什么(采取什么行动)。
  • 利用是一种策略,我们利用已经学到的东西;也就是说,在给定的状态下采取哪种动作。

在初始阶段,最好进行大量的探索,因为代理不知道最初应该采取什么样的最佳行动。通过情节,随着代理人随着时间的推移学习各种状态-动作组合的 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,info=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)

前面代码中的粗体行是添加到上一节中显示的代码中的内容。在这段代码中,我们指定,随着情节的增加,我们更多的是利用而不是探索。

一旦我们获得了 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,info=env.step(action) 
        if done:
            print("Number of Steps",step+1)
            break
        state=new_state
env.close()

在前面的代码中,我们获取代理所在的当前state,识别在给定的状态-动作组合中产生最大值的action,采取动作(step)获取代理所在的new_state对象,并重复这些步骤,直到游戏完成(终止)。

上述代码会产生以下输出:

注意,这是一个简化的例子,因为状态空间是离散的,导致我们建立一个 Q 表。如果状态空间是连续的(例如,状态空间是游戏当前状态的快照图像)会怎样?构建 Q 表变得非常困难(因为可能状态的数量非常大)。在这种情况下,深度 Q 学习就派上了用场。我们将在下一节了解这一点。

实现深度 Q 学习

到目前为止,我们已经学习了如何构建 Q-table,它通过多次重播一个游戏(在本例中是冰封湖游戏)来提供与给定状态-动作组合相对应的值。然而,当状态空间是连续的时(比如一个乒乓游戏的快照),可能的状态空间的数量变得巨大。我们将在本节以及接下来的几节中使用深度 Q 学习来解决这个问题。在本节中,我们将学习如何使用神经网络在没有 Q 表的情况下估计状态-动作组合的 Q 值——因此有术语 deep Q-learning。

与 Q 表相比,深度 Q 学习利用神经网络将任何给定的状态-动作(其中状态可以是连续的或离散的)组合映射到 Q 值。

在这个练习中,我们将在体育馆里练习侧手翻。在这里,我们的任务是尽可能长时间地保持侧翻平衡。下图显示了 CartPole 环境的外观:

请注意,当手推车向右移动时,杆子会向左移动,反之亦然。该环境中的每个状态都使用四个观察值来定义,它们的名称以及最小和最大值如下:

| 观察 | 最小值 | 最大值 |
| 推车位置 | -2.4 | Two point four |
| 推车速度 | -inf | 中程核力量 |
| 磁极角度 | -41.8° | 41.8° |
| 尖端的极点速度 | -inf | 中程核力量 |

注意,代表一个状态的所有观察值都是连续的。

在一个高层次上,用于横竿平衡游戏的深度 Q 学习工作如下:

  1. 获取输入值(游戏的图像/游戏的元数据)。
  2. 通过具有尽可能多的输出的网络传递输入值。
  3. 输出层预测对应于在给定状态下采取动作的动作值。

网络架构的高级概述如下:

在上图中,网络架构将状态(四个观察值)用作输入,将在当前状态下进行左右操作的 Q 值用作输出。我们按如下方式训练神经网络:

  1. 在探索阶段,我们执行一个在输出层中具有最高值的随机操作。
  2. 然后,我们将动作、下一个状态、奖励和表示游戏是否完成的标志存储在内存中。
  3. 在给定状态下,如果游戏没有完成,将计算在给定状态下采取一个动作的 Q 值;即奖励+折扣因子 x 下一个状态所有行动的最大可能 Q 值。
  4. 当前状态-动作组合的 Q 值保持不变,除了在步骤 2 中采取的动作。
  5. 多次执行步骤 14 并存储经验。
  6. 拟合一个模型,该模型将状态作为输入,将动作值作为预期输出(来自记忆和重放经验),并最小化 MSE 损失。
  7. 在多个情节中重复上述步骤,同时降低探索率。

有了前面的策略,让我们编写深度 Q 学习的代码,这样我们就可以执行横向平衡:

The following code is available as Deep_Q_Learning_Cart_Pole_balancing.ipynb in the Chapter16 folder in this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from and is moderately lengthy. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components from text.

  1. 导入相关包:
import gym
import numpy as np
import cv2
from collections import deque
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
device = 'cuda' if torch.cuda.is_available() else 'cpu'
  1. 定义环境:
env = gym.make('CartPole-v1')
  1. 定义网络架构:
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

请注意,该架构相当简单,因为它在两个隐藏层中仅包含 24 个单元。输出层包含尽可能多的单元。

  1. 定义Agent类,如下所示:
  • 用定义的各种参数、网络和经验定义__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
  • 定义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 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)
  • 定义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))

请注意,在前面的代码中,我们在确定要采取的操作时,正在执行探索利用。

  • 定义符合模型的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()

在前面的代码中,我们获取了采样的体验,并预测了我们执行的动作的 Q 值。此外,假设我们已经知道下一个状态,我们可以预测下一个状态中的动作的最佳 Q 值。通过这种方式,我们现在知道了在给定状态下对应于所采取的动作的目标值。

最后,我们将计算在当前状态下采取的动作的 Q 值的期望值(Q_targets)和预测值(Q_expected)之间的损失。

  • 定义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)
  1. 定义agent对象:
agent = Agent(env.observation_space.shape[0], \
              env.action_space.n)
  1. 执行深度 Q 学习,如下所示:
  • 初始化您的列表:
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
  • 重置每集的环境,并提取状态的形状。此外,重塑它,以便我们可以将它传递到网络:
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
  • 循环通过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])
  • 通过在当前状态的顶部指定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 
  • 存储、定期打印,如果前 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 {}\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))
    if i_episode>10 and np.mean(scores[-10:])>450:
        break
  1. 绘制分数随情节增加的变化图:
import matplotlib.pyplot as plt
%matplotlib inline
plt.plot(scores)
plt.title('Scores over increasing episodes')

显示分数随剧集变化的曲线图如下:

从上图中,我们可以看到,在第 2000 集之后,模特在平衡侧手翻时获得了高分。

现在我们已经了解了如何实现深度 Q 学习,在下一节中,我们将了解如何在不同的状态空间(Pong 中的视频帧)上工作,而不是在 CartPole 环境中定义状态的四个状态空间。我们还将学习如何使用固定目标模型实现深度 Q 学习。

用固定目标模型实现深度 Q 学习

在上一节中,我们学习了如何利用深度 Q-learning 来解决体育馆中的横竿环境。在本节中,我们将研究一个更复杂的 Pong 游戏,并了解深度 Q 学习以及固定目标模型如何解决这个游戏。在处理这个用例时,您还将学习如何利用一个基于 CNN 的模型(代替我们在上一节中使用的普通神经网络)来解决这个问题。

这个用例的目标是构建一个可以与计算机对抗的代理(一个预先训练过的、没有学习能力的代理),并在 Pong 游戏中击败它,在这个游戏中,代理应该获得 21 分。

我们将采用以下策略来解决为 Pong 游戏创建成功代理的问题:

裁剪图像的不相关部分,以获取当前帧(状态):

请注意,在前面的图像中,我们获取了原始图像,并在处理后的图像中裁剪了原始图像的顶部和底部像素:

  • 堆叠四个连续的帧–代理需要状态序列来理解球是否正在接近它。
  • 让代理最初采取随机行动,并在内存中不断收集当前状态、未来状态、采取的行动和奖励。仅在内存中保留最近 10,000 次操作的信息,并刷新超过 10,000 次的历史操作。
  • 建立一个网络(本地网络),从内存中提取状态样本,并预测可能的动作值。
  • 定义作为本地网络副本的另一个网络(目标网络)。
  • 本地网络每更新 1,000 次,就更新一次目标网络。目标网络在每 1000 个时期结束时的权重与本地网络的权重相同。
  • 利用目标网络计算下一状态最佳行动的 Q 值。
  • 对于局部网络建议的行动,我们期望它预测下一个状态中最佳行动的即时奖励和 Q 值的总和。
  • 最小化本地网络的 MSE 损耗。
  • 让代理人一直玩下去,直到它的回报最大化。

有了前面的策略,我们现在可以对代理进行编码,使其在玩 Pong 时获得最大回报。

编写一个代理来玩乒乓

按照以下步骤对代理进行编码,以便它能够自学如何玩 Pong:

The following code is available as Pong_Deep_Q_Learning_with_Fixed_targets.ipynb in the Chapter16 folder in this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from and is moderately lengthy. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components from text.

  1. 导入相关包并设置游戏环境:
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')
  1. 定义状态大小和动作大小:
state_size = env.observation_space.shape[0]
action_size = env.action_space.n
  1. 定义一个函数来预处理一个帧,以去除不相关的底部和顶部像素:
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
  1. 定义一个将堆叠四个连续帧的函数,如下所示:
  • 该函数以stacked_frames、当前stateis_new_episode标志为输入:
def stack_frames(stacked_frames, state, is_new_episode):
    # Preprocess frame
    frame = preprocess_frame(state)
    stack_size = 4
  • 如果这一集是新的,我们将从一堆初始帧开始:
    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)
  • 如果这一集不是新的,我们将从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
  1. 定义网络架构;也就是,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
  1. 像我们在上一节中所做的那样,定义Agent类,如下所示:
  • 定义__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 = 10000
        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

请注意,与上一节中提供的代码相比,我们在前面的代码中对__init__方法所做的唯一添加是target网络和目标网络的更新频率(这些行在前面的代码中以粗体显示)。

  • 定义将更新权重(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)
  • 定义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))
  • 定义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() 

注意,在前面的代码中,Q_targets_next是使用目标模型而不是上一节中使用的本地模型来预测的。我们还在每 1000 步之后更新目标网络,其中learn_every_target_counter是帮助确定我们是否应该更新目标模型的计数器。

  • 定义一个更新目标模型的函数(target_update):
    def target_update(self):
        print('target updating')
        self.target.load_state_dict(self.local.state_dict())
  • 定义一个从记忆中取样体验的函数:
    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)
  1. 定义Agent对象:
agent = Agent(state_size, action_size)
  1. 定义将用于培训代理的参数:
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) 
  1. 按照我们在上一节中所做的那样,对代理进行越来越多的培训:
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))

下图显示了分数随情节增加而变化的情况:

从前面的图像中,我们可以看到代理逐渐学会了玩 Pong,到 800 集结束时,它学会了如何在获得高额奖励的同时玩 Pong。

现在,我们已经训练了一个代理来玩好 Pong,在下一部分,我们将训练一个代理,以便它可以在模拟环境中自动驾驶汽车。

实现一个代理来执行自动驾驶

现在,您已经看到了 RL 在越来越具有挑战性的环境中工作,我们将通过展示相同的概念可以应用于自动驾驶汽车来结束本章。由于在实际的汽车上看到这种工作是不切实际的,我们将求助于模拟环境。环境将是一个成熟的交通城市,在道路的图像中有汽车和其他细节。演员(经纪人)是一辆车。汽车的输入将是各种感官输入,如 dashcam、光探测和测距 ( 激光雷达)传感器和 GPS 坐标。输出将是汽车移动的快慢,以及转向的水平。这种模拟将试图成为真实世界物理的精确表示。因此,请注意,无论是模拟汽车还是真实汽车,基本原理都是一样的。

注意,我们将要安装的环境需要一个图形用户界面 ( GUI )来显示模拟。此外,培训至少需要一天时间,如果不是更多的话。由于视觉设置的不可用性和 Google-Colab 的时间使用限制,我们将不会像迄今为止那样使用 Google-Colab 笔记本。这是本书中唯一需要一个活动的 Linux 操作系统的部分,最好是一个 GPU,以便在几天的培训中达到可接受的结果。

安装 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/
  1. 将 CARLA 添加到PYTHONPATH以便机器上的任何模块都可以导入 CARLA:
$ 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__"。您应该得到以下输出:

  1. 最后,提供必要的权限并执行 CARLA,如下所示:
$ chmod +x /home/$(whoami)/Documents/CARLA_0.9.6/CarlaUE4.sh
$ ./home/$(whoami)/Documents/CARLA_0.9.6/CarlaUE4.sh

一两分钟后,您应该会看到一个类似如下的窗口,显示 CARLA 作为模拟运行,准备接受输入:

在这一节中,我们已经验证了CARLA是一个模拟环境,它的二进制文件按预期工作。让我们继续为它安装健身房环境。让终端保持原样运行,因为在整个练习中,我们需要二进制文件在后台运行。

安装卡拉健身房环境

由于没有官方的健身房环境,我们将利用用户实现的 GitHub 存储库,从那里为 CARLA 安装健身房环境。按照以下步骤安装 CARLA 的健身房环境:

  1. 将健身房存储库克隆到您选择的位置并安装库:
$ 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 .

  1. 通过运行以下命令来测试您的设置:
$ python test.py

应该会打开一个类似下面的窗口,显示我们已经向环境中添加了一辆假汽车。从这里,我们可以监控俯视图、激光雷达传感器点云以及我们的 dashcam:

在这里,我们可以观察到以下情况:

  • 第一个视图包含与汽车中的车辆 GPS 系统非常相似的视图;也就是我们的车辆、各种路点和道路车道。但是,我们不会将此输入用于训练,因为它还会在视图中显示其他汽车,这是不现实的。
  • 第二种观点更有趣。有人认为它是自动驾驶汽车的眼睛。激光雷达每秒多次向周围环境发射脉冲光(各个方向)。它捕捉反射光,以确定该方向上最近的障碍物有多远。车载计算机整理所有最近的障碍物信息,以重建 3D 点云,使其对周围环境有 3D 了解。
  • 在第一个和第二个视图中,我们可以看到汽车前面有一个条形。这是一个路标,指示汽车应该去的地方。
  • 第三个视图是一个简单的仪表板摄像机。

除了这三个以外,CARLA 还提供其他传感器数据,例如:

  • lateral-distance(偏离它应该在的车道)
  • delta-yaw(相对于前方道路的角度)
  • speed
  • 如果车辆前方有危险障碍物
  • 还有更多...

我们将使用前面提到的前四个传感器,以及激光雷达和我们的 dashcam 来训练模型。

我们现在已经准备好了解卡拉的组成部分,并为自动驾驶汽车创建一个 DQN 模型。

培训自动驾驶代理

在笔记本中开始培训过程之前,我们将创建两个文件;也就是model.pyactor.py。这些将分别包含模型架构和Agent类。Agent类包含了我们用来训练代理的各种方法。

这一节的代码指令以Carla.md的形式出现在本书的 GitHub 库的Chapter16文件夹中。

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将接受激光雷达传感器生成的图像。最后,self.sensor_branch将以 NumPy 数组的形式接受四个传感器输入。这四个项目分别是横向距离(偏离预期车道)、偏航角(相对于前方道路的角度)、速度以及车辆前方是否有危险障碍物。相同的输出见gym_carla/envs/carla_env.py(已经被 git 克隆的存储库)中的第 543 行。在神经网络中使用不同的分支将让模块为每个传感器提供不同的重要性水平,并且输出被加起来作为最终输出。注意,有 9 个输出;我们将在以后研究这些。

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'
  1. 接下来,我们将初始化目标网络和本地网络。除了正在导入的模块之外,没有对上一节中的代码进行任何更改:
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)
  1. 由于需要处理更多的传感器,我们将把它们作为状态字典来传输。状态包含'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))
  1. 现在,我们需要从重放存储器中获取项目。以下指令在以下代码中执行:
    1. 获得一批当前和下一个状态。
    2. 如果网络在当前状态下执行动作,则计算期望报酬Q_expected
    3. 将它与目标回报Q_targets进行比较,该目标回报是在下一个状态被馈送到网络时获得的。
  2. 使用本地网络定期更新目标网络:
    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)
  1. ReplayBuffer类中唯一的主要变化是数据的存储方式。由于我们有多个传感器,每个内存(statesnext_states)都存储为一个数据元组;也就是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)

注意,粗体代码行获取当前状态、动作、奖励和下一个状态的信息。

既然关键组件已经就绪,让我们将健身房环境加载到 Python 笔记本中并开始训练。

用固定目标训练 DQN

这里没有我们需要学习的额外理论。基本保持不变;我们将只对健身房环境、神经网络的架构以及我们的代理需要采取的行动进行更改:

  1. 首先,加载与环境相关的超参数。在下面的代码中,引用params字典中出现的每个键值对旁边的每个注释。由于我们正在模拟一个复杂的环境,我们需要选择环境的参数,例如城市中的汽车数量、步行者数量、要模拟的城镇、dashcam 图像的分辨率以及激光雷达传感器:
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_acc':[-1,0,1]:自动驾驶汽车在模拟过程中允许的所有可能的加速度。
  • 'discrete_steer':[-0.3,0,0.3]:自动驾驶汽车在模拟过程中允许进行的所有可能的转向幅度。

如您所见,discrete_accdiscrete_steer列表各包含三个项目。这意味着汽车可以采取 3 x 3 种可能的独特行动。这意味着model.py文件中的网络有九个离散的状态。

一旦你看完了官方文档,你可以随意修改参数。

  1. 这样,我们就有了训练模型所需的所有组件。加载预训练模型(如果存在):
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
  1. 固定集数并定义dqn函数来训练代理,如下所示:
  • 重置状态:
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()
  • 将状态包装到一个字典中(如在actor.py:Actor类中所讨论的),并在上面放置act:
        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)
  • 存储从环境中获得的下一个状态,然后存储state, next_state对(以及奖励和其他状态信息),以使用 DQN 来训练演员:
            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)

我们必须重复这个循环,直到我们得到一个 done 信号,之后我们重置环境并再次开始存储动作。每 100 集之后,存储模型。

  1. 调用dqn函数训练模型:
dqn()

由于这是一个更复杂的环境,培训可能需要几天时间,所以请耐心等待,使用load_pathsave_path参数一次持续培训几个小时。经过足够的训练,车辆可以自行操纵和学习驾驶。下面是我们经过两天训练后所能达到的训练效果的视频:【https://tinyurl.com/mcvp-self-driving-agent-result

摘要

在本章中,我们学习了如何计算给定状态下各种动作的值。然后,我们学习了代理如何使用在给定状态下采取行动的贴现值来更新 Q 表。在这样做的过程中,我们了解了在状态数很高的情况下 Q 表是不可行的。我们还学习了如何利用深度 Q 网络来处理可能状态数量较多的情况。接下来,我们继续利用基于 CNN 的神经网络,同时构建一个代理,该代理学习如何使用基于固定目标的 DQN 玩乒乓球。最后,我们学习了如何利用固定目标的 DQN,使用 CARLA 模拟器执行自动驾驶。正如我们在本章中反复看到的那样,你可以使用深度 Q 学习来学习非常不同的任务——比如横拉杆平衡、玩 Pong 和自驾导航——而代码几乎相同。虽然这不是我们探索 RL 之旅的终点,但在这一点上,我们应该能够理解如何一起使用基于 CNN 和基于强化学习的算法来解决复杂问题和构建学习代理。

到目前为止,我们已经学会了如何将基于计算机视觉的技术与其他重要研究领域的技术相结合,包括元学习、自然语言处理和强化学习。除此之外,我们还学习了如何使用 GANs 执行对象分类、检测、分割和图像生成。在下一章中,我们将切换齿轮,并学习如何将深度学习模型转移到生产中。

问题

  1. 给定状态的值是如何计算的?
  2. Q 表是如何填充的?
  3. 为什么我们在国家行为价值计算中要有一个贴现因子?
  4. 我们需要什么样的勘探开发战略?
  5. 为什么我们需要使用深度 Q 学习?
  6. 如何使用深度 Q 学习计算给定状态-动作组合的值?
  7. 一旦代理人在横竿环境中实现了报酬最大化,它以后还有机会学习次优策略吗?

十七、将模型移动到生产环境

将一个模型转移到生产环境中是向外部团体使用我们的模型迈出的一步。我们应该向世界展示我们的模型,并开始根据真实的、看不见的输入进行预测。

仅有一个训练有素的 PyTorch 模型来进行部署是不够的。我们需要额外的服务器组件来创建从真实世界到 PyTorch 模型再回到真实世界的通信通道。重要的是,我们知道如何创建一个 API(用户可以通过它与模型进行交互),将它包装为一个自包含的应用程序(以便它可以部署在任何计算机上),并将其发送到云——以便任何拥有所需 URL 和凭证的人都可以与模型进行交互。要成功地将模型转移到生产中,所有这些步骤都是必要的。在本章中,我们将部署一个简单的应用程序,它可以从互联网上的任何地方访问。我们还将了解如何部署时尚 MNIST ( FMNIST )模型,并让任何用户上传他们想要分类并获取结果的图片。

本章将涵盖以下主题:

  • 理解 API 的基础知识
  • 在本地服务器上创建 API 并进行预测
  • 将 API 迁移到云中

理解 API 的基础知识

到目前为止,我们知道如何为各种任务创建深度学习模型。它接受/返回张量作为输入/输出。但是像客户/最终用户这样的局外人只会谈论图像和类。此外,他们希望通过与 Python 无关的通道发送和接收输入/输出。互联网是最容易沟通的渠道。因此,对于客户端来说,最好的部署场景是我们可以建立一个公开的 URL,并要求他们在那里上传他们的图像。一个这样的范例被称为应用编程接口 ( API ),它拥有标准协议,通过互联网接受输入和发布输出,同时将用户从如何处理输入或生成输出中抽象出来。

API 中的一些常见协议是 POST、GET、PUT 和 DELETE,它们作为请求由客户端连同相关数据一起发送到主机服务器。基于请求和数据,服务器执行相关的任务,并以响应的形式返回适当的数据——客户端可以在它们的下游任务中使用。在我们的例子中,客户端将发送一个 POST 请求,其中包含一幅服装图片作为文件附件。我们应该保存文件,处理它,并返回适当的 FMNIST 类作为对请求的响应,我们的工作就完成了。

请求是通过互联网发送的有组织的数据包,用于与 API 服务器通信。通常,请求中的组件如下:

  • 端点 URL :这将是 API 服务的地址。例如,https://www.packtpub.com/将是一个连接到 Packt Publishing 服务并浏览其最新图书目录的端点。
  • 头集合:该信息帮助 API 服务器返回输出;如果头部包含客户机在移动设备上的信息,那么 API 可以返回一个 HTML 页面,其布局是移动设备友好的。
  • 查询的集合,以便只获取服务器数据库中的相关项目。例如,在前面的例子中,搜索字符串PyTorch将只返回 PyTorch 相关的书籍。(在本章中,我们将不处理查询,因为对图像的预测不需要查询-它需要文件名。)
  • 可以上传到服务器的文件列表,或者在我们的情况下,用于进行深度学习预测。

是一个计算机软件项目,提供了一个库和命令行工具,用于使用各种网络协议传输数据。它是调用 API 请求并获得响应的最轻量级、最常用和最简单的应用程序之一。

我们将使用一个名为Fast-API的现成 Python 模块,它将使我们能够完成以下任务:

  1. 设置通信 URL。
  2. 当输入发送到 URL 时,接受来自各种环境/格式的输入。
  3. 将每种形式的输入转换为机器学习模型需要作为输入的精确格式。
  4. 用经过训练的基于深度学习的模型进行预测。
  5. 将预测转换成正确的格式,并用预测响应客户的请求。

我们将以 FMNIST 分类器为例来演示这些概念。

在理解了基本设置和代码之后,您可以为任何类型的深度学习任务创建 API,并通过本地机器上的 URL 提供预测。虽然这是创建应用程序的逻辑终点,但同样重要的是,我们要将它部署在任何无法访问我们的计算机或模型的人都可以访问的地方。

在接下来的两节中,我们将介绍如何将应用程序包装在一个自包含的 Docker 映像中,该映像可以在云上的任何地方发布和部署。一旦准备好 Docker 映像,就可以从它创建一个容器,并部署在任何主要的云服务提供商上,因为他们都接受 Docker 作为标准输入。我们将在本章的最后一节具体介绍在一个亚马逊 Web 服务 ( AWS ) 弹性计算云 ( EC2 )实例上部署 FMNIST 分类器的例子。在下一节中,让我们使用 Python 库 FastAPI 来创建 API,并验证我们可以直接从终端进行预测(无需 Jupyter 笔记本)。

在本地服务器上创建 API 并进行预测

在本节中,我们将学习在本地服务器上进行预测(这与云无关)。概括地说,这包括以下步骤:

  1. 安装 FastAPI
  2. 创建接受传入请求的路由
  3. 将传入请求保存在磁盘上
  4. 加载所请求的图像,然后用训练好的模型进行预处理和预测
  5. 对结果进行后处理,并将预测作为对同一传入请求的响应发回

All of the steps in this section are summarized as a video walk-through here: tinyurl.com/MCVP-Model2FastAPI.

让我们在下面的小节中开始安装 FastAPI。

安装 API 模块和依赖项

由于 FastAPI 是一个 Python 模块,我们可以使用pip进行安装,并准备编码一个 API。我们现在将打开一个新的终端并运行以下命令:

$pip install fastapi uvicorn aiofiles jinja2 

我们已经安装了 FastAPI 需要的几个依赖项。uvicorn是一个最小的底层服务器/应用程序接口,用于设置 API。aiofiles使服务器能够与请求异步工作,比如同时接受和响应多个独立的并行请求。这两个模块是 FastAPI 的依赖项,我们不会直接与它们交互。

让我们在下一节中创建所需的文件并对它们进行编码。

为图像分类器服务

第一步是建立一个文件夹结构,如下所示:

设置非常简单,如下所示:

  • files文件夹将作为接收请求的下载位置。
  • fmnist.weights.pth包含我们训练的 FMNIST 模型的权重。
  • fmnist.py将包含加载权重、接受输入图像、预处理、预测和后处理预测的逻辑。
  • server.py将包含 FastAPI 功能,可以建立一个 URL,接受来自 URL 的客户端请求,发送/接收来自fmnist.py的输入/输出,并发送输出作为对客户端请求的响应。

请注意:
files文件夹是空的,仅用于存储上传的文件。
我们假设训练模型的权重为fmnist.weights.pth

现在让我们来了解一下fmnist.pyserver.py的构成和编码。

fmnist.py

如前所述,fmnist.py文件应该具有加载模型并返回给定图像预测的逻辑。

我们已经熟悉了如何创建 PyTorch 模型。该类唯一的附加组件是predict方法,它用于对图像进行任何必要的预处理,并对结果进行后处理。

在下面的代码中,我们首先创建构成模型架构的模型类,它通过torch.load用最优权重初始化:

from torch_snippets import *

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

class FMNIST(nn.Module):
    classes = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 
    'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']
    def __init__(self, fpath='fmnist.weights.pth'):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(28 * 28, 1000),
            nn.ReLU(),
            nn.Linear(1000, 10)
        ).to(device)
        self.model.load_state_dict(torch.load(fpath))
        logger.info('Loaded FMNIST Model')

下面的代码块突出显示了forward方法:

@torch.no_grad() 
def forward(self, x):
    x = x.view(1, -1).to(device)
    pred = self.model(x)
    pred = F.softmax(pred, -1)[0]
    conf, clss = pred.max(-1)
    clss = self.classes[clss.cpu().item()]
    return conf.item(), clss

下面的代码块突出显示了进行必要的预处理和后处理的predict方法:

def predict(self, path):
    image = cv2.imread(path,0)
    x = np.array(image)
    x = cv2.resize(x, (28,28))
    x = torch.Tensor(255 - x)/255.
    conf, clss = self.forward(x)
    return {'class': clss, 'confidence': f'{conf:.4f}'}

__init__方法中,我们正在初始化模型并加载预训练的权重。在forward方法中,我们通过模型传递图像并获取预测。在predict方法中,我们从预定义的路径加载图像,在通过模型的forward方法之前对图像进行预处理,并在返回预测类及其置信度的同时将输出包装在字典中。

server.py

这是 API 中连接用户请求和 PyTorch 模型的代码部分。让我们一步一步地创建文件:

  1. 加载库:
import os, io
from fmnist import FMNIST
from PIL import Image
from fastapi import FastAPI, Request, File, UploadFile

FastAPI是将用于创建 API 的基本服务器类。

RequestFileUploadFile是客户端请求和它们将上传的文件的代理占位符。要了解更多细节,我们鼓励您阅读官方的 FastAPI 文档。

  1. 加载模型:
# Load the model from fmnist.py
model = FMNIST()
  1. 创建一个app模型,它可以为我们提供上传和显示的 URL:
app = FastAPI()
  1. "/predict"创建一个 URL,这样客户端就可以向"<hosturl>/predict"发送POST请求(我们将在下一节了解服务器<hosturl>,并接收响应:
@app.post("/predict")
def predict(request: Request, file:UploadFile=File(...)):
    content = file.file.read()
    image = Image.open(io.BytesIO(content)).convert('L')
    output = model.predict(image)
    return output

就是这样!我们有所有的组件来利用我们的图像分类器通过我们的本地服务器进行预测。让我们设置服务器并在本地服务器上做一些预测。

运行服务器

现在我们已经设置好了所有的组件,我们准备运行服务器。打开一个新的终端和cd包含fmnist.pyserver.py的文件夹:

  1. 运行服务器:
$ uvicorn server:app

您将看到类似这样的消息:

Uvicorn running on ...消息表明服务器已经启动并正在运行。

  1. 为了获取预测,我们将在终端中运行以下命令来获取对出现在/home/me/Pictures/shirt.png中的样本图像的预测:
$ curl -X POST "http://127.0.0.1:8000/predict" -H "accept: application/json" -H "Content-Type: multipart/form-data" -F "file=@/home/me/Pictures/shirt.png;type=img/png"

前面一行代码的主要组件如下:

  • API 协议:我们正在调用的协议是POST,表示要将自己的数据发送给服务器。

  • URL–服务器地址:服务器主机 URL 为http://127.0.0.1:8000 /(为本地服务器,8000为默认端口)/predict/为客户端创建POST请求的路径;未来的客户必须将他们的数据上传到 URL-http://127.0.0.1:8000/predict

  • 报头:请求包含了-H标志形式的组件。这些解释了附加信息,例如:

    • 输入内容类型是什么—multipart/form-data——这是 API 术语,表示输入数据是文件形式的
    • 期望的输出类型是什么-application/json-这意味着 JSON 格式。还有其他格式,如 XML、text 和 octet-stream,根据所生成的输出的复杂程度而定。
  • 文件:最后一个-F标志指向我们要上传的文件所在的位置,以及它的类型。

一旦我们运行了前面的代码,输出字典将在终端中打印出来:

我们现在可以从本地服务器获取模型预测。在下一节中,我们将着眼于从云中获取模型预测,以便任何用户都可以获得模型预测。

将 API 迁移到云中

到目前为止,我们已经了解了如何在本地服务器上进行预测(http://127.0.0.1是本地服务器的 URL,在 web 上无法访问)——因此,只有本地机器的所有者才能使用该模型。在本节中,我们将了解如何将这个模型迁移到云中,以便任何人都可以使用图像进行预测。

一般来说,公司在冗余机器中部署服务以确保可靠性,并且对云提供商提供的硬件几乎没有控制。跟踪所有文件夹及其代码,或者复制粘贴所有代码,然后安装所有依赖项,确保代码在新环境中按预期工作,并在所有云机器上转发端口,这并不方便。对于每台新机器上的相同代码,需要遵循太多的步骤。重复这些步骤对开发人员来说是浪费时间,而且这样的过程很容易出错。

我们宁愿安装一个包含所有内容的包,也不愿安装多个单独的包(比如运行应用程序所需的单独模块和代码)并在以后连接它们。因此,我们能够将整个代码库和模块打包到单个包(类似于 Windows 中的.exe文件)中变得很重要,这样该包就可以用很少的一个命令来部署,并且仍然确保它在所有硬件上完全一样地工作。为此,我们需要学习如何使用 Docker——它本质上是一个带有代码的精简操作系统。创建的 Docker 容器是轻量级的,将只执行我们希望它们执行的任务。在我们的示例中,我们将创建的 Docker 映像将运行 API 来执行预测 FMNIST 映像类的任务。但首先,让我们了解一些 Docker 术语。

比较 Docker 容器和 Docker 图像

Docker 映像是一个标准的软件单元,它打包了代码及其所有依赖关系。这样,应用程序可以快速可靠地从一个计算环境运行到另一个计算环境。Docker 映像是一个轻量级的、独立的、可执行的软件包,包括运行应用程序所需的一切:代码、运行时、系统工具、系统库和设置。

一个 Docker 容器是映像的快照,它将在需要部署的任何地方被实例化。我们可以从单个映像创建任意数量的 Docker 映像副本,它们都应该执行相同的任务。将图像视为父副本,将容器视为子副本。

概括地说,我们将执行以下任务:

  1. 创建 Docker 图像。用它创建一个 Docker 容器并测试它。
  2. 将 Docker 映像推送到云端。
  3. 在云上构建 Docker 容器。
  4. 在云上部署 Docker 容器。

创建 Docker 容器

在上一节中,我们构建了一个 API,它获取一个图像并返回图像的类别以及与本地服务器上的图像类别相关联的概率。现在,是时候将我们的 API 打包成一个可以在任何地方运输和部署的包了。

Ensure Docker is installed on your machine. You can refer to docs.docker.com/get-docker/ for instructions on the installation.

创建 Docker 容器的过程有四个步骤:

  1. 创建一个requirements.txt文件。
  2. 创建 Dockerfile 文件。
  3. 建立码头工人形象。
  4. 从映像创建一个 Docker 容器并测试它。

The code in the following sections is also summarized as a video walkthrough here: tinyurl.com/MCVP-Model2FastAPI. The relevant part of this section starts at the 2-minute mark in the video.

我们现在将浏览并理解这四个步骤,在下一节中,我们将学习如何将映像发送到 AWS 服务器。

创建 requirements.txt 文件

我们需要告诉 Docker 镜像安装哪些 Python 模块来运行应用程序。requirements.txt文件包含所有这些 Python 模块的列表:

  1. 打开终端,进入包含fmnist.pyserver.py的文件夹。接下来,我们将创建一个空白虚拟环境,并在本地终端的根文件夹中激活它:
$ python3 -m venv fastapi-venv
$ source fastapi-env/bin/activate

我们创建空白虚拟环境的原因是为了确保环境中只安装了所需的模块,这样在发货时,我们就不会浪费宝贵的空间。

  1. 安装所需的软件包(fastapiuvicornaiofilestorchtorch_snippets,以运行 FMNIST 应用程序:
$ pip install fastapi uvicorn aiofiles torch torch_snippets
  1. 在同一个终端中,运行以下命令来安装所有必需的 Python 模块:
$ pip freeze > requirements.txt

前面的代码将所有 Python 模块及其对应的版本号提取到requirements.txt文件中,该文件将用于在 Docker 映像中安装依赖项:

我们可以打开文本文件,它看起来类似于前面的截图。现在我们已经有了所有的先决条件,让我们在下一节创建 Dockerfile。

创建 Dockerfile 文件

正如上一节所介绍的,Docker 映像是一个自包含的应用程序,拥有自己的操作系统和依赖项。给定一个计算平台(比如 EC2 实例),映像可以独立工作,执行它被设计来执行的任务。为此,我们需要为 Docker 应用程序提供启动应用程序所需的指令——依赖项、代码和命令。让我们在根目录下一个名为Dockerfile的文本文件中创建这些指令,这个根目录包含我们的 FMINST 项目的server.pyfmnist.py——我们已经在创建项目文件夹后放置了这些指令。按照惯例,该文件需要命名为Dockerfile(无扩展名)。文本文件的内容如下:

FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7
COPY ./requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
WORKDIR /app
COPY . /app
EXPOSE 5000
CMD ["uvicorn", "server:app", "--host", "0.0.0.0"]

让我们一步步理解前面的代码:

  1. FROM正在指示使用哪个操作系统库。tiangolo/uvicorn-gunicorn-fastapi:python3.7位置是 Docker 从互联网解析的一个地址,它获取一个已经安装了 Python 和其他 FastAPI 模块的基础映像。
  2. 接下来,我们复制我们创建的requirements.txt文件。这提供了我们想要安装的包。在下一行中,我们要求图像pip install包。
  3. WORKDIR是我们的应用程序将要运行的文件夹。因此,我们在 Docker 映像中创建了一个名为/app的新文件夹,并将根文件夹的内容复制到映像的/app文件夹中。
  4. 最后,我们像上一节一样运行服务器。

这样,我们就建立了一个蓝图,从头开始创建一个全新的操作系统和文件系统(把它想象成一个新的 Windows installable CD),它将只包含我们指定的代码,并且只运行一个应用程序,即 FastAPI。

构建 Docker 映像并创建 Docker 容器

注意,到目前为止,我们只创建了 Docker 映像的蓝图。让我们构建图像,并用它创建一个容器。

从同一终端运行以下命令(我们位于包含应用程序文件的根目录中):

  1. 构建 Docker 映像,并将其标记为fmnist:latest:
$ docker build -t fmnist:latest .

在一长串输出之后,我们得到以下内容,告诉我们映像已经构建好了:

我们已经成功地创建了一个名为fmnist:latest的 Docker 映像(其中fmnist是映像名,latest是我们给的一个标签,表示它的版本号)。Docker 在系统中维护一个注册表,所有这些图像都可以从该注册表中访问。这个 Docker 注册表现在包含一个独立的映像,其中包含运行 FMNIST API 的所有代码和逻辑。

我们总是可以通过在命令提示符下键入$ docker image ls来检查 Docker 注册表:

  1. 在本地机器上运行构建好的映像,将端口5000从映像内部转发到端口5000。最后一个参数是从图像创建的容器的名称:
$ docker run -p 5000:5000 fmnist:latest

Port forwarding is important. Often, we don't have a say on which ports the cloud is exposing. Hence, as a matter of demonstration, even though our uvicorn model created a 5000 port for the POST operation, we are still using Docker's functionality to route external requests from 5000 to 5000, which is where uvicorn is listening.

这将给出最后几行的提示,如下所示:

  1. 现在,从一个新的终端运行一个curl请求,并访问上一节中描述的 API,但是这一次,应用程序由 Docker 提供服务:

尽管到目前为止我们还没有将任何东西迁移到云中,但是将 API 封装在 Docker 中使我们再也不用担心pip install或者复制粘贴代码了。接下来,我们将把它交付给云提供商,并向全世界发布这款应用。

现在,您可以将图像发送到任何装有 Docker 的计算机上。不管我们将它装载到什么类型的计算机上,调用docker run总是会创建一个容器,它将完全按照我们想要的方式工作。我们不再需要担心pip install或者复制粘贴代码。

在云端运输和运行 Docker 容器

我们将依靠 AWS 来满足我们的云需求。出于我们的目的,我们将使用 AWS 的两个免费产品:

  • 弹性容器注册表 ( ECR ):在这里,我们将存储我们的 Docker 图像。
  • 这里,我们将创建一个 Linux 系统来运行我们的 API Docker 映像。

对于这一部分,让我们只关注它的 ECR 部分。下面是我们将 Docker 映像推送到云的步骤的概要:

  1. 在本地机器上配置 AWS。
  2. 在 AWS ECR 上创建 Docker 存储库,并推送fmnist:latest映像。
  3. 创建一个 EC2 实例。
  4. 在 EC2 实例上安装依赖项。
  5. 在 EC2 实例上创建并运行 Docker 映像。

The code in the following sections is also summarized as a video walkthrough here: tinyurl.com/MCVP-FastAPI2AWS.

让我们实现前面的步骤,在下一节中从配置 AWS 开始。

配置 AWS

我们将从命令提示符登录 AWS,并推送我们的 Docker 映像。让我们一步一步来:

  1. aws.amazon.com/创建一个 AWS 账户并登录。
  2. 在本地机器上安装 AWS CLI(包含 Docker 映像)。

The AWS CLI is a command-line interface application for all Amazon services. It should be installed from the official website for your operating system first. Visit docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html for more details.

  1. 通过在本地终端中运行aws --version来验证它是否已安装。
  2. 配置 AWS CLI。从aws.amazon.com/处获得以下代币:
    • aws_account_id
    • 访问密钥 ID
    • 秘密访问密钥
    • 地区

我们可以在 AWS 的身份和访问管理 ( IAM )部分找到所有前述变量。在终端中运行aws configure,并在被询问时给出适当的凭证:

$ aws configure
AWS Access Key ID [None]: *AKIAIOSFODNN7EXAMPLE*
AWS Secret Access Key [None]:*wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY*
Default region name [None]: *region*
Default output format [None]:*json* 

我们现在已经从电脑上登录了亚马逊的服务。原则上,我们可以直接从终端访问他们的任何服务。在下一节中,让我们连接到 ECR 并推送 Docker 映像。

在 AWS ECR 上创建 Docker 存储库并推送映像

现在,我们将创建 Docker 存储库,如下所示:

  1. 配置完成后,使用以下命令登录 AWS ECR(以下代码都是一行),在以下代码中以粗体显示的位置提供前面的区域和帐户 ID 详细信息:
$ aws ecr get-login-password --region region | docker login --username AWS --password-stdin aws_account_id.dkr.ecr.region.amazonaws.com

前面的代码行创建并连接到 Amazon cloud 中您自己的 Docker 注册中心。与本地系统中的 Docker 注册表非常相似,这是映像将要驻留的地方,但是它将位于云中。

  1. 通过运行以下命令,从 CLI 创建存储库:
$ aws ecr create-repository --repository-name fmnist_app

使用前面的代码,现在在云中创建了一个可以保存 Docker 图像的位置。

  1. 通过运行以下命令来标记您的本地映像,以便当您推送映像时,它将被推送到标记的存储库。记得在下面代码的粗体部分给出您自己的aws_accound_idregion值:
$ docker tag fmnist:latest aws_account_id.dkr.ecr.region.amazonaws.com/fmnist_app
  1. 运行以下命令将本地 Docker 映像推送到云中的 AWS 存储库:
$ docker push aws_account_id.dkr.ecr.region.amazonaws.com/fmnist_app

我们已经成功地在云中为我们的 API 创建了一个位置,并将 Docker 映像推送到这个位置。正如您现在所知道的,这个映像已经有了运行 API 的所有组件。唯一剩下的方面是在云中创建一个 Docker 容器,这样我们就成功地将我们的应用程序转移到生产环境中了!

创建 EC2 实例

将 Docker 映像推送到 AWS ECR 就像将代码推送到 GitHub 存储库一样。它只是驻留在一个地方,我们仍然需要用它来构建应用程序。

为此,您必须创建一个 Amazon EC2 实例来服务您的 web 应用程序:

  1. 进入 AWS 管理控制台的搜索栏,搜索EC2

  2. 选择启动实例。

  3. 您将获得一个可用实例的列表。AWS 在免费层提供了许多实例。我们在这里选择了具有 20 GB 空间的 Amazon Linux 2 AMI - t2.micro 实例(您也可以使用其他实例,但要记得相应地更改配置)。

  4. 在配置实例创建时,在 Configure Security Group 部分,添加一个带有自定义 TCP 集的规则,并将端口范围设置为5000(因为我们已经在 Docker 映像中公开了端口5000,如下所示:

  1. 在最后一步的启动实例弹出窗口中(见下面的截图),创建一个新的密钥对(这将下载一个.pem文件,这是登录实例所需要的)。这就像一个密码,所以不要丢失这个文件:

  1. .pem文件移动到一个安全的位置,并通过运行chmod 400 fastapi.pem更改其权限。

此时,您应该在 EC2 仪表板中看到一个实例正在运行:

  1. 复制 EC2 实例名称,如下所示:
ec2-18-221-11-226.us-east-2.compute.amazonaws.com
  1. 在本地终端中使用以下命令登录到 EC2 实例:
$ ssh -i fastapi.pem ec2-user@ec2-18-221-11-226.us-east-2.compute.amazonaws.com

我们已经创建了一个具有必要空间和操作系统的 EC2 实例。此外,我们能够从机器上公开端口8000,并且还可以记录该机器的公共 URL(该 URL 将被客户端用于发送POST请求)。最后,我们成功地使用下载的.pem文件登录了它,就像对待其他可以安装软件的机器一样对待 EC2 机器。

提取图像并构建 Docker 容器

让我们安装在 EC2 机器上运行 Docker 映像的依赖项,然后我们就可以运行 API 了。以下命令都需要在我们在上一节中登录的 EC2 控制台中运行(上一节的s步骤 8 ):

  1. 在 Linux 机器上安装和配置 Docker 映像:
$ sudo yum install -y docker
$ sudo groupadd docker
$ sudo gpasswd -a ${USER} docker
$ sudo service docker restart

groupaddgpasswd确保 Docker 拥有运行所需的所有权限。

  1. 像前面一样,在 EC2 实例中配置 AWS,并重新启动机器:
$ aws configure
AWS Access Key ID [None]: *AKIAIOSFODNN7EXAMPLE*
AWS Secret Access Key [None]:*wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY*
Default region name [None]: *us-west-2*
Default output format [None]:*json* $ reboot 
  1. 使用以下命令从本地终端再次登录到实例:
$ ssh -i fastapi.pem ec2-user@ec2-18-221-11-226.us-east-2.compute.amazonaws.com
  1. 现在,从 EC2 登录控制台(安装了 Docker)登录到 AWS ECR(在下面的代码中更改以粗体显示的区域):
$ aws ecr get-login --region region --no-include-email

  1. 复制前面代码的输出,然后粘贴并在命令行中运行它。一旦您成功登录到 AWS ECR,您将在控制台中看到登录成功
  2. 从 AWS ECR 中提取 Docker 图像:
$ docker pull aws_account_id.dkr.ecr.region.amazonaws.com/fmnist_app:latest

  1. 最后,在 EC2 机器上运行提取的 Docker 映像:
docker run -p 5000:5000 aws_account_id.dkr.ecr.region.amazonaws.com/fmnist_app

我们在 EC2 上运行我们的 API。我们所要做的就是获得机器的公共 IP 地址,并用这个地址代替127.0.0.1运行curl请求。您可以在页面右侧的 EC2 仪表板上找到该地址:

  1. 现在,您可以从任何一台计算机上调用一个POST请求,EC2 实例将对其做出响应,预测我们上传的服装图像的类型:
$ curl -X POST "http://54.229.16.169:5000/predict" -H "accept: application/json" -H "Content-Type: multipart/form-data" -F "file=@/home/me/Pictures/shirt.png;type=img/png"

上述代码会产生以下输出:

{"class":"Coat","confidence":"0.6488"}

在本节中,我们能够安装 EC2 的依赖项,提取 Docker 映像,并运行 Docker 容器,使任何具有 URL 的用户能够对新映像进行预测。

摘要

在这一章中,我们学习了将模型转移到生产中还需要哪些额外的步骤。我们学习了什么是 API 以及它的组件是什么。在创建一个 API 之后,通过使用 FastAPI,我们浏览了创建 API 的 Docker 映像的核心步骤。使用 AWS,我们在云中创建了自己的 Docker 注册中心,并逐步将我们的 Docker 映像推送到那里。我们看到了创建 EC2 实例和安装所需的库,从 ECR 中提取 Docker 映像,从它构建 Docker 容器,并部署它供任何用户进行预测。

在下一章,也是最后一章,我们将学习 OpenCV,它有一些实用程序可以帮助解决一些受限环境中与图像相关的问题。我们将通过五个不同的用例来了解如何利用 OpenCV 进行图像分析。学习 OpenCV 的功能将进一步增强我们的计算机视觉技能。

十八、将 OpenCV 实用工具用于图像分析

到目前为止,在前面的章节中,我们已经学习了利用各种技术来执行对象分类、定位和分割,以及生成图像。虽然所有这些技术都利用深度学习来解决任务,但对于相对简单和定义良好的任务,我们可以利用 OpenCV 包中提供的特定功能。例如,如果需要检测的物体总是具有相同背景的相同物体,则我们不需要 YOLO。在图像来自受限环境的情况下,OpenCV 实用程序很有可能在很大程度上帮助解决这个问题。

在本章中,我们将只讨论几个用例,因为要讨论的实用程序实在太多了,以至于需要专门写一本关于 OpenCV 的书。在进行单词检测时,您将了解图像膨胀、腐蚀和提取连接组件周围的轮廓。之后,您将学习 Canny 边缘检测,以识别图像中对象的边缘。此外,您将了解在视频/图像背景中使用绿色屏幕的优势,同时对图像执行逐位运算以识别感兴趣的色彩空间。然后,您将了解一种通过将两幅图像拼接在一起来创建全景视图的技术。最后,您将了解如何利用预先训练的级联过滤器来识别对象,如车牌。

在本章中,我们将了解以下主题:

  • 在图像中的单词周围绘制边框
  • 检测道路图像中的车道
  • 基于颜色检测物体
  • 构建图像的全景视图
  • 检测汽车的牌照

在图像中的单词周围绘制边框

设想一个场景,您正在构建一个模型,该模型根据文档的图像执行单词转录。第一步是识别单词在图像中的位置。主要有两种方法来识别图像中的单词:

  • 使用深度学习技术,如 CRAFT、EAST 等
  • 使用基于 OpenCV 的技术

在这一部分,我们将了解如何在没有利用深度学习的情况下,在干净的图像中识别机器打印的单词。由于背景和前景之间的对比度很高,你不需要像 YOLO 这样的矫枉过正的解决方案来识别单个单词的位置。在这些场景中,使用 OpenCV 将会非常方便,因为我们可以用非常有限的计算资源得到一个解决方案,因此,即使是推理时间也会非常短。唯一的缺点是准确性可能不是 100%,但这也取决于扫描图像的清洁程度。如果扫描保证非常非常清晰,那么你可以期待接近 100%的准确性。

在较高层次上,让我们了解如何识别/隔离图像中的单词:

  1. 将图像转换为灰度,因为颜色不会影响图像中的识别文字。
  2. 稍微放大图像中的内容。膨胀将黑色像素渗色到紧邻的邻域中,从而连接同一个单词的字符之间的黑色像素。这有助于确保属于同一个单词的字符是相连的。但是,不要扩展得太多,否则属于不同相邻单词的字符也会连接起来。
  3. 一旦字符被连接,利用cv2.findContours方法在每个单词周围画一个边界框。

让我们编写前面的策略:

The following code is available as Drawing_bounding_boxes_around_words_in_an_image.ipynb in the Chapter18 folder of this book's GitHub repository - tinyurl.com/mcvp-packt Be sure to copy the URL from the notebook in GitHub to avoid any issue while reproducing the results

  1. 让我们从下载一个示例图像开始:
!wget https://www.dropbox.com/s/3jkwy16m6xdlktb/18_5.JPG
  1. 使用以下代码行查看下载的图像:
import cv2, numpy as np
img = cv2.imread('18_5.JPG')
img1 = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
import matplotlib.pyplot as plt,cv2
%matplotlib inline
plt.imshow(img1)

上述代码将返回以下输出:

  1. 将输入图像转换为灰度图像:
img_gray = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
  1. 获取原始图像的随机裁剪:
crop = img_gray[250:300,50:100]
plt.imshow(crop,cmap='gray')

上述代码会产生以下输出:

从前面的输出中,我们可以看到有一些像素包含噪声。接下来,我们将消除原始图像中的噪声。

  1. 将输入灰度图像二值化:
_img_gray = np.uint8(img_gray < 200)*255

前面的代码导致值小于 200 的像素的值为0,而亮的像素(像素强度大于 200)的值为255

  1. 查找图像中出现的各种人物的轮廓:
contours,hierarchy=cv2.findContours(_img_gray, \
                   cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)

cv2通过创建一组连续的像素作为对象的单个斑点来寻找轮廓。参考下面的截图了解cv2.findContours的工作原理:

  1. 将之前获得的阈值图像转换为具有三个通道,以便我们可以绘制字符周围的彩色边界框:
thresh1 = np.stack([_img_gray]*3,axis=2)
  1. 创建一个空白图像,以便我们可以将相关内容从thresh1复制到新图像中:
thresh2 = np.zeros((thresh1.shape[0],thresh1.shape[1]))
  1. 获取上一步中获得的轮廓,并在提到轮廓的地方绘制一个带有矩形的边界框。此外,将与thresh1图像中的外接矩形相对应的内容复制到thresh2:
for cnt in contours:
    if cv2.contourArea(cnt)>0:
        [x,y,w,h] = cv2.boundingRect(cnt)
        if ((h>5) & (h<100)):
            thresh2[y:(y+h),x:(x+w)] = thresh1[y:(y+h), \
                                       x:(x+w),0].copy()
            cv2.rectangle(thresh1,(x,y),(x+w,y+h),(255,0,0),2)

在前面的代码行中,我们只提取面积大于 5 个像素的轮廓,也只提取边界框高度在 5 到 100 个像素之间的轮廓(这样,我们就排除了太小的边界框,它们很可能是噪声,以及可能包含整个图像的大边界框)。

  1. 绘制结果图像:
fig = plt.figure()
fig.set_size_inches(20,20)
plt.imshow(thresh1)

上述代码获取以下输出:

到目前为止,我们可以在字符周围绘制边框,但是如果我们想在单词周围绘制边框,我们需要将单词中的像素组合成一个连续的单元。接下来,我们将看看如何利用单词膨胀技术在单词周围绘制边界框。

  1. 检查填充的图像,thresh2:
fig = plt.figure()
fig.set_size_inches(20,20)
plt.imshow(thresh2)

生成的图像如下所示:

现在,要解决的问题是如何将不同字符的像素连接成一个,使一个连续的像素集合构成一个单词。

我们使用一种叫做膨胀的技术(使用cv2.dilate),将白色像素渗透到周围的像素中。出血量取决于果仁的大小。如果内核大小是 5,那么白色区域的所有边界向外移动 5 个像素。直观的解释参考下面的截图:

  1. 使用 1 行 2 列的内核大小展开:
dilated = cv2.dilate(thresh2, np.ones((1,2),np.uint8), \
                    iterations=1)

请注意,我们指定了 1 行 2 列(np.ones((1,2),np.uint8))的内核大小,这样相邻的字符很可能会有一些交集。这样,cv2.findContours现在可以包含彼此非常接近的字符。

然而,如果内核大小更大,扩展的单词可能有一些交集,导致组合的单词被捕获在一个边界框中。

  1. 获取放大图像的轮廓:
contours,hierarchy = cv2.findContours(np.uint8(dilated), \
                    cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
  1. 在原始图像上绘制放大图像的轮廓:
for cnt in contours:
    if cv2.contourArea(cnt)>5:
        [x,y,w,h] = cv2.boundingRect(cnt)
        if ((h>5) & (h<100)):
            cv2.rectangle(img1,(x,y),(x+w,y+h),(255,0,0),2)
  1. 用等高线绘制原始图像:
fig = plt.figure()
fig.set_size_inches(20,20)
plt.imshow(img1)

上述代码会产生以下输出:

由此,您可以看到我们获取了一个对应于每个单词的边界框。

要学习的关键方面是我们如何识别像素的集合形成单个连接的单元,以及如果像素的集合没有形成单元,如何使用膨胀来操纵它们。当膨胀出血黑色像素时,有一个类似的函数叫做erode出血白色像素。我们鼓励你进行侵蚀并理解它是如何自己运作的。

到目前为止,我们已经学习了如何在图像中寻找人物(物体)的轮廓。在下一节中,我们将学习识别图像中的线条。

检测道路图像中的车道

想象一下这样一个场景,您必须检测道路图像中的车道。解决这个问题的一种方法是利用深度学习中的语义分割技术。使用 OpenCV 解决这个问题的传统方法之一是使用边缘和线检测器。在本节中,我们将了解边缘检测和线检测如何帮助识别道路图像中的车道。

在这里,我们将概述对该战略的高度理解:

  1. 找出图像中各种物体的边缘。
  2. 识别沿直线且相连的边。
  3. 将识别的线从图像的一端延伸到另一端。

让我们制定我们的策略:

以下代码在本书的 GitHub 知识库的Chapter18文件夹中以detecting_lanes_in_the_image_of_a_road.ipynb的形式提供-【https://tinyurl.com/mcvp-packt】请务必从 GitHub 中的笔记本上复制 URL,以避免在复制结果时出现任何问题

  1. 下载示例图像:
!wget https://www.dropbox.com/s/0n5cs04sb2y98hx/road_image3.JPG
  1. 导入包并检查映像:
!pip install torch_snippets
from torch_snippets import show, read, subplots, cv2, np
IMG = read('road_image3.JPG',1)
img = np.uint8(IMG.copy())

导入的图像如下所示:

图像中有太多的信息,我们只对直线感兴趣。一种快速获取图像边缘的方法是使用 Canny 边缘检测器,当颜色发生剧烈变化时,它会将某些东西识别为边缘。颜色变化在技术上取决于图像中像素的梯度。两个像素的差异越大,像素代表物体边缘的可能性就越高。

  1. 使用cv2.Canny边缘检测技术提取与图像内容相对应的边缘:
blur_img = cv2.blur(img, (5,5))
edges = cv2.Canny(blur_img,150,255)
edges_org = cv2.Canny(img,150,255)
subplots([img,edges_org,blur_img,edges],nc=4, \
        titles=['Original image','Edges of original image', \
        'Blurred image','Edges of blurred image'],sz=15)

在前面的代码中,我们首先使用cv2.blur对原始图像进行模糊处理,我们查看一个 5 x 5 的小块,获取该小块中像素值的平均值,并用每个像素周围像素值的平均值替换中心元素。

当使用cv2.Canny方法计算边缘时,值150255代表对应于边缘的最小和最大可能梯度值。注意,如果一个像素的一边具有某个像素值,而另一边具有与另一边上的像素显著不同的像素值,则该像素是边缘。

原始图像和模糊图像的图像和边缘如下所示:

从前面我们可以看到,当我们对原始图像进行模糊处理时,边缘更符合逻辑。既然边缘已经确定,我们只需要从图像中获取直线。这是使用HoughLines技术完成的。

  1. 使用cv2.HoughLines方法识别长度至少为 100 像素的线条:
lines = cv2.HoughLines(edges,1,np.pi/180,100)

注意,100的参数值指定被识别的线的长度应该至少为 100 个像素。

在这种情况下,获得的线具有 9×1×2 的形状;即图像中有九条线,每条线都有自己离图像左下角的距离和对应的角度(在极坐标中一般称为[rho, theta])。

  1. 画出不太水平的线:
lines = lines[:,0,:]
for rho,theta in lines:
    a = np.cos(theta)
    b = np.sin(theta)
    x0 = a*rho
    y0 = b*rho
    x1 = int(x0 + 10000*(-b))
    y1 = int(y0 + 10000*(a))
    x2 = int(x0 - 10000*(-b))
    y2 = int(y0 - 10000*(a))
    if theta < 75*3.141/180 or theta > 105*3.141/180:
        cv2.line(blur_img,(x1,y1),(x2,y2),(255,0,0),1)

show(blur_img,sz=10, grid=True)

上述代码生成以下输出:

总之,我们首先通过执行模糊和边缘检测从图像中过滤掉所有可能的噪声。只有几个像素仍然是车道的可能候选者。接下来,使用HoughLines,我们进一步过滤掉不是至少 100 个像素的直线的候选。虽然在该图像中道路上的车道被相当好地检测到,但是不能保证前面的逻辑在道路的每一幅图像上都有效。作为练习,在一些不同的道路图像上尝试上述过程。在这里,您将体会到深度学习在使用 OpenCV 进行车道检测方面的强大功能,其中模型学习在各种各样的图像上进行准确预测(假设我们在各种各样的图像上训练模型)。

基于颜色检测物体

绿屏是一种经典的视频编辑技术,我们可以让某人看起来好像站在一个完全不同的背景前。这在天气预报中被广泛使用,记者指向移动的云和地图的背景。这项技术的诀窍在于,记者从不穿某种颜色的衣服(比如说绿色),而是站在一个只有绿色的背景前。然后,识别绿色像素将识别什么是背景,并有助于仅替换这些像素处的内容。

在本节中,我们将了解如何利用cv2.inRangecv2.bitwise_and方法来检测任何给定图像中的绿色。

我们将采取的策略如下:

  1. 将图像从 RGB 转换到 HSV 空间。
  2. 指定对应于绿色的 HSV 空间的上限和下限。
  3. 确定有绿色的像素-这将是蒙版。
  4. 在原始图像和掩模图像之间执行bitwise_and操作。

上述策略在代码中实现如下:

The following code is available as Detecting_objects_based_on_color.ipynb in the Chapter18 folder of this book's GitHub repository - tinyurl.com/mcvp-packt Be sure to copy the URL from the notebook in GitHub to avoid any issue while reproducing the results

  1. 获取映像并安装所需的软件包:
!wget https://www.dropbox.com/s/utrkdooh08y9mvm/uno_card.png
!pip install torch_snippets
from torch_snippets import *
import cv2, numpy as np
  1. 读取图像并转换到 HSV ( 色相-饱和度-值)空间。从 RGB 转换到 HSV 空间将使我们从颜色中分离出亮度,这样我们就可以轻松地提取每个像素的颜色信息:
img = read('uno_card.png', 1)
show(img)
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)

以下是 RGB 空间中的图像:

  1. 定义 HSV 空间中绿色的上限和下限:
lower_green = np.array([45,100,100])
upper_green = np.array([80,255,255])
  1. 生成遮罩,仅激活位于定义的上限和下限阈值内的像素。cv2.inRange是检查像素值是否在最小值和最大值之间但在 HSV 标度上的比较操作:
mask = cv2.inRange(hsv, lower_green, upper_green)
  1. 在原始图像和蒙版之间执行cv2.bitwise_and操作以获取结果图像:
res = cv2.bitwise_and(img, img, mask=mask)
subplots([img, mask, res], nc=3, figsize=(10,5), \
        titles=['Original image','Mask on image', \
                'Resulting image'])

原始图像、蒙版和生成的图像如下:

从前面的图中,我们可以看到,该算法忽略了图像中的其余内容,只关注感兴趣的颜色。利用这一点,我们可以扩展逻辑,使用cv2.bitwise_not操作得出一个只不是绿色的前景蒙版,并执行绿屏技术。

总之,我们可以识别图像中的色彩空间,并且如果我们想要将另一个图像投影/覆盖到所识别的绿色屏幕上,我们从另一个图像中选取与原始图像中的绿色像素相对应的像素。

接下来,我们将学习使用关键点检测技术将一幅图像的特征与另一幅图像的特征进行匹配。

构建图像的全景视图

在本节中,我们将学习一种通过组合多幅图像来创建全景视图的技术。

想象一个场景,你正在用相机拍摄一个地方的全景。本质上,你正在拍摄多张照片,在后端,算法将图像中的公共元素映射到一张图像中(从最左边移动到最右边)。

为了执行图像的拼接,我们将利用cv2中可用的球体 ( 快速定向和旋转简要)方法。深入了解这些算法如何工作的细节超出了本书的范围——我们鼓励您浏览文档和论文,可从opencv-python-tutro als . readthedocs . io/en/latest/py _ tutorials/py _ feature 2d/py _ orb/py _ orb . html获得。

在高层次上,该方法识别在查询图像 ( image1)中的关键点,然后如果关键点匹配,则将它们与在另一个训练图像 ( image2)中识别的关键点相关联。

我们将采用以下策略来执行图像拼接:

  1. 计算关键点并在两幅图像中提取它们。
  2. 使用蛮力方法识别两幅图像中的共同特征。
  3. 利用cv2.findHomoGraphy方法来转换训练图像,以匹配查询图像的方向。
  4. 最后,我们利用cv2.warpperspective方法获取一个看起来像标准视图的视图。

现在,我们将使用以下代码实现上述策略:

The following code is available as Building_a_panoramic_view_of_images.ipynb in the Chapter18 folder of the book's GitHub repository - tinyurl.com/mcvp-packt Be sure to copy the URL from the notebook in GitHub to avoid any issue while reproducing the results

  1. 获取图像并导入相关包:
!pip install torch_snippets
from torch_snippets import *
!wget https://www.dropbox.com/s/mfg1codtc2rue84/g1.png
!wget https://www.dropbox.com/s/4yhui8s1xjndavm/g2.png
  1. 加载查询和训练图像,并将其转换为灰度图像:
queryImg = read('g1.png', 1)
queryImg_gray = read('g1.png')

trainImg = read('g2.png', 1)
trainImg_gray = read('g2.png')

subplots([trainImg, queryImg], nc=2, figsize=(10,5), \
        titles = ['Query image', \
    'Training image (Image to be stitched to Query image)'])

查询和训练图像如下所示:

  1. 使用 ORB 特征检测器提取两幅图像中的关键点和特征:
# Fetch the keypoints and features corresponding to the images
descriptor = cv2.ORB_create()
kpsA, featuresA = descriptor.detectAndCompute(trainImg_gray, \
                                                None)
kpsB, featuresB = descriptor.detectAndCompute(queryImg_gray, \
                                                None)
# Draw the keypoints obtained on images
img_kpsA = cv2.drawKeypoints(trainImg_gray,kpsA,None, \
                            color=(0,255,0))
img_kpsB = cv2.drawKeypoints(queryImg_gray,kpsB,None, \
                            color=(0,255,0))
subplots([img_kpsB, img_kpsA], nc=2, figsize=(10,5), \
            titles=['Query image with keypoints', \
                'Training image with keypoints'])

两个图像中提取的关键点的绘图如下:

ORB 或任何其他特征检测器分两步工作:

  1. 首先,它识别两幅图像中有趣的关键点。标准的关键点检测器之一是 Harris 角点检测器,它可以识别线的交点,以判断某个东西是否是尖角。
  2. 第二,将来自两个图像的所有关键点对相互比较,以查看关键点附近的图像块周围是否有高相关性。如果匹配度很高,这意味着两个关键点都指向图像中的同一个位置。

要深入了解 ORB,请参考 ORB:筛选或冲浪的高效替代方案(【https://ieeexplore.ieee.org/document/6126544】??)。

  1. 使用cv2.BFMatcher方法找到两幅图像特征的最佳匹配:
bf = cv2.BFMatcher(cv2.NORM_HAMMING)
best_matches = bf.match(featuresA,featuresB)
matches = sorted(best_matches, key = lambda x:x.distance)

匹配的输出是一个DMatch对象的列表。DMatch对象具有以下属性:

  • DMatch.distance:描述符之间的距离。越低越好
  • DMatch.trainIdx:训练描述符中描述符的索引
  • DMatch.queryIdx:查询描述符中描述符的索引
  • DMatch.imgIdx:列车图像索引

请注意,我们已经根据两个图像特征之间的距离对它们的匹配进行了排序。

  1. 使用以下代码绘制匹配项:
img3 = cv2.drawMatches(trainImg,kpsA,queryImg,kpsB, \
                        matches[:100],None, \
        flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS)
show(img3)

上述代码会产生以下输出:

现在,我们需要找到正确的平移、旋转和缩放设置,将第二幅图像叠加在第一幅图像之上。这组变换作为单应矩阵获得。

  1. 获取对应于两幅图像的单应性:
kpsA = np.float32([kp.pt for kp in kpsA])
kpsB = np.float32([kp.pt for kp in kpsB])
ptsA = np.float32([kpsA[m.queryIdx] for m in matches])
ptsB = np.float32([kpsB[m.trainIdx] for m in matches])

(H, status) = cv2.findHomography(ptsA, ptsB, cv2.RANSAC,4)

注意,我们只考虑被识别为两幅图像之间匹配的那些点。此外,通过执行单应性,我们得到了一个矩阵H,它能够使用下面的等式将ptsA与其在ptsB中的相关点进行变换:

  1. 执行图像拼接:

给定H矩阵,您可以使用cv2.warpPerspective函数进行实际的平移、旋转和缩放。做完这些后,在trainImg上,我们将在上面叠加queryImg,我们就有了我们的全景图像!

width = trainImg.shape[1] + queryImg.shape[1]
height = trainImg.shape[0] + queryImg.shape[0]

result = cv2.warpPerspective(trainImg, H, (width, height))
result[0:queryImg.shape[0], 0:queryImg.shape[1]] = queryImg

_x = np.nonzero(result.sum(0).sum(-1) == 0)[0][0]
_y = np.nonzero(result.sum(1).sum(-1) == 0)[0][0]

show(result[:_y,:_x])

上述操作会产生以下输出:

从前面的描述中,我们可以看到,我们已经使用被检测到在两幅图像之间具有匹配的关键点成功地组合了两幅图像。本节的关键在于,有几种关键点匹配技术可以识别两幅不同图像中的两个局部特征是否相同。

一旦确定了共同的关键点,我们就利用单应性来确定要执行的转换。最后,我们执行转换,通过利用cv2.warpperspective技术将两幅图像对齐,并将两幅图像缝合在一起。除了图像拼接之外,这种技术管道(关键点识别、识别两个图像之间的匹配关键点、识别要执行的变换以及执行变换)在诸如图像配准之类的应用中非常有用,其中一个图像需要叠加在另一个图像之上。

接下来,我们将了解在识别汽车牌照位置时如何利用预先训练好的级联分类器。

检测汽车的牌照

想象一个场景,我们要求您在汽车图像中识别车牌的位置。我们在关于目标检测的章节中学到的一种方法是提出基于锚盒的技术来识别车牌的位置。这将要求我们在利用模型之前,在几百张图像上训练模型。

但是,有一个级联分类器,它是一个预先训练好的文件,我们可以用它来识别汽车图像中车牌的位置。如果一个分类器由几个更简单的分类器(阶段)组成,那么它就是一个级联分类器,这些分类器随后被应用于感兴趣的区域,直到在某个阶段,候选区域被拒绝或者所有阶段都通过。这些类似于我们到目前为止已经学会如何使用的卷积核。这不是一个从其他内核学习内核的深度神经网络,而是一个内核列表,这些内核已经被识别为当它们的所有分类都被投票时给出良好的分类分数。

例如,一个人脸层叠可以有多达 6000 个内核来处理人脸的某个部分。其中一些内核可能看起来像这样:

这些级联也被称为哈尔级联。

有了这个高层次的理解,让我们用粉笔画出我们在利用预先训练的级联分类器来识别汽车图像中的车牌位置时将采用的策略:

  1. 导入相关的级联。
  2. 将图像转换为灰度图像。
  3. 指定图像中感兴趣对象的最小和最大比例。
  4. 获取来自级联分类器的区域建议。
  5. 围绕区域方案绘制边界框。

让我们用代码实现前面的策略:

The following code is available as Detecting_the_number_plate_of_a_car.ipynb in the Chapter18 folder of this book's GitHub repository - tinyurl.com/mcvp-packt Be sure to copy the URL from the notebook in GitHub to avoid any issue while reproducing the results

  1. 获取车牌识别级联:
!wget https://raw.githubusercontent.com/zeusees/HyperLPR/master/model/cascade.xml
  1. 获取图像:
!wget https://www.dropbox.com/s/4hbem2kxzqcwo0y/car1.jpg
  1. 加载图像和级联分类器:
!pip install torch_snippets
from torch_snippets import *
plate_cascade = cv2.CascadeClassifier('cascade.xml')
image = read("car1.jpg", 1)
  1. 将图像转换为灰度并绘制:
image_gray = cv2.cvtColor(image,cv2.COLOR_RGB2GRAY)
  1. 利用级联检测多个秤的车牌:
plates = plate_cascade.detectMultiScale(image_gray, 1.08, \
                                    2, minSize=(40, 40), \
                                    maxSize=(1000, 100))

plate_cascade.detectMultiScale将返回与级联核高度卷积匹配的所有可能的矩形区域,这有助于识别图像中车牌的位置。此外,我们正在指定宽度和高度的最小和最大尺寸。

  1. 循环通过板区域建议(板)并获取比区域建议稍大的区域:
image2 = image.astype('uint8')
for (x, y, w, h) in plates:
    print(x,y,w,h)
    x -= w * 0.14
    w += w * 0.75
    y -= h * 0.15
    h += h * 0.3
    cv2.rectangle(image2, (int(x), int(y)), \
                (int(x + w), int(y + h)), (0, 255, 0), 10)
show(image2, grid=True)

上述代码生成以下输出:

从前面的截图可以看出,预先训练好的级联分类器能够准确识别出车牌的位置。类似于道路车道检测练习,即使在车牌检测的情况下,我们也可能会遇到我们的策略在不同的图像集上不起作用的情况。我们鼓励您在不同的自定义图像上尝试上述步骤。

摘要

在这一章中,我们学习了利用一些基于 OpenCV 的技术来识别轮廓、边缘和线条,并跟踪彩色物体。虽然我们在本章中讨论了一些用例,但是这些技术在各种用例中有更广泛的应用。然后,我们学习了在拼接两幅彼此相关的图像时,使用关键点和特征提取技术来识别两幅图像之间的相似性。最后,我们学习了级联分类器,并利用预先训练好的分类器,只需很少的开发工作就能获得最佳解决方案,还能实时生成预测。

总的来说,通过这一章,我们希望表明,并不是所有的问题都需要神经网络,特别是在受约束的环境中,我们可以使用大量的历史知识和技术来快速解决这些问题。在 OpenCV 无法解决的地方,我们已经深入研究了神经网络。

图像很迷人。存储它们是人类最早的努力之一,也是获取内容的最有力的方式之一。在 21 世纪,捕捉图像变得很容易,这带来了许多问题,不管有没有人的干预,这些问题都可以得到解决。我们已经使用 PyTorch 完成了一些最常见也是最现代的任务——图像分类、目标检测、图像分割、图像嵌入、图像生成、处理生成的图像、用很少的数据点进行训练、将计算机视觉与 NLP 技术相结合以及强化学习。我们从头开始讲述了各种算法的工作细节。我们还学习了如何用公式表达一个问题,获取数据,创建网络,从训练好的模型中进行推断,以及如何训练和验证它们。我们了解了如何挑选代码库/预训练模型,并为我们的任务定制它们,最后,我们学习了如何部署我们的模型。

我们希望你已经掌握了处理图像的技能,就像处理图像是你的第二天性一样,并解决你自己感兴趣的任务。

最重要的是,我们希望这对你来说是一个快乐的旅程,你喜欢读这本书,就像我们喜欢写它一样!

十九、附录

第一章-人工神经网络基础

  1. 神经网络中的各层是什么?
    输入、隐藏和输出层

  2. 前馈传播的输出是什么?
    帮助计算损失值的预测

  3. 连续因变量的损失函数与二元因变量以及分类因变量的损失函数有何不同?MSE 是连续因变量的常用损失函数,二元因变量的常用交叉熵。分类交叉熵用于分类因变量。

  4. 什么是随机梯度下降?
    这是一个减少损失的过程,通过向梯度递减的方向调整权重

  5. 反向传播练习做什么?它使用链式法则计算所有权重相对于损失的梯度

  6. 在反向传播期间,跨层的所有权重的权重更新是如何发生的?使用公式 dW = W–alpha *(dW/dL)来实现

  7. 在训练神经网络的每个时期内,神经网络的所有功能是什么?
    对于一个时期中的每一批,执行正向推进- >、反向推进- >、更新权重- >对下一批重复,直到所有时期结束

  8. 为什么在 GPU 上训练网络比在 CPU 上训练更快?在 GPU 硬件上可以并行执行更多矩阵运算

  9. 学习率如何影响神经网络的训练?过高的学习率会使权重爆炸,过低的学习率根本不会改变权重

  10. 学习率参数的典型值是多少?1e-2 至 1e-5

第二章- PyTorch 基础知识

  1. 训练时为什么要把整数输入转换成浮点值?
    nn.Linear(以及几乎所有的火炬层)只接受浮动作为输入
  2. 重塑张量物体的各种方法有哪些?
    重塑、查看
  3. 为什么张量对象比 NumPy 数组的计算速度更快?在 GPU 上并行运行的能力仅在张量对象上可用
  4. 神经网络类中的 init 神奇函数是由什么构成的?
    调用super().__init__()并指定神经网络的层数
  5. 为什么我们在执行反向传播之前执行零梯度?
    确保冲洗掉之前计算的梯度
  6. 数据集类由哪些神奇的函数构成?
    __len____getitem__
  7. 我们如何对新的数据点做出预测?
    通过在张量上调用模型,就好像它是一个函数-模型(x)
  8. 我们如何获取神经网络的中间层值?
    通过创建自定义方法
  9. Sequential方法如何帮助简化神经网络架构的定义?
    我们可以通过连接一系列层来避免创建__init__forward方法

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

  1. 如果输入值未在输入数据集中进行缩放,会出现什么问题?
    将权重调整到最佳值需要更长时间,因为输入值在未缩放时变化很大
  2. 在训练神经网络时,如果背景具有白色像素颜色,而内容具有黑色像素颜色,会出现什么问题?神经网络必须学会忽略大多数不太有用的白色内容
  3. 批量大小对模型的训练时间、给定数量的时期的准确性有什么影响?
    批量越大,收敛所需的时间越长,达到高精度所需的迭代次数也越多
  4. 训练结束时,输入值范围对权重分布有什么影响?
    如果输入值没有缩放到某个范围,某些权重会有助于过度拟合
  5. 批处理规范化如何帮助提高准确性?
    正如我们缩放输入以更好地收敛人工神经网络是多么重要一样,批量标准化缩放激活以更好地收敛其下一层
  6. 我们如何知道一个模型是否过度适合训练数据?当验证损失是常数或随着更多的时期而不断增加,而训练损失随着时期的增加而不断减少时
  7. 正则化如何帮助避免过度拟合?正则化技术有助于模型在受限环境中训练,从而迫使人工神经网络以更少偏差的方式调整其权重
  8. L1 正则化和 L2 正则化有何不同?
    L1 =权重绝对值之和,L2 =除典型损失之外的损失值加上权重平方之和
  9. 辍学如何有助于减少过度拟合?通过减少人工神经网络中的一些连接,我们迫使网络从更少的数据中学习。这迫使模型一般化。

第四章-介绍卷积神经网络

  1. 为什么在使用传统神经网络时,对翻译图像的预测很低?所有图像都位于原始数据集中的中心,因此人工神经网络只学习位于中心的图像的任务。
  2. 卷积是怎么做的?
    卷积是两个矩阵之间的乘法。
  3. 如何确定过滤器中的最佳重量值?
    通过反向传播。
  4. 卷积和汇集的结合如何帮助解决图像转换的问题?
    虽然卷积给出了重要的图像特征,但是汇集提取了图像片中最显著的特征。这使得池化在邻近区域上是一个健壮的操作,即,即使某些东西被平移了几个像素,池化仍将返回预期的输出。
  5. 更接近输入层的层中的过滤器学习什么?像边缘这样的低级特征。
  6. 池化有助于构建模型的功能是什么?
    它通过减少特征图大小来减少输入大小,并使模型平移不变。
  7. 为什么我们不能获取输入图像,就像我们在 FashionMNIST 数据集上所做的那样展平,并为真实世界的图像训练一个模型?如果图像尺寸非常大,那么连接两个层的参数数量将会以百万计。
  8. 数据增强如何帮助改善图像翻译?
    数据增强创建了被平移了几个像素的图像副本。因此,即使图像中的对象偏离中心,模型也被迫学习正确的类。
  9. 在什么场景下我们利用collate_fn进行数据加载?
    当我们需要执行批处理级别的转换时,这在__getitem__中很难/很慢地执行。
  10. 改变训练数据点的数量对验证数据集的分类准确性有什么影响?
    一般来说,数据集越大,模型精度就越好。

第五章-图像分类的迁移学习

  1. VGG 和 ResNet 预培训架构的培训内容是什么?
    Imagenet 数据集中的图像。
  2. 为什么 VGG11 的精度不如 VGG16?与 VGG16 相比,VGG11 的层数较少。
  3. VGG11 中的数字 11 代表什么?
    11 层。
  4. 残余网络中的残余是什么?
    除了层的变换,层还返回输入。
  5. 残余网络的优势是什么?它有助于避免渐变消失,也有助于增加模型深度。
  6. 有哪些各种流行的预训模型?VGG,雷斯网,盗梦空间,AlexNet。
  7. 在迁移学习过程中,为什么要使用与预训练模型的训练中使用的相同的均值和标准差来标准化图像?
    模型被训练成使得它们期望输入图像用特定的平均值和标准偏差来归一化。
  8. 为什么我们要冻结模型中的某些参数?
    我们冻结,以便参数在反向传播期间不会更新。他们没有更新,因为他们已经训练有素。
  9. 我们如何知道预训练模型中存在的各种模块?
    print(model)
  10. 我们如何训练一个能同时预测分类值和数值的模型?
    通过使用多个预测头,并使用每个预测头的单独损失进行训练。
  11. 如果我们执行与我们在年龄和性别估计一节中所写的相同的代码,为什么年龄和性别预测代码可能不总是对您感兴趣的图像起作用?
    与训练数据不具有相似分布的图像可能会产生意想不到的结果。
  12. 我们如何进一步提高我们在面部关键点预测部分中编写的面部关键点识别模型的准确性?我们可以在训练过程中添加颜色和几何增强。

第六章-图像分类的实际应用

  1. 类激活图是如何获得的?
    参考生成凸轮一节中提供的 8 个步骤

  2. 批量规范化和数据扩充在训练模型时有什么帮助?它们有助于减少过度拟合

  3. CNN 模型过度拟合的常见原因是什么?
    无批量标准化、数据扩充、丢失

  4. CNN 模型在数据科学家端处理训练和验证数据,但在现实世界中不处理的各种场景有哪些?真实世界的数据可能与用于训练和验证模型的数据具有不同的分布。此外,模型可能会过度拟合训练数据

  5. 我们利用 OpenCV 包的各种场景是什么?
    在受限环境中工作时,以及推断速度更重要时

第七章-目标检测的基础

  1. 区域提议技术如何生成提议?它识别颜色、纹理、大小和形状相似的区域。

  2. 如果一个图像中有多个对象,如何计算 IoU?
    IoU 是使用交集/并集度量为每个对象计算的

  3. 为什么 R-CNN 生成预测需要很长时间?因为有多少提议,我们就创建多少正向传播

  4. 为什么快速 R-CNN 比 R-CNN 快?对于所有提案,从 VGG 主干提取特征图是常见的。与快速 RCNN 相比,这减少了几乎 90%的计算

  5. 投资回报池是如何工作的?
    所有的selectivesearch作物都经过自适应池内核,因此最终输出的大小相同

  6. 在预测边界框校正时,获取要素地图后没有多个图层会有什么影响?你可能没有注意到模型没有学会准确预测边界框

  7. 为什么在计算总体损失时,我们必须给回归损失分配较高的权重?分类损失是交叉熵,其通常为 log(n)量级,导致输出可能具有高范围。然而,边界框回归损失介于 0 和 1 之间。因此,回归损失必须按比例增加。

  8. 非最大抑制是如何工作的?通过组合相同类别且具有高 iou 的盒子,我们消除了冗余的包围盒预测。

第八章-高级目标检测

  1. 为什么快速 R-CNN 比快速 R-CNN 更快?
    使用selectivesearch技术,我们不需要每次都输入大量不必要的提议。相反,更快的 R-CNN 使用区域提议网络自动找到他们。
  2. 与更快的 R-CNN 相比,YOLO 和 SSD 的速度如何?我们不需要依赖新的提案网络。该网络一次就能直接找到这些建议。
  3. 是什么让 YOLO 和 SSD 单次检测算法?网络一次性预测所有的提议和预测
  4. 客观分和类分有什么区别?对象性标识一个对象是否存在。但是类分数预测了对象非零的锚盒的类

第九章-图像分割

  1. 向上扩展对 U-Net 架构有何帮助?
    放大有助于增加特征地图的大小,以便最终输出与输入大小相同。
  2. U-Net 中为什么需要全卷积网络?
    因为输出也是图像,并且很难使用线性层来预测图像成形张量。
  3. 在 Mask R-CNN 中,RoI Align 如何改进 RoI pooling?
    RoI Align 采用预测建议的偏移来精确对齐特征图。
  4. U-Net 和 Mask R-CNN 在分割方面的主要区别是什么?U-Net 是完全卷积的,具有单个端到端网络,而 Mask R-CNN 使用诸如 Backbone、RPN 等迷你网络来完成不同的任务。Mask R-CNN 能够识别和分离同类型的几个对象,而 U-Net 只能识别(但不能分离成单个实例)。
  5. 什么是实例分段?如果在同一个图像中有同一个类的不同对象,那么每个这样的对象被称为一个实例。应用图像分割在像素级别上分别预测所有实例被称为实例分割。

第十一章-自编码器和图像处理

  1. autoencoder 中的编码器是什么?
    将图像转换成矢量表示的较小的神经网络。

  2. autoencoder 针对什么损失函数进行优化?
    像素级均方误差,直接比较预测与输入。

  3. 自编码器如何帮助分组相似的图像?
    相似的图像会返回相似的编码,更容易聚类。

  4. 卷积自编码器什么时候有用?
    当输入是图像时。

  5. 如果我们从普通/卷积自编码器获得的嵌入向量空间中随机采样,为什么会得到不直观的图像?
    编码中的数值范围不受限制,因此正确的输出高度依赖于正确的数值范围。一般来说,随机抽样假设平均值为 0,标准差为 1。

  6. 变分自编码器优化的损失函数是什么?
    像素级 MSE 和 KL-来自编码器的平均值和标准偏差分布的散度。

  7. 变分自编码器如何克服普通/卷积自编码器生成新图像的限制?
    通过将预测编码限制为正态分布,所有编码都落在均值-0 和标准偏差 1 的区域内,这很容易采样。

  8. 在对抗性攻击中,为什么我们修改输入图像像素而不是权重值?在对抗性攻击中,我们无法控制神经网络。

  9. 在神经类型转移中,我们优化的损失是什么?
    生成图像与原始图像的感知(VGG)损失,以及来自生成图像和风格图像的格拉姆矩阵的风格损失。

  10. 为什么我们在计算样式和内容损失时考虑不同图层的激活而不考虑原始图像?
    使用更多中间层可确保生成的图像保留图像的更精细细节。此外,使用更多损耗会使梯度上升更加稳定。

  11. 为什么我们在计算风格损失时考虑的是克矩阵损失而不是图像之间的差异?Gram matrix 给出了图像风格的指示,即纹理、形状和颜色是如何排列的,并且将忽略实际内容。这就是为什么它更方便风格的损失。

  12. 为什么我们在建立模型的时候会扭曲图像,从而产生深度假像?扭曲图像有助于规则化。此外,它有助于生成所需数量的图像。

第十二章-使用 GANs 生成图像

  1. 如果生成器和鉴别器模型的学习率很高会怎么样?根据经验,观察到模型稳定性较低。
  2. 在生成器和鉴别器训练有素的场景中,给定图像是真实的概率是多少?
    0.5。
  3. 为什么我们在生成图像时使用 convtranspose2d?我们不能使用线性图层来放大/生成图像。
  4. 为什么我们在条件 GANs 中嵌入的大小比类的数量大?使用更多的参数给予模型更多的自由度来学习每一类的重要特征。
  5. 我们如何生成有胡子的男人的图像?
    用有条件的 GAN。就像我们有男性和女性的形象,我们可以有胡子的男性和其他类似的类,而训练模型。
  6. 为什么我们在发生器的最后一层激活 Tanh,而不是 ReLU 或 Sigmoid?
    归一化图像的像素范围是[-1,1],因此我们使用 Tanh
  7. 为什么我们得到了真实的图像,即使我们没有对生成的数据去归一化?
    即使像素值不在[0,255]之间,相对值也足以让make_grid实用程序去归一化输入
  8. 如果我们在训练 GAN 之前不裁剪对应于图像的面部,会发生什么?如果有太多的背景,GAN 会得到关于什么是脸什么不是脸的错误信号,因此它可能会专注于生成更真实的背景
  9. 为什么训练发生器更新时鉴频器的权重不更新(因为generator_train_step函数涉及鉴频器网络)?
    这是一个循序渐进的过程。当更新生成器时,我们假设鉴别器能够做到最好。
  10. 为什么我们在训练鉴别器时得到真实和虚假图像的损失,而在训练生成器时只得到虚假图像的损失?因为无论发生器创造了什么,都只是虚假的图像。

第十三章-处理图像的高级 GANs

  1. 为什么我们需要 Pix2Pix GAN,而像 U-Net 这样的监督学习算法可以从轮廓中生成图像?
    U-net 在训练时只使用像素级损失。我们需要 pix2pix,因为 U-net 生成图像时不会损失真实感。
  2. 为什么我们需要在 CycleGAN 中针对 3 种不同的损失函数进行优化?
    在 CycleGAN 的第 7 点中提供了答案。
  3. 进步中的技巧如何帮助建立风格?ProgressiveGAN 帮助网络一次学习几个上采样层,以便当图像必须增加尺寸时,负责生成当前尺寸图像的网络是最佳的。
  4. 我们如何识别对应于给定自定义图像的潜在向量?
    通过调整随机产生的噪声,使得产生的图像和感兴趣的图像之间的 MSE 损失尽可能最小。

第十四章-用最少的数据点进行训练

  1. 预训练的词向量是如何获得的?
    来自现有数据库,如 GLOVE 或 word2vec
  2. 零拍学习中我们如何从一个图像特征嵌入映射到单词嵌入?
    通过创建一个合适的神经网络,该网络返回一个与单词嵌入形状相同的向量,并用 mse-loss 进行训练(将预测与实际单词嵌入进行比较)
  3. 暹罗网为什么这么叫?因为我们总是产生两个输出并相互比较,以确保一致性。暹罗代表双胞胎。
  4. 暹罗网是怎么得出两幅图像的相似度的?
    损失函数迫使网络预测,如果图像相似,输出具有较小的距离。

第十五章-结合计算机视觉和自然语言处理技术

  1. 为什么 CNN 和 RNN 在图像字幕中结合在一起?CNN 需要捕捉图像特征,而 RNN 需要创建语言输出。
  2. 为什么图像字幕中提供了开始和结束标记,而手写转录中没有?CTC 丢失不需要这种令牌,而且,在 OCR 中,我们一次生成所有时间步的令牌。
  3. 为什么在手写转录中利用 CTC 丢失功能?我们无法描绘图像中的时间步长。CTC 负责将关键图像特征与时间步长对齐。
  4. 转换器如何帮助目标检测?通过将锚盒视为转换器解码器的嵌入输入,DETR 学习动态锚盒,从而帮助目标检测。

第十六章-结合计算机视觉和强化学习

  1. 给定状态的值是如何计算的?通过计算该状态下的预期回报

  2. Q 表是如何填充的?
    通过计算所有状态的期望报酬

  3. 为什么我们在国家行动价值计算中要有一个贴现因子?由于不确定性,我们不确定未来会如何发展。因此,我们通过贴现的方式来降低未来奖励的权重

  4. 勘探开发战略的必要性是什么?只有开发会使模型停滞不前并变得可预测,因此模型应该能够探索并发现看不见的步骤,这些步骤甚至比模型已经知道的更有价值。

  5. 深度 Q 学习的需求是什么?我们让神经网络学习可能的奖励系统,而不需要昂贵的算法,这些算法可能需要太多时间或要求整个环境的可见性。

  6. 如何使用深度 Q 学习计算给定状态动作组合的值?它只是神经网络的输出。输入是状态,网络为给定状态下的每一个行为预测一个期望的回报。

  7. 在卡拉环境下可能会有哪些动作?
    加速--1,0,1
    转向--1,0,1

posted @ 2024-10-01 21:03  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报