PyTorch 深度学习(GPT 重译)(四)(3)

简介: PyTorch 深度学习(GPT 重译)(四)

PyTorch 深度学习(GPT 重译)(四)(2)https://developer.aliyun.com/article/1485218

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个样本对第一/第二次运行有什么影响?
  1. LunaDataset的实现更改为在__init__期间对样本列表进行随机化。清除缓存,并运行修改后的版本。这对第一次和第二次运行的运行时间有什么影响?
  2. 恢复随机化,并将@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中收集结果以记录每个时期的进展。这两个张量现在包含了我们计算每个训练和验证运行的每类百分比正确和平均损失所需的一切。每个时期执行此操作是一个常见选择,尽管有些是任意的。在未来的章节中,我们将看到如何调整我们的时期大小,以便以合理的速率获得有关训练进度的反馈。

PyTorch 深度学习(GPT 重译)(四)(4)https://developer.aliyun.com/article/1485220

相关文章
|
PyTorch 算法框架/工具 Android开发
PyTorch 深度学习(GPT 重译)(六)(4)
PyTorch 深度学习(GPT 重译)(六)
38 2
|
13天前
|
存储 PyTorch 算法框架/工具
PyTorch 深度学习(GPT 重译)(一)(3)
PyTorch 深度学习(GPT 重译)(一)
32 2
|
13天前
|
机器学习/深度学习 PyTorch 算法框架/工具
PyTorch 深度学习(GPT 重译)(六)(2)
PyTorch 深度学习(GPT 重译)(六)
40 1
|
13天前
|
机器学习/深度学习 算法 PyTorch
PyTorch 深度学习(GPT 重译)(二)(4)
PyTorch 深度学习(GPT 重译)(二)
28 1
|
机器学习/深度学习 PyTorch 算法框架/工具
PyTorch 深度学习(GPT 重译)(六)(3)
PyTorch 深度学习(GPT 重译)(六)
28 2
|
13天前
|
存储 PyTorch 算法框架/工具
PyTorch 深度学习(GPT 重译)(一)(4)
PyTorch 深度学习(GPT 重译)(一)
36 3
|
13天前
|
机器学习/深度学习 PyTorch 算法框架/工具
PyTorch 深度学习(GPT 重译)(二)(1)
PyTorch 深度学习(GPT 重译)(二)
29 2
|
13天前
|
机器学习/深度学习 PyTorch 算法框架/工具
PyTorch 深度学习(GPT 重译)(六)(1)
PyTorch 深度学习(GPT 重译)(六)
36 1
|
13天前
|
机器学习/深度学习 编解码 PyTorch
PyTorch 深度学习(GPT 重译)(五)(2)
PyTorch 深度学习(GPT 重译)(五)
52 2
PyTorch 深度学习(GPT 重译)(五)(2)
|
13天前
|
机器学习/深度学习 PyTorch 测试技术
PyTorch 深度学习(GPT 重译)(四)(1)
PyTorch 深度学习(GPT 重译)(四)
29 2