第二部分:从现实世界的图像中学习:肺癌的早期检测
第 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 成像中无法明显看出,但我们将看看我们能走多远。最后,每个单独的结节分类可以组合成整体患者诊断。
更详细地说,我们将执行以下操作:
-
将我们的原始 CT 扫描数据加载到一个可以与 PyTorch 一起使用的形式中。将原始数据放入 PyTorch 可用的形式将是您面临的任何项目的第一步。对于 2D 图像数据,这个过程稍微复杂一些,对于非图像数据则更简单。
-
使用 PyTorch 识别肺部潜在肿瘤的体素,实现一种称为分割的技术。这大致相当于生成应该输入到我们第 3 步分类器中的区域的热图。这将使我们能够专注于肺部内部的潜在肿瘤,并忽略大片无趣的解剖结构(例如,一个人不能在胃部患肺癌)。
通常,在学习时能够专注于单一小任务是最好的。随着经验的积累,有些情况下更复杂的模型结构可以产生最优结果(例如,我们在第二章看到的 GAN 游戏),但是从头开始设计这些模型需要对基本构建模块有广泛的掌握。先学会走路,再跑步,等等。
-
将有趣的体素分组成块:也就是候选结节(有关结节的更多信息,请参见图 9.5)。在这里,我们将找到热图上每个热点的粗略中心。
每个结节可以通过其中心点的索引、行和列来定位。我们这样做是为了向最终分类器提供一个简单、受限的问题。将体素分组不会直接涉及 PyTorch,这就是为什么我们将其拆分为一个单独的步骤。通常,在处理多步解决方案时,会在项目的较大、由深度学习驱动的部分之间添加非深度学习的连接步骤。
-
使用 3D 卷积将候选结节分类为实际结节或非结节。
这将类似于我们在第八章中介绍的 2D 卷积。确定候选结构中肿瘤性质的特征是与问题中的肿瘤局部相关的,因此这种方法应该在限制输入数据大小和排除相关信息之间提供良好的平衡。做出这种限制范围的决定可以使每个单独的任务受限,这有助于在故障排除时限制要检查的事物数量。
-
使用组合的每个结节分类来诊断患者。
与上一步中的结节分类器类似,我们将尝试仅基于成像数据确定结节是良性还是恶性。我们将简单地取每个肿瘤恶性预测的最大值,因为只需要一个肿瘤是恶性,患者就会患癌症。其他项目可能希望使用不同的方式将每个实例的预测聚合成一个文件分数。在这里,我们问的是,“有什么可疑的吗?”所以最大值是一个很好的聚合方式。如果我们正在寻找定量信息,比如“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 个子集,分别命名为 subset0
到 subset9
。解压缩每个子集,以便您有单独的子目录,如 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_uid
是1.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
这样的第三方代码来解析磁盘上的位是完全可以的。找到了关于你的输入的一切与盲目接受你的数据加载库提供的一切之间的正确平衡可能需要一些经验。只需记住,我们主要关心的是数据,而不是位。重要的是信息,而不是它的表示方式。
能够唯一标识我们数据中的特定样本是很有用的。例如,清楚地传达哪个样本导致问题或得到较差的分类结果可以极大地提高我们隔离和调试问题的能力。根据我们样本的性质,有时这个唯一标识符是一个原子,比如一个数字或一个字符串,有时它更复杂,比如一个元组。
我们使用系列实例 UID(series_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
实例具有两种方法--TransformIndexToPhysicalPoint
和 TransformPhysicalPointToIndex
--可以做到这一点(除了从 CRI [列,行,索引] IRC 进行洗牌)。但是,我们希望能够在不保留 Image
对象的情况下进行此计算,因此我们将在这里手动执行数学运算。
轴翻转(以及可能的旋转或其他变换)被编码在从ct_mhd.GetDirections()
返回的 3 × 3 矩阵中,以元组形式返回。为了从体素索引转换为坐标,我们需要按顺序执行以下四个步骤:
-
将坐标从 IRC 翻转到 CRI,以与 XYZ 对齐。
-
用体素大小来缩放指数。
-
使用 Python 中的
@
矩阵乘以方向矩阵。 -
添加原点的偏移量。
要从 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 在内存中,所以如果我们不注意访问顺序,缓存未命中会频繁发生。
调用getCt
的getCtRawCandidate
函数也具有其输出被缓存,因此在我们的缓存被填充后,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 练习
-
实现一个程序,遍历
LunaDataset
实例,并计算完成此操作所需的时间。为了节省时间,可能有意义的是有一个选项将迭代限制在前N=1000
个样本。-
第一次运行需要多长时间?
-
第二次运行需要多长时间?
-
清除缓存对运行时间有什么影响?
-
使用最后的
N=1000
个样本对第一/第二次运行有什么影响?
-
-
将
LunaDataset
的实现更改为在__init__
期间对样本列表进行随机化。清除缓存,并运行修改后的版本。这对第一次和第二次运行的运行时间有什么影响? -
恢复随机化,并将
@functools.lru_cache(1, typed=True)
装饰器注释掉getCt
。清除缓存,并运行修改后的版本。现在运行时间如何变化?
摘要
-
通常,解析和加载原始数据所需的代码并不简单。对于这个项目,我们实现了一个
Ct
类,它从磁盘加载数据并提供对感兴趣点周围裁剪区域的访问。 -
如果解析和加载例程很昂贵,缓存可能会很有用。请记住,一些缓存可以在内存中完成,而一些最好在磁盘上执行。每种缓存方式都有其在数据加载管道中的位置。
-
PyTorch 的
Dataset
子类用于将数据从其原生形式转换为适合传递给模型的张量。我们可以使用这个功能将我们的真实世界数据与 PyTorch API 集成。 -
Dataset
的子类需要为两个方法提供实现:__len__
和__getitem__
。其他辅助方法是允许的,但不是必需的。 -
将我们的数据分成合理的训练集和验证集需要确保没有样本同时出现在两个集合中。我们通过使用一致的排序顺序,并为验证集取每第十个样本来实现这一点。
-
数据可视化很重要;能够通过视觉调查数据可以提供有关错误或问题的重要线索。我们正在使用 Jupyter Notebooks 和 Matplotlib 来呈现我们的数据。
¹ 对于那些事先准备好所有数据的稀有研究人员:你真幸运!我们其他人将忙于编写加载和解析代码。
² 有例外情况,但现在并不相关。
³ 你在这本书中找到拼写错误了吗?
标签:训练,self,PyTorch,重译,GPT,结节,数据,我们,CT From: https://www.cnblogs.com/apachecn/p/18086323