PyTorch-深度学习-GPT-重译--全-

PyTorch 深度学习(GPT 重译)(全)

第一部分:PyTorch 核心

欢迎来到本书的第一部分。在这里,我们将与 PyTorch 迈出第一步,获得理解其结构和解决 PyTorch 项目机制所需的基本技能。

在第一章中,我们将首次接触 PyTorch,了解它是什么,解决了什么问题,以及它与其他深度学习框架的关系。第二章将带领我们进行一次旅行,让我们有机会玩玩已经在有趣任务上预训练的模型。第三章会更加严肃,教授 PyTorch 程序中使用的基本数据结构:张量。第四章将带领我们再次进行一次旅行,这次是跨越不同领域的数据如何表示为 PyTorch 张量。第五章揭示了程序如何从示例中学习以及 PyTorch 如何支持这一过程。第六章介绍了神经网络的基础知识以及如何使用 PyTorch 构建神经网络。第七章通过一个神经网络架构解决了一个简单的图像分类问题。最后,第八章展示了如何使用卷积神经网络以更智能的方式解决同样的问题。

到第 1 部分结束时,我们将具备在第 2 部分中使用 PyTorch 解决真实世界问题所需的基本技能。

一、介绍深度学习和 PyTorch 库

本章涵盖

  • 深度学习如何改变我们对机器学习的方法

  • 了解为什么 PyTorch 非常适合深度学习

  • 检查典型的深度学习项目

  • 您需要的硬件来跟随示例

术语人工智能的定义模糊,涵盖了一系列经历了大量研究、审查、混乱、夸张和科幻恐慌的学科。现实当然要乐观得多。断言今天的机器在任何人类意义上都在“思考”是不诚实的。相反,我们发现了一类能够非常有效地逼近复杂非线性过程的算法,我们可以利用这些算法来自动化以前仅限于人类的任务。

例如,在inferkit.com/,一个名为 GPT-2 的语言模型可以逐字生成连贯的段落文本。当我们将这段文字输入时,它生成了以下内容:

接下来,我们将输入一组来自电子邮件地址语料库的短语列表,并查看程序是否能将列表解析为句子。再次强调,这比本文开头的搜索要复杂得多,也更加复杂,但希望能帮助您了解在各种编程语言中构建句子结构的基础知识。

对于一台机器来说,这是非常连贯的,即使在这些胡言乱语背后没有一个明确定义的论点。

更令人印象深刻的是,执行这些以前仅限于人类的任务的能力是通过示例获得的,而不是由人类编码为一组手工制作的规则。在某种程度上,我们正在学习智能是一个我们经常与自我意识混淆的概念,而自我意识绝对不是成功执行这类任务所必需的。最终,计算机智能的问题甚至可能并不重要。Edsger W. Dijkstra 发现,机器是否能够思考的问题“与潜艇是否能游泳的问题一样相关”。¹

我们谈论的那类算法属于深度学习的人工智能子类,它通过提供示例来训练名为深度神经网络的数学实体。深度学习使用大量数据来逼近输入和输出相距甚远的复杂函数,比如输入图像和输出的一行描述输入的文本;或者以书面脚本为输入,以自然语音朗读脚本为输出;甚至更简单的是将金毛寻回犬的图像与告诉我们“是的,金毛寻回犬在场”的标志相关联。这种能力使我们能够创建具有直到最近为止仅属于人类领域的功能的程序。

1.1 深度学习革命

要欣赏这种深度学习方法带来的范式转变,让我们退后一步,换个角度看一下。直到最近的十年,广义上属于机器学习范畴的系统在很大程度上依赖特征工程。特征是对输入数据的转换,有助于下游算法(如分类器)在新数据上产生正确的结果。特征工程包括想出正确的转换,以便下游算法能够解决任务。例如,为了在手写数字图像中区分 1 和 0,我们会想出一组滤波器来估计图像上边缘的方向,然后训练一个分类器来预测给定边缘方向分布的正确数字。另一个有用的特征可能是封闭孔的数量,如 0、8 和尤其是环绕的 2。

另一方面,深度学习处理的是自动从原始数据中找到这样的表示,以便成功执行任务。在二进制示例中,通过在训练过程中迭代地查看示例和目标标签对来逐步改进滤波器。这并不是说特征工程在深度学习中没有地位;我们经常需要在学习系统中注入某种形式的先验知识。然而,神经网络摄取数据并根据示例提取有用表示的能力是使深度学习如此强大的原因。深度学习从业者的重点不是手工制作这些表示,而是操作数学实体,使其自主地从训练数据中发现表示。通常,这些自动生成的特征比手工制作的特征更好!与许多颠覆性技术一样,这一事实导致了观念的变化。

在图 1.1 的左侧,我们看到一个从业者忙于定义工程特征并将其馈送给学习算法;在任务上的结果将取决于从业者工程的特征的好坏。在右侧,通过深度学习,原始数据被馈送给一个自动提取分层特征的算法,该算法受其在任务上性能优化的指导;结果将取决于从业者驱动算法朝着目标的能力。

图 1.1 深度学习交换了手工制作特征的需求,增加了数据和计算需求。

从图 1.1 的右侧开始,我们已经可以看到我们需要执行成功的深度学习所需的一瞥:

  • 我们需要一种方法来摄取手头的任何数据。

  • 我们以某种方式需要定义深度学习机器。

  • 我们必须有一种自动化的方式,训练,来获得有用的表示并使机器产生期望的输出。

这使我们不得不更仔细地看看我们一直在谈论的这个训练问题。在训练过程中,我们使用一个标准,这是模型输出和参考数据的实值函数,为我们的模型期望输出与实际输出之间的差异提供一个数值分数(按照惯例,较低的分数通常更好)。训练包括通过逐步修改我们的深度学习机器来将标准驱向更低的分数,直到它在训练过程中未见的数据上也能获得低分数。

1.2 PyTorch 用于深度学习

PyTorch 是一个用于 Python 程序的库,有助于构建深度学习项目。它强调灵活性,并允许用惯用 Python 表达深度学习模型。这种易接近性和易用性在研究界早期的采用者中得到了认可,在其首次发布后的几年里,它已经发展成为广泛应用于各种应用领域的最重要的深度学习工具之一。

就像 Python 用于编程一样,PyTorch 为深度学习提供了一个出色的入门。同时,PyTorch 已被证明完全适用于在实际工作中的专业环境中使用。我们相信 PyTorch 清晰的语法、简化的 API 和易于调试使其成为深入深度学习的绝佳选择。我们强烈推荐学习 PyTorch 作为你的第一个深度学习库。至于它是否应该是你学习的最后一个深度学习库,这是一个由你决定的问题。

在图 1.1 中的深度学习机器的核心是一个将输入映射到输出的相当复杂的数学函数。为了方便表达这个函数,PyTorch 提供了一个核心数据结构,张量,它是一个与 NumPy 数组有许多相似之处的多维数组。在这个基础上,PyTorch 提供了在专用硬件上执行加速数学运算的功能,这使得设计神经网络架构并在单台机器或并行计算资源上训练它们变得方便。

本书旨在成为软件工程师、数据科学家和精通 Python 的有动力的学生开始使用 PyTorch 构建深度学习项目的起点。我们希望这本书尽可能易于访问和有用,并且我们期望您能够将本书中的概念应用到其他领域。为此,我们采用了实践方法,并鼓励您随时准备好计算机,这样您就可以尝试示例并进一步深入研究。到本书结束时,我们期望您能够利用数据源构建出一个深度学习项目,并得到优秀的官方文档支持。

尽管我们强调使用 PyTorch 构建深度学习系统的实际方面,但我们认为提供一个易于理解的基础深度学习工具的介绍不仅仅是为了促进新技术技能的习得。这是向来自各种学科领域的新一代科学家、工程师和从业者提供工作知识的一步,这些知识将成为未来几十年许多软件项目的支柱。

为了充分利用本书,您需要两样东西:

一些在 Python 中编程经验。我们不会在这一点上有任何保留;您需要了解 Python 数据类型、类、浮点数等。

有愿意深入并动手实践的态度。我们将从基础开始建立工作知识,如果您跟着我们一起学习,学习将会更容易。

使用 PyTorch 进行深度学习 分为三个不同的部分。第一部分涵盖了基础知识,详细介绍了 PyTorch 提供的设施,以便用代码将图 1.1 中深度学习的草图付诸实践。第二部分将带您完成一个涉及医学成像的端到端项目:在 CT 扫描中查找和分类肿瘤,建立在第一部分介绍的基本概念基础上,并添加更多高级主题。简短的第三部分以 PyTorch 为主题,介绍了将深度学习模型部署到生产环境中的内容。

深度学习是一个庞大的领域。在本书中,我们将涵盖其中的一小部分:具体来说,使用 PyTorch 进行较小范围的分类和分割项目,其中大部分激励示例使用 2D 和 3D 数据集的图像处理。本书侧重于实用的 PyTorch,旨在涵盖足够的内容,使您能够解决真实世界的机器学习问题,例如在视觉领域使用深度学习,或者随着研究文献中出现新模型而探索新模型。大多数,如果不是全部,与深度学习研究相关的最新出版物都可以在 arXiV 公共预印本存储库中找到,托管在arxiv.org

1.3 为什么选择 PyTorch?

正如我们所说,深度学习使我们能够通过向我们的模型展示说明性示例来执行非常广泛的复杂任务,如机器翻译、玩策略游戏或在混乱场景中识别物体。为了在实践中做到这一点,我们需要灵活的工具,以便能够适应如此广泛的问题,并且高效,以便允许在合理时间内对大量数据进行训练;我们需要训练好的模型在输入变化时能够正确执行。让我们看看我们决定使用 PyTorch 的一些原因。

PyTorch 之所以易于推荐,是因为它的简单性。许多研究人员和实践者发现它易于学习、使用、扩展和调试。它符合 Python 的风格,虽然像任何复杂的领域一样,它有注意事项和最佳实践,但使用该库通常对之前使用过 Python 的开发人员来说感觉很熟悉。

更具体地说,在 PyTorch 中编程深度学习机器非常自然。PyTorch 给我们提供了一种数据类型,即Tensor,用于保存数字、向量、矩阵或一般数组。此外,它提供了用于操作它们的函数。我们可以像在 Python 中一样逐步编程,并且如果需要,可以交互式地进行,就像我们从 Python 中习惯的那样。如果你了解 NumPy,这将非常熟悉。

但是 PyTorch 提供了两个使其特别适用于深度学习的特点:首先,它利用图形处理单元(GPU)进行加速计算,通常比在 CPU 上进行相同计算速度提高了 50 倍。其次,PyTorch 提供了支持数值优化的功能,用于训练深度学习所使用的通用数学表达式。请注意,这两个特点不仅适用于深度学习,而且适用于科学计算。事实上,我们可以将 PyTorch 安全地描述为一个在 Python 中为科学计算提供优化支持的高性能库。

PyTorch 的设计驱动因素是表达能力,允许开发人员实现复杂模型而不受库施加的复杂性(它不是一个框架!)。可以说 PyTorch 在深度学习领域中最顺畅地将思想转化为 Python 代码之一。因此,PyTorch 在研究中得到了广泛的采用,这可以从国际会议上的高引用计数看出。

PyTorch 在从研究和开发转向生产方面也有引人注目的故事。虽然最初它专注于研究工作流程,但 PyTorch 已经配备了一个高性能的 C++ 运行时,可以用于在不依赖 Python 的情况下部署推断模型,并且可以用于在 C++ 中设计和训练模型。它还增加了对其他语言的绑定和用于部署到移动设备的接口。这些功能使我们能够利用 PyTorch 的灵活性,同时将我们的应用程序带到完全无法获得或会带来昂贵开销的完整 Python 运行时的地方。

当然,声称易用性和高性能是微不足道的。我们希望当你深入阅读本书时,你会同意我们在这里的声明是有充分根据的。

1.3.1 深度学习竞争格局

尽管所有类比都有缺陷,但似乎 PyTorch 0.1 在 2017 年 1 月的发布标志着从深度学习库、包装器和数据交换格式的富集到整合和统一的时代的转变。

注意 深度学习领域最近发展迅速,到您阅读这篇文章时,它可能已经过时。如果您对这里提到的一些库不熟悉,那没关系。

在 PyTorch 首个 beta 版本发布时:

  • Theano 和 TensorFlow 是首屈一指的低级库,使用用户定义计算图然后执行它。

  • Lasagne 和 Keras 是围绕 Theano 的高级封装,Keras 也封装了 TensorFlow 和 CNTK。

  • Caffe、Chainer、DyNet、Torch(PyTorch 的 Lua 前身)、MXNet、CNTK、DL4J 等填补了生态系统中的各种领域。

在接下来的大约两年时间里,情况发生了巨大变化。社区在 PyTorch 或 TensorFlow 之间大多数集中,其他库的采用量减少,除了填补特定领域的库。简而言之:

  • Theano,第一个深度学习框架之一,已经停止了活跃开发。

  • TensorFlow:

    • 完全消化了 Keras,将其提升为一流的 API

    • 提供了一个立即执行的“急切模式”,与 PyTorch 处理计算方式有些相似

    • 发布了默认启用急切模式的 TF 2.0

  • JAX 是 Google 开发的一个独立于 TensorFlow 的库,已经开始获得与 GPU、自动微分和 JIT 功能相当的 NumPy 等价物。

  • PyTorch:

    • 消化了 Caffe2 作为其后端

    • 替换了大部分从基于 Lua 的 Torch 项目中重复使用的低级代码

    • 添加了对 ONNX 的支持,这是一个供应商中立的模型描述和交换格式

    • 添加了一个延迟执行的“图模式”运行时称为TorchScript

    • 发布了 1.0 版本

    • 分别由各自公司的赞助商替换了 CNTK 和 Chainer 作为首选框架

TensorFlow 拥有强大的生产流水线、广泛的行业社区和巨大的知名度。PyTorch 在研究和教学社区中取得了巨大进展,得益于其易用性,并自那时起一直在增长,因为研究人员和毕业生培训学生并转向工业。它在生产解决方案方面也积累了动力。有趣的是,随着 TorchScript 和急切模式的出现,PyTorch 和 TensorFlow 的功能集开始收敛,尽管这些功能的展示和整体体验在两者之间仍然有很大的不同。

1.4 PyTorch 如何支持深度学习项目的概述

我们已经暗示了 PyTorch 中的一些构建模块。现在让我们花点时间来形式化一个构成 PyTorch 的主要组件的高级地图。我们最好通过查看深度学习项目从 PyTorch 中需要什么来做到这一点。

首先,PyTorch 中有“Py”代表 Python,但其中有很多非 Python 代码。实际上,出于性能原因,大部分 PyTorch 是用 C++和 CUDA(www.geforce.com/hardware/technology/cuda)编写的,CUDA 是 NVIDIA 的一种类似 C++的语言,可以编译成在 GPU 上进行大规模并行运行。有方法可以直接从 C++运行 PyTorch,我们将在第十五章中探讨这些方法。这种能力的一个动机是提供一个可靠的部署模型的策略。然而,大部分时间我们会从 Python 中与 PyTorch 交互,构建模型,训练它们,并使用训练好的模型解决实际问题。

实际上,Python API 是 PyTorch 在可用性和与更广泛的 Python 生态系统集成方面的亮点。让我们来看看 PyTorch 是什么样的思维模型。

正如我们已经提到的,PyTorch 的核心是一个提供多维数组或在 PyTorch 术语中称为张量的库(我们将在第三章详细介绍),以及由torch模块提供的广泛的操作库。张量和对它们的操作都可以在 CPU 或 GPU 上使用。在 PyTorch 中将计算从 CPU 移动到 GPU 不需要更多的函数调用。PyTorch 提供的第二个核心功能是张量能够跟踪对它们执行的操作,并分析地计算与计算输出相对于任何输入的导数。这用于数值优化,并且通过 PyTorch 的autograd引擎在底层提供。

通过具有张量和 autograd 启用的张量标准库,PyTorch 可以用于物理、渲染、优化、模拟、建模等领域--我们很可能会在整个科学应用的范围内看到 PyTorch 以创造性的方式使用。但 PyTorch 首先是一个深度学习库,因此它提供了构建神经网络和训练它们所需的所有构建模块。图 1.2 显示了一个标准设置,加载数据,训练模型,然后将该模型部署到生产环境。

用于构建神经网络的 PyTorch 核心模块位于torch.nn中,它提供常见的神经网络层和其他架构组件。全连接层、卷积层、激活函数和损失函数都可以在这里找到(随着我们在本书的后续部分的深入,我们将更详细地介绍这些内容)。这些组件可以用于构建和初始化我们在图 1.2 中看到的未经训练的模型。为了训练我们的模型,我们需要一些额外的东西:训练数据的来源,一个优化器来使模型适应训练数据,以及一种将模型和数据传输到实际执行训练模型所需计算的硬件的方法。

图 1.2 PyTorch 项目的基本高级结构,包括数据加载、训练和部署到生产环境

在图 1.2 的左侧,我们看到在训练数据到达我们的模型之前需要进行相当多的数据处理。首先,我们需要从某种存储中获取数据,最常见的是数据源。然后,我们需要将我们的数据中的每个样本转换为 PyTorch 实际可以处理的东西:张量。我们自定义数据(无论其格式是什么)与标准化的 PyTorch 张量之间的桥梁是 PyTorch 在torch.utils.data中提供的Dataset类。由于这个过程在不同问题之间差异很大,我们将不得不自己实现这个数据获取过程。我们将详细讨论如何将我们想要处理的各种类型的数据表示为张量在第四章。

由于数据存储通常较慢,特别是由于访问延迟,我们希望并行化数据加载。但由于 Python 受欢迎的许多功能并不包括简单、高效的并行处理,我们需要多个进程来加载数据,以便将它们组装成批次:包含多个样本的张量。这相当复杂;但由于它也相对通用,PyTorch 在DataLoader类中轻松提供了所有这些魔法。它的实例可以生成子进程,后台加载数据集中的数据,以便在训练循环可以使用时,数据已准备就绪。我们将在第七章中遇到并使用DatasetDataLoader

有了获取样本批次的机制,我们可以转向图 1.2 中心的训练循环本身。通常,训练循环被实现为标准的 Python for 循环。在最简单的情况下,模型在本地 CPU 或单个 GPU 上运行所需的计算,一旦训练循环有了数据,计算就可以立即开始。很可能这也是您的基本设置,这也是我们在本书中假设的设置。

在训练循环的每一步中,我们使用从数据加载器中获取的样本评估我们的模型。然后,我们使用一些标准损失函数将我们模型的输出与期望输出(目标)进行比较。正如它提供了构建模型的组件一样,PyTorch 还提供了各种损失函数供我们使用。它们也是在torch.nn中提供的。在我们用损失函数比较了实际输出和理想输出之后,我们需要稍微推动模型,使其输出更好地类似于目标。正如前面提到的,这就是 PyTorch 自动求导引擎的作用所在;但我们还需要一个优化器来进行更新,这就是 PyTorch 在torch.optim中为我们提供的。我们将在第五章开始研究带有损失函数和优化器的训练循环,然后在第 6 至 8 章中磨练我们的技能,然后开始我们的大型项目。

越来越普遍的是使用更复杂的硬件,如多个 GPU 或多台机器共同为训练大型模型提供资源,如图 1.2 底部中心所示。在这些情况下,可以使用torch.nn.parallel.Distributed-DataParalleltorch.distributed子模块来利用额外的硬件。

训练循环可能是深度学习项目中最不令人兴奋但最耗时的部分。在此之后,我们将获得一个在我们的任务上经过优化的模型参数:图中训练循环右侧所示的训练模型。拥有一个能解决问题的模型很棒,但为了让它有用,我们必须将其放在需要工作的地方。这个过程的部署部分在图 1.2 右侧描述,可能涉及将模型放在服务器上或将其导出以加载到云引擎中,如图所示。或者我们可以将其集成到更大的应用程序中,或在手机上运行。

部署练习的一个特定步骤可以是导出模型。如前所述,PyTorch 默认为即时执行模式(急切模式)。每当涉及 PyTorch 的指令被 Python 解释器执行时,相应的操作立即由底层 C++或 CUDA 实现执行。随着更多指令操作张量,更多操作由后端实现执行。

PyTorch 还提供了一种通过TorchScript提前编译模型的方法。使用 TorchScript,PyTorch 可以将模型序列化为一组指令,可以独立于 Python 调用:比如,从 C++程序或移动设备上。我们可以将其视为具有有限指令集的虚拟机,特定于张量操作。这使我们能够导出我们的模型,无论是作为可与 PyTorch 运行时一起使用的 TorchScript,还是作为一种称为ONNX的标准化格式。这些功能是 PyTorch 生产部署能力的基础。我们将在第十五章中介绍这一点。

1.5 硬件和软件要求

本书将需要编写和运行涉及大量数值计算的任务,例如大量矩阵相乘。事实证明,在新数据上运行预训练网络在任何最近的笔记本电脑或个人电脑上都是可以的。甚至拿一个预训练网络并重新训练其中的一小部分以使其在新数据集上专门化并不一定需要专门的硬件。您可以使用标准个人电脑或笔记本电脑跟随本书第 1 部分的所有操作。

然而,我们预计完成第 2 部分中更高级示例的完整训练运行将需要一个支持 CUDA 的 GPU。第 2 部分中使用的默认参数假定具有 8 GB RAM 的 GPU(我们建议使用 NVIDIA GTX 1070 或更高版本),但如果您的硬件可用 RAM 较少,则可以进行调整。明确一点:如果您愿意等待,这样的硬件并非强制要求,但在 GPU 上运行可以将训练时间缩短至少一个数量级(通常快 40-50 倍)。单独看,计算参数更新所需的操作速度很快(从几分之一秒到几秒)在现代硬件上,如典型笔记本电脑 CPU。问题在于训练涉及一遍又一遍地运行这些操作,逐渐更新网络参数以最小化训练误差。

中等规模的网络在配备良好 GPU 的工作站上从头开始训练大型真实世界数据集可能需要几小时到几天的时间。通过在同一台机器上使用多个 GPU,甚至在配备多个 GPU 的机器集群上进一步减少时间。由于云计算提供商的提供,这些设置比听起来的要容易访问。DAWNBench(dawn.cs.stanford.edu/benchmark/index.html)是斯坦福大学的一个有趣的倡议,旨在提供关于在公开可用数据集上进行常见深度学习任务的训练时间和云计算成本的基准。

因此,如果在您到达第 2 部分时有 GPU 可用,那太好了。否则,我们建议查看各种云平台的提供,其中许多提供预装 PyTorch 的支持 GPU 的 Jupyter 笔记本,通常还有免费配额。Google Colaboratory(colab.research.google.com)是一个很好的起点。

最后考虑的是操作系统(OS)。PyTorch 从首次发布开始就支持 Linux 和 macOS,并于 2018 年获得了 Windows 支持。由于当前的苹果笔记本不包含支持 CUDA 的 GPU,PyTorch 的预编译 macOS 包仅支持 CPU。在本书中,我们会尽量避免假设您正在运行特定的操作系统,尽管第 2 部分中的一些脚本显示为在 Linux 下的 Bash 提示符下运行。这些脚本的命令行应该很容易转换为兼容 Windows 的形式。为了方便起见,尽可能地,代码将被列为从 Jupyter Notebook 运行时的形式。

有关安装信息,请参阅官方 PyTorch 网站上的入门指南(pytorch.org/get-started/locally)。我们建议 Windows 用户使用 Anaconda 或 Miniconda 进行安装(www.anaconda.com/distributiondocs.conda.io/en/latest/miniconda.html)。像 Linux 这样的其他操作系统通常有更多可行的选项,Pip 是 Python 最常见的包管理器。我们提供一个 requirements.txt 文件,pip 可以使用它来安装依赖项。当然,有经验的用户可以自由选择最符合您首选开发环境的方式来安装软件包。

第 2 部分还有一些不容忽视的下载带宽和磁盘空间要求。第 2 部分癌症检测项目所需的原始数据约为 60 GB,解压后需要约 120 GB 的空间。解压缩后的数据可以在解压缩后删除。此外,由于为了性能原因缓存了一些数据,训练时还需要另外 80 GB。您需要在用于训练的系统上至少有 200 GB 的空闲磁盘空间。虽然可以使用网络存储进行此操作,但如果网络访问速度慢于本地磁盘,则可能会导致训练速度下降。最好在本地 SSD 上有空间存储数据以便快速检索。

1.5.1 使用 Jupyter 笔记本

我们假设您已经安装了 PyTorch 和其他依赖项,并已验证一切正常。之前我们提到了在书中跟随代码的可能性。我们将大量使用 Jupyter 笔记本来展示我们的示例代码。Jupyter 笔记本显示为浏览器中的页面,通过它我们可以交互式地运行代码。代码由一个内核评估,这是在服务器上运行的进程,准备接收要执行的代码并发送结果,然后在页面上内联呈现。笔记本保持内核的状态,例如在评估代码期间定义的变量,直到终止或重新启动。我们与笔记本交互的基本单元是单元格:页面上的一个框,我们可以在其中输入代码并让内核评估它(通过菜单项或按 Shift-Enter)。我们可以在笔记本中添加多个单元格,新单元格将看到我们在早期单元格中创建的变量。单元格的最后一行返回的值将在执行后直接在单元格下方打印出来,绘图也是如此。通过混合源代码、评估结果和 Markdown 格式的文本单元格,我们可以生成漂亮的交互式文档。您可以在项目网站上阅读有关 Jupyter 笔记本的所有内容(jupyter.org)。

此时,您需要从 GitHub 代码检出的根目录启动笔记本服务器。启动服务器的确切方式取决于您的操作系统的细节以及您安装 Jupyter 的方式和位置。如果您有问题,请随时在书的论坛上提问。⁵ 一旦启动,您的默认浏览器将弹出,显示本地笔记本文件列表。

注意 Jupyter Notebooks 是通过代码表达和探索想法的强大工具。虽然我们认为它们非常适合本书的用例,但并非人人都适用。我们认为专注于消除摩擦和最小化认知负担很重要,对每个人来说都会有所不同。在使用 PyTorch 进行实验时,请使用您喜欢的工具。

书中所有示例的完整工作代码可以在书的网站(www.manning.com/books/deep-learning-with-pytorch)和我们在 GitHub 上的存储库中找到(github.com/deep-learning-with-pytorch/dlwpt-code)。

1.6 练习

  1. 启动 Python 以获得交互式提示符。

    1. 您正在使用哪个 Python 版本?我们希望至少是 3.6!

    2. 您能够import torch吗?您得到了哪个 PyTorch 版本?

    3. torch.cuda.is_available()的结果是什么?它是否符合您基于所使用硬件的期望?

  2. 启动 Jupyter 笔记本服务器。

    1. Jupyter 使用的 Python 版本是多少?

    2. Jupyter 使用的torch库的位置与您从交互式提示符导入的位置相同吗?

1.7 总结

  • 深度学习模型会自动从示例中学习将输入和期望输出关联起来。

  • 像 PyTorch 这样的库允许您高效地构建和训练神经网络模型。

  • PyTorch 专注于灵活性和速度,同时最大限度地减少认知负担。它还默认立即执行操作。

  • TorchScript 允许我们预编译模型,并不仅可以从 Python 中调用它们,还可以从 C++程序和移动设备中调用。

  • 自 2017 年初发布 PyTorch 以来,深度学习工具生态系统已经显著巩固。

  • PyTorch 提供了许多实用库,以便促进深度学习项目。


¹Edsger W. Dijkstra,“计算科学的威胁”,mng.bz/nPJ5

² 我们还推荐www.arxiv-sanity.com来帮助组织感兴趣的研究论文。

³ 在 2019 年的国际学习表示会议(ICLR)上,PyTorch 在 252 篇论文中被引用,比前一年的 87 篇增加了很多,并且与 TensorFlow 的水平相同,后者在 266 篇论文中被引用。

⁴ 这只是在运行时进行的数据准备,而不是预处理,后者在实际项目中可能占据相当大的部分。

forums.manning.com/forums/deep-learning-with-pytorch

二、预训练网络

本章内容包括

  • 运行预训练图像识别模型

  • GANs 和 CycleGAN 简介

  • 能够生成图像文本描述的字幕模型

  • 通过 Torch Hub 分享模型

我们在第一章结束时承诺在这一章中揭示令人惊奇的事物,现在是时候兑现了。计算机视觉无疑是深度学习的出现最受影响的领域之一,原因有很多。存在对自然图像进行分类或解释内容的需求,非常庞大的数据集变得可用,以及发明了新的构造,如卷积层,并且可以在 GPU 上以前所未有的准确性快速运行。所有这些因素与互联网巨头希望理解数百万用户使用移动设备拍摄的图片,并在这些巨头平台上管理的愿望相结合。简直是一场完美的风暴。

我们将学习如何使用该领域最优秀研究人员的工作,通过下载和运行已经在开放的大规模数据集上训练过的非常有趣的模型。我们可以将预训练的神经网络看作类似于一个接受输入并生成输出的程序。这样一个程序的行为由神经网络的架构和训练过程中看到的示例所决定,以期望的输入-输出对或输出应满足的期望属性。使用现成的模型可以快速启动深度学习项目,因为它利用了设计模型的研究人员的专业知识,以及用于训练权重的计算时间。

在本章中,我们将探索三种流行的预训练模型:一种可以根据内容标记图像的模型,另一种可以从真实图像中制作新图像,以及一种可以使用正确的英语句子描述图像内容的模型。我们将学习如何在 PyTorch 中加载和运行这些预训练模型,并介绍 PyTorch Hub,这是一组工具,通过这些工具,像我们将讨论的预训练模型这样的 PyTorch 模型可以通过统一接口轻松提供。在这个过程中,我们将讨论数据来源,定义术语如标签,并参加斑马竞技表演。

如果您是从其他深度学习框架转到 PyTorch,并且宁愿直接学习 PyTorch 的基础知识,您可以跳到下一章。本章涵盖的内容比基础知识更有趣,而且与任何给定的深度学习工具有一定的独立性。这并不是说它们不重要!但是,如果您在其他深度学习框架中使用过预训练模型,那么您已经知道它们可以是多么强大的工具。如果你已经熟悉生成对抗网络(GAN)游戏,那么我们不需要向您解释。

我们希望您继续阅读,因为本章隐藏了一些重要的技能。学习如何使用 PyTorch 运行预训练模型是一项有用的技能--毫无疑问。如果模型经过大型数据集的训练,这将尤其有用。我们需要习惯在真实世界数据上获取和运行神经网络的机制,然后可视化和评估其输出,无论我们是否对其进行了训练。

2.1 识别图像主题的预训练网络

作为我们对深度学习的首次尝试,我们将运行一个在对象识别任务上预训练的最先进的深度神经网络。可以通过源代码存储库访问许多预训练网络。研究人员通常会在其论文中发布源代码,而且通常该代码附带通过在参考数据集上训练模型获得的权重。使用其中一个模型可以使我们例如,可以轻松地为我们的下一个网络服务配备图像识别功能。

我们将在这里探索的预训练网络是在 ImageNet 数据集的一个子集上训练的(imagenet.stanford.edu)。ImageNet 是由斯坦福大学维护的一个非常庞大的数据集,包含超过 1400 万张图像。所有图像都标有来自 WordNet 数据集(wordnet.princeton.edu)的名词层次结构,WordNet 是一个大型的英语词汇数据库。

ImageNet 数据集,像其他几个公共数据集一样,起源于学术竞赛。竞赛一直是研究机构和公司研究人员经常挑战彼此的主要领域之一。自 2010 年创立以来,ImageNet 大规模视觉识别挑战赛(ILSVRC)已经变得越来越受欢迎。这个特定的竞赛基于一些任务,每年可能会有所不同,例如图像分类(告诉图像包含哪些对象类别)、对象定位(识别图像中对象的位置)、对象检测(识别和标记图像中的对象)、场景分类(对图像中的情况进行分类)和场景解析(将图像分割成与语义类别相关的区域,如牛、房子、奶酪、帽子)。特别是,图像分类任务包括获取输入图像并生成 5 个标签列表,来自 1000 个总类别,按置信度排序,描述图像的内容。

ILSVRC 的训练集包含了 120 万张图像,每张图像都标有 1000 个名词中的一个(例如,“狗”),被称为图像的类别。在这个意义上,我们将使用标签类别这两个术语来互换使用。我们可以在图 2.1 中看到来自 ImageNet 的图像。

图 2.1 ImageNet 图像的一个小样本

图 2.2 推理过程

我们最终将能够将我们自己的图像输入到我们的预训练模型中,如图 2.2 所示。这将导致该图像的预测标签列表,然后我们可以检查模型认为我们的图像是什么。有些图像的预测是准确的,而其他的则不是!

输入图像将首先被预处理为torch.Tensor类的实例。它是一个具有高度和宽度的 RGB 图像,因此这个张量将具有三个维度:三个颜色通道和特定大小的两个空间图像维度。(我们将在第三章详细介绍张量是什么,但现在,可以将其视为浮点数的向量或矩阵。)我们的模型将获取处理过的输入图像,并将其传递到预训练网络中,以获取每个类别的分数。最高分对应于权重下最可能的类别。然后,每个类别都被一对一地映射到一个类别标签。该输出包含一个具有 1000 个元素的torch.Tensor,每个元素代表与该类别相关的分数。

在我们进行所有这些之前,我们需要获取网络本身,看看它的结构,了解如何准备数据以便模型使用。

2.1.1 获取用于图像识别的预训练网络

正如讨论的那样,我们现在将配备一个在 ImageNet 上训练过的网络。为此,我们将查看 TorchVision 项目(github.com/pytorch/vision),其中包含一些最佳性能的计算机视觉神经网络架构,如 AlexNet(mng.bz/lo6z)、ResNet(arxiv.org/pdf/ 1512.03385.pdf)和 Inception v3(arxiv.org/pdf/1512.00567.pdf)。它还可以轻松访问 ImageNet 等数据集,以及其他用于快速掌握 PyTorch 中计算机视觉应用的实用工具。我们将在本书后面深入研究其中一些。现在,让我们加载并运行两个网络:首先是 AlexNet,这是早期用于图像识别的突破性网络;然后是残差网络,简称 ResNet,它在 2015 年赢得了 ImageNet 分类、检测和定位比赛等多个比赛。如果你在第一章中没有安装 PyTorch,现在是一个很好的时机。

预定义的模型可以在torchvision.models(code/p1ch2/2 _pre_trained_networks.ipynb)中找到:

# In[1]:
from torchvision import models

我们可以看一下实际的模型:

# In[2]:
dir(models)

# Out[2]:
['AlexNet',
 'DenseNet',
 'Inception3',
 'ResNet',
 'SqueezeNet',
 'VGG',
...
 'alexnet',
 'densenet',
 'densenet121',
...
 'resnet',
 'resnet101',
 'resnet152',
...
 ]

大写的名称指的是实现一些流行模型的 Python 类。它们在架构上有所不同--即,在输入和输出之间发生的操作排列方式不同。小写的名称是方便函数,返回从这些类实例化的模型,有时使用不同的参数集。例如,resnet101返回一个具有 101 层的ResNet实例,resnet18有 18 层,依此类推。现在我们将注意力转向 AlexNet。

2.1.2 AlexNet

AlexNet 架构以绝对优势赢得了 2012 年 ILSVRC,其前 5 个测试错误率(即,正确标签必须在前 5 个预测中)为 15.4%。相比之下,第二名提交的模型,不是基于深度网络的,错误率为 26.2%。这是计算机视觉历史上的一个决定性时刻:社区开始意识到深度学习在视觉任务中的潜力。这一飞跃随后不断改进,更现代的架构和训练方法使得前 5 个错误率降至 3%。

从今天的标准来看,与最先进的模型相比,AlexNet 是一个相对较小的网络。但在我们的情况下,它非常适合初次了解一个做某事的神经网络,并学习如何在新图像上运行预训练版本。

我们可以在图 2.3 中看到 AlexNet 的结构。虽然我们现在已经具备了理解它的所有要素,但我们可以预见一些方面。首先,每个块由一堆乘法和加法组成,加上我们将在第五章中发现的输出中的其他函数。我们可以将其视为一个滤波器--一个接受一个或多个图像作为输入并产生其他图像作为输出的函数。它的工作方式是在训练过程中确定的,基于它所看到的示例和所需的输出。

图 2.3 AlexNet 架构

在图 2.3 中,输入图像从左侧进入,并经过五组滤波器,每组产生多个输出图像。在每个滤波器之后,图像会按照注释的方式减小尺寸。最后一组滤波器产生的图像被布置成一个 4,096 元素的一维向量,并进行分类以产生 1,000 个输出概率,每个输出类别一个。

为了在输入图像上运行 AlexNet 架构,我们可以创建一个AlexNet类的实例。操作如下:

# In[3]:
alexnet = models.AlexNet()

此时,alexnet是一个可以运行 AlexNet 架构的对象。目前,我们不需要了解这种架构的细节。暂时来说,AlexNet只是一个不透明的对象,可以像函数一样调用。通过为alexnet提供一些精确大小的输入数据(我们很快将看到这些输入数据应该是什么),我们将通过网络进行前向传递。也就是说,输入将通过第一组神经元,其输出将被馈送到下一组神经元,一直到最终输出。从实际角度来看,假设我们有一个正确类型的input对象,我们可以使用output = alexnet(input)来运行前向传递。

但如果我们这样做,我们将通过整个网络传递数据来产生...垃圾!这是因为网络未初始化:它的权重,即输入相加和相乘的数字,尚未经过任何训练--网络本身是一个空白(或者说是随机)状态。我们需要从头开始训练它,或者加载之前训练的权重,现在我们将这样做。

为此,让我们回到models模块。我们了解到大写名称对应于实现用于计算机视觉的流行架构的类。另一方面,小写名称是函数,用于实例化具有预定义层数和单元数的模型,并可选择下载和加载预训练权重。请注意,使用这些函数并非必要:它们只是方便地实例化具有与预训练网络构建方式相匹配的层数和单元数的模型。

2.1.3 ResNet

使用resnet101函数,我们现在将实例化一个 101 层的卷积神经网络。为了让事情有个对比,2015 年之前,在残差网络出现之前,实现这样深度的稳定训练被认为是极其困难的。残差网络使用了一个技巧,使这成为可能,并通过这样做,在当年一举超过了几个基准。

现在让我们创建网络的一个实例。我们将传递一个参数,指示函数下载在 ImageNet 数据集上训练的resnet101的权重,该数据集包含 1,200,000 张图像和 1,000 个类别:

# In[4]:
resnet = models.resnet101(pretrained=True)

当我们盯着下载进度时,我们可以花一分钟来欣赏resnet101拥有 4450 万个参数--这是一个需要自动优化的大量参数!

2.1.4 准备好了,几乎可以运行了

好的,我们刚刚得到了什么?由于我们很好奇,我们将看一眼resnet101是什么样子。我们可以通过打印返回模型的值来做到这一点。这给了我们一个文本表示形式,提供了与我们在 2.3 中看到的相同类型的关于网络结构的详细信息。目前,这将是信息过载,但随着我们在书中的进展,我们将增加理解这段代码告诉我们的能力:

# In[5]:
resnet

# Out[5]:
ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3),
                  bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True,
                     track_running_stats=True)
  (relu): ReLU(inplace)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1,
                       ceil_mode=False)
  (layer1): Sequential(
    (0): Bottleneck(
...
    )
  )
  (avgpool): AvgPool2d(kernel_size=7, stride=1, padding=0)
  (fc): Linear(in_features=2048, out_features=1000, bias=True)
)

我们在这里看到的是modules,每行一个。请注意,它们与 Python 模块没有任何共同之处:它们是单独的操作,神经网络的构建模块。在其他深度学习框架中,它们也被称为

如果我们向下滚动,我们会看到很多Bottleneck模块一个接一个地重复(共 101 个!),包含卷积和其他模块。这就是典型的用于计算机视觉的深度神经网络的解剖学:一个或多或少顺序级联的滤波器和非线性函数,最终以一个层(fc)产生每个 1,000 个输出类别(out_features)的分数。

resnet变量可以像函数一样调用,输入一个或多个图像,并为每个 1,000 个 ImageNet 类别产生相同数量的分数。然而,在这之前,我们必须对输入图像进行预处理,使其具有正确的大小,并使其值(颜色)大致处于相同的数值范围内。为了做到这一点,torchvision模块提供了transforms,允许我们快速定义基本预处理函数的流水线:

# In[6]:
from torchvision import transforms
preprocess = transforms.Compose([
        transforms.Resize(256),
        transforms.CenterCrop(224),
        transforms.ToTensor(),
        transforms.Normalize(
            mean=[0.485, 0.456, 0.406],
            std=[0.229, 0.224, 0.225]
        )])

在这种情况下,我们定义了一个preprocess函数,将输入图像缩放到 256×256,将图像裁剪到围绕中心的 224×224,将其转换为张量(一个 PyTorch 多维数组:在这种情况下,一个带有颜色、高度和宽度的 3D 数组),并对其 RGB(红色、绿色、蓝色)组件进行归一化,使其具有定义的均值和标准差。如果我们希望网络产生有意义的答案,这些值需要与训练期间呈现给网络的值匹配。当我们深入研究如何制作自己的图像识别模型时,我们将更深入地了解 transforms,见第 7.1.3 节。

现在我们可以获取我们最喜欢的狗的图片(比如,GitHub 仓库中的 bobby.jpg),对其进行预处理,然后看看 ResNet 对其的看法。我们可以从本地文件系统中使用 Pillow(pillow.readthedocs.io/en/stable)加载图像,这是 Python 的图像处理模块:

# In[7]:
from PIL import Image
img = Image.open("../data/p1ch2/bobby.jpg")

如果我们是从 Jupyter Notebook 中跟随进行的,我们将执行以下操作以内联查看图片(它将显示在以下内容中的<PIL.JpegImagePlugin...处):

# In[8]:
img
# Out[8]:
<PIL.JpegImagePlugin.JpegImageFile image mode=RGB size=1280x720 at
 0x1B1601360B8>

否则,我们可以调用show方法,这将弹出一个带有查看器的窗口,以查看图 2.4 中显示的图像:

图 2.4 Bobby,我们非常特殊的输入图像

>>> img.show()

接下来,我们可以通过我们的预处理流程传递图像:

# In[9]:
img_t = preprocess(img)

然后我们可以以网络期望的方式重塑、裁剪和归一化输入张量。我们将在接下来的两章中更多地了解这一点;现在请耐心等待:

# In[10]:
import torch
batch_t = torch.unsqueeze(img_t, 0)

现在我们准备运行我们的模型。

2.1.5 运行!

在深度学习领域,对新数据运行经过训练的模型的过程称为推断。为了进行推断,我们需要将网络设置为eval模式:

# In[11]:
resnet.eval()

# Out[11]:
ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3),
                  bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True,
                     track_running_stats=True)
  (relu): ReLU(inplace)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1,
                       ceil_mode=False)
  (layer1): Sequential(
    (0): Bottleneck(
...
    )
  )
  (avgpool): AvgPool2d(kernel_size=7, stride=1, padding=0)
  (fc): Linear(in_features=2048, out_features=1000, bias=True)
)

如果我们忘记这样做,一些预训练模型,如批量归一化丢弃,将不会产生有意义的答案,这仅仅是因为它们内部的工作方式。现在eval已经设置好,我们准备进行推断:

# In[12]:
out = resnet(batch_t)
out

# Out[12]:
tensor([[ -3.4803,  -1.6618,  -2.4515,  -3.2662,  -3.2466,  -1.3611,
          -2.0465,  -2.5112,  -1.3043,  -2.8900,  -1.6862,  -1.3055,
...
           2.8674,  -3.7442,   1.5085,  -3.2500,  -2.4894,  -0.3354,
           0.1286,  -1.1355,   3.3969,   4.4584]])

一个涉及 4450 万参数的惊人操作集刚刚发生,产生了一个包含 1,000 个分数的向量,每个分数对应一个 ImageNet 类别。这没花多少时间,是吧?

现在我们需要找出获得最高分数的类别的标签。这将告诉我们模型在图像中看到了什么。如果标签与人类描述图像的方式相匹配,那太棒了!这意味着一切正常。如果不匹配,那么要么在训练过程中出了问题,要么图像与模型期望的差异太大,模型无法正确处理,或者存在其他类似问题。

要查看预测标签列表,我们将加载一个文本文件,列出标签的顺序与网络在训练期间呈现给网络的顺序相同,然后我们将挑选出网络产生的最高分数的索引处的标签。几乎所有用于图像识别的模型的输出形式与我们即将处理的形式类似。

让我们加载包含 ImageNet 数据集类别的 1,000 个标签的文件:

# In[13]:
with open('../data/p1ch2/imagenet_classes.txt') as f:
    labels = [line.strip() for line in f.readlines()]

在这一点上,我们需要确定out张量中对应最高分数的索引。我们可以使用 PyTorch 中的max函数来做到这一点,该函数输出张量中的最大值以及发生最大值的索引:

# In[14]:
_, index = torch.max(out, 1)

现在我们可以使用索引来访问标签。这里,index不是一个普通的 Python 数字,而是一个单元素、一维张量(具体来说,tensor([207])),所以我们需要获取实际的数值以用作索引进入我们的labels列表,使用index[0]。我们还使用torch.nn.functional.softmax (mng.bz/BYnq) 来将我们的输出归一化到范围[0, 1],并除以总和。这给了我们大致类似于模型对其预测的信心。在这种情况下,模型有 96%的把握知道它正在看的是一只金毛寻回犬:

# In[15]:
percentage = torch.nn.functional.softmax(out, dim=1)[0] * 100
labels[index[0]], percentage[index[0]].item()

# Out[15]:
('golden retriever', 96.29334259033203)

哦哦,谁是个好孩子?

由于模型生成了分数,我们还可以找出第二、第三等等最好的是什么。为此,我们可以使用sort函数,它可以将值按升序或降序排序,并在原始数组中提供排序后值的索引:

# In[16]:
_, indices = torch.sort(out, descending=True)
[(labels[idx], percentage[idx].item()) for idx in indices[0][:5]]

# Out[16]:
[('golden retriever', 96.29334259033203),
 ('Labrador retriever', 2.80812406539917),
 ('cocker spaniel, English cocker spaniel, cocker', 0.28267428278923035),
 ('redbone', 0.2086310237646103),
 ('tennis ball', 0.11621569097042084)]

我们看到前四个是狗(红骨是一种品种;谁知道?),之后事情开始变得有趣起来。第五个答案“网球”可能是因为有足够多的狗旁边有网球的图片,以至于模型基本上在说:“我有 0.1%的机会完全误解了什么是网球。”这是人类和神经网络在看待世界的根本差异的一个很好的例子,以及奇怪、微妙的偏见如何很容易潜入我们的数据中。

玩耍的时候到了!我们可以继续用随机图像询问我们的网络,看看它会得出什么结果。网络的成功程度很大程度上取决于主题在训练集中是否得到很好的代表。如果我们呈现一个包含训练集之外主题的图像,网络很可能会以相当高的信心给出错误答案。实验和了解模型对未见数据的反应是很有用的。

我们刚刚运行了一个在 2015 年赢得图像分类比赛的网络。它学会了从狗的例子中识别我们的狗,以及许多其他真实世界的主题。现在我们将看看不同的架构如何实现其他类型的任务,从图像生成开始。

2.2 一个假装到成功的预训练模型

让我们假设一下,我们是职业罪犯,想要开始销售著名艺术家的“失落”画作的赝品。我们是罪犯,不是画家,所以当我们绘制我们的假雷姆布兰特和毕加索时,很快就会显而易见它们是业余的模仿品而不是真品。即使我们花了很多时间练习,直到我们也无法分辨出画作是假的,试图在当地艺术拍卖行兜售也会立即被赶出去。更糟糕的是,被告知“这显然是假的;滚出去”,并不能帮助我们改进!我们将不得不随机尝试很多事情,评估哪些需要稍微长一点时间才能识别为赝品,并在未来的尝试中强调这些特征,这将花费太长时间。

相反,我们需要找到一个道德标准有问题的艺术史学家来检查我们的作品,并告诉我们究竟是什么让他们发现这幅画不真实。有了这个反馈,我们可以以明确、有针对性的方式改进我们的作品,直到我们的可疑学者再也无法将我们的画作与真品区分开来。

很快,我们的“波提切利”将在卢浮宫展出,他们的百元钞票将进入我们的口袋。我们会变得富有!

尽管这种情景有点荒谬,但其基础技术是可靠的,并且很可能会对未来几年数字数据的真实性产生深远影响。整个“照片证据”的概念很可能会变得完全可疑,因为制作令人信服但虚假的图像和视频将变得非常容易。唯一的关键因素是数据。让我们看看这个过程是如何运作的。

2.2.1 GAN 游戏

在深度学习的背景下,我们刚刚描述的被称为GAN 游戏,其中两个网络,一个充当画家,另一个充当艺术史学家,竞争着互相愚弄,创造和检测伪造品。GAN 代表生成对抗网络,其中生成表示正在创建某物(在本例中是假的杰作),对抗表示两个网络正在竞争愚弄对方,而网络显而易见。这些网络是最近深度学习研究的最原创的成果之一。

请记住,我们的总体目标是生成一类图像的合成示例,这些示例无法被识别为伪造品。当与合法示例混合在一起时,一个熟练的检查员会很难确定哪些是真实的,哪些是我们的伪造品。

生成器网络在我们的场景中扮演画家的角色,负责从任意输入开始生成逼真的图像。鉴别器网络是无情的艺术检查员,需要判断给定的图像是由生成器制作还是属于真实图像集。这种双网络设计对于大多数深度学习架构来说是非典型的,但是,当用于实现 GAN 游戏时,可以产生令人难以置信的结果。

图 2.5 GAN 游戏的概念

图 2.5 展示了大致的情况。生成器的最终目标是欺骗鉴别器,混淆真实和虚假图像。鉴别器的最终目标是发现自己被欺骗,但它也帮助生成器找出生成图像中的可识别错误。在开始阶段,生成器产生混乱的三眼怪物,看起来一点也不像伦勃朗的肖像画。鉴别器很容易区分混乱的混乱图像和真实的绘画作品。随着训练的进行,信息从鉴别器返回,生成器利用这些信息进行改进。训练结束时,生成器能够产生令人信服的伪造品,鉴别器不再能分辨哪个是真实的。

请注意,“鉴别器获胜”或“生成器获胜”不应被字面意义上解释--两者之间没有明确的比赛。然而,两个网络都是基于另一个网络的结果进行训练的,这推动了每个网络参数的优化。

这种技术已经被证明能够导致生成器从仅有噪音和一个条件信号(比如,对于人脸:年轻、女性、戴眼镜)生成逼真图像,换句话说,一个训练良好的生成器学会了一个可信的模型,即使被人类检查也看起来很真实。

2.2.2 CycleGAN

这个概念的一个有趣演变是 CycleGAN。CycleGAN 可以将一个域的图像转换为另一个域的图像(反之亦然),而无需我们在训练集中明确提供匹配对。

在图 2.6 中,我们有一个 CycleGAN 工作流程,用于将一匹马的照片转换为斑马,反之亦然。请注意,有两个独立的生成器网络,以及两个不同的鉴别器。

图 2.6 一个 CycleGAN 训练到可以愚弄两个鉴别器网络的程度

如图所示,第一个生成器学会生成符合目标分布(在这种情况下是斑马)的图像,从属于不同分布(马)的图像开始,以便鉴别器无法判断从马照片生成的图像是否真的是斑马的真实图片。同时--这就是缩写中Cycle前缀的含义--生成的假斑马被发送到另一个生成器,沿着另一条路(在我们的情况下是从斑马到马),由另一个鉴别器进行判断。创建这样一个循环显著稳定了训练过程,解决了 GAN 的一个最初问题。

有趣的是,在这一点上,我们不需要匹配的马/斑马对作为地面真相(祝你好运,让它们匹配姿势!)。从一组不相关的马图片和斑马照片开始对生成器进行训练就足够了,超越了纯粹监督的设置。这个模型的影响甚至超出了这个范围:生成器学会了如何有选择性地改变场景中物体的外观,而不需要关于什么是什么的监督。没有信号表明鬃毛是鬃毛,腿是腿,但它们被转换成与另一种动物的解剖学相一致的东西。

2.2.3 将马变成斑马的网络

我们现在可以玩这个模型。CycleGAN 网络已经在从 ImageNet 数据集中提取的(不相关的)马图片和斑马图片数据集上进行了训练。网络学会了将一张或多张马的图片转换成斑马,尽可能保持其余部分的图像不变。虽然人类在过去几千年里并没有为将马变成斑马的工具而屏住呼吸,但这个任务展示了这些架构模拟复杂现实世界过程的能力,远程监督。虽然它们有局限性,但有迹象表明,在不久的将来,我们将无法在实时视频中区分真实和虚假,这打开了一个我们将立即关闭的潘多拉魔盒。

玩一个预训练的 CycleGAN 将给我们一个机会,更近距离地看一看网络--在这种情况下是一个生成器--是如何实现的。我们将使用我们的老朋友 ResNet。我们将在屏幕外定义一个ResNetGenerator类。代码在 3_cyclegan.ipynb 文件的第一个单元格中,但实现目前并不相关,而且在我们获得更多 PyTorch 经验之前,它太复杂了。现在,我们专注于它能做什么,而不是它是如何做到的。让我们用默认参数实例化这个类(code/p1ch2/3_cyclegan.ipynb):

# In[2]:
netG = ResNetGenerator()

netG模型已经创建,但它包含随机权重。我们之前提到过,我们将运行一个在 horse2zebra 数据集上预训练的生成器模型,该数据集的训练集包含 1068 张马和 1335 张斑马的图片。数据集可以在mng.bz/8pKP找到。模型的权重已保存在.pth 文件中,这只是模型张量参数的pickle文件。我们可以使用模型的load_state_dict方法将它们加载到ResNetGenerator中:

# In[3]:
model_path = '../data/p1ch2/horse2zebra_0.4.0.pth'
model_data = torch.load(model_path)
netG.load_state_dict(model_data)

此时,netG已经获得了在训练过程中获得的所有知识。请注意,这与我们在第 2.1.3 节中从torchvision中加载resnet101时发生的情况完全相同;但torchvision.resnet101函数将加载过程隐藏了起来。

让我们将网络设置为eval模式,就像我们为resnet101所做的那样:

# In[4]:
netG.eval()

# Out[4]:
ResNetGenerator(
  (model): Sequential(
...
  )
)

像之前打印模型一样,我们可以欣赏到它实际上相当简洁,考虑到它的功能。它接收一幅图像,通过查看像素识别出一匹或多匹马,并单独修改这些像素的值,使得输出看起来像一匹可信的斑马。我们在打印输出中(或者源代码中)不会认出任何类似斑马的东西:那是因为里面没有任何类似斑马的东西。这个网络是一个脚手架--关键在于权重。

我们准备加载一张随机的马的图像,看看我们的生成器会产生什么。首先,我们需要导入PILtorchvision

# In[5]:
from PIL import Image
from torchvision import transforms

然后我们定义一些输入转换,以确保数据以正确的形状和大小进入网络:

# In[6]:
preprocess = transforms.Compose([transforms.Resize(256),
                                 transforms.ToTensor()])

让我们打开一个马文件(见图 2.7):

图 2.7 一个骑马的人。马似乎不太乐意。

# In[7]:
img = Image.open("../data/p1ch2/horse.jpg")
img

好吧,有个家伙骑在马上。(看图片的话,可能不会持续太久。)不管怎样,让我们通过预处理并将其转换为正确形状的变量:

# In[8]:
img_t = preprocess(img)
batch_t = torch.unsqueeze(img_t, 0)

我们现在不必担心细节。重要的是我们要保持距离跟随。此时,batch_t可以被发送到我们的模型:

# In[9]:
batch_out = netG(batch_t)

batch_out现在是生成器的输出,我们可以将其转换回图像:

# In[10]:
out_t = (batch_out.data.squeeze() + 1.0) / 2.0
out_img = transforms.ToPILImage()(out_t)
# out_img.save('../data/p1ch2/zebra.jpg')
out_img

# Out[10]:
<PIL.Image.Image image mode=RGB size=316x256 at 0x23B24634F98>

哦,天啊。谁像那样骑斑马呢?结果图像(图 2.8)并不完美,但请考虑到网络发现有人(某种程度上)骑在马上有点不寻常。需要重申的是,学习过程并没有经过直接监督,人类没有勾勒出成千上万匹马或手动 Photoshop 成千上万条斑马条纹。生成器已经学会生成一幅图像,可以愚弄鉴别器,让它认为那是一匹斑马,而图像并没有什么奇怪之处(显然鉴别器从未去过竞技场)。

图 2.8 一个骑斑马的人。斑马似乎不太乐意。

许多其他有趣的生成器是使用对抗训练或其他方法开发的。其中一些能够创建出可信的不存在个体的人脸;其他一些可以将草图转换为看起来真实的虚构景观图片。生成模型也被用于产生听起来真实的音频、可信的文本和令人愉悦的音乐。这些模型很可能将成为未来支持创造过程的工具的基础。

说真的,这种工作的影响难以言表。像我们刚刚下载的这种工具只会变得更加高质量和更加普遍。特别是换脸技术已经引起了相当多的媒体关注。搜索“deep fakes”会出现大量示例内容¹(尽管我们必须指出有相当数量的不适宜工作场所的内容被标记为这样;就像互联网上的一切一样,要小心点击)。

到目前为止,我们有机会玩弄一个能看到图像的模型和一个能生成新图像的模型。我们将以一个涉及另一个基本要素的模型结束我们的旅程:自然语言。

2.3 描述场景的预训练网络

为了亲身体验涉及自然语言的模型,我们将使用一个由 Ruotian Luo 慷慨提供的预训练图像字幕模型。这是 Andrej Karpathy 的 NeuralTalk2 模型的一个实现。当呈现一幅自然图像时,这种模型会生成一个用英语描述场景的字幕,如图 2.9 所示。该模型是在大量图像数据集上训练的,配有一对句子描述:例如,“一只虎斑猫斜靠在木桌上,一只爪子放在激光鼠标上,另一只放在黑色笔记本电脑上。”³

图 2.9 字幕模型的概念

这个字幕模型有两个连接的部分。模型的第一部分是一个网络,学习生成场景的“描述性”数值表示(Tabby 猫,激光鼠标,爪子),然后将这些数值描述作为第二部分的输入。第二部分是一个循环神经网络,通过将这些数值描述组合在一起生成连贯的句子。模型的两个部分一起在图像-字幕对上进行训练。

模型的后半部分被称为循环,因为它在后续的前向传递中生成其输出(单词),其中每个前向传递的输入包括前一个前向传递的输出。这使得下一个单词依赖于先前生成的单词,这是我们在处理句子或一般序列时所期望的。

2.3.1 神经对话 2

神经对话 2 模型可以在github.com/deep-learning-with-pytorch/ImageCaptioning.pytorch找到。我们可以将一组图像放在data目录中,并运行以下脚本:

python eval.py --model ./data/FC/fc-model.pth--infos_path ./data/FC/fc-infos.pkl --image_folder ./data

让我们尝试使用我们的 horse.jpg 图像。它说:“一个人骑着马在海滩上。”非常合适。

现在,就只是为了好玩,让我们看看我们的 CycleGAN 是否也能愚弄这个神经对话 2 模型。让我们在数据文件夹中添加 zebra.jpg 图像并重新运行模型:“一群斑马站在田野上。”嗯,它正确地识别了动物,但它在图像中看到了不止一只斑马。显然,这不是网络曾经见过斑马的姿势,也没有见过斑马上有骑手(带有一些虚假的斑马图案)。此外,斑马很可能是以群体形式出现在训练数据集中,因此我们可能需要调查一些偏见。字幕网络也没有描述骑手。同样,这可能是同样的原因:网络在训练数据集中没有看到骑手骑在斑马上。无论如何,这是一个令人印象深刻的壮举:我们生成了一张带有不可能情景的假图像,而字幕网络足够灵活,能够正确地捕捉主题。

我们想强调的是,像这样的东西,在深度学习出现之前是极其难以实现的,现在可以用不到一千行代码,使用一个不知道关于马或斑马的通用架构,以及一组图像和它们的描述(在这种情况下是 MS COCO 数据集)来获得。没有硬编码的标准或语法--一切,包括句子,都是从数据中的模式中产生的。

在这种情况下,网络架构在某种程度上比我们之前看到的更复杂,因为它包括两个网络。其中一个是循环的,但是它是由 PyTorch 提供的相同构建块构建的。

在撰写本文时,这样的模型更多地存在于应用研究或新颖项目中,而不是具有明确定义的具体用途。尽管结果令人鼓舞,但还不足以使用...至少现在还不足够。随着时间的推移(和额外的训练数据),我们应该期望这类模型能够向视觉受损的人描述世界,从视频中转录场景,以及执行其他类似的任务。

2.4 Torch Hub

从深度学习的早期就开始发布预训练模型,但直到 PyTorch 1.0,没有办法确保用户可以获得统一的接口来获取它们。TorchVision 是一个良好的接口示例,正如我们在本章前面看到的那样;但其他作者,正如我们在 CycleGAN 和神经对话 2 中看到的那样,选择了不同的设计。

PyTorch 1.0 引入了 Torch Hub,这是一个机制,通过该机制,作者可以在 GitHub 上发布一个模型,带有或不带有预训练权重,并通过 PyTorch 理解的接口公开它。这使得从第三方加载预训练模型就像加载 TorchVision 模型一样简单。

作者通过 Torch Hub 机制发布模型所需的全部工作就是在 GitHub 存储库的根目录中放置一个名为 hubconf.py 的文件。该文件具有非常简单的结构:

dependencies = ['torch', 'math']               # ❶

def some_entry_fn(*args, **kwargs):            # ❷
    model = build_some_model(*args, **kwargs)
    return model

def another_entry_fn(*args, **kwargs):
    model = build_another_model(*args, **kwargs)
    return model

❶ 代码依赖的可选模块列表

❷ 一个或多个要向用户公开作为存储库入口点的函数。这些函数应根据参数初始化模型并返回它们

在我们寻找有趣的预训练模型的过程中,现在我们可以搜索包含 hubconf.py 的 GitHub 存储库,我们会立即知道可以使用 torch.hub 模块加载它们。让我们看看实际操作是如何进行的。为此,我们将回到 TorchVision,因为它提供了一个清晰的示例,展示了如何与 Torch Hub 交互。

让我们访问github.com/pytorch/vision,注意其中包含一个 hubconf.py 文件。很好,检查通过。首先要做的事情是查看该文件,看看存储库的入口点--我们稍后需要指定它们。在 TorchVision 的情况下,有两个:resnet18resnet50。我们已经知道这些是做什么的:它们分别返回一个 18 层和一个 50 层的 ResNet 模型。我们还看到入口点函数包括一个pretrained关键字参数。如果为True,返回的模型将使用从 ImageNet 学习到的权重进行初始化,就像我们在本章前面看到的那样。

现在我们知道存储库、入口点和一个有趣的关键字参数。这就是我们加载模型所需的全部内容,使用 torch.hub,甚至无需克隆存储库。没错,PyTorch 会为我们处理:

import torch
from torch import hub

resnet18_model = hub.load('pytorch/vision:master',  # ❶
                           'resnet18',              # ❷
                           pretrained=True)         # ❸

❶ GitHub 存储库的名称和分支

❷ 入口点函数的名称

❸ 关键字参数

这将下载 pytorch/vision 存储库的主分支的快照,以及权重,到本地目录(默认为我们主目录中的.torch/hub),并运行resnet18入口点函数,返回实例化的模型。根据环境的不同,Python 可能会抱怨缺少模块,比如PIL。Torch Hub 不会安装缺少的依赖项,但会向我们报告,以便我们采取行动。

此时,我们可以使用适当的参数调用返回的模型,在其上运行前向传递,就像我们之前做的那样。好处在于,现在通过这种机制发布的每个模型都将以相同的方式对我们可用,远远超出视觉领域。

请注意,入口点应该返回模型;但严格来说,它们并不一定要这样做。例如,我们可以有一个用于转换输入的入口点,另一个用于将输出概率转换为文本标签。或者我们可以有一个仅包含模型的入口点,另一个包含模型以及预处理和后处理步骤。通过保持这些选项开放,PyTorch 开发人员为社区提供了足够的标准化和很大的灵活性。我们将看到从这个机会中会出现什么样的模式。

在撰写本文时,Torch Hub 还很新,只有少数模型是以这种方式发布的。我们可以通过谷歌搜索“github.com hubconf.py”来找到它们。希望在未来列表会增长,因为更多作者通过这个渠道分享他们的模型。

2.5 结论

希望这是一个有趣的章节。我们花了一些时间玩弄用 PyTorch 创建的模型,这些模型经过优化,可以执行特定任务。事实上,我们中更有进取心的人已经可以将其中一个模型放在 Web 服务器后面,并开始一项业务,与原始作者分享利润!一旦我们了解了这些模型是如何构建的,我们还将能够利用在这里获得的知识下载一个预训练模型,并快速对稍有不同的任务进行微调。

我们还将看到如何使用相同的构建块构建处理不同问题和不同类型数据的模型。PyTorch 做得特别好的一件事是以基本工具集的形式提供这些构建块--从 API 的角度来看,PyTorch 并不是一个非常庞大的库,特别是与其他深度学习框架相比。

本书不专注于完整地介绍 PyTorch API 或审查深度学习架构;相反,我们将建立对这些构建块的实践知识。这样,您将能够在坚实的基础上消化优秀的在线文档和存储库。

从下一章开始,我们将踏上一段旅程,使我们能够从头开始教授计算机技能,使用 PyTorch。我们还将了解,从预训练网络开始,并在新数据上进行微调,而不是从头开始,是解决问题的有效方法,特别是当我们拥有的数据点并不是特别多时。这是预训练网络是深度学习从业者必备的重要工具的另一个原因。是时候了解第一个基本构建块了:张量。

2.6 练习

  1. 将金毛猎犬的图像输入到马到斑马模型中。

    1. 你需要对图像进行哪些处理?

    2. 输出是什么样的?

  2. 在 GitHub 上搜索提供 hubconf.py 文件的项目。

    1. 返回了多少个存储库?

    2. 找一个带有 hubconf.py 的看起来有趣的项目。你能从文档中理解项目的目的吗?

    3. 收藏这个项目,在完成本书后回来。你能理解实现吗?

2.7 总结

  • 预训练网络是已经在数据集上训练过的模型。这样的网络通常在加载网络参数后可以立即产生有用的结果。

  • 通过了解如何使用预训练模型,我们可以将神经网络集成到项目中,而无需设计或训练它。

  • AlexNet 和 ResNet 是两个深度卷积网络,在它们发布的年份为图像识别设立了新的基准。

  • 生成对抗网络(GANs)有两部分--生成器和判别器--它们共同工作以产生与真实物品无法区分的输出。

  • CycleGAN 使用一种支持在两种不同类别的图像之间进行转换的架构。

  • NeuralTalk2 使用混合模型架构来消耗图像并生成图像的文本描述。

  • Torch Hub 是一种标准化的方式,可以从具有适当的 hubconf.py 文件的任何项目中加载模型和权重。


¹Vox 文章“乔丹·皮尔模拟奥巴马公益广告是对假新闻的双刃警告”中描述了一个相关例子,作者是阿贾·罗曼诺;mng.bz/dxBz (警告:粗俗语言.

² 我们在github.com/deep-learning-with-pytorch/ImageCaptioning .pytorch上维护代码的克隆。

³Andrej Karpathy 和 Li Fei-Fei,“用于生成图像描述的深度视觉语义对齐”,cs.stanford.edu/people/karpathy/cvpr2015.pdf.

⁴ 联系出版商了解特许经营机会!

三、始于张量

本章涵盖

  • 理解张量,PyTorch 中的基本数据结构

  • 张量的索引和操作

  • 与 NumPy 多维数组的互操作

  • 将计算迁移到 GPU 以提高速度

在上一章中,我们参观了深度学习所能实现的许多应用。它们无一例外地包括将某种形式的数据(如图像或文本)转换为另一种形式的数据(如标签、数字或更多图像或文本)。从这个角度来看,深度学习实际上是构建一个能够将数据从一种表示转换为另一种表示的系统。这种转换是通过从一系列示例中提取所需映射的共同点来驱动的。例如,系统可能注意到狗的一般形状和金毛寻回犬的典型颜色。通过结合这两个图像属性,系统可以正确地将具有特定形状和颜色的图像映射到金毛寻回犬标签,而不是黑色实验室(或者一只黄褐色的公猫)。最终的系统可以处理数量相似的输入并为这些输入产生有意义的输出。

这个过程始于将我们的输入转换为浮点数。我们将在第四章中涵盖将图像像素转换为数字的过程,正如我们在图 3.1 的第一步中所看到的那样(以及许多其他类型的数据)。但在我们开始之前,在本章中,我们将学习如何通过张量在 PyTorch 中处理所有浮点数。

3.1 世界是由浮点数构成的

由于浮点数是网络处理信息的方式,我们需要一种方法将我们想要处理的现有世界数据编码为网络可以理解的内容,然后将输出解码回我们可以理解并用于我们目的的内容。

图 3.1 一个深度神经网络学习如何将输入表示转换为输出表示。(注意:神经元和输出的数量不是按比例缩放的。)

深度神经网络通常通过阶段性地学习从一种数据形式到另一种数据形式的转换来进行学习,这意味着每个阶段之间部分转换的数据可以被视为一系列中间表示。对于图像识别,早期的表示可以是边缘检测或某些纹理,如毛皮。更深层次的表示可以捕捉更复杂的结构,如耳朵、鼻子或眼睛。

一般来说,这种中间表示是描述输入并以对描述输入如何映射到神经网络输出至关重要的方式捕捉数据结构的一组浮点数。这种描述是针对手头的任务具体的,并且是从相关示例中学习的。这些浮点数集合及其操作是现代人工智能的核心--我们将在本书中看到几个这样的例子。

需要记住这些中间表示(如图 3.1 的第二步所示)是将输入与前一层神经元的权重相结合的结果。每个中间表示对应于其前面的输入是独一无二的。

在我们开始将数据转换为浮点输入的过程之前,我们必须首先对 PyTorch 如何处理和存储数据--作为输入、中间表示和输出有一个扎实的理解。本章将专门讨论这一点。

为此,PyTorch 引入了一种基本数据结构:张量。我们在第二章中已经遇到了张量,当我们对预训练网络进行推断时。对于那些来自数学、物理或工程领域的人来说,张量这个术语通常与空间、参考系统和它们之间的变换捆绑在一起。在深度学习的背景下,张量是将向量和矩阵推广到任意维数的概念,正如我们在图 3.2 中所看到的。同一概念的另一个名称是多维数组。张量的维数与用于引用张量内标量值的索引数量相一致。

图 3.2 张量是 PyTorch 中表示数据的基本构件。

PyTorch 并不是唯一处理多维数组的库。NumPy 是迄今为止最流行的多维数组库,以至于现在可以说它已经成为数据科学的通用语言。PyTorch 与 NumPy 具有无缝互操作性,这带来了与 Python 中其他科学库的一流集成,如 SciPy (www.scipy.org)、Scikit-learn (scikit-learn.org)和 Pandas (pandas.pydata.org)。

与 NumPy 数组相比,PyTorch 张量具有一些超能力,比如能够在图形处理单元(GPU)上执行非常快速的操作,将操作分布在多个设备或机器上,并跟踪创建它们的计算图。这些都是在实现现代深度学习库时的重要特性。

我们将通过介绍 PyTorch 张量来开始本章,涵盖基础知识,以便为本书其余部分的工作做好准备。首先,我们将学习如何使用 PyTorch 张量库来操作张量。这包括数据在内存中的存储方式,如何在常数时间内对任意大的张量执行某些操作,以及前面提到的 NumPy 互操作性和 GPU 加速。如果我们希望张量成为编程工具箱中的首选工具,那么理解张量的能力和 API 是很重要的。在下一章中,我们将把这些知识应用到实践中,并学习如何以一种能够利用神经网络进行学习的方式表示多种不同类型的数据。

3.2 张量:多维数组

我们已经学到了张量是 PyTorch 中的基本数据结构。张量是一个数组:即,一种数据结构,用于存储一组可以通过索引单独访问的数字,并且可以用多个索引进行索引。

3.2.1 从 Python 列表到 PyTorch 张量

让我们看看list索引是如何工作的,这样我们就可以将其与张量索引进行比较。在 Python 中,取一个包含三个数字的列表(.code/p1ch3/1_tensors.ipynb):

# In[1]:
a = [1.0, 2.0, 1.0]

我们可以使用相应的从零开始的索引来访问列表的第一个元素:

# In[2]:
a[0]

# Out[2]:
1.0

# In[3]:
a[2] = 3.0
a

# Out[3]:
[1.0, 2.0, 3.0]

对于处理数字向量的简单 Python 程序,比如 2D 线的坐标,使用 Python 列表来存储向量并不罕见。正如我们将在接下来的章节中看到的,使用更高效的张量数据结构,可以表示许多类型的数据--从图像到时间序列,甚至句子。通过定义张量上的操作,其中一些我们将在本章中探讨,我们可以高效地切片和操作数据,即使是从一个高级(并不特别快速)语言如 Python。

3.2.2 构建我们的第一个张量

让我们构建我们的第一个 PyTorch 张量并看看它是什么样子。暂时它不会是一个特别有意义的张量,只是一个列中的三个 1:

# In[4]:
import torch       # ❶
a = torch.ones(3)  # ❷
a

# Out[4]:
tensor([1., 1., 1.])

# In[5]:
a[1]

# Out[5]:
tensor(1.)

# In[6]:
float(a[1])

# Out[6]:
1.0

# In[7]:
a[2] = 2.0
a

# Out[7]:
tensor([1., 1., 2.])

❶ 导入 torch 模块

❷ 创建一个大小为 3、填充为 1 的一维张量

导入 torch 模块后,我们调用一个函数,创建一个大小为 3、填充值为 1.0 的(一维)张量。我们可以使用基于零的索引访问元素或为其分配新值。尽管表面上这个例子与数字对象列表没有太大区别,但在底层情况完全不同。

3.2.3 张量的本质

Python 列表或数字元组是单独分配在内存中的 Python 对象的集合,如图 3.3 左侧所示。另一方面,PyTorch 张量或 NumPy 数组是对(通常)包含未装箱的 C 数值类型而不是 Python 对象的连续内存块的视图。在这种情况下,每个元素是一个 32 位(4 字节)的 float,正如我们在图 3.3 右侧所看到的。这意味着存储 1,000,000 个浮点数的 1D 张量将需要确切的 4,000,000 个连续字节,再加上一些小的开销用于元数据(如维度和数值类型)。

图 3.3 Python 对象(带框)数值值与张量(未带框数组)数值值

假设我们有一个坐标列表,我们想用它来表示一个几何对象:也许是一个顶点坐标为 (4, 1), (5, 3) 和 (2, 1) 的 2D 三角形。这个例子与深度学习无关,但很容易理解。与之前将坐标作为 Python 列表中的数字不同,我们可以使用一维张量,将X存储在偶数索引中,Y存储在奇数索引中,如下所示:

# In[8]:
points = torch.zeros(6) # ❶
points[0] = 4.0         # ❷
points[1] = 1.0
points[2] = 5.0
points[3] = 3.0
points[4] = 2.0
points[5] = 1.0

❶ 使用 .zeros 只是获取一个适当大小的数组的一种方式。

❷ 我们用我们实际想要的值覆盖了那些零值。

我们也可以将 Python 列表传递给构造函数,效果相同:

# In[9]:
points = torch.tensor([4.0, 1.0, 5.0, 3.0, 2.0, 1.0])
points

# Out[9]:
tensor([4., 1., 5., 3., 2., 1.])

要获取第一个点的坐标,我们执行以下操作:

# In[10]:
float(points[0]), float(points[1])

# Out[10]:
(4.0, 1.0)

这是可以的,尽管将第一个索引指向单独的 2D 点而不是点坐标会更实用。为此,我们可以使用一个 2D 张量:

# In[11]:
points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
points

# Out[11]:
tensor([[4., 1.],
        [5., 3.],
        [2., 1.]])

在这里,我们将一个列表的列表传递给构造函数。我们可以询问张量的形状:

# In[12]:
points.shape

# Out[12]:
torch.Size([3, 2])

这告诉我们张量沿每个维度的大小。我们也可以使用 zerosones 来初始化张量,提供大小作为一个元组:

# In[13]:
points = torch.zeros(3, 2)
points

# Out[13]:
tensor([[0., 0.],
        [0., 0.],
        [0., 0.]])

现在我们可以使用两个索引访问张量中的单个元素:

# In[14]:
points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
points

# Out[14]:
tensor([[4., 1.],
        [5., 3.],
        [2., 1.]])

# In[15]:
points[0, 1]

# Out[15]:
tensor(1.)

这返回我们数据集中第零个点的Y坐标。我们也可以像之前那样访问张量中的第一个元素,以获取第一个点的 2D 坐标:

# In[16]:
points[0]

# Out[16]:
tensor([4., 1.])

输出是另一个张量,它呈现了相同基础数据的不同视图。新张量是一个大小为 2 的 1D 张量,引用了 points 张量中第一行的值。这是否意味着分配了一个新的内存块,将值复制到其中,并返回了包装在新张量对象中的新内存?不,因为那样会非常低效,特别是如果我们有数百万个点。当我们在本章后面讨论张量视图时,我们将重新讨论张量是如何存储的。

3.3 张量索引

如果我们需要获取一个不包含第一个点的张量,那很容易使用范围索引表示法,这也适用于标准 Python 列表。这里是一个提醒:

# In[53]:
some_list = list(range(6))
some_list[:]               # ❶
some_list[1:4]             # ❷
some_list[1:]              # ❸
some_list[:4]              # ❹
some_list[:-1]             # ❺
some_list[1:4:2]           # ❻

❶ 列表中的所有元素

❷ 从第 1 个元素(包括)到第 4 个元素(不包括)

❸ 从第 1 个元素(包括)到列表末尾

❹ 从列表开头到第 4 个元素(不包括)

❺ 从列表开头到倒数第二个元素之前

❻ 从第 1 个元素(包括)到第 4 个元素(不包括),步长为 2

为了实现我们的目标,我们可以使用与 PyTorch 张量相同的符号表示法,其中的额外好处是,就像在 NumPy 和其他 Python 科学库中一样,我们可以为张量的每个维度使用范围索引:

# In[54]:
points[1:]       # ❶
points[1:, :]    # ❷
points[1:, 0]    # ❸
points[None]     # ❹

❶ 第一个之后的所有行;隐式地所有列

❷ 第一个之后的所有行;所有列

❸ 第一个之后的所有行;第一列

❹ 添加一个大小为 1 的维度,就像 unsqueeze 一样

除了使用范围,PyTorch 还具有一种强大的索引形式,称为高级索引,我们将在下一章中看到。

3.4 命名张量

我们的张量的维度(或轴)通常索引像素位置或颜色通道之类的内容。这意味着当我们想要索引张量时,我们需要记住维度的顺序,并相应地编写我们的索引。随着数据通过多个张量进行转换,跟踪哪个维度包含什么数据可能会出错。

为了使事情具体化,想象我们有一个三维张量 img_t,来自第 2.1.4 节(这里为简单起见使用虚拟数据),我们想将其转换为灰度。我们查找了颜色的典型权重,以得出单个亮度值:¹

# In[2]:
img_t = torch.randn(3, 5, 5) # shape [channels, rows, columns]
weights = torch.tensor([0.2126, 0.7152, 0.0722])

我们经常希望我们的代码能够泛化--例如,从表示为具有高度和宽度维度的 2D 张量的灰度图像到添加第三个通道维度的彩色图像(如 RGB),或者从单个图像到一批图像。在第 2.1.4 节中,我们引入了一个额外的批处理维度 batch_t;这里我们假装有一个批处理为 2 的批次:

# In[3]:
batch_t = torch.randn(2, 3, 5, 5) # shape [batch, channels, rows, columns]

有时 RGB 通道在维度 0 中,有时它们在维度 1 中。但我们可以通过从末尾计数来概括:它们总是在维度-3 中,距离末尾的第三个。因此,懒惰的、无权重的平均值可以写成如下形式:

# In[4]:
img_gray_naive = img_t.mean(-3)
batch_gray_naive = batch_t.mean(-3)
img_gray_naive.shape, batch_gray_naive.shape

# Out[4]:
(torch.Size([5, 5]), torch.Size([2, 5, 5]))

但现在我们也有了权重。PyTorch 将允许我们将形状相同的东西相乘,以及其中一个操作数在给定维度上的大小为 1。它还会自动附加大小为 1 的前导维度。这是一个称为广播的特性。形状为 (2, 3, 5, 5) 的 batch_t 乘以形状为 (3, 1, 1) 的 unsqueezed_weights,得到形状为 (2, 3, 5, 5) 的张量,然后我们可以对末尾的第三个维度求和(三个通道):

# In[5]:
unsqueezed_weights = weights.unsqueeze(-1).unsqueeze_(-1)
img_weights = (img_t * unsqueezed_weights)
batch_weights = (batch_t * unsqueezed_weights)
img_gray_weighted = img_weights.sum(-3)
batch_gray_weighted = batch_weights.sum(-3)
batch_weights.shape, batch_t.shape, unsqueezed_weights.shape

# Out[5]:
(torch.Size([2, 3, 5, 5]), torch.Size([2, 3, 5, 5]), torch.Size([3, 1, 1]))

因为这很快变得混乱--出于效率考虑--PyTorch 函数 einsum(改编自 NumPy)指定了一个索引迷你语言²,为这些乘积的和给出维度的索引名称。就像在 Python 中经常一样,广播--一种总结未命名事物的形式--使用三个点 '...' 完成;但不要太担心 einsum,因为我们接下来不会使用它:

# In[6]:
img_gray_weighted_fancy = torch.einsum('...chw,c->...hw', img_t, weights)
batch_gray_weighted_fancy = torch.einsum('...chw,c->...hw', batch_t, weights)
batch_gray_weighted_fancy.shape

# Out[6]:
torch.Size([2, 5, 5])

正如我们所看到的,涉及到相当多的簿记工作。这是容易出错的,特别是当张量的创建和使用位置在我们的代码中相距很远时。这引起了从业者的注意,因此有人建议³给维度赋予一个名称。

PyTorch 1.3 添加了命名张量作为一个实验性功能(参见pytorch.org/tutorials/intermediate/named_tensor_tutorial.htmlpytorch.org/docs/stable/named_tensor.html)。张量工厂函数如 tensorrand 接受一个 names 参数。这些名称应该是一个字符串序列:

# In[7]:
weights_named = torch.tensor([0.2126, 0.7152, 0.0722], names=['channels'])
weights_named

# Out[7]:
tensor([0.2126, 0.7152, 0.0722], names=('channels',))

当我们已经有一个张量并想要添加名称(但不更改现有名称)时,我们可以在其上调用方法 refine_names。类似于索引,省略号 (...) 允许您省略任意数量的维度。使用 rename 兄弟方法,您还可以覆盖或删除(通过传入 None)现有名称:

# In[8]:
img_named =  img_t.refine_names(..., 'channels', 'rows', 'columns')
batch_named = batch_t.refine_names(..., 'channels', 'rows', 'columns')
print("img named:", img_named.shape, img_named.names)
print("batch named:", batch_named.shape, batch_named.names)

# Out[8]:
img named: torch.Size([3, 5, 5]) ('channels', 'rows', 'columns')
batch named: torch.Size([2, 3, 5, 5]) (None, 'channels', 'rows', 'columns')

对于具有两个输入的操作,除了通常的维度检查--大小是否相同,或者一个是否为 1 且可以广播到另一个--PyTorch 现在将为我们检查名称。到目前为止,它不会自动对齐维度,因此我们需要明确地执行此操作。方法 align_as 返回一个具有缺失维度的张量,并将现有维度排列到正确的顺序:

# In[9]:
weights_aligned = weights_named.align_as(img_named)
weights_aligned.shape, weights_aligned.names

# Out[9]:
(torch.Size([3, 1, 1]), ('channels', 'rows', 'columns'))

接受维度参数的函数,如 sum,也接受命名维度:

# In[10]:
gray_named = (img_named * weights_aligned).sum('channels')
gray_named.shape, gray_named.names

# Out[10]:
(torch.Size([5, 5]), ('rows', 'columns'))

如果我们尝试结合具有不同名称的维度,我们会收到一个错误:

gray_named = (img_named[..., :3] * weights_named).sum('channels')

 attempting to broadcast dims ['channels', 'rows',
  'columns'] and dims ['channels']: dim 'columns' and dim 'channels'
  are at the same position from the right but do not match.

如果我们想在不操作命名张量的函数之外使用张量,我们需要通过将它们重命名为 None 来删除名称。以下操作使我们回到无名称维度的世界:

# In[12]:
gray_plain = gray_named.rename(None)
gray_plain.shape, gray_plain.names

# Out[12]:
(torch.Size([5, 5]), (None, None))

鉴于在撰写时此功能的实验性质,并为避免处理索引和对齐,我们将在本书的其余部分坚持使用无名称。命名张量有潜力消除许多对齐错误的来源,这些错误——如果以 PyTorch 论坛为例——可能是头痛的根源。看到它们将被广泛采用将是很有趣的。

3.5 张量元素类型

到目前为止,我们已经介绍了张量如何工作的基础知识,但我们还没有涉及可以存储在 Tensor 中的数值类型。正如我们在第 3.2 节中暗示的,使用标准的 Python 数值类型可能不是最佳选择,原因有几个:

  • Python 中的数字是对象。 虽然浮点数可能只需要,例如,32 位来在计算机上表示,但 Python 会将其转换为一个完整的 Python 对象,带有引用计数等等。这个操作,称为装箱,如果我们需要存储少量数字,那么这并不是问题,但分配数百万个数字会变得非常低效。

  • Python 中的列表用于对象的顺序集合。 没有为例如高效地计算两个向量的点积或将向量相加等操作定义。此外,Python 列表无法优化其内容在内存中的布局,因为它们是指向 Python 对象(任何类型,不仅仅是数字)的可索引指针集合。最后,Python 列表是一维的,虽然我们可以创建列表的列表,但这同样非常低效。

  • 与优化的编译代码相比,Python 解释器速度较慢。 在大量数值数据上执行数学运算时,使用在编译、低级语言如 C 中编写的优化代码可以更快地完成。

出于这些原因,数据科学库依赖于 NumPy 或引入专用数据结构如 PyTorch 张量,它们提供了高效的低级数值数据结构实现以及相关操作,并包装在方便的高级 API 中。为了实现这一点,张量中的对象必须都是相同类型的数字,并且 PyTorch 必须跟踪这种数值类型。

3.5.1 使用 dtype 指定数值类型

张量构造函数(如 tensorzerosones)的 dtype 参数指定了张量中将包含的数值数据类型。数据类型指定了张量可以保存的可能值(整数与浮点数)以及每个值的字节数。dtype 参数故意与同名的标准 NumPy 参数相似。以下是 dtype 参数可能的值列表:

  • torch.float32torch.float:32 位浮点数

  • torch.float64torch.double:64 位,双精度浮点数

  • torch.float16torch.half:16 位,半精度浮点数

  • torch.int8:有符号 8 位整数

  • torch.uint8:无符号 8 位整数

  • torch.int16torch.short:有符号 16 位整数

  • torch.int32torch.int:有符号 32 位整数

  • torch.int64torch.long:有符号 64 位整数

  • torch.bool:布尔值

张量的默认数据类型是 32 位浮点数。

3.5.2 每个场合的 dtype

正如我们将在未来的章节中看到的,神经网络中发生的计算通常以 32 位浮点精度执行。更高的精度,如 64 位,不会提高模型的准确性,并且会消耗更多的内存和计算时间。16 位浮点、半精度数据类型在标准 CPU 上并不存在,但在现代 GPU 上提供。如果需要,可以切换到半精度以减少神经网络模型的占用空间,对准确性的影响很小。

张量可以用作其他张量的索引。在这种情况下,PyTorch 期望索引张量具有 64 位整数数据类型。使用整数作为参数创建张量,例如使用 torch.tensor([2, 2]),将默认创建一个 64 位整数张量。因此,我们将大部分时间处理 float32int64

最后,关于张量的谓词,如 points > 1.0,会产生 bool 张量,指示每个单独元素是否满足条件。这就是数值类型的要点。

3.5.3 管理张量的 dtype 属性

为了分配正确数值类型的张量,我们可以将适当的 dtype 作为构造函数的参数指定。例如:

# In[47]:
double_points = torch.ones(10, 2, dtype=torch.double)
short_points = torch.tensor([[1, 2], [3, 4]], dtype=torch.short)

通过访问相应的属性,我们可以了解张量的 dtype

# In[48]:
short_points.dtype

# Out[48]:
torch.int16

我们还可以使用相应的转换方法将张量创建函数的输出转换为正确的类型,例如

# In[49]:
double_points = torch.zeros(10, 2).double()
short_points = torch.ones(10, 2).short()

或更方便的 to 方法:

# In[50]:
double_points = torch.zeros(10, 2).to(torch.double)
short_points = torch.ones(10, 2).to(dtype=torch.short)

在幕后,to 检查转换是否必要,并在必要时执行。像 float 这样以 dtype 命名的转换方法是 to 的简写,但 to 方法可以接受我们将在第 3.9 节讨论的其他参数。

在操作中混合输入类型时,输入会自动转换为较大的类型。因此,如果我们想要 32 位计算,我们需要确保所有输入都是(最多)32 位:

# In[51]:
points_64 = torch.rand(5, dtype=torch.double)     # ❶
points_short = points_64.to(torch.short)
points_64 * points_short  # works from PyTorch 1.3 onwards

# Out[51]:
tensor([0., 0., 0., 0., 0.], dtype=torch.float64)

❶ rand 将张量元素初始化为介于 0 和 1 之间的随机数。

3.6 张量 API

到目前为止,我们知道 PyTorch 张量是什么,以及它们在幕后是如何工作的。在我们结束之前,值得看一看 PyTorch 提供的张量操作。在这里列出它们都没有太大用处。相反,我们将对 API 有一个大致了解,并在在线文档 pytorch.org/docs 中确定一些查找内容的方向。

首先,大多数张量上的操作都可以在 torch 模块中找到,并且也可以作为张量对象的方法调用。例如,我们之前遇到的 transpose 函数可以从 torch 模块中使用

# In[71]:
a = torch.ones(3, 2)
a_t = torch.transpose(a, 0, 1)

a.shape, a_t.shape

# Out[71]:
(torch.Size([3, 2]), torch.Size([2, 3]))

或作为 a 张量的方法:

# In[72]:
a = torch.ones(3, 2)
a_t = a.transpose(0, 1)

a.shape, a_t.shape

# Out[72]:
(torch.Size([3, 2]), torch.Size([2, 3]))

这两种形式之间没有区别;它们可以互换使用。

我们 之前提到过在线文档 (pytorch.org/docs)。它们非常详尽且组织良好,将张量操作分成了不同的组:

创建操作 --用于构建张量的函数,如 onesfrom_numpy

索引、切片、连接、变异操作 --用于改变张量形状、步幅或内容的函数,如 transpose

数学操作 --通过计算来操作张量内容的函数

  • 逐点操作 --通过独立地对每个元素应用函数来获取新张量的函数,如 abscos

  • 缩减操作 --通过迭代张量计算聚合值的函数,如 meanstdnorm

  • 比较操作 --用于在张量上评估数值谓词的函数,如 equalmax

  • 频谱操作 --用于在频域中进行转换和操作的函数,如 stfthamming_window

  • 其他操作 --在向量上操作的特殊函数,如 cross,或在矩阵上操作的函数,如 trace

  • BLAS 和 LAPACK 操作 --遵循基本线性代数子程序(BLAS)规范的函数,用于标量、向量-向量、矩阵-向量和矩阵-矩阵操作

随机抽样 --通过从概率分布中随机抽取值生成值的函数,如randnnormal

序列化 --用于保存和加载张量的函数,如loadsave

并行性 --用于控制并行 CPU 执行线程数的函数,如set_num_threads

花些时间玩玩通用张量 API。本章提供了进行这种交互式探索所需的所有先决条件。随着我们继续阅读本书,我们还将遇到几个张量操作,从下一章开始。

3.7 张量:存储的景观

是时候更仔细地查看底层实现了。张量中的值是由torch.Storage实例管理的连续内存块分配的。存储是一个一维数值数据数组:即,包含给定类型数字的连续内存块,例如float(表示浮点数的 32 位)或int64(表示整数的 64 位)。PyTorch 的Tensor实例是这样一个Storage实例的视图,能够使用偏移量和每维步长索引到该存储中。⁵

图 3.4 张量是Storage实例的视图。

即使多个张量以不同方式索引数据,它们可以索引相同的存储。我们可以在图 3.4 中看到这种情况。实际上,在我们在第 3.2 节请求points[0]时,我们得到的是另一个索引与points张量相同存储的张量--只是不是全部,并且具有不同的维度(1D 与 2D)。然而,底层内存只分配一次,因此可以快速创建数据的备用张量视图,而不管Storage实例管理的数据大小如何。

3.7.1 存储索引

让我们看看如何在实践中使用我们的二维点进行存储索引。给定张量的存储可以通过.storage属性访问:

# In[17]:
points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
points.storage()

# Out[17]:
 4.0
 1.0
 5.0
 3.0
 2.0
 1.0
[torch.FloatStorage of size 6]

尽管张量报告自身具有三行和两列,但底层存储是一个大小为 6 的连续数组。在这种意义上,张量只知道如何将一对索引转换为存储中的位置。

我们也可以手动索引到存储中。例如:

# In[18]:
points_storage = points.storage()
points_storage[0]

# Out[18]:
4.0

# In[19]:
points.storage()[1]

# Out[19]:
1.0

我们不能使用两个索引索引二维张量的存储。存储的布局始终是一维的,而不管可能引用它的任何和所有张量的维度如何。

在这一点上,改变存储的值导致改变其引用张量的内容应该不会让人感到意外:

# In[20]:
points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
points_storage = points.storage()
points_storage[0] = 2.0
points

# Out[20]:
tensor([[2., 1.],
        [5., 3.],
        [2., 1.]])

3.7.2 修改存储的值:原地操作

除了前一节介绍的张量操作外,还存在一小部分操作仅作为Tensor对象的方法存在。它们可以通过名称末尾的下划线识别,比如zero_,表示该方法通过修改输入来原地操作,而不是创建新的输出张量并返回它。例如,zero_方法将所有输入元素都置零。任何没有末尾下划线的方法都不会改变源张量,并且会返回一个新的张量:

# In[73]:
a = torch.ones(3, 2)

# In[74]:
a.zero_()
a

# Out[74]:
tensor([[0., 0.],
        [0., 0.],
        [0., 0.]])

3.8 张量元数据:大小、偏移和步长

为了索引到存储中,张量依赖于一些信息,这些信息与它们的存储一起,明确定义它们:尺寸、偏移和步幅。它们的相互作用如图 3.5 所示。尺寸(或形状,在 NumPy 术语中)是一个元组,指示张量在每个维度上代表多少个元素。存储偏移是存储中对应于张量第一个元素的索引。步幅是在存储中需要跳过的元素数量,以获取沿每个维度的下一个元素。

图 3.5 张量的偏移、尺寸和步幅之间的关系。这里的张量是一个更大存储的视图,就像在创建更大的张量时可能分配的存储一样。

3.8.1 另一个张量存储的视图

通过提供相应的索引,我们可以获取张量中的第二个点:

# In[21]:
points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
second_point = points[1]
second_point.storage_offset()

# Out[21]:
2

# In[22]:
second_point.size()

# Out[22]:
torch.Size([2])

结果张量在存储中的偏移为 2(因为我们需要跳过第一个点,它有两个项目),尺寸是Size类的一个实例,包含一个元素,因为张量是一维的。重要的是要注意,这与张量对象的shape属性中包含的信息相同:

# In[23]:
second_point.shape

# Out[23]:
torch.Size([2])

步幅是一个元组,指示当索引在每个维度上增加 1 时,必须跳过存储中的元素数量。例如,我们的points张量的步幅是(2, 1)

# In[24]:
points.stride()

# Out[24]:
(2, 1)

在 2D 张量中访问元素i, j会导致访问存储中的storage_offset + stride[0] * i + stride[1] * j元素。偏移通常为零;如果这个张量是一个查看存储的视图,该存储是为容纳更大的张量而创建的,则偏移可能是一个正值。

TensorStorage之间的这种间接关系使得一些操作变得廉价,比如转置张量或提取子张量,因为它们不会导致内存重新分配。相反,它们包括为尺寸、存储偏移或步幅分配一个具有不同值的新Tensor对象。

当我们索引特定点并看到存储偏移增加时,我们已经提取了一个子张量。让我们看看尺寸和步幅会发生什么变化:

# In[25]:
second_point = points[1]
second_point.size()

# Out[25]:
torch.Size([2])

# In[26]:
second_point.storage_offset()

# Out[26]:
2

# In[27]:
second_point.stride()

# Out[27]:
(1,)

底线是,子张量的维度少了一个,正如我们所期望的那样,同时仍然索引与原始points张量相同的存储。这也意味着改变子张量将对原始张量产生副作用:

# In[28]:
points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
second_point = points[1]
second_point[0] = 10.0
points

# Out[28]:
tensor([[ 4.,  1.],
        [10.,  3.],
        [ 2.,  1.]])

这可能并不总是理想的,所以我们最终可以将子张量克隆到一个新的张量中:

# In[29]:
points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
second_point = points[1].clone()
second_point[0] = 10.0
points

# Out[29]:
tensor([[4., 1.],
        [5., 3.],
        [2., 1.]])

3.8.2 在不复制的情况下转置

现在让我们尝试转置。让我们拿出我们的points张量,其中行中有单独的点,列中有XY坐标,并将其转向,使单独的点在列中。我们借此机会介绍t函数,这是二维张量的transpose的简写替代品:

# In[30]:
points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
points

# Out[30]:
tensor([[4., 1.],
        [5., 3.],
        [2., 1.]])

# In[31]:
points_t = points.t()
points_t

# Out[31]:
tensor([[4., 5., 2.],
        [1., 3., 1.]])

提示 为了帮助建立对张量机制的扎实理解,可能是一个好主意拿起一支铅笔和一张纸,像图 3.5 中的图一样在我们逐步执行本节代码时涂鸦图表。

我们可以轻松验证这两个张量共享相同的存储

# In[32]:
id(points.storage()) == id(points_t.storage())

# Out[32]:
True

它们只在形状和步幅上有所不同:

# In[33]:
points.stride()

# Out[33]:
(2, 1)
# In[34]:
points_t.stride()

# Out[34]:
(1, 2)

这告诉我们,在points中将第一个索引增加 1(例如,从points[0,0]points[1,0])将会跳过存储中的两个元素,而增加第二个索引(从points[0,0]points[0,1])将会跳过存储中的一个元素。换句话说,存储按行顺序顺序保存张量中的元素。

我们可以将points转置为points_t,如图 3.6 所示。我们改变了步幅中元素的顺序。之后,增加行(张量的第一个索引)将沿着存储跳过一个元素,就像我们在points中沿着列移动一样。这就是转置的定义。不会分配新的内存:转置只是通过创建一个具有不同步幅顺序的新Tensor实例来实现的。

图 3.6 张量的转置操作

3.8.3 高维度中的转置

在 PyTorch 中,转置不仅限于矩阵。我们可以通过指定应该发生转置(翻转形状和步幅)的两个维度来转置多维数组:

# In[35]:
some_t = torch.ones(3, 4, 5)
transpose_t = some_t.transpose(0, 2)
some_t.shape

# Out[35]:
torch.Size([3, 4, 5])

# In[36]:
transpose_t.shape

# Out[36]:
torch.Size([5, 4, 3])

# In[37]:
some_t.stride()

# Out[37]:
(20, 5, 1)

# In[38]:
transpose_t.stride()

# Out[38]:
(1, 5, 20)

从存储中右起维度开始排列数值(即,对于二维张量,沿着行移动)的张量被定义为contiguous。连续张量很方便,因为我们可以有效地按顺序访问它们,而不需要在存储中跳跃(改善数据局部性会提高性能,因为现代 CPU 的内存访问方式)。当然,这种优势取决于算法的访问方式。

3.8.4 连续张量

PyTorch 中的一些张量操作仅适用于连续张量,例如我们将在下一章中遇到的view。在这种情况下,PyTorch 将抛出一个信息性异常,并要求我们显式调用contiguous。值得注意的是,如果张量已经是连续的,则调用contiguous不会做任何事情(也不会影响性能)。

在我们的例子中,points是连续的,而其转置则不是:

# In[39]:
points.is_contiguous()

# Out[39]:
True

# In[40]:
points_t.is_contiguous()

# Out[40]:
False

我们可以使用contiguous方法从非连续张量中获得一个新的连续张量。张量的内容将保持不变,但步幅和存储将发生变化:

# In[41]:
points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]])
points_t = points.t()
points_t

# Out[41]:
tensor([[4., 5., 2.],
        [1., 3., 1.]])

# In[42]:
points_t.storage()

# Out[42]:
 4.0
 1.0
 5.0
 3.0
 2.0
 1.0
[torch.FloatStorage of size 6]

# In[43]:
points_t.stride()

# Out[43]:
(1, 2)

# In[44]:
points_t_cont = points_t.contiguous()
points_t_cont

# Out[44]:
tensor([[4., 5., 2.],
        [1., 3., 1.]])

# In[45]:
points_t_cont.stride()

# Out[45]:
(3, 1)

# In[46]:
points_t_cont.storage()

# Out[46]:
 4.0
 5.0
 2.0
 1.0
 3.0
 1.0
[torch.FloatStorage of size 6]

请注意,存储已经重新排列,以便元素按行排列在新存储中。步幅已更改以反映新布局。

作为复习,图 3.7 再次显示了我们的图表。希望现在我们已经仔细研究了张量是如何构建的,一切都会变得清晰。

图 3.7 张量的偏移、大小和步幅之间的关系。这里的张量是一个更大存储的视图,就像在创建更大的张量时可能分配的存储一样。

3.9 将张量移动到 GPU

到目前为止,在本章中,当我们谈论存储时,我们指的是 CPU 上的内存。PyTorch 张量也可以存储在不同类型的处理器上:图形处理单元(GPU)。每个 PyTorch 张量都可以传输到 GPU 中的一个(或多个)以执行高度并行、快速的计算。将在张量上执行的所有操作都将使用 PyTorch 提供的 GPU 特定例程执行。

PyTorch 对各种 GPU 的支持

截至 2019 年中期,主要的 PyTorch 发行版只在支持 CUDA 的 GPU 上有加速。PyTorch 可以在 AMD 的 ROCm 上运行(rocm.github.io),主存储库提供支持,但到目前为止,您需要自行编译它。(在常规构建过程之前,您需要运行tools/amd_build/build_amd.py来转换 GPU 代码。)对 Google 的张量处理单元(TPU)的支持正在进行中(github.com/pytorch/xla),当前的概念验证可在 Google Colab 上公开访问:https://colab.research.google.com。在撰写本文时,不计划在其他 GPU 技术(如 OpenCL)上实现数据结构和内核。](https://colab.research.google.com)

3.9.1 管理张量的设备属性

除了dtype,PyTorch 的Tensor还有device的概念,即张量数据所放置的计算机位置。以下是我们如何通过为构造函数指定相应参数来在 GPU 上创建张量的方法:

# In[64]:
points_gpu = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]], device='cuda')

我们可以使用to方法将在 CPU 上创建的张量复制到 GPU 上:

# In[65]:
points_gpu = points.to(device='cuda')

这样做会返回一个新的张量,其中包含相同的数值数据,但存储在 GPU 的 RAM 中,而不是常规系统 RAM 中。现在数据存储在 GPU 上,当对张量执行数学运算时,我们将开始看到之前提到的加速效果。在几乎所有情况下,基于 CPU 和 GPU 的张量都暴露相同的用户接口,这样编写代码就更容易,不用关心重要的数值计算到底在哪里运行。

如果我们的机器有多个 GPU,我们还可以通过传递一个从零开始的整数来决定将张量分配到哪个 GPU 上,例如

# In[66]:
points_gpu = points.to(device='cuda:0')

此时,对张量执行的任何操作,例如将所有元素乘以一个常数,都是在 GPU 上执行的:

# In[67]:
points = 2 * points                        # ❶
points_gpu = 2 * points.to(device='cuda')  # ❷

❶ 在 CPU 上执行的乘法

❷ 在 GPU 上执行的乘法

请注意,points_gpu张量在计算结果后并没有返回到 CPU。这是这一行中发生的事情:

  1. points张量被复制到 GPU 上。

  2. 在 GPU 上分配一个新的张量,并用于存储乘法的结果。

  3. 返回一个指向该 GPU 张量的句柄。

因此,如果我们还向结果添加一个常数

# In[68]:
points_gpu = points_gpu + 4

加法仍然在 GPU 上执行,没有信息流向 CPU(除非我们打印或访问生成的张量)。为了将张量移回 CPU,我们需要在to方法中提供一个cpu参数,例如

# In[69]:
points_cpu = points_gpu.to(device='cpu')

我们还可以使用cpucuda的简写方法,而不是to方法来实现相同的目标:

# In[70]:
points_gpu = points.cuda()      # ❶
points_gpu = points.cuda(0)
points_cpu = points_gpu.cpu()

❶ 默认为 GPU 索引 0

还值得一提的是,通过使用to方法,我们可以通过同时提供devicedtype作为参数来同时更改位置和数据类型。

3.10 NumPy 互操作性

我们在这里和那里提到了 NumPy。虽然我们不认为 NumPy 是阅读本书的先决条件,但我们强烈建议您熟悉 NumPy,因为它在 Python 数据科学生态系统中无处不在。PyTorch 张量可以与 NumPy 数组之间进行非常高效的转换。通过这样做,我们可以利用围绕 NumPy 数组类型构建起来的 Python 生态系统中的大量功能。这种与 NumPy 数组的零拷贝互操作性归功于存储系统与 Python 缓冲区协议的工作(docs.python.org/3/c-api/buffer.html)。

要从我们的points张量中获取一个 NumPy 数组,我们只需调用

# In[55]:
points = torch.ones(3, 4)
points_np = points.numpy()
points_np

# Out[55]:
array([[1., 1., 1., 1.],
       [1., 1., 1., 1.],
       [1., 1., 1., 1.]], dtype=float32)

这将返回一个正确大小、形状和数值类型的 NumPy 多维数组。有趣的是,返回的数组与张量存储共享相同的底层缓冲区。这意味着numpy方法可以在基本上不花费任何成本地执行,只要数据位于 CPU RAM 中。这也意味着修改 NumPy 数组将导致源张量的更改。如果张量分配在 GPU 上,PyTorch 将把张量内容复制到在 CPU 上分配的 NumPy 数组中。

相反,我们可以通过以下方式从 NumPy 数组获得一个 PyTorch 张量

# In[56]:
points = torch.from_numpy(points_np)

这将使用我们刚刚描述的相同的缓冲区共享策略。

注意 PyTorch 中的默认数值类型是 32 位浮点数,而 NumPy 中是 64 位。正如在第 3.5.2 节中讨论的那样,我们通常希望使用 32 位浮点数,因此在转换后,我们需要确保具有dtype torch .float的张量。

3.11 广义张量也是张量

对于本书的目的,以及一般大多数应用程序,张量都是多维数组,就像我们在本章中看到的那样。如果我们冒险窥探 PyTorch 的内部,会有一个转折:底层数据存储方式与我们在第 3.6 节讨论的张量 API 是分开的。只要满足该 API 的约定,任何实现都可以被视为张量!

PyTorch 将调用正确的计算函数,无论我们的张量是在 CPU 还是 GPU 上。这是通过调度机制实现的,该机制可以通过将用户界面 API 连接到正确的后端函数来满足其他张量类型的需求。确实,还有其他种类的张量:有些特定于某些类别的硬件设备(如 Google TPU),而其他的数据表示策略与我们迄今所见的稠密数组风格不同。例如,稀疏张量仅存储非零条目,以及索引信息。图 3.8 左侧的 PyTorch 调度程序被设计为可扩展的;图 3.8 右侧所示的用于适应各种数字类型的后续切换是实现的固定方面,编码到每个后端中。

图 3.8 PyTorch 中的调度程序是其关键基础设施之一。

我们将在第十五章遇到量化张量,它们作为另一种具有专门计算后端的张量类型实现。有时,我们使用的通常张量被称为稠密分步,以区别于使用其他内存布局的张量。

与许多事物一样,随着 PyTorch 支持更广泛的硬件和应用程序范围,张量种类的数量也在增加。我们可以期待随着人们探索用 PyTorch 表达和执行计算的新方法,新的种类将继续出现。

3.12 序列化张量

在需要的时候,即使现场创建张量也很好,但如果其中的数据很有价值,我们会希望将其保存到文件中,并在某个时候加载回来。毕竟,我们不想每次运行程序时都从头开始重新训练模型!PyTorch 在底层使用pickle来序列化张量对象,还有专门的存储序列化代码。这里是如何将我们的points张量保存到一个 ourpoints.t 文件中的方法:

# In[57]:
torch.save(points, '../data/p1ch3/ourpoints.t')

作为替代方案,我们可以传递文件描述符而不是文件名:

# In[58]:
with open('../data/p1ch3/ourpoints.t','wb') as f:
   torch.save(points, f)

类似地,加载我们的点也是一行代码

# In[59]:
points = torch.load('../data/p1ch3/ourpoints.t')

或者,等效地,

# In[60]:
with open('../data/p1ch3/ourpoints.t','rb') as f:
   points = torch.load(f)

虽然我们可以快速以这种方式保存张量,如果我们只想用 PyTorch 加载它们,但文件格式本身不具有互操作性:我们无法使用除 PyTorch 之外的软件读取张量。根据使用情况,这可能是一个限制,也可能不是,但我们应该学会如何在需要时以互操作的方式保存张量。接下来我们将看看如何做到这一点。

3.12.1 使用 h5py 序列化到 HDF5

每种用例都是独特的,但我们怀疑在将 PyTorch 引入已经依赖不同库的现有系统时,需要以互操作方式保存张量将更常见。新项目可能不需要这样做那么频繁。

然而,在需要时,您可以使用 HDF5 格式和库(www.hdfgroup.org/solutions/hdf5)。HDF5 是一种便携式、广泛支持的格式,用于表示序列化的多维数组,以嵌套的键值字典组织。Python 通过h5py库(www.h5py.org)支持 HDF5,该库接受并返回 NumPy 数组形式的数据。

我们可以使用以下命令安装h5py

$ conda install h5py

在这一点上,我们可以通过将其转换为 NumPy 数组(如前所述,没有成本)并将其传递给create_dataset函数来保存我们的points张量:

# In[61]:
import h5py

f = h5py.File('../data/p1ch3/ourpoints.hdf5', 'w')
dset = f.create_dataset('coords', data=points.numpy())
f.close()

这里的'coords'是 HDF5 文件中的一个键。我们可以有其他键--甚至是嵌套的键。在 HDF5 中的一个有趣之处是,我们可以在磁盘上索引数据集,并且只访问我们感兴趣的元素。假设我们只想加载数据集中的最后两个点:

# In[62]:
f = h5py.File('../data/p1ch3/ourpoints.hdf5', 'r')
dset = f['coords']
last_points = dset[-2:]

当打开文件或需要数据集时,数据不会被加载。相反,数据会保留在磁盘上,直到我们请求数据集中的第二行和最后一行。在那时,h5py访问这两列并返回一个类似 NumPy 数组的对象,封装了数据集中的那个区域,行为类似 NumPy 数组,并具有相同的 API。

由于这个事实,我们可以将返回的对象传递给torch.from_numpy函数,直接获得一个张量。请注意,在这种情况下,数据被复制到张量的存储中:

# In[63]:
last_points = torch.from_numpy(dset[-2:])
f.close()

加载数据完成后,我们关闭文件。关闭 HDFS 文件会使数据集无效,尝试在此之后访问dset将导致异常。只要我们按照这里显示的顺序进行操作,我们就可以正常工作并现在可以使用last_points张量。

3.13 结论

现在我们已经涵盖了我们需要开始用浮点数表示一切的一切。我们将根据需要涵盖张量的其他方面--例如创建张量的视图;使用其他张量对张量进行索引;以及广播,简化了在不同大小或形状的张量之间执行逐元素操作的操作--。

在第四章中,我们将学习如何在 PyTorch 中表示现实世界的数据。我们将从简单的表格数据开始,然后转向更复杂的内容。在这个过程中,我们将更多地了解张量。

3.14 练习

  1. list(range(9))创建一个张量a。预测并检查大小、偏移和步长。

    1. 使用b = a.view(3, 3)创建一个新的张量。view函数的作用是什么?检查ab是否共享相同的存储。

    2. 创建一个张量c = b[1:,1:]。预测并检查大小、偏移和步长。

  2. 选择一个数学运算,如余弦或平方根。你能在torch库中找到相应的函数吗?

    1. a逐元素应用函数。为什么会返回错误?

    2. 使函数工作需要什么操作?

    3. 是否有一个在原地操作的函数版本?

3.15 总结

  • 神经网络将浮点表示转换为其他浮点表示。起始和结束表示通常是人类可解释的,但中间表示则不太容易理解。

  • 这些浮点表示存储在张量中。

  • 张量是多维数组;它们是 PyTorch 中的基本数据结构。

  • PyTorch 拥有一个全面的标准库,用于张量的创建、操作和数学运算。

  • 张量可以序列化到磁盘并重新加载。

  • PyTorch 中的所有张量操作都可以在 CPU 和 GPU 上执行,而不需要更改代码。

  • PyTorch 使用尾随下划线来表示一个函数在张量上的原地操作(例如,Tensor.sqrt_)。


¹ 由于感知不是一个简单的规范,人们提出了许多权重。例如,参见en.wikipedia.org/wiki/Luma_(video)

²Tim Rocktäschel 的博文“Einsum is All You Need--Einstein Summation in Deep Learning”( rockt.github.io/2018/04/30/einsum)提供了很好的概述。

³ 参见 Sasha Rush 的博文“Tensor Considered Harmful”,Harvardnlp,nlp.seas.harvard.edu/NamedTensor

⁴ 以及在uint8的情况下的符号。

⁵ 在未来的 PyTorch 版本中,Storage可能无法直接访问,但我们在这里展示的内容仍然提供了张量在内部工作方式的良好思维图。

四、使用张量表示真实世界数据

本章内容包括

  • 将现实世界的数据表示为 PyTorch 张量

  • 处理各种数据类型

  • 从文件加载数据

  • 将数据转换为张量

  • 塑造张量,使其可以作为神经网络模型的输入

在上一章中,我们了解到张量是 PyTorch 中数据的构建块。神经网络将张量作为输入,并产生张量作为输出。事实上,神经网络内部的所有操作以及优化过程中的所有操作都是张量之间的操作,神经网络中的所有参数(例如权重和偏置)都是张量。对于成功使用 PyTorch 这样的工具,对张量执行操作并有效地对其进行索引的能力至关重要。现在您已经了解了张量的基础知识,随着您在本书中的学习过程中,您对张量的灵活性将会增长。

现在我们可以回答一个问题:我们如何将一段数据、一个视频或一行文本表示为张量,以便适合训练深度学习模型?这就是我们将在本章学习的内容。我们将重点介绍与本书相关的数据类型,并展示如何将这些数据表示为张量。然后,我们将学习如何从最常见的磁盘格式加载数据,并了解这些数据类型的结构,以便了解如何准备它们用于训练神经网络。通常,我们的原始数据不会完全符合我们想要解决的问题,因此我们将有机会通过一些更有趣的张量操作来练习我们的张量操作技能。

本章的每个部分将描述一种数据类型,并且每种数据类型都将配有自己的数据集。虽然我们已经将本章结构化,使得每种数据类型都建立在前一种数据类型的基础上,但如果你愿意,可以随意跳跃一下。

在本书的其余部分中,我们将使用大量图像和体积数据,因为这些是常见的数据类型,并且在书籍格式中可以很好地再现。我们还将涵盖表格数据、时间序列和文本,因为这些也将引起许多读者的兴趣。因为一图胜千言,我们将从图像数据开始。然后,我们将演示使用代表患者解剖结构的医学数据的三维数组。接下来,我们将处理关于葡萄酒的表格数据,就像我们在电子表格中找到的那样。之后,我们将转向有序表格数据,使用来自自行车共享计划的时间序列数据集。最后,我们将涉足简·奥斯汀的文本数据。文本数据保留了其有序性,但引入了将单词表示为数字数组的问题。

在每个部分中,我们将在深度学习研究人员开始的地方停下来:就在将数据馈送给模型之前。我们鼓励您保留这些数据集;它们将成为我们在下一章开始学习如何训练神经网络模型时的优秀材料。

4.1 处理图像

卷积神经网络的引入彻底改变了计算机视觉(参见 mng.bz/zjMa),基于图像的系统随后获得了全新的能力。以前需要高度调整的算法构建块的复杂流水线现在可以通过使用成对的输入和期望输出示例训练端到端网络以前所未有的性能水平解决。为了参与这场革命,我们需要能够从常见的图像格式中加载图像,然后将数据转换为 PyTorch 期望的方式排列图像各部分的张量表示。

图像被表示为一个规则网格中排列的标量集合,具有高度和宽度(以像素为单位)。 我们可能在每个网格点(像素)上有一个单一的标量,这将被表示为灰度图像;或者在每个网格点上有多个标量,这通常代表不同的颜色,就像我们在上一章中看到的那样,或者不同的 特征,比如来自深度相机的深度。

表示单个像素值的标量通常使用 8 位整数进行编码,如消费级相机。 在医疗、科学和工业应用中,发现更高的数值精度,如 12 位或 16 位,是很常见的。 这允许在像骨密度、温度或深度等物理属性的像素编码信息的情况下拥有更广泛的范围或增加灵敏度。

4.1.1 添加颜色通道

我们之前提到过颜色。 有几种将颜色编码为数字的方法。 最常见的是 RGB,其中颜色由表示红色、绿色和蓝色强度的三个数字定义。 我们可以将颜色通道看作是仅包含所讨论颜色的灰度强度图,类似于如果您戴上一副纯红色太阳镜看到的场景。 图 4.1 展示了一个彩虹,其中每个 RGB 通道捕获光谱的某个部分(该图简化了,省略了将橙色和黄色带表示为红色和绿色组合的内容)。

图 4.1 彩虹,分为红色、绿色和蓝色通道

彩虹的红色带在图像的红色通道中最亮,而蓝色通道既有彩虹的蓝色带又有天空作为高强度。 还要注意,白云在所有三个通道中都是高强度的。

4.1.2 加载图像文件

图像有多种不同的文件格式,但幸运的是在 Python 中有很多加载图像的方法。 让我们从使用 imageio 模块加载 PNG 图像开始(code/p1ch4/1_image_dog.ipynb)。

列表 4.1 code/p1ch4/1_image_dog.ipynb

# In[2]:
import imageio

img_arr = imageio.imread('../data/p1ch4/image-dog/bobby.jpg')
img_arr.shape

# Out[2]:
(720, 1280, 3)

注意 我们将在整个章节中使用 imageio,因为它使用统一的 API 处理不同的数据类型。 对于许多目的,使用 TorchVision 处理图像和视频数据是一个很好的默认选择。 我们在这里选择 imageio 进行稍微轻松的探索。

此时,img 是一个类似于 NumPy 数组的对象,具有三个维度:两个空间维度,宽度和高度;以及第三个维度对应于红色、绿色和蓝色通道。 任何输出 NumPy 数组的库都足以获得 PyTorch 张量。 唯一需要注意的是维度的布局。 处理图像数据的 PyTorch 模块要求张量按照 C × H × W 的方式布局:通道、高度和宽度。

4.1.3 更改布局

我们可以使用张量的 permute 方法,使用旧维度替换每个新维度,以获得适当的布局。 给定一个先前获得的输入张量 H × W × C,通过首先将通道 2 放在前面,然后是通道 0 和 1,我们得到一个正确的布局:

# In[3]:
img = torch.from_numpy(img_arr)
out = img.permute(2, 0, 1)

我们之前看到过这个,但请注意,此操作不会复制张量数据。 相反,out 使用与 img 相同的底层存储,并且仅在张量级别上处理大小和步幅信息。 这很方便,因为该操作非常便宜; 但是需要注意的是:更改 img 中的像素将导致 out 中的更改。

还要注意,其他深度学习框架使用不同的布局。 例如,最初 TensorFlow 将通道维度保留在最后,导致 H × W × C 的布局(现在支持多种布局)。 从低级性能的角度来看,这种策略有利有弊,但就我们的问题而言,只要我们正确地重塑张量,就不会有任何区别。

到目前为止,我们描述了单个图像。按照我们之前用于其他数据类型的相同策略,为了创建一个包含多个图像的数据集,以用作神经网络的输入,我们将图像存储在一个批次中,沿着第一个维度获得一个N × C × H × W张量。

作为使用stack构建张量的略微更高效的替代方法,我们可以预先分配一个适当大小的张量,并用从目录加载的图像填充它,如下所示:

# In[4]:
batch_size = 3
batch = torch.zeros(batch_size, 3, 256, 256, dtype=torch.uint8)

这表明我们的批次将由三个 RGB 图像组成,高度为 256 像素,宽度为 256 像素。注意张量的类型:我们期望每种颜色都表示为 8 位整数,就像大多数标准消费级相机的照片格式一样。现在我们可以从输入目录加载所有 PNG 图像并将它们存储在张量中:

# In[5]:
import os

data_dir = '../data/p1ch4/image-cats/'
filenames = [name for name in os.listdir(data_dir)
             if os.path.splitext(name)[-1] == '.png']
for i, filename in enumerate(filenames):
    img_arr = imageio.imread(os.path.join(data_dir, filename))
    img_t = torch.from_numpy(img_arr)
    img_t = img_t.permute(2, 0, 1)
    img_t = img_t[:3]                   # ❶
    batch[i] = img_t

❶ 这里我们仅保留前三个通道。有时图像还具有表示透明度的 alpha 通道,但我们的网络只需要 RGB 输入。

4.1.4 数据归一化

我们之前提到神经网络通常使用浮点张量作为它们的输入。当输入数据的范围大致从 0 到 1,或从-1 到 1 时,神经网络表现出最佳的训练性能(这是由它们的构建块定义方式所决定的效果)。

因此,我们通常需要做的一件事是将张量转换为浮点数并对像素的值进行归一化。将其转换为浮点数很容易,但归一化则更加棘手,因为它取决于我们决定将输入的哪个范围置于 0 和 1 之间(或-1 和 1 之间)。一种可能性是仅通过 255(8 位无符号数中的最大可表示数)来除以像素的值:

# In[6]:
batch = batch.float()
batch /= 255.0

另一种可能性是计算输入数据的均值和标准差,并对其进行缩放,使输出在每个通道上具有零均值和单位标准差:

# In[7]:
n_channels = batch.shape[1]
for c in range(n_channels):
    mean = torch.mean(batch[:, c])
    std = torch.std(batch[:, c])
    batch[:, c] = (batch[:, c] - mean) / std

注意 这里,我们仅对一批图像进行归一化,因为我们还不知道如何操作整个数据集。在处理图像时,最好提前计算所有训练数据的均值和标准差,然后减去并除以这些固定的、预先计算的量。我们在第 2.1.4 节中的图像分类器的预处理中看到了这一点。

我们可以对输入执行几种其他操作,如几何变换(旋转、缩放和裁剪)。这些操作可能有助于训练,或者可能需要使任意输入符合网络的输入要求,比如图像的大小。我们将在第 12.6 节中遇到许多这些策略。现在,只需记住你有可用的图像处理选项即可。

4.2 3D 图像:体积数据

我们已经学会了如何加载和表示 2D 图像,就像我们用相机拍摄的那些图像一样。在某些情境下,比如涉及 CT(计算机断层扫描)扫描的医学成像应用中,我们通常处理沿着头到脚轴堆叠的图像序列,每个图像对应于人体的一个切片。在 CT 扫描中,强度代表了身体不同部位的密度--肺部、脂肪、水、肌肉和骨骼,按照密度递增的顺序--在临床工作站上显示 CT 扫描时,从暗到亮进行映射。每个点的密度是根据穿过身体后到达探测器的 X 射线量计算的,通过一些复杂的数学将原始传感器数据解卷积为完整体积。

CT(计算机断层扫描)只有一个强度通道,类似于灰度图像。这意味着通常情况下,原生数据格式中会省略通道维度;因此,类似于上一节,原始数据通常具有三个维度。通过将单个 2D 切片堆叠成 3D 张量,我们可以构建代表主体的 3D 解剖结构的体积数据。与我们在图 4.1 中看到的情况不同,图 4.2 中的额外维度代表的是物理空间中的偏移,而不是可见光谱中的特定波段。

图 4.2 从头部到下颌的 CT 扫描切片

本书的第二部分将致力于解决现实世界中的医学成像问题,因此我们不会深入讨论医学成像数据格式的细节。目前,可以说存储体积数据与图像数据的张量之间没有根本区别。我们只是在通道维度之后多了一个维度,深度,导致了一个形状为N × C × D × H × W的 5D 张量。

4.2.1 加载专用格式

让我们使用imageio模块中的volread函数加载一个样本 CT 扫描,该函数以一个目录作为参数,并将所有数字影像与通信医学(DICOM)文件²组装成一个 NumPy 3D 数组(code/p1ch4/ 2_volumetric_ct.ipynb)。

代码清单 4.2 code/p1ch4/2_volumetric_ct.ipynb

# In[2]:
import imageio

dir_path = "../data/p1ch4/volumetric-dicom/2-LUNG 3.0  B70f-04083"
vol_arr = imageio.volread(dir_path, 'DICOM')
vol_arr.shape

# Out[2]:
Reading DICOM (examining files): 1/99 files (1.0%99/99 files (100.0%)
  Found 1 correct series.
Reading DICOM (loading data): 31/99  (31.392/99  (92.999/99  (100.0%)

(99, 512, 512)

就像在第 4.1.3 节中所述,布局与 PyTorch 期望的不同,因为没有通道信息。因此,我们将使用unsqueezechannel维度腾出空间:

# In[3]:
vol = torch.from_numpy(vol_arr).float()
vol = torch.unsqueeze(vol, 0)

vol.shape

# Out[3]:
torch.Size([1, 99, 512, 512])

此时,我们可以通过沿着batch方向堆叠多个体积来组装一个 5D 数据集,就像我们在上一节中所做的那样。在第二部分中我们将看到更多的 CT 数据。

4.3 表格数据的表示

在机器学习工作中我们会遇到的最简单形式的数据位于电子表格、CSV 文件或数据库中。无论媒介如何,它都是一个包含每个样本(或记录)一行的表格,其中列包含关于我们样本的一条信息。

起初,我们假设表格中样本出现的顺序没有意义:这样的表格是独立样本的集合,不像时间序列那样,其中样本由时间维度相关联。

列可能包含数值,例如特定位置的温度;或标签,例如表示样本属性的字符串,如“蓝色”。因此,表格数据通常不是同质的:不同列的类型不同。我们可能有一列显示苹果的重量,另一列用标签编码它们的颜色。

另一方面,PyTorch 张量是同质的。PyTorch 中的信息通常被编码为一个数字,通常是浮点数(尽管也支持整数类型和布尔类型)。这种数字编码是有意的,因为神经网络是数学实体,通过矩阵乘法和非线性函数的连续应用将实数作为输入并产生实数作为输出。

4.3.1 使用真实世界数据集

作为深度学习从业者的第一项工作是将异构的现实世界数据编码为浮点数张量,以便神经网络消费。互联网上有大量的表格数据集可供免费使用;例如,可以查看github.com/caesar0301/awesome-public-datasets。让我们从一些有趣的东西开始:葡萄酒!葡萄酒质量数据集是一个包含葡萄牙北部绿葡萄酒样本的化学特征和感官质量评分的免费表格。白葡萄酒数据集可以在这里下载:mng.bz/90Ol。为了方便起见,我们还在 Deep Learning with PyTorch Git 存储库中的 data/p1ch4/tabular-wine 目录下创建了数据集的副本。

该文件包含一个逗号分隔的值集合,由一个包含列名的标题行引导。前 11 列包含化学变量的值,最后一列包含从 0(非常糟糕)到 10(优秀)的感官质量评分。这些是数据集中按照它们出现的顺序的列名:

fixed acidity
volatile acidity
citric acid
residual sugar
chlorides
free sulfur dioxide
total sulfur dioxide
density
pH
sulphates
alcohol
quality

在这个数据集上的一个可能的机器学习任务是仅通过化学特征预测质量评分。不过,不用担心;机器学习不会很快消灭品酒。我们必须从某处获取训练数据!正如我们在图 4.3 中看到的,我们希望在我们的数据中的化学列和质量列之间找到一个关系。在这里,我们期望看到随着硫的减少,质量会提高。

图 4.3 我们(希望)在葡萄酒中硫和质量之间的关系

4.3.2 加载葡萄酒数据张量

然而,在进行这之前,我们需要以一种比在文本编辑器中打开文件更可用的方式来检查数据。让我们看看如何使用 Python 加载数据,然后将其转换为 PyTorch 张量。Python 提供了几种快速加载 CSV 文件的选项。三种流行的选项是

  • Python 自带的csv模块

  • NumPy

  • Pandas

第三个选项是最节省时间和内存的。然而,我们将避免在我们的学习轨迹中引入额外的库,只是因为我们需要加载一个文件。由于我们在上一节中已经介绍了 NumPy,并且 PyTorch 与 NumPy 有很好的互操作性,我们将选择这个。让我们加载我们的文件,并将生成的 NumPy 数组转换为 PyTorch 张量(code/p1ch4/3_tabular_wine.ipynb)。

代码清单 4.3 code/p1ch4/3_tabular_wine.ipynb

# In[2]:
import csv
wine_path = "../data/p1ch4/tabular-wine/winequality-white.csv"
wineq_numpy = np.loadtxt(wine_path, dtype=np.float32, delimiter=";",
                         skiprows=1)
wineq_numpy

# Out[2]:
array([[ 7\.  ,  0.27,  0.36, ...,  0.45,  8.8 ,  6\.  ],
       [ 6.3 ,  0.3 ,  0.34, ...,  0.49,  9.5 ,  6\.  ],
       [ 8.1 ,  0.28,  0.4 , ...,  0.44, 10.1 ,  6\.  ],
       ...,
       [ 6.5 ,  0.24,  0.19, ...,  0.46,  9.4 ,  6\.  ],
       [ 5.5 ,  0.29,  0.3 , ...,  0.38, 12.8 ,  7\.  ],
       [ 6\.  ,  0.21,  0.38, ...,  0.32, 11.8 ,  6\.  ]], dtype=float32)

在这里,我们只规定 2D 数组的类型应该是 32 位浮点数,用于分隔每行值的分隔符,以及不应读取第一行,因为它包含列名。让我们检查所有数据是否都已读取

# In[3]:
col_list = next(csv.reader(open(wine_path), delimiter=';'))

wineq_numpy.shape, col_list

# Out[3]:
((4898, 12),
 ['fixed acidity',
  'volatile acidity',
  'citric acid',
  'residual sugar',
  'chlorides',
  'free sulfur dioxide',
  'total sulfur dioxide',
  'density',
  'pH',
  'sulphates',
  'alcohol',
  'quality'])

然后将 NumPy 数组转换为 PyTorch 张量:

# In[4]:
wineq = torch.from_numpy(wineq_numpy)

wineq.shape, wineq.dtype

# Out[4]:
(torch.Size([4898, 12]), torch.float32)

此时,我们有一个包含所有列的浮点torch.Tensor,包括最后一列,指的是质量评分。³

连续值、有序值和分类值

当我们试图理解数据时,我们应该意识到三种不同类型的数值。第一种是连续值。当以数字表示时,这些值是最直观的。它们是严格有序的,各个值之间的差异具有严格的含义。声明 A 包比 B 包重 2 千克,或者 B 包比 A 包远 100 英里,无论 A 包是 3 千克还是 10 千克,或者 B 包来自 200 英里还是 2,000 英里,都有固定的含义。如果你在计数或测量带单位的东西,那么它很可能是一个连续值。文献实际上进一步将连续值分为不同类型:在前面的例子中,说某物体重两倍或距离远三倍是有意义的,因此这些值被称为比例尺。另一方面,一天中的时间具有差异的概念,但声称 6:00 比 3:00 晚两倍是不合理的;因此时间只提供一个区间尺度。

接下来是有序值。我们在连续值中具有的严格排序仍然存在,但值之间的固定关系不再适用。一个很好的例子是将小、中、大饮料排序,其中小映射到值 1,中 2,大 3。大饮料比中饮料大,就像 3 比 2 大一样,但这并不告诉我们有多大差异。如果我们将我们的 1、2 和 3 转换为实际容量(比如 8、12 和 24 液体盎司),那么它们将转变为区间值。重要的是要记住,我们不能在值上“做数学运算”以外的排序它们;尝试平均大=3 和小=1 并不会得到中等饮料!

最后,分类值既没有顺序也没有数值含义。这些通常只是分配了任意数字的可能性枚举。将水分配给 1,咖啡分配给 2,苏打分配给 3,牛奶分配给 4 就是一个很好的例子。将水放在第一位,牛奶放在最后一位并没有真正的逻辑;它们只是需要不同的值来区分它们。我们可以将咖啡分配给 10,牛奶分配给-3,这样也不会有显著变化(尽管在范围 0..N - 1 内分配值将对独热编码和我们将在第 4.5.4 节讨论的嵌入有优势)。因为数值值没有含义,它们被称为名义尺度。

4.3.3 表示分数

我们可以将分数视为连续变量,保留为实数,并执行回归任务,或将其视为标签并尝试从化学分析中猜测标签以进行分类任务。在这两种方法中,我们通常会从输入数据张量中删除分数,并将其保留在单独的张量中,以便我们可以将分数用作地面实况,而不将其作为模型的输入:

# In[5]:
data = wineq[:, :-1]     # ❶
data, data.shape

# Out[5]:
(tensor([[ 7.00,  0.27,  ...,  0.45,  8.80],
         [ 6.30,  0.30,  ...,  0.49,  9.50],
         ...,
         [ 5.50,  0.29,  ...,  0.38, 12.80],
         [ 6.00,  0.21,  ...,  0.32, 11.80]]), torch.Size([4898, 11]))

# In[6]:
target = wineq[:, -1]    # ❷
target, target.shape

# Out[6]:
(tensor([6., 6.,  ..., 7., 6.]), torch.Size([4898]))

❶ 选择所有行和除最后一列之外的所有列

❷ 选择所有行和最后一列

如果我们想要将target张量转换为标签张量,我们有两种选择,取决于策略或我们如何使用分类数据。一种方法是简单地将标签视为整数分数的向量:

# In[7]:
target = wineq[:, -1].long()
target

# Out[7]:
tensor([6, 6,  ..., 7, 6])

如果目标是字符串标签,比如葡萄酒颜色,为每个字符串分配一个整数编号将让我们遵循相同的方法。

4.3.4 独热编码

另一种方法是构建分数的独热编码:即,将 10 个分数中的每一个编码为一个具有 10 个元素的向量,其中所有元素均设置为 0,但一个元素在每个分数的不同索引上设置为 1。 这样,分数 1 可以映射到向量(1,0,0,0,0,0,0,0,0,0),分数 5 可以映射到(0,0,0,0,1,0,0,0,0,0),依此类推。请注意,分数对应于非零元素的索引纯属偶然:我们可以重新排列分配,从分类的角度来看,没有任何变化。

这两种方法之间有明显的区别。将葡萄酒质量分数保留在整数分数向量中会对分数产生排序--这在这种情况下可能是完全合适的,因为 1 分比 4 分低。它还会在分数之间引入某种距离:也就是说,1 和 3 之间的距离与 2 和 4 之间的距离相同。如果这对我们的数量成立,那就太好了。另一方面,如果分数完全是离散的,比如葡萄品种,独热编码将更适合,因为没有暗示的排序或距离。当分数是连续变量时,独热编码也适用,例如在整数分数之间没有意义的情况下,比如 2.4,对于应用程序来说要么是这个要么是那个

我们可以使用scatter_方法实现独热编码,该方法将源张量中的值沿提供的索引填充到张量中:

# In[8]:
target_onehot = torch.zeros(target.shape[0], 10)

target_onehot.scatter_(1, target.unsqueeze(1), 1.0)

# Out[8]:
tensor([[0., 0.,  ..., 0., 0.],
        [0., 0.,  ..., 0., 0.],
        ...,
        [0., 0.,  ..., 0., 0.],
        [0., 0.,  ..., 0., 0.]])

让我们看看scatter_做了什么。首先,我们注意到它的名称以下划线结尾。正如您在上一章中学到的,这是 PyTorch 中的一种约定,表示该方法不会返回新张量,而是会直接修改张量。scatter_的参数如下:

  • 指定以下两个参数的维度

  • 指示要散布元素索引的列张量

  • 包含要散布的元素或要散布的单个标量的张量(在本例中为 1)

换句话说,前面的调用读取,“对于每一行,取目标标签的索引(在我们的情况下与分数相符)并将其用作列索引设置值为 1.0。” 最终结果是一个编码分类信息的张量。

scatter_的第二个参数,索引张量,需要与我们要散布到的张量具有相同数量的维度。由于target_onehot有两个维度(4,898 × 10),我们需要使用unsqueeze添加一个额外的虚拟维度到target中:

# In[9]:
target_unsqueezed = target.unsqueeze(1)
target_unsqueezed

# Out[9]:
tensor([[6],
        [6],
        ...,
        [7],
        [6]])

调用unsqueeze函数会添加一个单例维度,将一个包含 4,898 个元素的 1D 张量转换为一个大小为 (4,898 × 1) 的 2D 张量,而不改变其内容--不会添加额外的元素;我们只是决定使用额外的索引来访问元素。也就是说,我们可以通过target[0]访问target的第一个元素,通过target_unsqueezed[0,0]访问其未挤压的对应元素。

PyTorch 允许我们在训练神经网络时直接使用类索引作为目标。但是,如果我们想将分数用作网络的分类输入,我们将不得不将其转换为一个独热编码张量。

4.3.5 何时进行分类

现在我们已经看到了如何处理连续和分类数据。您可能想知道早期边栏中讨论的有序情况是什么情况。对于这种情况,没有通用的处理方法;最常见的做法是将这些数据视为分类数据(失去排序部分,并希望也许我们的模型在训练过程中会捕捉到它,如果我们只有少数类别)或连续数据(引入一个任意的距离概念)。我们将在图 4.5 中的天气情况中采取后者。我们在图 4.4 中的一个小流程图中总结了我们的数据映射。

图 4.4 如何处理连续、有序和分类数据的列

让我们回到包含与化学分析相关的 11 个变量的data张量。我们可以使用 PyTorch 张量 API 中的函数以张量形式操作我们的数据。让我们首先获取每列的均值和标准差:

# In[10]:
data_mean = torch.mean(data, dim=0)
data_mean

# Out[10]:
tensor([6.85e+00, 2.78e-01, 3.34e-01, 6.39e+00, 4.58e-02, 3.53e+01,
        1.38e+02, 9.94e-01, 3.19e+00, 4.90e-01, 1.05e+01])

# In[11]:
data_var = torch.var(data, dim=0)
data_var

# Out[11]:
tensor([7.12e-01, 1.02e-02, 1.46e-02, 2.57e+01, 4.77e-04, 2.89e+02,
        1.81e+03, 8.95e-06, 2.28e-02, 1.30e-02, 1.51e+00])

在这种情况下,dim=0表示沿着维度 0 执行缩减。此时,我们可以通过减去均值并除以标准差来对数据进行归一化,这有助于学习过程(我们将在第五章的 5.4.4 节中更详细地讨论这一点):

# In[12]:
data_normalized = (data - data_mean) / torch.sqrt(data_var)
data_normalized

# Out[12]:
tensor([[ 1.72e-01, -8.18e-02,  ..., -3.49e-01, -1.39e+00],
        [-6.57e-01,  2.16e-01,  ...,  1.35e-03, -8.24e-01],
        ...,
        [-1.61e+00,  1.17e-01,  ..., -9.63e-01,  1.86e+00],
        [-1.01e+00, -6.77e-01,  ..., -1.49e+00,  1.04e+00]])

4.3.6 寻找阈值

接下来,让我们开始查看数据,看看是否有一种简单的方法可以一眼看出好酒和坏酒的区别。首先,我们将确定target中对应于得分小于或等于 3 的行:

# In[13]:
bad_indexes = target <= 3                                # ❶
bad_indexes.shape, bad_indexes.dtype, bad_indexes.sum()

# Out[13]:
(torch.Size([4898]), torch.bool, tensor(20))

❶ PyTorch 还提供比较函数,例如 torch.le(target, 3),但使用运算符似乎是一个很好的标准。

注意,bad_indexes中只有 20 个条目被设置为True!通过使用 PyTorch 中称为高级索引的功能,我们可以使用数据类型为torch.bool的张量来索引data张量。这将基本上将data过滤为仅包含索引张量中为True的项目(或行)的项。bad_indexes张量与target具有相同的形状,其值为FalseTrue,取决于我们的阈值与原始target张量中每个元素之间的比较结果:

# In[14]:
bad_data = data[bad_indexes]
bad_data.shape

# Out[14]:
torch.Size([20, 11])

注意,新的bad_data张量有 20 行,与bad_indexes张量中为True的行数相同。它保留了所有 11 列。现在我们可以开始获取关于被分为好、中等和差类别的葡萄酒的信息。让我们对每列进行.mean()操作:

# In[15]:
bad_data = data[target <= 3]
mid_data = data[(target > 3) & (target < 7)]    # ❶
good_data = data[target >= 7]

bad_mean = torch.mean(bad_data, dim=0)
mid_mean = torch.mean(mid_data, dim=0)
good_mean = torch.mean(good_data, dim=0)

for i, args in enumerate(zip(col_list, bad_mean, mid_mean, good_mean)):
    print('{:2} {:20} {:6.2f} {:6.2f} {:6.2f}'.format(i, *args))

# Out[15]:
 0 fixed acidity          7.60   6.89   6.73
 1 volatile acidity       0.33   0.28   0.27
 2 citric acid            0.34   0.34   0.33
 3 residual sugar         6.39   6.71   5.26
 4 chlorides              0.05   0.05   0.04
 5 free sulfur dioxide   53.33  35.42  34.55
 6 total sulfur dioxide 170.60 141.83 125.25
 7 density                0.99   0.99   0.99
 8 pH                     3.19   3.18   3.22
 9 sulphates              0.47   0.49   0.50
10 alcohol               10.34  10.26  11.42

❶ 对于布尔 NumPy 数组和 PyTorch 张量,& 运算符执行逻辑“与”操作。

看起来我们有所发现:乍一看,坏酒似乎具有更高的总二氧化硫含量,等等其他差异。我们可以使用总二氧化硫的阈值作为区分好酒和坏酒的粗略标准。让我们获取总二氧化硫列低于我们之前计算的中点的索引,如下所示:

# In[16]:
total_sulfur_threshold = 141.83
total_sulfur_data = data[:,6]
predicted_indexes = torch.lt(total_sulfur_data, total_sulfur_threshold)

predicted_indexes.shape, predicted_indexes.dtype, predicted_indexes.sum()

# Out[16]:
(torch.Size([4898]), torch.bool, tensor(2727))

这意味着我们的阈值意味着超过一半的葡萄酒将是高质量的。接下来,我们需要获取实际好葡萄酒的索引:

# In[17]:
actual_indexes = target > 5

actual_indexes.shape, actual_indexes.dtype, actual_indexes.sum()

# Out[17]:
(torch.Size([4898]), torch.bool, tensor(3258))

由于实际好酒比我们的阈值预测多约 500 瓶,我们已经有了不完美的确凿证据。现在我们需要看看我们的预测与实际排名的匹配程度。我们将在我们的预测索引和实际好酒索引之间执行逻辑“与”(记住每个都只是一个由零和一组成的数组),并使用这些一致的酒来确定我们的表现如何:

# In[18]:
n_matches = torch.sum(actual_indexes & predicted_indexes).item()
n_predicted = torch.sum(predicted_indexes).item()
n_actual = torch.sum(actual_indexes).item()

n_matches, n_matches / n_predicted, n_matches / n_actual

# Out[18]:
(2018, 0.74000733406674, 0.6193984039287906)

我们大约有 2,000 瓶酒是正确的!由于我们预测了 2,700 瓶酒,这给了我们 74%的机会,如果我们预测一瓶酒是高质量的,那它实际上就是。不幸的是,有 3,200 瓶好酒,我们只识别了其中的 61%。嗯,我们得到了我们签约的东西;这几乎比随机好不了多少!当然,这一切都很天真:我们确切地知道多个变量影响葡萄酒的质量,这些变量的值与结果之间的关系(可能是实际分数,而不是其二值化版本)可能比单个值的简单阈值更复杂。

实际上,一个简单的神经网络将克服所有这些限制,许多其他基本的机器学习方法也将克服这些限制。在接下来的两章中,一旦我们学会如何从头开始构建我们的第一个神经网络,我们将有解决这个问题的工具。我们还将在第十二章重新审视如何更好地评估我们的结果。现在让我们继续探讨其他数据类型。

4.4 处理时间序列

在前一节中,我们讨论了如何表示组织在平面表中的数据。正如我们所指出的,表中的每一行都是独立的;它们的顺序并不重要。或者等效地,没有列编码关于哪些行先出现和哪些行后出现的信息。

回到葡萄酒数据集,我们本可以有一个“年份”列,让我们看看葡萄酒质量是如何逐年演变的。不幸的是,我们手头没有这样的数据,但我们正在努力手动收集数据样本,一瓶一瓶地。在此期间,我们将转向另一个有趣的数据集:来自华盛顿特区自行车共享系统的数据,报告 2011-2012 年 Capital Bikeshare 系统中每小时租赁自行车的数量,以及天气和季节信息(可在此处找到:mng.bz/jgOx)。我们的目标是将一个平面的二维数据集转换为一个三维数据集,如图 4.5 所示。

图 4.5 将一维多通道数据集转换为二维多通道数据集,通过将每个样本的日期和小时分开到不同的轴上

4.4.1 添加时间维度

在源数据中,每一行是一个单独的小时数据(图 4.5 显示了这个的转置版本,以更好地适应打印页面)。我们希望改变每小时一行的组织方式,这样我们就有一个轴,它以每个索引增加一天的速度增加,另一个轴代表一天中的小时(与日期无关)。第三个轴将是我们的不同数据列(天气、温度等)。

让我们加载数据(code/p1ch4/4_time_series_bikes.ipynb)。

代码清单 4.4 code/p1ch4/4_time_series_bikes.ipynb

# In[2]:
bikes_numpy = np.loadtxt(
    "../data/p1ch4/bike-sharing-dataset/hour-fixed.csv",
    dtype=np.float32,
    delimiter=",",
    skiprows=1,
    converters={1: lambda x: float(x[8:10])})     # ❶
bikes = torch.from_numpy(bikes_numpy)
bikes

# Out[2]:
tensor([[1.0000e+00, 1.0000e+00,  ..., 1.3000e+01, 1.6000e+01],
        [2.0000e+00, 1.0000e+00,  ..., 3.2000e+01, 4.0000e+01],
        ...,
        [1.7378e+04, 3.1000e+01,  ..., 4.8000e+01, 6.1000e+01],
        [1.7379e+04, 3.1000e+01,  ..., 3.7000e+01, 4.9000e+01]])

❶ 将日期字符串转换为对应于第 1 列中的日期的数字

对于每个小时,数据集报告以下变量:

  • 记录索引:instant

  • 月份的日期:day

  • 季节:season1:春季,2:夏季,3:秋季,4:冬季)

  • 年份:yr0:2011,1:2012)

  • 月份:mnth112

  • 小时:hr023

  • 节假日状态:holiday

  • 一周的第几天:weekday

  • 工作日状态:workingday

  • 天气情况:weathersit1:晴朗,2:薄雾,3:小雨/小雪,4:大雨/大雪)

  • 摄氏度温度:temp

  • 摄氏度感知温度:atemp

  • 湿度:hum

  • 风速:windspeed

  • 休闲用户数量:casual

  • 注册用户数量:registered

  • 租赁自行车数量:cnt

在这样的时间序列数据集中,行代表连续的时间点:有一个维度沿着它们被排序。当然,我们可以将每一行视为独立的,并尝试根据一天中的特定时间来预测循环自行车的数量,而不考虑之前发生了什么。然而,存在排序给了我们利用时间上的因果关系的机会。例如,它允许我们根据较早时间下雨的事实来预测某个时间的骑车次数。目前,我们将专注于学习如何将我们的共享单车数据集转换为我们的神经网络能够以固定大小的块摄入的内容。

这个神经网络模型将需要看到每个不同数量的值的一些序列,比如骑行次数、时间、温度和天气条件:N个大小为C的并行序列。C代表神经网络术语中的通道,对于我们这里的 1D 数据来说,它与是相同的。N维度代表时间轴,这里每小时一个条目。

4.4.2 按时间段塑造数据

我们可能希望将这两年的数据集分成更宽的观测周期,比如天。这样我们将有N(用于样本数量)个长度为LC序列集合。换句话说,我们的时间序列数据集将是一个三维张量,形状为N × C × LC仍然是我们的 17 个通道,而L将是 24:每天的每小时一个。虽然我们必须使用 24 小时的块没有特别的原因,但一般的日常节奏可能会给我们可以用于预测的模式。如果需要,我们也可以使用 7 × 24 = 168 小时块按周划分。所有这些当然取决于我们的数据集具有正确的大小--行数必须是 24 或 168 的倍数。此外,为了使这有意义,我们的时间序列不能有间断。

让我们回到我们的共享单车数据集。第一列是索引(数据的全局排序),第二列是日期,第六列是一天中的时间。我们有一切需要创建每日骑行次数和其他外生变量序列的数据集。我们的数据集已经排序,但如果没有,我们可以使用torch.sort对其进行适当排序。

注意我们使用的文件版本 hour-fixed.csv 已经经过一些处理,包括在原始数据集中包含缺失的行。我们假设缺失的小时没有活跃的自行车(它们通常在清晨的小时内)。

要获得我们的每日小时数据集,我们只需将相同的张量按照 24 小时的批次查看。让我们看一下我们的bikes张量的形状和步幅:

# In[3]:
bikes.shape, bikes.stride()

# Out[3]:
(torch.Size([17520, 17]), (17, 1))

这是 17,520 小时,17 列。现在让我们重新塑造数据,使其具有 3 个轴--天、小时,然后我们的 17 列:

# In[4]:
daily_bikes = bikes.view(-1, 24, bikes.shape[1])
daily_bikes.shape, daily_bikes.stride()

# Out[4]:
(torch.Size([730, 24, 17]), (408, 17, 1))

这里发生了什么?首先,bikes.shape[1]是 17,即bikes张量中的列数。但这段代码的关键在于对view的调用,这非常重要:它改变了张量查看相同数据的方式,而数据实际上是包含在存储中的。

正如您在上一章中学到的,对张量调用view会返回一个新的张量,它会改变维度和步幅信息,但不会改变存储。这意味着我们可以在基本上零成本地重新排列我们的张量,因为不会复制任何数据。我们调用view需要为返回的张量提供新的形状。我们使用-1作为“剩下的索引数量,考虑到其他维度和原始元素数量”的占位符。

还要记住上一章中提到的存储是一个连续的、线性的数字容器(在本例中是浮点数)。我们的bikes张量将每一行按顺序存储在其相应的存储中。这是通过之前对bikes.stride()的调用输出来确认的。

对于daily_bikes,步幅告诉我们,沿着小时维度(第二维)前进 1 需要我们在存储中前进 17 个位置(或者一组列);而沿着天维度(第一维)前进需要我们前进的元素数量等于存储中一行的长度乘以 24(这里是 408,即 17×24)。

我们看到最右边的维度是原始数据集中的列数。然后,在中间维度,我们有时间,分成 24 个连续小时的块。换句话说,我们现在有一天中L小时的N序列,对应C个通道。为了得到我们期望的N×C×L顺序,我们需要转置张量:

# In[5]:
daily_bikes = daily_bikes.transpose(1, 2)
daily_bikes.shape, daily_bikes.stride()

# Out[5]:
(torch.Size([730, 17, 24]), (408, 1, 17))

现在让我们将之前学到的一些技巧应用到这个数据集上。

4.4.3 准备训练

“天气情况”变量是有序的。它有四个级别:1表示好天气,4表示,嗯,非常糟糕。我们可以将这个变量视为分类变量,其中级别被解释为标签,或者作为连续变量。如果我们决定采用分类方式,我们将把变量转换为一个独热编码向量,并将列与数据集连接起来。⁴

为了更容易呈现我们的数据,我们暂时限制在第一天。我们初始化一个以一天中小时数为行数,天气级别数为列数的零填充矩阵:

# In[6]:
first_day = bikes[:24].long()
weather_onehot = torch.zeros(first_day.shape[0], 4)
first_day[:,9]

# Out[6]:
tensor([1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 2, 2,
        2, 2])

然后我们根据每行对应级别向我们的矩阵中散布 1。记住在前几节中使用unsqueeze添加一个单例维度:

# In[7]:
weather_onehot.scatter_(
    dim=1,
    index=first_day[:,9].unsqueeze(1).long() - 1,    # ❶
    value=1.0)

# Out[7]:
tensor([[1., 0., 0., 0.],
        [1., 0., 0., 0.],
        ...,
        [0., 1., 0., 0.],
        [0., 1., 0., 0.]])

❶ 将值减 1 是因为天气情况范围从 1 到 4,而索引是从 0 开始的

我们的一天从天气“1”开始,以“2”结束,所以这看起来是正确的。

最后,我们使用cat函数将我们的矩阵与原始数据集连接起来。让我们看看我们的第一个结果:

# In[8]:
torch.cat((bikes[:24], weather_onehot), 1)[:1]

# Out[8]:
tensor([[ 1.0000,  1.0000,  1.0000,  0.0000,  1.0000,  0.0000,  0.0000,
          6.0000,  0.0000,  1.0000,  0.2400,  0.2879,  0.8100,  0.0000,
          3.0000, 13.0000, 16.0000,  1.0000,  0.0000,  0.0000,  0.0000]])

在这里,我们规定我们的原始bikes数据集和我们的独热编码的“天气情况”矩阵沿着维度(即 1)进行连接。换句话说,两个数据集的列被堆叠在一起;或者等效地,新的独热编码列被附加到原始数据集。为了使cat成功,需要确保张量在其他维度(在这种情况下是维度)上具有相同的大小。请注意,我们新的最后四列是1, 0, 0, 0,正如我们期望的天气值为 1 时一样。我们也可以对重塑后的daily_bikes张量执行相同操作。记住它的形状是(B, C, L),其中L = 24。我们首先创建一个零张量,具有相同的BL,但具有C个额外列:

# In[9]:
daily_weather_onehot = torch.zeros(daily_bikes.shape[0], 4,
                                   daily_bikes.shape[2])
daily_weather_onehot.shape

# Out[9]:
torch.Size([730, 4, 24])

然后我们将独热编码散布到张量的C维度中。由于这个操作是原地执行的,因此只有张量的内容会改变:

# In[10]:
daily_weather_onehot.scatter_(
    1, daily_bikes[:,9,:].long().unsqueeze(1) - 1, 1.0)
daily_weather_onehot.shape

# Out[10]:
torch.Size([730, 4, 24])

并且我们沿着C维度进行连接:

# In[11]:
daily_bikes = torch.cat((daily_bikes, daily_weather_onehot), dim=1)

我们之前提到这不是处理“天气情况”变量的唯一方式。实际上,它的标签具有有序关系,因此我们可以假设它们是连续变量的特殊值。我们可以将变量转换为从 0.0 到 1.0 的范围:

# In[12]:
daily_bikes[:, 9, :] = (daily_bikes[:, 9, :] - 1.0) / 3.0

正如我们在前一节中提到的,将变量重新缩放到[0.0, 1.0]区间或[-1.0, 1.0]区间是我们希望对所有定量变量进行的操作,比如temperature(我们数据集中的第 10 列)。稍后我们会看到为什么要这样做;现在,我们只需说这对训练过程有益。

对变量重新缩放有多种可能性。我们可以将它们的范围映射到[0.0, 1.0]

# In[13]:
temp = daily_bikes[:, 10, :]
temp_min = torch.min(temp)
temp_max = torch.max(temp)
daily_bikes[:, 10, :] = ((daily_bikes[:, 10, :] - temp_min)
                         / (temp_max - temp_min))

或者减去均值并除以标准差:

# In[14]:
temp = daily_bikes[:, 10, :]
daily_bikes[:, 10, :] = ((daily_bikes[:, 10, :] - torch.mean(temp))
                         / torch.std(temp))

在后一种情况下,我们的变量将具有 0 均值和单位标准差。如果我们的变量是从高斯分布中抽取的,那么 68%的样本将位于[-1.0, 1.0]区间内。

太棒了:我们建立了另一个不错的数据集,并且看到了如何处理时间序列数据。对于这次的概览,重要的是我们对时间序列的布局有了一个概念,以及我们如何将数据整理成网络可以处理的形式。

其他类型的数据看起来像时间序列,因为有严格的顺序。排在前两位的是什么?文本和音频。接下来我们将看一下文本,而“结论”部分有关于音频的附加示例的链接。

4.5 表示文本

深度学习已经席卷了自然语言处理(NLP)领域,特别是使用重复消耗新输入和先前模型输出组合的模型。这些模型被称为循环神经网络(RNNs),它们已经成功应用于文本分类、文本生成和自动翻译系统。最近,一类名为transformers的网络以更灵活的方式整合过去信息引起了轰动。以前的 NLP 工作负载以包含编码语言语法规则的规则的复杂多阶段管道为特征。现在,最先进的工作是从头开始在大型语料库上端对端训练网络,让这些规则从数据中出现。在过去几年里,互联网上最常用的自动翻译系统基于深度学习。

我们在这一部分的目标是将文本转换为神经网络可以处理的东西:一个数字张量,就像我们之前的情况一样。如果我们能够做到这一点,并且稍后选择适合我们文本处理工作的正确架构,我们就可以使用 PyTorch 进行自然语言处理。我们立即看到这一切是多么强大:我们可以使用相同的 PyTorch 工具在不同领域的许多任务上实现最先进的性能;我们只需要将问题表述得当。这项工作的第一部分是重新塑造数据。

4.5.1 将文本转换为数字

网络在文本上操作有两个特别直观的层次:在字符级别上,逐个处理字符,以及在单词级别上,其中单词是网络所看到的最细粒度的实体。我们将文本信息编码为张量形式的技术,无论我们是在字符级别还是单词级别操作,都是相同的。而且这并不是魔法。我们之前就偶然发现了它:独热编码。

让我们从字符级别的示例开始。首先,让我们获取一些要处理的文本。这里一个了不起的资源是古腾堡计划(www.gutenberg.org)。这是一个志愿者努力,将文化作品数字化并以开放格式免费提供,包括纯文本文件。如果我们的目标是更大规模的语料库,维基百科语料库是一个突出的选择:它是维基百科文章的完整集合,包含 19 亿字和 440 多万篇文章。在英语语料库网站(www.english-corpora.org)可以找到其他语料库。

让我们从古腾堡计划网站加载简·奥斯汀的《傲慢与偏见》:www.gutenberg.org/files/1342/1342-0.txt。我们只需保存文件并读取它(code/p1ch4/5_text_jane_austen.ipynb)。

代码清单 4.5 code/p1ch4/5_text_jane_austen.ipynb

# In[2]:
with open('../data/p1ch4/jane-austen/1342-0.txt', encoding='utf8') as f:
    text = f.read()

4.5.2 独热编码字符

在我们继续之前,还有一个细节需要注意:编码。这是一个非常广泛的主题,我们只会简单提及。每个书面字符都由一个代码表示:一个适当长度的比特序列,以便每个字符都可以被唯一识别。最简单的编码是 ASCII(美国信息交换标准代码),可以追溯到 1960 年代。ASCII 使用 128 个整数对 128 个字符进行编码。例如,字母a对应于二进制 1100001 或十进制 97,字母b对应于二进制 1100010 或十进制 98,依此类推。这种编码适合 8 位,这在 1965 年是一个很大的优势。

注意 128 个字符显然不足以涵盖所有需要正确表示非英语语言中的书写文本所需的字形、重音、连字等。为此,已经开发了许多使用更多比特作为代码以涵盖更广字符范围的编码。这更广范围的字符被标准化为 Unicode,将所有已知字符映射到数字,这些数字的位表示由特定编码提供。流行的编码包括 UTF-8、UTF-16 和 UTF-32,其中数字分别是 8 位、16 位或 32 位整数序列。Python 3.x 中的字符串是 Unicode 字符串。

我们将对字符进行独热编码。将独热编码限制在对所分析文本有用的字符集上是非常重要的。在我们的情况下,由于我们加载的是英文文本,使用 ASCII 并处理一个小编码是安全的。我们还可以将所有字符转换为小写,以减少编码中不同字符的数量。同样,我们可以筛选掉标点、数字或其他与我们期望的文本类型无关的字符。这可能对神经网络有实际影响,具体取决于手头的任务。

此时,我们需要遍历文本中的字符,并为每个字符提供一个独热编码。每个字符将由一个长度等于编码中不同字符数的向量表示。这个向量将包含除了在编码中字符位置对应的索引处的一个之外的所有零。

我们首先将文本拆分为一系列行,并选择一个任意的行进行关注:

# In[3]:
lines = text.split('\n')
line = lines[200]
line

# Out[3]:
'“Impossible, Mr. Bennet, impossible, when I am not acquainted with him'

让我们创建一个能够容纳整行所有独热编码字符总数的张量:

# In[4]:
letter_t = torch.zeros(len(line), 128)    # ❶
letter_t.shape

# Out[4]:
torch.Size([70, 128])

❶ 由于 ASCII 的限制,128 个硬编码

请注意,letter_t每行保存一个独热编码字符。现在我们只需在正确位置的每行设置一个 1,以便每行表示正确的字符。其中 1 应设置的索引对应于编码中字符的索引:

# In[5]:
for i, letter in enumerate(line.lower().strip()):
    letter_index = ord(letter) if ord(letter) < 128 else 0     # ❶
    letter_t[i][letter_index] = 1

❶ 文本使用方向性双引号,这不是有效的 ASCII,因此我们在这里将其筛选掉。

4.5.3 对整个单词进行独热编码

我们已经将我们的句子进行了独热编码,以便神经网络可以理解。单词级别的编码可以通过建立词汇表并对句子--单词序列--进行独热编码来完成。由于词汇表有很多单词,这将产生非常宽的编码向量,这可能不太实用。我们将在下一节看到,在单词级别表示文本有一种更有效的方法,即使用嵌入。现在,让我们继续使用独热编码,看看会发生什么。

我们将定义clean_words,它接受文本并以小写形式返回,并去除标点。当我们在我们的“不可能,本内特先生”line上调用它时,我们得到以下结果:

# In[6]:
def clean_words(input_str):
    punctuation = '.,;:"!?”“_-'
    word_list = input_str.lower().replace('\n',' ').split()
    word_list = [word.strip(punctuation) for word in word_list]
    return word_list

words_in_line = clean_words(line)
line, words_in_line

# Out[6]:
('“Impossible, Mr. Bennet, impossible, when I am not acquainted with him',
 ['impossible',
  'mr',
  'bennet',
  'impossible',
  'when',
  'i',
  'am',
  'not',
  'acquainted',
  'with',
  'him'])

接下来,让我们构建一个单词到编码索引的映射:

# In[7]:
word_list = sorted(set(clean_words(text)))
word2index_dict = {word: i for (i, word) in enumerate(word_list)}

len(word2index_dict), word2index_dict['impossible']

# Out[7]:
(7261, 3394)

请注意,word2index_dict现在是一个以单词为键、整数为值的字典。我们将使用它来高效地找到一个单词的索引,因为我们对其进行独热编码。现在让我们专注于我们的句子:我们将其分解为单词,并对其进行独热编码--也就是说,我们为每个单词填充一个独热编码向量的张量。我们创建一个空向量,并为句子中的单词分配独热编码值:

# In[8]:
word_t = torch.zeros(len(words_in_line), len(word2index_dict))
for i, word in enumerate(words_in_line):
    word_index = word2index_dict[word]
    word_t[i][word_index] = 1
    print('{:2} {:4} {}'.format(i, word_index, word))

print(word_t.shape)

# Out[8]:
 0 3394 impossible
 1 4305 mr
 2  813 bennet
 3 3394 impossible
 4 7078 when
 5 3315 i
 6  415 am
 7 4436 not
 8  239 acquainted
 9 7148 with
10 3215 him
torch.Size([11, 7261])

此时,tensor在大小为 7,261 的编码空间中表示了一个长度为 11 的句子,这是我们字典中的单词数。图 4.6 比较了我们拆分文本的两种选项的要点(以及我们将在下一节中看到的嵌入的使用)。

字符级别和单词级别编码之间的选择让我们需要做出权衡。在许多语言中,字符比单词要少得多:表示字符让我们只表示几个类别,而表示单词则要求我们表示非常多的类别,并且在任何实际应用中,要处理字典中不存在的单词。另一方面,单词传达的意义比单个字符要多得多,因此单词的表示本身就更具信息量。鉴于这两种选择之间的鲜明对比,或许并不奇怪中间方法已经被寻找、发现并成功应用:例如,字节对编码方法⁶从一个包含单个字母的字典开始,然后迭代地将最常见的对添加到字典中,直到达到规定的字典大小。我们的示例句子可能会被分割成这样的标记:⁷

▁Im|pos|s|ible|,|▁Mr|.|▁B|en|net|,|▁impossible|,|▁when|▁I|▁am|▁not|▁acquainted|▁with|▁him

图 4.6 编码单词的三种方式

对于大多数内容,我们的映射只是按单词拆分。但是,较少见的部分--大写的Impossible和名字 Bennet--由子单元组成。

4.5.4 文本嵌入

独热编码是一种在张量中表示分类数据的非常有用的技术。然而,正如我们预料的那样,当要编码的项目数量实际上是无限的时,独热编码开始失效,就像语料库中的单词一样。在仅仅一本书中,我们就有超过 7,000 个项目!

我们当然可以做一些工作来去重单词,压缩替代拼写,将过去和未来时态合并为一个标记,等等。但是,一个通用的英语编码将会非常庞大。更糟糕的是,每当我们遇到一个新单词时,我们都需要向向量中添加一个新列,这意味着需要向模型添加一组新的权重来解释这个新的词汇条目--这将从训练的角度来看是痛苦的。

如何将我们的编码压缩到一个更易管理的大小,并限制大小增长?嗯,与其使用许多零和一个单一的向量,我们可以使用浮点数向量。比如,一个包含 100 个浮点数的向量确实可以表示大量的单词。关键是要找到一种有效的方法,以便将单个单词映射到这个 100 维空间,从而促进下游学习。这被称为嵌入

原则上,我们可以简单地遍历我们的词汇表,并为每个单词生成一组 100 个随机浮点数。这样做是可以的,因为我们可以将一个非常庞大的词汇表压缩到只有 100 个数字,但它将放弃基于含义或上下文的单词之间距离的概念。使用这种单词嵌入的模型将不得不处理其输入向量中的非常少的结构。一个理想的解决方案是以这样一种方式生成嵌入,使得在相似上下文中使用的单词映射到嵌入的附近区域。

嗯,如果我们要手动设计一个解决这个问题的解决方案,我们可能会决定通过选择将基本名词和形容词映射到轴上来构建我们的嵌入空间。我们可以生成一个 2D 空间,其中轴映射到名词--水果(0.0-0.33)、花朵(0.33-0.66)和(0.66-1.0)--和形容词--红色(0.0-0.2)、橙色(0.2-0.4)、黄色(0.4-0.6)、白色(0.6-0.8)和棕色(0.8-1.0)。我们的目标是将实际的水果、花朵和狗放在嵌入中。

当我们开始嵌入词时,我们可以将苹果映射到水果红色象限中的一个数字。同样,我们可以轻松地将橘子柠檬荔枝猕猴桃(补充我们的五彩水果列表)进行映射。然后我们可以开始处理花朵,并将玫瑰罂粟花水仙花百合花等映射...嗯。不太多棕色的花朵。好吧,向日葵可以被映射到花朵黄色棕色,然后雏菊可以被映射到花朵白色黄色。也许我们应该将猕猴桃更新为接近水果棕色绿色的映射。对于狗和颜色,我们可以将红骨映射到红色附近;嗯,也许狐狸可以代表橙色金毛寻回犬代表黄色贵宾犬代表白色,以及...大多数狗都是棕色

现在我们的嵌入看起来像图 4.7。虽然对于大型语料库来说手动操作并不可行,但请注意,尽管我们的嵌入大小为 2,除了基本的 8 个词之外,我们描述了 15 个不同的词,并且如果我们花时间进行创造性思考,可能还能塞进更多词。

图 4.7 我们的手动词嵌入

正如你可能已经猜到的那样,这种工作可以自动化。通过处理大量的有机文本语料库,类似我们刚刚讨论的嵌入可以生成。主要区别在于嵌入向量中有 100 到 1,000 个元素,并且轴不直接映射到概念:相似概念的词在嵌入空间的相邻区域中映射,其轴是任意的浮点维度。

虽然具体的算法⁹有点超出了我们想要关注的范围,但我们想提一下,嵌入通常是使用神经网络生成的,试图从句子中附近的词(上下文)中预测一个词。在这种情况下,我们可以从单热编码的词开始,并使用一个(通常相当浅的)神经网络生成嵌入。一旦嵌入可用,我们就可以将其用于下游任务。

结果嵌入的一个有趣方面是相似的词不仅聚集在一起,而且与其他词有一致的空间关系。例如,如果我们取苹果的嵌入向量,并开始加减其他词的向量,我们可以开始执行类似苹果-红色-+黄色+的类比,最终得到一个与柠檬的向量非常相似的向量。

更现代的嵌入模型--BERT 和 GPT-2 甚至在主流媒体中都引起轰动--更加复杂且具有上下文敏感性:也就是说,词汇表中的一个词到向量的映射不是固定的,而是取决于周围的句子。然而,它们通常像我们在这里提到的更简单的经典嵌入一样使用。

4.5.5 文本嵌入作为蓝图

嵌入是一种必不可少的工具,当词汇表中有大量条目需要用数字向量表示时。但在本书中我们不会使用文本和文本嵌入,所以您可能会想知道为什么我们在这里介绍它们。我们认为文本如何表示和处理也可以看作是处理分类数据的一个示例。嵌入在独热编码变得繁琐的地方非常有用。事实上,在先前描述的形式中,它们是一种表示独热编码并立即乘以包含嵌入向量的矩阵的有效方式。

在非文本应用中,我们通常没有能力事先构建嵌入,但我们将从之前避开的随机数开始,并考虑将其改进作为我们学习问题的一部分。这是一种标准技术--以至于嵌入是任何分类数据的独热编码的一个突出替代方案。另一方面,即使我们处理文本,改进预先学习的嵌入在解决手头问题时已经成为一种常见做法。¹⁰

当我们对观察结果的共现感兴趣时,我们之前看到的词嵌入也可以作为一个蓝图。例如,推荐系统--喜欢我们的书的客户也购买了...--使用客户已经互动过的项目作为预测其他可能引起兴趣的上下文。同样,处理文本可能是最常见、最深入研究序列的任务;因此,例如,在处理时间序列任务时,我们可能会从自然语言处理中所做的工作中寻找灵感。

4.6 结论

在本章中,我们涵盖了很多内容。我们学会了加载最常见的数据类型并将其塑造为神经网络可以消费的形式。当然,现实中的数据格式比我们在一本书中描述的要多得多。有些,如医疗史,太复杂了,无法在此处涵盖。其他一些,如音频和视频,被认为对本书的路径不那么关键。然而,如果您感兴趣,我们在书的网站(www.manning.com/books/deep-learning-with-pytorch)和我们的代码库(github.com/deep-learning-with-pytorch/dlwpt-code/ tree/master/p1ch4)提供了音频和视频张量创建的简短示例。

现在我们熟悉了张量以及如何在其中存储数据,我们可以继续迈向本书目标的下一步:教会你训练深度神经网络!下一章将涵盖简单线性模型的学习机制。

4.7 练习

  1. 使用手机或其他数码相机拍摄几张红色、蓝色和绿色物品的照片(如果没有相机,则可以从互联网上下载一些)。

    1. 加载每个图像,并将其转换为张量。

    2. 对于每个图像张量,使用.mean()方法来了解图像的亮度。

    3. 获取图像每个通道的平均值。您能仅通过通道平均值识别红色、绿色和蓝色物品吗?

  2. 选择一个包含 Python 源代码的相对较大的文件。

    1. 构建源文件中所有单词的索引(随意将您的标记化设计得简单或复杂;我们建议从用空格替换r"[^a-zA-Z0-9_]+"开始)。

    2. 将您的索引与我们为傲慢与偏见制作的索引进行比较。哪个更大?

    3. 为源代码文件创建独热编码。

    4. 使用这种编码会丢失哪些信息?这些信息丢失与傲慢与偏见编码中丢失的信息相比如何?

4.8 总结

  • 神经网络要求数据表示为多维数值张量,通常是 32 位浮点数。

  • 一般来说,PyTorch 期望数据根据模型架构沿特定维度布局--例如,卷积与循环。我们可以使用 PyTorch 张量 API 有效地重塑数据。

  • 由于 PyTorch 库与 Python 标准库及周围生态系统的互动方式,加载最常见类型的数据并将其转换为 PyTorch 张量非常方便。

  • 图像可以有一个或多个通道。最常见的是典型数字照片的红绿蓝通道。

  • 许多图像的每个通道的位深度为 8,尽管每个通道的 12 和 16 位并不罕见。这些位深度都可以存储在 32 位浮点数中而不会丢失精度。

  • 单通道数据格式有时会省略显式通道维度。

  • 体积数据类似于 2D 图像数据,唯一的区别是添加了第三个维度(深度)。

  • 将电子表格转换为张量可能非常简单。分类和有序值列应与间隔值列处理方式不同。

  • 文本或分类数据可以通过使用字典编码为一热表示。很多时候,嵌入提供了良好且高效的表示。


这有点轻描淡写:en.wikipedia.org/wiki/Color_model

来自癌症影像存档的 CPTAC-LSCC 集合:mng.bz/K21K

作为更深入讨论的起点,请参考 en.wikipedia.org/wiki/Level_of_measurement

这也可能是一个超越主要路径的情况。可以尝试将一热编码推广到将我们这里的四个类别中的第i个映射到一个向量,该向量在位置 0...i 有一个,其他位置为零。或者--类似于我们在第 4.5.4 节讨论的嵌入--我们可以取嵌入的部分和,这种情况下可能有意义将其设为正值。与我们在实际工作中遇到的许多事物一样,这可能是一个尝试他人有效方法然后以系统化方式进行实验的好地方。

Nadkarni 等人,“自然语言处理:简介”,JAMIA,mng.bz/8pJP。另请参阅 en.wikipedia.org/wiki/Natural-language_processing

最常由 subword-nmt 和 SentencePiece 库实现。概念上的缺点是字符序列的表示不再是唯一的。

这是从一个在机器翻译数据集上训练的 SentencePiece 分词器。

实际上,通过我们对颜色的一维观点,这是不可能的,因为向日葵黄色棕色会平均为白色--但你明白我的意思,而且在更高维度下效果更好。

一个例子是 word2vec: code.google.com/archive/p/word2vec

这被称为微调

五、学习的机制

本章涵盖了

  • 理解算法如何从数据中学习

  • 将学习重新定义为参数估计,使用微分和梯度下降

  • 走进一个简单的学习算法

  • PyTorch 如何通过自动求导支持学习

随着过去十年中机器学习的蓬勃发展,从经验中学习的机器的概念已经成为技术和新闻界的主题。那么,机器是如何学习的呢?这个过程的机制是什么--或者说,背后的算法是什么?从观察者的角度来看,一个学习算法被提供了与期望输出配对的输入数据。一旦学习发生,当它被喂入与其训练时的输入数据足够相似的新数据时,该算法将能够产生正确的输出。通过深度学习,即使输入数据和期望输出相距很远,这个过程也能够工作:当它们来自不同的领域时,比如一幅图像和描述它的句子,正如我们在第二章中看到的那样。

5.1 建模中的永恒教训

允许我们解释输入/输出关系的建模模型至少可以追溯到几个世纪前。当德国数学天文学家约翰内斯·开普勒(1571-1630)在 17 世纪初发现他的三大行星运动定律时,他是基于他的导师第谷·布拉赫在裸眼观测(是的,用肉眼看到并写在一张纸上)中收集的数据。没有牛顿的万有引力定律(实际上,牛顿使用了开普勒的工作来解决问题),开普勒推断出了可能适合数据的最简单几何模型。顺便说一句,他花了六年时间盯着他看不懂的数据,连续的领悟,最终制定了这些定律。¹ 我们可以在图 5.1 中看到这个过程。

图 5.1 约翰内斯·开普勒考虑了多个可能符合手头数据的模型,最终选择了一个椭圆。

开普勒的第一定律是:“每颗行星的轨道都是一个椭圆,太阳位于两个焦点之一。”他不知道是什么导致轨道是椭圆的,但是在给定一个行星(或大行星的卫星,比如木星)的一组观测数据后,他可以估计椭圆的形状(离心率)和大小(半通径矢量)。通过从数据中计算出这两个参数,他可以预测行星在天空中的运行轨迹。一旦他弄清楚了第二定律--“连接行星和太阳的一条线在相等的时间间隔内扫过相等的面积”--他也可以根据时间观测推断出行星何时会在空间中的特定位置。²

那么,开普勒如何在没有计算机、口袋计算器甚至微积分的情况下估计椭圆的离心率和大小呢?我们可以从开普勒自己在他的书《新天文学》中的回忆中学到,或者从 J.V.菲尔德在他的一系列文章“证明的起源”中的描述中了解(mng.bz/9007):

本质上,开普勒不得不尝试不同的形状,使用一定数量的观测结果找到曲线,然后使用曲线找到更多位置,用于他有观测结果可用的时间,然后检查这些计算出的位置是否与观测到的位置一致。

--J.V.菲尔德

那么让我们总结一下。在六年的时间里,开普勒

  1. 从他的朋友布拉赫那里得到了大量的好数据(不是没有一点挣扎)

  2. 尝试尽可能将其可视化,因为他觉得有些不对劲

  3. 选择可能适合数据的最简单模型(椭圆)

  4. 将数据分割,以便他可以处理其中一部分,并保留一个独立的集合用于验证

  5. 从一个椭圆的初步离心率和大小开始,并迭代直到模型符合观测结果

  6. 在独立观测上验证了他的模型

  7. 惊讶地回顾过去

为你准备了一本数据科学手册,一直延续至 1609 年。科学的历史实际上是建立在这七个步骤上的。几个世纪以来,我们已经学会了偏离这些步骤是灾难的前兆。

这正是我们将要从数据中学习的内容。事实上,在这本书中,几乎没有区别是说我们将拟合数据还是让算法从数据中学习。这个过程总是涉及一个具有许多未知参数的函数,其值是从数据中估计的:简而言之,一个模型

我们可以认为从数据中学习意味着底层模型并非是为解决特定问题而设计的(就像开普勒的椭圆一样),而是能够逼近更广泛函数族的模型。一个神经网络可以非常好地预测第谷·布拉赫的轨迹,而无需开普勒的灵感来尝试将数据拟合成椭圆。然而,艾萨克·牛顿要从一个通用模型中推导出他的引力定律就要困难得多。

在这本书中,我们对不是为解决特定狭窄任务而设计的模型感兴趣,而是可以自动调整以专门为任何一个类似任务进行自我特化的模型--换句话说,根据与手头特定任务相关的数据训练的通用模型。特别是,PyTorch 旨在使创建模型变得容易,使拟合误差对参数的导数能够被解析地表达。如果最后一句话让你完全不明白,别担心;接下来,我们有一个完整的章节希望为你澄清这一点。

本章讨论如何自动化通用函数拟合。毕竟,这就是我们用深度学习做的事情--深度神经网络就是我们谈论的通用函数--而 PyTorch 使这个过程尽可能简单透明。为了确保我们理解关键概念正确,我们将从比深度神经网络简单得多的模型开始。这将使我们能够从本章的第一原则理解学习算法的机制,以便我们可以在第六章转向更复杂的模型。

5.2 学习只是参数估计

在本节中,我们将学习如何利用数据,选择一个模型,并估计模型的参数,以便在新数据上进行良好的预测。为此,我们将把注意力从行星运动的复杂性转移到物理学中第二难的问题:校准仪器。

图 5.2 展示了本章末尾我们将要实现的高层概述。给定输入数据和相应的期望输出(标准答案),以及权重的初始值,模型接收输入数据(前向传播),通过将生成的输出与标准答案进行比较来评估误差的度量。为了优化模型的参数--其权重,使用复合函数的导数链式法则(反向传播)计算单位权重变化后误差的变化(即误差关于参数的梯度)。然后根据导致误差减少的方向更新权重的值。该过程重复进行,直到在未见数据上评估的误差降至可接受水平以下。如果我们刚才说的听起来晦涩难懂,我们有整整一章来澄清事情。到我们完成时,所有的部分都会成为一体,这段文字将变得非常清晰。

现在,我们将处理一个带有嘈杂数据集的问题,构建一个模型,并为其实现一个学习算法。当我们开始时,我们将手工完成所有工作,但在本章结束时,我们将让 PyTorch 为我们完成所有繁重的工作。当我们完成本章时,我们将涵盖训练深度神经网络的许多基本概念,即使我们的激励示例非常简单,我们的模型实际上并不是一个神经网络(但!)。

图 5.2 我们对学习过程的心理模型

5.2.1 一个热门问题

我们刚从某个偏僻的地方旅行回来,带回了一个时髦的壁挂式模拟温度计。它看起来很棒,完全适合我们的客厅。它唯一的缺点是它不显示单位。不用担心,我们有一个计划:我们将建立一个读数和相应温度值的数据集,选择一个模型,迭代调整其权重直到误差的度量足够低,最终能够以我们理解的单位解释新的读数。⁴

让我们尝试按照开普勒使用的相同过程进行。在这个过程中,我们将使用一个他从未拥有过的工具:PyTorch!

5.2.2 收集一些数据

我们将首先记录摄氏度的温度数据和我们新温度计的测量值,并弄清楚事情。几周后,这是数据(code/p1ch5/1_parameter_estimation.ipynb):

# In[2]:
t_c = [0.5,  14.0, 15.0, 28.0, 11.0,  8.0,  3.0, -4.0,  6.0, 13.0, 21.0]
t_u = [35.7, 55.9, 58.2, 81.9, 56.3, 48.9, 33.9, 21.8, 48.4, 60.4, 68.4]
t_c = torch.tensor(t_c)
t_u = torch.tensor(t_u)

这里,t_c值是摄氏度温度,t_u值是我们未知的单位。我们可以预期两个测量中都会有噪音,来自设备本身和我们的近似读数。为了方便起见,我们已经将数据放入张量中;我们将在一分钟内使用它。

5.2.3 可视化数据

图 5.3 中我们数据的快速绘图告诉我们它很嘈杂,但我们认为这里有一个模式。

图 5.3 我们的未知数据可能遵循一个线性模型。

注意 剧透警告:我们知道线性模型是正确的,因为问题和数据都是虚构的,但请耐心等待。这是一个有用的激励性例子,可以帮助我们理解 PyTorch 在幕后做了什么。

5.2.4 选择线性模型作为第一次尝试

在没有进一步的知识的情况下,我们假设将两组测量值之间转换的最简单模型,就像开普勒可能会做的那样。这两者可能是线性相关的--也就是说,通过乘以一个因子并添加一个常数,我们可以得到摄氏度的温度(我们忽略的误差):

t_c = w * t_u + b

这个假设合理吗?可能;我们将看到最终模型的表现如何。我们选择将wb命名为权重偏差,这是线性缩放和加法常数的两个非常常见的术语--我们将一直遇到这些术语。⁶

现在,我们需要根据我们拥有的数据来估计wb,即我们模型中的参数。我们必须这样做,以便通过将未知温度t_u输入模型后得到的温度接近我们实际测量的摄氏度温度。如果这听起来像是在一组测量值中拟合一条直线,那么是的,因为这正是我们正在做的。我们将使用 PyTorch 进行这个简单的例子,并意识到训练神经网络实质上涉及将模型更改为稍微更复杂的模型,其中有一些(或者是一吨)更多的参数。

让我们再次详细说明一下:我们有一个具有一些未知参数的模型,我们需要估计这些参数,以便预测输出和测量值之间的误差尽可能低。我们注意到我们仍然需要准确定义一个误差度量。这样一个度量,我们称之为损失函数,如果误差很大,应该很高,并且应该在完美匹配时尽可能低。因此,我们的优化过程应该旨在找到wb,使得损失函数最小化。

5.3 较少的损失是我们想要的

损失函数(或成本函数)是一个计算单个数值的函数,学习过程将尝试最小化该数值。损失的计算通常涉及取一些训练样本的期望输出与模型在馈送这些样本时实际产生的输出之间的差异。在我们的情况下,这将是我们的模型输出的预测温度t_p与实际测量值之间的差异:t_p - t_c

我们需要确保损失函数在t_p大于真实t_c和小于真实t_c时都是正的,因为目标是让t_p匹配t_c。我们有几种选择,最直接的是|t_p - t_c|(t_p - t_c)²。根据我们选择的数学表达式,我们可以强调或折扣某些错误。概念上,损失函数是一种优先考虑从我们的训练样本中修复哪些错误的方法,以便我们的参数更新导致对高权重样本的输出进行调整,而不是对一些其他样本的输出进行更改,这些样本的损失较小。

这两个示例损失函数在零处有明显的最小值,并且随着预测值向任一方向偏离真值,它们都会单调增加。由于增长的陡峭度也随着远离最小值而单调增加,它们都被称为凸函数。由于我们的模型是线性的,所以损失作为wb的函数也是凸的。⁷ 损失作为模型参数的凸函数的情况通常很容易处理,因为我们可以通过专门的算法非常有效地找到最小值。然而,在本章中,我们将使用功能更弱但更普遍适用的方法。我们这样做是因为对于我们最终感兴趣的深度神经网络,损失不是输入的凸函数。

对于我们的两个损失函数|t_p - t_c|(t_p - t_c)²,如图 5.4 所示,我们注意到差的平方在最小值附近的行为更好:对于t_p,误差平方损失的导数在t_p等于t_c时为零。另一方面,绝对值在我们希望收敛的地方具有未定义的导数。实际上,这在实践中并不是问题,但我们暂时将坚持使用差的平方。

图 5.4 绝对差与差的平方

值得注意的是,平方差也比绝对差惩罚更严重的错误。通常,有更多略微错误的结果比有几个极端错误的结果更好,而平方差有助于按预期优先考虑这些结果。

5.3.1 从问题返回到 PyTorch

我们已经找到了模型和损失函数--我们已经在图 5.2 的高层图片中找到了一个很好的部分。现在我们需要启动学习过程并提供实际数据。另外,数学符号够了;让我们切换到 PyTorch--毕竟,我们来这里是为了乐趣

我们已经创建了我们的数据张量,现在让我们将模型写成一个 Python 函数:

# In[3]:
def model(t_u, w, b):
    return w * t_u + b

我们期望t_uwb分别是输入张量,权重参数和偏置参数。在我们的模型中,参数将是 PyTorch 标量(也称为零维张量),并且乘法操作将使用广播产生返回的张量。无论如何,是时候定义我们的损失了:

# In[4]:
def loss_fn(t_p, t_c):
    squared_diffs = (t_p - t_c)**2
    return squared_diffs.mean()

请注意,我们正在构建一个差异张量,逐元素取平方,最终通过平均所有结果张量中的元素产生一个标量损失函数。这是一个均方损失

现在我们可以初始化参数,调用模型,

# In[5]:
w = torch.ones(())
b = torch.zeros(())

t_p = model(t_u, w, b)
t_p

# Out[5]:
tensor([35.7000, 55.9000, 58.2000, 81.9000, 56.3000, 48.9000, 33.9000,
        21.8000, 48.4000, 60.4000, 68.4000])

并检查损失的值:

# In[6]:
loss = loss_fn(t_p, t_c)
loss

# Out[6]:
tensor(1763.8846)

我们在本节中实现了模型和损失。我们终于到达了示例的核心:我们如何估计wb,使损失达到最小?我们首先手动解决问题,然后学习如何使用 PyTorch 的超能力以更通用、现成的方式解决相同的问题。

广播

我们在第三章提到了广播,并承诺在需要时更仔细地研究它。在我们的例子中,我们有两个标量(零维张量)wb,我们将它们与长度为 b 的向量(一维张量)相乘并相加。

通常——在 PyTorch 的早期版本中也是如此——我们只能对形状相同的参数使用逐元素二元操作,如加法、减法、乘法和除法。在每个张量中的匹配位置的条目将用于计算结果张量中相应条目。

广播,在 NumPy 中很受欢迎,并被 PyTorch 采用,放宽了大多数二元操作的这一假设。它使用以下规则来匹配张量元素:

  • 对于每个索引维度,从后往前计算,如果其中一个操作数在该维度上的大小为 1,则 PyTorch 将使用该维度上的单个条目与另一个张量沿着该维度的每个条目。

  • 如果两个大小都大于 1,则它们必须相同,并且使用自然匹配。

  • 如果两个张量中一个的索引维度比另一个多,则另一个张量的整体将用于沿着这些维度的每个条目。

这听起来很复杂(如果我们不仔细注意,可能会出错,这就是为什么我们在第 3.4 节中将张量维度命名的原因),但通常,我们可以写下张量维度来看看会发生什么,或者通过使用空间维度来展示广播的方式来想象会发生什么,就像下图所示。

当然,如果没有一些代码示例,这一切都只是理论:

# In[7]:
x = torch.ones(())
y = torch.ones(3,1)
z = torch.ones(1,3)
a = torch.ones(2, 1, 1)
print(f"shapes: x: {x.shape}, y: {y.shape}")
print(f"        z: {z.shape}, a: {a.shape}")

print("x * y:", (x * y).shape)
print("y * z:", (y * z).shape)
print("y * z * a:", (y * z * a).shape)

# Out[7]:

shapes: x: torch.Size([]), y: torch.Size([3, 1])
        z: torch.Size([1, 3]), a: torch.Size([2, 1, 1])
x * y: torch.Size([3, 1])

y * z: torch.Size([3, 3])

5.4 沿着梯度下降

我们将使用梯度下降算法优化参数的损失函数。在本节中,我们将从第一原理建立对梯度下降如何工作的直觉,这将在未来对我们非常有帮助。正如我们提到的,有更有效地解决我们示例问题的方法,但这些方法并不适用于大多数深度学习任务。梯度下降实际上是一个非常简单的想法,并且在具有数百万参数的大型神经网络模型中表现出色。

图 5.5 优化过程的卡通描绘,一个人带有 w 和 b 旋钮,寻找使损失减少的旋钮转动方向

让我们从一个心理形象开始,我们方便地在图 5.5 中勾画出来。假设我们站在一台带有标有wb的两个旋钮的机器前。我们可以在屏幕上看到损失值,并被告知要将该值最小化。不知道旋钮对损失的影响,我们开始摆弄它们,并为每个旋钮决定哪个方向使损失减少。我们决定将两个旋钮都旋转到损失减少的方向。假设我们离最佳值很远:我们可能会看到损失迅速减少,然后随着接近最小值而减慢。我们注意到在某个时刻,损失再次上升,因此我们反转一个或两个旋钮的旋转方向。我们还了解到当损失变化缓慢时,调整旋钮更精细是个好主意,以避免达到损失再次上升的点。过一段时间,最终,我们收敛到一个最小值。

5.4.1 减小损失

梯度下降与我们刚刚描述的情景并没有太大不同。其思想是计算损失相对于每个参数的变化率,并将每个参数修改为减小损失的方向。就像我们在调节旋钮时一样,我们可以通过向wb添加一个小数并观察在该邻域内损失的变化来估计变化率:

# In[8]:
delta = 0.1

loss_rate_of_change_w = \
    (loss_fn(model(t_u, w + delta, b), t_c) -
     loss_fn(model(t_u, w - delta, b), t_c)) / (2.0 * delta)

这意味着在当前wb的值的邻域内,增加w会导致损失发生一些变化。如果变化是负的,那么我们需要增加w以最小化损失,而如果变化是正的,我们需要减少w。增加多少?根据损失的变化率对w应用变化是个好主意,特别是当损失有几个参数时:我们对那些对损失产生显著变化的参数应用变化。通常,总体上缓慢地改变参数是明智的,因为在当前w值的邻域之外,变化率可能截然不同。因此,我们通常应该通过一个小因子来缩放变化率。这个缩放因子有许多名称;我们在机器学习中使用的是learning_rate

# In[9]:
learning_rate = 1e-2

w = w - learning_rate * loss_rate_of_change_w

我们可以用b做同样的事情:

# In[10]:
loss_rate_of_change_b = \
    (loss_fn(model(t_u, w, b + delta), t_c) -
     loss_fn(model(t_u, w, b - delta), t_c)) / (2.0 * delta)

b = b - learning_rate * loss_rate_of_change_b

这代表了梯度下降的基本参数更新步骤。通过重复这些评估(并且只要我们选择足够小的学习率),我们将收敛到使给定数据上计算的损失最小的参数的最佳值。我们很快将展示完整的迭代过程,但我们刚刚计算变化率的方式相当粗糙,在继续之前需要进行升级。让我们看看为什么以及如何。

5.4.2 进行分析

通过重复评估模型和损失来计算变化率,以探究在wb邻域内损失函数的行为的方法在具有许多参数的模型中不具有良好的可扩展性。此外,并不总是清楚邻域应该有多大。我们在前一节中选择了delta等于 0.1,但这完全取决于损失作为wb函数的形状。如果损失相对于delta变化太快,我们将无法很好地了解损失减少最多的方向。

如果我们可以使邻域无限小,就像图 5.6 中那样,会发生什么?这正是当我们对参数的损失进行导数分析时发生的情况。在我们处理的具有两个或更多参数的模型中,我们计算损失相对于每个参数的各个导数,并将它们放入导数向量中:梯度

图 5.6 在离散位置评估时下降方向的估计差异与分析方法

计算导数

为了计算损失相对于参数的导数,我们可以应用链式法则,并计算损失相对于其输入(即模型的输出)的导数,乘以模型相对于参数的导数:

d loss_fn / d w = (d loss_fn / d t_p) * (d t_p / d w)

回想一下我们的模型是一个线性函数,我们的损失是平方和。让我们找出导数的表达式。回想一下损失的表达式:

# In[4]:
def loss_fn(t_p, t_c):
    squared_diffs = (t_p - t_c)**2
    return squared_diffs.mean()

记住d x² / d x = 2 x,我们得到

# In[11]:
def dloss_fn(t_p, t_c):
    dsq_diffs = 2 * (t_p - t_c) / t_p.size(0)    # ❶
    return dsq_diffs

❶ 分割是来自均值的导数。

将导数应用于模型

对于模型,回想一下我们的模型是

# In[3]:
def model(t_u, w, b):
    return w * t_u + b

我们得到这些导数:

# In[12]:
def dmodel_dw(t_u, w, b):
    return t_u

# In[13]:
def dmodel_db(t_u, w, b):
    return 1.0

定义梯度函数

将所有这些放在一起,返回损失相对于wb的梯度的函数是

# In[14]:
def grad_fn(t_u, t_c, t_p, w, b):
    dloss_dtp = dloss_fn(t_p, t_c)
    dloss_dw = dloss_dtp * dmodel_dw(t_u, w, b)
    dloss_db = dloss_dtp * dmodel_db(t_u, w, b)
    return torch.stack([dloss_dw.sum(), dloss_db.sum()])     # ❶

❶ 求和是我们在模型中将参数应用于整个输入向量时隐式执行的广播的反向。

用数学符号表示相同的想法如图 5.7 所示。再次,我们对所有数据点进行平均(即,求和并除以一个常数),以获得每个损失的偏导数的单个标量量。

图 5.7 损失函数相对于权重的导数

5.4.3 迭代拟合模型

现在我们已经准备好优化我们的参数了。从参数的一个暂定值开始,我们可以迭代地对其应用更新,进行固定次数的迭代,或直到wb停止改变。有几个停止标准;现在,我们将坚持固定次数的迭代。

训练循环

既然我们在这里,让我们介绍另一个术语。我们称之为训练迭代,我们在其中为所有训练样本更新参数一个时代

完整的训练循环如下(code/p1ch5/1_parameter_estimation .ipynb):

# In[15]:
def training_loop(n_epochs, learning_rate, params, t_u, t_c):
    for epoch in range(1, n_epochs + 1):
        w, b = params

        t_p = model(t_u, w, b)                             # ❶
        loss = loss_fn(t_p, t_c)
        grad = grad_fn(t_u, t_c, t_p, w, b)                # ❷

        params = params - learning_rate * grad

        print('Epoch %d, Loss %f' % (epoch, float(loss)))  # ❸

    return params

❶ 正向传播

❷ 反向传播

❸ 这个记录行可能非常冗长。

用于文本输出的实际记录逻辑更复杂(请参见同一笔记本中的第 15 单元:mng.bz/pBB8),但这些差异对于理解本章的核心概念并不重要。

现在,让我们调用我们的训练循环:

# In[17]:
training_loop(
    n_epochs = 100,
    learning_rate = 1e-2,
    params = torch.tensor([1.0, 0.0]),
    t_u = t_u,
    t_c = t_c)

# Out[17]:
Epoch 1, Loss 1763.884644
    Params: tensor([-44.1730,  -0.8260])
    Grad:   tensor([4517.2969,   82.6000])
Epoch 2, Loss 5802485.500000
    Params: tensor([2568.4014,   45.1637])
    Grad:   tensor([-261257.4219,   -4598.9712])
Epoch 3, Loss 19408035840.000000
    Params: tensor([-148527.7344,   -2616.3933])
    Grad:   tensor([15109614.0000,   266155.7188])
...
Epoch 10, Loss 90901154706620645225508955521810432.000000
    Params: tensor([3.2144e+17, 5.6621e+15])
    Grad:   tensor([-3.2700e+19, -5.7600e+17])
Epoch 11, Loss inf
    Params: tensor([-1.8590e+19, -3.2746e+17])
    Grad:   tensor([1.8912e+21, 3.3313e+19])

tensor([-1.8590e+19, -3.2746e+17])

过度训练

等等,发生了什么?我们的训练过程实际上爆炸了,导致损失变为inf。这清楚地表明params正在接收太大的更新,它们的值开始来回振荡,因为每次更新都超过了,下一个更正得更多。优化过程不稳定:它发散而不是收敛到最小值。我们希望看到对params的更新越来越小,而不是越来越大,如图 5.8 所示。

图 5.8 顶部:由于步长过大,在凸函数(类似抛物线)上发散的优化。底部:通过小步骤收敛的优化。

我们如何限制learning_rate * grad的幅度?嗯,这看起来很容易。我们可以简单地选择一个较小的learning_rate,实际上,当训练不如我们希望的那样顺利时,学习率是我们通常更改的事物之一。我们通常按数量级更改学习率,因此我们可以尝试使用1e-31e-4,这将使更新的幅度减少数量级。让我们选择1e-4,看看效果如何:

# In[18]:
training_loop(
    n_epochs = 100,
    learning_rate = 1e-4,
    params = torch.tensor([1.0, 0.0]),
    t_u = t_u,
    t_c = t_c)

# Out[18]:
Epoch 1, Loss 1763.884644
    Params: tensor([ 0.5483, -0.0083])
    Grad:   tensor([4517.2969,   82.6000])
Epoch 2, Loss 323.090546
    Params: tensor([ 0.3623, -0.0118])
    Grad:   tensor([1859.5493,   35.7843])
Epoch 3, Loss 78.929634
    Params: tensor([ 0.2858, -0.0135])
    Grad:   tensor([765.4667,  16.5122])
...
Epoch 10, Loss 29.105242
    Params: tensor([ 0.2324, -0.0166])
    Grad:   tensor([1.4803, 3.0544])
Epoch 11, Loss 29.104168
    Params: tensor([ 0.2323, -0.0169])
    Grad:   tensor([0.5781, 3.0384])
...
Epoch 99, Loss 29.023582
    Params: tensor([ 0.2327, -0.0435])
    Grad:   tensor([-0.0533,  3.0226])
Epoch 100, Loss 29.022669
    Params: tensor([ 0.2327, -0.0438])
    Grad:   tensor([-0.0532,  3.0226])

tensor([ 0.2327, -0.0438])

不错--行为现在稳定了。但还有另一个问题:参数的更新非常小,因此损失下降非常缓慢,最终停滞。我们可以通过使learning_rate自适应来避免这个问题:即根据更新的幅度进行更改。有一些优化方案可以做到这一点,我们将在本章末尾的第 5.5.2 节中看到其中一个。

然而,在更新项中还有另一个潜在的麻烦制造者:梯度本身。让我们回过头看看在优化期间第 1 个时期的grad

5.4.4 标准化输入

我们可以看到,权重的第一轮梯度大约比偏置的梯度大 50 倍。这意味着权重和偏置存在于不同比例的空间中。如果是这种情况,一个足够大以便有意义地更新一个参数的学习率对于另一个参数来说会太大而不稳定;而对于另一个参数来说合适的速率将不足以有意义地改变第一个参数。这意味着除非改变问题的表述,否则我们将无法更新我们的参数。我们可以为每个参数设置单独的学习率,但对于具有许多参数的模型来说,这将是太麻烦的事情;这是我们不喜欢的照看的一种方式。

有一个更简单的方法来控制事物:改变输入,使得梯度不那么不同。我们可以确保输入的范围不会远离-1.01.0的范围,粗略地说。在我们的情况下,我们可以通过简单地将t_u乘以 0.1 来实现接近这个范围:

# In[19]:
t_un = 0.1 * t_u

在这里,我们通过在变量名后附加一个n来表示t_u的归一化版本。此时,我们可以在我们的归一化输入上运行训练循环:

# In[20]:
training_loop(
    n_epochs = 100,
    learning_rate = 1e-2,
    params = torch.tensor([1.0, 0.0]),
    t_u = t_un,                  # ❶
    t_c = t_c)

# Out[20]:
Epoch 1, Loss 80.364342
    Params: tensor([1.7761, 0.1064])
    Grad:   tensor([-77.6140, -10.6400])
Epoch 2, Loss 37.574917
    Params: tensor([2.0848, 0.1303])
    Grad:   tensor([-30.8623,  -2.3864])
Epoch 3, Loss 30.871077
    Params: tensor([2.2094, 0.1217])
    Grad:   tensor([-12.4631,   0.8587])
...
Epoch 10, Loss 29.030487
    Params: tensor([ 2.3232, -0.0710])
    Grad:   tensor([-0.5355,  2.9295])
Epoch 11, Loss 28.941875
    Params: tensor([ 2.3284, -0.1003])
    Grad:   tensor([-0.5240,  2.9264])
...
Epoch 99, Loss 22.214186
    Params: tensor([ 2.7508, -2.4910])
    Grad:   tensor([-0.4453,  2.5208])
Epoch 100, Loss 22.148710
    Params: tensor([ 2.7553, -2.5162])
    Grad:   tensor([-0.4446,  2.5165])

tensor([ 2.7553, -2.5162])

❶ 我们已经将t_u更新为我们的新的、重新缩放的t_un

即使我们将学习率设置回1e-2,参数在迭代更新过程中不会爆炸。让我们看一下梯度:它们的数量级相似,因此对两个参数使用相同的learning_rate效果很好。我们可能可以比简单地乘以 10 进行更好的归一化,但由于这种方法对我们的需求已经足够好,我们暂时将坚持使用这种方法。

注意 这里的归一化绝对有助于训练网络,但你可以提出一个论点,即对于这个特定问题,严格来说并不需要优化参数。这绝对正确!这个问题足够小,有很多方法可以击败参数。然而,对于更大、更复杂的问题,归一化是一个简单而有效(如果不是至关重要!)的工具,用来改善模型的收敛性。

让我们运行足够的迭代次数来看到params的变化变得很小。我们将n_epochs更改为 5,000:

# In[21]:
params = training_loop(
    n_epochs = 5000,
    learning_rate = 1e-2,
    params = torch.tensor([1.0, 0.0]),
    t_u = t_un,
    t_c = t_c,
    print_params = False)

params

# Out[21]:
Epoch 1, Loss 80.364342
Epoch 2, Loss 37.574917
Epoch 3, Loss 30.871077
...
Epoch 10, Loss 29.030487
Epoch 11, Loss 28.941875
...
Epoch 99, Loss 22.214186
Epoch 100, Loss 22.148710
...
Epoch 4000, Loss 2.927680
Epoch 5000, Loss 2.927648

tensor([  5.3671, -17.3012])

很好:我们的损失在我们沿着梯度下降方向改变参数时减少。它并没有完全降到零;这可能意味着没有足够的迭代次数收敛到零,或者数据点并不完全位于一条直线上。正如我们预料的那样,我们的测量并不完全准确,或者在读数中存在噪音。

但是看:wb的值看起来非常像我们需要用来将摄氏度转换为华氏度的数字(在我们将输入乘以 0.1 进行归一化之后)。确切的值将是w=5.5556b=-17.7778。我们时髦的温度计一直显示的是华氏温度。没有什么大的发现,除了我们的梯度下降优化过程有效!

5.4.5 再次可视化

让我们重新审视一下我们一开始做的事情:绘制我们的数据。说真的,这是任何从事数据科学的人都应该做的第一件事。始终大量绘制数据:

# In[22]:
%matplotlib inline
from matplotlib import pyplot as plt

t_p = model(t_un, *params)                    # ❶

fig = plt.figure(dpi=600)
plt.xlabel("Temperature (°Fahrenheit)")
plt.ylabel("Temperature (°Celsius)")
plt.plot(t_u.numpy(), t_p.detach().numpy())   # ❷
plt.plot(t_u.numpy(), t_c.numpy(), 'o')

❶ 记住我们是在归一化的未知单位上进行训练。我们还使用参数解包。

❷ 但我们正在绘制原始的未知值。

我们在这里使用了一个名为参数解包的 Python 技巧:*params意味着将params的元素作为单独的参数传递。在 Python 中,这通常是用于列表或元组的,但我们也可以在 PyTorch 张量中使用参数解包,这些张量沿着主导维度分割。因此,在这里,model(t_un, *params)等同于model(t_un, params[0], params[1])

此代码生成图 5.9。我们的线性模型似乎是数据的一个很好的模型。看起来我们的测量有些不稳定。我们应该给我们的验光师打电话换一副新眼镜,或者考虑退还我们的高级温度计。

图 5.9 我们的线性拟合模型(实线)与输入数据(圆圈)的绘图

5.5 PyTorch 的 autograd:反向传播一切

在我们的小冒险中,我们刚刚看到了反向传播的一个简单示例:我们使用链式法则向后传播导数,计算了函数组合(模型和损失)相对于它们最内部参数(wb)的梯度。这里的基本要求是,我们处理的所有函数都可以在解析上进行微分。如果是这种情况,我们可以一次性计算出相对于参数的梯度--我们之前称之为“损失变化率”。

即使我们有一个包含数百万参数的复杂模型,只要我们的模型是可微的,计算相对于参数的损失梯度就相当于编写导数的解析表达式并评估它们一次。当然,编写一个非常深层次的线性和非线性函数组合的导数的解析表达式并不是一件有趣的事情。这也不是特别快的过程。

5.5.1 自动计算梯度

这就是当 PyTorch 张量发挥作用时的时候,PyTorch 组件 autograd 就派上用场了。第三章介绍了张量是什么以及我们可以在它们上调用什么函数的全面概述。然而,我们遗漏了一个非常有趣的方面:PyTorch 张量可以记住它们的来源,即生成它们的操作和父张量,并且可以自动提供这些操作相对于它们的输入的导数链。这意味着我们不需要手动推导我们的模型;给定一个前向表达式,无论多么嵌套,PyTorch 都会自动提供该表达式相对于其输入参数的梯度。

应用 autograd

此时,继续前进的最佳方式是重新编写我们的温度计校准代码,这次使用 autograd,并看看会发生什么。首先,我们回顾一下我们的模型和损失函数。

code/p1ch5/2_autograd.ipynb

# In[3]:
def model(t_u, w, b):
    return w * t_u + b

# In[4]:
def loss_fn(t_p, t_c):
    squared_diffs = (t_p - t_c)**2
    return squared_diffs.mean()

让我们再次初始化一个参数张量:

# In[5]:
params = torch.tensor([1.0, 0.0], requires_grad=True)

使用 grad 属性

注意张量构造函数中的 requires_grad=True 参数?该参数告诉 PyTorch 跟踪由于对 params 进行操作而产生的张量的整个家族树。换句话说,任何将 params 作为祖先的张量都将访问从 params 到该张量的链式函数。如果这些函数是可微的(大多数 PyTorch 张量操作都是可微的),导数的值将自动填充为 params 张量的 grad 属性。

一般来说,所有 PyTorch 张量都有一个名为 grad 的属性。通常,它是 None

# In[6]:
params.grad is None

# Out[6]:
True

我们只需开始一个 requires_grad 设置为 True 的张量,然后调用模型并计算损失,然后在 loss 张量上调用 backward

# In[7]:
loss = loss_fn(model(t_u, *params), t_c)
loss.backward()

params.grad

# Out[7]:
tensor([4517.2969,   82.6000])

此时,paramsgrad 属性包含了相对于每个元素的 params 的损失的导数。

当我们在参数 wb 需要梯度时计算我们的 loss 时,除了执行实际计算外,PyTorch 还会创建带有操作(黑色圆圈)的 autograd 图,如图 5.10 顶部行所示。当我们调用 loss.backward() 时,PyTorch 沿着这个图的反向方向遍历以计算梯度,如图的底部行所示的箭头所示。

图 5.10 模型的前向图和后向图,使用 autograd 计算

累积 grad 函数

我们可以有任意数量的张量,其requires_grad设置为True,以及任意组合的函数。在这种情况下,PyTorch 会计算整个函数链(计算图)中损失的导数,并将其值累积在这些张量的grad属性中(图的叶节点)。

警告!大坑在前方。这是 PyTorch 新手——以及许多更有经验的人——经常会遇到的问题。我们刚刚写的是累积,而不是存储

警告 调用backward会导致导数在叶节点累积。在使用参数更新后,我们需要显式地将梯度清零

让我们一起重复:调用backward会导致导数在叶节点累积。因此,如果backward在之前被调用,损失会再次被评估,backward会再次被调用(就像在任何训练循环中一样),并且每个叶节点的梯度会累积(即求和)在上一次迭代计算的梯度之上,这会导致梯度的值不正确。

为了防止这种情况发生,我们需要在每次迭代时显式地将梯度清零。我们可以很容易地使用就地zero_方法来实现:

# In[8]:
if params.grad is not None:
    params.grad.zero_()

注意 你可能会好奇为什么清零梯度是一个必需的步骤,而不是在每次调用backward时自动清零。这样做提供了更多在处理复杂模型中梯度时的灵活性和控制。

将这个提醒铭记在心,让我们看看我们启用自动求导的训练代码是什么样子,从头到尾:

# In[9]:
def training_loop(n_epochs, learning_rate, params, t_u, t_c):
    for epoch in range(1, n_epochs + 1):
        if params.grad is not None:                # ❶
            params.grad.zero_()

        t_p = model(t_u, *params)
        loss = loss_fn(t_p, t_c)
        loss.backward()

        with torch.no_grad():                      # ❷
            params -= learning_rate * params.grad

        if epoch % 500 == 0:
            print('Epoch %d, Loss %f' % (epoch, float(loss)))

    return params

❶ 这可以在调用 loss.backward()之前的循环中的任何时候完成。

❷ 这是一段有些繁琐的代码,但正如我们将在下一节看到的,实际上并不是问题。

请注意,我们更新params的代码并不像我们可能期望的那样直截了当。有两个特殊之处。首先,我们使用 Python 的with语句在no_grad上下文中封装更新。这意味着在with块内,PyTorch 自动求导机制应该不要关注:即,在前向图中不添加边。实际上,当我们执行这段代码时,PyTorch 记录的前向图在我们调用backward时被消耗掉,留下params叶节点。但现在我们想要在开始构建新的前向图之前更改这个叶节点。虽然这种用例通常包含在我们在第 5.5.2 节中讨论的优化器中,但当我们在第 5.5.4 节看到no_grad的另一个常见用法时,我们将更仔细地看一下。

其次,我们就地更新params。这意味着我们保留相同的params张量,但从中减去我们的更新。在使用自动求导时,我们通常避免就地更新,因为 PyTorch 的自动求导引擎可能需要我们将要修改的值用于反向传播。然而,在这里,我们在没有自动求导的情况下操作,保留params张量是有益的。在第 5.5.2 节中向优化器注册参数时,不通过将新张量分配给其变量名来替换参数将变得至关重要。

让我们看看它是否有效:

# In[10]:
training_loop(
    n_epochs = 5000,
    learning_rate = 1e-2,
    params = torch.tensor([1.0, 0.0], requires_grad=True),  # ❶
    t_u = t_un,                                             # ❷
    t_c = t_c)

# Out[10]:
Epoch 500, Loss 7.860116
Epoch 1000, Loss 3.828538
Epoch 1500, Loss 3.092191
Epoch 2000, Loss 2.957697
Epoch 2500, Loss 2.933134
Epoch 3000, Loss 2.928648
Epoch 3500, Loss 2.927830
Epoch 4000, Loss 2.927679
Epoch 4500, Loss 2.927652
Epoch 5000, Loss 2.927647

tensor([  5.3671, -17.3012], requires_grad=True)

❶ 添加 requires_grad=True 至关重要

❷ 再次,我们使用了标准化的 t_un 而不是 t_u。

结果与我们之前得到的相同。对我们来说很好!这意味着虽然我们能够手动计算导数,但我们不再需要这样做。

5.5.2 自选优化器

在示例代码中,我们使用了普通梯度下降进行优化,这对我们简单的情况效果很好。不用说,有几种优化策略和技巧可以帮助收敛,特别是在模型变得复杂时。

我们将在后面的章节深入探讨这个主题,但现在是介绍 PyTorch 如何将优化策略从用户代码中抽象出来的正确时机:也就是我们已经检查过的训练循环。这样可以避免我们不得不手动更新模型的每个参数的样板繁琐工作。torch模块有一个optim子模块,我们可以在其中找到实现不同优化算法的类。这里是一个简略列表(code/p1ch5/3_optimizers.ipynb):

# In[5]:
import torch.optim as optim

dir(optim)

# Out[5]:
['ASGD',
 'Adadelta',
 'Adagrad',
 'Adam',
 'Adamax',
 'LBFGS',
 'Optimizer',
 'RMSprop',
 'Rprop',
 'SGD',
 'SparseAdam',
...
]

每个优化器构造函数的第一个输入都是参数列表(也称为 PyTorch 张量,通常将requires_grad设置为True)。所有传递给优化器的参数都会被保留在优化器对象内部,因此优化器可以更新它们的值并访问它们的grad属性,如图 5.11 所示。

图 5.11(A)优化器如何保存参数的概念表示。(B)从输入计算损失后,(C)调用.backward会使参数上的.grad被填充。(D)此时,优化器可以访问.grad并计算参数更新。

每个优化器都暴露两个方法:zero_gradstepzero_grad将在构造时将所有传递给优化器的参数的grad属性清零。step根据特定优化器实现的优化策略更新这些参数的值。

使用梯度下降优化器

让我们创建params并实例化一个梯度下降优化器:

# In[6]:
params = torch.tensor([1.0, 0.0], requires_grad=True)
learning_rate = 1e-5
optimizer = optim.SGD([params], lr=learning_rate)

这里 SGD 代表随机梯度下降。实际上,优化器本身就是一个标准的梯度下降(只要momentum参数设置为0.0,这是默认值)。术语随机来自于梯度通常是通过对所有输入样本的随机子集进行平均得到的,称为小批量。然而,优化器不知道损失是在所有样本(标准)上评估的还是在它们的随机子集(随机)上评估的,所以在这两种情况下算法实际上是相同的。

无论如何,让我们尝试一下我们新的优化器:

# In[7]:
t_p = model(t_u, *params)
loss = loss_fn(t_p, t_c)
loss.backward()

optimizer.step()

params

# Out[7]:
tensor([ 9.5483e-01, -8.2600e-04], requires_grad=True)

在调用step时,params的值会被更新,而无需我们自己操作!发生的情况是,优化器查看params.grad并更新params,从中减去learning_rate乘以grad,与我们以前手动编写的代码完全相同。

准备将这段代码放入训练循环中?不!几乎让我们犯了大错--我们忘记了将梯度清零。如果我们在循环中调用之前的代码,梯度会在每次调用backward时在叶子节点中累积,我们的梯度下降会一团糟!这是循环准备就绪的代码,正确位置是在backward调用之前额外加上zero_grad

# In[8]:
params = torch.tensor([1.0, 0.0], requires_grad=True)
learning_rate = 1e-2
optimizer = optim.SGD([params], lr=learning_rate)

t_p = model(t_un, *params)
loss = loss_fn(t_p, t_c)

optimizer.zero_grad()      # ❶
loss.backward()
optimizer.step()

params

# Out[8]:
tensor([1.7761, 0.1064], requires_grad=True)

❶ 与以前一样,这个调用的确切位置有些随意。它也可以在循环中较早的位置。

太棒了!看看optim模块如何帮助我们将特定的优化方案抽象出来?我们所要做的就是向其提供一个参数列表(该列表可能非常长,对于非常深的神经网络模型是必需的),然后我们可以忘记细节。

让我们相应地更新我们的训练循环:

# In[9]:
def training_loop(n_epochs, optimizer, params, t_u, t_c):
    for epoch in range(1, n_epochs + 1):
        t_p = model(t_u, *params)
        loss = loss_fn(t_p, t_c)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        if epoch % 500 == 0:
            print('Epoch %d, Loss %f' % (epoch, float(loss)))

    return params

# In[10]:
params = torch.tensor([1.0, 0.0], requires_grad=True)
learning_rate = 1e-2
optimizer = optim.SGD([params], lr=learning_rate)   # ❶

training_loop(
    n_epochs = 5000,
    optimizer = optimizer,
    params = params,                                # ❶
    t_u = t_un,
    t_c = t_c)

# Out[10]:
Epoch 500, Loss 7.860118
Epoch 1000, Loss 3.828538
Epoch 1500, Loss 3.092191
Epoch 2000, Loss 2.957697
Epoch 2500, Loss 2.933134
Epoch 3000, Loss 2.928648
Epoch 3500, Loss 2.927830
Epoch 4000, Loss 2.927680
Epoch 4500, Loss 2.927651
Epoch 5000, Loss 2.927648

tensor([  5.3671, -17.3012], requires_grad=True)

❶ 很重要的一点是两个参数必须是同一个对象;否则优化器将不知道模型使用了哪些参数。

再次得到与以前相同的结果。太好了:这进一步证实了我们知道如何手动下降梯度!

测试其他优化器

为了测试更多的优化器,我们只需实例化一个不同的优化器,比如Adam,而不是SGD。其余代码保持不变。非常方便。

我们不会详细讨论 Adam;可以说,它是一种更复杂的优化器,其中学习率是自适应设置的。此外,它对参数的缩放不太敏感--如此不敏感,以至于我们可以回到使用原始(非归一化)输入t_u,甚至将学习率增加到1e-1,Adam 也不会有任何反应:

# In[11]:
params = torch.tensor([1.0, 0.0], requires_grad=True)
learning_rate = 1e-1
optimizer = optim.Adam([params], lr=learning_rate)  # ❶

training_loop(
    n_epochs = 2000,
    optimizer = optimizer,
    params = params,
    t_u = t_u,                                      # ❷
    t_c = t_c)

# Out[11]:
Epoch 500, Loss 7.612903
Epoch 1000, Loss 3.086700
Epoch 1500, Loss 2.928578
Epoch 2000, Loss 2.927646

tensor([  0.5367, -17.3021], requires_grad=True)

❶ 新的优化器类

❷ 我们又回到了将t_u作为我们的输入。

优化器并不是我们训练循环中唯一灵活的部分。让我们将注意力转向模型。为了在相同数据和相同损失上训练神经网络,我们需要改变的只是model函数。在这种情况下并没有特别意义,因为我们知道将摄氏度转换为华氏度相当于进行线性变换,但我们还是会在第六章中这样做。我们很快就会看到,神经网络允许我们消除对我们应该逼近的函数形状的任意假设。即使如此,我们将看到神经网络如何在基础过程高度非线性时进行训练(例如在描述图像与句子之间的情况,正如我们在第二章中看到的)。

我们已经涉及了许多基本概念,这些概念将使我们能够在了解内部运作的情况下训练复杂的深度学习模型:反向传播来估计梯度,自动微分,以及使用梯度下降或其他优化器来优化模型的权重。实际上,并没有太多内容。其余的大部分内容都是填空,无论填空有多广泛。

接下来,我们将提供一个关于如何分割样本的插曲,因为这为学习如何更好地控制自动微分提供了一个完美的用例。

5.5.3 训练、验证和过拟合

约翰内斯·开普勒教给我们一个迄今为止我们没有讨论过的最后一件事,记得吗?他将部分数据保留在一边,以便可以在独立观测上验证他的模型。这是一件至关重要的事情,特别是当我们采用的模型可能近似于任何形状的函数时,就像神经网络的情况一样。换句话说,一个高度适应的模型将倾向于使用其许多参数来确保损失在数据点处最小化,但我们无法保证模型在数据点之外或之间的表现。毕竟,这就是我们要求优化器做的事情:在数据点处最小化损失。毫无疑问,如果我们有一些独立的数据点,我们没有用来评估损失或沿着其负梯度下降,我们很快就会发现,在这些独立数据点上评估损失会产生比预期更高的损失。我们已经提到了这种现象,称为过拟合

我们可以采取的第一步对抗过拟合的行动是意识到它可能发生。为了做到这一点,正如开普勒在 1600 年发现的那样,我们必须从数据集中取出一些数据点(验证集),并仅在剩余数据点上拟合我们的模型(训练集),如图 5.12 所示。然后,在拟合模型时,我们可以在训练集上评估损失一次,在验证集上评估损失一次。当我们试图决定我们是否已经很好地将模型拟合到数据时,我们必须同时看两者!

图 5.12 数据生成过程的概念表示以及训练数据和独立验证数据的收集和使用。

评估训练损失

训练损失将告诉我们,我们的模型是否能够完全拟合训练集——换句话说,我们的模型是否具有足够的容量来处理数据中的相关信息。如果我们神秘的温度计以对数刻度测量温度,我们可怜的线性模型将无法拟合这些测量值,并为我们提供一个合理的摄氏度转换。在这种情况下,我们的训练损失(在训练循环中打印的损失)会在接近零之前停止下降。

深度神经网络可以潜在地逼近复杂的函数,只要神经元的数量,因此参数的数量足够多。参数数量越少,我们的网络将能够逼近的函数形状就越简单。所以,规则 1:如果训练损失不降低,那么模型对数据来说可能太简单了。另一种可能性是我们的数据只包含让其解释输出的有意义信息:如果商店里的好人卖给我们一个气压计而不是温度计,我们将很难仅凭压力来预测摄氏度,即使我们使用魁北克最新的神经网络架构(www.umontreal.ca/en/artificialintelligence)。

泛化到验证集

那验证集呢?如果在验证集中评估的损失不随着训练集一起减少,这意味着我们的模型正在改善对训练期间看到的样本的拟合,但没有泛化到这个精确集之外的样本。一旦我们在新的、以前未见过的点上评估模型,损失函数的值就会很差。所以,规则 2:如果训练损失和验证损失发散,我们就过拟合了。

让我们深入探讨这种现象,回到我们的温度计示例。我们可以决定用更复杂的函数来拟合数据,比如分段多项式或非常大的神经网络。它可能会生成一个模型,沿着数据点蜿蜒前进,就像图 5.13 中所示,只是因为它将损失推得非常接近零。由于函数远离数据点的行为不会增加损失,因此没有任何东西可以限制模型对训练数据点之外的输入。

图 5.13 过拟合的极端示例

那么,治疗方法呢?好问题。从我们刚才说的来看,过拟合看起来确实是确保模型在数据点之间的行为对我们试图逼近的过程是合理的问题。首先,我们应该确保我们为该过程收集足够的数据。如果我们通过以低频率定期对正弦过程进行采样来收集数据,我们将很难将模型拟合到它。

假设我们有足够的数据点,我们应该确保能够拟合训练数据的模型在它们之间尽可能地规则。有几种方法可以实现这一点。一种方法是向损失函数添加惩罚项,使模型更平滑、变化更慢(在一定程度上)更便宜。另一种方法是向输入样本添加噪声,人为地在训练数据样本之间创建新的数据点,并迫使模型尝试拟合这些数据点。还有其他几种方法,所有这些方法都与这些方法有些相关。但我们可以为自己做的最好的事情,至少作为第一步,是使我们的模型更简单。从直觉上讲,一个简单的模型可能不会像一个更复杂的模型那样完美地拟合训练数据,但它可能在数据点之间的行为更加规则。

我们在这里有一些不错的权衡。一方面,我们需要模型具有足够的容量来适应训练集。另一方面,我们需要模型避免过拟合。因此,为了选择神经网络模型的正确参数大小,该过程基于两个步骤:增加大小直到适应,然后缩小直到停止过拟合。

我们将在第十二章中更多地了解这一点--我们将发现我们的生活将是在拟合和过拟合之间的平衡。现在,让我们回到我们的例子,看看我们如何将数据分成训练集和验证集。我们将通过相同的方式对t_ut_c进行洗牌,然后将结果洗牌后的张量分成两部分。

分割数据集

对张量的元素进行洗牌相当于找到其索引的排列。randperm函数正是这样做的:

# In[12]:
n_samples = t_u.shape[0]
n_val = int(0.2 * n_samples)

shuffled_indices = torch.randperm(n_samples)

train_indices = shuffled_indices[:-n_val]
val_indices = shuffled_indices[-n_val:]

train_indices, val_indices           # ❶

# Out[12]:
(tensor([9, 6, 5, 8, 4, 7, 0, 1, 3]), tensor([ 2, 10]))

❶ 由于这些是随机的,如果你的数值与这里的不同,不要感到惊讶。

我们刚刚得到了索引张量,我们可以使用它们从数据张量开始构建训练和验证集:

# In[13]:
train_t_u = t_u[train_indices]
train_t_c = t_c[train_indices]

val_t_u = t_u[val_indices]
val_t_c = t_c[val_indices]

train_t_un = 0.1 * train_t_u
val_t_un = 0.1 * val_t_u

我们的训练循环并没有真正改变。我们只是想在每个时代额外评估验证损失,以便有机会识别我们是否过拟合:

# In[14]:
def training_loop(n_epochs, optimizer, params, train_t_u, val_t_u,
                  train_t_c, val_t_c):
    for epoch in range(1, n_epochs + 1):
        train_t_p = model(train_t_u, *params)        # ❶
        train_loss = loss_fn(train_t_p, train_t_c)

        val_t_p = model(val_t_u, *params)            # ❶
        val_loss = loss_fn(val_t_p, val_t_c)

        optimizer.zero_grad()
        train_loss.backward()                        # ❷
        optimizer.step()

        if epoch <= 3 or epoch % 500 == 0:
            print(f"Epoch {epoch}, Training loss {train_loss.item():.4f},"
                  f" Validation loss {val_loss.item():.4f}")

    return params

# In[15]:
params = torch.tensor([1.0, 0.0], requires_grad=True)
learning_rate = 1e-2
optimizer = optim.SGD([params], lr=learning_rate)

training_loop(
    n_epochs = 3000,
    optimizer = optimizer,
    params = params,
    train_t_u = train_t_un,                          # ❸
    val_t_u = val_t_un,                              # ❸
    train_t_c = train_t_c,
    val_t_c = val_t_c)

# Out[15]:
Epoch 1, Training loss 66.5811, Validation loss 142.3890
Epoch 2, Training loss 38.8626, Validation loss 64.0434
Epoch 3, Training loss 33.3475, Validation loss 39.4590
Epoch 500, Training loss 7.1454, Validation loss 9.1252
Epoch 1000, Training loss 3.5940, Validation loss 5.3110
Epoch 1500, Training loss 3.0942, Validation loss 4.1611
Epoch 2000, Training loss 3.0238, Validation loss 3.7693
Epoch 2500, Training loss 3.0139, Validation loss 3.6279
Epoch 3000, Training loss 3.0125, Validation loss 3.5756

tensor([  5.1964, -16.7512], requires_grad=True)

❶ 这两对行是相同的,除了 train_* vs. val_*输入。

❷ 注意这里没有val_loss.backward(),因为我们不想在验证数据上训练模型。

❸ 由于我们再次使用 SGD,我们又回到了使用归一化的输入。

在这里,我们对我们的模型并不完全公平。验证集真的很小,因此验证损失只有到一定程度才有意义。无论如何,我们注意到验证损失高于我们的训练损失,尽管不是数量级。我们期望模型在训练集上表现更好,因为模型参数是由训练集塑造的。我们的主要目标是看到训练损失和验证损失都在减小。虽然理想情况下,两个损失值应该大致相同,但只要验证损失保持与训练损失相当接近,我们就知道我们的模型继续学习关于我们数据的泛化内容。在图 5.14 中,情况 C 是理想的,而 D 是可以接受的。在情况 A 中,模型根本没有学习;在情况 B 中,我们看到过拟合。我们将在第十二章看到更有意义的过拟合示例。

图 5.14 当查看训练(实线)和验证(虚线)损失时的过拟合情况。 (A) 训练和验证损失不减少;模型由于数据中没有信息或模型容量不足而无法学习。 (B) 训练损失减少,而验证损失增加:过拟合。 (C) 训练和验证损失完全同步减少。性能可能进一步提高,因为模型尚未达到过拟合的极限。 (D) 训练和验证损失具有不同的绝对值,但趋势相似:过拟合得到控制。

5.5.4 自动微分细节和关闭它

从之前的训练循环中,我们可以看到我们只在train_loss上调用backward。因此,错误只会基于训练集反向传播--验证集用于提供对模型在未用于训练的数据上输出准确性的独立评估。

在这一点上,好奇的读者可能会有一个问题的雏形。模型被评估两次--一次在train_t_u上,一次在val_t_u上--然后调用backward。这不会让自动微分混乱吗?backward会受到在验证集上传递期间生成的值的影响吗?

幸运的是,这种情况并不会发生。训练循环中的第一行评估modeltrain_t_u上产生train_t_p。然后从train_t_p评估train_loss。这创建了一个计算图,将train_t_u链接到train_t_ptrain_loss。当再次在val_t_u上评估model时,它会产生val_t_pval_loss。在这种情况下,将创建一个将val_t_u链接到val_t_pval_loss的单独计算图。相同的张量已经通过相同的函数modelloss_fn运行,生成了不同的计算图,如图 5.15 所示。

图 5.15 显示当在其中一个上调用.backward 时,梯度如何通过具有两个损失的图传播

这两个图唯一共同拥有的张量是参数。当我们在train_loss上调用backward时,我们在第一个图上运行backward。换句话说,我们根据从train_t_u生成的计算累积train_loss相对于参数的导数。

如果我们(错误地)在val_loss上也调用了backward,那么我们将在相同的叶节点上累积val_loss相对于参数的导数。还记得zero_grad的事情吗?每次我们调用backward时,梯度都会累积在一起,除非我们明确地将梯度清零?嗯,在这里会发生类似的事情:在val_loss上调用backward会导致梯度在params张量中累积,这些梯度是在train_loss.backward()调用期间生成的。在这种情况下,我们实际上会在整个数据集上训练我们的模型(包括训练和验证),因为梯度会依赖于两者。非常有趣。

这里还有另一个讨论的要素。由于我们从未在val_loss上调用backward,那么我们为什么要首先构建计算图呢?实际上,我们可以只调用modelloss_fn作为普通函数,而不跟踪计算。然而,构建自动求导图虽然经过了优化,但会带来额外的成本,在验证过程中我们完全可以放弃这些成本,特别是当模型有数百万个参数时。

为了解决这个问题,PyTorch 允许我们在不需要时关闭自动求导,使用torch.no_grad上下文管理器。在我们的小问题上,我们不会看到任何关于速度或内存消耗方面的有意义的优势。然而,对于更大的模型,差异可能会累积。我们可以通过检查val_loss张量的requires_grad属性的值来确保这一点:

# In[16]:
def training_loop(n_epochs, optimizer, params, train_t_u, val_t_u,
                  train_t_c, val_t_c):
    for epoch in range(1, n_epochs + 1):
        train_t_p = model(train_t_u, *params)
        train_loss = loss_fn(train_t_p, train_t_c)

        with torch.no_grad():                         # ❶
            val_t_p = model(val_t_u, *params)
            val_loss = loss_fn(val_t_p, val_t_c)
            assert val_loss.requires_grad == False    # ❷

        optimizer.zero_grad()
        train_loss.backward()
        optimizer.step()

❶ 这里是上下文管理器

❷ 检查我们的输出requires_grad参数在此块内被强制为 False

使用相关的set_grad_enabled上下文,我们还可以根据布尔表达式(通常表示我们是在训练还是推理模式下运行)来条件运行代码,启用或禁用autograd。例如,我们可以定义一个calc_forward函数,根据布尔train_is参数,以有或无自动求导的方式运行modelloss_fn

# In[17]:
def calc_forward(t_u, t_c, is_train):
    with torch.set_grad_enabled(is_train):
        t_p = model(t_u, *params)
        loss = loss_fn(t_p, t_c)
    return loss

5.6 结论

我们从一个大问题开始了这一章:机器如何能够从示例中学习?我们在本章的其余部分描述了优化模型以拟合数据的机制。我们选择坚持使用简单模型,以便在不需要的复杂性的情况下看到所有移动部件。

现在我们已经品尝了开胃菜,在第六章中我们终于要进入主菜了:使用神经网络来拟合我们的数据。我们将继续解决相同的温度计问题,但使用torch.nn模块提供的更强大工具。我们将采用相同的精神,使用这个小问题来说明 PyTorch 的更大用途。这个问题不需要神经网络来找到解决方案,但它将让我们更简单地了解训练神经网络所需的内容。

5.7 练习

  1. 重新定义模型为 w2 * t_u ** 2 + w1 * t_u + b

    1. 哪些部分的训练循环等需要更改以适应这个重新定义?

    2. 哪些部分对于更换模型是不可知的?

    3. 训练后损失是更高还是更低?

    4. 实际结果是更好还是更差?

5.8 总结

  • 线性模型是用来拟合数据的最简单合理的模型。

  • 凸优化技术可以用于线性模型,但不适用于神经网络,因此我们专注于随机梯度下降进行参数估计。

  • 深度学习可以用于通用模型,这些模型并非专门用于解决特定任务,而是可以自动适应并专门化解决手头的问题。

  • 学习算法涉及根据观察结果优化模型参数。损失函数是执行任务时的错误度量,例如预测输出与测量值之间的误差。目标是尽可能降低损失函数。

  • 损失函数相对于模型参数的变化率可用于更新相同参数以减少损失。

  • PyTorch 中的 optim 模块提供了一系列用于更新参数和最小化损失函数的现成优化器。

  • 优化器使用 PyTorch 的 autograd 功能来计算每个参数的梯度,具体取决于该参数对最终输出的贡献。这使用户在复杂的前向传递过程中依赖于动态计算图。

  • with torch.no_grad(): 这样的上下文管理器可用于控制 autograd 的行为。

  • 数据通常被分成独立的训练样本集和验证样本集。这使我们能够在未经训练的数据上评估模型。

  • 过拟合模型发生在模型在训练集上的表现继续改善但在验证集上下降的情况下。这通常是由于模型没有泛化,而是记忆了训练集的期望输出。


¹ 据物理学家迈克尔·福勒回忆:mng.bz/K2Ej

² 理解开普勒定律的细节并不是理解本章所需的,但你可以在en.wikipedia.org/wiki/Kepler%27s_laws_of_planetary_motion找到更多信息。

³ 除非你是理论物理学家 😉.

⁴ 这个任务——将模型输出拟合为第四章讨论的类型的连续值——被称为回归问题。在第七章和第 2 部分中,我们将关注分类问题。

⁵ 本章的作者是意大利人,所以请原谅他使用合理的单位。

⁶ 权重告诉我们给定输入对输出的影响程度。偏差是如果所有输入都为零时的输出。

⁷ 与图 5.6 中显示的函数形成对比,该函数不是凸的。

⁸ 这个的花哨名称是超参数调整超参数指的是我们正在训练模型的参数,但超参数控制着这个训练的进行方式。通常这些是手动设置的。特别是,它们不能成为同一优化的一部分。

⁹ 或许是吧;我们不会判断你周末怎么过!

¹⁰ 糟糕!现在周六我们要做什么?

¹¹ 实际上,它将跟踪使用原地操作更改参数的情况。

¹² 我们不应认为使用 torch.no_grad 必然意味着输出不需要梯度。在特定情况下(涉及视图,如第 3.8.1 节所讨论的),即使在 no_grad 上下文中创建时,requires_grad 也不会设置为 False。如果需要确保,最好使用 detach 函数。

六、使用神经网络拟合数据

本章内容包括

  • 与线性模型相比,非线性激活函数是关键区别

  • 使用 PyTorch 的nn模块

  • 使用神经网络解决线性拟合问题

到目前为止,我们已经仔细研究了线性模型如何学习以及如何在 PyTorch 中实现这一点。我们专注于一个非常简单的回归问题,使用了一个只有一个输入和一个输出的线性模型。这样一个简单的例子使我们能够剖析一个学习模型的机制,而不会过于分散注意力于模型本身的实现。正如我们在第五章概述图中看到的,图 5.2(这里重复为图 6.1),了解训练模型的高级过程并不需要模型的确切细节。通过将错误反向传播到参数,然后通过对损失的梯度更新这些参数,无论底层模型是什么,这个过程都是相同的。

图 6.1 我们在第五章中实现的学习过程的心理模型

在本章中,我们将对我们的模型架构进行一些更改:我们将实现一个完整的人工神经网络来解决我们的温度转换问题。我们将继续使用上一章的训练循环,以及我们将华氏度转换为摄氏度的样本分为训练集和验证集。我们可以开始使用一个二次模型:将model重写为其输入的二次函数(例如,y = a * x**2 + b * x + c)。由于这样的模型是可微的,PyTorch 会负责计算梯度,训练循环将像往常一样工作。然而,对我们来说这并不是太有趣,因为我们仍然会固定函数的形状。

这是我们开始将我们的基础工作和您在项目中每天使用的 PyTorch 功能连接在一起的章节。您将了解 PyTorch API 背后的工作原理,而不仅仅是黑魔法。然而,在我们进入新模型的实现之前,让我们先了解一下人工神经网络的含义。

6.1 人工神经元

深度学习的核心是神经网络:能够通过简单函数的组合表示复杂函数的数学实体。术语神经网络显然暗示了与我们大脑工作方式的联系。事实上,尽管最初的模型受到神经科学的启发,现代人工神经网络与大脑中神经元的机制几乎没有相似之处。人工和生理神经网络似乎使用了略有相似的数学策略来逼近复杂函数,因为这类策略非常有效。

注意 从现在开始,我们将放弃人工这个词,将这些构造称为神经网络

这些复杂函数的基本构建块是神经元,如图 6.2 所示。在其核心,它只是输入的线性变换(例如,将输入乘以一个数字[权重]并加上一个常数[偏置])后跟一个固定的非线性函数(称为*激活函数)。

图 6.2 人工神经元:包含在非线性函数中的线性变换

从数学上讲,我们可以将其写为o = f(w * x + b),其中x是我们的输入,w是我们的权重或缩放因子,b是我们的偏置或偏移。f是我们的激活函数,这里设置为双曲正切函数,或者tan函数。一般来说,x和因此o可以是简单的标量,或者是矢量值(表示许多标量值);类似地,w可以是单个标量或矩阵,而b是标量或矢量(然而,输入和权重的维度必须匹配)。在后一种情况下,前面的表达式被称为一个神经元,因为它通过多维权重和偏置表示许多神经元。

6.1.1 组合多层网络

如图 6.3 所示,一个多层神经网络由我们刚刚讨论的函数组合而成

x_1 = f(w_0 * x + b_0)
x_2 = f(w_1 * x_1 + b_1)
...
y = f(w_n * x_n + b_n)

神经元层的输出被用作下一层的输入。请记住,这里的w_0是一个矩阵,而x是一个向量!使用向量允许w_0保存整个的神经元,而不仅仅是一个单独的权重。

图 6.3 一个具有三层的神经网络

6.1.2 理解误差函数

我们之前的线性模型和我们实际用于深度学习的模型之间的一个重要区别是误差函数的形状。我们的线性模型和误差平方损失函数具有凸误差曲线,具有一个明确定义的最小值。如果我们使用其他方法,我们可以自动和明确地解决最小化误差函数的参数。这意味着我们的参数更新试图估计那个明确的正确答案。

即使使用相同的误差平方损失函数,神经网络也不具有凸误差曲面的属性!对于我们试图逼近的每个参数,没有一个单一的正确答案。相反,我们试图让所有参数在协同作用下产生一个有用的输出。由于这个有用的输出只会近似真相,所以会有一定程度的不完美。不完美会在何处和如何显现在某种程度上是任意的,因此控制输出(因此也是不完美)的参数也是任意的。这导致神经网络训练在机械角度上看起来非常像参数估计,但我们必须记住理论基础是完全不同的。

神经网络具有非凸误差曲面的一个重要原因是激活函数。一组神经元能够逼近非常广泛的有用函数的能力取决于每个神经元固有的线性和非线性行为的组合。

6.1.3 我们只需要激活

正如我们所看到的,(深度)神经网络中最简单的单元是线性操作(缩放 + 偏移)后跟一个激活函数。我们在我们最新的模型中已经有了我们的线性操作--线性操作就是整个模型。激活函数发挥着两个重要的作用:

  • 在模型的内部部分,它允许输出函数在不同值处具有不同的斜率--这是线性函数根据定义无法做到的。通过巧妙地组合这些具有不同斜率的部分来产生许多输出,神经网络可以逼近任意函数,正如我们将在第 6.1.6 节中看到的。

  • 在网络的最后一层,它的作用是将前面的线性操作的输出集中到给定范围内。

让我们谈谈第二点的含义。假设我们正在为图像分配“好狗狗”分数。金毛猎犬和西班牙猎犬的图片应该有一个高分,而飞机和垃圾车的图片应该有一个低分。熊的图片也应该有一个较低的分数,尽管比垃圾车高。

问题在于,我们必须定义一个“高分”:我们有整个float32范围可供使用,这意味着我们可以得到相当高的分数。即使我们说“这是一个 10 分制”,仍然存在一个问题,即有时我们的模型会产生 11 分中的 11 分。请记住,在底层,这都是(w*x+b)矩阵乘法的总和,它们不会自然地限制自己在特定范围的输出。

限制输出范围

我们希望牢固地约束我们线性操作的输出到特定范围,这样输出的消费者就不必处理小狗得分为 12/10,熊得分为-10,垃圾车得分为-1,000 的数值输入。

一种可能性是简单地限制输出数值:低于 0 的设为 0,高于 10 的设为 10。这是一个简单的激活函数称为torch.nn.Hardtanhpytorch.org/docs/stable/nn.html#hardtanh,但请注意默认范围是-1 到+1)。

压缩输出范围

另一组效果良好的函数是torch.nn.Sigmoid,其中包括1 / (1 + e ** -x)torch.tanh,以及我们马上会看到的其他函数。这些函数的曲线在x趋于负无穷时渐近地接近 0 或-1,在x增加时接近 1,并且在x == 0时具有大致恒定的斜率。从概念上讲,这种形状的函数效果很好,因为我们线性函数输出的中间区域是我们的神经元(再次强调,这只是一个线性函数后跟一个激活函数)会敏感的区域,而其他所有内容都被归类到边界值旁边。正如我们在图 6.4 中看到的,我们的垃圾车得分为-0.97,而熊、狐狸和狼的得分则在-0.3 到 0.3 的范围内。

这导致垃圾车被标记为“不是狗”,我们的好狗被映射为“明显是狗”,而我们的熊则处于中间位置。在代码中,我们可以看到确切的数值:

>>> import math
>>> math.tanh(-2.2)    # ❶
-0.9757431300314515
>>> math.tanh(0.1)     # ❷
0.09966799462495582
>>> math.tanh(2.5)     # ❸
0.9866142981514303

❶ 垃圾车

❷ 熊

❸ 好狗狗

当熊处于敏感范围时,对熊进行微小的更改将导致结果明显变化。例如,我们可以从灰熊切换到北极熊(其面部略带更传统的犬类面孔),随着我们滑向图表“非常像狗”的一端,我们会看到Y轴上的跳跃。相反,考拉熊会被认为不太像狗,我们会看到激活输出下降。然而,我们几乎无法让垃圾车被认为像狗:即使进行 drastical 改变,我们可能只会看到从-0.97 到-0.8 左右的变化。

图 6.4 显示了狗、熊和垃圾车通过tanh激活函数映射为它们的狗样程度

6.1.4 更多激活函数

有许多激活函数,其中一些显示在图 6.5 中。在第一列中,我们看到平滑函数TanhSoftplus,而第二列有激活函数的“硬”版本:HardtanhReLUReLU修正线性单元)值得特别注意,因为它目前被认为是表现最佳的通用激活函数之一;许多最新技术的结果都使用了它。Sigmoid激活函数,也称为逻辑函数,在早期深度学习工作中被广泛使用,但自那时以来已经不再常用,除非我们明确希望将其移动到 0...1 范围内:例如,当输出应该是概率时。最后,LeakyReLU函数修改了标准的ReLU,使其具有小的正斜率,而不是对负输入严格为零(通常这个斜率为 0.01,但这里显示为 0.1 以便清楚显示)。

6.1.5 选择最佳激活函数

激活函数很奇特,因为有许多被证明成功的种类(远远不止图 6.5 中显示的),很明显几乎没有严格的要求。因此,我们将讨论一些关于激活函数的一般性,这些一般性可能在具体情况下很容易被证伪。也就是说,根据定义,激活函数

  • 是非线性的。重复应用(w*x+b)而没有激活函数会导致具有相同(仿射线性)形式的函数。非线性允许整个网络逼近更复杂的函数。

  • 是可微的,因此可以通过它们计算梯度。像HardtanhReLU中看到的点间断是可以接受的。

没有这些特征,网络要么退回成为线性模型,要么变得难以训练。

以下对这些函数是正确的:

  • 它们至少有一个敏感范围,在这个范围内对输入进行非平凡的更改会导致相应的输出发生非平凡的变化。这对于训练是必要的。

  • 许多激活函数具有不敏感(或饱和)范围,在这个范围内对输入进行更改几乎不会对输出产生任何或很少的变化。

举例来说,Hardtanh函数可以通过在输入上组合敏感范围与不同的权重和偏置来轻松地用于制作函数的分段线性逼近。

图 6.5 常见和不那么常见的激活函数集合

通常(但远非普遍如此),激活函数至少具有以下之一:

  • 一个下界,当输入趋于负无穷时接近(或达到)

  • 一个类似但相反的正无穷的上界

想想我们对反向传播如何工作的了解,我们可以得出结论,当输入处于响应范围时,错误将通过激活向后传播得更有效,而当输入饱和时,错误不会对神经元产生很大影响(因为梯度将接近于零,由于输出周围的平坦区域)。

将所有这些放在一起,这就产生了一个非常强大的机制:我们在说,当将不同的输入呈现给由线性 + 激活单元构建的网络时,(a)不同的单元将对相同的输入在不同的范围内做出响应,而(b)与这些输入相关的错误主要会影响在敏感范围内运行的神经元,使其他单元在学习过程中基本上不受影响。此外,由于激活函数相对于其输入的导数在敏感范围内通常接近于 1,通过梯度下降估计在该范围内运行的单元的线性变换的参数将看起来很像我们之前看到的线性拟合。

我们开始对如何将许多线性 + 激活单元并行连接并依次堆叠起来形成一个能够逼近复杂函数的数学对象有了更深入的直觉。不同的单元组合将对输入在不同的范围内做出响应,并且这些单元的参数相对容易通过梯度下降进行优化,因为学习过程将表现得很像线性函数直到输出饱和。

6.1.6 神经网络的学习意味着什么

通过堆叠线性变换和可微激活函数构建模型,可以得到能够近似高度非线性过程的模型,并且我们可以通过梯度下降方法出奇地好地估计其参数。即使处理具有数百万参数的模型时,这仍然成立。使用深度神经网络如此吸引人的原因在于,它使我们不必过多担心代表我们数据的确切函数--无论是二次的、分段多项式的,还是其他什么。通过深度神经网络模型,我们有一个通用的逼近器和一个估计其参数的方法。这个逼近器可以根据我们的需求进行定制,无论是模型容量还是模型复杂输入/输出关系的能力,只需组合简单的构建块。我们可以在图 6.6 中看到一些例子。

图 6.6 组合多个线性单元和tanh激活函数以产生非线性输出

四个左上角的图显示了四个神经元--A、B、C 和 D--每个都有自己(任意选择的)权重和偏置。每个神经元使用Tanh激活函数,最小值为-1,最大值为 1。不同的权重和偏置移动了中心点,并改变了从最小到最大的过渡有多么剧烈,但它们显然都有相同的一般形状。在这些右侧的列中,显示了两对神经元相加在一起(A + B,然后是 C + D)。在这里,我们开始看到一些模仿单层神经元的有趣特性。A + B 显示了一个轻微的S曲线,极端值接近 0,但中间有一个正峰和一个负峰。相反,C + D 只有一个大的正峰,峰值高于我们单个神经元的最大值 1。

在第三行,我们开始组合我们的神经元,就像它们在一个两层网络中的样子。C(A + B)和 D(A + B)都有与 A + B 相同的正负峰,但正峰更加微妙。C(A + B) + D(A + B)的组合显示了一个新的特性:两个明显的负峰,可能还有一个非常微妙的第二个正峰,位于主要感兴趣区域的左侧。所有这些只用了两层中的四个神经元!

再次强调,这些神经元的参数仅仅是为了得到一个视觉上有趣的结果而选择的。训练的过程是找到这些权重和偏置的可接受值,使得最终的网络能够正确执行任务,比如根据地理坐标和年份时间预测可能的温度。通过成功执行任务,我们指的是在由用于训练数据的相同数据生成过程产生的未见数据上获得正确的输出。一个成功训练的网络,通过其权重和偏置的值,将以有意义的数字表示形式捕捉数据的固有结构,这些数字表示对以前未见的数据能够正确工作。

让我们在了解学习机制方面再迈出一步:深度神经网络使我们能够近似高度非线性的现象,而无需为其建立明确的模型。相反,从一个通用的、未经训练的模型开始,我们通过提供一组输入和输出以及一个损失函数来专门针对一个任务进行特化,并通过反向传播来优化。通过示例将通用模型专门化到一个任务上,这就是我们所说的学习,因为该模型并不是为特定任务而构建的--没有规则描述该任务如何工作被编码在模型中。

对于我们的温度计示例,我们假设两个温度计都是线性测量温度的。这个假设是我们隐式编码任务规则的地方:我们硬编码了输入/输出函数的形状;我们无法逼近除了围绕一条直线的数据点之外的任何东西。随着问题的维度增加(即,许多输入到许多输出)和输入/输出关系变得复杂,假设输入/输出函数的形状不太可能奏效。物理学家或应用数学家的工作通常是从第一原理提出现象的功能性描述,这样我们就可以从测量中估计未知参数,并获得对世界的准确模型。另一方面,深度神经网络是一类函数族,具有近似各种输入/输出关系的能力,而不一定需要我们提出现象的解释模型。在某种程度上,我们放弃了解释,以换取解决日益复杂问题的可能性。另一方面,我们有时缺乏建立我们所面对的事物的显式模型的能力、信息或计算资源,因此数据驱动的方法是我们前进的唯一途径。

6.2 PyTorch nn 模块

所有这些关于神经网络的讨论可能让您对使用 PyTorch 从头开始构建一个神经网络感到非常好奇。我们的第一步将是用一个神经网络单元替换我们的线性模型。从正确性的角度来看,这将是一个有点无用的后退,因为我们已经验证了我们的校准只需要一个线性函数,但从足够简单的问题开始并随后扩展仍然是非常重要的。

PyTorch 有一个专门用于神经网络的子模块,称为torch.nn。它包含创建各种神经网络架构所需的构建模块。在 PyTorch 的术语中,这些构建模块称为模块(在其他框架中,这些构建模块通常被称为)。PyTorch 模块是从nn.Module基类派生的 Python 类。一个模块可以有一个或多个Parameter实例作为属性,这些张量的值在训练过程中进行优化(想想我们线性模型中的wb)。一个模块也可以有一个或多个子模块(nn.Module的子类)作为属性,并且它将能够跟踪它们的参数。

注意 子模块必须是顶级属性,而不是嵌套在listdict实例中!否则,优化器将无法定位子模块(因此也无法定位它们的参数)。对于您的模型需要子模块列表或字典的情况,PyTorch 提供了nn.ModuleListnn.ModuleDict

毫不奇怪,我们可以找到一个名为nn.Linearnn.Module子类,它对其输入应用一个仿射变换(通过参数属性weightbias)并等同于我们在温度计实验中早期实现的内容。我们现在将从我们离开的地方精确开始,并将我们以前的代码转换为使用nn的形式。

6.2.1 使用 call 而不是 forward

所有 PyTorch 提供的nn.Module的子类都定义了它们的__call__方法。这使我们能够实例化一个nn.Linear并将其调用为一个函数,就像这样(代码/p1ch6/1_neural_networks.ipynb):

# In[5]:
import torch.nn as nn

linear_model = nn.Linear(1, 1)    # ❶
linear_model(t_un_val)

# Out[5]:
tensor([[0.6018],
        [0.2877]], grad_fn=<AddmmBackward>)

❶ 我们马上会看构造函数参数。

使用一组参数调用nn.Module的实例最终会调用一个名为forward的方法,该方法使用相同的参数。forward方法执行前向计算,而__call__在调用forward之前和之后执行其他相当重要的任务。因此,从技术上讲,可以直接调用forward,它将产生与__call__相同的输出,但不应该从用户代码中这样做:

y = model(x)             # ❶
y = model.forward(x)     # ❷

❶ 正确!

❷ 沉默的错误。不要这样做!

这是 Module._call_ 的实现(我们省略了与 JIT 相关的部分,并对清晰起见进行了一些简化;torch/nn/modules/module.py,第 483 行,类:Module):

def __call__(self, *input, **kwargs):
    for hook in self._forward_pre_hooks.values():
        hook(self, input)

    result = self.forward(*input, **kwargs)

    for hook in self._forward_hooks.values():
        hook_result = hook(self, input, result)
        # ...

    for hook in self._backward_hooks.values():
        # ...

    return result

正如我们所看到的,如果我们直接使用 .forward(...),将无法正确调用许多钩子。

6.2.2 返回线性模型

回到我们的线性模型。nn.Linear 的构造函数接受三个参数:输入特征的数量、输出特征的数量,以及线性模型是否包括偏置(默认为 True):

# In[5]:
import torch.nn as nn

linear_model = nn.Linear(1, 1)     # ❶
linear_model(t_un_val)

# Out[5]:
tensor([[0.6018],
        [0.2877]], grad_fn=<AddmmBackward>)

❶ 参数是输入大小、输出大小和默认为 True 的偏置。

在我们的情况中,特征的数量只是指模块的输入和输出张量的大小,因此为 1 和 1。例如,如果我们将温度和气压作为输入,那么输入中将有两个特征,输出中将有一个特征。正如我们将看到的,对于具有多个中间模块的更复杂模型,特征的数量将与模型的容量相关联。

我们有一个具有一个输入和一个输出特征的 nn.Linear 实例。这只需要一个权重和一个偏置:

# In[6]:
linear_model.weight

# Out[6]:
Parameter containing:
tensor([[-0.0674]], requires_grad=True)

# In[7]:
linear_model.bias

# Out[7]:
Parameter containing:
tensor([0.7488], requires_grad=True)

我们可以使用一些输入调用该模块:

# In[8]:
x = torch.ones(1)
linear_model(x)

# Out[8]:
tensor([0.6814], grad_fn=<AddBackward0>)

尽管 PyTorch 让我们可以这样做,但实际上我们并没有提供正确维度的输入。我们有一个接受一个输入并产生一个输出的模型,但 PyTorch 的 nn.Module 及其子类是设计用于同时处理多个样本的。为了容纳多个样本,模块期望输入的零维是批次中的样本数量。我们在第四章遇到过这个概念,当时我们学习如何将现实世界的数据排列成张量。

批处理输入

nn 中的任何模块都是为了一次对批量中的多个输入产生输出而编写的。因此,假设我们需要在 10 个样本上运行 nn.Linear,我们可以创建一个大小为 B × Nin 的输入张量,其中 B 是批次的大小,Nin 是输入特征的数量,并将其一次通过模型运行。例如:

# In[9]:
x = torch.ones(10, 1)
linear_model(x)

# Out[9]:
tensor([[0.6814],
        [0.6814],
        [0.6814],
        [0.6814],
        [0.6814],
        [0.6814],
        [0.6814],
        [0.6814],
        [0.6814],
        [0.6814]], grad_fn=<AddmmBackward>)

让我们深入研究一下这里发生的情况,图 6.7 显示了批处理图像数据的类似情况。我们的输入是 B × C × H × W,批处理大小为 3(比如,一只狗、一只鸟和一辆车的图像),三个通道维度(红色、绿色和蓝色),以及高度和宽度的未指定像素数量。正如我们所看到的,输出是大小为 B × Nout 的张量,其中 Nout 是输出特征的数量:在这种情况下是四个。

优化批处理

我们希望进行批处理的原因是多方面的。一个重要的动机是确保我们请求的计算量足够大,以充分利用我们用来执行计算的计算资源。特别是 GPU 是高度并行化的,因此在小型模型上单个输入会使大多数计算单元处于空闲状态。通过提供输入的批处理,计算可以分布在否则空闲的单元上,这意味着批处理结果会像单个结果一样快速返回。另一个好处是一些高级模型使用整个批次的统计信息,这些统计信息随着批次大小的增加而变得更好。

图 6.7 三个 RGB 图像一起批处理并输入到神经网络中。输出是大小为 4 的三个向量的批处理结果。

回到我们的温度计数据,t_ut_c 是大小为 B 的两个 1D 张量。借助广播,我们可以将我们的线性模型写成 w * x + b,其中 wb 是两个标量参数。这是因为我们只有一个输入特征:如果有两个,我们需要添加一个额外维度,将该 1D 张量转换为一个矩阵,其中行中有样本,列中有特征。

这正是我们需要做的,以切换到使用 nn.Linear。我们将我们的 B 输入重塑为 B × Nin,其中 Nin 为 1。这可以很容易地通过 unsqueeze 完成:

# In[2]:
t_c = [0.5,  14.0, 15.0, 28.0, 11.0,  8.0,  3.0, -4.0,  6.0, 13.0, 21.0]
t_u = [35.7, 55.9, 58.2, 81.9, 56.3, 48.9, 33.9, 21.8, 48.4, 60.4, 68.4]
t_c = torch.tensor(t_c).unsqueeze(1)                                     # ❶
t_u = torch.tensor(t_u).unsqueeze(1)                                     # ❶

t_u.shape

# Out[2]:
torch.Size([11, 1])

❶ 在轴 1 处添加额外维度

我们完成了;让我们更新我们的训练代码。首先,我们用nn.Linear(1,1)替换我们手工制作的模型,然后我们需要将线性模型的参数传递给优化器:

# In[10]:
linear_model = nn.Linear(1, 1)    # ❶
optimizer = optim.SGD(
    linear_model.parameters(),    # ❷
    lr=1e-2)

❶ 这只是之前的重新定义。

❷ 这个方法调用替换了[params]。

之前,我们的责任是创建参数并将它们作为optim.SGD的第一个参数传递。现在我们可以使用parameters方法向任何nn.Module询问由它或其任何子模块拥有的参数列表:

# In[11]:
linear_model.parameters()

# Out[11]:
<generator object Module.parameters at 0x7f94b4a8a750>

# In[12]:
list(linear_model.parameters())

# Out[12]:
[Parameter containing:
 tensor([[0.7398]], requires_grad=True), Parameter containing:
 tensor([0.7974], requires_grad=True)]

此调用递归地进入模块的init构造函数中定义的子模块,并返回遇到的所有参数的平面列表,这样我们就可以方便地将其传递给优化器构造函数,就像我们之前做的那样。

我们已经可以弄清楚训练循环中发生了什么。优化器提供了一个张量列表,这些张量被定义为requires_grad = True--所有的Parameter都是这样定义的,因为它们需要通过梯度下降进行优化。当调用training_loss.backward()时,grad会在图的叶节点上累积,这些叶节点恰好是传递给优化器的参数。

此时,SGD 优化器已经拥有了一切所需的东西。当调用optimizer.step()时,它将遍历每个Parameter,并按照其grad属性中存储的量进行更改。设计相当干净。

现在让我们看一下训练循环:

# In[13]:
def training_loop(n_epochs, optimizer, model, loss_fn, t_u_train, t_u_val,
                  t_c_train, t_c_val):
    for epoch in range(1, n_epochs + 1):
        t_p_train = model(t_u_train)                  # ❶
        loss_train = loss_fn(t_p_train, t_c_train)

        t_p_val = model(t_u_val)                      # ❶
        loss_val = loss_fn(t_p_val, t_c_val)

        optimizer.zero_grad()
        loss_train.backward()                         # ❷
        optimizer.step()

        if epoch == 1 or epoch % 1000 == 0:
            print(f"Epoch {epoch}, Training loss {loss_train.item():.4f},"
                  f" Validation loss {loss_val.item():.4f}")

❶ 现在传入的是模型,而不是单独的参数。

❷ 损失函数也被传入。我们马上会用到它。

实际上几乎没有任何变化,只是现在我们不再显式地将params传递给model,因为模型本身在内部保存了它的Parameters

还有最后一点,我们可以从torch.nn中利用的:损失。确实,nn带有几种常见的损失函数,其中包括nn.MSELoss(MSE 代表均方误差),这正是我们之前定义的loss_fnnn中的损失函数仍然是nn.Module的子类,因此我们将创建一个实例并将其作为函数调用。在我们的情况下,我们摆脱了手写的loss_fn并替换它:

# In[15]:
linear_model = nn.Linear(1, 1)
optimizer = optim.SGD(linear_model.parameters(), lr=1e-2)

training_loop(
    n_epochs = 3000,
    optimizer = optimizer,
    model = linear_model,
    loss_fn = nn.MSELoss(),    # ❶
    t_u_train = t_un_train,
    t_u_val = t_un_val,
    t_c_train = t_c_train,
    t_c_val = t_c_val)

print()
print(linear_model.weight)
print(linear_model.bias)

# Out[15]:
Epoch 1, Training loss 134.9599, Validation loss 183.1707
Epoch 1000, Training loss 4.8053, Validation loss 4.7307
Epoch 2000, Training loss 3.0285, Validation loss 3.0889
Epoch 3000, Training loss 2.8569, Validation loss 3.9105

Parameter containing:
tensor([[5.4319]], requires_grad=True)
Parameter containing:
tensor([-17.9693], requires_grad=True)

❶ 我们不再使用之前手写的损失函数。

所有输入到我们的训练循环中的其他内容保持不变。即使我们的结果仍然与以前相同。当然,得到相同的结果是预期的,因为任何差异都意味着两种实现中的一个存在错误。

6.3 最后是神经网络

这是一个漫长的旅程--这 20 多行代码中有很多可以探索的内容,我们需要定义和训练一个模型。希望到现在为止,训练中涉及的魔法已经消失,为机械留下了空间。到目前为止我们学到的东西将使我们能够拥有我们编写的代码,而不仅仅是在事情变得更加复杂时摸黑箱。

还有最后一步要走:用神经网络替换我们的线性模型作为我们的逼近函数。我们之前说过,使用神经网络不会导致更高质量的模型,因为我们校准问题的过程基本上是线性的。然而,在受控环境中从线性到神经网络的跃迁是有好处的,这样我们以后就不会感到迷失。

6.3.1 替换线性模型

我们将保持其他所有内容不变,包括损失函数,并且只重新定义model。让我们构建可能的最简单的神经网络:一个线性模块,后跟一个激活函数,进入另一个线性模块。第一个线性 + 激活层通常被称为隐藏层,出于历史原因,因为它的输出不是直接观察到的,而是馈送到输出层。虽然模型的输入和输出都是大小为 1(它们具有一个输入和一个输出特征),但第一个线性模块的输出大小通常大于 1。回顾我们之前对激活作用的解释,这可以导致不同的单元对输入的不同范围做出响应,从而增加我们模型的容量。最后一个线性层将获取激活的输出,并将它们线性组合以产生输出值。

没有标准的神经网络表示方法。图 6.8 显示了两种似乎有些典型的方式:左侧显示了我们的网络可能在基本介绍中如何描述,而右侧类似于更高级文献和研究论文中经常使用的风格。通常制作大致对应于 PyTorch 提供的神经网络模块的图块(尽管有时像Tanh激活层这样的东西并没有明确显示)。请注意,两者之间的一个略微微妙的区别是左侧的图中将输入和(中间)结果放在圆圈中作为主要元素。右侧,计算步骤更加突出。

图 6.8 我们最简单的神经网络的两个视图。左:初学者版本。右:高级版本。

nn通过nn.Sequential容器提供了一种简单的方法来连接模块:

# In[16]:
seq_model = nn.Sequential(
            nn.Linear(1, 13),    # ❶
            nn.Tanh(),
            nn.Linear(13, 1))    # ❷
seq_model

# Out[16]:
Sequential(
  (0): Linear(in_features=1, out_features=13, bias=True)
  (1): Tanh()
  (2): Linear(in_features=13, out_features=1, bias=True)
)

❶ 我们随意选择了 13。我们希望这个数字与我们周围漂浮的其他张量形状大小不同。

❷ 这个 13 必须与第一个大小匹配。

最终结果是一个模型,它接受由nn.Sequential的第一个模块指定的输入,将中间输出传递给后续模块,并产生由最后一个模块返回的输出。该模型从 1 个输入特征扩展到 13 个隐藏特征,通过一个tanh激活,然后将产生的 13 个数字线性组合成 1 个输出特征。

6.3.2 检查参数

调用model.parameters()将收集第一个和第二个线性模块的weightbias。在这种情况下通过打印它们的形状来检查参数是很有启发性的:

# In[17]:
[param.shape for param in seq_model.parameters()]

# Out[17]:
[torch.Size([13, 1]), torch.Size([13]), torch.Size([1, 13]), torch.Size([1])]

这些是优化器将获得的张量。再次,在我们调用model.backward()之后,所有参数都将填充其grad,然后优化器在optimizer.step()调用期间相应地更新它们的值。和我们之前的线性模型没有太大不同,对吧?毕竟,它们都是可以使用梯度下降进行训练的可微分模型。

有关nn.Modules参数的一些注意事项。当检查由几个子模块组成的模型的参数时,能够通过名称识别参数是很方便的。有一个方法可以做到这一点,称为named_parameters

# In[18]:
for name, param in seq_model.named_parameters():
    print(name, param.shape)

# Out[18]:
0.weight torch.Size([13, 1])
0.bias torch.Size([13])
2.weight torch.Size([1, 13])
2.bias torch.Size([1])

Sequential中每个模块的名称只是模块在参数中出现的顺序。有趣的是,Sequential还接受一个OrderedDict,在其中我们可以为传递给Sequential的每个模块命名:

# In[19]:
from collections import OrderedDict

seq_model = nn.Sequential(OrderedDict([
    ('hidden_linear', nn.Linear(1, 8)),
    ('hidden_activation', nn.Tanh()),
    ('output_linear', nn.Linear(8, 1))
]))

seq_model

# Out[19]:
Sequential(
  (hidden_linear): Linear(in_features=1, out_features=8, bias=True)
  (hidden_activation): Tanh()
  (output_linear): Linear(in_features=8, out_features=1, bias=True)
)

这使我们可以为子模块获得更具解释性的名称:

# In[20]:
for name, param in seq_model.named_parameters():
    print(name, param.shape)

# Out[20]:
hidden_linear.weight torch.Size([8, 1])
hidden_linear.bias torch.Size([8])
output_linear.weight torch.Size([1, 8])
output_linear.bias torch.Size([1])

这更具描述性;但它并没有给我们更多控制数据流的灵活性,数据流仍然是纯粹的顺序传递--nn.Sequential的命名非常贴切。我们将在第八章中看到如何通过自己子类化nn.Module来完全控制输入数据的处理。

我们还可以通过使用子模块作为属性来访问特定的Parameter

# In[21]:
seq_model.output_linear.bias

# Out[21]:
Parameter containing:
tensor([-0.0173], requires_grad=True)

这对于检查参数或它们的梯度非常有用:例如,要监视训练过程中的梯度,就像我们在本章开头所做的那样。假设我们想要打印出隐藏层线性部分的weight的梯度。我们可以运行新神经网络模型的训练循环,然后在最后一个时期查看结果梯度:

# In[22]:
optimizer = optim.SGD(seq_model.parameters(), lr=1e-3)    # ❶

training_loop(
    n_epochs = 5000,
    optimizer = optimizer,
    model = seq_model,
    loss_fn = nn.MSELoss(),
    t_u_train = t_un_train,
    t_u_val = t_un_val,
    t_c_train = t_c_train,
    t_c_val = t_c_val)

print('output', seq_model(t_un_val))
print('answer', t_c_val)
print('hidden', seq_model.hidden_linear.weight.grad)

# Out[22]:
Epoch 1, Training loss 182.9724, Validation loss 231.8708
Epoch 1000, Training loss 6.6642, Validation loss 3.7330
Epoch 2000, Training loss 5.1502, Validation loss 0.1406
Epoch 3000, Training loss 2.9653, Validation loss 1.0005
Epoch 4000, Training loss 2.2839, Validation loss 1.6580
Epoch 5000, Training loss 2.1141, Validation loss 2.0215
output tensor([[-1.9930],
        [20.8729]], grad_fn=<AddmmBackward>)
answer tensor([[-4.],
        [21.]])
hidden tensor([[ 0.0272],
        [ 0.0139],
        [ 0.1692],
        [ 0.1735],
        [-0.1697],
        [ 0.1455],
        [-0.0136],
        [-0.0554]])

❶ 我们稍微降低了学习率以提高稳定性。

6.3.3 与线性模型比较

我们还可以评估模型在所有数据上的表现,并查看它与一条直线的差异:

# In[23]:
from matplotlib import pyplot as plt

t_range = torch.arange(20., 90.).unsqueeze(1)

fig = plt.figure(dpi=600)
plt.xlabel("Fahrenheit")
plt.ylabel("Celsius")
plt.plot(t_u.numpy(), t_c.numpy(), 'o')
plt.plot(t_range.numpy(), seq_model(0.1 * t_range).detach().numpy(), 'c-')
plt.plot(t_u.numpy(), seq_model(0.1 * t_u).detach().numpy(), 'kx')

结果显示在图 6.9 中。我们可以看到神经网络有过拟合的倾向,正如我们在第五章讨论的那样,因为它试图追踪测量值,包括嘈杂的值。即使我们微小的神经网络有太多参数来拟合我们所拥有的少量测量值。总的来说,它做得还不错。

图 6.9 我们的神经网络模型的绘图,包括输入数据(圆圈)和模型输出(X)。连续线显示样本之间的行为。

6.4 结论

尽管我们一直在处理一个非常简单的问题,但在第五章和第六章中我们已经涵盖了很多内容。我们分析了构建可微分模型并使用梯度下降进行训练,首先使用原始自动求导,然后依赖于nn。到目前为止,您应该对幕后发生的事情有信心。希望这一次 PyTorch 的体验让您对更多内容感到兴奋!

6.5 练习

  1. 在我们简单的神经网络模型中尝试隐藏神经元的数量以及学习率。

    1. 什么改变会导致模型输出更线性?

    2. 你能明显地使模型过拟合数据吗?

  2. 物理学中第三难的问题是找到一种合适的葡萄酒来庆祝发现。从第四章加载葡萄酒数据,并创建一个具有适当数量输入参数的新模型。

    1. 训练所需时间与我们一直在使用的温度数据相比需要多长时间?

    2. 你能解释哪些因素导致训练时间?

    3. 你能在这个数据集上训练时使损失减少吗?

    4. 你会如何绘制多个数据集的图表?

6.6 总结

  • 神经网络可以自动适应专门解决手头问题。

  • 神经网络允许轻松访问模型中任何参数相对于损失的解析导数,这使得演化参数非常高效。由于其自动微分引擎,PyTorch 轻松提供这些导数。

  • 环绕线性变换的激活函数使神经网络能够逼近高度非线性函数,同时保持足够简单以进行优化。

  • nn模块与张量标准库一起提供了创建神经网络的所有构建模块。

  • 要识别过拟合,保持训练数据点与验证集分开是至关重要的。没有一种对抗过拟合的固定方法,但增加数据量,或增加数据的变化性,并转向更简单的模型是一个良好的开始。

  • 做数据科学的人应该一直在绘制数据。


¹ 参见 F. Rosenblatt,“感知器:大脑中信息存储和组织的概率模型”,心理评论 65(6),386-408(1958 年),pubmed.ncbi.nlm.nih.gov/13602029/

² 为了直观地理解这种通用逼近性质,你可以从图 6.5 中选择一个函数,然后构建一个几乎在大部分区域为零且在x = 0 周围为正的基本函数,通过缩放(包括乘以负数)、平移激活函数的副本。通过这个基本函数的缩放、平移和扩展(沿X轴挤压)的副本,你可以逼近任何(连续)函数。在图 6.6 中,右侧中间行的函数可能是这样一个基本构件。Michael Nielsen 在他的在线书籍神经网络与深度学习中有一个交互式演示,网址为mng.bz/Mdon

³ 当然,即使这些说法并不总是正确;参见 Jakob Foerster 的文章,“深度线性网络中的非线性计算”,OpenAI,2019,mng.bz/gygE

⁴ 并非所有版本的 Python 都指定了dict的迭代顺序,因此我们在这里使用OrderedDict来确保层的顺序,并强调层的顺序很重要。

七、从图像中识别鸟类和飞机:从图像中学习

本章内容包括

  • 构建前馈神经网络

  • 使用DatasetDataLoader加载数据

  • 理解分类损失

上一章让我们有机会深入了解通过梯度下降学习的内部机制,以及 PyTorch 提供的构建模型和优化模型的工具。我们使用了一个简单的具有一个输入和一个输出的回归模型,这使我们可以一目了然,但诚实地说只是勉强令人兴奋。

在本章中,我们将继续构建我们的神经网络基础。这一次,我们将把注意力转向图像。图像识别可以说是让世界意识到深度学习潜力的任务。

我们将逐步解决一个简单的图像识别问题,从上一章中定义的简单神经网络开始构建。这一次,我们将使用一个更广泛的小图像数据集,而不是一组数字。让我们首先下载数据集,然后开始准备使用它。

7.1 一个小图像数据集

没有什么比对一个主题的直观理解更好,也没有什么比处理简单数据更能实现这一点。图像识别中最基本的数据集之一是被称为 MNIST 的手写数字识别数据集。在这里,我们将使用另一个类似简单且更有趣的数据集。它被称为 CIFAR-10,就像它的姐妹 CIFAR-100 一样,它已经成为计算机视觉领域的经典数据集十年。

CIFAR-10 由 60,000 个 32×32 彩色(RGB)图像组成,标记为 10 个类别中的一个整数:飞机(0)、汽车(1)、鸟(2)、猫(3)、鹿(4)、狗(5)、青蛙(6)、马(7)、船(8)和卡车(9)。如今,CIFAR-10 被认为对于开发或验证新研究来说过于简单,但对于我们的学习目的来说完全够用。我们将使用torchvision模块自动下载数据集,并将其加载为一组 PyTorch 张量。图 7.1 让我们一睹 CIFAR-10 的风采。

图 7.1 显示所有 CIFAR-10 类别的图像样本

7.1.1 下载 CIFAR-10

正如我们预期的那样,让我们导入torchvision并使用datasets模块下载 CIFAR-10 数据:

# In[2]:
from torchvision import datasets
data_path = '../data-unversioned/p1ch7/'
cifar10 = datasets.CIFAR10(data_path, train=True, download=True)        # ❶
cifar10_val = datasets.CIFAR10(data_path, train=False, download=True)   # ❷

❶ 为训练数据实例化一个数据集;如果数据不存在,TorchVision 会下载数据

❷ 使用 train=False,这样我们就得到了一个用于验证数据的数据集,如果需要的话会进行下载。

我们提供给CIFAR10函数的第一个参数是数据将被下载的位置;第二个参数指定我们是对训练集感兴趣还是对验证集感兴趣;第三个参数表示我们是否允许 PyTorch 在指定的位置找不到数据时下载数据。

就像CIFAR10一样,datasets子模块为我们提供了对最流行的计算机视觉数据集的预先访问,如 MNIST、Fashion-MNIST、CIFAR-100、SVHN、Coco 和 Omniglot。在每种情况下,数据集都作为torch.utils.data.Dataset的子类返回。我们可以看到我们的cifar10实例的方法解析顺序将其作为一个基类:

# In[4]:
type(cifar10).__mro__

# Out[4]:
(torchvision.datasets.cifar.CIFAR10,
 torchvision.datasets.vision.VisionDataset,
 torch.utils.data.dataset.Dataset,
 object)

7.1.2 Dataset 类

现在是一个好时机去了解在实践中成为torch.utils.data.Dataset子类意味着什么。看一下图 7.2,我们就能明白 PyTorch 的Dataset是什么。它是一个需要实现两个方法的对象:__len____getitem__。前者应该返回数据集中的项目数;后者应该返回项目,包括一个样本及其对应的标签(一个整数索引)。

在实践中,当一个 Python 对象配备了__len__方法时,我们可以将其作为参数传递给lenPython 内置函数:

# In[5]:
len(cifar10)

# Out[5]:
50000

图 7.2 PyTorch Dataset 对象的概念:它不一定保存数据,但通过 __len____getitem__ 提供统一访问。

同样,由于数据集配备了 __getitem__ 方法,我们可以使用标准的下标索引元组和列表来访问单个项目。在这里,我们得到了一个 PIL(Python Imaging Library,PIL 包)图像,输出我们期望的整数值 1,对应于“汽车”:

# In[6]:
img, label = cifar10[99]
img, label, class_names[label]

# Out[6]:
(<PIL.Image.Image image mode=RGB size=32x32 at 0x7FB383657390>,
 1,
 'automobile')

因此,data.CIFAR10 数据集中的样本是 RGB PIL 图像的一个实例。我们可以立即绘制它:

# In[7]:
plt.imshow(img)
plt.show()

这产生了图 7.3 中显示的输出。这是一辆红色的汽车!³

图 7.3 CIFAR-10 数据集中的第 99 张图像:一辆汽车

7.1.3 数据集转换

这一切都很好,但我们可能需要一种方法在对其进行任何操作之前将 PIL 图像转换为 PyTorch 张量。这就是 torchvision.transforms 的作用。该模块定义了一组可组合的、类似函数的对象,可以作为参数传递给 torchvision 数据集,如 datasets.CIFAR10(...),并在加载数据后但在 __getitem__ 返回数据之前对数据执行转换。我们可以查看可用对象的列表如下:

# In[8]:
from torchvision import transforms
dir(transforms)

# Out[8]:
['CenterCrop',
 'ColorJitter',
 ...
 'Normalize',
 'Pad',
 'RandomAffine',
 ...
 'RandomResizedCrop',
 'RandomRotation',
 'RandomSizedCrop',
 ...
 'TenCrop',
 'ToPILImage',
 'ToTensor',
 ...
]

在这些转换中,我们可以看到 ToTensor,它将 NumPy 数组和 PIL 图像转换为张量。它还会确保输出张量的维度布局为 C × H × W(通道、高度、宽度;就像我们在第四章中介绍的那样)。

让我们尝试一下 ToTensor 转换。一旦实例化,它可以像一个函数一样调用,参数是 PIL 图像,返回一个张量作为输出:

# In[9]:

to_tensor = transforms.ToTensor()
img_t = to_tensor(img)
img_t.shape

# Out[9]:
torch.Size([3, 32, 32])

图像已经转换为 3 × 32 × 32 张量,因此是一个 3 通道(RGB)32 × 32 图像。请注意 label 没有发生任何变化;它仍然是一个整数。

正如我们预期的那样,我们可以直接将转换作为参数传递给 dataset .CIFAR10

# In[10]:
tensor_cifar10 = datasets.CIFAR10(data_path, train=True, download=False,
                          transform=transforms.ToTensor())

此时,访问数据集的元素将返回一个张量,而不是一个 PIL 图像:

# In[11]:
img_t, _ = tensor_cifar10[99]
type(img_t)

# Out[11]:
torch.Tensor

如预期的那样,形状的第一个维度是通道,标量类型是 float32

# In[12]:
img_t.shape, img_t.dtype

# Out[12]:
(torch.Size([3, 32, 32]), torch.float32)

原始 PIL 图像中的值范围从 0 到 255(每个通道 8 位),ToTensor 转换将数据转换为每个通道的 32 位浮点数,将值从 0.0 缩放到 1.0。让我们验证一下:

# In[13]:
img_t.min(), img_t.max()

# Out[13]:
(tensor(0.), tensor(1.))

现在让我们验证一下我们得到了相同的图像:

# In[14]:
plt.imshow(img_t.permute(1, 2, 0))    # ❶
plt.show()

# Out[14]:
<Figure size 432x288 with 1 Axes>

❶ 改变轴的顺序从 C × H × W 到 H × W × C

正如我们在图 7.4 中看到的,我们得到了与之前相同的输出。

图 7.4 我们已经见过这个。

检查通过。请注意,我们必须使用 permute 来改变轴的顺序,从 C × H × W 变为 H × W × C,以匹配 Matplotlib 的期望。

7.1.4 数据标准化

转换非常方便,因为我们可以使用 transforms.Compose 链接它们,它们可以透明地处理标准化和数据增强,直接在数据加载器中进行。例如,标准化数据集是一个好习惯,使得每个通道具有零均值和单位标准差。我们在第四章中提到过这一点,但现在,在经历了第五章之后,我们也对此有了直观的理解:通过选择在 0 加减 1(或 2)附近线性的激活函数,保持数据在相同范围内意味着神经元更有可能具有非零梯度,因此会更快地学习。此外,将每个通道标准化,使其具有相同的分布,将确保通道信息可以通过梯度下降混合和更新,使用相同的学习率。这就像在第 5.4.4 节中,当我们将权重重新缩放为与温度转换模型中的偏差相同数量级时的情况。

为了使每个通道的均值为零,标准差为单位,我们可以计算数据集中每个通道的均值和标准差,并应用以下转换:v_n[c] = (v[c] - mean[c]) / stdev[c]。这就是transforms.Normalize所做的。meanstdev的值必须离线计算(它们不是由转换计算的)。让我们为 CIFAR-10 训练集计算它们。

由于 CIFAR-10 数据集很小,我们将能够完全在内存中操作它。让我们沿着额外的维度堆叠数据集返回的所有张量:

# In[15]:
imgs = torch.stack([img_t for img_t, _ in tensor_cifar10], dim=3)
imgs.shape

# Out[15]:
torch.Size([3, 32, 32, 50000])

现在我们可以轻松地计算每个通道的均值:

# In[16]:
imgs.view(3, -1).mean(dim=1)     # ❶

# Out[16]:
tensor([0.4915, 0.4823, 0.4468])

❶ 请记住,view(3, -1)保留了三个通道,并将所有剩余的维度合并成一个,找出适当的大小。这里我们的 3 × 32 × 32 图像被转换成一个 3 × 1,024 向量,然后对每个通道的 1,024 个元素取平均值。

计算标准差类似:

# In[17]:
imgs.view(3, -1).std(dim=1)

# Out[17]:
tensor([0.2470, 0.2435, 0.2616])

有了这些数据,我们可以初始化Normalize转换

# In[18]:
transforms.Normalize((0.4915, 0.4823, 0.4468), (0.2470, 0.2435, 0.2616))

# Out[18]:
Normalize(mean=(0.4915, 0.4823, 0.4468), std=(0.247, 0.2435, 0.2616))

并在ToTensor转换后连接它:

# In[19]:
transformed_cifar10 = datasets.CIFAR10(
    data_path, train=True, download=False,
    transform=transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.4915, 0.4823, 0.4468),
                             (0.2470, 0.2435, 0.2616))
    ]))

请注意,在这一点上,绘制从数据集中绘制的图像不会为我们提供实际图像的忠实表示:

# In[21]:
img_t, _ = transformed_cifar10[99]

plt.imshow(img_t.permute(1, 2, 0))
plt.show()

我们得到的重新归一化的红色汽车如图 7.5 所示。这是因为归一化已经将 RGB 级别移出了 0.0 到 1.0 的范围,并改变了通道的整体幅度。所有的数据仍然存在;只是 Matplotlib 将其渲染为黑色。我们将记住这一点以备将来参考。

图 7.5 归一化后的随机 CIFAR-10 图像

图 7.6 手头的问题:我们将帮助我们的朋友为她的博客区分鸟和飞机,通过训练一个神经网络来完成这项任务。

尽管如此,我们加载了一个包含成千上万张图片的花哨数据集!这非常方便,因为我们正需要这样的东西。

7.2 区分鸟和飞机

珍妮,我们在观鸟俱乐部的朋友,在机场南部的树林里设置了一组摄像头。当有东西进入画面时,摄像头应该保存一张照片并上传到俱乐部的实时观鸟博客。问题是,许多从机场进出的飞机最终触发了摄像头,所以珍妮花了很多时间从博客中删除飞机的照片。她需要的是一个像图 7.6 中所示的自动化系统。她需要一个神经网络--如果我们喜欢花哨的营销说辞,那就是人工智能--来立即丢弃飞机。

别担心!我们会处理好的,没问题--我们刚好有了完美的数据集(多么巧合啊,对吧?)。我们将从我们的 CIFAR-10 数据集中挑选出所有的鸟和飞机,并构建一个可以区分鸟和飞机的神经网络。

7.2.1 构建数据集

第一步是将数据整理成正确的形状。我们可以创建一个仅包含鸟和飞机的Dataset子类。然而,数据集很小,我们只需要在数据集上进行索引和len操作。它实际上不必是torch.utils.data.dataset.Dataset的子类!那么,为什么不简单地过滤cifar10中的数据并重新映射标签,使它们连续呢?下面是具体操作:

# In[5]:
label_map = {0: 0, 2: 1}
class_names = ['airplane', 'bird']
cifar2 = [(img, label_map[label])
          for img, label in cifar10
          if label in [0, 2]]
cifar2_val = [(img, label_map[label])
              for img, label in cifar10_val
              if label in [0, 2]]

cifar2对象满足Dataset的基本要求--也就是说,__len____getitem__已经定义--所以我们将使用它。然而,我们应该意识到,这是一个聪明的捷径,如果我们在使用中遇到限制,我们可能希望实现一个合适的Dataset。⁴

我们有了数据集!接下来,我们需要一个模型来处理我们的数据。

7.2.2 一个全连接的模型

我们在第五章学习了如何构建一个神经网络。我们知道它是一个特征的张量输入,一个特征的张量输出。毕竟,一幅图像只是以空间配置排列的一组数字。好吧,我们还不知道如何处理空间配置部分,但理论上,如果我们只是取图像像素并将它们展平成一个长的 1D 向量,我们可以将这些数字视为输入特征,对吧?这就是图 7.7 所说明的。

图 7.7 将我们的图像视为一维值向量并在其上训练一个全连接分类器

让我们试试看。每个样本有多少特征?嗯,32 × 32 × 3:也就是说,每个样本有 3072 个输入特征。从我们在第五章构建的模型开始,我们的新模型将是一个具有 3072 个输入特征和一些隐藏特征数量的nn.Linear,然后是一个激活函数,然后是另一个将网络缩减到适当的输出特征数量(对于这种用例为 2)的nn.Linear

# In[6]:
import torch.nn as nn

n_out = 2

model = nn.Sequential(
            nn.Linear(
                3072,     # ❶
                512,      # ❷
            ),
            nn.Tanh(),
            nn.Linear(
                512,      # ❷
                n_out,    # ❸
            )
        )

❶ 输入特征

❷ 隐藏层大小

❸ 输出类别

我们有点随意地选择了 512 个隐藏特征。神经网络至少需要一个隐藏层(激活层,所以两个模块),中间需要一个非线性激活函数,以便能够学习我们在第 6.3 节中讨论的任意函数--否则,它将只是一个线性模型。隐藏特征表示(学习的)输入之间通过权重矩阵编码的关系。因此,模型可能会学习“比较”向量元素 176 和 208,但它并不会事先关注它们,因为它在结构上不知道这些实际上是(第 5 行,第 16 像素)和

(第 6 行,第 16 像素),因此是相邻的。

所以我们有了一个模型。接下来我们将讨论我们模型的输出应该是什么。

7.2.3 分类器的输出

在第六章中,网络产生了预测的温度(具有定量意义的数字)作为输出。我们可以在这里做类似的事情:使我们的网络输出一个单一的标量值(所以n_out = 1),将标签转换为浮点数(飞机为 0.0,鸟为 1.0),并将其用作MSELoss的目标(批次中平方差的平均值)。这样做,我们将问题转化为一个回归问题。然而,更仔细地观察,我们现在处理的是一种性质有点不同的东西。

我们需要认识到输出是分类的:它要么是飞机,要么是鸟(或者如果我们有所有 10 个原始类别的话,还可能是其他东西)。正如我们在第四章中学到的,当我们必须表示一个分类变量时,我们应该切换到该变量的一种独热编码表示,比如对于飞机是[1, 0],对于鸟是[0, 1](顺序是任意的)。如果我们有 10 个类别,如完整的 CIFAR-10 数据集,这仍然有效;我们将只有一个长度为 10 的向量。

在理想情况下,网络将为飞机输出torch.tensor([1.0, 0.0]),为鸟输出torch.tensor([0.0, 1.0])。实际上,由于我们的分类器不会是完美的,我们可以期望网络输出介于两者之间的值。在这种情况下的关键认识是,我们可以将输出解释为概率:第一个条目是“飞机”的概率,第二个是“鸟”的概率。

将问题转化为概率的形式对我们网络的输出施加了一些额外的约束:

  • 输出的每个元素必须在[0.0, 1.0]范围内(一个结果的概率不能小于 0 或大于 1)。

  • 输出的元素必须加起来等于 1.0(我们确定两个结果中的一个将会发生)。

这听起来像是在一个数字向量上以可微分的方式强制执行一个严格的约束。然而,有一个非常聪明的技巧正是做到了这一点,并且是可微分的:它被称为softmax

7.2.4 将输出表示为概率

Softmax 是一个函数,它接受一个值向量并产生另一个相同维度的向量,其中值满足我们刚刚列出的表示概率的约束条件。Softmax 的表达式如图 7.8 所示。

图 7.8 手写 softmax

也就是说,我们取向量的元素,计算元素的指数,然后将每个元素除以指数的总和。在代码中,就像这样:

# In[7]:
def softmax(x):
    return torch.exp(x) / torch.exp(x).sum()

让我们在一个输入向量上测试一下:

# In[8]:
x = torch.tensor([1.0, 2.0, 3.0])

softmax(x)

# Out[8]:
tensor([0.0900, 0.2447, 0.6652])

如预期的那样,它满足概率的约束条件:

# In[9]:
softmax(x).sum()

# Out[9]:
tensor(1.)

Softmax 是一个单调函数,即输入中的较低值将对应于输出中的较低值。然而,它不是尺度不变的,即值之间的比率不被保留。事实上,输入的第一个和第二个元素之间的比率为 0.5,而输出中相同元素之间的比率为 0.3678。这并不是一个真正的问题,因为学习过程将以适当的比率调整模型的参数。

nn模块将 softmax 作为一个模块提供。由于通常输入张量可能具有额外的批次第 0 维,或者具有编码概率的维度和其他维度,nn.Softmax要求我们指定应用 softmax 函数的维度:

# In[10]:
softmax = nn.Softmax(dim=1)

x = torch.tensor([[1.0, 2.0, 3.0],
                  [1.0, 2.0, 3.0]])

softmax(x)

# Out[10]:
tensor([[0.0900, 0.2447, 0.6652],
        [0.0900, 0.2447, 0.6652]])

在这种情况下,我们有两个输入向量在两行中(就像我们处理批次时一样),因此我们初始化nn.Softmax以沿着第 1 维操作。

太棒了!我们现在可以在模型末尾添加一个 softmax,这样我们的网络就能够生成概率:

# In[11]:
model = nn.Sequential(
            nn.Linear(3072, 512),
            nn.Tanh(),
            nn.Linear(512, 2),
            nn.Softmax(dim=1))

实际上,我们可以在甚至训练模型之前尝试运行模型。让我们试试,看看会得到什么。我们首先构建一个包含一张图片的批次,我们的鸟(图 7.9):

# In[12]:
img, _ = cifar2[0]

plt.imshow(img.permute(1, 2, 0))
plt.show()

图 7.9 CIFAR-10 数据集中的一只随机鸟(归一化后)

哦,你好。为了调用模型,我们需要使输入具有正确的维度。我们记得我们的模型期望输入中有 3,072 个特征,并且nn将数据组织成沿着第零维的批次。因此,我们需要将我们的 3 × 32 × 32 图像转换为 1D 张量,然后在第零位置添加一个额外的维度。我们在第三章学习了如何做到这一点:

# In[13]:
img_batch = img.view(-1).unsqueeze(0)

现在我们准备调用我们的模型:

# In[14]:
out = model(img_batch)
out

# Out[14]:
tensor([[0.4784, 0.5216]], grad_fn=<SoftmaxBackward>)

所以,我们得到了概率!好吧,我们知道我们不应该太兴奋:我们的线性层的权重和偏置根本没有经过训练。它们的元素由 PyTorch 在-1.0 和 1.0 之间随机初始化。有趣的是,我们还看到输出的grad_fn,这是反向计算图的顶点(一旦我们需要反向传播时将被使用)。

另外,虽然我们知道哪个输出概率应该是哪个(回想一下我们的class_names),但我们的网络并没有这方面的指示。第一个条目是“飞机”,第二个是“鸟”,还是反过来?在这一点上,网络甚至无法判断。正是损失函数在反向传播后将这两个数字关联起来。如果标签提供为“飞机”索引 0 和“鸟”索引 1,那么输出将被诱导采取这个顺序。因此,在训练后,我们将能够通过计算输出概率的argmax来获得标签:也就是说,我们获得最大概率的索引。方便的是,当提供一个维度时,torch.max会返回沿着该维度的最大元素以及该值出现的索引。在我们的情况下,我们需要沿着概率向量(而不是跨批次)取最大值,因此是第 1 维:

# In[15]:
_, index = torch.max(out, dim=1)

index

# Out[15]:
tensor([1])

它说这张图片是一只鸟。纯属运气。但我们通过让模型输出概率来适应手头的分类任务,现在我们已经运行了我们的模型对输入图像进行验证,确保我们的管道正常工作。是时候开始训练了。与前两章一样,我们在训练过程中需要最小化的损失。

7.2.5 用于分类的损失

我们刚提到损失是给概率赋予意义的。在第 5 和第六章中,我们使用均方误差(MSE)作为我们的损失。我们仍然可以使用 MSE,并使我们的输出概率收敛到[0.0, 1.0][1.0, 0.0]。然而,仔细想想,我们并不真正关心精确复制这些值。回顾我们用于提取预测类别索引的 argmax 操作,我们真正感兴趣的是第一个概率对于飞机而言比第二个更高,对于鸟而言则相反。换句话说,我们希望惩罚错误分类,而不是费力地惩罚一切看起来不完全像 0.0 或 1.0 的东西。

在这种情况下,我们需要最大化的是与正确类别相关联的概率,out[class_index],其中out是 softmax 的输出,class_index是一个包含 0 表示“飞机”和 1 表示“鸟”的向量,对于每个样本。这个数量--即与正确类别相关联的概率--被称为似然度(给定数据的模型参数的)。换句话说,我们希望一个损失函数在似然度低时非常高:低到其他选择具有更高的概率。相反,当似然度高于其他选择时,损失应该很低,我们并不真正固执于将概率提高到 1。

有一个表现出这种行为的损失函数,称为负对数似然(NLL)。它的表达式为NLL = - sum(log(out_i[c_i])),其中求和是针对N个样本,c_i是样本i的正确类别。让我们看一下图 7.10,它显示了 NLL 作为预测概率的函数。

图 7.10 预测概率的 NLL 损失函数

图表显示,当数据被分配低概率时,NLL 增长到无穷大,而当概率大于 0.5 时,它以相对缓慢的速度下降。记住,NLL 以概率作为输入;因此,随着可能性增加,其他概率必然会减少。

总结一下,我们的分类损失可以计算如下。对于批次中的每个样本:

  1. 运行正向传播,并从最后(线性)层获取输出值。

  2. 计算它们的 softmax,并获得概率。

  3. 获取与正确类别对应的预测概率(参数的似然度)。请注意,我们知道正确类别是什么,因为这是一个监督问题--这是我们的真实值。

  4. 计算其对数,加上一个负号,并将其添加到损失中。

那么,在 PyTorch 中我们如何做到这一点呢?PyTorch 有一个nn.NLLLoss类。然而(注意),与您可能期望的相反,它不接受概率,而是接受对数概率的张量作为输入。然后,它计算给定数据批次的我们模型的 NLL。这种输入约定背后有一个很好的原因:当概率接近零时,取对数是棘手的。解决方法是使用nn.LogSoftmax而不是nn.Softmax,后者会确保计算在数值上是稳定的。

现在我们可以修改我们的模型,使用nn.LogSoftmax作为输出模块:

model = nn.Sequential(
             nn.Linear(3072, 512),
             nn.Tanh(),
             nn.Linear(512, 2),
             nn.LogSoftmax(dim=1))

然后我们实例化我们的 NLL 损失:

loss = nn.NLLLoss()

损失将nn.LogSoftmax的输出作为批次的第一个参数,并将类别索引的张量(在我们的情况下是零和一)作为第二个参数。现在我们可以用我们的小鸟来测试它:

img, label = cifar2[0]

out = model(img.view(-1).unsqueeze(0))

loss(out, torch.tensor([label]))

tensor(0.6509, grad_fn=<NllLossBackward>)

结束我们对损失的研究,我们可以看看使用交叉熵损失如何改善均方误差。在图 7.11 中,我们看到当预测偏离目标时,交叉熵损失有一些斜率(在低损失角落,正确类别被分配了预测概率为 99.97%),而我们在开始时忽略的均方误差更早饱和,关键是对于非常错误的预测也是如此。其根本原因是均方误差的斜率太低,无法弥补错误预测的 softmax 函数的平坦性。这就是为什么概率的均方误差不适用于分类工作。

图 7.11 预测概率与目标概率向量之间的交叉熵(左)和均方误差(右)作为预测分数的函数--也就是在(对数)softmax 之前

7.2.6 训练分类器

好了!我们准备好重新引入我们在第五章写的训练循环,并看看它是如何训练的(过程如图 7.12 所示):

import torch
import torch.nn as nn

model = nn.Sequential(
            nn.Linear(3072, 512),
            nn.Tanh(),
            nn.Linear(512, 2),
            nn.LogSoftmax(dim=1))

learning_rate = 1e-2

optimizer = optim.SGD(model.parameters(), lr=learning_rate)

loss_fn = nn.NLLLoss()

n_epochs = 100

for epoch in range(n_epochs):
    for img, label in cifar2:
        out = model(img.view(-1).unsqueeze(0))
        loss = loss_fn(out, torch.tensor([label]))

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    print("Epoch: %d, Loss: %f" % (epoch, float(loss)))    # ❶

❶ 打印最后一张图像的损失。在下一章中,我们将改进我们的输出,以便给出整个时代的平均值。

图 7.12 训练循环:(A)对整个数据集进行平均更新;(B)在每个样本上更新模型;(C)对小批量进行平均更新

更仔细地看,我们对训练循环进行了一点改变。在第五章,我们只有一个循环:在时代上(回想一下,一个时代在所有训练集中的样本都被评估完时结束)。我们认为在一个批次中评估所有 10,000 张图像会太多,所以我们决定有一个内部循环,在那里我们一次评估一个样本并在该单个样本上进行反向传播。

在第一种情况下,梯度在应用之前被累积在所有样本上,而在这种情况下,我们基于单个样本上梯度的非常部分估计来应用参数的变化。然而,基于一个样本减少损失的好方向可能不适用于其他样本。通过在每个时代对样本进行洗牌并在一次或(最好是为了稳定性)几个样本上估计梯度,我们有效地在梯度下降中引入了随机性。记得随机梯度下降(SGD)吗?这代表随机梯度下降,这就是S的含义:在洗牌数据的小批量(又称小批量)上工作。事实证明,遵循在小批量上估计的梯度,这些梯度是对整个数据集估计的梯度的较差近似,有助于收敛并防止优化过程在途中遇到的局部最小值中卡住。正如图 7.13 所示,来自小批量的梯度随机偏离理想轨迹,这也是为什么我们希望使用相当小的学习率的部分原因。在每个时代对数据集进行洗牌有助于确保在小批量上估计的梯度序列代表整个数据集上计算的梯度。

通常,小批量是一个在训练之前需要设置的固定大小,就像学习率一样。这些被称为超参数,以区别于模型的参数。

图 7.13 梯度下降在整个数据集上的平均值(浅色路径)与随机梯度下降,其中梯度是在随机选择的小批量上估计的。

在我们的训练代码中,我们选择了大小为 1 的小批量,一次从数据集中选择一个项目。torch.utils.data模块有一个帮助对数据进行洗牌和组织成小批量的类:DataLoader。数据加载器的工作是从数据集中抽样小批量,使我们能够选择不同的抽样策略。一个非常常见的策略是在每个时代洗牌数据后进行均匀抽样。图 7.14 显示了数据加载器对从Dataset获取的索引进行洗牌的过程。

图 7.14 通过使用数据集来采样单个数据项来分发小批量数据的数据加载器

让我们看看这是如何完成的。至少,DataLoader构造函数需要一个Dataset对象作为输入,以及batch_size和一个布尔值shuffle,指示数据是否需要在每个 epoch 开始时进行洗牌:

train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64,
                                           shuffle=True)

DataLoader可以被迭代,因此我们可以直接在新训练代码的内部循环中使用它:

import torch
import torch.nn as nn

train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64,
                                           shuffle=True)

model = nn.Sequential(
            nn.Linear(3072, 512),
            nn.Tanh(),
            nn.Linear(512, 2),
            nn.LogSoftmax(dim=1))

learning_rate = 1e-2

optimizer = optim.SGD(model.parameters(), lr=learning_rate)

loss_fn = nn.NLLLoss()

n_epochs = 100

for epoch in range(n_epochs):
    for imgs, labels in train_loader:
        batch_size = imgs.shape[0]
        outputs = model(imgs.view(batch_size, -1))
        loss = loss_fn(outputs, labels)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

     print("Epoch: %d, Loss: %f" % (epoch, float(loss)))    # ❶

❶ 由于洗牌,现在这会打印一个随机批次的损失--显然这是我们在第八章想要改进的地方

在每个内部迭代中,imgs是一个大小为 64 × 3 × 32 × 32 的张量--也就是说,64 个(32 × 32)RGB 图像的小批量--而labels是一个包含标签索引的大小为 64 的张量。

让我们运行我们的训练:

Epoch: 0, Loss: 0.523478
Epoch: 1, Loss: 0.391083
Epoch: 2, Loss: 0.407412
Epoch: 3, Loss: 0.364203
...
Epoch: 96, Loss: 0.019537
Epoch: 97, Loss: 0.008973
Epoch: 98, Loss: 0.002607
Epoch: 99, Loss: 0.026200

我们看到损失有所下降,但我们不知道是否足够低。由于我们的目标是正确地为图像分配类别,并最好在一个独立的数据集上完成,我们可以计算我们模型在验证集上的准确率,即正确分类的数量占总数的比例:

val_loader = torch.utils.data.DataLoader(cifar2_val, batch_size=64,
                                         shuffle=False)

correct = 0
total = 0

with torch.no_grad():
    for imgs, labels in val_loader:
        batch_size = imgs.shape[0]
        outputs = model(imgs.view(batch_size, -1))
        _, predicted = torch.max(outputs, dim=1)
        total += labels.shape[0]
        correct += int((predicted == labels).sum())

print("Accuracy: %f", correct / total)

Accuracy: 0.794000

不是很好的性能,但比随机好得多。为我们辩护,我们的模型是一个相当浅的分类器;奇迹的是它居然工作了。这是因为我们的数据集非常简单--两类样本中很多样本可能有系统性差异(比如背景颜色),这有助于模型根据少量像素区分鸟类和飞机。

我们可以通过添加更多的层来为我们的模型增加一些亮点,这将增加模型的深度和容量。一个相当任意的可能性是

model = nn.Sequential(
            nn.Linear(3072, 1024),
            nn.Tanh(),
            nn.Linear(1024, 512),
            nn.Tanh(),
            nn.Linear(512, 128),
            nn.Tanh(),
            nn.Linear(128, 2),
            nn.LogSoftmax(dim=1))

在这里,我们试图将特征数量逐渐缓和到输出,希望中间层能更好地将信息压缩到越来越短的中间输出中。

nn.LogSoftmaxnn.NLLLoss的组合等效于使用nn.CrossEntropyLoss。这个术语是 PyTorch 的一个特殊之处,因为nn.NLLoss实际上计算交叉熵,但输入是对数概率预测,而nn.CrossEntropyLoss采用分数(有时称为对数几率)。从技术上讲,nn.NLLLoss是 Dirac 分布之间的交叉熵,将所有质量放在目标上,并且由对数概率输入给出的预测分布。

为了增加混乱,在信息理论中,这个交叉熵可以被解释为预测分布在目标分布下的负对数似然,经过样本大小归一化。因此,这两种损失都是模型参数的负对数似然,给定数据时,我们的模型预测(应用 softmax 后的)概率。在本书中,我们不会依赖这些细节,但当你在文献中看到这些术语时,不要让 PyTorch 的命名混淆你。

通常会从网络中删除最后一个nn.LogSoftmax层,并使用nn.CrossEntropyLoss作为损失函数。让我们试试:

model = nn.Sequential(
            nn.Linear(3072, 1024),
            nn.Tanh(),
            nn.Linear(1024, 512),
            nn.Tanh(),
            nn.Linear(512, 128),
            nn.Tanh(),
            nn.Linear(128, 2))

loss_fn = nn.CrossEntropyLoss()

请注意,数字将与nn.LogSoftmaxnn.NLLLoss完全相同。只是一次性完成所有操作更方便,唯一需要注意的是,我们模型的输出将无法解释为概率(或对数概率)。我们需要明确通过 softmax 传递输出以获得这些概率。

训练这个模型并在验证集上评估准确率(0.802000)让我们意识到,一个更大的模型带来了准确率的提高,但并不多。训练集上的准确率几乎完美(0.998100)。这告诉我们什么?我们在两种情况下都过度拟合了我们的模型。我们的全连接模型通过记忆训练集来找到区分鸟类和飞机的方法,但在验证集上的表现并不是很好,即使我们选择了一个更大的模型。

PyTorch 通过nn.Modelparameters()方法(我们用来向优化器提供参数的相同方法)提供了一种快速确定模型有多少参数的方法。要找出每个张量实例中有多少元素,我们可以调用numel方法。将它们相加就得到了我们的总数。根据我们的用例,计算参数可能需要我们检查参数是否将requires_grad设置为True。我们可能希望区分可训练参数的数量与整个模型大小。让我们看看我们现在有什么:

# In[7]:
numel_list = [p.numel()
              for p in connected_model.parameters()
              if p.requires_grad == True]
sum(numel_list), numel_list

# Out[7]:
(3737474, [3145728, 1024, 524288, 512, 65536, 128, 256, 2])

哇,370 万个参数!对于这么小的输入图像来说,这不是一个小网络,是吗?即使我们的第一个网络也相当庞大:

# In[9]:
numel_list = [p.numel() for p in first_model.parameters()]
sum(numel_list), numel_list

# Out[9]:
(1574402, [1572864, 512, 1024, 2])

我们第一个模型中的参数数量大约是最新模型的一半。嗯,从单个参数大小的列表中,我们开始有了一个想法:第一个模块有 150 万个参数。在我们的完整网络中,我们有 1,024 个输出特征,这导致第一个线性模块有 3 百万个参数。这不应该出乎意料:我们知道线性层计算y = weight * x + bias,如果x的长度为 3,072(为简单起见忽略批处理维度),而y必须具有长度 1,024,则weight张量的大小需要为 1,024 × 3,072,bias大小必须为 1,024。而 1,024 * 3,072 + 1,024 = 3,146,752,正如我们之前发现的那样。我们可以直接验证这些数量:

# In[10]:
linear = nn.Linear(3072, 1024)

linear.weight.shape, linear.bias.shape

# Out[10]:
(torch.Size([1024, 3072]), torch.Size([1024]))

这告诉我们什么?我们的神经网络随着像素数量的增加不会很好地扩展。如果我们有一个 1,024 × 1,024 的 RGB 图像呢?那就是 3.1 百万个输入值。即使突然转向 1,024 个隐藏特征(这对我们的分类器不起作用),我们将有超过 30 亿个参数。使用 32 位浮点数,我们已经占用了 12 GB 的内存,甚至还没有到达第二层,更不用说计算和存储梯度了。这在大多数现代 GPU 上根本无法容纳。

7.2.7 完全连接的极限

让我们推理一下在图像的 1D 视图上使用线性模块意味着什么--图 7.15 展示了正在发生的事情。这就像是将每个输入值--也就是我们 RGB 图像中的每个分量--与每个输出特征的所有其他值进行线性组合。一方面,我们允许任何像素与图像中的每个其他像素进行组合,这可能与我们的任务相关。另一方面,我们没有利用相邻或远离像素的相对位置,因为我们将图像视为一个由数字组成的大向量。

图 7.15 使用带有输入图像的全连接模块:每个输入像素与其他每个像素组合以生成输出中的每个元素。

在一个 32 × 32 图像中捕捉到的飞机在蓝色背景上将非常粗略地类似于一个黑色的十字形状。如图 7.15 中的全连接网络需要学习,当像素 0,1 是黑色时,像素 1,1 也是黑色,依此类推,这是飞机的一个很好的指示。这在图 7.16 的上半部分有所说明。然而,将相同的飞机向下移动一个像素或更多像图的下半部分一样,像素之间的关系将不得不从头开始重新学习:这次,当像素 0,2 是黑色时,像素 1,2 是黑色,依此类推时,飞机很可能存在。更具体地说,全连接网络不是平移不变的。这意味着一个经过训练以识别从位置 4,4 开始的斯皮特火机的网络将无法识别完全相同的从位置 8,8 开始的斯皮特火机。然后,我们必须增广数据集--也就是在训练过程中对图像应用随机平移--以便网络有机会在整个图像中看到斯皮特火机,我们需要对数据集中的每个图像都这样做(值得一提的是,我们可以连接一个来自torchvision.transforms的转换来透明地执行此操作)。然而,这种数据增广策略是有代价的:隐藏特征的数量--也就是参数的数量--必须足够大,以存储关于所有这些平移副本的信息。

图 7.16 全连接层中的平移不变性或缺乏平移不变性

因此,在本章结束时,我们有了一个数据集,一个模型和一个训练循环,我们的模型学习了。然而,由于我们的问题与网络结构之间存在不匹配,我们最终过拟合了训练数据,而不是学习我们希望模型检测到的泛化特征。

我们已经创建了一个模型,允许将图像中的每个像素与其他像素相关联,而不考虑它们的空间排列。我们有一个合理的假设,即更接近的像素在理论上更相关。这意味着我们正在训练一个不具有平移不变性的分类器,因此如果我们希望在验证集上表现良好,我们被迫使用大量容量来学习平移副本。肯定有更好的方法,对吧?

当然,像这样的问题在这本书中大多是修辞性的。解决我们当前一系列问题的方法是改变我们的模型,使用卷积层。我们将在下一章中介绍这意味着什么。

7.3 结论

在本章中,我们解决了一个简单的分类问题,从数据集到模型,再到在训练循环中最小化适当的损失。所有这些都将成为你的 PyTorch 工具箱中的标准工具,并且使用它们所需的技能将在你使用 PyTorch 的整个期间都很有用。

我们还发现了我们模型的一个严重缺陷:我们一直将 2D 图像视为 1D 数据。此外,我们没有一种自然的方法来融入我们问题的平移不变性。在下一章中,您将学习如何利用图像数据的 2D 特性以获得更好的结果。⁹

我们可以立即利用所学知识处理没有这种平移不变性的数据。例如,在表格数据或我们在第四章中遇到的时间序列数据上使用它,我们可能已经可以做出很棒的事情。在一定程度上,也可以将其应用于适当表示的文本数据。¹⁰

7.4 练习

  1. 使用torchvision实现数据的随机裁剪。

    1. 结果图像与未裁剪的原始图像有何不同?

    2. 当第二次请求相同图像时会发生什么?

    3. 使用随机裁剪图像进行训练的结果是什么?

  2. 切换损失函数(也许是均方误差)。

    1. 训练行为是否会改变?

    2. 是否可能减少网络的容量,使其停止过拟合?

    3. 这样做时模型在验证集上的表现如何?

7.5 总结

  • 计算机视觉是深度学习的最广泛应用之一。

  • 有许多带有注释的图像数据集可以公开获取;其中许多可以通过torchvision访问。

  • DatasetDataLoader为加载和采样数据集提供了简单而有效的抽象。

  • 对于分类任务,在网络输出上使用 softmax 函数会产生满足概率解释要求的值。在这种情况下,用 softmax 的输出作为非负对数似然函数的输入得到的损失函数是理想的分类损失函数。在 PyTorch 中,softmax 和这种损失的组合称为交叉熵。

  • 没有什么能阻止我们将图像视为像素值向量,使用全连接网络处理它们,就像处理任何其他数值数据一样。然而,这样做会使利用数据中的空间关系变得更加困难。

  • 可以使用nn.Sequential创建简单模型。


¹ 这些图像是由加拿大高级研究所(CIFAR)的 Krizhevsky、Nair 和 Hinton 收集和标记的,并且来自麻省理工学院计算机科学与人工智能实验室(CSAIL)的更大的未标记 32×32 彩色图像集合:“8000 万小图像数据集”。

² 对于一些高级用途,PyTorch 还提供了IterableDataset。这可以用于数据集中随机访问数据代价过高或没有意义的情况:例如,因为数据是即时生成的。

³ 这在打印时无法很好地翻译;你必须相信我们的话,或者在电子书或 Jupyter Notebook 中查看。

⁴ 在这里,我们手动构建了新数据集,并且还想重新映射类别。在某些情况下,仅需要获取给定数据集的索引子集即可。这可以通过torch.utils.data.Subset类来实现。类似地,ConcatDataset用于将(兼容项的)数据集合并为一个更大的数据集。对于可迭代数据集,ChainDataset提供了一个更大的可迭代数据集。

⁵ 在“概率”向量上使用距离已经比使用MSELoss与类别编号要好得多——回想我们在第四章“连续、有序和分类值”侧边栏中讨论的值类型,对于类别来说,使用MSELoss没有意义,在实践中根本不起作用。然而,MSELoss并不适用于分类问题。

⁶ 对于特殊的二元分类情况,在这里使用两个值是多余的,因为一个总是另一个的 1 减。事实上,PyTorch 允许我们仅在模型末尾使用nn.Sigmoid激活输出单个概率,并使用二元交叉熵损失函数nn.BCELoss。还有一个将这两个步骤合并的nn.BCELossWithLogits

⁷ 虽然原则上可以说这里的模型不确定(因为它将 48%和 52%的概率分配给两个类别),但典型的训练结果是高度自信的模型。贝叶斯神经网络可以提供一些补救措施,但这超出了本书的范围。

⁸ 要了解术语的简明定义,请参考 David MacKay 的《信息理论、推断和学习算法》(剑桥大学出版社,2003 年),第 2.3 节。

⁹ 关于平移不变性的同样警告也适用于纯粹的 1D 数据:音频分类器应该在要分类的声音开始时间提前或延后十分之一秒时产生相同的输出。

¹⁰词袋模型,只是对单词嵌入进行平均处理,可以使用本章的网络设计进行处理。更现代的模型考虑了单词的位置,并需要更高级的模型。

八、使用卷积进行泛化

本章涵盖

  • 理解卷积

  • 构建卷积神经网络

  • 创建自定义nn.Module子类

  • 模块和功能 API 之间的区别

  • 神经网络的设计选择

在上一章中,我们构建了一个简单的神经网络,可以拟合(或过拟合)数据,这要归功于线性层中可用于优化的许多参数。然而,我们的模型存在问题,它更擅长记忆训练集,而不是泛化鸟类和飞机的属性。根据我们的模型架构,我们猜测这是为什么。由于需要完全连接的设置来检测图像中鸟或飞机的各种可能的平移,我们有太多的参数(使模型更容易记忆训练集)和没有位置独立性(使泛化更困难)。正如我们在上一章中讨论的,我们可以通过使用各种重新裁剪的图像来增加我们的训练数据,以尝试强制泛化,但这不会解决参数过多的问题。

有一种更好的方法!它包括用不同的线性操作替换我们神经网络单元中的密集、全连接的仿射变换:卷积。

8.1 卷积的理由

让我们深入了解卷积是什么以及我们如何在神经网络中使用它们。是的,是的,我们正在努力区分鸟和飞机,我们的朋友仍在等待我们的解决方案,但这个偏离值得额外花费的时间。我们将对计算机视觉中这个基础概念发展直觉,然后带着超能力回到我们的问题。

在本节中,我们将看到卷积如何提供局部性和平移不变性。我们将通过仔细查看定义卷积的公式并使用纸和笔应用它来做到这一点——但不用担心,要点将在图片中,而不是公式中。

我们之前说过,将我们的输入图像以 1D 视图呈现,并将其乘以一个n_output_features × n_input_features的权重矩阵,就像在nn.Linear中所做的那样,意味着对于图像中的每个通道,计算所有像素的加权和,乘以一组权重,每个输出特征一个。

我们还说过,如果我们想要识别与对象对应的模式,比如天空中的飞机,我们可能需要查看附近像素的排列方式,而不太关心远离彼此的像素如何组合。基本上,我们的斯皮特火箭的图像是否在角落里有树、云或风筝并不重要。

为了将这种直觉转化为数学形式,我们可以计算像素与其相邻像素的加权和,而不是与图像中的所有其他像素。这相当于构建权重矩阵,每个输出特征和输出像素位置一个,其中距离中心像素一定距离的所有权重都为零。这仍然是一个加权和:即,一个线性操作。

8.1.1 卷积的作用

我们之前确定了另一个期望的属性:我们希望这些局部模式对输出产生影响,而不管它们在图像中的位置如何:也就是说,要平移不变。为了在应用于我们在第七章中使用的图像-作为-向量的矩阵中实现这一目标,需要实现一种相当复杂的权重模式(如果它太复杂,不用担心;很快就会好转):大多数权重矩阵将为零(对应于距离输出像素太远而不会产生影响的输入像素的条目)。对于其他权重,我们必须找到一种方法来保持与输入和输出像素相同相对位置对应的条目同步。这意味着我们需要将它们初始化为相同的值,并确保所有这些绑定权重在训练期间网络更新时保持不变。这样,我们可以确保权重在邻域内运作以响应局部模式,并且无论这些局部模式在图像中的位置如何,都能识别出来。

当然,这种方法远非实用。幸运的是,图像上有一个现成的、局部的、平移不变的线性操作:卷积。我们可以对卷积提出更简洁的描述,但我们将要描述的正是我们刚刚勾勒的内容——只是从不同角度来看。

卷积,或更准确地说,离散卷积¹(这里有一个我们不会深入讨论的连续版本),被定义为 2D 图像的权重矩阵,卷积核,与输入中的每个邻域的点积。考虑一个 3 × 3 的卷积核(在深度学习中,我们通常使用小卷积核;稍后我们会看到原因)作为一个 2D 张量

weight = torch.tensor([[w00, w01, w02],
                       [w10, w11, w12],
                       [w20, w21, w22]])

以及一个 1 通道的 MxN 图像:

image = torch.tensor([[i00, i01, i02, i03, ..., i0N],
                      [i10, i11, i12, i13, ..., i1N],
                      [i20, i21, i22, i23, ..., i2N],
                      [i30, i31, i32, i33, ..., i3N],
                      ...
                      [iM0, iM1m iM2, iM3, ..., iMN]])

我们可以计算输出图像的一个元素(不包括偏置)如下:

o11 = i11 * w00 + i12 * w01 + i22 * w02 +
      i21 * w10 + i22 * w11 + i23 * w12 +
      i31 * w20 + i32 * w21 + i33 * w22

图 8.1 展示了这个计算的过程。

也就是说,我们在输入图像的i11位置上“平移”卷积核,并将每个权重乘以相应位置的输入图像的值。因此,输出图像是通过在所有输入位置上平移卷积核并执行加权求和来创建的。对于多通道图像,如我们的 RGB 图像,权重矩阵将是一个 3 × 3 × 3 矩阵:每个通道的一组权重共同贡献到输出值。

请注意,就像nn.Linearweight矩阵中的元素一样,卷积核中的权重事先是未知的,但它们是随机初始化并通过反向传播进行更新的。还要注意,相同的卷积核,因此卷积核中的每个权重,在整个图像中都会被重复使用。回想自动求导,这意味着每个权重的使用都有一个跨越整个图像的历史。因此,损失相对于卷积权重的导数包含整个图像的贡献。

图 8.1 卷积:局部性和平移不变性

现在可以看到与之前所述的连接:卷积等同于具有多个线性操作,其权重几乎在每个像素周围为零,并且在训练期间接收相等的更新。

总结一下,通过转换为卷积,我们得到

  • 对邻域进行局部操作

  • 平移不变性

  • 具有更少参数的模型

第三点的关键见解是,使用卷积层,参数的数量不取决于图像中的像素数量,就像在我们的全连接模型中一样,而是取决于卷积核的大小(3 × 3、5 × 5 等)以及我们决定在模型中使用多少卷积滤波器(或输出通道)。

8.2 卷积的实际应用

好吧,看起来我们已经花了足够的时间在一个兔子洞里!让我们看看 PyTorch 在我们的鸟类对比飞机挑战中的表现。torch.nn模块提供了 1、2 和 3 维的卷积:nn.Conv1d用于时间序列,nn.Conv2d用于图像,nn.Conv3d用于体积或视频。

对于我们的 CIFAR-10 数据,我们将使用nn.Conv2d。至少,我们提供给nn.Conv2d的参数是输入特征的数量(或通道,因为我们处理多通道图像:也就是,每个像素有多个值),输出特征的数量,以及内核的大小。例如,对于我们的第一个卷积模块,每个像素有 3 个输入特征(RGB 通道),输出中有任意数量的通道--比如,16。输出图像中的通道越多,网络的容量就越大。我们需要通道能够检测许多不同类型的特征。此外,因为我们是随机初始化它们的,所以即使在训练之后,我们得到的一些特征也会被证明是无用的。让我们坚持使用 3 × 3 的内核大小。

在所有方向上具有相同大小的内核尺寸是非常常见的,因此 PyTorch 为此提供了一个快捷方式:每当为 2D 卷积指定kernel_size=3时,它表示 3 × 3(在 Python 中提供为元组(3, 3))。对于 3D 卷积,它表示 3 × 3 × 3。我们将在本书第 2 部分中看到的 CT 扫描在三个轴中的一个轴上具有不同的体素(体积像素)分辨率。在这种情况下,考虑在特殊维度上具有不同大小的内核是有意义的。但现在,我们将坚持在所有维度上使用相同大小的卷积:

# In[11]:
conv = nn.Conv2d(3, 16, kernel_size=3)     # ❶
conv

# Out[11]:
Conv2d(3, 16, kernel_size=(3, 3), stride=(1, 1))

❶ 与快捷方式kernel_size=3相比,我们可以等效地传递我们在输出中看到的元组:kernel_size=(3, 3)。

我们期望weight张量的形状是什么?卷积核的大小为 3 × 3,因此我们希望权重由 3 × 3 部分组成。对于单个输出像素值,我们的卷积核会考虑,比如,in_ch = 3 个输入通道,因此单个输出像素值的权重分量(以及整个输出通道的不变性)的形状为in_ch × 3 × 3。最后,我们有与输出通道一样多的权重组件,这里out_ch = 16,因此完整的权重张量是out_ch × in_ch × 3 × 3,在我们的情况下是 16 × 3 × 3 × 3。偏置的大小将为 16(为了简单起见,我们已经有一段时间没有讨论偏置了,但就像在线性模块的情况下一样,它是一个我们添加到输出图像的每个通道的常数值)。让我们验证我们的假设:

# In[12]:
conv.weight.shape, conv.bias.shape

# Out[12]:
(torch.Size([16, 3, 3, 3]), torch.Size([16]))

我们可以看到卷积是从图像中学习的方便选择。我们有更小的模型寻找局部模式,其权重在整个图像上进行优化。

2D 卷积通过产生一个 2D 图像作为输出,其像素是输入图像邻域的加权和。在我们的情况下,卷积核权重和偏置conv.weight都是随机初始化的,因此输出图像不会特别有意义。通常情况下,如果我们想要使用一个输入图像调用conv模块,我们需要使用unsqueeze添加零批次维度,因为nn.Conv2d期望输入为B × C × H × W形状的张量:

# In[13]:
img, _ = cifar2[0]
output = conv(img.unsqueeze(0))
img.unsqueeze(0).shape, output.shape

# Out[13]:
(torch.Size([1, 3, 32, 32]), torch.Size([1, 16, 30, 30]))

我们很好奇,所以我们可以显示输出,如图 8.2 所示:

# In[15]:
plt.imshow(output[0, 0].detach(), cmap='gray')
plt.show()

图 8.2 我们的鸟经过随机卷积处理后的样子。(我们在代码中作弊一点,以展示给您输入。)

等一下。让我们看看output的大小:它是torch.Size([1, 16, 30, 30])。嗯;我们在过程中丢失了一些像素。这是怎么发生的?

8.2.1 填充边界

我们的输出图像比输入图像小的事实是决定在图像边界做什么的副作用。将卷积核应用为 3×3 邻域像素的加权和要求在所有方向上都有邻居。如果我们在 i00 处,我们只有右侧和下方的像素。默认情况下,PyTorch 将在输入图片内滑动卷积核,获得width - kernel_width + 1 个水平和垂直位置。对于奇数大小的卷积核,这导致图像在每一侧缩小卷积核宽度的一半(在我们的情况下,3//2 = 1)。这解释了为什么每个维度都缺少两个像素。

图 8.3 零填充以保持输出中的图像大小

然而,PyTorch 给了我们填充图像的可能性,通过在边界周围创建幽灵像素,这些像素在卷积方面的值为零。图 8.3 展示了填充的效果。

在我们的情况下,当kernel_size=3时指定padding=1意味着 i00 上方和左侧有额外的邻居,这样原始图像的角落处甚至可以计算卷积的输出。³最终结果是输出现在与输入具有完全相同的大小:

# In[16]:
conv = nn.Conv2d(3, 1, kernel_size=3, padding=1)    # ❶
output = conv(img.unsqueeze(0))
img.unsqueeze(0).shape, output.shape

# Out[16]:
(torch.Size([1, 3, 32, 32]), torch.Size([1, 1, 32, 32]))

❶ 现在有填充了

请注意,无论是否使用填充,weightbias的大小都不会改变。

填充卷积有两个主要原因。首先,这样做有助于我们分离卷积和改变图像大小的问题,这样我们就少了一件事要记住。其次,当我们有更复杂的结构,比如跳跃连接(在第 8.5.3 节讨论)或我们将在第 2 部分介绍的 U-Net 时,我们希望几个卷积之前和之后的张量具有兼容的大小,以便我们可以将它们相加或取差异。

8.2.2 用卷积检测特征

我们之前说过,weightbias是通过反向传播学习的参数,就像nn.Linear中的weightbias一样。然而,我们可以通过手动设置权重来玩转卷积,看看会发生什么。

首先让我们将bias归零,以消除任何混淆因素,然后将weights设置为一个恒定值,以便输出中的每个像素得到其邻居的平均值。对于每个 3×3 邻域:

# In[17]:
with torch.no_grad():
    conv.bias.zero_()

with torch.no_grad():
    conv.weight.fill_(1.0 / 9.0)

我们本可以选择conv.weight.one_()--这将导致输出中的每个像素是邻域像素的总和。除了输出图像中的值会大九倍之外,没有太大的区别。

无论如何,让我们看看对我们的 CIFAR 图像的影响:

# In[18]:
output = conv(img.unsqueeze(0))
plt.imshow(output[0, 0].detach(), cmap='gray')
plt.show()

正如我们之前所预测的,滤波器产生了图像的模糊版本,如图 8.4 所示。毕竟,输出的每个像素都是输入邻域的平均值,因此输出中的像素是相关的,并且变化更加平滑。

图 8.4 我们的鸟,这次因为一个恒定的卷积核而变模糊

接下来,让我们尝试一些不同的东西。下面的卷积核一开始可能看起来有点神秘:

# In[19]:
conv = nn.Conv2d(3, 1, kernel_size=3, padding=1)

with torch.no_grad():
    conv.weight[:] = torch.tensor([[-1.0, 0.0, 1.0],
                                   [-1.0, 0.0, 1.0],
                                   [-1.0, 0.0, 1.0]])
    conv.bias.zero_()

对于位置在 2,2 的任意像素计算加权和,就像我们之前为通用卷积核所做的那样,我们得到

o22 = i13 - i11 +
      i23 - i21 +
      i33 - i31

它执行 i22 右侧所有像素与 i22 左侧像素的差值。如果卷积核应用于不同强度相邻区域之间的垂直边界,o22 将具有较高的值。如果卷积核应用于均匀强度区域,o22 将为零。这是一个边缘检测卷积核:卷积核突出显示了水平相邻区域之间的垂直边缘。

图 8.5 我们鸟身上的垂直边缘,感谢手工制作的卷积核

将卷积核应用于我们的图像,我们看到了图 8.5 中显示的结果。如预期,卷积核增强了垂直边缘。我们可以构建更多复杂的滤波器,例如用于检测水平或对角边缘,或十字形或棋盘格模式,其中“检测”意味着输出具有很高的幅度。事实上,计算机视觉专家的工作历来是提出最有效的滤波器组合,以便在图像中突出显示某些特征并识别对象。

在深度学习中,我们让核根据数据以最有效的方式进行估计:例如,以最小化我们在第 7.2.5 节中介绍的输出和地面真相之间的负交叉熵损失为目标。从这个角度来看,卷积神经网络的工作是估计一组滤波器组的核,这些核将在连续层中将多通道图像转换为另一个多通道图像,其中不同通道对应不同特征(例如一个通道用于平均值,另一个通道用于垂直边缘等)。图 8.6 显示了训练如何自动学习核。

图 8.6 通过估计核权重的梯度并逐个更新它们以优化损失的卷积学习过程

8.2.3 深入探讨深度和池化

这一切都很好,但在概念上存在一个问题。我们之所以如此兴奋,是因为从全连接层转向卷积,我们实现了局部性和平移不变性。然后我们建议使用小卷积核,如 3 x 3 或 5 x 5:这确实是局部性的极致。那么大局观呢?我们怎么知道我们图像中的所有结构都是 3 像素或 5 像素宽的?好吧,我们不知道,因为它们不是。如果它们不是,我们的网络如何能够看到具有更大范围的这些模式?如果我们想有效解决鸟类与飞机的问题,我们真的需要这个,因为尽管 CIFAR-10 图像很小,但对象仍然具有跨越几个像素的(翼)跨度。

一种可能性是使用大型卷积核。当然,在极限情况下,我们可以为 32 x 32 图像使用 32 x 32 卷积核,但我们将收敛到旧的全连接、仿射变换,并丢失卷积的所有优点。另一种选项是在卷积神经网络中使用一层接一层的卷积,并在连续卷积之间同时对图像进行下采样。

从大到小:下采样

下采样原则上可以以不同方式发生。将图像缩小一半相当于将四个相邻像素作为输入,并产生一个像素作为输出。如何根据输入值计算输出值取决于我们。我们可以

  • 对四个像素求平均值。这种平均池化曾经是一种常见方法,但现在已经不太受青睐。

  • 取四个像素中的最大值。这种方法称为最大池化,目前是最常用的方法,但它的缺点是丢弃了其他四分之三的数据。

  • 执行步幅卷积,只计算每第N个像素。具有步幅 2 的 3 x 4 卷积仍然包含来自前一层的所有像素的输入。文献显示了这种方法的前景,但它尚未取代最大池化。

我们将继续关注最大池化,在图 8.7 中有所说明。该图显示了最常见的设置,即取非重叠的 2 x 2 瓦片,并将每个瓦片中的最大值作为缩小比例后的新像素。

图 8.7 详细介绍了最大池化

直觉上,卷积层的输出图像,特别是因为它们后面跟着一个激活函数,往往在检测到对应于估计内核的某些特征(如垂直线)时具有较高的幅度。通过将 2×2 邻域中的最高值作为下采样输出,我们确保找到的特征幸存下采样,以弱响应为代价。

最大池化由nn.MaxPool2d模块提供(与卷积一样,也有适用于 1D 和 3D 数据的版本)。它的输入是要进行池化操作的邻域大小。如果我们希望将图像下采样一半,我们将使用大小为 2。让我们直接在输入图像上验证它是否按预期工作:

# In[21]:
pool = nn.MaxPool2d(2)
output = pool(img.unsqueeze(0))

img.unsqueeze(0).shape, output.shape

# Out[21]:
(torch.Size([1, 3, 32, 32]), torch.Size([1, 3, 16, 16]))

结合卷积和下采样以获得更好的效果

现在让我们看看如何结合卷积和下采样可以帮助我们识别更大的结构。在图 8.8 中,我们首先在我们的 8×8 图像上应用一组 3×3 内核,获得相同大小的多通道输出图像。然后我们将输出图像缩小一半,得到一个 4×4 图像,并对其应用另一组 3×3 内核。这第二组内核在已经缩小一半的东西的 3×3 邻域上有效地映射回输入的 8×8 邻域。此外,第二组内核获取第一组内核的输出(如平均值、边缘等特征)并在其上提取额外的特征。

图 8.8 通过手动进行更多卷积,展示叠加卷积和最大池化的效果:使用两个小的十字形内核和最大池化突出显示一个大的十字形。

因此,一方面,第一组内核在第一阶低级特征的小邻域上操作,而第二组内核有效地在更宽的邻域上操作,产生由前一特征组成的特征。这是一个非常强大的机制,使卷积神经网络能够看到非常复杂的场景--比我们的 CIFAR-10 数据集中的 32×32 图像复杂得多。

输出像素的感受野

当第二个 3×3 卷积内核在图 8.8 中的卷积输出中产生 21 时,这是基于第一个最大池输出的左上角 3×3 像素。它们又对应于第一个卷积输出左上角的 6×6 像素,而这又是由第一个卷积从左上角的 7×7 像素计算得出的。因此,第二个卷积输出中的像素受到 7×7 输入方块的影响。第一个卷积还使用隐式“填充”列和行来在角落产生输出;否则,我们将有一个 8×8 的输入像素方块通知第二个卷积输出中的给定像素(远离边界)。在花哨的语言中,我们说,3×3 卷积,2×2 最大池,3×3 卷积结构的给定输出神经元具有 8×8 的感受野。

8.2.4 将所有内容整合到我们的网络中

有了这些基本模块,我们现在可以继续构建用于检测鸟类和飞机的卷积神经网络。让我们以前的全连接模型作为起点,并像之前描述的那样引入nn.Conv2dnn.MaxPool2d

# In[22]:
model = nn.Sequential(
            nn.Conv2d(3, 16, kernel_size=3, padding=1),
            nn.Tanh(),
            nn.MaxPool2d(2),
            nn.Conv2d(16, 8, kernel_size=3, padding=1),
            nn.Tanh(),
            nn.MaxPool2d(2),
            # ...
            )

第一个卷积将我们从 3 个 RGB 通道转换为 16 个通道,从而使网络有机会生成 16 个独立特征,这些特征操作(希望)能够区分鸟和飞机的低级特征。然后我们应用Tanh激活函数。得到的 16 通道 32 × 32 图像通过第一个MaxPool3d池化为一个 16 通道 16 × 16 图像。此时,经过下采样的图像经历另一个卷积,生成一个 8 通道 16 × 16 输出。幸运的话,这个输出将由更高级的特征组成。再次,我们应用Tanh激活,然后池化为一个 8 通道 8 × 8 输出。

这会在哪里结束?在输入图像被减少为一组 8 × 8 特征之后,我们期望能够从网络中输出一些概率,然后将其馈送到我们的负对数似然函数中。然而,概率是一个一维向量中的一对数字(一个用于飞机,一个用于鸟),但在这里我们仍然处理多通道的二维特征。

回想一下本章的开头,我们已经知道我们需要做什么:将一个 8 通道 8 × 8 图像转换为一维向量,并用一组全连接层完成我们的网络:

# In[23]:
model = nn.Sequential(
            nn.Conv2d(3, 16, kernel_size=3, padding=1),
            nn.Tanh(),
            nn.MaxPool2d(2),
            nn.Conv2d(16, 8, kernel_size=3, padding=1),
            nn.Tanh(),
            nn.MaxPool2d(2),
            # ...                      # ❶
            nn.Linear(8 * 8 * 8, 32),
            nn.Tanh(),
            nn.Linear(32, 2))

❶ 警告:这里缺少重要内容!

这段代码给出了图 8.9 中显示的神经网络。

图 8.9 典型卷积网络的形状,包括我们正在构建的网络。图像被馈送到一系列卷积和最大池化模块,然后被拉直成一个一维向量,然后被馈送到全连接模块。

先忽略“缺少内容”的评论一分钟。让我们首先注意到线性层的大小取决于MaxPool2d的预期输出大小:8 × 8 × 8 = 512。让我们计算一下这个小模型的参数数量:

# In[24]:
numel_list = [p.numel() for p in model.parameters()]
sum(numel_list), numel_list

# Out[24]:
(18090, [432, 16, 1152, 8, 16384, 32, 64, 2])

对于这样小图像的有限数据集来说,这是非常合理的。为了增加模型的容量,我们可以增加卷积层的输出通道数(即每个卷积层生成的特征数),这将导致线性层的大小也增加。

我们在代码中放置“警告”注释是有原因的。模型没有运行的可能性:

# In[25]:
model(img.unsqueeze(0))

# Out[25]:
...
RuntimeError: size mismatch, m1: [64 x 8], m2: [512 x 32] at c:\...\THTensorMath.cpp:940

诚然,错误消息有点晦涩,但并不是太过复杂。我们在回溯中找到了linear的引用:回顾模型,我们发现只有一个模块必须有一个 512 × 32 的张量,即nn.Linear(512, 32),也就是最后一个卷积块后的第一个线性模块。

缺失的是将一个 8 通道 8 × 8 图像重塑为一个 512 元素的一维向量(如果忽略批处理维度,则为一维)。这可以通过在最后一个nn.MaxPool2d的输出上调用view来实现,但不幸的是,当我们使用nn.Sequential时,我们没有任何明确的方式查看每个模块的输出。

8.3 继承 nn.Module

在开发神经网络的某个阶段,我们会发现自己想要计算一些预制模块不涵盖的内容。在这里,这是一些非常简单的操作,比如重塑;但在第 8.5.3 节中,我们使用相同的构造来实现残差连接。因此,在本节中,我们学习如何制作自己的nn.Module子类,然后我们可以像预构建的模块或nn.Sequential一样使用它们。

当我们想要构建比仅仅一层接一层应用更复杂功能的模型时,我们需要离开nn.Sequential,转而使用能够为我们提供更大灵活性的东西。PyTorch 允许我们通过继承nn.Module来在模型中使用任何计算。

要对 nn.Module 进行子类化,至少需要定义一个接受模块输入并返回输出的 forward 函数。这是我们定义模块计算的地方。这里的 forward 名称让人想起了很久以前的一个时期,当模块需要定义我们在第 5.5.1 节中遇到的前向和后向传递时。使用标准的 torch 操作,PyTorch 将自动处理后向传递;实际上,nn.Module 从不带有 backward

通常,我们的计算将使用其他模块--预制的如卷积或自定义的。要包含这些子模块,我们通常在构造函数 __init__ 中定义它们,并将它们分配给 self 以在 forward 函数中使用。它们将同时在我们模块的整个生命周期中保持其参数。请注意,您需要在执行这些操作之前调用 super().__init__()(否则 PyTorch 会提醒您)。

8.3.1 我们的网络作为 nn.Module

让我们将我们的网络编写为一个子模块。为此,我们在构造函数中实例化了所有之前传递给 nn.Sequentialnn.Conv2dnn.Linear 等,然后在 forward 中依次使用它们的实例:

# In[26]:
class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
        self.act1 = nn.Tanh()
        self.pool1 = nn.MaxPool2d(2)
        self.conv2 = nn.Conv2d(16, 8, kernel_size=3, padding=1)
        self.act2 = nn.Tanh()
        self.pool2 = nn.MaxPool2d(2)
        self.fc1 = nn.Linear(8 * 8 * 8, 32)
        self.act3 = nn.Tanh()
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = self.pool1(self.act1(self.conv1(x)))
        out = self.pool2(self.act2(self.conv2(out)))
        out = out.view(-1, 8 * 8 * 8)   # ❶ 
        out = self.act3(self.fc1(out))
        out = self.fc2(out)
        return out

❶ 这种重塑是我们之前缺少的

Net 类在子模块方面等效于我们之前构建的 nn.Sequential 模型;但通过显式编写 forward 函数,我们可以直接操作 self.pool3 的输出并在其上调用 view 将其转换为 B × N 向量。请注意,在调用 view 时,我们将批处理维度保留为 -1,因为原则上我们不知道批处理中会有多少样本。

图 8.10 我们基准的卷积网络架构

在这里,我们使用 nn.Module 的子类来包含我们的整个模型。我们还可以使用子类来定义更复杂网络的新构建块。继续第六章中的图表风格,我们的网络看起来像图 8.10 所示的那样。我们正在对要在哪里呈现的信息做一些临时选择。

请记住,分类网络的目标通常是在某种意义上压缩信息,即我们从具有大量像素的图像开始,将其压缩为(概率向量的)类。关于我们的架构有两件事情值得与这个目标有关的评论。

首先,我们的目标反映在中间值的大小通常会缩小--这是通过在卷积中减少通道数、通过池化减少像素数以及在线性层中使输出维度低于输入维度来实现的。这是分类网络的一个共同特征。然而,在许多流行的架构中,如我们在第二章中看到的 ResNets 并在第 8.5.3 节中更多讨论的,通过在空间分辨率中进行池化来实现减少,但通道数增加(仍导致尺寸减小)。似乎我们的快速信息减少模式在深度有限且图像较小的网络中效果良好;但对于更深的网络,减少通常较慢。

其次,在一个层中,输出大小与输入大小没有减少:初始卷积。如果我们将单个输出像素视为一个具有 32 个元素的向量(通道),那么它是 27 个元素的线性变换(作为 3 个通道 × 3 × 3 核大小的卷积)--仅有轻微增加。在 ResNet 中,初始卷积从 147 个元素(3 个通道 × 7 × 7 核大小)生成 64 个通道。⁶ 因此,第一层在整体维度(如通道乘以像素)方面大幅增加数据流经过它,但对于独立考虑的每个输出像素,输出仍大致与输入相同。⁷

8.3.2 PyTorch 如何跟踪参数和子模块

有趣的是,在nn.Module中的属性中分配一个nn.Module实例,就像我们在早期的构造函数中所做的那样,会自动将模块注册为子模块。

注意 子模块必须是顶级属性,而不是嵌套在listdict实例中!否则优化器将无法定位子模块(因此也无法定位它们的参数)。对于需要子模块列表或字典的模型情况,PyTorch 提供了nn.ModuleListnn.ModuleDict

我们可以调用nn.Module子类的任意方法。例如,对于一个模型,训练与预测等使用方式明显不同的情况下,可能有一个predict方法是有意义的。请注意,调用这些方法将类似于调用forward而不是模块本身--它们将忽略钩子,并且 JIT 在使用它们时不会看到模块结构,因为我们缺少第 6.2.1 节中显示的__call__位的等价物。

这使得Net可以访问其子模块的参数,而无需用户进一步操作:

# In[27]:
model = Net()

numel_list = [p.numel() for p in model.parameters()]
sum(numel_list), numel_list

# Out[27]:
(18090, [432, 16, 1152, 8, 16384, 32, 64, 2])

这里发生的情况是,parameters()调用深入到构造函数中分配为属性的所有子模块,并递归调用它们的parameters()。无论子模块嵌套多深,任何nn.Module都可以访问所有子参数的列表。通过访问它们的grad属性,该属性已被autograd填充,优化器将知道如何更改参数以最小化损失。我们从第五章中了解到这个故事。

现在我们知道如何实现我们自己的模块了--这在第 2 部分中我们将需要很多。回顾Net类的实现,并考虑在构造函数中注册子模块的实用性,以便我们可以访问它们的参数,看起来有点浪费,因为我们还注册了没有参数的子模块,如nn.Tanhnn.MaxPool2d。直接在forward函数中调用这些是否更容易,就像我们调用view一样?

8.3.3 功能 API

当然会!这就是为什么 PyTorch 为每个nn模块都提供了functional对应项。这里所说的“functional”是指“没有内部状态”--换句话说,“其输出值完全由输入参数的值决定”。实际上,torch.nn.functional提供了许多像我们在nn中找到的模块一样工作的函数。但是,与模块对应项不同,它们不会像模块对应项那样在输入参数和存储参数上工作,而是将输入和参数作为函数调用的参数。例如,nn.Linear的功能对应项是nn.functional.linear,它是一个具有签名linear(input, weight, bias=None)的函数。weightbias参数是函数调用的参数。

回到我们的模型,继续使用nn.Linearnn.Conv2dnn模块是有意义的,这样Net在训练期间将能够管理它们的Parameter。但是,我们可以安全地切换到池化和激活的功能对应项,因为它们没有参数:

# In[28]:
import torch.nn.functional as F

class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(16, 8, kernel_size=3, padding=1)
        self.fc1 = nn.Linear(8 * 8 * 8, 32)
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
        out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
        out = out.view(-1, 8 * 8 * 8)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

这比我们在第 8.3.1 节中之前定义的Net的定义要简洁得多,完全等效。请注意,在构造函数中实例化需要多个参数进行初始化的模块仍然是有意义的。

提示 虽然通用科学函数如tanh仍然存在于版本 1.0 的torch.nn.functional中,但这些入口点已被弃用,而是推荐使用顶级torch命名空间中的函数。像max_pool2d这样的更专业的函数将保留在torch.nn.functional中。

因此,功能方式也揭示了nn.Module API 的含义:Module是一个状态的容器,其中包含Parameter和子模块,以及执行前向操作的指令。

使用功能 API 还是模块化 API 是基于风格和品味的决定。当网络的一部分如此简单以至于我们想使用nn.Sequential时,我们处于模块化领域。当我们编写自己的前向传播时,对于不需要参数形式状态的事物,使用功能接口可能更自然。

在第十五章,我们将简要涉及量化。然后像激活这样的无状态位突然变得有状态,因为需要捕获有关量化的信息。这意味着如果我们打算量化我们的模型,如果我们选择非 JIT 量化,坚持使用模块化 API 可能是值得的。有一个风格问题将帮助您避免(最初未预料到的)用途带来的意外:如果您需要多个无状态模块的应用(如nn.HardTanhnn.ReLU),最好为每个模块实例化一个单独的实例。重用相同的模块似乎很聪明,并且在这里使用标准 Python 时会给出正确的结果,但是分析您的模型的工具可能会出错。

现在我们可以自己制作nn.Module,并且在需要时还有功能 API 可用,当实例化然后调用nn.Module过于繁琐时。这是了解在 PyTorch 中实现的几乎任何神经网络的代码组织方式的最后一部分。

让我们再次检查我们的模型是否运行正常,然后我们将进入训练循环:

# In[29]:
model = Net()
model(img.unsqueeze(0))

# Out[29]:
tensor([[-0.0157,  0.1143]], grad_fn=<AddmmBackward>)

我们得到了两个数字!信息正确传递。我们现在可能意识不到,但在更复杂的模型中,正确设置第一个线性层的大小有时会导致挫折。我们听说过一些著名从业者输入任意数字,然后依靠 PyTorch 的错误消息来回溯线性层的正确大小。很烦人,对吧?不,这都是合法的!

8.4 训练我们的卷积网络

现在我们已经到了组装完整训练循环的时候。我们在第五章中已经开发了整体结构,训练循环看起来很像第六章的循环,但在这里我们将重新审视它以添加一些细节,如一些用于准确性跟踪的内容。在运行我们的模型之后,我们还会对更快速度有所期待,因此我们将学习如何在 GPU 上快速运行我们的模型。但首先让我们看看训练循环。

请记住,我们的卷积网络的核心是两个嵌套循环:一个是epochs上的外部循环,另一个是从我们的Dataset生成批次的DataLoader上的内部循环。在每个循环中,我们需要

  1. 通过模型传递输入(前向传播)。

  2. 计算损失(也是前向传播的一部分)。

  3. 将任何旧的梯度清零。

  4. 调用loss.backward()来计算损失相对于所有参数的梯度(反向传播)。

  5. 使优化器朝着更低的损失方向迈出一步。

同时,我们收集并打印一些信息。所以这是我们的训练循环,看起来几乎与上一章相同--但记住每个事物的作用是很重要的:

# In[30]:
import datetime                                                       # ❶

def training_loop(n_epochs, optimizer, model, loss_fn, train_loader):
    for epoch in range(1, n_epochs + 1):                              # ❷
        loss_train = 0.0
        for imgs, labels in train_loader:                             # ❸

            outputs = model(imgs)                                     # ❹

            loss = loss_fn(outputs, labels)                           # ❺

            optimizer.zero_grad()                                     # ❻

            loss.backward()                                           # ❼

            optimizer.step()                                          # ❽

            loss_train += loss.item()                                 # ❾

        if epoch == 1 or epoch % 10 == 0:
            print('{} Epoch {}, Training loss {}'.format(
                datetime.datetime.now(), epoch,
                loss_train / len(train_loader)))                      # ❿

❶ 使用 Python 内置的 datetime 模块

❷ 我们在从 1 到 n_epochs 编号的 epochs 上循环,而不是从 0 开始

❸ 在数据加载器为我们创建的批次中循环遍历我们的数据集

❹ 通过我们的模型传递一个批次...

❺ ... 并计算我们希望最小化的损失

❻ 在摆脱上一轮梯度之后...

❼ ... 执行反向步骤。也就是说,我们计算我们希望网络学习的所有参数的梯度。

❽ 更新模型

❾ 对我们在 epoch 中看到的损失求和。请记住,将损失转换为 Python 数字并使用.item()是很重要的,以避免梯度。

❿ 除以训练数据加载器的长度以获得每批的平均损失。这比总和更直观。

我们使用第七章的Dataset;将其包装成DataLoader;像以前一样实例化我们的网络、优化器和损失函数;然后调用我们的训练循环。

与上一章相比,我们模型的重大变化是现在我们的模型是 nn.Module 的自定义子类,并且我们正在使用卷积。让我们在打印损失的同时运行 100 个周期的训练。根据您的硬件,这可能需要 20 分钟或更长时间才能完成!

# In[31]:
train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64,
                                           shuffle=True)          # ❶

model = Net()  #                                                  # ❷
optimizer = optim.SGD(model.parameters(), lr=1e-2)  #             # ❸
loss_fn = nn.CrossEntropyLoss()  #                                # ❹

training_loop(                                                    # ❺
    n_epochs = 100,
    optimizer = optimizer,
    model = model,
    loss_fn = loss_fn,
    train_loader = train_loader,
)

# Out[31]:
2020-01-16 23:07:21.889707 Epoch 1, Training loss 0.5634813266954605
2020-01-16 23:07:37.560610 Epoch 10, Training loss 0.3277610331109375
2020-01-16 23:07:54.966180 Epoch 20, Training loss 0.3035225479086493
2020-01-16 23:08:12.361597 Epoch 30, Training loss 0.28249378549824855
2020-01-16 23:08:29.769820 Epoch 40, Training loss 0.2611226033253275
2020-01-16 23:08:47.185401 Epoch 50, Training loss 0.24105800626574048
2020-01-16 23:09:04.644522 Epoch 60, Training loss 0.21997178820477928
2020-01-16 23:09:22.079625 Epoch 70, Training loss 0.20370126601047578
2020-01-16 23:09:39.593780 Epoch 80, Training loss 0.18939699422401987
2020-01-16 23:09:57.111441 Epoch 90, Training loss 0.17283396527266046
2020-01-16 23:10:14.632351 Epoch 100, Training loss 0.1614033816868712

❶ DataLoader 对我们的 cifar2 数据集的示例进行批处理。Shuffling 使数据集中示例的顺序随机化。

❷ 实例化我们的网络 ...

❸ ... 我们一直在使用的随机梯度下降优化器 ...

❹ ... 以及我们在第 7.10 节中遇到的交叉熵损失

❺ 调用我们之前定义的训练循环

现在我们可以训练我们的网络了。但是,我们的鸟类观察者朋友在告诉她我们训练到非常低的训练损失时可能不会感到满意。

8.4.1 测量准确性

为了得到比损失更具可解释性的度量,我们可以查看训练和验证数据集上的准确率。我们使用了与第七章相同的代码:

# In[32]:
train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64,
                                           shuffle=False)
val_loader = torch.utils.data.DataLoader(cifar2_val, batch_size=64,
                                         shuffle=False)

def validate(model, train_loader, val_loader):
    for name, loader in [("train", train_loader), ("val", val_loader)]:
        correct = 0
        total = 0

        with torch.no_grad():                                    # ❶
            for imgs, labels in loader:
                outputs = model(imgs)
                _, predicted = torch.max(outputs, dim=1)         # ❷
                total += labels.shape[0]                         # ❸
                correct += int((predicted == labels).sum())      # ❹

        print("Accuracy {}: {:.2f}".format(name , correct / total))

validate(model, train_loader, val_loader)

# Out[32]:
Accuracy train: 0.93
Accuracy val: 0.89

❶ 我们这里不需要梯度,因为我们不想更新参数。

❷ 将最高值的索引作为输出给出

❸ 计算示例的数量,因此总数增加了批次大小

❹ 比较具有最大概率的预测类和地面真实标签,我们首先得到一个布尔数组。求和得到批次中预测和地面真实一致的项目数。

我们将转换为 Python 的 int--对于整数张量,这等同于使用 .item(),类似于我们在训练循环中所做的。

这比全连接模型要好得多,全连接模型只能达到 79%的准确率。我们在验证集上的错误数量几乎减半。而且,我们使用的参数要少得多。这告诉我们,模型在通过局部性和平移不变性从新样本中识别图像主题的任务中更好地泛化。现在我们可以让它运行更多周期,看看我们能够挤出什么性能。

8.4.2 保存和加载我们的模型

由于我们目前对我们的模型感到满意,所以实际上保存它会很好,对吧?这很容易做到。让我们将模型保存到一个文件中:

# In[33]:
torch.save(model.state_dict(), data_path + 'birds_vs_airplanes.pt')

birds_vs_airplanes.pt 文件现在包含了 model 的所有参数:即两个卷积模块和两个线性模块的权重和偏置。因此,没有结构--只有权重。这意味着当我们为我们的朋友在生产中部署模型时,我们需要保持 model 类方便,创建一个实例,然后将参数加载回去:

# In[34]:
loaded_model = Net()                                 # ❶
loaded_model.load_state_dict(torch.load(data_path
                                        + 'birds_vs_airplanes.pt'))

# Out[34]:
<All keys matched successfully>

❶ 我们必须确保在保存和后续加载模型状态之间不更改 Net 的定义。

我们还在我们的代码库中包含了一个预训练模型,保存在 ../data/ p1ch7/birds_vs_airplanes.pt 中。

8.4.3 在 GPU 上训练

我们有一个网络并且可以训练它!但是让它变得更快会很好。到现在为止,我们通过将训练移至 GPU 来实现这一点并不奇怪。使用我们在第三章中看到的 .to 方法,我们可以将从数据加载器获取的张量移动到 GPU,之后我们的计算将自动在那里进行。但是我们还需要将参数移动到 GPU。令人高兴的是,nn.Module 实现了一个 .to 函数,将其所有参数移动到 GPU(或在传递 dtype 参数时转换类型)。

Module.toTensor.to 之间有一些微妙的区别。Module.to 是就地操作:模块实例被修改。但 Tensor.to 是非就地操作(在某种程度上是计算,就像 Tensor.tanh 一样),返回一个新的张量。一个影响是在将参数移动到适当设备后创建 Optimizer 是一个良好的实践。

如果有 GPU 可用,将事物移动到 GPU 被认为是一种良好的风格。一个好的模式是根据 torch.cuda.is_available 设置一个变量 device

# In[35]:
device = (torch.device('cuda') if torch.cuda.is_available()
          else torch.device('cpu'))
print(f"Training on device {device}.")

然后我们可以通过使用Tensor.to方法将从数据加载器获取的张量移动到 GPU 来修改训练循环。请注意,代码与本节开头的第一个版本完全相同,除了将输入移动到 GPU 的两行代码:

# In[36]:
import datetime

def training_loop(n_epochs, optimizer, model, loss_fn, train_loader):
    for epoch in range(1, n_epochs + 1):
        loss_train = 0.0
        for imgs, labels in train_loader:
            imgs = imgs.to(device=device)       # ❶
            labels = labels.to(device=device)
            outputs = model(imgs)
            loss = loss_fn(outputs, labels)

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            loss_train += loss.item()

        if epoch == 1 or epoch % 10 == 0:
            print('{} Epoch {}, Training loss {}'.format(
                datetime.datetime.now(), epoch,
                loss_train / len(train_loader)))

❶ 将图像和标签移动到我们正在训练的设备上的这两行是与我们之前版本的唯一区别。

validate函数必须做出相同的修正。然后我们可以实例化我们的模型,将其移动到device,并像以前一样运行它:⁸

# In[37]:
train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64,
                                           shuffle=True)

model = Net().to(device=device)                     # ❶
optimizer = optim.SGD(model.parameters(), lr=1e-2)
loss_fn = nn.CrossEntropyLoss()

training_loop(
    n_epochs = 100,
    optimizer = optimizer,
    model = model,
    loss_fn = loss_fn,
    train_loader = train_loader,
)

# Out[37]:
2020-01-16 23:10:35.563216 Epoch 1, Training loss 0.5717791349265227
2020-01-16 23:10:39.730262 Epoch 10, Training loss 0.3285350770137872
2020-01-16 23:10:45.906321 Epoch 20, Training loss 0.29493294959994637
2020-01-16 23:10:52.086905 Epoch 30, Training loss 0.26962305994550134
2020-01-16 23:10:56.551582 Epoch 40, Training loss 0.24709946277794564
2020-01-16 23:11:00.991432 Epoch 50, Training loss 0.22623272664892446
2020-01-16 23:11:05.421524 Epoch 60, Training loss 0.20996672821462534
2020-01-16 23:11:09.951312 Epoch 70, Training loss 0.1934866009719053
2020-01-16 23:11:14.499484 Epoch 80, Training loss 0.1799132404908253
2020-01-16 23:11:19.047609 Epoch 90, Training loss 0.16620008706761774
2020-01-16 23:11:23.590435 Epoch 100, Training loss 0.15667157247662544

❶ 将我们的模型(所有参数)移动到 GPU。如果忘记将模型或输入移动到 GPU,将会出现关于张量不在同一设备上的错误,因为 PyTorch 运算符不支持混合 GPU 和 CPU 输入。

即使对于我们这里的小型网络,我们也看到了速度的显著增加。在大型模型上,使用 GPU 进行计算的优势更加明显。

在加载网络权重时存在一个小复杂性:PyTorch 将尝试将权重加载到与保存时相同的设备上--也就是说,GPU 上的权重将被恢复到 GPU 上。由于我们不知道是否要相同的设备,我们有两个选择:我们可以在保存之前将网络移动到 CPU,或者在恢复后将其移回。通过将map_location关键字参数传递给torch.load,更简洁地指示 PyTorch 在加载权重时覆盖设备信息:

# In[39]:
loaded_model = Net().to(device=device)
loaded_model.load_state_dict(torch.load(data_path
                                        + 'birds_vs_airplanes.pt',
                                        map_location=device))

# Out[39]:
<All keys matched successfully>

8.5 模型设计

我们将我们的模型构建为nn.Module的子类,这是除了最简单的模型之外的事实标准。然后我们成功地训练了它,并看到了如何使用 GPU 来训练我们的模型。我们已经达到了可以构建一个前馈卷积神经网络并成功训练它来对图像进行分类的程度。自然的问题是,接下来呢?如果我们面对一个更加复杂的问题会怎么样?诚然,我们的鸟类与飞机数据集并不那么复杂:图像非常小,而且所研究的对象位于中心并占据了大部分视口。

如果我们转向,比如说,ImageNet,我们会发现更大、更复杂的图像,正确答案将取决于多个视觉线索,通常是按层次组织的。例如,当试图预测一个黑色砖块形状是遥控器还是手机时,网络可能正在寻找类似屏幕的东西。

此外,在现实世界中,图像可能不是我们唯一关注的焦点,我们还有表格数据、序列和文本。神经网络的承诺在于提供足够的灵活性,以解决所有这些类型数据的问题,只要有适当的架构(即层或模块的互连)和适当的损失函数。

PyTorch 提供了一个非常全面的模块和损失函数集合,用于实现从前馈组件到长短期记忆(LSTM)模块和变压器网络(这两种非常流行的顺序数据架构)的最新架构。通过 PyTorch Hub 或作为torchvision和其他垂直社区努力的一部分提供了几种模型。

我们将在第 2 部分看到一些更高级的架构,我们将通过分析 CT 扫描的端到端问题来介绍,但总的来说,探讨神经网络架构的变化超出了本书的范围。然而,我们可以借助迄今为止积累的知识来理解如何通过 PyTorch 的表现力实现几乎任何架构。本节的目的正是提供概念工具,使我们能够阅读最新的研究论文并开始在 PyTorch 中实现它--或者,由于作者经常发布他们论文的 PyTorch 实现,也可以在不被咖啡呛到的情况下阅读实现。

8.5.1 添加内存容量:宽度

鉴于我们的前馈架构,在进一步复杂化之前,我们可能想要探索一些维度。第一个维度是网络的宽度:每层的神经元数量,或者每个卷积的通道数。在 PyTorch 中,我们可以很容易地使模型更宽。我们只需在第一个卷积中指定更多的输出通道数,并相应增加后续层,同时要注意更改forward函数以反映这样一个事实,即一旦我们转换到全连接层,我们现在将有一个更长的向量:

# In[40]:
class NetWidth(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(32, 16, kernel_size=3, padding=1)
        self.fc1 = nn.Linear(16 * 8 * 8, 32)
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
        out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
        out = out.view(-1, 16 * 8 * 8)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

如果我们想避免在模型定义中硬编码数字,我们可以很容易地将一个参数传递给init,并将宽度参数化,同时要注意在forward函数中也将view的调用参数化:

# In[42]:
class NetWidth(nn.Module):
    def __init__(self, n_chans1=32):
        super().__init__()
        self.n_chans1 = n_chans1
        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
                               padding=1)
        self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32)
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
        out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
        out = out.view(-1, 8 * 8 * self.n_chans1 // 2)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

每一层指定通道和特征的数字与模型中的参数数量直接相关;其他条件相同的情况下,它们会增加模型的容量。就像之前所做的那样,我们可以看看我们的模型现在有多少参数:

# In[44]:
sum(p.numel() for p in model.parameters())

# Out[44]:
38386

容量越大,模型将能够处理输入的变化性就越多;但与此同时,过拟合的可能性也越大,因为模型可以使用更多的参数来记忆输入的不重要方面。我们已经探讨了对抗过拟合的方法,最好的方法是增加样本量,或者在没有新数据的情况下,通过对同一数据进行人工修改来增加现有数据。

在模型级别(而不是在数据上)我们可以采取一些更多的技巧来控制过拟合。让我们回顾一下最常见的几种。

8.5.2 帮助我们的模型收敛和泛化:正则化

训练模型涉及两个关键步骤:优化,当我们需要在训练集上减少损失时;和泛化,当模型不仅需要在训练集上工作,还需要在之前未见过的数据上工作,如验证集。旨在简化这两个步骤的数学工具有时被归纳为正则化的标签下。

控制参数:权重惩罚

稳定泛化的第一种方法是向损失中添加正则化项。这个项被设计成使模型的权重自行趋向于较小,限制训练使它们增长的程度。换句话说,这是对较大权重值的惩罚。这使得损失具有更加平滑的拓扑结构,从拟合单个样本中获得的收益相对较少。

这种类型的最受欢迎的正则化项是 L2 正则化,它是模型中所有权重的平方和,以及 L1 正则化,它是模型中所有权重的绝对值之和。它们都由一个(小)因子缩放,这是我们在训练之前设置的超参数。

L2 正则化也被称为权重衰减。这个名称的原因是,考虑到 SGD 和反向传播,L2 正则化项对参数w_i的负梯度为- 2 * lambda * w_i,其中lambda是前面提到的超参数,在 PyTorch 中简称为权重衰减。因此,将 L2 正则化添加到损失函数中等同于在优化步骤中减少每个权重的数量与其当前值成比例的量(因此,称为权重衰减)。请注意,权重衰减适用于网络的所有参数,如偏置。

在 PyTorch 中,我们可以通过向损失中添加一个项来很容易地实现正则化。在计算损失后,无论损失函数是什么,我们都可以迭代模型的参数,对它们各自的平方(对于 L2)或abs(对于 L1)求和,并进行反向传播:

# In[45]:
def training_loop_l2reg(n_epochs, optimizer, model, loss_fn,
                        train_loader):
    for epoch in range(1, n_epochs + 1):
        loss_train = 0.0
        for imgs, labels in train_loader:
            imgs = imgs.to(device=device)
            labels = labels.to(device=device)
            outputs = model(imgs)
            loss = loss_fn(outputs, labels)

            l2_lambda = 0.001
            l2_norm = sum(p.pow(2.0).sum()
                          for p in model.parameters())   # ❶
            loss = loss + l2_lambda * l2_norm

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            loss_train += loss.item()
        if epoch == 1 or epoch % 10 == 0:
            print('{} Epoch {}, Training loss {}'.format(
                datetime.datetime.now(), epoch,
                loss_train / len(train_loader)))

❶ 用 abs()替换 pow(2.0)以进行 L1 正则化

然而,PyTorch 中的 SGD 优化器已经有一个weight_decay参数,对应于2 * lambda,并且在更新过程中直接执行权重衰减,如前所述。这完全等同于将权重的 L2 范数添加到损失中,而无需在损失中累积项并涉及 autograd。

不要过分依赖单个输入:Dropout

一种有效的对抗过拟合策略最初是由 2014 年多伦多 Geoff Hinton 小组的 Nitish Srivastava 及其合著者提出的,题为“Dropout:一种简单防止神经网络过拟合的方法”(mng.bz/nPMa)。听起来就像是我们正在寻找的东西,对吧?dropout 背后的想法确实很简单:在整个网络中随机将一部分神经元的输出置零,其中随机化发生在每个训练迭代中。

该过程有效地在每次迭代中生成具有不同神经元拓扑的略有不同的模型,使模型中的神经元在发生过拟合时的记忆过程中有更少的协调机会。另一个观点是 dropout 扰乱了模型生成的特征,产生了一种接近增强的效果,但这次是在整个网络中。

在 PyTorch 中,我们可以通过在非线性激活函数和后续层的线性或卷积模块之间添加一个nn.Dropout模块来实现模型中的 dropout。作为参数,我们需要指定输入被置零的概率。在卷积的情况下,我们将使用专门的nn.Dropout2dnn.Dropout3d,它们会将输入的整个通道置零:

# In[47]:
class NetDropout(nn.Module):
    def __init__(self, n_chans1=32):
        super().__init__()
        self.n_chans1 = n_chans1
        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.conv1_dropout = nn.Dropout2d(p=0.4)
        self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
                               padding=1)
        self.conv2_dropout = nn.Dropout2d(p=0.4)
        self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32)
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
        out = self.conv1_dropout(out)
        out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
        out = self.conv2_dropout(out)
        out = out.view(-1, 8 * 8 * self.n_chans1 // 2)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

注意,在训练期间通常会激活 dropout,而在生产中评估经过训练的模型时,会绕过 dropout,或者等效地将概率分配为零。这通过Dropout模块的train属性来控制。请记住,PyTorch 允许我们通过调用在两种模式之间切换

model.train()

model.eval()

在任何nn.Model子类上。调用将自动复制到子模块,因此如果其中包含Dropout,它将在后续的前向和后向传递中相应地行为。

保持激活在适当范围内:批量归一化

在 2015 年,谷歌的 Sergey Ioffe 和 Christian Szegedy 发表了另一篇具有开创性意义的论文,名为“批量归一化:通过减少内部协变量转移加速深度网络训练”(arxiv.org/abs/1502.03167)。该论文描述了一种对训练有多种有益影响的技术:使我们能够增加学习率,使训练不那么依赖初始化并充当正则化器,从而代替了 dropout。

批量归一化背后的主要思想是重新缩放网络的激活输入,以便小批量具有某种理想的分布。回顾学习的机制和非线性激活函数的作用,这有助于避免输入到激活函数过于饱和部分,从而杀死梯度并减慢训练速度。

在实际操作中,批量归一化使用小批量样本中在该中间位置收集的均值和标准差来移位和缩放中间输入。正则化效果是因为模型始终将单个样本及其下游激活视为根据随机提取的小批量样本的统计数据而移位和缩放。这本身就是一种原则性的增强。论文的作者建议使用批量归一化消除或至少减轻了对 dropout 的需求。

在 PyTorch 中,批量归一化通过nn.BatchNorm1Dnn.BatchNorm2dnn.BatchNorm3d模块提供,取决于输入的维度。由于批量归一化的目的是重新缩放激活的输入,自然的位置是在线性变换(在这种情况下是卷积)和激活之后,如下所示:

# In[49]:
class NetBatchNorm(nn.Module):
    def __init__(self, n_chans1=32):
        super().__init__()
        self.n_chans1 = n_chans1
        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.conv1_batchnorm = nn.BatchNorm2d(num_features=n_chans1)
        self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
                               padding=1)
        self.conv2_batchnorm = nn.BatchNorm2d(num_features=n_chans1 // 2)
        self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32)
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = self.conv1_batchnorm(self.conv1(x))
        out = F.max_pool2d(torch.tanh(out), 2)
        out = self.conv2_batchnorm(self.conv2(out))
        out = F.max_pool2d(torch.tanh(out), 2)
        out = out.view(-1, 8 * 8 * self.n_chans1 // 2)
        out = torch.tanh(self.fc1(out))
        out = self.fc2(out)
        return out

与 dropout 一样,批量归一化在训练和推断期间需要有不同的行为。实际上,在推断时,我们希望避免特定输入的输出依赖于我们向模型呈现的其他输入的统计信息。因此,我们需要一种方法来进行归一化,但这次是一次性固定归一化参数。

当处理小批量时,除了估计当前小批量的均值和标准差之外,PyTorch 还更新代表整个数据集的均值和标准差的运行估计,作为近似值。这样,当用户指定时

model.eval()

如果模型包含批量归一化模块,则冻结运行估计并用于归一化。要解冻运行估计并返回使用小批量统计信息,我们调用model.train(),就像我们对待 dropout 一样。

8.5.3 深入学习更复杂的结构:深度

早些时候,我们谈到宽度作为第一个要处理的维度,以使模型更大,从某种意义上说,更有能力。第二个基本维度显然是深度。由于这是一本深度学习书,深度是我们应该关注的东西。毕竟,深层模型总是比浅层模型更好,不是吗?嗯,这取决于情况。随着深度增加,网络能够逼近的函数的复杂性通常会增加。就计算机视觉而言,一个较浅的网络可以识别照片中的人的形状,而一个更深的网络可以识别人、头部上半部分的脸和脸部内的嘴巴。深度使模型能够处理分层信息,当我们需要理解上下文以便对某些输入进行分析时。

还有另一种思考深度的方式:增加深度与增加网络在处理输入时能够执行的操作序列的长度有关。这种观点--一个执行顺序操作以完成任务的深度网络--对于习惯于将算法视为“找到人的边界,寻找边界上方的头部,寻找头部内的嘴巴”等操作序列的软件开发人员可能是迷人的。

跳过连接

深度带来了一些额外的挑战,这些挑战阻碍了深度学习模型在 2015 年之前达到 20 层或更多层。增加模型的深度通常会使训练更难收敛。让我们回顾反向传播,并在非常深的网络环境中思考一下。损失函数对参数的导数,特别是早期层中的导数,需要乘以许多其他数字,这些数字来自于损失和参数之间的导数操作链。这些被乘以的数字可能很小,生成越来越小的数字,或者很大,由于浮点近似而吞噬较小的数字。归根结底,长链的乘法将使参数对梯度的贡献消失,导致该层的训练无效,因为该参数和类似的其他参数将无法得到适当更新。

2015 年 12 月,Kaiming He 和合著者提出了残差网络(ResNets),这是一种使用简单技巧的架构,使得非常深的网络能够成功训练( arxiv.org/abs/1512.03385)。该工作为从几十层到 100 层深度的网络打开了大门,超越了当时计算机视觉基准问题的最新技术。我们在第二章中使用预训练模型时遇到了残差网络。我们提到的技巧是:使用跳跃连接来绕过一组层,如图 8.11 所示。

图 8.11 我们具有三个卷积层的网络架构。跳跃连接是NetResNetDepth的区别所在。

跳跃连接只是将输入添加到一组层的输出中。这正是在 PyTorch 中所做的。让我们向我们简单的卷积模型添加一层,并让我们使用 ReLU 作为激活函数。带有额外一层的香草模块如下所示:

# In[51]:
class NetDepth(nn.Module):
    def __init__(self, n_chans1=32):
        super().__init__()
        self.n_chans1 = n_chans1
        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
                               padding=1)
        self.conv3 = nn.Conv2d(n_chans1 // 2, n_chans1 // 2,
                               kernel_size=3, padding=1)
        self.fc1 = nn.Linear(4 * 4 * n_chans1 // 2, 32)
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.relu(self.conv1(x)), 2)
        out = F.max_pool2d(torch.relu(self.conv2(out)), 2)
        out = F.max_pool2d(torch.relu(self.conv3(out)), 2)
        out = out.view(-1, 4 * 4 * self.n_chans1 // 2)
        out = torch.relu(self.fc1(out))
        out = self.fc2(out)
        return out

向这个模型添加一个类 ResNet 的跳跃连接相当于将第一层的输出添加到第三层的输入中的forward函数中:

# In[53]:
class NetRes(nn.Module):
    def __init__(self, n_chans1=32):
        super().__init__()
        self.n_chans1 = n_chans1
        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
                               padding=1)
        self.conv3 = nn.Conv2d(n_chans1 // 2, n_chans1 // 2,
                               kernel_size=3, padding=1)
        self.fc1 = nn.Linear(4 * 4 * n_chans1 // 2, 32)
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.relu(self.conv1(x)), 2)
        out = F.max_pool2d(torch.relu(self.conv2(out)), 2)
        out1 = out
        out = F.max_pool2d(torch.relu(self.conv3(out)) + out1, 2)
        out = out.view(-1, 4 * 4 * self.n_chans1 // 2)
        out = torch.relu(self.fc1(out))
        out = self.fc2(out)
        return out

换句话说,我们将第一个激活的输出用作最后一个的输入,除了标准的前馈路径。这也被称为恒等映射。那么,这如何缓解我们之前提到的梯度消失问题呢?

想想反向传播,我们可以欣赏到在深度网络中的跳跃连接,或者一系列跳跃连接,为深层参数到损失创建了一条直接路径。这使得它们对损失的梯度贡献更直接,因为对这些参数的损失的偏导数有机会不被一长串其他操作相乘。

已经观察到跳跃连接对收敛特别是在训练的初始阶段有益。此外,深度残差网络的损失景观比相同深度和宽度的前馈网络要平滑得多。

值得注意的是,当 ResNets 出现时,跳跃连接并不是新鲜事物。Highway 网络和 U-Net 使用了各种形式的跳跃连接。然而,ResNets 使用跳跃连接的方式使得深度大于 100 的模型易于训练。

自 ResNets 出现以来,其他架构已经将跳跃连接提升到了一个新水平。特别是 DenseNet,提出通过跳跃连接将每一层与数个下游层连接起来,以较少的参数实现了最先进的结果。到目前为止,我们知道如何实现类似 DenseNets 的东西:只需将早期中间输出算术地添加到下游中间输出中。

在 PyTorch 中构建非常深的模型

我们谈到了在卷积神经网络中超过 100 层。我们如何在 PyTorch 中构建该网络而不至于在过程中迷失方向?标准策略是定义一个构建块,例如(Conv2d,ReLU,Conv2d) + 跳跃连接块,然后在for循环中动态构建网络。让我们看看实践中是如何完成的。我们将创建图 8.12 中所示的网络。

图 8.12 我们带有残差连接的深度架构。在左侧,我们定义了一个简单的残差块。如右侧所示,我们将其用作网络中的构建块。

我们首先创建一个模块子类,其唯一任务是为一个提供计算--也就是说,一组卷积、激活和跳跃连接:

# In[55]:
class ResBlock(nn.Module):
    def __init__(self, n_chans):
        super(ResBlock, self).__init__()
        self.conv = nn.Conv2d(n_chans, n_chans, kernel_size=3,
                              padding=1, bias=False)            # ❶
        self.batch_norm = nn.BatchNorm2d(num_features=n_chans)
        torch.nn.init.kaiming_normal_(self.conv.weight,
                                      nonlinearity='relu')      # ❷
        torch.nn.init.constant_(self.batch_norm.weight, 0.5)
        torch.nn.init.zeros_(self.batch_norm.bias)

    def forward(self, x):
        out = self.conv(x)
        out = self.batch_norm(out)
        out = torch.relu(out)
        return out + x

❶ BatchNorm 层会抵消偏差的影响,因此通常会被省略。

❷ 使用自定义初始化。kaiming_normal_ 使用正态随机元素进行初始化,标准差与 ResNet 论文中计算的一致。批量归一化被初始化为产生初始具有 0 均值和 0.5 方差的输出分布。

由于我们计划生成一个深度模型,我们在块中包含了批量归一化,因为这将有助于防止训练过程中梯度消失。我们现在想生成一个包含 100 个块的网络。这意味着我们需要做一些严肃的剪切和粘贴吗?一点也不;我们已经有了想象这个模型可能是什么样子的所有要素。

首先,在init中,我们创建包含一系列ResBlock实例的nn.Sequentialnn.Sequential将确保一个块的输出被用作下一个块的输入。它还将确保块中的所有参数对Net可见。然后,在forward中,我们只需调用顺序遍历 100 个块并生成输出:

# In[56]:
class NetResDeep(nn.Module):
    def __init__(self, n_chans1=32, n_blocks=10):
        super().__init__()
        self.n_chans1 = n_chans1
        self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
        self.resblocks = nn.Sequential(
            *(n_blocks * [ResBlock(n_chans=n_chans1)]))
        self.fc1 = nn.Linear(8 * 8 * n_chans1, 32)
        self.fc2 = nn.Linear(32, 2)

    def forward(self, x):
        out = F.max_pool2d(torch.relu(self.conv1(x)), 2)
        out = self.resblocks(out)
        out = F.max_pool2d(out, 2)
        out = out.view(-1, 8 * 8 * self.n_chans1)
        out = torch.relu(self.fc1(out))
        out = self.fc2(out)
        return out

在实现中,我们参数化了实际层数,这对实验和重复使用很重要。此外,不用说,反向传播将按预期工作。毫不奇怪,网络收敛速度要慢得多。它在收敛方面也更加脆弱。这就是为什么我们使用更详细的初始化,并将我们的NetRes训练学习率设置为 3e - 3,而不是我们为其他网络使用的 1e - 2。我们没有训练任何网络到收敛,但如果没有这些调整,我们将一事无成。

所有这些都不应该鼓励我们在一个 32×32 像素的数据集上寻求深度,但它清楚地展示了如何在更具挑战性的数据集(如 ImageNet)上实现这一点。它还为理解像 ResNet 这样的现有模型实现提供了关键要素,例如在torchvision中。

初始化

让我们简要评论一下早期的初始化。初始化是训练神经网络的重要技巧之一。不幸的是,出于历史原因,PyTorch 具有不理想的默认权重初始化。人们正在努力解决这个问题;如果取得进展,可以在 GitHub 上跟踪(github.com/pytorch/pytorch/issues/18182)。与此同时,我们需要自己修复权重初始化。我们发现我们的模型无法收敛,查看了人们通常选择的初始化方式(权重较小的方差;批量归一化的输出为零均值和单位方差),然后在网络无法收敛时,将批量归一化的输出方差减半。

权重初始化可能需要一个完整的章节来讨论,但我们认为那可能有些过分。在第十一章中,我们将再次遇到初始化,并使用可能是 PyTorch 默认值的内容,而不做过多解释。一旦你进步到对权重初始化的细节感兴趣的程度--可能在完成本书之前--你可能会重新访问这个主题。

8.5.4 比较本节中的设计

我们在图 8.13 中总结了我们每个设计修改的效果。我们不应该过分解释任何具体的数字--我们的问题设置和实验是简单的,使用不同的随机种子重复实验可能会产生至少与验证准确性差异一样大的变化。在这个演示中,我们保持了所有其他因素不变,从学习率到训练的时代数;在实践中,我们会通过变化这些因素来获得最佳结果。此外,我们可能会想要结合一些额外的设计元素。

但是可能需要进行定性观察:正如我们在第 5.5.3 节中看到的,在讨论验证和过拟合时,权重衰减和丢弃正则化,比批量归一化更具有更严格的统计估计解释作为正则化,两个准确率之间的差距要小得多。批量归一化更像是一个收敛助手,让我们将网络训练到接近 100%的训练准确率,因此我们将前两者解释为正则化。

图 8.13 修改后的网络表现都相似。

8.5.5 它已经过时了

深度学习从业者的诅咒和祝福是神经网络架构以非常快的速度发展。这并不是说我们在本章中看到的内容一定是老派的,但对最新和最伟大的架构进行全面说明是另一本书的事情(而且它们很快就会不再是最新和最伟大的)。重要的是我们应该尽一切努力将论文背后的数学精通地转化为实际的 PyTorch 代码,或者至少理解其他人为了相同目的编写的代码。在最近的几章中,您已经希望积累了一些将想法转化为 PyTorch 中实现模型的基本技能。

8.6 结论

经过相当多的工作,我们现在有一个模型,我们的虚构朋友简可以用来过滤她博客中的图像。我们所要做的就是拿到一张进入的图像,裁剪并调整大小为 32 × 32,看看模型对此有何看法。诚然,我们只解决了问题的一部分,但这本身就是一段旅程。

我们只解决了问题的一部分,因为还有一些有趣的未知问题我们仍然需要面对。其中一个是从较大图像中挑选出鸟或飞机。在图像中创建物体周围的边界框是我们这种模型无法做到的。

另一个障碍是当猫弗雷德走到摄像头前会发生什么。我们的模型会毫不犹豫地发表关于猫有多像鸟的观点!它会高兴地输出“飞机”或“鸟”,也许概率为 0.99。这种对远离训练分布的样本非常自信的问题被称为过度泛化。当我们将一个(假设良好的)模型投入生产中时,我们无法真正信任输入的情况下,这是主要问题之一(遗憾的是,这是大多数真实世界案例)。

在本章中,我们已经在 PyTorch 中构建了合理的、可工作的模型,可以从图像中学习。我们以一种有助于我们建立对卷积网络直觉的方式来做到这一点。我们还探讨了如何使我们的模型更宽更深,同时控制过拟合等影响。虽然我们仍然只是触及了表面,但我们已经比上一章更进一步了。我们现在有了一个坚实的基础,可以面对在深度学习项目中遇到的挑战。

现在我们熟悉了 PyTorch 的约定和常见特性,我们准备着手处理更大的问题。我们将从每一章或两章呈现一个小问题的模式转变为花费多章来解决一个更大的、现实世界的问题。第 2 部分以肺癌的自动检测作为一个持续的例子;我们将从熟悉 PyTorch API 到能够使用 PyTorch 实现整个项目。我们将在下一章开始从高层次解释问题,然后深入了解我们将要使用的数据的细节。

8.7 练习

  1. 更改我们的模型,使用kernel_size=5传递给nn.Conv2d构造函数的 5 × 5 内核。

    1. 这种变化对模型中的参数数量有什么影响?

    2. 这种变化是改善还是恶化了过拟合?

    3. 阅读pytorch.org/docs/stable/nn.html#conv2d

    4. 你能描述kernel_size=(1,3)会做什么吗?

    5. 这种卷积核会如何影响模型的行为?

  2. 你能找到一张既不含鸟也不含飞机的图像,但模型声称其中有一个或另一个的置信度超过 95%吗?

    1. 你能手动编辑一张中性图像,使其更像飞机吗?

    2. 你能手动编辑一张飞机图像,以欺骗模型报告有鸟吗?

    3. 这些任务随着容量较小的网络变得更容易吗?容量更大呢?

8.8 总结

  • 卷积可用作处理图像的前馈网络的线性操作。使用卷积可以产生参数更少的网络,利用局部性并具有平移不变性。

  • 将多个卷积层及其激活函数依次堆叠在一起,并在它们之间使用最大池化,可以使卷积应用于越来越小的特征图像,从而在深度增加时有效地考虑输入图像更大部分的空间关系。

  • 任何nn.Module子类都可以递归收集并返回其自身和其子类的参数。这种技术可用于计数参数、将其馈送到优化器中或检查其值。

  • 函数式 API 提供了不依赖于存储内部状态的模块。它用于不持有参数且因此不被训练的操作。

  • 训练后,模型的参数可以保存到磁盘并用一行代码加载回来。


¹PyTorch 的卷积与数学的卷积之间存在微妙的差异:一个参数的符号被翻转了。如果我们情绪低落,我们可以称 PyTorch 的卷积为离散互相关

² 这是彩票票据假设的一部分:许多卷积核将像丢失的彩票一样有用。参见 Jonathan Frankle 和 Michael Carbin,“The Lottery Ticket Hypothesis: Finding Sparse, Trainable Neural Networks,” 2019,arxiv.org/abs/1803.03635

³ 对于偶数大小的卷积核,我们需要在左右(和上下)填充不同数量。PyTorch 本身不提供在卷积中执行此操作的功能,但函数torch.nn.functional.pad可以处理。但最好保持奇数大小的卷积核;偶数大小的卷积核只是奇数大小的。

⁴ 无法在nn.Sequential内执行此类操作是 PyTorch 作者明确的设计选择,并且长时间保持不变;请参阅@soumith 在github.com/pytorch/pytorch/issues/2486中的评论。最近,PyTorch 增加了一个nn.Flatten层。

⁵ 我们可以从 PyTorch 1.3 开始使用nn.Flatten

⁶ 由第一个卷积定义的像素级线性映射中的维度在 Jeremy Howard 的 fast.ai 课程中得到强调(www.fast.ai)。

⁷ 在深度学习之外且比其更古老的,将投影到高维空间然后进行概念上更简单(比线性更简单)的机器学习通常被称为核技巧。通道数量的初始增加可以被视为一种类似的现象,但在嵌入的巧妙性和处理嵌入的模型的简单性之间达到不同的平衡。

⁸ 数据加载器有一个pin_memory选项,将导致数据加载器使用固定到 GPU 的内存,目的是加快传输速度。然而,我们是否获得了什么是不确定的,因此我们不会在这里追求这个。

⁹ 我们将重点放在 L2 正则化上。L1 正则化--在更一般的统计文献中因其在 Lasso 中的应用而广为流行--具有产生稀疏训练权重的吸引人特性。

¹⁰ 该主题的开创性论文是由 X. Glorot 和 Y. Bengio 撰写的:“理解训练深度前馈神经网络的困难”(2010 年),介绍了 PyTorch 的Xavier初始化( mng.bz/vxz7)。我们提到的 ResNet 论文也扩展了这个主题,提供了之前使用的 Kaiming 初始化。最近,H. Zhang 等人对初始化进行了调整,以至于在他们对非常深的残差网络进行实验时不需要批量归一化(arxiv.org/abs/1901.09321)。

第二部分:从现实世界的图像中学习:肺癌的早期检测

第 2 部分的结构与第 1 部分不同;它几乎是一本书中的一本书。我们将以几章的篇幅深入探讨一个单一用例,从第 1 部分学到的基本构建模块开始,构建一个比我们迄今为止看到的更完整的项目。我们的第一次尝试将是不完整和不准确的,我们将探讨如何诊断这些问题,然后修复它们。我们还将确定我们解决方案的各种其他改进措施,实施它们,并衡量它们的影响。为了训练第 2 部分中将开发的模型,您将需要访问至少 8 GB RAM 的 GPU,以及数百 GB 的可用磁盘空间来存储训练数据。

第九章介绍了我们将要消耗的项目、环境和数据,以及我们将要实施的项目结构。第十章展示了我们如何将数据转换为 PyTorch 数据集,第十一章和第十二章介绍了我们的分类模型:我们需要衡量数据集训练效果的指标,并实施解决阻止模型良好训练的问题的解决方案。在第十三章,我们将转向端到端项目的开始,通过创建一个生成热图而不是单一分类的分割模型。该热图将用于生成位置进行分类。最后,在第十四章,我们将结合我们的分割和分类模型进行最终诊断。

九、使用 PyTorch 来对抗癌症

本章涵盖内容

  • 将一个大问题分解为更小、更容易的问题

  • 探索复杂深度学习问题的约束,并决定结构和方法

  • 下载训练数据

本章有两个主要目标。我们将首先介绍本书第二部分的整体计划,以便我们对接下来的各个章节将要构建的更大范围有一个坚实的概念。在第十章中,我们将开始构建数据解析和数据操作例程,这些例程将在第十一章中训练我们的第一个模型时产生要消耗的数据。为了很好地完成即将到来的章节所需的工作,我们还将利用本章来介绍我们的项目将运行的一些背景:我们将讨论数据格式、数据来源,并探索我们问题领域对我们施加的约束。习惯执行这些任务,因为你将不得不为任何严肃的深度学习项目做这些任务!

9.1 用例简介

本书这一部分的目标是为您提供处理事情不顺利的工具,这种情况比第 1 部分可能让你相信的更常见。我们无法预测每种失败情况或涵盖每种调试技术,但希望我们能给你足够的东西,让你在遇到新的障碍时不感到困惑。同样,我们希望帮助您避免您自己的项目出现情况,即当您的项目表现不佳时,您不知道接下来该做什么。相反,我们希望您的想法列表会很长,挑战将是如何优先考虑!

为了呈现这些想法和技术,我们需要一个具有一些细微差别和相当重要性的背景。我们选择使用仅通过患者胸部的 CT 扫描作为输入来自动检测肺部恶性肿瘤。我们将专注于技术挑战而不是人类影响,但不要误解--即使从工程角度来看,第 2 部分也需要比第 1 部分更严肃、更有结构的方法才能使项目成功。

注意 CT 扫描本质上是 3D X 射线,表示为单通道数据的 3D 数组。我们很快会更详细地讨论它们。

正如你可能已经猜到的,本章的标题更多是引人注目的、暗示夸张,而不是严肃的声明意图。让我们准确一点:本书的这一部分的项目将以人体躯干的三维 CT 扫描作为输入,并输出怀疑的恶性肿瘤的位置,如果有的话。

早期检测肺癌对生存率有巨大影响,但手动进行这项工作很困难,特别是在任何全面、整体人口意义上。目前,审查数据的工作必须由经过高度训练的专家执行,需要极其细致的注意,而且主要是由不存在癌症的情况主导。

做好这项工作就像被放在 100 堆草垛前,并被告知:“确定这些中哪些,如果有的话,包含一根针。”这样搜索会导致潜在的警告信号被忽略,特别是在早期阶段,提示更加微妙。人类大脑并不适合做那种单调的工作。当然,这就是深度学习发挥作用的地方。

自动化这个过程将使我们在一个不合作的环境中获得经验,在那里我们必须从头开始做更多的工作,而且可能会遇到更少的问题容易解决。不过,我们一起努力,最终会成功的!一旦你读完第二部分,我们相信你就准备好开始解决你自己选择的一个真实且未解决的问题了。

我们选择了肺部肿瘤检测这个问题,有几个原因。主要原因是这个问题本身尚未解决!这很重要,因为我们想要明确表明你可以使用 PyTorch 有效地解决尖端项目。我们希望这能增加你对 PyTorch 作为框架以及作为开发者的信心。这个问题空间的另一个好处是,虽然它尚未解决,但最近许多团队一直在关注它,并且已经看到了有希望的结果。这意味着这个挑战可能正好处于我们集体解决能力的边缘;我们不会浪费时间在一个实际上离合理解决方案还有几十年的问题上。对这个问题的关注也导致了许多高质量的论文和开源项目,这些是灵感和想法的重要来源。如果你有兴趣继续改进我们创建的解决方案,这将在我们完成书的第二部分后非常有帮助。我们将在第十四章提供一些额外信息的链接。

本书的这一部分将继续专注于检测肺部肿瘤的问题,但我们将教授的技能是通用的。学习如何调查、预处理和呈现数据以进行训练对于你正在进行的任何项目都很重要。虽然我们将在肺部肿瘤的具体背景下涵盖预处理,但总体思路是这是你应该为你的项目做好准备的。同样,建立训练循环、获得正确的性能指标以及将项目的模型整合到最终应用程序中都是我们将在第 9 至 14 章中使用的通用技能。

注意 尽管第 2 部分的最终结果将有效,但输出不够准确以用于临床。我们专注于将其作为教授 PyTorch的激励示例,而不是利用每一个技巧来解决问题。

9.2 准备一个大型项目

这个项目将建立在第 1 部分学到的基础技能之上。特别是,从第八章开始的模型构建内容将直接相关。重复的卷积层后跟着一个分辨率降低的下采样层仍将构成我们模型的大部分。然而,我们将使用 3D 数据作为我们模型的输入。这在概念上类似于第 1 部分最后几章中使用的 2D 图像数据,但我们将无法依赖 PyTorch 生态系统中所有 2D 特定工具。

我们在第八章使用卷积模型的工作与第 2 部分中将要做的工作之间的主要区别与我们投入到模型之外的事情有关。在第八章,我们使用一个提供的现成数据集,并且在将数据馈送到模型进行分类之前几乎没有进行数据操作。我们几乎所有的时间和注意力都花在构建模型本身上,而现在我们甚至不会在第十一章开始设计我们的两个模型架构之一。这是由于有非标准数据,没有预先构建的库可以随时提供适合插入模型的训练样本。我们将不得不了解我们的数据并自己实现相当多的内容。

即使完成了这些工作,这也不会成为将 CT 转换为张量,将其馈送到神经网络中,并在另一侧得到答案的情况。对于这样的真实用例,一个可行的方法将更加复杂,以考虑到限制数据可用性、有限的计算资源以及我们设计有效模型的能力的限制因素。请记住这一点,因为我们将逐步解释我们项目架构的高级概述。

谈到有限的计算资源,第 2 部分将需要访问 GPU 才能实现合理的训练速度,最好是至少具有 8 GB 的 RAM。尝试在 CPU 上训练我们将构建的模型可能需要几周时间!¹ 如果你手头没有 GPU,我们在第十四章提供了预训练模型;那里的结节分析脚本可能可以在一夜之间运行。虽然我们不想将本书与专有服务绑定,但值得注意的是,目前,Colaboratory(colab.research.google.com)提供免费的 GPU 实例,可能会有用。PyTorch 甚至已经预安装!你还需要至少 220 GB 的可用磁盘空间来存储原始训练数据、缓存数据和训练模型。

注意 第 2 部分中呈现的许多代码示例省略了复杂的细节。与其用日志记录、错误处理和边缘情况来混淆示例,本书的文本只包含表达讨论中核心思想的代码。完整的可运行代码示例可以在本书的网站(www.manning.com/books/deep-learning-with-pytorch)和 GitHub(github.com/deep-learning-with-pytorch/dlwpt-code)上找到。

好的,我们已经确定了这是一个困难、多方面的问题,但我们要怎么解决呢?我们不是要查看整个 CT 扫描以寻找肿瘤或其潜在恶性,而是要解决一系列更简单的问题,这些问题将组合在一起提供我们感兴趣的端到端结果。就像工厂的装配线一样,每个步骤都会接收原材料(数据)和/或前一步骤的输出,进行一些处理,并将结果交给下一个站点。并不是每个问题都需要这样解决,但将问题分解成独立解决的部分通常是一个很好的开始。即使最终发现这种方法对于特定项目来说是错误的,但在处理各个部分时,我们可能已经学到足够多的知识,以便知道如何重新构建我们的方法以取得成功。

在我们深入了解如何分解问题的细节之前,我们需要了解一些关于医学领域的细节。虽然代码清单会告诉你我们在做什么,但了解放射肿瘤学将解释为什么我们这样做。无论是哪个领域,了解问题空间都是至关重要的。深度学习很强大,但它不是魔法,盲目地将其应用于非平凡问题可能会失败。相反,我们必须将对空间的洞察力与对神经网络行为的直觉相结合。从那里,有纪律的实验和改进应该为我们提供足够的信息,以便找到可行的解决方案。

9.3 什么是 CT 扫描,确切地说?

在我们深入项目之前,我们需要花点时间解释一下什么是 CT 扫描。我们将广泛使用 CT 扫描数据作为我们项目的主要数据格式,因此对数据格式的优势、劣势和基本特性有一个工作理解将对其有效利用至关重要。我们之前指出的关键点是:CT 扫描本质上是 3D X 射线,表示为单通道数据的 3D 数组。正如我们可能从第四章中记得的那样,这就像一组堆叠的灰度 PNG 图像。

体素

一个体素是熟悉的二维像素的三维等价物。它包围着空间的一个体积(因此,“体积像素”),而不是一个区域,并且通常排列在一个三维网格中以表示数据场。每个维度都将与之关联一个可测量的距离。通常,体素是立方体的,但在本章中,我们将处理的是长方体体素。

除了医学数据,我们还可以在流体模拟、从 2D 图像重建的 3D 场景、用于自动驾驶汽车的光探测与测距(LIDAR)数据等问题领域看到类似的体素数据。这些领域都有各自的特点和微妙之处,虽然我们将在这里介绍的 API 通常适用,但如果我们想要有效地使用这些 API,我们也必须了解我们使用的数据的性质。

每个 CT 扫描的体素都有一个数值,大致对应于内部物质的平均质量密度。大多数数据的可视化显示高密度材料如骨骼和金属植入物为白色,低密度的空气和肺组织为黑色,脂肪和组织为各种灰色。再次,这看起来与 X 射线有些相似,但也有一些关键区别。

CT 扫描和 X 射线之间的主要区别在于,X 射线是将 3D 强度(在本例中为组织和骨密度)投影到 2D 平面上,而 CT 扫描保留了数据的第三维。这使我们能够以各种方式呈现数据:例如,作为一个灰度实体,我们可以在图 9.1 中看到。

图 9.1 人体躯干的 CT 扫描,从上到下依次显示皮肤、器官、脊柱和患者支撑床。来源:mng.bz/04r6; Mindways CT Software / CC BY-SA 3.0 (creativecommons.org/licenses/by-sa/3.0/deed.en)。

注意 CT 扫描实际上测量的是辐射密度,这是受检材料的质量密度和原子序数的函数。在这里,区分并不相关,因为无论输入的确切单位是什么,模型都会处理和学习 CT 数据。

这种 3D 表示还允许我们通过隐藏我们不感兴趣的组织类型来“看到”主体内部。例如,我们可以将数据呈现为 3D,并将可见性限制在骨骼和肺组织,如图 9.2 所示。

图 9.2 显示了肋骨、脊柱和肺结构的 CT 扫描

与 X 射线相比,CT 扫描要难得多,因为这需要像图 9.3 中所示的那种机器,通常新机器的成本高达一百万美元,并且需要受过培训的工作人员来操作。大多数医院和一些设备齐全的诊所都有 CT 扫描仪,但它们远不及 X 射线机器普及。这与患者隐私规定结合在一起,可能会使得获取 CT 扫描有些困难,除非已经有人做好了收集和整理这些数据的工作。

图 9.3 还显示了 CT 扫描中包含区域的示例边界框。患者躺在的床来回移动,使扫描仪能够成像患者的多个切片,从而填充边界框。扫描仪较暗的中心环是实际成像设备的位置。

图 9.3 一个患者在 CT 扫描仪内,CT 扫描的边界框叠加显示。除了库存照片外,患者在机器内通常不穿着便装。

CT 扫描与 X 射线之间的最后一个区别是数据仅以数字格式存在。CT代表计算机断层扫描(en.wikipedia.org/wiki/CT_scan#Process)。扫描过程的原始输出对人眼来说并不特别有意义,必须由计算机正确重新解释为我们可以理解的内容。扫描时 CT 扫描仪的设置会对结果数据产生很大影响。

尽管这些信息可能看起来并不特别相关,但实际上我们学到了一些东西:从图 9.3 中,我们可以看到 CT 扫描仪测量头到脚轴向距离的方式与其他两个轴不同。患者实际上沿着这个轴移动!这解释了(或至少是一个强烈的暗示)为什么我们的体素可能不是立方体,并且也与我们在第十二章中如何处理数据有关。这是一个很好的例子,说明我们需要了解我们的问题领域,如果要有效地选择如何解决问题。在开始处理自己的项目时,确保您对数据的细节进行相同的调查。

9.4 项目:肺癌端到端检测器

现在我们已经掌握了 CT 扫描的基础知识,让我们讨论一下我们项目的结构。大部分磁盘上的字节将用于存储包含密度信息的 CT 扫描的 3D 数组,我们的模型将主要消耗这些 3D 数组的各种子切片。我们将使用五个主要步骤,从检查整个胸部 CT 扫描到给患者做出肺癌诊断。

我们在图 9.4 中展示的完整端到端解决方案将加载 CT 数据文件以生成包含完整 3D 扫描的Ct实例,将其与执行分割(标记感兴趣的体素)的模块结合,然后将有趣的体素分组成小块,以寻找候选结节

结节

肺部由增殖细胞组成的组织块称为肿瘤。肿瘤可以是良性的,也可以是恶性的,此时也被称为癌症。肺部的小肿瘤(仅几毫米宽)称为结节。大约 40%的肺结节最终被证实是恶性的--小癌症。尽早发现这些对于医学影像非常重要,这取决于我们正在研究的这种类型的医学影像。

图 9.4 完整胸部 CT 扫描并确定患者是否患有恶性肿瘤的端到端过程

结节位置与 CT 体素数据结合,产生结节候选,然后可以由我们的结节分类模型检查它们是否实际上是结节,最终是否是恶性的。后一项任务特别困难,因为恶性可能仅从 CT 成像中无法明显看出,但我们将看看我们能走多远。最后,每个单独的结节分类可以组合成整体患者诊断。

更详细地说,我们将执行以下操作:

  1. 将我们的原始 CT 扫描数据加载到一个可以与 PyTorch 一起使用的形式中。将原始数据放入 PyTorch 可用的形式将是您面临的任何项目的第一步。对于 2D 图像数据,这个过程稍微复杂一些,对于非图像数据则更简单。

  2. 使用 PyTorch 识别肺部潜在肿瘤的体素,实现一种称为分割的技术。这大致相当于生成应该输入到我们第 3 步分类器中的区域的热图。这将使我们能够专注于肺部内部的潜在肿瘤,并忽略大片无趣的解剖结构(例如,一个人不能在胃部患肺癌)。

    通常,在学习时能够专注于单一小任务是最好的。随着经验的积累,有些情况下更复杂的模型结构可以产生最优结果(例如,我们在第二章看到的 GAN 游戏),但是从头开始设计这些模型需要对基本构建模块有广泛的掌握。先学会走路,再跑步,等等。

  3. 将有趣的体素分组成块:也就是候选结节(有关结节的更多信息,请参见图 9.5)。在这里,我们将找到热图上每个热点的粗略中心。

    每个结节可以通过其中心点的索引、行和列来定位。我们这样做是为了向最终分类器提供一个简单、受限的问题。将体素分组不会直接涉及 PyTorch,这就是为什么我们将其拆分为一个单独的步骤。通常,在处理多步解决方案时,会在项目的较大、由深度学习驱动的部分之间添加非深度学习的连接步骤。

  4. 使用 3D 卷积将候选结节分类为实际结节或非结节。

    这将类似于我们在第八章中介绍的 2D 卷积。确定候选结构中肿瘤性质的特征是与问题中的肿瘤局部相关的,因此这种方法应该在限制输入数据大小和排除相关信息之间提供良好的平衡。做出这种限制范围的决定可以使每个单独的任务受限,这有助于在故障排除时限制要检查的事物数量。

  5. 使用组合的每个结节分类来诊断患者。

    与上一步中的结节分类器类似,我们将尝试仅基于成像数据确定结节是良性还是恶性。我们将简单地取每个肿瘤恶性预测的最大值,因为只需要一个肿瘤是恶性,患者就会患癌症。其他项目可能希望使用不同的方式将每个实例的预测聚合成一个文件分数。在这里,我们问的是,“有什么可疑的吗?”所以最大值是一个很好的聚合方式。如果我们正在寻找定量信息,比如“A 型组织与 B 型组织的比例”,我们可能会选择适当的平均值。

肩上的巨人

当我们决定采用这种五步方法时,我们站在巨人的肩膀上。我们将在第十四章更详细地讨论这些巨人及其工作。我们事先并没有特别的理由认为这种项目结构对这个问题会很有效;相反,我们依赖于那些实际实施过类似事物并报告成功的人。在转向不同领域时,预计需要进行实验以找到可行的方法,但始终尝试从该领域的早期努力和那些在类似领域工作并发现可能转移的事物的人那里学习。走出去,寻找他人所做的事情,并将其作为一个基准。同时,避免盲目获取代码并运行,因为您需要完全理解您正在运行的代码,以便利用结果为自己取得进展。

图 9.4 仅描述了在构建和训练所有必要模型后通过系统的最终路径。训练相关模型所需的实际工作将在我们接近实施每个步骤时详细说明。

我们将用于训练的数据为步骤 3 和 4 提供了人工注释的输出。这使我们可以将步骤 2 和 3(识别体素并将其分组为结节候选)几乎视为与步骤 4(结节候选分类)分开的项目。人类专家已经用结节位置注释了数据,因此我们可以按照自己喜欢的顺序处理步骤 2 和 3 或步骤 4。

我们将首先处理步骤 1(数据加载),然后跳到步骤 4,然后再回来实现步骤 2 和 3,因为步骤 4(分类)需要一种类似于我们在第八章中使用的方法,即使用多个卷积和池化层来聚合空间信息,然后将其馈送到线性分类器中。一旦我们掌握了分类模型,我们就可以开始处理步骤 2(分割)。由于分割是更复杂的主题,我们希望在不必同时学习分割和 CT 扫描以及恶性肿瘤的基础知识的情况下解决它。相反,我们将在处理一个更熟悉的分类问题的同时探索癌症检测领域。

从问题中间开始并逐步解决问题的方法可能看起来很奇怪。从第 1 步开始逐步向前推进会更直观。然而,能够将问题分解并独立解决各个步骤是有用的,因为这样可以鼓励更模块化的解决方案;此外,将工作负载在小团队成员之间划分会更容易。此外,实际的临床用户可能更喜欢一个系统,可以标记可疑的结节供审查,而不是提供单一的二进制诊断。将我们的模块化解决方案适应不同的用例可能会比如果我们采用了单一的、自上而下的系统更容易。

当我们逐步实施每一步时,我们将详细介绍肺部肿瘤,以及展示大量关于 CT 扫描的细节。虽然这可能看起来与专注于 PyTorch 的书籍无关,但我们这样做是为了让你开始对问题空间产生直觉。这是至关重要的,因为所有可能的解决方案和方法的空间太大,无法有效地编码、训练和评估。

如果我们在做一个不同的项目(比如你在完成这本书后要处理的项目),我们仍然需要进行调查来了解数据和问题空间。也许你对卫星地图制作感兴趣,你的下一个项目需要处理从轨道拍摄的地球图片。你需要询问关于收集的波长的问题--你只得到正常的 RGB 吗,还是更奇特的东西?红外线或紫外线呢?此外,根据白天时间或者成像位置不直接在卫星正上方,可能会使图像倾斜。图像是否需要校正?

即使你假设的第三个项目的数据类型保持不变,你将要处理的领域可能会改变事情,可能会发生显著变化。处理自动驾驶汽车的相机输出仍然涉及 2D 图像,但复杂性和注意事项却大不相同。例如,映射卫星不太可能需要担心太阳照射到相机中,或者镜头上沾上泥巴!

我们必须能够运用直觉来引导我们对潜在优化和改进的调查。这对于深度学习项目来说是真实的,我们将在第 2 部分中练习使用我们的直觉。所以,让我们这样做。快速退后一步,做一个直觉检查。你的直觉对这种方法有什么看法?对你来说是否过于复杂?

9.4.1 为什么我们不能简单地将数据输入神经网络直到它工作?

在阅读最后一节之后,如果你认为,“这和第八章完全不同!”我们并不会责怪你。你可能会想知道为什么我们有两种不同的模型架构,或者为什么整体数据流如此复杂。嗯,我们之所以采取这种方法与第八章不同是有原因的。自动化这个任务很困难,人们还没有完全弄清楚。这种困难转化为复杂性;一旦我们作为一个社会彻底解决了这个问题,可能会有一个现成的库包,我们可以直接使用,但我们还没有达到那一步。

为什么会这么困难呢?

首先,大部分 CT 扫描基本上与回答“这个患者是否患有恶性肿瘤?”这个问题无关。这是很直观的,因为患者身体的绝大部分组织都是健康的细胞。在有恶性肿瘤的情况下,CT 中高达 99.9999%的体素仍然不是癌症。这个比例相当于高清电视上某处两个像素的颜色错误或者一本小说书架上一个拼错的单词。

你能够在图 9.5 的三个视图中识别被标记为结节的白点吗?²

如果你需要提示,索引、行和列值可以帮助找到相关的密集组织块。你认为只有这些图像(这意味着只有图像--没有索引、行和列信息!)你能找出肿瘤的相关特性吗?如果你被给予整个 3D 扫描,而不仅仅是与扫描的有趣部分相交的三个切片呢?

注意 如果你找不到肿瘤,不要担心!我们试图说明这些数据有多微妙--难以在视觉上识别是这个例子的全部意义。

图 9.5 一张 CT 扫描,大约有 1,000 个对于未经训练的眼睛看起来像肿瘤的结构。当由人类专家审查时,只有一个被确定为结节。其余的是正常的解剖结构,如血管、病变和其他无问题的肿块。

你可能在其他地方看到端到端方法在对象检测和分类中非常成功。TorchVision 包括像 Fast R-CNN/Mask R-CNN 这样的端到端模型,但这些模型通常在数十万张图像上进行训练,而这些数据集并不受稀有类别样本数量的限制。我们将使用的项目架构有利于在更适量的数据上表现良好。因此,虽然从理论上讲,可以向神经网络投入任意大量的数据,直到它学会寻找传说中的丢失的针,以及如何忽略干草,但实际上收集足够的数据并等待足够长的时间来正确训练网络是不现实的。这不会是最佳方法,因为结果很差,大多数读者根本无法获得计算资源来实现它。

要想得出最佳解决方案,我们可以研究已被证明能够更好地端到端集成数据的模型设计。这些复杂的设计能够产生高质量的结果,但它们并不是最佳,因为要理解它们背后的设计决策需要先掌握基本概念。这使得这些先进模型在教授这些基本概念时不是很好的选择!

这并不是说我们的多步设计是最佳方法,因为“最佳”只是相对于我们选择用来评估方法的标准而言。有许多“最佳”方法,就像我们在项目中工作时可能有许多目标一样。我们的自包含、多步方法也有一些缺点。

回想一下第二章的 GAN 游戏。在那里,我们有两个网络合作,制作出老大师艺术家的逼真赝品。艺术家会制作一个候选作品,学者会对其进行评论,给予艺术家如何改进的反馈。用技术术语来说,模型的结构允许梯度从最终分类器(假或真)传播到项目的最早部分(艺术家)。

我们解决问题的方法不会使用端到端梯度反向传播直接优化我们的最终目标。相反,我们将分别优化问题的离散块,因为我们的分割模型和分类模型不会同时训练。这可能会限制我们解决方案的最高效果,但我们认为这将带来更好的学习体验。

我们认为,能够一次专注于一个步骤使我们能够放大并集中精力学习的新技能数量更少。我们的两个模型将专注于执行一个任务。就像人类放射科医生在逐层查看 CT 切片时一样,如果范围被很好地限定,训练工作就会变得更容易。我们还希望提供能够对数据进行丰富操作的工具。能够放大并专注于特定位置的细节将对训练模型的整体生产率产生巨大影响,而不是一次查看整个图像。我们的分割模型被迫消耗整个图像,但我们将构建结构,使我们的分类模型获得感兴趣区域的放大视图。

第 3 步(分组)将生成数据,第 4 步(分类)将消耗类似于图 9.6 中包含肿瘤顺序横截面的图像。这幅图像是(潜在恶性,或至少不确定)肿瘤的近距离视图,我们将训练第 4 步模型识别,并训练第 5 步模型将其分类为良性或恶性。对于未经训练的眼睛(或未经训练的卷积网络)来说,这个肿块可能看起来毫无特征,但在这个样本中识别恶性的预警信号至少比消耗我们之前看到的整个 CT 要容易得多。我们下一章的代码将提供生成类似图 9.6 的放大结节图像的例程。

图 9.6 CT 扫描中肿瘤的近距离、多层切片裁剪

我们将在第十章中进行第 1 步数据加载工作,第十一章和第十二章将专注于解决分类这些结节的问题。之后,我们将回到第十三章工作于第 2 步(使用分割找到候选肿瘤),然后我们将在第十四章中结束本书的第 2 部分,通过实现第 3 步(分组)和第 5 步(结节分析和诊断)的端到端项目。

注意 CT 的标准呈现将上部放在图像的顶部(基本上,头向上),但 CT 按顺序排列其切片,使第一切片是下部(向脚)。因此,Matplotlib 会颠倒图像,除非我们注意翻转它们。由于这种翻转对我们的模型并不重要,我们不会在原始数据和模型之间增加代码路径的复杂性,但我们会在渲染代码中添加翻转以使图像正面朝上。有关 CT 坐标系统的更多信息,请参见第 10.4 节。

让我们在图 9.7 中重复我们的高层概述。

图 9.7 完成全胸 CT 扫描并确定患者是否患有恶性肿瘤的端到端过程

9.4.2 什么是结节?

正如我们所说的,为了充分了解我们的数据以有效使用它,我们需要学习一些关于癌症和放射肿瘤学的具体知识。我们需要了解的最后一件重要事情是什么是结节。简单来说,结节是可能出现在某人肺部内部的无数肿块和隆起之一。有些从患者健康角度来看是有问题的;有些则不是。精确的定义将结节的大小限制在 3 厘米以下,更大的肿块被称为肺块;但我们将使用结节来交替使用所有这样的解剖结构,因为这是一个相对任意的分界线,我们将使用相同的代码路径处理 3 厘米两侧的肿块。肺部的小肿块--结节--可能是良性或恶性肿瘤(也称为癌症)。从放射学的角度来看,结节与其他有各种原因的肿块非常相似:感染、炎症、血液供应问题、畸形血管以及除肿瘤外的其他疾病。

关键部分在于:我们试图检测的癌症将始终是结节,要么悬浮在肺部非密集组织中,要么附着在肺壁上。这意味着我们可以将我们的分类器限制在仅检查结节,而不是让它检查所有组织。能够限制预期输入范围将有助于我们的分类器学习手头的任务。

这是另一个例子,说明我们将使用的基础深度学习技术是通用的,但不能盲目应用。我们需要了解我们所从事的领域,以做出对我们有利的选择。

在图 9.8 中,我们可以看到一个恶性结节的典型例子。我们关注的最小结节直径仅几毫米,尽管图 9.8 中的结节较大。正如我们在本章前面讨论的那样,这使得最小结节大约比整个 CT 扫描小一百万倍。患者检测到的结节中超过一半不是恶性的。

图 9.8 一张显示恶性结节与其他结节视觉差异的 CT 扫描

9.4.3 我们的数据来源:LUNA 大挑战

我们刚刚查看的 CT 扫描来自 LUNA(LUng Nodule Analysis)大挑战。LUNA 大挑战是一个开放数据集与患者 CT 扫描(许多带有肺结节的)高质量标签的结合,以及对数据的分类器的公开排名。有一种公开分享医学数据集用于研究和分析的文化;对这些数据的开放访问使研究人员能够在不必在机构之间签订正式研究协议的情况下使用、结合和对这些数据进行新颖的工作(显然,某些数据也是保密的)。LUNA 大挑战的目标是通过让团队轻松竞争排名榜上的高位来鼓励结节检测的改进。项目团队可以根据标准化标准(提供的数据集)测试其检测方法的有效性。要包含在公开排名中,团队必须提供描述项目架构、训练方法等的科学论文。这为提供进一步的想法和启发项目改进提供了很好的资源。

注意 许多 CT 扫描“在野外”非常混乱,因为各种扫描仪和处理程序之间存在独特性。例如,一些扫描仪通过将那些超出扫描仪视野范围的 CT 扫描区域的密度设置为负值来指示这些体素。CT 扫描也可以使用各种设置在 CT 扫描仪上获取,这可能会以微妙或截然不同的方式改变结果图像。尽管 LUNA 数据通常很干净,但如果您整合其他数据源,请务必检查您的假设。

我们将使用 LUNA 2016 数据集。LUNA 网站(luna16.grand-challenge.org/Description)描述了挑战的两个轨道:第一轨道“结节检测(NDET)”大致对应于我们的第 1 步(分割);第二轨道“假阳性减少(FPRED)”类似于我们的第 3 步(分类)。当该网站讨论“可能结节的位置”时,它正在讨论一个类似于我们将在第十三章中介绍的过程。

9.4.4 下载 LUNA 数据

在我们进一步探讨项目的细节之前,我们将介绍如何获取我们将使用的数据。压缩后的数据约为 60 GB,因此根据您的互联网连接速度,可能需要一段时间才能下载。解压后,它占用约 120 GB 的空间;我们还需要另外约 100 GB 的缓存空间来存储较小的数据块,以便我们可以比读取整个 CT 更快地访问它。

导航至 luna16.grand-challenge.org/download 并注册使用电子邮件或使用 Google OAuth 登录。登录后,您应该看到两个指向 Zenodo 数据的下载链接,以及指向 Academic Torrents 的链接。无论哪个链接,数据应该是相同的。

提示 截至目前,luna.grand-challenge.org 域名没有链接到数据下载页面。如果您在查找下载页面时遇到问题,请仔细检查 luna16. 的域名,而不是 luna.,如果需要,请重新输入网址。

我们将使用的数据分为 10 个子集,分别命名为 subset0subset9。解压缩每个子集,以便您有单独的子目录,如 code/data-unversioned/ part2/luna/subset0,依此类推。在 Linux 上,您将需要 7z 解压缩实用程序(Ubuntu 通过 p7zip-full 软件包提供此功能)。Windows 用户可以从 7-Zip 网站(www.7-zip.org)获取提取器。某些解压缩实用程序可能无法打开存档;如果出现错误,请确保您使用的是提取器的完整版本。

另外,您需要 candidates.csv 和 annotations.csv 文件。为了方便起见,我们已经在书的网站和 GitHub 仓库中包含了这些文件,因此它们应该已经存在于 code/data/part2/luna/*.csv 中。也可以从与数据子集相同的位置下载它们。

注意 如果您没有轻松获得约 220 GB 的免费磁盘空间,可以仅使用 1 或 2 个数据子集来运行示例。较小的训练集将导致模型表现得更差,但这总比完全无法运行示例要好。

一旦您拥有候选文件和至少一个已下载、解压缩并放置在正确位置的子集,您应该能够开始运行本章的示例。如果您想提前开始,可以使用 code/p2ch09_explore_data .ipynb Jupyter Notebook 来开始。否则,我们将在本章后面更深入地讨论笔记本。希望您的下载能在您开始阅读下一章之前完成!

9.5 结论

我们已经取得了完成项目的重大进展!您可能会觉得我们没有取得多少成就;毕竟,我们还没有实现一行代码。但请记住,当您独自处理项目时,您需要像我们在这里做的研究和准备一样。

在本章中,我们着手完成了两件事:

  • 了解我们的肺癌检测项目周围的更大背景

  • 勾勒出我们第二部分项目的方向和结构

如果您仍然觉得我们没有取得实质性进展,请意识到这种心态是一个陷阱--理解项目所处的领域至关重要,我们所做的设计工作将在我们继续前进时大大获益。一旦我们在第十章开始实现数据加载例程,我们将很快看到这些回报。

由于本章仅提供信息,没有任何代码,我们将暂时跳过练习。

9.6 总结

  • 我们检测癌性结节的方法将包括五个大致步骤:数据加载、分割、分组、分类以及结节分析和诊断。

  • 将我们的项目分解为更小、半独立的子项目,使得教授每个子项目变得更容易。对于未来具有不同目标的项目,可能会采用其他方法,而不同于本书的目标。

  • CT 扫描是一个包含大约 3200 万体素的强度数据的 3D 数组,大约比我们想要识别的结节大一百万倍。将模型集中在与手头任务相关的 CT 扫描裁剪部分上,将使训练得到合理结果变得更容易。

  • 理解我们的数据将使编写处理数据的程序更容易,这些程序不会扭曲或破坏数据的重要方面。CT 扫描数据的数组通常不会具有立方体像素;将现实世界单位的位置信息映射到数组索引需要进行转换。CT 扫描的强度大致对应于质量密度,但使用独特的单位。

  • 识别项目的关键概念,并确保它们在我们的设计中得到很好的体现是至关重要的。我们项目的大部分方面将围绕着结节展开,这些结节是肺部的小肿块,在 CT 上可以被发现,与许多其他具有类似外观的结构一起。

  • 我们正在使用 LUNA Grand Challenge 数据来训练我们的模型。LUNA 数据包含 CT 扫描,以及用于分类和分组的人工注释输出。拥有高质量的数据对项目的成功有重大影响。


¹ 我们假设--我们还没有尝试过,更不用说计时了。

² 这个样本的series_uid1.3.6.1.4.1.14519.5.2.1.6279.6001.12626457893177825889037 1755354,如果您以后想要详细查看它,这可能会很有用。

³ 例如,Retina U-Net (arxiv.org/pdf/1811.08661.pdf) 和 FishNet (mng.bz/K240)。

⁴Eric J. Olson,“肺结节:它们可能是癌症吗?”梅奥诊所,mng.bz/yyge

⁵ 至少如果我们想要得到像样的结果的话,是不行的。

⁶ 根据国家癌症研究所癌症术语词典:mng.bz/jgBP

⁷ 所需的缓存空间是按章节计算的,但一旦完成了一个章节,你可以删除缓存以释放空间。

十、将数据源合并为统一数据集

本章涵盖

  • 加载和处理原始数据文件

  • 实现一个表示我们数据的 Python 类

  • 将我们的数据转换为 PyTorch 可用的格式

  • 可视化训练和验证数据

现在我们已经讨论了第二部分的高层目标,以及概述了数据将如何在我们的系统中流动,让我们具体了解一下这一章我们将要做什么。现在是时候为我们的原始数据实现基本的数据加载和数据处理例程了。基本上,你在工作中涉及的每个重要项目都需要类似于我们在这里介绍的内容。¹ 图 10.1 展示了我们项目的高层地图,来自第九章。我们将在本章的其余部分专注于第 1 步,数据加载。

图 10.1 我们端到端的肺癌检测项目,重点关注本章的主题:第 1 步,数据加载

我们的目标是能够根据我们的原始 CT 扫描数据和这些 CT 的注释列表生成一个训练样本。这听起来可能很简单,但在我们加载、处理和提取我们感兴趣的数据之前,需要发生很多事情。图 10.2 展示了我们需要做的工作,将我们的原始数据转换为训练样本。幸运的是,在上一章中,我们已经对我们的数据有了一些理解,但在这方面我们还有更多工作要做。

图 10.2 制作样本元组所需的数据转换。这些样本元组将作为我们模型训练例程的输入。

这是一个关键时刻,当我们开始将沉重的原始数据转变,如果不是成为黄金,至少也是我们的神经网络将会将其转变为黄金的材料。我们在第四章中首次讨论了这种转变的机制。

10.1 原始 CT 数据文件

我们的 CT 数据分为两个文件:一个包含元数据头信息的.mhd 文件,以及一个包含组成 3D 数组的原始字节的.raw 文件。每个文件的名称都以称为系列 UID(名称来自数字影像和通信医学[DICOM]命名法)的唯一标识符开头,用于讨论的 CT 扫描。例如,对于系列 UID 1.2.3,将有两个文件:1.2.3.mhd 和 1.2.3.raw。

我们的Ct类将消耗这两个文件并生成 3D 数组,以及转换矩阵,将患者坐标系(我们将在第 10.6 节中更详细地讨论)转换为数组所需的索引、行、列坐标(这些坐标在图中显示为(I,R,C),在代码中用_irc变量后缀表示)。现在不要为所有这些细节担心;只需记住,在我们应用这些坐标到我们的 CT 数据之前,我们需要进行一些坐标系转换。我们将根据需要探讨细节。

我们还将加载 LUNA 提供的注释数据,这将为我们提供一个结节坐标列表,每个坐标都有一个恶性标志,以及相关 CT 扫描的系列 UID。通过将结节坐标与坐标系转换信息结合起来,我们得到了我们结节中心的体素的索引、行和列。

使用(I,R,C)坐标,我们可以裁剪我们的 CT 数据的一个小的 3D 切片作为我们模型的输入。除了这个 3D 样本数组,我们必须构建我们的训练样本元组的其余部分,其中将包括样本数组、结节状态标志、系列 UID 以及该样本在结节候选 CT 列表中的索引。这个样本元组正是 PyTorch 从我们的Dataset子类中期望的,并代表了我们从原始原始数据到 PyTorch 张量的标准结构的桥梁的最后部分。

限制或裁剪我们的数据以避免让模型淹没在噪音中是重要的,同样重要的是确保我们不要过于激进,以至于我们的信号被裁剪掉。我们希望确保我们的数据范围行为良好,尤其是在归一化之后。裁剪数据以去除异常值可能很有用,特别是如果我们的数据容易出现极端异常值。我们还可以创建手工制作的、算法转换的输入;这被称为特征工程;我们在第一章中简要讨论过。通常我们会让模型大部分工作;特征工程有其用处,但在第 2 部分中我们不会使用它。

10.2 解析 LUNA 的注释数据

我们需要做的第一件事是开始加载我们的数据。在着手新项目时,这通常是一个很好的起点。确保我们知道如何处理原始输入是必需的,无论如何,知道我们的数据加载后会是什么样子可以帮助我们制定早期实验的结构。我们可以尝试加载单个 CT 扫描,但我们认为解析 LUNA 提供的包含每个 CT 扫描中感兴趣点信息的 CSV 文件是有意义的。正如我们在图 10.3 中看到的,我们期望获得一些坐标信息、一个指示坐标是否为结节的标志以及 CT 扫描的唯一标识符。由于 CSV 文件中的信息类型较少,而且更容易解析,我们希望它们能给我们一些线索,告诉我们一旦开始加载 CT 扫描后要寻找什么。

图 10.3 candidates.csv 中的 LUNA 注释包含 CT 系列、结节候选位置以及指示候选是否实际为结节的标志。

candidates.csv 文件包含有关所有潜在看起来像结节的肿块的信息,无论这些肿块是恶性的、良性肿瘤还是完全不同的东西。我们将以此为基础构建一个完整的候选人列表,然后将其分成训练和验证数据集。以下是 Bash shell 会话显示文件包含的内容:

$ wc -l candidates.csv                     # ❶
551066 candidates.csv

$ head data/part2/luna/candidates.csv      # ❷
seriesuid,coordX,coordY,coordZ,class       # ❸
1.3...6860,-56.08,-67.85,-311.92,0
1.3...6860,53.21,-244.41,-245.17,0
1.3...6860,103.66,-121.8,-286.62,0
1.3...6860,-33.66,-72.75,-308.41,0
...

$ grep ',1$' candidates.csv | wc -l        # ❹
1351

❶ 统计文件中的行数

❷ 打印文件的前几行

❸ .csv 文件的第一行定义了列标题。

❹ 统计以 1 结尾的行数,表示恶性

注意 seriesuid 列中的值已被省略以更好地适应打印页面。

因此,我们有 551,000 行,每行都有一个seriesuid(我们在代码中将其称为series_uid)、一些(X,Y,Z)坐标和一个class列,对应于结节状态(这是一个布尔值:0 表示不是实际结节的候选人,1 表示是结节的候选人,无论是恶性还是良性)。我们有 1,351 个标记为实际结节的候选人。

annotations.csv 文件包含有关被标记为结节的一些候选人的信息。我们特别关注diameter_mm信息:

$ wc -l annotations.csv
1187 annotations.csv                           # ❶

$ head data/part2/luna/annotations.csv
seriesuid,coordX,coordY,coordZ,diameter_mm     # ❷
1.3.6...6860,-128.6994211,-175.3192718,-298.3875064,5.651470635
1.3.6...6860,103.7836509,-211.9251487,-227.12125,4.224708481
1.3.6...5208,69.63901724,-140.9445859,876.3744957,5.786347814
1.3.6...0405,-24.0138242,192.1024053,-391.0812764,8.143261683
...

❶ 这是与 candidates.csv 文件中不同的数字。

❷ 最后一列也不同。

我们有大约 1,200 个结节的大小信息。这很有用,因为我们可以使用它来确保我们的训练和验证数据包含了结节大小的代表性分布。如果没有这个,我们的验证集可能只包含极端值,使得看起来我们的模型表现不佳。

10.2.1 训练和验证集

对于任何标准的监督学习任务(分类是典型示例),我们将把数据分成训练集和验证集。我们希望确保两个集合都代表我们预期看到和正常处理的真实世界输入数据范围。如果任一集合与我们的真实用例有实质性不同,那么我们的模型行为很可能与我们的预期不同--我们收集的所有训练和统计数据在转移到生产使用时将不具有预测性!我们并不试图使这成为一门精确的科学,但您应该在未来的项目中留意,以确保您正在对不适合您操作环境的数据进行训练和测试。

让我们回到我们的结节。我们将按大小对它们进行排序,并取每第N个用于我们的验证集。这应该给我们所期望的代表性分布。不幸的是,annotations.csv 中提供的位置信息并不总是与 candidates.csv 中的坐标精确对齐:

$ grep 100225287222365663678666836860 annotations.csv
1.3.6...6860,-128.6994211,-175.3192718,-298.3875064,5.651470635   # ❶
1.3.6...6860,103.7836509,-211.9251487,-227.12125,4.224708481

$ grep '100225287222365663678666836860.*,1$' candidates.csv
1.3.6...6860,104.16480444,-211.685591018,-227.011363746,1
1.3.6...6860,-128.94,-175.04,-297.87,1                            # ❶

❶ 这两个坐标非常接近。

如果我们从每个文件中截取相应的坐标,我们得到的是(-128.70, -175.32,-298.39)与(-128.94,-175.04,-297.87)。由于问题中的结节直径为 5 毫米,这两个点显然都是结节的“中心”,但它们并不完全对齐。决定处理这种数据不匹配是否值得并忽略该文件是完全合理的反应。然而,我们将努力使事情对齐,因为现实世界的数据集通常以这种方式不完美,并且这是您需要做的工作的一个很好的例子,以从不同的数据源中组装数据。

10.2.2 统一我们的注释和候选数据

现在我们知道我们的原始数据文件是什么样子的,让我们构建一个getCandidateInfoList函数,将所有内容串联起来。我们将使用文件顶部定义的命名元组来保存每个结节的信息。

列表 10.1 dsets.py:7

from collections import namedtuple
# ... line 27
CandidateInfoTuple = namedtuple(
  'CandidateInfoTuple',
  'isNodule_bool, diameter_mm, series_uid, center_xyz',
)

这些元组不是我们的训练样本,因为它们缺少我们需要的 CT 数据块。相反,这些代表了我们正在使用的人工注释数据的经过消毒、清洁、统一的接口。将必须处理混乱数据与模型训练隔离开非常重要。否则,你的训练循环会很快变得混乱,因为你必须在本应专注于训练的代码中不断处理特殊情况和其他干扰。

提示 明确地将负责数据消毒的代码与项目的其余部分分开。如果需要,不要害怕重写数据一次并将其保存到磁盘。

我们的候选信息列表将包括结节状态(我们将训练模型对其进行分类)、直径(有助于在训练中获得良好的分布,因为大和小结节不会具有相同的特征)、系列(用于定位正确的 CT 扫描)、候选中心(用于在较大的 CT 中找到候选)。构建这些NoduleInfoTuple实例列表的函数首先使用内存缓存装饰器,然后获取磁盘上存在的文件列表。

列表 10.2 dsets.py:32

@functools.lru_cache(1)                                              # ❶
def getCandidateInfoList(requireOnDisk_bool=True):                   # ❷
  mhd_list = glob.glob('data-unversioned/part2/luna/subset*/*.mhd')
  presentOnDisk_set = {os.path.split(p)[-1][:-4] for p in mhd_list}

❶ 标准库内存缓存

❷ requireOnDisk_bool 默认筛选掉尚未就位的数据子集中的系列。

由于解析某些数据文件可能很慢,我们将在内存中缓存此函数调用的结果。这将在以后很有用,因为我们将在未来的章节中更频繁地调用此函数。通过仔细应用内存或磁盘缓存来加速我们的数据流水线,可以在训练速度上取得一些令人印象深刻的收益。在您的项目中工作时,请留意这些机会。

之前我们说过,我们将支持使用不完整的训练数据集运行我们的训练程序,因为下载时间长且磁盘空间要求高。requireOnDisk_bool 参数是实现这一承诺的关键;我们正在检测哪些 LUNA 系列 UID 实际上存在并准备从磁盘加载,并且我们将使用该信息来限制我们从即将解析的 CSV 文件中使用的条目。能够通过训练循环运行我们数据的子集对于验证代码是否按预期工作很有用。通常情况下,当这样做时,模型的训练结果很差,几乎无用,但是进行日志记录、指标、模型检查点等功能的练习是有益的。

在获取候选人信息后,我们希望合并注释.csv 中的直径信息。首先,我们需要按 series_uid 对我们的注释进行分组,因为这是我们将用来交叉参考两个文件中每一行的第一个关键字。

代码清单 10.3 dsets.py:40,def getCandidateInfoList

diameter_dict = {}
with open('data/part2/luna/annotations.csv', "r") as f:
  for row in list(csv.reader(f))[1:]:
    series_uid = row[0]
    annotationCenter_xyz = tuple([float(x) for x in row[1:4]])
    annotationDiameter_mm = float(row[4])

    diameter_dict.setdefault(series_uid, []).append(
      (annotationCenter_xyz, annotationDiameter_mm)
    )

现在我们将使用 candidates.csv 文件中的信息构建候选人的完整列表。

代码清单 10.4 dsets.py:51,def getCandidateInfoList

candidateInfo_list = []
with open('data/part2/luna/candidates.csv', "r") as f:
  for row in list(csv.reader(f))[1:]:
    series_uid = row[0]

    if series_uid not in presentOnDisk_set and requireOnDisk_bool:        # ❶
      continue

    isNodule_bool = bool(int(row[4]))
    candidateCenter_xyz = tuple([float(x) for x in row[1:4]])

    candidateDiameter_mm = 0.0
    for annotation_tup in diameter_dict.get(series_uid, []):
      annotationCenter_xyz, annotationDiameter_mm = annotation_tup
      for i in range(3):
        delta_mm = abs(candidateCenter_xyz[i] - annotationCenter_xyz[i])
        if delta_mm > annotationDiameter_mm / 4:                          # ❷
          break
      else:
        candidateDiameter_mm = annotationDiameter_mm
        break

    candidateInfo_list.append(CandidateInfoTuple(
      isNodule_bool,
      candidateDiameter_mm,
      series_uid,
      candidateCenter_xyz,
    ))

❶ 如果系列 UID 不存在,则它在我们没有在磁盘上的子集中,因此我们应该跳过它。

❷ 将直径除以 2 得到半径,并将半径除以 2 要求两个结节中心点相对于结节大小不要相距太远。(这导致一个边界框检查,而不是真正的距离检查。)

对于给定 series_uid 的每个候选人条目,我们循环遍历我们之前收集的相同 series_uid 的注释,看看这两个坐标是否足够接近以将它们视为同一个结节。如果是,太好了!现在我们有了该结节的直径信息。如果我们找不到匹配项,那没关系;我们将只将该结节视为直径为 0.0。由于我们只是使用这些信息来在我们的训练和验证集中获得结节尺寸的良好分布,对于一些结节的直径尺寸不正确不应该是问题,但我们应该记住我们这样做是为了防止我们这里的假设是错误的情况。

这是为了合并我们的结节直径而进行的许多有些繁琐的代码。不幸的是,根据您的原始数据,必须进行这种操作和模糊匹配可能是相当常见的。然而,一旦我们到达这一点,我们只需要对数据进行排序并返回即可。

代码清单 10.5 dsets.py:80,def getCandidateInfoList

candidateInfo_list.sort(reverse=True)     # ❶
return candidateInfo_list

❶ 这意味着我们所有实际结节样本都是从最大的开始,然后是所有非结节样本(这些样本没有结节大小信息)。

元组成员在 noduleInfo_list 中的排序是由此排序驱动的。我们使用这种排序方法来帮助确保当我们取数据的一个切片时,该切片获得一组具有良好结节直径分布的实际结节。我们将在第 10.5.3 节中进一步讨论这一点。

10.3 加载单个 CT 扫描

接下来,我们需要能够将我们的 CT 数据从磁盘上的一堆位转换为一个 Python 对象,从中我们可以提取 3D 结节密度数据。我们可以从图 10.4 中看到这条路径,从 .mhd 和 .raw 文件到 Ct 对象。我们的结节注释信息就像是我们原始数据中有趣部分的地图。在我们可以按照这张地图找到我们感兴趣的数据之前,我们需要将数据转换为可寻址的形式。

图 10.4 加载 CT 扫描产生一个体素数组和一个从患者坐标到数组索引的转换。

提示 拥有大量原始数据,其中大部分是无趣的,是一种常见情况;在处理自己的项目时,寻找方法限制范围仅限于相关数据是很重要的。

CT 扫描的本机文件格式是 DICOM(www.dicomstandard.org)。DICOM 标准的第一个版本是在 1984 年编写的,正如我们可能期望的那样,来自那个时期的任何与计算有关的东西都有点混乱(例如,现在已经废弃的整个部分专门用于选择要使用的数据链路层协议,因为当时以太网还没有胜出)。

注意 我们已经找到了正确的库来解析这些原始数据文件,但对于你从未听说过的其他格式,你将不得不自己找到一个解析器。我们建议花时间去做这件事!Python 生态系统几乎为太阳下的每种文件格式都提供了解析器,你的时间几乎肯定比写解析器来处理奇特数据格式的工作更值得花费在项目的新颖部分上。

令人高兴的是,LUNA 已经将我们将在本章中使用的数据转换为 MetaIO 格式,这样使用起来要容易得多(itk.org/Wiki/MetaIO/Documentation#Quick_Start)。如果你以前从未听说过这种格式,不用担心!我们可以将数据文件的格式视为黑匣子,并使用SimpleITK将其加载到更熟悉的 NumPy 数组中。

代码清单 10.6 dsets.py:9

import SimpleITK as sitk
# ... line 83
class Ct:
  def __init__(self, series_uid):
    mhd_path = glob.glob(
     'data-unversioned/part2/luna/subset*/{}.mhd'.format(series_uid)   # ❶
     )[0]

    ct_mhd = sitk.ReadImage(mhd_path)                                  # ❷
    ct_a = np.array(sitk.GetArrayFromImage(ct_mhd), dtype=np.float32)  # ❸

❶ 我们不关心给定 series_uid 属于哪个子集,因此我们使用通配符来匹配子集。

sitk.ReadImage隐式消耗了传入的.mhd文件以及.raw文件。

❸ 重新创建一个 np.array,因为我们想将值类型转换为 np.float3。

对于真实项目,你会想要了解原始数据中包含哪些类型的信息,但依赖像SimpleITK这样的第三方代码来解析磁盘上的位是完全可以的。找到了关于你的输入的一切与盲目接受你的数据加载库提供的一切之间的正确平衡可能需要一些经验。只需记住,我们主要关心的是数据,而不是。重要的是信息,而不是它的表示方式。

能够唯一标识我们数据中的特定样本是很有用的。例如,清楚地传达哪个样本导致问题或得到较差的分类结果可以极大地提高我们隔离和调试问题的能力。根据我们样本的性质,有时这个唯一标识符是一个原子,比如一个数字或一个字符串,有时它更复杂,比如一个元组。

我们使用系列实例 UIDseries_uid)来唯一标识特定的 CT 扫描,该 UID 是在创建 CT 扫描时分配的。DICOM 在个别 DICOM 文件、文件组、治疗过程等方面大量使用唯一标识符(UID),这些标识符在概念上类似于 UUIDs(docs.python.org/3.6/library/uuid.html),但它们具有不同的创建过程和不同的格式。对于我们的目的,我们可以将它们视为不透明的 ASCII 字符串,用作引用各种 CT 扫描的唯一键。官方上,DICOM UID 中只有字符 0 到 9 和句点(.)是有效字符,但一些野外的 DICOM 文件已经通过替换 UID 为十六进制(0-9 和 a-f)或其他技术上不符合规范的值进行了匿名化(这些不符合规范的值通常不会被 DICOM 解析器标记或清理;正如我们之前所说,这有点混乱)。

我们之前讨论的 10 个子集中,每个子集大约有 90 个 CT 扫描(总共 888 个),每个 CT 扫描表示为两个文件:一个带有.mhd扩展名的文件和一个带有.raw扩展名的文件。数据被分割到多个文件中是由sitk例程隐藏的,因此我们不需要直接关注这一点。

此时,ct_a 是一个三维数组。所有三个维度都是空间维度,单一的强度通道是隐含的。正如我们在第四章中看到的,在 PyTorch 张量中,通道信息被表示为一个大小为 1 的第四维。

10.3.1 豪斯菲尔德单位

回想一下,我们之前说过我们需要了解我们的数据,而不是存储数据的。在这里,我们有一个完美的实例。如果不了解数据值和范围的微妙之处,我们将向模型输入值,这将妨碍其学习我们想要的内容。

继续__init__方法,我们需要对ct_a值进行一些清理。CT 扫描体素以豪斯菲尔德单位(HU;en.wikipedia.org/ wiki/Hounsfield_scale)表示,这是奇怪的单位;空气为-1,000 HU(对于我们的目的足够接近 0 克/立方厘米),水为 0 HU(1 克/立方厘米),骨骼至少为+1,000 HU(2-3 克/立方厘米)。

注意 HU 值通常以有符号的 12 位整数(塞入 16 位整数)的形式存储在磁盘上,这与 CT 扫描仪提供的精度水平相匹配。虽然这可能很有趣,但与项目无关。

一些 CT 扫描仪使用与负密度对应的 HU 值来指示那些体素位于 CT 扫描仪视野之外。对于我们的目的,患者之外的一切都应该是空气,因此我们通过将值的下限设置为-1,000 HU 来丢弃该视野信息。同样,骨骼、金属植入物等的确切密度与我们的用例无关,因此我们将密度限制在大约 2 克/立方厘米(1,000 HU),即使在大多数情况下这在生物学上并不准确。

列表 10.7 dsets.py:96,Ct.__init__

ct_a.clip(-1000, 1000, ct_a)

高于 0 HU 的值与密度并不完全匹配,但我们感兴趣的肿瘤通常在 1 克/立方厘米(0 HU)左右,因此我们将忽略 HU 与克/立方厘米等常见单位并不完全对应的事实。这没关系,因为我们的模型将被训练直接使用 HU。

我们希望从我们的数据中删除所有这些异常值:它们与我们的目标没有直接关联,而且这些异常值可能会使模型的工作变得更加困难。这种情况可能以多种方式发生,但一个常见的例子是当批量归一化被这些异常值输入时,关于如何最佳归一化数据的统计数据会被扭曲。始终注意清理数据的方法。

我们现在已经将所有构建的值分配给self

列表 10.8 dsets.py:98,Ct.__init__

self.series_uid = series_uid
self.hu_a = ct_a

重要的是要知道我们的数据使用-1,000 到+1,000 的范围,因为在第十三章中,我们最终会向我们的样本添加信息通道。如果我们不考虑 HU 和我们额外数据之间的差异,那么这些新通道很容易被原始 HU 值所掩盖。对于我们项目的分类步骤,我们不会添加更多的数据通道,因此我们现在不需要实施特殊处理。

10.4 使用患者坐标系定位结节

深度学习模型通常需要固定大小的输入,²因为有固定数量的输入神经元。我们需要能够生成一个包含候选者的固定大小数组,以便我们可以将其用作分类器的输入。我们希望训练我们的模型时使用一个裁剪的 CT 扫描,其中候选者被很好地居中,因为这样我们的模型就不必学习如何注意藏在输入角落的结节。通过减少预期输入的变化,我们使模型的工作变得更容易。

10.4.1 患者坐标系

不幸的是,我们在第 10.2 节加载的所有候选中心数据都是以毫米为单位表示的,而不是体素!我们不能简单地将毫米位置插入数组索引中,然后期望一切按我们想要的方式进行。正如我们在图 10.5 中所看到的,我们需要将我们的坐标从以毫米表示的坐标系(X,Y,Z)转换为用于从 CT 扫描数据中获取数组切片的基于体素地址的坐标系(I,R,C)。这是一个重要的例子,说明了一致处理单位的重要性!

图 10.5 使用转换信息将病人坐标中的结节中心坐标(X,Y,Z)转换为数组索引(索引,行,列)。

正如我们之前提到的,处理 CT 扫描时,我们将数组维度称为索引、行和列,因为 X、Y 和 Z 有不同的含义,如图 10.6 所示。病人坐标系定义正 X 为病人左侧(),正 Y 为病人后方(后方),正 Z 为朝向病人头部(上部)。左后上有时会缩写为LPS

图 10.6 我们穿着不当的病人展示了病人坐标系的轴线

病人坐标系以毫米为单位测量,并且具有任意位置的原点,不与 CT 体素数组的原点对应,如图 10.7 所示。

图 10.7 数组坐标和病人坐标具有不同的原点和比例。

病人坐标系通常用于指定有趣解剖的位置,这种方式与任何特定扫描无关。定义 CT 数组与病人坐标系之间关系的元数据存储在 DICOM 文件的头部中,而该元图像格式也保留了头部中的数据。这些元数据允许我们构建从(X,Y,Z)到(I,R,C)的转换,如图 10.5 所示。原始数据包含许多其他类似的元数据字段,但由于我们现在不需要使用它们,这些不需要的字段将被忽略。

10.4.2 CT 扫描形状和体素大小

CT 扫描之间最常见的变化之一是体素的大小;通常它们不是立方体。相反,它们可以是 1.125 毫米×1.125 毫米×2.5 毫米或类似的。通常行和列维度的体素大小相同,而索引维度具有较大的值,但也可以存在其他比例。

当使用方形像素绘制时,非立方体体素可能看起来有些扭曲,类似于使用墨卡托投影地图时在北极和南极附近的扭曲。这是一个不完美的类比,因为在这种情况下,扭曲是均匀和线性的--在图 10.8 中,病人看起来比实际上更矮胖或胸部更宽。如果我们希望图像反映真实比例,我们将需要应用一个缩放因子。

图 10.8 沿索引轴具有非立方体体素的 CT 扫描。请注意从上到下肺部的压缩程度。

知道这些细节在试图通过视觉解释我们的结果时会有所帮助。没有这些信息,很容易会认为我们的数据加载出了问题:我们可能会认为数据看起来很矮胖是因为我们不小心跳过了一半的切片,或者类似的情况。很容易会浪费很多时间来调试一直正常运行的东西,熟悉你的数据可以帮助避免这种情况。

CT 通常是 512 行×512 列,索引维度从大约 100 个切片到可能达到 250 个切片(250 个切片乘以 2.5 毫米通常足以包含感兴趣的解剖区域)。这导致下限约为 225 个体素,或约 3200 万数据点。每个 CT 都会在文件元数据中指定体素大小;例如,在列表 10.10 中我们会调用ct_mhd .GetSpacing()

10.4.3 毫米和体素地址之间的转换

我们将定义一些实用代码来帮助在病人坐标中的毫米和(I,R,C)数组坐标之间进行转换(我们将在代码中用变量和类似的后缀_xyz表示病人坐标中的变量,用_irc后缀表示(I,R,C)数组坐标)。

您可能想知道 SimpleITK 库是否带有实用函数来进行转换。确实,Image 实例具有两种方法--TransformIndexToPhysicalPointTransformPhysicalPointToIndex--可以做到这一点(除了从 CRI [列,行,索引] IRC 进行洗牌)。但是,我们希望能够在不保留 Image 对象的情况下进行此计算,因此我们将在这里手动执行数学运算。

轴翻转(以及可能的旋转或其他变换)被编码在从ct_mhd.GetDirections()返回的 3 × 3 矩阵中,以元组形式返回。为了从体素索引转换为坐标,我们需要按顺序执行以下四个步骤:

  1. 将坐标从 IRC 翻转到 CRI,以与 XYZ 对齐。

  2. 用体素大小来缩放指数。

  3. 使用 Python 中的 @ 矩阵乘以方向矩阵。

  4. 添加原点的偏移量。

要从 XYZ 转换为 IRC,我们需要按相反顺序执行每个步骤的逆操作。

我们将体素大小保留在命名元组中,因此我们将其转换为数组。

列表 10.9 util.py:16

IrcTuple = collections.namedtuple('IrcTuple', ['index', 'row', 'col'])
XyzTuple = collections.namedtuple('XyzTuple', ['x', 'y', 'z'])

def irc2xyz(coord_irc, origin_xyz, vxSize_xyz, direction_a):
  cri_a = np.array(coord_irc)[::-1]                                        # ❶
  origin_a = np.array(origin_xyz)
  vxSize_a = np.array(vxSize_xyz)
  coords_xyz = (direction_a @ (cri_a * vxSize_a)) + origin_a               # ❷
  return XyzTuple(*coords_xyz)

def xyz2irc(coord_xyz, origin_xyz, vxSize_xyz, direction_a):
  origin_a = np.array(origin_xyz)
  vxSize_a = np.array(vxSize_xyz)
  coord_a = np.array(coord_xyz)
  cri_a = ((coord_a - origin_a) @ np.linalg.inv(direction_a)) / vxSize_a   # ❸
  cri_a = np.round(cri_a)                                                  # ❹
  return IrcTuple(int(cri_a[2]), int(cri_a[1]), int(cri_a[0]))             # ❺

❶ 在转换为 NumPy 数组时交换顺序

❷ 我们计划的最后三个步骤,一行搞定

❸ 最后三个步骤的逆操作

❹ 在转换为整数之前进行适当的四舍五入

❺ 洗牌并转换为整数

哦。如果这有点沉重,不要担心。只需记住我们需要将函数转换并使用为黑匣子。我们需要从患者坐标(_xyz)转换为数组坐标(_irc)的元数据包含在 MetaIO 文件中,与 CT 数据本身一起。我们从 .mhd 文件中提取体素大小和定位元数据的同时获取 ct_a

列表 10.10 dsets.py:72, class Ct

class Ct:
  def __init__(self, series_uid):
    mhd_path = glob.glob('data-unversioned/part2/luna/subset*/{}.mhd'.format(series_uid))[0]

        ct_mhd = sitk.ReadImage(mhd_path)
        # ... line 91
        self.origin_xyz = XyzTuple(*ct_mhd.GetOrigin())
        self.vxSize_xyz = XyzTuple(*ct_mhd.GetSpacing())
        self.direction_a = np.array(ct_mhd.GetDirection()).reshape(3, 3)   # ❶

❶ 将方向转换为数组,并将九元素数组重塑为其正确的 3 × 3 矩阵形状

这些是我们需要传递给我们的 xyz2irc 转换函数的输入,除了要转换的单个点。有了这些属性,我们的 CT 对象实现现在具有将候选中心从患者坐标转换为数组坐标所需的所有数据。

10.4.4 从 CT 扫描中提取结节

正如我们在第九章中提到的,对于肺结节患者的 CT 扫描,高达 99.9999% 的体素不会是实际结节的一部分(或者癌症)。再次强调,这个比例相当于高清电视上某处不正确着色的两个像素斑点,或者一本小说书架上一个拼写错误的单词。强迫我们的模型检查如此庞大的数据范围,寻找我们希望其关注的结节的线索,将会像要求您从一堆用您不懂的语言写成的小说中找到一个拼写错误的单词一样有效!³

相反,正如我们在图 10.9 中所看到的,我们将提取每个候选者周围的区域,并让模型一次关注一个候选者。这类似于让您阅读外语中的单个段落:仍然不是一项容易的任务,但要少得多!寻找方法来减少我们模型的问题范围可以帮助,特别是在项目的早期阶段,当我们试图让我们的第一个工作实现运行起来时。

图 10.9 通过使用候选者中心的数组坐标信息(索引,行,列)从较大的 CT 体素数组中裁剪候选样本

getRawNodule函数接受以患者坐标系(X,Y,Z)表示的中心(正如在 LUNA CSV 数据中指定的那样),以及以体素为单位的宽度。它返回一个 CT 的立方块,以及将候选者中心转换为数组坐标的中心。

列表 10.11 dsets.py:105, Ct.getRawCandidate

def getRawCandidate(self, center_xyz, width_irc):
  center_irc = xyz2irc(
    center_xyz,
    self.origin_xyz,
    self.vxSize_xyz,
    self.direction_a,
  )

  slice_list = []
  for axis, center_val in enumerate(center_irc):
    start_ndx = int(round(center_val - width_irc[axis]/2))
    end_ndx = int(start_ndx + width_irc[axis])
    slice_list.append(slice(start_ndx, end_ndx))

  ct_chunk = self.hu_a[tuple(slice_list)]
  return ct_chunk, center_irc

实际实现将需要处理中心和宽度的组合将裁剪区域的边缘放在数组外部的情况。但正如前面所述,我们将跳过使函数的更大意图变得模糊的复杂情况。完整的实现可以在书的网站上找到(www.manning.com/books/deep-learning-with-pytorch?query=pytorch)以及 GitHub 仓库中(github.com/deep-learning-with-pytorch/dlwpt-code)。

10.5 一个直接的数据集实现

我们在第七章首次看到了 PyTorch 的Dataset实例,但这将是我们第一次自己实现一个。通过子类化Dataset,我们将把我们的任意数据插入到 PyTorch 生态系统的其余部分中。每个Ct实例代表了数百个不同的样本,我们可以用它们来训练我们的模型或验证其有效性。我们的LunaDataset类将规范化这些样本,将每个 CT 的结节压缩成一个单一集合,可以从中检索样本,而不必考虑样本来自哪个Ct实例。这种压缩通常是我们处理数据的方式,尽管正如我们将在第十二章中看到的,有些情况下简单的数据压缩不足以很好地训练模型。

在实现方面,我们将从子类化Dataset所施加的要求开始,并向后工作。这与我们之前使用的数据集不同;在那里,我们使用的是外部库提供的类,而在这里,我们需要自己实现和实例化类。一旦我们这样做了,我们就可以像之前的例子那样使用它。幸运的是,我们自定义子类的实现不会太困难,因为 PyTorch API 只要求我们想要实现的任何Dataset子类必须提供这两个函数:

一个__len__的实现,在初始化后必须返回一个单一的常量值(在某些情况下该值会被缓存)

__getitem__方法接受一个索引并返回一个元组,其中包含用于训练(或验证,视情况而定)的样本数据

首先,让我们看看这些函数的函数签名和返回值是什么样的。

列表 10.12 dsets.py:176, LunaDataset.__len__

def __len__(self):
  return len(self.candidateInfo_list)

def __getitem__(self, ndx):
  # ... line 200
  return (
    candidate_t,  1((CO10-1))
    pos_t,  1((CO10-2))
    candidateInfo_tup.series_uid,   # ❶
    torch.tensor(center_irc),       # ❶
  )

这是我们的训练样本。

我们的__len__实现很简单:我们有一个候选列表,每个候选是一个样本,我们的数据集大小与我们拥有的样本数量一样大。我们不必使实现像这里这样简单;在后面的章节中,我们会看到这种变化!⁴唯一的规则是,如果__len__返回值为N,那么__getitem__需要对所有输入 0 到 N - 1 返回有效值。

对于__getitem__,我们取ndx(通常是一个整数,根据支持输入 0 到 N - 1 的规则)并返回如图 10.2 所示的四项样本元组。构建这个元组比获取数据集长度要复杂一些,因此让我们来看看。

这个方法的第一部分意味着我们需要构建self.candidateInfo _list以及提供getCtRawNodule函数。

列表 10.13 dsets.py:179, LunaDataset.__getitem__

def __getitem__(self, ndx):
  candidateInfo_tup = self.candidateInfo_list[ndx]
  width_irc = (32, 48, 48)

  candidate_a, center_irc = getCtRawCandidate(     # ❶
    candidateInfo_tup.series_uid,
    candidateInfo_tup.center_xyz,
    width_irc,
  )

返回值 candidate_a 的形状为 (32,48,48);轴是深度、高度和宽度。

我们将在 10.5.1 和 10.5.2 节中马上看到这些。

__getitem__方法中,我们需要将数据转换为下游代码所期望的正确数据类型和所需的数组维度。

列表 10.14 dsets.py:189, LunaDataset.__getitem__

candidate_t = torch.from_numpy(candidate_a)
candidate_t = candidate_t.to(torch.float32)
candidate_t = candidate_t.unsqueeze(0)       # ❶

.unsqueeze(0) 添加了‘Channel’维度。

目前不要太担心我们为什么要操纵维度;下一章将包含最终使用此输出并施加我们在此主动满足的约束的代码。这是你应该期望为每个自定义Dataset实现的内容。这些转换是将您的“荒野数据”转换为整洁有序张量的关键部分。

最后,我们需要构建我们的分类张量。

列表 10.15 dsets.py:193,LunaDataset.__getitem__

pos_t = torch.tensor([
    not candidateInfo_tup.isNodule_bool,
    candidateInfo_tup.isNodule_bool
  ],
  dtype=torch.long,
)

这有两个元素,分别用于我们可能的候选类别(结节或非结节;或正面或负面)。我们可以为结节状态设置单个输出,但nn.CrossEntropyLoss期望每个类别有一个输出值,这就是我们在这里提供的内容。您构建的张量的确切细节将根据您正在处理的项目类型而变化。

让我们看看我们最终的样本元组(较大的nodule_t输出并不特别可读,所以我们在列表中省略了大部分内容)。

列表 10.16 p2ch10_explore_data.ipynb

# In[10]:
LunaDataset()[0]

# Out[10]:
(tensor([[[[-899., -903., -825.,  ..., -901., -898., -893.],       # ❶
           ...,                                                    # ❶
           [ -92.,  -63.,    4.,  ...,   63.,   70.,   52.]]]]),   # ❶
  tensor([0, 1]),                                                  # ❷
  '1.3.6...287966244644280690737019247886',                        # ❸
  tensor([ 91, 360, 341]))                                         # ❹

❶ candidate_t

❷ cls_t

❸ candidate_tup.series_uid(省略)

❹ center_irc

这里我们看到了我们__getitem__返回语句的四个项目。

10.5.1 使用getCtRawCandidate函数缓存候选数组

为了使LunaDataset获得良好的性能,我们需要投资一些磁盘缓存。这将使我们避免为每个样本从磁盘中读取整个 CT 扫描。这样做将速度非常慢!确保您注意项目中的瓶颈,并在开始减慢速度时尽力优化它们。我们有点过早地进行了这一步,因为我们还没有证明我们在这里需要缓存。没有缓存,LunaDataset的速度会慢 50 倍!我们将在本章的练习中重新讨论这个问题。

函数本身很简单。它是我们之前看到的Ct.getRawCandidate方法的文件缓存包装器(pypi.python.org/pypi/ diskcache)。

列表 10.17 dsets.py:139

@functools.lru_cache(1, typed=True)
def getCt(series_uid):
  return Ct(series_uid)

@raw_cache.memoize(typed=True)
def getCtRawCandidate(series_uid, center_xyz, width_irc):
  ct = getCt(series_uid)
  ct_chunk, center_irc = ct.getRawCandidate(center_xyz, width_irc)
  return ct_chunk, center_irc

我们在这里使用了几种不同的缓存方法。首先,我们将getCt返回值缓存在内存中,这样我们就可以重复请求相同的Ct实例而不必重新从磁盘加载所有数据。在重复请求的情况下,这将极大地提高速度,但我们只保留一个 CT 在内存中,所以如果我们不注意访问顺序,缓存未命中会频繁发生。

调用getCtgetCtRawCandidate函数具有其输出被缓存,因此在我们的缓存被填充后,getCt将不会被调用。这些值使用 Python 库diskcache缓存在磁盘上。我们将在第十一章讨论为什么有这种特定的缓存设置。目前,知道从磁盘中读取 215 个float32值要比读取 225 个int16值,转换为float32,然后选择 215 个子集要快得多。从第二次通过数据开始,输入的 I/O 时间应该降至可以忽略的程度。

注意 如果这些函数的定义发生实质性变化,我们将需要从磁盘中删除缓存的数值。如果不这样做,即使现在函数不再将给定的输入映射到旧的输出,缓存仍将继续返回它们。数据存储在 data-unversioned/cache 目录中。

10.5.2 在 LunaDataset.init 中构建我们的数据集

几乎每个项目都需要将样本分为训练集和验证集。我们将通过指定的val_stride参数将每个第十个样本指定为验证集的成员来实现这一点。我们还将接受一个isValSet_bool参数,并使用它来确定我们应该保留仅训练数据、验证数据还是所有数据。

列表 10.18 dsets.py:149,class LunaDataset

class LunaDataset(Dataset):
  def __init__(self,
         val_stride=0,
         isValSet_bool=None,
         series_uid=None,
      ):
    self.candidateInfo_list = copy.copy(getCandidateInfoList())    # ❶

    if series_uid:
      self.candidateInfo_list = [
        x for x in self.candidateInfo_list if x.series_uid == series_uid
      ]

❶ 复制返回值,以便通过更改 self.candidateInfo_list 不会影响缓存副本

如果我们传入一个真值series_uid,那么实例将只包含该系列的结节。这对于可视化或调试非常有用,因为这样可以更容易地查看单个有问题的 CT 扫描。

10.5.3 训练/验证分割

我们允许Dataset将数据的 1/N部分分割成一个用于验证模型的子集。我们将如何处理该子集取决于isValSet _bool参数的值。

列表 10.19 dsets.py:162, LunaDataset.__init__

if isValSet_bool:
  assert val_stride > 0, val_stride
  self.candidateInfo_list = self.candidateInfo_list[::val_stride]
  assert self.candidateInfo_list
elif val_stride > 0:
  del self.candidateInfo_list[::val_stride]      # ❶
  assert self.candidateInfo_list

❶ 从self.candidateInfo_list中删除验证图像(列表中每个val_stride个项目)。我们之前复制了一份,以便不改变原始列表。

这意味着我们可以创建两个Dataset实例,并确信我们的训练数据和验证数据之间有严格的分离。当然,这取决于self.candidateInfo_list具有一致的排序顺序,我们通过确保候选信息元组有一个稳定的排序顺序,并且getCandidateInfoList函数在返回列表之前对列表进行排序来实现这一点。

关于训练和验证数据的另一个注意事项是,根据手头的任务,我们可能需要确保来自单个患者的数据只出现在训练或测试中,而不是同时出现在两者中。在这里这不是问题;否则,我们需要在到达结节级别之前拆分患者和 CT 扫描列表。

让我们使用p2ch10_explore_data.ipynb来查看数据:

# In[2]:
from p2ch10.dsets import getCandidateInfoList, getCt, LunaDataset
candidateInfo_list = getCandidateInfoList(requireOnDisk_bool=False)
positiveInfo_list = [x for x in candidateInfo_list if x[0]]
diameter_list = [x[1] for x in positiveInfo_list]

# In[4]:
for i in range(0, len(diameter_list), 100):
    print('{:4}  {:4.1f} mm'.format(i, diameter_list[i]))

# Out[4]:
   0  32.3 mm
 100  17.7 mm
 200  13.0 mm
 300  10.0 mm
 400   8.2 mm
 500   7.0 mm
 600   6.3 mm
 700   5.7 mm
 800   5.1 mm
 900   4.7 mm
1000   4.0 mm
1100   0.0 mm
1200   0.0 mm
1300   0.0 mm

我们有一些非常大的候选项,从 32 毫米开始,但它们迅速减半。大部分候选项在 4 到 10 毫米的范围内,而且有几百个根本没有尺寸信息。这看起来正常;您可能还记得我们实际结节比直径注释多的情况。对数据进行快速的健全性检查非常有帮助;及早发现问题或错误的假设可能节省数小时的工作!

更重要的是,我们的训练和验证集应该具有一些属性,以便良好地工作:

两个集合都该包含所有预期输入变化的示例。

任何一个集合都不应该包含不代表预期输入的样本,除非它们有一个特定的目的,比如训练模型以对异常值具有鲁棒性。

训练集不应该提供关于验证集的不真实的提示,这些提示在真实世界的数据中不成立(例如,在两个集合中包含相同的样本;这被称为训练集中的泄漏)。

10.5.4 渲染数据

再次,要么直接使用p2ch10_explore_data.ipynb,要么启动 Jupyter Notebook 并输入

# In[7]:
%matplotlib inline                                     # ❶
from p2ch10.vis import findNoduleSamples, showNodule
noduleSample_list = findNoduleSamples()

❶ 这个神奇的行设置了通过笔记本内联显示图像的能力。

提示 有关 Jupyter 的 matplotlib 内联魔术的更多信息,请参阅mng.bz/rrmD

# In[8]:
series_uid = positiveSample_list[11][2]
showCandidate(series_uid)

这产生了类似于本章前面显示的 CT 和结节切片的图像。

如果您感兴趣,我们邀请您编辑p2ch10/vis.py中渲染代码的实现,以满足您的需求和口味。渲染代码大量使用 Matplotlib (matplotlib.org),这是一个对我们来说太复杂的库,我们无法在这里覆盖。

记住,渲染数据不仅仅是为了获得漂亮的图片。重点是直观地了解您的输入是什么样子的。一眼就能看出“这个有问题的样本与我的其他数据相比非常嘈杂”或“奇怪的是,这看起来非常正常”可能在调查问题时很有用。有效的渲染还有助于培养洞察力,比如“也许如果我修改这样的东西,我就能解决我遇到的问题。”随着您开始处理越来越困难的项目,这种熟悉程度将是必不可少的。

注意由于每个子集的划分方式,以及在构建LunaDataset.candidateInfo_list时使用的排序方式,noduleSample_list中条目的排序高度依赖于代码执行时存在的子集。请记住这一点,尤其是在解压更多子集后尝试第二次找到特定样本时。

10.6 结论

在第九章中,我们已经对我们的数据有了深入的了解。在这一章中,我们让PyTorch对我们的数据有了深入的了解!通过将我们的 DICOM-via-meta-image 原始数据转换为张量,我们已经为开始实现模型和训练循环做好了准备,这将在下一章中看到。

不要低估我们已经做出的设计决策的影响:我们的输入大小、缓存结构以及如何划分训练和验证集都会对整个项目的成功或失败产生影响。不要犹豫在以后重新审视这些决策,特别是当你在自己的项目上工作时。

10.7 练习

  1. 实现一个程序,遍历LunaDataset实例,并计算完成此操作所需的时间。为了节省时间,可能有意义的是有一个选项将迭代限制在前N=1000个样本。

    1. 第一次运行需要多长时间?

    2. 第二次运行需要多长时间?

    3. 清除缓存对运行时间有什么影响?

    4. 使用最后N=1000个样本对第一/第二次运行有什么影响?

  2. LunaDataset的实现更改为在__init__期间对样本列表进行随机化。清除缓存,并运行修改后的版本。这对第一次和第二次运行的运行时间有什么影响?

  3. 恢复随机化,并将@functools.lru_cache(1, typed=True)装饰器注释掉getCt。清除缓存,并运行修改后的版本。现在运行时间如何变化?

摘要

  • 通常,解析和加载原始数据所需的代码并不简单。对于这个项目,我们实现了一个Ct类,它从磁盘加载数据并提供对感兴趣点周围裁剪区域的访问。

  • 如果解析和加载例程很昂贵,缓存可能会很有用。请记住,一些缓存可以在内存中完成,而一些最好在磁盘上执行。每种缓存方式都有其在数据加载管道中的位置。

  • PyTorch 的Dataset子类用于将数据从其原生形式转换为适合传递给模型的张量。我们可以使用这个功能将我们的真实世界数据与 PyTorch API 集成。

  • Dataset的子类需要为两个方法提供实现:__len____getitem__。其他辅助方法是允许的,但不是必需的。

  • 将我们的数据分成合理的训练集和验证集需要确保没有样本同时出现在两个集合中。我们通过使用一致的排序顺序,并为验证集取每第十个样本来实现这一点。

  • 数据可视化很重要;能够通过视觉调查数据可以提供有关错误或问题的重要线索。我们正在使用 Jupyter Notebooks 和 Matplotlib 来呈现我们的数据。


¹ 对于那些事先准备好所有数据的稀有研究人员:你真幸运!我们其他人将忙于编写加载和解析代码。

² 有例外情况,但现在并不相关。

³ 你在这本书中找到拼写错误了吗? 😉

⁴ 实际上更简单一些;但重点是,我们有选择。

⁵ 他们的术语,不是我们的!

十一、训练一个分类模型以检测可疑肿瘤

本章涵盖

  • 使用 PyTorch 的DataLoader加载数据

  • 实现一个在我们的 CT 数据上执行分类的模型

  • 设置我们应用程序的基本框架

  • 记录和显示指标

在前几章中,我们为我们的癌症检测项目做好了准备。我们涵盖了肺癌的医学细节,查看了我们项目将使用的主要数据来源,并将原始 CT 扫描转换为 PyTorch Dataset实例。现在我们有了数据集,我们可以轻松地使用我们的训练数据。所以让我们开始吧!

11.1 一个基础模型和训练循环

在本章中,我们将做两件主要的事情。我们将首先构建结节分类模型和训练循环,这将是第 2 部分探索更大项目的基础。为此,我们将使用我们在第十章实现的CtLunaDataset类来提供DataLoader实例。这些实例将通过训练和验证循环向我们的分类模型提供数据。

我们将通过运行训练循环的结果来结束本章,引入本书这一部分中最困难的挑战之一:如何从混乱、有限的数据中获得高质量的结果。在后续章节中,我们将探讨我们的数据受限的具体方式,并减轻这些限制。

让我们回顾一下第九章的高层路线图,如图 11.1 所示。现在,我们将致力于生成一个能够执行第 4 步分类的模型。作为提醒,我们将候选者分类为结节或非结节(我们将在第十四章构建另一个分类器,试图区分恶性结节和良性结节)。这意味着我们将为呈现给模型的每个样本分配一个单一特定的标签。在这种情况下,这些标签是“结节”和“非结节”,因为每个样本代表一个候选者。

图 11.1 我们的端到端项目,用于检测肺癌,重点是本章的主题:第 4 步,分类

获得项目中一个有意义部分的早期端到端版本是一个重要的里程碑。拥有一个足够好使得结果可以进行分析评估的东西,让你可以有信心进行未来的改变,确信你正在通过每一次改变来改进你的结果,或者至少你能够搁置任何不起作用的改变和实验!在自己的项目中进行大量的实验是必须的。获得最佳结果通常需要进行大量的调试和微调。

但在我们进入实验阶段之前,我们必须打下基础。让我们看看我们第 2 部分训练循环的样子,如图 11.2 所示:鉴于我们在第五章看到了一组类似的核心步骤,这应该会让人感到熟悉。在这里,我们还将使用验证集来评估我们的训练进展,如第 5.5.3 节所讨论的那样。

图 11.2 我们将在本章实现的训练和验证脚本

我们将要实现的基本结构如下:

  • 初始化我们的模型和数据加载。

  • 循环遍历一个半随机选择的 epoch 数。

    • 循环遍历LunaDataset返回的每个训练数据批次。

    • 数据加载器工作进程在后台加载相关批次的数据。

    • 将批次传入我们的分类模型以获得结果。

    • 根据我们预测结果与地面真实数据之间的差异来计算我们的损失。

    • 记录关于我们模型性能的指标到一个临时数据结构中。

    • 通过误差的反向传播更新模型权重。

    • 循环遍历每个验证数据批次(与训练循环非常相似的方式)。

    • 加载相关的验证数据批次(同样,在后台工作进程中)。

    • 对批次进行分类,并计算损失。

    • 记录模型在验证数据上的表现信息。

    • 打印出本轮的进展和性能信息。

当我们阅读本章的代码时,请注意我们正在生成的代码与第一部分中用于训练循环的代码之间的两个主要区别。首先,我们将在程序周围放置更多结构,因为整个项目比我们在早期章节中做的要复杂得多。没有额外的结构,代码很快就会变得混乱。对于这个项目,我们将使我们的主要训练应用程序使用许多良好封装的函数,并进一步将像数据集这样的代码分离为独立的 Python 模块。

确保对于您自己的项目,您将结构和设计水平与项目的复杂性水平匹配。结构太少,将难以进行实验、排除问题,甚至描述您正在做的事情!相反,结构太意味着您正在浪费时间编写您不需要的基础设施,并且在所有管道都就位后,您可能会因为不得不遵守它而减慢自己的速度。此外,花时间在基础设施上很容易成为一种拖延策略,而不是投入艰苦工作来实际推进项目。不要陷入这种陷阱!

本章代码与第一部分的另一个重大区别将是专注于收集有关训练进展的各种指标。如果没有良好的指标记录,准确确定变化对训练的影响是不可能的。在不透露下一章内容的情况下,我们还将看到收集不仅仅是指标,而是适合工作的正确指标是多么重要。我们将在本章中建立跟踪这些指标的基础设施,并通过收集和显示损失和正确分类的样本百分比来运用该基础设施,无论是总体还是每个类别。这足以让我们开始,但我们将在第十二章中涵盖一组更现实的指标。

11.2 我们应用程序的主要入口点

本书中与之前训练工作的一个重大结构性差异是,第二部分将我们的工作封装在一个完整的命令行应用程序中。它将解析命令行参数,具有完整功能的 --help 命令,并且可以在各种环境中轻松运行。所有这些都将使我们能够轻松地从 Jupyter 和 Bash shell 中调用训练例程。¹

我们的应用功能将通过一个类来实现,以便我们可以实例化应用程序并在需要时传递它。这可以使测试、调试或从其他 Python 程序调用更容易。我们可以调用应用程序而无需启动第二个 OS 级别的进程(在本书中我们不会进行显式单元测试,但我们创建的结构对于需要进行这种测试的真实项目可能会有所帮助)。

利用能够通过函数调用或 OS 级别进程调用我们的训练的方式之一是将函数调用封装到 Jupyter Notebook 中,以便代码可以轻松地从本机 CLI 或浏览器中调用。

代码清单 11.1 code/p2_run_everything.ipynb

# In[2]:w
def run(app, *argv):
    argv = list(argv)
    argv.insert(0, '--num-workers=4')                       # ❶
    log.info("Running: {}({!r}).main()".format(app, argv))

    app_cls = importstr(*app.rsplit('.', 1))                # ❷
    app_cls(argv).main()

    log.info("Finished: {}.{!r}).main()".format(app, argv))

# In[6]:
run('p2ch11.training.LunaTrainingApp', '--epochs=1')

❶ 我们假设您有一台四核八线程 CPU。如有需要,请更改 4。

❷ 这是一个稍微更干净的 import 调用。

注意 这里的训练假设您使用的是一台四核八线程 CPU、16 GB RAM 和一块具有 8 GB RAM 的 GPU 的工作站。如果您的 GPU RAM 较少,请减小 --batch-size,如果 CPU 核心较少或 CPU RAM 较少,请减小 --num-workers

让我们先把一些半标准的样板代码搞定。我们将从文件末尾开始,使用一个相当标准的 if main 语句块,实例化应用对象并调用 main 方法。

代码清单 11.2 training.py:386

if __name__ == '__main__':
  LunaTrainingApp().main()

从那里,我们可以跳回文件顶部,查看应用程序类和我们刚刚调用的两个函数,__init__main。我们希望能够接受命令行参数,因此我们将在应用程序的__init__函数中使用标准的argparse库(docs.python.org/3/library/argparse.html)。请注意,如果需要,我们可以向初始化程序传递自定义参数。main方法将是应用程序核心逻辑的主要入口点。

列表 11.3 training.py:31,class LunaTrainingApp

class LunaTrainingApp:
  def __init__(self, sys_argv=None):
    if sys_argv is None:                                                   # ❶
       sys_argv = sys.argv[1:]

    parser = argparse.ArgumentParser()
    parser.add_argument('--num-workers',
      help='Number of worker processes for background data loading',
      default=8,
      type=int,
    )
    # ... line 63
    self.cli_args = parser.parse_args(sys_argv)
    self.time_str = datetime.datetime.now().strftime('%Y-%m-%d_%H.%M.%S')  # ❷

  # ... line 137
  def main(self):
    log.info("Starting {}, {}".format(type(self).__name__, self.cli_args))

❶ 如果调用者没有提供参数,我们会从命令行获取参数。

❷ 我们将使用时间戳来帮助识别训练运行。

这种结构非常通用,可以在未来的项目中重复使用。特别是在__init__中解析参数允许我们将应用程序的配置与调用分开。

如果您在本书网站或 GitHub 上检查本章的代码,您可能会注意到一些额外的提到TensorBoard的行。现在请忽略这些;我们将在本章后面的第 11.9 节中详细讨论它们。

11.3 预训练设置和初始化

在我们开始迭代每个 epoch 中的每个批次之前,需要进行一些初始化工作。毕竟,如果我们还没有实例化模型,我们就无法训练模型!正如我们在图 11.3 中所看到的,我们需要做两件主要的事情。第一,正如我们刚才提到的,是初始化我们的模型和优化器;第二是初始化我们的DatasetDataLoader实例。LunaDataset将定义组成我们训练 epoch 的随机样本集,而我们的DataLoader实例将负责从我们的数据集中加载数据并将其提供给我们的应用程序。

图 11.3 我们将在本章实现的训练和验证脚本,重点放在预循环变量初始化上

11.3.1 初始化模型和优化器

对于这一部分,我们将LunaModel的细节视为黑匣子。在第 11.4 节中,我们将详细介绍内部工作原理。您可以探索对实现进行更改,以更好地满足我们对模型的目标,尽管最好是在至少完成第十二章之后再进行。

让我们看看我们的起点是什么样的。

列表 11.4 training.py:31,class LunaTrainingApp

class LunaTrainingApp:
  def __init__(self, sys_argv=None):
    # ... line 70
    self.use_cuda = torch.cuda.is_available()
    self.device = torch.device("cuda" if self.use_cuda else "cpu")

    self.model = self.initModel()
    self.optimizer = self.initOptimizer()

  def initModel(self):
    model = LunaModel()
    if self.use_cuda:
      log.info("Using CUDA; {} devices.".format(torch.cuda.device_count()))
      if torch.cuda.device_count() > 1:                                    # ❶
         model = nn.DataParallel(model)                                    # ❷
       model = model.to(self.device)                                       # ❸
     return model

  def initOptimizer(self):
    return SGD(self.model.parameters(), lr=0.001, momentum=0.99)

❶ 检测多个 GPU

❷ 包装模型

❸ 将模型参数发送到 GPU。

如果用于训练的系统有多个 GPU,我们将使用nn.DataParallel类在系统中的所有 GPU 之间分发工作,然后收集和重新同步参数更新等。就模型实现和使用该模型的代码而言,这几乎是完全透明的。

DataParallel vs. DistributedDataParallel

在本书中,我们使用DataParallel来处理利用多个 GPU。我们选择DataParallel,因为它是我们现有模型的简单插入包装器。然而,它并不是使用多个 GPU 的性能最佳解决方案,并且它仅限于与单台机器上可用的硬件一起使用。

PyTorch 还提供DistributedDataParallel,这是在需要在多个 GPU 或机器之间分配工作时推荐使用的包装类。由于正确的设置和配置并不简单,而且我们怀疑绝大多数读者不会从复杂性中获益,因此我们不会在本书中涵盖DistributedDataParallel。如果您希望了解更多,请阅读官方文档:pytorch.org/tutorials/intermediate/ddp_tutorial.html

假设self.use_cuda为真,则调用self.model.to(device)将模型参数移至 GPU,设置各种卷积和其他计算以使用 GPU 进行繁重的数值计算。在构建优化器之前这样做很重要,否则优化器将只查看基于 CPU 的参数对象,而不是复制到 GPU 的参数对象。

对于我们的优化器,我们将使用基本的随机梯度下降(SGD;pytorch.org/docs/stable/optim.html#torch.optim.SGD)与动量。我们在第五章中首次看到了这个优化器。回想第 1 部分,PyTorch 中提供了许多不同的优化器;虽然我们不会详细介绍大部分优化器,但官方文档(pytorch.org/docs/stable/optim.html#algorithms)很好地链接到相关论文。

当选择优化器时,使用 SGD 通常被认为是一个安全的起点;有一些问题可能不适合 SGD,但它们相对较少。同样,学习率为 0.001,动量为 0.9 是相当安全的选择。从经验上看,SGD 与这些值一起在各种项目中表现得相当不错,如果一开始效果不佳,可以尝试学习率为 0.01 或 0.0001。

这并不意味着这些值中的任何一个对我们的用例是最佳的,但试图找到更好的值是在超前。系统地尝试不同的学习率、动量、网络大小和其他类似配置设置的值被称为超参数搜索。在接下来的章节中,我们需要先解决其他更为突出的问题。一旦我们解决了这些问题,我们就可以开始微调这些值。正如我们在第五章的“测试其他优化器”部分中提到的,我们还可以选择其他更为奇特的优化器;但除了可能将torch.optim.SGD替换为torch.optim.Adam之外,理解所涉及的权衡是本书所讨论的范围之外的一个过于高级的主题。

11.3.2 数据加载器的照料和喂养

我们在上一章中构建的LunaDataset类充当着我们拥有的任何“荒野数据”与 PyTorch 构建模块期望的更加结构化的张量世界之间的桥梁。例如,torch.nn.Conv3d ( pytorch.org/docs/stable/nn.html#conv3d) 期望五维输入:(N, C, D, H, W):样本数量,每个样本的通道数,深度,高度和宽度。这与我们的 CT 提供的本机 3D 非常不同!

您可能还记得上一章中LunaDataset.__getitem__中的ct_t.unsqueeze(0)调用;它提供了第四维,即我们数据的“通道”。回想一下第四章,RGB 图像有三个通道,分别用于红色、绿色和蓝色。天文数据可能有几十个通道,每个通道代表电磁波谱的各个切片--伽马射线、X 射线、紫外线、可见光、红外线、微波和/或无线电波。由于 CT 扫描是单一强度的,我们的通道维度只有大小 1。

还要回顾第 1 部分,一次训练单个样本通常是对计算资源的低效利用,因为大多数处理平台能够进行更多的并行计算,而模型处理单个训练或验证样本所需的计算量要少。解决方案是将样本元组组合成批元组,如图 11.4 所示,允许同时处理多个样本。第五维度(N)区分了同一批中的多个样本。

图 11.4 将样本元组整合到数据加载器中的单个批元组中

方便的是,我们不必实现任何批处理:PyTorch 的DataLoader类将处理所有的整理工作。我们已经通过LunaDataset类将 CT 扫描转换为 PyTorch 张量,所以唯一剩下的就是将我们的数据集插入数据加载器中。

列表 11.5 training.py:89,LunaTrainingApp.initTrainDl

def initTrainDl(self):
  train_ds = LunaDataset(                    # ❶
    val_stride=10,
    isValSet_bool=False,
  )

  batch_size = self.cli_args.batch_size
  if self.use_cuda:
    batch_size *= torch.cuda.device_count()

  train_dl = DataLoader(                     # ❷
    train_ds,
    batch_size=batch_size,                   # ❸
    num_workers=self.cli_args.num_workers,
    pin_memory=self.use_cuda,                # ❹
  )

  return train_dl

# ... line 137
def main(self):
  train_dl = self.initTrainDl()
  val_dl = self.initValDl()                # ❺

❶ 我们的自定义数据集

❷ 一个现成的类

❸ 批处理是自动完成的。

❹ 固定内存传输到 GPU 快速。

❺ 验证数据加载器与训练非常相似。

除了对单个样本进行分批处理外,数据加载器还可以通过使用单独的进程和共享内存提供数据的并行加载。我们只需在实例化数据加载器时指定num_workers=...,其余工作都在幕后处理。每个工作进程生成完整的批次,如图 11.4 所示。这有助于确保饥饿的 GPU 得到充分的数据供应。我们的validation_dsvalidation_dl实例看起来很相似,除了明显的isValSet_bool=True

当我们迭代时,比如for batch_tup in self.train_dl:,我们不必等待每个Ct被加载、样本被取出和分批处理等。相反,我们将立即获得已加载的batch_tup,并且后台的工作进程将被释放以开始加载另一个批次,以便在以后的迭代中使用。使用 PyTorch 的数据加载功能可以加快大多数项目的速度,因为我们可以将数据加载和处理与 GPU 计算重叠。

11.4 我们的第一次神经网络设计

能够检测肿瘤的卷积神经网络的设计空间实际上是无限的。幸运的是,在过去的十年左右,已经付出了相当大的努力来研究有效的图像识别模型。虽然这些模型主要集中在 2D 图像上,但一般的架构思想也很适用于 3D,因此有许多经过测试的设计可以作为起点。这有助于我们,因为尽管我们的第一个网络架构不太可能是最佳选择,但现在我们只是追求“足够好以让我们开始”。

我们将基于第八章中使用的内容设计网络。我们将不得不稍微更新模型,因为我们的输入数据是 3D 的,并且我们将添加一些复杂的细节,但图 11.5 中显示的整体结构应该感觉熟悉。同样,我们为这个项目所做的工作将是您未来项目的良好基础,尽管您离开分类或分割项目越远,就越需要调整这个基础以适应。让我们从组成网络大部分的四个重复块开始剖析这个架构。

图 11.5 LunaModel类的架构由批量归一化尾部、四个块的主干和由线性层后跟 softmax 组成的头部。

11.4.1 核心卷积

分类模型通常由尾部、主干(或身体)和头部组成。尾部是处理网络输入的前几层。这些早期层通常具有与网络其余部分不同的结构或组织,因为它们必须将输入调整为主干所期望的形式。在这里,我们使用简单的批量归一化层,尽管通常尾部也包含卷积层。这些卷积层通常用于大幅度降低图像的大小;由于我们的图像尺寸已经很小,所以这里不需要这样做。

接下来,网络的骨干通常包含大部分层,这些层通常按的系列排列。每个块具有相同(或至少类似)的层集,尽管通常从一个块到另一个块,预期输入的大小和滤波器数量会发生变化。我们将使用一个由两个 3 × 3 卷积组成的块,每个卷积后跟一个激活函数,并在块末尾进行最大池化操作。我们可以在图 11.5 的扩展视图中看到标记为Block[block1]的块的实现。以下是代码中块的实现。

代码清单 11.6 model.py:67,class LunaBlock

class LunaBlock(nn.Module):
  def __init__(self, in_channels, conv_channels):
    super().__init__()

    self.conv1 = nn.Conv3d(
      in_channels, conv_channels, kernel_size=3, padding=1, bias=True,
    )
    self.relu1 = nn.ReLU(inplace=True)  1((CO5-1))
     self.conv2 = nn.Conv3d(
      conv_channels, conv_channels, kernel_size=3, padding=1, bias=True,
    )
    self.relu2 = nn.ReLU(inplace=True)    # ❶

    self.maxpool = nn.MaxPool3d(2, 2)

  def forward(self, input_batch):
    block_out = self.conv1(input_batch)
    block_out = self.relu1(block_out)     # ❶
    block_out = self.conv2(block_out)
    block_out = self.relu2(block_out)     # ❶

    return self.maxpool(block_out)

❶ 这些可以作为对功能 API 的调用来实现。

最后,网络的头部接收来自骨干的输出,并将其转换为所需的输出形式。对于卷积网络,这通常涉及将中间输出展平并传递给全连接层。对于一些网络,也可以考虑包括第二个全连接层,尽管这通常更适用于具有更多结构的分类问题(比如想想汽车与卡车有轮子、灯、格栅、门等)和具有大量类别的项目。由于我们只进行二元分类,并且似乎不需要额外的复杂性,我们只有一个展平层。

使用这样的结构可以作为卷积网络的良好第一构建块。虽然存在更复杂的设计,但对于许多项目来说,它们在实现复杂性和计算需求方面都过于复杂。最好从简单开始,只有在确实需要时才增加复杂性。

我们可以在图 11.6 中看到我们块的卷积在 2D 中表示。由于这是较大图像的一小部分,我们在这里忽略填充。(请注意,未显示 ReLU 激活函数,因为应用它不会改变图像大小。)

让我们详细了解输入体素和单个输出体素之间的信息流。当输入发生变化时,我们希望对输出如何响应有一个清晰的认识。最好回顾第八章,特别是第 8.1 至 8.3 节,以确保您对卷积的基本机制完全掌握。

图 11.6 LunaModel块的卷积架构由两个 3 × 3 卷积和一个最大池组成。最终像素具有 6 × 6 的感受野。

我们在我们的块中使用 3 × 3 × 3 卷积。单个 3 × 3 × 3 卷积具有 3 × 3 × 3 的感受野,这几乎是显而易见的。输入了 27 个体素,输出一个体素。

当我们使用两个连续的 3 × 3 × 3 卷积时,情况变得有趣。堆叠卷积层允许最终输出的体素(或像素)受到比卷积核大小所示的更远的输入的影响。如果将该输出体素作为边缘体素之一输入到另一个 3 × 3 × 3 卷积核中,则第一层的一些输入将位于第二层的输入 3 × 3 × 3 区域之外。这两个堆叠层的最终输出具有 5 × 5 × 5 的有效感受野。这意味着当两者一起考虑时,堆叠层的作用类似于具有更大尺寸的单个卷积层。

换句话说,每个 3 × 3 × 3 卷积层为感受野添加了额外的一像素边界。如果我们在图 11.6 中向后跟踪箭头,我们可以看到这一点;我们的 2 × 2 输出具有 4 × 4 的感受野,进而具有 6 × 6 的感受野。两个堆叠的 3 × 3 × 3 层比完整的 5 × 5 × 5 卷积使用更少的参数(因此计算速度更快)。

我们两个堆叠的卷积的输出被送入一个 2×2×2 的最大池,这意味着我们正在取一个 6×6×6 的有效区域,丢弃了七分之八的数据,并选择了产生最大值的一个 5×5×5 区域。现在,那些“被丢弃”的输入体素仍然有机会贡献,因为距离一个输出体素的最大池还有一个重叠的输入区域,所以它们可能以这种方式影响最终输出。

请注意,虽然我们展示了每个卷积层的感受野随着每个卷积层的缩小而缩小,但我们使用了填充卷积,它在图像周围添加了一个虚拟的一像素边框。这样做可以保持输入和输出图像的大小不变。

nn.ReLU 层与我们在第六章中看到的层相同。大于 0.0 的输出将保持不变,小于 0.0 的输出将被截断为零。

这个块将被多次重复以形成我们模型的主干。

11.4.2 完整模型

让我们看一下完整模型的实现。我们将跳过块的定义,因为我们刚刚在代码清单 11.6 中看到了。

代码清单 11.7 model.py:13,class LunaModel

class LunaModel(nn.Module):
  def __init__(self, in_channels=1, conv_channels=8):
    super().__init__()

    self.tail_batchnorm = nn.BatchNorm3d(1)                           # ❶

    self.block1 = LunaBlock(in_channels, conv_channels)               # ❷
    self.block2 = LunaBlock(conv_channels, conv_channels * 2)         # ❷
    self.block3 = LunaBlock(conv_channels * 2, conv_channels * 4)     # ❷
    self.block4 = LunaBlock(conv_channels * 4, conv_channels * 8)     # ❷

    self.head_linear = nn.Linear(1152, 2)                             # ❸
    self.head_softmax = nn.Softmax(dim=1)                             # ❸

❶ 尾部

❷ 主干

❸ 头部

在这里,我们的尾部相对简单。我们将使用nn.BatchNorm3d对输入进行归一化,正如我们在第八章中看到的那样,它将移动和缩放我们的输入,使其具有均值为 0 和标准差为 1。因此,我们的输入单位处于的有点奇怪的汉斯菲尔德单位(HU)尺度对网络的其余部分来说并不明显。这是一个有点武断的选择;我们知道我们的输入单位是什么,我们知道相关组织的预期值,所以我们可能很容易地实现一个固定的归一化方案。目前尚不清楚哪种方法更好。

我们的主干是四个重复的块,块的实现被提取到我们之前在代码清单 11.6 中看到的单独的nn.Module子类中。由于每个块以 2×2×2 的最大池操作结束,经过 4 层后,我们将在每个维度上将图像的分辨率降低 16 倍。回想一下第十章,我们的数据以 32×48×48 的块返回,最终将变为 2×3×3。

最后,我们的尾部只是一个全连接层,然后调用nn.Softmax。Softmax 是用于单标签分类任务的有用函数,并具有一些不错的特性:它将输出限制在 0 到 1 之间,对输入的绝对范围相对不敏感(只有输入的相对值重要),并且允许我们的模型表达对答案的确定程度。

函数本身相对简单。输入的每个值都用于求幂e,然后得到的一系列值除以所有求幂结果的总和。以下是一个简单的非优化 softmax 实现的 Python 代码示例:

>>> logits = [1, -2, 3]
>>> exp = [e ** x for x in logits]
>>> exp
[2.718, 0.135, 20.086]

>>> softmax = [x / sum(exp) for x in exp]
>>> softmax
[0.118, 0.006, 0.876]

当然,我们在模型中使用 PyTorch 版本的nn.Softmax,因为它本身就能理解批处理和张量,并且会快速且如预期地执行自动梯度。

复杂性:从卷积转换为线性

继续我们的模型定义,我们遇到了一个复杂性。我们不能简单地将self.block4的输出馈送到全连接层,因为该输出是每个样本的 64 通道的 2×3×3 图像,而全连接层期望一个 1D 向量作为输入(技术上说,它们期望一个批量的 1D 向量,这是一个 2D 数组,但无论如何不匹配)。让我们看一下forward方法。

代码清单 11.8 model.py:50,LunaModel.forward

def forward(self, input_batch):
  bn_output = self.tail_batchnorm(input_batch)

  block_out = self.block1(bn_output)
  block_out = self.block2(block_out)
  block_out = self.block3(block_out)
  block_out = self.block4(block_out)

  conv_flat = block_out.view(
    block_out.size(0),          # ❶
    -1,
  )
  linear_output = self.head_linear(conv_flat)

  return linear_output, self.head_softmax(linear_output)

❶ 批处理大小

请注意,在将数据传递到全连接层之前,我们必须使用view函数对其进行展平。由于该操作是无状态的(没有控制其行为的参数),我们可以简单地在forward函数中执行该操作。这在某种程度上类似于我们在第八章讨论的功能接口。几乎每个使用卷积并产生分类、回归或其他非图像输出的模型都会在网络头部具有类似的组件。

对于forward方法的返回值,我们同时返回原始logits和 softmax 生成的概率。我们在第 7.2.6 节中首次提到了 logits:它们是网络在被 softmax 层归一化之前产生的数值。这可能听起来有点复杂,但 logits 实际上只是 softmax 层的原始输入。它们可以有任何实值输入,softmax 会将它们压缩到 0-1 的范围内。

在训练时,我们将使用 logits 来计算nn.CrossEntropyLoss,⁴而在实际对样本进行分类时,我们将使用概率。在训练和生产中使用的输出之间存在这种轻微差异是相当常见的,特别是当两个输出之间的差异是像 softmax 这样简单、无状态的函数时。

初始化

最后,让我们谈谈初始化网络参数。为了使我们的模型表现良好,网络的权重、偏置和其他参数需要表现出一定的特性。让我们想象一个退化的情况,即网络的所有权重都大于 1(且没有残差连接)。在这种情况下,重复乘以这些权重会导致数据通过网络层时层输出变得非常大。类似地,小于 1 的权重会导致所有层输出变得更小并消失。类似的考虑也适用于反向传播中的梯度。

许多规范化技术可以用来保持层输出的良好行为,但其中最简单的一种是确保网络的权重初始化得当,使得中间值和梯度既不过小也不过大。正如我们在第八章讨论的那样,PyTorch 在这里没有给予我们足够的帮助,因此我们需要自己进行一些初始化。我们可以将以下_init_weights函数视为样板,因为确切的细节并不特别重要。

列表 11.9 model.py:30,LunaModel._init_weights

def _init_weights(self):
  for m in self.modules():
    if type(m) in {
      nn.Linear,
      nn.Conv3d,
    }:
      nn.init.kaiming_normal_(
        m.weight.data, a=0, mode='fan_out', nonlinearity='relu',
      )
      if m.bias is not None:
        fan_in, fan_out = \
          nn.init._calculate_fan_in_and_fan_out(m.weight.data)
        bound = 1 / math.sqrt(fan_out)
        nn.init.normal_(m.bias, -bound, bound)

11.5 训练和验证模型

现在是时候将我们一直在处理的各种部分组装起来,以便我们实际执行。这个训练循环应该很熟悉--我们在第五章看到了类似图 11.7 的循环。

图 11.7 我们将在本章实现的训练和验证脚本,重点是在每个时期和时期中的批次上进行嵌套循环

代码相对紧凑(doTraining函数仅有 12 个语句;由于行长限制,这里较长)。

列表 11.10 training.py:137,LunaTrainingApp.main

def main(self):
  # ... line 143
  for epoch_ndx in range(1, self.cli_args.epochs + 1):
    trnMetrics_t = self.doTraining(epoch_ndx, train_dl)
    self.logMetrics(epoch_ndx, 'trn', trnMetrics_t)

# ... line 165
def doTraining(self, epoch_ndx, train_dl):
  self.model.train()
  trnMetrics_g = torch.zeros(                 # ❶
    METRICS_SIZE,
    len(train_dl.dataset),
    device=self.device,
  )

  batch_iter = enumerateWithEstimate(         # ❷
    train_dl,
    "E{} Training".format(epoch_ndx),
    start_ndx=train_dl.num_workers,
  )
  for batch_ndx, batch_tup in batch_iter:
    self.optimizer.zero_grad()                # ❸

    loss_var = self.computeBatchLoss(         # ❹
      batch_ndx,
      batch_tup,
      train_dl.batch_size,
      trnMetrics_g
    )

    loss_var.backward()                       # ❺
    self.optimizer.step()                     # ❺

  self.totalTrainingSamples_count += len(train_dl.dataset)

  return trnMetrics_g.to('cpu')

❶ 初始化一个空的指标数组

❷ 设置我们的批次循环和时间估计

❸ 释放任何剩余的梯度张量

❹ 我们将在下一节详细讨论这种方法。

❺ 实际更新模型权重

我们从前几章的训练循环中看到的主要区别如下:

  • trnMetrics_g张量在训练过程中收集了详细的每类指标。对于像我们这样的大型项目,这种洞察力可能非常有用。

  • 我们不直接遍历train_dl数据加载器。我们使用enumerateWithEstimate来提供预计完成时间。这并不是必要的;这只是一种风格上的选择。

  • 实际的损失计算被推入computeBatchLoss方法中。再次强调,这并不是绝对必要的,但代码重用通常是一个优点。

我们将在第 11.7.2 节讨论为什么我们在enumerate周围包装了额外的功能;目前,假设它与enumerate(train_dl)相同。

trnMetrics_g张量的目的是将有关模型在每个样本基础上的行为信息从computeBatchLoss函数传输到logMetrics函数。让我们接下来看一下computeBatchLoss。在完成主要训练循环的其余部分后,我们将讨论logMetrics

11.5.1 computeBatchLoss函数

computeBatchLoss函数被训练和验证循环调用。顾名思义,它计算一批样本的损失。此外,该函数还计算并记录模型产生的每个样本信息。这使我们能够计算每个类别的正确答案百分比,从而让我们专注于模型遇到困难的领域。

当然,函数的核心功能是将批次输入模型并计算每个批次的损失。我们使用CrossEntropyLoss ( pytorch.org/docs/stable/nn.html#torch.nn.CrossEntropyLoss),就像在第七章中一样。解包批次元组,将张量移动到 GPU,并调用模型应该在之前的训练工作后都感到熟悉。

列表 11.11 training.py:225,.computeBatchLoss

def computeBatchLoss(self, batch_ndx, batch_tup, batch_size, metrics_g):
  input_t, label_t, _series_list, _center_list = batch_tup

  input_g = input_t.to(self.device, non_blocking=True)
  label_g = label_t.to(self.device, non_blocking=True)

  logits_g, probability_g = self.model(input_g)

  loss_func = nn.CrossEntropyLoss(reduction='none')   # ❶
  loss_g = loss_func(
    logits_g,
    label_g[:,1],                                     # ❷
  )
  # ... line 238
  return loss_g.mean()                                # ❸

reduction=‘none’给出每个样本的损失。

❷ one-hot 编码类别的索引

❸ 将每个样本的损失重新组合为单个值

在这里,我们使用默认行为来获得平均批次的损失值。相反,我们得到一个损失值的张量,每个样本一个。这使我们能够跟踪各个损失,这意味着我们可以按照自己的意愿进行聚合(例如,按类别)。我们马上就会看到这一点。目前,我们将返回这些每个样本损失的均值,这等同于批次损失。在不想保留每个样本统计信息的情况下,使用批次平均损失是完全可以的。是否这样取决于您的项目和目标。

一旦完成了这些,我们就完成了对调用函数的义务,就 backpropagation 和权重更新而言,需要做的事情。然而,在这之前,我们还想要记录我们每个样本的统计数据以供后人(和后续分析)使用。我们将使用传入的metrics_g参数来实现这一点。

列表 11.12 training.py:26

METRICS_LABEL_NDX=0                                       # ❶
METRICS_PRED_NDX=1
METRICS_LOSS_NDX=2
METRICS_SIZE = 3

  # ... line 225
  def computeBatchLoss(self, batch_ndx, batch_tup, batch_size, metrics_g):
    # ... line 238
    start_ndx = batch_ndx * batch_size
    end_ndx = start_ndx + label_t.size(0)

    metrics_g[METRICS_LABEL_NDX, start_ndx:end_ndx] = \   # ❷
      label_g[:,1].detach()                               # ❷
    metrics_g[METRICS_PRED_NDX, start_ndx:end_ndx] = \    # ❷
      probability_g[:,1].detach()                         # ❷
    metrics_g[METRICS_LOSS_NDX, start_ndx:end_ndx] = \    # ❷
      loss_g.detach()                                     # ❷

    return loss_g.mean()                                  # ❸

❶ 这些命名的数组索引在模块级别范围内声明

❷ 我们使用detach,因为我们的指标都不需要保留梯度。

❸ 再次,这是整个批次的损失。

通过记录每个训练(以及后续的验证)样本的标签、预测和损失,我们拥有大量详细信息,可以用来研究我们模型的行为。目前,我们将专注于编译每个类别的统计数据,但我们也可以轻松地使用这些信息找到被错误分类最多的样本,并开始调查原因。同样,对于一些项目,这种信息可能不那么有趣,但记住你有这些选项是很好的。

11.5.2 验证循环类似

图 11.8 中的验证循环看起来与训练很相似,但有些简化。关键区别在于验证是只读的。具体来说,返回的损失值不会被使用,权重也不会被更新。

图 11.8 我们将在本章实现的训练和验证脚本,重点放在每个 epoch 的验证循环上

在函数调用的开始和结束之间,模型的任何内容都不应该发生变化。此外,由于with torch.no_grad()上下文管理器明确告知 PyTorch 不需要计算梯度,因此速度要快得多。

LunaTrainingApp.main 中的 training.py:137,代码清单 11.13

def main(self):
  for epoch_ndx in range(1, self.cli_args.epochs + 1):
    # ... line 157
    valMetrics_t = self.doValidation(epoch_ndx, val_dl)
    self.logMetrics(epoch_ndx, 'val', valMetrics_t)

# ... line 203
def doValidation(self, epoch_ndx, val_dl):
  with torch.no_grad():
    self.model.eval()                  # ❶
    valMetrics_g = torch.zeros(
      METRICS_SIZE,
      len(val_dl.dataset),
      device=self.device,
    )

    batch_iter = enumerateWithEstimate(
      val_dl,
      "E{} Validation ".format(epoch_ndx),
      start_ndx=val_dl.num_workers,
    )
    for batch_ndx, batch_tup in batch_iter:
      self.computeBatchLoss(
        batch_ndx, batch_tup, val_dl.batch_size, valMetrics_g)

  return valMetrics_g.to('cpu')

❶ 关闭训练时的行为

在不需要更新网络权重的情况下(回想一下,这样做会违反验证集的整个前提;我们绝不希望这样做!),我们不需要使用computeBatchLoss返回的损失,也不需要引用优化器。 在循环内部剩下的只有对computeBatchLoss的调用。请注意,尽管我们不使用computeBatchLoss返回的每批损失来做任何事情,但我们仍然在valMetrics_g中收集指标作为调用的副作用。

11.6 输出性能指标

每个时期我们做的最后一件事是记录本时期的性能指标。如图 11.9 所示,一旦我们记录了指标,我们就会返回到下一个训练时期的训练循环中。在训练过程中随着进展记录结果是很重要的,因为如果训练出现问题(在深度学习术语中称为“不收敛”),我们希望能够注意到这一点,并停止花费时间训练一个不起作用的模型。在较小的情况下,能够监视模型行为是很有帮助的。

图 11.9 我们将在本章实现的训练和验证脚本,重点放在每个时期结束时的指标记录上

之前,我们在trnMetrics_gvalMetrics_g中收集结果以记录每个时期的进展。这两个张量现在包含了我们计算每个训练和验证运行的每类百分比正确和平均损失所需的一切。每个时期执行此操作是一个常见选择,尽管有些是任意的。在未来的章节中,我们将看到如何调整我们的时期大小,以便以合理的速率获得有关训练进度的反馈。

11.6.1 logMetrics 函数

让我们谈谈logMetrics函数的高级结构。签名看起来像这样。

LunaTrainingApp.logMetrics 中的 training.py:251,代码清单 11.14

def logMetrics(
    self,
    epoch_ndx,
    mode_str,
    metrics_t,
    classificationThreshold=0.5,
):

我们仅使用epoch_ndx来在记录结果时显示。mode_str参数告诉我们指标是用于训练还是验证。

我们要么使用传入的metrics_t参数中的trnMetrics_tvalMetrics_t。回想一下,这两个输入都是浮点值的张量,在computeBatchLoss期间我们填充了数据,然后在我们从doTrainingdoValidation返回它们之前将它们转移到 CPU。这两个张量都有三行,以及我们有样本数(训练样本或验证样本,取决于)的列数。作为提醒,这三行对应以下常量。

training.py:26,代码清单 11.15

METRICS_LABEL_NDX=0     # ❶
METRICS_PRED_NDX=1
METRICS_LOSS_NDX=2
METRICS_SIZE = 3 

❶ 这些在模块级别范围内声明。

张量掩码和布尔索引

掩码张量是一种常见的使用模式,如果您以前没有遇到过,可能会感到不透明。您可能熟悉 NumPy 概念称为掩码数组;张量和数组掩码的行为方式相同。

如果您对掩码数组不熟悉,NumPy 文档中的一个优秀页面(mng.bz/XPra)很好地描述了其行为。 PyTorch 故意使用与 NumPy 相同的语法和语义。

构建掩码

接下来,我们将构建掩码,以便仅将指标限制为结节或非结节(也称为阳性或阴性)样本。我们还将计算每个类别的总样本数,以及我们正确分类的样本数。

LunaTrainingApp.logMetrics 中的 training.py:264,代码清单 11.16

negLabel_mask = metrics_t[METRICS_LABEL_NDX] <= classificationThreshold
negPred_mask = metrics_t[METRICS_PRED_NDX] <= classificationThreshold

posLabel_mask = ~negLabel_mask
posPred_mask = ~negPred_mask

虽然我们在这里没有assert,但我们知道存储在metrics _t[METRICS_LABEL_NDX]中的所有值属于集合{0.0, 1.0},因为我们知道我们的结节状态标签只是TrueFalse。通过与默认值为 0.5 的classificationThreshold进行比较,我们得到一个二进制值数组,其中True值对应于所讨论样本的非结节(也称为负)标签。

我们进行类似的比较以创建negPred_mask,但我们必须记住METRICS_PRED_NDX值是我们模型产生的正预测,可以是介于 0.0 和 1.0 之间的任意浮点值。这并不改变我们的比较,但这意味着实际值可能接近 0.5。正掩模只是负掩模的反向。

注意 虽然其他项目可以利用类似的方法,但重要的是要意识到,我们正在采取一些捷径,这是因为这是一个二元分类问题。如果您的下一个项目有超过两个类别或样本同时属于多个类别,您将需要使用更复杂的逻辑来构建类似的掩模。

接下来,我们使用这些掩模计算一些每个标签的统计数据,并将其存储在字典metrics_dict中。

代码清单 11.17 training.py:270,LunaTrainingApp.logMetrics

neg_count = int(negLabel_mask.sum())                            # ❶
pos_count = int(posLabel_mask.sum())

neg_correct = int((negLabel_mask & negPred_mask).sum())
pos_correct = int((posLabel_mask & posPred_mask).sum())

metrics_dict = {}
metrics_dict['loss/all'] = \
  metrics_t[METRICS_LOSS_NDX].mean()
metrics_dict['loss/neg'] = \
  metrics_t[METRICS_LOSS_NDX, negLabel_mask].mean()
metrics_dict['loss/pos'] = \
  metrics_t[METRICS_LOSS_NDX, posLabel_mask].mean()

metrics_dict['correct/all'] = (pos_correct + neg_correct) \
  / np.float32(metrics_t.shape[1]) * 100                        # ❷
metrics_dict['correct/neg'] = neg_correct / np.float32(neg_count) * 100
metrics_dict['correct/pos'] = pos_correct / np.float32(pos_count) * 100

❶ 转换为普通的 Python 整数

❷ 避免整数除法,转换为 np.float32

首先,我们计算整个时期的平均损失。由于损失是训练过程中要最小化的单一指标,我们始终希望能够跟踪它。然后,我们将损失平均限制为仅使用我们刚刚制作的negLabel_mask的那些带有负标签的样本。我们对正损失也是一样的。像这样计算每类损失在某种情况下是有用的,如果一个类别比另一个类别更难分类,那么这种知识可以帮助推动调查和改进。

我们将通过确定我们正确分类的样本比例以及每个标签的正确比例来结束计算,因为我们将在稍后将这些数字显示为百分比,所以我们还将这些值乘以 100。与损失类似,我们可以使用这些数字来帮助指导我们在进行改进时的努力。计算完成后,我们通过三次调用log.info记录我们的结果。

代码清单 11.18 training.py:289,LunaTrainingApp.logMetrics

log.info(
  ("E{} {:8} {loss/all:.4f} loss, "
     + "{correct/all:-5.1f}% correct, "
  ).format(
    epoch_ndx,
    mode_str,
    **metrics_dict,
  )
)
log.info(
  ("E{} {:8} {loss/neg:.4f} loss, "
     + "{correct/neg:-5.1f}% correct ({neg_correct:} of {neg_count:})"
  ).format(
    epoch_ndx,
    mode_str + '_neg',
    neg_correct=neg_correct,
    neg_count=neg_count,
    **metrics_dict,
  )
)
log.info(            # ❶
  # ... line 319
)

❶ “pos”日志与之前的“neg”日志类似。

第一个日志包含从所有样本计算得出的值,并标记为/all,而负(非结节)和正(结节)值分别标记为/neg/pos。我们这里不显示正值的第三个日志声明;它与第二个相同,只是在所有情况下将neg替换为pos

11.7 运行训练脚本

现在我们已经完成了 training.py 脚本的核心部分,我们将开始实际运行它。这将初始化和训练我们的模型,并打印关于训练进展情况的统计信息。我们的想法是在我们详细介绍模型实现的同时,将其启动在后台运行。希望我们完成后能够查看结果。

我们从主代码目录运行此脚本;它应该有名为 p2ch11、util 等的子目录。所使用的python环境应该安装了 requirements.txt 中列出的所有库。一旦这些库准备就绪,我们就可以运行:

$ python -m p2ch11.training        # ❶
Starting LunaTrainingApp,
    Namespace(batch_size=256, channels=8, epochs=20, layers=3, num_workers=8)
<p2ch11.dsets.LunaDataset object at 0x7fa53a128710>: 495958 training samples
<p2ch11.dsets.LunaDataset object at 0x7fa537325198>: 55107 validation samples
Epoch 1 of 20, 1938/216 batches of size 256
E1 Training ----/1938, starting
E1 Training   16/1938, done at 2018-02-28 20:52:54, 0:02:57
...

❶ 这是 Linux/Bash 的命令行。Windows 用户可能需要根据所使用的安装方法以不同方式调用 Python。

作为提醒,我们还提供了一个包含训练应用程序调用的 Jupyter 笔记本。

代码清单 11.19 code/p2_run_everything.ipynb

# In[5]:
run('p2ch11.prepcache.LunaPrepCacheApp')

# In[6]:
run('p2ch11.training.LunaTrainingApp', '--epochs=1')

如果第一个时代似乎需要很长时间(超过 10 或 20 分钟),这可能与需要准备 LunaDataset 需要的缓存数据有关。有关缓存的详细信息,请参阅第 10.5.1 节。第十章的练习包括编写一个脚本以有效地预先填充缓存。我们还提供了 prepcache.py 文件来执行相同的操作;可以使用 python -m p2ch11 .prepcache 调用它。由于我们每章都重复我们的 dsets.py 文件,因此缓存需要为每一章重复。这在一定程度上是空间和时间上的低效,但这意味着我们可以更好地保持每一章的代码更加完整。对于您未来的项目,我们建议更多地重用您的缓存。

一旦训练开始,我们要确保我们正在按照预期使用手头的计算资源。判断瓶颈是数据加载还是计算的一个简单方法是在脚本开始训练后等待几分钟(查看类似 E1 Training 16/7750, done at... 的输出),然后同时检查 topnvidia-smi

如果八个 Python 工作进程消耗了 >80% 的 CPU,那么缓存可能需要准备(我们知道这一点是因为作者已经确保在这个项目的实现中没有 CPU 瓶颈;这不会是普遍的情况)。

如果 nvidia-smi 报告 GPU-Util >80%,那么你的 GPU 已经饱和了。我们将在第 11.7.2 节讨论一些有效等待的策略。

我们的意图是 GPU 饱和;我们希望尽可能多地利用计算能力来快速完成时代。一块 NVIDIA GTX 1080 Ti 应该在 15 分钟内完成一个时代。由于我们的模型相对简单,CPU 不需要太多的预处理才能成为瓶颈。当处理更深的模型(或者总体需要更多计算的模型)时,处理每个批次将需要更长的时间,这将增加 CPU 处理的数量,以便在 GPU 在下一批输入准备好之前耗尽工作之前。

11.7.1 训练所需的数据

如果训练样本数量少于 495,958 个,验证样本数量少于 55,107 个,可能有必要进行一些合理性检查,以确保完整的数据已经准备就绪。对于您未来的项目,请确保您的数据集返回您期望的样本数量。

首先,让我们看一下我们的 data-unversioned/ part2/luna 目录的基本目录结构:

$ ls -1p data-unversioned/part2/luna/
subset0/
subset1/
...
subset9/

接下来,让我们确保每个系列 UID 都有一个 .mhd 文件和一个 .raw 文件

$ ls -1p data-unversioned/part2/luna/subset0/
1.3.6.1.4.1.14519.5.2.1.6279.6001.105756658031515062000744821260.mhd
1.3.6.1.4.1.14519.5.2.1.6279.6001.105756658031515062000744821260.raw
1.3.6.1.4.1.14519.5.2.1.6279.6001.108197895896446896160048741492.mhd
1.3.6.1.4.1.14519.5.2.1.6279.6001.108197895896446896160048741492.raw
...

以及我们是否有正确数量的文件:

$ ls -1 data-unversioned/part2/luna/subset?/* | wc -l
1776
$ ls -1 data-unversioned/part2/luna/subset0/* | wc -l
178
...
$ ls -1 data-unversioned/part2/luna/subset9/* | wc -l
176

如果所有这些看起来都正确,但事情仍然不顺利,请在 Manning LiveBook 上提问(livebook.manning.com/book/deep-learning-with-pytorch/chapter-11),希望有人可以帮助解决问题。

11.7.2 插曲:enumerateWithEstimate 函数

使用深度学习涉及大量的等待。我们谈论的是现实世界中坐在那里,看着墙上的时钟,一个看着的壶永远不会煮开(但你可以在 GPU 上煎蛋),纯粹的 无聊

唯一比坐在那里盯着一个一个小时都没有移动的闪烁光标更糟糕的是,让您的屏幕充斥着这些:

2020-01-01 10:00:00,056 INFO training batch 1234
2020-01-01 10:00:00,067 INFO training batch 1235
2020-01-01 10:00:00,077 INFO training batch 1236
2020-01-01 10:00:00,087 INFO training batch 1237
...etc...

至少安静闪烁的光标不会让你的滚动缓冲区溢出!

从根本上说,在所有这些等待的过程中,我们想要回答“我有时间去倒满水杯吗?”这个问题,以及关于是否有时间的后续问题

冲一杯咖啡

准备晚餐

在巴黎吃晚餐⁵

要回答这些紧迫的问题,我们将使用我们的 enumerateWithEstimate 函数。使用方法如下:

>>> for i, _ in enumerateWithEstimate(list(range(234)), "sleeping"):
...   time.sleep(random.random())
...
11:12:41,892 WARNING sleeping ----/234, starting
11:12:44,542 WARNING sleeping    4/234, done at 2020-01-01 11:15:16, 0:02:35
11:12:46,599 WARNING sleeping    8/234, done at 2020-01-01 11:14:59, 0:02:17
11:12:49,534 WARNING sleeping   16/234, done at 2020-01-01 11:14:33, 0:01:51
11:12:58,219 WARNING sleeping   32/234, done at 2020-01-01 11:14:41, 0:01:59
11:13:15,216 WARNING sleeping   64/234, done at 2020-01-01 11:14:43, 0:02:01
11:13:44,233 WARNING sleeping  128/234, done at 2020-01-01 11:14:35, 0:01:53
11:14:40,083 WARNING sleeping ----/234, done at 2020-01-01 11:14:40
>>>

这是超过 200 次迭代的 8 行输出。即使考虑到random.random()的广泛变化,该函数在 16 次迭代后(不到 10 秒)就有了相当不错的估计。对于具有更稳定时间的循环体,估计会更快地稳定下来。

就行为而言,enumerateWithEstimate与标准的enumerate几乎完全相同(差异在于我们的函数返回一个生成器,而enumerate返回一个专门的<enumerate object at 0x...>)。

列表 11.20 util.py:143,def enumerateWithEstimate

def enumerateWithEstimate(
    iter,
    desc_str,
    start_ndx=0,
    print_ndx=4,
    backoff=None,
    iter_len=None,
):
  for (current_ndx, item) in enumerate(iter):
    yield (current_ndx, item)

然而,副作用(特别是日志记录)才是使函数变得有趣的地方。与其陷入细节中试图覆盖实现的每个细节,如果您感兴趣,可以查阅函数文档字符串(github .com/deep-learning-with-pytorch/dlwpt-code/blob/master/util/util.py#L143)以获取有关函数参数的信息并对实现进行桌面检查。

深度学习项目可能非常耗时。知道何时预计完成意味着您可以明智地利用这段时间,它还可以提示您某些地方出了问题(或者某种方法行不通),如果预计完成时间远远超出预期。

11.8 评估模型:达到 99.7%的正确率意味着我们完成了,对吧?

让我们来看一下我们训练脚本的一些(缩减的)输出。作为提醒,我们使用命令行python -m p2ch11.training运行了这个脚本:

E1 Training ----/969, starting
...
E1 LunaTrainingApp
E1 trn      2.4576 loss,  99.7% correct
...
E1 val      0.0172 loss,  99.8% correct
...

经过一轮训练,训练集和验证集都显示至少 99.7%的正确结果。这是 A+!是时候来一轮高五,或者至少满意地点点头微笑了。我们刚刚解决了癌症!...对吧?

嗯,不是。

让我们更仔细地(不那么缩减地)看一下第 1 个时代的输出:

E1 LunaTrainingApp
E1 trn      2.4576 loss,  99.7% correct,
E1 trn_neg  0.1936 loss,  99.9% correct (494289 of 494743)
E1 trn_pos  924.34 loss,   0.2% correct (3 of 1215)
...
E1 val      0.0172 loss,  99.8% correct,
E1 val_neg  0.0025 loss, 100.0% correct (494743 of 494743)
E1 val_pos  5.9768 loss,   0.0% correct (0 of 1215)

在验证集上,我们对非结节的分类 100%正确,但实际结节却 100%错误。网络只是将所有东西都分类为非结节!数值 99.7%只意味着大约 0.3%的样本是结节。

经过 10 个时代,情况只是稍微好转:

E10 LunaTrainingApp
E10 trn      0.0024 loss,  99.8% correct
E10 trn_neg  0.0000 loss, 100.0% correct
E10 trn_pos  0.9915 loss,   0.0% correct
E10 val      0.0025 loss,  99.7% correct
E10 val_neg  0.0000 loss, 100.0% correct
E10 val_pos  0.9929 loss,   0.0% correct

分类输出保持不变--没有一个结节(也称为阳性)样本被正确识别。有趣的是,我们开始看到val_pos损失有所减少,然而,val_neg损失并没有相应增加。这意味着网络正在学习。不幸的是,它学习得非常,非常慢。

更糟糕的是,这种特定的失败模式在现实世界中是最危险的!我们希望避免将肿瘤误分类为无害的结构,因为这不会促使患者接受可能需要的评估和最终治疗。了解所有项目的误分类后果很重要,因为这可能会对您设计、训练和评估模型的方式产生很大影响。我们将在下一章中更详细地讨论这个问题。

然而,在此之前,我们需要升级我们的工具,使结果更易于理解。我们相信您和任何人一样喜欢盯着数字列,但图片价值千言。让我们绘制一些这些指标的图表。

11.9 使用 TensorBoard 绘制训练指标图表

我们将使用一个名为 TensorBoard 的工具,作为一种快速简便的方式,将我们的训练指标从训练循环中提取出来,并呈现为一些漂亮的图表。这将使我们能够跟踪这些指标的趋势,而不仅仅查看每个时代的瞬时值。当您查看可视化表示时,要知道一个值是异常值还是趋势的最新值就容易得多。

“嘿,等等”,您可能会想,“TensorBoard 不是 TensorFlow 项目的一部分吗?它在我的 PyTorch 书中做什么?”

嗯,是的,它是另一个深度学习框架的一部分,但我们的理念是“使用有效的工具”。没有理由限制自己不使用一个工具,只因为它捆绑在我们不使用的另一个项目中。PyTorch 和 TensorBoard 的开发人员都同意,因为他们合作将 TensorBoard 的官方支持添加到 PyTorch 中。TensorBoard 很棒,它有一些易于使用的 PyTorch API,让我们可以将数据从几乎任何地方连接到其中进行快速简单的显示。如果您坚持深度学习,您可能会看到(并使用)很多 TensorBoard。

实际上,如果您一直在运行本章的示例,您应该已经有一些准备好并等待显示的数据在磁盘上。让我们看看如何运行 TensorBoard,并查看它可以向我们展示什么。

11.9.1 运行 TensorBoard

默认情况下,我们的训练脚本将指标数据写入 runs/ 子目录。如果在 Bash shell 会话期间列出目录内容,您可能会看到类似于以下内容:

$ ls -lA runs/p2ch11/
total 24
drwxrwxr-x 2 elis elis 4096 Sep 15 13:22 2020-01-01_12.55.27-trn-dlwpt/  # ❶
drwxrwxr-x 2 elis elis 4096 Sep 15 13:22 2020-01-01_12.55.27-val-dlwpt/  # ❶
drwxrwxr-x 2 elis elis 4096 Sep 15 15:14 2020-01-01_13.31.23-trn-dwlpt/  # ❷
drwxrwxr-x 2 elis elis 4096 Sep 15 15:14 2020-01-01_13.31.23-val-dwlpt/  # ❷

❶ 之前的单次运行

❷ 最近的 10 次训练运行

要获取 tensorboard 程序,请安装 tensorflow (pypi.org/project/tensorflow) Python 包。由于我们实际上不会使用 TensorFlow 本身,所以如果您安装默认的仅 CPU 包也是可以的。如果您已经安装了另一个版本的 TensorBoard,那也没问题。确保适当的目录在您的路径上,或者使用 ../path/to/tensorboard --logdir runs/ 来调用它。从哪里调用它并不重要,只要您使用 --logdir 参数将其指向存储数据的位置即可。最好将数据分隔到单独的文件夹中,因为一旦进行了 10 或 20 次实验,TensorBoard 可能会变得有些难以管理。您将不得不在每个项目中决定最佳的做法。如果需要,随时移动数据也是个好主意。

现在让我们开始 TensorBoard 吧:

$ tensorboard --logdir runs/
2020-01-01 12:13:16.163044: I tensorflow/core/platform/cpu_feature_guard.cc:140]# ❶
    Your CPU supports instructions that this TensorFlow binary was not compiled to use: AVX2 FMA  1((CO17-2))
TensorBoard 1.14.0 at http://localhost:6006/ (Press CTRL+C to quit)

❶ 这些消息可能对您来说是不同的或不存在的;这没关系。

完成后,您应该能够将浏览器指向 http://localhost:6006 并查看主仪表板。图 11.10 展示了这是什么样子。

图 11.10 主要的 TensorBoard 用户界面,显示了一对训练和验证运行

在浏览器窗口的顶部,您应该看到橙色的标题。标题的右侧有用于设置的典型小部件,一个指向 GitHub 存储库的链接等。我们现在可以忽略这些。标题的左侧有我们提供的数据类型的项目。您至少应该有以下内容:

  • 标量(默认选项卡)

  • 直方图

  • 精确-召回曲线(显示为 PR 曲线)

您可能还会看到分布,以及图 11.10 中标量右侧的第二个 UI 选项卡。我们这里不会使用或讨论这些。确保您已经通过单击选择了标量。

左侧是一组用于显示选项的控件,以及当前存在的运行列表。如果您的数据特别嘈杂,平滑选项可能会很有用;它会使事情变得平静,这样您就可以找出整体趋势。原始的非平滑数据仍然以相同颜色的淡线的形式显示在背景中。图 11.11 展示了这一点,尽管在黑白打印时可能难以辨认。

图 11.11 带有平滑设置为 0.6 和选择了两个运行以显示的 TensorBoard 侧边栏

根据您运行训练脚本的次数,您可能有多个运行可供选择。如果呈现的运行太多,图表可能会变得过于嘈杂,所以不要犹豫在目前不感兴趣的情况下取消选择运行。

如果你想永久删除一个运行,可以在 TensorBoard 运行时从磁盘中删除数据。您可以这样做来摆脱崩溃、有错误、不收敛或太旧不再有趣的实验。运行的数量可能会增长得相当快,因此经常修剪并重命名运行或将特别有趣的运行移动到更永久的目录以避免意外删除是有帮助的。要删除trainvalidation运行,执行以下操作(在更改章节、日期和时间以匹配要删除的运行之后):

$ rm -rf runs/p2ch11/2020-01-01_12.02.15_*

请记住,删除运行将导致列表中后面的运行向上移动,这将导致它们被分配新的颜色。

好的,让我们来谈谈 TensorBoard 的要点:漂亮的图表!屏幕的主要部分应该填满了从收集训练和验证指标中得到的数据,如图 11.12 所示。

图 11.12 主要的 TensorBoard 数据显示区域向我们展示了我们在实际结节上的结果非常糟糕

比起E1 trn_pos 924.34 loss, 0.2% correct (3 of 1215),这样解析和吸收起来要容易得多!虽然我们将把讨论这些图表告诉我们的内容保存到第 11.10 节,现在是一个好时机确保清楚这些数字对应我们的训练程序中的内容。花点时间交叉参考你通过鼠标悬停在线条上得到的数字和训练.py 在同一训练运行期间输出的数字。你应该看到工具提示的值列和训练期间打印的值之间有直接对应关系。一旦你对 TensorBoard 显示的内容感到舒适和自信,让我们继续讨论如何让这些数字首次出现。

11.9.2 将 TensorBoard 支持添加到度量记录函数

我们将使用torch.utils.tensorboard模块以 TensorBoard 可消费的格式编写数据。这将使我们能够快速轻松地为此项目和任何其他项目编写指标。TensorBoard 支持 NumPy 数组和 PyTorch 张量的混合使用,但由于我们没有将数据放入 NumPy 数组的理由,我们将专门使用 PyTorch 张量。

我们需要做的第一件事是创建我们的SummaryWriter对象(我们从torch.utils.tensorboard导入)。我们将传入的唯一参数初始化为类似runs/p2ch11/2020-01-01_12 .55.27-trn-dlwpt的内容。我们可以在我们的训练脚本中添加一个注释参数,将dlwpt更改为更具信息性的内容;使用python -m p2ch11.training --help获取更多信息。

我们创建两个写入器,一个用于训练运行,一个用于验证运行。这些写入器将在每个时代重复使用。当SummaryWriter类被初始化时,它还会作为副作用创建log_dir目录。如果训练脚本在写入任何数据之前崩溃,这些目录将显示在 TensorBoard 中,并且可能会用空运行杂乱 UI,这在你尝试某些东西时很常见。为了避免写入太多空的垃圾运行,我们等到准备好第一次写入数据时才实例化SummaryWriter对象。这个函数从logMetrics()中调用。

列表 11.21 training.py:127,.initTensorboardWriters

def initTensorboardWriters(self):
  if self.trn_writer is None:
    log_dir = os.path.join('runs', self.cli_args.tb_prefix, self.time_str)

    self.trn_writer = SummaryWriter(
      log_dir=log_dir + '-trn_cls-' + self.cli_args.comment)
    self.val_writer = SummaryWriter(
      log_dir=log_dir + '-val_cls-' + self.cli_args.comment)

如果你回忆起来,第一个时代有点混乱,训练循环中的早期输出基本上是随机的。当我们保存来自第一批次的指标时,这些随机结果最终会使事情有点偏斜。从图 11.11 中可以看出,TensorBoard 具有平滑功能,可以消除趋势线上的噪音,这在一定程度上有所帮助。

另一种方法可能是在第一个 epoch 的训练数据中完全跳过指标,尽管我们的模型训练速度足够快,仍然有必要查看第一个 epoch 的结果。随意根据需要更改此行为;第 2 部分的其余部分将继续采用包括第一个嘈杂训练 epoch 的模式。

提示 如果你最终进行了许多实验,导致异常或相对快速终止训练脚本,你可能会留下许多垃圾运行,混乱了你的 runs/目录。不要害怕清理它们!

向 TensorBoard 写入标量

写入标量很简单。我们可以取出已经构建的metrics_dict,并将每个键值对传递给writer.add_scalar方法。torch.utils.tensorboard.SummaryWriter类具有add_scalar方法( mng.bz/RAqj),具有以下签名。

代码清单 11.22 PyTorch torch/utils/tensorboard/writer.py:267

def add_scalar(self, tag, scalar_value, global_step=None, walltime=None):
    # ...

tag参数告诉 TensorBoard 我们要向哪个图形添加值,scalar_value参数是我们数据点的 Y 轴值。global_step参数充当 X 轴值。

请记住,我们在doTraining函数内更新了totalTrainingSamples_count变量。我们将通过将其作为global_step参数传入来将totalTrainingSamples_count用作我们 TensorBoard 图表的 X 轴。以下是我们代码中的示例。

代码清单 11.23 training.py:323,LunaTrainingApp.logMetrics

for key, value in metrics_dict.items():
  writer.add_scalar(key, value, self.totalTrainingSamples_count)

请注意,我们键名中的斜杠(例如'loss/all')导致 TensorBoard 通过斜杠前的子字符串对图表进行分组。

文档建议我们应该将 epoch 数作为global_step参数传入,但这会导致一些复杂性。通过使用向网络呈现的训练样本数,我们可以做一些事情,比如改变每个 epoch 的样本数,仍然能够将未来的图形与我们现在创建的图形进行比较。如果每个 epoch 花费的时间是四倍长,那么说一个模型在一半的 epoch 中训练是没有意义的!请记住,这可能不是标准做法;然而,预计会看到各种值用于全局步骤。

11.10 为什么模型无法学习检测结节?

我们的模型显然在学习某些东西--随着 epoch 增加,损失趋势线是一致的,结果是可重复的。然而,模型正在学习的内容与我们希望它学习的内容之间存在分歧。发生了什么?让我们用一个简单的比喻来说明问题。

想象一下,一位教授给学生一份包含 100 个真/假问题的期末考试。学生可以查阅这位教授过去 30 年的考试版本,每次只有一个或两个问题的答案是 True。其他 98 或 99 个问题每次都是 False。

假设分数不是按曲线划分的,而是有一个典型的 90%正确或更高为 A 的等级刻度,便很容易获得 A+:只需将每个问题标记为 False!让我们想象今年只有一个 True 答案。像图 11.13 中左侧的学生那样毫无头绪地将每个答案标记为 False 的学生会在期末考试中得到 99%的分数,但实际上并没有证明他们学到了什么(除了如何从旧测试中临时抱佛脚)。这基本上就是我们的模型目前正在做的事情。

图 11.13 一位教授给予两名学生相同的分数,尽管知识水平不同。问题 9 是唯一一个答案为 True 的问题。

将其与右侧学生进行对比,右侧学生也回答了 99%的问题,但是通过回答两个问题为 True 来实现。直觉告诉我们,图 11.13 中右侧的学生可能比所有回答为 False 的学生更好地掌握了材料。在只有一个错误答案的情况下找到一个正确答案是相当困难的!不幸的是,我们的学生分数和我们模型的评分方案都没有反映这种直觉。

我们有一个类似的情况,99.7%的“这个候选人是结节吗?”的答案是“不是”。我们的模型正在采取简单的方式,对每个问题都回答 False。

然而,如果我们更仔细地查看模型的数字,训练集和验证集上的损失在减少!我们在癌症检测问题上取得任何进展都应该给我们带来希望。下一章的工作将是实现这一潜力。我们将在第十二章开始时介绍一些新的相关术语,然后我们将提出一个更好的评分方案,不像我们迄今为止所做的那样容易被操纵。

11.11 结论

本章我们走了很长的路--我们现在有了一个模型和一个训练循环,并且能够使用我们在上一章中生成的数据。我们的指标不仅被记录在控制台上,还以图形方式呈现。

虽然我们的结果还不能使用,但实际上我们比看起来更接近。在第十二章中,我们将改进用于跟踪进度的指标,并利用它们来指导我们需要做出的改变,以使我们的模型产生合理的结果。

11.12 练习

  1. 实现一个程序,通过将LunaDataset实例包装在DataLoader实例中来迭代,同时计时完成此操作所需的时间。将这些时间与第十章练习中的时间进行比较。在运行脚本时要注意缓存的状态。

    1. num_workers=...设置为 0、1 和 2 会产生什么影响?

    2. 在给定batch_size=...num_workers=...组合下,您的机器支持的最高值是多少,而不会耗尽内存?

  2. 颠倒noduleInfo_list的排序顺序。在训练一个周期后,模型的行为会如何改变?

  3. logMetrics更改为修改在 TensorBoard 中使用的运行和键的命名方案。

    1. 尝试不同的斜杠放置方式,将键传递给writer.add_scalar

    2. 让训练和验证运行使用相同的写入器,并在键的名称中添加trnval字符串。

    3. 自定义日志目录和键的命名以适应您的口味。

11.13 总结

  • 数据加载器可以在多个进程中从任意数据集加载数据。这使得否则空闲的 CPU 资源可以用于准备数据以供 GPU 使用。

  • 数据加载器从数据集中加载多个样本并将它们整理成一个批次。PyTorch 模型期望处理数据批次,而不是单个样本。

  • 数据加载器可以通过改变个别样本的相对频率来操作任意数据集。这允许对数据集进行“售后”调整,尽管直接更改数据集实现可能更合理。

  • 我们将在第二部分中使用 PyTorch 的torch.optim.SGD(随机梯度下降)优化器,学习率为 0.001,动量为 0.99。这些值也是许多深度学习项目的合理默认值。

  • 我们用于分类的初始模型将与第八章中使用的模型非常相似。这让我们可以开始使用一个我们有理由相信会有效的模型。如果我们认为模型设计是阻止项目表现更好的原因,我们可以重新审视模型设计。

  • 训练过程中监控的指标选择很重要。很容易不小心选择那些对模型表现误导性的指标。使用样本分类正确的整体百分比对我们的数据没有用处。第十二章将详细介绍如何评估和选择更好的指标。

  • TensorBoard 可以用来直观显示各种指标。这使得消化某些形式的信息(特别是趋势数据)在每个训练周期中发生变化时更容易。


¹ 任何 shell 都可以,但如果你使用的是非 Bash shell,你已经知道这一点。

² 请记住,尽管是 2D 图,但我们实际上是在 3D 中工作。

³ 这就是为什么下一章有一个练习来尝试两者的原因!

⁴ 这样做有数值稳定性的好处。通过使用 32 位浮点数计算的指数来准确传播梯度可能会有问题。

⁵ 如果在法国吃晚餐不涉及机场,可以随意用“Texas 的巴黎”来制造笑话;en.wikipedia.org/wiki/Paris_(disambiguation)

⁶ 如果你在不同的计算机上运行训练,你需要用适当的主机名或 IP 地址替换localhost

十二、通过指标和增强改进训练

本章涵盖

  • 定义和计算精确率、召回率以及真/假阳性/阴性

  • 使用 F1 分数与其他质量指标

  • 平衡和增强数据以减少过拟合

  • 使用 TensorBoard 绘制质量指标图

上一章的结束让我们陷入了困境。虽然我们能够将深度学习项目的机制放置好,但实际上没有任何结果是有用的;网络只是将一切都分类为非结节!更糟糕的是,结果表面看起来很好,因为我们正在查看训练和验证集中被正确分类的整体百分比。由于我们的数据严重倾向于负样本,盲目地将一切都视为负面是我们的模型快速得分的一种简单而快速的方法。太糟糕了,这样做基本上使模型无用!

这意味着我们仍然专注于与第十一章相同的图 12.1 的同一部分。但现在我们正在努力使我们的分类模型工作良好而不是只是工作。本章重点讨论如何衡量、量化、表达,然后改进我们的模型执行工作的能力。

图 12.1 我们的端到端肺癌检测项目,重点放在本章的主题上:第 4 步,分类

12.1 改进的高层计划

虽然有点抽象,图 12.2 向我们展示了我们将如何处理那些广泛的主题。

让我们详细地走过本章的这张有些抽象的地图。我们将处理我们面临的问题,比如过度关注单一、狭窄的指标以及由此产生的行为在一般意义上是无用的。为了使本章的一些概念更具体化,我们将首先使用一个比喻来将我们的困境更具体化:在图 12.2 中,(1)看门狗和(2)鸟和窃贼。

图 12.2 我们将使用的比喻来修改衡量我们模型的指标,使其变得出色

之后,我们将开发一个图形语言来代表上一章实施中所需的核心概念:(3)比率:召回率和精确率。一旦我们将这些概念巩固下来,我们将涉及一些使用这些概念的数学,这将包括一种更健壮的评估我们模型性能的方式,并将其压缩为一个数字:(4)新指标:F1 分数。我们将实施这些新指标的公式,并查看在训练过程中每个时期这些结果值如何变化。最后,我们将对我们的LunaDataset实现进行一些急需的更改,以改善我们的训练结果:(5)平衡和(6)增强。然后我们将看看这些实验性的更改是否对我们的性能指标产生了预期的影响。

到本章结束时,我们训练的模型将表现得更好:(7)工作得很棒!虽然它还没有准备好立即投入临床使用,但它将能够产生明显优于随机的结果。这意味着我们已经有了可行的第 4 步实现,结节候选分类;一旦完成,我们可以开始考虑如何将第 2 步(分割)和第 3 步(分组)纳入项目中。

12.2 好狗与坏家伙:假阳性和假阴性

我们不再考虑模型和肿瘤,而是考虑图 12.3 中的两只看门狗,它们刚从服从学校毕业。它们都想警告我们有窃贼——这是一种罕见但严重的情况,需要及时处理。

图 12.3 本章的主题集,重点放在框架比喻上

不幸的是,虽然两只狗都是好狗,但都不是好的警卫狗。我们的梗犬(Roxie)对几乎所有事情都会吠,而我们的老猎犬(Preston)几乎只会对入室者吠叫——但前提是他在他们到达时恰好醒着。

Roxie 几乎每次都会警告我们有入室者。她还会警告我们有消防车、雷暴、直升机、鸟、邮递员、松鼠、路人等。如果我们对每次吠叫进行跟进,我们几乎永远不会被抢劫(只有最狡猾的偷窃者才能溜过)。完美!... 除了那么勤奋意味着我们实际上并没有通过养警卫狗节省任何工作。相反,我们每隔几个小时就会起床,手持手电筒,因为 Roxie 闻到了猫的气味,或者听到了猫头鹰的叫声,或者看到了一辆晚点的公共汽车经过。Roxie 有一个问题性的假阳性数量。

假阳性是被分类为感兴趣或所需类别的成员(阳性表示“是的,这是我感兴趣了解的类型”)的事件,但实际上并不是真正感兴趣的。对于结节检测问题,当一个实际上无趣的候选者被标记为结节,因此需要放射科医生的关注时,就会发生假阳性。对于 Roxie 来说,这些可能是消防车、雷暴等。在接下来的章节和随后的图中,我们将使用一张猫的图片作为典型的假阳性。

将假阳性与真阳性进行对比:被正确分类的感兴趣项目。这些将在图中由一个人类强盗表示。

与此同时,如果 Preston 吠叫,请立即报警,因为这意味着几乎肯定有人闯入,房子着火了,或者哥斯拉在袭击。然而,Preston 睡得很沉,正在进行家庭入侵的声音不太可能唤醒他,所以每当有人尝试时,我们几乎总是会被抢劫。虽然比没有好,但我们并没有真正获得最初让我们养狗的平静心态。Preston 有一个问题性的假阴性数量。

假阴性是被分类为不感兴趣或不是所需类别的成员(阴性表示“不,这不是我感兴趣了解的类型”)的事件,但实际上确实是感兴趣的。对于结节检测问题,当一个结节(即潜在的癌症)未被检测到时,就会发生假阴性。对于 Preston 来说,这些将是他睡过的抢劫案。在这里我们将有点创意,使用一张啮齿强盗的图片来代表假阴性。它们很狡猾!

将假阴性与真阴性进行对比:正确识别为无趣项目的项目。我们将用一只鸟的图片来代表这些。

为了完成这个比喻,第十一章的模型基本上是一只拒绝对不是金鱼罐的任何东西发出喵声的猫(同时坚定地忽视 Roxie)。我们在上一章末尾的重点是整体训练和验证集的正确百分比。显然,这不是一个很好的评分方式,正如我们从我们每只狗对单一指标的近视关注——比如真阳性或真阴性的数量——可以看出的那样,我们需要一个更广泛关注的指标来捕捉我们的整体表现。

12.3 绘制阳性和阴性

让我们开始制定我们将用来描述真/假阳性/阴性的视觉语言。如果我们的解释变得重复,请耐心等待;我们希望确保您对我们将要讨论的比率形成坚实的心理模型。考虑图 12.4,显示了可能对我们其中一只警卫狗感兴趣的事件。

图 12.4 中的猫、鸟、啮齿动物和强盗构成了我们的四个分类象限。它们由人类标签和狗的分类阈值分隔。

在图 12.4 中,我们将使用两个阈值。第一个是人为决定的将入室盗窃犯与无害动物分开的分界线。具体来说,这是为每个训练或验证样本分配的标签。第二个是狗确定的分类阈值,它决定了狗是否会对某物吠叫。对于深度学习模型,这是在考虑样本时模型产生的预测值。

这两个阈值的组合将我们的事件分成四个象限:真/假阳性/阴性。我们将关注的事件用较深的背景色进行阴影处理(因为那些坏家伙总是在黑暗中潜行)。

当然,现实要复杂得多。并不存在一个关于入室盗窃犯的柏拉图理想,也没有一个相对于分类阈值的单一点,所有入室盗窃犯都会在那里。相反,图 12.5 向我们展示了一些入室盗窃犯会特别狡猾,一些鸟类会特别烦人。我们还将把我们的实例放在一个图中。我们的 X 轴将保持每个事件的吠声价值,由我们的一只看门狗确定。我们将让 Y 轴代表我们作为人类能够感知的一些模糊特质,但我们的狗却无法感知。

由于我们的模型产生二元分类,我们可以将预测阈值视为将单一数值输出与我们的分类阈值值进行比较。这就是为什么我们要求图 12.5 中的分类阈值线是完全垂直的。

图 12.5 每种事件都会有许多可能的实例,我们的看门狗需要评估。

每个可能的入室盗窃犯都是不同的,因此我们的看门狗将需要评估许多不同的情况,这意味着更多犯错的机会。我们可以看到明显的对角线将鸟类与入室盗窃犯分开,但普雷斯顿和洛克西只能在这里感知 X 轴:他们在我们的图中间有一组混乱、重叠的事件。他们必须选择一个垂直的吠声价值阈值,这意味着他们中的任何一个都不可能完美地做到。有时候把你的家电搬到他们的货车上的人是你雇来修理洗衣机的维修人员,有时候入室盗窃犯会开着一辆侧面写着“洗衣机维修”的货车出现。期望狗能够察觉到这些微妙之处注定会失败。

我们要使用的实际输入数据具有高维度--我们需要考虑大量 CT 体素值,以及更抽象的事物,如候选大小、在肺部的整体位置等等。我们模型的工作是将每个事件及其属性映射到这个矩形中,以便我们可以使用单一垂直线(我们的分类阈值)清晰地分离这些正面和负面事件。这是通过我们模型末端的nn.Linear层完成的。垂直线的位置与我们在第 11.6.1 节中看到的classificationThreshold_float完全对应。在那里,我们选择了硬编码值 0.5 作为我们的阈值。

请注意,实际上,所呈现的数据不是二维的;在倒数第二层之后,它变成了非常高维度,到输出时变成了一维(这里是我们的 X 轴)--每个样本只有一个标量(然后被分类阈值二分)。在这里,我们使用第二维(Y 轴)来表示我们的模型无法看到或使用的每个样本特征:例如患者的年龄或性别,结节候选在肺部的位置,甚至模型尚未利用的候选局部特征。它还为我们提供了一种方便的方式来表示非结节和结节样本之间的混淆。

图 12.5 中的象限区域和每个区域中包含的样本数将是我们用来讨论模型性能的值,因为我们可以使用这些值之间的比率来构建越来越复杂的指标,以客观地衡量我们的表现。正如他们所说,“证据在于比例。”¹ 接下来,我们将使用这些事件子集之间的比率来开始定义更好的指标。

12.3.1 召回率是罗克西的优势

召回率基本上是“确保你永远不会错过任何有趣的事件!”正式地说,召回率是真阳性与真阳性和假阴性的并集的比率。我们可以在图 12.6 中看到这一点。

图 12.6 召回率是真阳性与真阳性和假阴性的并集的比率。高召回率可以最小化假阴性。

在某些情境中,召回率被称为敏感性

为了提高召回率,要尽量减少假阴性。在看门狗的术语中,这意味着如果你不确定,就叫一声,以防万一。不要让任何啮齿动物小偷在你的监视下溜走!

罗克西通过将分类阈值推到最左边,使其包含图 12.7 中几乎所有的正面事件,从而实现了极高的召回率。请注意,这样做意味着她的召回值接近 1.0,即 99%的盗贼都会被叫唤。由于这是罗克西定义成功的方式,在她看来,她做得很好。不要在意大量的假阳性!

图 12.7 罗克西选择的阈值优先考虑减少假阴性。每只老鼠都会被叫唤……还有猫,大多数鸟。

12.3.2 精确性是普雷斯顿的长处

精确性基本上是“除非你确定,否则不要叫。”为了提高精确性,要尽量减少假阳性。普雷斯顿不会对某事物叫唤,除非他确定那是一个盗贼。更正式地说,精确性是真阳性与真阳性和假阳性的并集的比率,如图 12.8 所示。

图 12.8 精确性是真阳性与真阳性和假阳性的并集的比率。高精确性可以最小化假阳性。

普雷斯顿通过将分类阈值推到最右边,排除尽可能多的无趣、负面事件,从而实现了极高的精确性(见图 12.9)。这与罗克西的方法相反,意味着普雷斯顿的精确性接近 1.0:他叫的 99%的事物都是盗贼。尽管有大量事件未被检测到,但这也符合他作为一只好看门狗的定义。

虽然精确性和召回率都不能作为评估我们模型的单一指标,但它们在训练过程中是有用的数字。让我们计算并显示这些作为我们训练程序的一部分,然后我们将讨论其他可以使用的指标。

图 12.9 普雷斯顿选择的阈值优先考虑减少假阳性。猫被放过,只有盗贼会被叫唤!

12.3.3 在 logMetrics 中实现精确性和召回率

在训练过程中,精确性和召回率都是有价值的指标,因为它们提供了关于模型行为的重要见解。如果它们中的任何一个降至零(正如我们在第十一章中看到的!),那么我们的模型可能已经开始表现出退化的方式。我们可以使用行为的确切细节来指导我们在哪里进行调查和实验,以使训练重新回到正轨。我们希望更新logMetrics函数,以在每个时期的输出中添加精确性和召回率,以补充我们已经拥有的损失和正确性指标。

到目前为止,我们一直在以“真阳性”等术语定义精确度和召回率,因此我们将在代码中继续这样做。事实证明,我们已经计算了一些我们需要的值,尽管我们给它们起了不同的名称。

列表 12.1 training.py:315,LunaTrainingApp.logMetrics

neg_count = int(negLabel_mask.sum())
pos_count = int(posLabel_mask.sum())

trueNeg_count = neg_correct = int((negLabel_mask & negPred_mask).sum())
truePos_count = pos_correct = int((posLabel_mask & posPred_mask).sum())

falsePos_count = neg_count - neg_correct
falseNeg_count = pos_count - pos_correct

在这里,我们可以看到neg_correcttrueNeg_count是相同的!这其实是有道理的,因为非结节是我们的“负面”值(如“负面诊断”),如果分类器预测正确,那么这就是真阴性。同样,正确标记的结节样本是真阳性。

我们确实需要添加我们的假阳性和假阴性值的变量。这很简单,因为我们可以取良性标签的总数并减去正确的计数。剩下的是被误分类为阳性的非结节样本的计数。因此,它们是假阳性。同样,假阴性计算形式相同,但使用结节计数。

有了这些数值,我们可以计算precisionrecall,并将它们存储在metrics_dict中。

列表 12.2 training.py:333,LunaTrainingApp.logMetrics

precision = metrics_dict['pr/precision'] = \
  truePos_count / np.float32(truePos_count + falsePos_count)
recall  = metrics_dict['pr/recall'] = \
  truePos_count / np.float32(truePos_count + falseNeg_count)

注意双重赋值:虽然有单独的precisionrecall变量并不是绝对必要的,但它们提高了下一节的可读性。我们还扩展了logMetrics中的日志语句以包括新值,但我们暂时跳过实现(我们将在本章稍后重新讨论日志记录)。

12.3.4 我们的终极性能指标:F1 分数

尽管有用,但精确度和召回率都无法完全捕捉我们评估模型所需的内容。正如我们在 Roxie 和 Preston 中看到的,通过操纵我们的分类阈值,可能会单独操纵其中一个,导致模型在其中一个上得分良好,但以牺牲任何实际效用为代价。我们需要一种以防止这种操纵的方式结合这两个值的东西。正如我们在图 12.10 中看到的,现在是引入我们的终极指标的时候了。

通常接受的结合精确度和召回率的方法是使用 F1 分数(en.wikipedia.org/wiki/F1_score)。与其他指标一样,F1 分数的范围在 0(没有真实世界预测能力的分类器)和 1(具有完美预测的分类器)之间。我们将更新logMetrics以包括这一点。

列表 12.3 training.py:338,LunaTrainingApp.logMetrics

metrics_dict['pr/f1_score'] = \
  2 * (precision * recall) / (precision + recall)

乍一看,这可能比我们需要的更复杂,当在精确度和召回率之间进行权衡时,F1 分数的行为可能不会立即显而易见。然而,这个公式有很多好的性质,并且与我们可能考虑的几种其他更简单的替代方案相比较有利。

图 12.10 本章的主题集,重点是最终的 F1 分数指标

一个立即可能的评分函数是将精确度和召回率的值平均起来。不幸的是,这使得avg(p=1.0, r=0.0)avg(p=0.5, r=0.5)都得到相同的 0.5 分数,正如我们之前讨论的,精确度或召回率为零的分类器通常是无用的。将无用的东西与有用的东西赋予相同的非零分数,立即使平均成为一个没有意义的指标。

然而,让我们在图 12.11 中直观比较平均和 F1。有几件事引人注目。首先,我们可以看到平均值的等高线中没有曲线或拐点。这就是让我们的精确度或召回率偏向一侧的原因!永远不会出现这样的情况,即通过使召回率达到 100%(Roxie 方法)然后消除任何容易消除的假阳性来最大化分数是没有意义的。这就为添加分数至少为 0.5 设置了一个底线!拥有一个质量指标,可以轻松获得至少 50% 的分数,感觉不对劲。

图 12.11 使用avg(p, r)计算最终分数。较浅的值接近 1.0。

注意 我们实际上在这里做的是取精确度和召回率的算术平均值en.wikipedia.org/wiki/Arithmetic_mean),这两者都是比率而不是可计数的标量值。取比率的算术平均值通常不会给出有意义的结果。F1 分数是两个比率的调和平均值en.wikipedia.org/wiki/Harmonic_mean)的另一个名称,这是结合这些值的更合适的方式。

与 F1 分数相比:当召回率高而精确度低时,为了将分数移动到平衡的甜蜜点,牺牲很多召回率以换取一点精确度将使分数更接近。有一个漂亮、深刻的拐点,很容易滑入其中。鼓励具有平衡精确度和召回率是我们希望从我们的评分指标中得到的。

假设我们仍然希望有一个更简单的指标,但不会奖励任何偏斜。为了纠正加法的弱点,我们可能会取精确度和召回率的最小值(图 12.12)。

图 12.12 使用min(p, r)计算最终分数

这很好,因为如果任一值为 0,分数也为 0,而要获得 1.0 的分数的唯一方法是两个值都为 1.0。然而,它仍然有待改进,因为使召回率从 0.7 提高到 0.9 而将精确度保持在 0.5 不会改善分数,降低召回率到 0.6 也不会改善分数!尽管这个指标肯定惩罚了精确度和召回率之间的不平衡,但它并没有捕捉到关于这两个值的许多细微差别。正如我们所见,通过简单地移动分类阈值,很容易将一个值换成另一个值。我们希望我们的指标能反映这些交易。

为了更好地实现我们的目标,我们将不得不接受至少更复杂一点。我们可以将这两个值相乘,如图 12.13 所示。这种方法保持了一个很好的特性,即如果任一值为 0,分数也为 0,而分数为 1.0 意味着两个输入都完美。它还有利于在低值处精确度和召回率之间的平衡折衷,尽管当接近完美结果时,它变得更加线性。这并不好,因为我们真的需要将两者都提高才能在那一点上有意义的改进。

图 12.13 使用mult(p, r)计算最终分数

注意 在这里我们正在取两个比率的几何平均值en.wikipedia.org/wiki/Geometric_mean),这也不会产生有意义的结果。

还有一个问题,几乎整个象限从(0, 0)到(0.5, 0.5)都非常接近于零。正如我们将看到的,拥有一个对该区域的变化敏感的指标是重要的,特别是在我们模型设计的早期阶段。

虽然将乘法作为我们的评分函数是可行的(它没有任何立即淘汰的资格,就像之前的评分函数一样),但我们将使用 F1 分数来评估我们的分类模型的性能。

更新日志输出以包括精确度、召回率和 F1 分数

现在我们有了新的指标,将它们添加到我们的日志输出中非常简单。我们将在我们的训练和验证集的主要日志声明中包括精确度、召回率和 F1。

列表 12.4 training.py:341, LunaTrainingApp.logMetrics

log.info(
  ("E{} {:8} {loss/all:.4f} loss, "
     + "{correct/all:-5.1f}% correct, "
     + "{pr/precision:.4f} precision, "   # ❶
     + "{pr/recall:.4f} recall, "         # ❶
     + "{pr/f1_score:.4f} f1 score"       # ❶
  ).format(
    epoch_ndx,
    mode_str,
    **metrics_dict,
  )
)

❶ 格式字符串已更新

另外,我们将为每个负样本和正样本的正确识别计数和总样本数包括精确值。

列表 12.5 training.py:353, LunaTrainingApp.logMetrics

log.info(
  ("E{} {:8} {loss/neg:.4f} loss, "
     + "{correct/neg:-5.1f}% correct ({neg_correct:} of {neg_count:})"
  ).format(
    epoch_ndx,
    mode_str + '_neg',
    neg_correct=neg_correct,
    neg_count=neg_count,
    **metrics_dict,
  )
)

新版本的正日志声明看起来基本相同。

12.3.5 我们的模型如何使用我们的新指标?

现在我们已经实施了闪亮的新指标,让我们试试它们;我们将在展示 Bash shell 会话结果后讨论结果。在您的系统进行数字计算时,您可能想提前阅读;这可能需要大约半个小时,具体时间取决于您的系统。实际所需时间取决于您的系统的 CPU、GPU 和磁盘速度;我们的系统配备 SSD 和 GTX 1080 Ti,每个完整时期大约需要 20 分钟:

$ ../.venv/bin/python -m p2ch12.training
Starting LunaTrainingApp...
...
E1 LunaTrainingApp

.../p2ch12/training.py:274: RuntimeWarning: invalid value encountered in double_scalars
  metrics_dict['pr/f1_score'] = 2 * (precision * recall) / (precision + recall)                                          # ❶

E1 trn      0.0025 loss,  99.8% correct, 0.0000 prc, 0.0000 rcl, nan f1
E1 trn_ben  0.0000 loss, 100.0% correct (494735 of 494743)
E1 trn_mal  1.0000 loss,   0.0% correct (0 of 1215)

.../p2ch12/training.py:269: RuntimeWarning: invalid value encountered in long_scalars
  precision = metrics_dict['pr/precision'] = truePos_count / (truePos_count + falsePos_count)

E1 val      0.0025 loss,  99.8% correct, nan prc, 0.0000 rcl, nan f1
E1 val_ben  0.0000 loss, 100.0% correct (54971 of 54971)
E1 val_mal  1.0000 loss,   0.0% correct (0 of 136)

❶ 这些 RuntimeWarning 行的确切计数和行号可能会因运行而异。

糟糕。我们收到了一些警告,考虑到我们计算的一些值是nan,可能在某处发生了除零操作。让我们看看我们能找出什么。

首先,由于训练集中没有一个正样本被分类为正,这意味着精确度和召回率都为零,导致我们的 F1 分数计算除以零。其次,对于我们的验证集,由于没有任何东西被标记为正,truePos_countfalsePos_count都为零。这导致我们的precision计算的分母也为零;这是合理的,因为这就是我们看到另一个RuntimeWarning的地方。

少数负训练样本被分类为正(494743 个中有 494735 个被分类为负,因此有 8 个样本被错误分类)。虽然一开始可能看起来很奇怪,但请记住我们是在整个时期内收集我们的训练结果,而不是像我们对验证结果那样使用模型的时期末状态。这意味着第一批次实际上产生了随机结果。其中一些来自第一批次的样本被标记为正并不奇怪。

注意 由于网络权重的随机初始化和训练样本的随机排序,单独的运行可能会表现出略有不同的行为。确切可重现的行为可能是可取的,但超出了我们在本书第 2 部分中所尝试的范围。

嗯,那有点痛苦。切换到我们的新指标导致从 A+降至“零,如果你幸运的话”--如果我们不幸运,分数会很糟糕,甚至不是一个数字。哎呀。

话虽如此,在长期来看,这对我们是有利的。自第十一章以来,我们就知道我们的模型性能很差。如果我们的指标告诉我们除了那个,那将指向指标中的一个基本缺陷!

12.4 理想数据集是什么样的?

在我们为当前糟糕的情况哭泣之前,让我们想想我们实际上希望我们的模型做什么。图 12.14 说,首先我们需要平衡我们的数据,以便我们的模型能够正确训练。让我们建立起达到这个目标所需的逻辑步骤。

图 12.14 本章主题集,重点关注平衡我们的正负样本

回想一下之前的图 12.5,并讨论分类阈值。通过移动阈值来获得更好的结果具有有限的效果--正负类之间有太多重叠无法处理。

相反,我们想看到一个类似图 12.15 的图像。在这里,我们的标签阈值几乎是垂直的。这就是我们想要的,因为这意味着标签阈值和我们的分类阈值可以相当好地对齐。同样,大多数样本集中在图表的两端。这两件事都要求我们的数据易于分离,并且我们的模型具有执行该分离的能力。我们的模型目前具有足够的容量,所以问题不在于此。相反,让我们看看我们的数据。

图 12.15 一个训练良好的模型可以清晰地分离数据,使得很容易选择一个具有少量折衷的分类阈值。

图 12.16 一个大致近似我们 LUNA 分类数据中不平衡的数据集

请记住,我们的数据极度不平衡。正样本与负样本的比例为 400:1。这是极端不平衡的!图 12.16 展示了这种情况。难怪我们的“实际结节”样本在人群中被忽略!

现在,让我们非常清楚:当我们完成时,我们的模型将能够很好地处理这种数据不平衡。我们甚至可以在不改变平衡的情况下训练模型到最后,假设我们愿意等待无数个纪元。但我们是忙碌的人,有很多事情要做,所以与其等到 GPU 烧毁到宇宙的热死亡,不如尝试通过改变我们训练的类平衡使我们的训练数据看起来更理想。

12.4.1 使数据看起来不那么实际,更像“理想”的方法

最好的做法是有相对更多的正样本。在训练的初始时期,当我们从随机混乱过渡到更有组织的状态时,由于正样本太少,它们会被淹没。

然而,这种情况发生的方式有些微妙。请记住,由于我们的网络权重最初是随机的,网络每个样本的输出也是随机的(但被夹在[0-1]范围内)。

注意 我们的损失函数是nn.CrossEntropyLoss,严格来说是在原始 logits 上操作,而不是类概率。在我们的讨论中,我们将忽略这一区别,假设损失和标签预测之间的差异是相同的。

预测与正确标签数值接近的结果对网络权重几乎没有影响,而与正确答案明显不同的预测结果会导致权重发生更大的变化。由于模型在随机权重初始化时输出是随机的,我们可以假设在我们约 500k 个训练样本(准确地说是 495,958 个)中,我们将有以下近似组:

  1. 250,000 个负样本将被预测为负面(0.0 到 0.5),并最多对网络权重产生一点朝向预测负面的变化。

  2. 250,000 个负样本将被预测为正面(0.5 到 1.0),并导致网络权重向预测负面的方向发生大幅变化。

  3. 500 个正样本将被预测为负面,并导致网络权重向预测正面的方向发生变化。

  4. 500 个正样本将被预测为正面,并几乎不会对网络权重产生任何变化。

注意 请记住,实际预测是介于 0.0 和 1.0 之间的实数,因此这些组没有严格的界限。

这里的关键是:1 组和 4 组可以是任意大小,它们对训练几乎没有影响。唯一重要的是 2 组和 3 组能够相互抵消,防止网络崩溃到退化的“只输出一种结果”的状态。由于 2 组比 3 组大 500 倍,我们使用的批量大小为 32,大约需要经过 500/32 = 15 批次才能看到一个正样本。这意味着 15 个训练批次中有 14 个将是 100%负面的,只会将所有模型权重拉向预测负面的方向。这种不平衡的拉力产生了我们一直看到的退化行为。

相反,我们希望正样本和负样本数量相同。因此,在训练的第一部分中,一半的标签将被错误分类,这意味着第 2 组和第 3 组的大小应该大致相等。我们还希望确保我们呈现的批次中包含负样本和正样本的混合。平衡将导致拉锯战平衡,每个批次中的类别混合将使模型有很好的机会学会区分这两个类别。由于我们的 LUNA 数据只有少量固定数量的正样本,我们将不得不接受我们拥有的正样本并在训练期间重复呈现它们。

歧视

在这里,我们将歧视定义为“将两个类别彼此分开的能力”。构建和训练一个能够将“实际结节”候选者与正常解剖结构区分开的模型是我们在第 2 部分所做的全部工作的重点。

歧视的一些其他定义更具问题性。虽然超出了我们在这里讨论工作的范围,但从真实世界数据训练的模型存在更大的问题。如果真实世界数据集是从存在真实世界歧视性偏见的来源收集的(例如,种族偏见在逮捕和定罪率中,或者从社交媒体收集的任何内容),并且在数据集准备或训练期间没有纠正这种偏见,那么生成的模型将继续表现出训练数据中存在的相同偏见。就像在人类中一样,种族主义是被学习的。

这意味着几乎任何从互联网大数据源训练的模型都会在某种程度上受到损害,除非极端小心地清除这些模型中的偏见。请注意,就像我们在第 2 部分的目标一样,这被认为是一个未解决的问题。

回想一下我们在第十一章中提到的教授,他的期末考试有 99 个错误答案和 1 个正确答案。下学期,在被告知“你应该有更平衡的真假答案”后,教授决定增加一次期中考试,其中有 99 个正确答案和 1 个错误答案。“问题解决了!”

显然,正确的方法是以一种不允许学生利用测试的更大结构来回答问题的方式交替真实和错误答案。虽然学生可能会注意到“奇数问题是真实的,偶数问题是错误的”这样的模式,但 PyTorch 使用的批处理系统不允许模型“注意到”或利用那种模式。我们的训练数据集将需要更新,以在正样本和负样本之间交替,就像图 12.17 中那样。

不平衡数据就像我们在第九章开始提到的草堆中的针。如果您必须手动执行这项分类工作,您可能会开始同情普雷斯顿。

图 12.17 不平衡数据的批次将在第一个正事件之前只有负事件,而平衡数据可以每隔一个样本交替出现。

然而,我们不会为验证进行任何平衡。我们的模型需要在现实世界中表现良好,而现实世界是不平衡的(毕竟,这就是我们获取原始数据的地方!)。

我们应该如何实现这种平衡?让我们讨论我们的选择。

取样器可以重塑数据集

DataLoader 的一个可选参数是sampler=...。这允许数据加载器覆盖传入数据集的本机迭代顺序,而是根据需要塑造、限制或重新强调底层数据。当使用一个不受您控制的数据集时,这可能非常有用。将公共数据集重新塑造以满足您的需求比从头开始重新实现该数据集要少得多。

不足之处在于,我们可以通过采样器实现的许多变异需要我们打破底层数据集的封装。例如,假设我们有一个类似于 CIFAR-10(www.cs.toronto.edu/~kriz/cifar.html)的数据集,由 10 个权重相同的类组成,我们想要让 1 个类(比如“飞机”)现在占所有训练图像的 50%。我们可以决定使用WeightedRandomSamplermng.bz/8plK)并将每个“飞机”样本索引的权重提高,但构建weights参数需要我们事先知道哪些索引是飞机。

正如我们讨论的那样,Dataset API 只规定子类提供__len____getitem__,但我们无法直接询问“哪些样本是飞机?”我们要么事先加载每个样本以查询该样本的类别,要么打破封装并希望我们需要的信息可以轻松从查看Dataset子类的内部实现中获得。

由于在我们可以直接控制数据集的情况下,这两种选项都不是特别理想的,因此第 2 部分的代码在Dataset子类内部实现任何所需的数据整形,而不依赖外部采样器。

在数据集中实现类平衡

我们将直接更改我们的LunaDataset,以呈现平衡的正负样本比例进行训练。我们将保留负训练样本和正训练样本的分开列表,并交替从这两个列表中返回样本。这将防止模型通过简单地回答每个呈现的样本为“false”而得分良好的退化行为。此外,正负类别将交错排列,以便权重更新被迫区分类别。

让我们在LunaDataset中添加一个ratio_int,用于控制第N个样本的标签,并跟踪我们按标签分开的样本。

列表 12.6 dsets.py:217,class LunaDataset

class LunaDataset(Dataset):
  def __init__(self,
         val_stride=0,
         isValSet_bool=None,
         ratio_int=0,
      ):
    self.ratio_int = ratio_int
    # ... line 228
    self.negative_list = [
      nt for nt in self.candidateInfo_list if not nt.isNodule_bool
    ]
    self.pos_list = [
      nt for nt in self.candidateInfo_list if nt.isNodule_bool
    ]
    # ... line 265

  def shuffleSamples(self):               # ❶
    if self.ratio_int:
      random.shuffle(self.negative_list)
      random.shuffle(self.pos_list)

❶ 我们将在每个周期的开头调用这个函数,以随机化呈现的样本顺序。

有了这个,我们现在为每个标签都有专门的列表。使用这些列表,更容易根据数据集中的索引返回我们想要的标签。为了确保我们的索引正确,我们应该勾画出我们想要的排序。假设ratio_int为 2,意味着负样本与正样本的比例为 2:1。这意味着每三个索引应该是正样本:

DS Index   0 1 2 3 4 5 6 7 8 9 ...
Label      + - - + - - + - - +
Pos Index  0     1     2     3
Neg Index    0 1   2 3   4 5

数据集索引与正索引之间的关系很简单:将数据集索引除以 3 然后向下取整。负索引稍微复杂一些,因为我们必须从数据集索引中减去 1,然后再减去最近的正索引。

在我们的LunaDataset类中实现,看起来像下面这样。

列表 12.7 dsets.py:286,LunaDataset.__getitem__

def __getitem__(self, ndx):
  if self.ratio_int:                                   # ❶
    pos_ndx = ndx // (self.ratio_int + 1)

    if ndx % (self.ratio_int + 1):                     # ❷
      neg_ndx = ndx - 1 - pos_ndx
      neg_ndx %= len(self.negative_list)               # ❸
      candidateInfo_tup = self.negative_list[neg_ndx]
    else:
      pos_ndx %= len(self.pos_list)                    # ❸
      candidateInfo_tup = self.pos_list[pos_ndx]
  else:
    candidateInfo_tup = self.candidateInfo_list[ndx]   # ❹

❶ 零的ratio_int意味着使用本地平衡。

❷ 非零余数表示这应该是一个负样本。

❸ 溢出导致环绕。

❹ 如果不平衡类别,则返回第 N 个样本

这可能有点复杂,但如果你仔细检查一下,就会明白。请记住,如果比率较低,我们会在用尽数据集之前用完正样本。我们通过在索引到self.pos_list之前取pos_ndx的模来处理这个问题。虽然由于大量负样本的存在,neg_ndx不太可能发生相同类型的索引溢出,但我们仍然执行模运算,以防以后做出可能导致溢出的更改。

我们还将对数据集的长度进行更改。虽然这并非绝对必要,但加快单个周期的速度是很好的。我们将硬编码我们的__len__为 200,000。

列表 12.8 dsets.py:280,LunaDataset.__len__

def __len__(self):
  if self.ratio_int:
    return 200000
  else:
    return len(self.candidateInfo_list)

我们不再受限于特定数量的样本,并且提供“完整的一轮”在我们必须多次重复正样本以呈现平衡的训练集时并不是很有意义。通过选择 20 万个样本,我们减少了开始训练运行并看到结果之间的时间(更快的反馈总是不错!),并且我们给自己一个漂亮、清晰的每轮样本数。随时调整一轮的长度以满足您的需求。

为了完整起见,我们还添加了一个命令行参数。

列表 12.9 training.py:31,class LunaTrainingApp

class LunaTrainingApp:
  def __init__(self, sys_argv=None):
    # ... line 52
    parser.add_argument('--balanced',
      help="Balance the training data to half positive, half negative.",
      action='store_true',
      default=False,
    )

然后我们将该参数传递给LunaDataset构造函数。

列表 12.10 training.py:137,LunaTrainingApp.initTrainDl

def initTrainDl(self):
  train_ds = LunaDataset(
    val_stride=10,
    isValSet_bool=False,
    ratio_int=int(self.cli_args.balanced),    # ❶
  )

❶ 这里我们依赖于 Python 的True可转换为1

我们已经准备就绪。让我们运行它!

12.4.2 将平衡的 LunaDataset 与之前的运行进行对比

作为提醒,我们不平衡的训练运行结果如下:

$ python -m p2ch12.training
...
E1 LunaTrainingApp
E1 trn      0.0185 loss,  99.7% correct, 0.0000 precision, 0.0000 recall, nan f1 score
E1 trn_neg  0.0026 loss, 100.0% correct (494717 of 494743)
E1 trn_pos  6.5267 loss,   0.0% correct (0 of 1215)
...
E1 val      0.0173 loss,  99.8% correct, nan precision, 0.0000 recall, nan f1 score
E1 val_neg  0.0026 loss, 100.0% correct (54971 of 54971)
E1 val_pos  5.9577 loss,   0.0% correct (0 of 136)

但是当我们使用--balanced运行时,我们看到以下情况:

$ python -m p2ch12.training --balanced
...
E1 LunaTrainingApp
E1 trn      0.1734 loss,  92.8% correct, 0.9363 precision, 0.9194 recall, 0.9277 f1 score
E1 trn_neg  0.1770 loss,  93.7% correct (93741 of 100000)
E1 trn_pos  0.1698 loss,  91.9% correct (91939 of 100000)
...
E1 val      0.0564 loss,  98.4% correct, 0.1102 precision, 0.7941 recall, 0.1935 f1 score
E1 val_neg  0.0542 loss,  98.4% correct (54099 of 54971)
E1 val_pos  0.9549 loss,  79.4% correct (108 of 136)

这看起来好多了!我们放弃了大约 5%的负样本正确答案,以获得 86%的正确正样本答案。我们又回到了一个扎实的 B 范围内!⁵

然而,就像第十一章一样,这个结果是具有欺骗性的。由于负样本比正样本多 400 倍,即使只有 1%的错误,也意味着我们会将负样本错误地分类为正样本,比实际正样本总数多四倍!

尽管如此,这显然比第十一章的完全错误行为要好得多,比随机抛硬币要好得多。事实上,我们甚至已经进入了(几乎)在实际场景中有用的领域。回想一下我们过度劳累的放射科医生仔细检查每一个 CT 上的每一个斑点:现在我们有了一些可以合理地筛除 95%的假阳性的东西。这是一个巨大的帮助,因为这意味着机器辅助人类的生产力增加了大约十倍。

当然,还有那令人讨厌的 14%被错过的正样本问题,我们可能需要处理一下。也许增加一些额外的训练轮次会有所帮助。让我们看看(再次提醒,每轮至少需要花费 10 分钟):

$ python -m p2ch12.training --balanced --epochs 20
...
E2 LunaTrainingApp
E2 trn      0.0432 loss,  98.7% correct, 0.9866 precision, 0.9879 recall, 0.9873 f1 score
E2 trn_ben  0.0545 loss,  98.7% correct (98663 of 100000)
E2 trn_mal  0.0318 loss,  98.8% correct (98790 of 100000)
E2 val      0.0603 loss,  98.5% correct, 0.1271 precision, 0.8456 recall, 0.2209 f1 score
E2 val_ben  0.0584 loss,  98.6% correct (54181 of 54971)
E2 val_mal  0.8471 loss,  84.6% correct (115 of 136)
...
E5 trn      0.0578 loss,  98.3% correct, 0.9839 precision, 0.9823 recall, 0.9831 f1 score
E5 trn_ben  0.0665 loss,  98.4% correct (98388 of 100000)
E5 trn_mal  0.0490 loss,  98.2% correct (98227 of 100000)
E5 val      0.0361 loss,  99.2% correct, 0.2129 precision, 0.8235 recall, 0.3384 f1 score
E5 val_ben  0.0336 loss,  99.2% correct (54557 of 54971)
E5 val_mal  1.0515 loss,  82.4% correct (112 of 136)...
...
E10 trn      0.0212 loss,  99.5% correct, 0.9942 precision, 0.9953 recall, 0.9948 f1 score
E10 trn_ben  0.0281 loss,  99.4% correct (99421 of 100000)
E10 trn_mal  0.0142 loss,  99.5% correct (99530 of 100000)
E10 val      0.0457 loss,  99.3% correct, 0.2171 precision, 0.7647 recall, 0.3382 f1 score
E10 val_ben  0.0407 loss,  99.3% correct (54596 of 54971)
E10 val_mal  2.0594 loss,  76.5% correct (104 of 136)
...
E20 trn      0.0132 loss,  99.7% correct, 0.9964 precision, 0.9974 recall, 0.9969 f1 score
E20 trn_ben  0.0186 loss,  99.6% correct (99642 of 100000)
E20 trn_mal  0.0079 loss,  99.7% correct (99736 of 100000)
E20 val      0.0200 loss,  99.7% correct, 0.4780 precision, 0.7206 recall, 0.5748 f1 score
E20 val_ben  0.0133 loss,  99.8% correct (54864 of 54971)
E20 val_mal  2.7101 loss,  72.1% correct (98 of 136)

哎呀。要滚动到我们感兴趣的数字,需要滚过很多文本。让我们坚持下去,专注于val_mal XX.X% correct数字(或者直接跳到下一节的 TensorBoard 图表)。第 2 轮之后,我们达到了 87.5%;第 5 轮时,我们达到了 92.6%的峰值;然后到了第 20 轮,我们下降到了 86.8%--低于我们的第二轮!

注意 正如前面提到的,由于网络权重的随机初始化和每轮训练样本的随机选择和排序,预计每次运行都会有独特的行为。

训练集的数字似乎没有同样的问题。负训练样本被正确分类的概率为 98.8%,正样本则为 99.1%。发生了什么?

12.4.3 识别过拟合的症状

我们所看到的是过拟合的明显迹象。让我们看一下我们在正样本上的损失图,见图 12.18。

图 12.18 我们的正损失显示出明显的过拟合迹象,因为训练损失和验证损失趋势不同。

在这里,我们可以看到我们的正样本的训练损失几乎为零--每个正样本训练样本都得到了几乎完美的预测。然而,我们的正样本的验证损失却在增加,这意味着我们的实际表现可能正在变差。在这一点上,最好停止训练脚本,因为模型不再改进。

提示 通常,如果您的模型在训练集上的表现正在提高,而在验证集上表现变差,那么模型已经开始过拟合。

然而,我们必须注意检查正确的指标,因为这种趋势只发生在我们的损失上。如果我们看一下我们的整体损失,一切似乎都很好!这是因为我们的验证集不平衡,所以整体损失被我们的负样本所主导。正如图 12.19 所示,我们在我们的负样本中没有看到相同的发散行为。相反,我们的负损失看起来很好!这是因为我们有 400 倍的负样本,所以模型要记住个别细节要困难得多。然而,我们的正训练集只有 1,215 个样本。虽然我们多次重复这些样本,但这并不会使它们更难记忆。模型正在从泛化原则转变为基本上记住这 1,215 个样本的怪癖,并声称不属于这几个样本之一的任何东西都是负样本。这包括负训练样本和我们验证集中的所有内容(正负样本都有)。

图 12.19 我们的负损失没有显示过拟合的迹象

显然,仍然存在一些泛化,因为我们大约正确分类了 70%的正验证集。我们只需要改变我们训练模型的方式,使我们的训练集和验证集都朝着正确的方向发展。

12.5 重新审视过拟合问题

我们在第五章中提到了过拟合的概念,现在是时候更仔细地看看如何解决这种常见情况了。我们训练模型的目标是教会它识别我们感兴趣的类别的一般属性,如我们数据集中所表达的那样。这些一般属性存在于该类别的一些或所有样本中,并且可以泛化并用于预测未经训练的样本。当模型开始学习训练集的特定属性时,就会发生过拟合,模型开始失去泛化的能力。如果这有点抽象,让我们使用另一个类比。

12.5.1 一个过拟合的人脸到年龄预测模型

假设我们有一个模型,它以人脸图像作为输入,并输出预测的年龄。一个好的模型会注意到年龄的特征,如皱纹、白发、发型、服装选择等,并利用这些建立不同年龄看起来的一般模型。当呈现一张新图片时,它会考虑“保守的发型”、“眼镜”和“皱纹”等因素,得出“大约 65 岁”的结论。

与之相比,过拟合模型则是通过记住识别细节来记住特定的人。“那个发型和那副眼镜意味着那是弗兰克。他 62.8 岁了”;“哦,那个伤疤意味着那是哈里。他 39.3 岁了”;等等。当展示一个新的人时,模型将无法识别这个人,也完全不知道该预测多少岁。

更糟糕的是,如果展示弗兰克的儿子的照片(看起来像他爸爸,至少戴着眼镜时是这样!),模型会说:“我认为那是弗兰克。他 62.8 岁了。”尽管小弗兰克实际上年轻了 25 岁!

过拟合通常是由于训练样本太少,与模型仅仅记住答案的能力相比。普通人可以记住自己家人的生日,但在预测比一个小村庄规模更大的群体的年龄时,就必须求助于概括。

我们的人脸到年龄模型有能力简单地记住那些看起来不完全符合其年龄的照片。正如我们在第 1 部分中讨论的,模型容量是一个有点抽象的概念,但大致是模型参数数量乘以这些参数的有效使用方式。当模型的容量相对于需要记住训练集中难样本的数据量很高时,模型很可能会开始过拟合这些更难的训练样本。

12.6 通过数据增强防止过拟合

是时候将我们的模型训练从好到优秀了。我们需要完成图 12.20 中的最后一步。

图 12.20 本章的主题集,重点是数据增强

我们通过对单个样本应用合成的改变来增强数据集,从而得到一个有效大小比原始数据集更大的新数据集。典型的目标是使改变导致合成样本仍然代表与源样本相同的一般类别,但不能与原始样本一起轻松记忆。当正确执行时,这种增强可以将训练集大小增加到模型能够记忆的范围之外,从而迫使模型越来越依赖泛化,这正是我们想要的。在处理有限数据时,这种增强尤其有用,正如我们在第 12.4.1 节中看到的。

当然,并非所有的增强都同样有用。回到我们的面部年龄预测模型的例子,我们可以轻松地将每个图像的四个角像素的红色通道更改为随机值 0-255,这将导致数据集比原始数据集大 40 亿倍。当然,这并不特别有用,因为模型可以相当轻松地学会忽略图像角落的红点,而图像的其余部分仍然像单个未经增强的原始图像一样容易记忆。将这种方法与左右翻转图像进行对比。这样做只会使数据集比原始数据集大两倍,但每个图像对于训练目的来说会更有用。年龄的一般属性与左右无关,因此镜像图像仍然具有代表性。同样,面部图片很少是完全对称的,因此镜像版本不太可能与原始版本轻松记忆。

12.6.1 具体的数据增强技术

我们将实现五种特定类型的数据增强。我们的实现将允许我们单独或合并地对任何一种或全部进行实验。这五种技术如下:

  • 将图像上下、左右和/或前后镜像

  • 将图像移动几个体素

  • 将图像放大或缩小

  • 将图像围绕头-脚轴旋转

  • 添加噪声到图像

对于每种技术,我们希望确保我们的方法保持训练样本的代表性,同时又足够不同,以便样本用于训练时是有用的。

我们将定义一个函数 getCtAugmentedCandidate,负责获取我们标准的 CT 块并对其中的候选进行修改。我们的主要方法将定义一个仿射变换矩阵(mng.bz/Edxq),并将其与 PyTorch 的 affine_gridpytorch.org/docs/stable/nn.html#affine-grid)和 grid_samplepytorch.org/docs/stable/nn.html#torch.nn.functional.grid_sample)函数一起使用,以对我们的候选进行重新采样。

列表 12.11 dsets.py:149, def getCtAugmentedCandidate

def getCtAugmentedCandidate(
    augmentation_dict,
    series_uid, center_xyz, width_irc,
    use_cache=True):
  if use_cache:
    ct_chunk, center_irc = \
      getCtRawCandidate(series_uid, center_xyz, width_irc)
  else:
    ct = getCt(series_uid)
    ct_chunk, center_irc = ct.getRawCandidate(center_xyz, width_irc)

  ct_t = torch.tensor(ct_chunk).unsqueeze(0).unsqueeze(0).to(torch.float32)

我们首先获取 ct_chunk,可以从缓存中获取,也可以直接通过加载 CT 获取(这在我们创建自己的候选中会很方便),然后将其转换为张量。接下来是仿射网格和采样代码。

列表 12.12 dsets.py:162, def getCtAugmentedCandidate

transform_t = torch.eye(4)
# ...                        # ❶
# ... line 195
affine_t = F.affine_grid(
    transform_t[:3].unsqueeze(0).to(torch.float32),
    ct_t.size(),
    align_corners=False,
  )

augmented_chunk = F.grid_sample(
    ct_t,
    affine_t,
    padding_mode='border',
    align_corners=False,
  ).to('cpu')
# ... line 214
return augmented_chunk[0], center_irc

❶ 转换 transform_tensor 的修改将在这里进行。

没有任何额外的东西,这个函数不会有太多作用。让我们看看需要添加一些实际变换的步骤。

注意 重要的是要构建数据流水线,使得缓存步骤发生在增强之前!否则将导致数据被增强一次,然后保留在那种状态,这违背了初衷。

镜像

当镜像一个样本时,我们保持像素值完全相同,只改变图像的方向。由于肿瘤生长与左右或前后没有强烈的相关性,我们应该能够在不改变样本代表性质的情况下翻转它们。指数轴(在患者坐标中称为Z)对应于直立人体中的重力方向,然而,肿瘤的顶部和底部可能存在差异的可能性。我们将假设这没问题,因为快速的视觉调查并没有显示任何明显的偏差。如果我们正在进行一个临床相关的项目,我们需要向专家确认这一假设。

列表 12.13 dsets.py:165,def getCtAugmentedCandidate

for i in range(3):
  if 'flip' in augmentation_dict:
    if random.random() > 0.5:
      transform_t[i,i] *= -1

grid_sample 函数将范围 [-1, 1] 映射到旧张量和新张量的范围(如果大小不同,则会隐式地进行重新缩放)。这个范围映射意味着为了镜像数据,我们只需要将变换矩阵的相关元素乘以 -1。

通过随机偏移进行移动

将结节候选物体移动一下不会产生很大的影响,因为卷积是独立于平移的,尽管这会使我们的模型对不完全居中的结节更加稳健。更重要的是,偏移量可能不是整数个体素数;相反,数据将使用三线性插值重新采样,这可能会引入一些轻微的模糊。样本边缘的体素将被重复,这可以看作是沿边界的一部分呈现出模糊、条纹状的区域。

列表 12.14 dsets.py:165,def getCtAugmentedCandidate

for i in range(3):
  # ... line 170
  if 'offset' in augmentation_dict:
    offset_float = augmentation_dict['offset']
    random_float = (random.random() * 2 - 1)
    transform_t[i,3] = offset_float * random_float

请注意,我们的 'offset' 参数是以与网格采样函数期望的 [-1, 1] 范围相同的比例表示的最大偏移量。

缩放

稍微缩放图像与镜像和移动非常相似。这样做也会导致我们刚刚讨论的在移动样本时提到的相同重复边缘体素。

列表 12.15 dsets.py:165,def getCtAugmentedCandidate

for i in range(3):
  # ... line 175
  if 'scale' in augmentation_dict:
    scale_float = augmentation_dict['scale']
    random_float = (random.random() * 2 - 1)
    transform_t[i,i] *= 1.0 + scale_float * random_float

由于 random_float 被转换为在范围 [-1, 1],所以实际上无论我们将 scale_float * random_float 添加到 1.0 还是从 1.0 中减去它都没有关系。

旋转

旋转是我们将使用的第一种增强技术,我们必须仔细考虑我们的数据,以确保我们不会通过导致其不再具有代表性的转换来破坏我们的样本。请记住,我们的 CT 切片在行和列(X 和 Y 轴)上具有均匀间距,但在指数(或 Z)方向上,体素是非立方体的。这意味着我们不能将这些轴视为可互换的。

一种选择是重新采样我们的数据,使得我们沿指数轴的分辨率与其他两个轴的分辨率相同,但这并不是一个真正的解决方案,因为沿着那个轴的数据会非常模糊和模糊。即使我们插入更多的体素,数据的保真度仍然很差。相反,我们将把这个轴视为特殊轴,并将我们的旋转限制在 X-Y 平面上。

列表 12.16 dsets.py:181,def getCtAugmentedCandidate

if 'rotate' in augmentation_dict:
  angle_rad = random.random() * math.pi * 2
  s = math.sin(angle_rad)
  c = math.cos(angle_rad)

  rotation_t = torch.tensor([
    [c, -s, 0, 0],
    [s, c, 0, 0],
    [0, 0, 1, 0],
    [0, 0, 0, 1],
  ])

  transform_t @= rotation_t

噪音

我们的最终增强技术与其他技术不同,因为它在某种程度上对我们的样本进行了积极破坏,而翻转或旋转样本则没有这种情况。如果我们向样本添加太多噪音,它将淹没真实数据,并使其实际上无法分类。虽然如果我们使用极端输入值,移动和缩放样本也会产生类似的效果,但我们选择的值只会影响样本的边缘。噪音将对整个图像产生影响。

列表 12.17 dsets.py:208,def getCtAugmentedCandidate

if 'noise' in augmentation_dict:
  noise_t = torch.randn_like(augmented_chunk)
  noise_t *= augmentation_dict['noise']

  augmented_chunk += noise_t

其他增强类型已经增加了我们数据集的有效大小。噪音使我们模型的工作更加困难。一旦我们看到一些训练结果,我们将重新审视这一点。

检查增强候选物体

我们可以在图 12.21 中看到我们努力的结果。左上角的图像显示了一个未增强的正候选样本,接下来的五个图像显示了每种增强类型的效果。最后,底部行显示了三次组合结果。

图 12.21 在正结节样本上执行的各种增强类型

由于对增强数据集的每次__getitem__调用都会随机重新应用增强,底部行的每个图像看起来都不同。这也意味着几乎不可能再次生成完全相同的图像!还要记住,有时'flip'增强会导致没有翻转。始终返回翻转图像与一开始不翻转一样限制。现在让我们看看这是否有所不同。

12.6.2 从数据增强中看到改进

我们将训练额外的模型,每种增强类型一个,还有一个将所有增强类型组合在一起的额外模型训练运行。一旦它们完成,我们将在 TensorBoard 中查看我们的数据。

为了能够打开和关闭我们的新增强类型,我们需要将augmentation_dict的构建暴露给我们的命令行界面。程序的参数将通过parser.add_argument调用添加(未显示,但类似于我们的程序已经具有的那些),然后将被馈送到实际构建augmentation_dict的代码中。

列表 12.18 training.py:105,LunaTrainingApp.__init__

self.augmentation_dict = {}
if self.cli_args.augmented or self.cli_args.augment_flip:
  self.augmentation_dict['flip'] = True
if self.cli_args.augmented or self.cli_args.augment_offset:
  self.augmentation_dict['offset'] = 0.1                     # ❶
if self.cli_args.augmented or self.cli_args.augment_scale:
  self.augmentation_dict['scale'] = 0.2                      # ❶
if self.cli_args.augmented or self.cli_args.augment_rotate:
  self.augmentation_dict['rotate'] = True
if self.cli_args.augmented or self.cli_args.augment_noise:
  self.augmentation_dict['noise'] = 25.0                     # ❶

❶ 这些值是经验选择的,具有合理的影响,但可能存在更好的值。

现在我们已经准备好这些命令行参数,您可以运行以下命令,或者重新查看 p2_run_everything.ipynb 并运行第 8 到 16 个单元格。无论如何运行,都需要花费相当长的时间才能完成:

$ .venv/bin/python -m p2ch12.prepcache                   # ❶

$ .venv/bin/python -m p2ch12.training --epochs 20 \
        --balanced sanity-bal                            # ❷

$ .venv/bin/python -m p2ch12.training --epochs 10 \
        --balanced --augment-flip   sanity-bal-flip

$ .venv/bin/python -m p2ch12.training --epochs 10 \
        --balanced --augment-shift  sanity-bal-shift

$ .venv/bin/python -m p2ch12.training --epochs 10 \
        --balanced --augment-scale  sanity-bal-scale

$ .venv/bin/python -m p2ch12.training --epochs 10 \
        --balanced --augment-rotate sanity-bal-rotate

$ .venv/bin/python -m p2ch12.training --epochs 10 \
        --balanced --augment-noise  sanity-bal-noise

$ .venv/bin/python -m p2ch12.training --epochs 20 \
        --balanced --augmented sanity-bal-aug

❶ 您每章只需要准备一次缓存。

❷ 您可能在本章的早些时候运行过这个;在这种情况下,无需重新运行!

在此期间,我们可以启动 TensorBoard。让我们通过更改logdir参数来指示它仅显示这些运行,如下所示:../path/to/tensorboard --logdir runs/p2ch12

根据您手头的硬件情况,训练可能需要很长时间。如果需要加快进度,可以跳过flipshiftscale训练任务,并将第一次和最后一次运行减少到 11 个周期。我们选择了 20 次运行,因为这有助于使它们脱颖而出,但 11 次也可以。

如果让所有内容运行到完成,您的 TensorBoard 应该有类似图 12.22 所示的数据。我们将取消选择除验证数据之外的所有内容,以减少混乱。当您实时查看数据时,还可以更改平滑值,这有助于澄清趋势线。快速查看一下图,然后我们将详细介绍它。

图 12.22 用各种增强方案训练的网络在验证集上正确分类的百分比、损失、F1 分数、精度和召回率

在左上角的图表中第一件要注意的事情(“标签:正确/全部”)是各个增强类型有些混乱。我们的未增强和完全增强的运行位于该混乱的两侧。这意味着当结合时,我们的增强效果超过了其各部分之和。还有一个有趣的地方是,我们的完全增强运行得到了更多错误答案。虽然这通常是不好的,但如果我们看一下右侧的图像列(重点是我们实际关心的正候选样本--那些真正的结节),我们会发现我们的完全增强模型在查找正候选样本方面要好得多。完全增强模型的召回率很高!它也更不容易过拟合。正如我们之前看到的,我们的未增强模型随着时间的推移变得更糟。

值得注意的一点是,噪声增强模型在识别结节方面比未增强模型更差。如果我们记得我们说过噪声会让模型的工作变得更困难,这就说得通了。

在实时数据中看到的另一个有趣的事情(在这里有点混乱)是,旋转增强模型在召回方面几乎与完全增强模型一样好,并且在精度上有很大提高。由于我们的 F1 分数受精度限制(由于负样本数量较高),旋转增强模型的 F1 分数也更高。

未来我们将继续使用完全增强的模型,因为我们的用例需要高召回率。F1 分数仍将用于确定哪个时期保存为最佳。在实际项目中,我们可能希望花费额外的时间来调查不同的增强类型和参数值组合是否能产生更好的结果。

12.7 结论

在本章中,我们花了很多时间和精力重新构思我们对模型性能的看法。通过糟糕的评估方法很容易被误导,而且对评估模型的因素有强烈的直觉理解至关重要。一旦这些基本原理内化,就更容易发现我们何时被误导。

我们还学习了如何处理数据源不足的情况。能够合成代表性的训练样本非常有用。确实很少有太多的训练数据的情况!

现在我们有一个表现合理的分类器,我们将把注意力转向自动查找候选结节进行分类。第十三章将从那里开始;然后,在第十四章中,我们将把这些候选者反馈到我们在这里开发的分类器中,并着手构建另一个分类器来区分恶性结节和良性结节。

12.8 练习

  1. F1 分数可以推广支持除 1 以外的值。

    1. 阅读en.wikipedia.org/wiki/F1_score,并实现 F2 和 F0.5 分数。

    2. 确定 F1、F2 和 F0.5 中哪个对这个项目最有意义。跟踪该值,并与 F1 分数进行比较和对比。⁶

  2. 实现WeightedRandomSampler方法来平衡LunaDataset的正负训练样本,ratio_int设置为0

    1. 您如何获取每个样本类别的所需信息?

    2. 哪种方法更容易?哪种导致更易读的代码?

  3. 尝试不同的类平衡方案。

    1. 两个时期后哪个比例得分最高?20 个时期后呢?

    2. 如果比例是epoch_ndx的函数会怎样?

  4. 尝试不同的数据增强方法。

    1. 是否可以使任何现有方法更具侵略性(噪声、偏移等)?

    2. 噪声增强的包含是否有助于或妨碍您的训练结果?

      • 是否有其他值会改变这个结果?
    3. 研究其他项目使用的数据增强方法。这里有哪些适用的?

      • 为正结节候选实现“mixup”增强。这有帮助吗?
  5. 将初始归一化从nn.BatchNorm更改为自定义内容,并重新训练模型。

    1. 使用固定归一化能获得更好的结果吗?

    2. 什么归一化偏移和比例是有意义的?

    3. 非线性归一化如平方根是否有帮助?

  6. TensorBoard 除了我们在这里介绍的内容之外还可以显示哪些其他数据?

    1. 你能让它显示有关网络权重的信息吗?

    2. 在运行模型对特定样本的中间结果时有什么?

      • 将模型的骨干包装在nn.Sequential的实例中是否有助于或妨碍这一努力?

12.9 总结

  • 二进制标签和二进制分类阈值结合在一起,将数据集分成四个象限:真正阳性、真正阴性、假阴性和假阳性。这四个量为我们改进的性能指标提供了基础。

  • 回忆是模型最大化真正阳性的能力。选择每一个项目都能保证完美的回忆——因为所有正确答案都包括在内——但也表现出较低的精度。

  • 精度是模型最小化假阳性的能力。不选择任何内容保证了完美的精度——因为没有错误答案被包括在内——但也表现出较低的回忆。

  • F1 分数将精度和回忆结合成一个描述模型性能的单一指标。我们使用 F1 分数来确定对训练或模型进行的更改对我们的性能有何影响。

  • 在训练过程中平衡训练集,使得正负样本数量相等,可以使模型表现更好(定义为具有正的、增加的 F1 分数)。

  • 数据增强是指采用现有的有机数据样本并对其进行修改,使得生成的增强样本与原始样本有明显不同,但仍代表同一类别的样本。这样可以在数据有限的情况下进行额外的训练而不会过拟合。

  • 常见的数据增强策略包括改变方向、镜像、重新缩放、偏移、添加噪音。根据项目的不同,其他更具体的策略也可能相关。


¹ 没有人实际说过这个。

² 如果花费的时间超过这个时间,请确保您已运行prepcache脚本。

³ 请记住,这些图像只是分类空间的一种表示,不代表真实情况。

⁴ 目前尚不清楚这是否属实,但这是有可能的,而且损失确实在改善中……

⁵ 请记住,这是在仅呈现了 200,000 个训练样本之后,而不是不平衡数据集的 500,000+个样本之后,所以我们用了不到一半的时间就达到了这个结果。

⁶ 是的,这是一个暗示,这不是 F1 分数!

十三、使用分割找到可疑结节

本章涵盖

  • 使用像素到像素模型对数据进行分割

  • 使用 U-Net 进行分割

  • 使用 Dice 损失理解掩模预测

  • 评估分割模型的性能

在过去的四章中,我们取得了很大的进展。我们了解了 CT 扫描和肺部肿瘤,数据集和数据加载器,以及指标和监控。我们还应用了我们在第一部分学到的许多东西,并且我们有一个可用的分类器。然而,我们仍然在一个有些人为的环境中操作,因为我们需要手动注释的结节候选信息加载到我们的分类器中。我们没有一个很好的方法可以自动创建这个输入。仅仅将整个 CT 输入到我们的模型中——也就是说,插入重叠的 32×32×32 数据块——会导致每个 CT 有 31×31×7=6,727 个数据块,大约是我们拥有的注释样本数量的 10 倍。我们需要重叠边缘;我们的分类器期望结节候选位于中心,即使如此,不一致的定位可能会带来问题。

正如我们在第九章中解释的,我们的项目使用多个步骤来解决定位可能结节、识别它们,并指示可能恶性的问题。这是从业者中常见的方法,而在深度学习研究中,有一种倾向是展示单个模型解决复杂问题的能力。我们在本书中使用的多阶段项目设计给了我们一个很好的借口,逐步介绍新概念。

13.1 向我们的项目添加第二个模型

在前两章中,我们完成了图 13.1 中显示的计划的第 4 步:分类。在本章中,我们不仅要回到上一步,而是回到上两步。我们需要找到一种方法告诉我们的分类器在哪里查找。为此,我们将对原始 CT 扫描进行处理,找出可能是结节的所有内容。这是图中突出显示的第 2 步。为了找到这些可能的结节,我们必须标记看起来可能是结节的体素,这个过程被称为分割。然后,在第十四章中,我们将处理第 3 步,并通过将这幅图像的分割掩模转换为位置注释来提供桥梁。

图 13.1 我们的端到端肺癌检测项目,重点关注本章主题:第 2 步,分割

到本章结束时,我们将创建一个新模型,其架构可以执行像素级标记,或分割。完成这项任务的代码将与上一章的代码非常相似,特别是如果我们专注于更大的结构。我们将要做出的所有更改都将更小且有针对性。正如我们在图 13.2 中看到的,我们需要更新我们的模型(图中的第 2A 步),数据集(2B),以及训练循环(2C),以适应新模型的输入、输出和其他要求。(如果你在图中右侧的步骤 2 中不认识每个组件,不要担心。我们在到达每个步骤时会详细讨论。)最后,我们将检查运行新模型时得到的结果(图中的第 3 步)。

图 13.2 用于分割的新模型架构,以及我们将实施的模型、数据集和训练循环更新

将图 13.2 分解为步骤,我们本章的计划如下:

  1. 分割。首先,我们将学习使用 U-Net 模型进行分割的工作原理,包括新模型组件是什么,以及在我们进行分割过程中会发生什么。这是图 13.2 中的第 1 步。

  2. 更新。为了实现分割,我们需要在三个主要位置更改我们现有的代码库,如图 13.2 右侧的子步骤所示。代码在结构上与我们为分类开发的代码非常相似,但在细节上有所不同:

    1. 更新模型(步骤 2A)。我们将把一个现有的 U-Net 集成到我们的分割模型中。我们在第十二章的模型输出一个简单的真/假分类;而在本章中的模型将输出整个图像。

    2. 更改数据集(步骤 2B)。我们需要更改我们的数据集,不仅提供 CT 的片段,还要为结节提供掩模。分类数据集由围绕结节候选的 3D 裁剪组成,但我们需要收集完整的 CT 切片和用于分割训练和验证的 2D 裁剪。

    3. 调整训练循环(步骤 2C)。我们需要调整训练循环,以引入新的损失进行优化。因为我们想在 TensorBoard 中显示我们的分割结果的图像,我们还会做一些事情,比如将我们的模型权重保存到磁盘上。

  3. 结果。最后,当我们查看定量分割结果时,我们将看到我们努力的成果。

13.2 各种类型的分割

要开始,我们需要讨论不同类型的分割。对于这个项目,我们将使用语义分割,这是使用标签对图像中的每个像素进行分类的行为,就像我们在分类任务中看到的那样,例如,“熊”,“猫”,“狗”等。如果做得正确,这将导致明显的块或区域,表示诸如“所有这些像素都是猫的一部分”之类的事物。这采用标签掩模或热图的形式,用于识别感兴趣的区域。我们将有一个简单的二进制标签:真值将对应结节候选,假值表示无趣的健康组织。这部分满足了我们找到结节候选的需求,稍后我们将把它们馈送到我们的分类网络中。

在深入细节之前,我们应该简要讨论我们可以采取的其他方法来找到结节候选。例如,实例分割使用不同的标签标记感兴趣的单个对象。因此,语义分割会为两个人握手的图片使用两个标签(“人”和“背景”),而实例分割会有三个标签(“人 1”,“人 2”和“背景”),其中边界大约在握手处。虽然这对我们区分“结节 1”和“结节 2”可能有用,但我们将使用分组来识别单个结节。这种方法对我们很有效,因为结节不太可能接触或重叠。

另一种处理这类任务的方法是目标检测,它在图像中定位感兴趣的物品并在该物品周围放置一个边界框。虽然实例分割和目标检测对我们来说可能很好,但它们的实现有些复杂,我们认为它们不是你接下来学习的最好内容。此外,训练目标检测模型通常需要比我们的方法更多的计算资源。如果你感到挑战,YOLOv3 论文比大多数深度学习研究论文更有趣。² 对我们来说,语义分割就是最好的选择。

注意 当我们在本章的代码示例中进行操作时,我们将依赖您从 GitHub 检查大部分更大上下文的代码。我们将省略那些无趣或与之前章节类似的代码,以便我们可以专注于手头问题的关键。

13.3 语义分割:逐像素分类

通常,分割用于回答“这张图片中的猫在哪里?”这种问题。显然,大多数猫的图片,如图 13.3,其中有很多非猫的部分;背景中的桌子或墙壁,猫坐在上面的键盘,这种情况。能够说“这个像素是猫的一部分,这个像素是墙壁的一部分”需要基本不同的模型输出和不同的内部结构,与我们迄今为止使用的分类模型完全不同。分类可以告诉我们猫是否存在,而分割将告诉我们在哪里可以找到它。

图 13.3 分类结果产生一个或多个二进制标志,而分割产生一个掩码或热图。

如果您的项目需要区分近处猫和远处猫,或者左边的猫和右边的猫,那么分割可能是正确的方法。迄今为止我们实现的图像消费分类模型可以被看作是漏斗或放大镜,将大量像素聚焦到一个“点”(或者更准确地说,一组类别预测)中,如图 13.4 所示。分类模型提供的答案形式为“是的,这一大堆像素中有一只猫”,或者“不,这里没有猫”。当您不关心猫在哪里,只关心图像中是否有猫时,这是很好的。

图 13.4 用于分类的放大镜模型结构

重复的卷积和下采样层意味着模型从消耗原始像素开始,产生特定的、详细的检测器,用于识别纹理和颜色等内容,然后构建出更高级的概念特征检测器,用于眼睛、耳朵、嘴巴和鼻子等部位³,最终得出“猫”与“狗”的结论。由于每个下采样层后卷积的接受域不断增加,这些更高级的检测器可以利用来自输入图像越来越大区域的信息。

不幸的是,由于分割需要产生类似图像的输出,最终得到一个类似于单一分类列表的二进制标志是行不通的。正如我们从第 11.4 节回忆的那样,下采样是增加卷积层接受域的关键,也是帮助将构成图像的像素数组减少到单一类别列表的关键。请注意图 13.5,它重复了图 11.6。

图 13.5 LunaModel块的卷积架构,由两个 3×3 卷积和一个最大池组成。最终像素具有 6×6 的接受域。

在图中,我们的输入从左到右在顶部行中流动,并在底部行中继续。为了计算出影响右下角单个像素的接受域--我们可以向后推导。最大池操作有 2×2 的输入,产生每个最终输出像素。底部行中的 3×3 卷积在每个方向(包括对角线)查看一个相邻像素,因此导致 2×2 输出的卷积的总接受域为 4×4(带有右侧的“x”字符)。顶部行中的 3×3 卷积然后在每个方向添加一个额外的像素上下文,因此右下角单个输出像素的接受域是顶部左侧输入的 6×6 区域。通过来自最大池的下采样,下一个卷积块的接受域将具有双倍宽度,每次额外的下采样将再次使其加倍,同时缩小输出的大小。

如果我们希望输出与输入大小相同,我们将需要不同的模型架构。一个用于分割的简单模型可以使用重复的卷积层而没有任何下采样。在适当的填充下,这将导致输出与输入大小相同(好),但由于基于多层小卷积的有限重叠,会导致非常有限的感受野(坏)。分类模型使用每个下采样层来使后续卷积的有效范围加倍;没有这种有效领域大小的增加,每个分割像素只能考虑一个非常局部的邻域。

注意 假设 3×3 卷积,堆叠卷积的简单模型的感受野大小为 2 * L + 1,其中L是卷积层数。

四层 3×3 卷积将每个输出像素的感受野大小为 9×9。通过在第二个和第三个卷积之间插入一个 2×2 最大池,并在最后插入另一个,我们将感受野增加到...

注意 看看你是否能自己算出数学问题;完成后,回到这里查看。

... 16×16。最终的一系列 conv-conv-pool 具有 6×6 的感受野,但这发生在第一个最大池之后,这使得原始输入分辨率中的最终有效感受野为 12×12。前两个卷积层在 12×12 周围添加了总共 2 个像素的边框,总共为 16×16。

因此问题仍然是:如何在保持输入像素与输出像素 1:1 比率的同时改善输出像素的感受野?一个常见的答案是使用一种称为上采样的技术,它将以给定分辨率的图像生成更高分辨率的图像。最简单的上采样只是用一个N×N像素块替换每个像素,每个像素的值与原始输入像素相同。从那里开始,可能性变得更加复杂,选项包括线性插值和学习反卷积。

13.3.1 U-Net 架构

在我们陷入可能的上采样算法的兔子洞之前,让我们回到本章的目标。根据图 13.6,第一步是熟悉一个名为 U-Net 的基础分割算法。

图 13.6 我们将使用的分割新模型架构

U-Net 架构是一种可以产生像素级输出的神经网络设计,专为分割而发明。从图 13.6 的突出部分可以看出,U-Net 架构的图表看起来有点像字母U,这解释了名称的起源。我们还立即看到,它比我们熟悉的大多数顺序结构的分类器要复杂得多。不久我们将在图 13.7 中看到 U-Net 架构的更详细版本,并了解每个组件的具体作用。一旦我们了解了模型架构,我们就可以开始训练一个来解决我们的分割任务。

图 13.7 来自 U-Net 论文的架构,带有注释。来源:本图的基础由 Olaf Ronneberger 等人提供,来源于论文“U-Net:用于生物医学图像分割的卷积网络”,可在arxiv.org/abs/1505.04597lmb.informatik.uni-freiburg.de/people/ronneber/u-net找到。

图 13.7 中显示的 U-Net 架构是图像分割的一个早期突破。让我们看一看,然后逐步了解架构。

在这个图表中,方框代表中间结果,箭头代表它们之间的操作。架构的 U 形状来自网络操作的多个分辨率。顶部一行是完整分辨率(对我们来说是 512×512),下面一行是其一半,依此类推。数据从左上流向底部中心,通过一系列卷积和下采样,正如我们在分类器中看到的并在第八章中详细讨论的那样。然后我们再次上升,使用上采样卷积回到完整分辨率。与原始 U-Net 不同,我们将填充物,以便不会在边缘丢失像素,因此我们左右两侧的分辨率相同。

早期的网络设计已经具有这种 U 形状,人们试图利用它来解决完全卷积网络的有限感受野大小问题。为了解决这个有限的感受野大小问题,他们使用了一种设计,复制、反转并附加图像分类网络的聚焦部分,以创建一个从精细详细到宽感受野再到精细详细的对称模型。

然而,早期的网络设计存在收敛问题,这很可能是由于在下采样过程中丢失了空间信息。一旦信息到达大量非常缩小的图像,对象边界的确切位置变得更难编码,因此更难重建。为了解决这个问题,U-Net 的作者在图中心添加了我们看到的跳跃连接。我们在第八章首次接触到跳跃连接,尽管它们在这里的应用方式与 ResNet 架构中的不同。在 U-Net 中,跳跃连接将输入沿着下采样路径短路到上采样路径中的相应层。这些层接收来自 U 较低位置的宽感受野层的上采样结果以及通过“复制和裁剪”桥接连接的早期精细详细层的输出作为输入。这是 U-Net 的关键创新(有趣的是,这比 ResNet 更早)。

所有这些意味着这些最终的细节层在最佳状态下运作。它们既具有关于周围环境的更大背景信息,又具有来自第一组全分辨率层的精细详细数据。

最右侧的“conv 1x1”层位于网络头部,将通道数从 64 改变为 2(原始论文有 2 个输出通道;我们的情况下有 1 个)。这在某种程度上类似于我们在分类网络中使用的全连接层,但是逐像素、逐通道:这是一种将最后一次上采样步骤中使用的滤波器数量转换为所需的输出类别数量的方法。

13.4 更新用于分割的模型

现在是按照图 13.8 中的步骤 2A 进行操作的时候了。我们已经对分割理论和 U-Net 的历史有了足够的了解;现在我们想要更新我们的代码,从模型开始。我们不再只输出一个给出真或假的二进制分类,而是集成一个 U-Net,以获得一个能够为每个像素输出概率的模型:也就是执行分割。我们不打算从头开始实现自定义 U-Net 分割模型,而是打算从 GitHub 上的一个开源存储库中适用一个现有的实现。

github.com/jvanvugt/pytorch-unet 上的 U-Net 实现似乎很好地满足我们的需求。它是 MIT 许可的(版权 2018 Joris),包含在一个单独的文件中,并且有许多参数选项供我们调整。该文件包含在我们的代码存储库中的 util/unet.py 中,同时附有原始存储库的链接和使用的完整许可证文本。

注意 虽然对于个人项目来说这不是太大问题,但重要的是要注意你为项目使用的开源软件附带的许可条款。MIT 许可证是最宽松的开源许可证之一,但它仍对使用 MIT 许可的代码的用户有要求!还要注意,即使作者在公共论坛上发布他们的作品,他们仍保留版权(是的,即使在 GitHub 上也是如此),如果他们没有包含许可证,这并意味着该作品属于公共领域。恰恰相反!这意味着你没有任何使用代码的许可,就像你没有权利从图书馆借来的书中全文复制一样。

我们建议花一些时间检查代码,并根据你到目前为止建立的知识,识别体系结构中反映在代码中的构建模块。你能发现跳跃连接吗?对你来说一个特别有价值的练习是通过查看代码绘制显示模型布局的图表。

现在我们找到了一个符合要求的 U-Net 实现,我们需要调整它以使其适用于我们的需求。一般来说,留意可以使用现成解决方案的情况是一个好主意。重要的是要了解存在哪些模型,它们是如何实现和训练的,以及是否可以拆解和应用到我们当前正在进行的项目中。虽然这种更广泛的知识是随着时间和经验而来的,但现在开始建立这个工具箱是一个好主意。

13.4.1 将现成模型调整为我们的项目

现在我们将对经典 U-Net 进行一些更改,并在此过程中加以证明。对你来说一个有用的练习是比较原始模型和经过调整后的模型的结果,最好一次删除一个以查看每个更改的影响(这在研究领域也称为消融研究)。

图 13.8 本章大纲,重点关注我们分割模型所需的更改

首先,我们将通过批量归一化将输入传递。这样,我们就不必在数据集中自己归一化数据;更重要的是,我们将获得在单个批次上估计的归一化统计数据(读取均值和标准差)。这意味着当某个批次由于某种原因变得单调时--也就是说,当所有馈送到网络中的 CT 裁剪中没有什么可见时--它将被更强烈地缩放。每个时期随机选择批次中的样本将最大程度地减少单调样本最终进入全单调批次的机会,从而过度强调这些单调样本。

其次,由于输出值是不受限制的,我们将通过一个 nn.Sigmoid 层将输出传递以将输出限制在 [0, 1] 范围内。第三,我们将减少模型允许使用的总深度和滤波器数量。虽然这有点超前,但使用标准参数的模型容量远远超过我们的数据集大小。这意味着我们不太可能找到一个与我们确切需求匹配的预训练模型。最后,尽管这不是一种修改,但重要的是要注意我们的输出是单通道,输出的每个像素表示模型估计该像素是否属于结节的概率。

通过实现一个具有三个属性的模型来简单地包装 U-Net:分别是我们想要添加的两个特征和 U-Net 本身--我们可以像在这里处理任何预构建模块一样对待。我们还将把收到的任何关键字参数传递给 U-Net 构造函数。

列表 13.1 model.py:17,class UNetWrapper

class UNetWrapper(nn.Module):
  def __init__(self, **kwargs):                                    # ❶
    super().__init__()

    self.input_batchnorm = nn.BatchNorm2d(kwargs['in_channels'])   # ❷
    self.unet = UNet(**kwargs)                                     # ❸
    self.final = nn.Sigmoid()

    self._init_weights()                                           # ❹

❶ kwarg 是一个包含传递给构造函数的所有关键字参数的字典。

❷ BatchNorm2d 要求我们指定输入通道的数量,我们从关键字参数中获取。

❸ U-Net:这里包含的是一个小细节,但它确实在发挥作用。

❹ 就像第十一章中的分类器一样,我们使用我们自定义的权重初始化。该函数已复制,因此我们不会再次显示代码。

forward方法是一个同样简单的序列。我们可以使用nn.Sequential的实例,就像我们在第八章中看到的那样,但为了代码的清晰度和堆栈跟踪的清晰度,我们在这里明确说明。

第 13.2 节 model.py:50, UNetWrapper.forward

def forward(self, input_batch):
  bn_output = self.input_batchnorm(input_batch)
  un_output = self.unet(bn_output)
  fn_output = self.final(un_output)
  return fn_output

请注意,我们在这里使用nn.BatchNorm2d。这是因为 U-Net 基本上是一个二维分割模型。我们可以调整实现以使用 3D 卷积,以便跨切片使用信息。直接实现的内存使用量将大大增加:也就是说,我们将不得不分割 CT 扫描。此外,Z 方向的像素间距比平面方向大得多,这使得结节不太可能跨越多个切片存在。这些考虑因素使得我们的目的不太吸引人的完全 3D 方法。相反,我们将调整我们的 3D 数据,一次对一个切片进行分割,提供相邻切片的上下文(例如,随着相邻切片的出现,检测到明亮的块确实是血管变得更容易)。由于我们仍然坚持以 2D 形式呈现数据,我们将使用通道来表示相邻切片。我们对第三维的处理类似于我们在第七章中将全连接模型应用于图像的方式:模型将不得不重新学习我们沿轴向丢弃的邻接关系,但对于模型来说这并不困难,尤其是考虑到由于目标结构的小尺寸而给出的上下文切片数量有限。

13.5 更新用于分割的数据集

本章的源数据保持不变:我们正在使用 CT 扫描和有关它们的注释数据。但是我们的模型期望输入和输出的形式与以前不同。正如我们在图 13.9 的第 2B 步骤中所暗示的,我们以前的数据集生成了 3D 数据,但现在我们需要生成 2D 数据。

图 13.9 本章概述,重点关注我们分割数据集所需的变化

原始 U-Net 实现没有使用填充卷积,这意味着虽然输出分割地图比输入小,但输出的每个像素都具有完全填充的感受野。用于确定该输出像素的所有输入像素都没有填充、虚构或不完整。因此,原始 U-Net 的输出将完全平铺,因此它可以与任何大小的图像一起使用(除了输入图像的边缘,那里将缺少一些上下文)。

对于我们的问题采用相同的像素完美方法存在两个问题。第一个与卷积和下采样之间的交互有关,第二个与我们的数据性质是三维的有关。

13.5.1 U-Net 具有非常具体的输入尺寸要求

第一个问题是 U-Net 的输入和输出补丁的大小非常具体。为了使每个卷积线的两个像素损失在下采样之前和之后对齐(特别是考虑到在较低分辨率处进一步卷积收缩),只有某些输入尺寸才能起作用。U-Net 论文使用了 572×572 的图像补丁,导致了 388×388 的输出地图。输入图像比我们的 512×512 CT 切片大,输出则小得多!这意味着靠近 CT 扫描切片边缘的任何结节都不会被分割。尽管在处理非常大的图像时这种设置效果很好,但对于我们的用例来说并不理想。

我们将通过将 U-Net 构造函数的padding标志设置为True来解决这个问题。这意味着我们可以使用任何大小的输入图像,并且我们将得到相同大小的输出。我们可能会在图像边缘附近失去一些保真度,因为位于那里的像素的感受野将包括已被人为填充的区域,但这是我们决定接受的妥协。

13.5.2 3D 与 2D 数据的 U-Net 权衡

第二个问题是我们的 3D 数据与 U-Net 的 2D 预期输入不完全对齐。简单地将我们的 512×512×128 图像输入到转换为 3D 的 U-Net 类中是行不通的,因为我们会耗尽 GPU 内存。每个图像是 29×29×27,每个体素 22 字节。U-Net 的第一层是 64 个通道,或 26。这是 9 + 9 + 7 + 2 + 6 的指数= 33,或 8 GB 仅用于第一个卷积层。有两个卷积层(16 GB);然后每次下采样都会减半分辨率但加倍通道,这是第一个下采样后每层另外 2 GB(记住,减半分辨率会导致数据减少八分之一,因为我们处理的是 3D 数据)。因此,甚至在我们到达第二次下采样之前,我们就已经达到了 20 GB,更不用说模型上采样端或处理自动梯度的任何内容了。

注意 有许多巧妙和创新的方法可以解决这些问题,我们绝不认为这是唯一可行的方法。⁶ 我们认为这种方法是在这本书中我们项目所需的水平上完成工作的最简单方法之一。我们宁愿保持简单,这样我们就可以专注于基本概念;聪明的东西可以在你掌握基础知识后再来。

如预期的那样,我们不会尝试在 3D 中进行操作,而是将每个切片视为一个 2D 分割问题,并通过提供相邻切片作为单独的通道来绕过第三维中的上下文问题。我们的主要通道不再是我们从照片图像中熟悉的“红色”,“绿色”和“蓝色”通道,而是“上面两个切片”,“上面一个切片”,“我们实际分割的切片”,“下面一个切片”等。

然而,这种方法并非没有权衡。当表示为通道时,我们失去了切片之间的直接空间关系,因为所有通道将被卷积核线性组合,没有它们相隔一两个切片,上下的概念。我们还失去了来自真正的 3D 分割的深度维度中更广泛的感受野。由于 CT 切片通常比行和列的分辨率厚,我们获得的视野比起初看起来要宽一些,这应该足够了,考虑到结节通常跨越有限数量的切片。

要考虑的另一个方面,对于当前和完全 3D 方法都相关的是,我们现在忽略了确切的切片厚度。这是我们的模型最终将不得不学会对抗的东西,通过呈现具有不同切片间距的数据。

一般来说,没有一个简单的流程图或经验法则可以提供关于做出哪些权衡或给定一组妥协是否太多的标准答案。然而,仔细的实验至关重要,系统地测试假设之后的假设可以帮助缩小哪些变化和方法对手头问题有效的范围。虽然在等待最后一组结果计算时进行一连串的更改很诱人,但要抵制这种冲动

这一点非常重要:不要同时测试多个修改。有很高的机会其中一个改变会与另一个产生不良互动,你将没有坚实的证据表明任何一个值得进一步调查。说了这么多,让我们开始构建我们的分割数据集。

13.5.3 构建地面真实数据

我们需要解决的第一件事是我们的人工标记的训练数据与我们希望从模型中获得的实际输出之间存在不匹配。我们有注释点,但我们想要一个逐体素掩模,指示任何给定的体素是否属于结节。我们将不得不根据我们拥有的数据构建该掩模,然后进行一些手动检查,以确保构建掩模的例程表现良好。

在规模上验证这些手动构建的启发式方法可能会很困难。当涉及确保每个结节都得到适当处理时,我们不会尝试做任何全面的工作。如果我们有更多资源,像“与(或支付)某人合作创建和/或手动验证所有内容”这样的方法可能是一个选择,但由于这不是一个资金充足的努力,我们将依靠检查少量样本并使用非常简单的“输出看起来合理吗?”方法。

为此,我们将设计我们的方法和我们的 API,以便轻松调查我们的算法正在经历的中间步骤。虽然这可能导致稍微笨重的函数调用返回大量中间值的元组,但能够轻松获取结果并在笔记本中绘制它们使得这种笨重值得。

边界框

我们将从将我们拥有的结节位置转换为覆盖整个结节的边界框开始(请注意,我们只会为实际结节这样做)。如果我们假设结节位置大致位于肿块中心,我们可以沿着所有三个维度从该点向外追踪,直到遇到低密度的体素,表明我们已经到达了主要充满空气的正常肺组织。让我们在图 13.10 中遵循这个算法。

图 13.10 围绕肺结节找到边界框的算法

我们从我们的搜索起点(图中的 O)开始在注释的结节中心的体素处。然后我们检查沿着列轴的原点相邻体素的密度,用问号(?)标记。由于两个检查的体素都包含密集组织,显示为浅色,我们继续我们的搜索。在将列搜索距离增加到 2 后,我们发现左侧的体素密度低于我们的阈值,因此我们在 2 处停止搜索。

接下来,我们在行方向上执行相同的搜索。同样,我们从原点开始,这次我们向上下搜索。当我们的搜索距离变为 3 时,在上下搜索位置都遇到了低密度的体素。我们只需要一个就可以停止我们的搜索!

我们将跳过在第三维度中显示搜索。我们最终的边界框宽度为五个体素,高度为七个体素。这是在代码中的索引方向的样子。

代码清单 13.3 dsets.py:131,Ct.buildAnnotationMask

center_irc = xyz2irc(
  candidateInfo_tup.center_xyz,                                   # ❶
  self.origin_xyz,
  self.vxSize_xyz,
  self.direction_a,
)
ci = int(center_irc.index)                                        # ❷
cr = int(center_irc.row)
cc = int(center_irc.col)

index_radius = 2
try:
  while self.hu_a[ci + index_radius, cr, cc] > threshold_hu and \
     self.hu_a[ci - index_radius, cr, cc] > threshold_hu:         # ❸
     index_radius += 1
except IndexError:                                                # ❹
  index_radius -= 1

❶ 这里的 candidateInfo_tup 与我们之前看到的相同:由 getCandidateInfoList 返回。

❷ 获取中心体素的索引,这是我们的起点

❸ 先前描述的搜索

❹ 超出张量大小的索引的安全网

我们首先获取中心数据,然后在while循环中进行搜索。作为一个轻微的复杂性,我们的搜索可能超出张量的边界。我们对这种情况并不太担心,也很懒,所以我们只捕获索引异常。

请注意,当密度降低到阈值以下时,我们停止增加非常粗略的radius值,因此我们的边界框应包含低密度组织的一个体素边界(至少在一侧;由于结节可能与肺壁等密度较高的组织相邻,当我们在任一侧遇到空气时,我们必须停止搜索)。由于我们将center_index + index_radiuscenter_index - index_radius与该阈值进行比较,因此该一个体素边界仅存在于最接近结节位置的边缘。这就是为什么我们需要这些位置相对居中。由于一些结节与肺和肌肉或骨骼等密度较高的组织之间的边界相邻,我们不能独立追踪每个方向,因为一些边缘最终会远离实际结节。

然后,我们使用row_radiuscol_radius重复相同的半径扩展过程(为简洁起见,此代码被省略)。完成后,我们可以将边界框掩码数组中的一个框设置为True(我们很快就会看到boundingBox_ary的定义;这并不令人惊讶)。

好的,让我们将所有这些封装在一个函数中。我们遍历所有结节。对于每个结节,我们执行之前显示的搜索(我们在代码清单 13.4 中省略了)。然后,在一个布尔张量boundingBox_a中,我们标记我们找到的边界框。

循环结束后,我们通过取边界框掩码和密度高于-700 HU(或 0.3 g/cc)的组织之间的交集来进行一些清理。这将剪裁掉我们的盒子的角(至少是那些不嵌入在肺壁中的盒子),使其更符合结节的轮廓。

代码清单 13.4 dsets.py:127,Ct.buildAnnotationMask

def buildAnnotationMask(self, positiveInfo_list, threshold_hu = -700):
  boundingBox_a = np.zeros_like(self.hu_a, dtype=np.bool)                # ❶

  for candidateInfo_tup in positiveInfo_list:                            # ❷
    # ... line 169
    boundingBox_a[
       ci - index_radius: ci + index_radius + 1,
       cr - row_radius: cr + row_radius + 1,
       cc - col_radius: cc + col_radius + 1] = True                      # ❸

  mask_a = boundingBox_a & (self.hu_a > threshold_hu)                    # ❹

  return mask_a

❶ 从与 CT 相同大小的全 False 张量开始

❷ 遍历结节。作为我们只查看结节的提醒,我们称之为 positiveInfo_list。

❸ 在获取结节半径后(搜索本身被省略了),我们标记边界框。

❹ 将掩码限制为高于我们密度阈值的体素

让我们看一下图 13.11,看看这些掩码在实践中是什么样子。完整彩色图像可以在 p2ch13_explore_data.ipynb 笔记本中找到。

图 13.11 ct.positive_mask中突出显示的三个结节,白色标记

右下角的结节掩码展示了我们矩形边界框方法的局限性,包括部分肺壁。这当然是我们可以修复的问题,但由于我们还没有确信这是我们时间和注意力的最佳利用方式,所以我们暂时让它保持原样。接下来,我们将继续将此掩码添加到我们的 CT 类中。

在 CT 初始化期间调用掩码创建

现在我们可以将结节信息元组列表转换为与 CT 形状相同的二进制“这是一个结节吗?”掩码,让我们将这些掩码嵌入到我们的 CT 对象中。首先,我们将我们的候选人筛选为仅包含结节的列表,然后我们将使用该列表构建注释掩码。最后,我们将收集具有至少一个结节掩码体素的唯一数组索引集。我们将使用这些数据来塑造我们用于验证的数据。

代码清单 13.5 dsets.py:99,Ct.__init__

def __init__(self, series_uid):
  # ... line 116
  candidateInfo_list = getCandidateInfoDict()[self.series_uid]

  self.positiveInfo_list = [
    candidate_tup
    for candidate_tup in candidateInfo_list
    if candidate_tup.isNodule_bool                                       # ❶
  ]
  self.positive_mask = self.buildAnnotationMask(self.positiveInfo_list)
  self.positive_indexes = (self.positive_mask.sum(axis=(1,2))            # ❷
                .nonzero()[0].tolist())                                  # ❸

❶ 用于结节的过滤器

❷ 给出一个 1D 向量(在切片上)中每个切片中标记的掩码体素数量

❸ 获取具有非零计数的掩码切片的索引,我们将其转换为列表

敏锐的眼睛可能已经注意到了getCandidateInfoDict函数。定义并不令人惊讶;它只是getCandidateInfoList函数中相同信息的重新表述,但是预先按series_uid分组。

代码清单 13.6 dsets.py:87

@functools.lru_cache(1)                                        # ❶
def getCandidateInfoDict(requireOnDisk_bool=True):
  candidateInfo_list = getCandidateInfoList(requireOnDisk_bool)
  candidateInfo_dict = {}

  for candidateInfo_tup in candidateInfo_list:
    candidateInfo_dict.setdefault(candidateInfo_tup.series_uid,
                    []).append(candidateInfo_tup)              # ❷

  return candidateInfo_dict

❶ 这对于避免 Ct init 成为性能瓶颈很有用。

❷ 获取字典中系列 UID 的候选人列表,如果找不到,则默认为一个新的空列表。然后将当前的 candidateInfo_tup 附加到其中。

缓存掩模的块以及 CT

在早期章节中,我们缓存了围绕结节候选项中心的 CT 块,因为我们不想每次想要 CT 的小块时都读取和解析整个 CT 的数据。我们希望对我们的新的 positive _mask 也做同样的处理,因此我们还需要从我们的 Ct.getRawCandidate 函数中返回它。这需要额外的一行代码和对 return 语句的编辑。

列表 13.7 dsets.py:178, Ct.getRawCandidate

def getRawCandidate(self, center_xyz, width_irc):
  center_irc = xyz2irc(center_xyz, self.origin_xyz, self.vxSize_xyz,
             self.direction_a)

  slice_list = []
  # ... line 203
  ct_chunk = self.hu_a[tuple(slice_list)]
  pos_chunk = self.positive_mask[tuple(slice_list)]   # ❶

  return ct_chunk, pos_chunk, center_irc              # ❷

❶ 新添加的

❷ 这里返回了新值

这将通过 getCtRawCandidate 函数缓存到磁盘,该函数打开 CT,获取指定的原始候选项,包括结节掩模,并在返回 CT 块、掩模和中心信息之前剪裁 CT 值。

列表 13.8 dsets.py:212

@raw_cache.memoize(typed=True)
def getCtRawCandidate(series_uid, center_xyz, width_irc):
  ct = getCt(series_uid)
  ct_chunk, pos_chunk, center_irc = ct.getRawCandidate(center_xyz,
                             width_irc)
  ct_chunk.clip(-1000, 1000, ct_chunk)
  return ct_chunk, pos_chunk, center_irc

prepcache 脚本为我们预先计算并保存所有这些值,帮助保持训练速度。

清理我们的注释数据

我们在本章还要处理的另一件事是对我们的注释数据进行更好的筛选。事实证明,candidates.csv 中列出的几个候选项出现了多次。更有趣的是,这些条目并不是彼此的完全重复。相反,原始的人类注释在输入文件之前并没有经过充分的清理。它们可能是关于同一结节在不同切片上的注释,这甚至可能对我们的分类器有益。

在这里我们将进行一些简化,并提供一个经过清理的 annotation.csv 文件。为了完全了解这个清理文件的来源,您需要知道 LUNA 数据集源自另一个名为肺部图像数据库协会图像集(LIDC-IDRI)的数据集,并包含来自多名放射科医生的详细注释信息。我们已经完成了获取原始 LIDC 注释、提取结节、去重并将它们保存到文件 /data/part2/luna/annotations_with_malignancy.csv 的工作。

有了那个文件,我们可以更新我们的 getCandidateInfoList 函数,从我们的新注释文件中提取结节。首先,我们遍历实际结节的新注释。使用 CSV 读取器,¹⁰我们需要将数据转换为适当的类型,然后将它们放入我们的 CandidateInfoTuple 数据结构中。

列表 13.9 dsets.py:43, def getCandidateInfoList

candidateInfo_list = []
with open('data/part2/luna/annotations_with_malignancy.csv', "r") as f:
  for row in list(csv.reader(f))[1:]:                                   # ❶
    series_uid = row[0]
    annotationCenter_xyz = tuple([float(x) for x in row[1:4]])
    annotationDiameter_mm = float(row[4])
    isMal_bool = {'False': False, 'True': True}[row[5]]
    candidateInfo_list.append(                                          # ❷
      CandidateInfoTuple(
        True,                                                           # ❸
        True,                                                           # ❹
        isMal_bool,
        annotationDiameter_mm,
        series_uid,
        annotationCenter_xyz,
      )
    )

❶ 对于注释文件中表示一个结节的每一行,...

❷ ... 我们向我们的列表添加一条记录。

❸ isNodule_bool

❹ hasAnnotation_bool

类似地,我们像以前一样遍历 candidates.csv 中的候选项,但这次我们只使用非结节。由于这些不是结节,结节特定信息将只填充为 False0

列表 13.10 dsets.py:62, def getCandidateInfoList

with open('data/part2/luna/candidates.csv', "r") as f:
  for row in list(csv.reader(f))[1:]:                  # ❶
    series_uid = row[0]
    # ... line 72
    if not isNodule_bool:                              # ❷
      candidateInfo_list.append(                       # ❸
        CandidateInfoTuple(
          False,                                       # ❹
          False,                                       # ❺
          False,                                       # ❻
          0.0,
          series_uid,
          candidateCenter_xyz,
        )
      )

❶ 对于候选文件中的每一行...

❷ ... 但只有非结节(我们之前有其他的)...

❸ ... 我们添加一个候选记录。

❹ isNodule_bool

❺ hasAnnotation_bool

❻ isMal_bool

除了添加hasAnnotation_boolisMal_bool标志(我们在本章不会使用),新的注释将插入并可像旧的一样使用。

注意 您可能会想知道为什么我们到现在才讨论 LIDC。事实证明,LIDC 已经围绕基础数据集构建了大量工具,这些工具是特定于 LIDC 的。您甚至可以从 PyLIDC 获取现成的掩模。这些工具呈现了一个有些不切实际的图像,说明了给定数据集可能具有的支持类型,因为 LIDC 的支持异常充分。我们对 LUNA 数据所做的工作更具典型性,并提供更好的学习,因为我们花时间操纵原始数据,而不是学习别人设计的 API。

13.5.4 实现 Luna2dSegmentationDataset

与之前的章节相比,我们在本章将采用不同的方法来进行训练和验证集的划分。我们将有两个类:一个作为适用于验证数据的通用基类,另一个作为基类的子类,用于训练集,具有随机化和裁剪样本。

尽管这种方法在某些方面有些复杂(例如,类并不完全封装),但实际上简化了选择随机训练样本等逻辑。它还非常清楚地显示了哪些代码路径影响训练和验证,哪些是仅与训练相关的。如果没有这一点,我们发现一些逻辑可能会以难以跟踪的方式嵌套或交织在一起。这很重要,因为我们的训练数据与验证数据看起来会有很大不同!

注意 其他类别的安排也是可行的;例如,我们考虑过完全分开两个独立的Dataset子类。标准软件工程设计原则适用,因此尽量保持结构相对简单,尽量不要复制粘贴代码,但不要发明复杂的框架来防止重复三行代码。

我们生成的数据将是具有多个通道的二维 CT 切片。额外的通道将保存相邻的 CT 切片。回想图 4.2,这里显示为图 13.12;我们可以看到每个 CT 扫描切片都可以被视为二维灰度图像。

图 13.12 CT 扫描的每个切片代表空间中的不同位置。

我们如何组合这些切片取决于我们。对于我们分类模型的输入,我们将这些切片视为数据的三维数组,并使用三维卷积来处理每个样本。对于我们的分割模型,我们将把每个切片视为单个通道,生成一个多通道的二维图像。这样做意味着我们将每个 CT 扫描切片都视为 RGB 图像的颜色通道,就像我们在图 4.1 中看到的那样,这里重复显示为图 13.13。CT 的每个输入切片将被堆叠在一起,并像任何其他二维图像一样被消耗。我们堆叠的 CT 图像的通道不会对应颜色,但是二维卷积并不要求输入通道是颜色,所以这样做没问题。

图 13.13 摄影图像的每个通道代表不同的颜色。

对于验证,我们需要为每个具有正面掩模条目的 CT 切片生成一个样本,对于我们拥有的每个验证 CT。由于不同的 CT 扫描可能具有不同的切片计数,我们将引入一个新函数,将每个 CT 扫描及其正面掩模的大小缓存到磁盘上。我们需要这样做才能快速构建完整的验证集大小,而无需在Dataset初始化时加载每个 CT。我们将继续使用与之前相同的缓存装饰器。填充这些数据也将在 prepcache.py 脚本中进行,我们必须在开始任何模型训练之前运行一次。

列表 13.11 dsets.py:220

@raw_cache.memoize(typed=True)
def getCtSampleSize(series_uid):
  ct = Ct(series_uid)
  return int(ct.hu_a.shape[0]), ct.positive_indexes

Luna2dSegmentationDataset.__init__方法的大部分处理与我们之前看到的类似。我们有一个新的contextSlices_count参数,以及类似于我们在第十二章介绍的augmentation_dict

指示这是否应该是训练集还是验证集的标志处理需要有所改变。由于我们不再对单个结节进行训练,我们将不得不将整个系列列表作为一个整体划分为训练集和验证集。这意味着整个 CT 扫描以及其中包含的所有结节候选者将分别位于训练集或验证集中。

列表 13.12 dsets.py:242, .__init__

if isValSet_bool:
  assert val_stride > 0, val_stride
  self.series_list = self.series_list[::val_stride]   # ❶
  assert self.series_list
elif val_stride > 0:
  del self.series_list[::val_stride]                  # ❷
  assert self.series_list

❶ 从包含所有系列的系列列表开始,我们仅保留每个val_stride元素,从 0 开始。

❷ 如果我们在训练中,我们会删除每个val_stride元素。

谈到验证,我们将有两种不同的模式可以验证我们的训练。首先,当fullCt_boolTrue时,我们将使用 CT 中的每个切片作为我们的数据集。当我们评估端到端性能时,这将非常有用,因为我们需要假装我们对 CT 没有任何先前信息。我们将在训练期间使用第二种模式进行验证,即当我们限制自己只使用具有阳性掩模的 CT 切片时。

由于我们现在只想考虑特定的 CT 序列,我们循环遍历我们想要的序列 UID,并获取总切片数和有趣切片的列表。

列表 13.13 dsets.py:250, .__init__

self.sample_list = []
for series_uid in self.series_list:
  index_count, positive_indexes = getCtSampleSize(series_uid)

  if self.fullCt_bool:
    self.sample_list += [(series_uid, slice_ndx)      # ❶
               for slice_ndx in range(index_count)]
  else:
    self.sample_list += [(series_uid, slice_ndx)      # ❷
               for slice_ndx in positive_indexes]

❶ 在这里,我们通过使用范围扩展样本列表中的每个 CT 切片...

❷ ... 而在这里我们只取有趣的切片。

以这种方式进行将保持我们的验证相对快速,并确保我们获得真阳性和假阴性的完整统计数据,但我们假设其他切片的假阳性和真阴性统计数据与我们在验证期间评估的统计数据相对类似。

一旦我们有了要使用的series_uid值集合,我们可以将我们的candidateInfo_list过滤为仅包含series_uid包含在该系列集合中的结节候选者。此外,我们将创建另一个仅包含阳性候选者的列表,以便在训练期间,我们可以将它们用作我们的训练样本。

列表 13.14 dsets.py:261, .__init__

self.candidateInfo_list = getCandidateInfoList()                   # ❶

series_set = set(self.series_list)                                 # ❷
self.candidateInfo_list = [cit for cit in self.candidateInfo_list
               if cit.series_uid in series_set]                    # ❸

self.pos_list = [nt for nt in self.candidateInfo_list
          if nt.isNodule_bool]                                     # ❹

❶ 这是缓存的。

❷ 创建一个集合以加快查找速度。

❸ 过滤掉不在我们集合中的系列的候选者

❹ 对于即将到来的数据平衡,我们需要一个实际结节的列表。

我们的__getitem__实现也会更加复杂,通过将大部分逻辑委托给一个函数,使得检索特定样本变得更容易。在其核心,我们希望以三种不同形式检索我们的数据。首先,我们有 CT 的完整切片,由series_uidct_ndx指定。其次,我们有围绕结节的裁剪区域,这将用于训练数据(我们稍后会解释为什么我们不使用完整切片)。最后,DataLoader将通过整数ndx请求样本,数据集将根据是训练还是验证来返回适当的类型。

基类或子类__getitem__函数将根据需要从整数ndx转换为完整切片或训练裁剪。如前所述,我们的验证集的__getitem__只是调用另一个函数来执行真正的工作。在此之前,它将索引包装到样本列表中,以便将 epoch 大小(由数据集长度给出)与实际样本数量分离。

列表 13.15 dsets.py:281, .__getitem__

def __getitem__(self, ndx):
  series_uid, slice_ndx = self.sample_list[ndx % len(self.sample_list)]  # ❶
  return self.getitem_fullSlice(series_uid, slice_ndx)

❶ 模运算进行包装。

这很容易,但我们仍然需要实现getItem_fullSlice方法中的有趣功能。

列表 13.16 dsets.py:285, .getitem_fullSlice

def getitem_fullSlice(self, series_uid, slice_ndx):
  ct = getCt(series_uid)
  ct_t = torch.zeros((self.contextSlices_count * 2 + 1, 512, 512))    # ❶

  start_ndx = slice_ndx - self.contextSlices_count
  end_ndx = slice_ndx + self.contextSlices_count + 1
  for i, context_ndx in enumerate(range(start_ndx, end_ndx)):
    context_ndx = max(context_ndx, 0)                                 # ❷
    context_ndx = min(context_ndx, ct.hu_a.shape[0] - 1)
    ct_t[i] = torch.from_numpy(ct.hu_a[context_ndx].astype(np.float32))
  ct_t.clamp_(-1000, 1000)

  pos_t = torch.from_numpy(ct.positive_mask[slice_ndx]).unsqueeze(0)

  return ct_t, pos_t, ct.series_uid, slice_ndx

❶ 预先分配输出

❷ 当我们超出 ct_a 的边界时,我们复制第一个或最后一个切片。

将函数分割成这样可以让我们始终向数据集询问特定切片(或裁剪的训练块,我们将在下一节中看到)通过序列 UID 和位置索引。仅对于整数索引,我们通过__getitem__进行,然后从(打乱的)列表中获取样本。

除了ct_tpos_t之外,我们返回的元组的其余部分都是我们包含用于调试和显示的信息。我们在训练中不需要任何这些信息。

13.5.5 设计我们的训练和验证数据

在我们开始实现训练数据集之前,我们需要解释为什么我们的训练数据看起来与验证数据不同。我们将不再使用完整的 CT 切片,而是将在我们的正候选项周围(实际上是结节候选项)训练 64×64 的裁剪。这些 64×64 的补丁将随机从以结节为中心的 96×96 裁剪中取出。我们还将在两个方向上包括三个切片的上下文作为我们 2D 分割的附加“通道”。

我们这样做是为了使训练更加稳定,收敛更快。我们之所以知道这样做是因为我们尝试在整个 CT 切片上进行训练,但我们发现结果令人不满意。经过一些实验,我们发现 64×64 的半随机裁剪方法效果不错,所以我们决定在书中使用这种方法。当你在自己的项目上工作时,你需要为自己做这种实验!

我们认为整个切片训练不稳定主要是由于类平衡问题。由于每个结节与整个 CT 切片相比非常小,我们又回到了上一章中摆脱的类似于大海捞针的情况,其中我们的正样本被负样本淹没。在这种情况下,我们谈论的是像素而不是结节,但概念是相同的。通过在裁剪上进行训练,我们保持了正像素数量不变,并将负像素数量减少了几个数量级。

因为我们的分割模型是像素到像素的,并且接受任意大小的图像,所以我们可以在具有不同尺寸的样本上进行训练和验证。验证使用相同的卷积和相同的权重,只是应用于更大的像素集(因此需要填充边缘数据的像素较少)。

这种方法的一个缺点是,由于我们的验证集包含数量级更多的负像素,我们的模型在验证期间将有很高的假阳性率。我们的分割模型有很多机会被欺骗!并且我们还将追求高召回率。我们将在第 13.6.3 节中更详细地讨论这一点。

13.5.6 实现 TrainingLuna2dSegmentationDataset

有了这个,让我们回到代码。这是训练集的__getitem__。它看起来就像验证集的一个,只是现在我们从pos_list中采样,并使用候选信息元组调用getItem_trainingCrop,因为我们需要系列和确切的中心位置,而不仅仅是切片。

代码清单 13.17 dsets.py:320,.__getitem__

def __getitem__(self, ndx):
  candidateInfo_tup = self.pos_list[ndx % len(self.pos_list)]
  return self.getitem_trainingCrop(candidateInfo_tup)

要实现getItem_trainingCrop,我们将使用一个类似于分类训练中使用的getCtRawCandidate函数。在这里,我们传入一个不同尺寸的裁剪,但该函数除了现在返回一个包含ct.positive_mask裁剪的额外数组外,没有改变。

我们将我们的pos_a限制在我们实际分割的中心切片上,然后构建我们的 96×96 给定的裁剪的 64×64 随机裁剪。一旦我们有了这些,我们返回一个与我们的验证数据集相同项目的元组。

代码清单 13.18 dsets.py:324,.getitem_trainingCrop

def getitem_trainingCrop(self, candidateInfo_tup):
  ct_a, pos_a, center_irc = getCtRawCandidate(     # ❶
    candidateInfo_tup.series_uid,
    candidateInfo_tup.center_xyz,
    (7, 96, 96),
  )
  pos_a = pos_a[3:4]                               # ❷

  row_offset = random.randrange(0,32)              # ❸
  col_offset = random.randrange(0,32)
  ct_t = torch.from_numpy(ct_a[:, row_offset:row_offset+64,
                 col_offset:col_offset+64]).to(torch.float32)
  pos_t = torch.from_numpy(pos_a[:, row_offset:row_offset+64,
                   col_offset:col_offset+64]).to(torch.long)

  slice_ndx = center_irc.index

  return ct_t, pos_t, candidateInfo_tup.series_uid, slice_ndx

❶ 获取带有一点额外周围的候选项

❷ 保留第三维度的一个元素切片,这将是(单一的)输出通道。

❸ 使用 0 到 31 之间的两个随机数,我们裁剪 CT 和掩模。

你可能已经注意到我们的数据集实现中缺少数据增强。这一次我们将以稍有不同的方式处理:我们将在 GPU 上增强我们的数据。

13.5.7 在 GPU 上进行数据增强

在训练深度学习模型时的一个关键问题是避免训练管道中的瓶颈。嗯,这并不完全正确--总会有一个瓶颈。[¹²]

一些常见的瓶颈出现在以下情况:

  • 在数据加载管道中,无论是在原始 I/O 中还是在将数据解压缩后。我们使用diskcache库来解决这个问题。

  • 在加载数据的 CPU 预处理中。这通常是数据归一化或增强。

  • 在 GPU 上的训练循环中。这通常是我们希望瓶颈出现的地方,因为 GPU 的总体深度学习系统成本通常高于存储或 CPU。

  • 瓶颈通常不太常见,有时可能是 CPU 和 GPU 之间的内存带宽。这意味着与发送的数据大小相比,GPU 的工作量并不大。

由于 GPU 在处理适合 GPU 的任务时可以比 CPU 快 50 倍,因此在 CPU 使用率变高时,通常有意义将这些任务从 CPU 移动到 GPU。特别是如果数据在此处理过程中被扩展;通过首先将较小的输入移动到 GPU,扩展的数据保持在 GPU 本地,使用的内存带宽较少。

在我们的情况下,我们将数据增强移到 GPU 上。这将使我们的 CPU 使用率较低,GPU 将轻松地承担额外的工作量。与其让 GPU 空闲等待 CPU 努力完成增强过程,不如让 GPU 忙于少量额外工作。

我们将通过使用第二个模型来实现这一点,这个模型与本书中迄今为止看到的所有nn.Module的子类类似。主要区别在于我们不感兴趣通过模型反向传播梯度,并且forward方法将执行完全不同的操作。由于我们在本章中处理的是 2D 数据,因此实际增强例程将进行一些轻微修改,但除此之外,增强将与我们在第十二章中看到的非常相似。该模型将消耗张量并产生不同的张量,就像我们实现的其他模型一样。

我们模型的__init__接受相同的数据增强参数--flipoffset等--这些参数在上一章中使用过,并将它们分配给self

列表 13.19 model.py:56,class SegmentationAugmentation

class SegmentationAugmentation(nn.Module):
  def __init__(
      self, flip=None, offset=None, scale=None, rotate=None, noise=None
  ):
    super().__init__()

    self.flip = flip
    self.offset = offset
    # ... line 64

我们的增强forward方法接受输入和标签,并调用构建transform_t张量,然后驱动我们的affine_gridgrid_sample调用。这些调用应该在第十二章中感到非常熟悉。

列表 13.20 model.py:68,SegmentationAugmentation.forward

def forward(self, input_g, label_g):
  transform_t = self._build2dTransformMatrix()
  transform_t = transform_t.expand(input_g.shape[0], -1, -1)    # ❶
  transform_t = transform_t.to(input_g.device, torch.float32)
  affine_t = F.affine_grid(transform_t[:,:2],                   # ❷
      input_g.size(), align_corners=False)

  augmented_input_g = F.grid_sample(input_g,
      affine_t, padding_mode='border',
      align_corners=False)
  augmented_label_g = F.grid_sample(label_g.to(torch.float32),
      affine_t, padding_mode='border',
      align_corners=False)                                      # ❸

  if self.noise:
    noise_t = torch.randn_like(augmented_input_g)
    noise_t *= self.noise

    augmented_input_g += noise_t

  return augmented_input_g, augmented_label_g > 0.5             # ❹

❶ 请注意,我们正在增强 2D 数据。

❷ 变换的第一个维度是批处理,但我们只想要每个批处理项的 3×3 矩阵的前两行。

❸ 我们需要将相同的变换应用于 CT 和掩码,因此我们使用相同的网格。因为 grid_sample 仅适用于浮点数,所以我们在这里进行转换。

❹ 在返回之前,我们通过与 0.5 比较将掩码转换回布尔值。grid_sample 导致插值产生分数值。

现在我们知道了如何处理transform_t以获取我们的数据,让我们来看看实际创建我们使用的变换矩阵的_build2dTransformMatrix函数。

列表 13.21 model.py:90,._build2dTransformMatrix

def _build2dTransformMatrix(self):
  transform_t = torch.eye(3)                    # ❶

  for i in range(2):                            # ❷
    if self.flip:
      if random.random() > 0.5:
        transform_t[i,i] *= -1
  # ... line 108
  if self.rotate:
    angle_rad = random.random() * math.pi * 2   # ❸
    s = math.sin(angle_rad)
    c = math.cos(angle_rad)

    rotation_t = torch.tensor([                 # ❹
      [c, -s, 0],
      [s, c, 0],
      [0, 0, 1]])

    transform_t @= rotation_t                   # ❺

  return transform_t

❶ 创建一个 3×3 矩阵,但我们稍后会删除最后一行。

❷ 再次,我们在这里增强 2D 数据。

❸ 以弧度形式取一个随机角度,范围为 0 .. 2{pi}

❹ 2D 旋转的旋转矩阵,由第一个两个维度中的随机角度确定

❺ 使用 Python 矩阵乘法运算符将旋转应用于变换矩阵

除了处理 2D 数据的轻微差异外,我们的 GPU 数据增强代码看起来与我们的 CPU 数据增强代码非常相似。这很好,因为这意味着我们能够编写不太关心运行位置的代码。主要区别不在核心实现中:而是我们如何将该实现封装到nn.Module子类中。虽然我们一直认为模型是一种专门用于深度学习的工具,但这向我们展示了在 PyTorch 中,张量可以被用得更加普遍。在开始下一个项目时请记住这一点--使用 GPU 加速张量可以实现的事情范围相当广泛!

13.6 更新用于分割的训练脚本

我们有一个模型。我们有数据。我们需要使用它们,当图 13.14 的步骤 2C 建议我们应该用新数据训练我们的新模型时,你不会感到惊讶。

图 13.14 本章概述,重点关注我们训练循环所需的更改

为了更准确地描述训练模型的过程,我们将更新影响我们在第十二章获得的训练代码结果的三个方面:

  • 我们需要实例化新模型(不足为奇)。

  • 我们将引入一种新的损失函数:Dice 损失。

  • 我们还将研究除了我们迄今使用的可敬的 SGD 之外的另一种优化器。我们将坚持使用一种流行的优化器,即 Adam。

但我们还将加强我们的记录工作,通过

  • 将图像记录到 TensorBoard 以进行分割的可视检查

  • 在 TensorBoard 中执行更多指标记录

  • 根据验证结果保存我们最佳的模型

总的来说,训练脚本 p2ch13/training.py 与我们在第十二章用于分类训练的代码非常相似,比我们迄今为止看到的调整后的代码更相似。任何重大变化将在文本中介绍,但请注意一些细微调整被省略。要了解完整的故事,请查看源代码。

13.6.1 初始化我们的分割和数据增强模型

我们的initModel方法非常不足为奇。我们正在使用UNetWrapper类并为其提供我们的配置参数--我们很快将详细查看这些参数。此外,我们现在有了第二个用于数据增强的模型。就像以前一样,如果需要,我们可以将模型移动到 GPU,并可能使用DataParallel设置多 GPU 训练。我们在这里跳过这些管理任务。

列表 13.22 training.py:133, .initModel

def initModel(self):
  segmentation_model = UNetWrapper(
    in_channels=7,
    n_classes=1,
    depth=3,
    wf=4,
    padding=True,
    batch_norm=True,
    up_mode='upconv',
  )

  augmentation_model = SegmentationAugmentation(**self.augmentation_dict)

  # ... line 154
  return segmentation_model, augmentation_model

对于输入到UNet,我们有七个输入通道:3 + 3 上下文切片,以及一个是我们实际进行分割的焦点切片。我们有一个输出类指示这个体素是否是结节的一部分。depth参数控制 U 的深度;每个下采样操作将深度增加 1。使用wf=5意味着第一层将有2**wf == 32个滤波器,每个下采样都会翻倍。我们希望卷积进行填充,以便我们得到与输入相同大小的输出图像。我们还希望批量归一化在每个激活函数后面,我们的上采样函数应该是一个上卷积层,由nn.ConvTranspose2d实现(参见 util/unet.py,第 123 行)。

13.6.2 使用 Adam 优化器

Adam 优化器(arxiv.org/abs/1412.6980)是在训练模型时使用 SGD 的替代方案。Adam 为每个参数维护单独的学习率,并随着训练的进行自动更新该学习率。由于这些自动更新,通常在使用 Adam 时我们不需要指定非默认学习率,因为它会快速自行确定一个合理的学习率。

这是我们在代码中实例化Adam的方式。

列表 13.23 training.py:156, .initOptimizer

def initOptimizer(self):
  return Adam(self.segmentation_model.parameters())

一般认为 Adam 是开始大多数项目的合理优化器。通常有一种配置的随机梯度下降与 Nesterov 动量,可以胜过 Adam,但在为给定项目初始化 SGD 时找到正确的超参数可能会很困难且耗时。

有许多关于 Adam 的变体--AdaMax、RAdam、Ranger 等等--每种都有优点和缺点。深入研究这些细节超出了本书的范围,但我们认为了解这些替代方案的存在是重要的。我们将在第十三章中使用 Adam。

13.6.3 Dice 损失

Sørensen-Dice 系数(en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient),也称为Dice 损失,是分割任务常见的损失度量。使用 Dice 损失而不是每像素交叉熵损失的一个优点是,Dice 处理了只有整体图像的一小部分被标记为正的情况。正如我们在第十一章第 10 节中回忆的那样,当使用交叉熵损失时,不平衡的训练数据可能会有问题。这正是我们在这里的情况--大部分 CT 扫描不是结节。幸运的是,使用 Dice,这不会构成太大问题。

Sørensen-Dice 系数基于正确分割像素与预测像素和实际像素之和的比率。这些比率在图 13.15 中列出。在左侧,我们看到 Dice 分数的插图。它是两倍的联合区域(真正正例,有条纹)除以整个预测区域和整个地面实况标记区域的总和(重叠部分被计算两次)。右侧是高一致性/高 Dice 分数和低一致性/低 Dice 分数的两个典型示例。

图 13.15 组成 Dice 分数的比率

这可能听起来很熟悉;这是我们在第十二章中看到的相同比率。我们基本上将使用每像素的 F1 分数!

注意 这是一个每像素的 F1 分数,其中“总体”是一个图像的像素。由于总体完全包含在一个训练样本中,我们可以直接用它进行训练。在分类情况下,F1 分数无法在单个小批量上计算,因此我们不能直接用它进行训练。

由于我们的label_g实际上是一个布尔掩码,我们可以将其与我们的预测相乘以获得我们的真正正例。请注意,我们在这里没有将prediction_devtensor视为布尔值。使用它定义的损失将不可微分。相反,我们将真正正例的数量替换为地面实况为 1 的像素的预测值之和。这收敛到与预测值接近 1 的相同结果,但有时预测值将是在 0.4 到 0.6 范围内的不确定预测。这些未决定的值将大致对我们的梯度更新产生相同的贡献,无论它们落在 0.5 的哪一侧。利用连续预测的 Dice 系数有时被称为软 Dice

这里有一个小小的复杂性。由于我们希望最小化损失,我们将取我们的比率并从 1 中减去。这样做将反转我们损失函数的斜率,使得在高重叠情况下,我们的损失较低;而在低重叠情况下,它较高。以下是代码中的样子。

列表 13.24 training.py:315,.diceLoss

def diceLoss(self, prediction_g, label_g, epsilon=1):
  diceLabel_g = label_g.sum(dim=[1,2,3])                      # ❶
  dicePrediction_g = prediction_g.sum(dim=[1,2,3])
  diceCorrect_g = (prediction_g * label_g).sum(dim=[1,2,3])

  diceRatio_g = (2 * diceCorrect_g + epsilon) \
    / (dicePrediction_g + diceLabel_g + epsilon)              # ❷

  return 1 - diceRatio_g                                      # ❸

❶ 对除批处理维度以外的所有内容求和,以获取每个批处理项的正标记、(软)正检测和(软)正确正例

❷ Dice 比率。为了避免当我们意外地既没有预测也没有标签时出现问题,我们在分子和分母上都加 1。

❸ 为了将其转化为损失,我们取 1 - Dice 比率,因此较低的损失更好。

我们将更新我们的computeBatchLoss函数来调用self.diceLoss。两次。我们将为训练样本计算正常的 Dice 损失,以及仅计算label_g中包含的像素的 Dice 损失。通过将我们的预测(请记住,这些是浮点值)乘以标签(实际上是布尔值),我们将得到伪预测,这些预测使每个负像素“完全正确”(因为所有这些像素的值都乘以label_g中的假为零值)。唯一会产生损失的像素是假阴性像素(应该被预测为真,但实际上没有)。这将非常有帮助,因为召回率对我们的整体项目非常重要;毕竟,如果我们一开始就无法检测到肿瘤,我们就无法正确分类肿瘤!

列表 13.25 training.py:282,.computeBatchLoss

def computeBatchLoss(self, batch_ndx, batch_tup, batch_size, metrics_g,
           classificationThreshold=0.5):
  input_t, label_t, series_list, _slice_ndx_list = batch_tup

  input_g = input_t.to(self.device, non_blocking=True)              # ❶
  label_g = label_t.to(self.device, non_blocking=True)

  if self.segmentation_model.training and self.augmentation_dict:   # ❷
    input_g, label_g = self.augmentation_model(input_g, label_g)

  prediction_g = self.segmentation_model(input_g)                   # ❸

  diceLoss_g = self.diceLoss(prediction_g, label_g)                 # ❹
  fnLoss_g = self.diceLoss(prediction_g * label_g, label_g)
  # ... line 313
  return diceLoss_g.mean() + fnLoss_g.mean() * 8                    # ❺

❶ 转移到 GPU

❷ 根据需要进行数据增强,如果我们正在训练。在验证中,我们会跳过这一步。

❸ 运行分割模型...

❹ ... 并应用我们精细的 Dice 损失

❺ 哎呀。这是什么?

让我们稍微谈谈我们在diceLoss_g .mean() + fnLoss_g.mean() * 8返回语句中所做的事情。

损失加权

在第十二章中,我们讨论了塑造我们的数据集,使得我们的类别不会严重失衡。这有助于训练收敛,因为每个批次中出现的正负样本能够相互抵消,模型必须学会区分它们以改进。我们通过将训练样本裁剪到包含较少非正像素的方式来近似相同的平衡;但是高召回率非常重要,我们需要确保在训练过程中提供反映这一事实的损失。

我们将使用加权损失,偏向于一类而不是另一类。通过将fnLoss_g乘以 8,我们的意思是正确预测我们的正像素总体比正确预测负像素总体重要八倍(九倍,如果计算diceLoss_g中的一个)。由于正掩模覆盖的区域远远小于整个 64 × 64 裁剪,这也意味着每个单独的正像素在反向传播时具有更大的影响力。

我们愿意在一般的 Dice 损失中放弃许多正确预测的负像素,以获得一个在假阴性损失中的正确像素。由于一般的 Dice 损失是假阴性损失的严格超集,可以进行交易的唯一正确像素是起初为真负的像素(所有真正的正像素已经包含在假阴性损失中,因此没有交易可进行)。

由于我们愿意牺牲大片真负像素以追求更好的召回率,我们通常会预期大量的假阳性。¹⁴ 我们这样做是因为召回率对我们的用例非常重要,我们宁愿有一些假阳性,也不愿有一个假阴性。

我们应该注意,这种方法仅在使用 Adam 优化器时有效。使用 SGD 时,过度预测会导致每个像素都返回为正。Adam 优化器微调学习率的能力意味着强调假阴性损失不会变得过于强大。

收集指标

由于我们将故意扭曲我们的数字以获得更好的召回率,让我们看看事情会变得多么倾斜。在我们的分类computeBatchLoss中,我们计算各种每个样本的值,用于度量等。我们还为整体分割结果计算类似的值。这些真正的正样本和其他指标以前是在logMetrics中计算的,但由于结果数据的大小(请记住,验证集中的每个单个 CT 切片是 25 万像素!),我们需要在computeBatchLoss函数中实时计算这些摘要统计信息。

列表 13.26 training.py:297, .computeBatchLoss

start_ndx = batch_ndx * batch_size
end_ndx = start_ndx + input_t.size(0)

with torch.no_grad():
  predictionBool_g = (prediction_g[:, 0:1]
            > classificationThreshold).to(torch.float32)        # ❶

  tp = (   predictionBool_g *  label_g).sum(dim=[1,2,3])        # ❷
  fn = ((1 - predictionBool_g) *  label_g).sum(dim=[1,2,3])
  fp = (   predictionBool_g * (~label_g)).sum(dim=[1,2,3])

  metrics_g[METRICS_LOSS_NDX, start_ndx:end_ndx] = diceLoss_g   # ❸
  metrics_g[METRICS_TP_NDX, start_ndx:end_ndx] = tp
  metrics_g[METRICS_FN_NDX, start_ndx:end_ndx] = fn
  metrics_g[METRICS_FP_NDX, start_ndx:end_ndx] = fp

❶ 我们对预测进行阈值处理以获得“硬” Dice 但为后续乘法转换为浮点数。

❷ 计算真阳性、假阳性和假阴性与我们计算 Dice 损失时类似。

❸ 我们将我们的指标存储到一个大张量中以供将来参考。这是每个批次项目而不是批次平均值。

正如我们在本节开头讨论的,我们可以通过将我们的预测(或其否定)与我们的标签(或其否定)相乘来计算我们的真正阳性等。由于我们在这里并不太担心我们的预测的确切值(如果我们将像素标记为 0.6 或 0.9 并不重要--只要超过阈值,我们将其称为结节候选的一部分),我们将通过将其与我们的阈值 0.5 进行比较来创建predictionBool_g

13.6.4 将图像导入 TensorBoard

在处理分割任务时的一个好处是输出可以很容易地以视觉方式表示。能够直观地看到我们的结果对于确定模型是否进展顺利(但可能需要更多训练)或者是否偏离轨道(因此我们需要停止继续浪费时间进行进一步训练)非常有帮助。我们可以将结果打包成图像的方式有很多种,也可以有很多种展示方式。TensorBoard 对这种数据有很好的支持,而且我们已经将 TensorBoard SummaryWriter 实例集成到我们的训练中,所以我们将使用 TensorBoard。让我们看看如何将所有内容连接起来。

我们将在我们的主应用程序类中添加一个logImages函数,并使用我们的训练和验证数据加载器调用它。在此过程中,我们将对我们的训练循环进行另一个更改:我们只会在第一个周期以及每第五个周期执行验证和图像记录。我们通过将周期数与一个新的常量validation_cadence进行比较来实现这一点。

在训练时,我们试图平衡几件事:

  • 在不必等待太久的情况下大致了解我们的模型训练情况

  • 大部分 GPU 周期用于训练,而不是验证

  • 确保我们在验证集上表现良好

第一个意味着我们需要相对较短的周期,以便更频繁地调用logMetrics。然而,第二个意味着我们希望在调用doValidation之前训练相对较长的时间。第三个意味着我们需要定期调用doValidation,而不是在训练结束时或其他不可行的情况下只调用一次。通过仅在第一个周期以及每第五个周期执行验证,我们可以实现所有这些目标。我们可以及早获得训练进展的信号,大部分时间用于训练,并在进行过程中定期检查验证集。

列表 13.27 training.py:210, SegmentationTrainingApp.main

def main(self):
  # ... line 217
  self.validation_cadence = 5
  for epoch_ndx in range(1, self.cli_args.epochs + 1):              # ❶
    # ... line 228
    trnMetrics_t = self.doTraining(epoch_ndx, train_dl)             # ❷
    self.logMetrics(epoch_ndx, 'trn', trnMetrics_t)                 # ❸

    if epoch_ndx == 1 or epoch_ndx % self.validation_cadence == 0:  # ❹
      # ... line 239
      self.logImages(epoch_ndx, 'trn', train_dl)                    # ❺
      self.logImages(epoch_ndx, 'val', val_dl)

❶ 我们最外层的循环,跨越各个周期

❷ 训练一个周期

❸ 在每个周期后记录来自训练的(标量)指标

❹ 仅在每个验证间隔的倍数时...

❺ ...我们验证模型并记录图像。

我们没有一种单一正确的方式来构建我们的图像记录。我们将从训练集和验证集中各选取几个 CT 图像。对于每个 CT 图像,我们将选择 6 个均匀间隔的切片,端到端显示地面真实和我们模型的输出。我们之所以选择 6 个切片,仅仅是因为 TensorBoard 每次会显示 12 张图像,我们可以将浏览器窗口排列成一行标签图像在模型输出上方。以这种方式排列事物使得我们可以轻松地进行视觉比较,正如我们在图 13.16 中所看到的。

图 13.16 顶部行:训练的标签数据。底部行:分割模型的输出。

还请注意prediction图像上的小滑块点。该滑块将允许我们查看具有相同标签的先前版本的图像(例如 val/0_prediction_3,但在较早的时期)。当我们尝试调试某些内容或进行调整以实现特定结果时,能够查看我们的分割输出随时间变化的情况是有用的。随着训练的进行,TensorBoard 将限制从滑块中可查看的图像数量为 10,可能是为了避免用大量图像淹没浏览器。

生成此输出的代码首先从相关数据加载器中获取 12 个系列和每个系列的 6 个图像。

列表 13.28 training.py:326, .logImages

def logImages(self, epoch_ndx, mode_str, dl):
  self.segmentation_model.eval()                                    # ❶

  images = sorted(dl.dataset.series_list)[:12]                      # ❷
  for series_ndx, series_uid in enumerate(images):
    ct = getCt(series_uid)

    for slice_ndx in range(6):
      ct_ndx = slice_ndx * (ct.hu_a.shape[0] - 1) // 5              # ❸
      sample_tup = dl.dataset.getitem_fullSlice(series_uid, ct_ndx)

      ct_t, label_t, series_uid, ct_ndx = sample_tup

❶ 将模型设置为评估模式

❷ 通过绕过数据加载器并直接使用数据集,获取(相同的)12 个 CT。系列列表可能已经被洗牌,所以我们进行排序。

❸ 选择 CT 中的六个等距切片

然后,我们将ct_t输入模型。这看起来非常像我们在computeBatchLoss中看到的;如果需要详情,请参阅 p2ch13/training.py。

一旦我们有了prediction_a,我们需要构建一个image_a来保存 RGB 值以供显示。我们使用np.float32值,需要在 0 到 1 的范围内。我们的方法会通过将各种图像和掩模相加,使数据在 0 到 2 的范围内,然后将整个数组乘以 0.5 将其恢复到正确的范围内。

列表 13.29 training.py:346, .logImages

ct_t[:-1,:,:] /= 2000
ct_t[:-1,:,:] += 0.5

ctSlice_a = ct_t[dl.dataset.contextSlices_count].numpy()

image_a = np.zeros((512, 512, 3), dtype=np.float32)
image_a[:,:,:] = ctSlice_a.reshape((512,512,1))          # ❶
image_a[:,:,0] += prediction_a & (1 - label_a)    
image_a[:,:,0] += (1 - prediction_a) & label_a           # ❷
image_a[:,:,1] += ((1 - prediction_a) & label_a) * 0.5   # ❸

image_a[:,:,1] += prediction_a & label_a                 # ❹
image_a *= 0.5
image_a.clip(0, 1, image_a)

❶ 将 CT 强度分配给所有 RGB 通道,以提供灰度基础图像。

❷ 假阳性标记为红色,并叠加在图像上。

❸ 假阴性标记为橙色。

❹ 真阳性标记为绿色。

我们的目标是在半强度的灰度 CT 上叠加预测的结节(或更正确地说,结节候选)像素以各种颜色显示。我们将使用红色表示所有不正确的像素(假阳性和假阴性)。这主要是假阳性,我们不太关心(因为我们专注于召回率)。1 - label_a反转标签,乘以prediction_a给出我们只有预测像素不在候选结节中的像素。假阴性得到添加到绿色的半强度掩模,这意味着它们将显示为橙色(1.0 红和 0.5 绿在 RGB 中呈橙色)。每个正确预测的结节内像素都设置为绿色;因为我们正确预测了这些像素,不会添加红色,因此它们将呈现为纯绿色。

然后,我们将数据重新归一化到0...1范围并夹紧它(以防我们在这里开始显示增强数据,当噪声超出我们预期的 CT 范围时会导致斑点)。最后一步是将数据保存到 TensorBoard。

列表 13.30 training.py:361, .logImages

writer = getattr(self, mode_str + '_writer')
writer.add_image(
  f'{mode_str}/{series_ndx}_prediction_{slice_ndx}',
  image_a,
  self.totalTrainingSamples_count,
  dataformats='HWC',
)

这看起来与我们之前看到的writer.add_scalar调用非常相似。dataformats='HWC'参数告诉 TensorBoard 我们的图像轴的顺序将 RGB 通道作为第三个轴。请记住,我们的网络层经常指定输出为B × C × H × W,如果我们指定'CHW',我们也可以直接将数据放入 TensorBoard。

我们还想保存用于训练的地面真相,这将形成我们之前在图 13.16 中看到的 TensorBoard CT 切片的顶行。代码与我们刚刚看到的类似,我们将跳过它。如果您想了解详情,请查看 p2ch13/training.py。

13.6.5 更新我们的指标记录

为了让我们了解我们的表现如何,我们计算每个时期的指标:特别是真阳性、假阴性和假阳性。以下列表所做的事情不会特别令人惊讶。

列表 13.31 training.py:400, .logMetrics

sum_a = metrics_a.sum(axis=1)
allLabel_count = sum_a[METRICS_TP_NDX] + sum_a[METRICS_FN_NDX]
metrics_dict['percent_all/tp'] = \
  sum_a[METRICS_TP_NDX] / (allLabel_count or 1) * 100
metrics_dict['percent_all/fn'] = \
  sum_a[METRICS_FN_NDX] / (allLabel_count or 1) * 100
metrics_dict['percent_all/fp'] = \
  sum_a[METRICS_FP_NDX] / (allLabel_count or 1) * 100    # ❶

❶ 可能大于 100%,因为我们正在与标记为候选结节的像素总数进行比较,这是每个图像的一个微小部分

我们将开始对我们的模型进行评分,以确定特定训练运行是否是迄今为止我们见过的最佳模型。在第十二章中,我们说我们将使用 F1 得分来对我们的模型进行排名,但我们在这里的目标不同。我们需要确保我们的召回率尽可能高,因为如果我们一开始就找不到潜在的结节,我们就无法对其进行分类!

我们将使用我们的召回率来确定“最佳”模型。只要该时代的 F1 得分合理,我们只想尽可能提高召回率。筛选出任何误报阳性将是分类模型的责任。

列表 13.32 training.py:393, .logMetrics

def logMetrics(self, epoch_ndx, mode_str, metrics_t):
  # ... line 453
  score = metrics_dict['pr/recall']

  return score

当我们在下一章的分类训练循环中添加类似的代码时,我们将使用 F1 得分。

回到主训练循环中,我们将跟踪到目前为止在这次训练运行中见过的best_score。当我们保存我们的模型时,我们将包含一个指示这是否是迄今为止我们见过的最佳得分的标志。回想一下第 13.6.4 节,我们只对第一个和每隔五个时代调用doValidation函数。这意味着我们只会在这些时代检查最佳得分。这不应该是问题,但如果您需要调试发生在第 7 个时代的事情时,请记住这一点。我们在保存图像之前进行这个检查。

列表 13.33 training.py:210, SegmentationTrainingApp.main

def main(self):
  best_score = 0.0
  for epoch_ndx in range(1, self.cli_args.epochs + 1):         # ❶
      # if validation is wanted
      # ... line 233
      valMetrics_t = self.doValidation(epoch_ndx, val_dl)
      score = self.logMetrics(epoch_ndx, 'val', valMetrics_t)  # ❷
      best_score = max(score, best_score)

      self.saveModel('seg', epoch_ndx, score == best_score)    # ❸

❶ 我们已经看到的时代循环

❷ 计算得分。正如我们之前看到的,我们采用召回率。

❸ 现在我们只需要编写saveModel。第三个参数是我们是否也要将其保存为最佳模型。

让我们看看如何将我们的模型持久化到磁盘。

13.6.6 保存我们的模型

PyTorch 使将模型保存到磁盘变得非常容易。在幕后,torch.save使用标准的 Python pickle库,这意味着我们可以直接传递我们的模型实例,并且它会正确保存。然而,这并不被认为是持久化我们模型的理想方式,因为我们会失去一些灵活性。

相反,我们只会保存我们模型的参数。这样做可以让我们将这些参数加载到任何期望具有相同形状参数的模型中,即使该类别与保存这些参数的模型不匹配。仅保存参数的方法使我们可以以比保存整个模型更多的方式重复使用和混合我们的模型。

我们可以使用model.state_dict()函数获取我们模型的参数。

列表 13.34 training.py:480, .saveModel

def saveModel(self, type_str, epoch_ndx, isBest=False):
  # ... line 496
  model = self.segmentation_model
  if isinstance(model, torch.nn.DataParallel):
    model = model.module                             # ❶

  state = {
    'sys_argv': sys.argv,
    'time': str(datetime.datetime.now()),
    'model_state': model.state_dict(),               # ❷
    'model_name': type(model).__name__,
    'optimizer_state' : self.optimizer.state_dict(), # ❸
    'optimizer_name': type(self.optimizer).__name__,
    'epoch': epoch_ndx,
    'totalTrainingSamples_count': self.totalTrainingSamples_count,
  }
  torch.save(state, file_path)

❶ 摆脱 DataParallel 包装器,如果存在的话

❷ 重要部分

❸ 保留动量等

我们将file_path设置为类似于data-unversioned/part2/models/p2ch13/ seg_2019-07-10_02.17.22_ch12.50000.state.50000.部分是迄今为止我们向模型呈现的训练样本数量,而路径的其他部分是显而易见的。

提示 通过保存优化器状态,我们可以无缝恢复训练。虽然我们没有提供这方面的实现,但如果您的计算资源访问可能会中断,这可能会很有用。有关加载模型和优化器以重新开始训练的详细信息,请参阅官方文档(pytorch.org/tutorials/beginner/saving_loading_models.html)。

如果当前模型的得分是迄今为止我们见过的最好的,我们会保存第二份state的副本,文件名为.best.state。这可能会被另一个得分更高的模型版本覆盖。通过只关注这个最佳文件,我们可以让我们训练模型的客户摆脱每个训练时期的细节(当然,前提是我们的得分指标质量很高)。

列表 13.35 training.py:514, .saveModel

if isBest:
  best_path = os.path.join(
    'data-unversioned', 'part2', 'models',
    self.cli_args.tb_prefix,
    f'{type_str}_{self.time_str}_{self.cli_args.comment}.best.state')
  shutil.copyfile(file_path, best_path)

  log.info("Saved model params to {}".format(best_path))

with open(file_path, 'rb') as f:
  log.info("SHA1: " + hashlib.sha1(f.read()).hexdigest())

我们还输出了刚保存的模型的 SHA1。类似于 sys.argv 和我们放入状态字典中的时间戳,这可以帮助我们在以后出现混淆时准确调试我们正在使用的模型(例如,如果文件被错误重命名)。

我们将在下一章更新我们的分类训练脚本,使用类似的例程保存分类模型。为了诊断 CT,我们将需要这两个模型。

13.7 结果

现在我们已经做出了所有的代码更改,我们已经到达了图 13.17 步骤 3 的最后一部分。是时候运行 python -m p2ch13.training --epochs 20 --augmented final_seg。让我们看看我们的结果如何!

图 13.17 本章概述,重点关注我们从训练中看到的结果

如果我们限制自己只看我们有验证指标的时期,那么我们的训练指标看起来是这样的(接下来我们将查看这些指标,这样可以进行苹果对苹果的比较):

E1 trn      0.5235 loss, 0.2276 precision, 0.9381 recall, 0.3663 f1 score # ❶
E1 trn_all  0.5235 loss,  93.8% tp, 6.2% fn,     318.4% fp                # ❶
...
E5 trn      0.2537 loss, 0.5652 precision, 0.9377 recall, 0.7053 f1 score # ❷
E5 trn_all  0.2537 loss,  93.8% tp, 6.2% fn,      72.1% fp                # ❶
...
E10 trn      0.2335 loss, 0.6011 precision, 0.9459 recall, 0.7351 f1 score# ❷
E10 trn_all  0.2335 loss,  94.6% tp, 5.4% fn,      62.8% fp               # ❶
...
E15 trn      0.2226 loss, 0.6234 precision, 0.9536 recall, 0.7540 f1 score# ❸
E15 trn_all  0.2226 loss,  95.4% tp, <2>  4.6% fn,      57.6% fp          # ❹
 ...
E20 trn      0.2149 loss, 0.6368 precision, 0.9584 recall, 0.7652 f1 score# ❸
E20 trn_all  0.2149 loss,  95.8% tp, <2>  4.2% fn,      54.7% fp          # ❹

❶ TPs 也在上升,太好了!而 FNs 和 FPs 在下降。

❷ 在这些行中,我们特别关注 F1 分数--它在上升。很好!

❸ 在这些行中,我们特别关注 F1 分数--它在上升。很好!

❹ TPs 也在上升,太好了!而 FNs 和 FPs 在下降。

总体来看,情况看起来相当不错。真正的正例和 F1 分数在上升,假正例和假负例在下降。这正是我们想要看到的!验证指标将告诉我们这些结果是否合法。请记住,由于我们是在 64 × 64 的裁剪上进行训练,但在整个 512 × 512 的 CT 切片上进行验证,我们几乎肯定会有截然不同的 TP:FN:FP 比例。让我们看看:

E1 val      0.9441 loss, 0.0219 precision, 0.8131 recall, 0.0426 f1 score
E1 val_all  0.9441 loss,  81.3% tp,  18.7% fn,    3637.5% fp

E5 val      0.9009 loss, 0.0332 precision, 0.8397 recall, 0.0639 f1 score
E5 val_all  0.9009 loss,  84.0% tp,  16.0% fn,    2443.0% fp

E10 val      0.9518 loss, 0.0184 precision, 0.8423 recall, 0.0360 f1 score
E10 val_all  0.9518 loss,  84.2% tp,  15.8% fn,    4495.0% fp              # ❶

E15 val      0.8100 loss, 0.0610 precision, 0.7792 recall, 0.1132 f1 score
E15 val_all  0.8100 loss,  77.9% tp,  22.1% fn,    1198.7% fp

E20 val      0.8602 loss, 0.0427 precision, 0.7691 recall, 0.0809 f1 score
E20 val_all  0.8602 loss,  76.9% tp,  23.1% fn,    1723.9% fp

❶ 最高的 TP 率(太好了)。请注意,TP 率与召回率相同。但 FPs 为 4495%--听起来很多。

哎呀--超过 4,000% 的假正例率?是的,实际上这是预期的。我们的验证切片面积为 218 像素(512 是 29),而我们的训练裁剪只有 212。这意味着我们在一个表面是 26 = 64 倍大的切片上进行验证!假阳性计数也增加了 64 倍是有道理的。请记住,我们的真正正例率不会有实质性变化,因为它们都已经包含在我们首次训练的 64 × 64 样本中。这种情况还导致了非常低的精确度,因此 F1 分数也很低。这是我们如何构建训练和验证的自然结果,所以不必担心。

然而,问题在于我们的召回率(因此也是真正的正例率)。我们的召回率在第 5 到 10 个时期之间趋于平稳,然后开始下降。很明显,我们很快就开始过拟合,我们可以在图 13.18 中看到更多证据--虽然训练召回率继续上升,但验证召回率在 300 万个样本后开始下降。这就是我们在第五章中识别过拟合的方式,特别是图 5.14。

图 13.18 验证集召回率,在第 10 个时期后显示出过拟合的迹象(300 万个样本)

注意 请始终记住,TensorBoard 默认会平滑您的数据线。实色背后的浅色幽灵线显示了原始值。

U-Net 架构具有很大的容量,即使我们减少了滤波器和深度计数,它也能够很快地记住我们的训练集。一个好处是我们不需要训练模型很长时间!

回忆是我们对分割的首要任务,因为我们将让精度问题由下游的分类模型处理。减少这些假阳性是我们拥有这些分类模型的全部原因!这种倾斜的情况确实意味着我们很难评估我们的模型。我们可以使用更加重视召回率的 F2 分数(或 F5,或 F10...),但我们必须选择一个足够高的N来几乎完全忽略精度。我们将跳过中间步骤,只通过召回率评分我们的模型,并使用我们的人类判断来确保给定的训练运行不会对此产生病理性影响。由于我们是在 Dice 损失上进行训练,而不是直接在召回率上进行训练,所以应该会有所作用。

这是我们有点作弊的情况之一,因为我们(作者)已经为第十四章进行了训练和评估,我们知道所有这些将会发生什么。没有好的方法来看待这种情况,知道我们看到的结果会起作用。有教养的猜测是有帮助的,但它们不能替代实际运行实验直到有所突破。

就目前而言,我们的结果已经足够好,即使我们的度量有一些相当极端的值。我们离完成我们的端到端项目又近了一步!

13.8 结论

在本章中,我们讨论了一种为像素到像素分割构建模型的新方法;介绍了 U-Net,这是一种经过验证的用于这类任务的现成模型架构;并为我们自己的使用调整了一个实现。我们还改变了我们的数据集,以满足我们新模型的训练需求,包括用于训练的小裁剪和用于验证的有限切片集。我们的训练循环现在可以将图像保存到 TensorBoard,并且我们已经将增强从数据集移动到可以在 GPU 上运行的单独模型中。最后,我们查看了我们的训练结果,并讨论了即使假阳性率(特别是)看起来与我们所希望的不同,但考虑到我们对来自更大项目的需求,我们的结果将是可以接受的。在第十四章中,我们将把我们写的各种模型整合成一个连贯的端到端整体。

13.9 练习

  1. 为分类模型实现模型包装器方法来增强(就像我们用于分割训练的那样)。

    1. 你不得不做出什么妥协?

    2. 这种变化对训练速度有什么影响?

  2. 更改分割Dataset实现,使其具有用于训练、验证和测试集的三分割。

    1. 你用于测试集的数据占了多少比例?

    2. 测试集和验证集上的性能看起来一致吗?

    3. 较小的训练集会导致训练受到多大影响?

  3. 使模型尝试分割恶性与良性,除了结节状态。

    1. 你的度量报告需要如何改变?你的图像生成呢?

    2. 你看到了什么样的结果?分割是否足够好以跳过分类步骤?

  4. 你能训练模型同时使用 64×64 裁剪和整个 CT 切片的组合吗?¹⁶

  5. 除了仅使用 LUNA(或 LIDC)数据,你能找到其他数据来源吗?

13.10 总结

  • 分割标记单个像素或体素属于某一类。这与分类相反,分类是在整个图像级别操作的。

  • U-Net 是用于分割任务的突破性模型架构。

  • 使用分割后跟分类,我们可以用相对较少的数据和计算需求实现检测。

  • 对于当前一代 GPU 来说,对 3D 分割的天真方法可能会迅速使用过多的 RAM。仔细限制呈现给模型的范围可以帮助限制 RAM 使用。

  • 可以在图像裁剪上训练分割模型,同时在整个图像切片上进行验证。这种灵活性对于类别平衡可能很重要。

  • 损失加权是对从训练数据的某些类别或子集计算的损失进行强调,以鼓励模型专注于期望的结果。它可以补充类平衡,并在尝试调整模型训练性能时是一个有用的工具。

  • TensorBoard 可以显示在训练过程中生成的 2D 图像,并将保存这些模型在训练运行中如何变化的历史记录。这可以用来在训练过程中直观地跟踪模型输出的变化。

  • 模型参数可以保存到磁盘并重新加载,以重新构建之前保存的模型。只要旧参数和新参数之间有 1:1 的映射,确切的模型实现可以更改。


¹ 我们预计会标记很多不是结节的东西;因此,我们使用分类步骤来减少这些数量。

²Joseph Redmon 和 Ali Farhadi,“YOLOv3: An Incremental Improvement”,pjreddie.com/media/files/papers/YOLOv3.pdf。也许在你完成这本书后可以看看。

³...“头、肩膀、膝盖和脚趾、膝盖和脚趾”,就像我的(Eli 的)幼儿们会唱的那样。

⁴ 这里包含的实现与官方论文不同,使用平均池化而不是最大池化进行下采样。GitHub 上最新版本已更改为使用最大池化。

⁵ 在我们的代码抛出任何异常的不太可能的情况下--显然不会发生,对吧?

⁶ 例如,Stanislav Nikolov 等人,“Deep Learning to Achieve Clinically Applicable Segmentation of Head and Neck Anatomy for Radiotherapy”,arxiv.org/pdf/1809.04430.pdf

⁷ 这里的错误是 0 处的环绕将不会被检测到。对我们来说并不重要。作为练习,实现适当的边界检查。

⁸ 修复这个问题对教会你关于 PyTorch 并没有太大帮助。

⁹Samuel G. Armato 第三等人,2011,“The Lung Image Database Consortium (LIDC) and Image Database Resource Initiative (IDRI): A Completed Reference Database of Lung Nodules on CT Scans”,Medical Physics 38,第 2 卷(2011 年):915-31,pubmed.ncbi.nlm.nih.gov/21452728/。另请参阅 Bruce Vendt,LIDC-IDRI,Cancer Imaging Archive,mng.bz/mBO4

¹⁰ 如果你经常这样做,那么在 2020 年刚发布的pandas库是一个使这个过程更快的好工具。我们在这里使用标准 Python 发行版中包含的 CSV 读取器。

¹¹ 大多数 CT 扫描仪产生 512×512 的切片,我们不会担心那些做了不同处理的扫描仪。

¹² 否则,你的模型将会立即训练!

¹³ 参见cs231n.github.io/neural-networks-3

¹⁴Roxie 会感到骄傲!

¹⁵ 是的,“合理”有点含糊。如果你想要更具体的东西,那么“非零”是一个很好的起点。

¹⁶ 提示:要一起批处理的每个样本元组必须对应张量的形状相同,但下一批可能有不同形状的不同样本。

十四、端到端结节分析,以及接下来的步骤

本章内容包括

  • 连接分割和分类模型

  • 为新任务微调网络

  • 将直方图和其他指标类型添加到 TensorBoard

  • 从过拟合到泛化

在过去的几章中,我们已经构建了许多对我们的项目至关重要的系统。我们开始加载数据,构建和改进结节候选的分类器,训练分割模型以找到这些候选,处理训练和评估这些模型所需的支持基础设施,并开始将我们的训练结果保存到磁盘。现在是时候将我们拥有的组件统一起来,以便实现我们项目的完整目标:是时候自动检测癌症了。

14.1 迈向终点

通过查看图 14.1 我们可以得到剩余工作的一些线索。在第 3 步(分组)中,我们看到我们仍需要建立第十三章的分割模型和第十二章的分类器之间的桥梁,以确定分割网络找到的是否确实是结节。右侧是第 5 步(结节分析和诊断),整体目标的最后一步:查看结节是否为癌症。这是另一个分类任务;但为了在过程中学到一些东西,我们将通过借鉴我们已有的结节分类器来采取新的方法。

图 14.1 我们的端到端肺癌检测项目,重点关注本章的主题:第 3 步和第 5 步,分组和结节分析

当然,这些简短的描述及其在图 14.1 中的简化描述遗漏了很多细节。让我们通过图 14.2 放大一下,看看我们还有哪些任务要完成。

图 14.2 一个关于我们端到端项目剩余工作的详细查看

正如您所看到的,还有三项重要任务。以下列表中的每一项对应于图 14.2 的一个主要项目:

  1. 生成结节候选。这是整个项目的第 3 步。这一步骤包括三项任务:

    1. 分割 --第十三章的分割模型将预测给定像素是否感兴趣:如果我们怀疑它是结节的一部分。这将在每个 2D 切片上完成,并且每个 2D 结果将被堆叠以形成包含结节候选预测的体素的 3D 数组。

    2. 分组 --我们将通过将预测应用于阈值来将体素分组为结节候选,然后将连接区域的标记体素分组。

    3. 构建样本元组 --每个识别的结节候选将用于构建一个用于分类的样本元组。特别是,我们需要生成该结节中心的坐标(索引、行、列)。

一旦实现了这一点,我们将拥有一个应用程序,该应用程序接收患者的原始 CT 扫描并生成检测到的结节候选列表。生成这样的列表是 LUNA 挑战的任务。如果这个项目被临床使用(我们再次强调我们的项目不应该被使用!),这个结节列表将适合由医生进行更仔细的检查。

  1. 对结节和恶性进行分类。我们将取出我们刚刚产生的结节候选并将其传递到我们在第十二章实现的候选分类步骤,然后对被标记为结节的候选进行恶性检测:

    1. 结节分类 --从分割和分组中得到的每个结节候选将被分类为结节或非结节。这样做将允许我们筛选出被我们的分割过程标记为许多正常解剖结构。

    2. ROC/AUC 指标 --在我们开始最后的分类步骤之前,我们将定义一些用于检查分类模型性能的新指标,并建立一个基准指标,以便与我们的恶性分类器进行比较。

    3. 微调恶性模型 --一旦我们的新指标就位,我们将定义一个专门用于分类良性和恶性结节的模型,对其进行训练,并查看其表现。我们将通过微调进行训练:这个过程会剔除现有模型的一些权重,并用新值替换它们,然后我们将这些值调整到我们的新任务中。

到那时,我们将离我们的最终目标不远了:将结节分类为良性和恶性类别,然后从 CT 中得出诊断。再次强调,在现实世界中诊断肺癌远不止盯着 CT 扫描,因此我们进行这种诊断更多是为了看看我们能够使用深度学习和成像数据单独走多远。

  1. 端到端检测。最后,我们将把所有这些组合起来,达到终点,将组件组合成一个端到端的解决方案,可以查看 CT 并回答问题“肺部是否存在恶性结节?”

    1. IRC --我们将对我们的 CT 进行分割,以获取结节候选样本进行分类。

    2. 确定结节 --我们将对候选进行结节分类,以确定是否应将其输入恶性分类器。

    3. 确定恶性程度 --我们将对通过结节分类器的结节进行恶性分类,以确定患者是否患癌症。

我们有很多事情要做。冲刺终点!

注意 正如前一章中所述,我们将在文本中详细讨论关键概念,并略过重复、繁琐或显而易见的代码部分。完整的细节可以在书籍的代码存储库中找到。

14.2 验证集的独立性

我们面临着一个微妙但关键的错误的危险,我们需要讨论并避免:我们有一个潜在的从训练集到验证集的泄漏!对于分割和分类模型的每一个,我们都小心地将数据分割成一个训练集和一个独立的验证集,通过将每十个示例用于验证,其余用于训练。

然而,分类模型的分割是在结节列表上进行的,分割模型的分割是在 CT 扫描列表上进行的。这意味着我们很可能在分类模型的训练集中有来自分割验证集的结节,反之亦然。我们必须避免这种情况!如果不加以修正,这种情况可能导致性能指标人为地高于我们在独立数据集上获得的性能。这被称为泄漏,它将使我们的验证失效。

为了纠正这种潜在的数据泄漏,我们需要重新设计分类数据集,以便像我们在第十三章中为分割任务所做的那样也在 CT 扫描级别上工作。然后我们需要用这个新数据集重新训练分类模型。好消息是,我们之前没有保存我们的分类模型,所以我们无论如何都需要重新训练。

你应该从中得到的启示是在定义验证集时要注意整个端到端的过程。可能最简单的方法(也是对大多数重要数据集采用的方法)是尽可能明确地进行验证分割--例如,通过为训练和验证分别设置两个目录--然后在整个项目中坚持这种分割。当您需要重新分割时(例如,当您需要按某些标准对数据集进行分层时),您需要使用新分割的数据集重新训练所有模型。

我们为您做的是从第 10-12 章的LunaDataset中复制候选列表,并从第十三章的Luna2dSegmentationDataset中将其分割为测试和验证数据集。由于这是非常机械的,并且没有太多细节可供学习(您现在已经是数据集专家了),我们不会详细展示代码。

我们将通过重新运行分类器的训练来重新训练我们的分类模型:¹

$ python3 -m p2ch14.training --num-workers=4 --epochs 100 nodule-nonnodule

经过 100 个周期,我们对正样本的准确率达到约 95%,对负样本达到 99%。由于验证损失没有再次上升的趋势,我们可以继续训练模型以查看是否会继续改善。

经过 90 个周期,我们达到了最大的 F1 分数,并且在验证准确率方面达到了 99.2%,尽管在实际结节上只有 92.8%。我们将采用这个模型,尽管我们可能也会尝试在恶性结节的准确率上稍微牺牲一些总体准确率(在此期间,模型在实际结节上的准确率为 95.4%,总准确率为 98.9%)。这对我们来说已经足够了,我们准备连接这些模型。

14.3 连接 CT 分割和结节候选分类

现在我们已经从第十三章保存了一个分割模型,并且在上一节刚刚训练了一个分类模型,图 14.3 的步骤 1a、1b 和 1c 显示我们已经准备好开始编写代码,将我们的分割输出转换为样本元组。我们正在进行分组:在图 14.3 的步骤 1b 的高亮周围找到虚线轮廓。我们的输入是分割:由第 1a 中的分割模型标记的体素。我们想要找到 1c,即每个“块”中心的质心坐标:我们需要在样本元组列表中提供的是 1b 加号标记的索引、行和列。

图 14.3 我们本章的计划,重点是将分割的体素分组为结节候选

运行模型时,其处理方式与我们在训练和验证(尤其是验证)期间处理它们的方式非常相似。这里的区别在于对 CT 进行循环。对于每个 CT,我们会分割每个切片,然后将所有分割输出作为分组的输入。分组的输出将被馈送到结节分类器中,通过该分类器幸存下来的结节将被馈送到恶性分类器中。

这是对 CT 的外部循环,对每个 CT 进行分割、分组、分类候选,并提供分类以进行进一步处理。

列表 14.1 nodule_analysis.py:324,NoduleAnalysisApp.main

for _, series_uid in series_iter:                        # ❶
  ct = getCt(series_uid)                                 # ❷
  mask_a = self.segmentCt(ct, series_uid)                # ❸

  candidateInfo_list = self.groupSegmentationOutput(     # ❹
    series_uid, ct, mask_a)
  classifications_list = self.classifyCandidates(        # ❺
    ct, candidateInfo_list)

❶ 循环遍历系列 UID

❷ 获取 CT(大图中的步骤 1)

❸ 在其上运行我们的分割模型(步骤 2)

❹ 对输出中的标记体素进行分组(步骤 3)

❺ 在它们上运行我们的结节分类器(步骤 4)

我们将在以下部分详细介绍segmentCtgroupSegmentationOutputclassifyCandidates方法。

14.3.1 分割

首先,我们将对整个 CT 扫描的每个切片执行分割。由于我们需要逐个患者的 CT 逐个切片进行处理,我们构建一个Dataset,加载具有单个series_uid的 CT 并返回每个切片,每次调用__getitem__

注意 特别是在 CPU 上执行时,分割步骤可能需要相当长的时间。尽管我们在这里只是简单提及,但代码将在可用时使用 GPU。

除了更广泛的输入之外,主要区别在于我们如何处理输出。回想一下,输出是每个像素的概率数组(即在 0...1 范围内),表示给定像素是否属于结节。在遍历切片时,我们在一个与我们的 CT 输入形状相同的掩模数组中收集切片预测。之后,我们对预测进行阈值处理以获得二进制数组。我们将使用 0.5 的阈值,但如果需要,我们可以尝试不同的阈值来在增加假阳性的情况下获得更多真阳性。

我们还包括一个使用 scipy.ndimage.morphology 中的腐蚀操作进行小的清理步骤。它删除一个边缘体素层,仅保留内部体素——那些所有八个相邻体素在轴方向上也被标记的体素。这使得标记区域变小,并导致非常小的组件(小于 3 × 3 × 3 体素)消失。结合数据加载器的循环,我们指示它向我们提供来自单个 CT 的所有切片,我们有以下内容。

列表 14.2 nodule_analysis.py:384, .segmentCt

def segmentCt(self, ct, series_uid):
  with torch.no_grad():                                     # ❶
    output_a = np.zeros_like(ct.hu_a, dtype=np.float32)     # ❷
    seg_dl = self.initSegmentationDl(series_uid)  #         # ❸
    for input_t, _, _, slice_ndx_list in seg_dl:

      input_g = input_t.to(self.device)                     # ❹
      prediction_g = self.seg_model(input_g)                # ❺

      for i, slice_ndx in enumerate(slice_ndx_list):        # ❻
        output_a[slice_ndx] = prediction_g[i].cpu().numpy()

    mask_a = output_a > 0.5                                 # ❼
    mask_a = morphology.binary_erosion(mask_a, iterations=1)

  return mask_a

❶ 我们这里不需要梯度,所以我们不构建图。

❷ 这个数组将保存我们的输出:一个概率注释的浮点数组。

❸ 我们获得一个数据加载器,让我们可以按批次循环遍历我们的 CT。

❹ 将输入移动到 GPU 后...

❺ ... 我们运行分割模型 ...

❻ ... 并将每个元素复制到输出数组中。

❼ 将概率输出阈值化以获得二进制输出,然后应用二进制腐蚀进行清理

这已经足够简单了,但现在我们需要发明分组。

14.3.2 将体素分组为结节候选

我们将使用一个简单的连通分量算法将我们怀疑的结节体素分组成块以输入分类。这种分组方法标记连接的组件,我们将使用 scipy.ndimage.measurements.label 完成。label 函数将获取所有与另一个非零像素共享边缘的非零像素,并将它们标记为属于同一组。由于我们从分割模型输出的大部分都是高度相邻像素的块,这种方法很好地匹配了我们的数据。

列表 14.3 nodule_analysis.py:401

def groupSegmentationOutput(self, series_uid,  ct, clean_a):
  candidateLabel_a, candidate_count = measurements.label(clean_a)   # ❶
  centerIrc_list = measurements.center_of_mass(                     # ❷
    ct.hu_a.clip(-1000, 1000) + 1001,
    labels=candidateLabel_a,
    index=np.arange(1, candidate_count+1),
  )

❶ 为每个体素分配所属组的标签

❷ 获取每个组的质心作为索引、行、列坐标

输出数组 candidateLabel_a 与我们用于输入的 clean_a 具有相同的形状,但在背景体素处为 0,并且递增的整数标签 1、2、...,每个连接的体素块组成一个结节候选。请注意,这里的标签 是分类意义上的标签!这只是在说“这个体素块是体素块 1,这边的体素块是体素块 2,依此类推”。

SciPy 还提供了一个函数来获取结节候选的质心:scipy.ndimage.measurements.center_of_mass。它接受一个每个体素密度的数组,刚刚调用的 label 函数返回的整数标签,以及需要计算质心的这些标签的列表。为了匹配函数期望的质量为非负数,我们将(截取的)ct.hu_a 偏移了 1,001。请注意,这导致所有标记的体素都携带一些权重,因为我们将最低的空气值在本机 CT 单位中夹紧到 -1,000 HU。

列表 14.4 nodule_analysis.py:409

candidateInfo_list = []
for i, center_irc in enumerate(centerIrc_list):
  center_xyz = irc2xyz(                                                   # ❶
    center_irc,
    ct.origin_xyz,
    ct.vxSize_xyz,
    ct.direction_a,
  )
  candidateInfo_tup = \
    CandidateInfoTuple(False, False, False, 0.0, series_uid, center_xyz)  # ❷
  candidateInfo_list.append(candidateInfo_tup)

return candidateInfo_list

❶ 将体素坐标转换为真实患者坐标

❷ 构建我们的候选信息元组并将其附加到检测列表中

作为输出,我们得到一个包含三个数组的列表(分别为索引、行和列),与我们的 candidate_count 长度相同。我们可以使用这些数据来填充一个 candidateInfo_tup 实例的列表;我们已经对这种小数据结构产生了依恋,所以我们将结果放入自从第十章以来一直在使用的相同类型的列表中。由于我们实际上没有适合的数据来填充前四个值(isNodule_boolhasAnnotation_boolisMal_booldiameter_mm),我们插入了适当类型的占位符值。然后我们在循环中将我们的坐标从体素转换为物理坐标,创建列表。将我们的坐标从基于数组的索引、行和列移开可能看起来有点愚蠢,但所有消耗 candidateInfo_tup 实例的代码都期望 center_xyz,而不是 center_irc。如果我们尝试互换一个和另一个,我们将得到极其错误的结果!

耶--我们征服了第 3 步,从体素级别的检测中获取结节位置!现在我们可以裁剪出疑似结节,并将它们馈送给我们的分类器,以进一步消除一些假阳性。

14.3.3 我们找到了结节吗?分类以减少假阳性

当我们开始本书的第 2 部分时,我们描述了放射科医生查看 CT 扫描以寻找癌症迹象的工作如下:

目前,审查数据的工作必须由经过高度训练的专家执行,需要对细节进行仔细的注意,主要是在不存在癌症的情况下。

做好这项工作就像被放在 100 堆草垛前,并被告知:“确定这些草垛中是否有针。”

我们已经花费了时间和精力讨论谚语中的针;让我们通过查看图 14.4 来讨论一下草垛。我们的工作,可以说,就是尽可能多地从我们那位眼睛发直的放射科医生面前的草垛中分离出来,这样他们就可以重新聚焦他们经过高度训练的注意力,以便发挥最大的作用。

图 14.4 我们端到端检测项目的步骤,以及每个步骤删除的数据的数量级。

让我们看看在执行端到端诊断时每个步骤丢弃了多少数据。图 14.4 中的箭头显示了数据从原始 CT 体素流经我们的项目到最终恶性确定的过程。以 X 结尾的每个箭头表示上一步丢弃的一部分数据;指向下一步的箭头代表经过筛选幸存下来的数据。请注意,这里的数字是非常近似的。

让我们更详细地看一下图 14.4 中的步骤:

  1. 分割 --分割从整个 CT 开始:数百张切片,或大约 3300 万(225)体素(加减很多)。大约有 220 个体素被标记为感兴趣的;这比总输入要小几个数量级,这意味着我们要丢弃 97%的体素(这是左边导致 X 的 225)。

  2. 分组。虽然分组并没有明确删除任何内容,但它确实减少了我们考虑的项目数量,因为我们将体素合并为结节候选者。分组从 100 万体素中产生了大约 1000 个候选者(210)。一个 16×16×2 体素的结节将有总共 210 个体素。²

  3. 结节分类。这个过程丢弃了剩下的大多数~210 个项目。从我们成千上万的结节候选者中,我们剩下了数十个结节:大约 25 个。

  4. 恶性分类。最后,恶性分类器会取出数十个结节(25 个),找出其中一个或两个(21 个)是癌症的。

沿途的每一步都允许我们丢弃大量数据,我们的模型确信这些数据与我们的癌症检测目标无关。我们从数百万数据点到少数肿瘤。

完全自动化与辅助系统

完全自动化系统和旨在增强人类能力的系统之间存在差异。对于我们的自动化系统,一旦一条数据被标记为无关紧要,它就永远消失了。然而,当向人类呈现数据供其消化时,我们应该允许他们剥开一些层次,查看近似情况,并用一定的信心程度注释我们的发现。如果我们设计一个用于临床使用的系统,我们需要仔细考虑我们确切的预期用途,并确保我们的系统设计能够很好地支持这些用例。由于我们的项目是完全自动化的,我们可以继续前进,而不必考虑如何最好地展示近似情况和不确定的答案。

现在我们已经确定了图像中我们的分割模型认为是潜在候选的区域,我们需要从 CT 中裁剪这些候选并将它们馈送到分类模块中。幸运的是,我们有前一节的 candidateInfo_list,所以我们只需要从中创建一个 DataSet,将其放入 DataLoader,并对其进行迭代。概率预测的第一列是预测的这是一个结节的概率,这是我们想要保留的。就像以前一样,我们收集整个循环的输出。

列表 14.5 结节分析.py:357,.classifyCandidates

def classifyCandidates(self, ct, candidateInfo_list):
  cls_dl = self.initClassificationDl(candidateInfo_list)        # ❶
  classifications_list = []
  for batch_ndx, batch_tup in enumerate(cls_dl):
    input_t, _, _, series_list, center_list = batch_tup

    input_g = input_t.to(self.device)                           # ❷
    with torch.no_grad():
      _, probability_nodule_g = self.cls_model(input_g)         # ❸
      if self.malignancy_model is not None:                     # ❹
        _, probability_mal_g = self.malignancy_model(input_g)
      else:
        probability_mal_g = torch.zeros_like(probability_nodule_g)

    zip_iter = zip(center_list,
      probability_nodule_g[:,1].tolist(),
      probability_mal_g[:,1].tolist())
    for center_irc, prob_nodule, prob_mal in zip_iter:          # ❺
      center_xyz = irc2xyz(center_irc,
        direction_a=ct.direction_a,
        origin_xyz=ct.origin_xyz,
        vxSize_xyz=ct.vxSize_xyz,
      )
      cls_tup = (prob_nodule, prob_mal, center_xyz, center_irc)
      classifications_list.append(cls_tup)
  return classifications_list

❶ 再次,我们获得一个数据加载器来循环遍历,这次是基于我们的候选列表。

❷ 将输入发送到设备

❸ 将输入通过结节与非结节网络运行

❹ 如果我们有一个恶性模型,我们也运行它。

❺ 进行我们的簿记,构建我们结果的列表

这太棒了!我们现在可以将输出概率阈值化,得到我们的模型认为是实际结节的列表。在实际设置中,我们可能希望将它们输出供放射科医生检查。同样,我们可能希望调整阈值以更安全地出错一点:也就是说,如果我们的阈值是 0.3 而不是 0.5,我们将呈现更多的候选,结果证明不是结节,同时减少错过实际结节的风险。

列表 14.6 结节分析.py:333,NoduleAnalysisApp.main

  if not self.cli_args.run_validation:                                  # ❶
    print(f"found nodule candidates in {series_uid}:")
    for prob, prob_mal, center_xyz, center_irc in classifications_list:
      if prob > 0.5:                                                    # ❷
        s = f"nodule prob {prob:.3f}, "
        if self.malignancy_model:
          s += f"malignancy prob {prob_mal:.3f}, "
        s += f"center xyz {center_xyz}"
        print(s)

  if series_uid in candidateInfo_dict:                                  # ❸
    one_confusion = match_and_score(
      classifications_list, candidateInfo_dict[series_uid]
    )
    all_confusion += one_confusion
    print_confusion(
      series_uid, one_confusion, self.malignancy_model is not None
    )

print_confusion(
  "Total", all_confusion, self.malignancy_model is not None
)

❶ 如果我们不通过运行验证,我们打印单独的信息...

❷ ... 对于分割找到的所有候选,其中分类器分配的结节概率为 50% 或更高。

❸ 如果我们有真实数据,我们计算并打印混淆矩阵,并将当前结果添加到总数中。

让我们针对验证集中的给定 CT 运行这个:³

$ python3.6 -m p2ch14.nodule_analysis 1.3.6.1.4.1.14519.5.2.1.6279.6001.592821488053137951302246128864
...
found nodule candidates in 1.3.6.1.4.1.14519.5.2.1.6279.6001.592821488053137951302246128864:
nodule prob 0.533, malignancy prob 0.030, center xyz XyzTuple   # ❶(x=-128.857421875, y=-80.349609375, z=-31.300007820129395) 
nodule prob 0.754, malignancy prob 0.446, center xyz XyzTuple(x=-116.396484375, y=-168.142578125, z=-238.30000233650208)
...
nodule prob 0.974, malignancy prob 0.427, center xyz XyzTuple   # ❷(x=121.494140625, y=-45.798828125, z=-211.3000030517578)
nodule prob 0.700, malignancy prob 0.310, center xyz XyzTuple(x=123.759765625, y=-44.666015625, z=-211.3000030517578)
...

❶ 这个候选被分配了 53% 的恶性概率,所以它勉强达到了 50% 的概率阈值。恶性分类分配了一个非常低(3%)的概率。

❷ 被检测为结节,具有非常高的置信度,并被分配了 42% 的恶性概率

脚本总共找到了 16 个结节候选。由于我们正在使用验证集,我们对每个 CT 都有完整的注释和恶性信息,我们可以使用这些信息创建一个混淆矩阵来展示我们的结果。行是真相(由注释定义),列显示我们的项目如何处理每种情况:

1.3.6.1.4.1.14519.5.2.1.6279.6001.592821488053137951302246128864           # ❶
                  |    Complete Miss |     Filtered Out |     Pred. Nodule # ❷
      Non-Nodules |                  |             1088 |               15 # ❸
           Benign |                1 |                0 |                0
        Malignant |                0 |                0 |                1

❶ 扫描 ID

❷ 预后:完全未检出表示分割未找到结节,被过滤掉是分类器的工作,预测结节是它标记为结节的。

❸ 行包含了真相。

完全未检出列是当我们的分割器根本没有标记结节时。由于分割器并不试图标记非结节,我们将该单元格留空。我们的分割器经过训练具有很高的召回率,因此有大量的非结节,但我们的结节分类器很擅长筛选它们。

所以我们在这个扫描中找到了 1 个恶性结节,但漏掉了第 17 个良性结节。此外,有 15 个误报的非结节通过了结节分类器。分类器的过滤将误报降至 1,000 多个!正如我们之前看到的,1,088 大约是 O(210),所以这符合我们的预期。同样,15 大约是 O(24),这与我们估计的 O(25) 差不多。

很棒!但更大的画面是什么?

14.4 定量验证

现在我们有了一些个案证据表明我们建立的东西可能在一个案例上起作用,让我们看看我们的模型在整个验证集上的表现。这样做很简单:我们将我们的验证集通过之前的预测运行,检查我们得到了多少结节,漏掉了多少,以及多少候选被错误地识别为结节。

我们运行以下内容,如果在 GPU 上运行,应该需要半小时到一个小时。喝完咖啡(或者睡个好觉)后,这是我们得到的结果:

$ python3 -m p2ch14.nodule_analysis --run-validation

...
Total
                 |    Complete Miss |     Filtered Out |     Pred. Nodule
     Non-Nodules |                  |           164893 |             2156
          Benign |               12 |                3 |               87
       Malignant |                1 |                6 |               45

我们检测到了 154 个结节中的 132 个,或者 85%。我们错过的 22 个中,有 13 个未被分割认为是候选结节,因此这将是改进的明显起点。

大约 95%的检测到的结节是假阳性。这当然不是很好;另一方面,这并不是很关键--不得不查看 20 个结节候选才能找到一个结节要比查看整个 CT 要容易得多。我们将在第 14.7.2 节中更详细地讨论这一点,但我们要强调的是,与其将这些错误视为黑匣子,不如调查被错误分类的情况并看看它们是否有共同点。有什么特征可以将它们与被正确分类的样本区分开吗?我们能找到什么可以用来改善我们表现的东西吗?

目前,我们将接受我们的数字如此:不错,但并非完美。当您运行自己训练的模型时,确切的数字可能会有所不同。在本章末尾,我们将提供一些指向可以帮助改善这些数字的论文和技术。通过灵感和一些实验,我们确信您可以获得比我们在这里展示的更好的分数。

14.5 预测恶性

现在我们已经实现了 LUNA 挑战的结节检测任务,并可以生成自己的结节预测,我们问自己一个逻辑上的下一个问题:我们能区分恶性结节和良性结节吗?我们应该说,即使有一个好的系统,诊断恶性可能需要更全面地查看患者,额外的非 CT 背景信息,最终可能需要活检,而不仅仅是孤立地查看 CT 扫描中的单个结节。因此,这似乎是一个可能由医生执行的任务,未来可能会有一段时间。

14.5.1 获取恶性信息

LUNA 挑战专注于结节检测,并不包含恶性信息。LIDC-IDRI 数据集(mng.bz/4A4R)包含了用于 LUNA 数据集的 CT 扫描的超集,并包括有关已识别肿瘤恶性程度的额外信息。方便地,有一个可以轻松安装的 PyLIDC 库,如下所示:

$ pip3 install pylidc

pylicd库为我们提供了我们想要的额外恶性信息的便捷访问。就像我们在第 10 章中所做的那样,将 LIDC 的注释与 LUNA 候选者的坐标匹配,我们需要将 LIDC 的注释信息与 LUNA 候选者的坐标关联起来。

在 LIDC 注释中,恶性信息按照每个结节和诊断放射科医师(最多四位医师查看同一结节)使用从 1(高度不可能)到适度不可能、不确定、适度可疑,最后是 5(高度可疑)的有序五值量表进行编码。这些注释基于图像本身,并受到关于患者的假设的影响。为了将数字列表转换为单个布尔值是/否,我们将考虑当至少有两位放射科医师将该结节评为“适度可疑”或更高时,结节被认为是恶性的。请注意,这个标准有些是任意的;事实上,文献中有许多不同的处理这些数据的方法,包括预测五个步骤,使用平均值,或者从数据集中删除放射科医师评级不确定或不一致的结节。

结合数据的技术方面与第十章相同,因此我们跳过在此处显示代码(代码存储库中有此章节的代码),并将使用扩展的 CSV 文件。我们将以与我们为结节分类器所做的非常相似的方式使用数据集,只是现在我们只需要处理实际结节,并使用给定结节是否为恶性作为要预测的标签。这在结构上与我们在第十二章中使用的平衡非常相似,但我们不是从pos_listneg_list中抽样,而是从mal_listben_list中抽样。就像我们为结节分类器所做的那样,我们希望保持训练数据平衡。我们将这些放入MalignancyLunaDataset类中,该类是LunaDataset的子类,但在其他方面非常相似。

为了方便起见,我们在 training.py 中创建了一个dataset命令行参数,并动态使用命令行指定的数据集类。我们通过使用 Python 的getattr函数来实现这一点。例如,如果self.cli_args.dataset是字符串MalignancyLunaDataset,它将获取p2ch14.dsets.MalignancyLunaDataset并将此类型分配给ds_cls,我们可以在这里看到。

列表 14.7 training.py:154,.initTrainDl

ds_cls = getattr(p2ch14.dsets, self.cli_args.dataset)   # ❶

train_ds = ds_cls(
  val_stride=10,
  isValSet_bool=False,
  ratio_int=1,                                          # ❷
)

❶ 动态类名查找

❷ 请记住,这是训练数据之间的一对一平衡,这里是良性和恶性之间的平衡。

14.5.2 曲线下面积基线:按直径分类

有一个基线总是好的,可以看到什么性能比没有好。我们可以追求比随机更好,但在这里我们可以使用直径作为恶性的预测因子--更大的结节更有可能是恶性的。图 14.5 的第 2b 步提示了一个我们可以用来比较分类器的新度量标准。

图 14.5 我们在本章中实施的端到端项目,重点是 ROC 图

我们可以将结节直径作为假设分类器预测结节是否为恶性的唯一输入。这不会是一个很好的分类器,但事实证明,说“一切大于这个阈值 X 的东西都是恶性的”比我们预期的更好地预测了恶性。当然,选择正确的阈值是关键--有一个甜蜜点,可以获取所有巨大的肿瘤,而没有任何微小的斑点,并且大致分割了那个不确定区域,其中有一堆较大的良性结节和较小的恶性结节。

正如我们可能从第十二章中记得的那样,我们的真正阳性、假正性、真正性和假负性计数会根据我们选择的阈值值而改变。当我们降低我们预测结节为恶性的阈值时,我们将增加真正阳性的数量,但也会增加假正性的数量。假正率(FPR)是 FP /(FP + TN),而真正率(TPR)是 TP /(TP + FN),您可能还记得这是从第十二章中的召回中得到的。

测量假阳性没有一种真正的方法:精度与假阳性率

这里的 FPR 和第十二章中的精度是(介于 0 和 1 之间的)率,用于衡量不完全相反的事物。正如我们讨论过的,精度是 TP /(TP + FP),用于衡量预测为阳性的样本中有多少实际上是阳性的。FPR 是 FP /(FP + TN),用于衡量实际上为负的样本中有多少被预测为阳性。对于极度不平衡的数据集(如结节与非结节分类),我们的模型可能会实现非常好的 FPR(这与交叉熵标准作为损失密切相关),而精度--因此 F1 分数--仍然非常差。低 FPR 意味着我们正在淘汰我们不感兴趣的很多内容,但如果我们正在寻找那根传说中的针,我们仍然主要是干草。

让我们为我们的阈值设定一个范围。下限将是使得所有样本都被分类为阳性的值,上限将是相反的情况,即所有样本都被分类为阴性。在一个极端情况下,我们的 FPR 和 TPR 都将为零,因为不会有任何阳性;在另一个极端情况下,两者都将为一,因为不会有 TN 和 FN(一切都是阳性!)。

对于我们的结节数据,直径范围从 3.25 毫米(最小结节)到 22.78 毫米(最大结节)。如果我们选择一个介于这两个值之间的阈值,然后可以计算 FPR(阈值)和 TPR(阈值)。如果我们将 FPR 值设为X,TPR 设为Y,我们可以绘制代表该阈值的点;如果我们反而绘制每个可能阈值的 FPR 对 TPR,我们得到一个名为受试者工作特征(ROC)的图表,如图 14.6 所示。阴影区域是ROC 曲线下的面积,或者 AUC。它的取值范围在 0 到 1 之间,数值越高越好。⁵

图 14.6 我们基线的受试者工作特征(ROC)曲线

在这里,我们还指出了两个特定的阈值:直径为 5.42 毫米和 10.55 毫米。我们选择这两个值,因为它们为我们可能考虑的阈值范围提供了相对合理的端点,如果我们需要选择一个单一的阈值。小于 5.42 毫米,我们只会降低我们的 TPR。大于 10.55 毫米,我们只会将恶性结节标记为良性而没有任何收益。这个分类器的最佳阈值可能会在中间某处。

我们实际上是如何计算这里显示的数值的呢?我们首先获取候选信息列表,过滤出已注释的结节,并获取恶性标签和直径。为了方便起见,我们还获取了良性和恶性结节的数量。

列表 14.8 p2ch14_malben_baseline.ipynb

# In[2]:
ds = p2ch14.dsets.MalignantLunaDataset(val_stride=10, isValSet_bool=True) # ❶
nodules = ds.ben_list + ds.mal_list
is_mal = torch.tensor([n.isMal_bool for n in nodules])                    # ❷
diam  = torch.tensor([n.diameter_mm for n in nodules])
num_mal = is_mal.sum()                                                    # ❸
num_ben = len(is_mal) - num_mal

❶ 获取常规数据集,特别是良性和恶性结节的列表

❷ 获取恶性状态和直径的列表

❸ 为了对 TPR 和 FPR 进行归一化,我们获取了恶性和良性结节的数量。

要计算 ROC 曲线,我们需要一个可能阈值的数组。我们从 torch.linspace 获取这个数组,它取两个边界元素。我们希望从零预测的阳性开始,所以我们从最大阈值到最小阈值。这就是我们已经提到的 3.25 到 22.78:

# In[3]:
threshold = torch.linspace(diam.max(), diam.min())

然后我们构建一个二维张量,其中行是每个阈值,列是每个样本信息,值是该样本是否被预测为阳性。然后根据样本的标签(恶性或良性)对此布尔张量进行过滤。我们对行求和以计算True条目的数量。除以恶性或良性结节的数量给出了 TPR 和 FPR--ROC 曲线的两个坐标:

# In[4]:
predictions = (diam[None] >= threshold[:, None])                   # ❶
tp_diam = (predictions & is_mal[None]).sum(1).float() / num_mal    # ❷
fp_diam = (predictions & ~is_mal[None]).sum(1).float() / num_ben

❶ 通过 None 索引添加了一个大小为 1 的维度,就像 .unsqueeze(ndx) 一样。这使我们得到一个 2D 张量,其中给定结节(在列中)是否被分类为恶性,直径(在行中)。

❷ 使用预测矩阵,我们可以通过对列求和来计算每个直径的 TPR 和 FPR。

要计算这条曲线下的面积,我们使用梯形法进行数值积分(en.wikipedia.org/wiki/Trapezoidal_rule),其中我们将两点之间的平均 TPR(Y 轴上)乘以两个 FPR 之间的差值(X 轴上)--图表中两点之间梯形的面积。然后我们将梯形的面积相加:

# In[5]:
fp_diam_diff =  fp_diam[1:] - fp_diam[:-1]
tp_diam_avg  = (tp_diam[1:] + tp_diam[:-1])/2
auc_diam = (fp_diam_diff * tp_diam_avg).sum()

现在,如果我们运行pyplot.plot(fp_diam, tp_diam, label=f"diameter baseline, AUC={auc_diam:.3f}")(以及我们在第 8 单元中看到的适当图表设置),我们将得到图 14.6 中看到的图表。

14.5.3 重复使用预先存在的权重:微调

一种快速获得结果的方法(通常也可以用更少的数据完成)是不从随机初始化开始,而是从在某个具有相关数据的任务上训练过的网络开始。这被称为迁移学习或者,当仅训练最后几层时,称为微调。从图 14.7 中突出显示的部分可以看出,在步骤 2c 中,我们将剪掉模型的最后一部分,并用新的东西替换它。

图 14.7 我们在本章中实施的端到端项目,重点是微调

回想一下第八章,我们可以将中间值解释为从图像中提取的特征--特征可以是模型检测到的边缘或角落,或者任何模式的指示。在深度学习之前,很常见使用手工制作的特征,类似于我们在卷积开始时简要尝试的内容。深度学习使网络从数据中提取对当前任务有用的特征,例如区分类别。现在,微调让我们混合使用古老的方法(将近十年前!)使用预先存在的特征和使用学习特征的新方法。我们将网络的一部分(通常是大部分)视为固定的特征提取器,只训练其上的相对较小的部分。

这通常效果非常好。像我们在第二章中看到的在 ImageNet 上训练的预训练网络对处理自然图像的许多任务非常有用--有时它们也对完全不同的输入效果惊人,从绘画或风格转移中的仿制品到音频频谱图。有些情况下,这种策略效果不佳。例如,在训练在 ImageNet 上的模型时,常见的数据增强策略之一是随机翻转图像--一个向右看的狗与向左看的狗属于同一类。因此,翻转图像之间的特征非常相似。但是如果我们现在尝试使用预训练模型进行一个左右有关的任务,我们可能会遇到准确性问题。如果我们想要识别交通标志,这里左转这里右转是完全不同的;但是基于 ImageNet 特征构建的网络可能会在这两个类之间产生许多错误的分配。

在我们的情况下,我们有一个在类似数据上训练过的网络:结节分类网络。让我们尝试使用它。

为了说明,我们在微调方法中保持非常基本。在图 14.8 中的模型架构中,两个特别感兴趣的部分被突出显示:最后的卷积块和head_linear模块。最简单的微调是剪掉head_linear部分--事实上,我们只是保留了随机初始化。在尝试了这个之后,我们还将探索一种重新训练head_linear和最后一个卷积块的变体。

图 14.8 章节 11 中的模型架构,突出显示了深度-1 和深度-2 的权重

我们需要做以下事情:

  • 加载我们希望从中开始的模型的权重,除了最后的线性层,我们希望保留初始化。

  • 对于我们不想训练的参数禁用梯度(除了以head开头的参数)。

当我们在超过head_linear上进行微调训练时,我们仍然只将head_linear重置为随机值,因为我们认为先前的特征提取层可能不太适合我们的问题,但我们期望它们是一个合理的起点。这很简单:我们在模型设置中添加一些加载代码。

列表 14.9 training.py:124,.initModel

d = torch.load(self.cli_args.finetune, map_location='cpu')
model_blocks = [
  n for n, subm in model.named_children()
  if len(list(subm.parameters())) > 0                            # ❶
]
finetune_blocks = model_blocks[-self.cli_args.finetune_depth:]   # ❷
model.load_state_dict(
  {
    k: v for k,v in d['model_state'].items()
    if k.split('.')[0] not in model_blocks[-1]                   # ❸
  },
  strict=False,                                                  # ❹
)
for n, p in model.named_parameters():
  if n.split('.')[0] not in finetune_blocks:                     # ❺
    p.requires_grad_(False)

❶ 过滤掉具有参数的顶层模块(而不是最终激活)

❷ 获取最后的 finetune_depth 块。默认值(如果进行微调)为 1。

❸ 过滤掉最后一个块(最后的线性部分)并且不加载它。从一个完全初始化的模型开始将使我们从(几乎)所有结节被标记为恶性的状态开始,因为在我们开始的分类器中,该输出表示“结节”。

❹ 通过 strict=False 参数,我们可以仅加载模块的一些权重(其中过滤的权重缺失)。

❺ 对于除 finetune_blocks 之外的所有部分,我们不希望梯度。

我们准备好了!我们可以通过运行以下命令来仅训练头部:

python3 -m p2ch14.training \
    --malignant \
    --dataset MalignantLunaDataset \
    --finetune data/part2/models/cls_2020-02-06_14.16.55_final-nodule-nonnodule.best.state \
    --epochs 40 \
    malben-finetune

让我们在验证集上运行我们的模型并获得 ROC 曲线,如图 14.9 所示。这比随机要好得多,但考虑到我们没有超越基线,我们需要看看是什么阻碍了我们。

图 14.9 我们重新训练最后一个线性层的微调模型的 ROC 曲线。不算太糟糕,但也不如基线那么好。

图 14.10 显示了我们训练的 TensorBoard 图表。观察验证损失,我们可以看到虽然 AUC 缓慢增加,损失减少,但即使训练损失似乎在一个相对较高的水平(比如 0.3)上趋于平稳,而不是朝向零。我们可以进行更长时间的训练来检查是否只是非常缓慢;但将这与第五章讨论的损失进展进行比较--特别是图 5.14--我们可以看到我们的损失值并没有像图中的 A 案那样完全平稳,但我们的损失停滞问题在质量上是相似的。当时,A 案表明我们的容量不足,因此我们应考虑以下三种可能的原因:

  • 通过在结节与非结节分类上训练网络获得的特征(最后一个卷积的输出)对恶性检测并不有用。

  • 头部的容量--我们唯一训练的部分--并不够大。

  • 整体网络的容量可能太小了。

图 14.10 最后一个线性层微调的 AUC(左)和损失(右)

如果仅对全连接部分进行微调训练不够,下一步尝试的是将最后一个卷积块包括在微调训练中。幸运的是,我们引入了一个参数,所以我们可以将block4部分包含在我们的训练中:

python3 -m p2ch14.training \
    --malignant \
    --dataset MalignantLunaDataset \
    --finetune data/part2/models/cls_2020-02-06_14.16.55_final-nodule-nonnodule.best.state \
    --finetune-depth 2 \      # ❶
    --epochs 10 \
    malben-finetune-twolayer

❶ 这个 CLI 参数是新的。

完成后,我们可以将我们的新最佳模型与基线进行比较。图 14.11 看起来更合理!我们几乎没有误报,就能标记出约 75%的恶性结节。这显然比直径基线的 65%要好。当我们试图超过 75%时,我们的模型性能会回到基线。当我们回到分类问题时,我们将希望在 ROC 曲线上选择一个平衡真阳性与假阳性的点。

图 14.11 我们修改后模型的 ROC 曲线。现在我们离基线非常接近。

我们大致与基线持平,我们会对此感到满意。在第 14.7 节中,我们暗示了许多可以探索以改善这些结果的方法,但这些内容没有包含在本书中。

从图 14.12 中观察损失曲线,我们可以看到我们的模型现在很早就开始过拟合;因此下一步将是进一步检查正则化方法。我们将留给您处理。

图 14.12 最后一个卷积块和全连接层微调的 AUC(左)和损失(右)

有更精细的微调方法。有些人主张逐渐解冻层,从顶部开始。其他人建议用通常的学习率训练后面的层,并为较低的层使用较小的学习率。PyTorch 本身支持使用不同的优化参数,如学习率、权重衰减和动量,通过将它们分开在几个参数组中,这些参数组只是那样:具有单独超参数的参数列表(pytorch.org/docs/stable/optim.html#per-parameter-options)。

14.5.4 TensorBoard 中的更多输出

当我们重新训练模型时,值得看一看我们可以添加到 TensorBoard 中的一些额外输出,以查看我们的表现如何。对于直方图,TensorBoard 有一个预制的记录功能。对于 ROC 曲线,它没有,因此我们有机会满足 Matplotlib 接口。

直方图

我们可以获取恶性的预测概率并制作一个直方图。实际上,我们制作了两个:一个是(根据地面实况)良性的,一个是恶性结节的。这些直方图让我们深入了解模型的输出,并让我们看到是否有完全错误的大集群输出概率。

注意 一般来说,塑造您显示的数据是从数据中获取高质量信息的重要部分。如果您有许多非常自信的正确分类,您可能希望排除最左边的箱子。将正确的内容显示在屏幕上通常需要一些仔细思考和实验的迭代。不要犹豫调整您显示的内容,但也要注意记住,如果您更改了特定指标的定义而没有更改名称,将很容易将苹果与橙子进行比较。除非您在命名方案或删除现在无效的数据运行时有纪律地更改。

我们首先在保存我们的数据的张量metrics_t中创建一些空间。回想一下,我们在某处定义了索引。

列表 14.10 training.py:31

METRICS_LABEL_NDX=0
METRICS_PRED_NDX=1
METRICS_PRED_P_NDX=2    # ❶
METRICS_LOSS_NDX=3
METRICS_SIZE = 4

❶ 我们的新指数,携带着预测概率(而不是经过阈值处理的预测)

一旦完成这一步,我们可以调用writer.add_histogram,传入一个标签、数据以及设置为我们呈现的训练样本数的global_step计数器;这类似于之前的标量调用。我们还传入bins设置为一个固定的尺度。

列表 14.11 training.py:496,.logMetrics

bins = np.linspace(0, 1)

writer.add_histogram(
  'label_neg',
  metrics_t[METRICS_PRED_P_NDX, negLabel_mask],
  self.totalTrainingSamples_count,
  bins=bins
)
writer.add_histogram(
  'label_pos',
  metrics_t[METRICS_PRED_P_NDX, posLabel_mask],
  self.totalTrainingSamples_count,
  bins=bins
)

现在我们可以看一看我们对良性样本的预测分布以及它在每个时期如何演变。我们想要检查图 14.13 中直方图的两个主要特征。正如我们所期望的,如果我们的网络正在学习任何东西,在良性样本和非结节的顶行中,左侧有一个山峰,表示网络非常确信它所看到的不是恶性的。同样,在恶性样本中右侧也有一个山峰。

但仔细观察,我们看到了仅微调一个层的容量问题。专注于左上角的直方图系列,我们看到左侧的质量有些分散,并且似乎没有减少太多。甚至在 1.0 附近有一个小峰值,而且相当多的概率质量分布在整个范围内。这反映了损失不愿意降到 0.3 以下。

图 14.13 TensorBoard 直方图显示仅微调头部

鉴于对训练损失的观察,我们不必再深入研究,但让我们假装一下。在右侧的验证结果中,似乎在顶部右侧图表中,远离“正确”一侧的概率质量对于非恶性样本比底部右侧图表中的恶性样本更大。因此,网络更经常将非恶性样本错误分类为恶性样本。这可能会让我们考虑重新平衡数据以展示更多的非恶性样本。但再次强调,这是当我们假装左侧的训练没有任何问题时。我们通常希望先修复训练!

为了比较,让我们看看我们深度为 2 的微调相同图表(图 14.14)。在训练方面(左侧两个图表),我们在正确答案处有非常尖锐的峰值,其他内容不多。这反映了训练效果很好。

图 14.14 TensorBoard 直方图显示,深度为 2 的微调

在验证方面,我们现在看到最明显的问题是底部右侧直方图中预测概率为 0 的小峰值。因此,我们的系统性问题是将恶性样本误分类为非恶性。这与我们之前看到的两层微调过拟合相反!可能最好查看一些这种类型的图像,看看发生了什么。

TensorBoard 中的 ROC 和其他曲线

正如前面提到的,TensorBoard 本身不支持绘制 ROC 曲线。但是,我们可以利用 Matplotlib 导出任何图形的功能。数据准备看起来就像第 14.5.2 节中的一样:我们使用了在直方图中绘制的数据来计算 TPR 和 FPR--分别是tprfpr。我们再次绘制我们的数据,但这次我们跟踪pyplot.figure并将其传递给SummaryWriter方法add_figure

列表 14.12 training.py:482,.logMetrics

fig = pyplot.figure()                                           # ❶
pyplot.plot(fpr, tpr)                                           # ❷
writer.add_figure('roc', fig, self.totalTrainingSamples_count)  # ❸

❶ 设置一个新的 Matplotlib 图。通常我们不需要它,因为 Matplotlib 会隐式完成,但在这里我们需要。

❷ 使用任意 pyplot 函数

❸ 将我们的图表添加到 TensorBoard

因为这是作为图像提供给 TensorBoard 的,所以它出现在该标题下。我们没有绘制比较曲线或其他任何内容,以免让您分心,但我们可以在这里使用任何 Matplotlib 工具。在图 14.15 中,我们再次看到深度为 2 的微调(左侧)过拟合,而仅对头部进行微调(右侧)则没有。

图 14.15 在 TensorBoard 中训练 ROC 曲线。滑块让我们浏览迭代。

14.6 当我们进行诊断时看到的情况

沿着图 14.16 中的步骤 3a、3b 和 3c,我们现在需要运行从左侧的步骤 3a 分割到右侧的步骤 3c 恶性模型的完整流程。好消息是,我们几乎所有的代码都已经就位!我们只需要将它们组合起来:现在是时候实际编写并运行我们的端到端诊断脚本了。

我们在第 14.3.3 节的代码中首次看到了处理恶性模型的线索。如果我们向nodule_analysis调用传递一个参数--malignancy-path,它将运行在此路径找到的恶性模型并输出信息。这适用于单个扫描和--run-validation变体。

图 14.16 我们在本章实施的端到端项目,重点是端到端检测

请注意,脚本可能需要一段时间才能完成;即使只有验证集中的 89 个 CT 花费了大约 25 分钟。⁷

让我们看看我们得到了什么:

Total
             | Complete Miss | Filtered Out | Pred. Benign | Pred. Malignant
 Non-Nodules |               |       164893 |         1593 |             563
      Benign |            12 |            3 |           70 |              17
   Malignant |             1 |            6 |            9 |              36

不算太糟糕!我们检测到大约 85%的结节,并正确标记了约 70%的恶性结节,从头到尾。⁸ 虽然我们有很多假阳性,但似乎每个真结节有 16 个假阳性减少了需要查看的内容(好吧,如果没有 30%的假阴性的话)。正如我们在第九章中已经警告过的那样,这还不到你可以为你的医疗人工智能初创公司筹集数百万资金的水平,⁹ 但这是一个相当合理的起点。总的来说,我们应该对我们得到的明显有意义的结果感到满意;当然,我们真正的目标一直是在学习深度学习的过程中。

接下来,我们可能会选择查看实际被错误分类的结节。请记住,对于我们手头的任务,即使标注数据集的放射科医生们在看法上也存在差异。我们可能会根据他们清晰地将结节识别为恶性的程度来分层我们的验证集。

14.6.1 训练、验证和测试集

我们必须提到一个警告。虽然我们没有明确地在验证集上训练我们的模型,尽管我们在本章的开头冒了这个风险,但我们确实选择了基于模型在验证集上的表现来使用的训练时期。这也是一种数据泄漏。事实上,我们应该预期我们的实际性能会略逊色于这个,因为最好的模型在我们的验证集上表现得很好,不太可能在每个其他未见过的数据集上表现得同样出色(至少平均而言)。

由于这个原因,实践者经常将数据分为组:

  • 一个训练集,就像我们在这里所做的一样

  • 一个验证集,用于确定模型演化的哪个时期被认为是“最佳”

  • 一个测试集,用于实际预测模型的性能(由验证集选择)在未见过的真实世界数据上

添加第三组将导致我们再次拉取我们的训练数据的另一个非常重要的部分,考虑到我们已经不得不为了对抗过拟合而努力。这也会使呈现变得更加复杂,所以我们故意将其排除在外。如果这是一个有资源获取更多数据并迫切需要构建在野外使用的最佳系统的项目,我们将不得不在这里做出不同的决定,并积极寻找更多数据用作独立的测试集。

总的来说,偏见潜入我们的模型的方式是微妙的。我们应该特别小心地控制信息泄漏的每一步,并尽可能使用独立数据验证其不存在。采取捷径的代价是在后期惨败,而这种情况发生的时间是最糟糕的:当我们接近生产时。

14.7 接下来呢?灵感(和数据)的额外来源

在这一点上,进一步的改进将很难衡量。我们的分类验证集包含 154 个结节,我们的结节分类模型通常至少有 150 个正确,大部分的变化来自每个时期的训练变化。即使我们对模型进行了显著改进,我们的验证集中也没有足够的准确性来确定这种改变是否肯定是改进!这在良性与恶性分类中也非常明显,验证损失经常曲折。如果我们将验证步幅从 10 减少到 5,我们的验证集的大小将翻倍,代价是我们训练数据的九分之一。如果我们想尝试其他改进,这可能是值得的。当然,我们还需要解决测试集的问题,这将减少我们已经有限的训练数据。

我们还希望仔细研究网络表现不如我们期望的情况,看看是否能够识别出任何模式。但除此之外,让我们简要谈谈一些通用的方法,我们可以改进我们的项目。在某种程度上,这一部分就像第八章中的第 8.5 节。我们将努力为您提供尝试的想法;如果您不详细了解每个想法也不要担心。

14.7.1 防止过拟合:更好的正则化

回顾第 2 部分我们所做的事情,在三个问题中--第十一章和第 14.5 节中的分类器,以及第十三章中的分割--我们都有过拟合模型。在第一种情况下,过拟合是灾难性的;我们通过在第十二章中平衡数据和增强来处理它。这种数据平衡以防止过拟合也是训练 U-Net 在结节和候选者周围的裁剪而不是完整切片的主要动机。对于剩余的过拟合,我们选择了退出,当过拟合开始影响我们的验证结果时提前停止训练。这意味着预防或减少过拟合将是改善我们结果的好方法。

这种模式--获得一个过拟合的模型,然后努力减少过拟合--实际上可以看作是一个配方。因此,当我们想要改进我们现在所取得的状态时,应该使用这种两步方法。

经典正则化和增强

您可能已经注意到,我们甚至没有使用第八章中的所有正则化技术。例如,辍学将是一个容易尝试的事情。

虽然我们已经进行了一些增强,但我们可以走得更远。我们没有尝试使用的一个相对强大的增强方法是弹性变形,其中我们将“数字皱褶”放入输入中。这比仅仅旋转和翻转产生了更多的变化,似乎也适用于我们的任务。

更抽象的增强

到目前为止,我们的增强受到几何启发--我们将输入转换为更或多或少看起来像我们可能看到的合理东西。事实证明,我们不必局限于这种类型的增强。

回顾第八章,从数学上讲,我们一直在使用的交叉熵损失是预测和将所有概率质量放在标签上的分布之间的差异度量,可以用标签的独热向量表示。如果我们的网络存在过度自信的问题,我们可以尝试的一个简单方法是不使用独热分布,而是在“错误”类别上放置一小部分概率质量。这被称为标签平滑

我们还可以同时处理输入和标签。一个非常通用且易于应用的增强技术被提出,名为mixup:作者建议随机插值输入和标签。有趣的是,在对损失进行线性假设(这由二元交叉熵满足)的情况下,这等效于仅使用从适当调整的分布中绘制的权重来操作输入。显然,在处理真实数据时,我们不希望出现混合输入,但似乎这种混合鼓励预测的稳定性并且非常有效。

超越单一最佳模型:集成

我们对过拟合问题的一个观点是,如果我们知道正确的参数,我们的模型可以按照我们想要的方式工作,但我们实际上并不知道这些参数。如果我们遵循这种直觉,我们可能会尝试提出几组参数(也就是几个模型),希望每个模型的弱点可以互相补偿。这种评估几个模型并组合输出的技术称为集成。简而言之,我们训练几个模型,然后为了预测,运行它们所有并平均预测。当每个单独模型过拟合时(或者我们在开始看到过拟合之前拍摄了模型的快照),似乎这些模型可能开始对不同的输入做出错误预测,而不总是首先过拟合相同的样本。

在集成中,我们通常使用完全独立的训练运行或者不同的模型结构。但如果我们想要简化,我们可以从单次训练运行中获取几个模型的快照--最好是在结束前不久或者在开始观察到过拟合之前。我们可以尝试构建这些快照的集成,但由于它们仍然相互接近,我们可以选择对它们进行平均。这就是随机权重平均的核心思想。我们在这样做时需要一些小心:例如,当我们的模型使用批量归一化时,我们可能需要调整统计数据,但即使没有这样做,我们也可能获得一些小的准确度提升。

概括我们要求网络学习的内容

我们还可以看看多任务学习,在这里我们要求模型学习除了我们将要评估的输出之外的额外输出,这已经被证明可以改善结果。我们可以尝试同时训练结节与非结节以及良性与恶性。实际上,恶性数据的数据源提供了我们可以用作额外任务的额外标签;请参见下一节。这个想法与我们之前看到的迁移学习概念密切相关,但在这里我们通常会同时训练两个任务,而不是先完成一个再尝试转移到下一个。

如果我们没有额外的任务,而是有一堆额外的未标记数据,我们可以研究半监督学习。最近提出的一个看起来非常有效的方法是无监督数据增强。在这里,我们像往常一样在数据上训练我们的模型。在未标记数据上,我们对未增强的样本进行预测。然后我们将该预测作为该样本的目标,并训练模型在增强样本上也预测该目标。换句话说,我们不知道预测是否正确,但我们要求网络无论增强与否都产生一致的输出。

当我们没有更多感兴趣的任务但又没有额外数据时,我们可能会考虑捏造数据。捏造数据有些困难(尽管有时人们会使用类似第二章中简要介绍的 GANs,取得一定成功),因此我们选择捏造任务。这时我们进入了自监督学习的领域;这些任务通常被称为借口任务。一个非常流行的借口任务系列是对一些输入进行某种形式的破坏。然后我们可以训练一个网络来重建原始数据(例如,使用类似 U-Net 结构)或者训练一个分类器来检测真实数据和破坏数据,同时共享模型的大部分部分(例如卷积层)。

这仍然取决于我们想出一种损坏输入的方法。如果我们没有这样的方法并且没有得到想要的结果,还有其他方法可以进行自监督学习。一个非常通用的任务是,如果模型学习的特征足够好,可以让模型区分数据集的不同样本。这被称为对比学习

为了使事情更具体,考虑以下情况:我们从当前图像中提取的特征以及另外 K 张图像的特征。这是我们的关键特征集。现在我们设置一个分类前提任务如下:给定当前图像的特征,即查询,它属于 K + 1 个关键特征中的哪一个?这乍一看可能很琐碎,但即使对于正确类别的查询特征和关键特征之间存在完美一致,训练这个任务也鼓励查询特征在分类器输出中被分配低概率时与 K 其他图像的特征最大程度地不同。当然,还有许多细节需要填充;我们建议(有些是任意的)查看动量对比。²⁰

14.7.2 优化的训练数据

我们可以通过几种方式改进我们的训练数据。我们之前提到恶性分类实际上是基于几位放射科医生更细致的分类。通过将我们丢弃的数据转化为“恶性或非恶性?”的二分法,一个简单的方法是使用这五类。然后,放射科医生的评估可以用作平滑标签:我们可以对每个评估进行独热编码,然后对给定结节的评估进行平均。因此,如果四位放射科医生观察一个结节,其中两位称其为“不确定”,一位将同一结节称为“中度可疑”,第四位将其标记为“高度可疑”,我们将根据模型输出和目标概率分布之间的交叉熵进行训练,给定向量0 0 0.5 0.25 0.25。这类似于我们之前提到的标签平滑,但以更智能、问题特定的方式。然而,我们必须找到一种新的评估这些模型的方法,因为我们失去了在二元分类中简单的准确性、ROC 和 AUC 的概念。

利用多个评估的另一种方法是训练多个模型而不是一个,每个模型都是根据单个放射科医生给出的注释进行训练的。在推断时,我们可以通过例如平均它们的输出概率来集成模型。

在之前提到的多任务方向上,我们可以再次回到 PyLIDC 提供的注释数据,其中为每个注释提供了其他分类(微妙性、内部结构、钙化、球形度、边缘定义性、分叶、刺状和纹理 (pylidc.github.io/annotation.html))。不过,首先我们可能需要更多地了解结节。

在分割中,我们可以尝试看看 PyLIDC 提供的掩模是否比我们自己生成的掩模效果更好。由于 LIDC 数据具有多位放射科医生的注释,可以将结节分组为“高一致性”和“低一致性”组。看看这是否对应于“易”和“难”分类的结节,即看看我们的分类器是否几乎完全正确地处理所有易处理的结节,只在那些对人类专家更模糊的结节上遇到困难。或者我们可以从另一方面解决问题,通过定义结节在我们的模型性能方面的检测难度:将其分为“易”(经过一两个训练周期后正确分类)、“中”(最终正确分类)和“难”(持续错误分类)三个桶。

除了现成的数据,一个可能有意义的事情是进一步按恶性类型对结节进行分区。让专业人士更详细地检查我们的训练数据,并为每个结节标记一个癌症类型,然后强制模型报告该类型,可能会导致更有效的训练。外包这项工作的成本对于业余项目来说是高昂的,但在商业环境中支付可能是合理的。

尤其困难的情况也可能会受到人类专家的有限重复审查,以检查错误。同样,这将需要预算,但对于认真的努力来说绝对是合理的。

14.7.3 比赛结果和研究论文

我们在第 2 部分的目标是呈现从问题到解决方案的自包含路径,我们做到了。但是寻找和分类肺结节的特定问题以前已经有人研究过;因此,如果您想深入了解,您也可以看看其他人做了什么。

Data Science Bowl 2017

尽管我们将第 2 部分的范围限定在 LUNA 数据集中的 CT 扫描上,但在 Data Science Bowl 2017(www.kaggle .com/c/data-science-bowl-2017)中也有大量信息可供参考,该比赛由 Kaggle(www.kaggle.com)主办。数据本身已不再可用,但有许多人描述了对他们有效和无效的方法。例如,一些 Data Science Bowl(DSB)的决赛选手报告说,来自 LIDC 的详细恶性程度(1...5)信息在训练过程中很有用。

您可以查看的两个亮点是这些:²¹

  • 第二名解决方案的撰写:Daniel Hammack 和 Julian de Wit mng.bz/Md48

  • 第九名解决方案的撰写:Team Deep Breath mng.bz/aRAX

注意 我们之前暗示的许多新技术对 DSB 参与者尚不可用。2017 年 DSB 和本书印刷之间的三年在深度学习领域是一个漫长的时间!

一个更合理的测试集的一个想法是使用 DSB 数据集而不是重复使用我们的验证集。不幸的是,DSB 停止分享原始数据,所以除非您碰巧有旧版本的副本,否则您需要另一个数据来源。

LUNA 论文

LUNA Grand Challenge 已经收集了一些结果(luna16.grand-challenge.org/Results),显示出相当大的潜力。虽然并非所有提供的论文都包含足够的细节来重现结果,但许多论文确实包含了足够的信息来改进我们的项目。您可以查阅一些论文,并尝试复制看起来有趣的方法。

14.8 结论

本章结束了第 2 部分,并实现了我们在第九章中承诺的承诺:我们现在有一个可以尝试从 CT 扫描中诊断肺癌的工作端到端系统。回顾我们的起点,我们已经走了很长的路,希望也学到了很多。我们使用公开可用的数据训练了一个能够做出有趣且困难的事情的模型。关键问题是,“这对现实世界有好处吗?”随之而来的问题是,“这准备好投入生产了吗?”生产的定义关键取决于预期用途,因此,如果我们想知道我们的算法是否可以取代专业放射科医师,那肯定不是这种情况。我们认为这可以代表未来支持放射科医师在临床例行工作中的工具的 0.1 版本:例如,通过提供对可能被忽视的事项的第二意见。

这样的工具需要通过监管机构(如美国食品药品监督管理局)的批准,以便在研究环境之外使用。我们肯定会缺少一个广泛的、经过精心策划的数据集来进一步训练,甚至更重要的是验证我们的工作。个别案例需要在研究协议的背景下由多位专家评估;而对于各种情况的适当表达,从常见病例到边缘情况,都是必不可少的。

所有这些情况,从纯研究用途到临床验证再到临床使用,都需要我们在一个适合扩展的环境中执行我们的模型。不用说,这带来了一系列挑战,无论是技术上还是流程上。我们将在第十五章讨论一些技术挑战。

14.8.1 幕后花絮

当我们结束第二部分的建模时,我们想拉开幕布,让你一窥在深度学习项目中工作的真相。从根本上说,这本书呈现了一种偏颇的看法:一系列经过策划的障碍和机会;一个经过精心呵护的花园小径,穿过深度学习的更广阔领域。我们认为这种半有机的挑战系列(尤其是第二部分)会使这本书更好,也希望会有更好的学习体验。然而,这并不意味着会有一个更真实的体验。

很可能,你的大部分实验都不会成功。并非每个想法都会成为一个发现,也不是每个改变都会是一个突破。深度学习是棘手的。深度学习是善变的。请记住,深度学习实际上是在推动人类知识的前沿;这是我们每天都在探索和拓展的领域,就在此刻。现在是从事这个领域的激动人心的时刻,但就像大多数野外工作一样,你的靴子上总会沾上一些泥巴。

符合透明度精神,这里有一些我们尝试过的、我们遇到困难的、不起作用的,或者至少不够好以至于不值得保留的事情:

  • 在分类网络中使用HardTanh而不是Softmax(这样更容易解释,但实际上效果并不好)。

  • 试图通过使分类网络更复杂(跳跃连接等)来解决HardTanh引起的问题。

  • 不良的权重初始化导致训练不稳定,特别是对于分割。

  • 对完整的 CT 切片进行分割训练。

  • 使用 SGD 进行分割的损失加权。这并没有起作用,需要使用 Adam 才能使其有用。

  • CT 扫描的真正三维分割。对我们来说不起作用,但 DeepMind 后来还是做到了。这是在我们转向裁剪到结节之前,我们的内存用完了,所以你可以根据当前的设置再试一次。

  • 误解 LUNA 数据中class列的含义,导致在撰写本书的过程中进行了一些重写。

  • 无意中留下一个“我想快速获得结果”的技巧,导致分割模块找到的候选结节中有 80%被丢弃,直到我们弄清楚问题所在(这花了整个周末!)。

  • 一系列不同的优化器、损失函数和模型架构。

  • 以各种方式平衡训练数据。

我们肯定还忘记了更多。很多事情在变得正确之前都出了错!请从我们的错误中学习。

我们可能还要补充一点,对于这篇文章中的许多内容,我们只是选择了一种方法;我们强调并不意味着其他方法不如(其中许多可能更好!)。此外,编码风格和项目设计在人们之间通常有很大的不同。在机器学习中,人们经常在 Jupyter 笔记本中进行大量编程。笔记本是一个快速尝试事物的好工具,但它们也有自己的注意事项:例如,如何跟踪你所做的事情。最后,与我们之前使用的prepcache缓存机制不同,我们可以有一个单独的预处理步骤,将数据写出为序列化张量。这些方法中的每一种似乎都是一种品味;即使在三位作者中,我们中的任何一位都会略有不同地做事情。尝试事物并找出哪种方法最适合你,同时在与同事合作时保持灵活性是很好的。

14.9 练习

  1. 为分类实现一个测试集,或者重用第十三章练习中的测试集。在训练时使用验证集选择最佳时期,但在最终项目评估时使用测试集。验证集上的性能与测试集上的性能如何相匹配?

  2. 你能训练一个能够在一次传递中进行三路分类,区分非结节、良性结节和恶性结节的单一模型吗?

    1. 什么类平衡分割对训练效果最好?

    2. 与我们在书中使用的两遍方法相比,这种单遍模型的表现如何?

  3. 我们在注释上训练了我们的分类器,但期望它在我们分割的输出上表现。使用分割模型构建一个非结节的列表,用于训练,而不是提供的非结节。

    1. 当在这个新集合上训练时,分类模型的性能是否有所提高?

    2. 你能描述哪种结节候选者在新训练的模型中看到了最大的变化吗?

  4. 我们使用的填充卷积导致图像边缘附近的上下文不足。计算 CT 扫描切片边缘附近分割像素的损失,与内部的损失相比。这两者之间是否有可测量的差异?

  5. 尝试使用重叠的 32×48×48 块在整个 CT 上运行分类器。这与分割方法相比如何?

14.10 总结

  • 训练集和验证(以及测试)集之间的明确分割至关重要。在这里,按病人分割要比其他方式更不容易出错。当您的管道中有几个模型时,这一点更为真实。

  • 从像素标记到结节的转换可以通过非常传统的图像处理实现。我们不想看不起经典,但重视这些工具,并在适当的地方使用它们。

  • 我们的诊断脚本同时执行分割和分类。这使我们能够诊断我们以前没有见过的 CT,尽管我们当前的Dataset实现未配置为接受来自 LUNA 以外来源的series_uid

  • 微调是在使用最少的训练数据的情况下拟合模型的好方法。确保预训练模型具有与您的任务相关的特征,并确保重新训练具有足够容量的网络的一部分。

  • TensorBoard 允许我们编写许多不同类型的图表,帮助我们确定发生了什么。但这并不是查看我们的模型在哪些数据上表现特别糟糕的替代品。

  • 成功的训练似乎在某个阶段涉及过拟合网络,然后我们对其进行正则化。我们可能也可以将其视为一种配方;我们可能应该更多地了解正则化。

  • 训练神经网络是尝试事物,看看出了什么问题,然后改进它。通常没有什么灵丹妙药。

  • Kaggle 是深度学习项目创意的绝佳来源。许多新数据集为表现最佳者提供现金奖励,而旧的比赛则有可用作进一步实验起点的示例。


¹ 你也可以使用 p2_run_everything 笔记本。

² 任何给定结节的大小显然是高度可变的。

³ 我们特意选择了这个系列,因为它有一个很好的结果混合。

⁴ 查看 PyLIDC 文档以获取完整详情:mng.bz/Qyv6

⁵ 请注意,在平衡数据集上的随机预测将导致 AUC 为 0.5,因此这为我们的分类器必须有多好提供了一个下限。

⁶ 你可以尝试使用受人尊敬的德国交通标志识别基准数据集,网址为 mng.bz/XPZ9

⁷ 大部分延迟来自于 SciPy 对连接组件的处理。在撰写本文时,我们还不知道有加速实现。

⁸ 请记住,我们之前的“几乎没有假阳性的 75%” ROC 数字是针对恶性分类的孤立情况。在我们甚至进入恶性分类器之前,我们已经过滤掉了七个恶性结节。

⁹ 如果是这样的话,我们会选择这样做而不是写这本书!

¹⁰ 至少有一位作者很愿意在本节涉及的主题上写一本完整的书。

¹¹ 另请参阅 Andrej Karparthy 的博客文章“A Recipe for Training Neural Networks”,网址为karpathy.github .io/2019/04/25/recipe以获取更详细的配方。

¹² 你可以在mng.bz/Md5Q找到一个配方(尽管是针对 TensorFlow 的)。

¹³ 你可以使用nn.KLDivLoss损失函数。

¹⁴Hongyi Zhang 等人,“mixup:超越经验风险最小化”,arxiv.org/abs/1710.09412

¹⁵ 请参阅 Ferenc Huszár 在mng.bz/aRJj/发布的文章;他还提供了 PyTorch 代码。

¹⁶ 我们可能会将其扩展为纯贝叶斯,但我们只会使用这一点直觉。

¹⁷Pavel Izmailov 和 Andrew Gordon Wilson 在mng.bz/gywe提供了一个 PyTorch 代码的介绍。

¹⁸ 请参阅 Sebastian Ruder,“深度神经网络中多任务学习概述”,arxiv.org/ abs/1706.05098;但这也是许多领域的关键思想。

¹⁹Q. Xie 等人,“无监督数据增强用于一致性训练”,arxiv.org/abs/ 1904.12848

²⁰K. He 等人,“动量对比用于无监督视觉表示学习”,arxiv.org/ abs/1911.05722

²¹ 感谢互联网档案馆将它们从重新设计中保存下来。

²²Stanislav Nikolov 等人,“用于放射治疗头颈解剖学临床适用分割的深度学习”,arxiv.org/pdf/1809.04430.pdf

²³ 哦,我们进行过的讨论!

第三部分:部署

*在第三部分中,我们将看看如何使我们的模型达到可以使用的程度。我们在前几部分中看到了如何构建模型:第一部分介绍了模型的构建和训练,第二部分从头到尾详细介绍了一个示例,所以辛苦的工作已经完成了。

但是在你真正能够使用模型之前,没有任何模型是有用的。因此,现在我们需要将模型投入使用,并将其应用于它们设计解决的任务。这部分在精神上更接近第一部分,因为它介绍了许多 PyTorch 组件。与以往一样,我们将专注于我们希望解决的应用和任务,而不仅仅是为了看 PyTorch 本身。

在第三部分的单一章节中,我们将了解 2020 年初的 PyTorch 部署情况。我们将了解并使用 PyTorch 即时编译器(JIT)将模型导出以供第三方应用程序使用,以及用于移动支持的 C++ API。

十五、部署到生产环境

本章涵盖内容

  • 部署 PyTorch 模型的选项

  • 使用 PyTorch JIT

  • 部署模型服务器和导出模型

  • 在 C++中运行导出和本地实现的模型

  • 在移动设备上运行模型

在本书的第一部分,我们学到了很多关于模型的知识;第二部分为我们提供了创建特定问题的好模型的详细路径。现在我们有了这些优秀的模型,我们需要将它们带到可以发挥作用的地方。在规模化执行深度学习模型推理的基础设施维护方面,从架构和成本的角度来看都具有影响力。虽然 PyTorch 最初是一个专注于研究的框架,但从 1.0 版本开始,添加了一组面向生产的功能,使 PyTorch 成为从研究到大规模生产的理想端到端平台。

部署到生产环境意味着会根据用例而有所不同:

  • 我们在第二部分开发的模型可能最自然的部署方式是建立一个网络服务,提供对我们模型的访问。我们将使用轻量级的 Python Web 框架来实现这一点:Flask ( flask.pocoo.org) 和 Sanic (sanicframework.org)。前者可以说是这些框架中最受欢迎的之一,后者在精神上类似,但利用了 Python 的新的异步操作支持 async/await 来提高效率。

  • 我们可以将我们的模型导出为一个标准化的格式,允许我们使用优化的模型处理器、专门的硬件或云服务进行部署。对于 PyTorch 模型,Open Neural Network Exchange (ONNX)格式起到了这样的作用。

  • 我们可能希望将我们的模型集成到更大的应用程序中。为此,如果我们不受 Python 的限制将会很方便。因此,我们将探讨使用 PyTorch 模型从 C++中使用的想法,这也是通往任何语言的一个过渡。

  • 最后,对于一些像我们在第二章中看到的图像斑马化这样的事情,可能很好地在移动设备上运行我们的模型。虽然你不太可能在手机上有一个 CT 模块,但其他医疗应用程序如自助皮肤检查可能更自然,用户可能更喜欢在设备上运行而不是将他们的皮肤发送到云服务。幸运的是,PyTorch 最近增加了移动支持,我们将探索这一点。

当我们学习如何实现这些用例时,我们将以第十四章的分类器作为我们提供服务的第一个示例,然后切换到斑马化模型处理其他部署的内容。

15.1 提供 PyTorch 模型

我们将从将模型放在服务器上需要做什么开始。忠于我们的实践方法,我们将从最简单的服务器开始。一旦我们有了基本的工作内容,我们将看看它的不足之处,并尝试解决。最后,我们将看看在撰写本文时的未来。让我们创建一个监听网络的东西。¹

15.1.1 我们的模型在 Flask 服务器后面

Flask 是最广泛使用的 Python 模块之一。可以使用pip进行安装:²

pip install Flask

API 可以通过装饰函数创建。

列表 15.1 flask_hello_world.py:1

from flask import Flask
app = Flask(__name__)

@app.route("/hello")
def hello():
  return "Hello World!"

if __name__ == '__main__':
  app.run(host='0.0.0.0', port=8000)

应用程序启动后将在端口 8000 上运行,并公开一个路由/hello,返回“Hello World”字符串。此时,我们可以通过加载先前保存的模型并通过POST路由公开它来增强我们的 Flask 服务器。我们将以第十四章的模块分类器为例。

我们将使用 Flask 的(有点奇怪地导入的)request来获取我们的数据。更准确地说,request.files 包含一个按字段名称索引的文件对象字典。我们将使用 JSON 来解析输入,并使用 flask 的jsonify助手返回一个 JSON 字符串。

现在,我们将暴露一个/predict 路由,该路由接受一个二进制块(系列的像素内容)和相关的元数据(包含一个以shape为键的字典的 JSON 对象)作为POST请求提供的输入文件,并返回一个 JSON 响应,其中包含预测的诊断。更确切地说,我们的服务器接受一个样本(而不是一批),并返回它是恶性的概率。

为了获取数据,我们首先需要将 JSON 解码为二进制,然后使用numpy.frombuffer将其解码为一维数组。我们将使用torch.from_numpy将其转换为张量,并查看其实际形状。

模型的实际处理方式就像第十四章中一样:我们将从第十四章实例化LunaModel,加载我们从训练中得到的权重,并将模型置于eval模式。由于我们不进行训练任何东西,我们会在with torch.no_grad()块中告诉 PyTorch 在运行模型时不需要梯度。

列表 15.2 flask_server.py:1

import numpy as np
import sys
import os
import torch
from flask import Flask, request, jsonify
import json

from p2ch13.model_cls import LunaModel

app = Flask(__name__)

model = LunaModel()                                                # ❶
model.load_state_dict(torch.load(sys.argv[1],
                 map_location='cpu')['model_state'])
model.eval()

def run_inference(in_tensor):
  with torch.no_grad():                                           # ❷
    # LunaModel takes a batch and outputs a tuple (scores, probs)
    out_tensor = model(in_tensor.unsqueeze(0))[1].squeeze(0)
  probs = out_tensor.tolist()
  out = {'prob_malignant': probs[1]}
  return out

@app.route("/predict", methods=["POST"])                          # ❸
def predict():
  meta = json.load(request.files['meta'])                         # ❹
  blob = request.files['blob'].read()
  in_tensor = torch.from_numpy(np.frombuffer(
    blob, dtype=np.float32))                                      # ❺
  in_tensor = in_tensor.view(*meta['shape'])
  out = run_inference(in_tensor)
  return jsonify(out)                                             # ❻

if __name__ == '__main__':
  app.run(host='0.0.0.0', port=8000)
  print (sys.argv[1])

❶ 设置我们的模型,加载权重,并转换为评估模式

❷ 对我们来说没有自动求导。

❸ 我们期望在“/predict”端点进行表单提交(HTTP POST)。

❹ 我们的请求将有一个名为 meta 的文件。

❺ 将我们的数据从二进制块转换为 torch

❻ 将我们的响应内容编码为 JSON

运行服务器的方法如下:

python3 -m p3ch15.flask_server data/part2/models/cls_2019-10-19_15.48.24_final_cls.best.state

我们在 cls_client.py 中准备了一个简单的客户端,发送一个示例。从代码目录中,您可以运行它如下:

python3 p3ch15/cls_client.py

它应该告诉您结节极不可能是恶性的。显然,我们的服务器接受输入,通过我们的模型运行它们,并返回输出。那我们完成了吗?还不完全。让我们看看下一节中可以改进的地方。

15.1.2 部署的期望

让我们收集一些为提供模型服务而期望的事情。首先,我们希望支持现代协议及其特性。老式的 HTTP 是深度串行的,这意味着当客户端想要在同一连接中发送多个请求时,下一个请求只会在前一个请求得到回答后才会发送。如果您想发送一批东西,这并不是很有效。我们在这里部分交付--我们升级到 Sanic 肯定会使我们转向一个有雄心成为非常高效的框架。

在使用 GPU 时,批量请求通常比逐个处理或并行处理更有效。因此,接下来,我们的任务是从几个连接收集请求,将它们组装成一个批次在 GPU 上运行,然后将结果返回给各自的请求者。这听起来很复杂,(再次,当我们编写这篇文章时)似乎在简单的教程中并不经常做。这足以让我们在这里正确地做。但请注意,直到由模型运行持续时间引起的延迟成为问题(在等待我们自己的运行时是可以的;但在请求到达时等待正在运行的批次完成,然后等待我们的运行给出结果是禁止的),在给定时间内在一个 GPU 上运行多个批次没有太多理由。增加最大批量大小通常更有效。

我们希望并行提供几件事情。即使使用异步提供服务,我们也需要我们的模型在第二个线程上高效运行--这意味着我们希望通过我们的模型摆脱(臭名昭著的)Python 全局解释器锁(GIL)。

我们还希望尽量减少复制。无论从内存消耗还是时间的角度来看,反复复制东西都是不好的。许多 HTTP 事物都是以 Base64 编码(一种将二进制编码为更多或更少字母数字字符串的格式,每字节限制为 6 位)的形式编码的,比如,对于图像,将其解码为二进制,然后再转换为张量,然后再转换为批处理显然是相对昂贵的。我们将部分实现这一点——我们将使用流式PUT请求来避免分配 Base64 字符串,并避免通过逐渐追加到字符串来增长字符串(对于字符串和张量来说,这对性能非常糟糕)。我们说我们没有完全实现,因为我们并没有真正最小化复制。

为了提供服务,最后一个理想的事情是安全性。理想情况下,我们希望有安全的解码。我们希望防止溢出和资源耗尽。一旦我们有了固定大小的输入张量,我们应该大部分都没问题,因为从固定大小的输入开始很难使 PyTorch 崩溃。为了达到这个目标,解码图像等工作可能更令人头疼,我们不做任何保证。互联网安全是一个足够庞大的领域,我们将完全不涉及它。我们应该注意到神经网络容易受到输入操纵以生成期望但错误或意想不到的输出(称为对抗性示例),但这与我们的应用并不是非常相关,所以我们会在这里跳过它。

言归正传。让我们改进一下我们的服务器。

15.1.3 请求批处理

我们的第二个示例服务器将使用 Sanic 框架(通过同名的 Python 包安装)。这将使我们能够使用异步处理来并行处理许多请求,因此我们将在列表中勾选它。顺便说一句,我们还将实现请求批处理。

图 15.1 请求批处理的数据流

异步编程听起来可能很可怕,并且通常伴随着大量术语。但我们在这里所做的只是允许函数非阻塞地等待计算或事件的结果。

为了进行请求批处理,我们必须将请求处理与运行模型分离。图 15.1 显示了数据的流动。

在图 15.1 的顶部是客户端,发出请求。这些一个接一个地通过请求处理器的上半部分。它们导致工作项与请求信息一起入队。当已经排队了一个完整的批次或最老的请求等待了指定的最长时间时,模型运行器会从队列中取出一批,处理它,并将结果附加到工作项上。然后这些工作项一个接一个地由请求处理器的下半部分处理。

实现

我们通过编写两个函数来实现这一点。模型运行函数从头开始运行并永远运行。每当需要运行模型时,它会组装一批输入,在第二个线程中运行模型(以便其他事情可以发生),然后返回结果。

请求处理器然后解码请求,将输入加入队列,等待处理完成,并返回带有结果的输出。为了理解这里异步的含义,可以将模型运行器视为废纸篓。我们为本章所涂鸦的所有图纸都可以快速地放在桌子右侧的垃圾桶里处理掉。但是偶尔——无论是因为篮子已满还是因为到了晚上清理的时候——我们需要将所有收集的纸张拿出去扔到垃圾桶里。类似地,我们将新请求加入队列,如果需要则触发处理,并在发送结果作为请求答复之前等待结果。图 15.2 展示了我们在执行的两个函数块之前无间断执行的情况。

图 15.2 我们的异步服务器由三个模块组成:请求处理器、模型运行器和模型执行。这些模块有点像函数,但前两个在中间会让出事件循环。

相对于这个图片,一个轻微的复杂性是我们有两个需要处理事件的场合:如果我们积累了一个完整的批次,我们立即开始;当最老的请求达到最大等待时间时,我们也想运行。我们通过为后者设置一个定时器来解决这个问题。⁵

所有我们感兴趣的代码都在一个ModelRunner类中,如下列表所示。

列表 15.3 request_batching_server.py:32, ModelRunner

class ModelRunner:
  def __init__(self, model_name):
    self.model_name = model_name
    self.queue = []                                    # ❶

    self.queue_lock = None                             # ❷

    self.model = get_pretrained_model(self.model_name,
                      map_location=device)             # ❸

    self.needs_processing = None                       # ❹

    self.needs_processing_timer = None                 # ❺

❶ 队列

❷ 这将成为我们的锁。

❸ 加载并实例化模型。这是我们将需要更改以切换到 JIT 的(唯一)事情。目前,我们从 p3ch15/cyclegan.py 导入 CycleGAN(稍微修改为标准化为 0..1 的输入和输出)。

❹ 我们运行模型的信号

❺ 最后,定时器

ModelRunner 首先加载我们的模型并处理一些管理事务。除了模型,我们还需要一些其他要素。我们将请求输入到一个queue中。这只是一个 Python 列表,我们在后面添加工作项,然后在前面删除它们。

当我们修改queue时,我们希望防止其他任务在我们下面更改队列。为此,我们引入了一个queue_lock,它将是由asyncio模块提供的asyncio.Lock。由于我们在这里使用的所有asyncio对象都需要知道事件循环,而事件循环只有在我们初始化应用程序后才可用,因此我们在实例化时将其临时设置为None。尽管像这样锁定可能并不是绝对必要的,因为我们的方法在持有锁时不会返回事件循环,并且由于 GIL 的原因,对队列的操作是原子的,但它确实明确地编码了我们的基本假设。如果我们有多个工作进程,我们需要考虑加锁。一个警告:Python 的异步锁不是线程安全的。(叹气。)

ModelRunner 在没有任务时等待。我们需要从RequestProcessor向其发出信号,告诉它停止偷懒,开始工作。这通过名为needs_processingasyncio.Event完成。ModelRunner使用wait()方法等待needs_processing事件。然后,RequestProcessor使用set()来发出信号,ModelRunner会被唤醒并清除事件。

最后,我们需要一个定时器来保证最大等待时间。当我们需要时,通过使用app.loop.call_at来创建此定时器。它设置needs_processing事件;我们现在只是保留一个插槽。因此,实际上,有时事件将直接被设置,因为一个批次已经完成,或者当定时器到期时。当我们在定时器到期之前处理一个批次时,我们将清除它,以便不做太多的工作。

从请求到队列

接下来,我们需要能够将请求加入队列,这是图 15.2 中RequestProcessor的第一部分的核心(不包括解码和重新编码)。我们在我们的第一个async方法process_input中完成这个操作。

列表 15.4 request_batching_server.py:54

async def process_input(self, input):
  our_task = {"done_event": asyncio.Event(loop=app.loop),   # ❶
        "input": input,
        "time": app.loop.time()}
  async with self.queue_lock:                               # ❷
    if len(self.queue) >= MAX_QUEUE_SIZE:
      raise HandlingError("I'm too busy", code=503)
    self.queue.append(our_task)
    self.schedule_processing_if_needed()                    # ❸

  await our_task["done_event"].wait()                       # ❹
  return our_task["output"]

❶ 设置任务数据

❷ 使用锁,我们添加我们的任务和...

❸ ...安排处理。处理将设置needs_processing,如果我们有一个完整的批次。如果我们没有,并且没有设置定时器,它将在最大等待时间到达时设置一个定时器。

❹ 等待(并使用 await 将控制权交还给循环)处理完成。

我们设置一个小的 Python 字典来保存我们任务的信息:当然是input,任务被排队的time,以及在任务被处理后将被设置的done_event。处理会添加一个output

持有队列锁(方便地在async with块中完成),我们将我们的任务添加到队列中,并在需要时安排处理。作为预防措施,如果队列变得太大,我们会报错。然后,我们只需等待我们的任务被处理,并返回它。

注意 使用循环时间(通常是单调时钟)非常重要,这可能与time.time()不同。否则,我们可能会在排队之前为处理安排事件,或者根本不进行处理。

这就是我们处理请求所需的一切(除了解码和编码)。

从队列中运行批处理

接下来,让我们看一下图 15.2 右侧的model_runner函数,它执行模型调用。

列表 15.5 request_batching_server.py:71,.run_model

async def model_runner(self):
  self.queue_lock = asyncio.Lock(loop=app.loop)
  self.needs_processing = asyncio.Event(loop=app.loop)
  while True:
    await self.needs_processing.wait()                 # ❶
    self.needs_processing.clear()
    if self.needs_processing_timer is not None:        # ❷
      self.needs_processing_timer.cancel()
      self.needs_processing_timer = None
    async with self.queue_lock:
      # ... line 87
      to_process = self.queue[:MAX_BATCH_SIZE]         # ❸
      del self.queue[:len(to_process)]
      self.schedule_processing_if_needed()
    batch = torch.stack([t["input"] for t in to_process], dim=0)
    # we could delete inputs here...

    result = await app.loop.run_in_executor(
      None, functools.partial(self.run_model, batch)   # ❹
    )
    for t, r in zip(to_process, result):               # ❺
      t["output"] = r
      t["done_event"].set()
    del to_process

❶ 等待有事情要做

❷ 如果设置了定时器,则取消定时器

❸ 获取一个批次并安排下一个批次的运行(如果需要)

❹ 在单独的线程中运行模型,将数据移动到设备,然后交给模型处理。处理完成后我们继续进行处理。

❺ 将结果添加到工作项中并设置准备事件

如图 15.2 所示,model_runner进行一些设置,然后无限循环(但在之间让出事件循环)。它在应用程序实例化时被调用,因此它可以设置我们之前讨论过的queue_lockneeds_processing事件。然后它进入循环,等待needs_processing事件。

当事件发生时,首先我们检查是否设置了时间,如果设置了,就清除它,因为我们现在要处理事情了。然后model_runner从队列中获取一个批次,如果需要的话,安排下一个批次的处理。它从各个任务中组装批次,并启动一个使用asyncioapp.loop.run_in_executor评估模型的新线程。最后,它将输出添加到任务中并设置done_event

基本上就是这样。Web 框架--大致看起来像是带有asyncawait的 Flask--需要一个小包装器。我们需要在事件循环中启动model_runner函数。正如之前提到的,如果我们没有多个运行程序从队列中取出并可能相互中断,那么锁定队列就不是必要的,但是考虑到我们的代码将被适应到其他项目,我们选择保守一点,以免丢失请求。

我们通过以下方式启动我们的服务器

python3 -m p3ch15.request_batching_server data/p1ch2/horse2zebra_0.4.0.pth

现在我们可以通过上传图像数据/p1ch2/horse.jpg 进行测试并保存结果:

curl -T data/p1ch2/horse.jpg http://localhost:8000/image --output /tmp/res.jpg

请注意,这个服务器确实做了一些正确的事情--它为 GPU 批处理请求并异步运行--但我们仍然使用 Python 模式,因此 GIL 阻碍了我们在主线程中并行运行模型以响应请求。在潜在的敌对环境(如互联网)中,这是不安全的。特别是,请求数据的解码似乎既不是速度最优也不是完全安全的。

一般来说,如果我们可以进行解码,那将会更好,我们将请求流传递给一个函数,同时传递一个预分配的内存块,函数将从流中为我们解码图像。但我们不知道有哪个库是这样做的。

15.2 导出模型

到目前为止,我们已经从 Python 解释器中使用了 PyTorch。但这并不总是理想的:GIL 仍然可能阻塞我们改进的 Web 服务器。或者我们可能希望在 Python 过于昂贵或不可用的嵌入式系统上运行。这就是我们导出模型的时候。我们可以以几种方式进行操作。我们可能完全放弃 PyTorch 转向更专业的框架。或者我们可能留在 PyTorch 生态系统内部并使用 JIT,这是 PyTorch 专用 Python 子集的即时编译器。即使我们在 Python 中运行 JIT 模型,我们可能也追求其中的两个优势:有时 JIT 可以实现巧妙的优化,或者--就像我们的 Web 服务器一样--我们只是想摆脱 GIL,而 JIT 模型可以做到。最后(但我们需要一些时间才能到达那里),我们可能在libtorch下运行我们的模型,这是 PyTorch 提供的 C++ 库,或者使用衍生的 Torch Mobile。

15.2.1 与 ONNX 一起实现跨 PyTorch 的互操作性

有时,我们希望带着手头的模型离开 PyTorch 生态系统--例如,为了在具有专门模型部署流程的嵌入式硬件上运行。为此,Open Neural Network Exchange 提供了一个用于神经网络和机器学习模型的互操作格式(onnx.ai)。一旦导出,模型可以使用任何兼容 ONNX 的运行时执行,例如 ONNX Runtime,⁶前提是我们模型中使用的操作得到 ONNX 标准和目标运行时的支持。例如,在树莓派上比直接运行 PyTorch 要快得多。除了传统硬件外,许多专门的 AI 加速器硬件都支持 ONNX(onnx.ai/supported-tools .html#deployModel)。

从某种意义上说,深度学习模型是一个具有非常特定指令集的程序,由矩阵乘法、卷积、relutanh等粒度操作组成。因此,如果我们可以序列化计算,我们可以在另一个理解其低级操作的运行时中重新执行它。ONNX 是描述这些操作及其参数的格式的标准化。

大多数现代深度学习框架支持将它们的计算序列化为 ONNX,其中一些可以加载 ONNX 文件并执行它(尽管 PyTorch 不支持)。一些低占用量(“边缘”)设备接受 ONNX 文件作为输入,并为特定设备生成低级指令。一些云计算提供商现在可以上传 ONNX 文件并通过 REST 端点查看其暴露。

要将模型导出到 ONNX,我们需要使用虚拟输入运行模型:输入张量的值并不重要;重要的是它们具有正确的形状和类型。通过调用torch.onnx.export函数,PyTorch 将跟踪模型执行的计算,并将其序列化为一个带有提供的名称的 ONNX 文件:

torch.onnx.export(seg_model, dummy_input, "seg_model.onnx")

生成的 ONNX 文件现在可以在运行时运行,编译到边缘设备,或上传到云服务。在安装onnxruntimeonnxruntime-gpu并将batch作为 NumPy 数组获取后,可以从 Python 中使用它。

代码清单 15.6 onnx_example.py

import onnxruntime

sess = onnxruntime.InferenceSession("seg_model.onnx")   # ❶
input_name = sess.get_inputs()[0].name
pred_onnx, = sess.run(None, {input_name: batch})

❶ ONNX 运行时 API 使用会话来定义模型,然后使用一组命名输入调用运行方法。这在处理静态图中定义的计算时是一种典型的设置。

并非所有 TorchScript 运算符都可以表示为标准化的 ONNX 运算符。如果导出与 ONNX 不兼容的操作,当我们尝试使用运行时时,将会出现有关未知aten运算符的错误。

15.2.2 PyTorch 自己的导出:跟踪

当互操作性不是关键,但我们需要摆脱 Python GIL 或以其他方式导出我们的网络时,我们可以使用 PyTorch 自己的表示,称为TorchScript 图。我们将在下一节中看到这是什么,以及生成它的 JIT 如何工作。但现在就让我们试一试。

制作 TorchScript 模型的最简单方法是对其进行跟踪。这看起来与 ONNX 导出完全相同。这并不奇怪,因为在幕后 ONNX 模型也使用了这种方法。在这里,我们只需使用torch.jit.trace函数将虚拟输入馈送到模型中。我们从第十三章导入UNetWrapper,加载训练参数,并将模型置于评估模式。

在我们追踪模型之前,有一个额外的注意事项:任何参数都不应该需要梯度,因为使用torch.no_grad()上下文管理器严格来说是一个运行时开关。即使我们在no_grad内部追踪模型,然后在外部运行,PyTorch 仍会记录梯度。如果我们提前看一眼图 15.4,我们就会明白为什么:在模型被追踪之后,我们要求 PyTorch 执行它。但是在执行记录的操作时,追踪的模型将需要梯度的参数,并且会使所有内容都需要梯度。为了避免这种情况,我们必须在torch.no_grad上下文中运行追踪的模型。为了避免这种情况--根据经验,很容易忘记然后对性能的缺乏感到惊讶--我们循环遍历模型参数并将它们全部设置为不需要梯度。

但我们只需要调用torch.jit.trace

列出 15.7 trace_example.py

import torch
from p2ch13.model_seg import UNetWrapper

seg_dict = torch.load('data-unversioned/part2/models/p2ch13/seg_2019-10-20_15.57.21_none.best.state', map_location='cpu')
seg_model = UNetWrapper(in_channels=8, n_classes=1, depth=4, wf=3, padding=True, batch_norm=True, up_mode='upconv')
seg_model.load_state_dict(seg_dict['model_state'])
seg_model.eval()
for p in seg_model.parameters():                             # ❶
    p.requires_grad_(False)

dummy_input = torch.randn(1, 8, 512, 512)
traced_seg_model = torch.jit.trace(seg_model, dummy_input)   # ❷

❶ 将参数设置为不需要梯度

❷ 追踪

追踪给我们一个警告:

TracerWarning: Converting a tensor to a Python index might cause the trace 
to be incorrect. We can't record the data flow of Python values, so this 
value will be treated as a constant in the future. This means the trace 
might not generalize to other inputs!
  return layer[:, :, diff_y:(diff_y + target_size[0]), diff_x:(diff_x + target_size[1])]

这源自我们在 U-Net 中进行的裁剪,但只要我们计划将大小为 512 × 512 的图像馈送到模型中,我们就没问题。在下一节中,我们将更仔细地看看是什么导致了警告,以及如何避开它突出的限制(如果需要的话)。当我们想要将比卷积网络和 U-Net 更复杂的模型转换为 TorchScript 时,这也将很重要。

我们可以保存追踪的模型

torch.jit.save(traced_seg_model, 'traced_seg_model.pt')

然后加载回来而不需要任何东西,然后我们可以调用它:

loaded_model = torch.jit.load('traced_seg_model.pt')
prediction = loaded_model(batch)

PyTorch JIT 将保留我们保存模型时的状态:我们已经将其置于评估模式,并且我们的参数不需要梯度。如果我们之前没有注意到这一点,我们将需要在执行中使用with torch.no_grad():

提示 您可以运行 JIT 编译并导出的 PyTorch 模型而不保留源代码。但是,我们总是希望建立一个工作流程,自动从源模型转换为已安装的 JIT 模型以进行部署。如果不这样做,我们将发现自己处于这样一种情况:我们想要调整模型的某些内容,但已经失去了修改和重新生成的能力。永远保留源代码,卢克!

15.2.3 带有追踪模型的服务器

现在是时候将我们的网络服务器迭代到这种情况下的最终版本了。我们可以将追踪的 CycleGAN 模型导出如下:

python3 p3ch15/cyclegan.py data/p1ch2/horse2zebra_0.4.0.pth data/p3ch15/traced_zebra_model.pt

现在我们只需要在服务器中用torch.jit.load替换对get_pretrained_model的调用(并删除现在不再需要的import get_pretrained_model)。这也意味着我们的模型独立于 GIL 运行--这正是我们希望我们的服务器在这里实现的。为了您的方便,我们已经将小的修改放在 request_batching_jit_server.py 中。我们可以用追踪的模型文件路径作为命令行参数来运行它。

现在我们已经尝试了 JIT 对我们有什么帮助,让我们深入了解细节吧!

15.3 与 PyTorch JIT 交互

在 PyTorch 1.0 中首次亮相,PyTorch JIT 处于围绕 PyTorch 的许多最新创新的中心,其中之一是提供丰富的部署选项。

15.3.1 超越经典 Python/PyTorch 时可以期待什么

经常有人说 Python 缺乏速度。虽然这有一定道理,但我们在 PyTorch 中使用的张量操作通常本身足够大,以至于它们之间的 Python 速度慢并不是一个大问题。对于像智能手机这样的小设备,Python 带来的内存开销可能更重要。因此,请记住,通常通过将 Python 排除在计算之外来加快速度的提升是 10% 或更少。

另一个不在 Python 中运行模型的即时加速仅在多线程环境中出现,但这时它可能是显著的:因为中间结果不是 Python 对象,计算不受所有 Python 并行化的威胁,即 GIL。这是我们之前考虑到的,并且当我们在服务器上使用跟踪模型时实现了这一点。

从经典的 PyTorch 执行一项操作后再查看下一项的方式转变过来,确实让 PyTorch 能够全面考虑计算:也就是说,它可以将计算作为一个整体来考虑。这为关键的优化和更高级别的转换打开了大门。其中一些主要适用于推断,而其他一些也可以在训练中提供显著的加速。

让我们通过一个快速示例来让你体会一下为什么一次查看多个操作会有益。当 PyTorch 在 GPU 上运行一系列操作时,它为每个操作调用一个子程序(在 CUDA 术语中称为内核)。每个内核从 GPU 内存中读取输入,计算结果,然后存储结果。因此,大部分时间通常不是用于计算,而是用于读取和写入内存。这可以通过仅读取一次,计算多个操作,然后在最后写入来改进。这正是 PyTorch JIT 融合器所做的。为了让你了解这是如何工作的,图 15.3 展示了长短期记忆(LSTM;en.wikipedia.org/wiki/ Long_short-term_memory)单元中进行的逐点计算,这是递归网络的流行构建块。

图 15.3 的细节对我们来说并不重要,但顶部有 5 个输入,底部有 2 个输出,中间有 7 个圆角指数表示的中间结果。通过在一个单独的 CUDA 函数中一次性计算所有这些,并将中间结果保留在寄存器中,JIT 将内存读取次数从 12 降低到 5,写入次数从 9 降低到 2。这就是 JIT 带来的巨大收益;它可以将训练 LSTM 网络的时间缩短四倍。这看似简单的技巧使得 PyTorch 能够显著缩小 LSTM 和在 PyTorch 中灵活定义的通用 LSTM 单元与像 cuDNN 这样提供的高度优化 LSTM 实现之间速度差距。

总之,使用 JIT 来避免 Python 的加速并不像我们可能天真地期望的那样大,因为我们被告知 Python 非常慢,但避免 GIL 对于多线程应用程序来说是一个重大胜利。JIT 模型的大幅加速来自 JIT 可以实现的特殊优化,但这些优化比仅仅避免 Python 开销更为复杂。

图 15.3 LSTM 单元逐点操作。从顶部的五个输入,该块计算出底部的两个输出。中间的方框是中间结果,普通的 PyTorch 会将其存储在内存中,但 JIT 融合器只会保留在寄存器中。

15.3.2 PyTorch 作为接口和后端的双重性质

要理解如何摆脱 Python 的工作原理,有益的是在头脑中将 PyTorch 分为几个部分。我们在第 1.4 节中初步看到了这一点。我们的 PyTorch torch.nn 模块--我们在第六章首次看到它们,自那以后一直是我们建模的主要工具--保存网络的参数,并使用功能接口实现:接受和返回张量的函数。这些被实现为 C++ 扩展,交给了 C++ 级别的自动求导启用层。 (然后将实际计算交给一个名为 ATen 的内部库,执行计算或依赖后端来执行,但这不重要。)

鉴于 C++ 函数已经存在,PyTorch 开发人员将它们制作成了官方 API。这就是 LibTorch 的核心,它允许我们编写几乎与其 Python 对应物相似的 C++ 张量操作。由于torch.nn模块本质上只能在 Python 中使用,C++ API 在一个名为torch::nn的命名空间中镜像它们,设计上看起来很像 Python 部分,但是独立的。

这将使我们能够在 C++ 中重新做我们在 Python 中做的事情。但这不是我们想要的:我们想要导出模型。幸运的是,PyTorch 还提供了另一个接口来访问相同的函数:PyTorch JIT。PyTorch JIT 提供了计算的“符号”表示。这个表示是TorchScript 中间表示(TorchScript IR,有时只是 TorchScript)。我们在第 15.2.2 节讨论延迟计算时提到了 TorchScript。在接下来的章节中,我们将看到如何获取我们 Python 模型的这种表示以及如何保存、加载和执行它们。与我们讨论常规 PyTorch API 时所述类似,PyTorch JIT 函数用于加载、检查和执行 TorchScript 模块也可以从 Python 和 C++ 中访问。

总结一下,我们有四种调用 PyTorch 函数的方式,如图 15.4 所示:从 C++ 和 Python 中,我们可以直接调用函数,也可以让 JIT 充当中介。所有这些最终都会调用 C++ 的 LibTorch 函数,从那里进入 ATen 和计算后端。

图 15.4 调用 PyTorch 的多种方式

15.3.3 TorchScript

TorchScript 是 PyTorch 设想的部署选项的核心。因此,值得仔细研究它的工作原理。

创建 TorchScript 模型有两种简单直接的方式:追踪和脚本化。我们将在接下来的章节中分别介绍它们。在非常高的层面上,这两种方式的工作原理如下:

追踪中,我们在第 15.2.2 节中使用过,使用样本(随机)输入执行我们通常的 PyTorch 模型。PyTorch JIT 对每个函数都有钩子(在 C++ autograd 接口中),允许它记录计算过程。在某种程度上,这就像在说“看我如何计算输出--现在你也可以这样做。”鉴于 JIT 仅在调用 PyTorch 函数(以及nn.Module)时才起作用,你可以在追踪时运行任何 Python 代码,但 JIT 只会注意到那些部分(尤其是对控制流一无所知)。当我们使用张量形状--通常是整数元组--时,JIT 会尝试跟踪发生的情况,但可能不得不放弃。这就是在追踪 U-Net 时给我们警告的原因。

脚本化中,PyTorch JIT 查看我们计算的实际 Python 代码,并将其编译成 TorchScript IR。这意味着,虽然我们可以确保 JIT 捕获了程序的每个方面,但我们受限于编译器理解的部分。这就像在说“我告诉你如何做--现在你也这样做。”听起来真的像编程。

我们不是来讨论理论的,所以让我们尝试使用一个非常简单的函数进行追踪和脚本化,该函数在第一维上进行低效的加法:

# In[2]:
def myfn(x):
    y = x[0]
    for i in range(1, x.size(0)):
        y = y + x[i]
    return y

我们可以追踪它:

# In[3]:
inp = torch.randn(5,5)
traced_fn = torch.jit.trace(myfn, inp)
print(traced_fn.code)

# Out[3]:
def myfn(x: Tensor) -> Tensor:
  y = torch.select(x, 0, 0)                                                # ❶
  y0 = torch.add(y, torch.select(x, 0, 1), alpha=1)                        # ❷
  y1 = torch.add(y0, torch.select(x, 0, 2), alpha=1)
  y2 = torch.add(y1, torch.select(x, 0, 3), alpha=1)
  _0 = torch.add(y2, torch.select(x, 0, 4), alpha=1)
  return _0

TracerWarning: Converting a tensor to a Python index might cause the trace # ❸
to be incorrect. We can't record the data flow of Python values, so this
value will be treated as a constant in the future. This means the
trace might not generalize to other inputs!

❶ 在我们函数的第一行中进行索引

❷ 我们的循环--但完全展开并固定为 1...4,不管 x 的大小如何

❸ 令人害怕,但却如此真实!

我们看到了一个重要的警告--实际上,这段代码已经为五行修复了索引和添加,但对于四行或六行的情况并不会按预期处理。

这就是脚本化的用处所在:

# In[4]:
scripted_fn = torch.jit.script(myfn)
print(scripted_fn.code)

# Out[4]:
def myfn(x: Tensor) -> Tensor:
  y = torch.select(x, 0, 0)
  _0 = torch.__range_length(1, torch.size(x, 0), 1)     # ❶
  y0 = y
  for _1 in range(_0):                                  # ❷
    i = torch.__derive_index(_1, 1, 1)
    y0 = torch.add(y0, torch.select(x, 0, i), alpha=1)  # ❸
  return y0

❶ PyTorch 从张量大小构建范围长度。

❷ 我们的 for 循环--即使我们必须采取看起来有点奇怪的下一行来获取我们的索引 i

❸ 我们的循环体,稍微冗长一点

我们还可以打印脚本化的图,这更接近 TorchScript 的内部表示:

# In[5]:
xprint(scripted_fn.graph)
# end::cell_5_code[]

# tag::cell_5_output[]
# Out[5]:
graph(%x.1 : Tensor):
  %10 : bool = prim::Constant[value=1]()               # ❶
  %2 : int = prim::Constant[value=0]()
  %5 : int = prim::Constant[value=1]()
  %y.1 : Tensor = aten::select(%x.1, %2, %2)           # ❷
  %7 : int = aten::size(%x.1, %2)
  %9 : int = aten::__range_length(%5, %7, %5)          # ❸
  %y : Tensor = prim::Loop(%9, %10, %y.1)              # ❹
    block0(%11 : int, %y.6 : Tensor):
      %i.1 : int = aten::__derive_index(%11, %5, %5)
      %18 : Tensor = aten::select(%x.1, %2, %i.1)      # ❺
      %y.3 : Tensor = aten::add(%y.6, %18, %5)
      -> (%10, %y.3)
  return (%y)

❶ 看起来比我们需要的要冗长得多

❷ y 的第一个赋值

❸ 在看到代码后,我们可以识别出构建范围的方法。

❹ 我们的 for 循环返回它计算的值(y)。

❺ for 循环的主体:选择一个切片,并将其添加到 y 中

在实践中,您最常使用torch.jit.script作为装饰器的形式:

@torch.jit.script
def myfn(x):
  ...

您也可以使用自定义的trace装饰器来处理输入,但这并没有流行起来。

尽管 TorchScript(语言)看起来像 Python 的一个子集,但存在根本性差异。如果我们仔细观察,我们会发现 PyTorch 已经向代码添加了类型规范。这暗示了一个重要的区别:TorchScript 是静态类型的--程序中的每个值(变量)都有且只有一个类型。此外,这些类型限于 TorchScript IR 具有表示的类型。在程序内部,JIT 通常会自动推断类型,但我们需要用它们的类型注释脚本化函数的任何非张量参数。这与 Python 形成鲜明对比,Python 中我们可以将任何内容分配给任何变量。

到目前为止,我们已经追踪函数以获取脚本化函数。但是我们很久以前就从仅在第五章中使用函数转向使用模块了。当然,我们也可以追踪或脚本化模型。然后,这些模型将大致表现得像我们熟悉和喜爱的模块。对于追踪和脚本化,我们分别将Module的实例传递给torch.jit.trace(带有示例输入)或torch.jit.script(不带示例输入)。这将给我们带来我们习惯的forward方法。如果我们想要暴露其他方法(这仅适用于脚本化)以便从外部调用,我们在类定义中用@torch.jit.export装饰它们。

当我们说 JIT 模块的工作方式与 Python 中的工作方式相同时,这包括我们也可以用它们进行训练。另一方面,这意味着我们需要为推断设置它们(例如,使用torch.no_grad()上下文),就像我们传统的模型一样,以使它们做正确的事情。

对于算法相对简单的模型--如 CycleGAN、分类模型和基于 U-Net 的分割--我们可以像之前一样追踪模型。对于更复杂的模型,一个巧妙的特性是我们可以在构建和追踪或脚本化模块时使用来自其他脚本化或追踪代码的脚本化或追踪函数,并且我们可以在调用nn.Models时追踪函数,但是我们需要将所有参数设置为不需要梯度,因为这些参数将成为追踪模型的常数。

由于我们已经看到了追踪,让我们更详细地看一个脚本化的实际示例。

15.3.4 脚本化追踪的间隙

在更复杂的模型中,例如用于检测的 Fast R-CNN 系列或用于自然语言处理的循环网络,像for循环这样的控制流位需要进行脚本化。同样,如果我们需要灵活性,我们会找到追踪器警告的代码片段。

代码清单 15.8 来自 utils/unet.py

class UNetUpBlock(nn.Module):
    ...
    def center_crop(self, layer, target_size):
        _, _, layer_height, layer_width = layer.size()
        diff_y = (layer_height - target_size[0]) // 2
        diff_x = (layer_width - target_size[1]) // 2
        return layer[:, :, diff_y:(diff_y + target_size[0]), diff_x:(diff_x + target_size[1])]                            # ❶

    def forward(self, x, bridge):
        ...
        crop1 = self.center_crop(bridge, up.shape[2:])
 ...

❶ 追踪器在这里发出警告。

发生的情况是,JIT 神奇地用包含相同信息的 1D 整数张量替换了形状元组up.shape。现在切片[2:]和计算diff_xdiff_y都是可追踪的张量操作。然而,这并不能拯救我们,因为切片然后需要 Python int;在那里,JIT 的作用范围结束,给我们警告。

但是我们可以通过一种简单直接的方式解决这个问题:我们对center_crop进行脚本化。我们通过将up传递给脚本化的center_crop并在那里提取大小来略微更改调用者和被调用者之间的切割。除此之外,我们所需的只是添加@torch.jit.script装饰器。结果是以下代码,使 U-Net 模型可以无警告地进行追踪。

代码清单 15.9 从 utils/unet.py 重写的节选

@torch.jit.script
def center_crop(layer, target):                         # ❶
    _, _, layer_height, layer_width = layer.size()
    _, _, target_height, target_width = target.size()   # ❷
    diff_y = (layer_height - target_height) // 2
    diff_x = (layer_width - target_width]) // 2
    return layer[:, :, diff_y:(diff_y + target_height),  diff_x:(diff_x + target_width)]                     # ❸

class UNetUpBlock(nn.Module):
    ...

    def forward(self, x, bridge):
        ...
        crop1 = center_crop(bridge, up)                 # ❹
  ...

❶ 更改签名,接受目标而不是目标大小

❷ 在脚本化部分内获取大小

❸ 索引使用我们得到的大小值。

❹ 我们调整我们的调用以传递上而不是大小。

我们可以选择的另一个选项--但我们这里不会使用--是将不可脚本化的内容移入在 C++ 中实现的自定义运算符中。TorchVision 库为 Mask R-CNN 模型中的一些特殊操作执行此操作。

15.4 LibTorch:在 C++ 中使用 PyTorch

我们已经看到了各种导出模型的方式,但到目前为止,我们使用了 Python。现在我们将看看如何放弃 Python 直接使用 C++。

让我们回到从马到斑马的 CycleGAN 示例。我们现在将从第 15.2.3 节中获取 JITed 模型,并在 C++ 程序中运行它。

15.4.1 从 C++ 运行 JITed 模型

在 C++ 中部署 PyTorch 视觉模型最困难的部分是选择一个图像库来选择数据。⁸ 在这里,我们选择了非常轻量级的库 CImg (cimg.eu)。如果你非常熟悉 OpenCV,你可以调整代码以使用它;我们只是觉得 CImg 对我们的阐述最容易。

运行 JITed 模型非常简单。我们首先展示图像处理;这并不是我们真正想要的,所以我们会很快地完成这部分。⁹

代码清单 15.10 cyclegan_jit.cpp

#include "torch/script.h"                                       # ❶
#define cimg_use_jpeg
#include "CImg.h"
using namespace cimg_library;
int main(int argc, char **argv) {
  CImg<float> image(argv[2]);                                   # ❷
  image = image.resize(227, 227);                               # ❸
  // ...here we need to produce an output tensor from input
  CImg<float> out_img(output.data_ptr<float>(), output.size(2), # ❹
                      output.size(3), 1, output.size(1));
  out_img.save(argv[3]);                                        # ❺
  return 0;
}

❶ 包括 PyTorch 脚本头文件和具有本地 JPEG 支持的 CImg

❷ 将图像加载并解码为浮点数组

❸ 调整为较小的尺寸

❹ 方法 data_ptr() 给我们一个指向张量存储的指针。有了它和形状信息,我们可以构建输出图像。

❺ 保存图像

对于 PyTorch 部分,我们包含了一个 C++ 头文件 torch/script.h。然后我们需要设置并包含 CImg 库。在 main 函数中,我们从命令行中加载一个文件中的图像并调整大小(在 CImg 中)。所以现在我们有一个 CImg<float> 变量 image 中的 227 × 227 图像。在程序的末尾,我们将从我们的形状为 (1, 3, 277, 277) 的张量创建一个相同类型的 out_img 并保存它。

不要担心这些细节。它们不是我们想要学习的 PyTorch C++,所以我们可以直接接受它们。

实际的计算也很简单。我们需要从图像创建一个输入张量,加载我们的模型,并将输入张量通过它运行。

代码清单 15.11 cyclegan_jit.cpp

auto input_ = torch::tensor(
    torch::ArrayRef<float>(image.data(), image.size()));  # ❶
  auto input = input_.reshape({1, 3, image.height(),
                   image.width()}).div_(255);             # ❷

  auto module = torch::jit::load(argv[1]);                # ❸

  std::vector<torch::jit::IValue> inputs;                 # ❹
  inputs.push_back(input);
  auto output_ = module.forward(inputs).toTensor();       # ❺

  auto output = output_.contiguous().mul_(255);           # ❻

❶ 将图像数据放入张量中

❷ 重新调整和重新缩放以从 CImg 约定转换为 PyTorch 的

❸ 从文件加载 JITed 模型或函数

❹ 将输入打包成一个(单元素)IValues 向量

❺ 调用模块并提取结果张量。为了效率,所有权被移动,所以如果我们保留了 IValue,之后它将为空。

❻ 确保我们的结果是连续的

从第三章中回想起,PyTorch 将张量的值保存在特定顺序的大块内存中。CImg 也是如此,我们可以使用 image.data() 获取指向此内存块的指针(作为 float 数组),并使用 image.size() 获取元素的数量。有了这两个,我们可以创建一个稍微更智能的引用:一个 torch::ArrayRef(这只是指针加大小的简写;PyTorch 在 C++ 级别用于数据但也用于返回大小而不复制)。然后我们可以将其解析到 torch::tensor 构造函数中,就像我们对列表做的那样。

提示 有时候你可能想要使用类似工作的 torch::from_blob 而不是 torch::tensor。区别在于 tensor 会复制数据。如果你不想复制,可以使用 from_blob,但是你需要确保在张量的生命周期内底层内存是可用的。

我们的张量只有 1D,所以我们需要重新调整它。方便的是,CImg 使用与 PyTorch 相同的顺序(通道、行、列)。如果不是这样,我们需要调整重新调整并排列轴,就像我们在第四章中所做的那样。由于 CImg 使用 0...255 的范围,而我们使我们的模型使用 0...1,所以我们在这里除以后面再乘以。当然,这可以被吸收到模型中,但我们想重用我们的跟踪模型。

避免的一个常见陷阱:预处理和后处理

当从一个库切换到另一个库时,很容易忘记检查转换步骤是否兼容。除非我们查看 PyTorch 和我们使用的图像处理库的内存布局和缩放约定,否则它们是不明显的。如果我们忘记了,我们将因为没有得到预期的结果而感到失望。

在这里,模型会变得疯狂,因为它接收到非常大的输入。然而,最终,我们模型的输出约定是在 0 到 1 的范围内给出 RGB 值。如果我们直接将其与 CImg 一起使用,结果看起来会全是黑色。

其他框架有其他约定:例如 OpenCV 喜欢将图像存储为 BGR 而不是 RGB,需要我们翻转通道维度。我们始终要确保在部署中向模型提供的输入与我们在 Python 中输入的相同。

使用 torch::jit::load 加载跟踪模型非常简单。接下来,我们必须处理 PyTorch 引入的一个在 Python 和 C++ 之间桥接的抽象:我们需要将我们的输入包装在一个 IValue(或多个 IValue)中,这是任何值的通用数据类型。 JIT 中的一个函数接收一个 IValue 向量,所以我们声明这个向量,然后 push_back 我们的输入张量。这将自动将我们的张量包装成一个 IValue。我们将这个 IValue 向量传递给前向并得到一个返回的单个 IValue。然后我们可以使用 .toTensor 解包结果 IValue 中的张量。

这里我们了解一下 IValue:它们有一个类型(这里是 Tensor),但它们也可以持有 int64_tdouble 或一组张量。例如,如果我们有多个输出,我们将得到一个持有张量列表的 IValue,这最终源自于 Python 的调用约定。当我们使用 .toTensorIValue 中解包张量时,IValue 将转移所有权(变为无效)。但让我们不要担心这个;我们得到了一个张量。因为有时模型可能返回非连续数据(从第三章的存储中存在间隙),但 CImg 合理地要求我们提供一个连续的块,我们调用 contiguous。重要的是,我们将这个连续的张量分配给一个在使用底层内存时处于作用域内的变量。就像在 Python 中一样,如果 PyTorch 发现没有张量在使用内存,它将释放内存。

所以让我们编译这个!在 Debian 或 Ubuntu 上,你需要安装 cimg-devlibjpeg-devlibx11-dev 来使用 CImg

你可以从 PyTorch 页面下载一个 PyTorch 的 C++ 库。但考虑到我们已经安装了 PyTorch,¹⁰我们可能会选择使用它;它已经包含了我们在 C++ 中所需的一切。我们需要知道我们的 PyTorch 安装位置在哪里,所以打开 Python 并检查 torch.__file__,它可能会显示 /usr/local/lib/python3.7/dist-packages/ torch/init.py。这意味着我们需要的 CMake 文件在 /usr/local/lib/python3.7/dist-packages/torch/share/cmake/ 中。

尽管对于一个单个源文件项目来说使用 CMake 似乎有点大材小用,但链接到 PyTorch 有点复杂;因此我们只需使用以下内容作为一个样板 CMake 文件。¹¹

列表 15.12 CMakeLists.txt

cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
project(cyclegan-jit)                                         # ❶

find_package(Torch REQUIRED)                                  # ❷
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${TORCH_CXX_FLAGS}")

add_executable(cyclegan-jit cyclegan_jit.cpp)                 # ❸
target_link_libraries(cyclegan-jit pthread jpeg X11)          # ❹
target_link_libraries(cyclegan-jit "${TORCH_LIBRARIES}")
set_property(TARGET cyclegan-jit PROPERTY CXX_STANDARD 14)

❶ 项目名称。用你自己的项目名称替换这里和其他行。

❷ 我们需要 Torch。

❸ 我们想要从 cyclegan_jit.cpp 源文件编译一个名为 cyclegan-jit 的可执行文件。

❹ 链接到 CImg 所需的部分。CImg 本身是全包含的,所以这里不会出现。

最好在源代码所在的子目录中创建一个构建目录,然后在其中运行 CMake,如¹² CMAKE_PREFIX_PATH=/usr/local/lib/python3.7/ dist-packages/torch/share/cmake/ cmake ..,最后 make。这将构建 cyclegan-jit 程序,然后我们可以运行如下:

./cyclegan-jit ../traced_zebra_model.pt  ../../data/p1ch2/horse.jpg /tmp/z.jpg

我们刚刚在没有 Python 的情况下运行了我们的 PyTorch 模型。太棒了!如果你想发布你的应用程序,你可能想将 /usr/local/lib/python3.7/dist-packages/torch/lib 中的库复制到可执行文件所在的位置,这样它们就会始终被找到。

15.4.2 从头开始的 C++:C++ API

C++ 模块化 API 旨在感觉很像 Python 的 API。为了体验一下,我们将把 CycleGAN 生成器翻译成在 C++ 中本地定义的模型,但没有 JIT。但是,我们需要预训练的权重,因此我们将保存模型的跟踪版本(在这里重要的是跟踪模型而不是函数)。

我们将从一些行政细节开始:包括和命名空间。

列表 15.13 cyclegan_cpp_api.cpp

#include <torch/torch.h>   # ❶
#define cimg_use_jpeg
#include <CImg.h>
using torch::Tensor;       # ❷

❶ 导入一站式 torch/torch.h 头文件和 CImg

❷ 拼写torch::Tensor可能很繁琐,因此我们将名称导入主命名空间。

当我们查看文件中的源代码时,我们发现ConvTransposed2d是临时定义的,理想情况下应该从标准库中获取。问题在于 C++ 模块化 API 仍在开发中;并且在 PyTorch 1.4 中,预制的ConvTranspose2d模块无法在Sequential中使用,因为它需要一个可选的第二个参数。通常我们可以像我们为 Python 所做的那样留下Sequential,但我们希望我们的模型具有与第二章 Python CycleGAN 生成器相同的结构。

接下来,让我们看看残差块。

列表 15.14 cyclegan_cpp_api.cpp 中的残差块

struct ResNetBlock : torch::nn::Module {
  torch::nn::Sequential conv_block;
  ResNetBlock(int64_t dim)
      : conv_block(                                   # ❶
           torch::nn::ReflectionPad2d(1),
           torch::nn::Conv2d(torch::nn::Conv2dOptions(dim, dim, 3)),
           torch::nn::InstanceNorm2d(
           torch::nn::InstanceNorm2dOptions(dim)),
           torch::nn::ReLU(/*inplace=*/true),
        torch::nn::ReflectionPad2d(1),
           torch::nn::Conv2d(torch::nn::Conv2dOptions(dim, dim, 3)),
           torch::nn::InstanceNorm2d(
           torch::nn::InstanceNorm2dOptions(dim))) {
    register_module("conv_block", conv_block);        # ❷
  }

  Tensor forward(const Tensor &inp) {
    return inp + conv_block->forward(inp);            # ❸
  }
};.

❶ 初始化 Sequential,包括其子模块

❷ 始终记得注册您分配的模块,否则会发生糟糕的事情!

❸ 正如我们所预期的那样,我们的前向函数非常简单。

就像我们在 Python 中所做的那样,我们注册torch::nn::Module的子类。我们的残差块有一个顺序的conv_block子模块。

就像我们在 Python 中所做的那样,我们需要初始化我们的子模块,特别是Sequential。我们使用 C++ 初始化语句来做到这一点。这类似于我们在 Python 中在__init__构造函数中构造子模块的方式。与 Python 不同,C++ 没有启发式和挂钩功能,使得将__setattr__重定向以结合对成员的赋值和注册成为可能。

由于缺乏关键字参数使得带有默认参数的参数规范变得笨拙,模块(如张量工厂函数)通常需要一个options参数。Python 中的可选关键字参数对应于我们可以链接的选项对象的方法。例如,我们需要转换的 Python 模块nn.Conv2d(in_channels, out_channels, kernel_size, stride=2, padding=1)对应于torch::nn::Conv2d(torch::nn::Conv2dOptions (in_channels, out_channels, kernel_size).stride(2).padding(1))。这有点繁琐,但您正在阅读这篇文章是因为您热爱 C++,并且不会被它让您跳过的环节吓倒。

我们应始终确保注册和分配给成员的同步,否则事情将不会按预期进行:例如,在训练期间加载和更新参数将发生在注册的模块上,但实际被调用的模块是一个成员。这种同步在 Python 的 nn.Module 类后台完成,但在 C++ 中不是自动的。未能这样做将给我们带来许多头痛。

与我们在 Python 中所做的(应该!)相反,我们需要为我们的模块调用m->forward(...)。一些模块也可以直接调用,但对于Sequential,目前不是这种情况。

最后关于调用约定的评论是:根据您是否修改传递给函数的张量,张量参数应始终作为const Tensor&传递,对于不会更改的张量,或者如果它们被更改,则传递Tensor。应返回张量作为Tensor。错误的参数类型,如非 const 引用(Tensor&),将导致无法解析的编译器错误。

在主生成器类中,我们将更加密切地遵循 C++ API 中的典型模式,通过将我们的类命名为 ResNetGeneratorImpl 并使用 TORCH_MODULE 宏将其提升为 torch 模块 ResNetGenerator。背景是我们希望大部分处理模块作为引用或共享指针。包装类实现了这一点。

列表 15.15 cyclegan_cpp_api.cpp 中的 ResNetGenerator

struct ResNetGeneratorImpl : torch::nn::Module {
  torch::nn::Sequential model;
  ResNetGeneratorImpl(int64_t input_nc = 3, int64_t output_nc = 3,
                      int64_t ngf = 64, int64_t n_blocks = 9) {
    TORCH_CHECK(n_blocks >= 0);
    model->push_back(torch::nn::ReflectionPad2d(3));    # ❶
    ...                                                 # ❷
      model->push_back(torch::nn::Conv2d(
          torch::nn::Conv2dOptions(ngf * mult, ngf * mult * 2, 3)
              .stride(2)
              .padding(1)));                            # ❸
    ...
    register_module("model", model);
  }
  Tensor forward(const Tensor &inp) { return model->forward(inp); }
};

TORCH_MODULE(ResNetGenerator);                          # ❹

❶ 在构造函数中向 Sequential 容器添加模块。这使我们能够在 for 循环中添加可变数量的模块。

❷ 使我们免于重复一些繁琐的事情

❸ Options 的一个示例

❹ 在我们的 ResNetGeneratorImpl 类周围创建一个包装器 ResNetGenerator。尽管看起来有些过时,但匹配的名称在这里很重要。

就是这样--我们定义了 Python ResNetGenerator 模型的完美 C++ 对应物。现在我们只需要一个 main 函数来加载参数并运行我们的模型。加载图像使用 CImg 并将图像转换为张量,再将张量转换回图像与上一节中相同。为了增加一些变化,我们将显示图像而不是将其写入磁盘。

列表 15.16 cyclegan_cpp_api.cpp main

ResNetGenerator model;                                                    # ❶
  ...
  torch::load(model, argv[1]);                                            # ❷
  ...
  cimg_library::CImg<float> image(argv[2]);
  image.resize(400, 400);
  auto input_ =
      torch::tensor(torch::ArrayRef<float>(image.data(), image.size()));
  auto input = input_.reshape({1, 3, image.height(), image.width()});
  torch::NoGradGuard no_grad;                                             # ❸

  model->eval();                                                          # ❹

  auto output = model->forward(input);                                    # ❺
  ...
  cimg_library::CImg<float> out_img(output.data_ptr<float>(),
                    output.size(3), output.size(2),
                    1, output.size(1));
  cimg_library::CImgDisplay disp(out_img, "See a C++ API zebra!");        # ❻
  while (!disp.is_closed()) {
    disp.wait();
  }

❶ 实例化我们的模型

❷ 加载参数

❸ 声明一个守卫变量相当于 torch.no_grad() 上下文。如果需要限制关闭梯度的时间,可以将其放在 { ... } 块中。

❹ 就像在 Python 中一样,打开 eval 模式(对于我们的模型来说可能并不严格相关)。

❺ 再次调用 forward 而不是 model。

❻ 显示图像时,我们需要等待按键而不是立即退出程序。

有趣的变化在于我们如何创建和运行模型。正如预期的那样,我们通过声明模型类型的变量来实例化模型。我们使用 torch::load 加载模型(这里重要的是我们包装了模型)。虽然这看起来对于 PyTorch 从业者来说非常熟悉,但请注意它将在 JIT 保存的文件上工作,而不是 Python 序列化的状态字典。

运行模型时,我们需要相当于 with torch.no_grad(): 的功能。这是通过实例化一个类型为 NoGradGuard 的变量并在我们不希望梯度时保持其范围来实现的。就像在 Python 中一样,我们调用 model->eval() 将模型设置为评估模式。这一次,我们调用 model->forward 传入我们的输入张量并得到一个张量作为结果--不涉及 JIT,因此我们不需要 IValue 的打包和解包。

哎呀。对于我们这些 Python 粉丝来说,在 C++ 中编写这个是很费力的。我们很高兴我们只承诺在这里进行推理,但当然 LibTorch 也提供了优化器、数据加载器等等。使用 API 的主要原因当然是当你想要创建模型而 JIT 和 Python 都不合适时。

为了您的方便,CMakeLists.txt 中还包含了构建 cyclegan-cpp-api 的说明,因此构建就像在上一节中一样简单。

我们可以运行程序如下

./cyclegan_cpp_api ../traced_zebra_model.pt ../../data/p1ch2/horse.jpg

但我们知道模型会做什么,不是吗?

15.5 走向移动

作为部署模型的最后一个变体,我们将考虑部署到移动设备。当我们想要将我们的模型带到移动设备时,通常会考虑 Android 和/或 iOS。在这里,我们将专注于 Android。

PyTorch 的 C++ 部分--LibTorch--可以编译为 Android,并且我们可以通过使用 Android Java Native Interface (JNI) 编写的应用程序从 Java 中访问它。但实际上我们只需要从 PyTorch 中使用少量函数--加载 JIT 模型,将输入转换为张量和 IValue,通过模型运行它们,并将结果返回。为了避免使用 JNI 的麻烦,PyTorch 开发人员将这些函数封装到一个名为 PyTorch Mobile 的小型库中。

在 Android 中开发应用程序的标准方式是使用 Android Studio IDE,我们也将使用它。但这意味着有几十个管理文件--这些文件也会随着 Android 版本的更改而改变。因此,我们专注于将 Android Studio 模板(具有空活动的 Java 应用程序)转换为一个拍照、通过我们的斑马 CycleGAN 运行图片并显示结果的应用程序的部分。遵循本书的主题,我们将在示例应用程序中高效处理 Android 部分(与编写 PyTorch 代码相比可能会更痛苦)。

要使模板生动起来,我们需要做三件事。首先,我们需要定义一个用户界面。为了尽可能简单,我们有两个元素:一个名为headlineTextView,我们可以点击以拍摄和转换图片;以及一个用于显示我们图片的ImageView,我们称之为image_view。我们将把拍照留给相机应用程序(在应用程序中可能会避免这样做以获得更流畅的用户体验),因为直接处理相机会模糊我们专注于部署 PyTorch 模型的焦点。

然后,我们需要将 PyTorch 作为依赖项包含进来。这是通过编辑我们应用程序的 build.gradle 文件并添加pytorch_androidpytorch_android_torchvision来完成的。

15.17 build.gradle 的添加部分

dependencies {                                                     # ❶
  ...
  implementation 'org.pytorch:pytorch_android:1.4.0'               # ❷

  implementation 'org.pytorch:pytorch_android_torchvision:1.4.0'   # ❸
}

❶ 依赖部分很可能已经存在。如果没有,请在底部添加。

❷ pytorch_android 库获取了文本中提到的核心内容。

❸ 辅助库 pytorch_android_torchvision--与其更大的 TorchVision 兄弟相比可能有点自负地命名--包含一些将位图对象转换为张量的实用程序,但在撰写本文时没有更多内容。

我们需要将我们的跟踪模型添加为资产。

最后,我们可以进入我们闪亮应用的核心部分:从活动派生的 Java 类,其中包含我们的主要代码。我们这里只讨论一个摘录。它以导入和模型设置开始。

15.18 MainActivity.java 第 1 部分

...
import org.pytorch.IValue;                                                 # ❶
import org.pytorch.Module;
import org.pytorch.Tensor;
import org.pytorch.torchvision.TensorImageUtils;
...
public class MainActivity extends AppCompatActivity {
  private org.pytorch.Module model;                                        # ❷

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    ...
    try {                                                                  # ❸
      model = Module.load(assetFilePath(this, "traced_zebra_model.pt"));   # ❹
    } catch (IOException e) {
      Log.e("Zebraify", "Error reading assets", e);
      finish();
    }
    ...
  }
  ...
}

❶ 你喜欢导入吗?

❷ 包含我们的 JIT 模型

❸ 在 Java 中我们必须捕获异常。

❹ 从文件加载模块

我们需要从org.pytorch命名空间导入一些内容。在 Java 的典型风格中,我们导入IValueModuleTensor,它们的功能符合我们的预期;以及org.pytorch.torchvision.TensorImageUtils类,其中包含在张量和图像之间转换的实用函数。

首先,当然,我们需要声明一个变量来保存我们的模型。然后,在我们的应用启动时--在我们的活动的onCreate中--我们将使用Model.load方法从给定的位置加载模块。然而,有一个小复杂之处:应用程序的数据是由供应商提供的资产,这些资产不容易从文件系统中访问。因此,一个名为assetFilePath的实用方法(取自 PyTorch Android 示例)将资产复制到文件系统中的一个位置。最后,在 Java 中,我们需要捕获代码抛出的异常,除非我们想要(并且能够)依次声明我们编写的方法抛出异常。

当我们使用 Android 的Intent机制从相机应用程序获取图像时,我们需要运行它通过我们的模型并显示它。这发生在onActivityResult事件处理程序中。

15.19 MainActivity.java,第 2 部分

@Override
protected void onActivityResult(int requestCode, int resultCode,
                                Intent data) {
  if (requestCode == REQUEST_IMAGE_CAPTURE &&
      resultCode == RESULT_OK) {                                          # ❶
    Bitmap bitmap = (Bitmap) data.getExtras().get("data");

    final float[] means = {0.0f, 0.0f, 0.0f};                             # ❷
    final float[] stds = {1.0f, 1.0f, 1.0f};

    final Tensor inputTensor = TensorImageUtils.bitmapToFloat32Tensor(    # ❸
        bitmap, means, stds);

    final Tensor outputTensor = model.forward(                            # ❹
        IValue.from(inputTensor)).toTensor();
    Bitmap output_bitmap = tensorToBitmap(outputTensor, means, stds,
        Bitmap.Config.RGB_565);                                           # ❺
    image_view.setImageBitmap(output_bitmap);
  }
}

❶ 当相机应用程序拍照时执行此操作。

❷ 执行归一化,但默认情况下图像范围为 0...1,因此我们不需要转换:即具有 0 偏移和 1 的缩放除数。

❸ 从位图获取张量,结合 TorchVision 的 ToTensor 步骤(将其转换为介于 0 和 1 之间的浮点张量)和 Normalize

❹ 这看起来几乎和我们在 C++中做的一样。

❺ tensorToBitmap 是我们自己的创造。

将从 Android 获取的位图转换为张量由TensorImageUtils.bitmapToFloat32Tensor函数(静态方法)处理,该函数除了bitmap之外还需要两个浮点数组meansstds。在这里,我们指定输入数据(集)的均值和标准差,然后将其映射为具有零均值和单位标准差的数据,就像 TorchVision 的Normalize变换一样。Android 已经将图像给我们提供在 0..1 范围内,我们需要将其馈送到我们的模型中,因此我们指定均值为 0,标准差为 1,以防止归一化改变我们的图像。

在实际调用model.forward时,我们执行与在 C++中使用 JIT 时相同的IValue包装和解包操作,只是我们的forward接受一个IValue而不是一个向量。最后,我们需要回到位图。在这里,PyTorch 不会帮助我们,因此我们需要定义自己的tensorToBitmap(并向 PyTorch 提交拉取请求)。我们在这里不详细介绍,因为这些细节很繁琐且充满复制(从张量到float[]数组到包含 ARGB 值的int[]数组到位图),但事实就是如此。它被设计为bitmapToFloat32Tensor的逆过程。

图 15.5 我们的 CycleGAN 斑马应用

这就是我们需要做的一切,就可以将 PyTorch 引入 Android。使用我们在这里留下的最小代码补充来请求一张图片,我们就有了一个看起来像图 15.5 中所见的Zebraify Android 应用程序。干得好!¹⁶

我们应该注意到,我们在 Android 上使用了 PyTorch 的完整版本,其中包含所有操作。一般来说,这也会包括您在特定任务中不需要的操作,这就引出了一个问题,即我们是否可以通过将它们排除在外来节省一些空间。事实证明,从 PyTorch 1.4 开始,您可以构建一个定制版本的 PyTorch 库,其中只包括您需要的操作(参见pytorch.org/mobile/android/#custom-build)。

15.5.1 提高效率:模型设计和量化

如果我们想更详细地探索移动端,我们的下一步是尝试使我们的模型更快。当我们希望减少模型的内存和计算占用空间时,首先要看的是简化模型本身:也就是说,使用更少的参数和操作计算相同或非常相似的输入到输出的映射。这通常被称为蒸馏。蒸馏的细节各不相同--有时我们尝试通过消除小或无关的权重来缩小每个权重;在其他示例中,我们将网络的几层合并为一层(DistilBERT),甚至训练一个完全不同、更简单的模型来复制较大模型的输出(OpenNMT 的原始 CTranslate)。我们提到这一点是因为这些修改很可能是使模型运行更快的第一步。

另一种方法是减少每个参数和操作的占用空间:我们将模型转换为使用整数(典型选择是 8 位)而不是以浮点数的形式花费通常的 32 位每个参数。这就是量化。¹⁸

PyTorch 确实为此目的提供了量化张量。它们被公开为一组类似于torch.floattorch.doubletorch.long的标量类型(请参阅第 3.5 节)。最常见的量化张量标量类型是torch.quint8torch.qint8,分别表示无符号和有符号的 8 位整数。PyTorch 在这里使用单独的标量类型,以便使用我们在第 3.11 节简要介绍的分派机制。

使用 8 位整数而不是 32 位浮点数似乎能够正常工作可能会让人感到惊讶;通常结果会有轻微的降级,但不会太多。有两个因素似乎起到作用:如果我们将舍入误差视为基本上是随机的,并且将卷积和线性层视为加权平均,我们可能期望舍入误差通常会抵消。¹⁹ 这允许将相对精度从 32 位浮点数的 20 多位减少到有符号整数提供的 7 位。量化的另一件事(与使用 16 位浮点数进行训练相反)是从浮点数转换为固定精度(每个张量或通道)。这意味着最大值被解析为 7 位精度,而是最大值的八分之一的值仅为 7 - 3 = 4 位。但如果像 L1 正则化(在第八章中简要提到)这样的事情起作用,我们可能希望类似的效果使我们在量化时能够为权重中的较小值提供更少的精度。在许多情况下,确实如此。

量化功能于 PyTorch 1.3 首次亮相,但在 PyTorch 1.4 中在支持的操作方面仍有些粗糙。不过,它正在迅速成熟,我们建议如果您真的关心计算效率的部署,不妨试试看。

15.6 新兴技术:企业 PyTorch 模型服务

我们可能会问自己,迄今为止讨论的所有部署方面是否都需要像它们现在这样涉及大量编码。当然,有人编写所有这些代码是很常见的。截至 2020 年初,当我们忙于为这本书做最后的润色时,我们对不久的将来寄予厚望;但与此同时,我们感觉到部署领域将在夏季发生重大变化。

目前,RedisAI(github.com/RedisAI/redisai-py)中的一位作者正在等待将 Redis 的优势应用到我们的模型中。PyTorch 刚刚实验性发布了 TorchServe(在这本书完成后,请查看pytorch.org/ blog/pytorch-library-updates-new-model-serving-library/#torchserve-experimental)。

同样,MLflow(mlflow.org)正在不断扩展更多支持,而 Cortex(cortex.dev)希望我们使用它来部署模型。对于更具体的信息检索任务,还有 EuclidesDB(euclidesdb.readthedocs.io/ en/latest)来执行基于 AI 的特征数据库。

令人兴奋的时刻,但不幸的是,它们与我们的写作计划不同步。我们希望在第二版(或第二本书)中有更多内容可以告诉您!

15.7 结论

这结束了我们如何将我们的模型部署到我们想要应用它们的地方的简短介绍。虽然现成的 Torch 服务在我们撰写本文时还不够完善,但当它到来时,您可能会希望通过 JIT 导出您的模型--所以您会很高兴我们在这里经历了这一过程。与此同时,您现在知道如何将您的模型部署到网络服务、C++ 应用程序或移动设备上。我们期待看到您将会构建什么!

希望我们也实现了这本书的承诺:对深度学习基础知识有所了解,并对 PyTorch 库感到舒适。我们希望您阅读的过程和我们写作的过程一样愉快。²⁰

15.8 练习

当我们结束 使用 PyTorch 进行深度学习 时,我们为您准备了最后一个练习:

  1. 选择一个让您感到兴奋的项目。Kaggle 是一个很好的开始地方。开始吧。

您已经掌握了成功所需的技能并学会了必要的工具。我们迫不及待想知道接下来您会做什么;在书的论坛上给我们留言,让我们知道!

15.9 总结

  • 我们可以通过将 PyTorch 模型包装在 Python Web 服务器框架(如 Flask)中来提供 PyTorch 模型的服务。

  • 通过使用 JIT 模型,我们可以避免即使从 Python 调用它们时也避免 GIL,这对于服务是一个好主意。

  • 请求批处理和异步处理有助于有效利用资源,特别是在 GPU 上进行推理时。

  • 要将模型导出到 PyTorch 之外,ONNX 是一个很好的格式。ONNX Runtime 为许多目的提供后端支持,包括树莓派。

  • JIT 允许您轻松导出和运行任意 PyTorch 代码在 C++中或在移动设备上。

  • 追踪是获得 JIT 模型的最简单方法;对于一些特别动态的部分,您可能需要使用脚本。

  • 对于运行 JIT 和本地模型,C++(以及越来越多的其他语言)也有很好的支持。

  • PyTorch Mobile 让我们可以轻松地将 JIT 模型集成到 Android 或 iOS 应用程序中。

  • 对于移动部署,我们希望简化模型架构并在可能的情况下对模型进行量化。

  • 几个部署框架正在兴起,但标准尚不太明显。


¹ 为了安全起见,请勿在不受信任的网络上执行此操作。

² 或者对于 Python3 使用pip3。您可能还希望从 Python 虚拟环境中运行它。

³ 早期公开讨论 Flask 为 PyTorch 模型提供服务的不足之处之一是 Christian Perone 的“PyTorch under the Hood”,mng.bz/xWdW

⁴ 高级人士将这些异步函数称为生成器,有时更宽松地称为协程 en.wikipedia.org/wiki/Coroutine

⁵ 另一种选择可能是放弃计时器,只有在队列不为空时才运行。这可能会运行较小的“第一”批次,但对于大多数应用程序来说,整体性能影响可能不会太大。

⁶ 代码位于github.com/microsoft/onnxruntime,但请务必阅读隐私声明!目前,自行构建 ONNX Runtime 将为您提供一个不会向母公司发送信息的软件包。

⁷ 严格来说,这将模型追踪为一个函数。最近,PyTorch 获得了使用torch.jit.trace_module保留更多模块结构的能力,但对我们来说,简单的追踪就足够了。

⁸ 但 TorchVision 可能会开发一个方便的函数来加载图像。

⁹ 该代码适用于 PyTorch 1.4 及以上版本。在 PyTorch 1.3 之前的版本中,您需要使用data代替data_ptr

¹⁰ 我们希望您一直在尝试阅读的内容。

¹¹ 代码目录有一个稍长版本,以解决 Windows 问题。

¹² 您可能需要将路径替换为您的 PyTorch 或 LibTorch 安装位置。请注意,与 Python 相比,C++库在兼容性方面可能更挑剔:如果您使用的是支持 CUDA 的库,则需要安装匹配的 CUDA 头文件。如果您收到关于“Caffe2 使用 CUDA”的神秘错误消息,则需要安装一个仅支持 CPU 的库版本,但 CMake 找到了一个支持 CUDA 的库。

¹³ 这是对 PyTorch 1.3 的巨大改进,我们需要为 ReLU、ÌnstanceNorm2d和其他模块实现自定义模块。

¹⁴ 这有点模糊,因为你可以创建一个与输入共享内存并就地修改的新张量,但最好尽量避免这样做。

¹⁵ 我们对这个主题隐喻感到非常自豪。

¹⁶ 撰写时,PyTorch Mobile 仍然相对年轻,您可能会遇到一些问题。在 Pytorch 1.3 上,实际的 32 位 ARM 手机在模拟器中工作时颜色不正确。原因很可能是 ARM 上仅在使用的计算后端函数中存在错误。使用 PyTorch 1.4 和更新的手机(64 位 ARM)似乎效果更好。

¹⁷ 示例包括彩票假设和 WaveRNN。

¹⁸ 与量化相比,(部分)转向 16 位浮点数进行训练通常被称为减少或(如果某些位保持 32 位)混合精度训练。

¹⁹ 时髦的人们可能会在这里提到中心极限定理。确实,我们必须注意保持舍入误差的独立性(在统计意义上)。例如,我们通常希望零(ReLU 的一个显著输出)能够被精确表示。否则,所有的零将会在舍入中被完全相同的数量改变,导致误差累积而不是抵消。

²⁰ 实际上更多;写书真的很难!

posted @ 2025-11-18 09:35  绝不原创的飞龙  阅读(28)  评论(0)    收藏  举报