PyTorch 深度学习实用指南:1~5

2023-04-27 15:13:02 浏览数 (1)

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

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

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

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

了解 PyTorch 的历史

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

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

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

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

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

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

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

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

什么是 PyTorch?

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

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

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

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

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

安装 PyTorch

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P42aq36p-1681786181061)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_01.jpg)]

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

是什么让 PyTorch 受欢迎?

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

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

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

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

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

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

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

使用计算图

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

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

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xJaVxcDk-1681786181062)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_02.jpg)]

图 1.2:等式的图形表示

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

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

使用静态图

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OoG6sioH-1681786181063)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_03.jpg)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5dHLXXSL-1681786181063)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_04.jpg)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rIscuaZ2-1681786181063)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_05.jpg)]

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

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

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

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

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

代码语言:javascript复制
with tf.Session() as sess:
    sess.run(init)
    for i in range(epoch):
        sess.run(train_step, feed_dict={x_: XOR_X, y_: XOR_Y})

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

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

输入

输出

A

B

异或

0

0

0

0

1

1

1

0

1

1

1

0

使用动态图

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mF3k7x8j-1681786181064)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_06.jpg)]

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

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

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

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

代码语言:javascript复制
x = torch.FloatTensor(XOR_X)
y = torch.FloatTensor(XOR_Y)
w1 = torch.randn(2, 5, requires_grad=True)
w2 = torch.randn(5, 2, requires_grad=True)
b1 = torch.zeros(5, requires_grad=True)
b2 = torch.zeros(2, requires_grad=True)

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

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

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

探索深度学习

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-injIyoFM-1681786181064)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_07.jpg)]

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kXUdlJkL-1681786181064)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_08.jpg)]

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Bxr86ANe-1681786181065)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_09.jpg)]

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zTbAPGgd-1681786181065)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_10.jpg)]

图 1.10:深度神经网络

了解不同的架构

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

全连接网络

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

编码器和解码器

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ygOjgVot-1681786181065)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_11.jpg)]

图 1.11:Seq2seq 网络

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gUotrjll-1681786181066)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_12.jpg)]

图 1.12:自编码器的结构

循环神经网络

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YMARBu2v-1681786181066)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_13.jpg)]

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

递归神经网络

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MSLbcbxv-1681786181066)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_14.jpg)]

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

卷积神经网络

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TshdtSEe-1681786181066)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_15.jpg)]

图 1.15:典型的 CNN

生成对抗网络

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aze3LwJP-1681786181067)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_16.jpg)]

Figure 1.16: GAN setup

强化学习

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SonuIFgs-1681786181067)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_17.jpg)]

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

代码入门

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

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

学习基本操作

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

代码语言:javascript复制
import torch

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

代码语言:javascript复制
uninitialized = torch.Tensor(3,2)
rand_initialized = torch.rand(3,2)
matrix_with_ones = torch.ones(3,2)
matrix_with_zeros = torch.zeros(3,2)

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

数据类型

CPU 张量

GPU 张量

32 位浮点

torch.FloatTensor

torch.cuda.FloatTensor

64 位浮点

torch.DoubleTensor

torch.cuda.DoubleTensor

16 位浮点

torch.HalfTensor

torch.cuda.HalfTensor

8 位整数(无符号)

torch.ByteTensor

torch.cuda.ByteTensor

8 位整数(有符号)

torch.CharTensor

torch.cuda.CharTensor

16 位整数(有符号)

torch.ShortTensor

torch.cuda.ShortTensor

32 位整数(有符号)

torch.IntTensor

torch.cuda.IntTensor

64 位整数(有符号)

torch.LongTensor

torch.cuda.LongTensor

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

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

代码语言:javascript复制
>>> size = rand_initialized.size()
>>> shape = rand_initialized.shape
>>> print(size == shape)
True

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

代码语言:javascript复制
>>> print(shape[0])
3
>>> print(shape[1])
2

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

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

代码语言:javascript复制
 >>> x = torch.ones(3,2)
>>> x
tensor([[1., 1.],
	   [1., 1.],
	   [1., 1.]])
>>>
>>> y = torch.ones(3,2)   2
>>> y
tensor([[3., 3.],
	   [3., 3.],
	   [3., 3.]])
>>>
>>> z = torch.ones(2,1)
>>> z
tensor([[1.],
      [1.]])
>>>
>>> x * y @ z
tensor([[6.],
	   [6.],
	   [6.]]) 

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

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

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

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

注意

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

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

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

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

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

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FCaNnCeM-1681786181067)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_18.jpg)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y3hQoHN2-1681786181067)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_19.jpg)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lDYkaYmh-1681786181068)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_20.jpg)]

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

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

PyTorch 的内部

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

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

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

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

代码语言:javascript复制
x = torch.rand(2,3,4)
x_with_2n3_dimension = x[1, :, :]
scalar_x = x[1,1,1]     # first value from each dimension

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

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

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

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

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ih45vr9a-1681786181068)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_01_21.jpg)]

图 1.19:THTensor 到 THStorage 到原始数据

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

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

代码语言:javascript复制
>>> import torch
>>> import numpy as np >>> x = torch.ones(2,2)
>>> xv = x.view(-1)
>>> xn = x.numpy()
>>> x
tensor([[1., 1.],[1., 1.]])
>>> xv
tensor([1., 1., 1., 1.])
>>> xn
array([[1. 1.],[1. 1.]], dtype=float32)

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

代码语言:javascript复制
>>> x.storage()
1.0
1.0
1.0
1.0
[torch.FloatStorage of size 4]
>>> xv.storage()
1.0
1.0
1.0
1.0
[torch.FloatStorage of size 4]
>>> x.storage().data_ptr() == xv.storage().data_ptr()
True

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

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

代码语言:javascript复制
>>> x[0,0]=20
>>> x
tensor([[20.,  1.],[ 1.,  1.]])
>>> xv
tensor([20.,  1.,  1.,  1.])
>>> xn
array([[20.,  1.],[ 1.,  1.]], dtype=float32)

总结

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

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

参考

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

二、简单的神经网络

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

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

神经网络介绍

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

问题

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

注意

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

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

数据集

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

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hP9sB6yZ-1681786181068)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_01.jpg)]

图 2.1:输入到输出映射

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

代码语言:javascript复制
def binary_encoder(input_size):
    def wrapper(num):
        ret = [int(i) for i in '{0:b}'.format(num)]
        return [0] * (input_size - len(ret))   ret
    return wrapper

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

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

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

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

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

代码语言:javascript复制
epochs = 500
batches = 64
lr = 0.01
input_size = 10
output_size = 4
hidden_size = 100

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

代码语言:javascript复制
for i in epoch:
    network_execution_over_whole_dataset()

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

代码语言:javascript复制
weight -= lr * loss

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XiSpnbnk-1681786181068)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_02.jpg)]

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

徒手模型

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

Autograd

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

代码语言:javascript复制
x = torch.from_numpy(trX).to(device=device, dtype=dtype)
y = torch.from_numpy(trY).to(device=device, dtype=dtype)
w1 = torch.randn(input_size, hidden_size, requires_grad=True, device=device, dtype=dtype)
w2 = torch.randn(hidden_size, output_size, requires_grad=True, device=device, dtype=dtype)
b1 = torch.zeros(1, hidden_size, requires_grad=True, device=device, dtype=dtype)
b2 = torch.zeros(1, output_size, requires_grad=True, device=device, dtype=dtype)

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

注意

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

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

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

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

代码语言:javascript复制
output = X * W   B

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y575YlH1-1681786181073)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_03.jpg)]

图 2.3:Autograd 的工作方式

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iCCozKcd-1681786181074)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_04.jpg)]

图 2.4:Autograd 使用的链式规则

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

代码语言:javascript复制
>>> import torch
>>> inputs = torch.FloatTensor([2])
>>> weights = torch.rand(1, requires_grad=True)
>>> bias = torch.rand(1, requires_grad=True)
>>> t = inputs @ weights
>>> out = t   bias
>>> out.backward()
>>> weights.grad
tensor([2.])
>>>bias.grad
tensor([1.])

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

张量的 Autograd 属性

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g9rxwcAm-1681786181074)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_05.jpg)]

图 2.5:datagradgrad_fn

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

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

代码语言:javascript复制
print(x.grad, x.grad_fn, x)
# None None tensor([[...]])
print(w1.grad, w1.grad_fn, w1)
# None None tensor([[...]])

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

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

代码语言:javascript复制
for epoch in range(epochs):
    for batch in range(no_of_batches):
        start = batch * batches
        end = start   batches
        x_ = x[start:end]
        y_ = y[start:end]

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

        output.backward()

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

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

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

构建图

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jitzPi8h-1681786181074)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_06.jpg)]

图 2.6:网络架构

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

代码语言:javascript复制
print(a2.grad, a2.grad_fn, a2)
# None <AddBackward0 object at 0x7f5f3b9253c8> tensor([[...]])
注意

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

查找误差

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

反向传播

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-It19pKrL-1681786181074)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_07.jpg)]

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

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

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

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

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

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

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

代码语言:javascript复制
print(x.grad, x.grad_fn, x)
# None None tensor([[...]])
print(w1.grad, w1.grad_fn, w1)
# tensor([[...]], None, tensor([[...]]
print(a2.grad, a2.grad_fn, a2)
# None <AddBackward0 object at 0x7f5f3d42c780> tensor([[...]])

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

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

参数更新

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

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

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

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

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

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

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

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

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

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

代码语言:javascript复制
# traversing the graph using .grad_fn
print(output.grad_fn)
# <DivBackward0 object at 0x7eff00ae3ef0>
print(output.grad_fn.next_functions[0][0])
# <SumBackward0 object at 0x7eff017b4128>
print(output.grad_fn.next_functions[0][0].next_functions[0][0])
# <PowBackward0 object at 0x7eff017b4128>

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3WTOiDAV-1681786181075)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_08.jpg)]

图 2.8:链接张量和函数

PyTorch 方式

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

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

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

高级 API

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

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

代码语言:javascript复制
net = FizBuzNet(input_size, hidden_size, output_size)

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

代码语言:javascript复制
class FizBuzNet(nn.Module):
    """
    2 layer network for predicting fiz or buz
    param: input_size -> int
    param: output_size -> int
    """

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

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

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

nn.Module

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iadDxqR2-1681786181075)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_09.jpg)]

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

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

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

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

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

代码语言:javascript复制
self.hidden = nn.Linear(input_size, hidden_size)
self.out = nn.Linear(hidden_size, output_size)

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

代码语言:javascript复制
class Linear(torch.nn.Module):
    def __init__(self, in_features, out_features, bias):
        super(Linear, self).__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.weight = torch.nn.Parameter(torch.Tensor(out_features, in_features))
        self.bias = torch.nn.Parameter(torch.Tensor(out_features))

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

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

代码语言:javascript复制
a2 = x_.matmul(w1)
a2 = a2.add(b1)

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

apply()

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

cuda()cpu()

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

代码语言:javascript复制
net = FizBuzNet(input_size, hidden_size, output_size)
net.cpu()     # convert all parameters to CPU tensors
net.cuda()    # convert all parameters to GPU tensors

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

代码语言:javascript复制
if torch.cuda.is_available():
    xtype = torch.cuda.FloatTensor
    ytype = torch.cuda.LongTensor
else:
    xtype = torch.FloatTensor
    ytype = torch.LongTensor
x = torch.from_numpy(trX).type(xtype)
y = torch.from_numpy(trY).type(ytype)

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

train()eval()

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

parameters()

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

代码语言:javascript复制
net = FizBuzNet(input_size, hidden_size, output_size)

#building graph
# backpropagation
# zeroing the gradients

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

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

zero_grad()

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

其他层

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

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

代码语言:javascript复制
import torch.nn as nn

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

functional模块

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

代码语言:javascript复制
>>> a = torch.randn(1,2)
>>> b = torch.randn(2,1,requires_grad=True)
>>> a.requires_grad
False
>>> b.requires_grad
True
>>> c = a @ b
>>> c.grad_fn
<MmBackward at 0x7f1cd5222c88>

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

代码语言:javascript复制
>>> import torch
>>> import torch.nn.functional as F
>>> a = torch.Tensor([[1,1]])
>>> w1 = torch.Tensor([[2,2]])
>>> F.linear(a,w1) == a.matmul(w1.t())
tensor([[1]], dtype=torch.uint8)

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

注意

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-k6G5zOlE-1681786181075)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_02_10.jpg)]

图 2.10:Sigmoid 激活

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

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

损失函数

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

代码语言:javascript复制
loss = nn.MSELoss()
output = loss(hyp, y_)
output.backward()

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

代码语言:javascript复制
error = hyp - y_
output = error.pow(2).sum() / 2.0

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

优化器

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

代码语言:javascript复制
# updating weight
with torch.no_grad():
    w1 -= lr * w1.grad
    w2 -= lr * w2.grad
    b1 -= lr * b1.grad
    b2 -= lr * b2.grad

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

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

代码语言:javascript复制
w1.grad.zero_()
w2.grad.zero_()
b1.grad.zero_()
b2.grad.zero_()

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

代码语言:javascript复制
optimizer = optim.SGD(net.parameters(), lr=lr)

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

代码语言:javascript复制
for epoch in range(epochs):
    for batch in range(no_of_batches):
        start = batch * batches
        end = start   batches
        x_ = x[start:end]
        y_ = y[start:end]
        hyp = net(x_)
        loss = loss_fn(hyp, y_)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

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

总结

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

参考

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

三、深度学习工作流程

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kfl5Pjij-1681786181075)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_03_01.jpg)]

图 3.1:深度学习工作流程

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

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

构思和计划

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

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

设计与实验

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

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

数据集和DataLoader

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

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

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

代码语言:javascript复制
from dataclasses import dataclass
from torch.utils.data import Dataset, DataLoader

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

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

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

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

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

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

注意

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

代码语言:javascript复制
dataset = FizBuzDataset()
for i in range(len(dataset)):
    x, y = dataset[i]

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

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

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

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

工具包

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

torchvision
代码语言:javascript复制
pip install torchvision

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

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

数据集

描述

MNIST

70,000 28 x 28手写数字的数据集。

KMNIST

平假名字符的排列方式与普通 MNIST 相同。

时尚 MNIST

类似于 MNIST 的数据集,包含 70,000 张28 x 28张标记的时尚图片。

EMNIST

该数据集是一组28 x 28个手写字符数字。

COCO

大规模对象检测,分割和字幕数据集。

LSUN

类似于 COCO 的大规模“场景理解挑战”数据集。

Imagenet-12

2012 年大规模视觉识别挑战赛的 1400 万张图像的数据集。

CIFAR

以 10/100 类标记的 60,000 张32 x 32彩色图像的数据集。

STL10

另一个受 CIFAR 启发的图像数据集。

SVHN

街景门牌号码的数据集,类似于 MNIST。

PhotoTour

华盛顿大学提供的旅游景点数据集。

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

代码语言:javascript复制
>>> mnist = v.datasets.MNIST('.', download=True)
Downloading …
Processing…
Done!

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

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

代码语言:javascript复制
>>> mnist[1]
(<PIL.Image.Image image mode=L size=28×28 at 0x7F61AE0EA518>, tensor(0))
>>> len(mnist)
60000

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

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

代码语言:javascript复制
>>> images = torchvision.datasets.ImageFolder('/path/to/image/folder')
>>> images [0]
(<PIL.Image.Image image mode=RGB size=1198×424 at 0x7F61715D6438>, 0)

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

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

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

代码语言:javascript复制
>>> resnet18 = torchvision.models.resnet18(pretrained=False)
>>> resnet18 = torchvision.models.resnet18(pretrained=True)
>>> for param in resnet18.layer1.parameters():
 param.requires_grad = False

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

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

代码语言:javascript复制
transform = transforms.Compose(
    [
        transforms.ToTensor(),
        transforms.Normalize(mean, std),
    ]
)

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

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

代码语言:javascript复制
def __call__(self, img):
    for t in self.transforms:
        img = t(img)
    return img

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

torchtext
代码语言:javascript复制
pip install torchtext

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

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

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

文本

标签

How do you measure earthquakes?(您如何测量地震?)

DESC

Who is Duke Ellington?(埃灵顿公爵是谁?)

HUM

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

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

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

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

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

代码语言:javascript复制
spacy_en = spacy.load('en')

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

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

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

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

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

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

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

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

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

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

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

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

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

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

print(next(iter(test_iter)))

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

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

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

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

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

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

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

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

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

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

数据集

任务

BaBi

问题回答

SST

情感分析

IMDB

情感分析

TREC

问题分类

SNLI

蕴涵

MultiNLI

蕴涵

WikiText2

语言建模

WikiText103

语言建模

PennTreebank

语言建模

WMT14

机器翻译

IWSLT

机器翻译

Multi30k

机器翻译

UDPOS

序列标记

CoNLL2000Chunking

序列标记

torchaudio

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

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

代码语言:javascript复制
python setup.py install

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

代码语言:javascript复制
>>> data, sample_rate = torchaudio.load('foo.mp3')
>>> print(data.size())
torch.Size([278756, 2])
>>> print(sample_rate)
44100
>>> torchaudio.save('foo.wav', data, sample_rate)

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

代码语言:javascript复制
yesno_data = torchaudio.datasets.YESNO('.', download=True)
data_loader = torch.utils.data.DataLoader(yesno_data)

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

代码语言:javascript复制
transform = transforms.Compose(
    [
        transforms.Scale(),
        transforms.PadTrim(max_len=16000)
    ]
)

模型实现

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

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

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

瓶颈和性能分析

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

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

代码语言:javascript复制
with torch.autograd.profiler.profile() as prof:
    hyp = net(x_)

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bt0knZj9-1681786181076)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_03_02.jpg)]

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8ZCbDFBx-1681786181076)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_03_03.jpg)]

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jdW1G2jW-1681786181076)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_03_04.jpg)]

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

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

代码语言:javascript复制
python -m torch.utils.bottleneck /path/to/source/script.py [args]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6ANazbeF-1681786181076)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_03_05.jpg)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HPVL6bFT-1681786181076)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_03_06.jpg)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wxnZmetV-1681786181077)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_03_07.jpg)]

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

训练和验证

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

Ignite

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

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

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

代码语言:javascript复制
for epoch in range(epochs):
    for x_batch, y_batch in dataset:
        optimizer.zero_grad()
        hyp = net(x_batch)
        loss = loss_fn(hyp, y_batch)
        loss.backward()
        optimizer.step()

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

代码语言:javascript复制
def training_loop(trainer, batch)
    x_batch, y_batch = process_batch(batch)
    optimizer.zero_grad()
    hyp = net(x_batch)
    loss = loss_fn(hyp, y_batch)
    loss.backward()
    optimizer.step()

trainer = Engine(training_loop)

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

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

代码语言:javascript复制
from ignite.engine import create_supervised_trainer, create_supervised_evaluator

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

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

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

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

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

事件

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

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

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

代码语言:javascript复制
@trainer.on(Events.ITERATION_COMPLETED)
def log_training_loss(engine):
    epoch = engine.state.epoch
    iteration = engine.state.iteration
    loss = engine.state.output
    print("Epoch:{epoch} Iteration:{iteration} Loss: {loss}")

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

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

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

注意

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

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

代码语言:javascript复制
def _fire_event(self, event_name, *event_args):
    if event_name in self._event_handlers.keys():
        self._logger.debug("firing handlers for event %s", event_name)
        for func, args, kwargs in self._event_handlers[event_name]:
            func(self, *(event_args   args), **kwargs)

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

指标

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

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

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

代码语言:javascript复制
metrics = {'accuracy': CategoricalAccuracy(), 'null': Loss(F.null_loss)}
evaluator = create_supervised_evaluator(model, metrics=metrics)

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

代码语言:javascript复制
def attach(self, engine, name):
    engine.add_event_handler(Events.EPOCH_STARTED, self.started)
    engine.add_event_handler(Events.ITERATION_COMPLETED, self.iteration_completed)
    engine.add_event_handler(Events.EPOCH_COMPLETED, self.completed, name)

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

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

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

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

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

保存检查点

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

代码语言:javascript复制
from ignite.handlers import EngineCheckpoint

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

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

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

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

代码语言:javascript复制
if engine.state.iteration % self.save_interval !=0:
    save_checkpoint()

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

总结

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

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

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

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

参考

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

四、计算机视觉

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

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

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

CNN 简介

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

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

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8mVCTt6t-1681786181077)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09075_04_01.jpg)]

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

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

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

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-POynkVzQ-1681786181077)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09075_04_02.jpg)]

图 4.2:一个 CNN

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

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

使用 PyTorch 的计算机视觉

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

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

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

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

简单 CNN

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

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

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

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

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

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

代码语言:javascript复制
def normalize(image, mean, std):
    for channel in range(3):
        image[channel] = (image[channel] - mean[channel]) / std[channel]

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

模型

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

代码语言:javascript复制
class SimpleCNNModel(nn.Module):
    """ A basic CNN model implemented with the the basic building blocks """

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

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

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

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

代码语言:javascript复制
class Conv(nn.Module):
    """
    Custom conv layer
    Assumes the image is squre
    """

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-toE7TS7s-1681786181077)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_04_03.jpg)]

图 4.3a

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Am6D4zBG-1681786181077)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_04_04.jpg)]

图 4.3b

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

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

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

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

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

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

代码语言:javascript复制
>>> F.pad(torch.zeros(1,1), (2,) * 4)
Variable containing:
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
[torch.FloatTensor of size (5,5)]

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J3RNF8P0-1681786181078)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_04_05.jpg)]

图 4.4:左步幅为 1

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

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

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

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

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

代码语言:javascript复制
out = torch.zeros(batch_size, depth, new_height, new_width)
for h in range(new_height):
    for w in range(new_width):
        for d in range(depth):
            val = x[:, d, h:h   self.kernel_size, w:w   self.kernel_size]
            out[:, d, h, w] = val.max(2)[0].max(1)[0]

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

代码语言:javascript复制
>>> tensor
1 2
3 4
[torch.FloatTensor of size 2x2]
>>> tensor.max(0)[0]
3
4
[torch.FloatTensor of size 2]
>>> tensor.max(0)[1]
1
1
[torch.LongTensor of size 2]

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

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

代码语言:javascript复制
>>> simple = SimpleCNNModel()
>>> simple
SimpleCNNModel((conv1): Conv()(pool): MaxPool()(conv2): Conv()
 (fc1): Linear(in_features=400, out_features=120, bias=True)
 (fc2): Linear(in_features=120, out_features=84, bias=True)
 (fc3): Linear(in_features=84, out_features=10, bias=True)
)

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

代码语言:javascript复制
net = SimpleCNNModel()
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)
trainloader, testloader = get_data()

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

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

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

注意

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-smHv50Lb-1681786181078)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_04_06.jpg)]

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

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

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

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

代码语言:javascript复制
inputs, labels = data
optimizer.zero_grad()
outputs = net(inputs)
loss = loss_fn(outputs, labels)
loss.backward()
optimizer.step()
running_loss  = loss.item()

语义分割

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FVSKSSzJ-1681786181078)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_04_08.jpg)]

图 4.6:语义分割示例

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

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

LinkNet

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xF7diLmK-1681786181078)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_04_10.jpg)]

图 4.7:自编码器

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X7ZsOOHW-1681786181078)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_04_11.jpg)]

图 4.8:LinkNet 架构

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

反卷积

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

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

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

注意

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-38mlOYbN-1681786181079)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_04_12.jpg)]

图 4.9:反卷积工作

跳跃连接

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

模型

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

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

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

卷积块
代码语言:javascript复制
class ConvBlock(nn.Module):
    """ LinkNet uses initial block with conv -> batchnorm -> relu """

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

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

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

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

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

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

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

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

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

代码语言:javascript复制
class ConvBlockWithoutSequential(nn.Module):
    """ LinkNet uses initial block with conv -> batchnorm -> relu """

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

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

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

代码语言:javascript复制
class DeconvBlock(nn.Module):
    """ LinkNet uses Deconv block with transposeconv -> batchnorm -> relu """

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

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

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

池化

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

编码器块

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y1ooQOfA-1681786181079)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/missing.jpg)]

图 4.10:编码器图

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

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

代码语言:javascript复制
class EncoderBlock(nn.Module):
    """ Residucal Block in linknet that does Encoding - layers in ResNet18 """

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GcuRkmm3-1681786181079)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_04_14.jpg)]

图 4.11:LinkNet 的解码器图片

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

代码语言:javascript复制
class DecoderBlock(nn.Module):
    """ Residucal Block in linknet that does Encoding """

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

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

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

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

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

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

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

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

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

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

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

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

        return final_deconv2

总结

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

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

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

参考

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

五、序列数据处理

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

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

循环神经网络简介

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

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

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

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

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

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

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

问题

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UolKkGqW-1681786181079)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_01.jpg)]

图 5.1:问题的图示

方法

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

简单 RNN

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

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

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

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

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

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

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

词嵌入

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

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

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

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

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

代码语言:javascript复制
>>> vocab_size = 100
>>> embedding_dim = 300
>>> embed = nn.Embedding(vocab_size, embedding_dim)
>>> input_tensor = torch.LongTensor([5])
>>> embed(input_tensor).size()
torch.Size([1, 300])

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

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

代码语言:javascript复制
inputs = data.Field(lower=True)
answers = data.Field(sequential=False)
train, dev, test = datasets.SNLI.splits(inputs, answers)
inputs.build_vocab(train, dev, test)
inputs.vocab.load_vectors('glove.6B.300d')

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

RNNCell

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

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ubihGJ7H-1681786181079)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_02.jpg)]

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JoJQDQKO-1681786181080)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_03.jpg)]

图 5.3:RNN 单元流程图

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

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

代码语言:javascript复制
class RNNCell(nn.Module):
    def __init__(self, embed_dim, hidden_size, vocab_dim):
        super().__init__()

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

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

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

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

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

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

工具

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

注意

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

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

填充序列

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

代码语言:javascript复制
>>> import torch.nn.utils.rnn as rnn_utils
>>> a = torch.Tensor([1, 2, 3])
>>> b = torch.Tensor([4, 5])
>>> c = torch.Tensor([6])
>>> rnn_utils.pad_sequence([a, b, c], True)

1 2 3
4 5 0
6 0 0

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

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

打包序列

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pFcSjGsc-1681786181080)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_04.jpg)]

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

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

代码语言:javascript复制
>>> import torch.nn.utils.rnn as rnn_utils
>>> import torch
>>> a = torch.Tensor([1, 2, 3])
>>> b = torch.Tensor([1, 2])
>>> c = torch.Tensor([1])
>>> packed = rnn_utils.pack_sequence([a, b, c])
>>> packed
PackedSequence(data=tensor([1., 1., 1., 2., 2., 3.]), batch_sizes=tensor([3, 2, 1]))

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

编码器
代码语言:javascript复制
class Encoder(nn.Module):

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

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

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

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

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

分类器

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

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

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

代码语言:javascript复制
class Merger(nn.Module):

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

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

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

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

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

代码语言:javascript复制
class RNNClassifier(nn.Module):

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

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

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

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

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

丢弃

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6jgNyG60-1681786181080)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_05.jpg)]

图 5.5:丢弃

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

训练

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

代码语言:javascript复制
model = RNNClassifier(config)
model.embed.weight.data = inputs.vocab.vectors
criterion = nn.CrossEntropyLoss()
opt = optim.Adam(model.parameters(), lr=lr)

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

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

高级 RNN

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

LSTM

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pWdfv5CO-1681786181080)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_06.jpg)]

图 5.6:LSTM 单元

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

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

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

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

GRU

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CJKlderF-1681786181080)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_07.jpg)]

图 5.7:一个 GRU 单元

架构

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

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

代码语言:javascript复制
class Encoder(nn.Module):

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

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

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

LSTM 和 GRU

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

代码语言:javascript复制
class Encoder(nn.Module):

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

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

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

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

增加层数

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F6KXLfPC-1681786181081)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_08.jpg)]

图 5.8:多层 RNN

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

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

双向 RNN

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kUvwcWo7-1681786181081)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_09.jpg)]

图 5.9:双向 RNN

分类器

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

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

注意

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

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

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

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

循环神经网络

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YYSpi8AT-1681786181081)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/pt-dl-handson/img/B09475_05_11.jpg)]

图 5.10:Shift-Reduce 解析器

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

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

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

简化

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

代码语言:javascript复制
class Reduce(nn.Module):

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

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

追踪器

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

代码语言:javascript复制
class Tracker(nn.Module):

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

    def reset_state(self):
        self.state = None

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

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

代码语言:javascript复制
class SPINN(nn.Module):

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

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

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

总结

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

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

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

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

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

0 人点赞