PyTorch-深度学习实用指南-全-

PyTorch 深度学习实用指南(全)

原文:PyTorch Deep Learning Hands-On

协议:CC BY-NC-SA 4.0

零、前言

《PyTorch 深度学习实用指南》非常适合初学者,也可以帮助读者快速进入深度学习的深度。 在过去的几年中,我们已经看到了深度学习成为新的力量。 它已经从学术界进入工业界,帮助解决了成千上万的谜团,如果没有它,人类将无法想象解决。 深度学习作为首选实现方式的主流采用主要是由一堆框架驱动的,这些框架可靠地将复杂算法作为有效的内置方法提供。 本书展示了 PyTorch 在构建深度学习模型原型,构建深度学习工作流程以及将原型模型投入生产中的优势。 总体而言,这本书着重于 PyTorch 的实际实现,而不是解释其背后的数学原理,但是,它还将您链接到一些概念上可能落后的地方。

这本书适合谁

这本书是为初学者而写的,但不会让他们跳来跳去另一本书转向高级主题。 因此,我们避免尽可能多地解释算法,而是专注于在 PyTorch 中的实现,有时着眼于使用这些算法的实际应用的实现。 对于那些了解如何使用 Python 进行编程并了解深度学习基础知识的人来说,这本书是理想的选择。 本书适用于已经在实践传统机器学习概念的人,或者是开发人员并且希望实际探索深度学习的世界并将其实现部署到生产中的人们。

这本书涵盖的内容

第 1 章,“深度学习演练和 PyTorch 简介”是对 PyTorch 进行深度学习的方式以及 PyTorch 的基本 API 的介绍。 它首先显示了 PyTorch 的历史以及为什么 PyTorch 应该成为深度学习开发的必备框架。 它还介绍了不同的深度学习方法,我们将在接下来的章节中介绍这些方法。

第 2 章,“一个简单的神经网络”可帮助您构建第一个简单的神经网络,并展示如何连接神经网络,优化器和参数更新之类的点点滴滴来构建一个菜鸟级深度学习模型。 它还介绍了 PyTorch 如何进行反向传播,这是所有最新的深度学习算法的关键。

第 3 章,“深度学习工作流程”深入研究了深度学习工作流程的实现和有助于构建工作流程的 PyTorch 生态系统。 如果您打算为即将进行的项目建立深度学习团队或开发渠道,那么这可能是最关键的一章。 在本章中,我们将遍历深度学习管道的不同阶段,并了解 PyTorch 社区如何通过制作适当的工具在工作流程的每个阶段中不断改进。

第 4 章,“计算机视觉”是迄今为止深度学习最成功的结果,它讨论了成功背后的关键思想,并贯穿了使用最广泛的视觉算法– 卷积神经网络(CNN)。 我们将逐步实现 CNN 以了解其工作原理,然后使用 PyTorch 的nn包中预定义的 CNN。 本章可帮助您制作简单的 CNN 和基于高级 CNN 的视觉算法,称为语义分割。

第 5 章,“序列数据处理”着眼于循环神经网络,它是目前最成功的序列数据处理算法。 本章向您介绍主要的 RNN 组件,例如长短期记忆LSTM)网络和门控循环单元GRU)。 然后,在探索循环神经网络之前,我们将经历 RNN 实现中的算法更改,例如双向 RNN,并增加层数。 为了理解循环网络,我们将使用斯坦福大学 NLP 小组的著名示例(栈增强的解析器-解释器神经网络(SPINN)),并将其在 PyTorch 中实现。

第 6 章,“生成网络”,简要讨论了生成网络的历史,然后解释了各种生成网络。 在这些不同的类别中,本章向我们介绍了自回归模型和 GAN。 我们将研究作为自动回归模型一部分的 PixelCNN 和 WaveNet 的实现细节,然后详细研究 GAN。

第 7 章,“强化学习”介绍了强化学习的概念,它实际上并不是深度学习的子类。 我们首先来看定义问题陈述。 然后,我们将探讨累积奖励的概念。 我们将探索 Markov 决策过程和贝尔曼方程,然后转向深层 Q 学习。 我们还将看到由 OpenAI 开发的工具包 Gym 的介绍,该工具包用于开发和尝试强化学习算法。

第 8 章,“生产中的 PyTorch”着眼于在将深度学习模型部署到生产过程中人们甚至是深度学习专家所面临的困难。 我们将探索用于生产部署的不同选项,包括使用围绕 PyTorch 的 Flask 包装器以及使用 RedisAI,RedisAI 是高度优化的运行时,用于在多集群环境中部署模型,并且每秒可以处理数百万个请求。

要充分利用这本书

  • 该代码用 Python 编写并托管在 GitHub 上。 尽管可以下载压缩代码存储库,但在线 GitHub 存储库将收到错误修复和更新。 因此,需要具备对 GitHub 的基本了解,以及具有良好的 Python 知识。
  • 尽管不是强制性的,但如果您未使用任何预训练的模型,则使用 CUDA 驱动程序将有助于加快训练过程。
  • 这些代码示例是在 Ubuntu 18.10 机器上开发的,但是可以在所有流行的平台上运行。 但是,如果您遇到任何困难,请随时在 GitHub 存储库中提出问题。
  • 本书中的某些示例要求您使用其他服务或包,例如 redis-server 和 Flask 框架。 所有这些外部依赖项和“操作方法”指南均在其出现的章节中进行了记录。

下载示例代码文件

您可以从这里的帐户中下载本书的示例代码文件。 如果您在其他地方购买了此书,则可以访问这里并注册以将文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. 登录或登录这里
  2. 选择支持标签。
  3. 单击代码下载 & 勘误表
  4. 搜索框中输入书籍的名称,然后按照屏幕上的说明进行操作。

下载文件后,请确保使用以下最新版本解压缩或解压缩文件夹:

  • Windows 的 WinRAR/7-Zip
  • 适用于 macOS 的 Zipeg/iZip/UnRarX
  • 适用于 Linux 的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上。 我们还从这里提供了丰富的书籍和视频目录中的其他代码包。 去看一下!

下载彩色图像

我们还提供了 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。 您可以在此处下载

使用的约定

本书中使用了许多文本约定。

CodeInText:指示文本,数据库表名称,文件夹名称,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄中的代码字。 例如; “将下载的WebStorm-10*.dmg磁盘映像文件安装为系统中的另一个磁盘。”

代码块设置如下:

def forward(self, batch):
    hidden = self.hidden(batch)
    activated = torch.sigmoid(hidden)
    out = self.out(activated)
    return out

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

def binary_encoder(input_size):
    def wrapper(num):
        ret = [int(i) for i in '{0:b}'.format(num)]
 return [0] * (input_size - len(ret)) + ret
    return wrapper

任何命令行输入或输出的编写方式如下:

python -m torch.utils.bottleneck /path/to/source/script.py [args]

粗体:表示新的术语,重要的单词或您在屏幕上看到的单词,例如在菜单或对话框中,也显示在这样的文本中。 例如:“从管理面板中选择系统信息。”

注意

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

提示

提示和技巧如下所示。

一、深度学习演练和 PyTorch 简介

目前,有数十种深度学习框架可以解决 GPU 上的任何种类的深度学习问题,那么为什么我们还需要一个呢? 本书是对这一百万美元问题的解答。 PyTorch 进入了深度学习家族,并有望成为 GPU 上的 NumPy。 自加入以来,社区一直在努力兑现这一承诺。 如官方文档所述,PyTorch 是针对使用 GPU 和 CPU 进行深度学习的优化张量库。 尽管所有著名的框架都提供相同的功能,但 PyTorch 相对于几乎所有框架都具有某些优势。

本书中的各章为希望从 PyTorch 的功能中受益的开发人员提供了逐步指南,以处理和解释数据。 在探索深度学习工作流程的不同阶段之前,您将学习如何实现简单的神经网络。 我们将深入研究基本的卷积网络和生成对抗网络,然后是有关如何使用 OpenAI 的 Gym 库训练模型的动手教程。 在最后一章中,您将准备生产 PyTorch 模型。

在第一章中,我们将介绍 PyTorch 背后的理论,并解释为什么 PyTorch 在某些用例上胜过其他框架。 在此之前,我们将简要介绍 PyTorch 的历史,并了解为什么 PyTorch 是需要而不是选择。 在上一部分中,我们还将介绍 NumPy-PyTorch 桥和 PyTorch 内部,这将使我们在即将到来的代码密集型章节中有所作为。

了解 PyTorch 的历史

随着越来越多的人迁移到引人入胜的机器学习世界,不同的大学和组织开始建立自己的框架来支持日常研究,并且 Torch 是该家族的早期成员之一。 Ronan Collobert,Koray Kavukcuoglu 和 Clement Farabet 于 2002 年发布了 Torch,后来被 Facebook AI Research 以及其他几所大学和研究小组的许多人所采用。 许多初创公司和研究人员接受了 Torch,公司开始生产其 Torch 模型,以服务数百万用户。 Twitter,Facebook,DeepMind 等都属于该列表。 根据核心团队发布的 Torch7 官方论文[1],Torch 在设计时考虑了三个关键功能:

  1. 它应该简化数值算法的开发。
  2. 它应该容易扩展。
  3. 应该很快。

尽管 Torch 赋予了骨骼灵活性,并且 Lua + C 组合满足了上述所有要求,但是社区面临的主要缺点是对新语言 Lua 的学习曲线。 尽管 Lua 并不难掌握,并且已经在行业中使用了一段时间以进行高效的产品开发,但是它并没有像其他几种流行语言一样被广泛接受。

Python 在深度学习社区中的广泛接受使一些研究人员和开发人员重新考虑了核心作者做出的选择 Lua 而不是 Python 的决定。 这不仅仅是语言:缺少具有易于调试功能的命令式框架也触发了 PyTorch 的构想。

深度学习的前端开发人员发现符号图的概念很困难。 不幸的是,几乎所有的深度学习框架都是在此基础上构建的。 实际上,一些开发人员小组试图通过动态图来改变这种方法。 哈佛智能概率系统集团的 Autograd 是第一个这样做的流行框架。 然后,Twitter 上的 Torch 社区采纳了这个想法,并实现了 torch-autograd。

接下来,来自卡内基梅隆大学CMU)的研究小组提出了 DyNet,然后 Chainer 提出了动态图的功能和可解释的开发环境。

所有这些事件都是启动惊人的框架 PyTorch 的巨大灵感,事实上,PyTorch 最初是 Chainer 的分支。 它最初是由 Torch 的核心开发人员 Soumith Chintala 领导的 Adam Paszke 的实习项目开始的。 然后,PyTorch 聘请了另外两名核心开发人员以及来自不同公司和大学的约 100 位 Alpha 测试人员。

整个团队在六个月内将链条拉到了一起,并于 2017 年 1 月向公众发布了该 Beta。尽管产品开发人员最初并未使用 PyTorch,但大部分研究社区都接受了 PyTorch。 一些大学开始在 PyTorch 上开设课程,包括纽约大学NYU),牛津大学和其他一些欧洲大学。

什么是 PyTorch?

如前所述,PyTorch 是可以由 GPU 提供支持的张量计算库。 PyTorch 的构建具有特定目标,这使其与所有其他深度学习框架有所不同。 在本书中,您将通过不同的应用重新审视这些目标,并且到本书结束时,无论您打算要进行原型设计,您都应该能够开始使用 PyTorch 的各种用例。 一个想法或建立生产的超可扩展模型。

作为 Python 优先框架,PyTorch 大大超越了在整体 C++ 或 C 引擎上实现 Python 包装器的其他框架。 在 PyTorch 中,您可以继承 PyTorch 类并根据需要进行自定义。 内置于 PyTorch 核心的命令式编码风格仅由于 Python 优先方法才有可能。 尽管诸如 TensorFlow,MXNet 和 CNTK 的某些符号图框架提出了一种强制性方法,但由于社区的支持及其灵活性,PyTorch 仍能保持领先地位。

基于磁带的自动微分系统使 PyTorch 具有动态图功能。 这是 PyTorch 与其他流行的符号图框架之间的主要区别之一。 基于磁带的 Autograd 也支持 Chainer,Autograd 和 Torch-Autograd 的反向传播算法。 具有动态图功能,您的图将在 Python 解释器到达相应行时创建。 与 TensorFlow 的定义并运行方法不同,这称为通过运行定义

基于磁带的 Autograd 使用反向模式自动微分,在前进过程中,图将每个操作保存到磁带中,然后在磁带中向后移动以进行反向传播。 动态图和 Python 优先方法使易于调试,您可以在其中使用常用的 Python 调试器,例如 Pdb 或基于编辑器的调试器。

PyTorch 核心社区不仅为 Torch 的 C 二进制文件构建了 Python 包装器,还优化了内核并对其进行了改进。 PyTorch 根据输入数据智能地选择要为定义的每个操作运行的算法。

安装 PyTorch

如果您已安装 CUDA 和 CuDNN,则 PyTorch 的安装非常简单(出于对 GPU 的支持,但是如果您尝试在 PyTorch 中尝试并且没有 GPU,那也可以)。 PyTorch 的主页[2]显示一个交互式屏幕,用于选择您所选择的操作系统和包管理器。 选择选项并执行命令进行安装。

尽管最初仅支持 Linux 和 Mac 操作系统,但从 PyTorch 0.4 Windows 开始,Windows 也在受支持的操作系统列表中。 PyTorch 已包装并运送到 PyPI 和 Conda。 PyPI 是包的官方 Python 存储库,并且包管理器pip可以在 Torch 的名称下找到 PyTorch。

但是,如果您想冒险并获取最新代码,则可以按照 GitHub README页面上的说明从源代码安装 PyTorch。 PyTorch 的每晚版本都将推送到 PyPI 和 Conda。 如果您希望获得最新的代码而无需经历从源代码安装的麻烦,那么每晚构建将非常有用。

Installing PyTorch

图 1.1:来自 PyTorch 网站的交互式 UI 中的安装过程

是什么让 PyTorch 受欢迎?

在可靠的深度学习框架的众多中,由于速度和效率的原因,几乎每个人都在使用静态图或基于符号图的方法。 动态网络的内在问题(例如表现问题)使开发人员无法花费大量时间来实现它。 但是,静态图的限制使研究人员无法思考解决问题的多种不同方法,因为思维过程必须限制在静态计算图的框内。

如前所述,哈佛大学的 Autograd 包最初是作为解决此问题的方法,然后 Torch 社区从 Python 采纳了这个想法并实现了 torch-autograd。 Chainer 和 CMU 的 DyNet 可能是接下来的两个基于动态图的框架,得到了社区的大力支持。 尽管所有这些框架都可以解决借助强制方法创建的静态图所产生的问题,但它们没有其他流行的静态图框架所具有的动力。 PyTorch 绝对是答案。 PyTorch 团队采用了经过良好测试的著名 Torch 框架的后端,并将其与 Chainer 的前端合并以得到最佳组合。 团队优化了内核,添加了更多的 Pythonic API,并正确设置了抽象,因此 PyTorch 不需要像 Keras 这样的抽象库即可让初学者入门。

PyTorch 在研究界获得了广泛的接受,因为大多数人已经在使用 Torch,并且可能对 TensorFlow 之类的框架在没有提供太多灵活性的情况下的发展感到沮丧。 PyTorch 的动态性质对许多人来说是一个好处,并帮助他们在早期阶段接受 PyTorch。

PyTorch 允许用户定义 Python 在向前传递中允许他们执行的任何操作。 向后遍历自动找到遍历图直到根节点的路径,并在向后遍历时计算梯度。 尽管这是一个革命性的想法,但是产品开发社区并未接受 PyTorch,就像他们不能接受遵循类似实现的其他框架一样。 但是,随着时间的流逝,越来越多的人开始迁移到 PyTorch。 Kaggle 目睹了所有顶级玩家都使用 PyTorch 进行的比赛,并且如前所述,大学开始在 PyTorch 中开设课程。 这有助于学生避免像使用基于符号图的框架时那样学习新的图语言。

在 Caffe2 发布之后,自社区宣布 PyTorch 模型向 Caffe2 的迁移策略以来,甚至产品开发人员也开始尝试 PyTorch。 Caffe2 是一个静态图框架,即使在移动电话中也可以运行您的模型,因此使用 PyTorch 进行原型设计是一种双赢的方法。 构建网络时,您可以获得 PyTorch 的灵活性,并且可以将其转移到 Caffe2 并在任何生产环境中使用。 但是,在 1.0 版本说明中,PyTorch 团队从让人们学习两个框架(一个用于生产,一个用于研究)到学习在原型阶段具有动态图功能并且可以突然转换为一个框架的巨大跃进。 需要速度和效率的静态优化图。 PyTorch 团队将 Caffe2 的后端与 PyTorch 的 Aten 后端合并在一起,这使用户可以决定是要运行优化程度较低但高度灵活的图,还是运行优化程度较不灵活的图而无需重写代码库。

ONNX 和 DLPack 是 AI 社区看到的下两个“大事情”。 微软和 Facebook 共同宣布了 开放神经网络交换ONNX)协议,该协议旨在帮助开发人员将任何模型从任何框架迁移到任何其他框架。 ONNX 与 PyTorch,Caffe2,TensorFlow,MXNet 和 CNTK 兼容,并且社区正在构建/改善对几乎所有流行框架的支持。

ONNX 内置在 PyTorch 的核心中,因此将模型迁移到 ONNX 表单不需要用户安装任何其他包或工具。 同时,DLPack 通过定义不同框架应遵循的标准数据结构,将互操作性提高到一个新水平,从而使张量在同一程序中从一个框架到另一个框架的迁移不需要用户序列化数据,或遵循任何其他解决方法。 例如,如果您有一个程序可以将训练过的 TensorFlow 模型用于计算机视觉,而一个高效的 PyTorch 模型用于循环数据,则可以使用一个程序来处理视频中的每个三维帧, TensorFlow 模型并将 TensorFlow 模型的输出直接传递给 PyTorch 模型以预测视频中的动作。 如果您退后一步,看看深度学习社区,您会发现整个世界都趋向于一个单一的点,在这个点上,所有事物都可以与其他事物互操作,并尝试以类似方法解决问题。 那是我们大家都想生活的世界。

使用计算图

通过的演变,人类发现对神经网络进行图绘制可以使我们将复杂性降低到最低限度。 计算图通过操作描述了网络中的数据流。

由一组节点和连接它们的边组成的图是一种已有数十年历史的数据结构,仍然在几种不同的实现方式中大量使用,并且该数据结构可能一直有效,直到人类不复存在。 在计算图中,节点表示张量,边表示它们之间的关系。

计算图可帮助我们解决数学问题并使大型网络变得直观。 神经网络,无论它们有多复杂或多大,都是一组数学运算。 解决方程的明显方法是将方程分成较小的单元,并将一个输出传递给另一个,依此类推。 图方法背后的想法是相同的。 您将网络内部的操作视为节点,并将它们映射到一个图,图中节点之间的关系表示从一个操作到另一个操作的过渡。

计算图是,是人工智能当前所有先进技术的核心。 他们奠定了深度学习框架的基础。 现在,所有现有的深度学习框架都使用图方法进行计算。 这有助于框架找到独立的节点并作为独立的线程或进程进行计算。 计算图可帮助您轻松进行反向传播,就像从子节点移动到先前的节点一样,并在返回时携带梯度。 此操作称为自动微分,这是 40 年前的想法。 自动微分被认为是上个世纪十大数值算法之一。 具体来说,反向模式自动微分是计算图背后用于反向传播的核心思想。 PyTorch 是基于反向模式自动微分而构建的,因此所有节点都将与它们一起保留操作信息,直到控件到达叶节点为止。 然后,反向传播从叶节点开始并向后遍历。 在向后移动时,流将随其一起获取梯度,并找到与每个节点相对应的偏导数。 1970 年,芬兰数学家和计算机科学家 Seppo Linnainmaa 发现自动微分可以用于算法验证。 几乎同时在同一概念上记录了许多其他并行的工作。

在深度学习中,神经网络用于求解数学方程。 无论任务多么复杂,一切都取决于一个巨大的数学方程式,您可以通过优化神经网络的参数来求解。 解决问题的明显方法是“手工”。 考虑使用大约 150 层神经网络来求解 ResNet 的数学方程; 对于人类来说,要遍历数千次图,每次手动进行相同的操作来优化参数,都是不可能的。 计算图通过将所有操作逐级映射到图并一次求解每个节点来解决此问题。 “图 1.2”显示了具有三个运算符的简单计算图。

两侧的矩阵乘法运算符给出两个矩阵作为输出,它们经过加法运算符,加法运算符又经过另一个 Sigmoid 运算符。 整个图实际上是在尝试求解以下等式:

Using computational graphs

图 1.2:等式的图形表示

但是,当您将映射到图时,一切都变得清晰起来。 您可以可视化并了解正在发生的事情,并轻松编写代码,因为流程就在您的眼前。

所有深度学习框架都建立在自动微分和计算图的基础上,但是有两种固有的实现方法–静态图和动态图。

使用静态图

处理神经网络架构的传统方法是使用静态图。 在对给出的数据进行任何处理之前,该程序将构建图的正向和反向传递。 不同的开发小组尝试了不​​同的方法。 有些人先构建正向传播,然后将相同的图实例用于正向传播和后向传递。 另一种方法是先构建前向静态图,然后创建后向图并将其附加到前向图的末尾,以便可以将整个前向-后向传递作为单个图执行来执行。 按时间顺序排列节点。

Using static graphs

Using static graphs

图 1.3 和 1.4:用于正向和反向传递的静态图相同

Using static graphs

图 1.5:静态图:正向和反向传递的不同图

静态图具有相对于其他方法的某些固有优势。 由于要限制程序的动态变化,因此程序可以在执行图时做出与内存优化和并行执行有关的假设。 内存优化是框架开发人员在整个开发过程中都会担心的关键方面,原因是优化内存的范围非常庞大,并且伴随着这些优化的微妙之处。 Apache MXNet 开发人员已经写了一个很棒的博客[3],详细讨论了这个问题。

TensorFlow 静态图 API 中用于预测 XOR 输出的神经网络如下所示。 这是静态图如何执行的典型示例。 最初,我们声明所有输入的占位符,然后构建图。 如果仔细看,我们在图定义中的任何地方都不会将数据传递给它。 输入变量实际上是占位符,期望在将来的某个时间获取数据。 尽管图定义看起来像我们在对数据执行数学操作,但实际上是在定义流程,这就是 TensorFlow 使用内部引擎构建优化的图实现的时候:

x = tf.placeholder(tf.float32, shape=[None, 2], name='x-input')
y = tf.placeholder(tf.float32, shape=[None, 2], name='y-input')
w1 = tf.Variable(tf.random_uniform([2, 5], -1, 1), name="w1")
w2 = tf.Variable(tf.random_uniform([5, 2], -1, 1), name="w2")
b1 = tf.Variable(tf.zeros([5]), name="b1")
b2 = tf.Variable(tf.zeros([2]), name="b2")
a2 = tf.sigmoid(tf.matmul(x, w1) + b1)
hyp = tf.matmul(a2, w2) + b2
cost = tf.reduce_mean(tf.losses.mean_squared_error(y, hyp))
train_step = tf.train.GradientDescentOptimizer(lr).minimize(cost)
prediction = tf.argmax(tf.nn.softmax(hyp), 1)

解释器读取完图定义后,我们就开始遍历数据:

with tf.Session() as sess:
    sess.run(init)
    for i in range(epoch):
        sess.run(train_step, feed_dict={x_: XOR_X, y_: XOR_Y})

接下来我们开始 TensorFlow 会话。 这是与预先构建的图进行交互的唯一方法。 在会话内部,您可以遍历数据,并使用session.run方法将数据传递到图。 因此,输入的大小应与图中定义的大小相同。

如果您忘记了什么是 XOR,则下表应为您提供足够的信息以从内存中重新收集它:

输入 输出
A B 异或
0 0 0
0 1 1
1 0 1
1 1 0

使用动态图

势在必行的编程风格始终拥有较大的用户群,因为程序流程对于任何开发人员都是直观的。 动态能力是命令式图构建的良好副作用。 与静态图不同,动态图架构不会在数据传递之前构建图。 程序将等待数据到达并在遍历数据时构建图。 结果,每次数据迭代都会构建一个新的图实例,并在完成反向传播后销毁它。 由于图为每次迭代构建的,因此它不依赖于数据大小,长度或结构。 自然语言处理是需要这种方法的领域之一。

例如,如果您试图对成千上万的句子进行情感分析,则需要使用静态图来破解并采取变通办法。 在普通的循环神经网络RNN)模型中,每个单词都经过一个 RNN 单元,该单元生成输出和隐藏状态。 该隐藏状态将提供给下一个 RNN,后者处理句子中的下一个单词。 由于您在构建静态图时做了一个固定长度的插槽,因此您需要增加短句并减少长句。

Using dynamic graphs

图 1.6:带有短句,适当句和长句的 RNN 单元的静态图

示例中给出的静态图显示了如何为每次迭代设置数据格式,以免破坏预建图。 但是,在动态图中,网络是灵活的,因此每次传递数据时都会创建网络,如上图所示。

动态能力附带成本。 您不能基于假设对图进行预优化,因此必须在每次迭代时支付创建图的开销。 但是,PyTorch 旨在尽可能降低成本。 由于预优化不是动态图所能做的事情,因此 PyTorch 开发人员设法将即时图创建的成本降低到可以忽略的程度。 由于所有优化都进入了 PyTorch 的核心,因此即使提供了动态功能,它也比其他几个针对特定用例的框架要快。

以下是用 PyTorch 编写的代码段,用于与我们之前在 TensorFlow 中开发的 XOR 操作相同的代码:

x = torch.FloatTensor(XOR_X)
y = torch.FloatTensor(XOR_Y)
w1 = torch.randn(2, 5, requires_grad=True)
w2 = torch.randn(5, 2, requires_grad=True)
b1 = torch.zeros(5, requires_grad=True)
b2 = torch.zeros(2, requires_grad=True)

for epoch in range(epochs):
    a1 = x @ w1 + b1
    h1 = a2.sigmoid()
    a2 = h2 @ w2 + b1
    hyp = a3.sigmoid()
    cost = (hyp - y).pow(2).sum()
    cost.backward()

在 PyTorch 代码中,输入变量定义未创建占位符。 而是将变量对象包装到您的输入上。 图定义不会执行一次; 相反,它在循环内,并且每次迭代都在构建图。 您在每个图实例之间共享的唯一信息是您要优化的权重矩阵。

在这种方法中,如果您在遍历数据时改变了数据大小或形状,则在图中运行新形状的数据绝对好,因为新创建的图可以接受新形状。 可能性不止于此。 如果要动态更改图的行为,也可以这样做。 在第 5 章,“序列数据处理”中的循环神经网络会话中给出的示例均基于此思想。

探索深度学习

自从人类发明了计算机以来,我们就将它们称为智能系统,但我们一直在努力增强其智能。 在过去,计算机可以做的任何人类无法做到的事情都被认为是人工智能。 记住大量数据,对数百万或数十亿个数字进行数学运算,等等,被认为是人工智能。 我们称其为 Deep Blue,这是一款在国际象棋上击败国际象棋大师 Garry Kasparov 的机器。

最终,人类不能做的事情和计算机可以做的事情变成了计算机程序。 我们意识到对于程序员来说,人类可以轻松完成的某些事情是不可能的。 这种演变改变了一切。 我们可以写下并让像我们这样的计算机正常工作的可能性或规则的数量如此之大。 机器学习解救了人们。 人们找到了一种方法,使计算机可以从示例中学习规则,而不必明确地编写代码。 这就是所谓的机器学习。 “图 1.9”中给出了一个示例,该示例显示了我们如何根据客户过去的购物历史来预测客户是否会购买产品。

Exploring deep learning

图 1.7:显示客户购买产品的数据集

即使不是全部,我们也可以预测大多数结果。 但是,如果我们可以从中进行预测的数据点数量太多而又无法用凡人的大脑来处理它们该怎么办? 计算机可以浏览数据,并可能根据以前的数据吐出答案。 这种数据驱动的方法可以为我们提供很多帮助,因为我们唯一要做的就是假设相关的特征,然后将其交给包含不同算法的黑盒,以从特征集中学习规则或模式。

有问题。 即使我们知道要查找的内容,清理数据并提取特征也不是一件有趣的事情。 然而,最主要的麻烦不是这个。 我们无法有效预测高维数据和其他媒体类型的数据的特征。 例如,在人脸识别中,我们最初使用基于规则的程序找到人脸的细节长度,并将其作为输入输入神经网络,因为我们认为这是人类用来识别人脸的特征集。

Exploring deep learning

图 1.8:人为选择的面部特征

事实证明,对于人类来说如此明显的功能对计算机而言并不那么明显,反之亦然。 特征选择问题的实现使我们进入了深度学习的时代。 这是机器学习的子集,其中我们使用相同的数据驱动方法,但不是让计算机明确选择特征,而是让计算机决定特征应该是什么。

让我们再次考虑面部识别示例。 Google 于 2014 年发表的 FaceNet 论文在深度学习的帮助下解决了它。 FaceNet 使用两个深层网络实现了整个应用。 第一个网络是从面孔识别特征集,第二个网络是使用该特征集并识别面孔(从技术上讲,将面孔分类为不同的存储桶)。 本质上,第一个网络正在做我们以前做的事情,第二个网络是一个简单而传统的机器学习算法。

深度网络能够从数据集中识别特征,前提是我们拥有大型的标记数据集。 FaceNet 的第一个网络接受了带有相应标签的庞大人脸数据集的训练。 第一个网络经过训练,可以预测每个人脸的 128 个特征(通常来说,从我们的面孔有 128 个测量值,例如左眼和右眼之间的距离),第二个网络仅使用这 128 个特征来识别人。

Exploring deep learning

图 1.9:一个简单的神经网络

一个简单的神经网络具有一个单独的隐藏层,一个输入层和一个输出层。 从理论上讲,单个隐藏层应该能够近似任何复杂的数学方程式,并且对于单个层我们应该没问题。 然而,事实证明,单隐藏层理论并不是那么实用。 在深度网络中,每一层负责查找某些特征。 初始层找到更详细的特征,而最终层抽象这些详细特征并找到高级特征。

Exploring deep learning

图 1.10:深度神经网络

了解不同的架构

深度学习已经存在了数十年,针对不同的用例演变出了不同的结构和架构。 其中一些基于我们对大脑的想法,而另一些则基于大脑的实际工作。 即将到来的所有章节均基于业界正在使用的的最新架构。 我们将介绍每种架构下的一个或多个应用,每一章都涵盖所有概念,规范和技术细节,其中显然都包含 PyTorch 代码。

全连接网络

全连接或密集或线性网络是最基本但功能最强大的架构。 这是通常所谓的机器学习的直接扩展,在该机器学习中,您使用具有单个隐藏层的神经网络。 全连接层充当所有架构的端点,以使用下面的深度网络来找到分数的概率分布。 顾名思义,一个全连接网络将所有神经元在上一层和下一层相互连接。 网络可能最终决定通过设置权重来关闭某些神经元,但是在理想情况下,最初,所有神经元都参与了通信。

编码器和解码器

编码器和解码器可能是深度学习框架下的下一个最基本的架构。 所有网络都有一个或多个编码器-解码器层。 您可以将全连接层中的隐藏层视为来自编码器的编码形式,而将输出层视为将隐藏层解码为输出的解码器。 通常,编码器将输入编码为中间状态,其中输入表示为向量,然后解码器网络将其解码为我们想要的输出形式。

编码器-解码器网络的一个典型示例是序列到序列seq2seq)网络,可以将其用作机器翻译。 用英语说的句子将被编码为中间向量表示,其中整个句子将以一些浮点数的形式进行分块,并且解码器从中间向量以另一种语言解码输出句子。

Encoders and decoders

图 1.11:Seq2seq 网络

自编码器是一种特殊的编码器-解码器网络,属于无监督学习类别。 自编码器尝试从未标记的数据中学习,将目标值设置为等于输入值。 例如,如果输入的图像尺寸为100 x 100,则输入向量的尺寸为 10,000。 因此,输出大小也将为 10,000,但隐藏层的大小可能为 500。简而言之,您尝试将输入转换为较小尺寸的隐藏状态表示,从而从隐藏状态重新生成相同的输入 。

如果您能够训练一个可以做到这一点的神经网络,那么,您将找到一个很好的压缩算法,可以将高维输入转移到低维向量,并获得一个数量级的幅度的收益。

如今,自编码器被用于不同的情况和行业。 当我们讨论语义分割时,您将在第 4 章,“计算机视觉”中看到类似的架构。

Encoders and decoders

图 1.12:自编码器的结构

循环神经网络

RNN 是最常见的深度学习算法之一,它们席卷全球。 我们现在在自然语言处理或理解中几乎拥有所有最先进的表现,这是由于 RNN 的变体。 在循环网络中,您尝试识别数据中的最小单位,并使数据成为这些单位的组。 在自然语言的示例中,最常见的方法是使一个单词成为一个单元,并在处理该句子时将其视为一组单词。 您展开整个句子的 RNN,然后一次处理一个单词。 RNN 具有适用于不同数据集的变体,有时,选择变体时可以考虑效率。 长短期记忆LSTM)和门控循环单元GRU)单元是最常见的 RNN 单元。

Recurrent neural networks

图 1.13:循环网络中单词的向量表示

递归神经网络

顾名思义,递归神经网络是树状网络,用于了解序列数据的层次结构。 递归网络已在自然语言处理应用中大量使用,尤其是 Salesforce 首席科学家 Richard Socher 及其团队。

词向量,我们将在第 5 章,“序列数据处理”中很快看到,它们能够将词的含义有效地映射到向量空间中,但是涉及到整个句子中的含义,没有像 word2vec 这样的单词适合的解决方案。 递归神经网络是此类应用最常用的算法之一。 递归网络可以创建一个解析树和组成向量,并映射其他层次关系,这反过来又帮助我们找到了结合单词和句子的规则。 斯坦福自然语言推断小组发现了一种著名的且使用良好的算法,称为 SNLI,这是递归网络使用的一个很好的例子。

Recursive neural networks

图 1.14:递归网络中单词的向量表示

卷积神经网络

卷积神经网络CNN)使我们能够在计算机视觉中获得超人的表现。 在的早期,我们达到了的人类准确率,并且我们仍在逐年提高准确率。

卷积网络是最易理解的网络,因为我们有可视化工具可以显示每一层的特征。 Facebook AI ResearchFAIR)负责人 Yann LeCun 于 1990 年代发明了 CNN。 那时我们无法使用它们,因为我们没有足够的数据集和计算能力。 CNN 基本上像滑动窗口一样扫描您的输入并进行中间表示,然后在最终到达全连接层之前对其进行逐层抽象。 CNN 也成功地用于非图像数据集中。

Facebook 研究团队发现了一种具有卷积网络的先进自然语言处理系统,该系统优于 RNN,RNN 被认为是任何序列数据集的首选架构。 尽管一些神经科学家和一些 AI 研究人员不喜欢 CNN,但是由于他们认为大脑不能像 CNN 那样工作,因此基于 CNN 的网络正在击败所有现有实现。

Convolutional neural networks

图 1.15:典型的 CNN

生成对抗网络

生成对抗网络GAN)由 Ian Goodfellow 于 2014 年发明,从那时起,它们使整个 AI 社区颠倒了。 它们是最简单,最明显的实现方式之一,但具有以其功能吸引世界的力量。 在 GAN 中,两个网络相互竞争并达到平衡,生成器网络可以生成数据,而判别器网络很难与实际图像区分开来。 一个真实的例子就是警察与假冒者之间的斗争。

造假者试图制造假币,而警察试图对其进行侦查。 最初,造假者知识不足,无法制作出看起来很原始的假币。 随着时间的流逝,造假者越来越擅长制作看起来更像原始货币的货币。 然后,警察开始无法识别假币,但最终他们会再次变得更好。 这一世代歧视过程最终导致了平衡。 GAN 的优势是巨大的,我们将在后面详细讨论。

Generative adversarial networks

Figure 1.16: GAN setup

强化学习

通过互动学习是人类智能的基础。 强化学习是引导我们朝这个方向发展的方法。 强化学习曾经是一个完全不同的领域,它是基于概念的,即人们通过反复试验来学习。 但是,随着深度学习的发展,弹出了另一个领域,称为深度强化学习,它将深度学习和强化学习的力量结合在一起。

现代强化学习使用深度网络进行学习,这与我们以前明确编码那些规则的旧方法不同。 我们将研究 Q 学习和深度 Q 学习,向您展示有无深度学习的强化学习之间的区别。

强化学习被认为是通向一般智能的途径之一,在这种途径中,计算机或智能体通过与现实世界,对象或实验的交互或从反馈中学习。 教一个强化学习智能体人相当于通过负面和正面奖励来训练狗。 当您给一块饼干拿起球时,或者当您对狗不捡球而大喊时,您会通过消极和积极的奖励来增强对狗大脑的了解。 我们对 AI 智能体执行相同的操作,但是正数奖励将为正数,负数奖励将为负数。 即使我们不能将强化学习视为类似于 CNN/RNN 等的另一种架构,但我还是在这里将其作为使用深度神经网络解决实际问题的另一种方法:

Reinforcement learning

图 1.17:强化学习设置的图示

代码入门

让我们用一些代码弄脏一下。 如果您以前使用过 NumPy,那么您将在这里。 如果没有,请不要担心。 PyTorch 旨在简化初学者的生活。

作为深度学习框架,PyTorch 也可以用于数值计算。 在这里,我们讨论 PyTorch 中的基本操作。 本章中的基本 PyTorch 操作将在下一章中简化您的工作,在下一章中,我们将尝试为一个简单的用例构建一个实际的神经网络。 本书中的所有程序都将使用 Python 3.7 和 PyTorch 1.0。 GitHub 存储库也使用相同的配置构建:尽管 PyTorch 团队推荐使用该包管理器,但它是从 PyPI 而不是 Conda 获得的 PyTorch。

学习基本操作

让我们从导入torch到命名空间开始编码:

import torch

PyTorch 中的基本数据抽象是Tensor对象,它是 NumPy 中ndarray的替代方案。 您可以在 PyTorch 中以多种方式创建张量。 我们将在此处讨论一些基本方法,在构建应用时,您将在接下来的各章中看到所有这些方法:

uninitialized = torch.Tensor(3,2)
rand_initialized = torch.rand(3,2)
matrix_with_ones = torch.ones(3,2)
matrix_with_zeros = torch.zeros(3,2)

rand方法为您提供给定大小的随机矩阵,而Tensor函数返回未初始化的张量。 要从 Python 列表创建张量对象,请调用torch.FloatTensor(python_list),它类似于np.array(python_list)FloatTensor是 PyTorch 支持的几种类型之一。 下表列出了可用的类型:

数据类型 CPU 张量 GPU 张量
32 位浮点 torch.FloatTensor torch.cuda.FloatTensor
64 位浮点 torch.DoubleTensor torch.cuda.DoubleTensor
16 位浮点 torch.HalfTensor torch.cuda.HalfTensor
8 位整数(无符号) torch.ByteTensor torch.cuda.ByteTensor
8 位整数(有符号) torch.CharTensor torch.cuda.CharTensor
16 位整数(有符号) torch.ShortTensor torch.cuda.ShortTensor
32 位整数(有符号) torch.IntTensor torch.cuda.IntTensor
64 位整数(有符号) torch.LongTensor torch.cuda.LongTensor

表 1.1:PyTorch 支持的数据类型。 资料来源

在每个版本中,PyTorch 都会对该 API 进行一些更改,以使所有可能的 API 都类似于 NumPy API。 形状是 0.2 版本中引入的那些更改之一。 调用shape属性可以得到张量的形状(在 PyTorch 术语中为大小),也可以通过size函数进行访问:

>>> size = rand_initialized.size()
>>> shape = rand_initialized.shape
>>> print(size == shape)
True

shape对象是从 PythoN 元组继承的,因此对shape对象也可以对元组进行所有可能的操作。 作为一个很好的副作用,shape对象是不可变的。

>>> print(shape[0])
3
>>> print(shape[1])
2

现在,由于您知道张量是什么以及如何创建张量,因此我们将从最基本的数学运算开始。 一旦您熟悉乘法加法和矩阵运算之类的操作,其他所有都不过是乐高积木。

PyTorch 张量对象具有覆盖了 Python 的数值运算,并且您可以使用普通运算符。 张量标量运算可能是最简单的:

 >>> x = torch.ones(3,2)
>>> x
tensor([[1., 1.],
	   [1., 1.],
	   [1., 1.]])
>>>
>>> y = torch.ones(3,2) + 2
>>> y
tensor([[3., 3.],
	   [3., 3.],
	   [3., 3.]])
>>>
>>> z = torch.ones(2,1)
>>> z
tensor([[1.],
      [1.]])
>>>
>>> x * y @ z
tensor([[6.],
	   [6.],
	   [6.]]) 

变量xy3 x 2张量,Python 乘法运算符执行逐元素乘法并给出相同形状的张量。 这个张量和形状为3 x 2z张量正在通过 Python 的矩阵乘法运算符,并吐出3 x 2矩阵。

如上例所示,张量-张量操作有多个选项,例如普通的 Python 运算符,原地 PyTorch 函数和原地 PyTorch 函数。

 >>> z = x.add(y) 
>>> print(z) 
tensor([[1.4059, 1.0023, 1.0358], 
             [0.9809, 0.3433, 1.7492]]) 
>>> z = x.add_(y) #in place addition. 
>>> print(z) 
tensor([[1.4059, 1.0023, 1.0358], 
            [0.9809, 0.3433, 1.7492]]) 
>>> print(x) 
tensor([[1.4059, 1.0023, 1.0358],
            [0.9809, 0.3433, 1.7492]]) 
>>> print(x == z) 
tensor([[1, 1, 1], 
            [1, 1, 1]], dtype=torch.uint8) 
>>> 
>>> 
>>> 
>>> x = torch.rand(2,3) 
>>> y = torch.rand(3,4) 
>>> x.matmul(y) 
tensor([[0.5594, 0.8875, 0.9234, 1.1294], 
            [0.7671, 1.7276, 1.5178, 1.7478]]) 

可以使用+运算符或add函数将两个大小相同的张量相加,以获得相同形状的输出张量。 PyTorch 遵循对相同操作使用尾部下划线的约定,但这确实发生了。 例如,a.add(b)为您提供了一个新的张量,其总和超过了ab。 此操作不会对现有的ab张量进行任何更改。 但是a.add_(b)用总和值更新张量a并返回更新后的a。 这适用于 PyTorch 中的所有运算符。

注意

原地运算符遵循尾部下划线的约定,例如add_sub_

可以使用函数matmul完成矩阵乘法,而出于相同目的,还有其他函数,例如mm和 Python 的@。 切片,索引和连接是在对网络进行编码时最终要完成的下一个最重要的任务。 PyTorch 使您能够使用基本的 Pythonic 或 NumPy 语法来完成所有这些操作。

索引张量就像索引普通的 Python 列表一样。 可以通过递归索引每个维度来索引多个维度。 索引从第一个可用维中选择索引。 索引时可以使用逗号分隔每个维度。 切片时可以使用此方法。 起始和结束索引可以使用完整的冒号分隔。 可以使用属性t访问矩阵的转置。 每个 PyTorch 张量对象都具有t属性。

连接是工具箱中需要执行的另一项重要操作。 PyTorch 出于相同的目的制作了函数cat。 所有尺寸上的两个张量相同的张量(一个张量除外)可以根据需要使用cat进行连接。 例如,大小为3 x 2 x 4的张量可以与另一个大小为3 x 2 x 4的张量在第一维上级联,以获得大小为3 x 2 x 4的张量。stack操作看起来非常类似于连接,但这是完全不同的操作。 如果要向张量添加新尺寸,则可以使用stack。 与cat相似,您可以将轴传递到要添加新尺寸的位置。 但是,请确保两个张量的所有尺寸都与附着尺寸相同。

splitchunk是用于拆分张量的类似操作。 split接受每个输出张量要的大小。 例如,如果要在第 0 个维度上拆分大小为3 x 2的张量,尺寸为 1,则将得到三个大小均为3 x 2的张量。但是,如果在第 0 个维度上使用 2 作为大小,则会得到3 x 2的张量和另一个3 x 2的张量。

squeeze函数有时可以节省您的时间。 在某些情况下,您将具有一个或多个尺寸为 1 的张量。有时,您的张量中不需要那些多余的尺寸。 这就是squeeze将为您提供帮助的地方。 squeeze删除值为 1 的维。例如,如果您正在处理句子,并且有 10 个句子的批量,每个句子包含 5 个单词,则将其映射到张量对象时,将得到10 x 5的张量。然后,您意识到必须将其转换为一热向量,以便神经网络进行处理。

您可以使用大小为 100 的单热点编码向量为张量添加另一个维度(因为词汇量为 100 个单词)。 现在,您有了一个尺寸为10 x 5 x 100的张量对象,并且每个批量和每个句子一次传递一个单词。

现在,您必须对句子进行拆分和切分,最有可能的结果是,张量的大小为10 x 1 x 100(每 10 个单词中的一个单词带有 100 维向量)。 您可以使用10 x 100的张量处理它,这使您的生活更加轻松。 继续使用squeeze10 x 1 x 100张量得到10 x 100张量。

PyTorch 具有称为unsqueeze的防挤压操作,该操作会为张量对象添加另一个伪尺寸。 不要将unsqueezestack混淆,这也会增加另一个维度。 unsqueeze添加了伪尺寸,并且不需要其他张量,但是stack正在将其他形状相同的张量添加到参考张量的另一个尺寸中。

Learning the basic operations

Learning the basic operations

Learning the basic operations

图 1.18:级联,栈,压缩和取消压缩的图示

如果您对的所有这些基本操作感到满意,则可以继续第二章并立即开始编码会话。 PyTorch 附带了许多其他重要操作,当您开始构建网络时,您一定会发现它们非常有用。 我们将在接下来的各章中看到其中的大多数内容,但是如果您想首先学习这一点,请访问 PyTorch 网站并查看其张量教程页面,该页面描述了张量对象可以执行的所有操作。

PyTorch 的内部

互操作性是 PyTorch 自身发展的核心哲学之一。 开发团队投入了大量时间来实现不同框架(例如 ONNX,DLPack 等)之间的互操作性。 这些示例将在后面的章节中显示,但是在这里,我们将讨论 PyTorch 的内部设计如何在不影响速度的前提下满足这一要求。

普通的 Python 数据结构是可以保存数据和元数据的单层内存对象。 但是 PyTorch 数据结构是分层设计的,这使得该框架不仅可以互操作而且还可以提高内存效率。 PyTorch 核心的计算密集型部分已通过 ATen 和 Caffe2 库迁移到了 C/C++ 后端,而不是将其保留在 Python 本身中,以便提高速度。

即使将 PyTorch 创建为研究框架,也已将其转换为面向研究但可用于生产的框架。 通过引入两种执行类型,可以解决多用例需求所带来的折衷。 我们将在第 8 章和“生产中的 PyTorch”中看到更多相关信息,我们将在其中讨论如何将 PyTorch 投入生产。

C/C++ 后端中设计的自定义数据结构已分为不同的层。 为简单起见,我们将省略 CUDA 数据结构,而将重点放在简单的 CPU 数据结构上。 PyTorch 中的面向用户的主要数据结构是THTensor对象,它保存有关尺寸,偏移,步幅等信息。 但是,THTensor存储的另一个主要信息是指向THStorage对象的指针,该对象是为存储而保存的张量对象的内部层。

x = torch.rand(2,3,4)
x_with_2n3_dimension = x[1, :, :]
scalar_x = x[1,1,1]     # first value from each dimension

# numpy like slicing
x = torch.rand(2,3)
print(x[:, 1:])        # skipping first column
print(x[:-1, :])       # skipping last row

# transpose
x = torch.rand(2,3)
print(x.t())           # size 3x2

# concatenation and stacking
x = torch.rand(2,3)
concat = torch.cat((x,x))
print(concat)         # Concatenates 2 tensors on zeroth dimension

x = torch.rand(2,3)
concat = torch.cat((x,x), dim=1)
print(concat)         # Concatenates 2 tensors on first dimension

x = torch.rand(2,3)
stacked = torch.stack((x,x), dim=0)
print(stacked)        # returns 2x2x3 tensor

# split: you can use chunk as well
x = torch.rand(2,3)
splitted = x.split(split_size=2, dim=0)
print(splitted)       # 2 tensors of 2x2 and 1x2 size

#sqeeze and unsqueeze
x = torch.rand(3,2,1) # a tensor of size 3x2x1
squeezed = x.squeeze()
print(squeezed)       # remove the 1 sized dimension

x = torch.rand(3)
with_fake_dimension = x.unsqueeze(0)
print(with_fake_dimension)        # added a fake zeroth dimension

The internals of PyTorch

图 1.19:THTensor 到 THStorage 到原始数据

正如您可能已经假设的那样,THStorage层不是一个智能数据结构,它实际上并不知道张量的元数据。 THStorage层负责保持指向原始数据和分配器的指针。 分配器完全是另一个主题,中有用于 CPU,GPU,共享内存等的不同分配器。 来自THStorage的指向原始数据的指针是互操作性的关键。 原始数据是存储实际数据的位置,但没有任何结构。 每个张量对象的这种三层表示使 PyTorch 的实现内存效率更高。 以下是一些示例。

将变量x创建为2 x 2的张量,并填充 1。 然后,我们创建另一个变量xv,它是同一张量x的另一个视图。 我们将2 x 2张量展平为大小为 4 的单维张量。我们还通过调用.NumPy()方法并将其存储在变量xn中来创建 NumPy 数组:

>>> import torch
>>> import numpy as np >>> x = torch.ones(2,2)
>>> xv = x.view(-1)
>>> xn = x.numpy()
>>> x
tensor([[1., 1.],[1., 1.]])
>>> xv
tensor([1., 1., 1., 1.])
>>> xn
array([[1\. 1.],[1\. 1.]], dtype=float32)

PyTorch 提供了多种 API 来检查内部信息,storage()是其中之一。 storage()方法返回存储对象(THStorage),该存储对象是先前描述的 PyTorch 数据结构中的第二层。 xxv的存储对象如下所示。 即使两个张量的视图(尺寸)不同,存储区仍显示相同的尺寸,这证明THTensor存储有关尺寸的信息,但存储层是一个转储层,仅将用户指向原始数据对象。 为了确认这一点,我们使用THStorage对象中的另一个 API data_ptr。 这将我们指向原始数据对象。 将xxvdata_ptr等同可证明两者相同:

>>> x.storage()
1.0
1.0
1.0
1.0
[torch.FloatStorage of size 4]
>>> xv.storage()
1.0
1.0
1.0
1.0
[torch.FloatStorage of size 4]
>>> x.storage().data_ptr() == xv.storage().data_ptr()
True

接下来,我们更改张量中的第一个值,索引值为 0、0 到 20。变量xxv具有不同的THTensor层,因为尺寸已更改,但实际原始数据对于两者都相同,这使得在不同张量下创建同一张量的n个视图确实非常容易且节省存储空间。

甚至 NumPy 数组xn也与其他变量共享相同的原始数据对象,因此一个张量中值的变化反映了指向同一原始数据对象的所有其他张量中相同值的变化。 DLPack 是该思想的扩展,它使同一程序中不同框架之间的通信变得容易。

>>> x[0,0]=20
>>> x
tensor([[20.,  1.],[ 1.,  1.]])
>>> xv
tensor([20.,  1.,  1.,  1.])
>>> xn
array([[20.,  1.],[ 1.,  1.]], dtype=float32)

总结

在本章中,我们了解了 PyTorch 的历史以及动态图库相对于静态图库的优缺点。 我们还浏览了人们为解决各个领域的复杂问题而提出的不同架构和模型。 我们介绍了 PyTorch 中最重要的内容:Torch 张量的内部。 张量的概念是深度学习的基础,并且对于您使用的所有深度学习框架都是通用的。

在下一章中,我们将采用更多的动手方法,并将在 PyTorch 中实现一个简单的神经网络。

参考

  1. Ronan Collobert,Koray Kavukcuoglu 和 Clement Farabet,《Torch7:类似于 Matlab 的机器学习环境》
  2. PyTorch 的主页
  3. 《优化深度学习的内存消耗》

二、简单的神经网络

学习构建神经网络的 PyTorch 方法非常重要。 这是编写 PyTorch 代码的最有效,最简洁的方法,并且由于它们具有相同的结构,因此还可以帮助您找到易于理解的教程和示例代码片段。 更重要的是,您将获得高效的代码形式,该形式也具有很高的可读性。

不用担心,PyTorch 不会尝试通过采用全新的方法来在学习曲线中增加另一个峰值。 如果您知道如何使用 Python 进行编码,那么您会立刻感到宾至如归。 但是,我们不会像在第一章中那样学习这些构件。 在本章中,我们将构建一个简单的网络。 与其选择典型的入门级神经网络用例,不如讲授我们的网络以 NumPy 方式进行数学运算。 然后,我们将其转换为 PyTorch 网络。 在本章结束时,您将具备成为 PyTorch 开发人员的技能。

神经网络介绍

在本节中,我们将通过手头的问题陈述以及正在使用的数据集。 然后,我们将构建一个基本的神经网络,然后再将其构建为适当的 PyTorch 网络。

问题

您曾经玩过 Fizz buzz 游戏吗? 如果没有,请不要担心。 以下是有关游戏的简单说明。

注意

根据维基百科的说法,Fizz buzz [1]是一款针对儿童的小组文字游戏,可以教他们有关分裂的知识。 玩家轮流进行递增计数。 被三整除的任何数字[2]被单词 fizz 替换,被五整除的任何数字被 buzz 单词替换。 两者均分的数字成为嘶嘶声。

艾伦人工智能研究所AI2)的研究工程师之一乔尔·格鲁斯(Joel Grus)在一个有趣的示例中使用了 Fizz 嗡嗡声,而则在博客中发文[3]在 TensorFlow 上。 尽管该示例没有解决任何实际问题,但该博客文章颇具吸引力,很高兴看到神经网络如何学会从数字流中找到数学模式。

数据集

建立数据管道与网络的架构一样重要,尤其是在实时训练网络时。 从野外获得的数据永远不会干净,在将其扔到网络之前,您必须对其进行处理。 例如,如果我们要收集数据以预测某人是否购买产品,那么最终将出现异常值。 离群值可以是任何种类且不可预测的。 例如,某人可能不小心下了订单,或者他们可以访问后来下订单的朋友,依此类推。

从理论上讲,深度神经网络非常适合从数据集中查找模式和解,因为它们应该模仿人的大脑。 但是,实际上,情况并非总是如此。 如果您的数据干净且格式正确,您的网络将能够通过找到模式来轻松解决问题。 PyTorch 开箱即用地提供了数据预处理包装器,我们将在第 3 章和“深度学习工作流程”中进行讨论。 除此之外,我们将讨论如何格式化或清除数据集。

为简单起见,我们将使用一些简单的函数来生成数据。 让我们开始为 FizzBu​​zz 模型构建简单的数据集。 当我们的模型得到一个数字时,它应该预测下一个输出,就好像是在玩游戏的人一样。 例如,如果输入为三,则模型应预测下一个数字为四。 如果输入为八,则模型应显示“嘶嘶声”,因为九可以被三整除。

我们不希望我们的模型遭受复杂的输出。 因此,为使我们的模型更容易,我们将问题描述为一个简单的分类问题,其中模型将输出分为四个不同类别:fizzbuzzfizzbuzzContinue_without_change。 对于任何输入模型,我们都将尝试在这四个类别上进行概率分布,而在训练下,我们可以尝试使概率分布集中在正确类别上。

我们还将输入的数字转换为二进制编码的形式,这使网络比整数更容易处理。

Dataset

图 2.1:输入到输出映射

以下代码以二进制形式生成输入,并以大小为 4 的向量生成输出:

def binary_encoder(input_size):
    def wrapper(num):
        ret = [int(i) for i in '{0:b}'.format(num)]
        return [0] * (input_size - len(ret)) + ret
    return wrapper

def get_numpy_data(input_size=10, limit=1000):
    x = []
    y = []
    encoder = binary_encoder(input_size)
    for i in range(limit):
        x.append(encoder(i))
        if i % 15 == 0:
            y.append([1, 0, 0, 0])
        elif i % 5 == 0:
            y.append([0, 1, 0, 0])
        elif i % 3 == 0:
            y.append([0, 0, 1, 0])
        else:
            y.append([0, 0, 0, 1])
    return training_test_gen(np.array(x), np.array(y))

编码器函数将输入编码为二进制数,从而使神经网络易于学习。 将数值直接传递到神经网络会对网络施加更多约束。 不要担心最后一行中的training_test_gen函数; 我们将在第 3 章和“深度学习工作流程”中进行更多讨论。 现在,请记住,它将数据集拆分为训练和测试集,并将其作为 NumPy 数组返回。

利用到目前为止我们拥有的关于数据集的信息,我们可以按以下方式构建网络:

  • 我们将输入转换为 10 位二进制数,因此我们的第一个输入层需要 10 个神经元才能接受这 10 位数字。
  • 由于我们的输出始终是大小为 4 的向量,因此我们需要有四个输出神经元。
  • 看来我们要解决的问题很简单:比较深度学习在当今世界中产生的虚构冲动。 首先,我们可以有一个大小为 100 的隐藏层。
  • 由于在处理之前批量数据总是更好,为了获得良好的结果,我们将对输入的批量添加 64 个数据点。 请查看本章末尾的“查找误差”部分,以了解批量为什么更好。

让我们定义超参数并调用我们先前定义的函数以获取训练和测试数据。 我们将为各种神经网络模型定义五个典型的超参数:

epochs = 500
batches = 64
lr = 0.01
input_size = 10
output_size = 4
hidden_size = 100

我们需要在程序顶部定义输入和输出大小,这将帮助我们在不同的地方使用输入和输出大小,例如网络设计函数。 隐藏大小是隐藏层中神经元的数量。 如果要手动设计神经网络,则权重矩阵的大小为input_size x hidden_size,这会将您输入的大小input_size转换为大小hidden_sizeepoch是通过网络进行迭代的计数器值。 epoch的概念最终取决于程序员如何定义迭代过程。 通常,对于每个周期,您都要遍历整个数据集,然后对每个周期重复一次。

for i in epoch:
    network_execution_over_whole_dataset()

学习率决定了我们希望我们的网络从每次迭代的误差中获取反馈的速度。 它通过忘记网络从所有先前迭代中学到的知识来决定从当前迭代中学到的知识。 将学习率保持为 1 可使网络考虑完全误差,并根据完全误差调整权重。 学习率为零意味着向网络传递的信息为零。 学习率将是神经网络中梯度更新方程式中的选择因子。 对于每个神经元,我们运行以下公式来更新神经元的权重:

weight -= lr * loss

较低的学习率可帮助网络沿着山路走很小的步,而较高的学习率可帮助网络沿山路走。 但是,这是有代价的。 一旦损失接近最小值,较高的学习率可能会使网络跳过最小值,并导致网络永远找不到最小值。 从技术上讲,在每次迭代中,网络都会对近似值进行线性近似,而学习率将控制该近似值。

如果损失函数高度弯曲,则以较高的学习率进行较长的步骤可能会导致模型变坏。 因此,理想的学习率始终取决于问题陈述和当前的模型架构。 《深度学习》[4]的第四章是了解学习重要性的好资料。 来自 Coursera 上著名的吴恩达(Andrew Ng)课程的精美图片代表清楚地了解了学习率如何影响网络学习。

Dataset

图 2.2:学习率低而学习率高

徒手模型

现在,我们将建立一个徒手,类似于 NumPy 的模型,而不使用任何 PyTorch 特定的方法。 然后,在下一个会话中,我们将把相同的模型转换为 PyTorch 的方法。 如果您来自 NumPy,那么您会感到宾至如归,但是如果您是使用其他框架的高级深度学习从业者,请随意跳过本节。

Autograd

因此,既然我们知道张量应该为类型,就可以根据从get_numpy_data()获得的 NumPy 数组创建 PyTorch 张量。

x = torch.from_numpy(trX).to(device=device, dtype=dtype)
y = torch.from_numpy(trY).to(device=device, dtype=dtype)
w1 = torch.randn(input_size, hidden_size, requires_grad=True, device=device, dtype=dtype)
w2 = torch.randn(hidden_size, output_size, requires_grad=True, device=device, dtype=dtype)
b1 = torch.zeros(1, hidden_size, requires_grad=True, device=device, dtype=dtype)
b2 = torch.zeros(1, output_size, requires_grad=True, device=device, dtype=dtype)

对于初学者来说,这可能看起来很吓人,但是一旦您学习了基本的构建块,就只有六行代码。 我们从 PyTorch 中最重要的模块开始,该模块是 PyTorch 框架的主框架 autograd。 它可以帮助用户进行自动微分,从而使我们在深度学习领域取得了所有突破。

注意

注意:自动微分,有时也称为算法微分,是通过计算机程序利用函数执行顺序的技术。 自动微分的两种主要方法是正向模式和反向模式。 在前向模式自动微分中,我们首先找到外部函数的导数,然后递归进入内部,直到我们探索所有子节点。 反向模式自动微分正好相反,并且被深度学习社区和框架使用。 它由 Seppo Linnainmaa 于 1970 年在其硕士论文中首次出版。反向模式微分的主要构建模块是存储中间变量的存储器,以及使这些变量计算导数的功能,同时从子节点移回到父节点。

正如 PyTorch 主页所说,PyTorch 中所有神经网络的中心都是 Autograd 包。 PyTorch 借助 Autograd 包获得了动态功能。 程序执行时,Autograd 将每个操作写入磁带状数据结构并将其存储在内存中。

这是反向模式自动微分的关键特征之一。 这有助于 PyTorch 动态化,因为无论用户在向前传递中作为操作编写的内容都可以写入磁带,并且在反向传播开始时,Autograd 可以在磁带上向后移动并随梯度一起移动,直到到达最外层父级。

磁带或内存的写操作可忽略不计,PyTorch 通过将操作写到磁带上并在向后遍历后销毁磁带来利用每次正向遍历中的行为。 尽管我会在本书中尽量避免使用尽可能多的数学方法,但是有关 Autograd 如何工作的数学示例绝对可以为您提供帮助。 在下面的两个图中,说明了反向传播算法和使用链式规则的 Autograd 的方法。 下图中我们有一个小型网络,其中有一个乘法节点和一个加法节点。 乘法节点获取输入张量和权重张量,将其传递到加法节点以进行加法运算。

output = X * W + B

由于将方程分为几步,因此我们可以根据下一阶段找到每个阶段的斜率,然后使用链式规则将其链接在一起,从而根据最终输出获得权重的误差。 第二张图显示了 Autograd 如何将这些导数项中的每一个链接起来以获得最终误差。

Autograd

图 2.3:Autograd 的工作方式

Autograd

图 2.4:Autograd 使用的链式规则

前面的图可以使用以下代码转换为 PyTorch 图:

>>> import torch
>>> inputs = torch.FloatTensor([2])
>>> weights = torch.rand(1, requires_grad=True)
>>> bias = torch.rand(1, requires_grad=True)
>>> t = inputs @ weights
>>> out = t + bias
>>> out.backward()
>>> weights.grad
tensor([2.])
>>>bias.grad
tensor([1.])

通常,用户可以使用两个主要的 API 访问 autograd,这将处理您在构建神经网络时几乎会遇到的所有操作。

张量的 Autograd 属性

当成为图的一部分时,张量需要存储 Autograd 自动微分所需的信息。 张量充当计算图中的一个节点,并通过函数式模块实例连接到其他节点。 张量实例主要具有支持 Autograd 的三个属性:.grad.datagrad_fn()(注意字母大小写:Function代表 PyTorch Function模块,而function代表 Python 函数)。

.grad属性在任何时间点存储梯度,所有向后调用将当前梯度累积到.grad属性。 .data属性可访问其中包含数据的裸张量对象。

Autograd attributes of a tensor

图 2.5:datagradgrad_fn

如果您想知道,前面的代码片段中的required_grad参数会通知张量或 Autograd 引擎在进行反向传播时需要梯度。 创建张量时,可以指定是否需要该张量来承载梯度。 在我们的示例中,我们没有使用梯度更新输入张量(输入永远不会改变):我们只需要更改权重即可。 由于我们没有在迭代中更改输入,因此不需要输入张量即可计算梯度。 因此,在包装输入张量时,我们将False作为required_grad参数传递,对于权重,我们传递True。 检查我们之前创建的张量实例的graddata属性。

TensorFunction实例在图中时是相互连接的,并且一起构成了非循环计算图。 除了用户明确创建的张量以外,每个张量都连接到一个函数。 (如果用户未明确创建张量,则必须通过函数创建张量。例如,表达式c = a + b中的c由加法函数创建。 )您可以通过在张量上调用grade_fn来访问创建器函数。 打印grad.data.grade_fn()的值可得到以下结果:

print(x.grad, x.grad_fn, x)
# None None tensor([[...]])
print(w1.grad, w1.grad_fn, w1)
# None None tensor([[...]])

我们的输入x和第一层权重矩阵w1目前没有gradgrad_fn。 我们将很快看到这些属性的更新方式和时间。 x.data属性为900 x 10形状,因为我们传递了 900 个数据点,每个数据点的大小均为 10(二进制编码数)。 现在,您可以准备进行数据迭代了。

我们已经准备好输入,权重和偏差,并等待数据输入。如前所述,PyTorch 是一个基于动态图的网络,该网络在每次迭代时构建计算图。 因此,当我们遍历数据时,我们实际上是在动态构建图,并在到达最后一个或根节点时对其进行反向传播。 这是显示此代码段:

for epoch in range(epochs):
    for batch in range(no_of_batches):
        start = batch * batches
        end = start + batches
        x_ = x[start:end]
        y_ = y[start:end]

        # building graph
        a2 = x_.matmul(w1)
        a2 = a2.add(b1)
        print(a2.grad, a2.grad_fn, a2)
        # None <AddBackward0 object at 0x7f5f3b9253c8> tensor([[...]])
        h2 = a2.sigmoid()
        a3 = h2.matmul(w2)
        a3 = a3.add(b2)
        hyp = a3.sigmoid()
        error = hyp - y_
        output = error.pow(2).sum() / 2.0

        output.backward()

        print(x.grad, x.grad_fn, x)
        # None None tensor([[...]])
        print(w1.grad, w1.grad_fn, w1)
        # tensor([[...]], None, tensor([[...]]
        print(a2.grad, a2.grad_fn, a2)
        # None <AddBackward0 object at 0x7f5f3d42c780> tensor([[...]])

        # parameter update
        with torch.no_grad():
            w1 -= lr * w1.grad
            w2 -= lr * w2.grad
            b1 -= lr * b1.grad
            b2 -= lr * b2.grad

前面的代码段与在第 1 章,“深度学习演练和 PyTorch 简介”中看到的相同,其中解释了静态和动态计算图,但在这里我们从另一个角度来看一下代码:模型说明。 它从循环遍历每个周期的批量开始,并使用我们正在构建的模型处理每个批量。 与基于静态计算图的框架不同,我们尚未构建图。 我们刚刚定义了超参数,并根据我们的数据制作了张量。

构建图

我们正在构建该图,如下图所示:

Building the graph

图 2.6:网络架构

第一层由批量输入矩阵,权重和偏差之间的矩阵乘法和加法组成。 此时,a2张量应具有一个grad_fn,这应该是矩阵加法的后向操作。 但是,由于我们还没有进行反向传递,因此.grad应该返回None.data,并且将一如既往地返回张量,以及矩阵乘法和偏差加法的结果。 神经元活动由 Sigmoid 激活函数定义,它以h2(代表第二层中的隐藏单元)的输出形式提供给我们。 第二层采用相同的结构:矩阵乘法,偏差加法和 Sigmoid。 最后得到hyp,它具有预期的结果:

print(a2.grad, a2.grad_fn, a2)
# None <AddBackward0 object at 0x7f5f3b9253c8> tensor([[...]])
注意

Softmax:让 Sigmoid 曲面吐出分类问题的预测是很不寻常的,但是我们将其保留下来,因为这样会使我们的模型易于理解,因为它重复了第一层。 通常,分类问题由 softmax 层和交叉熵损失处理,这会增加一类相对于另一类的概率。 由于所有类别的概率加在一起,因此增加一个类别的概率会降低其他类别的概率,这是一个不错的函数。 在以后的章节中将对此进行更多介绍。

查找误差

是时候找出了,我们的模型在 Fizz 嗡嗡声中的预测效果如何。 我们使用最基本的回归损失,称为均方误差MSE)。 最初,我们发现批量中每个元素的预测与输出之间的差异(还记得我们为每个输入数据点创建的大小为 4 的向量吗?)。 然后我们对所有差异求平方,并将所有差异求和在一起,以获得一个单一值。 如果您不熟悉损失函数,则不必担心被 2.0 除。 这样做是为了使数学在进行反向传播时保持整洁。

反向传播

来自 NumPy 背景的人们,准备被吹走。 在 TensorFlow 或 PyTorch 等高级框架中开始进行深度学习的人,不要认为这是理所当然的。 现代框架的强大功能(自动微分)使反向传播成为一线。 图中的最后一个节点是我们刚刚发现的损失结果。 现在,我们有了一个值,该值说明了我们的模型对结果的预测程度(或良好),我们需要根据该值更新参数。 反向传播可以为您提供帮助。 我们需要承担这种损失,然后移回每个神经元以查找每个神经元的贡献。

Backpropagation

图 2.7:反向传播和减少损失的例子

考虑损失函数的图形,其中Y轴是误差(我们的模型有多糟糕)。 最初,模型的预测将是随机的,并且对于整个数据集而言确实是不利的,也就是说,Y轴上的误差确实很高。 我们需要像爬山一样将其向下移动:我们要爬下山并找到山谷中能提供接近准确结果的最低点。

反向传播通过找到每个参数应移动的方向来实现这一点,从而使损失值的整体运动爬下山。 我们为此寻求微积分的帮助。 任何函数相对于最终误差的导数都可以告诉我们上图中该函数的斜率是多少。 因此,反向传播通过获取关于最终损失的每个神经元(通常每个神经元通常是非线性函数)的导数并告诉我们必须移动的方向来帮助我们。

在拥有框架之前,这不是一个容易的过程。 实际上,找到每个参数的导数并进行更新是一项繁琐且容易出错的任务。 在 PyTorch 中,您要做的就是在最后一个节点上调用backward,它将反向传播并更新它。 具有梯度的grad属性。

PyTorch 的backward函数进行反向传播,并找到每个神经元的误差。 但是,我们需要基于此误差因子来更新神经元的权重。 更新发现的误差的过程通常称为优化,并且有不同的优化策略。 PyTorch 为我们提供了另一个名为optim的模块,用于实现不同的优化算法。 在先前的实现中,我们使用了基本且最受欢迎的优化算法,称为随机梯度下降SGD)。 当我们使用复杂的神经网络时,我们将在后面的章节中看到不同的优化算法。

PyTorch 还通过将反向传播和优化分为不同的步骤,为我们提供了更大的灵活性。 请记住,反向传播会在.grad属性中累积梯度。 这是有帮助的,特别是在我们的项目更注重研究,或者想要深入研究权重-梯度关系,或者想要了解梯度的变化方式时。 有时,我们希望更新除特定神经元之外的所有参数,或者有时我们可能认为不需要更新特定层。 在需要对参数更新进行更多控制的情况下,具有显式的参数更新步骤会带来很大的好处。

在前进之前,我们检查之前检查过的所有张量,以了解在反向传播之后发生了什么变化。

print(x.grad, x.grad_fn, x)
# None None tensor([[...]])
print(w1.grad, w1.grad_fn, w1)
# tensor([[...]], None, tensor([[...]]
print(a2.grad, a2.grad_fn, a2)
# None <AddBackward0 object at 0x7f5f3d42c780> tensor([[...]])

事情变了! 由于我们使用required_grad作为False创建了输入张量,因此我们首先进行打印以检查输入的属性没有显示任何差异。 w1已更改。 在反向传播之前,.grad属性为None,现在它具有一些梯度。 令人耳目一新!

权重是我们需要根据梯度更改的参数,因此我们获得了它们的梯度。 我们没有梯度函数,因为它是由用户创建的,因此grad_fn仍然是None,而.data仍然相同。 如果我们尝试打印数据的值,它将仍然是相同的,因为反向传播不会隐式更新张量。 总之,在xw1a2中,只有w1得到了梯度。 这是因为由内部函数(例如a2)创建的中间节点将不保存梯度,因为它们是无参数节点。 影响神经网络输出的唯一参数是我们为层定义的权重。

参数更新

参数更新或优化步骤采用反向传播生成的梯度,并使用一些策略来更新权重,以通过一小步来减小参数的贡献因子。 然后重复此步骤,直到找到一组良好的参数。

所有用户创建的张量都要求梯度在gradient属性中具有值,并且我们需要更新参数。 所有参数张量都具有.data属性和.grad属性,它们分别具有张量值和梯度。 显然,我们需要做的是获取梯度并将其从数据中减去。 但是,事实证明,从参数减小整个梯度并不是一个好主意。 其背后的想法是,参数更新的数量决定了网络从每个示例(每次迭代)中学到的知识,并且如果我们给出的特定示例是一个异常值,我们不希望我们的网络学习虚假信息。

我们希望我们的网络得到推广,从所有示例中学习一些,并最终变得擅长于推广任何新示例。 因此,我们不是从数据中减少整个梯度,而是使用学习率来决定在特定更新中应使用多少梯度。 找到最佳学习率始终是一个重要的决定,因为这会影响模型的整体表现。 基本的经验法则是找到一个学习率,该学习率应足够小以使模型最终能够学习,而又要足够高以至于不会永远收敛。

前面描述的训练策略称为梯度下降。 诸如亚当之类的更复杂的训练策略将在下一章中讨论。 梯度下降本身已从其他两个变体演变而来。 梯度下降的最原始版本是 SGD,如前所述。 使用 SGD,每个网络执行都在单个样本上运行,并使用从一个样本获得的梯度更新模型,然后继续进行下一个样本。

SGD 的主要缺点是效率低下。 例如,考虑我们的 FizzBu​​zz 数据集,每个数据集包含 1,000 个大小为 10 的样本。一次执行一个样本要求我们将大小为1 x 10的张量传递给隐藏层,并使用权重张量1 x 10的像素,将1 x 10的输入转换为1 x 10的隐藏状态。 为了处理整个数据集,我们必须运行 1,000 次迭代。 通常,我们会在具有数千个内核的 GPU 上运行我们的模型,但是一次只有一个样本,我们就不会使用 GPU 的全部功能。 现在考虑一次传递整个数据集。 第一层获得大小为1,000 x 10的输入,该输入将转移到大小为1,000 x 100的隐藏状态。现在这很有效,因为张量乘法将在多核 GPU 上并行执行。

使用完整数据集的梯度下降的变种称为批梯度下降。 它并不比 SGD 更好。 批量梯度下降实际上提高了效率,但降低了网络的泛化能力。 SGD 必须逐个通过噪声,因此它将具有很高的抖动率,这会导致网络移出局部最小值,而分批梯度下降避免了陷入局部最小值的机会。

批量梯度下降的另一个主要缺点是其内存消耗。 由于整个批量都在一起处理,因此应将庞大的数据集加载到 RAM 或 GPU 内存中,这在大多数情况下我们尝试训练数百万个样本时不切实际。 下一个变体是前面两种方法的混合,称为“小批量梯度下降”(尽管顾名思义是“小批量梯度下降”,但人们通常会使用 SGD 来指代)。

除了我们刚才介绍的新超参数,学习率和批量大小以外,其他所有内容均保持不变。 我们用学习率乘以.grad属性来更新.data属性,并针对每次迭代进行此操作。 选择批量大小几乎总是取决于内存的可用性。 我们尝试使小批量尽可能大,以便可以将其放置在 GPU 内存中。 将整个批量划分为小批量,以确保每次梯度更新都会产生足够的抽动,从而在使用 GPU 提供的全部功能的同时,将模型从局部最小值中剔除。

我们已经到达了模型构建旅程的最后一部分。 到目前为止,所有操作都很直观,简单,但是最后一部分有点令人困惑。 zero_grad做什么? 还记得关于权重w1.grad的第一份印刷声明吗? 它是空的,现在具有当前反向传递的梯度。 因此,我们需要在下一次反向传播之前清空梯度,因为梯度会累积而不是被重写。 参数更新后,我们在每个迭代的每个张量上调用zero_grad(),然后继续进行下一个迭代。

.grad_fn通过连接函数和张量将图保持在一起。 在Function模块中定义了对张量的每种可能的操作。 所有张量的.grad_fn始终指向函数对象,除非用户创建了它。 PyTorch 允许您使用grad_fn向后浏览图。 从图中的任何节点,可以通过在grad_fn的返回值上调用next_functions来到达任何父节点。

# traversing the graph using .grad_fn
print(output.grad_fn)
# <DivBackward0 object at 0x7eff00ae3ef0>
print(output.grad_fn.next_functions[0][0])
# <SumBackward0 object at 0x7eff017b4128>
print(output.grad_fn.next_functions[0][0].next_functions[0][0])
# <PowBackward0 object at 0x7eff017b4128>

训练显示出其创建者之后,立即在输出张量上打印grad_fn,在output的情况下,是除法运算符执行最后的二分运算。 然后,对任何梯度函数(或向后函数)的next_functions调用都会向我们展示返回输入节点的方式。 在该示例中,除法运算符遵循求和函数,该函数将一批中所有数据点的平方误差相加。 下一个运算符是幂运算符,该运算符用于平方各个误差。 下图显示了使用函数链接张量的想法:

Parameter update

图 2.8:链接张量和函数

PyTorch 方式

到目前为止,我们已经以 NumPy-PyTorch 混合形式开发了一个简单的两层神经网络。 我们已经在 NumPy 中逐行编码了每个操作,就像我们在 NumPy 中进行编码一样,并且我们采用了与 PyTorch 的自动微分,因此我们不必对反向传递进行编码。

在途中,我们学习了如何在 PyTorch 中包装矩阵(或张量),这有助于我们进行反向传播。 使用 PyTorch 进行相同操作的方式更加方便,这就是我们将在本节中讨论的内容。 PyTorch 可以访问内置的深度学习项目所需的几乎所有功能。 由于 PyTorch 支持 Python 中所有可用的数学函数,因此,如果在内核中不可用,则构建一个函数并不是一件艰巨的任务。 您不仅可以构建所需的任何函数,而且 PyTorch 隐式定义了所构建函数的导函数。

PyTorch 对需要了解底层操作的人很有帮助,但同时,PyTorch 通过torch.nn模块提供了高层 API。 因此,如果用户不想知道黑盒内部发生了什么,而只需要构建模型,则 PyTorch 允许他们这样做。 同样,如果用户不喜欢引擎盖下的提升操作,并且需要知道到底发生了什么,PyTorch 也可以提供这种灵活性。 将这种组合构建到单个框架上可以改变游戏规则,并使 PyTorch 成为整个深度学习社区最喜欢的框架之一。

高级 API

高级 API 使初学者可以从头开始构建网络,同时,它们使高级用户可以花时间在其他关键部件上,而不必将发明的模块留给 PyTorch。 PyTorch 中构建神经网络所需的所有模块都是具有正向反向函数的 Python 类实例。 当您开始执行神经网络时,在后台执行的是正向函数,该函数又将操作添加到磁带上。 由于 PyTorch 知道所有操作的导函数,因此 PyTorch 很容易在磁带上移回。 现在,我们将代码模块化为较小的单元,以制造相同的 FizzBu​​zz 网络。

模块化代码具有相同的结构,因为我们获取数据并从 NumPy 数据输入创建张量。 其余的“复杂”代码可以替换为我们创建的模型类。

net = FizBuzNet(input_size, hidden_size, output_size)

我们使该类灵活地接受任何输入大小和输出大小,如果我们改变主意通过单次热编码而不是二进制编码输入,这将使我们更容易。 那么,FizBuzNet来自哪里?

class FizBuzNet(nn.Module):
    """
    2 layer network for predicting fiz or buz
    param: input_size -> int
    param: output_size -> int
    """

    def __init__(self, input_size, hidden_size, output_size):
        super(FizBuzNet, self).__init__()
        self.hidden = nn.Linear(input_size, hidden_size)
        self.out = nn.Linear(hidden_size, output_size)

    def forward(self, batch):
        hidden = self.hidden(batch)
        activated = torch.sigmoid(hidden)
        out = self.out(activated)
        return out

我们定义了FizBuzNet的结构,并将其包装在从torch.nn.Module继承的 Python 类中。 PyTorch 中的nn模块是用于访问深度学习世界中所有流行层的高级 API。 让我们逐步进行。

nn.Module

允许用户编写其他高级 API 的高级 API 是nn.Module。 您可以将网络的每个可分离部分定义为单独的 Python 类,并继承自nn.Module。 例如,假设您想建立一个深度学习模型来交易加密货币。 您已经从某个交易所收集了每种硬币的交易数据,并将这些数据解析为可以传递到网络的某种形式。 现在您处于两难境地:如何对每个硬币进行排名? 一种简单的方法是对硬币进行一次热编码,然后将其传递给神经元,但是您对此并不满意。 另一种相当简单的方法是制作另一个小模型来对硬币进行排名,您可以将该排名从该小模型传递到您的主模型作为输入。 啊哈! 这看起来很简单而且很聪明,但是您又该怎么做呢? 让我们看一下下图:

nn.Module

图 2.9:一个简单的网络,用于硬币排名并将输出传递给主要网络

nn.Module使您更容易拥有如此漂亮的抽象。 初始化class对象时,将调用__init__(),这又将初始化层并返回对象。 nn.Module实现了两个主要函数,即__call__backward(),并且用户需要覆盖forward__init__()

一旦返回了层初始化的对象,就可以通过调用model对象本身将输入数据传递给模型。 通常,Python 对象不可调用。 要调用对象方法,用户必须显式调用它们。 但是,nn.Module实现了魔术函数__call__(),该函数又调用了用户定义的forward函数。 用户具有在正向调用中定义所需内容的特权。

只要 PyTorch 知道如何反向传播forward中的内容,您就很安全。 但是,如果您在forward中具有自定义函数或层,则 PyTorch 允许您覆盖backward函数,并且该函数将在返回磁带时执行。

用户可以选择在__init__()定义中构建层,这将照顾我们在新手模型中手工完成的权重和偏差创建。 在下面的FizBuzNet中,__init__()中的线创建了线性层。 线性层也称为全连接层或密集层,它在权重和输入之间进行矩阵乘法,并在内部进行偏差加法:

self.hidden = nn.Linear(input_size, hidden_size)
self.out = nn.Linear(hidden_size, output_size)

让我们看一下 PyTorch 的nn.Linear的源代码,它应该使我们对 nn.Module的工作方式以及如何扩展nn.Module来创建另一个自定义模块有足够的了解:

class Linear(torch.nn.Module):
    def __init__(self, in_features, out_features, bias):
        super(Linear, self).__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.weight = torch.nn.Parameter(torch.Tensor(out_features, in_features))
        self.bias = torch.nn.Parameter(torch.Tensor(out_features))

    def forward(self, input):
        return input.matmul(self.weight.t()) + self.bias

该代码段是 PyTorch 源代码中Linear层的修改版本。 用Parameter包裹张量对于您来说似乎很奇怪,但是不必担心。 Parameter类将权重和偏差添加到模块参数列表中,当您调用model.parameters()时将可用。 初始化器将所有参数保存为对象属性。 forward函数的功能与我们在上一示例中的自定义线性层中完全一样。

a2 = x_.matmul(w1)
a2 = a2.add(b1)

在以后的章节中,我们将使用nn.module的更重要的函数。

apply()

此函数可帮助我们将自定义函数应用于模型的所有参数。 它通常用于进行自定义权重初始化,但是通常,model_name.apply(custom_function)对每个模型参数执行custom_function

cuda()cpu()

这些函数与我们之前讨论的目的相同。 但是,model.cpu()将所有参数转换为 CPU 张量,当您的模型中有多个参数并且分别转换每个参数很麻烦时,这非常方便。

net = FizBuzNet(input_size, hidden_size, output_size)
net.cpu()     # convert all parameters to CPU tensors
net.cuda()    # convert all parameters to GPU tensors

在整个程序中,此决定应统一。 如果我们决定将网络保留在 GPU 上,并且如果我们通过 CPU 张量(张量的存储位于 CPU 内存中),它将无法对其进行处理。 在创建张量本身时,PyTorch 允许您通过将张量类型作为参数传递给工厂函数来执行此操作。 做出此决定的理想方法是使用 PyTorch 的内置cuda.is_available()函数测试 CUDA 是否可用,并相应地创建张量:

if torch.cuda.is_available():
    xtype = torch.cuda.FloatTensor
    ytype = torch.cuda.LongTensor
else:
    xtype = torch.FloatTensor
    ytype = torch.LongTensor
x = torch.from_numpy(trX).type(xtype)
y = torch.from_numpy(trY).type(ytype)

我们不止于此。 如果您已开始在 GPU 上进行操作,并且在脚本之间进行了 CPU 优化的操作,则只需调用 CPU 方法即可将 GPU 张量转换为 CPU 张量,反之亦然。 我们将在以后的章节中看到这样的例子。

train()eval()

就像名称所示,这些函数告诉 PyTorch 模型正在训练模式或评估模式下运行。 仅在要关闭或打开模块(例如DropoutBatchNorm)时,此函数才有效。 在以后的章节中,我们将经常使用它们。

parameters()

调用parameters()会返回所有模型参数,这对于优化程序或要使用参数进行实验非常有用。 在我们开发的新手模型中,它具有四个参数w1w2b1b2,并且逐行使用梯度更新了参数。 但是,在FizBuzNet中,由于我们有一个模型类,并且尚未创建模型的权重和偏差,因此.parameter()调用是可行的方法。

net = FizBuzNet(input_size, hidden_size, output_size)

#building graph
# backpropagation
# zeroing the gradients

with torch.no_grad():
    for p in net.parameters():
        p -= p.grad * lr

无需用户逐行写下的每个参数更新,我们可以归纳为for循环,因为.parameters()返回所有具有特殊张量并具有.grad.data属性的参数。 我们有更好的方法来更新权重,但这是人们不需要像 Adam 这样的奇特更新策略时最常用和直观的方式之一。

zero_grad()

这是一个方便的函数,可将梯度设为零。 但是,与我们在新手模型中执行此操作的方式不同,它是一个更简单,直接的函数调用。 使用zero_grad驱动的模型,我们不必查找每个参数并分别调用zero_grad,但是对模型对象的单个调用将使所有参数的梯度为零。

其他层

nn模块具有丰富的,具有不同的层,您需要使用当前的深度学习技术来构建几乎所有内容。

nn.Module附带的一个重要层是顺序容器,如果模型的结构是连续且直接的,则它提供了一个易于使用的 API 来制作模型对象而无需用户编写类结构。 FizBuzNet结构为线性 | Sigmoid | 线性 | Sigmoid,可以通过单行代码用Sequential实现,这就像我们之前构建的FizBuzNet网络一样:

import torch.nn as nn

net = nn.Sequential(
    nn.Linear(i, h),
    nn.Sigmoid(),
    nn.Linear(h, o),
    nn.Sigmoid())

functional模块

nn.functional模块附带我们需要将网络节点连接在一起的操作。 在我们的模型中,我们使用functional模块中的 Sigmoid 作为非线性激活。 functional模块具有更多函数,例如您正在执行的所有数学函数都指向functional模块。 在下面的示例中,乘法运算符从functional模块调用mul运算符:

>>> a = torch.randn(1,2)
>>> b = torch.randn(2,1,requires_grad=True)
>>> a.requires_grad
False
>>> b.requires_grad
True
>>> c = a @ b
>>> c.grad_fn
<MmBackward at 0x7f1cd5222c88>

functional模块也具有层次,但是它比nn提供的抽象程度小,比我们构建新手模型的方式更抽象:

>>> import torch
>>> import torch.nn.functional as F
>>> a = torch.Tensor([[1,1]])
>>> w1 = torch.Tensor([[2,2]])
>>> F.linear(a,w1) == a.matmul(w1.t())
tensor([[1]], dtype=torch.uint8)

如前面的示例所示,F.linear允许我们传递权重和输入,并返回与在新手模型中使用的普通matmul相同的值。 functional中的其他层函数也以相同的方式工作。

注意

Sigmoid 激活:激活函数在神经网络的各层之间创建非线性。 这是必不可少的,因为在没有非线性的情况下,各层只是将输入值与权重相乘。 在那种情况下,神经网络的单层可以完成 100 层的确切函数; 这只是增加或减少权重值的问题。 Sigmoid 激活可能是最传统的激活函数。 它将输入压缩到[0,1]的范围。

The functional module

图 2.10:Sigmoid 激活

尽管 sigmoid 对输入非线性作用,但它不会产生以零为中心的输出。 逐渐梯度消失和计算上昂贵的取幂是 Sigmoid 曲线的其他缺点,由于这些原因,几乎所有深度学习从业人员如今都没有在任何用例中使用 Sigmoid 曲线。 找到合适的非线性是一个主要的研究领域,人们已经提出了更好的解决方案,例如 ReLU,Leaky ReLU 和 ELU。 在以后的章节中,我们将看到其中的大多数。

FizBuzNetforward函数内部,我们有两个线性层和两个非线性激活层。 通常,forward函数的输出返回是代表概率分布的对数,其中正确的类获得较高的值,但是在我们的模型中,我们从 Sigmoid 返回输出。

损失函数

现在我们有了FizBuzNet返回的预测,我们需要找出模型预测的水平,然后反向传播该误差。 我们调用损失函数来查找误差。 社区中普遍存在不同的损失函数。 PyTorch 带有nn模块中内置的所有流行损失函数。 损失函数接受对数和实际值,并在其上应用损失函数以查找损失得分。 此过程给出了错误率,该错误率代表了模型预测的好坏。 在新手模型中,我们使用了基本的 MSE 损失,已在nn模块中将其定义为MSELoss()

loss = nn.MSELoss()
output = loss(hyp, y_)
output.backward()

nn模块的损失比我们在以后的章节中看到的要复杂得多,但是对于我们当前的用例,我们将使用MSELoss。 我们用nn.MSELoss()创建的损失节点等效于我们在第一个示例中定义的损失:

error = hyp - y_
output = error.pow(2).sum() / 2.0

然后,由loss(hyp, y_)返回的节点将成为叶节点,我们可以在该叶节点上向后调用以找到梯度。

优化器

在新手模型中,在我们调用backward()之后,我们通过减去梯度的一小部分来更新权重。 我们通过显式调用权重参数来做到这一点。

# updating weight
with torch.no_grad():
    w1 -= lr * w1.grad
    w2 -= lr * w2.grad
    b1 -= lr * b1.grad
    b2 -= lr * b2.grad

但是,对于具有很多参数的大型模型,我们无法做到这一点。 更好的替代方法是像我们以前看到的那样循环遍历net.parameters(),但是这样做的主要缺点是,循环遍历了作为样板的 Python 中的参数。 此外,有不同的权重更新策略。 我们使用的是最基本的梯度下降方法。 复杂的方法可以处理学习率衰减,动量等等。 这些帮助网络比常规 SGD 更快地达到全局最小值。

optim包是 PyTorch 提供的替代方案,可有效处理权重更新。 除此之外,一旦使用模型参数初始化了优化器对象,用户就可以在其上调用zero_grad。 因此,不再像以前那样显式地在每个权重和偏置参数上调用zero_grad

w1.grad.zero_()
w2.grad.zero_()
b1.grad.zero_()
b2.grad.zero_()

optim包内置了所有流行的优化器。 在这里,我们使用完全相同的简单优化程序– SGD

optimizer = optim.SGD(net.parameters(), lr=lr)

optimizer对象现在具有模型参数。 optim包提供了一个方便的函数,称为step(),该函数根据优化程序定义的策略进行参数更新:

for epoch in range(epochs):
    for batch in range(no_of_batches):
        start = batch * batches
        end = start + batches
        x_ = x[start:end]
        y_ = y[start:end]
        hyp = net(x_)
        loss = loss_fn(hyp, y_)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

这是循环遍历批量并使用输入批量调用net的代码。 然后,将net(x_)返回的hyp与实际值y_一起传递给损失函数。 损失函数返回的误差用作叶子节点来调用backward()。 然后,我们调用optimizerstep()函数,该函数将更新参数。 更新之后,用户负责将梯度归零,这现在可以通过optimizer.zero_grad()实现。

总结

在本章中,我们学习了如何以最基本的方式构建简单的神经网络,并将其转换为 PyTorch 的方式。 深度学习的基本构建模块从此处开始。 一旦知道了我们遵循的方法的方式和原因,那么我们将能够采取重大措施。 任何深度学习模型,无论大小,用法或算法如何,都可以使用我们在本章中学到的概念来构建。 因此,全面理解本章对于以后的章节至关重要。 在下一章中,我们将深入研究深度学习工作流程。

参考

  1. Fizz buzz 维基百科页面
  2. 除法(数学)维基百科页面
  3. Joel Grus,《Tensorflow 中的 Fizz buzz》
  4. Ian Goodfellow,Yoshua Bengio 和 Aaron Courville,《深度学习》

三、深度学习工作流程

尽管深度学习正在从学术界向行业发展转变,并每天为数百万用户的需求提供动力,但该领域的新参与者仍在努力建立深度学习管道的工作流程。 本章旨在介绍 PyTorch 可以帮助完成的工作流部分。

PyTorch 最初是由 Facebook 实习生作为研究框架开始的,现已发展到由超级优化的 Caffe2 核心支持后端的阶段。 因此,简而言之,PyTorch 可以用作研究或原型框架,同时可以用来编写带有服务模块的有效模型,并且还可以部署到单板计算机和移动设备上。

典型的深度学习工作流程始于围绕问题陈述的构想和研究,这是架构设计和模型决策发挥作用的地方。 然后使用原型对理论模型进行实验。 这包括尝试不同的模型或技术(例如跳跃连接),或决定不尝试什么。 同样,选择合适的数据集进行原型设计并将数据集的无缝集成添加到管道中对于此阶段至关重要。 一旦实现了模型并通过训练和验证集对其进行了验证,则可以针对生产服务优化该模型。 下图描述了一个五阶段的深度学习工作流程:

Deep Learning Workflow

图 3.1:深度学习工作流程

先前的深度学习工作流程几乎等同于业内几乎每个人所实现的工作流程,即使对于高度复杂的实现,也略有不同。 本章简要说明了第一和最后一个阶段,并进入了中间三个阶段的核心,即设计和实验,模型实现以及训练和验证。

工作流的最后阶段通常是人们很费劲的,尤其是在应用规模很大的情况下。 之前我曾提到,尽管 PyTorch 是作为面向研究的框架构建的,但是社区设法将 Caffe2 集成到 PyTorch 的后端,这为 Facebook 使用的数千种模型提供了支持。 因此,在第 8 章, “生产中的 PyTorch”中详细讨论了将模型交付生产的过程,并举例说明了如何使用 ONNX,PyTorch JIT 等来展示如何交付用于服务数百万个请求的 PyTorch 模型,以及将模型迁移到单板计算机和移动设备。

构思和计划

通常,在组织中,产品团队会向工程团队显示问题陈述,希望知道他们是否可以解决。 这是构想阶段的开始。 在学术界,这可能是决策阶段,在此阶段,候选人必须为其论文找到问题。 在构思阶段,工程师们集思广益并找到了可能解决问题的理论方法。 除了将问题陈述转换为理论解决方案外,构想阶段还包括确定数据类型以及应使用哪些数据集来构建概念证明POC)或最低可行产品MVP)。 在这个阶段,团队通过分析问题陈述的行为,现有的可用实现,可用的预先训练的模型等来决定采用哪种框架。

这个阶段在行业中很常见,我有成千上万个示例,其中计划周密的构思阶段帮助团队按时推出了可靠的产品,而计划外的构思阶段破坏了整个产品的创建。

设计与实验

构建问题陈述的理论基础之后,我们进入设计和/或实验阶段,在其中通过尝试几种模型实现来构建 POC。 设计和实验的关键部分在于数据集和数据集的预处理。 对于任何数据科学项目,主要的时间份额都花在了数据清理和预处理上。 深度学习与此不同。

数据预处理是构建深度学习管道的重要部分之一。 通常,不清理或格式化现实世界的数据集以供神经网络处理。 在进行进一步处理之前,需要转换为浮点数或整数,进行规范化等操作。 建立数据处理管道也是一项艰巨的任务,其中包括编写大量样板代码。 为了使其更容易,将数据集构建器和DataLoader管道包内置到 PyTorch 的核心中。

数据集和DataLoader

不同类型的深度学习问题需要不同类型的数据集,并且每种类型的可能需要不同类型的预处理,具体取决于我们使用的神经网络架构。 这是深度学习管道构建中的核心问题之一。

尽管社区已经免费提供了用于不同任务的数据集,但是编写预处理脚本几乎总是很痛苦。 PyTorch 通过提供抽象类来编写自定义数据集和数据加载器来解决此问题。 这里给出的示例是一个简单的dataset类,用于加载我们在第 2 章,“一个简单神经网络”中使用的fizzbuzz数据集,但是将其扩展来可以处理任何类型的数据集非常简单。 PyTorch 的官方文档使用类似的方法对图像数据集进行预处理,然后再将其传递给复杂的卷积神经网络CNN)架构。

PyTorch 中的dataset类是高级抽象,可处理数据加载程序几乎需要的所有内容。 用户定义的自定义dataset类需要覆盖父类的__len__函数和__getitem__函数,其中数据加载程序正在使用__len__来确定数据集的长度,而__getitem__ 数据加载器正在使用该物品来获取物品。 __getitem__函数希望用户将索引作为参数传递,并获取驻留在该索引上的项目:

from dataclasses import dataclass
from torch.utils.data import Dataset, DataLoader

@dataclass(eq=False)
class FizBuzDataset(Dataset):
    input_size: int
    start: int = 0
    end: int = 1000

    def encoder(self,num):
        ret = [int(i) for i in '{0:b}'.format(num)]
        return[0] * (self.input_size - len(ret)) + ret

    def __getitem__(self, idx):
        idx += self.start
		x = self.encoder(idx)
        if idx % 15 == 0:
            y = [1,0,0,0]
        elif idx % 5 ==0:
            y = [0,1,0,0]
        elif idx % 3 == 0:
            y = [0,0,1,0]
        else:
            y = [0,0,0,1]
        return x,y

    def __len__(self):
        return self.end - self.start

自定义数据集的实现使用 Python 3.7 中的全新dataclassesdataclasses通过使用动态代码生成,有助于消除 Python 魔术函数的样板代码,例如__init__。 这需要代码被类型提示,这就是类中前三行的用途。 您可以在 Python 的官方文档[1]中阅读有关dataclasses的更多信息。

__len__函数返回传递给该类的结束值和起始值之间的差。 在fizzbuzz数据集中,数据正在由程序生成。 数据生成的实现在__getitem__函数内部,其中,类实例根据DataLoader传递的索引生成数据。 PyTorch 使类抽象尽可能通用,以便用户可以定义数据加载器应为每个 ID 返回的内容。 在这种特殊情况下,类实例为每个索引返回输入和输出,其中输入x是索引本身的二进制编码器版本,而输出是具有四个状态的单热编码输出。 四个状态表示下一个数字是三的倍数(嘶嘶声)或五的倍数(嗡嗡声),三或五的倍数(嘶嘶声)或不是三或五的倍数。

注意

对于 Python 新手,可以通过首先查看从 0 到数据集长度的整数循环来理解数据集的工作方式(当len(object)len(object)时,长度由__len__函数返回) 称为)。 以下代码段显示了简单的循环。

dataset = FizBuzDataset()
for i in range(len(dataset)):
    x, y = dataset[i]

dataloader = DataLoader(dataset, batch_size=10, shuffle=True, num_workers=4)
for batch in dataloader:
    print(batch)

DataLoader类接受从torch.utils.data.Dataset继承的dataset类。 DataLoader接受dataset并执行不重要的操作,例如小批量,多线程,打乱等,以从数据集中获取数据。 它接受来自用户的dataset实例,并使用采样器策略以小批量的形式采样数据。

num_worker参数决定应该操作多少个并行线程来获取数据。 这有助于避免 CPU 瓶颈,以便 CPU 可以赶上 GPU 的并行操作。 数据加载器允许用户指定是否使用固定的 CUDA 内存,这会将数据张量复制到 CUDA 的固定的内存中,然后再返回给用户。 使用固定内存是设备之间快速数据传输的关键,因为数据是由数据加载程序本身加载到固定内存中的,而无论如何,这都是由 CPU 的多个内核完成的。

大多数情况下,尤其是在进行原型制作时,开发人员可能无法使用自定义数据集,在这种情况下,自定义数据集必须依赖现有的开放数据集。 处理开放数据集的好处是,大多数数据集免于许可负担,成千上万的人已经尝试过对其进行预处理,因此社区将提供帮助。 PyTorch 提出了针对所有三种类型的数据集的工具包,这些包具有经过预训练的模型,经过预处理的数据集以及与这些数据集一起使用的工具函数。

工具包

该社区针对视觉(torchvision),文本(torchtext)和音频(torchaudio)制作了三种不同的工具包。 它们针对不同的数据域都解决了相同的问题,并且使用户不必担心用户可能拥有的几乎所有用例中的数据处理和清理问题。 实际上,所有工具包都可以轻松地插入到可能理解或不理解 PyTorch 数据结构的任何类型的程序中。

torchvision

pip install torchvision

torchvision是 PyTorch 中最成熟,使用最多的工具包,它由数据集,预先训练的模型和预先构建的转换脚本组成。 torchvision具有功能强大的 API,使用户能够轻松进行数据的预处理,并且在原型阶段(甚至可能无法使用数据集)特别有用。

torchvision的功能分为三类:预加载的,可下载的数据集,用于几乎所有类型的计算机视觉问题; 流行的计算机视觉架构的预训练模型; 以及用于计算机视觉问题的常见转换函数。 另外一个好处是,torchvision包的函数式 API 的简单性使用户可以编写自定义数据集或转换函数。 以下是torchvision包中可用的所有当前数据集的表格及其说明:

数据集 描述
MNIST 70,000 28 x 28手写数字的数据集。
KMNIST 平假名字符的排列方式与普通 MNIST 相同。
时尚 MNIST 类似于 MNIST 的数据集,包含 70,000 张28 x 28张标记的时尚图片。
EMNIST 该数据集是一组28 x 28个手写字符数字。
COCO 大规模对象检测,分割和字幕数据集。
LSUN 类似于 COCO 的大规模“场景理解挑战”数据集。
Imagenet-12 2012 年大规模视觉识别挑战赛的 1400 万张图像的数据集。
CIFAR 以 10/100 类标记的 60,000 张32 x 32彩色图像的数据集。
STL10 另一个受 CIFAR 启发的图像数据集。
SVHN 街景门牌号码的数据集,类似于 MNIST。
PhotoTour 华盛顿大学提供的旅游景点数据集。

以下代码片段给出了 MNIST 数据集的一个示例。 上表中的所有数据集都需要传递一个位置参数,即要下载的数据集所在的路径,或者如果已经下载了该数据集则用于存储该数据集的路径。 数据集的返回值将打印有关数据集状态的基本信息。 稍后,我们将使用相同的数据集来启用转换,并查看数据集输出的描述性。

>>> mnist = v.datasets.MNIST('.', download=True)
Downloading …
Processing…
Done!

>>> mnist
Dataset MNIST
 Number of datapoints: 60000
 Split: train
 Root Location: .
 Transforms (if any): None
 Target Transforms (if any): None

torchvision使用枕头(PIL)作为加载图像的默认后端。 但是通过方便的函数torchvision.set_image_backend(backend),可以将其更改为任何兼容的后端。 torchvision提供的所有数据都继承自torch.utils.data.Dataset类,因此,已经针对其中每个实现了__len____getitem__。 这两个魔术函数都使所有这些数据集都能与DataLoader兼容,就像我们实现简单数据集并将其加载到DataLoader的方式一样。

>>> mnist[1]
(<PIL.Image.Image image mode=L size=28×28 at 0x7F61AE0EA518>, tensor(0))
>>> len(mnist)
60000

如果用户已经有需要从磁盘上的某个位置读取的图像数据该怎么办? 传统方式是通过编写预处理脚本来循环遍历图像,并使用PILskimage之类的任何包加载它们,然后将其传递给 PyTorch(或任何其他框架),可能会通过 NumPy。

torchvision对此也有解决方案。 将图像数据集以适当的目录层次结构存储在磁盘中后,torchvision.ImageFolder可以从目录结构本身中获取所需的信息,就像我们使用自定义脚本所做的一样,并使加载更加容易。 用户。 给定的代码段和文件夹结构显示了工作所需的简单步骤。 一旦将图像作为类名存储在层次结构中的最后一个文件夹中(图像的名称在这里并不重要),那么ImageFolder就会读取数据并智能地累积所需的信息:

>>> images = torchvision.datasets.ImageFolder('/path/to/image/folder')
>>> images [0]
(<PIL.Image.Image image mode=RGB size=1198×424 at 0x7F61715D6438>, 0)

/path/to/image/folder/class_a/img1.jpg
/path/to/image/folder/class_a/img2.jpg
/path/to/image/folder/class_a/img3.jpg
/path/to/image/folder/class_a/img4.jpg

/path/to/image/folder/class_b/img1.jpg
/path/to/image/folder/class_b/img2.jpg
/path/to/image/folder/class_b/img3.jpg

torchvisionmodels模块包装有几种常用的模型,可以直接使用。 由于当今大多数高级模型都使用迁移学习来获得其他架构学习的权重(例如,第三章中的语义分段模型使用经过训练的 resnet18 网络),因此这是模型最常用的torchvision功能之一。 以下代码段显示了如何从torchvision.models下载 resnet18 模型。 标志pretrained告诉torchvision仅使用模型或获取从 PyTorch 服务器下载的预训练模型。

>>> resnet18 = torchvision.models.resnet18(pretrained=False)
>>> resnet18 = torchvision.models.resnet18(pretrained=True)
>>> for param in resnet18.layer1.parameters():
 param.requires_grad = False

PyTorch 的 Python API 允许冻结用户决定使其不可训练的模型部分。 前面的代码中给出了一个示例。 循环访问resnet18的第 1 层参数的循环可访问每个参数的requires_grad属性,这是 Autograd 在反向传播以进行梯度更新时所寻找的。 将requires_grad设置为False会屏蔽autograd中的特定参数,并使权重保持冻结状态。

torchvisiontransforms模块是另一个主要参与者,它具有用于数据预处理和数据扩充的工具模块。 transforms模块为常用的预处理函数(例如填充,裁切,灰度缩放,仿射变换,将图像转换为 PyTorch 张量等)提供了开箱即用的实现,以及一些实现数据扩充,例如翻转,随机裁剪和色彩抖动。 Compose工具将多个转换组合在一起,以形成一个管道对象。

transform = transforms.Compose(
    [
        transforms.ToTensor(),
        transforms.Normalize(mean, std),
    ]
)

前面的示例显示了transforms.Compose如何将ToTensorNormalize组合在一起以组成单个管道。 ToTensor将三通道输入 RGB 图像转换为尺寸为通道×宽度×高度的三维张量。 这是 PyTorch 中视觉网络期望的尺寸顺序。

ToTensor还将每个通道的像素值从 0 到 255 转换为 0.0 到 1.0 的范围。 Transforms.Normalize是具有均值和标准差的简单归一化。 因此,Compose循环遍历所有转换,并使用先前转换的结果调用转换。 以下是从源代码复制的torchvision转换撰写的__call__函数:

def __call__(self, img):
    for t in self.transforms:
        img = t(img)
    return img

转换带有很多工具,并且它们在不同的情况下都非常有用。 最好阅读不断完善的torchvision文档,以详细了解更多功能。

torchtext

pip install torchtext

与其他两个工具包不同,torchtext保留自己的 API 结构,该结构与torchvisiontorchaudio完全不同。 torchtext是一个非常强大的库,可以为自然语言处理NLP)数据集执行所需的预处理任务。 它带有一组用于常见 NLP 任务的数据集,但是与torchvision不同,它没有可供下载的预训练网络。

torchtext可以插入输入或输出端的任何 Python 包中。 通常,spaCy 或 NLTK 是帮助torchtext进行预处理和词汇加载的好选择。 torchtext提供 Python 数据结构作为输出,因此可以连接到任何类型的输出框架,而不仅仅是 PyTorch。 由于torchtext的 API 与torchvisiontorchaudio不相似,并且不如其他人简单明了,因此下一个部分将通过一个示例演示torchtext在 NLP 中的主要作用。

torchtext本身是一个包装器工具,而不是支持语言操作,因此这就是我在以下示例中使用 spaCy 的原因。 例如,我们使用文本检索会议TREC)数据集,它是一个问题分类器。

文本 标签
How do you measure earthquakes?(您如何测量地震?) DESC
Who is Duke Ellington?(埃灵顿公爵是谁?) HUM

用于此类数据集上的 NLP 任务的常规数据预处理管道包括:

  • 将数据集分为训练集,测试集和验证集。
  • 将数据集转换为神经网络可以理解的形式。 数值化,单热编码和词嵌入是常见的方法。
  • 批量。
  • 填充到最长序列的长度。

没有像torchtext这样的帮助程序类,这些平凡的任务令人沮丧且无济于事。 我们将使用torchtext的强大 API 来简化所有这些任务。

torchtext有两个主要模块:Data模块和Datasets模块。 如官方文档所述,Data模块承载了多个数据加载器,抽象和文本迭代器(包括词汇和单词向量),而Datasets模块则为常见的 NLP 任务预先构建了数据集。

在此示例中,我们将使用Data模块加载以制表符分隔的数据,并使用 spaCy 的分词对其进行预处理,然后再将文本转换为向量。

spacy_en = spacy.load('en')

def tokenizer(text):
    return [tok.text for tok in spacy_en.tokenizer(text)]

TEXT = data.Field(sequential=True, tokenize=tokenizer, lower=True)
LABEL = data.Field(sequential=False, use_vocab=True)

train, val, test = data.TabularDataset.splits(
    path='./data/', train='TRECtrain.tsv',
    validation='TRECval.tsv', test='TRECtest.tsv', format='tsv',
    fields=[('Text', TEXT), ('Label', LABEL)])

上一小节的第一部分在 spaCy 中加载英语,并定义了分词器函数。 下一部分是使用torchtext.data.Field定义输入和输出字段的位置。 Field类用于定义将数据加载到DataLoader之前的预处理步骤。

在所有输入语句之间共享Field变量TEXT,并且在所有输出标签之间共享Field变量LABEL。 该示例中的TEXT设置为顺序的,这告诉Field实例数据是顺序相关的,并且分词是将其分成较小块的更好选择。 如果sequential设置为False,则不会对数据应用分词。

由于sequentialTEXTTrue,因此我们开发的分词函数设置为tokenizer。 该选项默认为 Python 的str.split,但是我们需要更智能的分词函数,而 spaCy 的分词功能可以为我们提供帮助。

常规 NLP 管道所做的另一个重要修改是将所有数据转换为相同的情况。 将lower设置为True会发生这种情况,但是默认情况下是False。 除了示例中给出的三个参数外,Field类还接受许多其他参数,其中包括fix_length以固定序列的长度; pad_token,默认为<pad>,用于填充序列以匹配fixed_length或批量中最长序列的长度; 和unk_token(默认为<unk>),用于替换没有词汇向量的标记。

Field的官方文档详细介绍了所有参数。 因为我们只有一个单词作为标签,所以LABEL字段的sequential设置为False。 这对于不同的实例非常方便,尤其是在语言翻译(输入和输出均为序列)的情况下。

Field的另一个重要参数是use_vocab,默认情况下将其设置为True。 此参数告诉Field实例是否对数据使用词汇表生成器。 在示例数据集中,我们将输入和输出都用作单词,甚至将输出转换为单词向量也是有意义的,但是在几乎所有情况下,输出将是单编码的向量或将其数字化。 在torchtext不会尝试将其转换为单词嵌入词典的索引的情况下,将use_vocab设置为False很有帮助。

一旦使用Field设置了预处理机制,我们就可以将它们与数据位置一起传递给DataLoader。 现在DataLoader负责从磁盘加载数据并将其通过预处理管道。

Data模块带有多个DataLoader实例。 我们在这里使用的是TabularDataset,因为我们的数据是 TSV 格式。 torchtext的官方文档显示了其他示例,例如 JSON 加载器。 TabularDataset接受磁盘中数据位置的路径以及训练,测试和验证数据的名称。 这对于加载不同的数据集非常方便,因为将数据集加载到内存中的时间少于,只需少于五行代码。 如前所述,我们将之前制作的Field对象传递给DataLoader,它知道现在如何进行预处理。 DataLoader返回torchtext对象以获取训练,测试和验证数据。

我们仍然必须从一些预训练的词嵌入词典构建词汇表,然后将我们的数据集转换为词典中的索引。 Field对象通过放弃名为build_vocab的 API 来实现这一点。 但是在这里,它变得有些古怪,变成了类似循环依赖的东西,但是请放心。 我们会习惯的。

Fieldbuild_vocab要求我们传递上一步中DataSet.split方法返回的data对象。 Field就是这样知道数据集中存在的单词,总词汇量的长度等等。 build_vocab方法还可以为您下载预训练的词汇向量(如果您还没有的话)。 通过torchtext可用的词嵌入为:

  • 字符 N 元组
  • Fasttext
  • GloVe 向量
TEXT.build_vocab(train, vectors="glove.6B.50d")
LABEL.build_vocab(train, vectors="glove.6B.50d")
train_iter, val_iter, test_iter = data.Iterator.splits((train, val, test), sort_key=lambda x: len(x.Text),batch_sizes=(32, 99, 99), device=-1)

print(next(iter(test_iter)))

# [torchtext.data.batch.Batch of size 99]
# [.Text]:[torch.LongTensor of size 16x99]
# [.Label]:[torch.LongTensor of size 99]

建立词汇表后,我们可以要求torchtext给我们迭代器,该迭代器可以循环执行神经网络。 上面的代码片段显示了build_vocab如何接受参数,然后如何调用Iterator包的splits函数来为我们的训练,验证和测试数据创建三个不同的迭代器。

为了使用 CPU,将device参数设置为-1。 如果是0,则Iterator会将数据加载到默认 GPU,或者我们可以指定设备编号。 批量大小期望我们传递的每个数据集的批量大小。 在这种情况下,我们具有用于训练,验证和测试的三个数据集,因此我们传递具有三个批量大小的元组。

sort_key使用我们传递的lambda函数对数据集进行排序。 在某些情况下,对数据集进行排序会有所帮助,而在大多数情况下,随机性会帮助网络学习一般情况。 Iterator足够聪明,可以使用通过参数传递的批量大小来批量输入数据集,但是它并不止于此。 它可以动态地将所有序列填充到每批最长序列的长度。 Iterator的输出(如print语句所示)为TEXT数据,其大小为16x99,其中99是我们为测试数据集传递的批量大小,而 16 是该数据集的长度。 该特定批量中最长的序列。

如果Iterator类需要更巧妙地处理事情怎么办? 如果数据集用于语言建模,并且我们需要一个数据集来进行时间上的反向传播BPTT),那该怎么办? torchtext也为这些模块抽象了模块,这些模块继承自我们刚刚使用的Iterator类。 BucketIterator模块将序列进行更智能的分组,以便将具有相同长度的序列归为一组,并且此减少了将噪声引入数据集的不必要填充的长度。 BucketIterator还可以在每个周期对批量进行混洗,并在数据集中保持足够的随机性,从而使网络无法从数据集中的顺序中学习,这实际上并没有在教授任何现实世界的信息。

BPTTIterator是从Iterator类继承的另一个模块,可帮助语言建模数据集,并且需要为t的每个输入从t + 1获取标签。t是时间。 BPTTIterator接受输入数据的连续流和输出数据的连续流(在翻译网络的情况下,输入流和输出流可以不同,在语言建模网络的情况下,输入流和输出流可以相同)并将其转换为迭代器,它遵循前面描述的时间序列规则。

torchtext还保存了开箱即用的数据集。 下面是一个示例,说明访问数据集的可用版本有多么容易:

>>> import torchtext
>>> from torchtext import data
>>> TextData = data.Field()
>>> LabelData = data.Field()
>>> dataset = torchtext.datasets.SST('torchtextdata', TextData, LabelData)
>>> dataset.splits(TextData, LabelData)
(<torchtext.datasets.sst.SST object at 0x7f6a542dcc18>, <torchtext.datasets.sst.SST object at 0x7f69ff45fcf8>, <torchtext.datasets.sst.SST object at 0x7f69ff45fc88>)
>>> train, val, text = dataset.splits(TextData, LabelData)
>>> train[0]
<torchtext.data.example.Example object at 0x7f69fef9fcf8>

在这里,我们下载了 SST 情感分析数据集,并使用相同的dataset.splits方法来获取具有__len____getitem__定义为与实例相似的data对象。

下表显示torchtext中当前可用的数据集以及它们特定的任务:

数据集 任务
BaBi 问题回答
SST 情感分析
IMDB 情感分析
TREC 问题分类
SNLI 蕴涵
MultiNLI 蕴涵
WikiText2 语言建模
WikiText103 语言建模
PennTreebank 语言建模
WMT14 机器翻译
IWSLT 机器翻译
Multi30k 机器翻译
UDPOS 序列标记
CoNLL2000Chunking 序列标记

torchaudio

音频工具可能是 PyTorch 所有工具包中最不成熟的包。 无法安装在pip之上的事实证明了这一主张。 但是,torchaudio涵盖了音频域中任何问题陈述的基本用例。 此外,PyTorch 还向内核添加了一些方便的功能,例如逆快速傅里叶变换IFFT)和稀疏快速傅里叶变换SFFT) ,显示 PyTorch 在音频领域的进步。

torchaudio依赖于跨平台音频格式更改器声音交换SoX)。 一旦安装了依赖项,就可以使用 Python 设置文件从源文件中安装。

python setup.py install

torchaudio带有两个预先构建的数据集,一些转换以及一个用于音频文件的加载和保存工具。 让我们深入探讨其中的每一个。 加载和保存音频文件总是很麻烦,并且依赖于其他几个包。 torchaudio通过提供简单的加载和保存函数式 API 使其变得更加容易。 torchtext可以加载任何常见的音频文件并将其转换为 PyTorch 张量。 它还可以对数据进行规范化和非规范化,以及以任何通用格式写回磁盘。 保存的 API 接受文件路径,并从文件路径推断输出格式,然后将其转换为该格式,然后再将其写回磁盘。

>>> data, sample_rate = torchaudio.load('foo.mp3')
>>> print(data.size())
torch.Size([278756, 2])
>>> print(sample_rate)
44100
>>> torchaudio.save('foo.wav', data, sample_rate)

torchvision一样,torchaudio的数据集直接继承自torch.utils.data.Dataset,这意味着它们已经实现了__getitem____len__,并且与DataLoader兼容。 现在,torchaudiodatasets模块预先加载了两个不同的音频数据集VCTKYESNO,它们都具有与torchvision的数据集相似的 API。 使用 Torch DataLoader加载YESNO数据集的示例如下:

yesno_data = torchaudio.datasets.YESNO('.', download=True)
data_loader = torch.utils.data.DataLoader(yesno_data)

transforms模块也受到torchvision API 的启发,借助Compose,我们可以将一个或多个转换包装到一个管道中。 此处提供了一个来自官方文档的示例。 它依次将Scale转换和PadTrim转换组成一个管道。 官方文档中详细说明了所有可用转换的列表。

transform = transforms.Compose(
    [
        transforms.Scale(),
        transforms.PadTrim(max_len=16000)
    ]
)

模型实现

毕竟,实现模型是我们开发流程中最重要的一步。 在某种程度上,我们为此步骤构建了整个管道。 除了构建网络架构之外,我们还需要考虑许多细节来优化实现(在工作量,时间以及代码效率方面)。

在本次会议中,我们将讨论 PyTorch 包本身和ignite(PyTorch 的推荐训练者工具)中提供的性能分析和瓶颈工具。 第一部分介绍了瓶颈和性能分析工具,当模型开始表现不佳并且您需要知道哪里出了问题时,这是必不可少的。 本课程的第二部分介绍了训练器模块ignite

训练器网络并不是真正必需的组件,但它是一个很好的帮助程序工具,可以节省大量时间来编写样板文件和修复错误。 有时,它可以将程序的行数减少一半,这也有助于提高可读性。

瓶颈和性能分析

PyTorch 的 Python 优先方法阻止核心团队在的第一年建立一个单独的探查器,但是当模块开始转向 C/C++ 内核时,就很明显需要在 Python 的 cProfiler 上安装一个独立的探查器,这就是 autograd.profiler故事的开始。

本节将提供更多的表和统计信息,而不是分步指导,因为 PyTorch 已经使概要分析尽可能简单。 对于概要分析,我们将使用在第二章中开发的相同的 FizzBu​​zz 模型。 尽管autograd.profiler可以分析图中的所有操作,但是在此示例中,仅分析了主网络的正向传播,而没有损失函数和后向通过。

with torch.autograd.profiler.profile() as prof:
    hyp = net(x_)

print(prof)
prof.export_chrome_trace('chrometrace')
print(prof.key_averages())
print(prof.table('cpu_time'))

第一个print语句只是以表格形式吐出t概要文件输出,而第二个print语句将 op 节点分组在一起并平均一个特定节点所花费的时间。 在下面的屏幕快照中显示了该内容:

Bottleneck and profiling

图 3.2:按名称分组的autograd.profiler输出

下一个print语句基于作为参数传递的头按升序对数据进行排序。 该有助于找到需要更多时间的节点,并可能提供某种方式来优化模型。

Bottleneck and profiling

图 3.3:autograd.profiler输出按 CPU 时间排序

最后一个print语句只是可视化 Chrome 跟踪工具执行时间的另一种方式。 export_chrome_trace函数接受文件路径,并将输出写入 Chrome 跟踪器可以理解的文件:

Bottleneck and profiling

图 3.4:autograd.profiler输出转换为 chrometrace

但是,如果用户需要结合使用autograd.profiler和 cProfiler(这将使我们在多个节点操作之间实现简洁的关联),或者用户仅需要调用另一个工具而不是更改用于获取配置文件的源代码, 信息是瓶颈。 瓶颈是 Torch 工具,可以从命令行作为 Python 模块执行:

python -m torch.utils.bottleneck /path/to/source/script.py [args]

瓶颈可以找到有关环境的更多信息,还可以从autograd.profiler和 cProfiler 提供配置文件信息。 但是对于两者而言,瓶颈都会两次执行该程序,因此减少的周期数是使程序在相当长的时间内停止执行的一个好选择。 我在第二章的同一程序上使用了瓶颈,这是输出屏幕:

Bottleneck and profiling

图 3.5:环境摘要上的瓶颈输出

Bottleneck and profiling

图 3.6:瓶颈输出显示autograd.profiler

Bottleneck and profiling

图 3.7:瓶颈输出显示 cProfile 输出

训练和验证

尽管工作流实际上以将深度模型的部署到生产中而结束,但我们已经到达深度学习工作的最后一步,我们将在第 8 章和“PyTorch 投入生产”。 在完成所有预处理和模型构建之后,现在我们必须训练网络,测试准确率并验证可靠性。 在开源世界(甚至在本书中)中,我们看到的大多数现有代码实现都使用直接方法,在该方法中,我们明确编写了训练,测试和验证所需的每一行,以提高可读性,因为可以避免样板的特定工具会增加学习曲线,尤其是对于新手。 很显然,对于那些每天都在使用神经网络的程序员来说,可以避免样板的工具将是一个救生员。 因此,PyTorch 社区构建的不是一个而是两个工具:Torchnet 和 Ignite。 本次会议仅与点燃有关,因为它被发现比 Torchnet 更为有用和抽象,但两者都是积极开发的工具,有可能在不久的将来合并。

Ignite

Ignite 是一种神经网络训练工具,可将某些样板代码抽象出来,以使代码简洁明了。 Ignite 的核心是Engine模块。 该模块非常强大,因为:

  • 它基于默认/自定义训练器或评估者运行模型。
  • 它可以接受处理器和指标,并对其执行操作。
  • 它可以创建触发器并执行回调。

Engine

Engine接受一个训练器函数,该函数实质上是用于训练神经网络算法的典型循环。 它包括循环遍历,循环遍历,将现有梯度值归零,使用批量调用模型,计算损失以及更新梯度。 以下示例显示了这一点,该示例取自第 2 章和“简单神经网络”:

for epoch in range(epochs):
    for x_batch, y_batch in dataset:
        optimizer.zero_grad()
        hyp = net(x_batch)
        loss = loss_fn(hyp, y_batch)
        loss.backward()
        optimizer.step()

Engine可以帮助您避免前两个循环,并且如果您定义了需要执行其余代码的函数,它将为您完成。 以下是与Engine兼容的先前代码段的重写版本:

def training_loop(trainer, batch)
    x_batch, y_batch = process_batch(batch)
    optimizer.zero_grad()
    hyp = net(x_batch)
    loss = loss_fn(hyp, y_batch)
    loss.backward()
    optimizer.step()

trainer = Engine(training_loop)

这很聪明,但这并没有节省用户大量时间,也没有兑现承诺,例如删除样板。 它所做的只是删除两个for循环并添加Engine对象创建的另一行。 这并不是 Ignite 的真正目的。 Ignite 尝试同时使编码变得有趣且灵活,从而有助于避免重复样板。

Ignite 提供了一些常用函数,例如有监督的训练或有监督的评估,并且还使用户可以灵活地定义自己的训练函数,例如训练 GAN,强化学习RL)算法,依此类推。

from ignite.engine import create_supervised_trainer, create_supervised_evaluator

epochs = 1000
train_loader, val_loader = get_data_loaders(train_batch_size, val_batch_size)
trainer = create_supervised_trainer(model, optimizer, F.nll_loss)
evaluator = create_supervised_evaluator(model)
trainer.run(train_loader, max_epochs=epochs)
evaluator.run(val_loader)

函数create_supervised_trainercreate_supervised_evaluator返回一个Engine对象,该对象具有类似于training_loop的函数来执行代码的公共模式,如先前给出的那样。 除了给定的参数,这两个函数还接受一个设备(CPU 或 GPU),该设备返回在我们指定的设备上运行的训练器或评估器Engine实例。 现在情况越来越好了吧? 我们传递了定义的模型,所需的优化器以及正在使用的损失函数,但是在有了训练器和evaluator对象之后我们该怎么办?

Engine对象定义了run方法,该方法使循环根据传递给run函数的周期和加载器开始执行。 与往常一样,run方法使trainer循环从零到周期数。 对于每次迭代,我们的训练器都会通过加载程序进行梯度更新。

训练完成后,evaluatorval_loader开始,并通过使用评估数据集运行相同的模型来确保情况得到改善。

那很有趣,但仍然缺少一些片段。 如果用户需要在每个周期之后运行evaluator,或者如果用户需要训练器将模型的精度打印到终端,或者将其绘制到 Visdom,Turing 或 Network 图上,该怎么办? 在前面的设置中,有没有办法让知道验证准确率是什么? 您可以通过覆盖Engine的默认记录器来完成大部分操作,该记录器本质上是保存在trainer_logger变量中的 Python 记录器,但实际的答案是事件。

事件

Ignite 打开了一种通过事件或触发器与循环进行交互的特殊方式。 当事件发生并执行用户在函数中定义的操作时,每个设置函数都会触发。 这样,用户就可以灵活地设置任何类型的事件,并且通过避免将那些复杂的事件写入循环中并使循环变得更大且不可读,从而使用户的生活变得更加轻松。 Engine中当前可用的事件是:

  • EPOCH_STARTED
  • EPOCH_COMPLETED
  • STARTED
  • COMPLETED
  • ITERATION_STARTED
  • ITERATION_COMPLETED
  • EXCEPTION_RAISED

在这些事件上设置函数触发器的最佳和推荐方法是使用 Python 装饰器。 训练器的on方法接受这些事件之一作为参数,并返回一个装饰器,该装饰器设置要在该事件上触发的自定义函数。 这里给出了一些常见事件和用例:

@trainer.on(Events.ITERATION_COMPLETED)
def log_training_loss(engine):
    epoch = engine.state.epoch
    iteration = engine.state.iteration
    loss = engine.state.output
    print("Epoch:{epoch} Iteration:{iteration} Loss: {loss}")

@trainer.on(Events.EPOCH_COMPLETED)
def run_evaluator_on_training_data(engine):
    evaluator.run(train_loader)

@trainer.on(Events.EPOCH_COMPLETED)
def run_evaluator_on_validation_data(engine):
    evaluator.run(val_loader)

到目前为止,我必须已经使您相信 Ignite 是工具箱中的必备工具。 在前面的示例中,已为三个事件设置了@trainer.on装饰器; 实际上,在两个事件上,我们在EPOCH_COMPLETED事件上设置了两个函数。 使用第一个函数,我们可以将训练状态打印到终端上。 但是有些事情我们还没有看到。 状态是Engine用来保存有关执行信息的state变量。 在示例中,我们看到状态保存了有关周期,迭代乃至输出的信息,这实际上是训练循环的损失。 state属性包含周期,迭代,当前数据,指标(如果有)(我们将很快了解指标); 调用run函数时设置的最大周期,以及training_loop函数的输出。

注意

注意:在create_supervised_trainer的情况下,training_loop函数返回损失,在create_supervised_evaluator的情况下,training_loop函数返回模型的输出。 但是,如果我们定义一个自定义training_loop函数,则此函数返回的内容将是Engine.state.output保留的内容。

第二和第三事件处理器正在EPOCH_COMPLETED上运行evaluator,但具有不同的数据集。 在第一个函数中,evaluator使用训练数据集,在第二个函数中,它使用评估数据集。 太好了,因为现在我们可以在每个周期完成时运行evaluator,而不是像第一个示例那样在整个执行结束时运行。 但是,除了运行它之外,处理器实际上并没有做任何事情。 通常,这里是我们检查平均准确率和平均损失的地方,并且我们会进行更复杂的分析,例如混淆度量的创建,我们将在后面看到。 但是,目前的主要收获是:可以为单个事件设置n处理器数量,Ignite 会毫不犹豫地依次调用所有这些处理器。 接下来是事件的内部_fire_event函数,该事件在training_loop函数的每个事件中触发。

def _fire_event(self, event_name, *event_args):
    if event_name in self._event_handlers.keys():
        self._logger.debug("firing handlers for event %s", event_name)
        for func, args, kwargs in self._event_handlers[event_name]:
            func(self, *(event_args + args), **kwargs)

在下一节中,我们将使EPOCH_COMPLETED事件处理器使用 Ignite 的指标进行更明智的操作。

指标

就像Engine一样,指标也是 Ignite 源代码的重要组成部分,源代码正在不断发展。 度量将用于分析神经网络的表现和效率的几种常用度量包装为Engine可以理解的简单可配置类。 接下来给出当前构建的指标。 我们将使用其中一些来构建前面的事件处理器:

  • Accuracy
  • Loss
  • MeanAbsoluteError
  • MeanPairwiseDistance
  • MeanSquaredError
  • Precision
  • Recall
  • RootMeanSquaredError
  • TopKCategoricalAccuracy
  • RunningAverageŁ
  • IoU
  • mIoU

Ignite 具有父metrics类,该类由列表中的所有类继承。 可以通过将词典对象传递给用户,该词典对象以用户可读的名称作为键,并将先前类之一的实例化对象作为值传递给Engine创建调用,以完成设置指标。 因此,我们现在使用指标重新定义evaluator的创建。

metrics = {'accuracy': CategoricalAccuracy(), 'null': Loss(F.null_loss)}
evaluator = create_supervised_evaluator(model, metrics=metrics)

Engine的初始化器获取指标,并调用Metrics.attach函数来设置触发器,以计算EPOCH_STARTEDITERATION_COMPLETEDEPOCH_COMPLETED的指标。 来自Metrics源代码的attach函数如下:

def attach(self, engine, name):
    engine.add_event_handler(Events.EPOCH_STARTED, self.started)
    engine.add_event_handler(Events.ITERATION_COMPLETED, self.iteration_completed)
    engine.add_event_handler(Events.EPOCH_COMPLETED, self.completed, name)

通过Engine设置事件处理器后,事件发生时将自动调用它们。 EPOCH_STARTED事件通过调用reset()方法来清理指标,并使存储对于当前周期指标集合保持干净。

ITERATION_COMPLETED触发器将调用相应指标的update()方法并进行指标更新。 例如,如果度量等于损失,则它会在创建Engine时调用我们作为参数传递给Loss类的损失函数来计算当前损失。 然后将计算出的损失保存到对象变量中,以备将来使用。

EPOCH_COMPLETED事件将是最终事件,它将使用ITERATION_COMPLETED中更新的内容来计算最终指标得分。 一旦将metrics字典作为参数传递给Engine创建,所有这些都将作为流在用户不知道的情况下发生。 以下代码段显示了用户如何在运行evaluatorEPOCH_COMPLETED触发器上取回此信息:

@trainer.on(Events.EPOCH_COMPLETED)
def run_evaluator_on_validation_data(engine):
    evaluator.run(val_loader)
    metrics = evaluator.state.metrics
    avg_accuracy = metrics['accuracy']
    avg_null = metrics['nll']
    print(f"Avg accuracy: {avg_accuracy} Avg loss: {avg_nll}")

metrics状态以与最初传递的用户同名的名称保存在Engine状态变量中,作为字典,并以输出作为值。 Ignite 只是为用户提供了整个流程流畅和无缝的接口,因此用户不必担心编写所有普通代码。

保存检查点

使用 Ignite 的另一个好处是检查点保存功能,PyTorch 中不提供此功能。 人们想出了不同的方法来有效地编写和加载检查点。 EngineCheckpoint是 Ignite 处理器的一部分,可以这样导入:

from ignite.handlers import EngineCheckpoint

Ignite 的检查点保护程序具有非常简单的 API。 用户需要定义检查点的保存位置,检查点的保存频率以及除默认参数(如迭代计数,用于恢复操作的周期数)以外的对象要保存的内容。 在该示例中,我们为每一百次迭代检查点。 然后可以将定义的值作为参数传递给EngineCheckpoint模块,以获取检查点事件处理器对象。

返回的处理器具有常规事件处理器的所有功能,并且可以为 Ignite 触发的任何事件进行设置。 在以下示例中,我们将其设置为ITERATION_COMPLETED事件:

dirname = 'path/to/checkpoint/directory'
objects_to_checkpoint = {"model": model, "optimizer": optimizer}
engine_checkpoint = EngineCheckpoint(dirname=dirname,to_save=objects_to_checkpoint,save_interval=100)
trainer.add_event_handler(Events.ITERATION_COMPLETED, engine_checkpoint)

触发器在每个ITERATION_COMPLETED事件上调用处理器,但是我们只需要为每百次迭代保存一次即可,并且 Ignite 没有用于自定义事件的方法。 Ignite 通过为用户提供在处理器内部进行此检查的灵活性来解决此问题。 对于检查点处理器,Ignite 在内部检查当前完成的迭代是否为百分之一,并仅在检查通过后才保存该迭代,如以下代码片段所示:

if engine.state.iteration % self.save_interval !=0:
    save_checkpoint()

可以使用torch.load('checkpont_path')加载保存的检查点。 这将为您提供具有模型和优化器的字典objects_to_checkpoint

总结

本章都是关于如何为深度学习开发建立基础管道的。 我们在本章中定义的系统是一种非常普遍/通用的方法,其后是不同类型的公司,但略有变化。 从这样的通用工作流程开始的好处是,随着团队/项目的发展,您可以构建一个非常复杂的工作流程。

同样,在开发的早期阶段拥有工作流本身将使您的冲刺稳定且可预测。 最后,工作流中各个步骤之间的划分有助于定义团队成员的角色,为每个步骤设置截止日期,尝试有效地将每个步骤容纳在 sprint 中以及并行执行这些步骤。

PyTorch 社区正在制作不同的工具和工具包以整合到工作流中。 ignitetorchvisiontorchtexttorchaudio等是这样的示例。 随着行业的发展,我们可以看到很多此类工具的出现,可以将其安装到此工作流的不同部分中,以帮助我们轻松地对其进行迭代。 但最重要的部分是:从一个开始。

在下一章中,我们将探讨计算机视觉和 CNN。

参考

  1. dataclasses的 Python 官方文档
  2. Ignite 部分中使用的示例均受 Ignite 官方示例的启发

四、计算机视觉

计算机视觉是使计算机具有视觉效果的工程流。 它支持各种图像处理,例如 iPhone,Google Lens 等中的人脸识别。 计算机视觉已经存在了几十年,可能最好在人工智能的帮助下进行探索,这将在本章中进行演示。

几年前,我们在 ImageNet 挑战中达到了计算机视觉的人类准确率。 在过去的十年中,计算机视觉发生了巨大的变化,从以学术为导向的对象检测问题到在实际道路上自动驾驶汽车使用的分割问题。 尽管人们提出了许多不同的网络架构来解决计算机视觉问题,但是卷积神经网络CNN)击败了所有这些。

在本章中,我们将讨论基于 PyTorch 构建的基本 CNN,以及它们的变体,它们已经成功地应用于一些为大公司提供支持的最新模型中。

CNN 简介

CNN 是具有数十年历史的机器学习算法,直到 Geoffrey Hinton 和他的实验室提出 AlexNet 时,才证明其功能强大。 从那时起,CNN 经历了多次迭代。 现在,我们在 CNN 之上构建了一些不同的架构,这些架构为世界各地的所有计算机视觉实现提供了动力。

CNN 是一种基本上由小型网络组成的网络架构,几乎类似于第 2 章,“简单神经网络”中引入的简单前馈网络,但用于解决图像作为输入的问题。 CNN 由神经元组成,这些神经元具有非线性,权重参数,偏差并吐出一个损失值,基于该值,可以使用反向传播对整个网络进行重新排列。

如果这听起来像简单的全连接网络,那么 CNN 为何特别适合处理图像? CNN 让开发人员做出适用于图像的某些假设,例如像素值的空间关系。

简单的全连接层具有更大的权重,因为它们存储信息以处理所有权重。 全连接层的另一个功能使其无法进行图像处理:它不能考虑空间信息,因为它在处理时会删除像素值的顺序/排列结构。

CNN 由几个三维核组成,它们像滑动窗口一样在输入张量中移动,直到覆盖整个张量为止。 核是三维张量,其深度与输入张量的深度(在第一层中为 3;图像的深度在 RGB 通道中)相同。 核的高度和宽度可以小于或等于输入张量的高度和宽度。 如果核的高度和宽度与输入张量的高度和宽度相同,则其设置与正常神经网络的设置非常相似。

每次核通过输入张量移动时,它都可能吐出单个值输出,该输出会经历非线性。 当核作为滑动窗口移动时,核从输入图像覆盖的每个插槽都将具有此输出值。 滑动窗口的移动将创建输出特征映射(本质上是张量)。 因此,我们可以增加核数量以获得更多的特征映射,并且从理论上讲,每个特征映射都能够保存一种特定类型的信息。

Introduction to CNNs

图 4.1:不同的层显示不同的信息

来源:《可视化和理解卷积网络》,Matthew D. Zeiler 和 Rob Fergus

由于使用了相同的核来覆盖整个图像,因此我们正在重用核参数,从而减少了参数数量。

CNN 实质上会降低xy轴(高度和宽度)中图像的尺寸,并增加深度(z轴)。z轴上的每个切片都是一个如上所述的特征映射,由每个多维核创建。

CNN 中的降级有助于 CNN 的位置不变。 位置不变性可帮助其识别图像不同部分中的对象。 例如,如果您有两只猫的图像,其中一只猫在一张图像的左侧,另一只猫在右侧,那么您希望您的网络从这两幅图像中识别出这只猫,对吗?

CNN 通过两种机制实现位置不变:跨步和合并。 步幅值决定了滑动窗口的运动程度。 池化是 CNN 的固有部分。 我们有三种主要的池化类型:最大池化,最小池化和平均池化。 在最大池化的情况下,池化从输入张量的子块中获取最大值,在最小池化的情况下从池中获取最小值,而在平均池化的情况下,池化将取所有值的平均值。 池化层和卷积核的输入和输出基本相同。 两者都作为滑动窗口在输入张量上移动并输出单个值。

接下来是 CNN 运作方式的描述。 要更深入地了解 CNN,请查看斯坦福大学的 CS231N。 或者,如果您需要通过动画视频快速介绍 CNN,Udacity [1]提供了很好的资源。

Introduction to CNNs

图 4.2:一个 CNN

建立完整的 CNN 网络有四种主要操作类型:

  • 卷积层
  • 非线性层
  • 池化层
  • 全连接层

使用 PyTorch 的计算机视觉

PyTorch 为计算机视觉提供了几个便捷函数,其中包括卷积层和池化层。 PyTorch 在torch.nn包下提供Conv1dConv2dConv3d。 听起来,Conv1d处理一维卷积,Conv2d处理带有图像之类输入的二维卷积,Conv3d处理诸如视频之类的输入上的三维卷积。 显然,这很令人困惑,因为指定的尺寸从未考虑输入的深度。 例如,Conv2d处理四维输入,其中第一维将是批量大小,第二维将是图像的深度(在 RGB 通道中),最后两个维将是图像的高度和宽度。 图片。

除了用于计算机视觉的高层函数之外,torchvision还具有一些方便的工具函数来建立网络。 在本章中,我们将探讨其中的一些。

本章使用两个神经网络应用说明 PyTorch:

  • 简单 CNN:用于对 CIFAR10 图像进行分类的简单神经网络架构
  • 语义分割:使用来自简单 CNN 的概念进行语义分割的高级示例

简单 CNN

我们正在开发 CNN 以执行简单的分类任务。 使用简单 CNN 的想法是为了了解 CNN 的工作原理。 弄清基础知识后,我们将转到高级网络设计,在其中使用高级 PyTorch 函数,该函数与该应用具有相同的功能,但效率更高。

我们将使用 CIFAR10 作为输入数据集,它由 10 类 60,000 张32x32彩色图像组成,每类 6,000 张图像。 torchvision具有更高级别的函数,可下载和处理数据集。 如我们在第 3 章,“深度学习工作流”中看到的示例一样,我们下载数据集,然后使用转换对其进行转换,并将其包装在get_data()函数下。

def get_data():
    transform = transforms.Compose(
        [transforms.ToTensor(),
         transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
    trainset = torchvision.datasets.CIFAR10(
        root='./data', train=True, download=True, transform=transform)
    trainloader = torch.utils.data.DataLoader(
        trainset, batch_size=100, shuffle=True, num_workers=2)

    testset = torchvision.datasets.CIFAR10(
        root='./data', train=False, download=True, transform=transform)
    testloader = torch.utils.data.DataLoader(
        testset, batch_size=100, shuffle=False, num_workers=2)
    return trainloader, testloader

函数的第一部分对来自 CIFAR10 数据集的 NumPy 数组进行转换。 首先将其转换为 Torch 张量,然后进行归一化转换。 ToTensor不仅将 NumPy 数组转换为 Torch 张量,而且还更改了维度的顺序和值的范围。

PyTorch 的所有更高层 API 都希望通道(张量的深度)成为批量大小之后的第一维。 因此,形状(高度 x 宽度 x 通道 (RGB))[0, 255]范围内的输入将转换为形状(通道 (RGB) x 高度 x 宽度)[0.0, 1.0]之间的torch.FloatTensor。 然后,将每个通道(RGB)的平均值和标准差设置为 0.5,进行标准化。 torchvision转换完成的规范化操作与以下 Python 函数相同:

def normalize(image, mean, std):
    for channel in range(3):
        image[channel] = (image[channel] - mean[channel]) / std[channel]

get_data()返回经过测试的可迭代迭代器和训练装载器。 现在数据已经准备好了,我们需要像建立 FizBuzz 网络时那样,设置模型,损失函数和优化器。

模型

SimpleCNNModel是从 PyTorch 的nn.Module继承的模型类。 这是使用其他自定义类和 PyTorch 类来设置架构的父类。

class SimpleCNNModel(nn.Module):
    """ A basic CNN model implemented with the the basic building blocks """

    def __init__(self):
        super().__init__()
        self.conv1 = Conv(3, 6, 5)
        self.pool = MaxPool(2)
        self.conv2 = Conv(6, 16, 5)
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = x.view(-1, 16 * 5 * 5)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

该模型具有由最大池化层分隔的两个卷积层。 第二个卷积层连接到三个全连接层,一个接一个,将十个类的分数吐出来。

我们为SimpleCNNModel构建了自定义卷积和最大池化层。 定制层可能是实现这些层的效率最低的方法,但是它们具有很高的可读性和易于理解性。

class Conv(nn.Module):
    """
    Custom conv layer
    Assumes the image is squre
    """

    def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0):
        super().__init__()
        self.kernel_size = kernel_size
        self.stride = stride
        self.padding = padding
        self.weight = Parameter(torch.Tensor(out_channels, in_channels, kernel_size, kernel_size))
        self.bias = Parameter(torch.zeros(out_channels))

图像上的卷积运算使用过滤器对输入图像进行乘法和加法运算,并创建单个输出值。 因此,现在我们有了一个输入映像和一个核。 为简单起见,让我们考虑输入图像为大小为7x7的单通道(灰度)图像,并假设核的大小为3x3,如下图所示。 我们将核的中间值称为锚点,因为我们将锚点保留在图像中的某些值上进行卷积。

Model

图 4.3a

Model

图 4.3b

我们通过将核锚定在图像的左上像素开始卷积,如图“图 4.3b”所示。 现在,我们将图像中的每个像素值与相应的核值相乘,然后将所有像素值相加,得到一个值。 但是我们有一个要处理的问题。 核的顶行和左列将乘以什么? 为此,我们介绍了填充。

我们在输入张量的外侧添加行和列,其值为零,以便核中的所有值在输入图像中都有一个对应的值要配对。 我们从乘法中得到的单个值和加法运算是我们对该实例进行的卷积运算的输出。

现在,我们将核右移一个像素,然后像滑动窗口一样再次执行该操作,并重复此操作,直到覆盖图像为止。 我们可以从每个卷积运算中获得的每个输出一起创建该层的特征映射或输出。 下面的代码片段在最后三行中完成了所有这些操作。

PyTorch 支持普通的 Python 索引,我们使用它来为特定迭代查找滑动窗口所在的插槽,并将其保存到名为val的变量中。 但是索引创建的张量可能不是连续的内存块。 通过使用view()不能更改非连续存储块张量,因此我们使用contiguous()方法将张量移动到连续块。 然后,将该张量与核(权重)相乘,并对其添加偏倚。 然后将卷积运算的结果保存到out张量,将其初始化为零作为占位符。 预先创建占位符并向其中添加元素比最后在一组单个通道上进行堆叠要高效一个数量级。

out = torch.zeros(batch_size, new_depth, new_height, new_width)
        padded_input = F.pad(x, (self.padding,) * 4)
        for nf, f in enumerate(self.weight):
            for h in range(new_height):
                for w in range(new_width):
                    val = padded_input[:, :, h:h + self.kernel_size, w:w + self.kernel_size]
                    out[:, nf, h, w] = val.contiguous().view(batch_size, -1) @ f.view(-1)
                    out[:, nf, h, w] += self.bias[nf]

PyTorch 中的functional模块具有帮助我们进行填充的方法。 F.pad接受每一侧的输入张量和填充大小。 在这种情况下,我们需要对图像的所有四个边进行恒定的填充,因此我们创建了一个大小为 4 的元组。 如果您想知道填充的工作原理,下面的示例显示在对大小为(2, 2, 2, 2)的大小(1, 1)的张量进行F.pad后将大小更改为(5, 5)

>>> F.pad(torch.zeros(1,1), (2,) * 4)
Variable containing:
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
[torch.FloatTensor of size (5,5)]

如您所知,如果我们使用大小为1 x 1 x 深度的核,则通过对整个图像进行卷积,将获得与输入相同大小的输出。 在 CNN 中,如果我们想减小输出的大小而与核的大小无关,我们将使用一个不错的技巧通过跨步来对输出的大小进行下采样。 “图 4.4”显示了步幅减小对输出大小的影响。 以下公式可用于计算输出的大小以及核的大小,填充宽度和步幅。

W = (WF + 2P) / S + 1,其中W是输入大小,F是核大小,S跨步应用P填充。

Model

图 4.4:左步幅为 1

我们建立的卷积层没有进行跨步的能力,因为我们使用最大池进行了下采样。 但是在高级示例中,我们将使用 PyTorch 的卷积层,该层在内部处理跨步和填充。

前面的示例使用了一个单通道输入并创建了一个单通道输出。 我们可以将其扩展为使用n个输入通道来创建n个输出通道,这是卷积网络的基本构建块。 通过进行两次更改,可以推断出相同的概念以处理任意数量的输入通道以创建任意数量的输出通道:

  • 由于输入图像具有多个通道,因此用于与相应元素相乘的核必须为n维。 如果输入通道为三个,并且核大小为五个,则核形状应为5 x 5 x 3
  • 但是,如何创建n个输出通道? 现在我们知道,不管输入通道有多少,一次卷积都会创建一个单值输出,而完整的滑动窗口会话会创建一个二维矩阵作为输出。 因此,如果我们有两个核做完全相同的事情,那就是:滑动输入并创建二维输出。 然后,我们将获得两个二维输出,并将它们堆叠在一起将为我们提供具有两个通道的输出。 随着输出中需要更多通道,我们增加了核数量。

我们拥有的自定义卷积层可以完成卷积。 它接受输入和输出通道的数量,核大小,步幅和填充作为参数。 核的形状为[kernel_size, kernel_size, input_channels]。 我们没有创建n个核并将输出堆叠在一起以获得多通道输出,而是创建了一个大小为output_channel, input_channel, kernal_size, kernal_size的单个权重张量,这给出了我们想要的。

在所有池化选项中,人们倾向于使用最大池化。 合并操作采用张量的一个子部分,并获取单个值作为输出。 最大池从概念上讲获取该子部件的突出特征,而平均池则取平均值并平滑该特征。 而且,从历史上看,最大池化比其他池化算法提供更好的结果,可能是因为它从输入中获取最突出的特征并将其传递到下一个级别。 因此,我们也使用最大池。 定制的最大池化层具有相同的结构,但是复杂的卷积操作由简单的最大操作代替。

out = torch.zeros(batch_size, depth, new_height, new_width)
for h in range(new_height):
    for w in range(new_width):
        for d in range(depth):
            val = x[:, d, h:h + self.kernel_size, w:w + self.kernel_size]
            out[:, d, h, w] = val.max(2)[0].max(1)[0]

PyTorch 的max()方法接受尺寸作为输入,并返回具有索引/索引到最大值和实际最大值的元组。

>>> tensor
1 2
3 4
[torch.FloatTensor of size 2x2]
>>> tensor.max(0)[0]
3
4
[torch.FloatTensor of size 2]
>>> tensor.max(0)[1]
1
1
[torch.LongTensor of size 2]

例如,前面示例中的max(0)返回一个元组。 元组中的第一个元素是张量,其值为 3 和 4,这是第 0 维的最大值;另一个张量,其值为 1 和 1,是该维的 3 和 4 的索引。 最大池化层的最后一行通过采用第二维的max()和第一维的max()来获取子部件的最大值。

卷积层和最大池化层之后是三个线性层(全连接),这将维数减小到 10,从而为每个类给出了概率得分。 接下来是 PyTorch 模型存储为实际网络图的字符串表示形式。

>>> simple = SimpleCNNModel()
>>> simple
SimpleCNNModel((conv1): Conv()(pool): MaxPool()(conv2): Conv()
 (fc1): Linear(in_features=400, out_features=120, bias=True)
 (fc2): Linear(in_features=120, out_features=84, bias=True)
 (fc3): Linear(in_features=84, out_features=10, bias=True)
)

我们已经按照需要的方式连接了神经网络,以便在看到图像时可以给出类评分。 现在我们定义损失函数和优化器。

net = SimpleCNNModel()
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)
trainloader, testloader = get_data()

我们创建神经网络类的实例。 还记得正向函数的工作原理吗? 网络类将定义__call__()函数,并依次调用我们为正向传播定义的forward()函数。

在下一行中定义的损失函数也是torch.nn.Module的子类,它也具有forward()函数,该函数由__call__()和向后函数调用。 这使我们可以灵活地创建自定义损失函数。

在以后的章节中,我们将提供示例。 现在,我们将使用一个称为CrossEntropyLoss()的内置损失函数。 就像前面几章中的一样,我们将使用 PyTorch 优化包来获取预定义的优化程序。 对于此示例,我们将随机梯度下降SGD)用于示例,但与上一章不同,我们将使用带有动量的 SGD,这有助于我们向正确方向加速梯度。

注意

动量是当今与优化算法一起使用的一种非常流行的技术。 我们将当前梯度的因数添加到当前梯度本身以获得更大的值,然后将其从权重中减去。 动量在与现实世界动量类似的极小方向上加速损失的运动。

Model

图 4.5:没有动力和有动力的 SGD

现在我们已经准备好训练我们的神经网络。 至此,我们可以使用模板代码进行训练了:

  1. 遍历周期。
  2. 循环遍历每个周期的数据。
  3. 通过调用以下命令使现有的梯度为零:
    • optimizer.zero_grad()
    • net.zero_grad()
  4. 运行网络的正向传播。
  5. 通过使用网络输出调用损失函数来获取损失。
  6. 运行反向传播。
  7. 使用优化程序进行梯度更新。
  8. 如果需要,可以保存运行损失。

在保存运行损失时要小心,因为 PyTorch 会在变量进行反向传播之前保存整个图。 增量保存图只是图中的另一种操作,其中每次迭代中的图都使用求和运算将先前的图附加到图上,最终导致内存不足。 始终从图中取出值并将其保存为没有图历史记录的普通张量。

inputs, labels = data
optimizer.zero_grad()
outputs = net(inputs)
loss = loss_fn(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()

语义分割

我们已经了解了 CNN 的工作原理。 现在,我们将进行下一步,并开发 CNN 的高级应用,称为语义分段。 顾名思义,该技术将图像的一部分标记为一个类别,例如,将所有树木标记为绿色,将建筑物标记为红色,将汽车标记为灰色,等等。 分割本身意味着从图像中识别结构,区域等。

语义分割是智能的,在我们想要了解图像中的内容而不是仅识别结构或区域时将使用它。 语义分割正在识别和理解像素级图像中的内容。

Semantic segmentation

图 4.6:语义分割示例

语义分割为现实世界中的几个主要应用提供支持,从闭路电视摄像机和自动驾驶汽车到分割不同的对象。 在本章中,我们将实现一种称为 LinkNet [2][7]的最快的语义分割架构。

在本章中,我们将 CamVid 数据集用于我们的 LinkNet 实现。 CamVid 是一个真实情况数据集,由高质量视频组成,这些高质量视频转换为手动分割和标记的帧。 手动标记的输出图像将颜色用作对象的标识。 例如,数据集输出目录中的所有图像都将洋红色用于道路。

LinkNet

LinkNet 利用自编码器的思想,该思想曾经是一种数据压缩技术。 自编码器的架构有两个部分:编码器和解码器。 编码器将输入编码到低维空间,而解码器从低维空间解码/重新创建输入。 自编码器被广泛用于减小压缩的尺寸等。

LinkNet

图 4.7:自编码器

LinkNet 由一个初始块,一个最终块,一个带有四个卷积模块的编码器块以及一个带有四个解卷积模块的解码器组成。 初始块使用跨步卷积和最大池化层对输入图像进行两次下采样。 然后,编码器模块中的每个卷积模块都会以大步卷积对输入进行一次下采样。 然后将编码后的输出传递到解码器块,该解码器块会在每个反卷积块中使用步进反卷积对输入进行上采样; 反卷积将在以下部分中说明。

然后,解码器模块的输出通过最终模块,该模块将上采样两次,就像初始模块下采样两次一样。 还有更多:与其他语义分割模型相比,LinkNet 通过使用跳跃连接的思想可以减少架构中的参数数量。

在每个卷积块之后,编码器块与解码器块进行通信,这使编码器块在正向传播之后会忘记某些信息。 由于编码器模块的输出不必保留该信息,因此参数的数量可能比其他现有架构的数量少得多。 实际上,该论文的作者使用 ResNet18 作为编码器,并且仍然能够以惊人的表现获得最新的结果。 下面是 LinkNet 的架构:

LinkNet

图 4.8:LinkNet 架构

因此,我们已经看到了某些以前从未见过的东西。 让我们谈谈这些。

反卷积

反卷积可以模糊地描述为卷积运算的逆过程。 Clarifai 的创始人兼首席执行官 Matthew Zeiler 最初在他的 CNN 层可视化论文[3]中使用了去卷积,尽管当时他没有给它起名字。 自从成功以来,反卷积已在几篇论文中使用。

命名操作反卷积很有意义,因为它的作用与卷积相反。 它有许多名称,例如转置卷积(因为之间使用的矩阵已转置)和后向卷积(因为操作是反向传播时卷积的反向传递)。 但是实际上,我们本质上是在进行卷积运算,但是我们更改了像素在输入中的排列方式。

对于具有填充和跨度的反卷积,输入图像将在像素周围具有填充,并且之间将具有零值像素。 在所有情况下,核滑动窗口的移动将保持不变。

注意

有关反卷积的更多信息,请参见论文《深度学习卷积算法指南》[5]或 GitHub 存储库[6]。

Deconvolution

图 4.9:反卷积工作

跳跃连接

LinkNet 架构中编码器和解码器之间的平行水平线是跳跃连接表示。 跳跃连接有助于网络在编码过程中忘记某些信息,并在解码时再次查看。 由于网络解码和生成图像所需的信息量相对较低,因此这减少了网络所需的参数数量。 可以通过不同的操作来实现跳跃连接。 使用跳跃连接的另一个优点是,梯度梯度流可以容易地流过相同的连接。 LinkNet 将隐藏的编码器输出添加到相应的解码器输入,而另一种语义分割算法 Tiramisu [4]将两者连接在一起,将其发送到下一层。

模型

语义分割模型的编码器是我们在第一个会话中构建的 SimpleCNN 模型的扩展,但具有更多的卷积模块。 我们的主类使用五个次要组件/模块来构建前面描述的架构:

  • ConvBlock是自定义的nn.Module类,可实现卷积和非线性。
  • DeconvBlock是一个自定义nn.Module类,可实现解卷积和非线性。
  • nn.MaxPool2d是内置的 PyTorch 层,可进行 2D 最大合并。
  • EncoderBlock
  • DecoderBlock

正如在较早的会话中看到的那样,我们通过forward()调用主类的__init__()中的主类,并像链接一样链接每个主类,但是在这里,我们需要实现一个跳跃连接。 我们使用编码器层的输出,并通过将其与正常输入添加到解码器的方式将其传递到解码器层。

卷积块
class ConvBlock(nn.Module):
    """ LinkNet uses initial block with conv -> batchnorm -> relu """

    def __init__(self, inp, out, kernal, stride, pad, bias, act):
        super().__init__()
        if act:
            self.conv_block = nn.Sequential(
                nn.Conv2d(inp, out, kernal, stride, pad, bias=bias),
                nn.BatchNorm2d(num_features=out),
                nn.ReLU())
        else:
            self.conv_block = nn.Sequential(
                nn.Conv2d(inp, out, kernal, stride, pad, bias=bias),
                nn.BatchNorm2d(num_features=out))

    def forward(self, x):
        return self.conv_block(x)

LinkNet 中的所有卷积都紧随其后的是批量规范化和 ReLU 层,但是有一些例外,没有 ReLU 层。 这就是ConvBlock的目标。 如前所述,ConvBlocktorch.nn.Module的子类,可以根据正向传播中发生的任何事情进行反向传播。 __init__接受输入和输出尺寸,核大小,步幅值,填充宽度,表示是否需要偏置的布尔值和表示是否需要激活(ReLU)的布尔值。

我们使用torch.nn.Conv2dtorch.nn.BatchNorm2dtorch.nn.ReLu来配置ConvBlock。 PyTorch 的Conv2D接受ConvBlock__init__的所有参数,但表示类似激活要求的布尔值除外。 除此之外,Conv2D还接受另外两个用于dilationgroup的可选参数。 torch.nn的 ReLU 函数仅接受一个称为inplace的可选参数,默认为False。 如果inplaceTrue,则 ReLU 将应用于原地数据,而不是创建另一个存储位置。 在许多情况下,这可能会稍微节省内存,但会导致问题,因为我们正在破坏输入。 经验法则是:除非您迫切需要内存优化,否则请远离它。

批量规范化用于规范每个批量中的数据,而不是一开始只进行一次。 在开始时,标准化对于获得相等比例的输入至关重要,这反过来又可以提高精度。 但是,随着数据流经网络,非线性和权重和偏差的增加可能导致内部数据规模不同。

标准化每一层被证明是解决此特定问题的一种方法,即使我们提高了学习速度,也可以提高准确率。 批量归一化还可以帮助网络从更稳定的输入分布中学习,从而加快了网络的收敛速度。 PyTorch 对不同尺寸的输入实现了批量归一化,就像卷积层一样。 在这里我们使用BatchNorm2d,因为我们有四维数据,其中一维是批量大小,另一维是深度。

BatchNorm2d用两个可学习的参数实现:伽玛和贝塔。 除非我们将仿射参数设置为False,否则 PyTorch 会在反向传播时处理这些特征的学习。 现在,BatchNorm2d接受特征数量,ε 值,动量和仿射作为参数。

ε值将添加到平方根内的分母中以保持数值稳定性,而动量因子决定应从上一层获得多少动量以加快操作速度。

__init__检查是否需要激活并创建层。 这是torch.nn.Sequential有用的地方。 将三个不同的层(卷积,批量规范化和 ReLU)定义为单个ConvBlock层的明显方法是为所有三个层创建 Python 属性,并将第一层的输出传递给第二层,然后将该输出传递给第三层。但是使用nn.Sequential,我们可以将它们链接在一起并创建一个 Python 属性。 这样做的缺点是,随着网络的增长,您将为所有小模块提供额外的Sequential包装器,这将使解释网络图变得困难。 存储库中的可用代码(带有nn.Sequential包装器)将生成类似“图 4.10a”的图形,而没有使用Sequential包装器构建的层将生成类似“图 4.10b”的图形。

class ConvBlockWithoutSequential(nn.Module):
    """ LinkNet uses initial block with conv -> batchnorm -> relu """

    def __init__(self, inp, out, kernel, stride, pad, bias, act):
        super().__init__()
        if act:
            self.conv = nn.Conv2d(inp, out, kernel, stride, pad, bias=bias)
            self.bn = nn.BatchNorm2d(num_features=out)
            self.relu = nn.ReLU()
        else:
            self.conv = nn.Conv2d(inp, out, kernel, stride, pad, bias=bias)
            self.bn = nn.BatchNorm2d(num_features=out)

    def forward(self, x):
        conv_r = self.conv(x)
        self.bn_r = self.bn(conv_r)
        if act:
            return self.relu(self.bn_r)
        return self.bn_r
反卷积块

反卷积块是 LinkNet 中解码器的构建块。 就像我们如何制作卷积块一样,反卷积块由三个基本模块组成:转置卷积,BatchNorm和 ReLU。 在那种情况下,卷积块和反卷积块之间的唯一区别是将torch.nn.Conv2d替换为torch.nn.ConvTranspose2d。 正如我们之前所见,转置卷积与卷积执行相同的操作,但给出相反的结果。

class DeconvBlock(nn.Module):
    """ LinkNet uses Deconv block with transposeconv -> batchnorm -> relu """

    def __init__(self, inp, out, kernal, stride, pad):
        super().__init__()
        self.conv_transpose = nn.ConvTranspose2d(inp, out, kernal, stride, pad)
        self.batchnorm = nn.BatchNorm2d(out)
        self.relu = nn.ReLU()

    def forward(self, x, output_size):
        convt_out = self.conv_transpose(x, output_size=output_size)
        batchnormout = self.batchnorm(convt_out)
        return self.relu(batchnormout)

DeconvBlock的前向调用不使用torch.nn.Sequential,并且与ConvBlock中对Conv2d所做的工作相比,还做了其他工作。 我们将期望的output_size传递给转置卷积的前向调用,以使尺寸稳定。 使用torch.nn.Sequential将整个反卷积块变成单个变量,可以防止我们将变量传递到转置卷积中。

池化

PyTorch 有几个用于池化操作的选项,我们从其中选择使用MaxPool。 正如我们在SimpleCNN示例中看到的那样,这是一个显而易见的操作,我们可以通过仅从池中提取突出的特征来减少输入的维数。 MaxPool2d接受类似于Conv2d的参数来确定核大小,填充和步幅。 但是除了这些参数之外,MaxPool2d接受两个额外的参数,即返回索引和ciel。 返回索引返回最大值的索引,可在某些网络架构中进行池化时使用。 ciel是布尔参数,它通过确定尺寸的上限或下限来确定输出形状。

编码器块

这将对网络的一部分进行编码,对输入进行下采样,并尝试获得包含输入本质的输入的压缩版本。 编码器的基本构建模块是我们之前开发的ConvBlock

EncoderBlock

图 4.10:编码器图

如上图所示,LinkNet 中的每个编码器块均由四个卷积块组成。 前两个卷积块被分组为一个块。 然后将其与残差输出(由 ResNet 推动的架构决策)相加。 然后,带有该加法的残差输出将进入第二块,这也与第一块类似。 然后将块 2 的输入添加到块 2 的输出中,而无需通过单独的残差块。

第一个块用因子 2 对输入进行下采样,第二个块对输入的尺寸没有任何作用。 这就是为什么我们需要一个残差网以及第一个模块,而对于第二个模块,我们可以直接添加输入和输出。 实现该架构的代码如下。 init函数实际上是在初始化conv块和residue块。 PyTorch 帮助我们处理张量的加法,因此我们只需要编写我们想做的数学运算,就像您在普通的 Python 变量上执行此操作一样,而 PyTorch 的autograd将从那里完成。

class EncoderBlock(nn.Module):
    """ Residucal Block in linknet that does Encoding - layers in ResNet18 """

    def __init__(self, inp, out):
        """
        Resnet18 has first layer without downsampling.
        The parameter ''downsampling'' decides that
        # TODO - mention about how n - f/s + 1 is handling output size in
        # in downsample
        """
        super().__init__()
        self.block1 = nn.Sequential(
            ConvBlock(inp=inp, out=out, kernal=3, stride=2, pad=1, bias=True, act=True),
            ConvBlock(inp=out, out=out, kernal=3, stride=1, pad=1, bias=True, act=True))
        self.block2 = nn.Sequential(
            ConvBlock(inp=out, out=out, kernal=3, stride=1, pad=1, bias=True, act=True),
            ConvBlock(inp=out, out=out, kernal=3, stride=1, pad=1, bias=True, act=True))
        self.residue = ConvBlock(
            inp=inp, out=out, kernal=3, stride=2, pad=1, 
bias=True, act=True)

    def forward(self, x):
        out1 = self.block1(x)
        residue = self.residue(x)
        out2 = self.block2(out1 + residue)
        return out2 + out1
解码器块

DecoderBlock

图 4.11:LinkNet 的解码器图片

解码器是建立在DeconvBlock顶部之上的块,并且比EncoderBlock简单得多。 它没有与网络一起运行的任何残差,而只是两个卷积块之间通过反卷积块之间的直接链连接。 就像一个编码器块如何以两倍的系数对输入进行下采样一样,DecoderBlock以两倍的系数对输入进行上采样。 因此,我们有准确数量的编码器和解码器块来获取相同大小的输出。

class DecoderBlock(nn.Module):
    """ Residucal Block in linknet that does Encoding """

    def __init__(self, inp, out):
        super().__init__()
        self.conv1 = ConvBlock(
            inp=inp, out=inp // 4, kernal=1, stride=1, pad=0, bias=True, act=True)
        self.deconv = DeconvBlock(
            inp=inp // 4, out=inp // 4, kernal=3, stride=2, pad=1)
        self.conv2 = ConvBlock(
            inp=inp // 4, out=out, kernal=1, stride=1, pad=0, bias=True, act=True)

    def forward(self, x, output_size):
        conv1 = self.conv1(x)
        deconv = self.deconv(conv1, output_size=output_size)
        conv2 = self.conv2(deconv)
        return conv2

这样,我们的 LinkNet 模型设计就完成了。 我们将所有构造块放在一起以创建 LinkNet 模型,然后在开始训练之前使用torchvision预处理输入。 __init__将初始化整个网络架构。 它将创建初始块和最大池化层,四个编码器块,四个解码器块和两个包装另一个conv块的deconv块。 四个解码器块对图像进行升采样,以补偿由四个编码器完成的降采样。 编码器块(其中四个)之前的大步卷积和最大池化层也对图像进行了下采样两次。 为了弥补这一点,我们有两个DeconvBlocks,其中放置在DeconvBlock之间的ConvBlock完全不影响尺寸。

前向调用只是将所有初始化变量链接在一起,但是需要注意的部分是DecoderBlock。 我们必须将预期的输出传递给DecoderBlock,然后将其传递给torch.nn.ConvTranspose2d。 同样,我们将编码器输出的输出添加到下一步的解码器输入中。 这是我们之前看到的跳跃连接。 由于我们将编码器输出直接传递给解码器,因此我们传递了一些重建图像所需的信息。 这就是 LinkNet 即使在不影响速度的情况下也能如此出色运行的根本原因。

class SegmentationModel(nn.Module):
    """
    LinkNet for Semantic segmentation. Inspired heavily by
    https://github.com/meetshah1995/pytorch-semseg
    # TODO -> pad = kernal // 2
    # TODO -> change the var names
    # find size > a = lambda n, f, p, s: (((n + (2 * p)) - f) / s) + 1
    # Cannot have resnet18 architecture because it doesn't do downsampling on first layer
    """

    def __init__(self):
        super().__init__()
        self.init_conv = ConvBlock(
            inp=3, out=64, kernal=7, stride=2, pad=3, bias=True, act=True)
        self.init_maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)

        self.encoder1 = EncoderBlock(inp=64, out=64)
        self.encoder2 = EncoderBlock(inp=64, out=128)
        self.encoder3 = EncoderBlock(inp=128, out=256)
        self.encoder4 = EncoderBlock(inp=256, out=512)

        self.decoder4 = DecoderBlock(inp=512, out=256)
        self.decoder3 = DecoderBlock(inp=256, out=128)
        self.decoder2 = DecoderBlock(inp=128, out=64)
        self.decoder1 = DecoderBlock(inp=64, out=64)

        self.final_deconv1 = DeconvBlock(inp=64, out=32, kernal=3, stride=2, pad=1)
        self.final_conv = ConvBlock(
            inp=32, out=32, kernal=3, stride=1, pad=1, bias=True, act=True)
        self.final_deconv2 = DeconvBlock(inp=32, out=2, kernal=2, stride=2, pad=0)

    def forward(self, x):
        init_conv = self.init_conv(x)
        init_maxpool = self.init_maxpool(init_conv)
        e1 = self.encoder1(init_maxpool)
        e2 = self.encoder2(e1)
        e3 = self.encoder3(e2)
        e4 = self.encoder4(e3)

        d4 = self.decoder4(e4, e3.size()) + e3
        d3 = self.decoder3(d4, e2.size()) + e2
        d2 = self.decoder2(d3, e1.size()) + e1
        d1 = self.decoder1(d2, init_maxpool.size())

        final_deconv1 = self.final_deconv1(d1, init_conv.size())
        final_conv = self.final_conv(final_deconv1)
        final_deconv2 = self.final_deconv2(final_conv, x.size())

        return final_deconv2

总结

在过去的十年中,借助人工智能,计算机视觉领域得到了显着改善。 现在,它不仅用于诸如对象检测/识别之类的传统用例,而且还用于提高图像质量,从图像/视频进行丰富的搜索,从图像/视频生成文本,3D 建模等等。

在本章中,我们已经介绍了 CNN,这是迄今为止计算机视觉取得所有成功的关键。 CNN 的许多架构变体已用于不同目的,但是所有这些实现的核心是 CNN 的基本构建块。 关于 CNN 的技术局限性,已经进行了大量研究,尤其是从人类视觉仿真的角度。 已经证明,CNN 不能完全模拟人类视觉系统的工作方式。 这使许多研究小组认为应该有替代方案。 替代 CNN 的一种最流行的方法是使用胶囊网络,这也是杰弗里·欣顿实验室的成果。 但是现在,CNN 正在作为成千上万的实时和关键计算机视觉应用的核心。

在下一章中,我们将研究另一种基本的网络架构:循环神经网络。

参考

  1. 卷积网络,Udacity
  2. LinkNet
  3. Matthew D. Zeiler 和 Rob Fergus,《可视化和理解卷积网络》
  4. 《一百层提拉米苏:用于语义分割的完全卷积 DenseNets》
  5. 《深度学习卷积算法指南》
  6. 用于卷积算法的 GitHub 存储库
  7. 《LinkNet:利用编码器表示形式进行有效的语义分割》,Abhishek Chaurasia 和 Eugenio Culurciello,2017 年

五、序列数据处理

神经网络今天试图解决的主要挑战是处理,理解,压缩和生成序列数据。 序列数据可以被模糊地描述为任何依赖于上一个数据点和下一个数据点的东西。 尽管可以概括基本方法,但是处理不同类型的序列数据需要不同的技术。 我们将探讨序列数据处理单元的基本构建模块,以及常见问题及其广泛接受的解决方案。

在本章中,我们将研究序列数据。 人们用于序列数据处理的规范数据是自然语言,尽管时间序列数据,音乐,声音和其他数据也被视为序列数据。 自然语言处理NLP)和理解已被广泛探索,并且它是当前活跃的研究领域。 人类的语言异常复杂,我们整个词汇的可能组合超过了宇宙中原子的数量。 但是,深层网络通过使用诸如嵌入和注意之类的某些技术可以很好地处理此问题。

循环神经网络简介

循环神经网络RNN)是序列数据处理的实际实现。 顾名思义,RNN 重新遍历上一次运行中保存的信息的数据,并试图像人类一样找到序列的含义。

尽管原始 RNN(在输入中为每个单元展开一个简单的 RNN 单元)是一个革命性的想法,但未能提供可用于生产的结果。 主要障碍是长期依赖问题。 当输入序列的长度增加时,网络到达最后一个单元时将无法从初始单元(单词,如果是自然语言)中记住信息。 我们将在接下来的部分中看到 RNN 单元包含的内容以及如何将其展开。

几次迭代和多年的研究得出了 RNN 架构设计的几种不同方法。 最新的模型现在使用长短期记忆LSTM)实现或门控循环单元GRU)。 这两种实现都将 RNN 单元内的门用于不同目的,例如遗忘门,它使网络忘记不必要的信息。 这些架构具有原始 RNN 所存在的长期依赖性问题,因此使用门不仅要忘记不必要的信息,而且要记住在长距离移动到最后一个单元时所必需的信息。

注意是下一个重大发明,它可以帮助网络将注意力集中在输入的重要部分上,而不是搜索整个输入并试图找到答案。 实际上,来自 Google Brain 和多伦多大学的一个团队证明,注意力可以击败 LSTM 和 GRU 网络[1]。 但是,大多数实现都同时使用 LSTM/GRU 和注意力。

嵌入是通过比较单词在单词群集中的分布来找到单词的概念含义的另一种革命性思想。 嵌入保持单词之间的关系,并将这种关系(它从单词群集中的单词分布中找到)转换为一组浮点数。 嵌入大大减少了输入大小,并极大地提高了表现和准确率。 我们将使用 word2vec 进行实验。

数据处理是序列数据(尤其是自然语言)的主要挑战之一。 PyTorch 提供了一些工具包来处理该问题。 我们将使用预处理后的数据来简化实现,但是我们将遍历工具包以了解它们的工作原理。 与这些工具包一起,我们将使用torchtext,它消除了处理输入数据时将面临的许多困难。

尽管本章全都是关于序列数据的,但我们将专注于序列数据的一个子集,这是自然语言。 特定于自然语言的一些研究人员认为,我们使用 LSTM 或 GRU 处理输入的方式不是应该如何处理自然语言。 自然语言在单词之间保持树状的层次关系,我们应该加以利用。 栈式增强型解析器-解释器神经网络SPINN)[2]是来自 Stanford NLP 组的一种此类实现。 这种处理树状结构序列数据的特殊类型的网络是递归神经网络(与循环神经网络不同)。 在本章的最后一部分中,我们将详细介绍 SPINN。

问题

在本章中,我将首先解决要解决的问题,然后说明概念,同时解决我们遇到的问题。 问题是用三种不同的方法来找到两个英语句子之间的相似性。 为了使比较公平,我们将在所有实现中使用单词嵌入。 不用担心,我们还将进行单词嵌入。 手头的问题通常称为包含问题,其中我们每次都有两个句子,我们的工作是预测这些句子之间的相似性。 我们可以将句子分为三类:

  • 蕴含:这两个句子是同一意思:
    • A soccer game with multiple males playing.
    • Some men are playing a sport.
  • 中性:两个句子有一个共同点:
    • An older and younger man smiling.
    • Two men are smiling and laughing at the cats playing on the floor.
  • 矛盾:两个句子都传达两种不同的含义:
    • A black race car starts up in front of a crowd of people.
    • A man is driving down a lonely road.

The problem

图 5.1:问题的图示

方法

在遍历 SNLI 数据集之前,我们将实现所有这三种方法:基本 RNN,高级 LNN(如 LSTM 或 GRU)和递归网络(如 SPINN)。 每个数据实例给我们一对句子,一个前提和一个假设句子。 句子首先转换为嵌入,然后传递到每个实现中。 虽然简单 RNN 和高级 RNN 的过程相同,但 SPINN 引入了完全不同的训练和推理流程。 让我们从一个简单的 RNN 开始。

简单 RNN

RNN 已被用作理解数据含义的 NLP 技术,并且我们可以根据从中发现的顺序关系来完成许多任务。 我们将使用这个简单的 RNN 来展示循环如何有效地积累单词的含义并根据单词所处的上下文来理解单词的含义。

在开始构建网络的任何核心模块之前,我们必须处理数据集并对其进行修改以供使用。 我们将使用来自 Stanford 的 SNLI 数据集(包含标记为包含,矛盾和中立的句子对的数据集),该数据集已经过预处理并保存在torchtext中。

加载的数据集包含数据实例,这些实例是标记为蕴含,矛盾和中立的句子对。 每个句子与一组将与循环网络一起使用的转换相关联。 在以下代码块中显示了从BucketIterator加载的数据集。 我们可以通过调用batch.premise.hypothesis访问一对句子(get_data()函数是伪代码,以避免显示长行;获取数据的实际代码可在 GitHub 存储库中找到):

>>> train_iter, dev_iter, test_iter = get_data()
>>> batch = next(iter(train_iter))
>>> batch
[torchtext.data.batch.Batch of size 64 from SNLI]
 [.premise]:[torch.LongTensor of size 32x64]
 [.hypothesis]:[torch.LongTensor of size 22x64]
 [.label]:[torch.LongTensor of size 64]

现在我们有了所需的一切(每个数据实例两个句子和一个相应的标签),我们可以开始对网络进行编码。 但是我们如何使我们的神经网络处理英语呢? 普通的神经网络对数值执行运算,但是现在我们有了字符。 旧的方法是将输入转换为单编码序列。 这是一个很好的旧 NumPy 的简单示例:

>>> vocab = {
 'am': 0,
 'are': 1,
 'fine': 2,
 'hai': 3,
 'how': 4,
 'i': 5,
 'thanks': 6,
 'you': 7,
 ',': 8,
 '.': 9
 }
>>> # input = hai, how are you -> 3, 8, 4, 1, 7
 seq = [3, 8, 4, 1, 7]
>>> a = np.array(seq)
>>> b = np.zeros((len(seq), len(vocab)))
>>> b[np.arange(len(seq)), seq] = 1
>>> b
array([[O., 0., 0., 1., 0., 0., 0., 0., 0., 0.],
 [0., 0., 0., 0., 0., 0., 0., 0., 1., 0.],
 [0., 0., 0., 0., 1., 0., 0., 0., 0., 0.],
 [0., 1., 0., 0., 0., 0., 0., 0., 0., 0.],
 [0., 0., 0., 0., 0., 0., 0., 1., 0., 0.]])

该示例中的b变量是我们传递给神经网络的变量。 因此,我们的神经网络将具有与词汇量相等的许多输入神经元。 对于每个实例,我们传递一个只有一个元素的稀疏数组作为1。 您看到单热编码会出现什么问题吗? 随着词汇量的增加,您最终将拥有巨大的输入层。 就是说嵌入可以为您提供帮助。

词嵌入

使用自然语言(或由离散的单个单元组成的任何序列)的标准方法是将每个单词转换为单热编码向量,并将其用于网络的后期。 这种方法的明显缺点是,随着词汇量的增加,输入层的大小也会增加。

词嵌入是减少数组或张量维数的数十年历史的想法。 潜在迪利克雷分配LDA)和潜在语义分析LSA)是我们用来进行嵌入的两个此类示例。 但是,在 Facebook 研究科学家 Tomas Mikolov 和他的团队于 2013 年实现 word2vec 之后,就开始将嵌入视为前提。

Word2vec 是一种无监督的学习算法,在这种算法中,网络未经训练就进行嵌入。 这意味着您可以在一个英语数据集上训练 word2vec 模型,并使用它为另一模型生成嵌入。

另一种流行的单词嵌入算法叫做 GloVe(我们将在本章中使用它),它来​​自斯坦福大学 NLP 小组。 尽管两种实现都试图解决相同的问题,但是它们都使用了截然不同的方法。 Word2vec 正在使用嵌入来提高预测能力; 也就是说,算法尝试通过使用上下文词来预测目标词。 随着预测精度的提高,嵌入变得更强。 GloVe 是一个基于计数的模型,其中我们制作了一个庞大的表,该表显示每个单词与其他单词对应的频率。 显然,如果词汇量很高,并且使用的是诸如维基百科之类的大型文本集,那么这将构成一个巨大的表格。 因此,我们对该表进行降维,以获得大小合理的嵌入矩阵。

像其他 PyTorch 层一样,PyTorch 在torch.nn中创建了一个嵌入层。 尽管我们可以使用预训练的模型,但它对于我们的自定义数据集是可训练的。 嵌入层需要词汇量和我们要保留的嵌入尺寸的大小。 通常,我们使用300作为嵌入维度:

>>> vocab_size = 100
>>> embedding_dim = 300
>>> embed = nn.Embedding(vocab_size, embedding_dim)
>>> input_tensor = torch.LongTensor([5])
>>> embed(input_tensor).size()
torch.Size([1, 300])

如今,嵌入层还用于所有类型的分类输入,而不仅仅是嵌入自然语言。 例如,如果您要为英超联赛预测获胜者,则最好嵌入球队名称或地名,而不是将它们作为一站式编码向量传递给您的网络。

但是对于我们的用例,torchtext将前面的方法包装为一种将输入转换为嵌入的简单方法。 下面是一个示例,其中我们转移了从 GloVe 向量获得的学习信息,以从 Google 新闻中获得对 60 亿个标记进行训练的预训练嵌入:

inputs = data.Field(lower=True)
answers = data.Field(sequential=False)
train, dev, test = datasets.SNLI.splits(inputs, answers)
inputs.build_vocab(train, dev, test)
inputs.vocab.load_vectors('glove.6B.300d')

我们将 SNLI 数据集分为trainingdevtest集,并将它们作为参数传递给build_vocab函数。 build_vocab函数遍历给定的数据集,并找到单词,频率和其他属性的数字,并创建vocab对象。 该vocab对象公开了load_vectors API,以接受预先训练的模型来进行迁移学习。

RNNCell

接下来,我们将开始构建网络的最小基础构建块,即 RNN 单元。 它的工作方式是一个 RNN 单元能够一一处理句子中的所有单词。 最初,我们将句子中的第一个单词传递到单元格,该单元格生成输出和中间状态。 此状态是序列的运行含义,由于在完成对整个序列的处理之前不会输出此状态,因此将其称为隐藏状态。

在第一个单词之后,我们具有从 RNN 单元生成的输出和隐藏状态。 输出状态和隐藏状态都有自己的目的。 可以训练输出以预测句子中的下一个字符或单词。 这就是大多数语言建模任务的工作方式。

如果您试图创建一个顺序网络来预测诸如股票价格之类的时间序列数据,那么很可能这就是您构建网络的方式。 但是在我们的例子中,我们只担心句子的整体含义,因此我们将忽略每个单元格生成的输出。 除了输出,我们将重点放在隐藏状态。 如前所述,隐藏状态的目的是保持句子的连续含义。 听起来像我们要找的东西,对吗? 每个 RNN 单元都将一个隐藏状态作为输入之一,并吐出另一个隐藏状态,如“图 5.2”中所给。

我们将为每个单词使用相同的 RNN 单元,并将从上一次单词处理生成的隐藏状态作为当前单词执行的输入传递。 因此,RNN 单元在每个字处理阶段具有两个输入:字本身和上一次执行时的隐藏状态。

开始执行时会发生什么? 我们手中没有隐藏状态,但是我们设计了单元以期望隐藏状态。 我们几乎总是创建一个零值的隐藏状态,只是为了模拟第一个单词的过程,尽管已经进行了研究以尝试使用不同的值而不是零。

RNNCell

图 5.2:具有输入,隐藏状态和输出展开序列的通用 RNN 单元流程图

“图 5.2”显示了展开的同一 RNN 单元,以可视化如何处理句子中的每个单词。 由于我们为每个单词使用相同的 RNN 单元,因此大大减少了神经网络所需的参数数量,这使我们能够处理大型小批量。 网络参数学习的方式是处理序列的顺序。 这是 RNN 的核心原则。

RNNCell

图 5.3:RNN 单元流程图

已经尝试了不同的布线机制来设计 RNN 单元以获得最有效的输出。 在本节中,我们将使用最基本的一层,它由两个全连接层和一个 softmax 层组成。 但是在现实世界中,人们将 LSTM 或 GRU 用作 RNN 单元,事实证明,这在许多用例中都可以提供最新的结果。 我们将在下一部分中看到它们。 实际上,已经进行了大量比较以找到所有顺序任务的最佳架构,例如《LSTM:搜索空间漫游》[3]。

我们开发了一个简单的 RNN,如以下代码所示。 没有复杂的门控机制,也没有架构模式。 这是理所当然的。

class RNNCell(nn.Module):
    def __init__(self, embed_dim, hidden_size, vocab_dim):
        super().__init__()

        self.hidden_size = hidden_size
        self.input2hidden = nn.Linear(embed_dim + hidden_size,hidden_size)
        # Since it's encoder
		# We are not concerned about output
		# self.input2output = nn.Linear(embed_dim + hidden_size, vocab_dim)
		# self.softmax = nn.LogSoftmax(dim=1)

    def forward(self, inputs, hidden):
        combined = torch.cat((inputs, hidden), 1)
        hidden = torch.relu(self.input2hidden(combined))
        output = self.input2output(combined)
        output = self.softmax(output)
        return output, hidden

    def init_hidden(self):
        return torch.zeros(1, self.hidden_size)

如图“图 5.3”所示,我们有两个全连接层,每个层负责创建输出和输入的隐藏状态。 RNNCellforward函数接受先前状态的当前输入和隐藏状态,然后我们将它们连接在一起。

一个Linear层采用级联张量并为下一个单元生成隐藏状态,而另一Linear层为当前单元生成输出。 然后,输出返回softmax,然后返回训练循环。 RNNCell拥有一个称为init_hidden的类方法,可以方便地保留该类方法,以便在初始化RNNCell中的对象时使用我们通过的隐藏状态大小生成第一个隐藏状态。 在开始遍历序列以获取第一个隐藏状态之前,我们将调用init_hidden,该状态将被初始化为零。

现在,我们已准备好网络中最小的组件。 下一个任务是创建循环遍历序列的更高级别的组件,并使用RNNCell处理序列中的每个单词以生成隐藏状态。 我们称这个Encoder节点,它用词汇量大小和隐藏大小初始化RNNCell。 请记住,RNNCell需要用于嵌入层的词汇量和用于生成隐藏状态的隐藏大小。 在forward函数中,我们获得输入作为自变量,这将是一个小批量的序列。 在这种特殊情况下,我们遍历torchtextBucketIterator,它识别相同长度的序列并将它们分组在一起。

工具

如果我们不使用BucketIterator怎么办,或者如果我们根本没有相同长度的序列怎么办? 我们有两种选择:要么逐个执行序列,要么将除最长句子之外的所有句子填充为零,以使所有句子的长度与最长序列相同。

注意

尽管如果在 PyTorch 中一个接一个地传递序列长度,我们不会遇到不同序列长度的问题,但是如果我们的框架是基于静态计算图的框架,则会遇到麻烦。 在静态计算图中,甚至序列长度也必须是静态的,这就是基于静态图的框架与基于 NLP 的任务极不兼容的原因。 但是,像 TensorFlow 这样的高度复杂的框架通过为用户提供另一个名为dynamic_rnn的 API 来处理此问题。

第一种方法似乎很好用,因为我们每次分别为每个句子处理一个单词。 但是,小批量的输入要比一次处理一个数据输入更有效,以使我们的损失函数收敛到全局最小值。 做到这一点的明显有效的方法是填充。 用零填充输入(或输入数据集中不存在的任何预定义值)有助于我们解决此特定问题。 但是,当我们尝试手动执行操作时,它变得很繁琐,并且变得多余,因为每次处理序列数据时都必须这样做。 PyTorch 在torch.nn下有一个单独的工具包,其中包含我们 RNN 所需的工具。

填充序列

函数pad_sequence听起来很像:在标识批量中最长的序列后,将序列用零填充,然后将其他所有句子填充到该长度:

>>> import torch.nn.utils.rnn as rnn_utils
>>> a = torch.Tensor([1, 2, 3])
>>> b = torch.Tensor([4, 5])
>>> c = torch.Tensor([6])
>>> rnn_utils.pad_sequence([a, b, c], True)

1 2 3
4 5 0
6 0 0

[torch.FloatTensor of size (3,3)]

在给定的示例中,我们具有三个具有三个不同长度的序列,其中最长的序列的长度为三个。 PyTorch 填充其他两个序列,以使它们现在的长度均为三。 pad_sequence函数接受一个位置参数,该位置参数是序列的排序序列(即最长序列(a)在前和最短序列(c)在后)和一个关键字参数,该参数决定用户是否希望它是否为batch_first

打包序列

您是否看到用零填充输入并使用 RNN 处理输入的问题,特别是在我们如此关心最后一个隐藏状态的情况下? 批量中包含一个非常大的句子的简短句子最终将填充很多零,并且在生成隐藏状态时,我们也必须遍历这些零。

下图显示了一个包含三个句子的批量输入示例。 短句子用零填充,以使长度等于最长句子。 但是在处理它们时,我们最终也会处理零。 对于双向 RNN,问题更加复杂,因为我们必须从两端进行处理。

Pack sequence

图 5.4:具有零的句子也具有针对零计算的隐藏状态

将零添加到输入将污染结果,这是非常不希望的。 打包序列是为了避免这种影响。 PyTorch 完全具有工具函数pack_sequence

>>> import torch.nn.utils.rnn as rnn_utils
>>> import torch
>>> a = torch.Tensor([1, 2, 3])
>>> b = torch.Tensor([1, 2])
>>> c = torch.Tensor([1])
>>> packed = rnn_utils.pack_sequence([a, b, c])
>>> packed
PackedSequence(data=tensor([1., 1., 1., 2., 2., 3.]), batch_sizes=tensor([3, 2, 1]))

pack_sequence函数返回PackedSequence类的实例,所有用 PyTorch 编写的 RNN 模块都可以接受。 由于PackedSequence掩盖了输入中不需要的部分,因此提高了模型的效率和准确率。 前面的示例显示了PackedSequence的内容。 但是,为简单起见,我们将避免在模型中使用打包序列,而将始终使用填充序列或BucketIterator的输出。

编码器

class Encoder(nn.Module):

    def __init__(self, embed_dim, vocab_dim, hidden_size):
        super(Encoder, self).__init__()
        self.rnn = RNNCell(embed_dim, hidden_size, vocab_dim)

    def forward(self, inputs):
        ht = self.rnn.init_hidden()
        for word in inputs.split(1, dim=1):
            outputs, ht = self.rnn(word, ht)
        return ht

forward函数中,我们首先将RNNCell的隐藏状态初始化为零; 这是通过调用我们先前创建的init_hidden完成的。 然后,我们通过将输入的序列以大小 1 拆分为维度 1 来遍历该序列。 这是在假设输入为batch_first,因此是之后,第一维将是序列长度。 为了遍历每个单词,我们必须遍历第一维。

对于每个单词,我们用当前单词(输入)和先前状态的隐藏状态调用self.rnnforwardself.rnn返回下一个单元的输出和隐藏状态,我们继续循环直到序列结束。 对于我们的问题案例,我们不担心输出,也不对可能从输出中获得的损失进行反向传播。 相反,我们假设最后一个隐藏状态具有句子的含义。

如果我们也能获得该对中另一个句子的含义,则可以比较这些含义以预测该类是矛盾的,必然的或中立的,并反向传播损失。 这听起来像个主意。 但是,我们将如何比较这两种含义? 接下来。

分类器

我们网络的最后一个组成部分是分类器。 因此,我们手头有两个句子,经过编码器,我们得到了两个句子的最终隐藏状态。 现在是时候定义损失函数了。 一种方法是从两个句子中找出高维隐藏状态之间的距离。 可以按以下方式处理损失:

  1. 如果需要的话,将损失最大化到一个很大的正值。
  2. 如果存在矛盾,请将损失最小化为较大的负值。
  3. 如果它是中性的,则将损失保持在零附近(在两到三倍的范围内可行)。

另一种方法可能是连接两个句子的隐藏状态并将它们传递到另一组层,并定义最终的分类器层,该层可以将连接的值分类为我们想要的三个类。 实际的 SPINN 实现使用后一种方法,但是合并机制比简单的连接更为复杂。

class Merger(nn.Module):

    def __init__(self, size, dropout=0.5):
        super().__init__()
        self.bn = nn.BatchNorm1d(size * 4)
        self.dropout = nn.Dropout(p=dropout)

    def forward(self, data):
		prem = data[0]
		hypo = data[1]
		diff = prem - hypo
		prod = prem * hypo
		cated_data = torch.cat([prem, hypo, diff, prod], 2)
		cated_data = cated_data.squeeze()
		return self.dropout(self.bn(cated_data))

在这里,Merger节点被构建为模拟 SPINN 的实际实现。 Mergerforward函数获得两个序列:premhypo。 我们首先通过正常减法确定两个句子之间的差异,然后通过逐元素相乘找到它们之间的乘积。 然后,我们将实际句子与差异和刚刚找到的乘积连接起来,然后将它们传递给批量规范化层和丢弃层。

Merger节点也是我们的简单 RNN 的最终分类器层的一部分,该分类器由其他几个节点组成。

包装类RNNClassifier包装到目前为止我们定义的所有组件,并创建最终的分类器层作为torch.nn.Sequential的实例。 整个网络的流程显示在“图 5.3”中,并在以下块中以代码形式表示:

class RNNClassifier(nn.Module):

    def __init__(self, config):
        super().__init__()
        self.config = config
        self.embed = nn.Embedding(config.vocab_dim,config.embed_dim)
        self.encoder = Encoder(config)
        self.classifier = nn.Sequential(
            Merger(config.embed_dim, config.dropout),
            nn.Linear(4 * config.embed_dim, config.fc1_dim),
            nn.ReLU(),
            nn.BatchNorm1d(config.fc1_dim),
            nn.Dropout(p=config.dropout),
            nn.Linear(config.fci_dim, config.fc2_dim)
        )

    def forward(self, batch):
        prem_embed = self.embed(batch.premise)
        hypo_embed = self.embed(batch.hypothesis)
        premise = self.encoder(prem_embed)
        hypothesis = self.encoder(hypo_embed)
        scores = self.classifier(premise, hypothesis)
        return scores

RNNClassifier模块具有三个主要层,我们在前面进行了讨论:

  • 嵌入层已保存到self.embed
  • 使用RNNCell的编码器层,该层存储在self.encoder
  • self.classifier中存储的nn.Sequential层的实例

最后的顺序层从Merger节点开始。 合并后的输出的序列长度维度将增大四倍,因为我们将两个句子,它们的差和它们的乘积都附加到Merger的输出中。 然后将其穿过一个全连接层,然后在ReLU非线性之后使用batchnorm1d将其标准化。 之后的丢弃减少了过拟合的机会,过拟合的机会随后传递到另一个全连接层,该层为我们的输入数据创建了得分。 输入数据决定数据点所属的包围,矛盾或中性类​​别。

丢弃

丢弃是 Apple 的机器学习工程师 Nitish Srivastava 提出的革命性想法。 它消除了对通常的正则化技术的需要,该技术在引入丢弃之前一直很普遍。 借助丢弃,我们丢弃了网络中神经元之间的随机连接,因此网络必须泛化并且不能偏向任何类型的外部因素。 要删除神经元,只需将​​其输出设置为零即可。 丢弃随机神经元可防止网络共同适应,因此在很大程度上减少了过拟合。

Dropout

图 5.5:丢弃

PyTorch 作为torch.nn包的一部分提供了更高级别的丢弃层,该层在初始化时接受退出因子。 它的forward函数只是关闭一些输入。

训练

我们为制作的所有小组件提供了一个包装模块,称为RNNClassifier。 训练过程与我们整本书所遵循的过程相似。 我们初始化model类,定义损失函数,然后定义优化器。 一旦完成所有这些设置并初始化了超参数,就将整个控件交给ignite。 但是在简单的 RNN 中,由于我们正在从 GloVe 向量的学习的嵌入中进行迁移学习,因此我们必须将这些学习的权重转移到嵌入层的权重矩阵中。 这是通过以下代码段的第二行完成的。

model = RNNClassifier(config)
model.embed.weight.data = inputs.vocab.vectors
criterion = nn.CrossEntropyLoss()
opt = optim.Adam(model.parameters(), lr=lr)

尽管 PyTorch 会为用户进行反向传播,并且反向传播在概念上始终是相同的,但顺序网络的反向传播与我们在普通网络中看到的反向传播并不完全相似。 在这里,我们进行时间上的反向传播BPTT)。 为了了解 BPTT 的工作原理,我们必须假设 RNN 是相似 RNN 单元的长重复单元,而不是将相同的输入视为通过同一 RNN 单元传递。

如果我们在句子中有五个单词,则我们有五个 RNN 单元,但是所有单元的权重都相同,并且当我们更新一个 RNN 单元的权重时,我们将更新所有 RNN 单元的权重。 现在,如果将输入分为五个时间步,每个单词位于每个时间步,则我们应该能够轻松描绘每个单词如何通过每个 RNN 单元。 在进行反向传播时,我们将遍历每个 RNN 单元,并在每个时间步长累积梯度。 更新一个 RNN 单元的权重也会更新其他 RNN 单元的权重。 由于所有五个单元都具有梯度,并且每次更新都会更新所有五个单元的权重,因此我们最终将每个单元的权重更新了五次。 无需进行五次更新,而是将梯度累加在一起并更新一次。 这是 BPTT。

高级 RNN

对于基于 LSTM 和 GRU 的网络,高级可能是一个模糊的术语,因为默认情况下,这些是在所有序列数据处理网络中使用的网络架构。 与 1990 年代提出的 LSTM 网络相比,GRU 网络是一个相对较新的设计。 两种网络都是门控循环网络的不同形式,其中 LSTM 网络建立的架构比 GRU 网络复杂。 这些架构被概括为门控循环网络,因为它们具有用于处理通过网络的输入/梯度流的门。 门从根本上是激活,例如 Sigmoid,以决定要流经的数据量。 在这里,我们将详细研究 LSTM 和 GRU 的架构,并了解 PyTorch 如何提供对 LSTM 和 GRU 的 API 的访问。

LSTM

LSTM

图 5.6:LSTM 单元

LSTM 网络由 Sepp Hochreiter 于 1991 年引入,并于 1997 年发布。LSTM 网络在循环单元中建立了多个门,其中正常的RNNCell具有Linear层,该层通过softmax层相互作用以生成输出,另一个Linear层会生成隐藏状态。 有关 LSTM 的详细说明,请参见原始论文或克里斯托弗·奥拉(Christopher Olah)的博客,标题为《了解 LSTM 网络》[4]。

LSTM 主要由遗忘门,更新门和单元状态组成,这使得 LSTM 与常规 RNN 单元不同。 该架构经过精心设计,可以执行特定任务。 遗忘门使用输入向量和先前状态的隐藏状态来确定例如应忘记的内容,更新门使用当前输入和先前的隐藏状态来确定应添加到信息存储库中的内容。

这些决定基于 Sigmoid 层的输出,该层始终输出一个介于 0 到 1 范围内的值。 因此,“遗忘门”中的值 1 表示记住所有内容,而值 0 则表示忘记所有内容。 更新门同样适用。

所有操作都将在并行流经网络的单元状态上执行,这与网络中的信息仅具有线性交互作用,因此允许数据无缝地向前和向后流动。

GRU

GRU 是一个相对较新的设计,与 LSTM 相比,它效率高且复杂度低。 简而言之,GRU 将遗忘门和更新门合并在一起,并且只对单元状态进行一次一次性更新。 实际上,GRU 没有单独的单元状态和隐藏状态,两者都合并在一起以创建一个状态。 这些简化在不影响网络准确率的前提下,极大地降低了 GRU 的复杂性。 由于 GRU 比 LSTM 具有更高的表现,因此 GRU 如今已被广泛使用。

GRUs

图 5.7:一个 GRU 单元

架构

我们的模型架构与RNNClassifier相似,但是RNNCell被 LSTM 或 GRU 单元所替代。 PyTorch 具有函数式 API,可用于将 LSTM 单元或 GRU 单元用作循环网络的最小单元。 借助动态图功能,使用 PyTorch 完全可以遍历序列并调用单元。

高级 RNN 和简单 RNN 之间的唯一区别在于编码器网络。 RNNCell类已替换为torch.nn.LSTMCelltorch.nn.GRUCell,并且Encoder类使用了这些预建单元,而不是我们上次创建的自定义RNNCell

class Encoder(nn.Module):

    def __init__(self, config):
        super(Encoder, self).__init__()
        self.config = config
        if config.type == 'LSTM':
            self.rnn = nn.LSTMCell(config.embed_dim,config.hidden_size)
        elif config.type == 'GRU':
            self.rnn = nn.GRUCell(config.embed_dim,config.hidden_size)

    def forward(self, inputs):
        ht = self.rnn.init_hidden()
        for word in inputs.split(1, dim=1):
            ht, ct = self.rnn(word, (ht, ct))
LSTMCellGRUCell

LSTMCellGRUCell的函数式 API 绝对相似,这也正是定制RNNCell的方式。 它们接受输入大小和初始化器的隐藏大小。 forward调用接受具有输入大小的微型输入批量,并为该实例创建单元状态和隐藏状态,然后将其传递给下一个执行输入。 在静态图框架中实现这种的实现非常困难,因为该图在整个执行期间都是预先编译的并且是静态的。 循环语句也应作为图节点作为图的一部分。 这需要用户学习那些额外的操作节点或其他在内部处理循环的函数式 API。

LSTM 和 GRU

虽然 PyTorch 允许访问粒度LSTMCellGRUCell API,但它也可以处理用户不需要粒度的情况。 这在用户不需要更改 LSTM 工作原理的内部但表现最为重要的情况下特别有用,因为 Python 循环的速度很慢。 torch.nn模块具有用于 LSTM 和 GRU 网络的高级 API,这些 API 封装了LSTMCellGRUCell,并使用 cuDNNCUDA 深度神经网络)实现了有效执行。 LSTM 和 cuDNN GRU。

class Encoder(nn.Module):

    def __init__(self, config):
        super(Encoder, self).__init__()
        self.config = config
        if config.type == 'LSTM':
            self.rnn = nn.LSTM(input_size=config.out_dim,hidden_size=config.hidden_size,num_layers=config.n_layers,dropout=config.dropout,bidirectional=config.birnn)
        elif config.type == 'GRU':
            self.rnn = nn.GRU(input_size=config.out_dim,hidden_size=config.hidden_size,num_layers=config.n_layers,dropout=config.dropout,bidirectional=config.birnn)

    def forward(self, inputs):
        batch_size = inputs.size()[1]
        state_shape = self.config.n_cells, batch_size,self.config.hidden_size
        h0 = c0 = inputs.new(*state_shape).zero_()
        outputs, (ht, ct) = self.rnn(inputs, (h0, c0))
        if not self.config.birnn:
            return ht[-1]
        else:
            return ht[-2:].transpose(0, 1).contiguous().view(batch_size, -1)

LSTMCellGRUCell相似,LSTM 和 GRU 具有相似的函数式 API,以使它们彼此兼容。 此外,与单元对应物相比,LSTM 和 GRU 接受更多的参数,其中num_layersdropoutbidirectional很重要。

如果将True作为参数,则dropout参数将为网络实现添加一个丢弃层,这有助于避免过拟合和规范化网络。 使用 LSTM 之类的高级 API 消除了对 Python 循环的需要,并一次接受了完整的序列作为输入。 尽管可以接受常规序列作为输入,但始终建议传递打包(掩码)输入,这样可以提高性能,因为 cuDNN 后端希望输入如此。

增加层数

Increasing the number of layers

图 5.8:多层 RNN

RNN 中的层数在语义上类似于任何类型的神经网络中层数的增加。 由于它可以保存有关数据集的更多信息,因此增加了网络的学习能力。

在 PyTorch 中的 LSTM 中,添加多个层只是对象初始化的一个参数:num_layers。 但这要求单元状态和隐藏状态的形状为[num_layers * num_directions, batch, hidden_size],其中num_layers是层数,num_directions对于单向是1,对于双向是2(尝试通过使用更多数量的层和双向 RNN 来保留示例的表现)。

双向 RNN

RNN 实现通常是单向的,这就是到目前为止我们已经实现的。 单向和双向 RNN 之间的区别在于,在双向 RNN 中,后向通过等效于在相反方向上的正向传播。 因此,反向传递的输入是相同的序列,但是是反向的。

事实证明,双向 RNN 的表现要优于单方向的 RNN,并且很容易理解原因,尤其是对于 NLP。 但这不能一概而论,并非在所有情况下都是如此。 从理论上讲,如果手头的任务需要过去和将来的信息,则双向 RNN 往往会工作得更好。 例如,预测单词填补空白需要上一个序列和下一个序列。

在我们的分类任务中,双向 RNN 效果更好,因为当 RNN 使序列具有上下文的含义时,它会在两侧使用序列流。 PyTorch 的 LSTM 或 GRU 接受参数bidirectional的布尔值,该值确定网络是否应该是双向的。

如前一节所述,隐藏状态和单元状态必须与bidirectional标志一起保持形状[num_layers * num_directions, batch, hidden_size],如果num_directions是双向的,则必须为2。 另外,我还警告您,双向 RNN 并非总是首选,尤其是对于那些我们手头没有未来信息(例如股价预测等)的数据集。

Bidirectional RNN

图 5.9:双向 RNN

分类器

高级RNNClassifier与简单RNNClassifier完全相同,唯一的例外是 RNN 编码器已被 LSTM 或 GRU 编码器替代。 但是,高级分类器由于使用了高度优化的 cuDNN 后端,因此可以显着提高网络表现,尤其是在 GPU 上。

我们为高级 RNN 开发的模型是多层双向 LSTM/GRU 网络。 增加对秘籍的关注可大大提高性能。 但这不会改变分类器,因为所有这些组件都将使用Encoder方法包装,并且分类器仅担心Encoder的函数式 API 不会改变。

注意

如前所述,注意力是与正常神经网络过程一起集中在重要区域上的过程。 注意不是我们现有实现的一部分; 而是充当另一个模块,该模块始终查看输入,并作为额外输入传递到当前网络。

注意背后的想法是,当我们阅读句子时,我们专注于句子的重要部分。 例如,将一个句子从一种语言翻译成另一种语言,我们将更专注于上下文信息,而不是构成句子的文章或其他单词。

一旦概念清晰,在 PyTorch 中获得关注就很简单。 注意可以有效地用于许多应用中,包括语音处理; 翻译,以前自编码器是首选实现; CNN 到 RNN,用于图像字幕; 和别的。

实际上,《注意力就是您所需要的全部》[5]是该论文的作者仅通过关注并删除所有其他复杂的网络架构(如 LSTM)就能够获得 SOTA 结果的方法。

循环神经网络

语言研究人员的一部分永远不会认可 RNN 的工作方式,即从左到右依次进行,尽管那是多少人阅读一个句子。 某些人坚信语言具有层次结构,利用这种结构有助于我们轻松解决 NLP 问题。 循环神经网络是使用该方法解决 NLP 的尝试,其中,基于要处理的语言的短语,将序列安排为树。 SNLI 是为此目的而创建的数据集,其中每个句子都排列成一棵树。

我们正在尝试构建的特定递归网络是 SPINN,它是通过充分考虑这两个方面的优点而制成的。 SPINN 从左到右处理数据,就像人类的阅读方式一样,但仍保持层次结构完整。 从左向右读取的方法相对于按层次进行解析还有另一个优势:网络从左向右读取时可以最终学习生成解析树。 这可以通过使用称为移位减少解析器的特殊实现以及栈和缓冲区数据结构的使用来实现。

Recursive neural networks

图 5.10:Shift-Reduce 解析器

SPINN 将输入的句子编码为固定长度的向量,就像基于 RNN 的编码器如何从每个序列创建“含义”向量一样。 来自每个数据点的两个句子都将通过 SPINN 传递并为每个句子创建编码的向量,然后使用合并网络和分类器网络对其进行处理以获得这三个类别中每个类别的得分。

如果您想知道需要在不公开 PyTorch 的任何其他函数式 API 的情况下显示 SPINN 实现的方法,那么答案是 SPINN 是展示 PyTorch 如何适应任何类型的神经网络架构的最佳示例。 你发展。 无论您考虑的架构要求如何,PyTorch 都不会妨碍您。

静态计算图之上构建的框架不能实现 SPINN 这样的网络架构,而不会造成混乱。 这可能是所有流行框架围绕其核心实现构建动态计算图包装的原因,例如 TensorFlow 的热切需求,MXNet,CNTK 的 Gluon API 等。 我们将看到 PyTorch 的 API 对实现任何类型的条件或循环到计算图中的 API 有多么直观。 SPINN 是展示这些的完美示例。

简化

简化网络将最左边的单词,最右边的单词和句子上下文作为输入,并在forward调用中生成单个归约的输出。 句子上下文由另一个称为Tracker的深度网络给出。 Reduce不在乎网络中正在发生的事情; 它总是接受三个输入,并由此减少输出。 树 LSTM 是标准 LSTM 的变体,用于与bundleunbundle等其他辅助函数一起批量Reduce网络中发生的繁重操作。

class Reduce(nn.Module):

    def __init__(self, size, tracker_size=None):
        super().__init__()
        self.left = nn.Linear(size, 5 * size)
        self.right = nn.Linear(size, 5 * size, bias=False)
        if tracker_size is not None:
            self.track = nn.Linear(tracker_size, 5 * size,bias=False)
    def forward(self, left_in, right_in, tracking=None):
        left, right = bundle(left_in), bundle(right_in)
        tracking = bundle(tracking)
        lstm_in = self.left(left[0])
        lstm_in += self.right(right[0])
        if hasattr(self, 'track'):
            lstm_in += self.track(tracking[0])
        out = unbundle(tree_lstm(left[1], right[1], lstm_in))
        return out

Reduce本质上是一个典型的神经网络模块,它对三参数输入执行 LSTM 操作。

追踪器

在循环中每次 SPINN 的forward调用中都会调用Trackerforward方法。 在归约运算开始之前,我们需要将上下文向量传递到Reduce网络,因此,我们需要遍历transition向量并创建缓冲区,栈和上下文向量,然后才能执行 SPINN 的forward()函数。 由于 PyTorch 变量会跟踪历史事件,因此将跟踪所有这些循环操作并可以反向传播:

class Tracker(nn.Module):

    def __init__(self, size, tracker_size, predict):
        super().__init__()
        self.rnn = nn.LSTMCell(3 * size, tracker_size)
        if predict:
            self.transition = nn.Linear(tracker_size, 4)
        self.state_size = tracker_size

    def reset_state(self):
        self.state = None

    def forward(self, bufs, stacks):
        buf = bundle(buf[-1] for buf in bufs)[0]
        stack1 = bundle(stack[-1] for stack in stacks)[0]
        stack2 = bundle(stack[-2] for stack in stacks)[0]
        x = torch.cat((buf, stack1, stack2), 1)
        if self.state is None:
            self.state = 2 * [x.data.new(x.size(0),self.state_size).zero_()]
        self.state = self.rnn(x, self.state)
        if hasattr(self, 'transition'):
            return unbundle(self.state),self.transition(self.state[0])
        return unbundle(self.state), None

SPINN

SPINN模块是所有小型组件的包装器类。 SPINN的初始化器与一样简单,包括组件模块ReduceTracker的初始化。 内部节点之间的所有繁重工作和协调都通过 SPINN 的forward调用进行管理。

class SPINN(nn.Module):

    def __init__(self, config):
        super().__init__()
        self.config = config
        assert config.d_hidden == config.d_proj / 2
        self.reduce = Reduce(config.d_hidden, config.d_tracker)
        self.tracker = Tracker(config.d_hidden, config.d_tracker,predict=config.predict)

forward调用的主要部分是对Trackerforward方法的调用,该方法将处于循环中。 我们遍历输入序列,并为转换序列中的每个单词调用Trackerforward方法,然后根据转换实例将输出保存到上下文向量列表中。 如果转换是shift,则栈将在后面附加当前单词;如果转换是reduce,则将调用Reduce并创建跟踪,并在最左边和最右边的单词, 这将从左侧和右侧列表中弹出。

def forward(self, buffers, transitions):
    buffers = [list(torch.split(b.squeeze(1), 1, 0))
               for b in torch.split(buffers, 1, 1)]
    stacks = [[buf[0], buf[0]] for buf in buffers]
    if hasattr(self, 'tracker'):
        self.tracker.reset_state()
    else:
        assert transitions is not None
    if transitions is not None:
        num_transitions = transitions.size(0)
    else:
        num_transitions = len(buffers[0]) * 2 - 3
    for i in range(num_transitions):
        if transitions is not None:
            trans = transitions[i]
        if hasattr(self, 'tracker'):
            tracker_states, trans_hyp = self.tracker(buffers,stacks)
            if trans_hyp is not None:
                trans = trans_hyp.max(1)[1]
        else:
            tracker_states = itertools.repeat(None)
        lefts, rights, trackings = [], [], []
        batch = zip(trans.data, buffers, stacks, tracker_states)
        for transition, buf, stack, tracking in batch:
            if transition == 3: # shift
                stack.append(buf.pop())
            elif transition == 2: # reduce
                rights.append(stack.pop())
                lefts.append(stack.pop())
                trackings.append(tracking)
        if rights:
            reduced = iter(self.reduce(lefts, rights, trackings))
            for transition, stack in zip(trans.data, stacks):
                if transition == 2:
                    stack.append(next(reduced))
    return bundle([stack.pop() for stack in stacks])[0]

总结

序列数据是深度学习中最活跃的研究领域之一,尤其是因为自然语言数据是顺序的。 但是,序列数据处理不仅限于此。 时间序列数据本质上是我们周围发生的一切,包括声音,其他波形等等,实际上都是顺序的。

处理序列数据中最困难的问题是长期依赖性,但是序列数据要复杂得多。 RNN 是序列数据处理领域的突破。 研究人员已经探索了成千上万种不同的 RNN 变体,并且它仍然是一个活跃的领域。

在本章中,我们介绍了序列数据处理的基本构建块。 尽管我们只使用英语,但是我们在这里学到的技术通常适用于任何类型的数据。 对于初学者来说,了解这些构建模块至关重要,因为随后的所有操作都基于它们。

即使我没有详细解释高级主题,本章中给出的解释也应该足以进入更高级的解释和教程。 存在不同的 RNN 组合,甚至存在 RNN 与 CNN 的组合以用于序列数据处理。 了解本书给出的概念将使您开始探索人们尝试过的不同方法。

在下一章中,我们将探索生成对抗网络,这是深度学习的最新巨大发展。

参考

  1. https://arxiv.org/pdf/1706.03762.pdf
  2. https://github.com/stanfordnlp/spinn
  3. 《LSTM:搜索空间漫游》,Greff,Klaus,Rupesh Kumar Srivastava,JanKoutník,Bas R.Steunebrink 和 JürgenSchmidhuber,IEEE Transactions on Neural Networks and Learning Systems,2017 年 12 月 28 日,第 2222-2232 页
  4. http://colah.github.io/posts/2015-08-Understanding-LSTMs/
  5. 《您所需要的是注意力》,Vaswani,Ashish,Noam Shazeer,Niki Parmar,Jakob Uszkoreit,Llion Jones,Aidan N. Gomez,Lukasz Kaiser 和 Illia Polosukhin,NIPS,2017 年

六、生成网络

生成网络得到了加州理工学院理工学院本科物理学教授理查德·费曼(Richard Feynman)和诺贝尔奖获得者的名言的支持:“我无法创造,就无法理解”。 生成网络是拥有可以理解世界并在其中存储知识的系统的最有前途的方法之一。 顾名思义,生成网络学习真实数据分布的模式,并尝试生成看起来像来自此真实数据分布的样本的新样本。

生成模型是无监督学习的子类别,因为它们通过尝试生成样本来学习基本模式。 他们通过推送低维潜向量和参数向量来了解生成图像所需的重要特征,从而实现了这一目的。 网络在生成图像时获得的知识本质上是关于系统和环境的知识。 从某种意义上说,我们通过要求网络做某事来欺骗网络,但是网络必须在不了解自己正在学习的情况下学习我们的需求。

生成网络已经在不同的深度学习领域,特别是在计算机视觉领域显示出了可喜的成果。 去模糊或提高图像的分辨率,图像修补以填充缺失的片段,对音频片段进行降噪,从文本生成语音,自动回复消息以及从文本生成图像/视频是一些研究的活跃领域。

在本章中,我们将讨论一些主要的生成网络架构。 更准确地说,我们将看到一个自回归模型和一个生成对抗网络GAN)。 首先,我们将了解这两种架构的基本组成部分是什么,以及它们之间的区别。 除此说明外,我们还将介绍一些示例和 PyTorch 代码。

定义方法

生成网络现今主要用于艺术应用中。 样式迁移,图像优化,去模糊,分辨率改善以及其他一些示例。 以下是计算机视觉中使用的生成模型的两个示例。

Defining the approaches

Defining the approaches

图 6.1:生成模型应用示例,例如超分辨率和图像修复

来源:《具有上下文注意的生成图像修复》,余佳辉等人;《使用生成对抗网络的照片级逼真的单图像超分辨率》,Christian Ledig 等人

GAN 的创建者 Ian Goodfellow 描述了几类生成网络:

Defining the approaches

图 6.2 生成网络的层次结构

我们将讨论这两个主要类别,它们在过去已经讨论过很多并且仍然是活跃的研究领域:

  • 自回归模型
  • GAN

自回归模型是从先前的值推断当前值的模型,正如我们在第 5 章,“序列数据处理”中使用 RNN 所讨论的那样。 变分自编码器VAE)是自编码器的一种变体,由编码器和解码器组成,其中编码器将输入编码为低维潜在空间向量, 解码器解码潜向量以生成类似于输入的输出。

整个研究界都同意,GAN 是人工智能世界中的下一个重要事物之一。 GAN 具有生成网络和对抗网络,并且两者相互竞争以生成高质量的输出图像。 GAN 和自回归模型都基于不同的原理工作,但是每种方法都有其自身的优缺点。 在本章中,我们将使用这两种方法开发一个基本示例。

自回归模型

自回归模型使用先前步骤中的信息并创建下一个输出。 RNN 为语言建模任务生成文本是自回归模型的典型示例。

Autoregressive models

图 6.3:用于 RNN 语言建模的自回归模型

自回归模型独立生成第一个输入,或者我们将其提供给网络。 例如,对于 RNN,我们将第一个单词提供给网络,而网络使用我们提供的第一个单词来假设第二个单词是什么。 然后,它使用第一个和第二个单词来预测第三个单词,依此类推。

尽管大多数生成任务都是在图像上完成的,但我们的自回归生成是在音频上。 我们将构建 WaveNet,它是 Google DeepMind 的研究成果,它是当前音频生成的最新实现,尤其是用于文本到语音处理。 通过这一过程,我们将探索什么是用于音频处理的 PyTorch API。 但是在查看 WaveNet 之前,我们需要实现 WaveNet 的基础模块 PixelCNN,它基于自回归卷积神经网络CNN)构建。

自回归模型已经被使用和探索了很多,因为每种流行的方法都有其自身的缺点。 自回归模型的主要缺点是它们的速度,因为它们顺序生成输出。 由于正向传播也是顺序的,因此在 PixelRNN 中情况变得更糟。

PixelCNN

PixelCNN

图 6.4:从 PixelCNN 生成的图像

资料来源:《使用 PixelCNN 解码器的条件图像生成》,Aäronvan den Oord 和其他人

PixelCNN 由 DeepMind 引入,并且是 DeepMind 引入的三种自回归模型之一。 在首次引入 PixelCNN 之后,已经进行了多次迭代以提高速度和效率,但是我们将学习基本的 PixelCNN,这是构建 WaveNet 所需要的。

PixelCNN 一次生成一个像素,并使用该像素生成下一个像素,然后使用前两个像素生成下一个像素。 在 PixelCNN 中,有一个概率密度模型,该模型可以学习所有图像的密度分布并从该分布生成图像。 但是在这里,我们试图通过采用所有先前预测的联合概率来限制在所有先前生成的像素上生成的每个像素。

与 PixelRNN 不同,PixelCNN 使用卷积层作为接收场,从而缩短了输入的读取时间。 考虑一下图像被某些东西遮挡了; 假设我们只有一半的图像。 因此,我们有一半的图像,并且我们的算法需要生成后半部分。 在 PixelRNN 中,网络需要像图像中的单词序列一样逐个获取每个像素,并生成一半的图像,而 PixelCNN 则通过卷积层一次获取图像。 但是,无论如何,PixelCNN 的生成都必须是顺序的。 您可能想知道只有一半的图像会进行卷积。 答案是遮罩卷积,我们将在后面解释。

“图 6.5”显示了如何对像素集应用卷积运算以预测中心像素。 与其他模型相比,自回归模型的主要优点是联合概率学习技术易于处理,可以使用梯度下降进行学习。 没有近似值,也没有解决方法。 我们只是尝试在给定所有先前像素值的情况下预测每个像素值,并且训练完全由反向传播支持。 但是,由于生成始终是顺序的,因此我们很难使用自回归模型来实现可伸缩性。 PixelCNN 是一个结构良好的模型,在生成新像素的同时,将各个概率的乘积作为所有先前像素的联合概率。 在 RNN 模型中,这是默认行为,但是 CNN 模型通过使用巧妙设计的遮罩来实现此目的,如前所述。

PixelCNN 捕获参数中像素之间的依存关系分布,这与其他方法不同。 VAE 通过生成隐藏的潜在向量来学习此分布,该向量引入了独立的假设。 在 PixelCNN 中,学习的依赖性不仅在先前的像素之间,而且在不同的通道之间; 在正常的彩色图像中,它是红色,绿色和蓝色(RGB)。

PixelCNN

图 6.5:从周围像素预测像素值

有一个基本问题:如果 CNN 尝试使用当前像素或将来的像素来学习当前像素怎么办? 这也由掩码管理,掩码将自身的粒度也提高到了通道级别。 例如,当前像素的红色通道不会从当前像素中学习,但会从先前的像素中学习。 但是绿色通道现在可以使用当前红色通道和所有先前的像素。 同样,蓝色通道可以从当前像素的绿色和红色通道以及所有先前的像素中学习。

整个网络中使用两种类型的掩码,但是后面的层不需要具有这种安全性,尽管它们在进行并行卷积操作时仍需要模拟顺序学习。 因此,PixelCNN 论文[1]引入了两种类型的蒙版:类型 A 和类型 B。

使 PixelCNN 与其他传统 CNN 模型脱颖而出的主要架构差异之一是缺少池化层。 由于 PixelCNN 的目的不是以缩小尺寸的形式捕获图像的本质,并且我们不能承担通过合并丢失上下文的风险,因此作者故意删除了合并层。

fm = 64

net = nn.Sequential(
    MaskedConv2d('A', 1, fm, 7, 1, 3, bias=False),
    nn.BatchNorm2d(fm), nn.ReLU(True),
    MaskedConv2d('B', fm, fm, 7, 1, 3, bias=False),
    nn.BatchNorm2d(fm), nn.ReLU(True),
    MaskedConv2d('B', fm, fm, 7, 1, 3, bias=False),
    nn.BatchNorm2d(fm), nn.ReLU(True),
    MaskedConv2d('B', fm, fm, 7, 1, 3, bias=False),
    nn.BatchNorm2d(fm), nn.ReLU(True),
    MaskedConv2d('B', fm, fm, 7, 1, 3, bias=False),
    nn.BatchNorm2d(fm), nn.ReLU(True),
    MaskedConv2d('B', fm, fm, 7, 1, 3, bias=False),
    nn.BatchNorm2d(fm), nn.ReLU(True),
    MaskedConv2d('B', fm, fm, 7, 1, 3, bias=False),
    nn.BatchNorm2d(fm), nn.ReLU(True),
    MaskedConv2d('B', fm, fm, 7, 1, 3, bias=False),
    nn.BatchNorm2d(fm), nn.ReLU(True),
    nn.Conv2d(fm, 256, 1))

前面的代码段是完整的 PixelCNN 模型,该模型包装在顺序单元中。 它由一堆MaskedConv2d实例组成,这些实例继承自torch.nn.Conv2d,并使用了torch.nnConv2d的所有*args**kwargs。 每个卷积单元之后是批量规范层和 ReLU 层,这是与卷积层成功组合的。 作者决定不在普通层上使用线性层,而是决定使用普通的二维卷积,事实证明,该方法比线性层更好。

遮罩卷积

PixelCNN 中使用了遮罩卷积,以防止在训练网络时信息从将来的像素和当前的像素流向生成任务。 这很重要,因为在生成像素时,我们无法访问将来的像素或当前像素。 但是,有一个例外,之前已描述过。 当前绿色通道值的生成可以使用红色通道的预测,而当前蓝色通道的生成可以使用绿色和红色通道的预测。

通过将所有不需要的像素清零来完成屏蔽。 将创建一个与张量相等的掩码张量,其值为 1 和 0,对于所有不必要的像素,其值为 0。 然后,在进行卷积运算之前,此掩码张量与权重张量相乘。

Masked convolution

图 6.6:左侧是遮罩,右侧是 PixelCNN 中的上下文

由于 PixelCNN 不使用池化层和反卷积层,因此随着流的进行,通道大小应保持恒定。 遮罩 A 专门负责阻止网络从当前像素学习值,而遮罩 B 将通道大小保持为三(RGB),并通过允许当前像素值取决于本身的值来允许网络具有更大的灵活性。

Masked convolution

图 6.7:遮罩 A 和遮罩 B

class MaskedConv2d(nn.Conv2d):
    def __init__(self, mask_type, *args, **kwargs):
        super(MaskedConv2d, self).__init__(*args, **kwargs)
        assert mask_type in ('A', 'B')
        self.register_buffer('mask', self.weight.data.clone())
        _, _, kH, kW = self.weight.size()
        self.mask.fill_(1)
        self.mask[:, :, kH // 2, kW // 2 + (mask_type == 'B'):] = 0
        self.mask[:, :, kH // 2 + 1:] = 0

    def forward(self, x):
        self.weight.data *= self.mask
        return super(MaskedConv2d, self).forward(x)

先前的类MaskedConv2dtorch.nn.Conv2d继承,而不是从torch.nn.Module继承。 即使我们从torch.nn.Module继承来正常创建自定义模型类,但由于我们试图使Conv2d增强带掩码的操作,我们还是从torch.nn.Conv2D继承,而torch.nn.Conv2D则从torch.nn.Conv2D继承 torch.nn.Module。 类方法register_buffer是 PyTorch 提供的方便的 API 之一,可以将任何张量添加到state_dict字典对象,如果尝试将模型保存到磁盘,则该对象随模型一起保存到磁盘。

添加有状态变量(然后可以在forward函数中重用)的明显方法是将其添加为对象属性:

self.mask = self.weight.data.clone()

但这绝不会成为state_dict的一部分,也永远不会保存到磁盘。 使用register_buffer,我们可以确保我们创建的新张量将成为state_dict的一部分。 然后使用原地fill_操作将掩码张量填充为 1s,然后向其添加 0 以得到类似于“图 6.6”的张量,尽管该图仅显示了二维张量, 实际权重张量是三维的。 forward函数仅用于通过乘以遮罩张量来遮罩权重张量。 乘法将保留与掩码具有 1 的索引对应的所有值,同时删除与掩码具有 0 的索引对应的所有值。然后,对父级Conv2d层的常规调用使用权重张量,并执行二维卷积操作。

网络的最后一层是 softmax 层,该层可预测像素的 256 个可能值中的值,从而离散化网络的输出生成,而先前使用的最先进的自回归模型将在网络的最后一层上继续生成值。

optimizer = optim.Adam(net.parameters())
for epoch in range(25):
    net.train(True)
    for input, _ in tr:
        target = (input[:,0] * 255).long()
        out = net(input)
        loss = F.cross_entropy(out, target)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

训练使用具有默认动量速率的Adam优化器。 另外,损失函数是从 PyTorch 的Functional模块创建的。 除了创建target变量以外,其他所有操作均与常规训练操作相同。

到目前为止,我们一直在有监督的学习中工作,其中明确给出了标签,但是在这种情况下,目标与输入相同,因为我们试图重新创建相同的输出。 torchvision包对像素应用了转换和归一化,并将像素值范围从 0 到 255 转换为 -1 到 1。我们需要转换回 0 到 255 的范围,因为我们在最后一层使用了 softmax,并且会在 0 到 255 之间生成概率分布。

门控 PixelCNN

DeepMind 在 PixelCNN 的一篇迭代论文中成功地使用了门控 PixelCNN ,该方法通过用 Sigmoid 和 tanh 构建的门代替 ReLU 激活函数。 PixelCNN [1]的介绍性论文提供了三种用于解决同一代网络的不同方法,其中具有 RNN 的模型优于其他两种。 DeepMind 仍引入了基于 CNN 的模型来显示与 PixelRNN 相比的速度增益。 但是,随着 PixelCNN 中门控激活的引入,作者能够将表现与 RNN 变体相匹配,从而获得更大的表现增益。 同一篇论文介绍了一种避免盲点并在生成时增加全局和局部条件的机制,这超出了本书的范围,因为对于 WaveNet 模型而言这不是必需的。

WaveNet

DeepMind 在另一篇针对其自回归生成网络的迭代论文[2]中引入了 WaveNet,其中包括 PixelCNN。 实际上,WaveNet 架构是基于 PixelCNN 的构建的,与 PixelRNN 相比,WaveNet 架构使网络能够以相对更快的方式生成输出。 借助 WaveNet,我们在书中首次探索了针对音频信号的神经网络实现。 我们对音频信号使用一维卷积,这与 PixelCNN 的二维卷积不同,对于初学者而言,这是相当复杂的。

WaveNet 取代了对音频信号使用傅里叶变换的传统方法。 它通过使神经网络找出要执行的转换来做到这一点。 因此,转换可以反向传播,原始音频数据可以使用一些技术来处理,例如膨胀卷积,8 位量化等。 但是人们一直在研究将 WaveNet 方法与传统方法相结合,尽管该方法将损失函数转换为多元回归,而不是 WaveNet 使用的分类。

PyTorch 向后公开了此类传统方法的 API。 以下是对傅立叶变换的结果进行快速傅立叶变换和傅立叶逆变换以获取实际输入的示例。 两种操作都在二维张量上,最后一个维为 2,表示复数的实部和虚部。

PyTorch 提供了用于快速傅里叶变换(torch.fft),快速傅里叶逆变换(torch.ifft),实数到复杂傅里叶变换(torch.rfft),实数到复杂傅里叶变换(torch.irfft)的 API。 ),短时傅立叶变换(torch.stft)和几个窗口函数,例如 Hann 窗口,Hamming 窗口和 Bartlett 窗口。

>>> x = torch.ones(3,2)
>>> x

 1 1
 1 1
 1 1
[torch.FloatTensor of size (3,2)]

>>> torch.fft(x, 1)

 3 3
 0 0
 0 0
[torch.FloatTensor of size (3,2)]

>>> fft_x = torch.fft(x, 1)
>>> torch.ifft(fft_x, 1)

 1 1
 1 1
 1 1
[torch.FloatTensor of size (3,2)]

WaveNet 并不是第一个引入序列数据卷积网络或扩张的卷积网络以加快操作速度的架构。 但是 WaveNet 成功地将两者结合使用,从而产生了可区分的音频。 第一波 WaveNet 的作者发布了另一篇迭代论文,该论文极大地加速了的产生,称为并行 WaveNet。 但是,在本章中,我们将重点关注普通的 WaveNet,这在很大程度上受到了戈尔宾的资料库的启发[3]。

WaveNet 的基本构件是膨胀卷积,它取代了 RNN 的功能来获取上下文信息。

WaveNet

图 6.8:没有卷积卷积的 WaveNet 架构

来源: 《WaveNet:原始音频的生成模型》,Aaron van den Oord 等

“图 6.8”显示了 WaveNet 在进行新值预测时如何提取有关上下文的信息。 输入以蓝色(图片的底部)给出,它是原始音频样本。 例如,一个 16 kHz 的音频样本具有一秒钟音频的 16,000 个数据点,如果与自然语言的序列长度(每个单词将是一个数据点)相比,这是巨大的。 这些长序列是为什么 RNN 对原始音频样本不太有效的一个很好的原因。

LSTM 网络可以记住上下文信息的实际序列长度为 50 到 100。上图具有三个隐藏层,这些隐藏层使用来自上一层的信息。 第一层输入经过一维卷积层以生成第二层的数据。 卷积可以并行完成,这与 RNN 的情况不同,在卷积中,每个数据点都需要先前的输入顺序地传递。 为了使收集更多上下文,我们可以增加层数。 在“图 6.8”中,位于第四层的输出将从输入层中的五个节点获取上下文信息。 因此,每一层将另外一个输入节点添加到上下文中。 也就是说,如果我们有 10 个隐藏层,则最后一层将从 12 个输入节点获取上下文信息。

WaveNet

图 6.9:膨胀卷积

来源: 《WaveNet:原始音频的生成模型》,Aaron van den Oord 等

到目前为止,应该很明显,要达到 LSTM 网络的上下文保持能力为 50 到 100 的实际限制,该网络需要 98 层,这在计算上是昂贵的。 这是我们使用膨胀卷积的地方。 使用膨胀卷积,我们将为每个层都有一个膨胀因子,并且以指数方式增加该膨胀因子将以对数形式减少任何特定上下文窗口宽度所需的层数。

WaveNet

图 6.10:膨胀为 0、2 和 4 的卷积

资料来源:通过扩散卷积进行的多尺度上下文聚合,Fisher Yu 和 Vladlen Koltun

“图 6.9”显示了 WaveNet 中使用的膨胀卷积方案(尽管为了更好地理解膨胀卷积,我们在这里使用的是二维图片; WaveNet 使用一维卷积)。 尽管该实现方案跳过了中参数的日志,但最终节点仍然可以通过这种巧妙设计的方案从上下文中的所有节点获取信息。 在具有扩张卷积和三个隐藏层的情况下,先前的实现覆盖了 16 个输入节点,而先前没有扩张卷积的实现仅覆盖了五个输入节点。

dilatedcausalconv = torch.nn.Conv1d(
									res_channels,
									res_channels,
									kernel_size=2,
									dilation=dilation,
									padding=0,
									bias=False)

可以用“图 6.10”中给出的二维图片直观地解释膨胀卷积的实现。 所有这三个示例均使用大小为 3x3 的核,其中最左边的块显示的是正常卷积或膨胀卷积,其膨胀因子等于零。 中间块具有相同的核,但膨胀因子为 2,最后一个块的膨胀因子为 4。 扩张卷积的实现技巧是在核之间添加零以扩展核的大小,如图“图 6.11”所示:

WaveNet

图 6.11:带有核扩展的膨胀卷积

PyTorch 通过使用户能够将膨胀作为关键字参数传递,从而使进行膨胀卷积变得容易,如先前代码块中的DilatedCausalConv1d节点中所给出的。 如前所述,每一层具有不同的扩张因子,并且可以为每一层的扩张卷积节点创建传递该因子。 由于跨步为 1,所以填充保持为 0,目的不是上采样或下采样。 init_weights_for_test是通过将权重矩阵填充 1 来进行测试的便捷函数。

PyTorch 提供的灵活性使用户可以在线调整参数,这对于调试网络更加有用。 forward传递仅调用 PyTorch conv1d对象,该对象是可调用的并保存在self.conv变量中:

causalconv = torch.nn.Conv1d(
							in_channels,
							res_channels,
							kernel_size=2,
							padding=1,
							bias=False)

WaveNet 的完整架构建立在膨胀卷积网络和卷积后门控激活的基础之上。 WaveNet 中的数据流从因果卷积运算开始,这是一种正常的一维卷积,然后传递到膨胀的卷积节点。 WaveNet 图片中的每个白色圆圈(“图 6.9”)是一个扩展的卷积节点。 然后,将正常卷积的数据点传递到膨胀的卷积节点,然后将其独立地通过 Sigmoid 门和 tanh 激活。 然后,两个运算的输出通过逐点乘法运算符和1x1卷积进行。 WaveNet 使用剩余连接和跳跃连接来平滑数据流。 与主流程并行运行的剩余线程通过加法运算与1x1卷积的输出合并。

WaveNet

图 6.12:WaveNet 架构

来源: 《WaveNet:原始音频的生成模型》,Aaron van den Oord 等

“图 6.12”中提供的 WaveNet 的结构图显示了所有这些小组件以及它们如何连接在一起。 跳跃连接之后的部分在程序中称为密集层,尽管它不是上一章介绍的密集层。 通常,密集层表示全连接层,以将非线性引入网络并获得所有数据的概览。 但是 WaveNet 的作者发现,正常的密集层可以由一串 ReLU 代替,并且1x1卷积可以通过最后的 softmax 层实现更高的精度,该层可以展开为 256 个单元(巨大扇出的 8 位µ律量化) 音频)。

class WaveNetModule(torch.nn.Module):
    def __init__(self, layer_size, stack_size,
                        in_channels, res_channels):
        super().__init__()
        self.causal = CausalConv1d(in_channels, res_channels)
        self.res_stack = ResidualStack(layer_size,
                                        stack_size,
                                        res_channels,
                                        in_channels)
        self.convdensnet = ConvDensNet(in_channels)

    def forward(self, x):
        output = self.causal(output)
        skip_connections = self.res_stack(output, output_size)
        output = torch.sum(skip_connections, dim=0)
        output = self.convdensnet(output)
        return output.contiguous()

前面的代码块中给出的程序是主要的父 WaveNet 模块,该模块使用所有子组件来创建图。 init定义了三个主要成分,其中是第一个普通卷积,然后是res_stack(它是由所有膨胀卷积和 Sigmoid 正切门组成的残差连接块)。 然后,最后的convdensnet1x1卷积的顶部进行。 forward引入一个求和节点,依次执行这些模块。 然后,将convdensnet创建的输出通过contiguous()移动到存储器的单个块。 这是其余网络所必需的。

ResidualStack是需要更多说明的模块,它是 WaveNet 架构的核心。 ResidualStackResidualBlock的层的栈。 WaveNet 图片中的每个小圆圈都是一个残差块。 在正常卷积之后,数据到达ResidualBlock,如前所述。 ResidualBlock从膨胀的卷积开始,并且期望得到膨胀。 因此,ResidualBlock决定了架构中每个小圆节点的膨胀因子。 如前所述,膨胀卷积的输出然后通过类似于我们在 PixelCNN 中看到的门的门。

在那之后,它必须经历两个单独的卷积以进行跳跃连接和残差连接。 尽管作者并未将其解释为两个单独的卷积,但使用两个单独的卷积更容易理解。

class ResidualBlock(torch.nn.Module):
    def __init__(self, res_channels, skip_channels, dilation=1):
super().__init__()
        self.dilatedcausalconv = torch.nn.Conv1d(
           res_channels, res_channels, kernel_size=2,
dilation=dilation,
           padding=0, bias=False)
self.conv_res = torch.nn.Conv1d(res_channels, res_channels, 1)
self.conv_skip = torch.nn.Conv1d(res_channels, skip_channels, 1)
self.gate_tanh = torch.nn.Tanh()
self.gate_sigmoid = torch.nn.Sigmoid()
def forward(self, x, skip_size):
    x = self.dilatedcausalconv(x)
    # PixelCNN Gate
    # ---------------------------
    gated_tanh = self.gate_tanh(x)
    gated_sigmoid = self.gate_sigmoid(x)
    gated = gated_tanh * gated_sigmoid
    # ---------------------------
    x = self.conv_res(gated)
    x += x[:, :, -x.size(2):]
    skip = self.conv_skip(gated)[:, :, -skip_size:]
    return x, skip

ResidualStack使用层数和栈数来创建膨胀因子。 通常,每个层具有2 ^ l作为膨胀因子,其中l是层数。 从12 ^ l开始,每个栈都具有相同数量的层和相同样式的膨胀因子列表。

方法stack_res_block使用我们前面介绍的ResidualBlock为每个栈和每个层中的每个节点创建一个残差块。 该程序引入了一个新的 PyTorch API,称为torch.nn.DataParallel。 如果有多个 GPU,则DataParallel API 会引入​​并行性。 将模型制作为数据并行模型可以使 PyTorch 知道用户可以使用更多 GPU,并且 PyTorch 从那里获取了它,而没有给用户带来任何障碍。 PyTorch 将数据划分为尽可能多的 GPU,并在每个 GPU 中并行执行模型。

它还负责从每个 GPU 收集回结果,并将其合并在一起,然后再继续进行。

class ResidualStack(torch.nn.Module):
 def __init__(self, layer_size, stack_size, res_channels,
skip_channels):
   super().__init__()
   self.res_blocks = torch.nn.ModuleList()
   for s in range(stack_size):
      for l in range(layer_size):
         dilation = 2 ** l
		 block = ResidualBlock(res_channels, skip_channels,
				 dilation)
         self.res_blocks.append(block)
  def forward(self, x, skip_size):
      skip_connections = []
      for res_block in self.res_blocks:
          x, skip = res_block(x, skip_size)
          skip_connections.append(skip)
return torch.stack(skip_connections)

GAN

在许多深度学习研究人员看来,GAN 是过去十年的主要发明之一。 它们在本质上不同于其他生成网络,尤其是在训练方式上。 Ian Goodfellow 撰写的第一篇有关对抗网络生成数据的论文于 2014 年发表。 GAN 被认为是一种无监督学习算法,其中有监督学习算法学习使用标记数据y来推理函数y' = f(x)

这种监督学习算法本质上是判别式的,这意味着它学会对条件概率分布函数进行建模,在此条件函数中,它说明了某事物的概率被赋予了另一事物的状态。 例如,如果购买房屋的价格为 100,000 美元,那么房屋位置的概率是多少? GAN 从随机分布生成输出,因此随机输入的变化使输出不同。

GAN 从随机分布中获取样本,然后由网络将其转换为输出。 GAN 在学习输入分布的模式时不受监督,并且与其他生成网络不同,GAN 不会尝试明确地学习密度分布。 相反,他们使用博弈论方法来找到两个参与者之间的纳什均衡。 GAN 实现将始终拥有一个生成网络和一个对抗网络,这被视为两个试图击败的参与者。 GAN 的核心思想在于从统一或高斯等数据分布中采样,然后让网络将采样转换为真正的数据分布样。 我们将实现一个简单的 GAN,以了解 GAN 的工作原理,然后转向名为 CycleGAN 的高级 GAN 实现。

简单的 GAN

了解 GAN 的直观方法是从博弈论的角度了解它。 简而言之,GAN 由两个参与者组成,一个生成器和一个判别器,每一个都试图击败对方。 生成器从分布中获取一些随机噪声,并尝试从中生成一些输出分布。 生成器总是尝试创建与真实分布没有区别的分布; 也就是说,伪造的输出应该看起来像是真实的图像。

Simple GAN

Figure 6.13: GAN architecture

但是,如果没有明确的训练或标签,生成器将无法确定真实图像的外观,并且其唯一的来源就是随机浮点数的张量。 然后,GAN 将第二个玩家介绍给游戏,这是一个判别器。 判别器仅负责通知生成器生成的输出看起来不像真实图像,以便生成器更改其生成图像的方式以使判别器确信它是真实图像。 但是判别器总是可以告诉生成器图像不是真实的,因为判别器知道图像是从生成器生成的。 这就是事情变得有趣的地方。 GAN 将真实,真实的图像引入游戏中,并将判别器与生成器隔离。 现在,判别器从一组真实图像中获取一个图像,并从生成器中获取一个伪图像,并且判别器必须找出每个图像的来源。 最初,判别器什么都不知道,只能预测随机结果。

class DiscriminatorNet(torch.nn.Module):
    """
    A three hidden-layer discriminative neural network
    """
    def __init__(self):
        super().__init__()
        n_features = 784
        n_out = 1

        self.hidden0 = nn.Sequential(
            nn.Linear(n_features, 1024),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3)
        )
        self.hidden1 = nn.Sequential(
            nn.Linear(1024, 512),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3)
        )
        self.hidden2 = nn.Sequential(
            nn.Linear(512, 256),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3)
        )
        self.out = nn.Sequential(
            torch.nn.Linear(256, n_out),
            torch.nn.Sigmoid()
        )

    def forward(self, x):
        x = self.hidden0(x)
        x = self.hidden1(x)
        x = self.hidden2(x)
        x = self.out(x)
        return x

但是,可以将辨别器的任务修改为分类任务。 判别器可以将输入图像分类为原始生成的,这是二分类。 同样,我们训练判别器网络正确地对图像进行分类,最终,通过反向传播,判别器学会了区分真实图像和生成的图像。

该会话中使用的示例将生成类似 MNIST 的输出。 前面的代码显示了 MNIST 上的鉴别播放器,该播放器总是从真实源数据集或生成器中获取图像。 GAN 众所周知非常不稳定,因此使用LeakyReLU是研究人员发现比常规ReLU更好工作的黑客之一。 现在,LeakyReLU通过它泄漏了负极,而不是将所有内容限制为零到零。 与正常的ReLU相比,这有助于使梯度更好地流过网络,对于小于零的值,梯度为零。

Simple GAN

图 6.14:ReLU 和泄漏的 ReLU

我们开发的的简单判别器具有三个连续层。 每个层都有一个线性层,泄漏的 ReLU 和一个夹在中间的漏失层,然后是一个线性层和一个 Sigmoid 门。 通常,概率预测网络使用 softmax 层作为最后一层; 像这样的简单 GAN 最适合 Sigmoid 曲面。

def train_discriminator(optimizer, real_data, fake_data):
    optimizer.zero_grad()

    # 1.1 Train on Real Data
    prediction_real = discriminator(real_data)
    # Calculate error and backpropagate
    error_real = loss(prediction_real,real_data_target(real_data.size(0)))
    error_real.backward()

    # 1.2 Train on Fake Data
    prediction_fake = discriminator(fake_data)
    # Calculate error and backpropagate
    error_fake = loss(prediction_fake,fake_data_target(real_data.size(0)))
    error_fake.backward()

    # 1.3 Update weights with gradients
    optimizer.step()

    # Return error
    return error_real + error_fake, prediction_real, prediction_fake

在前面的代码块中定义的函数train_generator接受optimizer对象,伪数据和实数据,然后将它们传递给判别器。 函数fake_data_target(在下面的代码块中提供)创建一个零张量,该张量的大小与预测大小相同,其中预测是从判别器返回的值。 判别器的训练策略是使任何真实数据被归类为真实分布的概率最大化,并使任何数据点被归类为真实分布的概率最小化。 在实践中,使用了来自判别器或生成器的结果的日志,因为这会严重损害网络的分类错误。 然后在应用optimizer.step函数之前将误差反向传播,该函数将通过学习率以梯度更新权重。

接下来给出用于获得真实数据目标和伪数据目标的函数,这与前面讨论的最小化或最大化概率的概念基本一致。 实际数据生成器返回一个张量为 1s 的张量,该张量是我们作为输入传递的形状。 在训练生成器时,我们正在尝试通过生成图像来最大程度地提高其概率,该图像看起来应该是从真实数据分布中获取的。 这意味着判别器应将 1 预测为图像来自真实分布的置信度分数。

def real_data_target(size):
    '''
    Tensor containing ones, with shape = size
    '''
    return torch.ones(size, 1).to(device)

def fake_data_target(size):
    '''
    Tensor containing zeros, with shape = size
    '''
    return torch.zeros(size, 1).to(device)

因此,判别器的实现很容易实现,因为它本质上只是分类任务。 生成器网络将涉及所有卷积上采样/下采样,因此有点复杂。 但是对于当前示例,由于我们希望它尽可能简单,因此我们将在全连接网络而不是卷积网络上进行工作。

def noise(size):
    n = torch.randn(size, 100)
    return n.to(device)

可以定义一个噪声生成函数,该函数可以生成随机样本(事实证明,这种采样在高斯分布而非随机分布下是有效的,但为简单起见,此处使用随机分布)。 如果 CUDA 可用,我们会将随机产生的噪声从 CPU 内存传输到 GPU 内存,并返回张量,其输出大小为100。 因此,生成网络期望输入噪声的特征数量为 100,而我们知道 MNIST 数据集中有 784 个数据点(28x28)。

对于生成器,我们具有与判别器类似的结构,但是在最后一层具有 tanh 层,而不是 Sigmoid。 进行此更改是为了与我们对 MNIST 数据进行的归一化同步,以将其转换为 -1 到 1 的范围,以便判别器始终获得具有相同范围内数据点的数据集。 生成器中的三层中的每一层都将输入噪声上采样到 784 的输出大小,就像我们在判别器中下采样以进行分类一样。

class GeneratorNet(torch.nn.Module):
    """
    A three hidden-layer generative neural network
    """
    def __init__(self):
        super().__init__()
        n_features = 100
        n_out = 784

        self.hidden0 = nn.Sequential(
            nn.Linear(n_features, 256),
            nn.LeakyReLU(0.2)
        )
        self.hidden1 = nn.Sequential(
            nn.Linear(256, 512),
            nn.LeakyReLU(0.2)
        )
        self.hidden2 = nn.Sequential(
            nn.Linear(512, 1024),
            nn.LeakyReLU(0.2)
        )

        self.out = nn.Sequential(
            nn.Linear(1024, n_out),
            nn.Tanh()
        )

    def forward(self, x):
        x = self.hidden0(x)
        x = self.hidden1(x)
        x = self.hidden2(x)
        x = self.out(x)
        return x

生成器训练器函数比判别器训练器函数简单得多,因为它不需要从两个来源获取输入,也不必针对不同的目的进行训练,而判别器则必须最大化将真实图像分类为真实图像的可能性。 图像,并最小化将噪声图像分类为真实图像的可能性。 此函数仅接受伪图像数据和优化器,其中伪图像是生成器生成的图像。 生成器训练器函数代码可以在 GitHub 存储库中找到。

我们分别创建判别器和生成器网络的实例。 到目前为止,我们所有的网络实现都具有单个模型或单个神经网络,但第一次,我们有两个单独的网络在同一个数据集上工作,并具有不同的优化目标。 对于两个单独的网络,我们还需要创建两个单独的优化器。 从历史上看,Adam优化器最适合学习速度非常慢的 GAN。

两个网络都使用判别器的输出进行训练。 唯一的区别是,在训练判别器时,我们尝试使伪造图像被分类为真实图像的可能性最小,而在训练生成器时,我们试图使伪造图像被分类为真实图像的可能性最大。 由于它始终是试图预测 0 和 1 的二分类器,因此我们使用torch.nn中的BCELoss来尝试预测 0 或 1:

discriminator = DiscriminatorNet().to(device)
generator = GeneratorNet().to(device)
d_optimizer = optim.Adam(discriminator.parameters(), lr=0.0002)
g_optimizer = optim.Adam(generator.parameters(), lr=0.0002)
loss = nn.BCELoss()

接下来是简单 GAN 在不同周期生成的输出,该图显示了网络如何学会将输入随机分布映射到输出真实分布。

Simple GAN

图 6.15:100 个周期后的输出

Simple GAN

图 6.16:200 个周期后的输出

Simple GAN

图 6.17:300 个周期后的输出

CycleGAN

CycleGAN

图 6.18:实践中的 CycleGAN

资料来源:《使用周期一致的对抗性网络的不成对图像翻译》,朱俊彦等

CycleGAN 是 GAN 类型的智能变体之一。 在同一架构中,两个 GAN 之间巧妙设计的循环流可教导两个不同分布之间的映射。 先前的方法需要来自不同分布的成对图像,以便网络学习映射。 对于示例,如果目标是建立一个可以将黑白图像转换为彩色图像的网络,则数据集在训练集中需要将同一图像的黑白和彩色版本作为一对。 尽管很难,但在一定程度上这是可能的。 但是,如果要使冬天拍摄的图像看起来像夏天拍摄的图像,则训练集中的这对图像必须是在冬天和夏天拍摄的具有相同对象和相同帧的完全相同的图像。 这是完全不可能的,而那正是 CycleGAN 可以提供帮助的地方。

CycleGAN 学习每种分布的模式,并尝试将图像从一种分布映射到另一种分布。 “图 6.19”中给出了 CycleGAN 的简单架构图。 上面的图显示了如何训练一个 GAN,下面的图显示了如何使用正在工作的 CycleGAN 典型示例:马和斑马来训练另一个。

在 CycleGAN 中,我们不是从分布中随机采样的数据开始,而是使用来自集合 A(在本例中为一组马)的真实图像。 委托生成器 A 到 B(我们称为 A2B)将同一匹马转换为斑马,但没有将成对的马匹转换为斑马的配对图像。 训练开始时,A2B 会生成无意义的图像。 判别器 B 从 A2B 生成的图像或从集合 B(斑马的集合)中获取真实图像。 与其他任何判别器一样,它负责预测图像是生成的还是真实的。 这个过程是正常的 GAN,它永远不能保证同一匹马转换为斑马。 而是将马的图像转换为斑马的任何图像,因为损失只是为了确保图像看起来像集合 B 的分布; 它不需要与集合 A 相关。为了强加这种相关性,CycleGAN 引入了循环。

然后,从 A2B 生成的图像会通过另一个生成器 B2A,以获得Cyclic_A。 施加到Cyclic_A的损失是 CycleGAN 的关键部分。 在这里,我们尝试减小Cyclic_AInput_A之间的距离。 第二个损失背后的想法是,第二个生成器必须能够生成马,因为我们开始时的分布是马。 如果 A2B 知道如何将马匹映射到斑马而不改变图片中的任何其他内容,并且如果 B2A 知道如何将斑马线映射到匹马而不改变图片中的其他任何东西,那么我们对损失所做的假设应该是正确的。

CycleGAN

图 6.19:CycleGAN 架构

当判别器 A 获得马的真实图像时,判别器 B 从 A2B 获得斑马的生成图像,当判别器 B 获得斑马的真实图像时,判别器 A 从 B2A 获得马的生成图像。 要注意的一点是,判别器 A 总是能够预测图像是否来自马具,而判别器 B 总是能够预测图像是否来自斑马具。 同样,A2B 始终负责将马集合映射到斑马分布,而 B2A 始终负责将斑马集合映射到马分布。

生成器和判别器的这种周期性训练可确保网络学会使用模式变化来映射图像,但图像的所有其他特征均保持不变。

Generator(
  (model): Sequential(
    (0): ReflectionPad2d((3, 3, 3, 3))
    (1): Conv2d(3, 64, kernel_size=(7, 7), stride=(1, 1))
    (2): InstanceNorm2d(64, eps=1e-05, momentum=0.1, affine=False,track_running_stats=False)
    (3): ReLU(inplace)
    (4): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2),padding=(1, 1))
    (5): InstanceNorm2d(128, eps=1e-05, momentum=0.1,affine=False, track_running_stats=False)
    (6): ReLU(inplace)
    (7): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2),padding=(1, 1))
    (8): InstanceNorm2d(256, eps=1e-05, momentum=0.1,affine=False, track_running_stats=False)
    (9): ReLU(inplace)
    (10): ResidualBlock()
    (11): ResidualBlock()
    (12): ResidualBlock()
    (13): ResidualBlock()
    (14): ResidualBlock()
    (15): ResidualBlock()
    (16): ResidualBlock()
    (17): ResidualBlock()
    (18): ResidualBlock()
    (19): ConvTranspose2d(256, 128, kernel_size=(3, 3), stride=(2,2), padding=(1, 1), output_padding=(1, 1))
    (20): InstanceNorm2d(128, eps=1e-05, momentum=0.1,affine=False, track_running_stats=False)
    (21): ReLU(inplace)
    (22): ConvTranspose2d(128, 64, kernel_size=(3, 3), stride=(2,2), padding=(1, 1), output_padding=(1, 1))
    (23): InstanceNorm2d(64, eps=1e-05, momentum=0.1,affine=False, track_running_stats=False)
    (24): ReLU(inplace)
    (25): ReflectionPad2d((3, 3, 3, 3))
    (26): Conv2d(64, 3, kernel_size=(7, 7), stride=(1, 1))
    (27): Tanh()
  )
)

PyTorch 为用户提供了进入网络并进行操作的完全灵活性。 其中一部分是将模型打印到终端上,以显示其中包含所有模块的地形排序图。

之前我们在 CycleGAN 中看到了生成器的图。 与我们探讨的第一个简单 GAN 不同,A2B 和 B2A 都具有相同的内部结构,内部具有卷积。 整个生成器都包装在以ReflectionPad2D开头的单个序列模块中。

反射填充涉及填充输入的边界,跳过批量尺寸和通道尺寸。 填充之后是典型的卷积模块布置,即二维卷积。

实例归一化分别对每个输出批量进行归一化,而不是像“批量归一化”中那样对整个集合进行归一化。 二维实例归一化确实在 4D 输入上实例化归一化,且批量尺寸和通道尺寸为第一维和第二维。 PyTorch 通过传递affine=True允许实例规范化层可训练。 参数track_running_stats决定是否存储训练循环的运行平均值和方差,以用于评估模式(例如归一化)。 默认情况下,它设置为False; 也就是说,它在训练和评估模式下都使用从输入中收集的统计信息。

下图给出了批量规范化和实例规范化的直观比较。 在图像中,数据表示为三维张量,其中C是通道,N是批量,D是其他维,为简单起见,在一个维中表示。 如图中所示,批量归一化对整个批量中的数据进行归一化,而实例归一化则在两个维度上对一个数据实例进行归一化,从而使批量之间的差异保持完整。

CycleGAN

图 6.20:

Source: Group Normalization, Yuxin Wu and Kaiming He

原始 CycleGAN 的生成器在三个卷积块之后使用九个残差块,其中每个卷积块由卷积层,归一化层和激活层组成。 残差块之后是几个转置卷积,然后是最后一层具有 tanh 函数的一个卷积层。 如简单 GAN 中所述,tanh 输出的范围是 -1 至 1,这是所有图像的归一化值范围。

残余块的内部是按顺序排列的另一组填充,卷积,归一化和激活单元。 但是forward方法与residueNet中的求和操作建立了残余连接。 在以下示例中,所有内部块的顺序包装都保存到变量conv_block中。 然后,将经过此块的数据与加法运算符一起输入到网络x。 此残留连接通过允许信息更容易地双向流动来帮助网络变得稳定:

class ResidualBlock(nn.Module):
    def __init__(self, in_features):
        super().__init__()

	conv_block = [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)]
	self.conv_block = nn.Sequential(*conv_block)
    def forward(self, x):
        return x + self.conv_block(x)

总结

在本章中,我们学习了一系列全新的神经网络,这些神经网络使人工智能世界发生了翻天覆地的变化。 生成网络对我们始终很重要,但是直到最近我们才能达到人类无法比拟的准确率。 尽管有一些成功的生成网络架构,但在本章中我们仅讨论了两个最受欢迎的网络。

生成网络使用 CNN 或 RNN 之类的基本架构作为整个网络的构建块,但是使用一些不错的技术来确保网络正在学习生成一些输出。 到目前为止,生成网络已在艺术中得到广泛使用,并且由于模型必须学习数据分布以生成输出,因此我们可以轻松地预测生成网络将成为许多复杂网络的基础。 生成网络最有前途的用途可能不是生成,而是通过生成学习数据分发并将该信息用于其他目的。

在下一章中,我们将研究最受关注的网络:强化学习算法。

参考

  1. 《使用 PixelCNN 解码器的条件图像生成》,Oord,Aäronvan den,Nal Kalchbrenner,Oriol Vinyals,Lasse Espeholt,Alex Graves 和 Koray Kavukcuoglu,NIPS,2016 年
  2. 《并行 WaveNet:快速高保真语音合成》,Oord,Aäronvan den,Yazhe Li,Igor Babuschkin,Karen Simonyan,Oriol Vinyals,Koray Kavukcuoglu,George van den Driessche,Edward Lockhart,Luis C. Cobo, Florian Stimberg,Norman Casagrande,Dominik Grewe,Seb Noury,Sander Dieleman,Erich Elsen,Nal Kalchbrenner,Heiga Zen,Alex Graves,Helen King,Tom Walters,Dan Belov 和 Demis Hassabis,ICML,2018
  3. 戈尔宾的 WaveNet 存储库

七、强化学习

让我们谈谈学习的本质。 我们不是天生就知道这个世界。 通过与世界互动,我们了解了行动的效果。 一旦我们了解了世界的运转方式,我们就可以利用这些知识来做出可以将我们引向特定目标的决策。

在本章中,我们将使用一种称为强化学习的方法来制定这种计算学习方法。 它与本书中介绍的其他类型的深度学习算法非常不同,并且本身就是一个广阔的领域。

强化学习的应用范围从在数字环境中玩游戏到在现实环境中控制机器人的动作。 它也恰好是您用来训练狗和其他动物的技术。 如今,强化学习已被用于驾驶自动驾驶汽车,这是一个非常受欢迎的领域。

当计算机(AlphaGo)击败世界围棋冠军 Lee Sedol [1]时,发生了最近的重大突破之一。 这是一个突破,因为围棋一直以来被认为是让计算机掌握很长时间的游戏圣杯。 这是因为据说围棋游戏中的配置数量大于我们宇宙中的原子数量。

在世界冠军输给 AlphaGo 之后,甚至有人说他已经从计算机中学到了一些东西。 这听起来很疯狂,但这是事实。 听起来更疯狂的是,算法的输入只不过是棋盘游戏当前状态的图像,而 AlphaGo 则一遍又一遍地对自己进行训练。 但在此之前,它从观看世界冠军的视频中学习了数小时。

如今,强化学习已被用于使机器人学习如何走路。 在这种情况下,输入将是机器人可以施加到其关节的力以及机器人将要行走的地面状态。 强化学习也被用于预测股价,并且在该领域引起了很多关注。

这些现实问题似乎非常复杂。 我们将需要对所有这些事情进行数学公式化,以便计算机可以解决它们。 为此,我们需要简化环境和决策过程以实现特定目标。

在强化学习的整个范式中,我们仅关注从交互中学习,而学习器或决策者则被视为智能体。 在自动驾驶汽车中,智能体是汽车,而在乒乓球中,智能体是球拍。 当智能体最初进入世界时,它将对世界一无所知。 智能体将必须观察其环境并根据其做出决策或采取行动。 它从环境中返回的响应称为奖励,可以是肯定的也可以是否定的。 最初,智能体将随机采取行动,直到获得正面奖励为止,并告诉他们这些决定可能对其有利。

这似乎很简单,因为智能体程序要做的就是考虑环境的当前状态进行决策,但是我们还想要更多。 通常,座席的目标是在其一生中最大化其累积奖励,重点是“累积”一词。 智能体不仅关心在下一步中获得的报酬,而且还关心将来可能获得的报酬。 这需要有远见,并将使智能体学习得更好。

这个元素使问题变得更加复杂,因为我们必须权衡两个因素:探索与利用。 探索将意味着做出随机决策并对其进行测试,而利用则意味着做出智能体已经知道的决策将给其带来积极的结果,因此智能体现在需要找到一种方法来平衡这两个因素以获得最大的累积结果。 。 这是强化学习中非常重要的概念。 这个概念催生了各种算法来平衡这两个因素,并且是一个广泛的研究领域。

在本章中,我们将使用 OpenAI 名为 Gym 的库。 这是一个开放源代码库,为强化学习算法的训练和基准测试设定了标准。 体育馆提供了许多研究人员用来训练强化学习算法的环境。 它包括许多 Atari 游戏,用于拾取物品的机器人仿真,用于步行和跑步的各种机器人仿真以及驾驶仿真。 该库提供了智能体程序和环境之间相互交互所必需的参数。

问题

现在,我们已经准备好用数学公式来表达强化学习问题,因此让我们开始吧。

The problem

图 7.1:强化学习框架

在上图中,您可以看到任何强化学习问题的设置。 通常,强化学习问题的特征在于,智能体试图学习有关其环境的信息,如前所述。

假设时间以不连续的时间步长演化,则在时间步长 0 处,智能体查看环境。 您可以将这种观察视为环境呈现给智能体的情况。 这也称为观察环境状态。 然后,智能体必须为该特定状态选择适当的操作。 接下来,环境根据智能体采取的行动向智能体提出了新的情况。 在同一时间步长中,环境会给智能体提供奖励,从而可以指示智能体是否做出了适当的响应。 然后该过程继续。 环境为坐席提供状态和奖励,然后坐席采取行动。

The problem

图 7.2:每个时间步骤都有一个状态,动作和奖励

因此,状态,动作和奖励的顺序现在随着时间而流动,在这个过程中,对智能体而言最重要的是其奖励。 话虽如此,智能体的目标是使累积奖励最大化。 换句话说,智能体需要制定一项策略,以帮助其采取使累积奖励最大化的行动。 这只能通过与环境交互来完成。

这是因为环境决定了对每个动作给予智能体多少奖励。 为了用数学公式表述,我们需要指定状态,动作和奖励,以及环境规则。

情景任务与连续任务

在现实世界中,我们指定的许多任务都有明确定义的终点。 例如,如果智能体正在玩游戏,则当智能体获胜或失败或死亡时,剧集或任务便会结束。

在无人驾驶汽车的情况下,任务在汽车到达目的地或撞车时结束。 这些具有明确终点的任务称为剧集任务。 智能体在每个剧集的结尾都会获得奖励,这是智能体决定自己在环境中做得如何的时候。 然后,智能体从头开始但继续拥有下一个剧集的先验信息,然后继续执行下一个剧集,因此效果更好。

随着时间的流逝,在一段剧集中,智能体将学会玩游戏或将汽车开到特定的目的地,因此将受到训练。 您会记得,智能体的目标是在剧集结束时最大限度地提高累积奖励。

但是,有些任务可能永远持续下去。 例如,在股票市场上交易股票的机器人没有明确的终点,必须在每个时间步骤中学习和提高自己。 这些任务称为连续任务。 因此,在那种情况下,奖励是在特定的时间间隔提供给业务代表的,但任务没有尽头,因此业务代表必须从环境中学习并同时进行预测。

在本章中,我们将只关注情景任务,但为连续任务制定问题陈述并不会有太大不同。

累积折扣奖励

为了使智能体最大化累积奖励,可以考虑的一种方法是在每个时间步长上最大化奖励。 这样做可能会产生负面影响,因为在初始时间步长中最大化回报可能会导致智能体在将来很快失败。 让我们以步行机器人为例。 假定机器人的速度是奖励的一个因素,如果机器人在每个时间步长上都最大化其速度,则可能会使其不稳定并使其更快落下。

我们正在训练机器人走路; 因此,我们可以得出结论,智能体不能仅仅专注于当前时间步长来最大化报酬。 它需要考虑所有时间步骤。 所有强化学习问题都会是这种情况。 动作可能具有短期或长期影响,智能体需要了解动作的复杂性以及环境带来的影响。

在前述情况下,如果智能体将了解到其移动速度不能超过某个可能会使它不稳定并对其产生长期影响的极限,则它将自行学习阈值速度。 因此,智能体将在每个时间步长处获得较低的报酬,但会避免将来跌倒,从而使累积报酬最大化。

假设在所有未来时间步长处的奖励都由RₜR[t + 1]R[t + 2]表示,依此类推:

Cumulative discounted rewards

由于这些时间步伐是在将来,智能体无法确定地知道将来的回报是什么。 它只能估计或预测它们。 未来奖励的总和也称为回报。 我们可以更明确地指定智能体的目标是使期望收益最大化。

让我们还考虑一下,未来回报中的所有回报并不那么重要。 为了说明这一点,假设您想训练一只狗。 您给它命令,如果它正确地遵循了它们,则给它一种奖赏。 您能期望狗像称重从现在起数年可能获得的奖励一样,来权衡明天可能获得的奖励吗? 这似乎不可行。

为了让狗决定现在需要采取什么行动,它需要更加重视可能早日获得的奖励,而不再重视可能会从现在开始获得的奖励。 这也被认为是合乎逻辑的,因为狗不确定未来的把握,特别是当狗仍在学习环境并改变其从环境中获得最大回报的策略时。 因为与未来成千上万步长的奖励相比,未来数个时间步长的奖励更可预测,所以折扣收益的概念应运而生。

Cumulative discounted rewards

可以看到,我们在Goal方程中引入了可变伽玛。 接近 1 的Gamma表示您将来对每个奖励的重视程度相同。 接近 0 的Gamma表示只有最近的奖励才具有很高的权重。

一个良好的做法是将Gamma = 0.9,因为您希望智能体对未来有足够的关注,但又不是无限远。 您可以在训练时设置Gamma,并且Gamma会保持固定,直到实验结束。 重要的是要注意,折扣在连续任务中非常有用,因为它们没有尽头。 但是,继续执行的任务不在本章范围之内。

马尔可夫决策过程

让我们通过学习称为马尔可夫决策过程MDP)的数学框架来完成对强化学习问题的定义。

MDP 定义有五件事:

  • 有限状态集
  • 有限动作集
  • 有限奖励集
  • 折扣率
  • 环境的单步动态

我们已经了解了如何指定状态,操作,奖励和折扣率。 让我们找出如何指定环境的一步式动态。

下图描述了垃圾收集机器人的 MDP。 机器人的目标是收集垃圾桶。 机器人将继续寻找垃圾桶,并不断收集垃圾桶,直到电池用完,然后再回到扩展坞为电池充电。 可以将机器人的状态定义为高和低,以表示其电池电量。 机器人可以执行的一组操作是搜索垃圾桶,在自己的位置等待,然后返回对接站为电池充电。

Markov decision processes

图 7.3:垃圾收集机器人的 MDP

例如,假设机器人处于高电量状态。 如果决定搜索垃圾桶,则状态保持高状态的概率为 70%,状态变为低状态的概率为 30%,每种状态获得的奖励为 4。

同样,如果电池处于高电量状态,则决定在其当前位置等待,电池处于高电量状态的可能性为 100%,但是获得的奖励也很低。

花一点时间浏览所有动作和状态,以更好地了解它们。 通过详细说明智能体可以处于的所有状态以及智能体在其所有状态下可以执行的所有操作,并确定每个操作的概率,可以指定环境。 一旦指定了所有这些,就可以指定环境的一站式动态。

在任何 MDP 中,智能体都会知道状态,操作和折扣率,而不会知道环境的回报和一步动态。

现在,您了解了制定任何实际问题(通过强化学习解决)的所有知识。

解决方案

既然我们已经学习了如何使用 MDP 来指定问题,那么智能体需要制定解决方案。 此策略也可以称为策略。

策略和值函数

策略定义学习智能体在给定时间的行为方式。 保单用希腊字母Pi表示。 该策略不能用公式定义; 它更多是基于直觉的概念。

让我们举个例子。 对于需要在房间外寻找出路的机器人,它可能具有以下策略:

  • 随机走
  • 沿着墙壁走
  • 找到通往门的最短路径

为了使我们能够数学地预测在特定状态下要采取的行动,我们需要一个函数。 让我们定义一个函数,该函数将设为当前状态,并输出一个数字,该数字表示该状态的值。例如,如果您要越过河流,那么靠近桥梁的位置的值将比远离目标位置更大。 此函数称为值函数,也用V表示。

我们可以使用另一个函数来帮助我们度量事物:一个函数,该函数为我们提供由所有可以采取的行动所导致的所有未来状态的值。

Policies and value functions

图 7.4:MDP 中的状态和动作

让我们举个例子。 让我们考虑通用状态S0。 现在我们需要预测在a1a2a3之间要采取什么行动才能获得最大的回报(累积折扣奖励)。 我们将此函数命名为Q。 我们的函数Q,将预测每个操作的预期收益(值(V))。 此Q函数也称为动作值函数,因为它考虑了状态和动作,并预测了它们各自的组合的预期收益。

我们通常会选择最大值。 因此,这些最高限额将指导智能体到最后,这将是我们的策略。 请注意,我大部分时间都在说。 通常,在选择非最大动作值对时,我们会保持很小的随机机会。 我们这样做是为了提高模型的可探索性。 该随机探索机会的百分比称为ε,该策略称为 ε 贪婪策略。 这是人们用来解决强化学习问题的最常见策略。 如果我们一直都只选择最大值,而不进行任何探索,则该策略简称为贪婪策略。 我们将在实现过程中同时使用这两种策略。

但是起初,我们可能不知道最佳作用值函数。 因此,由此产生的策略也将不是最佳策略。 我们将需要遍历动作值函数,并找到提供最佳回报的函数。 一旦找到它,我们将获得最优的Q。 最佳Q也称为Q*。 因此,我们将能够找到最优的Pi,也称为Pi*

Q函数是智能体必须学习的函数。 我们将使用神经网络来学习此函数,因为神经网络也是通用函数逼近器。 一旦有了行动值函数,座席就可以了解问题的最佳策略,我们就可以完成目标。

贝尔曼方程

如果我们使用最近定义的 Q 函数重新定义目标方程,则可以编写:

Bellman equation

现在让我们递归定义相同的方程式。 我们将提出贝尔曼方程:

Bellman equation

简而言之,Bellman 等式指出,每个点的收益等于下一时间步长的估计报酬加上随后状态的折扣报酬。 可以肯定地说,某些策略的任何值函数都遵循贝尔曼方程。

寻找最佳 Q 函数

现在我们知道,如果我们具有最优 Q 函数,则可以通过选择收益最高的操作来找到最优策略。

深度 Q 学习

深度 Q 学习算法使用神经网络来解决 Q 学习问题。 它对于连续空间的强化学习问题非常有效。 也就是说,任务不会结束。

前面我们讨论了值函数(V)和操作值函数(Q)。 由于神经网络是通用函数逼近器,因此我们可以假设它们中的任何一个都是神经网络,具有可以训练的权重。

因此,值函数现在将接受网络的状态和权重,并输出当前状态的值。 我们将需要计算某种误差并将其反向传播到网络,然后使用梯度下降进行训练。 我们需要将网络的输出(值函数)与我们认为最佳的值进行比较。

根据贝尔曼方程:

Deep Q-learning

我们可以通过考虑下一个状态的值来计算预期的Q。 我们可以通过考虑到目前为止的累积奖励来计算当前的Q。 在这些 Q 函数之间的差上使用均方误差MSE)可能是我们的损失。 研究人员建议的一项改进是,当误差较大时,使用平均绝对误差代替 MSE。 当 Q 函数的估计值非常嘈杂时,这使它对异常值更加健壮。 这种损失称为胡贝尔损失。

Deep Q-learning

我们的代码的训练循环如下所示:

  • 随机初始化w, π <- ε
  • 对于所有剧集:
    • 观察S
    • 虽然S并非在每个时间步都是终端:
    • 使用π, QS中选择A
    • 观察RS'
    • 更新Q
    • S <- S'

这里要注意的一件事是,我们将使用相同的 ε 贪婪策略在“步骤 6”中选择动作,并在“步骤 8”中更新相同的策略。 这种算法称为策略上算法。 从某种意义上讲,这是很好的,因为在我们观察和更新同一策略时,将更快地学习该策略。 它收敛非常快。 它也有一些缺点,即所学习的策略和用于决策的策略彼此紧密地联系在一起。 如果我们想要一个更具探索性的策略,以便在“步骤 6”中选择观察结果,并在“步骤 8”中更新更优化的策略,该怎么办? 这样的算法被称为非策略算法。

Q 学习是一种非策略算法,因此,在 Q 学习中,我们将有两个策略。 我们用来推断动作的策略将是 ε 贪婪策略,并且我们将其称为策略网络。 我们将使用更新步骤更新的网络将是我们的目标网络。 那只能由一个贪婪的策略来控制,这意味着我们将始终选择ε等于零的最大值。 我们不会对此策略采取随机措施。 我们这样做是为了使我们更快地朝着更高的值前进。 我们将通过不时复制策略网的权重(例如每隔一集一次)来更新目标网的权重。

其背后的想法是不追逐一个移动的目标。 让我们举个例子:假设您想训练一头驴走路。 如果您坐在驴上并在其嘴前悬挂胡萝卜,驴可能会向前走,胡萝卜仍与驴保持相同的距离。 但是,与普遍的看法相反,这并不那么有效。 胡萝卜可能会随机反弹,并可能使驴远离其路径。 取而代之的是,通过从驴上下来并站在要驴来的地方使驴和胡萝卜脱钩,这似乎是一个更好的选择。 它提供了一个更稳定的学习环境。

经验回放

我们可以对算法进行的另一项改进是添加有限的经验和已保存交易记录。 每笔交易都包含学习某些东西所需的所有相关信息。 它是状态,执行的动作,随后的下一个状态以及对该动作给予的奖励的元组。

Transition = namedtuple('Transition', ('state', 'action', 'next_state', 'reward'))

我们将随机采样一些经验或交易,并在优化模型时向他们学习。

class ReplayMemory(object):
    def __init__(self, capacity):
        self.capacity = capacity
        self.memory = []
        self.position = 0

    def push(self, *args):
        if len(self.memory) < self.capacity:
            self.memory.append(None)
            self.memory[self.position] = Transition(*args)
            self.position = (self.position + 1) % self.capacity

    def sample(self, batch_size):
        return random.sample(self.memory, batch_size)

    def __len__(self):
        return len(self.memory)

memory = ReplayMemory(10000)

在这里,我们为交易定义了一个存储库。 有一个称为push的函数可将事务推送到内存中。 还有另一个函数可以从内存中随机采样。

Gym

我们将使用 OpenAI 的 Gym 从环境env中获取参数。 环境变量很多,例如智能体的速度和位置。 我们将训练一个平衡点来平衡自己。

Gym

图 7.5:卡特彼勒平衡环境

Gym

图 7.6:Gym 暴露的环境变量

在环境中的每个观察值或状态在 Cartpole 环境(env)中都有四个值。 上面的屏幕快照来自于 Cartpole 环境的 Gym 代码。 每个观测值在尖端都有位置,速度,极角和极速度。 您可以采取的行动是向左或向右移动。

env = gym.make('CartPole-v0').unwrapped
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

screen_width = 600

def get_screen():
    screen = env.render(mode='rgb_array').transpose((2, 0, 1))  # transpose into torch order (CHW)
    screen = screen[:, 160:320]  # Strip off the top and bottom of the screen

    # Get cart location
    world_width = env.x_threshold * 2
    scale = screen_width / world_width
    cart_location = int(env.state[0] * scale + screen_width / 2.0)  # MIDDLE OF CART

    # Decide how much to strip
    view_width = 320
    if cart_location < view_width // 2:
        slice_range = slice(view_width)
    elif cart_location > (screen_width - view_width // 2):
        slice_range = slice(-view_width, None)
    else:
        slice_range = slice(cart_location - view_width // 2,
                            cart_location + view_width // 2)

    # Strip off the edges, so that we have a square image centered on a cart
    screen = screen[:, :, slice_range]

    screen = np.ascontiguousarray(screen, dtype=np.float32) / 255
    screen = torch.from_numpy(screen)
    resize = T.Compose([T.ToPILImage(),
                        T.Resize(40, interpolation=Image.CUBIC),
                        T.ToTensor()])

    return resize(screen).unsqueeze(0).to(device)  # Resize, and add a batch dimension (BCHW)

在这里,我们定义了get_screen函数。 柱状环境渲染并返回一个屏幕(3D 像素数组)。 我们将要剪裁一个正方形的图像,其中心是小刀。 我们从env.state[0]获得了位置。 根据文档,第一个参数是推车位置。 然后我们去掉顶部,底部,左侧和右侧,以使小柱位于中心。 接下来,我们将其转换为张量,进行一些转换,添加另一个尺寸,然后返回图像。

class DQN(nn.Module):
    def __init__(self):
        super(DQN, self).__init__()
        self.conv1 = nn.Conv2d(3, 16, kernel_size=5, stride=2)
        self.bn1 = nn.BatchNorm2d(16)
        self.conv2 = nn.Conv2d(16, 32, kernel_size=5, stride=2)
        self.bn2 = nn.BatchNorm2d(32)
        self.conv3 = nn.Conv2d(32, 32, kernel_size=5, stride=2)
        self.bn3 = nn.BatchNorm2d(32)
        self.head = nn.Linear(448, 2)

    def forward(self, x):
        x = F.relu(self.bn1(self.conv1(x)))
        x = F.relu(self.bn2(self.conv2(x)))
        x = F.relu(self.bn3(self.conv3(x)))
        return self.head(x.view(x.size(0), -1))

policy_net = DQN().to(device)
target_net = DQN().to(device)
target_net.load_state_dict(policy_net.state_dict())
target_net.eval()

接下来,我们定义我们的网络。 网络采用当前状态,对其进行一些卷积运算,最后收敛到线性层,并给出当前状态值的输出,和表示在该状态下有多大好处的值。

我们定义了两个网络policy_nettarget_net。 我们将policy_net的权重复制到target_net,以便它们代表相同的网络。 我们将target_net设为评估模式,以便在反向传播时不更新网络的权重。 我们将在每个步骤中推断policy_net,但会不时更新target_net

EPS_START = 0.9
EPS_END = 0.05
EPS_DECAY = 200
steps_done = 0

def select_action(state):
    global steps_done
    eps_threshold = EPS_END + (EPS_START - EPS_END) * \
        math.exp(-1\. * steps_done / EPS_DECAY)
    steps_done += 1

    sample = random.random()
    if sample > eps_threshold:

        # freeze the network and get predictions
        with torch.no_grad():
            return policy_net(state).max(1)[1].view(1, 1)

    else:

        # select random action
        return torch.tensor([[random.randrange(2)]], device=device, dtype=torch.long)

接下来,我们定义一种使用 ε 贪婪策略为我们采取行动的方法。 我们可以从策略网中推断出一定时间百分比,但是也有eps_threshold的机会,这意味着我们将随机选择操作。

num_episodes = 20
TARGET_UPDATE = 5

for i_episode in range(num_episodes):
    env.reset()
    last_screen = get_screen()
    current_screen = get_screen()
    state = current_screen - last_screen

    for t in count():  # for each timestep in an episode
        # Select action for the given state and get rewards
        action = select_action(state)
        _, reward, done, _ = env.step(action.item())
        reward = torch.tensor([reward], device=device)

        # Observe new state
        last_screen = current_screen
        current_screen = get_screen()
        if not done:
            next_state = current_screen - last_screen
        else:
            next_state = None

        # Store the transition in memory
        memory.push(state, action, next_state, reward)

        # Move to the next state
        state = next_state

        # Perform one step of the optimization (on the target network)
        optimize_model()
        if done:
            break

    # Update the target network every TARGET_UPDATE episodes
    if i_episode % TARGET_UPDATE == 0:
        target_net.load_state_dict(policy_net.state_dict())

env.close()

让我们看看我们的训练循环。 对于每个剧集,我们都会重置环境。 我们从环境中获得了两个屏幕,将当前状态定义为两个屏幕之间的差异。 然后,对于剧集中的每个时间步,我们使用select_action函数选择一个动作。 我们要求环境采取该行动,并将奖励和done标志归还(它告诉我们剧集是否结束,也就是卡塔普尔跌倒了)。 我们观察到已经提出的新状态。 然后,我们将刚刚经历的事务推入存储体,并移至下一个状态。 下一步是优化模型。 我们将很快介绍该函数。

我们还将每五集使用policy_net权重的副本更新target_net

BATCH_SIZE = 64
GAMMA = 0.999
optimizer = optim.RMSprop(policy_net.parameters())

def optimize_model():

    # Dont optimize till atleast BATCH_SIZE memories are filled
    if len(memory) < BATCH_SIZE:
        return

    transitions = memory.sample(BATCH_SIZE)
    batch = Transition(*zip(*transitions))

    # Get the actual Q
    state_batch = torch.cat(batch.state)
    action_batch = torch.cat(batch.action)
    state_values = policy_net(state_batch)  # Values of States for all actions

    # Values of states for the selected action
    state_action_values = state_values.gather(1, action_batch)

    # Get the expected Q
    # # Mask to identify if next state is final
    non_final_mask = torch.tensor(tuple(map
                                        (lambda s: s is not None,
                                         batch.next_state)),
                                         device=device, 
                                         dtype=torch.uint8)
    non_final_next_states = torch.cat([s for s in batch.next_state if s is not None])
    next_state_values = torch.zeros(BATCH_SIZE, device=device)  # init to zeros
    # predict next non final state values from target_net using next states
    next_state_values[non_final_mask] = target_net(non_final_next_states).max(1)[0].detach()
    reward_batch = torch.cat(batch.reward)
    # calculate the predicted values of states for actions
    expected_state_action_values = (next_state_values * GAMMA) + reward_batch

    # Compute Huber loss
    loss = F.smooth_l1_loss(state_action_values, expected_state_action_values.unsqueeze(1))

    # Optimize the model
    optimizer.zero_grad()
    loss.backward()
    for param in policy_net.parameters():
        param.grad.data.clamp_(-1, 1)
    optimizer.step()

然后是主要部分:优化器步骤。 这是我们使用RMSProp找出损失和反向传播的地方。 我们从存储库中提取了一些经验。 然后,我们将所有状态,动作和奖励转换为批量。 我们通过policy_net传递状态并获得相应的值。

Gym

然后,我们收集与操作批量相对应的值。

Gym

现在我们有了状态动作对,以及与之相关的值。 这对应于实际的 Q 函数。

接下来,我们需要找到期望的 Q 函数。 我们创建一个由 0 和 1 组成的掩码,将非 0 状态映射为 1,将 0 状态(终端状态)映射为 0。通过算法的设计,我们知道终端状态将始终具有值 0。 状态的值为正,但终端状态的值为 0。掩码如下所示:

Gym

在那批状态中,置于 0 的 1 是终端状态。 所有其他均为非最终状态。 我们将所有非最终的下一个状态连接到non_final_next_states中。 之后,我们将next_state_values初始化为全 0。 然后,我们将non_final_next_states传递给target_network,从中获得最大值的操作值,并将其应用于next_state_values[non_final_mask]。 我们将从非最终状态预测的所有值都放入非最终next_state_values数组。 next_state_values的外观如下:

Gym

最后,我们计算期望的 Q 函数。 根据我们先前的讨论,它将是R + Gamma(下一个状态值)。 然后,我们根据实际 Q 函数和预期 Q 函数计算损失,然后将误差反向传播到策略网络(请记住target_net处于eval模式)。 我们还使用梯度钳制来确保梯度较小且不会转移得太远。

训练神经网络将花费一些时间,因为该过程将渲染每个帧并计算该误差。 我们本可以使用一种更简单的方法,直接获取速度和位置来表示损失函数,并且由于不需要渲染每一帧,因此可以花费更少的时间进行训练。 它只会直接从env.state接受输入。

此算法有许多改进,例如为智能体增加了想象力,以便可以更好地探索和想象其脑海中的动作,并做出更好的预测。

总结

在本章中,我们学习了无监督学习的一个全新领域:强化学习。 这是一个完全不同的领域,我们在本章中仅涉及了这个主题。 我们学习了如何对问题进行措辞以进行强化学习,然后我们训练了一个模型,该模型可以看到环境提供的一些测量结果,并且可以学习如何平衡赤字。 您可以应用相同的知识来教机器人走路,驾驶汽车以及玩游戏。 这是深度学习的更多物理应用之一。

在下一章和最后一章中,我们将着眼于生产我们的 PyTorch 模型,以便您可以在任何框架或语言上运行它们,并扩展您的深度学习应用。

参考

  1. Google DeepMind 挑战赛:Lee Sedol 与 AlphaGo

本章由 Sudhanshu Passi 贡献。

八、生产中的 PyTorch

2017 年,当 PyTorch 发布其可用版本时,它的承诺是成为研究人员的 Python 优先框架。 PyTorch 社区对此严格了一年,但随后看到了大量的生产要求,并决定将生产能力与 PyTorch 的第一个稳定版本 1.0 合并,但又不影响其创建的可用性和灵活性。

PyTorch 以其干净的框架而闻名,因此要获得研究所需的生产能力和灵活性是一项艰巨的任务。 我认为,将生产支持推向核心的主要障碍是摆脱 Python 的境界,并将 PyTorch 模型转移到具有多线程功能的更快的线程安全语言中。 但是随后,这违反了 PyTorch 当时所遵循的 Python 优先原则。

解决此问题的第一步是使开放式神经网络交换ONNX)格式稳定,并与所有流行的框架兼容(至少与具有良好功能的框架兼容) 模块)。 ONNX 定义了深度学习图所需的基本运算符和标准数据类型。 这引导了 ONNX 进入 PyTorch 核心的道路,并且它与 ONNX 转换器一起为流行的深度学习框架(例如 CNTK,MXNet,TensorFlow 等)构建。

ONNX 很棒,并且每个人都喜欢它,但是 ONNX 的主要缺点之一是其脚本模式。 也就是说,ONNX 运行一次图以获取有关图的信息,然后将其转换为 ONNX 格式。 因此,ONNX 无法迁移模型中的控制流(将for循环用于循环神经网络(RNN)模型的不同序列长度)。

生产 PyTorch 的第二种方法是在 PyTorch 本身中构建高性能后端。 Caffe2 的核心与 PyTorch 核心合并在一起,而不是从头开始构建一个,但 Python API 保持不变。 但是,这并不能解决 Python 语言所具有的问题。

接下来是 TorchScript 的引入,它可以将本机 Python 模型转换为可以在高性能 Universe 中加载的序列化形式,例如 C++ 线程。 PyTorch 的后端 LibTorch 可以读取 TorchScript,这使 PyTorch 高效。 有了它,开发人员可以对模型进行原型设计,甚至可以使用 Python 本身对其进行训练。 训练后,可以将模型转换为到中间表示IR)。 目前,仅开发了 C++ 后端,因此可以将 IR 作为 C++ 对象加载,然后可以从 PyTorch 的 C++ API 中读取。 TorchScript 甚至可以在 Python 程序中转换控制流,这在生产支持的情况下使其优于 ONNX 方法。 TorchScript 本身是 Python 语言中可能的操作的子集,因此不允许任何 Python 操作用 TorchScript 编写。 官方文档本身提供了非常详细的说明,并讨论了可能的情况和不可能的情况,以及许多示例[1]。

在本章中,我们将从使用 Flask(流行的 Python Web 框架)提供普通的 Python PyTorch 模型开始。 这样的设置通常就足够了,特别是如果您要设置示例 Web 应用或满足您个人需求或类似用例的东西。 然后,我们将探索 ONNX 并将 PyTorch 模型转换为 MXNet,然后可以使用 MXNet 模型服务器提供服务。 从那里,我们将转到 TorchScript,这是 PyTorch 社区的新东西。 使用 TorchScript,我们将制作 C++ 可执行文件,然后可以在 LibTorch 的帮助下从 C++ 执行该可执行文件。 然后,可以从稳定,高性能的 C++ 服务器甚至使用 cgo 的 Go 服务器提供高效的 C++ 可执行文件。 对于所有份量,我们将使用在第 2 章,“简单神经网络”中构建的 fizzbuzz 网络。

与 Flask 一起使用

在 Python 本身中提供 PyTorch 模型是在生产环境中提供模型的最简单方法。 但是在解释如何完成之前,让我们快速看一下 Flask 是什么。 完全解释 Flask 不在本章的讨论范围内,但我们仍将介绍 Flask 的最基本概念。

Flask 简介

Flask 是的微框架,已被 Python 领域的多家大公司用于生产。 即使 Flask 提供了可用于将 UI 推送到客户端的模板引擎,我们也没有使用它。 相反,我们将制作一个提供 API 的 RESTful 后端。

可以使用pip来安装 Flask ,就像其他任何 Python 包一样:

pip install Flask

这将安装其他依赖项 Werkzeug(应用和服务器之间的 Python 接口),Jinga(作为模板引擎),其危险(用于安全签名数据)和 Click(作为 CLI 构建器)。

安装后,用户将可以访问 CLI,并使用flask run调用我们的脚本将启动服务器:

from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello World!"

该示例包含四个部分:

  • 第一行是我们导入 Flask 包的位置。
  • 我们创建一个 Flask 对象,这是我们的大型 Web 应用对象,Flask 服务器将使用该对象来运行我们的服务器。
  • 有了应用对象后,我们需要存储有关对象应对其执行操作的 URL 的信息。 为此,应用对象带有route方法,该方法接受所需的 URL 并返回装饰器。 这是我们希望应用现在提供的 URL。
  • 由应用对象返回的装饰器对一个函数进行装饰,当 URL 命中时,将触发该函数。 我们将其命名为hello。 函数的名称在这里并不重要。 在前面的示例中,它只是检查输入并做出相应的响应。 但是对于我们的模型服务器,我们使此函数稍微复杂一点,以便它可以接受输入并将该输入提供给我们构建的模型。 然后,我们模型的返回值将作为 HTTP 响应推回给用户。

我们通过建立flask_trial目录开始实现,并将该文件另存为app.py在该目录中:

mkdir flask_trial
cd flask_trial

然后,我们执行 Flask 随附的 CLI 命令来启动服务器。 执行后,如果未提供自定义参数,您将看到http://127.0.0.1:5000正在为服务器提供服务。

flask run

我们可以通过向服务器位置发出 HTTP 请求来测试简单的 Flask 应用。 如果一切正常,我们应该得到一个“你好,世界!” 来自服务器的消息。

-> curl "http://127.0.0.1:5000"
-> Hello World!

我们已经建立了简单的 Flask 应用。 现在,将 fizzbuzz 模型引入我们的应用。 以下代码片段显示了与第 2 章和“简单神经网络”相同的模型,供您参考。 该模型将从路由函数中调用。 我们已经在第 2 章和“一个简单的神经网络”中对模型进行了训练,因此,我们将在这里加载训练后的模型,而不是再次对其进行训练:

import torch.nn as nn
import torch

class FizBuzNet(nn.Module):
    """
    2 layer network for predicting fiz or buz
    param: input_size -> int
    param: output_size -> int
    """

    def __init__(self, input_size, hidden_size, output_size):
        super(FizBuzNet, self).__init__()
        self.hidden = nn.Linear(input_size, hidden_size)
        self.out = nn.Linear(hidden_size, output_size)

    def forward(self, batch):
        hidden = self.hidden(batch)
        activated = torch.sigmoid(hidden)
        out = self.out(activated)
        return out

用于 Flask 的模型

下面的屏幕快照给出了我们应用的目录结构。 assets文件夹具有训练好的模型,在加载模型时,controller.py文件将使用该模型。 根目录中的app.py是 Flask 应用的入口。 Flask 首选app.py作为入口点文件的默认名称。

当您执行flask run时,Flask 将在当前目录中查找app.py文件并执行该文件。 controller.py文件是我们从model.py文件加载模型的地方。 然后,加载的模型将等待用户通过 HTTP 端点输入。 app.py将用户输入重定向到controller,然后将其转换为 Torch 张量。

张量对象将通过神经网络传递,并且controller将神经网络的结果传递给后处理操作后,从神经网络返回结果。

Model serving with Flask

图 8.1:当前目录

目录中有四个组件用于制作 Flask 应用。 assets文件夹是我们保留模型的地方。 其他三个文件是代码所在的位置。 让我们研究一下每个。 我们将从入口文件app.py开始。 它是先前提供的简单 Flask 应用的扩展版本。 该文件教我们如何定义 URL 端点,以及如何将 URL 端点映射到 Python 函数。 我们的扩展app.py文件显示在以下代码块中:

import json

from flask import Flask
from flask import request

import controller

app = Flask('FizBuzAPI')

@app.route('/predictions/fizbuz_package', methods=['POST'])
def predict():
    which = request.get_json().get('input.1')
    if not which:
        return "InvalidData"
    try:
        number = int(which) + 1
        prediction = controller.run(number)
        out = json.dumps({'NextNumber': prediction})
    except ValueError:
        out = json.dumps({'NextNumber': 'WooHooo!!!'})
    return out

Flask 为我们提供了request工具,它是一个全局变量,但对于存储有关当前请求信息的当前线程而言是局部的。 我们使用request对象的get_json函数从request对象获取主体POST参数。 然后,将通过 HTTP 传入的字符串数据转换为整数。 这个整数是我们从前端传递的数字。 我们应用的任务是预测下一个数字的状态。 那将是下一个数字本身还是嘶嘶声,嗡嗡声或嘶嘶声? 但是,如果您还记得,我们会训练我们的网络来预测我们通过的号码的状态。 但是,我们需要下一个号码的状态。 因此,我们将一个加到当前数上,然后将结果传递给我们的模型。

我们的下一个导入是controller,我们在其中加载了模型文件。 我们正在调用run方法并将数字传递给模型。 然后,将controller的预测值作为字典传递回。 Flask 会将其转换为响应正文并将其发送回用户。

在继续之前,我们可以从以前的简单 Flask 应用的扩展版本中看到两个主要差异。 一种是 URL 路由:/predictions/fizbuz_package。 如前所述,Flask 允许您将任何 URL 端点映射到您选择的函数。

其次,我们在装饰器中使用了另一个关键字参数:methods。 这样,我们告诉 Flask,不仅需要通过 URL 规则来调用此函数,而且还需要在对该 URL 的POST方法调用上进行调用。 因此,我们像以前一样使用flask run运行该应用,并使用curl命令对其进行测试。

-> curl -X POST http://127.0.0.1:5000/predictions/fizbuz_package \
 -H "Content-Type: application/json" \
 -d '{"input.1": 14}'

-> {"NextNumber": "FizBuz"}

在 HTTP POST请求中,我们传递了输入数字为14的 JSON 对象,我们的服务器返回了下一个数字FizBuz。 所有这些魔术都发生在我们的app.py调用的controller.run()方法中。 现在,让我们看看该函数在做什么。

接下来是使用run()方法的controller文件。 在这里,我们将输入数字转换为 10 位二进制数(请记住,在第 2 章,“简单神经网络”中,这是我们作为输入传递给 fizzbuzz 网络的东西),将其变为 Torch 张量。 然后将二进制张量传递给我们模型的正向函数,以得到具有预测的1 x 4张量。

通过从加载了保存的.pth文件的模型文件中调用FizBuz类来创建我们的模型。 我们使用 Torch 的load_state_dict方法将参数加载到初始化的模型中。 之后,我们将模型转换为eval()模式,这将模型设置为评估模式(它在评估模式下关闭了batchnorm丢弃层)。 模型的输出是运行max并确定哪个索引具有最大值,然后将其转换为可读输出的概率分布。

为生产准备的服务器

这是关于如何使用 Flask 将 PyTorch 模型部署到服务器的非常基本的演练。 但是 Flask 的内置服务器尚未投入生产,只能用于开发目的。 开发完成后,我们应该使用其他服务器包在生产中为 Flask 应用提供服务。

Gunicorn 是 Python 开发人员使用的最受欢迎的服务器包之一,将其与 Flask 应用绑定非常容易。 您可以使用pip安装 Gunicorn,就像我们安装 Flask 一样:

pip install gunicorn

Gunicorn 需要我们传递模块名称,以便它能够拾取模块并运行服务器。 但是 Gunicorn 希望应用对象具有名称application,而我们的项目则不是这样。 因此,我们需要显式传递应用对象名称和模块名称。 Gunicorn 的命令行工具有很多选择,但是我们正在尝试使其尽可能简单:

gunicorn app:app

import torch
from model import FizBuzNet

input_size = 10
output_size = 4
hidden_size = 100

def binary_encoder():
    def wrapper(num):
        ret = [int(i) for i in '{0:b}'.format(num)]
        return [0] * (input_size - len(ret)) + ret
    return wrapper

net = FizBuzNet(input_size, hidden_size, output_size)
net.load_state_dict(torch.load('assets/fizbuz_model.pth'))
net.eval()
encoder = binary_encoder()

def run(number):
    with torch.no_grad():
        binary = torch.Tensor([encoder(number)])
        out = net(binary)[0].max(0)[1].item()
    return get_readable_output(number, out)

ONNX

建立 ONNX 协议是为了创建不同框架之间的互操作性。 这可以帮助 AI 开发人员和组织选择合适的框架来开发他们花费大部分时间的 AI 模型。 一旦开发和训练阶段结束,他们便可以将模型迁移到他们选择的任何框架中,以在生产中提供服务。

可以针对不同目的优化不同的框架,例如移动部署,可读性和灵活性,生产部署等。 有时将模型转换为不同的框架是不可避免的,手动转换很耗时。 这是 ONNX 试图通过互操作性解决的另一个用例。

让我们以任何框架示例为例,看看 ONNX 适合什么地方。框架将具有语言 API(供开发人员使用),然后是由他们开发的模型的图形表示。 然后,该 IR 进入高度优化的运行时以执行。 ONNX 为此 IR 提供了统一的标准,并使所有框架都了解 ONNX 的 IR。 借助 ONNX,开发人员可以使用 API​​制作模型,然后将其转换为框架的 IR。 ONNX 转换器可以将该 IR 转换为 ONNX 的标准 IR,然后可以将其转换为其他框架的 IR。

这是 PyTorch 的 Fizzbuzz 网络的 IR 的可读表示:

graph(%input.1 : Float(1, 10)
      %weight.1 : Float(100, 10)
      %bias.1 : Float(100)
      %weight : Float(4, 100)
      %bias : Float(4)) {
  %5 : Float(10!, 100!) = aten::t(%weight.1),scope: FizBuzNet/Linear[hidden]
  %6 : int = prim::Constant[value=1](),scope: FizBuzNet/Linear[hidden]
  %7 : int = prim::Constant[value=1](),scope: FizBuzNet/Linear[hidden]
  %hidden : Float(1, 100) = aten::addmm(%bias.1, %input.1, %5, %6,%7), scope: FizBuzNet/Linear [hidden]
  %input : Float(1, 100) = aten::sigmoid(%hidden),scope: FizBuzNet
  %10 : Float(100!, 4!) = aten::t(%weight),scope: FizBuzNet/Linear[out]
  %11 : int = prim::Constant[value=1](),scope: FizBuzNet/Linear[out]
  %12 : int = prim::Constant[value=1](),scope: FizBuzNet/Linear[out]
  %13 : Float(1, 4) = aten::addmm(%bias, %input, %10, %11, %12),scope: FizBuzNet/Linear[out]
  return (%13);
}

表示清楚地表明了整个网络的结构。 前五行显示参数和输入张量,并为每一个标记一个名称。 例如,整个网络将输入张量定为input.i,它是形状为1 x 10的浮点张量。然后,它显示了我们第一层和第二层的权重和偏差张量。

从第六行开始,显示了图的结构。 每行的第一部分(以%符号开头的全冒号之前的字符)是每行的标识符,这是其他行中用来引用这些行的标识符。 例如,以%5作为标识符的线对aten::t(%weight.i)表示的第一层的权重进行转置,从而输出形状为10 x 100的浮点张量。

ONNX

图 8.2:另一个 IR 转换为 ONNX 的 IR,然后又转换为另一个 IR

PyTorch 具有内置的 ONNX 导出器,它可以帮助我们创建 ONNX IR,而无需离开 PyTorch。 在此处给出的示例中,我们将 fizbuzz 网络导出到 ONNX,然后由 MXNet 模型服务器提供服务。 在以下代码段中,我们使用 PyTorch 的内置export模块将 fizzbuzz 网络转换为 ONNX 的 IR:

>>> import torch
>>> dummy_input = torch.Tensor([[0, 0, 0, 0, 0, 0, 0, 0, 1, 0]])
>>> dummy_inputtensor([[O., 0., 0., 0., 0., 0., 0., O., 1., 0.]])
>>> net = FizBuzNet(input_size, hidden_size, output_size)
>>> net.load_state_dict(torch.load('assets/fizbuz_model.pth'))
>>> dummy_input = torch.Tensor([[0, 0, 0, 0, 0, 0, 0, 0, 1, 0]])
>>> torch.onnx.export(net, dummy_input, "fizbuz.onnx", verbose=True)

在最后一行,我们调用export模块,并传递 PyTorch 的net,虚拟输入和输出文件名。 ONNX 通过跟踪图进行转换; 也就是说,它使用我们提供的虚拟输入执行一次图。

在执行图时,它会跟踪我们执行的 PyTorch 操作,然后将每个操作转换为 ONNX 格式。 键值参数verbose=True在导出时将输出写入到终端屏幕。 它为我们提供了 ONNX 中相同图的 IR 表示:

graph(%input.1 : Float(1, 10)
      %1 : Float(100, 10)
      %2 : Float(100)
      %3 : Float(4, 100)
      %4 : Float(4)) {
  %5 : Float(1, 100) = onnx::Gemm[alpha=1, beta=1,transB=1](%input.1, %1, %2),scope: FizBuzNet/Linear[hidden]
  %6 : Float(1, 100) = onnx::Sigmoid(%5), scope: FizBuzNet
  %7 : Float(1, 4) = onnx::Gemm[alpha=1, beta=1,transB=1](%6, %3, %4),scope: FizBuzNet/Linear[out]
  return (%7);
}

它还显示了图执行所需的所有操作,但比 PyTorch 的图形表示要小。 虽然 PyTorch 向我们显示了每个操作(包括转置操作),但 ONNX 会在高级功能(例如onnx:Gemm)下抽象该粒度信息,前提是其他框架的import模块可以读取这些抽象。

PyTorch 的export模块将 ONNX 模型保存在fizbuz.onnx文件中。 可以从 ONNX 本身或其他框架中内置的 ONNX 导入程序中加载。 在这里,我们将 ONNX 模型加载到 ONNX 本身并进行模型检查。 ONNX 还具有由 Microsoft 管理的高性能运行时,这超出了本书的解释范围,但可在这个页面上获得。

由于 ONNX 已成为框架之间互操作性的规范,因此围绕它构建了其他工具。 最常用/最有用的工具可能是 Netron,它是 ONNX 模型的可视化工具。 尽管 Netron 不像 TensorBoard 那样具有交互性,但 Netron 足以用于基本可视化。

拥有.onnx文件后,您可以将文件位置作为参数传递给 Netron 命令行工具,该工具将构建服务器并在浏览器中显示该图:

pip install netron
netron -b fizbuz.onnx

前面的命令将使用 Fizzbuzz 网络的图可视化来启动 Netron 服务器,如下图所示。 除了可缩放的图外,Netron 还可以可视化其他基本信息,例如版本,生成器,图的生成方式等等。 另外,每个节点都是可单击的,它将显示有关该特定节点的信息。 当然,这还不够复杂,无法满足可视化工具所需的所有要求,但足以让我们对整个网络有所了解。

ONNX

图 8.3:Fizzbuzz 网络的 Netron 可视化

从成为 ONNX 可视化工具开始,Netron 逐渐接受所有流行框架的导出模型。 目前,根据官方文件,Netron 接受 ONNX,Keras,CoreML,Caffe2,MXNet,TensorFlow Lite,TensorFlow.js,TensorFlow,Caffe,PyTorch,Torch,CNTK,PaddlePaddle,Darknet 和 scikit-learn 的模型。

MXNet 模型服务器

现在我们离开了 PyTorch 世界。 我们现在有不同的模型服务器,但我们选择了 MXNet 模型服务器。 MXNet 模型服务器由社区维护,由亚马逊团队领导,也称为 MMS。 从这里开始,我将交替使用 MMS 和 MXNet 模型服务器。

MXNet 比其他服务模块更好。 在撰写本文时,TensorFlow 与 Python 3.7 不兼容,并且 MXNet 的服务模块已与内置的 ONNX 模型集成,这使开发人员可以轻松地以很少的命令行为模型提供服务,而无需了解分布式或高度可扩展的部署的复杂性。

其他模型服务器,例如 TensorRT 和 Clipper,不像 MXNet 服务器那样易于设置和管理。 而且,MXNet 附带了另一个名为 MXNet 存档器的工具,该工具将所有必需的文件打包成一个捆绑包,这些文件可以独立部署,而不必担心其他依赖项。 除了 MXNet 模型服务器具备的所有这些很酷的功能之外,最大的好处是能够自定义预处理和后处理步骤。 我们将在接下来的部分中介绍如何完成所有这些操作。

整个过程的流程从我们尝试使用模型存档器创建具有.mar格式的单个存档文件的位置开始。 单个捆绑包文件需要 ONNX 模型文件signature.json,该文件提供有关输入大小,名称等的信息。 认为它是可以随时更改的配置文件。 如果您决定将所有值硬编码到代码中,而不是从配置中读取,则它甚至不必成为存档的一部分。 然后,您需要服务文件,您可以在其中定义预处理,推理功能,后处理功能和其他工具函数。

制作完模型档案后,我们可以调用模型服务器,并将位置作为输入传递给我们的模型档案。 而已; 您现在可以从超级性能模型服务器提供模型。

MXNet 模型存档器

我们将通过安装 MXNet 模型存档器开始我们的旅程。 MXNet 模型服务器随附的默认模型存档器不支持 ONNX,因此我们需要单独安装。 ONNX 的模型存档器依赖于协议缓冲区和 MXNet 包本身。 官方文档中提供了为每个操作系统安装 protobuf 编译器的指南。 可以通过pip来安装 MXNet 包,就像我们已经安装了其他包一样(对于 GPU,MXNet 还有另一个包,但是这里我们正在安装 MXNet 的基本版本):

pip install mxnet
pip install model-archiver[onnx]

现在,我们可以安装 MXNet 模型服务器。 它基于 Java 虚拟机JVM)构建,因此从 JVM 调用了运行有我们模型实例的多个线程。 利用 JVM 支持的复杂性,可以将 MXNet 服务器扩展为处理数千个请求的多个进程。

MXNet 服务器带有管理 API,该 API 通过 HTTP 提供。 这有助于生产团队根据需要增加/减少资源。 除了处理工作器规模之外,管理 API 还具有其他选项。 但是我们不会在这里深入探讨。 由于模型服务器在 JVM 上运行,因此我们需要安装 Java8。此外,MXNet 模型服务器在 Windows 上仍处于试验模式,但在 Linux 风味和 Mac 上稳定。

pip install mxnet-model-server

现在,在安装了所有前提条件之后,我们可以开始使用 MXNet 模型服务器对可用于生产的 PyTorch 模型进行编码。 首先,我们创建一个新目录,以保存所有需要的文件以供模型存档器创建捆绑文件。 然后,我们移动在上一步中创建的.onnx文件。

MMS 的一项强制性要求是其中包含服务类的服务文件。 MMS 执行服务文件中唯一可用类的initialize()handle()函数。 在下一节中,我们将逐一进行介绍,但这是我们可以用来制作服务文件的框架。

MXNet model archiver

图 8.4:fizbuz_package的目录结构

class MXNetModelService(object):

    def __init__(self):
        ...
    def initialize(self, context):
        ...
    def preprocess(self, batch):
        ...
    def inference(self, model_input):
        ...
    def postprocess(self, inference_output):
        ...
    def handle(self, data, context):
        ...

然后,我们需要一个签名文件。 正如我们之前所看到的,签名文件只是配置文件。 我们可以通过将值硬编码到脚本本身来避免发生这种情况,但是 MMS 人士也建议这样做。 我们为 fizzbuzz 网络制作了最小的签名文件,如下所示:

{
  "inputs": [
    {
      "data_name": "input.1",
      "data_shape": [
        1,
        10
      ]
    }
  ],
  "input_type": "application/json"
}

在签名文件中,我们描述了数据名称,输入形状和输入类型。 当通过 HTTP 读取数据流时,这就是我们的服务器假定的数据信息。 通常,我们可以通过在签名文件中进行配置来使我们的 API 接受任何类型的数据。 但是然后我们的脚本也应该能够处理这些类型。 让我们完成服务文件,然后将其与 MMS 捆绑在一起。

如您先前所见,MMS 调用服务文件中唯一可用的单个类的initialize()方法。 如果服务文件中存在更多类,那就完全是另一回事了,但是让我们足够简单地理解它。 顾名思义,initialize()文件初始化所需的属性和方法:

def initialize(self, context):
    properties = context.system_properties
    model_dir = properties.get("model_dir")
    gpu_id = properties.get("gpu_id")
    self._batch_size = properties.get('batch_size')
    signature_file_path = os.path.join(
        model_dir, "signature.json")
    if not os.path.isfile(signature_file_path):
        raise RuntimeError("Missing signature.json file.")
    with open(signature_file_path) as f:
        self.signature = json.load(f)
    data_names = []
    data_shapes = []
    input_data = self.signature["inputs"][0]
    data_name = input_data["data_name"]
    data_shape = input_data["data_shape"]
    data_shape[0] = self._batch_size
    data_names.append(data_name)
    data_shapes.append((data_name, tuple(data_shape)))
    self.mxnet_ctx = mx.cpu() if gpu_id is None elsemx.gpu(gpu_id)
    sym, arg_params, aux_params = mx.model.load_checkpoint(checkpoint_prefix, self.epoch)
    self.mx_model = mx.mod.Module(
        symbol=sym, context=self.mxnet_ctx,
        data_names=data_names, label_names=None)
    self.mx_model.bind(
        for_training=False, data_shapes=data_shapes)
    self.mx_model.set_params(
        arg_params, aux_params,
        allow_missing=True, allow_extra=True)
    self.has_initialized = True

MMS 在调用initialize()时传递上下文参数,该参数具有在解压缩存档文件时获取的信息。 当首先使用存档文件路径作为参数调用 MMS 时,在调用服务文件之前,MMS 解压缩存档文件并安装模型,并收集信息,其中存储模型,MMS 可以使用多少个内核,它是否具有 GPU 等。 所有这些信息都作为上下文参数传递给initialize()

initialize()的第一部分是收集此信息以及来自签名 JSON 文件的信息。 函数的第二部分从第一部分中收集的信息中获取与输入有关的数据。 然后,该函数的第三部分是创建 MXNet 模型并将训练后的参数加载到模型中。 最后,我们将self.has_initialized变量设置为True,然后将其用于检查服务文件其他部分的初始化状态:

def handle(self, data, context):
    try:
        if not self.has_initialized:
            self.initialize()
        preprocess_start = time.time()
        data = self.preprocess(data)
        inference_start = time.time()
        data = self.inference(data)
        postprocess_start = time.time()
        data = self.postprocess(data)
        end_time = time.time()

        metrics = context.metrics
        metrics.add_time(self.add_first())
        metrics.add_time(self.add_second())
        metrics.add_time(self.add_third())
        return data
    except Exception as e:
        request_processor = context.request_processor
        request_processor.report_status(
            500, "Unknown inference error")
        return [str(e)] * self._batch_size

MMS 被编程为在每个请求上调用相同类的handle()方法,这是我们控制流程的地方。 initialize()函数只会在启动线程时被调用一次; 每个用户请求都将调用handle()函数。 由于handle()函数是针对每个用户请求被调用的,以及上下文信息,因此它也将在参数中获取当前数据。 但是,为了使程序模块化,我们没有在handle()中进行任何操作; 取而代之的是,我们正在调用其他仅指定做一件事的函数:该函数应该做什么。

我们将整个流分为四个部分:预处理,推理,后处理和矩阵记录。 在handle()的第一行中,我们验证是否正在使用上下文和数据信息初始化线程。 完成后,我们将进入流程。 现在,我们将逐步完成流程。

我们首先使用data作为参数调用self.preprocess()函数,其中data将是 HTTP 请求的POST正文内容。 preprocess函数以与我们在signature.json文件中配置的名称相同的名称获取传递的数据。 一旦有了数据,这就是我们需要系统预测下一个数字的整数。 由于我们已经训练了模型来预测当前号码的嘶嘶声状态,因此我们将在数据中为号码添加一个嗡嗡声,然后在新号码的二进制文件上创建一个 MXNet 数组:

def preprocess(self, batch):
    param_name = self.signature['inputs'][0]['data_name']
    data = batch[0].get('body').get(param_name)
    if data:
        self.input = data + 1
        tensor = mx.nd.array(
            [self.binary_encoder(self.input, input_size=10)])
        return tensor
    self.error = 'InvalidData'

handle()函数获取已处理的数据,并将其传递给inference()函数,该函数将使用已处理的数据调用保存在initialize()函数上的 MXNet 模型。 inference()函数返回大小为1 x 4的输出张量,然后将其返回到handle()函数。

def inference(self, model_input):
    if self.error is not None:
        return None
    self.mx_model.forward(DataBatch([model_input]))
    model_output = self.mx_model.get_outputs()
    return model_output

然后将张量传递给postprocess()函数,以将其转换为人类可读的输出。 我们具有self.get_readable_output()函数,可根据需要将模型的输出转换为嘶嘶声,嗡嗡声,嘶嘶声嗡嗡声或下一个数字。

然后,后处理的数据返回到handle()函数,在其中进行矩阵创建。 之后,数据将返回到handle()函数的被调用方,该函数是 MMS 的一部分。 MMS 将该数据转换为 HTTP 响应,并将其返回给用户。 MMS 还记录矩阵的输出,以便操作可以实时查看矩阵并基于此做出决策:

def postprocess(self, inference_output):
    if self.error is not None:
        return [self.error] * self._batch_size
    prediction = self.get_readable_output(
        self.input,
        int(inference_output[0].argmax(1).asscalar()))
    out = [{'next_number': prediction}]
    return out

一旦将所有文件包含在前面给出的目录中,就可以创建.mar存档文件:

model-archiver \
        --model-name fizbuz_package \
        --model-path fizbuz_package \
        --handler fizbuz_service -f

这将在当前目录中创建一个fizbuz_package.mar文件。 然后可以将其作为 CLI 参数传递给 MMS:

mxnet-model-server \
        --start \
        --model-store FizBuz_with_ONNX \
        --models fizbuz_package.mar

现在,我们的模型服务器已启动并在端口 8080 上运行(如果您尚未更改端口)。 我们可以尝试执行与 Flask 应用相同的curl命令(显然,我们必须更改端口号)并检查模型。 我们应该获得与 Flask 应用完全相同的结果,但是现在我们可以根据需要动态地动态扩展或缩减工作器的数量。 MMS 为此提供了管理 API。 管理 API 带有几个可配置的选项,但是这里我们只关注于增加或减少工作器的数量。

除了在端口 8080 上运行的服务器之外,还将在 8081 上运行管理 API 服务,我们可以对其进行调用和控制配置。 使用简单的GET请求命中该端点将为您提供服务器的状态。 但是在探究这一点之前,我们将工作器数量设为 1(默认情况下为 4)。 API 端点是适当的 REST 端点; 我们在路径中指定模型名称,并传递参数max_worker=1以使工作器数为 1。 我们也可以通过min_worker=<number>来增加工作器数量。 官方文档[2]中详细介绍了管理 API 上可能的配置。

-> curl -v -X PUT "http://localhost:8081/models/fizbuz_package?max_worker=1"
...
{
 "status": "Processing worker updates..."
}
...

一旦减少了工作器的数量,我们就可以命中端点来确定服务器的状态。 示例输出(在我们减少了工作器数量之后)如下:

-> curl "http://localhost:8081/models/fizbuz_package"
{
 "modelName": "fizbuz_package",
 "modelUrl": "fizbuz_package.mar",
 "runtime": "python",
 "minWorkers": 1,
 "maxWorkers": 1,
 "batchSize": 1,
 "maxBatchDelay": 100,
 "workers": [
 {
 "id": "9000",
 "startTime": "2019-02-11T19:03:41.763Z",
 "status": "READY",
 "gpu": false,
 "memoryUsage": 0
 }
 ]
}

我们已经设置了模型服务器,现在我们知道如何根据比例配置服务器。 让我们使用 Locust 对服务器进行负载测试,并检查服务器的负载情况,以及根据我们的需求增加/减少资源有多容易。 将 AI 模型部署到生产环境并非易事。

负载测试

随后是示例蝗虫脚本,应将其另存为locust.py在当前目录中。 如果已安装 Locust(可以使用pip进行安装),则调用locust将打开 Locust 服务器并打开 UI,我们可以在其中输入要测试的比例尺。 我们可以逐步提高规模,并检查服务器在什么时候开始崩溃,然后点击管理 API 以增加工作量并确保我们的服务器可以容纳规模:

import random
from locust import HttpLocust, TaskSet, task

class UserBehavior(TaskSet):
    def on_start(self):
        self.url = "/predictions/fizbuz_package"
        self.headers = {"Content-Type": "application/json"}

    @task(1)
    def success(self):
        data = {'input.1': random.randint(0, 1000)}
        self.client.post(self.url, headers=self.headers, json=data)

class WebsiteUser(HttpLocust):
    task_set = UserBehavior
    host = "http://localhost: 8081"

Load testing

图 8.5:Locust UI,我们可以在其中配置用户数量以模拟生产负载

TorchScript 的效率

我们已经设置了简单的 Flask 应用服务器来为我们的模型提供服务,并且已经使用 MXNet 模型服务器实现了相同的模型,但是如果我们需要摆脱 Python 的世界,并使用 C++ 或 Go 创建高效的服务器 ,或使用其他有效的语言,PyTorch 提出了 TorchScript,它可以生成模型中最有效的形式,并且可以在 C++ 中读取。

现在的问题是:这不是我们对 ONNX 所做的吗? 也就是说,从 PyTorch 模型创建另一个 IR? 是的,过程相似,但区别在于 ONNX 使用跟踪创建了优化的 IR; 也就是说,它通过模型传递虚拟输入,并在执行模型时记录 PyTorch 操作,然后将这些操作转换为中间 IR。

这种方法有一个问题:如果模型是数据相关的,例如 RNN 中的循环,或者if/else条件是基于输入的,那么跟踪就不能真正做到这一点。 跟踪将仅发现在特定执行周期中发生的情况,而忽略其他情况。 例如,如果我们的虚拟输入是 10 个单词的句子,而我们的模型是基于循环的 RNN,则跟踪的图将对 RNN 单元的 10 次执行进行硬编码,如果句子的长度大于 10,或者较短的句子带有更少的单词,则它将中断。 考虑到这一点引入了 TorchScript。

TorchScript 支持此类 Python 控制流的一个子集,唯一要做的就是将现有程序转换为所有控制流都是 TorchScript 支持的控制流的阶段。 LibTorch 可以读取 TorchScript 创建的中间阶段。 在此会话中,我们将创建 TorchScript 输出并编写一个 C++ 模块以使用 LibTorch 加载它。

即使 TorchScript 是 PyTorch 早期版本的 JIT 包的一部分,它仍在 PyTorch 1.0 中引入了可用且稳定的 TorchScript 版本。 TorchScript 可以序列化和优化用 PyTorch 编写的模型。

与 ONNX 一样,TorchScripts 可以作为 IR 保存到磁盘中,但是与 ONNX 不同,该 IR 经过优化可在生产环境中运行。 保存的 TorchScript 模型可以在不依赖 Python 的环境中加载。 由于性能和多线程原因,Python 一直是生产部署的瓶颈,即使 Python 可以带给您的扩展能力足以满足现实世界中的大多数使用情况。

避免这种基本的瓶颈是所有可用于生产环境的框架的主要任务,这就是为什么静态计算图统治框架世界的原因。 PyTorch 通过引入具有高级 API 的基于 C++ 的运行库来解决此问题,如果开发人员希望使用 C++ 进行编程,则可以使用这些 API。

通过将 TorchScript 推到核心,PyTorch 可以投入生产了。 TorchScript 可以将用 Python 编写的模型转换为高度优化的 IR,然后可由 LibTorch 读取。 然后,可以将 LibTorch 加载的模型保存为 C++ 对象,并可以在 C++ 程序或其他高效编程语言(例如 Go)中运行。

PyTorch 允许您通过两种方法制作 TorchScript IR。 最简单的是通过跟踪,就像 ONNX 一样。 您可以通过虚拟输入将模型(甚至函数)传递给torch.jit.trace。 PyTorch 通过模型/函数运行虚拟输入,并在运行输入时跟踪操作。

然后,可以将跟踪的函数(PyTorch 操作)转换为优化的 IR,也称为静态单分配 IR。 像 ONNX 图一样,该图中的指令也具有张量库(ATen,PyTorch 的后端)可以理解的原始运算符。

这确实很容易,但是要付出代价。 基于跟踪的推理具有 ONNX 的基本问题:它无法处理依赖于数据的模型结构更改,即if/else条件检查或循环(序列数据)。 为了处理这种情况,PyTorch 引入了脚本模式。

可以通过使用torch.jit.script装饰器(用于常规函数)和torch.jit.script_method(用于 PyTorch 模型上的方法)来启用脚本模式。 通过此装饰器,函数/方法中的内容将直接转换为 TorchScript。 在对模型类使用torch.jit.script_method时要记住的另一件重要事情是关于父类。 通常,我们从torch.nn.Module继承,但是为了制作 TorchScript,我们从torch.jit.ScriptModule继承。 这有助于 PyTorch 避免使用无法转换为 TorchScript 的纯 Python 方法。 目前,TorchScript 不支持所有 Python 函数,但具有支持数据相关张量操作的所有必需函数。

我们将首先将模型导出到ScriptModule IR,以此开始 fizzbuzz 模型的 C++ 实现,就像我们对 ONNX 导出所做的一样:

net = FizBuzNet(input_size, hidden_size, output_size)
traced = torch.jit.trace(net, dummy_input)
traced.save('fizbuz.pt')

可以通过torch.load()方法将保存的模型加载回 Python,但是我们将使用 C++ 中引入的类似 API LibTorch 将模型加载到 C++。 在讨论逻辑之前,让我们将所需的标头导入当前作用域:

#include <torch/script.h>
#include <iostream>
#include <memory>
#include <string>

最重要的头是torch/script.h,它带来了 LibTorch 所需的所有方法和函数。 我们决定将模型名称和示例输入作为命令行参数传递。 因此,主程序的第一部分是读取命令行参数并将其解析为程序的其余部分:

std::string arg = argv[2];
int x = std::stoi(arg);
float array[10];

int i;
int j = 9;
for (i = 0; i < 10; ++i) {
    array[j] = (x >> i) & 1;
    j--;
}

程序读取第二个命令行参数,这是用户给出的用于获取预测的编号。 从命令行读取时,该数字为string类型。 我们将其转换为int。 对于stringint转换后的循环,我们需要将其转换为二进制数组。 这是 LibTorch 执行开始的地方:

std::shared_ptr<torch::jit::script::Module> module = torch::jit::load(argv[1]);
auto options = torch::TensorOptions().dtype(torch::kFloat32);
torch::Tensor tensor_in = torch::from_blob(array, {1, 10},options);
std::vector<torch::jit::IValue> inputs;
inputs.push_back(tensor_in);
at::Tensor output = module->forward(inputs).toTensor();

在第一行中,我们从路径加载模型,该路径作为第一个命令行参数传递(我们将变量声明为ScriptModule)。 在第三行,我们使用from_blob方法将二进制数组转换为二维 LibTorch 张量。 在最后一行,我们使用我们制作的张量执行模型的forward方法,并将输出返回给用户。 这可能是我们可以实现以展示 TorchScript 实际操​​作的最基本示例。 官方文档中有许多示例,它们显示了脚本模式(与跟踪模式不同)的功能,可以理解 Python 控制流并将模型推向 C++ 世界。

探索 RedisAI

我们已经看到可以通过 TorchScript 获得的优化,但是优化的二进制文件将如何处理? 是的,我们可以在 C++ 世界中加载它,并制作 Go 服务器,然后在其中加载它,但这仍然很痛苦。

Redis Labs 和 Orobix 为我们带来了另一个名为 RedisAI 的解决方案。 它是基于 LibTorch 构建的高度优化的运行时,可以接受已编译的 TorchScript 二进制文件,以通过 Redis 协议提供服务。 对于没有 Redis 经验的人, 这里有很好的文档,那里的介绍文档[3]应该是一个好的开始。

RedisAI 带有三个选项来配置三个后端:PyTorch,TensorFlow 和 ONNX 运行时。 它并不仅限于此:RedisAI 在后端使用 DLPack 来使张量能够通过不同的框架,而无需花费很多转换成本。

那有什么意思? 假设您有一个 TensorFlow 模型,该模型将人脸转换为 128 维嵌入(这是 FaceNet 所做的)。 现在,您可以使 PyTorch 模型使用此 128 维嵌入进行分类。 在正常情况下,将张量从 TensorFlow 传递到 PyTorch 需要深入了解事物在幕后的工作方式,但是使用 RedisAI,您可以使用几个命令来完成。

RedisAI 是作为 Redis 服务器(loadmodule开关)的模块构建的。 通过 RedisAI 提供模型的好处不仅在于拥有多个运行时以及它们之间的互操作性。 实际上,这对于生产部署来说是最不重要的。 RedisAI 附带的最重要的功能是故障转移和分布式部署选项已经嵌入到 Redis 服务器中。

借助 Redis Sentinel 和 Redis Cluster,我们可以在多集群,高可用性设置中部署 RedisAI,而无需对 DevOps 或基础架构建设有足够的了解。 另外,由于 Redis 拥有所有流行语言的客户端,因此,通过 RedisAI 部署 TorchScript 模型后,您基本上可以使用 Redis 的任何语言客户端与服务器通信以运行模型,将输入传递给模型,从模型获取输出,以及更多。

使用 RedisAI 的下一个亮点是 Redis 整个大型生态系统的可用性,例如 RedisGears(可将任何 Python 函数作为管道的一部分运行),RedisTimeSeries,Redis Streams 等。

让我们开始将使用 TorchScript 编译的 fizzbuzz 网络模型加载到 RedisAI。 首先,我们需要安装 Redis 服务器和 RedisAI 来设置环境。 installation.sh文件包含三个部分来执行此操作:

sudo apt update
sudo apt install -y build-essential tcl libjemalloc-dev
sudo apt install -y git cmake unzip

curl -O http://download.redis.io/redis-stable.tar.gz
tar xzvf redis-stable.tar.gz
cd redis-stable
make
sudo make install
cd ~
rm redis-stable.tar.gz

git clone https://github.com/RedisAI/RedisAI.git
cd RedisAl
bash get_deps.sh cpu
mkdir build
cd build
cmake -DDEPS_PATH=../deps/install ..
make
cd ~

第一部分是我们安装所需依赖项的位置。 第二部分是我们下载 Redis 服务器二进制文件并进行安装的地方。 第三部分是克隆 RedisAI 服务器并使用make进行构建。 安装完成后,我们可以运行run_server.sh文件以将 RedisAI 作为已加载的模块来构建 Redis 服务器。

cd redis-stable
redis-server redis.conf --loadmodule ../RedisAI/build/redisai.so

现在,我们的 Redis 服务器已全部就绪。 设置 RedisAI 服务器就这么简单。 现在,使用 Sentinel 或 Cluster 对其进行扩展也并不可怕。 官方文档具有足够的信息供您入门。

在这里,我们从最小的 Python 脚本开始,以使用 RedisAI 运行 fizzbuzz 示例。 我们正在使用 Python 包Redis与 Redis 服务器通信。 RedisAI 已经建立了一个正式的客户端,但是在撰写本文时还不能使用它。

r = redis.Redis()
MODEL_PATH = 'fizbuz_model.pt'
with open(MODEL_PATH,'rb') as f:
    model_pt = f.read()
r.execute_command('AI.MODELSET', 'model', 'TORCH', 'CPU',model_pt)

上面的脚本首先打开与本地主机的 Redis 连接。 它读取以前使用 TorchScript 保存的二进制模型,并使用命令AI.MODELSET在 RedisAI 中设置 Torch 模型。 该命令需要我们为服务器中的模型传递所需的名称,无论是要使用 CPU 还是 GPU,我们都想使用该后端,然后是二进制模型文件本身。 模型设置命令返回一条正常消息,然后循环浏览并等待用户输入。 如前所述,用户输入通过编码器传递,以将其转换为二进制编码格式。

while True:
    number = int(input('Enter number, press CTRL+c to exit: ')) + 1
    inputs = encoder(number)

    r.execute_command('AI. TENSORSET', 'a', 'FLOAT', *inputs.shape, 'BLOB',inputs.tobytes())
    r.execute_command('AI.MODELRUN', 'model', 'INPUTS', 'a','OUTPUTS', 'out')
    typ, shape, buf = r.execute_command('AI.TENSORGET', 'out','BLOB')
    prediction = np.frombuffer(buf, dtype=np.float32).argmax()
    print(get_readable_output(number, prediction))

然后,我们使用AI.TENSORSET来设置张量并将其映射到关键点。 您可能已经看到了我们将输入 NumPy 数组传递给后端的方式。 NumPy 有一个方便的函数tobytes(),它给出了如何将数据存储在内存中的字符串格式。 我们明确告诉命令我们需要将模型另存为BLOB。 保存模型的另一个选项是VALUES,当您要保存更大的数组时,它不是很有用。

我们还必须传递数据类型和输入张量的形状。 做张量集时,我们应该考虑的一件事是数据类型和形状。 由于我们将输入作为缓冲区传递,因此 RedisAI 尝试使用我们传递的形状和数据类型信息将缓冲区转换为 DLPack 张量。 如果这与我们传递的字节串的长度不匹配,RedisAI 将抛出错误。

设置张量后,我们将模型保存在名为model的键中,并将张量保存在名为a的键中。 现在,我们可以通过传递模型键名称和张量键名称来运行AI.MODELRUN命令。

如果有多个输入要传递,我们将使用张量集不止一次,并将所有键作为INPUTS传递给MODELRUN命令。 MODELRUN命令将输出保存到OUTPUTS下提到的键,然后AI.TENSORGET可以读取。

在这里,我们像保存了一样将张量读为BLOB。 张量命令为我们提供类型,形状和自身的缓冲。 然后将缓冲区传递给 NumPy 的frombuffer()函数,该函数为我们提供了结果的 NumPy 数组。

一旦我们从 RedisAI 中获得了数据,那么其他章节中的内容将相同。 RedisAI 似乎是当前市场上可用于 AI 开发人员的最有前途的生产部署系统。 它甚至还处于早期阶段,并于 4 月在 RedisConf 2019 上发布。 我们可以在不久的将来看到 RedisAI 带来的许多惊人功能,这使其成为大部分 AI 社区事实上的部署机制。

总结

在本章中,我们从最简单但性能最低的方法开始,使用了三种不同的方法将 PyTorch 投入生产:使用 Flask。 然后,我们转移到 MXNet 模型服务器,这是一个预先构建的,优化的服务器实现,可以使用管理 API 进行管理。 MXNet 模型服务器对不需要太多复杂性但需要可以根据需要扩展的高效服务器实现的人很有用。

最后,我们尝试使用 TorchScript 创建模型的最有效版本,并将其导入 C++ 中。 对于那些准备承担构建和维护 C++,Go 或 Rust 等底层语言服务器的复杂性的人,可以采用这种方法并构建自定义服务器,直到我们有可以读取脚本模块的更好的运行时为止,就像 MXNet 在 ONNX 模型上一样。

2018 年是模型服务器的一年; 有许多来自不同组织的模型服务器,它们具有不同的观点。 但是未来是光明的,我们可以看到越来越多的模型服务器每天都在问世,这可能会使所有前面提到的方法过时。

参考

  1. https://pytorch.org/docs/stable/jit.html
  2. https://github.com/awslabs/mxnet-model-server/blob/master/docs/management_api.md
  3. https://redis.io/topics/introduction
posted @ 2026-03-25 10:34  布客飞龙II  阅读(2)  评论(0)    收藏  举报