PyTorch-深度学习编程-全-

PyTorch 深度学习编程(全)

原文:Programming Pytorch for Deep Learning

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

当今世界的深度学习

你好,欢迎!本书将通过 PyTorch 这个由 Facebook 于 2017 年发布的开源库来介绍深度学习。除非你过去几年一直把头埋在地里,否则你一定会注意到神经网络如今无处不在。它们已经从计算机科学中人们学习后却不做任何事情的非常酷的部分,变成了我们每天随身携带的手机中,用来改善我们的图片或听取我们的语音指令。我们的电子邮件软件读取我们的邮件并生成上下文相关的回复,我们的扬声器倾听我们,汽车自动驾驶,计算机终于在围棋上战胜了人类。我们还看到这项技术被用于更邪恶的目的,在威权国家,神经网络支持的哨兵可以从人群中识别出面孔,并决定是否应该逮捕他们。

尽管感觉一切发生得如此迅速,但神经网络和深度学习的概念早已存在很久。证明这样一个网络可以作为一种以近似方式替代任何数学函数的方式运行的证据,这是神经网络可以被训练用于许多不同任务的基础,可以追溯到 1989 年,而卷积神经网络在 90 年代后期就被用来识别支票上的数字了。这一切时间里一直在建立坚实的基础,那么为什么感觉在过去的 10 年里发生了爆炸呢?

有很多原因,但其中最主要的原因必须是图形处理单元(GPU)性能的激增以及它们日益可负担的价格。最初设计用于游戏的 GPU 需要每秒执行数以百万计的矩阵运算,以便在您的游戏机或 PC 上渲染所有多边形,这些操作是标准 CPU 无法优化的。2009 年的一篇论文,“使用图形处理器进行大规模深度无监督学习”由 Rajat Raina 等人指出,训练神经网络也是基于执行大量矩阵运算,因此这些附加的图形卡可以用于加速训练,同时使更大、更深的神经网络架构首次变得可行。其他重要技术,如Dropout(我们将在第三章中讨论),也在过去的十年中被引入,作为不仅加速训练而且使训练更泛化的方法(这样网络不仅学会识别训练数据,还会遇到我们将在下一章中遇到的过拟合问题)。在过去几年里,公司们已经将这种基于 GPU 的方法推向了一个新的水平,谷歌创建了他们所描述的张量处理单元(TPUs),这些设备专门用于尽可能快地执行深度学习,并且甚至作为谷歌云生态系统的一部分向普通公众提供。

过去十年来,追踪深度学习的进展的另一种方式是通过 ImageNet 竞赛。ImageNet 是一个包含超过 1400 万张图片的庞大数据库,手动标记为 20000 个类别,对于机器学习目的来说,ImageNet 是一个标记数据的宝库。自 2010 年以来,每年的 ImageNet 大规模视觉识别挑战赛一直试图测试所有参与者对数据库的 1000 个类别子集的处理能力,直到 2012 年,挑战的错误率一直在 25%左右。然而,那一年,一个深度卷积神经网络以 16%的错误率赢得了比赛,远远超过了所有其他参赛者。随着接下来的几年,错误率不断下降,直到 2015 年,ResNet 架构获得了 3.6%的结果,超过了 ImageNet 上的平均人类表现(5%)。我们被超越了。

但深度学习究竟是什么,我需要博士学位才能理解吗?

深度学习的定义通常比启发性更令人困惑。一种定义深度学习的方式是说,深度学习是一种利用多个和众多层的非线性变换逐渐从原始输入中提取特征的机器学习技术。这是正确的,但并没有真正帮助,对吧?我更喜欢将其描述为一种通过提供输入和期望输出来解决问题的技术,并让计算机找到解决方案,通常使用神经网络。

深度学习中吓倒很多人的一件事是数学。看看这个领域的任何论文,你将会看到几乎无法理解的大量符号,到处都是希腊字母,你可能会吓得四处奔跑。事实是:在大多数情况下,你不需要成为数学天才来使用深度学习技术。实际上,对于技术的大多数日常基本用途,你根本不需要了解太多,要真正理解正在发生的事情(正如你将在第二章中看到的那样),你只需要稍微努力一下,理解你可能在高中学到的概念。所以不要太害怕数学。到第三章结束时,你将能够用几行代码组建一个图像分类器,与 2015 年最优秀的人才所能提供的相媲美。

PyTorch

正如我在开头提到的,PyTorch 是 Facebook 提供的一个开源工具,可以在 Python 中编写深度学习代码。它有两个来源。首先,也许并不奇怪,鉴于其名称,它从 Torch 中获得了许多功能和概念,Torch 是一个基于 Lua 的神经网络库,可以追溯到 2002 年。它的另一个主要来源是 Chainer,于 2015 年在日本创建。Chainer 是最早提供了一种急切的差异化方法而不是定义静态图的神经网络库之一,这种方法允许在创建、训练和操作网络时具有更大的灵活性。Torch 的遗产加上 Chainer 的思想使得 PyTorch 在过去几年中变得流行。

该库还配备了一些模块,可帮助处理文本、图像和音频(torchtexttorchvisiontorchaudio),以及流行架构的内置变体,如 ResNet(可下载权重以提供对迁移学习等技术的帮助,你将在第四章中看到)。

除了 Facebook 之外,PyTorch 在工业界得到了快速的接受,包括 Twitter、Salesforce、Uber 和 NVIDIA 等公司在其深度学习工作中以各种方式使用它。啊,但我感觉到有一个问题要来了……

那么 TensorFlow 呢?

是的,让我们来谈谈那只角落里的相当大的、带有 Google 标志的大象。PyTorch 提供了什么,TensorFlow 没有的?为什么你应该学习 PyTorch 呢?

答案是传统的 TensorFlow 与 PyTorch 的工作方式不同,这对于代码和调试有重大影响。在 TensorFlow 中,您使用库来构建神经网络架构的图表示,然后在该图上执行操作,这发生在 TensorFlow 库内部。这种声明式编程方法与 Python 更为命令式的范式有些不符,这意味着 Python TensorFlow 程序可能看起来和感觉有些奇怪和难以理解。另一个问题是静态图声明可能会使在训练和推断时动态修改架构变得更加复杂和充满样板代码,而不像 PyTorch 的方法那样简单。

出于这些原因,PyTorch 在面向研究的社区中变得流行。在过去一年中,提交给国际学习表示会议的论文中提到PyTorch的数量增加了 200%,提到TensorFlow的论文数量几乎同样增加。PyTorch 绝对会持续存在。

然而,在更近期的 TensorFlow 版本中,一项名为eager execution的新功能已被添加到库中,使其能够类似于 PyTorch 工作,并且将是 TensorFlow 2.0 中推广的范式。但由于在谷歌之外的资源帮助您学习这种与 PyTorch 类似的工作方法的资源稀缺,再加上您需要多年的工作经验来理解另一种范式,以便充分利用该库。

但这一切都不应让您对 TensorFlow 产生负面看法;它仍然是一个经过行业验证的库,得到了全球最大公司之一的支持。PyTorch(当然,由全球另一家最大公司支持)是我会说,更简化和专注于深度学习和微分编程的方法。因为它不必继续支持旧的、陈旧的 API,所以在 PyTorch 中教学和变得高效比在 TensorFlow 中更容易。

Keras 在其中的位置如何?有很多好问题!Keras 是一个高级深度学习库,最初支持 Theano 和 TensorFlow,现在也支持某些其他框架,如 Apache MXNet。它提供了一些功能,如训练、验证和测试循环,这些功能在低级框架中留给开发人员自己实现,以及构建神经网络架构的简单方法。它对 TensorFlow 的推广做出了巨大贡献,现在已经成为 TensorFlow 本身的一部分(作为tf.keras),同时仍然是一个独立的项目。相比之下,PyTorch 在原始 TensorFlow 和 Keras 之间有些中间地带;我们将不得不编写自己的训练和推断例程,但创建神经网络几乎和 Keras 一样简单(我会说 PyTorch 的创建和重用架构方法对于 Python 开发人员来说比某些 Keras 的魔法更合乎逻辑)。

正如您在本书中所看到的,尽管 PyTorch 在更多面向研究的职位中很常见,但随着 PyTorch 1.0 的出现,它完全适用于生产用例。

本书使用的约定

本书使用以下排版约定:

斜体

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

等宽

用于程序清单,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示用户应该按照字面输入的命令或其他文本。

等宽斜体

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

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素表示警告或注意事项。

使用代码示例

可下载补充材料(包括代码示例和练习)请访问https://oreil.ly/pytorch-github

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

我们感谢但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Ian Pointer(O'Reilly)的《深度学习 PyTorch 编程》。2019 年 Ian Pointer 著,978-1-492-04535-9。”

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

致谢

衷心感谢我的编辑 Melissa Potter,我的家人和 Tammy Edlund 在使这本书成为可能的过程中提供的所有帮助。还要感谢在写作过程中提供宝贵反馈的技术审阅人员,包括 Phil Rhodes、David Mertz、Charles Givre、Dominic Monn、Ankur Patel 和 Sarah Nagy。

请参见 George Cybenko(1989)的“由 Sigmoidal 函数的叠加逼近”。

请注意,PyTorch 从 Chainer 借鉴了一些想法,但没有实际代码。

第一章:开始使用 PyTorch

在本章中,我们设置了使用 PyTorch 所需的一切。一旦我们完成了这一步,随后的每一章都将在这个初始基础上构建,因此很重要我们做对。这导致我们的第一个基本问题:您应该构建一个自定义深度学习计算机,还是只使用众多可用的基于云的资源之一?

构建自定义深度学习机器

在深度学习中,有一种冲动,想要为自己的计算需求建造一个庞然大物。您可以花费数天时间查看不同类型的显卡,了解可能的 CPU 选择提供的内存通道,购买最佳类型的内存,以及购买多大的 SSD 驱动器以尽可能快地访问磁盘。我并不是在宣称自己对此免疫;几年前,我花了一个月的时间列出零件清单,在我的餐桌上组装了一台新电脑。

我的建议,特别是对于新手来说,是:不要这样做。您可以轻松地在一台您可能不会经常使用的机器上花费数千美元。相反,我建议您通过使用云资源(无论是亚马逊网络服务、谷歌云还是微软 Azure)来阅读本书,然后再考虑是否需要为自己建造一台机器,如果您觉得需要一台全天候运行的单机。您不需要在硬件上进行巨额投资来运行本书中的任何代码。

您可能永远不需要为自己建造一台定制机器。有一个甜蜜的点,如果您知道您的计算总是会受限于一台机器(最多几个 GPU),那么建造一个定制机器可能会更便宜。然而,如果您的计算开始需要跨越多台机器和 GPU,云再次变得有吸引力。考虑到组装一台定制机器的成本,我建议您在深入之前三思而后行。

如果我还没有劝阻您自己组装机器,接下来的部分将提供您需要做的建议。

GPU

每个深度学习盒子的核心,GPU,将为大多数 PyTorch 的计算提供动力,并且很可能是您机器中最昂贵的组件。近年来,由于它们在挖掘比特币等加密货币中的使用,GPU 的价格已经上涨,供应量也在减少。幸运的是,这个泡沫似乎正在消退,GPU 的供应量又变得更加充裕。

在撰写本文时,我建议选择 NVIDIA GeForce RTX 2080 Ti。如果想要更便宜的选项,可以选择 1080 Ti(尽管如果您因预算原因考虑选择 1080 Ti,我再次建议您考虑云选项)。尽管 AMD 制造的 GPU 卡确实存在,但它们在 PyTorch 中的支持目前还不够好,无法推荐除 NVIDIA 卡以外的其他选择。但请留意他们的 ROCm 技术,这将最终使它们成为 GPU 领域的可信替代品。

CPU/主板

您可能会选择 Z370 系列主板。许多人会告诉您,CPU 对于深度学习并不重要,只要有强大的 GPU,您可以使用速度较低的 CPU。但根据我的经验,CPU 往往会成为瓶颈,尤其在处理增强数据时。

RAM

更多的 RAM 是好的,因为这意味着您可以将更多数据保存在内存中,而不必访问速度慢得多的磁盘存储(尤其是在训练阶段非常重要)。您应该至少考虑为您的机器配备 64GB DDR4 内存。

存储

自定义机箱的存储应该分为两类:首先,一个 M2 接口固态硬盘(SSD)——尽可能大——用于存储您正在积极工作的项目时保持尽可能快的访问速度的数据。对于第二类存储,添加一个 4TB 串行 ATA(SATA)驱动器,用于您当前不在积极工作的数据,并根据需要转移到存储。

我建议您查看PCPartPicker来浏览其他人的深度学习机器(您还可以看到所有奇怪和疯狂的机箱设计想法!)。您将了解到机器零件清单和相关价格,这些价格可能会大幅波动,尤其是 GPU 卡的价格。

现在您已经查看了本地的物理机器选项,是时候转向云端了。

云端深度学习

好了,那么为什么云端选项更好呢?尤其是如果您已经查看了亚马逊网络服务(AWS)的定价方案,并计算出构建深度学习机器将在六个月内收回成本?想一想:如果您刚开始,您不会在这六个月内全天候使用那台机器。您就是不会。这意味着您可以关闭云端机器,并支付存储数据的几分钱。

如果您刚开始,您不需要立即使用 NVIDIA 的庞大 Tesla V100 卡连接到您的云实例。您可以从更便宜的(有时甚至是免费的)基于 K80 的实例开始,然后在准备好时升级到更强大的卡。这比在自定义盒子上购买基本 GPU 卡并升级到 2080Ti 便宜一点。此外,如果您想在单个实例中添加八张 V100 卡,您只需点击几下即可。试试用您自己的硬件做到这一点。

另一个问题是维护。如果您养成良好的习惯,定期重新创建云实例(最好每次回来进行实验时都重新开始),您几乎总是会有一个最新的机器。如果您有自己的机器,更新就取决于您。这就是我承认我有自己的定制深度学习机器的地方,我忽视了它上面的 Ubuntu 安装很长时间,结果是它不再接收支持的更新,最终花了一整天的时间来让系统恢复到可以再次接收更新的状态。令人尴尬。

无论如何,您已经决定转向云端。万岁!接下来:选择哪个提供商?

Google Colaboratory

但等等——在我们查看提供商之前,如果您根本不想做任何工作怎么办?不想建立一台机器或者不想费心设置云端实例?哪里有真正懒惰的选择?谷歌为您提供了正确的东西。Colaboratory(或Colab是一个大多数免费、无需安装的定制 Jupyter Notebook 环境。您需要一个谷歌账号来设置您自己的笔记本。图 1-1 显示了在 Colab 中创建的笔记本的屏幕截图。

Colab 之所以成为深度学习的绝佳方式,是因为它包含了预安装的 TensorFlow 和 PyTorch 版本,因此您无需进行任何设置,只需键入import torch,每个用户都可以免费获得长达 12 小时的连续运行时间的 NVIDIA T4 GPU。免费的。从这个角度来看,实证研究表明,您在训练时大约可以获得 1080 Ti 速度的一半,但内存额外增加了 5GB,因此您可以存储更大的模型。它还提供了连接到更近期的 GPU 和谷歌定制的 TPU 硬件的能力,但您几乎可以使用 Colab 免费完成本书中的每个示例。因此,我建议一开始就使用 Colab,并随后根据需要决定是否扩展到专用云实例和/或您自己的个人深度学习服务器。

谷歌 Colab

图 1-1. 谷歌 Colab(实验室)

Colab 是零工作量的方法,但你可能想要对安装方式或在云端实例上获取安全外壳(SSH)访问有更多控制,因此让我们看看主要云服务提供商提供了什么。

云服务提供商

三大云服务提供商(亚马逊网络服务、谷歌云平台和微软的 Azure)都提供基于 GPU 的实例(也称为虚拟机VMs)和官方镜像以部署在这些实例上。它们提供了一切你需要的,无需自己安装驱动程序和 Python 库即可运行。让我们看看每个提供商提供了什么。

亚马逊网络服务

AWS,云市场的 800 磅大猩猩,乐意满足你的 GPU 需求,并提供 P2 和 P3 实例类型来帮助你。(G3 实例类型更多用于实际的基于图形的应用程序,如视频编码,所以我们这里不涉及。)P2 实例使用较旧的 NVIDIA K80 卡(最多可以连接 16 个到一个实例),而 P3 实例使用快速的 NVIDIA V100 卡(如果你敢的话,你可以在一个实例上连接八个)。

如果你要使用 AWS,我建议选择p2.xlarge类。在撰写本书时,这将花费你每小时仅 90 美分,并为你提供足够的计算能力来完成示例。当你开始参加一些有挑战性的 Kaggle 比赛时,你可能会想升级到 P3 类。

在 AWS 上创建并运行深度学习框非常容易:

  1. 登录到 AWS 控制台。

  2. 选择 EC2 并点击启动实例。

  3. 搜索深度学习 AMI(Ubuntu)选项并选择它。

  4. 选择p2.xlarge作为你的实例类型。

  5. 启动实例,可以创建新的密钥对或重用现有的密钥对。

  6. 通过使用 SSH 连接并将本地机器上的端口 8888 重定向到实例来连接到实例:

    
    ssh-Llocalhost:8888:localhost:8888\ -i*`your``.pem``filename`*ubuntu@*`your``instance``DNS`*
    
  7. 通过输入**jupyter notebook**来启动 Jupyter Notebook。复制生成的 URL 并粘贴到浏览器中以访问 Jupyter。

记得在不使用时关闭你的实例!你可以通过在 Web 界面中右键单击实例并选择关闭选项来实现这一点。这将关闭实例,并在实例不运行时不会向你收费。然而,即使实例关闭,你仍会被收取为其分配的存储空间费用,所以请注意。要完全删除实例和存储,请选择终止选项。

Azure

与 AWS 一样,Azure 提供了一些更便宜的基于 K80 的实例和更昂贵的 Tesla V100 实例。Azure 还提供基于较旧的 P100 硬件的实例,作为其他两种之间的中间点。同样,我建议本书使用单个 K80(NC6)的实例类型,这也每小时花费 90 美分,并根据需要转移到其他 NC、NCv2(P100)或 NCv3(V100)类型。

以下是如何在 Azure 中设置 VM:

  1. 登录到 Azure 门户,并在 Azure Marketplace 中找到 Data Science Virtual Machine 镜像。

  2. 点击立即获取按钮。

  3. 填写 VM 的详细信息(为其命名,选择 SSD 磁盘而不是 HDD,一个 SSH 用户名/密码,将实例计费到的订阅,以及将位置设置为最接近你的提供 NC 实例类型的地点)。

  4. 点击创建选项。实例应该在大约五分钟内被配置。

  5. 你可以使用指定给该实例的公共域名系统(DNS)名称的用户名/密码来使用 SSH。

  6. 当实例被配置时,Jupyter Notebook 应该运行;导航至http://实例的 DNS 名称:8000并使用你用于 SSH 登录的用户名/密码组合登录。

谷歌云平台

除了像亚马逊和 Azure 一样提供 K80、P100 和 V100 支持的实例外,Google Cloud Platform(GCP)还为那些具有巨大数据和计算需求的人提供了上述的 TPUs。您不需要本书中的 TPUs,它们价格昂贵,但它们与 PyTorch 1.0 一起使用,因此不要认为您必须使用 TensorFlow 才能利用它们,如果您有一个需要使用它们的项目。

开始使用 Google Cloud 也非常简单:

  1. 在 GCP Marketplace 上搜索 Deep Learning VM。

  2. 在 Compute Engine 上点击启动。

  3. 为实例命名并将其分配给您最近的区域。

  4. 将机器类型设置为 8 个 vCPU。

  5. 将 GPU 设置为 1 K80。

  6. 确保在框架部分中选择 PyTorch 1.0。

  7. 选择“第一次启动时自动安装 NVIDIA GPU?”复选框。

  8. 将启动磁盘设置为 SSD 持久磁盘。

  9. 单击部署选项。虚拟机将需要大约 5 分钟才能完全部署。

  10. 要连接到实例上的 Jupyter,请确保您已登录到gcloud中的正确项目,并发出以下命令:

    gcloud compute ssh _INSTANCE_NAME_ -- -L 8080:localhost:8080
    

Google Cloud 的费用大约为每小时 70 美分,是三家主要云服务提供商中最便宜的。

应该使用哪个云服务提供商?

如果没有任何事情吸引您,我建议使用 Google Cloud Platform(GCP);这是最便宜的选择,如果需要,您可以扩展到使用 TPUs,比 AWS 或 Azure 提供的灵活性更大。但如果您已经在另外两个平台中的一个上拥有资源,那么在这些环境中运行将完全没问题。

一旦您的云实例运行起来,您将能够登录到其 Jupyter Notebook 的副本,所以下面让我们来看看。

使用 Jupyter Notebook

如果您以前没有接触过它,这里是关于 Jupyter Notebook 的简介:这个基于浏览器的环境允许您将实时代码与文本、图像和可视化混合在一起,已经成为全球数据科学家的事实标准工具之一。在 Jupyter 中创建的笔记本可以轻松共享;实际上,您会在本书中的所有笔记本中找到。您可以在图 1-2 中看到 Jupyter Notebook 的截图。

在本书中,我们不会使用 Jupyter 的任何高级功能;您只需要知道如何创建一个新的笔记本,以及 Shift-Enter 如何运行单元格的内容。但如果您以前从未使用过它,我建议在进入第二章之前浏览Jupyter 文档

Jupyter Notebook

图 1-2. Jupyter Notebook

在我们开始使用 PyTorch 之前,我们将讨论最后一件事:如何手动安装所有内容。

从头开始安装 PyTorch

也许您想对软件有更多控制,而不是使用之前提供的云镜像之一。或者您需要特定版本的 PyTorch 来运行您的代码。或者,尽管我发出了所有警告,您真的想要在地下室中安装那台设备。让我们看看如何在 Linux 服务器上通用安装 PyTorch。

警告

您可以使用 Python 2.x与 PyTorch 一起使用,但我强烈建议不要这样做。尽管 Python 2.x到 3.x的升级已经进行了十多年,但越来越多的软件包开始放弃对 Python 2.x的支持。因此,除非有充分理由,确保您的系统正在运行 Python 3。

下载 CUDA

尽管 PyTorch 可以完全在 CPU 模式下运行,但在大多数情况下,需要 GPU 支持的 PyTorch 才能实现实际用途,因此我们需要 GPU 支持。这相当简单;假设您有一张 NVIDIA 卡,这是由他们的 Compute Unified Device Architecture(CUDA)API 提供的。下载适合您 Linux 版本的适当软件包格式并安装软件包。

对于 Red Hat Enterprise Linux(RHEL)7:

sudo rpm -i cuda-repo-rhel7-10-0local-10.0.130-410.48-1.0-1.x86_64.rpm
sudo yum clean all
sudo yum install cuda

对于 Ubuntu 18.04:

sudo dpkg -i cuda-repo-ubuntu1804-10-0-local-10.0.130-410.48_1.0-1_amd64.deb
sudo apt-key add /var/cuda-repo-<version>/7fa2af80.pub
sudo apt-get update
sudo apt-get install cuda

Anaconda

Python 有各种打包系统,所有这些系统都有好坏之分。与 PyTorch 的开发人员一样,我建议您安装 Anaconda,这是一个专门为数据科学家提供最佳软件包分发的打包系统。与 CUDA 一样,它相当容易安装。

前往Anaconda并选择适合您机器的安装文件。因为这是一个通过 shell 脚本在您的系统上执行的大型存档,我建议您在下载的文件上运行md5sum并将其与签名列表进行比对,然后再使用bash Anaconda3-VERSION-Linux-x86_64.sh执行以确保您机器上的签名与网页上的签名匹配。这可以确保下载的文件没有被篡改,并且可以安全地在您的系统上运行。脚本将提供有关将要安装的位置的几个提示;除非有充分的理由,否则请接受默认设置。

注意

您可能会想:“我能在我的 MacBook 上做这个吗?”遗憾的是,如今大多数 Mac 都配备有 Intel 或 AMD GPU,实际上不支持在 GPU 加速模式下运行 PyTorch。我建议您使用 Colab 或云服务提供商,而不是尝试在本地使用 Mac。

最后,PyTorch!(和 Jupyter Notebook)

现在您已经安装了 Anaconda,使用 PyTorch 很简单:

conda install pytorch torchvision -c pytorch

这将安装 PyTorch 和我们在接下来的几章中使用的torchvision库,用于创建与图像一起工作的深度学习架构。Anaconda 还为我们安装了 Jupyter Notebook,因此我们可以通过启动它来开始:

jupyter notebook

在浏览器中前往http://YOUR-IP-ADDRESS:8888,创建一个新的笔记本,并输入以下内容:

import torch
print(torch.cuda.is_available())
print(torch.rand(2,2))

这应该产生类似于这样的输出:

True
 0.6040  0.6647
 0.9286  0.4210
[torch.FloatTensor of size 2x2]

如果cuda.is_available()返回False,则需要调试您的 CUDA 安装,以便 PyTorch 可以看到您的显卡。在您的实例上,张量的值将不同。

但这个张量是什么?张量几乎是 PyTorch 中的一切,因此您需要知道它们是什么以及它们可以为您做什么。

张量

张量既是数字的容器,也是定义在产生新张量之间的张量之间的转换规则的集合。对于我们来说,将张量视为多维数组可能是最容易的。每个张量都有一个与其维度空间对应的。一个简单的标量(例如,1)可以表示为秩为 0 的张量,一个向量是秩为 1 的,一个n×n矩阵是秩为 2 的,依此类推。在前面的示例中,我们使用torch.rand()创建了一个具有随机值的秩为 2 的张量。我们也可以从列表中创建它们:

x = torch.tensor([[0,0,1],[1,1,1],[0,0,0]])
x
>tensor([[0, 0, 1],
    [1, 1, 1],
    [0, 0, 0]])

我们可以通过使用标准的 Python 索引在张量中更改元素:

x[0][0] = 5
>tensor([[5, 0, 1],
    [1, 1, 1],
    [0, 0, 0]])

您可以使用特殊的创建函数生成特定类型的张量。特别是,ones()zeroes()将分别生成填充有 1 和 0 的张量:

torch.zeros(2,2)
> tensor([[0., 0.],
    [0., 0.]])

您可以使用张量执行标准的数学运算(例如,将两个张量相加):

tensor.ones(1,2) + tensor.ones(1,2)
> tensor([[2., 2.]])

如果您有一个秩为 0 的张量,可以使用item()提取值:

torch.rand(1).item()
> 0.34106671810150146

张量可以存在于 CPU 或 GPU 上,并且可以通过使用to()函数在设备之间进行复制:

cpu_tensor = tensor.rand(2)
cpu_tensor.device
> device(type='cpu')

gpu_tensor = cpu_tensor.to("cuda")
gpu_tensor.device
> device(type='cuda', index=0)

张量操作

如果您查看PyTorch 文档,您会发现有很多函数可以应用于张量——从查找最大元素到应用傅立叶变换等。在本书中,您不需要了解所有这些函数来将图像、文本和音频转换为张量并对其进行操作,但您需要了解一些。我强烈建议您在完成本书后浏览文档。现在我们将逐一介绍将在接下来的章节中使用的所有函数。

首先,我们经常需要找到张量中的最大项以及包含最大值的索引(因为这通常对应于神经网络在最终预测中决定的类)。这可以通过max()argmax()函数来实现。我们还可以使用item()从 1D 张量中提取标准的 Python 值。

torch.rand(2,2).max()
> tensor(0.4726)
torch.rand(2,2).max().item()
> 0.8649941086769104

有时,我们可能想要改变张量的类型;例如,从LongTensorFloatTensor。我们可以使用to()来实现:

long_tensor = torch.tensor([[0,0,1],[1,1,1],[0,0,0]])
long_tensor.type()
> 'torch.LongTensor'
float_tensor = torch.tensor([[0,0,1],[1,1,1],[0,0,0]]).to(dtype=torch.float32)
float_tensor.type()
> 'torch.FloatTensor'

大多数在张量上操作并返回张量的函数会创建一个新的张量来存储结果。然而,如果你想节省内存,可以查看是否定义了一个原地函数,它的名称应该与原始函数相同,但在末尾加上下划线(_)。

random_tensor = torch.rand(2,2)
random_tensor.log2()
>tensor([[-1.9001, -1.5013],
        [-1.8836, -0.5320]])
random_tensor.log2_()
> tensor([[-1.9001, -1.5013],
        [-1.8836, -0.5320]])

另一个常见的操作是重塑张量。这通常是因为你的神经网络层可能需要一个与你当前要输入的形状略有不同的输入形状。例如,手写数字的 Modified National Institute of Standards and Technology (MNIST)数据集是一组 28×28 的图像,但它的打包方式是长度为 784 的数组。为了使用我们正在构建的网络,我们需要将它们转换回 1×28×28 的张量(前导的 1 是通道数——通常是红、绿和蓝,但由于 MNIST 数字只是灰度的,我们只有一个通道)。我们可以使用view()reshape()来实现:

flat_tensor = torch.rand(784)
viewed_tensor = flat_tensor.view(1,28,28)
viewed_tensor.shape
> torch.Size([1, 28, 28])
reshaped_tensor = flat_tensor.reshape(1,28,28)
reshaped_tensor.shape
> torch.Size([1, 28, 28])

请注意,重塑后的张量形状必须与原始张量的总元素数相同。如果你尝试flat_tensor.reshape(3,28,28),你会看到这样的错误:

RuntimeError Traceback (most recent call last)
<ipython-input-26-774c70ba5c08> in <module>()
----> 1 flat_tensor.reshape(3,28,28)

RuntimeError: shape '[3, 28, 28]' is invalid for input of size 784

现在你可能想知道view()reshape()之间的区别是什么。答案是view()作为原始张量的视图操作,所以如果底层数据发生变化,视图也会发生变化(反之亦然)。然而,如果所需的视图不是连续的,view()可能会抛出错误;也就是说,如果它不与从头开始创建的具有所需形状的新张量共享相同的内存块。如果发生这种情况,你必须在使用view()之前调用tensor.contiguous()。然而,reshape()在幕后完成所有这些工作,所以一般来说,我建议使用reshape()而不是view()

最后,你可能需要重新排列张量的维度。你可能会在处理图像时遇到这种情况,图像通常以[height, width, channel]的张量形式存储,但 PyTorch 更喜欢以[channel, height, width]的形式处理。你可以使用permute()来以一种相当简单的方式处理这些:

hwc_tensor = torch.rand(640, 480, 3)
chw_tensor = hwc_tensor.permute(2,0,1)
chw_tensor.shape
> torch.Size([3, 640, 480])

在这里,我们刚刚对一个[640,480,3]的张量应用了permute,参数是张量维度的索引,所以我们希望最终维度(由于从零开始索引,是 2)在张量的前面,后面是剩下的两个维度按照原始顺序。

张量广播

从 NumPy 借鉴的广播允许你在张量和较小张量之间执行操作。如果从它们的尾部维度开始向后看,你可以在两个张量之间进行广播:

  • 两个维度相等。

  • 一个维度是 1。

在我们使用广播时,它有效是因为 1 有一个维度是 1,而且没有其他维度,1 可以扩展到另一个张量。如果我们尝试将一个[2,2]张量加到一个[3,3]张量上,我们会得到这样的错误消息:

The size of tensor a (2) must match the size of
tensor b (3) at non-singleton dimension 1

但是我们可以毫无问题地将一个[1,3]张量加到一个[3,3]张量上。广播是一个方便的小功能,可以增加代码的简洁性,并且通常比手动扩展张量更快。

关于张量的一切你需要开始的内容就到这里了!我们将在书中后面遇到其他一些操作,但这已经足够让你深入第二章了。

结论

无论是在云端还是在本地机器上,您现在应该已经安装了 PyTorch。我已经介绍了该库的基本构建模块,张量,您已经简要了解了 Jupyter Notebook。这就是您开始的全部所需!在下一章中,您将利用到目前为止所见的一切来开始构建神经网络和对图像进行分类,所以在继续之前,请确保您对张量和 Jupyter 感到舒适。

进一步阅读

第二章:使用 PyTorch 进行图像分类

在设置 PyTorch 之后,深度学习教材通常会在做任何有趣的事情之前向你抛出一堆行话。我尽量将其减少到最低限度,并通过一个例子来解释,尽管这个例子可以在你更熟悉使用 PyTorch 的过程中轻松扩展。我们在整本书中使用这个例子来演示如何调试模型(第七章)或将其部署到生产环境(第八章)。

从现在开始直到第四章结束,我们将构建一个图像分类器。神经网络通常用作图像分类器;网络被给予一张图片,并被问到对我们来说是一个简单的问题:“这是什么?”

让我们开始构建我们的 PyTorch 应用程序。

我们的分类问题

在这里,我们构建一个简单的分类器,可以区分鱼和猫之间的区别。我们将不断迭代设计和构建模型的过程,使其变得更加准确。

图 2-1 和 2-2 展示了一条鱼和一只猫的全貌。我不确定这条鱼是否有名字,但这只猫叫 Helvetica。

让我们从讨论传统分类中涉及的挑战开始。

一条鱼的图片

图 2-1. 一条鱼!

一个黑猫在盒子里的图片

图 2-2. 盒子里的 Helvetica

传统挑战

你会如何编写一个程序来区分鱼和猫?也许你会编写一组规则,描述猫有尾巴,或者鱼有鳞片,并将这些规则应用于图像以确定你看到的是什么。但这需要时间、精力和技能。另外,如果你遇到像曼克斯猫这样的东西会发生什么;虽然它显然是一只猫,但它没有尾巴。

你可以看到这些规则只会变得越来越复杂,以描述所有可能的情况。此外,我承认我在图形编程方面非常糟糕,所以不得不手动编写所有这些规则的想法让我感到恐惧。

我们追求的是一个函数,给定一张图片的输入,返回。对于我们来说,通过详细列出所有标准来构建这个函数是困难的。但深度学习基本上让计算机做所有那些我们刚刚谈到的规则的艰苦工作——只要我们创建一个结构,给网络大量数据,并让它找出是否得到了正确答案的方法。这就是我们要做的。在这个过程中,你将学习如何使用 PyTorch 的一些关键概念。

但首先,数据

首先,我们需要数据。需要多少数据?这取决于情况。对于任何深度学习技术都需要大量数据来训练神经网络的想法并不一定正确,正如你将在第四章中看到的那样。然而,现在我们将从头开始训练,这通常需要大量数据。我们需要很多鱼和猫的图片。

现在,我们可以花一些时间从 Google 图像搜索等地方下载许多图片,但在这种情况下,我们有一个捷径:一个用于训练神经网络的标准图像集合,称为ImageNet。它包含超过 1400 万张图片和 20000 个图像类别。这是所有图像分类器用来评判自己的标准。所以我从那里获取图片,但如果你愿意,可以自行下载其他图片。

除了数据,PyTorch 还需要一种确定什么是猫和什么是鱼的方法。这对我们来说很容易,但对计算机来说有点困难(这也是我们首次构建程序的原因!)。我们使用附加到数据的标签,以这种方式进行训练称为监督学习。(当您无法访问任何标签时,您必须使用无监督学习方法进行训练,这可能并不令人惊讶。)

现在,如果我们使用 ImageNet 数据,它的标签对我们来说并不是那么有用,因为它们包含了对我们来说太多的信息。tabby cattrout这样的标签,在计算机看来,与catfish是分开的。我们需要重新标记这些。因为 ImageNet 是如此庞大的图像集合,我已经整理了一份图像 URL 和标签的列表供鱼类和猫类使用。

您可以在该目录中运行download.py脚本,它将从 URL 下载图像并将其放置在适当的位置进行训练。重新标记很简单;脚本将猫的图片存储在train/cat目录中,将鱼的图片存储在train/fish目录中。如果您不想使用下载脚本,只需创建这些目录并将适当的图片放在正确的位置。现在我们有了数据,但我们需要将其转换为 PyTorch 可以理解的格式。

PyTorch 和数据加载器

加载和转换数据为训练准备的格式通常会成为数据科学中吸收我们太多时间的领域之一。PyTorch 已经发展了与数据交互的标准约定,使得与之一起工作变得相当一致,无论您是在处理图像、文本还是音频。

与数据交互的两个主要约定是数据集数据加载器数据集是一个 Python 类,允许我们访问我们提供给神经网络的数据。数据加载器是将数据从数据集传送到网络的工具。(这可能包括信息,例如,有多少个工作进程正在将数据传送到网络中?我们一次传入多少张图片?

让我们先看看数据集。无论数据集包含图像、音频、文本、3D 景观、股市信息还是其他任何内容,只要满足这个抽象的 Python 类,就可以与 PyTorch 进行交互:

class Dataset(object):
    def __getitem__(self, index):
        raise NotImplementedError

    def __len__(self):
        raise NotImplementedError

这是相当直接的:我们必须实现一个返回数据集大小的方法(len),并实现一个可以检索数据集中项目的方法,返回一个(*label**tensor*)对。这是由数据加载器调用的,因为它正在将数据推送到神经网络进行训练。因此,我们必须编写一个getitem的主体,它可以获取图像并将其转换为张量,然后返回该张量和标签,以便 PyTorch 可以对其进行操作。这很好,但你可以想象到这种情况经常发生,所以也许 PyTorch 可以让事情变得更容易?

构建训练数据集

torchvision包含一个名为ImageFolder的类,几乎为我们做了一切,只要我们的图像结构中每个目录都是一个标签(例如,所有猫都在一个名为cat的目录中)。对于我们的猫和鱼的示例,这是您需要的:

import torchvision
from torchvision import transforms

train_data_path = "./train/"

transforms = transforms.Compose([
    transforms.Resize(64),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                    std=[0.229, 0.224, 0.225] )
    ])

train_data = torchvision.datasets.ImageFolder
(root=train_data_path,transform=transforms)

这里发生了更多的事情,因为torchvision还允许您指定一系列将应用于图像的转换,然后将其馈送到神经网络之前。默认转换是将图像数据转换为张量(在前面的代码中看到的transforms.ToTensor()方法),但我们还在做一些其他可能不太明显的事情。

首先,GPU 被设计为快速执行标准大小的计算。但我们可能有许多分辨率的图像。为了提高我们的处理性能,我们通过Resize(64)转换将每个传入的图像缩放到相同的分辨率 64×64。然后我们将图像转换为张量,最后我们将张量归一化到一组特定的均值和标准差点周围。

归一化很重要,因为当输入通过神经网络的层时会发生大量的乘法运算;保持输入值在 0 和 1 之间可以防止值在训练阶段变得过大(称为梯度爆炸问题)。这种神奇的化身只是 ImageNet 数据集作为整体的均值和标准差。你可以专门为这个猫和鱼子集计算它,但这些值已经足够好了。(如果你在完全不同的数据集上工作,你将不得不计算那个均值和偏差,尽管许多人只是使用这些 ImageNet 常数并报告可接受的结果。)

可组合的转换还允许我们轻松地进行图像旋转和扭曲以进行数据增强,我们将在第四章中回到这个话题。

注意

在这个例子中,我们将图像调整为 64×64。我做出了这个任意选择,以便使我们即将到来的第一个网络的计算变得快速。大多数现有的架构在第三章中使用 224×224 或 299×299 作为图像输入。一般来说,输入尺寸越大,网络学习的数据就越多。另一方面,你通常可以将更小的图像批次适应到 GPU 的内存中。

我们对数据集还没有完成。但是为什么我们需要不止一个训练数据集呢?

构建验证和测试数据集

我们的训练数据已经设置好了,但我们需要为我们的验证数据重复相同的步骤。这里有什么区别?深度学习(实际上所有机器学习)的一个危险是过拟合的概念:你的模型在训练过的内容上表现得非常好,但无法推广到它没有见过的例子。所以它看到一张猫的图片,除非所有其他猫的图片都与那张图片非常相似,否则模型不认为它是一只猫,尽管它显然是一只猫。为了防止我们的网络这样做,我们在download.py中下载了一个验证集,其中包含一系列不在训练集中出现的猫和鱼的图片。在每个训练周期(也称为epoch)结束时,我们会与这个集合进行比较,以确保我们的网络没有出错。但不用担心,这段代码非常简单,因为它只是稍微更改了一些变量名的早期代码:

val_data_path = "./val/"
val_data = torchvision.datasets.ImageFolder(root=val_data_path,
                                            transform=transforms)

我们只是重新使用了transforms链,而不必再次定义它。

除了验证集,我们还应该创建一个测试集。这用于在所有训练完成后测试模型:

test_data_path = "./test/"
test_data = torchvision.datasets.ImageFolder(root=test_data_path,
                                             transform=transforms)

区分数据集类型可能有点困惑,所以我编制了一张表来指示哪个数据集用于模型训练的哪个部分;请参见表 2-1。

表 2-1. 数据集类型

训练集 用于训练过程中更新模型的数据集
验证集 用于评估模型在问题领域中的泛化能力,而不是适应训练数据;不直接用于更新模型
测试集 在训练完成后提供最终评估模型性能的最终数据集

然后我们可以用几行 Python 代码构建我们的数据加载器:

batch_size=64
train_data_loader = data.DataLoader(train_data, batch_size=batch_size)
val_data_loader  = data.DataLoader(val_data, batch_size=batch_size)
test_data_loader  = data.DataLoader(test_data, batch_size=batch_size)

从这段代码中需要注意的新内容是batch_size。这告诉我们在训练和更新之前有多少图像会通过网络。理论上,我们可以将batch_size设置为测试集和训练集中图像的数量,以便网络在更新之前看到每个图像。实际上,我们通常不这样做,因为较小的批次(在文献中更常被称为小批量)需要比存储数据集中每个图像的所有信息更少的内存,并且较小的批次大小会使训练更快,因为我们更快地更新我们的网络。

默认情况下,PyTorch 的数据加载器设置为batch_size为 1。你几乎肯定会想要更改这个值。虽然我在这里选择了 64,但你可能想要尝试一下,看看你可以使用多大的小批量而不会耗尽 GPU 的内存。你可能还想尝试一些额外的参数:你可以指定数据集如何被采样,是否在每次运行时对整个集合进行洗牌,以及使用多少个工作进程来从数据集中提取数据。所有这些都可以在PyTorch 文档中找到。

这涵盖了将数据导入 PyTorch,所以现在让我们介绍一个简单的神经网络来开始对我们的图像进行分类。

最后,一个神经网络!

我们将从最简单的深度学习网络开始:一个输入层,用于处理输入张量(我们的图像);一个输出层,其大小将是输出类别数量(2)的大小;以及它们之间的一个隐藏层。在我们的第一个示例中,我们将使用全连接层。图 2-3 展示了一个具有三个节点的输入层,三个节点的隐藏层和两个节点输出的样子。

一个简单的神经网络

图 2-3. 一个简单的神经网络

正如你所看到的,在这个全连接的例子中,每一层中的每个节点都会影响到下一层中的每个节点,并且每个连接都有一个权重,它决定了从该节点传入下一层的信号的强度。当我们训练网络时,这些权重通常会从随机初始化中更新。当一个输入通过网络时,我们(或 PyTorch)可以简单地将该层的权重和偏置进行矩阵乘法,然后将结果传递到下一个函数中,该结果会经过一个激活函数,这只是一种在我们的系统中插入非线性的方法。

激活函数

激活函数听起来很复杂,但你在文献中最常见的激活函数是ReLU,或者修正线性单元。这再次听起来很复杂!但事实证明,它只是实现max(0,x)的函数,所以如果输入是负数,则结果为 0,如果x是正数,则结果就是输入(x)。简单!

你可能会遇到的另一个激活函数是softmax,在数学上稍微复杂一些。基本上它会产生一组介于 0 和 1 之间的值,加起来等于 1(概率!),并且加权这些值以夸大差异——也就是说,它会在向量中产生一个比其他所有值都高的结果。你经常会看到它被用在分类网络的末尾,以确保网络对输入属于哪个类别做出明确的预测。

有了所有这些构建块,我们可以开始构建我们的第一个神经网络。

创建一个网络

在 PyTorch 中创建一个网络是一个非常 Pythonic 的事情。我们从一个名为torch.nn.Network的类继承,并填写__init__forward方法:

class SimpleNet(nn.Module):

def __init__(self):
    super(Net, self).__init__()
    self.fc1 = nn.Linear(12288, 84)
    self.fc2 = nn.Linear(84, 50)
    self.fc3 = nn.Linear(50,2)

def forward(self):
    x = x.view(-1, 12288)
    x = F.relu(self.fc1(x))
    x = F.relu(self.fc2(x))
    x = F.softmax(self.fc3(x))
    return x

simplenet = SimpleNet()

同样,这并不太复杂。我们在init()中进行任何所需的设置,这种情况下调用我们的超类构造函数和三个全连接层(在 PyTorch 中称为Linear,而不是 Keras 中的Dense)。forward()方法描述了数据如何在网络中流动,无论是在训练还是进行预测(推理)。首先,我们必须将图像中的 3D 张量(xy加上三通道的颜色信息—红色、绿色、蓝色)转换为 1D 张量,以便将其馈送到第一个Linear层中,我们使用view()来实现这一点。从那里,您可以看到我们按顺序应用层和激活函数,最后返回softmax输出以给出我们对该图像的预测。

隐藏层中的数字有些是任意的,除了最终层的输出是 2,与我们的两类猫或鱼相匹配。一般来说,您希望在层中的数据在向下堆栈时压缩。如果一个层要将 50 个输入传递到 100 个输出,那么网络可能会通过简单地将 50 个连接传递给 100 个输出中的 50 个来学习,并认为其工作完成。通过减小输出相对于输入的大小,我们迫使网络的这部分学习使用更少的资源来学习原始输入的表示,这希望意味着它提取了一些对我们要解决的问题重要的图像特征;例如,学习识别鳍或尾巴。

我们有一个预测,我们可以将其与原始图像的实际标签进行比较,以查看预测是否正确。但是我们需要一种让 PyTorch 能够量化预测是正确还是错误,以及有多错误或正确的方法。这由损失函数处理。

损失函数

损失函数是有效深度学习解决方案的关键组成部分之一。PyTorch 使用损失函数来确定如何更新网络以达到期望的结果。

损失函数可以是您想要的复杂或简单。PyTorch 配备了一个全面的损失函数集合,涵盖了您可能会遇到的大多数应用程序,当然,如果您有一个非常自定义的领域,您也可以编写自己的损失函数。在我们的情况下,我们将使用一个名为CrossEntropyLoss的内置损失函数,这是推荐用于多类别分类任务的,就像我们在这里所做的那样。您可能会遇到的另一个损失函数是MSELoss,这是一个标准的均方损失,您可能在进行数值预测时使用。

要注意的一件事是,CrossEntropyLoss还将softmax()作为其操作的一部分,因此我们的forward()方法变为以下内容:

def forward(self):
    # Convert to 1D vector
    x = x.view(-1, 12288)
    x = F.relu(self.fc1(x))
    x = F.relu(self.fc2(x))
    x = self.fc3(x)
    return x

现在让我们看看在训练循环期间神经网络的层如何更新。

优化

训练网络涉及通过网络传递数据,使用损失函数确定预测和实际标签之间的差异,然后使用该信息来更新网络的权重,以尽可能使损失函数返回尽可能小的损失。为了对神经网络进行更新,我们使用一个优化器

如果我们只有一个权重,我们可以绘制损失值与权重值的图表,它可能看起来像图 2-4。

损失的二维图

图 2-4。损失的二维图

如果我们从一个随机位置开始,用 X 标记,将我们的权重值放在 x 轴上,损失函数放在 y 轴上,我们需要到曲线的最低点找到我们的最佳解决方案。我们可以通过改变权重的值来移动,这将给我们一个新的损失函数值。要知道我们正在做出的移动有多好,我们可以根据曲线的梯度进行检查。可视化优化器的一种常见方法是像滚动大理石一样,试图找到一系列山谷中的最低点(或最小值)。如果我们将视图扩展到两个参数,创建一个如图 2-5 所示的 3D 图,这可能更清晰。

损失的 3D 图

图 2-5。损失的 3D 图

在这种情况下,我们可以在每个点检查所有潜在移动的梯度,并选择使我们在山下移动最多的那个。

但是,您需要注意一些问题。首先是陷入局部最小值的危险,这些区域看起来像是损失曲线最浅的部分,如果我们检查梯度,但实际上在其他地方存在更浅的区域。如果我们回到图 2-4 中的 1D 曲线,我们可以看到如果通过短跳下陷入左侧的最小值,我们永远不会有离开该位置的理由。如果我们采取巨大的跳跃,我们可能会发现自己进入通往实际最低点的路径,但由于我们一直跳得太大,我们一直在到处弹跳。

我们的跳跃大小被称为学习率,通常是需要调整的关键参数,以便使您的网络学习正确和高效。您将在第四章中看到确定良好学习率的方法,但现在,您将尝试不同的值:尝试从 0.001 开始。正如刚才提到的,较大的学习率会导致网络在训练过程中到处反弹,并且不会收敛到一组良好的权重上。

至于局部最小值问题,我们对获取所有可能梯度进行了轻微修改,并在批处理期间指示样本随机梯度。称为随机梯度下降(SGD),这是优化神经网络和其他机器学习技术的传统方法。但是还有其他优化器可用,事实上对于深度学习来说更可取。PyTorch 提供了 SGD 和其他优化器,如 AdaGrad 和 RMSProp,以及 Adam,我们将在本书的大部分内容中使用的优化器。

Adam 的一个关键改进(RMSProp 和 AdaGrad 也是如此)是它为每个参数使用一个学习率,并根据这些参数的变化速率调整该学习率。它保持梯度和这些梯度的平方的指数衰减列表,并使用这些来缩放 Adam 正在使用的全局学习率。经验表明,Adam 在深度学习网络中优于大多数其他优化器,但您可以将 Adam 替换为 SGD 或 RMSProp 或另一个优化器,以查看是否使用不同的技术能够为您的特定应用程序提供更快更好的训练。

创建基于 Adam 的优化器很简单。我们调用optim.Adam()并传入网络的权重(通过simplenet.parameters()获得)和我们示例的学习率 0.001:

import torch.optim as optim
optimizer = optim.Adam(simplenet.parameters(), lr=0.001)

优化器是拼图的最后一块,所以我们终于可以开始训练我们的网络了。

训练

这是我们完整的训练循环,将迄今为止看到的所有内容结合起来训练网络。我们将其编写为一个函数,以便可以将诸如损失函数和优化器之类的部分作为参数传递。目前看起来相当通用:

for epoch in range(epochs):
    for batch in train_loader:
        optimizer.zero_grad()
        input, target = batch
        output = model(input)
        loss = loss_fn(output, target)
        loss.backward()
        optimizer.step()

这是相当简单的,但你应该注意几点。我们在循环的每次迭代中从训练集中取一个批次,这由我们的数据加载器处理。然后我们通过模型运行这些数据,并计算出期望输出的损失。为了计算梯度,我们在模型上调用backward()方法。optimizer.step()方法随后使用这些梯度来执行我们在前一节中讨论过的权重调整。

然而,zero_grad()调用是在做什么呢?事实证明,默认情况下计算的梯度会累积,这意味着如果我们在批次迭代结束时不将梯度清零,下一个批次将不得不处理这个批次的梯度以及自己的梯度,接下来的批次将不得不处理前两个批次的梯度,依此类推。这并不有用,因为我们希望在每次迭代中只查看当前批次的梯度进行优化。我们使用zero_grad()确保在我们完成循环后将它们重置为零。

这是训练循环的抽象版本,但在写完我们的完整函数之前,我们还需要解决一些问题。

使其在 GPU 上运行

到目前为止,如果你运行了任何代码,你可能已经注意到它并不那么快。那么那块闪亮的 GPU 呢,它就坐在我们云端实例上(或者我们在桌面上组装的非常昂贵的机器上)?PyTorch 默认使用 CPU 进行计算。为了利用 GPU,我们需要通过显式地使用to()方法将输入张量和模型本身移动到 GPU 上。这里有一个将SimpleNet复制到 GPU 的示例:

if torch.cuda.is_available():
        device = torch.device("cuda")
else
    device = torch.device("cpu")

model.to(device)

在这里,如果 PyTorch 报告有 GPU 可用,我们将模型复制到 GPU 上,否则保持模型在 CPU 上。通过使用这种构造,我们可以确定 GPU 是否在我们的代码开始时可用,并在程序的其余部分中使用tensor|model.to(device),确信它会到达正确的位置。

注意

在早期版本的 PyTorch 中,你会使用cuda()方法将数据复制到 GPU 上。如果在查看其他人的代码时遇到这个方法,只需注意它与to()做的是相同的事情!

这就是训练所需的所有步骤。我们快要完成了!

将所有内容整合在一起

在本章中,你已经看到了许多不同的代码片段,让我们整合它们。我们将它们放在一起,创建一个通用的训练方法,接受一个模型,以及训练和验证数据,还有学习率和批次大小选项,并对该模型进行训练。我们将在本书的其余部分中使用这段代码:

def train(model, optimizer, loss_fn, train_loader, val_loader,
epochs=20, device="cpu"):
    for epoch in range(epochs):
        training_loss = 0.0
        valid_loss = 0.0
        model.train()
        for batch in train_loader:
            optimizer.zero_grad()
            inputs, target = batch
            inputs = inputs.to(device)
            target = targets.to(device)
            output = model(inputs)
            loss = loss_fn(output, target)
            loss.backward()
            optimizer.step()
            training_loss += loss.data.item()
        training_loss /= len(train_iterator)

        model.eval()
        num_correct = 0
        num_examples = 0
        for batch in val_loader:
            inputs, targets = batch
            inputs = inputs.to(device)
            output = model(inputs)
            targets = targets.to(device)
            loss = loss_fn(output,targets)
            valid_loss += loss.data.item()
            correct = torch.eq(torch.max(F.softmax(output), dim=1)[1],
							   target).view(-1)
            num_correct += torch.sum(correct).item()
            num_examples += correct.shape[0]
        valid_loss /= len(valid_iterator)

        print('Epoch: {}, Training Loss: {:.2f},
        Validation Loss: {:.2f},
        accuracy = {:.2f}'.format(epoch, training_loss,
        valid_loss, num_correct / num_examples))

这是我们的训练函数,我们可以通过传入所需的参数来启动训练:

train(simplenet, optimizer, torch.nn.CrossEntropyLoss(),
      train_data_loader, test_data_loader,device)

网络将训练 20 个 epochs(你可以通过向train()传入一个值来调整这个值),并且你应该在每个 epoch 结束时得到模型在验证集上的准确性打印输出。

你已经训练了你的第一个神经网络——恭喜!现在你可以用它进行预测,让我们看看如何做到这一点。

进行预测

在本章的开头,我说过我们将制作一个神经网络,可以对图像进行分类,判断是猫还是鱼。我们现在已经训练了一个可以做到这一点的网络,但是我们如何使用它来为单个图像生成预测呢?这里有一段快速的 Python 代码,它将从文件系统加载一张图像,并打印出我们的网络是说“猫”还是“鱼”:

from PIL import Image

labels = ['cat','fish']

img = Image.open(FILENAME)
img = transforms(img)
img = img.unsqueeze(0)

prediction = simplenet(img)
prediction = prediction.argmax()
print(labels[prediction])

大部分代码都很简单;我们重用了之前制作的转换流水线,将图像转换为神经网络所需的正确形式。然而,因为我们的网络使用批次,实际上它期望一个 4D 张量,第一个维度表示批次中的不同图像。我们没有批次,但我们可以通过使用unsqueeze(0)创建一个长度为 1 的批次,这会在我们的张量前面添加一个新的维度。

获取预测就像将我们的batch传递到模型中一样简单。然后我们必须找出具有更高概率的类别。在这种情况下,我们可以简单地将张量转换为数组并比较两个元素,但通常情况下不止这两个元素。幸运的是,PyTorch 提供了argmax()函数,它返回张量中最高值的索引。然后我们使用该索引来索引我们的标签数组并打印出我们的预测。作为练习,使用前面的代码作为基础,在本章开头创建的测试集上进行预测。您不需要使用unsqueeze(),因为您从test_data_loader中获取批次。

这就是您现在需要了解的有关进行预测的全部内容;在第八章中,我们将为生产使用加固事项时再次回顾这一点。

除了进行预测,我们可能希望能够在将来的任何时间点重新加载模型,使用我们训练好的参数,因此让我们看看如何在 PyTorch 中完成这个任务。

模型保存

如果您对模型的性能感到满意或因任何原因需要停止,您可以使用torch.save()方法将模型的当前状态保存为 Python 的pickle格式。相反,您可以使用torch.load()方法加载先前保存的模型迭代。

因此,保存我们当前的参数和模型结构将像这样工作:

torch.save(simplenet, "/tmp/simplenet")

我们可以按以下方式重新加载:

simplenet = torch.load("/tmp/simplenet")

这将模型的参数和结构都存储到文件中。如果以后更改模型的结构,这可能会成为一个问题。因此,更常见的做法是保存模型的state_dict。这是一个标准的 Pythondict,其中包含模型中每个层的参数映射。保存state_dict看起来像这样:

torch.save(model.state_dict(), PATH)

要恢复,首先创建模型的一个实例,然后使用load_state_dict。对于SimpleNet

simplenet = SimpleNet()
simplenet_state_dict = torch.load("/tmp/simplenet")
simplenet.load_state_dict(simplenet_state_dict)

这里的好处是,如果以某种方式扩展了模型,可以向load_state_dict提供一个strict=False参数,该参数将参数分配给模型中存在的层,但如果加载的state_dict中的层缺失或添加到模型的当前结构中,则不会失败。因为它只是一个普通的 Pythondict,您可以更改键名称以适应您的模型,如果您从完全不同的模型中提取参数,这可能会很方便。

在训练运行期间可以将模型保存到磁盘,并在另一个时间点重新加载,以便可以在离开的地方继续训练。当使用像 Google Colab 这样的工具时,这非常有用,它让您在大约 12 小时内持续访问 GPU。通过跟踪时间,您可以在截止日期之前保存模型,并在新的 12 小时会话中继续训练。

结论

您已经快速浏览了神经网络的基础知识,并学会了如何使用 PyTorch 对其进行训练,对其他图像进行预测,并将模型保存/恢复到磁盘。

在阅读下一章之前,尝试一下我们在这里创建的SimpleNet架构。调整Linear层中的参数数量,也许添加一两个额外的层。查看 PyTorch 中提供的各种激活函数,并将ReLU替换为其他函数。看看如果调整学习率或将优化器从 Adam 切换到其他选项(也许尝试普通的 SGD),训练会发生什么变化。也许改变批量大小和图像在前向传递开始时被转换为 1D 张量的初始大小。许多深度学习工作仍处于手工调整阶段;学习率是手动调整的,直到网络被适当训练,因此了解所有移动部件如何相互作用是很重要的。

你可能对SimpleNet架构的准确性有些失望,但不用担心!第三章将引入卷积神经网络,带来明显的改进,取代我们目前使用的非常简单的网络。

进一步阅读

  • PyTorch 文档

  • 《Adam:一种随机优化方法》(2014)作者 Diederik P. Kingma 和 Jimmy Ba

  • 《梯度下降优化算法概述》(2016)作者 Sebstian Ruder

第三章:卷积神经网络

在第二章中尝试使用全连接神经网络后,您可能注意到了一些问题。如果您尝试添加更多层或大幅增加参数数量,您几乎肯定会在 GPU 上耗尽内存。此外,训练时间很长,准确率也不尽如人意,尤其考虑到深度学习的炒作。到底发生了什么呢?

确实,全连接或(前馈)网络可以作为通用逼近器,但理论并没有说明训练它成为您真正想要的函数逼近器需要多长时间。但我们可以做得更好,尤其是对于图像。在本章中,您将了解卷积神经网络(CNNs)以及它们如何构成当今最准确的图像分类器的基础(我们会详细看一些)。我们为我们的鱼与猫应用程序构建了一个基于卷积的新架构,并展示它比我们在上一章中所做的更快速更准确。让我们开始吧!

我们的第一个卷积模型

这一次,我将首先分享最终的模型架构,然后讨论所有新的部分。正如我在第二章中提到的,我们创建的训练方法与模型无关,因此您可以先测试这个模型,然后再回来了解解释!

class CNNNet(nn.Module):

    def __init__(self, num_classes=2):
        super(CNNNet, self).__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(64, 192, kernel_size=5, padding=2),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(192, 384, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        self.avgpool = nn.AdaptiveAvgPool2d((6, 6))
        self.classifier = nn.Sequential(
            nn.Dropout(),
            nn.Linear(256 * 6 * 6, 4096),
            nn.ReLU(),
            nn.Dropout(),
            nn.Linear(4096, 4096),
            nn.ReLU(),
            nn.Linear(4096, num_classes)
        )

    def forward(self, x):
      x = self.features(x)
      x = self.avgpool(x)
      x = torch.flatten(x, 1)
      x = self.classifier(x)
      return x

第一件要注意的事情是使用nn.Sequential()。这使我们能够创建一系列层。当我们在forward()中使用这些链中的一个时,输入会依次通过每个层的数组元素。您可以使用这个方法将模型分解为更合理的安排。在这个网络中,我们有两个链:features块和classifier。让我们看看我们正在引入的新层,从Conv2d开始。

卷积

Conv2d层是2D 卷积。如果我们有一个灰度图像,它由一个数组组成,x像素宽,y像素高,每个条目的值表示它是黑色、白色还是介于两者之间(我们假设是 8 位图像,因此每个值可以从 0 到 255 变化)。对于这个例子,我们看一个 4 像素高宽的小方形图像:

10 11 9 3 2 123 4 0 45 237 23 99 20 67 22 255

接下来我们介绍一种叫做filter卷积核的东西。这是另一个矩阵,很可能更小,我们将它拖过我们的图像。这是我们的 2×2 filter:

1 0 1 0

为了产生我们的输出,我们取较小的 filter 并将其传递到原始输入上,就像放大镜放在一张纸上一样。从左上角开始,我们的第一个计算如下:

10 11 2 123 1 0 1 0

我们所做的就是将矩阵中的每个元素与另一个矩阵中的对应成员相乘,并求和结果:(10 × 1) + (11 × 0) + (2 × 1) + (123 × 0) = 12。做完这个之后,我们将滤波器移动并重新开始。但是我们应该移动滤波器多少?在这种情况下,我们将滤波器移动 2 个单位,这意味着我们的第二次计算是:

9 3 4 0 1 0 1 0

这给我们一个输出为 13。现在我们将滤波器向下移动并向左移动,重复这个过程,给出这个最终结果(或特征图):

12 13 65 45

在图 3-1 中,您可以看到这是如何以图形方式工作的,一个 3×3 卷积核被拖动到一个 4×4 张量上,并产生一个 2×2 的输出(尽管每个部分基于九个元素而不是我们第一个示例中的四个)。

![3x3 卷积核在 4x4 输入上的操作

图 3-1。3×3 卷积核在 4×4 输入上的操作

卷积层将有许多这样的滤波器,这些滤波器的值是由网络的训练填充的,该层中的所有滤波器共享相同的偏置值。让我们回到如何调用Conv2d层并看看我们可以设置的其他选项:

nn.Conv2d(in_channels,out_channels, kernel_size, stride, padding)

in_channels是我们在该层接收的输入通道数。在网络的开始,我们将 RGB 图像作为输入,因此输入通道数为三。out_channels是输出通道数,对应于卷积层中的滤波器数量。接下来是kernel_size,描述了滤波器的高度和宽度。¹ 这可以是一个指定正方形的单个标量(例如,在第一个卷积层中,我们设置了一个 11×11 的滤波器),或者您可以使用一个元组(例如(3,5)表示一个 3×5 的滤波器)。

接下来的两个参数似乎无害,但它们可能对网络的下游层产生重大影响,甚至影响该特定层最终查看的内容。stride表示我们在调整滤波器到新位置时在输入上移动多少步。在我们的示例中,我们最终得到步幅为 2,这使得特征图的大小是输入的一半。但我们也可以使用步幅为 1 移动,这将给我们一个 4×4 的特征图输出,与输入的大小相同。我们还可以传入一个元组(a,b),允许我们在每一步上移动a个单位横向和b个单位纵向。现在,您可能想知道,当它到达末尾时会发生什么。让我们看看。如果我们以步幅 1 拖动我们的滤波器,最终会到达这一点:

3 ? 0 ?

我们的输入中没有足够的元素来进行完整的卷积。那么会发生什么?这就是padding参数发挥作用的地方。如果我们给出padding值为 1,我们的输入看起来有点像这样:

0 0 0 0 0 0 0 10 11 9 3 0 0 2 123 4 0 0 0 45 237 23 99 0 0 20 67 22 255 0 0 0 0 0 0 0

现在当我们到达边缘时,我们的过滤器覆盖的值如下:

3 0 0 0

如果不设置填充,PyTorch 在输入的最后几列中遇到的任何边缘情况都会被简单地丢弃。您需要适当设置填充。与stridekernel_size一样,您也可以传入一个height×weight填充的元组,而不是填充相同的单个数字。

这就是我们模型中的Conv2d层在做的事情。但是那些MaxPool2d层呢?

池化

与卷积层一起,您经常会看到池化层。这些层将网络的分辨率从前一个输入层降低,这使得我们在较低层中有更少的参数。这种压缩导致计算速度更快,有助于防止网络过拟合。

在我们的模型中,我们使用了一个核大小为 3,步长为 2 的MaxPool2d。让我们通过一个示例来看看它是如何工作的。这是一个 5×3 的输入:

1 2 1 4 1 5 6 1 2 5 5 0 0 9 6

使用 3×3 的核大小和步长 2,我们从池化中得到两个 3×3 的张量:

1 2 1 5 6 1 5 0 01 4 1 1 2 5 0 9 6

MaxPool中,我们从这些张量中取最大值,得到一个输出张量为[6,9]。就像在卷积层中一样,MaxPool有一个padding选项,可以在张量周围创建一个零值边界,以防步长超出张量窗口。

正如你可以想象的那样,除了从内核中取最大值之外,你还可以使用其他函数进行池化。一个流行的替代方法是取张量值的平均值,这样允许所有张量数据都参与到池中,而不仅仅是max情况下的一个值(如果你考虑一幅图像,你可以想象你可能想要考虑像素的最近邻)。此外,PyTorch 提供了AdaptiveMaxPoolAdaptiveAvgPool层,它们独立于传入输入张量的维度工作(例如,我们的模型中有一个AdaptiveAvgPool)。我建议在构建模型架构时使用这些,而不是标准的MaxPoolAvgPool层,因为它们允许你创建可以处理不同输入维度的架构;在处理不同数据集时这很方便。

我们还有一个新组件要讨论,这个组件非常简单但对训练非常重要。

Dropout

神经网络的一个经常出现的问题是它们倾向于过拟合训练数据,深度学习领域正在进行大量工作,以确定允许网络学习和泛化到非训练数据的方法,而不仅仅是学习如何对训练输入做出响应。Dropout 层是一个极其简单但重要的方法,它易于理解且有效:如果我们在训练周期内不训练网络中的一组随机节点会怎样?因为它们不会被更新,它们就不会有机会过拟合输入数据,而且因为是随机的,每个训练周期将忽略不同的输入选择,这应该进一步帮助泛化。

在我们示例 CNN 网络中,默认情况下,Dropout 层的初始化为0.5,意味着输入张量的 50%会被随机置零。如果你想将其更改为 20%,请在初始化调用中添加p参数:Dropout(p=0.2)

注意

Dropout 应该只在训练期间发生。如果在推理时发生,你会失去网络推理能力的一部分,这不是我们想要的!幸运的是,PyTorch 的Dropout实现会根据你运行的模式来确定,并在推理时通过Dropout层传递所有数据。

在查看了我们的小型 CNN 模型并深入研究了层类型之后,让我们看看过去十年中制作的其他模型。

CNN 架构的历史

尽管 CNN 模型已经存在几十年了(例如,LeNet-5 在 1990 年代末用于支票上的数字识别),但直到 GPU 变得广泛可用,深度 CNN 网络才变得实用。即使是在那时,深度学习网络开始压倒所有其他现有方法在图像分类中的应用也仅有七年。在本节中,我们将回顾过去几年的一些 CNN 学习里程碑,并探讨一些新技术。

AlexNet

AlexNet 在许多方面改变了一切。它于 2012 年发布,并在当年的 ImageNet 竞赛中以 15.3%的前五错误率摧毁了所有其他参赛作品(第二名的前五错误率为 26.2%,这让你了解了它比其他最先进方法好多少)。AlexNet 是最早引入MaxPoolDropout概念的架构之一,甚至推广了当时不太知名的ReLU激活函数。它是最早证明许多层次在 GPU 上训练是可能且高效的架构之一。虽然它不再是最先进的,但仍然是深度学习历史上的重要里程碑。

AlexNet 架构是什么样的?啊哈,是时候让你知道一个小秘密了。我们在本章中迄今为止一直在使用的网络?就是 AlexNet。惊喜!这就是为什么我们使用标准的MaxPool2d而不是AdaptiveMaxPool2d,以匹配原始的 AlexNet 定义。

Inception/GoogLeNet

让我们直接跳到 2014 年 ImageNet 比赛的获胜者。GoogLeNet 架构引入了Inception模块,解决了 AlexNet 的一些缺陷。在该网络中,卷积层的卷积核被固定在某个分辨率上。我们可能期望图像在宏观和微观尺度上都有重要的细节。使用较大的卷积核可能更容易确定一个对象是否是汽车,但要确定它是 SUV 还是掀背车可能需要一个较小的卷积核。而要确定车型,我们可能需要一个更小的卷积核来识别标志和徽标等细节。

Inception 网络代替了在同一输入上运行一系列不同尺寸的卷积,并将所有滤波器连接在一起传递到下一层。不过,在执行任何操作之前,它会进行一个 1×1 的卷积作为瓶颈,压缩输入张量,这意味着 3×3 和 5×5 的卷积核操作的过滤器数量比如果没有 1×1 卷积存在时要少。你可以在图 3-2 中看到一个 Inception 模块的示例。

一个 Inception 模块的图表

图 3-2。一个 Inception 模块

原始的 GoogLeNet 架构使用了九个这样的模块堆叠在一起,形成一个深度网络。尽管深度较大,但总体参数比 AlexNet 少,同时提供了一个 6.67%的前五名错误率,接近人类的表现。

VGG

2014 年 ImageNet 的第二名是来自牛津大学的 Visual Geometry Group(VGG)网络。与 GoogLeNet 相比,VGG 是一个更简单的卷积层堆叠。在最终分类层之前,它展示了简单深度架构的强大之处(在 VGG-16 配置中获得了 8.8%的前五名错误率)。图 3-3 展示了 VGG-16 从头到尾的层。

VGG 方法的缺点是最终的全连接层使网络膨胀到一个庞大的尺寸,与 GoogLeNet 的 700 万参数相比,达到了 1.38 亿参数。尽管如此,VGG 网络在深度学习领域仍然非常受欢迎,因为它的构造更简单,训练权重早期可用。你经常会看到它在样式转移应用中使用(例如,将照片转换为梵高的画作),因为它的卷积滤波器的组合似乎捕捉到了这种信息,这种信息比更复杂的网络更容易观察。

VGG-16 的图表

图 3-3。VGG-16

ResNet

一年后,微软的 ResNet 架构在 ImageNet 2015 比赛中获得了 ResNet-152 变体的 4.49%和集成模型的 3.57%的前五名得分(在这一点上基本超越了人类的能力)。ResNet 带来的创新是改进了 Inception 风格的层叠层次结构方法,其中每个层叠执行通常的 CNN 操作,但还将传入的输入添加到块的输出中,如图 3-4 所示。

这种设置的优势在于每个块将原始输入传递到下一层,允许训练数据的“信号”在比 VGG 或 Inception 更深的网络中传递。(在深度网络中的权重变化的损失被称为梯度消失,因为在训练过程中反向传播的梯度变化趋于零。)

一个 ResNet 块的图表

图 3-4。一个 ResNet 块

其他架构也是可用的!

自 2015 年以来,许多其他架构已经逐步提高了在 ImageNet 上的准确性,例如 DenseNet(ResNet 思想的延伸,允许构建 1,000 层的庞大架构),但也有很多工作致力于创建像 SqueezeNet 和 MobileNet 这样的架构,它们提供了合理的准确性,但与 VGG、ResNet 或 Inception 等架构相比,它们要小得多。

另一个重要的研究领域是让神经网络开始设计神经网络。到目前为止,最成功的尝试当然来自 Google,他们的 AutoML 系统生成了一个名为NASNet的架构,在 ImageNet 上的前五错误率为 3.8%,这是我在 2019 年初写这篇文章时的最新技术水平(还有另一个来自 Google 的自动生成架构称为PNAS)。事实上,ImageNet 比赛的组织者已经决定停止在这个领域进行进一步的比赛,因为这些架构已经超越了人类的能力水平。

这将我们带到了这本书出版时的最新技术水平,所以让我们看看我们如何可以使用这些模型而不是定义我们自己的。

在 PyTorch 中使用预训练模型

显然,每次想使用一个模型都要定义一个模型将是一件麻烦事,特别是一旦你远离 AlexNet,所以 PyTorch 在torchvision库中默认提供了许多最受欢迎的模型。对于 AlexNet,你只需要这样做:

import torchvision.models as models
alexnet = models.alexnet(num_classes=2)

VGG、ResNet、Inception、DenseNet 和 SqueezeNet 变体的定义也是可用的。这给了你模型的定义,但你也可以进一步调用models.alexnet(pretrained=True)来下载 AlexNet 的预训练权重,让你可以立即用它进行分类,无需额外的训练。(但正如你将在下一章中看到的那样,你可能需要进行一些额外的训练来提高你特定数据集上的准确性。)

话虽如此,至少建立自己的模型一次是有必要的,这样你就能感受到它们如何组合在一起。这是一个很好的练习,在 PyTorch 中构建模型架构的方法,当然你也可以与提供的模型进行比较,以确保你所构建的与实际定义相匹配。但是你如何找出那个结构是什么呢?

检查模型的结构

如果你对其中一个模型是如何构建的感到好奇,有一个简单的方法可以让 PyTorch 帮助你。例如,这里是整个 ResNet-18 架构的一个示例,我们只需调用以下内容:

print(model)

ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3),
  bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True,
track_running_stats=True)
  (relu): ReLU(inplace)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1,
  dilation=1, ceil_mode=False)
  (layer1): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1),
      padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True,
       track_running_stats=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1),
      padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True,
       track_running_stats=True)
    )
    (1): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1),
       padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True,
       track_running_stats=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1),
      padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True,
       track_running_stats=True)
    )
  )
  (layer2): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2),
       padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True,
       track_running_stats=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1),
       padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True,
       track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2),
         bias=False)
        (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True,
         track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1),
       padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True,
       track_running_stats=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1),
       padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True,
      track_running_stats=True)
    )
  )
  (layer3): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2),
       padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True,
      track_running_stats=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1),
      padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True,
      track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2),
        bias=False)
        (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True,
        track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1),
      padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True,
      track_running_stats=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1),
      padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True,
      track_running_stats=True)
    )
  )
  (layer4): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2),
      padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True,
      track_running_stats=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1),
      padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True,
      track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2),
        bias=False)
        (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True,
        track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1),
      padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True,
       track_running_stats=True)
      (relu): ReLU(inplace)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1),
      padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True,
      track_running_stats=True)
    )
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=512, out_features=1000, bias=True)
)

在这一章中,你几乎没有看到什么新东西,除了BatchNorm2d。让我们看看其中一个层中的作用。

BatchNorm

BatchNorm,即批量归一化,是一个简单的层,它的生活中只有一个任务:使用两个学习参数(意味着它将与网络的其余部分一起训练)来尝试确保通过网络的每个小批量具有以零为中心的均值和方差为 1。你可能会问为什么我们需要这样做,当我们已经通过使用第二章中的变换链对输入进行了归一化。对于较小的网络,BatchNorm确实不太有用,但随着它们变得更大,任何一层对另一层的影响,比如说 20 层之后,可能会很大,因为重复的乘法,你可能会得到消失或爆炸的梯度,这两者对训练过程都是致命的。BatchNorm层确保即使你使用像 ResNet-152 这样的模型,你网络内部的乘法也不会失控。

您可能会想:如果我们的网络中有BatchNorm,为什么在训练循环的转换链中还要对输入进行归一化呢?毕竟,BatchNorm不应该为我们做这项工作吗?答案是是的,您可以这样做!但网络将需要更长的时间来学习如何控制输入,因为它们将不得不自己发现初始转换,这将使训练时间更长。

我建议您实例化我们到目前为止讨论过的所有架构,并使用print(model)来查看它们使用的层以及操作发生的顺序。之后,还有另一个关键问题:我应该使用这些架构中的哪一个?

您应该使用哪个模型?

没有帮助的答案是,自然是哪个对您最有效!但让我们深入一点。首先,尽管我建议您目前尝试 NASNet 和 PNAS 架构,但我不会全力推荐它们,尽管它们在 ImageNet 上取得了令人印象深刻的结果。它们在操作中可能会消耗大量内存,并且迁移学习技术(您将在第四章中了解到)与人工构建的架构(包括 ResNet)相比并不那么有效。

我建议您在Kaggle上浏览基于图像的比赛,这是一个举办数百个数据科学比赛的网站,看看获胜作品在使用什么。很可能您会看到一堆基于 ResNet 的集成模型。就我个人而言,我喜欢并使用 ResNet 架构,因为它们提供了良好的准确性,并且很容易从 ResNet-34 模型开始尝试实验,然后转向更大的 ResNet(更现实地说,使用不同 ResNet 架构的集成模型,就像微软在 2015 年 ImageNet 比赛中使用的那样),一旦我觉得有所希望。

在结束本章之前,我有一些关于下载预训练模型的最新消息。

模型一站式购物:PyTorch Hub

PyTorch 世界最近的一项公告提供了另一种获取模型的途径:PyTorch Hub。这将成为未来获取任何已发布模型的中心位置,无论是用于处理图像、文本、音频、视频还是其他任何类型的数据。要以这种方式获取模型,您可以使用torch.hub模块:

model = torch.hub.load('pytorch/vision', 'resnet50', pretrained=True)

第一个参数指向一个 GitHub 所有者和存储库(字符串中还可以包含可选的标签/分支标识符);第二个是请求的模型(在本例中为resnet50);最后一个指示是否下载预训练权重。您还可以使用torch.hub.list('pytorch/vision')来发现该存储库中可供下载的所有模型。

PyTorch Hub 是 2019 年中新推出的,所以在我写这篇文章时可用的模型数量并不多,但我预计到年底它将成为一个流行的模型分发和下载方式。本章中的所有模型都可以通过 PytorchHub 中的pytorch/vision存储库加载,所以可以随意使用这种加载过程,而不是torchvision.models

结论

在这一章中,您已经快速了解了基于 CNN 的神经网络是如何工作的,包括DropoutMaxPoolBatchNorm等特性。您还看了当今工业中最流行的架构。在继续下一章之前,尝试一下我们讨论过的架构,看看它们之间的比较。(不要忘记,您不需要训练它们!只需下载权重并测试模型。)

我们将通过使用这些预训练模型作为我们猫对鱼问题的自定义解决方案的起点来结束我们对计算机视觉的探讨,这将使用迁移学习

进一步阅读

¹ 在文献中,核和滤波器往往可以互换使用。如果您有图形处理经验,核可能更熟悉,但我更喜欢滤波器。

第四章:迁移学习和其他技巧

在查看了上一章的架构之后,您可能会想知道是否可以下载一个已经训练好的模型,然后进一步训练它。答案是肯定的!这是深度学习领域中一种非常强大的技术,称为迁移学习,即将一个任务(例如 ImageNet)训练的网络适应到另一个任务(鱼与猫)。

为什么要这样做呢?事实证明,一个在 ImageNet 上训练过的架构已经对图像有了很多了解,特别是对于是否是猫或鱼(或狗或鲸鱼)有相当多的了解。因为您不再从一个基本上空白的神经网络开始,使用迁移学习,您可能会花费更少的时间进行训练,而且您可以通过一个远远较小的训练数据集来完成。传统的深度学习方法需要大量数据才能产生良好的结果。使用迁移学习,您可以用几百张图像构建人类级别的分类器。

使用 ResNet 进行迁移学习

现在,显而易见的事情是创建一个 ResNet 模型,就像我们在第三章中所做的那样,并将其插入到我们现有的训练循环中。您可以这样做!ResNet 模型中没有什么神奇的东西;它是由您已经看到的相同构建块构建而成。然而,这是一个庞大的模型,尽管您将看到一些改进,但您需要大量数据来确保训练信号到达架构的所有部分,并显著训练它们以适应您的新分类任务。我们试图避免在这种方法中使用大量数据。

然而,这里有一点需要注意:我们不再处理一个使用随机参数初始化的架构,就像我们过去所做的那样。我们的预训练的 ResNet 模型已经编码了大量信息,用于图像识别和分类需求,那么为什么要尝试重新训练它呢?相反,我们微调网络。我们稍微改变架构,以在末尾包含一个新的网络块,替换通常执行 ImageNet 分类的标准 1,000 个类别的线性层。然后,我们冻结所有现有的 ResNet 层,当我们训练时,我们只更新我们新层中的参数,但仍然从我们冻结的层中获取激活。这样可以快速训练我们的新层,同时保留预训练层已经包含的信息。

首先,让我们创建一个预训练的 ResNet-50 模型:

from torchvision import models
transfer_model = models.ResNet50(pretrained=True)

接下来,我们需要冻结层。我们这样做的方法很简单:通过使用requires_grad()来阻止它们累积梯度。我们需要为网络中的每个参数执行此操作,但幸运的是,PyTorch 提供了一个parameters()方法,使这变得相当容易:

for name, param in transfer_model.named_parameters():
    param.requires_grad = False
提示

您可能不想冻结模型中的BatchNorm层,因为它们将被训练来逼近模型最初训练的数据集的均值和标准差,而不是您想要微调的数据集。由于BatchNorm校正您的输入,您的数据中的一些信号可能会丢失。您可以查看模型结构,并仅冻结不是BatchNorm的层,就像这样:

for name, param in transfer_model.named_parameters():
    if("bn" not in name):
        param.requires_grad = False

然后,我们需要用一个新的分类块替换最终的分类块,用于检测猫或鱼。在这个例子中,我们用几个Linear层、一个ReLUDropout来替换它,但您也可以在这里添加额外的 CNN 层。令人高兴的是,PyTorch 对 ResNet 的实现定义了最终分类器块作为一个实例变量fc,所以我们只需要用我们的新结构替换它(PyTorch 提供的其他模型使用fcclassifier,所以如果您尝试使用不同的模型类型,您可能需要检查源代码中的定义):

transfer_model.fc = nn.Sequential(nn.Linear(transfer_model.fc.in_features,500),
nn.ReLU(),
nn.Dropout(), nn.Linear(500,2))

在上面的代码中,我们利用了in_features变量,它允许我们获取传入层的激活数量(在本例中为 2,048)。你也可以使用out_features来发现传出的激活数量。当你像搭积木一样组合网络时,这些都是很方便的函数;如果一层的传入特征与前一层的传出特征不匹配,你会在运行时得到一个错误。

最后,我们回到我们的训练循环,然后像往常一样训练模型。你应该在几个 epochs 内看到一些准确度的大幅提升。

迁移学习是提高深度学习应用准确性的关键技术,但我们可以采用一堆其他技巧来提升我们模型的性能。让我们看看其中一些。

找到那个学习率

你可能还记得我在第二章中介绍了训练神经网络的学习率的概念,提到它是你可以改变的最重要的超参数之一,然后又提到了你应该使用什么值,建议使用一个相对较小的数字,让你尝试不同的值。不过...坏消息是,很多人确实是这样发现他们架构的最佳学习率的,通常使用一种称为网格搜索的技术,通过穷举搜索一部分学习率值,将结果与验证数据集进行比较。这是非常耗时的,尽管有人这样做,但许多其他人更倾向于从实践者的传统中获得经验。例如,一个已经被观察到与 Adam 优化器一起工作的学习率值是 3e-4。这被称为 Karpathy 的常数,以安德烈·卡帕西(目前是特斯拉 AI 主管)在 2016 年发推文后得名。不幸的是,更少的人读到了他的下一条推文:“我只是想确保人们明白这是一个笑话。”有趣的是,3e-4 往往是一个可以提供良好结果的值,所以这是一个带有现实意味的笑话。

一方面,你可以进行缓慢而繁琐的搜索,另一方面,通过在无数架构上工作直到对一个好的学习率有了感觉来获得的晦涩和神秘的知识——甚至可以说是手工制作的神经网络。除了这两个极端,还有更好的方法吗?

幸运的是,答案是肯定的,尽管你会对有多少人没有使用这种更好的方法感到惊讶。美国海军研究实验室的研究科学家莱斯利·史密斯撰写的一篇有些晦涩的论文包含了一种寻找适当学习率的方法。但直到杰里米·霍华德在他的 fast.ai 课程中将这种技术推广开来,深度学习社区才开始关注。这个想法非常简单:在一个 epoch 的过程中,从一个小的学习率开始,逐渐增加到一个更高的学习率,每个小批次结束时都会有一个较高的学习率。计算每个速率的损失,然后查看绘图,选择使下降最大的学习率。例如,查看图 4-1 中的图表。

学习率损失图

图 4-1。学习率与损失

在这种情况下,我们应该考虑使用大约 1e-2 的学习率(在圆圈内标记),因为这大致是梯度下降最陡峭的点。

注意

请注意,你不是在寻找曲线的底部,这可能是更直观的地方;你要找的是最快到达底部的点。

以下是 fast.ai 库在幕后执行的简化版本:

import math
def find_lr(model, loss_fn, optimizer, init_value=1e-8, final_value=10.0):
    number_in_epoch = len(train_loader) - 1
    update_step = (final_value / init_value) ** (1 / number_in_epoch)
    lr = init_value
    optimizer.param_groups[0]["lr"] = lr
    best_loss = 0.0
    batch_num = 0
    losses = []
    log_lrs = []
    for data in train_loader:
        batch_num += 1
        inputs, labels = data
        inputs, labels = inputs, labels
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = loss_fn(outputs, labels)

        # Crash out if loss explodes

        if batch_num > 1 and loss > 4 * best_loss:
            return log_lrs[10:-5], losses[10:-5]

        # Record the best loss

        if loss < best_loss or batch_num == 1:
            best_loss = loss

        # Store the values

        losses.append(loss)
        log_lrs.append(math.log10(lr))

        # Do the backward pass and optimize

        loss.backward()
        optimizer.step()

        # Update the lr for the next step and store

        lr *= update_step
        optimizer.param_groups[0]["lr"] = lr
    return log_lrs[10:-5], losses[10:-5]

这里发生的情况是,我们遍历批次,几乎像往常一样训练;我们通过模型传递我们的输入,然后从该批次获取损失。我们记录到目前为止的best_loss是多少,并将新的损失与其进行比较。如果我们的新损失是best_loss的四倍以上,我们就会退出函数,返回到目前为止的内容(因为损失可能趋向无穷大)。否则,我们会继续附加当前学习率的损失和日志,并在循环结束时更新学习率到最大速率的下一步。然后可以使用matplotlibplt函数显示绘图:

logs,losses = find_lr()
plt.plot(logs,losses)
found_lr = 1e-2

请注意,我们返回lr日志和损失的切片。我们这样做只是因为训练的最初部分和最后几部分(特别是如果学习率变得非常快地变大)往往不会告诉我们太多信息。

fast.ai 库中的实现还包括加权平滑,因此您在绘图中会得到平滑的线条,而此代码段会产生尖锐的输出。最后,请记住,因为这个函数实际上确实训练了模型并干扰了优化器的学习率设置,所以您应该在调用find_lr()之前保存和重新加载您的模型,以恢复到调用该函数之前的状态,并重新初始化您选择的优化器,您现在可以这样做,传入您从图表中确定的学习率!

这为我们提供了一个良好的学习率值,但我们可以通过差异学习率做得更好。

差异学习率

到目前为止,我们对整个模型应用了一个学习率。从头开始训练模型时,这可能是有道理的,但是在迁移学习时,如果我们尝试一些不同的东西,通常可以获得更好的准确性:以不同速率训练不同组层。在本章的前面,我们冻结了模型中的所有预训练层,并只训练了我们的新分类器,但是我们可能想要微调我们正在使用的 ResNet 模型的一些层。也许给我们的分类器之前的层添加一些训练会使我们的模型更准确一点。但是由于这些前面的层已经在 ImageNet 数据集上进行了训练,也许与我们的新层相比,它们只需要一点点训练?PyTorch 提供了一种简单的方法来实现这一点。让我们修改 ResNet-50 模型的优化器:

optimizer = optimizer.Adam([
{ 'params': transfer_model.layer4.parameters(), 'lr': found_lr /3},
{ 'params': transfer_model.layer3.parameters(), 'lr': found_lr /9},
], lr=found_lr)

这将把layer4(就在我们的分类器之前)的学习率设置为找到的学习率的三分之一,layer3的学习率的九分之一。这种组合在我的工作中经验上表现得非常好,但显然您可以随意尝试。不过还有一件事。正如您可能还记得本章开头所说的,我们冻结了所有这些预训练层。给它们一个不同的学习率是很好的,但是目前,模型训练不会触及它们,因为它们不会累积梯度。让我们改变这一点:

unfreeze_layers = [transfer_model.layer3, transfer_model.layer4]
for layer in unfreeze_layers:
    for param in layer.parameters():
        param.requires_grad = True

现在这些层的参数再次接受梯度,当您微调模型时将应用差异学习率。请注意,您可以随意冻结和解冻模型的部分,并对每个层进行进一步的微调,如果您愿意的话!

现在我们已经看过学习率了,让我们来研究训练模型的另一个方面:我们输入的数据。

数据增强

数据科学中令人恐惧的短语之一是,“哦,不,我的模型在数据上过拟合了!”正如我在第二章中提到的,过拟合发生在模型决定反映训练集中呈现的数据而不是产生一个泛化解决方案时。你经常会听到人们谈论特定模型记住了数据集,意味着模型学习了答案,然后在生产数据上表现不佳。

传统的防范方法是积累大量数据。通过观察更多数据,模型对它试图解决的问题有一个更一般的概念。如果你把这种情况看作是一个压缩问题,那么如果你阻止模型简单地能够存储所有答案(通过用大量数据压倒性地超出其存储容量),它被迫压缩输入,因此产生一个不能简单地在自身内部存储答案的解决方案。这是可以的,而且效果很好,但是假设我们只有一千张图片,我们正在进行迁移学习。我们能做什么呢?

我们可以使用的一种方法是数据增强。如果我们有一张图像,我们可以对该图像做一些事情,应该可以防止过拟合,并使模型更加通用。考虑图 4-2(#normal-cat-in-box)和图 4-3(#flipped-cat-in-box)中的 Helvetica 猫的图像。

盒子里的猫

图 4-2. 我们的原始图像

翻转的盒子里的猫

图 4-3. 翻转的 Helvetica

显然对我们来说,它们是相同的图像。第二个只是第一个的镜像副本。张量表示将会不同,因为 RGB 值将在 3D 图像中的不同位置。但它仍然是一只猫,所以训练在这张图像上的模型希望能够学会识别左侧或右侧帧上的猫形状,而不仅仅是将整个图像与关联起来。在 PyTorch 中做到这一点很简单。你可能还记得这段代码片段来自第二章:

transforms = transforms.Compose([
        transforms.Resize(64),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.485, 0.456, 0.406],
                     std=[0.229, 0.224, 0.225] )
        ])

这形成了一个转换管道,所有图像在进入模型进行训练时都会经过。但是torchivision.transforms库包含许多其他可以用于增强训练数据的转换函数。让我们看一下一些更有用的转换,并查看 Helvetica 在一些不太明显的转换中会发生什么。

Torchvision 转换

torchvision包含了一个大量的潜在转换集合,可以用于数据增强,以及构建新转换的两种方式。在本节中,我们将看一下提供的最有用的转换,以及一些你可以在自己的应用中使用的自定义转换。

torchvision.transforms.ColorJitter(brightness=0, contrast=0, saturation=0, hue=0)

ColorJitter会随机改变图像的亮度、对比度、饱和度和色调。对于亮度、对比度和饱和度,你可以提供一个浮点数或一个浮点数元组,所有非负数在 0 到 1 的范围内,随机性将在 0 和提供的浮点数之间,或者它将使用元组生成在提供的一对浮点数之间的随机性。对于色调,需要一个在-0.5 到 0.5 之间的浮点数或浮点数元组,它将在[-hue,hue]或[min, max]之间生成随机色调调整。参见图 4-4 作为示例。

ColorJitter 应用于所有参数为 0.5

图 4-4. ColorJitter 应用于所有参数为 0.5

如果你想翻转你的图像,这两个转换会随机地在水平或垂直轴上反射图像:

torchvision.transforms.RandomHorizontalFlip(p=0.5)
torchvision.transforms.RandomVerticalFlip(p=0.5)

要么提供一个从 0 到 1 的概率来发生反射,要么接受默认的 50%反射几率。在图 4-5 中展示了一个垂直翻转的猫。

RandomVerticalFlip

图 4-5. 垂直翻转

RandomGrayscale是一种类似的转换类型,不同之处在于它会随机将图像变为灰度,取决于参数p(默认为 10%):

torchvision.transforms.RandomGrayscale(p=0.1)

RandomCropRandomResizeCrop,正如你所期望的那样,在图像上执行随机裁剪,size可以是一个整数,表示高度和宽度,或包含不同高度和宽度的元组。图 4-6 展示了RandomCrop的示例。

torchvision.transforms.RandomCrop(size, padding=None,
pad_if_needed=False, fill=0, padding_mode='constant')
torchvision.transforms.RandomResizedCrop(size, scale=(0.08, 1.0),
ratio=(0.75, 1.3333333333333333), interpolation=2)

现在在这里需要小心一点,因为如果您的裁剪区域太小,就有可能剪掉图像的重要部分,使模型训练错误的内容。例如,如果图像中有一只猫在桌子上玩耍,而裁剪掉了猫,只留下部分桌子被分类为,那就不太好。虽然RandomResizeCrop会调整裁剪大小以填充给定大小,但RandomCrop可能会取得靠近边缘并进入图像之外黑暗区域的裁剪。

注意

RandomResizeCrop使用双线性插值,但您也可以通过更改interpolation参数选择最近邻或双三次插值。有关更多详细信息,请参阅PIL 滤镜页面

正如您在第三章中看到的,我们可以添加填充以保持图像所需的大小。默认情况下,这是constant填充,它用fill中给定的值填充图像之外的空白像素。然而,我建议您改用reflect填充,因为经验上它似乎比只是填充空白常数空间要好一点。

尺寸为 100 的 RandomCrop

图 4-6。尺寸为 100 的 RandomCrop

如果您想要随机旋转图像,RandomRotation将在[-degrees, degrees]之间变化,如果degrees是一个单个浮点数或整数,或者在元组中是(min,max)

torchvision.transforms.RandomRotation(degrees, resample=False,expand=False, center=None)

如果expand设置为True,此函数将扩展输出图像,以便包含整个旋转;默认情况下,它设置为在输入尺寸内裁剪。您可以指定 PIL 重采样滤镜,并可选择提供一个(x,y)元组作为旋转中心;否则,变换将围绕图像中心旋转。图 4-7 是一个RandomRotation变换,其中degrees设置为 45。

旋转角度为 45 度的 RandomRotation

图 4-7。旋转角度为 45 度的 RandomRotation

Pad是一个通用的填充变换,它在图像的边界上添加填充(额外的高度和宽度):

torchvision.transforms.Pad(padding, fill=0, padding_mode=constant)

padding中的单个值将在所有方向上应用填充。两元组padding将在长度为(左/右,上/下)的方向上产生填充,四元组将在(左,上,右,下)的方向上产生填充。默认情况下,填充设置为constant模式,它将fill的值复制到填充槽中。其他选择是edge,它将图像边缘的最后值填充到填充长度;reflect,它将图像的值(除边缘外)反射到边界;以及symmetric,它是reflection,但包括图像边缘的最后值。图 4-8 展示了padding设置为 25 和padding_mode设置为reflect。看看盒子如何在边缘重复。

填充为 25 和填充模式为 reflect 的 Pad

图 4-8。使用填充为 25 和填充模式为 reflect 的填充

RandomAffine允许您指定图像的随机仿射变换(缩放、旋转、平移和/或剪切,或任何组合)。图 4-9 展示了仿射变换的一个示例。

torchvision.transforms.RandomAffine(degrees, translate=None, scale=None,
shear=None, resample=False, fillcolor=0)

旋转角度为 10 度和剪切为 50 的 RandomAffine

图 4-9。旋转角度为 10 度,剪切为 50 的 RandomAffine

degrees参数可以是单个浮点数或整数,也可以是一个元组。以单个形式,它会产生在(–*degrees**degrees*)之间的随机旋转。使用元组时,它会产生在(*min**max*)之间的随机旋转。必须明确设置degrees以防止旋转发生——没有默认设置。translate是一个包含两个乘数(*horizontal_multipler**vertical_multiplier*)的元组。在变换时,水平偏移dx在范围内取样,即(–*image_width × horizontal_multiplier < dx < img_width × horizontal_width*),垂直偏移也以相同的方式相对于图像高度和垂直乘数进行取样。

缩放由另一个元组(*min**max*)处理,从中随机抽取一个均匀缩放因子。剪切可以是单个浮点数/整数或一个元组,并以与degrees参数相同的方式随机取样。最后,resample允许您可选地提供一个 PIL 重采样滤波器,fillcolor是一个可选的整数,指定最终图像中位于最终变换之外的区域的填充颜色。

至于在数据增强流水线中应该使用哪些转换,我强烈建议开始使用各种随机翻转、颜色抖动、旋转和裁剪。

torchvision中还提供其他转换;查看文档以获取更多详细信息。但当然,您可能会发现自己想要创建一个特定于您的数据领域的转换,而这并不是默认包含的,因此 PyTorch 提供了各种定义自定义转换的方式,接下来您将看到。

颜色空间和 Lambda 转换

即使提到这似乎有点奇怪,但到目前为止,我们所有的图像工作都是在相当标准的 24 位 RGB 颜色空间中进行的,其中每个像素都有一个 8 位的红色、绿色和蓝色值来指示该像素的颜色。然而,其他颜色空间也是可用的!

HSV 是一种受欢迎的替代方案,它具有三个 8 位值,分别用于色调饱和度。一些人认为这种系统比传统的 RGB 颜色空间更准确地模拟了人类视觉。但为什么这很重要呢?在 RGB 中的一座山在 HSV 中也是一座山,对吧?

最近在着色方面的深度学习工作中有一些证据表明,其他颜色空间可能比 RGB 产生稍微更高的准确性。一座山可能是一座山,但在每个空间的表示中形成的张量将是不同的,一个空间可能比另一个更好地捕捉到您的数据的某些特征。

与集成结合使用时,您可以轻松地创建一系列模型,将 RGB、HSV、YUV 和 LAB 颜色空间的训练结果结合起来,从而从您的预测流水线中挤出更多的准确性百分点。

一个小问题是 PyTorch 没有提供可以执行此操作的转换。但它提供了一些工具,我们可以使用这些工具将标准 RGB 图像随机转换为 HSV(或其他颜色空间)。首先,如果我们查看 PIL 文档,我们会发现可以使用Image.convert()将 PIL 图像从一种颜色空间转换为另一种。我们可以编写一个自定义的transform类来执行这种转换,但 PyTorch 添加了一个transforms.Lambda类,以便我们可以轻松地包装任何函数并使其可用于转换流水线。这是我们的自定义函数:

def _random_colour_space(x):
    output = x.convert("HSV")
    return output

然后将其包装在transforms.Lambda类中,并可以在任何标准转换流水线中使用,就像我们以前看到的那样:

colour_transform = transforms.Lambda(lambda x: _random_colour_space(x))

如果我们想将每张图像都转换为 HSV,那倒没什么问题,但实际上我们并不想这样。我们希望它在每个批次中随机更改图像,因此很可能图像在不同的时期以不同的颜色空间呈现。我们可以更新我们的原始函数以生成一个随机数,并使用该随机数生成更改图像的随机概率,但相反,我们更懒惰,使用RandomApply

random_colour_transform = torchvision.transforms.RandomApply([colour_transform])

默认情况下,RandomApply会用值0.5填充参数p,所以转换被应用的概率是 50/50。尝试添加更多的颜色空间和应用转换的概率,看看它对我们的猫和鱼问题有什么影响。

让我们看看另一个稍微复杂一些的自定义转换。

自定义转换类

有时一个简单的 lambda 不够;也许我们有一些初始化或状态要跟踪,例如。在这些情况下,我们可以创建一个自定义转换,它可以操作 PIL 图像数据或张量。这样的类必须实现两个方法:__call__,转换管道在转换过程中将调用该方法;和__repr__,它应该返回一个字符串表示转换,以及可能对诊断目的有用的任何状态。

在下面的代码中,我们实现了一个转换类,它向张量添加随机高斯噪声。当类被初始化时,我们传入所需噪声的均值和标准分布,在__call__方法中,我们从这个分布中采样并将其添加到传入的张量中:

class Noise():
    """Adds gaussian noise to a tensor.

 >>> transforms.Compose([
 >>>     transforms.ToTensor(),
 >>>     Noise(0.1, 0.05)),
 >>> ])

 """
    def __init__(self, mean, stddev):
        self.mean = mean
        self.stddev = stddev

    def __call__(self, tensor):
        noise = torch.zeros_like(tensor).normal_(self.mean, self.stddev)
        return tensor.add_(noise)

    def __repr__(self):
        repr = f"{self.__class__.__name__  }(mean={self.mean},
               stddev={self.stddev})"
        return repr

如果我们将这个添加到管道中,我们可以看到__repr__方法被调用的结果:

transforms.Compose([Noise(0.1, 0.05))])
>> Compose(
    Noise(mean=0.1,sttdev=0.05)
)

因为转换没有任何限制,只是继承自基本的 Python 对象类,你可以做任何事情。想在运行时完全用来自 Google 图像搜索的东西替换图像?通过完全不同的神经网络运行图像并将结果传递到管道中?应用一系列图像转换,将图像变成其以前的疯狂反射阴影?所有这些都是可能的,尽管不完全推荐。尽管看到 Photoshop 的Twirl变换效果会使准确性变得更糟还是更好会很有趣!为什么不试试呢?

除了转换,还有一些其他方法可以尽可能地从模型中挤出更多性能。让我们看更多例子。

从小开始,变得更大!

这里有一个看起来奇怪但确实能获得真实结果的提示:从小开始,变得更大。我的意思是,如果你在 256×256 图像上训练,创建几个更多的数据集,其中图像已经缩放到 64×64 和 128×128。使用 64×64 数据集创建你的模型,像平常一样微调,然后使用完全相同的模型在 128×128 数据集上训练。不是从头开始,而是使用已经训练过的参数。一旦看起来你已经从 128×128 数据中挤出了最大的价值,转移到目标 256×256 数据。你可能会发现准确性提高了一个或两个百分点。

虽然我们不知道为什么这样做有效,但工作理论是通过在较低分辨率训练,模型学习图像的整体结构,并随着传入图像的扩展来完善这些知识。但这只是一个理论。然而,这并不能阻止它成为一个很好的小技巧,当你需要从模型中挤出每一点性能时。

如果你不想在存储中留下数据集的多个副本,你可以使用torchvision转换来使用Resize函数实时进行操作:

resize = transforms.Compose([ transforms.Resize(64),
 …_other augmentation transforms_…
 transforms.ToTensor(),
 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])

你要付出的代价是你最终会花更多时间在训练上,因为 PyTorch 必须每次应用调整大小。如果你事先调整了所有图像,你可能会得到更快的训练运行,但这会填满你的硬盘。但这种权衡难道不是一直存在的吗?

从小开始然后变得更大的概念也适用于架构。使用像 ResNet-18 或 ResNet-34 这样的 ResNet 架构来测试转换方法并了解训练的工作方式,比起一开始就使用 ResNet-101 或 ResNet-152 模型,提供了一个更紧密的反馈循环。从小开始,逐步建立,你可以在预测时通过将它们添加到集成模型中来潜在地重复使用较小的模型运行。

集成

有什么比一个模型做出预测更好?那么,多个模型怎么样?集成是一种在传统机器学习方法中相当常见的技术,在深度学习中也非常有效。其思想是从一系列模型中获得预测,并将这些预测组合起来产生最终答案。由于不同模型在不同领域具有不同的优势,希望所有预测的组合将产生比单个模型更准确的结果。

有很多集成方法,我们不会在这里详细介绍所有方法。相反,这里有一种简单的集成方法,可以在我的经验中再增加 1%的准确率;简单地平均预测结果:

# Assuming you have a list of models in models, and input is your input tensor

predictions = [m[i].fit(input) for i in models]
avg_prediction = torch.stack(b).mean(0).argmax()

stack方法将张量数组连接在一起,因此,如果我们在猫/鱼问题上工作,并且在我们的集成中有四个模型,我们将得到一个由四个 1×2 张量构成的 4×2 张量。mean执行您所期望的操作,取平均值,尽管我们必须传入维度 0 以确保它在第一维上取平均值,而不仅仅是将所有张量元素相加并产生标量输出。最后,argmax选择具有最高元素的张量索引,就像您以前看到的那样。

很容易想象更复杂的方法。也许可以为每个单独模型的预测结果添加权重,并且如果模型回答正确或错误,则调整这些权重。您应该使用哪些模型?我发现 ResNets(例如 34、50、101)的组合效果非常好,没有什么能阻止您定期保存模型并在集成中使用模型的不同快照!

结论

当我们结束第四章时,我们将离开图像,转向文本。希望您不仅了解卷积神经网络在图像上的工作原理,还掌握了一系列技巧,包括迁移学习、学习率查找、数据增强和集成,这些技巧可以应用于您特定的应用领域。

进一步阅读

如果您对图像领域的更多信息感兴趣,请查看 Jeremy Howard,Rachel Thomas 和 Sylvain Gugger 的fast.ai课程。正如我所提到的,本章的学习率查找器是他们使用的简化版本,但该课程详细介绍了本章中许多技术。建立在 PyTorch 上的 fast.ai 库使您可以轻松将它们应用于您的图像(和文本!)领域。

¹ 请参阅 Leslie Smith(2015)的“用于训练神经网络的循环学习率”

第五章:文本分类

我们暂时离开图像,转而关注另一个领域,深度学习在传统技术上已经被证明是一个重大进步的地方:自然语言处理(NLP)。一个很好的例子是谷歌翻译。最初,处理翻译的代码有 500,000 行代码。基于 TensorFlow 的新系统大约有 500 行代码,性能比旧方法更好。

最近的突破还发生在将迁移学习(你在第四章中学到的)引入到 NLP 问题中。新的架构,如 Transformer 架构,已经导致了像 OpenAI 的 GPT-2 这样的网络的创建,其更大的变体产生的文本几乎具有人类般的质量(事实上,OpenAI 没有发布这个模型的权重,因为担心它被恶意使用)。

本章提供了循环神经网络和嵌入的快速介绍。然后我们探讨了torchtext库以及如何使用基于 LSTM 的模型进行文本处理。

循环神经网络

如果我们回顾一下迄今为止我们如何使用基于 CNN 的架构,我们可以看到它们总是在一个完整的时间快照上工作。但考虑这两个句子片段:

The cat sat on the mat.

She got up and impatiently climbed on the chair, meowing for food.

假设你将这两个句子一个接一个地馈送到 CNN 中,并问,“猫在哪里?”你会遇到问题,因为网络没有“记忆”的概念。当处理具有时间域的数据时(例如文本、语音、视频和时间序列数据)这是非常重要的。通过循环神经网络(RNNs)通过隐藏状态给神经网络提供了记忆来解决这个问题。

RNN 是什么样子的?我最喜欢的解释是,“想象一个神经网络和一个for循环相交。” 图 5-1 显示了一个经典 RNN 结构的图表。

经典 RNN 图

图 5-1。一个 RNN

我们在时间步t添加输入,得到ht隐藏输出状态,并且输出也被馈送回 RNN 用于下一个时间步。我们可以展开这个网络,深入了解正在发生的事情,如图 5-2 所示。

!展开的 RNN 图

图 5-2。一个展开的 RNN

我们这里有一组全连接层(具有共享参数)、一系列输入和输出。输入数据被馈送到网络中,然后预测输出作为序列中的下一个项目。在展开的视图中,我们可以看到 RNN 可以被看作是一系列全连接层的管道,连续的输入被馈送到序列中的下一层(在层之间插入了通常的非线性,如ReLU)。当我们完成了预测的序列后,我们必须通过 RNN 将错误反向传播回去。因为这涉及到通过网络的步骤向后走,这个过程被称为通过时间的反向传播。错误是在整个序列上计算的,然后网络像图 5-2 中展开一样,为每个时间步计算梯度并组合以更新网络的共享参数。你可以想象它是在单独的网络上进行反向传播,然后将所有梯度相加。

这就是 RNN 的理论。但这种简单的结构存在问题,我们需要讨论如何通过更新的架构来克服这些问题。

长短期记忆网络

实际上,RNN 在梯度消失问题上特别容易受到影响,我们在第二章中讨论过,或者更糟糕的情况是梯度爆炸,其中你的错误趋向于无穷大。这两种情况都不好,因此 RNN 无法解决许多它们被认为适合的问题。这一切在 1997 年 Sepp Hochreiter 和 Jürgen Schmidhuber 引入长短期记忆(LSTM)变体的时候发生了改变。

图 5-3 展示了一个 LSTM 层。我知道,这里有很多东西,但并不太复杂。真诚地说。

LSTM 图示

图 5-3. 一个 LSTM

好的,我承认,这确实令人生畏。关键是要考虑三个门(输入、输出和遗忘)。在标准的 RNN 中,我们会永远“记住”一切。但这并不是我们大脑的工作方式(可悲!),LSTM 的遗忘门允许我们建模这样一个想法,即随着我们在输入链中继续,链的开始变得不那么重要。LSTM 忘记多少是在训练过程中学习的,因此,如果对网络来说最好是非常健忘的,遗忘门参数就会这样做。

单元最终成为网络层的“记忆”,而输入、输出和遗忘门将决定数据如何在层中流动。数据可能只是通过,它可能“写入”到单元中,这些数据可能(或可能不会!)通过输出门被传递到下一层,并受到输出门的影响。

这些部分的组合足以解决梯度消失问题,并且还具有图灵完备性,因此理论上,你可以使用其中之一进行计算机上的任何计算。

当然,事情并没有止步于此。自 LSTM 以来,RNN 领域发生了几项发展,我们将在接下来的部分中涵盖一些主要内容。

门控循环单元

自 1997 年以来,已经创建了许多基本 LSTM 网络的变体,其中大多数你可能不需要了解,除非你感兴趣。然而,2014 年出现的一个变体,门控循环单元(GRU),值得了解,因为在某些领域它变得非常流行。图 5-4 展示了一个 GRU 架构的组成。

GRU 图示

图 5-4. 一个 GRU

主要的要点是 GRU 已经将遗忘门与输出门合并。这意味着它比 LSTM 具有更少的参数,因此倾向于更快地训练并在运行时使用更少的资源。出于这些原因,以及它们基本上是 LSTM 的替代品,它们变得非常流行。然而,严格来说,它们比 LSTM 弱一些,因为合并了遗忘和输出门,所以一般我建议在网络中尝试使用 GRU 或 LSTM,并看看哪个表现更好。或者只是接受 LSTM 在训练时可能会慢一些,但最终可能是最佳选择。你不必追随最新的潮流——真诚地说!

双向 LSTM

LSTM 的另一个常见变体是双向 LSTM 或简称biLSTM。正如你目前所看到的,传统的 LSTM(以及 RNN 总体)在训练过程中可以查看过去并做出决策。不幸的是,有时候你也需要看到未来。这在翻译和手写识别等应用中尤为重要,因为当前状态之后发生的事情可能与之前状态一样重要,以确定输出。

双向 LSTM 以最简单的方式解决了这个问题:它本质上是两个堆叠的 LSTM,其中输入在一个 LSTM 中向前发送,而在第二个 LSTM 中向后发送。图 5-5 展示了双向 LSTM 如何双向跨越其输入以产生输出。

双向 LSTM 图示

图 5-5. 一个双向 LSTM

PyTorch 通过在创建LSTM()单元时传入bidirectional=True参数来轻松创建双向 LSTM,您将在本章后面看到。

这完成了我们对基于 RNN 的架构的介绍。在第九章中,当我们研究基于 Transformer 的 BERT 和 GPT-2 模型时,我们将回到架构问题。

嵌入

我们几乎可以开始编写一些代码了!但在此之前,您可能会想到一个小细节:我们如何在网络中表示单词?毕竟,我们正在将数字张量输入到网络中,并获得张量输出。对于图像,将它们转换为表示红/绿/蓝分量值的张量似乎是一件很明显的事情,因为它们已经自然地被认为是数组,因为它们带有高度和宽度。但是单词?句子?这将如何运作?

最简单的方法仍然是许多自然语言处理方法中常见的方法之一,称为one-hot 编码。这很简单!让我们从本章开头的第一句话开始看:

The cat sat on the mat.

如果我们考虑这是我们世界的整个词汇表,我们有一个张量[the, cat, sat, on, mat]。one-hot 编码简单地意味着我们创建一个与词汇表大小相同的向量,并为其中的每个单词分配一个向量,其中一个参数设置为 1,其余设置为 0:

the — [1 0 0 0 0]
cat — [0 1 0 0 0]
sat — [0 0 1 0 0]
on  — [0 0 0 1 0]
mat — [0 0 0 0 1]

我们现在已经将单词转换为向量,可以将它们输入到我们的网络中。此外,我们可以向我们的词汇表中添加额外的符号,比如UNK(未知,用于不在词汇表中的单词)和START/STOP来表示句子的开头和结尾。

当我们在示例词汇表中添加另一个单词时,one-hot 编码会显示出一些限制:kitty。根据我们的编码方案,kitty将由[0 0 0 0 0 1]表示(其他向量都用零填充)。首先,您可以看到,如果我们要建模一个现实的单词集,我们的向量将非常长,几乎没有信息。其次,也许更重要的是,我们知道小猫之间存在非常强烈的关系(还有该死,但幸运的是在我们的词汇表中被跳过了!),这是无法用 one-hot 编码表示的;这两个单词是完全不同的东西。

最近变得更受欢迎的一种方法是用嵌入矩阵替换 one-hot 编码(当然,one-hot 编码本身就是一个嵌入矩阵,只是不包含有关单词之间关系的任何信息)。这个想法是将向量空间的维度压缩到更易管理的尺寸,并利用空间本身的优势。

例如,如果我们在 2D 空间中有一个嵌入,也许cat可以用张量[0.56, 0.45]表示,kitten可以用[0.56, 0.445]表示,而mat可以用[0.2, -0.1]表示。我们在向量空间中将相似的单词聚集在一起,并可以使用欧几里得或余弦距离函数进行距离检查,以确定单词之间的接近程度。那么我们如何确定单词在向量空间中的位置呢?嵌入层与您迄今在构建神经网络中看到的任何其他层没有区别;我们随机初始化向量空间,希望训练过程更新参数,使相似的单词或概念相互靠近。

嵌入向量的一个著名例子是word2vec,它是由 Google 在 2013 年发布的。这是使用浅层神经网络训练的一组词嵌入,它揭示了向量空间转换似乎捕捉到了有关支撑单词的概念的一些内容。在其常被引用的发现中,如果您提取KingManWoman的向量,然后从King中减去Man的向量并加上Woman的向量,您将得到一个代表Queen的向量表示。自从word2vec以来,其他预训练的嵌入也已经可用,如ELMoGloVefasttext

至于在 PyTorch 中使用嵌入,非常简单:

embed = nn.Embedding(vocab_size, dimension_size)

这将包含一个vocab_size x dimension_size的张量,随机初始化。我更喜欢认为它只是一个巨大的数组或查找表。您词汇表中的每个单词索引到一个大小为dimension_size的向量条目,所以如果我们回到我们的猫及其在垫子上的史诗般的冒险,我们会得到这样的东西:

cat_mat_embed = nn.Embedding(5, 2)
cat_tensor = Tensor([1])
cat_mat_embed.forward(cat_tensor)

> tensor([[ 1.7793, -0.3127]], grad_fn=<EmbeddingBackward>)

我们创建了我们的嵌入,一个包含cat在我们词汇表中位置的张量,并通过层的forward()方法传递它。这给了我们我们的随机嵌入。结果还指出,我们有一个梯度函数,我们可以在将其与损失函数结合后用于更新参数。

我们现在已经学习了所有的理论,可以开始构建一些东西了!

torchtext

就像torchvision一样,PyTorch 提供了一个官方库torchtext,用于处理文本处理管道。然而,torchtext并没有像torchvision那样经过充分测试,也没有像torchvision那样受到很多关注,这意味着它不太容易使用或文档不够完善。但它仍然是一个强大的库,可以处理构建基于文本的数据集的许多琐碎工作,所以我们将在本章的其余部分中使用它。

安装torchtext相当简单。您可以使用标准的pip

pip install torchtext

或者特定的conda渠道:

conda install -c derickl torchtext

您还需要安装spaCy(一个 NLP 库)和 pandas,如果您的系统上没有它们的话(再次使用pipconda)。我们使用spaCy来处理torchtext管道中的文本,使用 pandas 来探索和清理我们的数据。

获取我们的数据:推文!

在这一部分,我们构建一个情感分析模型,所以让我们获取一个数据集。torchtext通过torchtext.datasets模块提供了一堆内置数据集,但我们将从头开始工作,以便了解构建自定义数据集并将其馈送到我们创建的模型的感觉。我们使用Sentiment140 数据集。这是基于 Twitter 上的推文,每个推文被排名为 0 表示负面,2 表示中性,4 表示积极。

下载 zip 存档并解压。我们使用文件training.1600000.processed.noemoticon.csv。让我们使用 pandas 查看文件:

import pandas as pd
tweetsDF = pd.read_csv("training.1600000.processed.noemoticon.csv",
                        header=None)

此时您可能会遇到这样的错误:

UnicodeDecodeError: 'utf-8' codec can't decode bytes in
position 80-81: invalid continuation byte

恭喜你,现在你是一个真正的数据科学家,你可以处理数据清洗了!从错误消息中可以看出,pandas 使用的默认基于 C 的 CSV 解析器不喜欢文件中的一些 Unicode,所以我们需要切换到基于 Python 的解析器:

tweetsDF = pd.read_csv("training.1600000.processed.noemoticon.csv",
engine="python", header=None)

让我们通过显示前五行来查看数据的结构:

>>> tweetDF.head(5)
0  0  1467810672  ...  NO_QUERY   scotthamilton  is upset that ...
1  0  1467810917  ...  NO_QUERY        mattycus  @Kenichan I dived many times ...
2  0  1467811184  ...  NO_QUERY         ElleCTF    my whole body feels itchy
3  0  1467811193  ...  NO_QUERY          Karoli  @nationwideclass no, it's ...
4  0  1467811372  ...  NO_QUERY        joy_wolf  @Kwesidei not the whole crew

令人恼火的是,这个 CSV 中没有标题字段(再次欢迎来到数据科学家的世界!),但通过查看网站并运用我们的直觉,我们可以看到我们感兴趣的是最后一列(推文文本)和第一列(我们的标签)。然而,标签不是很好,所以让我们做一些特征工程来解决这个问题。让我们看看我们的训练集中有哪些计数:

>>> tweetsDF[0].value_counts()
4    800000
0    800000
Name: 0, dtype: int64

有趣的是,在训练数据集中没有中性值。这意味着我们可以将问题制定为 0 和 1 之间的二元选择,并从中得出我们的预测,但目前我们坚持原始计划,即未来可能会有中性推文。为了将类别编码为从 0 开始的数字,我们首先从标签列创建一个category类型的列:

tweetsDF["sentiment_cat"] = tweetsDF[0].astype('category')

然后我们将这些类别编码为另一列中的数字信息:

tweetsDF["sentiment"] = tweetsDF["sentiment_cat"].cat.codes

然后我们将修改后的 CSV 保存回磁盘:

tweetsDF.to_csv("train-processed.csv", header=None, index=None)

我建议您保存另一个 CSV 文件,其中包含 160 万条推文的小样本,供您进行测试:

tweetsDF.sample(10000).to_csv("train-processed-sample.csv", header=None,
    index=None)

现在我们需要告诉torchtext我们认为对于创建数据集而言重要的内容。

定义字段

torchtext采用了一种直接的方法来生成数据集:您告诉它您想要什么,它将为您处理原始 CSV(或 JSON)数据。您首先通过定义字段来实现这一点。Field类有相当多的参数可以分配给它,尽管您可能不会同时使用所有这些参数,但表 5-1 提供了一个方便的指南,说明您可以使用Field做什么。

表 5-1. 字段参数类型

参数 描述 默认值
sequential 字段是否表示序列数据(即文本)。如果设置为False,则不会应用标记化。 True
use_vocab 是否包含Vocab对象。如果设置为False,字段应包含数字数据。 True
init_token 将添加到此字段开头以指示数据开始的令牌。 None
eos_token 附加到每个序列末尾的句子结束令牌。 None
fix_length 如果设置为整数,所有条目将填充到此长度。如果为None,序列长度将是灵活的。 None
dtype 张量批次的类型。 torch.long
lower 将序列转换为小写。 False
tokenize 将执行序列标记化的函数。如果设置为spacy,将使用 spaCy 分词器。 string.split
pad_token 将用作填充的令牌。 <pad>
unk_token 用于表示Vocab dict中不存在的单词的令牌。 <unk>
pad_first 在序列开始处填充。 False
truncate_first 在序列开头截断(如果需要)。 False

正如我们所指出的,我们只对标签和推文文本感兴趣。我们通过使用Field数据类型来定义这些内容:

from torchtext import data

LABEL = data.LabelField()
TWEET = data.Field(tokenize='spacy', lower=true)

我们将LABEL定义为LabelField,它是Field的子类,将sequential设置为False(因为它是我们的数字类别)。TWEET是一个标准的Field对象,我们决定使用 spaCy 分词器并将所有文本转换为小写,但在其他方面,我们使用前面表中列出的默认值。如果在运行此示例时,构建词汇表的步骤花费了很长时间,请尝试删除tokenize参数并重新运行。这将使用默认值,即简单地按空格分割,这将大大加快标记化步骤,尽管创建的词汇表不如 spaCy 创建的那么好。

定义了这些字段后,我们现在需要生成一个列表,将它们映射到 CSV 中的行列表:

 fields = [('score',None), ('id',None),('date',None),('query',None),
      ('name',None),
      ('tweet', TWEET),('category',None),('label',LABEL)]

有了我们声明的字段,我们现在使用TabularDataset将该定义应用于 CSV:

twitterDataset = torchtext.data.TabularDataset(
        path="training-processed.csv",
        format="CSV",
        fields=fields,
        skip_header=False)

这可能需要一些时间,特别是使用 spaCy 解析器。最后,我们可以使用split()方法将其拆分为训练、测试和验证集:

(train, test, valid) = twitterDataset.split(split_ratio=[0.8,0.1,0.1])

(len(train),len(test),len(valid))
> (1280000, 160000, 160000)

以下是从数据集中提取的示例:

>vars(train.examples[7])

{'label': '6681',
 'tweet': ['woah',
  ',',
  'hell',
  'in',
  'chapel',
  'thrill',
  'is',
  'closed',
  '.',
  'no',
  'more',
  'sweaty',
  'basement',
  'dance',
  'parties',
  '?',
  '?']}

在一个令人惊讶的巧合中,随机选择的推文提到了我经常访问的教堂山俱乐部的关闭。看看您在数据中的浏览中是否发现了任何奇怪的事情!

构建词汇表

传统上,在这一点上,我们将构建数据集中每个单词的独热编码——这是一个相当乏味的过程。幸运的是,torchtext会为我们做这个工作,并且还允许传入一个max_size参数来限制词汇表中最常见的单词。通常这样做是为了防止构建一个巨大的、占用内存的模型。毕竟,我们不希望我们的 GPU 被压倒。让我们将词汇表限制在训练集中最多 20,000 个单词:

vocab_size = 20000
TWEET.build_vocab(train, max_size = vocab_size)

然后我们可以查询vocab类实例对象,以发现关于我们数据集的一些信息。首先,我们问传统的“我们的词汇量有多大?”:

len(TWEET.vocab)
> 20002

等等,等等,什么? 是的,我们指定了 20,000,但默认情况下,torchtext会添加两个特殊的标记,<unk>表示未知单词(例如,那些被我们指定的 20,000 max_size截断的单词),以及<pad>,一个填充标记,将用于将所有文本填充到大致相同的大小,以帮助在 GPU 上进行有效的批处理(请记住,GPU 的速度来自于对常规批次的操作)。当您声明一个字段时,您还可以指定eos_tokeninit_token符号,但它们不是默认包含的。

现在让我们来看看词汇表中最常见的单词:

>TWEET.vocab.freqs.most_common(10)
[('!', 44802),
 ('.', 40088),
 ('I', 33133),
 (' ', 29484),
 ('to', 28024),
 ('the', 24389),
 (',', 23951),
('a', 18366),
 ('i', 17189),
('and', 14252)]

基本上符合您的预期,因为我们的 spaCy 分词器没有去除停用词。(因为它只有 140 个字符,如果我们去除了停用词,我们的模型将丢失太多信息。)

我们几乎已经完成了我们的数据集。我们只需要创建一个数据加载器来输入到我们的训练循环中。torchtext提供了BucketIterator方法,它将生成一个称为Batch的东西,几乎与我们在图像上使用的数据加载器相同,但又有所不同。(很快您将看到,我们必须更新我们的训练循环来处理Batch接口的一些奇怪之处。)

train_iterator, valid_iterator, test_iterator = data.BucketIterator.splits(
(train, valid, test),
batch_size = 32,
device = device)

将所有内容放在一起,这是构建我们数据集的完整代码:

from torchtext import data

device = "cuda"
LABEL = data.LabelField()
TWEET = data.Field(tokenize='spacy', lower=true)

fields = [('score',None), ('id',None),('date',None),('query',None),
      ('name',None),
      ('tweet', TWEET),('category',None),('label',LABEL)]

twitterDataset = torchtext.data.TabularDataset(
        path="training-processed.csv",
        format="CSV",
        fields=fields,
        skip_header=False)

(train, test, valid) = twitterDataset.split(split_ratio=[0.8,0.1,0.1])

vocab_size = 20002
TWEET.build_vocab(train, max_size = vocab_size)

train_iterator, valid_iterator, test_iterator = data.BucketIterator.splits(
(train, valid, test),
batch_size = 32,
device = device)

有了我们的数据处理完成,我们可以继续定义我们的模型。

创建我们的模型

我们在 PyTorch 中使用了我们在本章前半部分讨论过的EmbeddingLSTM模块来构建一个简单的推文分类模型:

import torch.nn as nn

class OurFirstLSTM(nn.Module):
    def __init__(self, hidden_size, embedding_dim, vocab_size):
        super(OurFirstLSTM, self).__init__()

        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.encoder = nn.LSTM(input_size=embedding_dim,
                hidden_size=hidden_size, num_layers=1)
        self.predictor = nn.Linear(hidden_size, 2)

    def forward(self, seq):
        output, (hidden,_) = self.encoder(self.embedding(seq))
        preds = self.predictor(hidden.squeeze(0))
        return preds

model = OurFirstLSTM(100,300, 20002)
model.to(device)

在这个模型中,我们所做的就是创建三个层。首先,我们的推文中的单词被推送到一个Embedding层中,我们已经将其建立为一个 300 维的向量嵌入。然后将其输入到一个具有 100 个隐藏特征的LSTM中(同样,我们正在从 300 维的输入中进行压缩,就像我们在处理图像时所做的那样)。最后,LSTM 的输出(处理传入推文后的最终隐藏状态)被推送到一个标准的全连接层中,有三个输出对应于我们的三个可能的类别(负面、积极或中性)。接下来我们转向训练循环!

更新训练循环

由于一些torchtext的怪癖,我们需要编写一个稍微修改过的训练循环。首先,我们创建一个优化器(通常我们使用 Adam)和一个损失函数。因为对于每个推文,我们有三个潜在的类别,所以我们使用CrossEntropyLoss()作为我们的损失函数。然而,事实证明数据集中只有两个类别;如果我们假设只有两个类别,我们实际上可以改变模型的输出,使其产生一个介于 0 和 1 之间的单个数字,然后使用二元交叉熵(BCE)损失(我们可以将将输出压缩在 0 和 1 之间的 sigmoid 层和 BCE 层组合成一个单一的 PyTorch 损失函数,BCEWithLogitsLoss())。我提到这一点是因为如果您正在编写一个必须始终处于一种状态或另一种状态的分类器,那么它比我们即将使用的标准交叉熵损失更合适。

optimizer = optim.Adam(model.parameters(), lr=2e-2)
criterion = nn.CrossEntropyLoss()

def train(epochs, model, optimizer, criterion, train_iterator, valid_iterator):
    for epoch in range(1, epochs + 1):

        training_loss = 0.0
        valid_loss = 0.0
        model.train()
        for batch_idx, batch in enumerate(train_iterator):
            opt.zero_grad()
            predict = model(batch.tweet)
            loss = criterion(predict,batch.label)
            loss.backward()
            optimizer.step()
            training_loss += loss.data.item() * batch.tweet.size(0)
        training_loss /= len(train_iterator)

        model.eval()
        for batch_idx,batch in enumerate(valid_iterator):
            predict = model(batch.tweet)
            loss = criterion(predict,batch.label)
            valid_loss += loss.data.item() * x.size(0)

        valid_loss /= len(valid_iterator)
        print('Epoch: {}, Training Loss: {:.2f},
        Validation Loss: {:.2f}'.format(epoch, training_loss, valid_loss))

在这个新的训练循环中需要注意的主要事项是,我们必须引用batch.tweetbatch.label来获取我们感兴趣的特定字段;它们并不像在torchvision中那样从枚举器中很好地脱落出来。

一旦我们使用这个函数训练了我们的模型,我们就可以用它来对一些推文进行简单的情感分析。

分类推文

torchtext的另一个麻烦是它有点难以预测事物。你可以模拟内部发生的处理流程,并在该流程的输出上进行所需的预测,如这个小函数所示:

def classify_tweet(tweet):
    categories = {0: "Negative", 1:"Positive"}
    processed = TWEET.process([TWEET.preprocess(tweet)])
    return categories[model(processed).argmax().item()]

我们必须调用preprocess(),它执行基于 spaCy 的标记化。之后,我们可以调用process()将标记基于我们已构建的词汇表转换为张量。我们唯一需要注意的是torchtext期望一批字符串,因此在将其传递给处理函数之前,我们必须将其转换为列表的列表。然后我们将其馈送到模型中。这将产生一个如下所示的张量:

tensor([[ 0.7828, -0.0024]]

具有最高值的张量元素对应于模型选择的类别,因此我们使用argmax()来获取该索引,然后使用item()将零维张量转换为 Python 整数,然后将其索引到我们的categories字典中。

训练完我们的模型后,让我们看看如何执行你在第 2–4 章中学到的其他技巧和技术。

数据增强

你可能会想知道如何增强文本数据。毕竟,你不能像处理图像那样水平翻转文本!但你可以使用一些文本技术,为模型提供更多训练信息。首先,你可以用同义词替换句子中的单词,如下所示:

The cat sat on the mat

可以变成

The cat sat on the rug

除了猫坚持认为地毯比垫子更柔软之外,句子的含义并没有改变。但是matrug将映射到词汇表中的不同索引,因此模型将学习到这两个句子映射到相同标签,并希望这两个单词之间存在联系,因为句子中的其他内容都是相同的。

2019 年初,论文“EDA:用于提高文本分类任务性能的简单数据增强技术”提出了另外三种增强策略:随机插入、随机交换和随机删除。让我们看看每种方法。³

随机插入

随机插入技术查看一个句子,然后随机插入现有非停用词的同义词n次。假设你有一种获取单词同义词和消除停用词(常见单词如anditthe等)的方法,通过get_synonyms()get_stopwords()在这个函数中显示,但没有实现,这个实现如下:

def random_insertion(sentence,n):
    words = remove_stopwords(sentence)
    for _ in range(n):
        new_synonym = get_synonyms(random.choice(words))
        sentence.insert(randrange(len(sentence)+1), new_synonym)
    return sentence

在实践中,替换cat的示例可能如下所示:

The cat sat on the mat
The cat mat sat on feline the mat

随机删除

顾名思义,随机删除从句子中删除单词。给定概率参数p,它将遍历句子,并根据该随机概率决定是否删除单词:

def random_deletion(words, p=0.5):
    if len(words) == 1:
        return words
    remaining = list(filter(lambda x: random.uniform(0,1) > p,words))
    if len(remaining) == 0:
        return [random.choice(words)]
    else
        return remaining

该实现处理边缘情况——如果只有一个单词,该技术将返回它;如果我们最终删除了句子中的所有单词,该技术将从原始集合中随机抽取一个单词。

随机交换

随机交换增强接受一个句子,然后在其中* n *次交换单词,每次迭代都在先前交换的句子上进行。这里是一个实现:

def random_swap(sentence, n=5):
    length = range(len(sentence))
    for _ in range(n):
        idx1, idx2 = random.sample(length, 2)
        sentence[idx1], sentence[idx2] = sentence[idx2], sentence[idx1]
    return sentence

我们根据句子的长度随机抽取两个随机数,然后一直交换直到达到n

EDA 论文中的技术在使用少量标记示例(大约 500 个)时平均提高了约 3%的准确性。如果你的数据集中有 5000 个以上的示例,该论文建议这种改进可能会降至 0.8%或更低,因为模型从更多可用数据量中获得更好的泛化能力,而不是从 EDA 提供的改进中获得。

回译

另一种流行的增强数据集的方法是回译。这涉及将一个句子从我们的目标语言翻译成一个或多个其他语言,然后将它们全部翻译回原始语言。我们可以使用 Python 库googletrans来实现这个目的。在写作时,你可以使用pip安装它,因为它似乎不在conda中:

pip install googletrans

然后,我们可以将我们的句子从英语翻译成法语,然后再翻译回英语:

import googletrans
import googletrans.Translator

translator = Translator()

sentences = ['The cat sat on the mat']

translation_fr = translator.translate(sentences, dest='fr')
fr_text = [t.text for t in translations_fr]
translation_en = translator.translate(fr_text, dest='en')
en_text = [t.text for t in translation_en]
print(en_text)

>> ['The cat sat on the carpet']

这样我们就得到了一个从英语到法语再到英语的增强句子,但让我们再进一步,随机选择一种语言:

import random

available_langs = list(googletrans.LANGUAGES.keys())
tr_lang = random.choice(available_langs)
print(f"Translating to {googletrans.LANGUAGES[tr_lang]}")

translations = translator.translate(sentences, dest=tr_lang)
t_text = [t.text for t in translations]
print(t_text)

translations_en_random = translator.translate(t_text, src=tr_lang, dest='en')
en_text = [t.text for t in translations_en_random]
print(en_text)

在这种情况下,我们使用random.choice来选择一个随机语言,将句子翻译成该语言,然后再翻译回来。我们还将语言传递给src参数,以帮助谷歌翻译的语言检测。试一试,看看它有多像电话这个老游戏。

你需要了解一些限制。首先,一次只能翻译最多 15,000 个字符,尽管如果你只是翻译句子的话,这应该不会是太大的问题。其次,如果你要在一个大型数据集上使用这个方法,你应该在云实例上进行数据增强,而不是在家里的电脑上,因为如果谷歌封禁了你的 IP,你将无法正常使用谷歌翻译!确保你一次发送几批数据而不是整个数据集。这也应该允许你在谷歌翻译后端出现错误时重新启动翻译批次。

增强和 torchtext

到目前为止,你可能已经注意到我所说的关于增强的一切都没有涉及torchtext。遗憾的是,这是有原因的。与torchvisiontorchaudio不同,torchtext并没有提供转换管道,这有点让人恼火。它确实提供了一种执行预处理和后处理的方式,但这只在标记(单词)级别上操作,这对于同义词替换可能足够了,但对于像回译这样的操作并没有提供足够的控制。如果你尝试在增强中利用这些管道,你应该在预处理管道中而不是后处理管道中进行,因为在后处理管道中你只会看到由整数组成的张量,你需要通过词汇规则将其映射到单词。

出于这些原因,我建议不要浪费时间试图把torchtext搞得一团糟来进行数据增强。相反,使用诸如回译之类的技术在 PyTorch 之外进行增强,生成新数据并将其输入模型,就像它是真实数据一样。

增强已经讨论完毕,但在结束本章之前,我们应该解决一个悬而未决的问题。

迁移学习?

也许你会想知道为什么我们还没有谈论迁移学习。毕竟,这是一个关键技术,可以帮助我们创建准确的基于图像的模型,那么为什么我们不能在这里做呢?事实证明,在 LSTM 网络上实现迁移学习有点困难。但并非不可能。我们将在第九章中回到这个主题,你将看到如何在基于 LSTM 和 Transformer 的网络上实现迁移学习。

结论

在这一章中,我们涵盖了一个文本处理流程,涵盖了编码和嵌入,一个简单的基于 LSTM 的神经网络用于执行分类,以及一些针对基于文本数据的数据增强策略。到目前为止,您有很多可以尝试的内容。在标记化阶段,我选择将每条推文都转换为小写。这是自然语言处理中的一种流行方法,但它确实丢弃了推文中的潜在信息。想想看:“为什么这不起作用?”对我们来说甚至更暗示了负面情绪,而不是“为什么这不起作用?”但是在它进入模型之前,我们已经丢弃了这两条推文之间的差异。因此,一定要尝试在标记化文本中保留大小写敏感性。尝试从输入文本中删除停用词,看看是否有助于提高准确性。传统的自然语言处理方法非常强调删除停用词,但我经常发现当保留输入中的停用词时,深度学习技术可以表现得更好(这是我们在本章中所做的)。这是因为它们为模型提供了更多的上下文信息,而将句子简化为仅包含重要单词的情况可能会丢失文本中的细微差别。

您可能还想改变嵌入向量的大小。更大的向量意味着嵌入可以捕捉更多关于其建模的单词的信息,但会使用更多内存。尝试从 100 到 1,000 维的嵌入,并查看它如何影响训练时间和准确性。

最后,您也可以尝试使用 LSTM。我们使用了一种简单的方法,但您可以增加num_layers以创建堆叠的 LSTM,增加或减少层中隐藏特征的数量,或设置bidirectional=true以创建双向 LSTM。将整个 LSTM 替换为 GRU 层也是一个有趣的尝试;它训练速度更快吗?准确性更高吗?尝试实验并看看您会发现什么!

与此同时,我们将从文本转向音频领域,使用torchaudio

进一步阅读

¹ 请注意,使用 CNN 也可以做到这些事情;在过去几年中,已经进行了大量深入研究,以将基于 CNN 的网络应用于时间域。我们不会在这里涵盖它们,但“时间卷积网络:动作分割的统一方法” 作者:Colin Lea 等(2016 年)提供了更多信息。还有 seq2seq!

² 参见“在向量空间中高效估计单词表示” 作者:Tomas Mikolov 等(2013 年)

³ 参见“EDA:用于提升文本分类任务性能的简单数据增强技术” 作者:Jason W. Wei 和 Kai Zou(2019 年)

第六章:声音之旅

深度学习最成功的应用之一是我们每天随身携带的东西。无论是 Siri 还是 Google Now,驱动这两个系统以及亚马逊的 Alexa 的引擎都是神经网络。在本章中,我们将看一下 PyTorch 的torchaudio库。您将学习如何使用它来构建一个用于分类音频数据的基于卷积的模型的流水线。之后,我将建议一种不同的方法,让您可以使用一些您学到的图像技巧,并在 ESC-50 音频数据集上获得良好的准确性。

但首先,让我们看看声音本身。它是什么?它通常以数据形式表示,这是否为我们提供了任何线索,告诉我们应该使用什么类型的神经网络来从数据中获得洞察?

声音

声音是通过空气的振动产生的。我们听到的所有声音都是高低压力的组合,我们通常用波形来表示,就像图 6-1 中的那个。在这个图像中,原点上方的波是高压,下方的部分是低压。

正弦波

图 6-1. 正弦波

图 6-2 显示了一首完整歌曲的更复杂的波形。

歌曲波形

图 6-2. 歌曲波形

在数字声音中,我们以每秒多次对这个波形进行采样,传统上是 44,100 次,用于 CD 音质,然后存储每个采样点的波的振幅值。在时间t,我们有一个单一的存储值。这与图像略有不同,图像需要两个值xy来存储值(对于灰度图像)。如果我们在神经网络中使用卷积滤波器,我们需要一个 1D 滤波器,而不是我们用于图像的 2D 滤波器。

现在您对声音有了一些了解,让我们看看我们使用的数据集,这样您就可以更加熟悉它。

ESC-50 数据集

环境声音分类(ESC)数据集是一组现场录音,每个录音长达 5 秒,并分配给 50 个类别之一(例如,狗叫、打鼾、敲门声)。我们将在本章的其余部分中使用此集合,以尝试两种分类音频的方法,并探索使用torchaudio来简化加载和操作音频。

获取数据集

ESC-50 数据集是一组 WAV 文件。您可以通过克隆 Git 存储库来下载它:

git clone https://github.com/karoldvl/ESC-50

或者您可以使用 curl 下载整个存储库:

curl https://github.com/karoldvl/ESC-50/archive/master.zip

所有的 WAV 文件都存储在audio目录中,文件名如下:

1-100032-A-0.wav

我们关心文件名中的最后一个数字,因为这告诉我们这个声音片段被分配到了哪个类别。文件名的其他部分对我们来说并不重要,但大多数与 ESC-50 所绘制的更大的 Freesound 数据集相关(有一个例外,我马上会回来解释)。如果您想了解更多信息,ESC-50 存储库中的README文档会提供更详细的信息。

现在我们已经下载了数据集,让我们看看它包含的一些声音。

在 Jupyter 中播放音频

如果您想真正听到 ESC-50 中的声音,那么您可以使用 Jupyter 内置的音频播放器IPython.display.Audio,而不是将文件加载到标准音乐播放器(如 iTunes)中:

import IPython.display as display
display.Audio('ESC-50/audio/1-100032-A-0.wav')

该函数将读取我们的 WAV 文件和 MP3 文件。您还可以生成张量,将它们转换为 NumPy 数组,并直接播放这些数组。播放ESC-50目录中的一些文件,以了解可用的声音。完成后,我们将更深入地探索数据集。

探索 ESC-50

处理新数据集时,最好在构建模型之前先了解数据的形状。例如,在分类任务中,你会想知道你的数据集是否实际包含了所有可能的类别的示例,并且最好所有类别的数量是相等的。让我们看看 ESC-50 是如何分解的。

注意

如果你的数据集的数据量是不平衡的,一个简单的解决方案是随机复制较小类别的示例,直到你将它们增加到其他类别的数量。虽然这感觉像是虚假的账务,但在实践中它是令人惊讶地有效(而且便宜!)。

我们知道每个文件名中最后一组数字描述了它所属的类别,所以我们需要做的是获取文件列表并计算每个类别的出现次数:

import glob
from collections import Counter

esc50_list = [f.split("-")[-1].replace(".wav","")
        for f in
        glob.glob("ESC-50/audio/*.wav")]
Counter(esc50_list)

首先,我们建立一个 ESC-50 文件名列表。因为我们只关心文件名末尾的类别编号,我们去掉 .wav 扩展名,并在 - 分隔符上分割文件名。最后我们取分割字符串中的最后一个元素。如果你检查 esc50_list,你会得到一堆从 0 到 49 的字符串。我们可以编写更多的代码来构建一个 dict 并为我们计算所有出现的次数,但我懒,所以我使用了一个 Python 的便利函数 Counter,它可以为我们做所有这些。

这是输出!

Counter({'15': 40,
     '22': 40,
     '36': 40,
     '44': 40,
     '23': 40,
     '31': 40,
     '9': 40,
     '13': 40,
     '4': 40,
     '3': 40,
     '27': 40,
     …})

我们有一种罕见的完全平衡的数据集。让我们拿出香槟,安装一些我们很快会需要的库。

SoX 和 LibROSA

torchaudio 进行的大部分音频处理依赖于另外两个软件:SoXLibROSALibROSA 是一个用于音频分析的 Python 库,包括生成梅尔频谱图(你将在本章稍后看到这些),检测节拍,甚至生成音乐。

另一方面,SoX 是一个你可能已经熟悉的程序,如果你多年来一直在使用 Linux 的话。事实上,SoX 是如此古老,以至于它早于 Linux 本身;它的第一个版本是在 1991 年 7 月发布的,而 Linux 的首次亮相是在 1991 年 9 月。我记得在 1997 年使用它将 WAV 文件转换为 MP3 文件在我的第一台 Linux 电脑上。但它仍然很有用!

如果你通过 conda 安装 torchaudio,你可以跳到下一节。如果你使用 pip,你可能需要安装 SoX 本身。对于基于 Red Hat 的系统,输入以下命令:

yum install sox

或者在基于 Debian 的系统上,你将使用以下命令:

apt intall sox

安装 SoX 后,你可以继续获取 torchaudio 本身。

torchaudio

安装 torchaudio 可以通过 condapip 进行:

conda install -c derickl torchaudio
pip install torchaudio

torchvision 相比,torchaudio 类似于 torchtext,因为它并不像 torchvision 那样受到喜爱、维护或文档化。我预计随着 PyTorch 变得更受欢迎,更好的文本和音频处理流程将被创建,这种情况将在不久的将来发生改变。不过,torchaudio 对我们的需求来说已经足够了;我们只需要编写一些自定义的数据加载器(对于音频或文本处理,我们不需要这样做)。

无论如何,torchaudio 的核心在于 load()save()。在本章中,我们只关心 load(),但如果你从输入生成新的音频(例如文本到语音模型),你需要使用 save()load() 接受在 filepath 中指定的文件,并返回音频文件的张量表示和该音频文件的采样率作为一个单独的变量。

我们现在有了从 ESC-50 数据集中加载一个 WAV 文件并将其转换为张量的方法。与我们之前处理文本和图像的工作不同,我们需要写更多的代码才能继续创建和训练模型。我们需要编写一个自定义的 dataset

构建 ESC-50 数据集

我们在第二章中讨论过数据集,但torchvisiontorchtext为我们做了所有繁重的工作,所以我们不必太担心细节。你可能还记得,自定义数据集必须实现两个类方法,__getitem____len__,以便数据加载器可以获取一批张量及其标签,以及数据集中张量的总数。我们还有一个__init__方法用于设置诸如文件路径之类的东西,这些东西将一遍又一遍地使用。

这是我们对 ESC-50 数据集的第一次尝试:

class ESC50(Dataset):

    def __init__(self,path):
        # Get directory listing from path
        files = Path(path).glob('*.wav')
        # Iterate through the listing and create a list of tuples (filename, label)
        self.items = [(f,int(f.name.split("-")[-1]
                    .replace(".wav",""))) for f in files]
        self.length = len(self.items)

    def __getitem__(self, index):
        filename, label = self.items[index]
        audio_tensor, sample_rate = torchaudio.load(filename)
        return audio_tensor, label

    def __len__(self):
        return self.length

类中的大部分工作发生在创建其新实例时。__init__方法接受path参数,找到该路径内的所有 WAV 文件,然后通过使用我们在本章早些时候使用的相同字符串拆分来生成(filename, label)元组,以获取该音频样本的标签。当 PyTorch 从数据集请求项目时,我们索引到items列表,使用torchaudio.load使torchaudio加载音频文件,将其转换为张量,然后返回张量和标签。

这就足够让我们开始了。为了进行健全性检查,让我们创建一个ESC50对象并提取第一个项目:

test_esc50 = ESC50(PATH_TO_ESC50)
tensor, label = list(test_esc50)[0]

tensor
tensor([-0.0128, -0.0131, -0.0143,  ...,  0.0000,  0.0000,  0.0000])

tensor.shape
torch.Size([220500])

label
'15'

我们可以使用标准的 PyTorch 构造来构建数据加载器:

example_loader = torch.utils.data.DataLoader(test_esc50, batch_size = 64,
shuffle = True)

但在这之前,我们必须回到我们的数据。您可能还记得,我们应该始终创建训练、验证和测试集。目前,我们只有一个包含所有数据的目录,这对我们的目的来说不好。将数据按 60/20/20 的比例分成训练、验证和测试集应该足够了。现在,我们可以通过随机抽取整个数据集的样本来做到这一点(注意要进行无重复抽样,并确保我们新构建的数据集仍然是平衡的),但是 ESC-50 数据集再次帮助我们省去了很多工作。数据集的编译者将数据分成了五个相等的平衡folds,文件名中的第一个数字表示。我们将1,2,3折作为训练集,4折作为验证集,5折作为测试集。但如果你不想无聊和连续,可以随意混合!将每个折叠移到testtrainvalidation目录中:

mv 1* ../train
mv 2* ../train
mv 3* ../train
mv 4* ../valid
mv 5* ../test

现在我们可以创建各个数据集和加载器:

from pathlib import Path

bs=64
PATH_TO_ESC50 = Path.cwd() / 'esc50'
path =  'test.md'
test

train_esc50 = ESC50(PATH_TO_ESC50 / "train")
valid_esc50 = ESC50(PATH_TO_ESC50 / "valid")
test_esc50  = ESC50(PATH_TO_ESC50 / "test")

train_loader = torch.utils.data.DataLoader(train_esc50, batch_size = bs,
                shuffle = True)
valid_loader = torch.utils.data.DataLoader(valid_esc50, batch_size = bs,
                shuffle = True)
test_loader  = torch.utils.data.DataLoader(test_esc50, batch_size = bs,
                shuffle = True)

我们已经准备好了我们的数据,所以我们现在准备好查看分类模型了。

ESC-50 的 CNN 模型

对于我们第一次尝试分类声音,我们构建了一个模型,它大量借鉴了一篇名为“用于原始波形的非常深度卷积网络”的论文。² 您会发现它使用了我们在第三章中的许多构建模块,但是我们使用的是 1D 变体,而不是 2D 层,因为我们的音频输入少了一个维度:

class AudioNet(nn.Module):
    def __init__(self):
        super(AudioNet, self).__init__()
        self.conv1 = nn.Conv1d(1, 128, 80, 4)
        self.bn1 = nn.BatchNorm1d(128)
        self.pool1 = nn.MaxPool1d(4)
        self.conv2 = nn.Conv1d(128, 128, 3)
        self.bn2 = nn.BatchNorm1d(128)
        self.pool2 = nn.MaxPool1d(4)
        self.conv3 = nn.Conv1d(128, 256, 3)
        self.bn3 = nn.BatchNorm1d(256)
        self.pool3 = nn.MaxPool1d(4)
        self.conv4 = nn.Conv1d(256, 512, 3)
        self.bn4 = nn.BatchNorm1d(512)
        self.pool4 = nn.MaxPool1d(4)
        self.avgPool = nn.AvgPool1d(30)
        self.fc1 = nn.Linear(512, 10)

    def forward(self, x):
        x = self.conv1(x)
        x = F.relu(self.bn1(x))
        x = self.pool1(x)
        x = self.conv2(x)
        x = F.relu(self.bn2(x))
        x = self.pool2(x)
        x = self.conv3(x)
        x = F.relu(self.bn3(x))
        x = self.pool3(x)
        x = self.conv4(x)
        x = F.relu(self.bn4(x))
        x = self.pool4(x)
        x = self.avgPool(x)
        x = x.permute(0, 2, 1)
        x = self.fc1(x)
        return F.log_softmax(x, dim = 2)

我们还需要一个优化器和一个损失函数。对于优化器,我们像以前一样使用 Adam,但你认为我们应该使用什么损失函数?(如果你回答CrossEntropyLoss,给自己一个金星!)

audio_net = AudioNet()
audio_net.to(device)

创建完我们的模型后,我们保存我们的权重,并使用第四章中的find_lr()函数:

audio_net.save("audionet.pth")
import torch.optim as optim
optimizer = optim.Adam(audionet.parameters(), lr=0.001)
logs,losses = find_lr(audio_net, nn.CrossEntropyLoss(), optimizer)
plt.plot(logs,losses)

从图 6-3 中的图表中,我们确定适当的学习率大约是1e-5(基于下降最陡的地方)。我们将其设置为我们的学习率,并重新加载我们模型的初始权重:

AudioNet 学习率图

图 6-3. AudioNet 学习率图
lr = 1e-5
model.load("audionet.pth")
import torch.optim as optim
optimizer = optim.Adam(audionet.parameters(), lr=lr)

我们对模型进行 20 个周期的训练:

train(audio_net, optimizer, torch.nn.CrossEntropyLoss(),
train_data_loader, valid_data_loader, epochs=20)

训练后,您应该发现模型在我们的数据集上达到了大约 13%至 17%的准确率。这比我们随机选择 50 个类别中的一个时可以期望的 2%要好。但也许我们可以做得更好;让我们探讨一种不同的查看音频数据的方式,可能会产生更好的结果。

这个频率是我的宇宙

如果您回顾一下 ESC-50 的 GitHub 页面,您会看到一个网络架构和其准确度得分的排行榜。您会注意到,与其他相比,我们的表现并不出色。我们可以扩展我们创建的模型使其更深,这可能会稍微提高我们的准确度,但要实现真正的性能提升,我们需要切换领域。在音频处理中,您可以像我们一直在做的那样处理纯波形;但大多数情况下,您将在频域中工作。这种不同的表示将原始波形转换为一个视图,显示了在特定时间点的所有声音频率。这可能是向神经网络呈现更丰富信息的表示形式,因为它可以直接处理这些频率,而不必弄清楚如何将原始波形信号映射为模型可以使用的内容。

让我们看看如何使用LibROSA生成频谱图。

Mel 频谱图

传统上,进入频域需要在音频信号上应用傅立叶变换。我们将通过在 mel 刻度上生成我们的频谱图来超越这一点。mel 刻度定义了一个音高刻度,其中相距相等,其中 1000 mels = 1000 Hz。这种刻度在音频处理中很常用,特别是在语音识别和分类应用中。使用LibROSA生成 mel 频谱图只需要两行代码:

sample_data, sr = librosa.load("ESC-50/train/1-100032-A-0.wav", sr=None)
spectrogram = librosa.feature.melspectrogram(sample_data, sr=sr)

这将生成一个包含频谱图数据的 NumPy 数组。如果我们像图 6-4 中所示显示这个频谱图,我们就可以看到我们声音中的频率:

librosa.display.specshow(spectrogram, sr=sr, x_axis='time', y_axis='mel')

Mel 频谱图

图 6-4。Mel 频谱图

然而,图像中并没有太多信息。我们可以做得更好!如果我们将频谱图转换为对数刻度,由于该刻度能够表示更广泛的值范围,我们可以看到音频结构的更多内容。这在音频处理中很常见,LibROSA包含了一个方法:

log_spectrogram = librosa.power_to_db(spectrogram, ref=np.max)

这将计算一个10 * log10(spectrogram / ref)的缩放因子。ref默认为1.0,但在这里我们传入np.max(),以便spectrogram / ref将落在[0,1]的范围内。图 6-5 显示了新的频谱图。

对数 Mel 频谱图

图 6-5。对数 mel 频谱图

现在我们有了一个对数刻度的 mel 频谱图!如果您调用log_spectrogram.shape,您会看到它是一个 2D 张量,这是有道理的,因为我们已经用张量绘制了图像。我们可以创建一个新的神经网络架构,并将这些新数据输入其中,但我有一个恶毒的技巧。我们刚刚生成了频谱图数据的图像。为什么不直接处理这些呢?

这一开始可能看起来有些愚蠢;毕竟,我们有基础频谱图数据,这比图像表示更精确(对我们来说,知道一个数据点是 58 而不是 60 对我们来说意义更大,而不是不同色调,比如紫色)。如果我们从头开始,这肯定是这样。但是!我们已经有了一些训练有素的网络,如 ResNet 和 Inception,我们知道它们擅长识别图像的结构和其他部分。我们可以构建音频的图像表示,并使用预训练网络再次利用迁移学习的超能力,通过很少的训练来大幅提高准确度。这对我们的数据集可能很有用,因为我们没有很多示例(只有 2000 个!)来训练我们的网络。

这个技巧可以应用于许多不同的数据集。如果您能找到一种便宜地将数据转换为图像表示的方法,那么值得这样做,并将 ResNet 网络应用于其,以了解迁移学习对您的作用,这样您就知道通过使用不同方法可以超越什么。有了这个,让我们创建一个新的数据集,以便根据需要为我们生成这些图像。

一个新的数据集

现在丢弃原始的ESC50数据集类,构建一个新的ESC50Spectrogram。虽然这将与旧类共享一些代码,但在这个版本的__get_item__方法中会有更多的操作。我们通过LibROSA生成频谱图,然后通过一些复杂的matplotlib操作将数据转换为 NumPy 数组。我们将该数组应用于我们的转换流水线(只使用ToTensor),并返回该数组和项目的标签。以下是代码:

class ESC50Spectrogram(Dataset):

def __init__(self,path):
    files = Path(path).glob('*.wav')
    self.items = [(f,int(f.name.split("-")[-1].replace(".wav","")))
                   for f in files]
    self.length = len(self.items)
    self.transforms = torchvision.transforms.Compose(
                 [torchvision.transforms.ToTensor()])

def __getitem__(self, index):
    filename, label = self.items[index]
    audio_tensor, sample_rate = librosa.load(filename, sr=None)
    spectrogram = librosa.feature.melspectrogram(audio_tensor, sr=sample_rate)
    log_spectrogram = librosa.power_to_db(spectrogram, ref=np.max)
    librosa.display.specshow(log_spectrogram, sr=sample_rate,
                             x_axis='time', y_axis='mel')
    plt.gcf().canvas.draw()
    audio_data = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8)
    audio_data = audio_data.reshape(fig.canvas.get_width_height()[::-1] + (3,))
    return (self.transforms(audio_data), label)

def __len__(self):
    return self.length

我们不会花太多时间在这个数据集的版本上,因为它有一个很大的缺陷,我用 Python 的process_time()方法演示了这一点:

oldESC50 = ESC50("ESC-50/train/")
start_time = time.process_time()
oldESC50.__getitem__(33)
end_time = time.process_time()
old_time = end_time - start_time

newESC50 = ESC50Spectrogram("ESC-50/train/")
start_time = time.process_time()
newESC50.__getitem__(33)
end_time = time.process_time()
new_time = end_time - start_time

old_time = 0.004786839000075815
new_time = 0.39544327499993415

新数据集的速度几乎比我们原始的只返回原始音频的数据集慢一百倍!这将使训练变得非常缓慢,甚至可能抵消使用迁移学习所能带来的任何好处。

我们可以使用一些技巧来解决大部分问题。第一种方法是添加一个缓存,将生成的频谱图存储在内存中,这样我们就不必每次调用__getitem__方法时都重新生成它。使用 Python 的functools包,我们可以很容易地做到这一点:

import functools

class ESC50Spectrogram(Dataset):
 #skipping init code

    @functools.lru_cache(maxsize=<size of dataset>)
    def __getitem__(self, index):

只要您有足够的内存来存储整个数据集的内容到 RAM 中,这可能就足够了。我们设置了一个最近最少使用(LRU)缓存,将尽可能长时间地保留内容在内存中,最近没有被访问的索引在内存紧张时首先被驱逐出缓存。然而,如果您没有足够的内存来存储所有内容,您将在每个批次迭代时遇到减速,因为被驱逐的频谱图需要重新生成。

我的首选方法是预计算所有可能的图表,然后创建一个新的自定义数据集类,从磁盘加载这些图像。(您甚至可以添加 LRU 缓存注释以进一步加快速度。)

我们不需要为预计算做任何花哨的事情,只需要一个将图表保存到正在遍历的目录中的方法:

def precompute_spectrograms(path, dpi=50):
    files = Path(path).glob('*.wav')
    for filename in files:
        audio_tensor, sample_rate = librosa.load(filename, sr=None)
        spectrogram = librosa.feature.melspectrogram(audio_tensor, sr=sr)
        log_spectrogram = librosa.power_to_db(spectrogram, ref=np.max)
        librosa.display.specshow(log_spectrogram, sr=sr, x_axis='time',
                                 y_axis='mel')
        plt.gcf().savefig("{}{}_{}.png".format(filename.parent,dpi,
                          filename.name),dpi=dpi)

这种方法比我们之前的数据集更简单,因为我们可以使用matplotlibsavefig方法直接将图表保存到磁盘,而不必与 NumPy 搞在一起。我们还提供了一个额外的输入参数dpi,允许我们控制生成输出的质量。在我们已经设置好的所有traintestvalid路径上运行(可能需要几个小时才能处理完所有图像)。

现在我们只需要一个新的数据集来读取这些图像。我们不能使用第二章到第四章中的标准ImageDataLoader,因为 PNG 文件名方案与其使用的目录结构不匹配。但没关系,我们可以使用 Python Imaging Library 打开一张图片:

from PIL import Image

    class PrecomputedESC50(Dataset):
        def __init__(self,path,dpi=50, transforms=None):
            files = Path(path).glob('{}*.wav.png'.format(dpi))
            self.items = [(f,int(f.name.split("-")[-1]
            .replace(".wav.png",""))) for f in files]
            self.length = len(self.items)
            if transforms=None:
                self.transforms =
                torchvision.transforms.Compose([torchvision.transforms.ToTensor()])
            else:
                self.transforms = transforms

        def __getitem__(self, index):
            filename, label = self.items[index]
            img = Image.open(filename)
            return (self.transforms(img), label)

        def __len__(self):
            return self.length

这段代码更简单,希望从数据集中获取一个条目所需的时间也反映在其中:

start_time = time.process_time()
b.__getitem__(33)
end_time = time.process_time()
end_time - start_time
>> 0.0031465259999094997

从这个数据集获取一个元素大致需要与我们原始基于音频的数据集相同的时间,所以我们不会因为转向基于图像的方法而失去任何东西,除了预计算所有图像并创建数据库的一次性成本。我们还提供了一个默认的转换流水线,将图像转换为张量,但在初始化期间可以替换为不同的流水线。有了这些优化,我们可以开始将迁移学习应用到这个问题上。

一只野生的 ResNet 出现了

正如您可能记得的那样,从第四章中,迁移学习要求我们使用已经在特定数据集上训练过的模型(在图像的情况下,可能是 ImageNet),然后在我们特定的数据领域上微调它,即我们将 ESC-50 数据集转换为频谱图像。您可能会想知道一个在正常照片上训练过的模型对我们是否有用。事实证明,预训练模型确实学到了很多结构,可以应用于乍看起来可能非常不同的领域。以下是我们从第四章中初始化模型的代码:

from torchvision import models
spec_resnet = models.ResNet50(pretrained=True)

for param in spec_resnet.parameters():
    param.requires_grad = False

spec_resnet.fc = nn.Sequential(nn.Linear(spec_resnet.fc.in_features,500),
nn.ReLU(),
nn.Dropout(), nn.Linear(500,50))

这使我们使用了一个预训练的(并冻结的)ResNet50模型,并将模型的头部替换为一个未经训练的Sequential模块,最后以一个输出为 50 的Linear结尾,每个类别对应 ESC-50 数据集中的一个类。我们还需要创建一个DataLoader,以获取我们预先计算的频谱图。当我们创建 ESC-50 数据集时,我们还希望使用标准的 ImageNet 标准差和均值对传入的图像进行归一化,因为预训练的 ResNet-50 架构就是用这种方式训练的。我们可以通过传入一个新的管道来实现:

esc50pre_train = PreparedESC50(PATH, transforms=torchvision.transforms
.Compose([torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize
(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])]))

esc50pre_valid = PreparedESC50(PATH, transforms=torchvision.transforms
.Compose([torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize
(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])]))

esc50_train_loader = (esc50pre_train, bs, shuffle=True)
esc50_valid_loader = (esc50pre_valid, bs, shuffle=True)

设置好数据加载器后,我们可以继续寻找学习率并准备训练。

寻找学习率

我们需要找到一个学习率来在我们的模型中使用。就像在第四章中一样,我们会保存模型的初始参数,并使用我们的find_lr()函数来找到一个适合训练的学习率。图 6-6 显示了损失与学习率之间的关系图。

spec_resnet.save("spec_resnet.pth")
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.Adam(spec_resnet.parameters(), lr=lr)
logs,losses = find_lr(spec_resnet, loss_fn, optimizer)
plt.plot(logs, losses)

SpecResNet 学习率图

图 6-6. SpecResNet 学习率图

查看学习率与损失之间的图表,似乎1e-2是一个不错的起点。由于我们的 ResNet-50 模型比之前的模型更深,我们还将使用不同的学习率[1e-2,1e-4,1e-8],其中最高的学习率应用于我们的分类器(因为它需要最多的训练!),而对于已经训练好的主干使用较慢的学习率。同样,我们使用 Adam 作为优化器,但可以尝试其他可用的优化器。

在我们应用这些不同的学习率之前,我们会训练几个周期,只更新分类器,因为我们在创建网络时冻结了 ResNet-50 的主干:

optimizer = optim.Adam(spec_resnet.parameters(), lr=[1e-2,1e-4,1e-8])

train(spec_resnet, optimizer, nn.CrossEntropyLoss(),
esc50_train_loader, esc50_val_loader,epochs=5,device="cuda")

现在我们解冻主干并应用我们的不同学习率:

for param in spec_resnet.parameters():
    param.requires_grad = True

optimizer = optim.Adam(spec_resnet.parameters(), lr=[1e-2,1e-4,1e-8])

train(spec_resnet, optimizer, nn.CrossEntropyLoss(),
esc50_train_loader, esc50_val_loader,epochs=20,device="cuda")

> Epoch 19, accuracy = 0.80

如您所见,验证准确率约为 80%,我们已经远远超过了原始的AudioNet模型。再次展示了迁移学习的强大!可以继续训练更多周期,看看准确率是否继续提高。如果查看 ESC-50 排行榜,我们已经接近人类水平的准确率。而这仅仅是使用 ResNet-50。您可以尝试使用 ResNet-101,或者尝试使用不同架构的集成来进一步提高分数。

还有数据增强要考虑。让我们看看在迄今为止我们一直在工作的两个领域中如何做到这一点。

音频数据增强

当我们在第四章中查看图像时,我们发现通过对传入的图片进行更改,如翻转、裁剪或应用其他转换,可以提高分类器的准确性。通过这些方式,我们让神经网络在训练阶段更加努力,并在最后得到一个更通用的模型,而不仅仅是适应所呈现的数据(过拟合的祸根,不要忘记)。我们能在这里做同样的事情吗?是的!事实上,我们可以使用两种方法——一种明显的方法适用于原始音频波形,另一种可能不太明显的想法源自我们决定在 mel 频谱图像上使用基于 ResNet 的分类器。让我们先看看音频转换。

torchaudio 转换

torchvision类似,torchaudio包括一个transforms模块,对传入数据执行转换。然而,提供的转换数量有些稀少,特别是与处理图像时得到的丰富多样相比。如果您感兴趣,请查看文档获取完整列表,但我们在这里只看一个torchaudio.transforms.PadTrim。在 ESC-50 数据集中,每个音频剪辑的长度都是相同的。这在现实世界中并不常见,但我们的神经网络喜欢(有时也坚持,取决于它们的构建方式)输入数据是规则的。PadTrim将接收到的音频张量填充到所需长度,或者将其修剪到不超过该长度。如果我们想将剪辑修剪到新长度,我们会这样使用PadTrim

audio_tensor, rate = torchaudio.load("test.wav")
audio_tensor.shape
trimmed_tensor = torchaudio.transforms.PadTrim(max_len=1000)(audio_orig)

然而,如果您正在寻找实际改变音频声音的增强(例如添加回声、噪音或更改剪辑的节奏),那么torchaudio.transforms模块对您没有用。相反,我们需要使用SoX

SoX 效果链

为什么它不是transforms模块的一部分,我真的不确定,但torchaudio.sox_effects.SoxEffectsChain允许您创建一个或多个SoX效果链,并将这些效果应用于输入文件。界面有点棘手,让我们在一个新版本的数据集中看看它的运行方式,该数据集改变了音频文件的音调:

class ESC50WithPitchChange(Dataset):

    def __init__(self,path):
        # Get directory listing from path
        files = Path(path).glob('*.wav')
        # Iterate through the listing and create a list of tuples (filename, label)
        self.items = [(f,f.name.split("-")[-1].replace(".wav","")) for f in files]
        self.length = len(self.items)
        self.E = torchaudio.sox_effects.SoxEffectsChain()
        self.E.append_effect_to_chain("pitch", [0.5])

    def __getitem__(self, index):
        filename, label = self.items[index]
        self.E.set_input_file(filename)
        audio_tensor, sample_rate = self.E.sox_build_flow_effects()
        return audio_tensor, label

    def __len__(self):
        return self.length

在我们的__init__方法中,我们创建一个新的实例变量E,一个SoxEffectsChain,它将包含我们要应用于音频数据的所有效果。然后,我们通过使用append_effect_to_chain添加一个新效果,该方法接受一个指示效果名称的字符串,以及要发送给sox的参数数组。您可以通过调用torchaudio.sox_effects.effect_names()获取可用效果的列表。如果我们要添加另一个效果,它将在我们已经设置的音调效果之后发生,因此如果您想创建一系列单独的效果并随机应用它们,您需要为每个效果创建单独的链。

当选择要返回给数据加载器的项目时,情况有所不同。我们不再使用torchaudio.load(),而是引用我们的效果链,并使用set_input_file指向文件。但请注意,这并不会加载文件!相反,我们必须使用sox_build_flow_effects(),它在后台启动SoX,应用链中的效果,并返回我们通常从load()中获得的张量和采样率信息。

SoX可以做的事情数量相当惊人,我不会详细介绍您可以使用的所有可能效果。我建议结合list_effects()查看SoX文档以了解可能性。

这些转换允许我们改变原始音频,但在本章的大部分时间里,我们都在构建一个处理管道,用于处理梅尔频谱图像。我们可以像为该管道生成初始数据集所做的那样,创建修改后的音频样本,然后从中创建频谱图像,但在那时,我们将创建大量数据,需要在运行时混合在一起。幸运的是,我们可以对频谱图像本身进行一些转换。

SpecAugment

现在,你可能会想到:“等等,这些频谱图只是图片!我们可以对它们使用任何图片变换!” 是的!后面的你获得金星。但是我们必须小心一点;例如,随机裁剪可能会剪掉足够的频率,从而潜在地改变输出类别。在我们的 ESC-50 数据集中,这不是一个大问题,但如果你在做类似语音识别的事情,那么在应用增强时肯定要考虑这一点。另一个有趣的可能性是,因为我们知道所有的频谱图具有相同的结构(它们总是一个频率图!),我们可以创建基于图像的变换,专门围绕这种结构工作。

2019 年,谷歌发布了一篇关于 SpecAugment 的论文,[3]在许多音频数据集上报告了新的最先进结果。该团队通过使用三种新的数据增强技术直接应用于梅尔频谱图:时间弯曲、频率掩码和时间掩码,从中获得了这些结果。我们不会讨论时间弯曲,因为从中获得的好处很小,但我们将为掩码时间和频率实现自定义变换。

频率掩码

频率掩码会随机地从我们的音频输入中移除一个频率或一组频率。这样做是为了让模型更加努力;它不能简单地记忆输入及其类别,因为在每个批次中输入的不同频率会被掩码。模型将不得不学习其他特征,以确定如何将输入映射到一个类别,这希望会导致一个更准确的模型。

在我们的梅尔频谱图中,这通过确保在任何时间步长中该频率的频谱图中没有任何内容来显示。图 6-7 展示了这是什么样子:基本上是在自然频谱图上画了一条空白线。

这是一个实现频率掩码的自定义Transform的代码:

class FrequencyMask(object):
    """
 Example:
 >>> transforms.Compose([
 >>>     transforms.ToTensor(),
 >>>     FrequencyMask(max_width=10, use_mean=False),
 >>> ])

 """

    def __init__(self, max_width, use_mean=True):
        self.max_width = max_width
        self.use_mean = use_mean

    def __call__(self, tensor):
        """
 Args:
 tensor (Tensor): Tensor image of
 size (C, H, W) where the frequency
 mask is to be applied.

 Returns:
 Tensor: Transformed image with Frequency Mask.
 """
        start = random.randrange(0, tensor.shape[2])
        end = start + random.randrange(1, self.max_width)
        if self.use_mean:
            tensor[:, start:end, :] = tensor.mean()
        else:
            tensor[:, start:end, :] = 0
        return tensor

    def __repr__(self):
        format_string = self.__class__.__name__ + "(max_width="
        format_string += str(self.max_width) + ")"
        format_string += 'use_mean=' + (str(self.use_mean) + ')')

        return format_string

当应用变换时,PyTorch 将使用图像的张量表示调用__call__方法(因此我们需要将其放在将图像转换为张量后的Compose链中,而不是之前)。我们假设张量将以通道×高度×宽度格式呈现,并且我们希望将高度值设置在一个小范围内,要么为零,要么为图像的平均值(因为我们使用对数梅尔频谱图,平均值应该与零相同,但我们包括两种选项,以便您可以尝试看哪种效果更好)。范围由max_width参数提供,我们得到的像素掩码将在 1 到max_pixels之间。我们还需要为掩码选择一个随机起点,这就是start变量的作用。最后,这个变换的复杂部分——我们应用我们生成的掩码:

tensor[:, start:end, :] = tensor.mean()

当我们将其分解时,情况就没有那么糟糕了。我们的张量有三个维度,但我们希望在所有红色、绿色和蓝色通道上应用这个变换,所以我们使用裸的:来选择该维度中的所有内容。使用start:end,我们选择我们的高度范围,然后我们选择宽度通道中的所有内容,因为我们希望在每个时间步长上应用我们的掩码。然后在表达式的右侧,我们设置值;在这种情况下,是tensor.mean()。如果我们从 ESC-50 数据集中取一个随机张量并将变换应用于它,我们可以在图 6-7 中看到这个类别正在创建所需的掩码。

torchvision.transforms.Compose([FrequencyMask(max_width=10, use_mean=False),
torchvision.transforms.ToPILImage()])(torch.rand(3,250,200))

应用于随机 ESC-50 样本的频率掩码

图 6-7。应用于随机 ESC-50 样本的频率掩码

接下来我们将转向时间掩码。

时间掩码

有了我们的频率掩码完成后,我们可以转向时间掩码,它与频率掩码相同,但在时间域中。这里的代码大部分是相同的:

class TimeMask(object):
    """
 Example:
 >>> transforms.Compose([
 >>>     transforms.ToTensor(),
 >>>     TimeMask(max_width=10, use_mean=False),
 >>> ])

 """

    def __init__(self, max_width, use_mean=True):
        self.max_width = max_width
        self.use_mean = use_mean

    def __call__(self, tensor):
        """
 Args:
 tensor (Tensor): Tensor image of
 size (C, H, W) where the time mask
 is to be applied.

 Returns:
 Tensor: Transformed image with Time Mask.
 """
        start = random.randrange(0, tensor.shape[1])
        end = start + random.randrange(0, self.max_width)
        if self.use_mean:
            tensor[:, :, start:end] = tensor.mean()
        else:
            tensor[:, :, start:end] = 0
        return tensor

    def __repr__(self):
        format_string = self.__class__.__name__ + "(max_width="
        format_string += str(self.max_width) + ")"
        format_string += 'use_mean=' + (str(self.use_mean) + ')')
        return format_string

正如您所看到的,这个类与频率掩码类似。唯一的区别是我们的start变量现在在高度轴上的某个点范围内,当我们进行掩码处理时,我们这样做:

tensor[:, :, start:end] = 0

这表明我们选择张量的前两个维度的所有值和最后一个维度中的start:end范围。再次,我们可以将这应用于来自 ESC-50 的随机张量,以查看掩码是否被正确应用,如图 6-8 所示。

torchvision.transforms.Compose([TimeMask(max_width=10, use_mean=False),
torchvision.transforms.ToPILImage()])(torch.rand(3,250,200))

应用于随机 ESC-50 样本的时间掩码

图 6-8。应用于随机 ESC-50 样本的时间掩码

为了完成我们的增强,我们创建一个新的包装器转换,确保一个或两个掩码应用于频谱图像:

class PrecomputedTransformESC50(Dataset):
    def __init__(self,path,dpi=50):
        files = Path(path).glob('{}*.wav.png'.format(dpi))
        self.items = [(f,f.name.split("-")[-1].replace(".wav.png",""))
                      for f in files]
        self.length = len(self.items)
        self.transforms = transforms.Compose([
    transforms.ToTensor(),
    RandomApply([FrequencyMask(self.max_freqmask_width)]p=0.5),
    RandomApply([TimeMask(self.max_timemask_width)]p=0.5)
])

    def __getitem__(self, index):
        filename, label = self.items[index]
        img = Image.open(filename)
        return (self.transforms(img), label)

    def __len__(self):
        return self.length

尝试使用这种数据增强重新运行训练循环,看看您是否像谷歌一样通过这些掩码获得更好的准确性。但也许我们还可以尝试更多与这个数据集有关的内容?

进一步实验

到目前为止,我们已经创建了两个神经网络——一个基于原始音频波形,另一个基于 mel 频谱图像——用于对 ESC-50 数据集中的声音进行分类。尽管您已经看到基于 ResNet 的模型在使用迁移学习的力量时更准确,但创建这两个网络的组合来查看是否增加或减少准确性将是一个有趣的实验。这样做的一个简单方法是重新审视第四章中的集成方法:只需组合和平均预测。此外,我们跳过了基于我们从频谱图中获取的原始数据构建网络的想法。如果创建了一个适用于该数据的模型,那么如果将其引入集成,是否会提高整体准确性?我们还可以使用其他版本的 ResNet,或者我们可以创建使用不同预训练模型(如 VGG 或 Inception)作为骨干的新架构。探索一些这些选项并看看会发生什么;在我的实验中,SpecAugment 将 ESC-50 分类准确性提高了约 2%。

结论

在本章中,我们使用了两种非常不同的音频分类策略,简要介绍了 PyTorch 的torchaudio库,并看到了在数据集上预先计算转换的方法,而在进行实时转换时会严重影响训练时间。我们讨论了两种数据增强方法。作为一个意外的奖励,我们再次通过使用迁移学习来训练基于图像的模型,快速生成一个与 ESC-50 排行榜上其他模型相比准确度较高的分类器。

这结束了我们对图像、测试和音频的导览,尽管我们将在第九章中再次涉及这三个方面,当我们看一些使用 PyTorch 的应用程序时。不过,接下来,我们将看看在模型训练不够正确或速度不够快时如何调试模型。

进一步阅读

理解SoX可以做什么超出了本书的范围,并且对于我们接下来在本章中要做的事情并不是必要的。

参见“用于原始波形的非常深度卷积神经网络” 由 Wei Dai 等人(2016 年)。

参见“SpecAugment:用于自动语音识别的简单数据增强方法” 由 Daniel S. Park 等人(2019 年)。

第七章:调试 PyTorch 模型

到目前为止,我们在本书中创建了许多模型,但在本章中,我们简要地看一下如何解释它们并弄清楚底层发生了什么。我们看一下如何使用 PyTorch 钩子和类激活映射来确定模型决策的焦点,以及如何将 PyTorch 连接到 Google 的 TensorBoard 进行调试。我将展示如何使用火焰图来识别转换和训练管道中的瓶颈,并提供一个加速缓慢转换的示例。最后,我们将看看如何在处理更大的模型时通过检查点来交换计算和内存。不过,首先,简要谈谈您的数据。

凌晨 3 点。你的数据在做什么?

在我们深入研究像 TensorBoard 或梯度检查点这样的闪亮东西之前,问问自己:您了解您的数据吗?如果您正在对输入进行分类,您是否在所有可用标签上拥有平衡的样本?在训练、验证和测试集中?

而且,您确定您的标签是正确的吗?像 MNIST 和 CIFAR-10(加拿大高级研究所)这样的重要基于图像的数据集已知包含一些不正确的标签。您应该检查您的数据,特别是如果类别彼此相似,比如狗品种或植物品种。简单地对数据进行合理性检查可能会节省大量时间,如果您发现,比如说,一个标签类别只有微小的图像,而其他所有类别都有大分辨率的示例。

一旦您确保数据处于良好状态,那么是的,让我们转到 TensorBoard 开始检查模型中的一些可能问题。

TensorBoard

TensorBoard是一个用于可视化神经网络各个方面的 Web 应用程序。它允许轻松实时查看诸如准确性、损失激活值等统计数据,以及您想要发送的任何内容。尽管它是为 TensorFlow 编写的,但它具有如此通用和相当简单的 API,以至于在 PyTorch 中使用它与在 TensorFlow 中使用它并没有太大不同。让我们安装它,看看我们如何使用它来获取有关我们模型的一些见解。

注意

在阅读 PyTorch 时,您可能会遇到一个名为Visdom的应用程序,这是 Facebook 对 TensorBoard 的替代方案。在 PyTorch v1.1 之前,支持可视化的方式是使用 Visdom 与 PyTorch,同时第三方库如tensorboardX可用于与 TensorBoard 集成。虽然 Visdom 仍在维护,但在 v1.1 及以上版本中包含了官方的 TensorBoard 集成,这表明 PyTorch 的开发人员已经认识到 TensorBoard 是事实上的神经网络可视化工具。

安装 TensorBoard

安装 TensorBoard 可以使用pipconda

pip install tensorboard
conda install tensorboard
注意

PyTorch 需要 v1.14 或更高版本的 TensorBoard。

然后可以在命令行上启动 TensorBoard:

tensorboard --logdir=runs

然后,您可以转到http://[your-machine]:6006,您将看到图 7-1 中显示的欢迎屏幕。现在我们可以向应用程序发送数据。

Tensorboard

图 7-1. TensorBoard

将数据发送到 TensorBoard

使用 PyTorch 的 TensorBoard 模块位于torch.utils.tensorboard中:

from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter()
writer.add_scalar('example', 3)

我们使用SummaryWriter类与 TensorBoard 通信,使用标准的日志输出位置./runs,可以通过使用带有标签的add_scalar发送标量。由于SummaryWriter是异步工作的,可能需要一会儿,但您应该看到 TensorBoard 更新,如图 7-2 所示。

Tensorboard 中的示例数据点

图 7-2. TensorBoard 中的示例数据点

这并不是很令人兴奋,对吧?让我们写一个循环,从初始起点发送更新:

import random
value = 10
writer.add_scalar('test_loop', value, 0)
for i in range(1,10000):
  value += random.random() - 0.5
  writer.add_scalar('test_loop', value, i)

通过传递我们在循环中的位置,如图 7-3 所示,TensorBoard 会给我们一个绘制我们从 10 开始进行的随机漫步的图。如果我们再次运行代码,我们会看到它在显示中生成了一个不同的run,我们可以在网页的左侧选择是否要查看所有运行或只查看特定的一些。

在 tensorboard 中绘制随机漫步

图 7-3. 在 TensorBoard 中绘制随机漫步

我们可以用这个函数来替换训练循环中的print语句。我们也可以发送模型本身以在 TensorBoard 中得到表示!

import torch
import torchvision
from torch.utils.tensorboard import SummaryWriter
from torchvision import datasets, transforms,models

writer = SummaryWriter()
model = models.resnet18(False)
writer.add_graph(model,torch.rand([1,3,224,224]))

def train(model, optimizer, loss_fn, train_data_loader, test_data_loader, epochs=20):
    model = model.train()
    iteration = 0

    for epoch in range(epochs):
        model.train()
        for batch in train_loader:
            optimizer.zero_grad()
            input, target = batch
            output = model(input)
            loss = loss_fn(output, target)
            writer.add_scalar('loss', loss, epoch)
            loss.backward()
            optimizer.step()

        model.eval()
        num_correct = 0
        num_examples = 0
        for batch in val_loader:
            input, target = batch
            output = model(input)
            correct = torch.eq(torch.max(F.softmax(output), dim=1)[1], target).view(-1)
            num_correct += torch.sum(correct).item()
            num_examples += correct.shape[0]
            print("Epoch {}, accuracy = {:.2f}".format(epoch,
                   num_correct / num_examples)
            writer.add_scalar('accuracy', num_correct / num_examples, epoch)
        iterations += 1

当使用add_graph()时,我们需要发送一个张量来跟踪模型,以及模型本身。一旦发生这种情况,你应该在 TensorBoard 中看到GRAPHS出现,并且如图 7-4 所示,点击大的 ResNet 块会显示模型结构的更多细节。

可视化 ResNet

图 7-4. 可视化 ResNet

现在我们可以将准确性和损失信息以及模型结构发送到 TensorBoard。通过聚合多次运行的准确性和损失信息,我们可以看到特定运行与其他运行有何不同,这在尝试弄清楚为什么训练运行产生糟糕结果时是一个有用的线索。我们很快会回到 TensorBoard,但首先让我们看看 PyTorch 为调试提供的其他功能。

PyTorch 钩子

PyTorch 有钩子,它们是可以附加到张量或模块的前向或后向传递的函数。当 PyTorch 在传递过程中遇到带有钩子的模块时,它会调用已注册的钩子。在张量上注册的钩子在计算其梯度时会被调用。

钩子是操纵模块和张量的潜在强大方式,因为如果你愿意,你可以完全替换钩子中的输出。你可以改变梯度,屏蔽激活,替换模块中的所有偏置等等。然而,在本章中,我们只会将它们用作在数据流过程中获取有关网络信息的一种方式。

给定一个 ResNet-18 模型,我们可以使用register_forward_hook在模型的特定部分附加一个前向钩子:

def print_hook(self, module, input, output):
  print(f"Shape of input is {input.shape}")

model = models.resnet18()
hook_ref  = model.fc.register_forward_hook(print_hook)
model(torch.rand([1,3,224,224]))
hook_ref.remove()
model(torch.rand([1,3,224,224]))

如果你运行这段代码,你应该会看到打印出的文本,显示模型的线性分类器层的输入形状。请注意,第二次通过模型传递随机张量时,你不应该看到print语句。当我们向模块或张量添加钩子时,PyTorch 会返回对该钩子的引用。我们应该始终保存该引用(这里我们在hook_ref中这样做),然后在完成时调用remove()。如果你不保存引用,那么它将一直存在并占用宝贵的内存(并在传递过程中浪费计算资源)。反向钩子的工作方式相同,只是你要调用register_backward_hook()

当然,如果我们可以print()某些内容,我们肯定可以将其发送到 TensorBoard!让我们看看如何使用钩子和 TensorBoard 来获取训练过程中关于我们层的重要统计信息。

绘制均值和标准差

首先,我们设置一个函数,将输出层的均值和标准差发送到 TensorBoard:

def send_stats(i, module, input, output):
  writer.add_scalar(f"{i}-mean",output.data.std())
  writer.add_scalar(f"{i}-stddev",output.data.std())

我们不能单独使用这个来设置一个前向钩子,但是使用 Python 函数partial(),我们可以创建一系列前向钩子,它们将自动附加到具有设置i值的层,以确保正确的值被路由到 TensorBoard 中的正确图表中:

from functools import partial

for i,m in enumerate(model.children()):
  m.register_forward_hook(partial(send_stats, i))

请注意,我们正在使用model.children(),它只会附加到模型的每个顶层块,因此如果我们有一个nn.Sequential()层(在基于 ResNet 的模型中会有),我们只会将钩子附加到该块,而不是每个nn.Sequential列表中的单个模块。

如果我们使用通常的训练函数训练我们的模型,我们应该看到激活开始流入 TensorBoard,如图 7-5 所示。您将不得不在 UI 中切换到挂钟时间,因为我们不再使用钩子将步骤信息发送回 TensorBoard(因为我们只在调用 PyTorch 钩子时获取模块信息)。

Tensorboard 中模块的均值和标准差

图 7-5。TensorBoard 中模块的均值和标准差

现在,我在第二章中提到,理想情况下,神经网络中的层应该具有均值为 0,标准差为 1,以确保我们的计算不会无限制地增长或减少到零。查看 TensorBoard 中的层。它们看起来是否保持在这个值范围内?图表有时会突然上升然后崩溃吗?如果是这样,这可能是网络训练困难的信号。在图 7-5 中,我们的均值接近零,但标准差也非常接近零。如果您的网络的许多层中都发生这种情况,这可能表明您的激活函数(例如ReLU)并不完全适合您的问题领域。尝试使用其他函数进行实验,看看它们是否可以提高模型的性能;PyTorch 的LeakyReLU是一个很好的替代品,提供与标准ReLU类似的激活,但可以传递更多信息,这可能有助于训练。

关于 TensorBoard 的介绍就到这里,但是“进一步阅读”将指引您查阅更多资源。与此同时,让我们看看如何让模型解释它是如何做出决定的。

类激活映射

类激活映射(CAM)是一种在网络对传入张量进行分类后可视化激活的技术。在基于图像的分类器中,通常显示为热图覆盖在原始图像上,如图 7-6 所示。

使用 Casper 生成类激活映射

图 7-6。Casper 的类激活映射

从热图中,我们可以直观地了解网络是如何从可用的 ImageNet 类中决定波斯猫的。网络的激活在猫的脸部和身体周围最高,在图像的其他地方较低。

要生成热图,我们捕获网络的最终卷积层的激活,就在它进入“线性”层之前,因为这样我们可以看到组合的 CNN 层认为在从图像到类的最终映射中重要的是什么。幸运的是,有了 PyTorch 的钩子功能,这是相当简单的。我们将钩子封装在一个类SaveActivations中:

class SaveActivations():
    activations=None
    def __init__(self, m):
      self.hook = m.register_forward_hook(self.hook_fn)
    def hook_fn(self, module, input, output):
      self.features = output.data
    def remove(self):
      self.hook.remove()

然后,我们将 Casper 的图像通过网络(对 ImageNet 进行归一化),应用softmax将输出张量转换为概率,并使用torch.topk()作为提取最大概率及其索引的方法:

import torch
from torchvision import models, transforms
from torch.nn import functional as F

casper = Image.open("casper.jpg")
# Imagenet mean/std

normalize = transforms.Normalize(
   mean=[0.485, 0.456, 0.406],
   std=[0.229, 0.224, 0.225]
)

preprocess = transforms.Compose([
   transforms.Resize((224,224)),
   transforms.ToTensor(),
   normalize
])

display_transform = transforms.Compose([
   transforms.Resize((224,224))])

casper_tensor = preprocess(casper)

model = models.resnet18(pretrained=True)
model.eval()
casper_activations = SaveActivations(model.layer_4)
prediction = model(casper_tensor.unsqueeze(0))
pred_probabilities = F.softmax(prediction).data.squeeze()
casper_activations.remove()
torch.topk(pred_probabilities,1)
注意

我还没有解释torch.nn.functional,但最好的理解方法是它包含在torch.nn中提供的函数的实现。例如,如果您创建torch.nn.softmax()的实例,您将获得一个具有执行softmaxforward()方法的对象。如果您查看torch.nn.softmax()的实际源代码,您会看到该方法只是调用F.softmax()。由于我们不需要将softmax作为网络的一部分,我们只是调用底层函数。

如果我们现在访问casper_activations.activations,我们将看到它已经被一个张量填充,其中包含我们需要的最终卷积层的激活。然后我们这样做:

fts = sf[0].features[idx]
        prob = np.exp(to_np(log_prob))
        preds = np.argmax(prob[idx])
        fts_np = to_np(fts)
        f2=np.dot(np.rollaxis(fts_np,0,3), prob[idx])
        f2-=f2.min()
        f2/=f2.max()
        f2
plt.imshow(dx)
plt.imshow(scipy.misc.imresize(f2, dx.shape), alpha=0.5, cmap='jet');

这计算了来自 Casper 的激活的点积(我们索引为 0 是因为输入张量的第一维中有批处理,记住)。如第一章中提到的,PyTorch 以 C × H × W 格式存储图像数据,因此我们接下来需要将维度重新排列为 H × W × C 以显示图像。然后,我们从张量中去除最小值,并通过最大值进行缩放,以确保我们只关注结果热图中最高的激活(即,与波斯猫相关的内容)。最后,我们使用一些matplot魔法来显示 Casper,然后在顶部显示张量,调整大小并给出标准的jet颜色映射。请注意,通过用不同的类替换idx,您可以看到热图指示图像中存在哪些激活(如果有的话)在分类时。因此,如果模型预测汽车,您可以看到图像的哪些部分被用来做出这个决定。Casper 的第二高概率是安哥拉兔,我们可以从该索引的 CAM 中看到它专注于他非常蓬松的毛皮!

我们已经了解了模型在做出决策时的情况。接下来,我们将调查模型在训练循环或推断期间大部分时间都在做什么。

火焰图

与 TensorBoard 相比,火焰图并不是专门为神经网络创建的。不,甚至不是为了 TensorFlow。事实上,火焰图的起源可以追溯到 2011 年,当时一位名叫 Brendan Gregg 的工程师在一家名为 Joyent 的公司工作,他想出了这种技术来帮助调试他在 MySQL 中遇到的问题。这个想法是将大量的堆栈跟踪转换成单个图像,这本身就可以呈现出 CPU 在一段时间内的运行情况。

注意

Brendan Gregg 现在在 Netflix 工作,并有大量与性能相关的工作可供阅读和消化。

以 MySQL 插入表中的一行为例,我们每秒对堆栈进行数百次或数千次的采样。每次采样时,我们会得到一个堆栈跟踪,显示出该时刻堆栈中的所有函数。因此,如果我们在一个被另一个函数调用的函数中,我们将得到一个包含调用者和被调用者函数的跟踪。一个采样跟踪看起来像这样:

65.00%     0.00%  mysqld   [kernel.kallsyms]   [k] entry_SYSCALL_64_fastpath
             |
             ---entry_SYSCALL_64_fastpath
                |
                |--18.75%-- sys_io_getevents
                |          read_events
                |          schedule
                |          __schedule
                |          finish_task_switch
                |
                |--10.00%-- sys_fsync
                |          do_fsync
                |          vfs_fsync_range
                |          ext4_sync_file
                |          |
                |          |--8.75%-- jbd2_complete_transaction
                |          |          jbd2_log_wait_commit
                |          |          |
                |          |          |--6.25%-- _cond_resched
                |          |          |          preempt_schedule_common
                |          |          |          __schedule

这里有很多信息;这只是一个 400KB 堆栈跟踪集的一个小样本。即使有这种整理(可能不是所有堆栈跟踪中都有),要看清楚这里发生了什么也是很困难的。

另一方面,火焰图版本简单明了,如您在图 7-7 中所见。y 轴是堆栈高度,x 轴是,虽然不是时间,但表示了在采样时该函数在堆栈中出现的频率。因此,如果我们在堆栈顶部有一个函数占据了 80%的图形,我们就会知道程序在该函数中花费了大量的运行时间,也许我们应该查看该函数,看看是什么让它运行如此缓慢。

MySQL 火焰图

图 7-7. MySQL 火焰图

您可能会问,“这与深度学习有什么关系?”好吧,没错;在深度学习研究中,一个常见的说法是,当训练变慢时,您只需再购买 10 个 GPU 或向谷歌支付更多 TPU Pod 的费用。但也许您的训练流水线并不是完全受 GPU 限制。也许您有一个非常慢的转换,当您获得所有那些闪亮的新显卡时,它们并没有像您想象的那样有所帮助。火焰图提供了一种简单、一目了然的方法来识别 CPU 限制的瓶颈,这在实际的深度学习解决方案中经常发生。例如,还记得我们在第四章中谈到的所有基于图像的转换吗?大多数都使用 Python Imaging Library,并且完全受 CPU 限制。对于大型数据集,您将在训练循环中一遍又一遍地执行这些转换!因此,虽然它们在深度学习的背景下并不经常被提及,但火焰图是您工具箱中很好的工具。如果没有其他办法,您可以将它们用作向老板证明您确实受到 GPU 限制,并且您需要在下周四之前获得所有那些 TPU 积分!我们将看看如何从您的训练周期中获取火焰图,并通过将慢转换从 CPU 移动到 GPU 来修复它。

安装 py-spy

有许多方法可以生成可以转换为火焰图的堆栈跟踪。前一节中生成的是使用 Linux 工具perf生成的,这是一个复杂而强大的工具。我们将采取一个相对简单的选项,并使用py-spy,一个基于 Rust 的堆栈分析器,直接生成火焰图。通过pip安装它:

pip install py-spy

您可以通过使用--pid参数找到正在运行进程的进程标识符(PID),并附加py-spy

py-spy --flame profile.svg --pid 12345

或者您可以传入一个 Python 脚本,这是我们在本章中运行它的方式。首先,让我们在一个简单的 Python 脚本上运行它:

import torch
import torchvision

def get_model():
    return torchvision.models.resnet18(pretrained=True)

def get_pred(model):
    return model(torch.rand([1,3,224,224]))

model = get_model()

for i in range(1,10000):
    get_pred(model)

将此保存为flametest.py,然后让我们在其上运行py-spy,每秒采样 99 次,运行 30 秒:

py-spy -r 99 -d 30 --flame profile.svg -- python t.py

在浏览器中打开profile.svg文件,让我们看看生成的图形。

阅读火焰图

图 7-8 展示了图形大致应该是什么样子(由于采样的原因,它在您的机器上可能不会完全像这样)。您可能首先注意到的是图形是向下的,而不是向上的。py-spyicicle格式编写火焰图,因此堆栈看起来像钟乳石,而不是经典火焰图的火焰。我更喜欢正常格式,但py-spy不提供更改选项,而且这并没有太大的区别。

ResNet 加载和推理的火焰图

图 7-8. ResNet 加载和推理的火焰图

一眼看去,您应该看到大部分执行时间都花在各种forward()调用中,这是有道理的,因为我们正在使用模型进行大量预测。左侧的那些小块呢?如果您单击它们,您会发现 SVG 文件会放大,如图 7-9 所示。

放大的火焰图

图 7-9. 放大的火焰图

在这里,我们可以看到脚本设置了 ResNet-18 模块,并调用load_state_dict()来从磁盘加载保存的权重(因为我们使用pretrained=True调用它)。您可以单击“重置缩放”以返回完整的火焰图。此外,右侧的搜索栏将用紫色突出显示匹配的条形,如果您试图查找一个函数。尝试使用resnet,它将显示堆栈中名称中带有resnet的每个函数调用。这对于查找不经常出现在堆栈中的函数或查看该模式在整个图中出现的频率很有用。

玩一下 SVG,看看在这个示例中 BatchNorm 和池化等东西占用了多少 CPU 时间。接下来,我们将看一种使用火焰图来查找问题、修复问题并使用另一个火焰图验证的方法。

修复慢转换

在现实情况下,你的数据管道的一部分可能会导致减速。如果你有一个慢转换,这将是一个特别的问题,因为它将在训练批次期间被调用多次,导致在创建模型时出现巨大的瓶颈。这里是一个示例转换管道和一个数据加载器:

import torch
import torchvision
from torch import optim
import torch.nn as nn
from torchvision import datasets, transforms, models
import torch.utils.data
from PIL import Image
import numpy as np

device = "cuda:0"
model = models.resnet18(pretrained=True)
model.to(device)

class BadRandom(object):
    def __call__(self, img):
        img_np = np.array(img)
        random = np.random.random_sample(img_np.shape)
        out_np = img_np + random
        out = Image.fromarray(out_np.astype('uint8'), 'RGB')
        return out

    def __repr__(self):
        str = f"{self.__class__.__name__  }"
        return str

train_data_path = "catfish/train"
image_transforms =
torchvision.transforms.Compose(
  [transforms.Resize((224,224)),BadRandom(), transforms.ToTensor()])

我们不会运行完整的训练循环;相反,我们模拟了从训练数据加载器中提取图像的 10 个时期:

train_data = torchvision.datasets.ImageFolder(root=train_data_path,
transform=image_transforms)
batch_size=32
train_data_loader = torch.utils.data.DataLoader(train_data,
batch_size=batch_size)

optimizer = optim.Adam(model.parameters(), lr=2e-2)
criterion = nn.CrossEntropyLoss()

def train(model, optimizer, loss_fn,  train_loader, val_loader,
epochs=20, device='cuda:0'):
    model.to(device)
    for epoch in range(epochs):
        print(f"epoch {epoch}")
        model.train()
        for batch in train_loader:
            optimizer.zero_grad()
            ww, target = batch
            ww = ww.to(device)
            target= target.to(device)
            output = model(ww)
            loss = loss_fn(output, target)
            loss.backward()
            optimizer.step()

        model.eval()
        num_correct = 0
        num_examples = 0
        for batch in val_loader:
            input, target = batch
            input = input.to(device)
            target= target.to(device)
            output = model(input)
            correct = torch.eq(torch.max(output, dim=1)[1], target).view(-1)
            num_correct += torch.sum(correct).item()
            num_examples += correct.shape[0]
        print("Epoch {}, accuracy = {:.2f}"
        .format(epoch, num_correct / num_examples))

train(model,optimizer,criterion,
train_data_loader,train_data_loader,epochs=10)

让我们像以前一样在py-spy下运行该代码:

py-spy -r 99 -d 120 --flame slowloader.svg -- python slowloader.py

如果你打开生成的slowloader.svg,你应该会看到类似于图 7-10 的东西。尽管火焰图大部分时间都被用于加载图像并将其转换为张量,但我们在应用随机噪声上花费了采样运行时间的 16.87%。看看代码,我们的BadRandom实现是在 PIL 阶段应用噪声,而不是在张量阶段,所以我们受制于图像处理库和 NumPy,而不是 PyTorch 本身。因此,我们的第一个想法可能是重写转换,使其在张量而不是 PIL 图像上操作。这可能会更快,但并非总是如此——在进行性能更改时的重要事情始终是要测量一切。

带有 BadRandom 的火焰图

图 7-10。带有 BadRandom 的火焰图

但有一件奇怪的事情,一直贯穿整本书,尽管我直到现在才注意到它:你是否注意到我们从数据加载器中提取批次,然后将这些批次放入 GPU?因为转换发生在加载器从数据集类获取批次时,这些转换总是会在 CPU 上发生。在某些情况下,这可能会导致一些疯狂的横向思维。我们在每个图像上应用随机噪声。如果我们能一次在每个图像上应用随机噪声呢?

这里可能一开始看起来有点费解的部分是:我们向图像添加随机噪声。我们可以将其写为x + y,其中x是我们的图像,y是我们的噪声。我们知道图像和噪声都是 3D 的(宽度、高度、通道),所以这里我们所做的就是矩阵乘法。在一个批次中,我们将这样做z次。我们只是在从加载器中取出每个图像时对每个图像进行迭代。但请考虑,在加载过程结束时,图像被转换为张量,一个批次的[z, c, h, w]。那么,你难道不能只是添加一个形状为[z, c, h, w]的随机张量,以这种方式应用随机噪声吗?而不是按顺序应用噪声,它一次性完成。现在我们有了一个矩阵运算,以及一个非常昂贵的 GPU,它碰巧非常擅长矩阵运算。在 Jupyter Notebook 中尝试这样做,看看 CPU 和 GPU 张量矩阵操作之间的差异:

cpu_t1 = torch.rand(64,3,224,224)
cpu_t2 = torch.rand(64,3,224,224)
%timeit cpu_t1 + cpu_t2
>> 5.39 ms ± 4.29 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

gpu_t1 = torch.rand(64,3,224,224).to("cuda")
gpu_t2 = torch.rand(64,3,224,224).to("cuda")
%timeit gpu_t1 + gpu_t2
>> 297 µs ± 338 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

这样做的速度快了近 20 倍。我们可以将这个转换从我们的数据加载器中取出,等到整个批次都准备好后再执行矩阵运算:

def add_noise_gpu(tensor, device):
  random_noise = torch_rand_like(tensor).to(device)
  return tensor.add_(random_noise)

在我们的训练循环中,在input.to(device)之后添加这行:

input = add_noise_gpu(input, device)

然后从转换管道中移除BadRandom转换,并使用py-spy再次进行测试。新的火焰图显示在图 7-11 中。它如此之快,以至于它甚至不再在我们的采样频率下显示。我们刚刚将代码加速了近 17%!现在,并非所有标准转换都可以以 GPU 友好的方式编写,但如果可能的话,如果转换正在减慢你的速度,那么这绝对是一个值得考虑的选项。

带有 GPU 加速随机噪声的火焰图

图 7-11。带有 GPU 加速随机噪声的火焰图

现在我们已经考虑了计算,是时候看看房间里的另一个大象了:内存,特别是 GPU 上的内存。

调试 GPU 问题

在本节中,我们将更深入地研究 GPU 本身。在训练更大的深度学习模型时,您很快会发现,您花了很多钱购买的闪亮 GPU(或者更明智地,连接到基于云的实例)经常陷入困境,痛苦地抱怨内存不足。但是那个 GPU 有几千兆字节的存储空间!您怎么可能用完?

模型往往会占用大量内存。例如,ResNet-152 大约有 6000 万个激活,所有这些都占据了 GPU 上宝贵的空间。让我们看看如何查看 GPU 内部,以确定在内存不足时可能发生了什么。

检查您的 GPU

假设您正在使用 NVIDIA GPU(如果使用其他设备,请查看备用 GPU 供应商的驱动程序网站以获取他们自己的实用程序),CUDA 安装包括一个非常有用的命令行工具,称为nvidia-smi。当不带参数运行时,此工具可以为您提供有关 GPU 上使用的内存的快照,甚至更好的是,是谁在使用它!图 7-12 显示了在终端中运行nvidia-smi的输出。在笔记本中,您可以通过使用!nvidia-smi调用该实用程序。

从 nvidia-smi 输出

图 7-12。从 nvidia-smi 输出

这个示例来自我家里运行的一台 1080 Ti 机器。我正在运行一堆笔记本,每个笔记本都占用了一部分内存,但有一个占用了 4GB!您可以使用os.getpid()获取笔记本的当前 PID。结果表明,占用最多内存的进程实际上是我用来测试上一节中 GPU 变换的实验性笔记本!您可以想象,随着模型、批数据以及前向和后向传递的数据,内存很快会变得紧张。

注意

我还有一些进程在运行,也许令人惊讶的是,正在进行图形处理——即 X 服务器和 GNOME。除非您构建了本地机器,否则几乎肯定看不到这些。

此外,PyTorch 将为每个进程分配大约 0.5GB 的内存给自身和 CUDA。这意味着最好一次只处理一个项目,而不要像我这样到处运行 Jupyter Notebook(您可以使用内核菜单关闭与笔记本连接的 Python 进程)。

仅运行nvidia-smi将为您提供 GPU 使用情况的当前快照,但您可以使用-l标志获得持续输出。以下是一个示例命令,每 5 秒将转储时间戳、已使用内存、空闲内存、总内存和 GPU 利用率:

nvidia-smi --query-gpu=timestamp,
memory.used, memory.free,memory.total,utilization.gpu --format=csv -l 5

如果您真的认为 GPU 使用的内存比应该使用的要多,可以尝试让 Python 的垃圾收集器参与其中。如果您有一个不再需要的tensor_to_be_deleted,并且希望它从 GPU 中消失,那么来自 fast.ai 库深处的一个提示是使用del将其推开:

import gc
del tensor_to_be_deleted
gc.collect()

如果您在 Jupyter Notebook 中进行大量工作,创建和重新创建模型,可能会发现删除一些引用并通过使用gc.collect()调用垃圾收集器将收回一些内存。如果您仍然遇到内存问题,请继续阅读,因为可能会有解决您困扰的答案!

梯度检查点

尽管在上一节中介绍了所有删除和垃圾收集技巧,您可能仍然会发现自己内存不足。对于大多数应用程序来说,下一步要做的事情是减少在训练循环中通过模型的数据批量大小。这样做会起作用,但您将增加每个时代的训练时间,并且很可能模型不会像使用足够内存处理更大批量大小的等效模型那样好,因为您将在每次传递中看到更多数据集。但是,我们可以通过使用梯度检查点在 PyTorch 中为大型模型交换计算和内存。

处理更大模型时的一个问题是,前向和后向传递会产生大量中间状态,所有这些状态都会占用 GPU 内存。梯度检查点的目标是通过分段模型来减少可能同时存在于 GPU 上的状态量。这种方法意味着您可以在非分段模型的情况下具有四到十倍的批量大小,但这会使训练更加计算密集。在前向传递期间,PyTorch 会将输入和参数保存到一个段中,但实际上不执行前向传递。在后向传递期间,PyTorch 会检索这些内容,并为该段计算前向传递。中间值会传递到下一个段,但这些值必须仅在段与段之间执行。

将模型分割成这些段的工作由torch.utils.checkpoint.checkpoint_sequential()处理。它适用于nn.Sequential层或生成的层列表,但需要注意它们需要按照模型中出现的顺序排列。以下是它在 AlexNet 的features模块上的工作方式:

from torch.utils.checkpoint import checkpoint_sequential
import torch.nn as nn

class CheckpointedAlexNet(nn.Module):

    def __init__(self, num_classes=1000, chunks=2):
        super(CheckpointedAlexNet, self).__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(64, 192, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(192, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        self.avgpool = nn.AdaptiveAvgPool2d((6, 6))
        self.classifier = nn.Sequential(
            nn.Dropout(),
            nn.Linear(256 * 6 * 6, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Linear(4096, num_classes),
        )

    def forward(self, x):
        x = checkpoint_sequential(self.features, chunks, x)
        x = self.avgpool(x)
        x = x.view(x.size(0), 256 * 6 * 6)
        x = self.classifier(x)
        return x

正如您所看到的,当需要时,检查点是模型的一个简单补充。我们在新版本的模型中添加了一个chunks参数,默认情况下将其分成两个部分。然后,我们只需要调用checkpoint_sequentialfeatures模块,段数和我们的输入。就是这样!

在检查点中的一个小问题是,它与BatchNormDropout层的交互方式会导致不良行为。为了解决这个问题,您可以在这些层之前和之后只检查点模型的部分。在我们的CheckpointedAlexNet中,我们可以将classifier模块分成两部分:一个包含未检查点的Dropout层,以及一个包含我们的Linear层的最终nn.Sequential模块,我们可以以与features相同的方式检查点。

如果您发现为了使模型运行而减少批量大小,请在要求更大的 GPU 之前考虑检查点!

结论

希望现在您已经具备了在训练模型不如预期时寻找答案的能力。从清理数据到运行火焰图或 TensorBoard 可视化,您有很多工具可供使用;您还看到了如何通过 GPU 转换以及使用检查点来交换内存和计算。

拥有经过适当训练和调试的模型,我们正走向最严酷的领域:生产

进一步阅读

第八章:PyTorch 在生产中

现在您已经学会了如何使用 PyTorch 对图像、文本和声音进行分类,下一步是看看如何将 PyTorch 应用程序部署到生产环境中。在本章中,我们创建应用程序,通过 HTTP 和 gRPC 在 PyTorch 模型上运行推断。然后,我们将这些应用程序打包到 Docker 容器中,并将它们部署到在 Google Cloud 上运行的 Kubernetes 集群中。

在下半部分,我们将看一下 TorchScript,这是 PyTorch 1.0 中引入的一项新技术,它允许我们使用即时(JIT)跟踪来生成优化的模型,这些模型可以从 C++中运行。我们还简要介绍了如何使用量化压缩模型。首先,让我们看一下模型服务。

模型服务

在过去的六章中,我们一直在 PyTorch 中构建模型,但构建模型只是构建深度学习应用程序的一部分。毕竟,一个模型可能具有惊人的准确性(或其他相关指标),但如果它从不进行任何预测,那么它是否有价值呢?我们希望有一种简单的方法来打包我们的模型,以便它们可以响应请求(无论是通过网络还是其他方式,我们将看到),并且可以在生产环境中运行,而不需要太多的努力。

幸运的是,Python 允许我们使用 Flask 框架快速启动 Web 服务。在本节中,我们构建一个简单的服务,加载我们基于 ResNet 的猫或鱼模型,接受包含图像 URL 的请求,并返回一个 JSON 响应,指示图像是否包含猫或鱼。

注意

如果我们向模型发送一张狗的图片会发生什么?模型会告诉您它是鱼还是猫。它没有其他选择之外的概念,总是会选择一个。一些深度学习从业者在训练过程中添加一个额外的类别Unknown,并加入一些不属于所需类别的标记示例。这在一定程度上有效,但实质上是试图让神经网络学习不是猫或鱼的所有内容,这对您和我来说都很难表达,更不用说一系列矩阵计算了!另一个选择是查看最终softmax生成的概率输出。如果模型产生的预测大致是 50/50 的猫/鱼或分布在您的类别中,那么可能建议Unknown

构建 Flask 服务

让我们启动一个启用 Web 服务的模型版本。Flask是一个用 Python 创建 Web 服务的流行框架,我们将在本章中一直使用它作为基础。使用pipconda安装 Flask 库:

conda install -c anaconda flask
pip install flask

创建一个名为catfish的新目录,并将您的模型定义复制到其中作为model.py

from torchvision import models

CatfishClasses = ["cat","fish"]

CatfishModel = models.ResNet50()
CatfishModel.fc = nn.Sequential(nn.Linear(transfer_model.fc.in_features,500),
                  nn.ReLU(),
                  nn.Dropout(), nn.Linear(500,2))

请注意,我们在这里没有指定预训练模型,因为我们将在 Flask 服务器启动过程中加载我们保存的权重。然后创建另一个 Python 脚本catfish_server.py,在其中我们将启动我们的 Web 服务:

from flask import Flask, jsonify
from . import CatfishModel
from torchvision import transforms
import torch
import os

def load_model():
  return model

app = Flask(__name__)

@app.route("/")
def status():
  return jsonify({"status": "ok"})

@app.route("/predict", methods=['GET', 'POST'])
def predict():
  img_url = request.image_url
  img_tensor = open_image(BytesIO(response.content))
  prediction = model(img_tensor)
  predicted_class = CatfishClasses[torch.argmax(prediction)]
  return jsonify({"image": img_url, "prediction": predicted_class})

if __name__ == '__main__':
  app.run(host=os.environ["CATFISH_HOST"], port=os.environ["CATFISH_PORT"])

您可以通过设置CATFISH_HOSTCATFISH_PORT环境变量在命令行上启动 Web 服务器:

CATFISH_HOST=127.0.0.1 CATFISH_PORT=8080 python catfish_server.py

如果您将您的 Web 浏览器指向http://127.0.0.1:8080,您应该会得到一个status: "ok"的 JSON 响应,如图 8-1 所示。

CATFISH 的 OK 响应

图 8-1. CATFISH 的 OK 响应
注意

我们将在本章后面更详细地讨论这一点,但不要直接将 Flask 服务部署到生产环境,因为内置服务器不适合生产使用。

要进行预测,找到一个图像 URL,并将其作为GET请求发送到/predict路径,其中包括image_url参数。您应该看到一个 JSON 响应,显示 URL 和预测的类别,如图 8-2 所示。

CATFISH 的预测

图 8-2. CATFISH 的预测

Flask 中的魔法在于@app.route()注解。这使我们能够附加普通的 Python 函数,当用户访问特定端点时将运行这些函数。在我们的predict()方法中,我们从GETPOSTHTTP 请求中提取img_url参数,将该 URL 打开为一个 PIL 图像,并通过一个简单的torchvision转换管道将其调整大小并将图像转换为张量。

这给我们一个形状为[3,224,224]的张量,但由于我们模型的工作方式,我们需要将其转换为大小为 1 的批次,即[1,3,224,224]。因此,我们再次使用unsqueeze()来通过在现有维度前插入一个新的空轴来扩展我们的张量。然后我们可以像往常一样将其传递给模型,这会给我们预测张量。与以前一样,我们使用torch.argmax()来找到张量中具有最高值的元素,并用它来索引CatfishClasses数组。最后,我们返回一个 JSON 响应,其中包含类的名称和我们执行预测的图像 URL。

如果你在这一点上尝试服务器,你可能会对分类性能感到有些失望。我们不是花了很多时间训练它吗?是的,我们是,但是在重新创建模型时,我们只是创建了一组具有标准 PyTorch 初始化的层!所以难怪它不好。让我们完善load_model()以加载我们的参数。

注意

这里我们只返回预测的类,而不是所有类别的完整预测集。当然你也可以返回预测张量,但要注意完整的张量输出会使攻击者更容易通过更多的信息泄漏来构建模型的副本。

设置模型参数

在第二章中,我们讨论了训练后保存模型的两种方法,一种是使用torch.save()将整个模型写入磁盘,另一种是保存模型的所有权重和偏置的state_dict()(但不包括结构)。对于我们基于生产的服务,我们需要加载一个已经训练好的模型,那么我们应该使用什么呢?

在我看来,你应该选择state_dict方法。保存整个模型是一个吸引人的选择,但是你将变得对模型结构的任何更改甚至训练设置的目录结构变得非常敏感。这很可能会导致在其他地方运行的单独服务中加载它时出现问题。如果我们要进行稍微不同布局的迁移,我们希望不必重新制作所有内容。

我们最好不要将保存的state_dicts()的文件名硬编码,这样我们可以将模型更新与服务解耦。这意味着我们可以轻松地使用新模型重新启动服务,或者回滚到早期的模型。我们将文件名作为参数传递,但应该指向哪里呢?暂时假设我们可以设置一个名为CATFISH_MODEL_LOCATION的环境变量,并在load_model()中使用它:

def load_model():
  m = CatfishModel()
  location = os.environ["CATFISH_MODEL_LOCATION"]
  m.load_state_dict(torch.load(location))
  return m

现在,将你在第四章中保存的模型权重文件之一复制到目录中,并将CATFISH_MODEL_LOCATION设置为指向该文件:

export CATFISH_MODEL_LOCATION=catfishweights.pt

重新启动服务器,你应该看到服务的准确性有了很大提升!

我们现在有一个工作的最小 Web 服务(你可能希望有更多的错误处理,但我把这留给你来练习!)。但是我们如何在服务器上运行它,比如在 AWS 或 Google Cloud 上?或者只是在别人的笔记本电脑上?毕竟,我们安装了一堆库来使其工作。我们可以使用 Docker 将所有内容打包到一个容器中,该容器可以在任何 Linux(或 Windows,使用新的 Windows Subsystem for Linux!)环境中安装,只需几秒钟。

构建 Docker 容器

在过去几年中,Docker 已成为应用程序打包的事实标准之一。尖端的集群环境,如 Kubernetes,将 Docker 作为部署应用程序的核心(您将在本章后面看到),它甚至在企业中也取得了很大的进展。

如果您之前没有接触过 Docker,这里有一个简单的解释:它的模型是基于集装箱的概念。您指定一组文件(通常使用 Dockerfile),Docker 用这些文件构建一个镜像,然后在容器中运行该镜像,容器是您系统上的一个隔离进程,只能看到您指定的文件和您告诉它运行的程序。然后您可以共享 Dockerfile,以便其他人构建自己的镜像,但更常见的方法是将创建的镜像推送到注册表,这是一个包含可以被任何有访问权限的人下载的 Docker 镜像列表。这些注册表可以是公共的或私有的;Docker 公司运行Docker Hub,这是一个包含超过 100,000 个 Docker 镜像的公共注册表,但许多公司也运行私有注册表供内部使用。

我们需要编写自己的 Dockerfile。这可能听起来有点令人不知所措。我们需要告诉 Docker 安装什么?我们的代码?PyTorch?Conda?Python?Linux 本身?幸运的是,Dockerfile 可以继承自其他镜像,因此我们可以,例如,继承标准 Ubuntu 镜像,并从那里安装 Python、PyTorch 和其他所有内容。但我们可以做得更好!可以选择一些 Conda 镜像,这些镜像将为我们提供一个基本的 Linux、Python 和 Anaconda 安装基础。以下是一个示例 Dockerfile,可用于构建我们服务的容器镜像:

FROM continuumio/miniconda3:latest

ARG model_parameter_location
ARG model_parameter_name
ARG port
ARG host

ENV CATFISH_PORT=$port
ENV CATFISH_HOST=$host
ENV CATFISH_MODEL_LOCATION=/app/$model_parameter_name

RUN conda install -y flask \
  && conda install -c pytorch  torchvision \
  && conda install waitress
RUN mkdir -p /app

COPY ./model.py /app
COPY ./server.py /app
COPY $model_location/$model_weights_name /app/
COPY ./run-model-service.sh /

EXPOSE $port

ENTRYPOINT ["/run-model-service.sh"]

这里发生了一些事情,让我们来看看。几乎所有 Dockerfile 中的第一行都是FROM,列出了此文件继承的 Docker 镜像。在这种情况下,它是continuumio/miniconda3:latest。这个字符串的第一部分是镜像名称。镜像也有版本,所以冒号后面的所有内容都是一个标签,指示我们想要下载哪个版本的镜像。还有一个魔术标签latest,我们在这里使用它来下载我们想要的镜像的最新版本。您可能希望将服务固定在特定版本上,以免基础镜像可能导致您的问题后续更改。

ARGENV处理变量。ARG指定在构建镜像时由 Docker 提供的变量,然后该变量可以在 Dockerfile 中稍后使用。ENV允许您指定在运行时将注入容器的环境变量。在我们的容器中,我们使用ARG来指定端口是可配置选项,然后使用ENV确保配置在启动时对我们的脚本可用。

完成了这些操作后,RUNCOPY允许我们操作继承的镜像。RUN在镜像内部运行实际命令,任何更改都会保存为镜像的新,叠加在基础层之上。COPY从 Docker 构建上下文中获取内容(通常是构建命令发出的目录中的任何文件或任何子目录),并将其插入到镜像文件系统的某个位置。通过使用RUN创建了/app后,我们使用COPY将代码和模型参数移动到镜像中。

EXPOSE指示 Docker 应将哪个端口映射到外部世界。默认情况下,没有打开任何端口,所以我们在这里添加一个,从文件中之前的ARG命令中获取。最后,ENTRYPOINT是创建容器时运行的默认命令。在这里,我们指定了一个脚本,但我们还没有创建它!在构建 Docker 镜像之前,让我们先做这个:

#!/bin/bash
#run-model-service.sh
cd /app
waitress-serve --call 'catfish_server:create_app'

等等,这里发生了什么?waitress是从哪里来的?问题在于,在之前运行基于 Flask 的服务器时,它使用了一个仅用于调试目的的简单 Web 服务器。如果我们想将其投入生产,我们需要一个适用于生产的 Web 服务器。Waitress 满足了这一要求。我们不需要详细讨论它,但如果您想了解更多信息,可以查看Waitress 文档

设置好这一切后,我们最终可以使用docker build来创建我们的镜像:

docker build -t catfish-service .

我们可以通过使用docker images来确保镜像在我们的系统上可用:

>docker images
REPOSITORY               TAG                            IMAGE ID
catfish-service          latest                         e5de5ad808b6

然后可以使用docker run来运行我们的模型预测服务:

docker run catfish-service -p 5000:5000

我们还使用-p参数将容器的端口 5000 映射到我们计算机的端口 5000。您应该能够像以前一样返回到http://localhost:5000/predict

当在本地运行docker images时,您可能会注意到我们的 Docker 镜像超过 4GB!考虑到我们没有写太多代码,这相当大。让我们看看如何使镜像更小,同时使我们的镜像更适合部署。

本地与云存储

显然,存储我们保存的模型参数的最简单方法是在本地文件系统上,无论是在我们的计算机上还是在 Docker 容器内的文件系统上。但是这样做有几个问题。首先,模型被硬编码到镜像中。此外,很可能在构建镜像并投入生产后,我们需要更新模型。使用我们当前的 Dockerfile,即使模型的结构没有改变,我们也必须完全重建镜像!其次,我们镜像的大部分大小来自参数文件的大小。您可能没有注意到它们往往相当大!试试看:

ls -l
total 641504
-rw------- 1 ian ian 178728960 Feb  4  2018 resnet101-5d3b4d8f.pth
-rw------- 1 ian ian 241530880 Feb 18  2018 resnet152-b121ed2d.pth
-rw------- 1 ian ian  46827520 Sep 10  2017 resnet18-5c106cde.pth
-rw------- 1 ian ian  87306240 Dec 23  2017 resnet34-333f7ec4.pth
-rw------- 1 ian ian 102502400 Oct  1  2017 resnet50-19c8e357.pth

如果我们在每次构建时将这些模型添加到文件系统中,我们的 Docker 镜像可能会相当大,这会使推送和拉取变慢。我建议的是如果您在本地运行,可以使用本地文件系统或 Docker 卷映射容器,但如果您正在进行云部署,那么可以利用云的优势。模型参数文件可以上传到 Azure Blob Storage、Amazon Simple Storage Service(Amazon S3)或 Google Cloud Storage,并在启动时拉取。

我们可以重写我们的load_model()函数在启动时下载参数文件:

from urllib.request import urlopen
from shutil import copyfileobj
from tempfile import NamedTemporaryFile

def load_model():
  m = CatfishModel()
  parameter_url = os.environ["CATFISH_MODEL_LOCATION"]
  with urlopen(url) as fsrc, NamedTemporaryFile() as fdst:
    copyfileobj(fsrc, fdst)
    m.load_state_dict(torch.load(fdst))
  return m

当然,有许多种使用 Python 下载文件的方法;Flask 甚至带有requests模块,可以轻松下载文件。然而,一个潜在的问题是,许多方法在将文件写入磁盘之前会将整个文件下载到内存中。大多数情况下,这是有道理的,但是当下载模型参数文件时,它们可能会达到几十 GB。因此,在这个新版本的load_model()中,我们使用urlopen()copyfileobj()来执行复制操作,并使用NamedTemporaryFile()来给我们一个在块结束时可以删除的目标,因为在那时,我们已经加载了参数,因此不再需要文件!这使我们能够简化我们的 Dockerfile:

FROM continuumio/miniconda3:latest

ARG port
ARG host

ENV CATFISH_PORT=$port
RUN conda install -y flask \
  && conda install -c pytorch torch torchvision \
  && conda install waitress
RUN mkdir -p /app

COPY ./model.py /app
COPY ./server.py /app
COPY ./run-model-service.sh /

EXPOSE $port

ENTRYPOINT ["/run-model-service.sh"]

当我们使用docker run运行时,我们传入环境变量:

docker run catfish-service --env CATFISH_MODEL_LOCATION=[URL]

该服务现在从 URL 中提取参数,并且 Docker 镜像可能比原始镜像小约 600MB-700MB。

注意

在这个例子中,我们假设模型参数文件位于一个公开可访问的位置。如果您部署一个模型服务,您可能不会处于这种情况,而是会从云存储层(如 Amazon S3、Google Cloud Storage 或 Azure Blob Storage)中拉取。您将需要使用各自提供商的 API 来下载文件并获取凭据以访问它,这两点我们在这里不讨论。

我们现在有一个能够通过 HTTP 与 JSON 进行通信的模型服务。现在我们需要确保在它进行预测时能够监控它。

日志和遥测

我们当前的服务中没有的一件事是任何日志记录的概念。虽然服务非常简单,也许不需要大量的日志记录(除非在捕获错误状态时),但对于我们来说,跟踪实际预测的内容是有用的,如果不是必不可少的。在某个时候,我们将想要评估模型;如果没有生产数据,我们该如何做呢?

假设我们有一个名为send_to_log()的方法,它接受一个 Python dict并将其发送到其他地方(也许是一个备份到云存储的 Apache Kafka 集群)。每次进行预测时,我们可以通过这种方法发送适当的信息:

import uuid
import logging
logging.basicConfig(level=logging.INFO)

def predict():
  img_url = request.image_url
  img_tensor = open_image(BytesIO(response.content))
  start_time = time.process_time()
  prediction = model(img_tensor)
  end_time = time.process_time()
  predicted_class = CatfishClasses[torch.argmax(prediction)]
  send_to_log(
    {"image": img_url,
    "prediction": predicted_class},
    "predict_tensor": prediction,
    "img_tensor": img_tensor,
    "predict_time": end_time-start_time,
    "uuid":uuid.uuid4()
    })
  return jsonify({"image": img_url, "prediction": predicted_class})

def send_to_log(log_line):
  logger.info(log_line)

通过对每个请求计算预测所需时间的几个补充,该方法现在会向记录器或外部资源发送消息,提供重要细节,如图像 URL、预测类别、实际预测张量,甚至完整的图像张量,以防所提供的 URL 是瞬态的。我们还包括一个生成的通用唯一标识符(UUID),以便以后始终可以唯一引用此预测,也许如果其预测类别需要更正。在实际部署中,您将包括user_id等内容,以便下游系统可以提供一个设施,让用户指示预测是正确还是错误,偷偷地生成更多用于模型进一步训练迭代的训练数据。

有了这些,我们就准备好将我们的容器部署到云端了。让我们快速看一下如何使用 Kubernetes 来托管和扩展我们的服务。

在 Kubernetes 上部署

本书的范围不包括深入讨论 Kubernetes,因此我们将坚持基础知识,包括如何快速启动和运行服务。Kubernetes(也称为 k8s)正在迅速成为云中的主要集群框架。它源自谷歌最初的集群管理软件 Borg,包含所有部件和粘合剂,形成了一种弹性和可靠的运行服务的方式,包括负载均衡器、资源配额、扩展策略、流量管理、共享密钥等。

您可以在本地计算机或云账户上下载和设置 Kubernetes,但推荐的方式是使用托管服务,其中 Kubernetes 本身的管理由云提供商处理,您只需安排您的服务。我们使用谷歌 Kubernetes 引擎(GKE)服务进行部署,但您也可以在亚马逊、Azure 或 DigitalOcean 上进行部署。

在谷歌 Kubernetes 引擎上设置

要使用 GKE,您需要一个谷歌云账户。此外,在 GKE 上运行服务并不是免费的。好消息是,如果您是谷歌云的新用户,您将获得价值 300 美元的免费信用额度,我们可能不会花费超过一两美元。

一旦您拥有账户,请为您的系统下载gcloud SDK。安装完成后,我们可以使用它来安装kubectl,这是我们将用来与我们将要创建的 Kubernetes 集群进行交互的应用程序:

gcloud login
gcloud components install kubectl

然后,我们需要创建一个新的项目,这是谷歌云在您的账户中组织计算资源的方式:

gcloud projects create ml-k8s --set-as-default

接下来,我们重建我们的 Docker 镜像并对其进行标记,以便将其推送到谷歌提供的内部注册表(我们需要使用gcloud进行身份验证),然后我们可以使用docker push将我们的容器镜像发送到云端。请注意,我们还使用v1版本标记标记了我们的服务,这是之前没有做的:

docker build -t gcr.io/ml-k8s/catfish-service:v1 .
gcloud auth configure-docker
docker push gcr.io/ml-k8s/catfish-service:v1

创建一个 k8s 集群

现在我们可以创建我们的 Kubernetes 集群。在以下命令中,我们创建了一个具有两个 n1-standard-1 节点的集群,这是谷歌最便宜和最低功率的实例。如果您真的要省钱,可以只创建一个节点的集群。

gcloud container clusters create ml-cluster --num-nodes=2

这可能需要几分钟来完全初始化新的集群。一旦准备就绪,我们就可以使用kubectl部署我们的应用程序!

kubectl run catfish-service
--image=gcr.io/ml-k8s/catfish-service:v1
--port 5000
--env CATFISH_MODEL_LOCATION=[URL]

请注意,我们在这里将模型参数文件的位置作为环境参数传递,就像我们在本地机器上使用docker run命令一样。使用kubectl get pods查看集群上正在运行的 pod。pod是一个包含一个或多个容器的组合,并附有如何运行和管理这些容器的规范。对于我们的目的,我们在一个 pod 中的一个容器中运行我们的模型。这是您应该看到的内容:

NAME                                  READY STATUS  RESTARTS  AGE
gcr.io/ml-k8s/catfish-service:v1      1/1   Running 0 4m15s

好的,现在我们可以看到我们的应用正在运行,但我们如何与它进行交流呢?为了做到这一点,我们需要部署一个服务,在这种情况下是一个负载均衡器,将外部 IP 地址映射到我们的内部集群:

kubectl expose deployment catfish-service
--type=LoadBalancer
--port 80
--target-port 5000

然后您可以使用kubectl get services查看正在运行的服务以获取外部 IP 地址:

kubectl get service

NAME               CLUSTER-IP      EXTERNAL-IP     PORT(S)          AGE
catfish-service    10.3.251.122    203.0.113.0     80:30877/TCP     3d

现在你应该能够像在本地机器上一样访问http://external-ip/predict。成功!我们还可以在不登录的情况下查看我们的 pod 日志:

kubectl logs catfish-service-xxdsd
>> log response

我们现在在 Kubernetes 集群中运行一个部署。让我们探索一些它提供的功能。

扩展服务

假设我们决定一个 pod 无法处理进入我们预测服务的所有流量。在传统部署中,我们必须启动新服务器,将它们添加到负载均衡器中,并解决如果其中一个服务器失败该怎么办的问题。但是使用 Kubernetes,我们可以轻松完成所有这些。让我们确保运行三个服务的副本:

kubectl scale deployment hello-web --replicas=3

如果您继续查看kubectl get pods,您很快会看到 Kubernetes 正在从您的 Docker 镜像中启动另外两个 pod,并将它们连接到负载均衡器。更好的是,让我们看看如果我们删除其中一个 pod 会发生什么:

kubectl delete pod [PODNAME]
kubectl get pods

您会看到我们指定的 pod 已被删除。但是—您还应该看到正在启动一个新的 pod 来替换它!我们告诉 Kubernetes 我们应该运行三个镜像的副本,因为我们删除了一个,集群会启动一个新的 pod 来确保副本计数是我们请求的。这也适用于更新我们的应用程序,所以让我们也看看这个。

更新和清理

当涉及推送更新到我们的服务代码时,我们创建一个带有v2标签的容器的新版本:

docker build -t gcr.io/ml-k8s/catfish-service:v2 .
docker push gcr.io/ml-k8s/catfish-service:v2

然后我们告诉集群使用新镜像进行部署:

kubectl set image deployment/catfish-service
  catfish-service=gcr.io/ml-k8s/catfish-service:v2

通过kubectl get pods持续监控,您会看到正在部署具有新镜像的新 pod,并且正在删除具有旧镜像的 pod。Kubernetes 会自动处理连接的排空和从负载均衡器中删除旧 pod。

最后,如果您已经玩够了集群,应该清理一下,以免出现任何意外费用:

kubectl delete service catfish-service
gcloud container clusters delete ml-k8s

这就是我们对 Kubernetes 的迷你之旅;您现在已经知道足够多,可以做出危险的决定,但是一定要查看Kubernetes 网站作为进一步了解该系统的起点(相信我,这方面有很多信息!)

我们已经讨论了如何部署基于 Python 的代码,但也许令人惊讶的是,PyTorch 并不仅限于 Python。在下一节中,您将看到 TorchScript 如何引入更广泛的 C++世界,以及对我们正常的 Python 模型的一些优化。

TorchScript

如果您还记得介绍(我知道!)的话,您会知道 PyTorch 和 TensorFlow 之间的主要区别在于 TensorfFlow 具有模型的基于图形的表示,而 PyTorch 具有基于执行的即时执行和基于磁带的微分。即时方法允许您执行各种动态方法来指定和训练模型,使 PyTorch 对研究目的具有吸引力。另一方面,基于图形的表示可能是静态的,但它从稳定性中获得力量;可以应用优化到图形表示中,确保不会发生任何变化。正如 TensorFlow 已经在 2.0 版本中转向支持即时执行一样,PyTorch 的 1.0 版本引入了 TorchScript,这是一种在不完全放弃 PyTorch 灵活性的情况下带来图形系统优势的方法。这通过两种可以混合和匹配的方式来实现:跟踪和直接使用 TorchScript。

跟踪

PyTorch 1.0 带有一个 JIT 跟踪引擎,它将现有的 PyTorch 模块或函数转换为 TorchScript。它通过将一个示例张量传递到模块中,并返回一个包含原始代码的 TorchScript 表示的ScriptModule结果来实现这一点。

让我们看看跟踪 AlexNet:

model = torchvision.models.AlexNet()
traced_model = torch.jit.trace(model,
                torch.rand(1, 3, 224, 224))

现在,这将起作用,但您将从 Python 解释器收到这样的消息,这会让您停下来思考:

TracerWarning: Trace had nondeterministic nodes. Nodes:
%input.15 :
Float(1, 9216) = aten::dropout(%input.14, %174, %175),
scope: AlexNet/Sequential[classifier]/Dropout[0]
%input.18 :
Float(1, 4096) = aten::dropout(%input.17, %184, %185),
scope: AlexNet/Sequential[classifier]/Dropout[3]

This may cause errors in trace checking.
To disable trace checking, pass check_trace=False to torch.jit.trace()

_check_trace([example_inputs], func, executor_options,
module, check_tolerance, _force_outplace)
/home/ian/anaconda3/lib/
python3.6/site-packages/torch/jit/__init__.py:642:
TracerWarning: Output nr 1. of the traced function does not
match the corresponding output of the Python function. Detailed error:

Not within tolerance rtol=1e-05 atol=1e-05 at input[0, 22]
(0.010976361110806465 vs. -0.005604125093668699)
and 996 other locations (99.00%)
_check_trace([example_inputs], func,
executor_options, module, check_tolerance
_force_outplace)

这里发生了什么?当我们创建 AlexNet(或其他模型)时,模型是在训练模式下实例化的。在许多模型(如 AlexNet)的训练过程中,我们使用Dropout层,它会在张量通过网络时随机关闭激活。JIT 所做的是将我们生成的随机张量通过模型两次,进行比较,并注意到Dropout层不匹配。这揭示了跟踪设施的一个重要注意事项;它无法处理不确定性或控制流。如果您的模型使用这些特性,您将不得不至少部分使用 TorchScript 进行转换。

在 AlexNet 的情况下,修复很简单:我们将通过使用model.eval()将模型切换到评估模式。如果再次运行跟踪行,您会发现它完成而没有任何抱怨。我们还可以print()跟踪的模型以查看其组成部分:

print(traced_model)

TracedModuleAlexNet: TracedModuleSequential: TracedModule[Conv2d]()
  (1): TracedModule[ReLU]()
  (2): TracedModule[MaxPool2d]()
  (3): TracedModule[Conv2d]()
  (4): TracedModule[ReLU]()
  (5): TracedModule[MaxPool2d]()
  (6): TracedModule[Conv2d]()
  (7): TracedModule[ReLU]()
  (8): TracedModule[Conv2d]()
  (9): TracedModule[ReLU]()
  (10): TracedModule[Conv2d]()
  (11): TracedModule[ReLU]()
  (12): TracedModule[MaxPool2d]()
)
(classifier): TracedModuleSequential: TracedModule[Dropout]()
  (1): TracedModule[Linear]()
  (2): TracedModule[ReLU]()
  (3): TracedModule[Dropout]()
  (4): TracedModule[Linear]()
  (5): TracedModule[ReLU]()
  (6): TracedModule[Linear]()
  )
)

如果调用print(traced_model.code),我们还可以看到 JIT 引擎创建的代码。

def forward(self,
  input_1: Tensor) -> Tensor:
  input_2 = torch._convolution(input_1, getattr(self.features, "0").weight,
  getattr(self.features, "0").bias,
  [4, 4], [2, 2], [1, 1], False, [0, 0], 1, False, False, True)
  input_3 = torch.threshold_(input_2, 0., 0.)
  input_4, _0 = torch.max_pool2d_with_indices
  (input_3, [3, 3], [2, 2], [0, 0], [1, 1], False)
  input_5 = torch._convolution(input_4, getattr
  (self.features, "3").weight, getattr(self.features, "3").bias,
  [1, 1], [2, 2], [1, 1], False, [0, 0], 1, False, False, True)
  input_6 = torch.threshold_(input_5, 0., 0.)
  input_7, _1 = torch.max_pool2d_with_indices
  (input_6, [3, 3], [2, 2], [0, 0], [1, 1], False)
  input_8 = torch._convolution(input_7, getattr(self.features, "6").weight,
  getattr
  (self.features, "6").bias,
  [1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True)
  input_9 = torch.threshold_(input_8, 0., 0.)
  input_10 = torch._convolution(input_9, getattr
  (self.features, "8").weight, getattr(self.features, "8").bias,
  [1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True)
  input_11 = torch.threshold_(input_10, 0., 0.)
  input_12 = torch._convolution(input_11, getattr
  (self.features, "10").weight, getattr(self.features, "10").bias,
  [1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True)
  input_13 = torch.threshold_(input_12, 0., 0.)
  x, _2 = torch.max_pool2d_with_indices
  (input_13, [3, 3], [2, 2], [0, 0], [1, 1], False)
  _3 = ops.prim.NumToTensor(torch.size(x, 0))
  input_14 = torch.view(x, [int(_3), 9216])
  input_15 = torch.dropout(input_14, 0.5, False)
  _4 = torch.t(getattr(self.classifier, "1").weight)
  input_16 = torch.addmm(getattr(self.classifier, "1").bias,
    input_15, _4, beta=1, alpha=1)
  input_17 = torch.threshold_(input_16, 0., 0.)
  input_18 = torch.dropout(input_17, 0.5, False)
  _5 = torch.t(getattr(self.classifier, "4").weight)
  input_19 = torch.addmm(getattr(self.classifier, "4").bias,
    input_18, _5, beta=1, alpha=1)
  input = torch.threshold_(input_19, 0., 0.)
  _6 = torch.t(getattr(self.classifier, "6").weight)
  _7 = torch.addmm(getattr(self.classifier, "6").bias, input,
    _6, beta=1, alpha=1)
  return _7

然后可以使用torch.jit.save保存模型(代码和参数):

torch.jit.save(traced_model, "traced_model")

这涵盖了跟踪的工作原理。让我们看看如何使用 TorchScript。

脚本化

您可能想知道为什么我们不能跟踪一切。尽管跟踪器在其所做的事情上很擅长,但它也有局限性。例如,像以下简单函数这样的函数不可能通过单次传递进行跟踪:

import torch

def example(x, y):
  if x.min() > y.min():
      r = x
  else:
      r = y
  return r

通过函数的单个跟踪将使我们沿着一条路径而不是另一条路径,这意味着函数将无法正确转换。在这些情况下,我们可以使用 TorchScript,这是 Python 的一个有限子集,并生成我们的编译代码。我们使用注释告诉 PyTorch 我们正在使用 TorchScript,因此 TorchScript 实现将如下所示:

@torch.jit.script
def example(x, y):
    if x.min() > y.min():
        r = x
    else:
        r = y
    return r

幸运的是,我们的函数中没有使用 TorchScript 中没有的构造或引用任何全局状态,因此这将正常工作。如果我们正在创建一个新的架构,我们需要继承自torch.jit.ScriptModule而不是nn.Module。您可能想知道如果所有模块都必须继承自这个不同的类,我们如何可以使用其他模块(比如基于 CNN 的层)。一切都稍微不同吗?解决方法是我们可以随意使用显式 TorchScript 和跟踪对象来混合和匹配。

让我们回到第三章中的 CNNNet/AlexNet 结构,看看如何使用这些方法的组合将其转换为 TorchScript。为简洁起见,我们只实现features组件:

class FeaturesCNNNet(torch.jit.ScriptModule):
    def __init__(self, num_classes=2):
        super(FeaturesCNNNet, self).__init__()
        self.features = torch.jit.trace(nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(64, 192, kernel_size=5, padding=2),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(192, 384, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=3, stride=2)
        ), torch.rand(1,3,224,224))

    @torch.jit.script_method
    def forward(self, x):
        x = self.features(x)
        return x

这里有两件事需要注意。首先,在类内部,我们需要使用 @torch.jit.script_method 进行注释。其次,尽管我们可以单独跟踪每个单独的层,但我们利用了 nn.Sequential 包装层,只需通过它来触发跟踪。你可以自己实现 classifier 块,以了解这种混合工作的感觉。请记住,你需要将 Dropout 层切换到 eval() 模式而不是训练模式,并且由于 features 块进行的下采样,你的输入跟踪张量的形状需要是 [1, 256, 6, 6]。是的,你可以像我们为跟踪模块所做的那样使用 torch.jit.save 来保存这个网络。让我们看看 TorchScript 允许和禁止什么。

TorchScript 的限制

与 Python 相比,至少在我看来,TorchScript 的最大限制是可用类型数量减少了。表 8-1 列出了可用和不可用的类型。

表 8-1. TorchScript 中可用的 Python 类型

类型 描述
tensor 一个 PyTorch 张量,可以是任何数据类型、维度或后端
tuple[T0, T1,…] 包含子类型 T0、T1 等的元组(例如,tuple[tensor, tensor]
boolean 布尔值
str 字符串
int 整数
float 浮点数
list 类型为 T 的列表
optional[T] 要么为 None,要么为类型 T
dict[K, V] 键为类型 K,值为类型 V 的字典;K 只能是 strintfloat

在标准 Python 中可以做但在 TorchScript 中不能做的另一件事是具有混合返回类型的函数。以下在 TorchScript 中是非法的:

def maybe_a_string_or_int(x):
  if x > 3:
    return "bigger than 3!"
  else
    return 2

当然,在 Python 中也不是一个好主意,但语言的动态类型允许这样做。TorchScript 是静态类型的(有助于应用优化),因此在 TorchScript 注释代码中你根本无法这样做。此外,TorchScript 假定传递给函数的每个参数都是张量,如果你不知道发生了什么,可能会导致一些奇怪的情况:

@torch.jit.script
def add_int(x,y):
  return x + y

print(add_int.code)
>> def forward(self,
  x: Tensor,
  y: Tensor) -> Tensor:
  return torch.add(x, y, alpha=1)

为了强制不同的类型,我们需要使用 Python 3 的类型装饰器:

@torch.jit.script
def add_int(x: int, y: int) -> int:
  return x + y
print(add_int.code)
>> def forward(self,
  x: int,
  y: int) -> int:
return torch.add(x, y)

正如你已经看到的,类是受支持的,但有一些细微差别。类中的所有方法都必须是有效的 TorchScript,但尽管这段代码看起来有效,它将失败:

@torch.jit.script
class BadClass:
  def __init__(self, x)
    self.x = x

  def set_y(y)
    self.y = y

这又是 TorchScript 静态类型的一个结果。所有实例变量都必须在 __init__ 中声明,不能在其他地方引入。哦,不要想着在类中包含任何不在方法中的表达式——这些都被 TorchScript 明确禁止了。

TorchScript 作为 Python 的一个子集的一个有用特性是,翻译可以以逐步的方式进行,中间代码仍然是有效的可执行 Python。符合 TorchScript 的代码可以调用不符合规范的代码,虽然在所有不符合规范的代码转换之前你无法执行 torch.jit.save(),但你仍然可以在 Python 下运行所有内容。

这些是我认为 TorchScript 的主要细微差别。你可以阅读更多关于PyTorch 文档中的内容,其中深入探讨了诸如作用域(主要是标准 Python 规则)之类的内容,但这里提供的概述足以将你在本书中迄今看到的所有模型转换过来。让我们看看如何在 C++中使用我们的一个 TorchScript 启用的模型。

使用 libTorch

除了 TorchScript 之外,PyTorch 1.0 还引入了libTorch,这是一个用于与 PyTorch 交互的 C++库。有各种不同级别的 C++交互可用。最低级别是ATenautograd,这是 PyTorch 本身构建在其上的张量和自动微分的 C++实现。在这些之上是一个 C++前端,它在 C++中复制了 Pythonic PyTorch API,一个与 TorchScript 的接口,最后是一个扩展接口,允许定义和暴露新的自定义 C++/CUDA 运算符给 PyTorch 的 Python 实现。在本书中,我们只关注 C++前端和与 TorchScript 的接口,但有关其他部分的更多信息可在PyTorch 文档中找到。让我们从获取libTorch开始。

获取 libTorch 和 Hello World

在我们做任何事情之前,我们需要一个 C++编译器和一种在我们的机器上构建 C++程序的方法。这是本书中少数几个部分之一,类似 Google Colab 之类的东西不适用,因此如果您没有轻松访问终端窗口,可能需要在 Google Cloud、AWS 或 Azure 中创建一个 VM。 (所有忽略了我的建议不要构建专用机器的人现在都感到自鸣得意,我打赌!)libTorch的要求是一个 C++编译器和CMake,所以让我们安装它们。对于基于 Debian 的系统,请使用以下命令:

apt install cmake g++

如果您使用的是基于 Red Hat 的系统,请使用以下命令:

yum install cmake g++

接下来,我们需要下载libTorch本身。为了让接下来的事情变得更容易,我们将使用基于 CPU 的libTorch分发,而不是处理启用 GPU 的分发带来的额外 CUDA 依赖。创建一个名为torchscript_export的目录并获取分发:

  wget https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-latest.zip

使用unzip来展开 ZIP 文件(它应该创建一个新的libtorch目录),并创建一个名为helloworld的目录。在这个目录中,我们将添加一个最小的CMakeLists.txtCMake将用它来构建我们的可执行文件:

cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
project(helloworld)

find_package(Torch REQUIRED)

add_executable(helloworld helloworld.cpp)
target_link_libraries(helloworld "${TORCH_LIBRARIES}")
set_property(TARGET helloword PROPERTY CXX_STANDARD 11)

然后helloworld.cpp如下:

#include <torch/torch.h>
#include <iostream>

int main() {
  torch::Tensor tensor = torch::ones({2, 2});
  std::cout << tensor << std::endl;
}

创建一个build目录并运行cmake,确保我们提供libtorch分发的绝对路径:

mkdir build
cd build
cmake -DCMAKE_PREFIX_PATH=/absolute/path/to/libtorch ..
cd ..

现在我们可以运行简单的make来创建我们的可执行文件:

make
./helloworld

1  1
1  1
[ Variable[CPUType]{2,2} ]

祝贺您使用libTorch构建了您的第一个 C++程序!现在,让我们扩展一下,看看如何使用该库加载我们之前用torch.jit.save()保存的模型。

导入 TorchScript 模型

我们将从第三章中导出我们的完整 CNNNet 模型,并将其加载到 C++中。在 Python 中,创建 CNNNet 的实例,将其切换到eval()模式以忽略Dropout,跟踪并保存到磁盘:

cnn_model = CNNNet()
cnn_model.eval()
cnn_traced = torch.jit.trace(cnn_model, torch.rand([1,3,224,224]))
torch.jit.save(cnn_traced, "cnnnet")

在 C++世界中,创建一个名为load-cnn的新目录,并添加这个新的CMakeLists.txt文件:

cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
project(load-cnn)

find_package(Torch REQUIRED)

add_executable(load-cnn.cpp load-cnn.cpp)
target_link_libraries(load-cnn "${TORCH_LIBRARIES}")
set_property(TARGET load-cnn PROPERTY CXX_STANDARD 11)

让我们创建我们的 C++程序load-cnn.cpp

#include <torch/script.h>
#include <iostream>
#include <memory>

int main(int argc, const char* argv[]) {

  std::shared_ptr<torch::jit::script::Module> module = torch::jit::load("cnnnet");

  assert(module != nullptr);
  std::cout << "model loaded ok\n";

  // Create a vector of inputs.
  std::vector<torch::jit::IValue> inputs;
  inputs.push_back(torch::rand({1, 3, 224, 224}));

  at::Tensor output = module->forward(inputs).toTensor();

  std::cout << output << '\n'
}

这个小程序中有一些新东西,尽管其中大部分应该让您想起 Python PyTorch API。我们的第一步是使用torch::jit::load加载我们的 TorchScript 模型(与 Python 中的torch.jit.load不同)。我们进行空指针检查以确保模型已正确加载,然后我们继续使用随机张量测试模型。虽然我们可以很容易地使用torch::rand来做到这一点,但在与 TorchScript 模型交互时,我们必须创建一个torch::jit::IValue输入向量,而不仅仅是一个普通张量,因为 TorchScript 在 C++中的实现方式。完成后,我们可以将张量传递给我们加载的模型,最后将结果写回标准输出。我们以与之前编译我们的程序相同的方式编译它:

mkdir build
cd build
cmake -DCMAKE_PREFIX_PATH=/absolute/path/to/libtorch ..
cd ..
make
./load-cnn

0.1775
0.9096
[ Variable[CPUType]{2} ]

看吧!一个 C++程序,可以轻松地执行自定义模型。请注意,C++接口在撰写本文时仍处于测试阶段,因此这里的一些细节可能会发生变化。在愤怒使用之前,请务必查看文档!

结论

希望您现在了解如何将经过训练(和调试!)的模型转换为可以通过 Kubernetes 部署的 Docker 化 Web 服务。您还看到了如何使用 JIT 和 TorchScript 功能优化我们的模型,以及如何在 C++中加载 TorchScript 模型,为我们提供了神经网络的低级集成承诺,以及在 Python 中。

显然,仅凭一章,我们无法涵盖有关模型服务的生产使用的所有内容。我们已经部署了我们的服务,但这并不是故事的结束;需要不断监控服务,确保其保持准确性,重新训练并针对基线进行测试,以及比我在这里介绍的更复杂的服务和模型参数版本控制方案。我建议您尽可能记录详细信息,并利用该日志信息进行重新训练以及监控目的。

至于 TorchScript,现在还处于早期阶段,但其他语言的一些绑定(例如 Go 和 Rust)开始出现;到 2020 年,将很容易将 PyTorch 模型与任何流行语言连接起来。

我故意省略了一些与本书范围不太符合的细节。在介绍中,我承诺您可以使用一块 GPU 完成本书中的所有操作,因此我们没有讨论 PyTorch 对分布式训练和推断的支持。此外,如果您阅读有关 PyTorch 模型导出的信息,几乎肯定会遇到许多关于 Open Neural Network Exchange(ONNX)的引用。这个标准由微软和 Facebook 联合撰写,在 TorchScript 出现之前是导出模型的主要方法。模型可以通过类似于 TorchScript 的跟踪方法导出,然后在其他框架(如 Caffe2、Microsoft Cognitive Toolkit 和 MXNet)中导入。ONNX 仍然受到 PyTorch v1.x的支持和积极开发,但似乎 TorchScript 是模型导出的首选方式。如果您感兴趣,可以查看“进一步阅读”部分,了解更多关于 ONNX 的详细信息。

成功创建、调试和部署了我们的模型后,我们将在最后一章中看看一些公司如何使用 PyTorch。

进一步阅读

¹ 使用 Kubernetes 的云原生 DevOps 由 John Arundel 和 Justin Domingus(O'Reilly)深入探讨了这一框架。

第九章:PyTorch 在实践中

在我们的最后一章中,我们将看看 PyTorch 如何被其他人和公司使用。您还将学习一些新技术,包括调整图片大小、生成文本和创建可以欺骗神经网络的图像。与之前章节略有不同的是,我们将集中在如何使用现有库快速上手,而不是从头开始使用 PyTorch。我希望这将成为进一步探索的跳板。

让我们从检查一些最新的方法开始,以充分利用您的数据。

数据增强:混合和平滑

回到第四章,我们看了各种增强数据的方法,以帮助减少模型在训练数据集上的过拟合。在深度学习研究中,能够用更少的数据做更多事情自然是一个活跃的领域,在本节中,我们将看到两种越来越受欢迎的方法,以从您的数据中挤出最后一滴信号。这两种方法也将使我们改变如何计算我们的损失函数,因此这将是对我们刚刚创建的更灵活的训练循环的一个很好的测试。

mixup

mixup是一种有趣的增强技术,它源于对我们希望模型做什么的侧面看法。我们对模型的正常理解是,我们向其发送一张像图 9-1 中的图像,并希望模型返回一个结果,即该图像是一只狐狸。

一只狐狸

图 9-1。一只狐狸

但是,正如您所知,我们不仅从模型中得到这些;我们得到一个包含所有可能类别的张量,希望该张量中具有最高值的元素是狐狸类。实际上,在理想情况下,我们将有一个张量,除了狐狸类中的 1 之外,其他都是 0。

除了神经网络很难做到这一点!总会有不确定性,我们的激活函数如softmax使得张量很难达到 1 或 0。mixup 利用这一点提出了一个问题:图 9-2 的类是什么?

一只猫和一只狐狸的混合

图 9-2。一只猫和一只狐狸的混合

对我们来说,这可能有点混乱,但是它是 60%的猫和 40%的狐狸。如果我们不试图让我们的模型做出明确的猜测,而是让它针对两个类别呢?这意味着我们的输出张量在训练中不会遇到接近但永远无法达到 1 的问题,我们可以通过不同的分数改变每个混合图像,提高我们模型的泛化能力。

但是我们如何计算这个混合图像的损失函数呢?如果p是混合图像中第一幅图像的百分比,那么我们有以下简单的线性组合:

p * loss(image1) + (1-p) * loss(image2)

它必须预测这些图像,对吧?我们需要根据这些图像在最终混合图像中的比例来缩放,因此这种新的损失函数似乎是合理的。要选择p,我们可以像在许多其他情况下那样,使用从正态分布或均匀分布中抽取的随机数。然而,mixup 论文的作者确定,从beta分布中抽取的样本在实践中效果要好得多。不知道 beta 分布是什么样子?嗯,我在看到这篇论文之前也不知道!图 9-3 展示了在给定论文中描述的特征时它的样子。

Beta 分布,其中⍺ = β

图 9-3。Beta 分布,其中⍺ = β

U 形状很有趣,因为它告诉我们,大部分时间,我们混合的图像主要是一张图像或另一张图像。再次,这是直观的,因为我们可以想象网络在工作中会更难以解决 50/50 混合比例而不是 90/10 的情况。

这是一个修改后的训练循环,它接受一个新的额外数据加载器mix_loader,并将批次混合在一起:

def train(model, optimizer, loss_fn, train_loader, val_loader,
epochs=20, device, mix_loader):
  for epoch in range(epochs):
    model.train()
    for batch in zip(train_loader,mix_loader):
      ((inputs, targets),(inputs_mix, targets_mix)) = batch
      optimizer.zero_grad()
      inputs = inputs.to(device)
      targets = targets.to(device)
      inputs_mix = inputs_mix.to(device)
      target_mix = targets_mix.to(device)

      distribution = torch.distributions.beta.Beta(0.5,0.5)
      beta = distribution.expand(torch.zeros(batch_size).shape).sample().to(device)

      # We need to transform the shape of beta
      # to be in the same dimensions as our input tensor
      # [batch_size, channels, height, width]

      mixup = beta[:, None, None, None]

      inputs_mixed = (mixup * inputs) + (1-mixup * inputs_mix)

      # Targets are mixed using beta as they have the same shape

      targets_mixed = (beta * targets) + (1-beta * inputs_mix)

      output_mixed = model(inputs_mixed)

      # Multiply losses by beta and 1-beta,
      # sum and get average of the two mixed losses

      loss = (loss_fn(output, targets) * beta
             + loss_fn(output, targets_mixed)
             * (1-beta)).mean()

      # Training method is as normal from herein on

      loss.backward()
      optimizer.step()
      …

这里发生的是在获取两个批次后,我们使用torch.distribution.Beta生成一系列混合参数,使用expand方法生成一个[1, batch_size]的张量。我们可以遍历批次并逐个生成参数,但这样更整洁,记住,GPU 喜欢矩阵乘法,所以一次跨批次进行所有计算会更快(这在第七章中展示了,当修复我们的BadRandom转换时,记住!)。我们将整个批次乘以这个张量,然后使用广播将要混合的批次乘以1 - mix_factor_tensor

然后我们计算两个图像的预测与目标之间的损失,最终的损失是这些损失之和的平均值。发生了什么?如果你查看CrossEntropyLoss的源代码,你会看到注释损失在每个 minibatch 的观察中进行平均。还有一个reduction参数,默认设置为mean(到目前为止我们使用了默认值,所以你之前没有看到它!)。我们需要保持这个条件,所以我们取我们合并的损失的平均值。

现在,拥有两个数据加载器并不会带来太多麻烦,但它确实使代码变得更加复杂。如果你运行这段代码,可能会出错,因为最终批次从加载器中出来时不平衡,这意味着你将不得不编写额外的代码来处理这种情况。mixup 论文的作者建议你可以用随机洗牌来替换混合数据加载器。我们可以使用torch.randperm()来实现这一点:

shuffle = torch.randperm(inputs.size(0))
inputs_mix = inputs[shuffle]
targets_mix = targets[shuffle]

在这种方式下使用 mixup 时,要注意更有可能出现碰撞,即最终将相同的参数应用于相同的图像集,可能会降低训练的准确性。例如,你可能有 cat1 与 fish1 混合,然后抽取一个 beta 参数为 0.3。然后在同一批次中的后续步骤中,你再次抽取 fish1 并将其与 cat1 混合,参数为 0.7—这样就得到了相同的混合!一些 mixup 的实现—特别是 fast.ai 的实现—通过用以下内容替换我们的混合参数来解决这个问题:

mix_parameters = torch.max(mix_parameters, 1 - mix_parameters)

这确保了非混洗的批次在与混合批次合并时始终具有最高的分量,从而消除了潜在的问题。

哦,还有一件事:我们在图像转换流程之后执行了 mixup 转换。此时,我们的批次只是我们相加在一起的张量。这意味着 mixup 训练不应该仅限于图像。我们可以对任何转换为张量的数据使用它,无论是文本、图像、音频还是其他任何类型的数据。

我们仍然可以做更多工作让我们的标签更加有效。现在进入另一种方法,这种方法现在是最先进模型的主要特点:标签平滑

标签平滑

与 mixup 类似,标签平滑有助于通过使模型对其预测不那么确定来提高模型性能。我们不再试图强迫它预测预测类别为1(这在前一节中讨论的所有问题中都有问题),而是将其改为预测 1 减去一个小值,epsilon。我们可以创建一个新的损失函数实现,将我们现有的CrossEntropyLoss函数与这个功能包装在一起。事实证明,编写自定义损失函数只是nn.Module的另一个子类:

class LabelSmoothingCrossEntropyLoss(nn.Module):
    def __init__(self, epsilon=0.1):
        super(LabelSmoothingCrossEntropyLoss, self).__init__()
        self.epsilon = epsilon

    def forward(self, output, target):
        num_classes = output.size()[-1]
        log_preds = F.log_softmax(output, dim=-1)
        loss = (-log_preds.sum(dim=-1)).mean()
        nll = F.nll_loss(log_preds, target)
        final_loss = self.epsilon * loss / num_classes +
                     (1-self.epsilon) * nll
        return final_loss

在计算损失函数时,我们按照CrossEntropyLoss的实现计算交叉熵损失。我们的final_loss由负对数似然乘以 1 减 epsilon(我们的平滑标签)加上损失乘以 epsilon 除以类别数构成。这是因为我们不仅将预测类别的标签平滑为 1 减 epsilon,还将其他标签平滑为不是被迫为零,而是在零和 epsilon 之间的值。

这个新的自定义损失函数可以替代书中任何地方使用的CrossEntropyLoss进行训练,并与 mixup 结合使用,是从输入数据中获得更多的一种非常有效的方法。

现在我们将从数据增强转向另一个当前深度学习趋势中的热门话题:生成对抗网络。

计算机,增强!

深度学习能力不断增强的一个奇怪后果是,几十年来,我们计算机人一直在嘲笑那些电视犯罪节目,其中侦探点击按钮,使模糊的摄像头图像突然变得清晰、聚焦。我们曾经嘲笑和嘲弄 CSI 等节目做这种事情。但现在我们实际上可以做到这一点,至少在一定程度上。这里有一个巫术的例子,将一个较小的 256×256 图像缩放到 512×512,见图 9-4 和 9-5。

256x256 分辨率下的邮箱

图 9-4. 256×256 分辨率下的邮箱

512x512 分辨率下的 ESRGAN 增强邮箱

图 9-5. 512×512 分辨率下的 ESRGAN 增强邮箱

神经网络学习如何幻想新的细节来填补不存在的部分,效果可能令人印象深刻。但这是如何工作的呢?

超分辨率简介

这是一个非常简单的超分辨率模型的第一部分。起初,它几乎与你迄今为止看到的任何模型完全相同:

class OurFirstSRNet(nn.Module):

  def __init__(self):
      super(OurFirstSRNet, self).__init__()
      self.features = nn.Sequential(
          nn.Conv2d(3, 64, kernel_size=8, stride=4, padding=2),
          nn.ReLU(inplace=True),
          nn.Conv2d(64, 192, kernel_size=2, padding=2),
          nn.ReLU(inplace=True),
          nn.Conv2d(192, 256, kernel_size=2, padding=2),
          nn.ReLU(inplace=True)
      )

  def forward(self, x):
      x = self.features(x)
      return x

如果我们通过网络传递一个随机张量,我们最终得到一个形状为[1, 256, 62, 62]的张量;图像表示已经被压缩为一个更小的向量。现在让我们引入一个新的层类型,torch.nn.ConvTranspose2d。你可以将其视为一个反转标准Conv2d变换的层(具有自己的可学习参数)。我们将添加一个新的nn.Sequential层,upsample,并放入一系列这些新层和ReLU激活函数。在forward()方法中,我们将输入通过其他层后通过这个整合层:

class OurFirstSRNet(nn.Module):
  def __init__(self):
      super(OurFirstSRNet, self).__init__()
      self.features = nn.Sequential(
          nn.Conv2d(3, 64, kernel_size=8, stride=4, padding=2),
          nn.ReLU(inplace=True),
          nn.Conv2d(64, 192, kernel_size=2, padding=2),
          nn.ReLU(inplace=True),
          nn.Conv2d(192, 256, kernel_size=2, padding=2),
          nn.ReLU(inplace=True)

      )
      self.upsample = nn.Sequential(
          nn.ConvTranspose2d(256,192,kernel_size=2, padding=2),
          nn.ReLU(inplace=True),
          nn.ConvTranspose2d(192,64,kernel_size=2, padding=2),
          nn.ReLU(inplace=True),
          nn.ConvTranspose2d(64,3, kernel_size=8, stride=4,padding=2),
          nn.ReLU(inplace=True)
      )

  def forward(self, x):
      x = self.features(x)
      x = self.upsample(x)
      return x

如果现在用一个随机张量测试模型,你将得到一个与输入完全相同大小的张量!我们构建的是一个自动编码器,一种网络类型,通常在将其压缩为更小维度后重新构建其输入。这就是我们在这里做的;features顺序层是一个编码器,将图像转换为大小为[1, 256, 62, 62]的张量,upsample层是我们的解码器,将其转换回原始形状。

用于训练图像的标签当然是我们的输入图像,但这意味着我们不能使用像我们相当标准的CrossEntropyLoss这样的损失函数,因为,嗯,我们没有类别!我们想要的是一个告诉我们输出图像与输入图像有多大不同的损失函数,为此,计算图像像素之间的均方损失或均绝对损失是一种常见方法。

注意

尽管以像素为单位计算损失非常合理,但事实证明,许多最成功的超分辨率网络使用增强损失函数,试图捕捉生成图像与原始图像的相似程度,容忍像素损失以获得更好的纹理和内容损失性能。一些列在“进一步阅读”中的论文深入讨论了这一点。

现在,这使我们回到了与输入相同大小的输入,但如果我们在其中添加另一个转置卷积会怎样呢?

self.upsample = nn.Sequential(...
nn.ConvTranspose2d(3,3, kernel_size=2, stride=2)
nn.ReLU(inplace=True))

试试吧!您会发现输出张量是输入的两倍大。如果我们有一组与该大小相同的地面真实图像作为标签,我们可以训练网络以接收大小为x的图像并为大小为2x的图像生成图像。在实践中,我们倾向于通过扩大两倍所需的量,然后添加一个标准的卷积层来执行这种上采样,如下所示:

self.upsample = nn.Sequential(......
nn.ConvTranspose2d(3,3, kernel_size=2, stride=2),
nn.ReLU(inplace=True),
nn.Conv2d(3,3, kernel_size=2, stride=2),
nn.ReLU(inplace=True))

我们这样做是因为转置卷积有添加锯齿和 moire 图案的倾向,因为它扩展图像。通过扩展两次,然后缩小到我们需要的大小,我们希望为网络提供足够的信息来平滑这些图案,并使输出看起来更真实。

这些是超分辨率背后的基础。目前大多数性能优越的超分辨率网络都是使用一种称为生成对抗网络的技术进行训练的,这种技术在过去几年中席卷了深度学习世界。

GANs 简介

深度学习(或任何机器学习应用)中的一个普遍问题是产生标记数据的成本。在本书中,我们大多数情况下通过使用精心标记的样本数据集来避免这个问题(甚至一些预先打包的易于训练/验证/测试集!)。但在现实世界中,产生大量标记数据。确实,到目前为止,您学到的技术,如迁移学习,都是关于如何用更少的资源做更多的事情。但有时您需要更多,生成对抗网络(GANs)有办法帮助。

GANs 是由 Ian Goodfellow 在 2014 年的一篇论文中提出的,是一种提供更多数据以帮助训练神经网络的新颖方法。而这种方法主要是“我们知道你喜欢神经网络,所以我们添加了另一个。”

伪造者和评论家

GAN 的设置如下。两个神经网络一起训练。第一个是生成器,它从输入张量的向量空间中获取随机噪声,并产生虚假数据作为输出。第二个网络是鉴别器,它在生成的虚假数据和真实数据之间交替。它的工作是查看传入的输入并决定它们是真实的还是虚假的。GAN 的简单概念图如图 9-6 所示。

一个简单的 GAN 设置

图 9-6。一个简单的 GAN 设置

GANs 的伟大之处在于,尽管细节最终变得有些复杂,但总体思想很容易传达:这两个网络相互对立,在训练过程中,它们尽力击败对方。在过程结束时,生成器应该生成与真实输入数据的分布匹配的数据,以迷惑鉴别器。一旦达到这一点,您可以使用生成器为所有需求生成更多数据,而鉴别器可能会退休到神经网络酒吧淹没忧愁。

训练 GAN

训练 GAN 比训练传统网络稍微复杂一些。在训练循环中,我们首先需要使用真实数据开始训练鉴别器。我们计算鉴别器的损失(使用 BCE,因为我们只有两类:真实或虚假),然后进行反向传播以更新鉴别器的参数,就像往常一样。但这一次,我们调用优化器来更新。相反,我们从生成器生成一批数据并通过模型传递。我们计算损失并进行另一次反向传播,因此此时训练循环已计算了两次通过模型的损失。现在,我们根据这些累积梯度调用优化器进行更新。

在训练的后半段,我们转向生成器。我们让生成器访问鉴别器,然后生成一批新数据(生成器坚持说这些都是真实的!)并将其与鉴别器进行测试。我们根据这些输出数据形成一个损失,鉴别器说是假的每个数据点都被视为错误答案——因为我们试图欺骗它——然后进行标准的反向/优化传递。

这是 PyTorch 中的一个通用实现。请注意,生成器和鉴别器只是标准的神经网络,因此从理论上讲,它们可以生成图像、文本、音频或任何类型的数据,并且可以由迄今为止看到的任何类型的网络构建:

generator = Generator()
discriminator = Discriminator()

# Set up separate optimizers for each network
generator_optimizer = ...
discriminator_optimizer = ...

def gan_train():
  for epoch in num_epochs:
    for batch in real_train_loader:
      discriminator.train()
      generator.eval()
      discriminator.zero_grad()

      preds = discriminator(batch)
      real_loss = criterion(preds, torch.ones_like(preds))
      discriminator.backward()

      fake_batch = generator(torch.rand(batch.shape))
      fake_preds = discriminator(fake_batch)
      fake_loss = criterion(fake_preds, torch.zeros_like(fake_preds))
      discriminator.backward()

      discriminator_optimizer.step()

      discriminator.eval()
      generator.train()
      generator.zero_grad()

      forged_batch = generator(torch.rand(batch.shape))
      forged_preds = discriminator(forged_batch)
      forged_loss = criterion(forged_preds, torch.ones_like(forged_preds))

      generator.backward()
      generator_optimizer.step()

请注意,PyTorch 的灵活性在这里非常有帮助。没有专门为更标准的训练而设计的训练循环,构建一个新的训练循环是我们习惯的事情,我们知道需要包含的所有步骤。在其他一些框架中,训练 GAN 有点更加繁琐。这很重要,因为训练 GAN 本身就是一个困难的任务,如果框架阻碍了这一过程,那就更加困难了。

模式崩溃的危险

在理想的情况下,训练过程中发生的是,鉴别器一开始会擅长检测假数据,因为它是在真实数据上训练的,而生成器只允许访问鉴别器而不是真实数据本身。最终,生成器将学会如何欺骗鉴别器,然后它将迅速改进以匹配数据分布,以便反复产生能够欺骗评论者的伪造品。

但是困扰许多 GAN 架构的一件事是模式崩溃。如果我们的真实数据有三种类型的数据,那么也许我们的生成器会开始生成第一种类型,也许它开始变得相当擅长。鉴别器可能会决定任何看起来像第一种类型的东西实际上是假的,甚至是真实的例子本身,然后生成器开始生成看起来像第三种类型的东西。鉴别器开始拒绝所有第三种类型的样本,生成器选择另一个真实例子来生成。这个循环无休止地继续下去;生成器永远无法进入一个可以从整个分布中生成样本的阶段。

减少模式崩溃是使用 GAN 的关键性能问题,也是一个正在进行研究的领域。一些方法包括向生成的数据添加相似性分数,以便可以检测和避免潜在的崩溃,保持一个生成图像的重放缓冲区,以便鉴别器不会过度拟合到最新批次的生成图像,允许从真实数据集中添加实际标签到生成器网络等等。

接下来,我们通过检查一个执行超分辨率的 GAN 应用程序来结束本节。

ESRGAN

增强超分辨率生成对抗网络(ESRGAN)是一种在 2018 年开发的网络,可以产生令人印象深刻的超分辨率结果。生成器是一系列卷积网络块,其中包含残差和稠密层连接的组合(因此是 ResNet 和 DenseNet 的混合),移除了BatchNorm层,因为它们似乎会在上采样图像中产生伪影。对于鉴别器,它不是简单地产生一个结果,说这是真实的这是假的,而是预测一个真实图像相对更真实的概率比一个假图像更真实,这有助于使模型产生更自然的结果。

运行 ESRGAN

为了展示 ESRGAN,我们将从GitHub 存储库下载代码。使用git克隆:

git clone https://github.com/xinntao/ESRGAN

然后我们需要下载权重,这样我们就可以在不训练的情况下使用模型。使用自述文件中的 Google Drive 链接,下载RRDB_ESRGAN_x4.pth文件并将其放在./models中。我们将对 Helvetica 的缩小版本进行上采样,但可以随意将任何图像放入./LR目录。运行提供的test.py脚本,您将看到生成的上采样图像并保存在results目录中。

这就是超分辨率的全部内容,但我们还没有完成图像处理。

图像检测的进一步探索

我们在第二章到第四章的图像分类都有一个共同点:我们确定图像属于一个类别,猫或鱼。显然,在实际应用中,这将扩展到一个更大的类别集。但我们也希望图像可能包含猫和鱼(这对鱼可能是个坏消息),或者我们正在寻找的任何类别。场景中可能有两个人、一辆车和一艘船,我们不仅希望确定它们是否出现在图像中,还希望确定它们在图像中的位置。有两种主要方法可以实现这一点:目标检测分割。我们将看看这两种方法,然后转向 Facebook 的 PyTorch 实现的 Faster R-CNN 和 Mask R-CNN,以查看具体示例。

目标检测

让我们看看我们的盒子里的猫。我们真正想要的是让网络将猫放在另一个盒子里!特别是,我们希望有一个边界框,包围模型认为是的图像中的所有内容,如图 9-7 所示。

盒子里的猫在一个边界框中

图 9-7. 盒子里的猫在一个边界框中

但我们如何让我们的网络解决这个问题呢?请记住,这些网络可以预测您想要的任何内容。如果在我们的 CATFISH 模型中,我们除了预测一个类别之外,还产生四个额外的输出怎么样?我们将有一个输出大小为6Linear层,而不是2。额外的四个输出将使用x[1]、x[2]、y[1]、y[2]坐标定义一个矩形。我们不仅要提供图像作为训练数据,还必须用边界框增强它们,以便模型有东西可以训练,当然。我们的损失函数现在将是类别预测的交叉熵损失和边界框的均方损失的组合损失。

这里没有魔法!我们只需设计模型以满足我们的需求,提供具有足够信息的数据进行训练,并包含一个告诉网络它的表现如何的损失函数。

与边界框的泛滥相比,分割 是一种替代方法。我们的网络不是生成框,而是输出与输入相同大小的图像掩模;掩模中的像素根据它们所属的类别着色。例如,草可能是绿色的,道路可能是紫色的,汽车可能是红色的,等等。

由于我们正在输出图像,您可能会认为我们最终会使用与超分辨率部分类似的架构。这两个主题之间存在很多交叉,近年来变得流行的一种模型类型是U-Net架构,如图 9-8 所示。³

简化的 U-Net 架构

图 9-8. 简化的 U-Net 架构

正如您所看到的,经典的 U-Net 架构是一组卷积块,将图像缩小,另一系列卷积将其缩放回目标图像。然而,U-Net 的关键在于从左侧块到右侧对应块的横跨线,这些线与输出张量连接在一起,当图像被缩放回来时,这些连接允许来自更高级别卷积块的信息传递,保留可能在卷积块减少输入图像时被移除的细节。

您会发现基于 U-Net 的架构在 Kaggle 分割竞赛中随处可见,从某种程度上证明了这种结构对于分割是一个不错的选择。已经应用到基本设置的另一种技术是我们的老朋友迁移学习。在这种方法中,U 的第一部分取自预训练模型,如 ResNet 或 Inception,U 的另一侧加上跳跃连接,添加到训练网络的顶部,并像往常一样进行微调。

让我们看看一些现有的预训练模型,可以直接从 Facebook 获得最先进的目标检测和分割。

Faster R-CNN 和 Mask R-CNN

Facebook Research 开发了maskrcnn-benchmark库,其中包含目标检测和分割算法的参考实现。我们将安装该库并添加代码来生成预测。在撰写本文时,构建模型的最简单方法是使用 Docker(当 PyTorch 1.2 发布时可能会更改)。从https://github.com/facebookresearch/maskrcnn-benchmark克隆存储库,并将此脚本predict.py添加到demo目录中,以设置使用 ResNet-101 骨干的预测管道:

import matplotlib.pyplot as plt

from PIL import Image
import numpy as np
import sys
from maskrcnn_benchmark.config import cfg
from predictor import COCODemo

config_file = "../configs/caffe2/e2e_faster_rcnn_R_101_FPN_1x_caffe2.yaml"

cfg.merge_from_file(config_file)
cfg.merge_from_list(["MODEL.DEVICE", "cpu"])

coco_demo = COCODemo(
    cfg,
    min_image_size=500,
    confidence_threshold=0.7,
)

pil_image = Image.open(sys.argv[1])
image = np.array(pil_image)[:, :, [2, 1, 0]]
predictions = coco_demo.run_on_opencv_image(image)
predictions = predictions[:,:,::-1]

plt.imsave(sys.argv[2], predictions)

在这个简短的脚本中,我们首先设置了COCODemo预测器,确保我们传入的配置设置了 Faster R-CNN 而不是 Mask R-CNN(后者会产生分割输出)。然后我们打开一个在命令行上设置的图像文件,但是我们必须将其转换为BGR格式而不是RGB格式,因为预测器是在 OpenCV 图像上训练的,而不是我们迄今为止使用的 PIL 图像。最后,我们使用imsavepredictions数组(原始图像加上边界框)写入一个新文件,也在命令行上指定。将一个测试图像文件复制到这个demo目录中,然后我们可以构建 Docker 镜像:

docker build docker/

我们从 Docker 容器内运行脚本,并生成类似于图 9-7 的输出(我实际上使用了该库来生成该图像)。尝试尝试不同的confidence_threshold值和不同的图片。您还可以切换到e2e_mask_rcnn_R_101_FPN_1x_caffe2.yaml配置,尝试 Mask R-CNN 并生成分割蒙版。

要在这些模型上训练您自己的数据,您需要提供一个数据集,为每个图像提供边界框标签。该库提供了一个名为BoxList的辅助函数。以下是一个数据集的骨架实现,您可以将其用作起点:

from maskrcnn_benchmark.structures.bounding_box import BoxList

class MyDataset(object):
    def __init__(self, path, transforms=None):
        self.images = # set up image list
        self.boxes = # read in boxes
        self.labels = # read in labels

    def __getitem__(self, idx):
        image = # Get PIL image from self.images
        boxes = # Create a list of arrays, one per box in x1, y1, x2, y2 format
        labels = # labels that correspond to the boxes

        boxlist = BoxList(boxes, image.size, mode="xyxy")
        boxlist.add_field("labels", labels)

        if self.transforms:
            image, boxlist = self.transforms(image, boxlist)

        return image, boxlist, idx

    def get_img_info(self, idx):
        return {"height": img_height, "width": img_width

然后,您需要将新创建的数据集添加到maskrcnn_benchmark/data/datasets/init.pymaskrcnn_benchmark/config/paths_catalog.py中。然后可以使用存储库中提供的train_net.py脚本进行训练。请注意,您可能需要减少批量大小以在单个 GPU 上训练这些网络中的任何一个。

这就是目标检测和分割的全部内容,但是请参阅“进一步阅读”以获取更多想法,包括标题为 You Only Look Once(YOLO)架构的内容。与此同时,我们将看看如何恶意破坏模型。

对抗样本

你可能在网上看到过关于图像如何阻止图像识别正常工作的文章。如果一个人将图像举到相机前,神经网络会认为它看到了熊猫或类似的东西。这些被称为对抗样本,它们是发现架构限制以及如何最好地防御的有趣方式。

创建对抗样本并不太困难,特别是如果你可以访问模型。这里有一个简单的神经网络,用于对来自流行的 CIFAR-10 数据集的图像进行分类。这个模型没有什么特别之处,所以可以随意将其替换为 AlexNet、ResNet 或本书中迄今为止介绍的任何其他网络:

class ModelToBreak(nn.Module):
    def __init__(self):
        super(ModelToBreak, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.pool = nn.MaxPool2d(2, 2)
        self.conv2 = nn.Conv2d(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

一旦网络在 CIFAR-10 上训练完成,我们可以为图 9-9 中的图像获得预测。希望训练已经足够好,可以报告这是一只青蛙(如果没有,可能需要再多训练一会儿!)。我们要做的是稍微改变我们的青蛙图片,让神经网络感到困惑,认为它是其他东西,尽管我们仍然可以清楚地认出它是一只青蛙。

我们的青蛙示例

图 9-9。我们的青蛙示例

为此,我们将使用一种名为快速梯度符号方法的攻击方法。⁴ 这个想法是拿我们想要误分类的图像并像往常一样通过模型运行它,这给我们一个输出张量。通常情况下,对于预测,我们会查看张量中哪个值最高,并将其用作我们类的索引,使用argmax()。但这一次,我们将假装再次训练网络,并将结果反向传播回模型,给出模型相对于原始输入(在这种情况下,我们的青蛙图片)的梯度变化。

完成后,我们创建一个新的张量,查看这些梯度并用+1 替换一个条目,如果梯度为正则用-1。这给我们这个图像推动模型决策边界的方向。然后我们乘以一个小标量(在论文中称为epsilon)来生成我们的恶意掩码,然后将其添加到原始图像中,创建一个对抗样本。

这里有一个简单的 PyTorch 方法,当提供批次的标签、模型和用于评估模型的损失函数时,返回输入批次的快速梯度符号张量:

def fgsm(input_tensor, labels, epsilon=0.02, loss_function, model):
    outputs = model(input_tensor)
    loss = loss_function(outputs, labels)
    loss.backward(retain_graph=True)
    fsgm = torch.sign(inputs.grad) * epsilon
    return fgsm

通过实验通常可以找到 Epsilon。通过尝试各种图像,我发现0.02对这个模型效果很好,但你也可以使用类似网格或随机搜索的方法来找到将青蛙变成船的值!

在我们的青蛙和模型上运行这个函数,我们得到一个掩码,然后我们可以将其添加到我们的原始图像中生成我们的对抗样本。看看图 9-10 看看它是什么样子!

model_to_break = # load our model to break here
adversarial_mask = fgsm(frog_image.unsqueeze(-1),
                        batch_labels,
                        loss_function,
                        model_to_break)
adversarial_image = adversarial_mask.squeeze(0) + frog_image

我们的对抗性青蛙

图 9-10。我们的对抗性青蛙

显然,我们创建的图像对我们的人眼来说仍然是一只青蛙。(如果对你来说看起来不像青蛙,那么你可能是一个神经网络。立即报告自己进行 Voight-Kampff 测试。)但如果我们从模型对这个新图像的预测中得到一个结果会发生什么?

model_to_break(adversarial_image.unsqueeze(-1))
# look up in labels via argmax()
>> 'cat'

我们打败了模型。但这是否像最初看起来的那样成为问题呢?

黑盒攻击

你可能已经注意到,要生成愚弄分类器的图像,我们需要了解使用的模型的很多信息。我们面前有整个模型的结构以及在训练模型时使用的损失函数,我们需要在模型中进行前向和后向传递以获得我们的梯度。这是计算机安全领域中所知的白盒攻击的一个典型例子,我们可以窥视代码的任何部分来弄清楚发生了什么并利用我们能找到的任何东西。

那么这有关系吗?毕竟,大多数你在网上遇到的模型都不会让你窥视内部。黑盒攻击,即只有输入和输出的攻击,实际上可能吗?很遗憾,是的。考虑我们有一组输入和一组要与之匹配的输出。输出是标签,可以使用模型的有针对性查询来训练一个新模型,你可以将其用作本地代理并以白盒方式进行攻击。就像你在迁移学习中看到的那样,对代理模型的攻击可以有效地作用于实际模型。我们注定要失败吗?

防御对抗性攻击

我们如何防御这些攻击?对于像将图像分类为猫或鱼这样的任务,这可能不是世界末日,但对于自动驾驶系统、癌症检测应用等,这可能真的意味着生与死的区别。成功地防御各种类型的对抗性攻击仍然是一个研究领域,但迄今为止的重点包括提炼和验证。

通过使用模型来训练另一个模型来提炼似乎有所帮助。使用本章前面概述的新模型的标签平滑也似乎有所帮助。使模型对其决策不那么确定似乎可以在一定程度上平滑梯度,使我们在本章中概述的基于梯度的攻击不那么有效。

更强大的方法是回到早期计算机视觉时代的一些部分。如果我们对传入数据进行输入验证,可能可以防止对抗性图像首先到达模型。在前面的例子中,生成的攻击图像有一些像素与我们看到青蛙时期望的非常不匹配。根据领域的不同,我们可以设置一个过滤器,只允许通过一些过滤测试的图像。你理论上也可以制作一个神经网络来做这个,因为攻击者必须尝试用相同的图像破坏两个不同的模型!

现在我们真的已经结束了关于图像的讨论。但让我们看看过去几年发生的文本网络方面的一些发展。

眼见不一定为实:变压器架构

在过去的十年中,迁移学习一直是使基于图像的网络变得如此有效和普遍的一个重要特征,但文本一直是一个更难解决的问题。然而,在过去几年中,已经迈出了一些重要的步骤,开始揭示了在文本中使用迁移学习的潜力,用于各种任务,如生成、分类和回答问题。我们还看到了一种新型架构开始占据主导地位:变压器网络。这些网络并不来自赛博特隆,但这种技术是我们看到的最强大的基于文本的网络背后的技术,OpenAI 于 2019 年发布的 GPT-2 模型展示了其生成文本的惊人质量,以至于 OpenAI 最初推迟了模型的更大版本,以防止其被用于不良目的。我们将研究变压器的一般理论,然后深入探讨如何使用 Hugging Face 的 GPT-2 和 BERT 实现。

专注

通往变压器架构的途中的初始步骤是注意力机制,最初引入到 RNN 中,以帮助序列到序列的应用,如翻译。⁵

注意力机制试图解决的问题是翻译句子如“猫坐在垫子上,她发出了咕噜声。”我们知道该句中的指的是猫,但让标准的 RNN 理解这个概念很困难。它可能有我们在第五章中讨论过的隐藏状态,但当我们到达时,我们已经有了很多时间步和每个步骤的隐藏状态!

那么attention的作用是为每个时间步添加一组额外的可学习权重,将网络聚焦在句子的特定部分。这些权重通常通过softmax层传递,生成每个步骤的概率,然后将注意力权重的点积与先前的隐藏状态进行计算。图 9-11 展示了关于我们句子的这个简化版本。

指向“cat”的注意向量

图 9-11. 指向“cat”的注意向量

这些权重确保当隐藏状态与当前状态组合时,“cat”将成为决定“she”时间步输出向量的主要部分,这将为将其翻译成法语提供有用的上下文!

我们不会详细介绍attention在具体实现中如何工作,但知道这个概念足够强大,足以在 2010 年代中期推动了谷歌翻译的显著增长和准确性。但更多的东西即将到来。

注意力机制就是你需要的一切

在开创性的论文“注意力就是你需要的一切”中,谷歌研究人员指出,我们花了很多时间将注意力添加到已经相对较慢的基于 RNN 的网络上(与 CNN 或线性单元相比)。如果我们根本不需要 RNN 呢?该论文表明,通过堆叠基于注意力的编码器和解码器,您可以创建一个完全不依赖于 RNN 隐藏状态的模型,为今天主导文本深度学习的更大更快的 Transformer 铺平了道路。

关键思想是使用作者称之为多头注意力,它通过使用一组Linear层在所有输入上并行化attention步骤。借助这些技巧,并从 ResNet 借鉴一些残差连接技巧,Transformer 迅速开始取代 RNN 用于许多基于文本的应用。两个重要的 Transformer 版本,BERT 和 GPT-2,代表了当前的最先进技术,本书付印时。

幸运的是,Hugging Face 有一个库在 PyTorch 中实现了这两个模型。它可以使用pipconda进行安装,您还应该git clone该存储库本身,因为我们稍后将使用一些实用脚本!

pip install pytorch-transformers
conda install pytorch-transformers

首先,我们将看一下 BERT。

BERT

谷歌 2018 年的双向编码器表示转换器(BERT)模型是将强大模型的迁移学习成功应用的首批案例之一。BERT 本身是一个庞大的基于 Transformer 的模型(在其最小版本中有 1.1 亿个参数),在维基百科和 BookCorpus 数据集上进行了预训练。传统上,Transformer 和卷积网络在处理文本时存在的问题是,因为它们一次看到所有数据,这些网络很难学习语言的时间结构。BERT 通过在预训练阶段随机屏蔽文本输入的 15%,并强制模型预测已被屏蔽的部分来解决这个问题。尽管在概念上很简单,但最大模型中 3.4 亿个参数的庞大规模与 Transformer 架构的结合,为一系列与文本相关的基准测试带来了新的最先进结果。

当然,尽管 BERT 是由 Google 与 TensorFlow 创建的,但也有适用于 PyTorch 的 BERT 实现。现在让我们快速看一下其中一个。

FastBERT

在您自己的分类应用程序中开始使用 BERT 模型的一种简单方法是使用FastBERT库,该库将 Hugging Face 的存储库与 fast.ai API 混合在一起(稍后我们将在 ULMFiT 部分更详细地看到)。它可以通过常规方式使用pip进行安装:

pip install fast-bert

以下是一个可以用来在我们在第五章中使用的 Sentiment140 Twitter 数据集上微调 BERT 的脚本:

import torch
import logger

from pytorch_transformers.tokenization import BertTokenizer
from fast_bert.data import BertDataBunch
from fast_bert.learner import BertLearner
from fast_bert.metrics import accuracy

device = torch.device('cuda')
logger = logging.getLogger()
metrics = [{'name': 'accuracy', 'function': accuracy}]

tokenizer = BertTokenizer.from_pretrained
                ('bert-base-uncased',
                  do_lower_case=True)

databunch = BertDataBunch([PATH_TO_DATA],
                          [PATH_TO_LABELS],
                          tokenizer,
                          train_file=[TRAIN_CSV],
                          val_file=[VAL_CSV],
                          test_data=[TEST_CSV],
                          text_col=[TEST_FEATURE_COL], label_col=[0],
                          bs=64,
                          maxlen=140,
                          multi_gpu=False,
                          multi_label=False)

learner = BertLearner.from_pretrained_model(databunch,
                      'bert-base-uncased',
                      metrics,
                      device,
                      logger,
                      is_fp16=False,
                      multi_gpu=False,
                      multi_label=False)

learner.fit(3, lr='1e-2')

在导入之后,我们设置了deviceloggermetrics对象,这些对象是BertLearner对象所需的。然后我们创建了一个BERTTokenizer来对我们的输入数据进行标记化,在这个基础上我们将使用bert-base-uncased模型(具有 12 层和 1.1 亿参数)。接下来,我们需要一个包含训练、验证和测试数据集路径的BertDataBunch对象,以及标签列的位置、批处理大小和我们输入数据的最大长度,对于我们的情况来说很简单,因为它只能是推文的长度,那时是 140 个字符。做完这些之后,我们将通过使用BertLearner.from_pretrained_model方法来设置 BERT 模型。这个方法传入了我们的输入数据、BERT 模型类型、我们在脚本开始时设置的metricdevicelogger对象,最后一些标志来关闭我们不需要但方法签名中没有默认值的训练选项。

最后,fit()方法负责在我们的输入数据上微调 BERT 模型,运行自己的内部训练循环。在这个例子中,我们使用学习率为1e-2进行三个 epochs 的训练。训练后的 PyTorch 模型可以通过learner.model进行访问。

这就是如何开始使用 BERT。现在,进入比赛。

GPT-2

现在,当谷歌悄悄地研究 BERT 时,OpenAI 正在研究自己版本的基于 Transformer 的文本模型。该模型不使用掩码来强制模型学习语言结构,而是将架构内的注意机制限制在简单地预测序列中的下一个单词,类似于第五章中的 RNN 的风格。结果,GPT 在 BERT 的出色性能下有些落后,但在 2019 年,OpenAI 推出了GPT-2,这是该模型的新版本,重新定义了文本生成的标准。

GPT-2 背后的魔力在于规模:该模型训练于 800 万个网站的文本,最大变体的 GPT-2 拥有 15 亿个参数。虽然它仍然无法在特定基准上击败 BERT,比如问答或其他 NLP 任务,但它能够从基本提示中创建出极为逼真的文本,这导致 OpenAI 将全尺寸模型锁在了闭门之后,以防止被武器化。然而,他们发布了模型的较小版本,其中 117 和 340 亿个参数。

这里是 GPT-2 可以生成的输出示例。所有斜体部分都是由 GPT-2 的 340M 模型编写的:

杰克和吉尔骑着自行车上山。天空是灰白色的,风在吹,导致大雪纷飞。下山真的很困难,我不得不向前倾斜一点点才能继续前行。但接着有一个我永远不会忘记的自由时刻:自行车完全停在山坡上,我就在其中间。我没有时间说一句话,但我向前倾斜,触碰了刹车,自行车开始前进。

除了从杰克和吉尔切换到I,这是一个令人印象深刻的文本生成。对于短文本,它有时几乎无法与人类创作的文本区分开。随着生成文本的继续,它揭示了幕后的机器,但这是一个令人印象深刻的成就,它现在可以写推文和 Reddit 评论。让我们看看如何在 PyTorch 中实现这一点。

使用 GPT-2 生成文本

与 BERT 一样,OpenAI 发布的官方 GPT-2 版本是一个 TensorFlow 模型。与 BERT 一样,Hugging Face 发布了一个 PyTorch 版本,该版本包含在同一个库(pytorch-transformers)中。然而,围绕原始 TensorFlow 模型构建了一个蓬勃发展的生态系统,而目前在 PyTorch 版本周围并不存在。因此,这一次,我们将作弊:我们将使用一些基于 TensorFlow 的库来微调 GPT-2 模型,然后导出权重并将其导入模型的 PyTorch 版本。为了节省我们太多的设置,我们还在 Colab 笔记本中执行所有 TensorFlow 操作!让我们开始吧。

打开一个新的 Google Colab 笔记本,并安装我们正在使用的库,Max Woolf 的gpt-2-simple,它将 GPT-2 微调封装在一个单一软件包中。通过将此添加到单元格中进行安装:

!pip3 install gpt-2-simple

接下来,您需要一些文本。在此示例中,我使用了 PG Wodehouse 的My Man Jeeves的公共领域文本。我还不打算在从 Project Gutenberg 网站使用wget下载文本后对文本进行任何进一步处理:

!wget http://www.gutenberg.org/cache/epub/8164/pg8164.txt

现在我们可以使用库进行训练。首先确保您的笔记本连接到 GPU(在 Runtime→Change Runtime Type 中查看),然后在单元格中运行此代码:

import gpt_2_simple as gpt2

gpt2.download_gpt2(model_name="117M")

sess = gpt2.start_tf_sess()
gpt2.finetune(sess,
              "pg8164.txt",model_name="117M",
              steps=1000)

用您正在使用的文本文件替换文本文件。当模型训练时,它将每一百步输出一个样本。在我的情况下,看到它从模糊的莎士比亚剧本变成接近伍德豪斯散文的东西很有趣。这可能需要一个小时或两个小时来训练 1,000 个时代,所以在云端的 GPU 忙碌时,去做一些更有趣的事情吧。

完成后,我们需要将权重从 Colab 中取出并放入您的 Google Drive 帐户,以便您可以将它们下载到运行 PyTorch 的任何地方:

gpt2.copy_checkpoint_to_gdrive()

这将指引您打开一个新的网页,将认证代码复制到笔记本中。完成后,权重将被打包并保存到您的 Google Drive 中,文件名为run1.tar.gz

现在,在运行 PyTorch 的实例或笔记本上,下载该 tar 文件并解压缩。我们需要重命名一些文件,使这些权重与 GPT-2 的 Hugging Face 重新实现兼容:

mv encoder.json vocab.json
mv vocab.bpe merges.txt

现在我们需要将保存的 TensorFlow 权重转换为与 PyTorch 兼容的权重。方便的是,pytorch-transformers存储库附带了一个脚本来执行此操作:

 python [REPO_DIR]/pytorch_transformers/convert_gpt2_checkpoint_to_pytorch.py
 --gpt2_checkpoint_path [SAVED_TENSORFLOW_MODEL_DIR]
 --pytorch_dump_folder_path [SAVED_TENSORFLOW_MODEL_DIR]

然后可以在代码中创建一个新的 GPT-2 模型实例:

from pytorch_transformers import GPT2LMHeadModel

model = GPT2LMHeadModel.from_pretrained([SAVED_TENSORFLOW_MODEL_DIR])

或者,只是为了玩弄模型,您可以使用run_gpt2.py脚本获得一个提示,输入文本并从基于 PyTorch 的模型获取生成的样本:

python [REPO_DIR]/pytorch-transformers/examples/run_gpt2.py
--model_name_or_path [SAVED_TENSORFLOW_MODEL_DIR]

随着 Hugging Face 在其存储库中整合所有模型的一致 API,训练 GPT-2 可能会变得更加容易,但目前使用 TensorFlow 方法是最容易入门的。

BERT 和 GPT-2 目前是文本学习中最流行的名称,但在我们结束之前,我们将介绍当前最先进模型中的黑马:ULMFiT。

ULMFiT

与 BERT 和 GPT-2 这两个庞然大物相比,ULMFiT基于一个老式的 RNN。看不到 Transformer,只有 AWD-LSTM,这是由 Stephen Merity 最初创建的架构。在 WikiText-103 数据集上训练,它已被证明适合迁移学习,尽管是老式的架构,但在分类领域已被证明与 BERT 和 GPT-2 具有竞争力。

虽然 ULMFiT 本质上只是另一个可以像其他模型一样在 PyTorch 中加载和使用的模型,但它的自然家园是 fast.ai 库,该库位于 PyTorch 之上,并为快速掌握深度学习并快速提高生产力提供了许多有用的抽象。为此,我们将看看如何在 Twitter 数据集上使用 fast.ai 库中的 ULMFiT,该数据集在第五章中使用过。

我们首先使用 fast.ai 的 Data Block API 为微调 LSTM 准备数据:

data_lm = (TextList
           .from_csv("./twitter-data/",
           'train-processed.csv', cols=5,
           vocab=data_lm.vocab)
           .split_by_rand_pct()
           .label_from_df(cols=0)
           .databunch())

这与第五章中的torchtext助手非常相似,只是产生了 fast.ai 称为databunch的东西,从中其模型和训练例程可以轻松获取数据。接下来,我们创建模型,但在 fast.ai 中,这种情况有些不同。我们创建一个learner,与之交互以训练模型,而不是模型本身,尽管我们将其作为参数传递。我们还提供了一个 dropout 值(我们使用了 fast.ai 培训材料中建议的值):

learn = language_model_learner(data_lm, AWD_LSTM, drop_mult=0.3)

一旦我们有了learner对象,我们可以找到最佳学习率。这与我们在第四章中实现的类似,只是它内置在库中,并使用指数移动平均值来平滑图表,我们的实现中相当尖锐:

learn.lr_find()
learn.recorder.plot()

从图 9-12 中的图表来看,1e-2是我们开始出现急剧下降的地方,因此我们将选择它作为我们的学习率。Fast.ai 使用一种称为fit_one_cycle的方法,它使用 1cycle 学习调度器(有关 1cycle 的更多详细信息,请参见“进一步阅读”),并使用非常高的学习率在数量级更少的时代内训练模型。

ULMFiT 学习率图

图 9-12. ULMFiT 学习率图

在这里,我们只训练一个周期,并保存网络的微调头部(编码器):

learn.fit_one_cycle(1, 1e-2)
learn.save_encoder('twitter_encoder')

随着语言模型的微调完成(您可能希望在训练中尝试更多周期),我们为实际分类问题构建了一个新的databunch

twitter_classifier_bunch = TextList
           .from_csv("./twitter-data/",
           'train-processed.csv', cols=5,
           vocab=data_lm.vocab)
           .split_by_rand_pct()
           .label_from_df(cols=0)
           .databunch())

这里唯一的真正区别是,我们通过使用label_from_df提供实际标签,并且从之前执行的语言模型训练中传入一个vocab对象,以确保它们使用相同的单词到数字的映射,然后我们准备创建一个新的text_classifier_learner,其中库在幕后为您创建所有模型。我们将微调的编码器加载到这个新模型上,并开始再次进行训练过程:

learn = text_classifier_learner(data_clas, drop_mult=0.5)
learn.load_encoder('fine_tuned_enc')

learn.lr_find()
learn.recorder.plot()

learn.fit_one_cycle(1, 2e-2, moms=(0.8,0.7))

通过少量代码,我们得到了一个报告准确率为 76%的分类器。我们可以通过训练语言模型更多周期,添加不同的学习率并在训练时冻结部分模型来轻松改进,所有这些都是 fast.ai 支持的,定义在learner上的方法。

使用什么?

在深度学习文本模型的当前前沿进行了一番快速的介绍后,您可能心中有一个问题:“这一切都很棒,但我应该实际使用哪一个?”一般来说,如果您正在处理分类问题,我建议您从 ULMFiT 开始。BERT 令人印象深刻,但在准确性方面,ULMFiT 与 BERT 竞争,并且它还有一个额外的好处,即您不需要购买大量的 TPU 积分来充分利用它。对于大多数人来说,单个 GPU 微调 ULMFiT 可能已经足够了。

至于 GPT-2,如果您想要生成的文本,那么是的,它更适合,但对于分类目的,接近 ULMFiT 或 BERT 的性能会更难。我认为可能有趣的一件事是让 GPT-2 在数据增强上自由发挥;如果您有一个类似 Sentiment140 的数据集,我们在整本书中一直在使用它,为什么不在该输入上微调一个 GPT-2 模型并使用它生成更多数据呢?

结论

本章介绍了 PyTorch 的更广泛世界,包括可以导入到自己项目中的现有模型的库,一些可应用于任何领域的尖端数据增强方法,以及可能破坏模型的对抗样本以及如何防御它们。希望当我们结束这段旅程时,你能理解神经网络是如何组装的,以及如何让图像、文本和音频作为张量流经它们。你应该能够训练它们,增强数据,尝试不同的学习率,并在模型出现问题时进行调试。一旦所有这些都完成了,你就知道如何将它们打包到 Docker 中,并让它们为更广泛的世界提供服务。

接下来我们去哪里?考虑查看 PyTorch 论坛和网站上的其他文档。我强烈推荐访问 fast.ai 社区,即使你最终不使用该库;这是一个充满活力的社区,充满了好主意和尝试新方法的人,同时也对新手友好!

跟上深度学习的前沿变得越来越困难。大多数论文都发表在arXiv,但论文的发表速度似乎以近乎指数级增长;当我写这个结论时,XLNet刚刚发布,据说在各种任务上击败了 BERT。永无止境!为了帮助解决这个问题,我在这里列出了一些 Twitter 账号,人们经常推荐有趣的论文。我建议关注它们,以了解当前和有趣的工作,然后你可以使用工具如arXiv Sanity Preserver来更轻松地深入研究。

最后,我在这本书上训练了一个 GPT-2 模型,它想说几句话:

深度学习是我们如何处理当今深度学习应用的关键驱动力,预计深度学习将继续扩展到新领域,如基于图像的分类,在 2016 年,NVIDIA 推出了 CUDA LSTM 架构。随着 LSTM 变得越来越流行,LSTM 也成为了一种更便宜、更易于生产的用于研究目的的构建方法,而 CUDA 已经证明在深度学习市场上是一种非常有竞争力的架构。

幸运的是,你可以看到在我们作者失业之前还有很长的路要走。但也许你可以帮助改变这一点!

进一步阅读

一些推荐关注的 Twitter 账号:

  • @jeremyphoward —— fast.ai 的联合创始人

  • @miles_brundage —— OpenAI 的研究科学家(政策)

  • @BrundageBot —— 一个每天生成有趣论文摘要的 Twitter 机器人(警告:通常每天推出 50 篇论文!)

  • @pytorch —— 官方 PyTorch 账号

¹ 请查看张宏毅等人(2017 年)的论文“混合:超越经验风险最小化”。

² 请查看 Ian J. Goodfellow 等人(2014 年)的论文“生成对抗网络”。

³ 请查看 Olaf Ronneberger 等人(2015 年)的论文“U-Net:用于生物医学图像分割的卷积网络”。

⁴ 请参阅 Ian Goodfellow 等人撰写的“解释和利用对抗样本”(2014 年)。

⁵ 请参阅 Dzmitry Bahdanau 等人撰写的“通过联合学习对齐和翻译进行神经机器翻译”(2014 年)。

⁶ 请参阅 Ashish Vaswani 等人撰写的“注意力机制就是一切”(2017 年)。

posted @ 2025-09-12 14:05  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报