PyTorch 深度学习——训练一个用于检测可疑肿瘤的分类模型

0 阅读59分钟

本章涵盖以下内容:

  • 使用 PyTorch DataLoader 加载数据
  • 实现一个对 CT 数据执行分类的模型
  • 为我们的应用搭建基础骨架
  • 在训练过程中添加日志并展示指标

在前几章中,我们已经为癌症检测项目搭好了舞台。我们讲解了肺癌的医学背景,查看了项目将要使用的主要数据源,并把原始 CT 扫描转换成了一个 PyTorch Dataset 实例。现在,既然已经有了数据集,我们就可以很方便地消费训练数据了。那么,开始吧!

13.1 一个基础模型与训练循环

本章我们主要要做两件事。首先,我们将构建“结节分类模型”和对应的训练循环,它们将成为本书第二部分其余内容继续展开整个大项目的基础。为此,我们会使用第 12 章中实现的 CtLunaDataset 类,把它们接入 DataLoader 实例。这些 DataLoader 实例接着会通过训练循环和验证循环,把数据送入我们的分类模型。

本章的后半部分,我们将利用训练循环跑出来的结果,引出本书第二部分中最难的挑战之一:如何从杂乱、有限的数据中获得高质量的结果。在后续章节里,我们会进一步分析数据具体受限于哪些因素,并尝试缓解这些限制。

让我们回顾一下第 11 章中的高层路线图,如图 13.1 所示。当前,我们要处理的是第 3 步:分类。再次提醒一下,我们现在要做的是把候选区域分类为结节或非结节(在第 14 章里,我们还会再构建一个分类器,试图区分恶性结节与良性结节)。这意味着:我们要为送入模型的每一个样本分配一个唯一、明确的标签。在当前场景中,这些标签就是“结节”和“非结节”,因为每个样本都表示一个单独的候选区域。

image.png

图 13.1 我们的端到端肺癌检测项目,本章聚焦于其中的主题:第 3 步,分类

尽早拿到项目中某个有意义部分的端到端版本,是一个非常好的里程碑。只要你手上有一个“足够能用、能让结果被分析地评估”的东西,你后续就可以在此基础上继续往前推进,并且有信心判断每一次修改是否真的让结果变好了——至少,你也能较为明确地把那些没效果的修改和实验先搁置一边!在做自己的项目时,你要预期会进行大量实验。想拿到最优结果,通常都需要相当多的试错、调参与打磨。

但在进入实验阶段之前,我们必须先打好基础。先来看一下本书第二部分中的训练循环大致长什么样,如图 13.2 所示;从整体上说,它应该会让你觉得相当熟悉,因为我们在第 5 章中已经见过一套非常相似的核心步骤。这里,我们同样会使用验证集来评估训练进展,正如 5.5.3 节中讨论的那样。

image.png

图 13.2 我们将在本章实现的训练与验证脚本

我们要实现的基本结构如下:

  • 初始化模型与数据加载逻辑

  • 进入一个按半任意方式选定轮数执行的 epoch 循环:

    • 遍历 LunaDataset 返回的每一个训练批次:

      • 把这个 batch 送入分类模型,得到结果
      • 根据预测结果与真实标注之间的差异计算 loss
      • 将模型表现指标记录到一个临时数据结构中
      • 通过误差反向传播来更新模型权重
    • 再遍历每一个验证数据批次(其过程与训练循环非常相似):

      • 加载对应的验证 batch(同样由后台 worker 进程完成)
      • 对 batch 做分类并计算 loss
      • 将模型表现指标记录到一个临时数据结构中
    • 打印该 epoch 的进度和性能信息

在阅读本章代码的过程中,请特别留意:这里生成的代码,与本书第一部分中我们写的训练循环相比,有两个主要区别。

首先,我们会为整个程序加上更多结构,因为这个项目整体上比前面章节做过的东西复杂得多。如果没有额外的结构,代码很快就会变得混乱。因此,在这个项目里,我们会让主训练应用由若干职责清晰的函数构成,并且进一步把数据集之类的代码拆分到各自独立的 Python 模块中。

对你自己的项目来说,也要确保结构和设计的复杂度与项目本身的复杂度相匹配。结构太少,代码会变得难以干净地做实验、难以排查问题,甚至很难清晰描述自己到底在做什么!反过来,如果结构太多,那你就会把时间浪费在并不需要的基础设施上,而且在那些繁复“管道”搭起来之后,还很可能反过来拖慢自己的速度。更糟糕的是,人很容易把“搭基础设施”当作一种拖延手段,而不是去真正啃那些推动项目前进的硬骨头。别掉进这个陷阱!

本章代码与我们之前实验相比的另一个重大区别在于:我们会特别关注在训练过程中收集各种指标。我们还会看到,重要的不只是“收集指标”,而是“收集适合这个任务的指标”。本章里,我们会先为跟踪这些指标搭建基础设施,并通过收集和展示以下内容来使用这套设施:loss,以及样本被正确分类的百分比——既包括整体百分比,也包括按类别分别统计的百分比。这足以让我们起步,不过更贴近真实项目的一组指标,我们会在第 14 章中再讲。

13.2 应用程序的主入口

与本书前面做过的训练工作相比,结构上一个很大的不同,是本书第二部分把我们的工作包裹进了一个完整的命令行应用程序中。它会解析命令行参数,提供功能完整的 --help 命令,并且能够方便地运行在多种环境中。这样一来,我们就可以轻松地从 Jupyter 和 Bash shell 中调用训练流程——当然,其实任何 shell 都可以,只不过如果你用的是非 Bash 的 shell,那你自己大概早就知道这一点了。

我们的应用功能会通过一个类来实现,这样我们如果愿意的话,就可以实例化这个应用对象,并在程序中到处传递它。这会让测试、调试,或者从其他 Python 程序中调用它变得更容易。我们不必为了调用应用,就非得再启动一个新的操作系统级进程。(本书不会展开显式的单元测试,但我们这里建立起来的结构,在那些确实适合做此类测试的真实项目里会非常有帮助。)

利用“既可以通过函数调用、也可以通过操作系统级进程调用训练”的一个方式,就是把这些函数调用包装进 Jupyter Notebook 中,这样同一套代码既能从原生命令行调用,也能从浏览器里调用。

代码清单 13.1 在 Jupyter 中运行训练脚本的辅助函数(p2_run_everything.ipynb

# In[2]:
def run(app, *argv):
    argv = list(argv)
    argv.insert(0, '--num-workers=4')    #1
    log.info(f"Running: {app}({argv!r}).main()")
    app_cls = importstr(*app.rsplit('.', 1))    #2
    app_cls(argv).main()

    log.info(f"Finished: {app}.{argv!r}).main()")

# In[6]:
run('p2ch13.training.LunaTrainingApp', '--epochs=1')

#1 这里默认你有一颗四核八线程 CPU。如有需要,请自行修改这个 4。
#2 这是一个比直接调用 __import__ 略干净一些的写法。

注意:这里的训练假定你的机器大致是一台工作站配置:四核八线程 CPU、16 GB 内存,以及一块带 8 GB 显存的 GPU。如果你的 GPU 显存更小,请减小 --batch-size;如果你的 CPU 核数或系统内存更少,请减小 --num-workers

先来处理一些半标准的样板代码。我们先从文件末尾看起,有一个很标准的 if __name__ == '__main__': 代码段,它会实例化应用对象并调用其 main 方法。

代码清单 13.2 从命令行运行训练应用(training.py

if __name__ == '__main__':
  LunaTrainingApp().main()

然后我们可以跳回到文件开头,来看应用类本身,以及刚才调用到的两个函数:__init__main。因为我们希望能够接收命令行参数,所以会在应用的 __init__ 函数中使用标准库 argparsedocs.python.org/3/library/a…)。注意,如果我们愿意的话,也可以向初始化器传入自定义参数。main方法则会成为应用核心逻辑的主要入口:

class LunaTrainingApp:
  def __init__(self, sys_argv=None):
    if sys_argv is None:            #1
       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')    #2

#1 如果调用方没有显式提供参数,我们就从命令行中读取。
#2 我们会用这个时间戳来帮助标识不同的训练运行。

这种结构非常通用,以后在其他项目中也可以复用。特别是,把参数解析放进 __init__ 中,使我们能够把“配置应用”和“执行应用”这两件事分离开来。

我们还可以很方便地在 notebook 中实例化这个应用,以便做更轻松的测试和实验。这样的结构允许我们直接在 notebook 的一个单元格里初始化应用对象,从而在后续单元格中演示我们定义的方法究竟是如何工作的。

代码清单 13.3 以交互方式运行我们的应用(sandbox.ipynb

# In[]
import torch
from p2ch13.training import LunaTrainingApp
args = [
    '--num-workers', '1',  # Example: Set number of workers to 1
    '--batch-size', '4',  # Example: Set batch size to 4
    '--epochs', '1',       # Example: Set number of epochs to 1
]
app = LunaTrainingApp(args)
app

# Out[]
2024-11-17 14:29:45,449 INFO     pid:1852 p2ch13.training:085:initModel↪
↪sing CUDA; 1 devices.
<p2ch13.training.LunaTrainingApp at 0x27416137700>

13.3 训练前的准备与初始化

在真正开始按 epoch 遍历每一个 batch 之前,我们还需要先做一些初始化工作。毕竟,如果连模型都还没有实例化,就根本谈不上训练!我们主要需要完成两件事,如图 13.3 所示。第一件是刚刚提到的:初始化模型和优化器;第二件则是初始化 DatasetDataLoader 实例。LunaDataset 将定义构成训练 epoch 的那组随机样本,而 DataLoader 实例则负责把这些数据从数据集中加载出来,并提供给我们的应用程序。

image.png

图 13.3 我们将在本章实现的训练与验证脚本,此处聚焦于进入主循环之前各变量的初始化

13.3.1 初始化模型与优化器

在本节里,我们暂时把 LunaModel 的内部细节当成黑盒处理。到了 13.4 节,我们会详细拆解其内部工作原理。先来看一眼我们的起点。

代码清单 13.4 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(f"Using CUDA; {torch.cuda.device_count()} devices.")
      if torch.cuda.device_count() > 1:      #1
        model = nn.DataParallel(model)      #2
      model = model.to(self.device)      #3
    return model

  def initOptimizer(self):
    return SGD(self.model.parameters(), lr=0.001, momentum=0.99)

#1 检测是否存在多块 GPU
#2 用一个包装器把模型包起来,以便在多块 GPU 上加速训练
#3 把模型参数发送到 GPU 上

如果训练所用系统中有多块 GPU,我们将使用 nn.DataParallel 类来把工作分发到系统中的所有 GPU 上,然后再收集、同步参数更新等。这对模型实现本身以及使用该模型的代码来说,几乎是完全透明的。

DataParallel 与 DistributedDataParallel

我们这里使用的是 DataParallel 来处理多 GPU 利用问题。之所以选择 DataParallel,是因为它对现有模型来说是一个简单的即插即用包装器。不过,它并不是使用多 GPU 的最佳性能方案,而且它只能工作在单机可见的硬件范围内。

PyTorch 还提供了 DistributedDataParallel,这是在需要把工作分布到多块 GPU、甚至多台机器上时推荐使用的包装类。不过它涉及到更复杂、更正式的设置与配置工作。我们会在本书分布式训练那一章(第 16 章)中再介绍这套 API。

假设 self.use_cuda 为真,那么 self.model.to(self.device) 这一调用就会把模型参数移动到 GPU 上,使得各种卷积以及其他计算都能在 GPU 上完成重负载的数值运算。这里有一个重要的顺序问题:必须在构建优化器之前完成这一步。否则,优化器拿到的仍然会是留在 CPU 上的参数对象,而不是已经拷贝到 GPU 上的那些参数。

至于优化器,我们会使用最基础的、带 momentum 的随机梯度下降(SGD,mng.bz/7Q27)。在选择优化器时,把 SGD 作为起点通常被认为是一个稳妥选择;确实存在一些问题不适合 SGD,但这种情况相对少见。同样地,0.001 的学习率和 0.99 的 momentum 也是非常常见的默认值。经验上看,使用这组参数的 SGD 在各种项目上都能有还算不错的表现;而且如果一开始效果不好,也很容易试试把学习率调成 0.01 或 0.0001。

这并不是说这些值对我们的具体任务就是最优的,只是现在去寻找更优值还为时尚早。系统地尝试不同的学习率、momentum、网络宽度等类似配置参数的过程,被称为超参数搜索(hyperparameter search)。不过,在接下来的几章中,还有一些更明显、更急需优先解决的问题。等这些问题处理掉之后,我们再来微调这些值。正如我们在第 5 章“Testing Other Optimizers”一节中提到的,我们当然也可以考虑其他、更“异域”的优化器。但除非只是简单地把 torch.optim.SGD 换成 torch.optim.Adam,更深入地理解其中权衡,已经超出了本书的范围。

13.3.2 DataLoader 的使用与维护

上一章中构建的 LunaDataset 类,充当了连接“蛮荒西部般的原始数据”和“PyTorch 组件所期待的较为有序的张量世界”之间的桥梁。比如,torch.nn.Conv3dmng.bz/mZWW)期望接收到一个五维输入:(N, C, D, H, W)——分别表示样本数量、每个样本的通道数、深度、高度和宽度。这与原始 CT 所提供的天然三维结构已经很不一样了!

你可能还记得上一章 LunaDataset.__getitem__ 中的 ct_t.unsqueeze(0) 调用;它正是提供了第四个维度,也就是我们数据中的“通道”维。回忆一下第 4 章,一个 RGB 图像有三个通道,分别对应红、绿、蓝。天文数据则可能有几十个通道,每个通道对应电磁谱中的一个不同切片——伽马射线、X 射线、紫外、可见光、红外、微波和/或无线电波等。由于 CT 扫描只有单一强度,因此我们的通道维大小只有 1。

另外,也请回忆一下本书第一部分提到的:一次只训练单个样本,通常是对计算资源的低效利用,因为大多数处理平台都具有比“仅处理一个训练样本”所需更多的并行计算能力。解决方法,就是把多个 sample tuple 组合成一个 batch tuple,如图 13.4 所示,这样就能同时处理多个样本。这里的第五个维度 N,正是用来区分同一个 batch 中不同样本的。

image.png

图 13.4 在 DataLoader 内部,多个 sample tuple 被整理合并为一个 batch tuple

好消息是,我们完全不需要自己实现这种批处理;PyTorch 的 DataLoader 类会替我们完成所有整理工作。既然我们已经通过 LunaDataset 类把 CT 扫描转换成 PyTorch 张量了,那么现在剩下的事情,就是把数据集接入 DataLoader:

def initTrainDl(self):
  train_ds = LunaDataset(      #1
    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(      #2
    train_ds,
    batch_size=batch_size,       #3
    num_workers=self.cli_args.num_workers,
    pin_memory=self.use_cuda,      #4
  )

  return train_dl

# In[]
train_dl = app.initTrainDl()
val_dl = app.initValDl()          #5
data_sample = next(iter(train_dl))
print(data_sample[0].shape)         #6
print(data_sample[1:])      #7

# Out[]
2024-11-17 14:42:27,055 INFO     pid:1852 p2ch13.dsets:182:__init__ <
  ↪p2ch13.dsets.LunaDataset object at 0x000002741C890F10>:
  ↪495958 training samples
2024-11-17 14:42:27,109 INFO     pid:1852 p2ch13.dsets:182:__init__ <
  ↪p2ch13.dsets.LunaDataset object at 0x00000274161377F0>:
  ↪55107 validation samples
torch.Size([2, 1, 32, 48, 48])
[tensor([[1, 0],
        [1, 0]]), ['1.3.6.1.4.1.14519.5.2.1.6279.6001.18305615178056746032
        ↪2586876100', '1.3.6.1.4.1.14519.5.2.1.6279.6001.76545923655035
        ↪8748053283544075'], tensor([[222, 244,  70],
        [115, 203, 190]])]

#1 我们自定义的数据集
#2 PyTorch 提供的一个用于加载与处理数据的工具类
#3 只需通过这个参数,批处理就会自动完成
#4 固定页内存会让数据从 CPU 传到 GPU 更快
#5 验证集的 DataLoader 与训练集非常相似
#6 batch 中第一个张量就是输入数据。回忆一下它的格式是 (batch_size, number_of_channels, depth, height, width)
#7 batch 中剩余 3 项分别是标签、series_uid 以及中心坐标

除了把单个样本拼成 batch 之外,DataLoader 还可以通过使用独立进程和共享内存来并行加载数据。我们要做的,只是在实例化 DataLoader 时指定 num_workers=...,其余工作都会在幕后完成。每一个 worker 进程都会生成如图 13.4 那样完整的 batch。这有助于确保“饥饿”的 GPU 能够持续吃到数据。我们的 validation_dsvalidation_dl 实例与此类似,只不过很明显地会把 isValSet_bool=True

当我们像 for batch_tup in self.train_dl: 这样去迭代时,不需要再等待每一个 Ct 被加载、样本被裁切并打包成 batch 等等。相反,我们会立刻拿到已经准备好的 batch_tup,与此同时,后台会有一个 worker 进程被释放出来,开始准备后续迭代会用到的下一个 batch。利用 PyTorch 的数据加载特性,通常可以加快大多数项目,因为我们能够把数据加载与预处理,与 GPU 计算这两件事并行重叠起来。

13.4 我们的第一版神经网络设计

一个能够检测肿瘤的卷积神经网络,其可能的设计空间实际上是无限的。幸运的是,在过去十年左右的时间里,人们已经在图像识别任务上投入了大量精力,探索出了许多有效的模型。尽管这些工作大多集中在二维图像上,但其中的架构思路通常都能很好地迁移到三维场景,因此我们有不少经过验证的设计可以作为起点。这很有帮助,因为虽然我们的第一版网络架构很可能不是最终最优方案,但此刻我们的目标只是“足够好,能让项目先启动起来”。

我们的网络设计会以第 8 章使用过的结构为基础。因为输入数据变成了三维,所以我们必须对模型进行一些调整,也会加入一些额外细节,但整体结构如图 13.5 所示,应该仍会让你感到熟悉。同样地,我们在这个项目中完成的工作,也会成为你之后项目的良好起点;当然,随着项目越来越远离“分类”和“分割”这种典型任务,你需要对这套基础做的调整也会越来越多。现在,让我们把这个架构拆开来看,先从构成网络主体的那四个重复 block 开始。

image.png

图 13.5 LunaModel 类的架构:由一个 batch normalization 的 tail、一个由四个 block 组成的 backbone,以及由线性层加 softmax 构成的 head 所组成

13.4.1 核心卷积部分

分类模型通常会采用一种由 tail、backbone(或 body)和 head 构成的结构。与“from head to tail(从头到尾)”这个短语不同,在这类模型里,数据流动方向实际上是从 tail 流向 head。Tail 是网络最开始处理输入的那几层。由于这些前几层必须把输入调整成 backbone 所期待的形式,因此它们的结构或组织方式常常与网络的其余部分不同。在这里,我们使用的是一个很直接的 batch normalization 层。不过,在很多网络中,tail 里也很常见地会包含卷积层。这些层通常会一边减小图像尺寸,一边学习重要特征。由于我们的图像尺寸本来就已经比较紧凑,因此在这里不需要额外的下采样。

接下来,网络的 backbone 通常包含绝大多数层,而且这些层通常被组织为一系列 block。每个 block 里都包含同样(或至少类似)的一组层,只不过从一个 block 到下一个 block,预期输入大小和卷积核数量往往会发生变化。我们这里用的 block,由两个 3 × 3 卷积组成,每个卷积后面都跟着一个激活函数,而在 block 的末尾再接一个 max pooling 操作。我们可以在图 13.5 中标为 Block[block1] 的展开视图中看到这一点。(在代码里,这部分由 LunaBlock 类实现。)这种 block 是作者所做的一种设计选择;通常来说,在面对一个问题时,从某个已经建立的成熟架构出发是很常见的。但出于本书教学目的,我们会把它从头写出来。下面的代码清单展示了这个 block 的实现。

代码清单 13.5 定义 LunaBlock 模型(model.py

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)
    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)

最后,网络的 head 会把 backbone 的输出转换成我们想要的最终输出形式。对于卷积网络来说,这通常意味着先把中间输出 flatten,然后送入一个全连接层。对于某些网络,再加一个额外的全连接层也是合理的,不过那通常更适用于那些图像对象本身结构更复杂的分类问题(例如汽车和卡车这种对象,具有轮子、灯、进气格栅、车门等部件),以及类别数较多的项目。由于我们这里只做二分类,而且看起来也不需要额外复杂度,所以这里只使用了一个 flatten 后接一个全连接层。

像这样的结构,通常可以作为卷积网络的一个很好的起点。外面当然也有更复杂的设计,但对很多项目来说,那些结构在实现复杂度和计算开销上都属于“杀鸡用牛刀”。一个稳妥的策略是:先从简单方案开始,只有在确实证明有必要时,才逐步增加复杂度。

图 13.6 展示了我们这个 block 在二维情况下的卷积结构。由于这个图只是更大图像中的一小块,我们在这里省略了卷积边缘上的 padding。(ReLU 激活函数没有画出来,因为应用它并不会改变图像尺寸。)

image.png

图 13.6 LunaModel 中一个 block 的卷积结构:由两个 3 × 3 卷积后接一个 max pool 组成。最终像素的感受野为 6 × 6

我们来逐步理解一下,从输入体素到单个输出体素之间,信息是如何流动的。我们希望对“输入变化时,输出会如何响应”有一个非常扎实的直觉。你可能会想回头复习一下第 8 章,尤其是 8.1 到 8.3 节,以确保你对卷积的基本机制已经非常牢固。

在我们的 block 中,用的是 3 × 3 × 3 卷积。一个单独的 3 × 3 × 3 卷积,其感受野就是 3 × 3 × 3:27 个体素输入,输出 1 个体素。

而有趣的地方在于:我们将两个 3 × 3 × 3 卷积背靠背堆叠使用。当我们这样做时,最终输出体素(在二维情况下对应一个像素)就会受到超出直接 3 × 3 × 3 邻域之外的输入数据影响。原因是:第一层的输出成为第二层的输入。如果第一层的某个输出体素恰好位于第二层 3 × 3 × 3 卷积核边缘的位置,那么这意味着原始输入中有些部分已经超出了第二层卷积核能直接看到的范围。两层叠加起来,就形成了一个等效的 5 × 5 × 5 感受野,好像相当于用了一个更大的单层卷积。与单个 5 × 5 × 5 卷积相比,堆叠两个 3 × 3 × 3 层所需参数更少,因此计算也更快。

如果你从图 13.6 中沿反方向(右下往左上)去看,就能看出这一点。(记住,我们实际上处理的是三维数据,图里只是画成二维。)图中 2 × 2 的输出,其感受野是 4 × 4,而这个 4 × 4 区域本身又对应着 6 × 6 的感受野。对于感受野的计算来说,每增加一层 3 × 3 × 3 卷积,感受野的每一条边就会额外扩展 1 个体素。

这两个卷积堆叠之后的输出,会再送入一个 2 × 2 × 2 的 max pool,它通过选取 2 × 2 × 2 区域中最大的值来降低数据尺寸。虽然这一步会丢弃一部分输入体素,但它们仍然能够影响最终输出,因为它们的贡献已经在之前的卷积过程中被捕捉进来了。

注意,虽然我们画图时看起来感受野在逐层收缩,但实际使用的是带 padding 的卷积,也就是说,在图像边缘加了一圈“虚拟像素”。这样一来,输入图像和输出图像的尺寸就能够保持一致。

nn.ReLU 层和我们在第 6 章里见过的是一样的。大于 0.0 的输出值保持不变,小于 0.0 的输出值则会被截断为 0。

这个 block 会被重复多次,以构成模型的 backbone。

13.4.2 完整模型

现在来看看完整模型的实现。block 的定义我们就略过了,因为上一段代码清单里已经看过:

class LunaModel(nn.Module):
  def __init__(self, in_channels=1, conv_channels=8):
    super().__init__()

    self.tail_batchnorm = nn.BatchNorm3d(1)     #1

    self.block1 = LunaBlock(in_channels,
    ↪conv_channels)                               #2
    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)     #3
    self.head_softmax = nn.Softmax(dim=1)     #3

#1 Tail(负责对输入数据进行初步处理)
#2 Backbone(执行主要工作,负责核心特征提取)
#3 Head(负责生成最终输出)

这里,我们的 tail 相当简单。我们会使用 nn.BatchNorm3d 对输入做归一化。正如第 8 章所见,这会平移并缩放输入,使其均值为 0、标准差为 1。这样一来,输入原本所处的 Hounsfield 单位(HU)尺度,就不会再直接暴露给网络后面的部分,因为此时我们已经是在另一个数值尺度上工作了。

我们的 backbone 是由四个重复 block 组成的,而 block 的实现被抽取到了前面见到的独立 nn.Module 子类中。由于每个 block 的结尾都有一个 2 × 2 × 2 的 max pooling 操作,因此经过四层之后,图像在每一个维度上的分辨率都会缩小 16 倍。我们的输入数据块尺寸是 32 × 48 × 48,因此到了 backbone 的末尾,就会变成 2 × 3 × 3

最后,head 只是一个全连接层,再加上一次对 nn.Softmax 的调用。Softmax 在单标签分类任务中非常有用,它有几个不错的性质:它会把输出限制在 0 到 1 之间;它对输入值的绝对大小并不太敏感(真正重要的是各输入之间的相对大小);而且它还允许模型表达自己对某个答案的确信程度。

这个函数本身并不复杂。输入中的每一个值都被拿去作为指数,计算 e^x,然后再把得到的这一列指数值,除以它们总和。下面是用纯 Python 写出来的一个简单、未优化版 softmax 实现:

# In[]
logits = [1, -2, 3]
exp = [math.exp(x) for x in logits]
print(exp)
softmax = [x / sum(exp) for x in exp]
print(softmax)

# Out
[2.718, 0.135, 20.086]
[0.118, 0.006, 0.876]

当然,在模型里我们会使用 PyTorch 自带的 nn.Softmax 版本,因为它天然支持 batch 和 tensor,并且能按预期高效地配合 autograd 工作。

一个小复杂点:从卷积输出转到线性层

继续看模型定义,会遇到一个小复杂点。我们不能直接把 self.block4 的输出喂给全连接层,因为那个输出对每个样本来说,是一张有 64 个通道的 2 × 3 × 3 图像,而全连接层期待的是一个一维向量作为输入(更准确地说,它期待的是“一批一维向量”,也就是二维数组,但不管怎样,维度仍然不匹配)。下面来看一下 forward 方法(model.py:50, LunaModel.forward)。

代码清单 13.6 LunaModel 的前向传播

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
    -1,
  )
  linear_output = self.head_linear(conv_flat)
  return linear_output, self.head_softmax(linear_output)

#1 保持 batch 大小不变,把其他维度全部 flatten

在把数据送进全连接层之前,我们必须先用 view 把它 flatten 掉。由于这个操作本身是无状态的(没有任何需要学习的参数),因此我们可以直接在 forward 函数中执行它。这和第 8 章中提到的函数式接口有点类似。几乎所有那些使用卷积、最终输出分类、回归或其他非图像结果的模型,在 head 中都会有类似的处理。

至于 forward 的返回值,我们同时返回原始 logitssoftmax 得到的概率。我们第一次讨论 logits 是在 7.2.6 节:它们是网络在经过 softmax 归一化成概率之前所产生的原始数值。听起来也许有点抽象,但其实 logits 就只是 softmax 层的直接输入而已。它们可以是任意实数,而 softmax 会把它们压缩映射到 0 到 1 的范围内。

在训练时,我们会用 logits 去计算 nn.CrossEntropyLoss。(这样做在数值稳定性上更有优势。要在 32 位浮点数上准确地通过指数运算传播梯度,可能会有问题。)而在真正要对样本做分类的时候,我们才会使用概率值。训练时用一种形式、推理时用另一种形式,这种轻微差异其实很常见,尤其是当两者之间只差一个像 softmax 这样简单、无状态的函数时。

参数初始化

最后,再来谈谈网络参数的初始化。为了让模型表现得比较“规矩”,网络中的权重、偏置以及其他参数需要具备一些合适的性质。想象一个极端情况:如果网络所有权重都大于 1,那么随着数据在网络层间不断传播,层输出就会因为反复乘上这些权重而变得越来越大。类似地,如果权重都小于 1,层输出则会越来越小,最终消失。反向传播中的梯度也有类似问题。

当然,可以通过许多归一化手段来保持层输出处于健康范围,但最简单的方法之一,就是一开始就把网络权重初始化成一种能让中间值和梯度既不会小得离谱、也不会大得离谱的状态。下面这个 _init_weights 函数可以先把它当作样板代码来看,具体细节并不是特别关键(model.py:30, LunaModel._init_weights)。

代码清单 13.7 对卷积层和线性层权重使用 Kaiming 初始化

def _init_weights(self):
  for m in self.modules():
    if type(m) in {
        nn.Linear, nn.Conv3d, nn.Conv2d, nn.ConvTranspose2d,
        ↪nn.ConvTranspose3d,
    }:
      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)

我们现在已经完成了模型的配置(sandbox.ipynb)。接下来就可以实例化它并开始使用了!

注意:如果你对此感兴趣,可以参考 Ye 等人的论文《Delving Deep into Rectifiers》,其中首次提出了 Kaiming 初始化(arxiv.org/pdf/1502.01…)。

代码清单 13.8 在 notebook 中查看完成初始化后的 LunaModel

# In[]
model = app.model
model

# Out[]
LunaModel(
(tail_batchnorm): BatchNorm3d(1, eps=1e-05, momentum=0.1, affine=True,↪
↪track_running_stats=True)
(block1): LunaBlock(
...

13.5 训练与验证模型

现在,是时候把前面一直在准备的各个部件真正组装起来,做成一个我们可以实际运行的东西了。这个训练循环应该会让你感到熟悉——我们在前面的章节里已经见过类似图 13.7 这样的循环。

image.png

图 13.7 我们将在本章实现的训练与验证脚本,此处聚焦于 epoch 循环及其中 batch 循环的嵌套结构

代码其实相当紧凑。首先是驱动代码:它调用 doTraining,并把返回结果保存到我们的 metrics 里(training.py:143, LunaTrainingApp.main)。

代码清单 13.9 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)

接着我们再往里看 doTraining 的实现(training.py:165, LunaTrainingApp.main)。

代码清单 13.10 主训练循环的一次迭代

def doTraining(self, epoch_ndx, train_dl):
  self.model.train()
  trnMetrics_g = torch.zeros(      #1
    METRICS_SIZE,
    len(train_dl.dataset),
    device=self.device,
  )

  train_progress = tqdm(       #2
    train_dl,
    desc="E{} Training".format(epoch_ndx),
    total=len(train_dl)
  )

  for batch_ndx, batch_tup in enumerate(train_progress):
    self.optimizer.zero_grad()     #3

    loss_var = self.computeBatchLoss(    #4
      batch_ndx,
      batch_tup,
      train_dl.batch_size,
      trnMetrics_g
    )

    loss_var.backward()         #5
    self.optimizer.step()       #5

  self.totalTrainingSamples_count += len(train_dl.dataset)

  return trnMetrics_g.to('cpu')

#1 初始化一个空的 metrics 数组
#2 用 tqdm 给 batched iterable 加上进度追踪
#3 清理掉可能残留的梯度张量
#4 下一节里我们会详细讲这个方法
#5 反向传播并执行优化器 step,更新模型权重

和前面几章训练循环相比,这里主要有这些不同点:

  • trnMetrics_g 张量会在训练过程中收集更细粒度、按类别分解的指标。对于像我们这样的大项目,这类洞察会非常有价值。
  • 我们没有直接迭代 train_dl 数据加载器,而是使用 tqdm 来显示处理速度和预计完成时间。这并不是必须的,只是一种风格选择。
  • 实际的 loss 计算被抽到了 computeBatchLoss 方法中。再次强调,这也不是必须的,但代码复用通常是件好事。

trnMetrics_g 张量的作用,是把模型对每个样本的表现信息,从 computeBatchLoss 函数传递给 logMetrics 函数。接下来我们先看 computeBatchLoss;等把主训练循环剩余部分讲完后,再来介绍 logMetrics

13.5.1 computeBatchLoss 函数

computeBatchLoss 函数会同时被训练循环和验证循环调用。顾名思义,它负责计算一个 batch 上的 loss。除此之外,它还会计算并记录模型输出在每个样本层面上的信息。这使得我们能够算出诸如“每个类别各自有多少比例被正确分类”这类指标,从而把注意力集中到模型真正困难的地方。

当然,这个函数最核心的功能还是:把 batch 喂给模型,并计算该 batch 的 loss。我们这里使用的是 CrossEntropyLossmng.bz/5v28)。把 batch tuple 解包、把 tensor 移到 GPU 上、调用模型,这些步骤在前面的训练工作中应该已经非常熟悉了(training.py:225, computeBatchLoss)。

代码清单 13.11 计算按样本展开的 cross-entropy loss

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')   #1
  loss_g = loss_func(
    logits_g,
    label_g[:,1],     #2
  )
  # ... line 238
  return loss_g.mean()    #3

#1 reduction='none' 会返回“每个样本各自的 loss”
#2 one-hot 编码类别中的索引位置
#3 把按样本的 loss 重新聚合成一个单值

这里,我们没有采用默认行为——直接返回一个对整个 batch 求平均的 loss 值。相反,我们得到的是一个 loss tensor,其中每个样本对应一个 loss。这样一来,我们就可以追踪单个样本的 loss,也就意味着之后可以按我们想要的任何方式去聚合它们(例如按类别聚合)。很快你就会看到这一点如何发挥作用。眼下,我们先简单地返回这些单样本 loss 的平均值,也就是整个 batch 的 loss。如果你的场景并不需要记录按样本统计信息,那么直接使用 batch 平均 loss 完全没有问题。至于是否需要这么细粒度的信息,则非常依赖于你的具体项目和目标。

做到这里,我们就已经满足了调用方对于“反向传播和权重更新所必需”的要求。与此同时,作为 loss 函数的一部分,我们还想顺带把每个样本的统计信息记录下来,以备后续分析使用。为此,我们会利用传入的 metrics_g 参数(training.py:26)。

代码清单 13.12 记录按样本统计的 metrics

METRICS_LABEL_NDX = 0     #1
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] = \     #2
      label_g[:,1].detach()                                 #2
    metrics_g[METRICS_PRED_NDX, start_ndx:end_ndx] = \      #2
      probability_g[:,1].detach()                           #2
    metrics_g[METRICS_LOSS_NDX, start_ndx:end_ndx] = \      #2
      loss_g.detach()                                       #2

    return loss_g.mean()      #3

#1 这些具名数组索引都被声明成常量
#2 我们使用 detach,因为这些 metrics 不需要保留梯度
#3 再次强调,这是整个 batch 的 loss

通过为每一个训练样本(之后还有每一个验证样本)记录标签、预测值和 loss,我们就得到了极为丰富的细粒度信息,可以用来分析模型的行为。调试“训练不正确”的神经网络是非常困难的,因此这些指标常常是定位问题的关键。眼下,我们主要关注的是按类别汇总统计信息;但同样地,我们其实也完全可以用这些信息找出“被错分得最严重”的那个样本,并从那里开始调查原因。再次强调,对某些项目来说,这类信息未必那么关键;但记住你手上有这些选择,总是好事。

我们可以在 sandbox.ipynb 中对 computeBatchLoss 做一个小测试:只用一小部分数据来跑它。

代码清单 13.13 带进度条运行 computeBatchLoss

from tqdm import tqdm
device = app.device
train_dl_subset = app.get_dl_subset(train_dl, num_samples=20)
trnMetrics_g = torch.zeros(
    3,
    len(train_dl_subset.dataset),
    device=device,
)

batch_iter = tqdm(train_dl_subset, desc="E{} Training".format(epoch_ndx),↪
↪total=len(train_dl_subset))
for batch_ndx, batch_tup in enumerate(batch_iter):
    loss_var = app.computeBatchLoss(
        batch_ndx, batch_tup, train_dl_subset.batch_size, trnMetrics_g
    )

13.5.2 验证循环与训练循环类似

图 13.8 中的验证循环,看起来和训练过程非常像,只不过做了一些简化。关键区别在于:验证是只读的。具体来说,返回的 loss 值不会被拿来更新参数,模型权重也不会发生变化。

image.png

图 13.8 我们将在本章实现的训练与验证脚本,此处聚焦于每个 epoch 内部的验证循环

在整个函数调用开始和结束之间,模型本身不应该有任何变化。另外,由于用了 with torch.no_grad() 上下文管理器,显式告诉 PyTorch 不需要计算梯度,因此验证过程也会快不少(training.py:137, LunaTrainingApp.main)。

代码清单 13.14 使用 eval() 模式和 no_grad 上下文的验证循环

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()         #1
    valMetrics_g = torch.zeros(
      METRICS_SIZE,
      len(val_dl.dataset),
      device=self.device,
    )

    val_progress = tqdm(
      val_dl,
      desc="E{} Validation".format(epoch_ndx),
      total=len(val_dl)
    )
    for batch_ndx, batch_tup in enumerate(val_progress):
      self.computeBatchLoss(
        batch_ndx, batch_tup, val_dl.batch_size, valMetrics_g)

  return valMetrics_g.to('cpu')

#1 model.eval() 非常重要,它会关闭训练期特有的行为

因为在验证阶段不需要更新网络权重(回忆一下:如果这么做,就破坏了整个验证集存在的前提,这是绝对不该发生的!),所以我们既不需要使用 computeBatchLoss 返回的 loss,也不需要引用优化器。循环内部剩下的就只是对 computeBatchLoss 的调用。注意,尽管我们没有使用它返回的“每 batch 整体 loss”,但这个调用仍然会把按样本记录的指标作为副作用写进 valMetrics_g 中。

13.6 输出性能指标

每个 epoch 的最后一件事,就是把这一轮的性能指标记录输出出来。正如图 13.9 所示,指标记录完之后,我们就会回到训练循环,进入下一个 epoch。边训练边记录结果与进度非常重要,因为如果训练出了问题(在深度学习行话里叫“does not converge”,即不收敛),我们希望尽早察觉,而不是在一个根本没有实质性进展的模型上继续浪费时间。即便问题没那么严重,能够持续观察模型行为也同样非常有帮助。

image.png

图 13.9 我们将在本章实现的训练与验证脚本,此处聚焦于每个 epoch 结束时的 metrics 记录

前面,我们一直在 trnMetrics_gvalMetrics_g 中积累这些结果,以便在每个 epoch 结束时记录训练进度。现在,这两个张量已经包含了我们所需的全部信息,可以用来计算训练集与验证集上的:整体正确率、每个类别的平均 loss,以及每个类别各自的正确率。按 epoch 记录指标,是一种很常见的做法,虽然多少有点任意。到了后续章节,我们还会看到:如何调整 epoch 的大小,以便让我们能以一个合理节奏获得训练反馈。

13.6.1 logMetrics 函数

先来看看 logMetrics 函数的高层结构。它的签名如下(training.py:251, LunaTrainingApp.logMetrics)。

代码清单 13.15 logMetrics 函数签名

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)。

代码清单 13.16 metrics 的常量索引

METRICS_LABEL_NDX = 0
METRICS_PRED_NDX = 1
METRICS_LOSS_NDX = 2

张量掩码与布尔索引

带掩码的张量是一种非常常见的用法;如果你之前没接触过,可能会觉得它有些晦涩。你也许熟悉 NumPy 中所谓的 masked arrays(带掩码数组)概念;张量上的 mask 与数组上的 mask,其行为是完全一样的。

如果你不熟悉 masked array,NumPy 文档中有一页讲得非常清楚(mng.bz/XPra)。PyTorch是有意采用了与 NumPy 相同的语法与语义。

构造掩码

接下来,我们会构造一些 mask,以便只针对“结节”或“非结节”(也就是正类或负类)样本来计算指标。我们还会统计每个类别各自的样本总数,以及模型分类正确的样本数(training.py:264, LunaTrainingApp.logMetrics)。

代码清单 13.17 用掩码计算分类指标

negLabel_mask = metrics_t[METRICS_LABEL_NDX] <= classificationThreshold
negPred_mask = metrics_t[METRICS_PRED_NDX] <= classificationThreshold

posLabel_mask = ~negLabel_mask
posPred_mask = ~negPred_mask

由于 metrics_t[METRICS_LABEL_NDX] 中存放的标签值只能取 {0.0, 1.0},因为我们知道结节状态标签本质上只是 True 或 False。把它和默认值为 0.5 的 classificationThreshold 做比较之后,我们就得到一个布尔数组,其中值为 True 的位置表示该样本的标签是“非结节”(也就是负类)。

我们对预测值做了类似的比较,构造 negPred_mask。不过这里要记住:METRICS_PRED_NDX 中存的是模型给出的“正类概率预测”,它可以取 0.0 到 1.0 之间的任意浮点值。虽然这并不改变比较的方式,但它意味着实际值可能非常接近 0.5。至于正类的两个 mask,则只是负类 mask 的按位取反(可以用 ~ 求反)。

注意:虽然类似的方法在其他项目中也可以使用,但要意识到,我们这里之所以能这么写,是因为当前是一个二分类问题。如果你未来的项目有两个以上的类别,或者样本可能同时属于多个类别,那么构造类似的掩码时,就必须使用更复杂的逻辑。

接下来,我们用这些掩码来计算一些按标签分解的统计量,并把它们存进字典 metrics_dict 中(training.py:270, LunaTrainingApp.logMetrics)。

代码清单 13.18 通过掩码计算分类指标

neg_count = int(negLabel_mask.sum())      #1
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      #2
metrics_dict['correct/neg'] = neg_correct / np.float32(neg_count) * 100
metrics_dict['correct/pos'] = pos_correct / np.float32(pos_count) * 100

#1 转成普通 Python 整数
#2 通过转成 np.float32 来避免整数除法

首先,我们计算整个 epoch 上的平均 loss。由于 loss 是训练过程中真正被优化目标所最小化的单一指标,因此我们始终希望能够追踪它。接着,我们利用刚刚构造的 negLabel_mask,把 loss 的平均限制到“标签为负类”的样本上。对正类 loss,我们也做同样的计算。像这样按类别分别查看 loss,会很有帮助;如果某个类别长期都比另一个类别更难分类,那么这种信息就能推动我们开展更有针对性的调查与改进。

最后,我们会计算被正确分类的样本比例,以及每个标签各自的正确率。由于稍后展示时我们打算用百分数形式,因此这里会再乘以 100。和 loss 一样,这些数字也能为后续改进提供方向。完成这些计算后,我们再用三次 log.info 调用,把结果写出来(training.py:289, LunaTrainingApp.logMetrics)。

代码清单 13.19 训练与验证结果的日志输出

log.info(
    f"E{epoch_ndx} {mode_str:8} {metrics_dict['loss/all']:.4f} loss, "
    f"{metrics_dict['correct/all']:-5.1f}% correct"
)
log.info(
    f"E{epoch_ndx} {mode_str + '_neg':8} {metrics_dict[
    ↪'loss/neg']:.4f} loss, "
    f"{metrics_dict['correct/neg']:-5.1f}% correct ({
    ↪neg_correct} of {neg_count})"
)
log.info(       #1
  # ... line 319
)

#1 正类 'pos' 的日志输出与前面的 'neg' 几乎完全相同,此处略去。

第一条日志是基于所有样本计算得到的,因此标签是 /all;负类(非结节)和正类(结节)的值则分别标记为 /neg/pos。出于篇幅考虑,这里没有展示第三条针对正类的日志语句;它与第二条完全一样,只是把所有地方的 neg 替换成了 pos

13.7 运行训练脚本

现在,既然 training.py 的核心已经完成,我们就可以真正跑起来了。它会初始化并训练模型,同时打印训练效果的统计信息。这里的思路是:把它先启动起来,让它在后台跑着,而我们继续详细讲解模型实现。等讲完的时候,希望就已经有结果可以看了。

我们会从主代码目录运行这个脚本;该目录下应该至少有 p2ch13util 等子目录。使用的 Python 环境应当已经安装好 requirements.txt 中列出的所有库。只要这些库准备就绪,我们就可以运行:

$ python -m p2ch13.training      #1
Starting LunaTrainingApp,
  Namespace(batch_size=256, channels=8, epochs=20, layers=3, num_workers=8)
<p2ch13.dsets.LunaDataset object at 0x7fa53a128710>: 495958 training samples
<p2ch13.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
...

#1 这是 Linux/Bash 下的命令行写法;Windows 用户大概率需要依据自己的安装方式,以不同方式调用 Python。

顺便提醒一下,我还提供了一个 Jupyter Notebook,其中包含了训练应用的调用方式。

现在,我们已经可以跑通整个工作流了。

代码清单 13.20 运行命令(code/p2_run_everything.ipynb

# In[5]:
run('p2ch13.prepcache.LunaPrepCacheApp')

# In[6]:
run('p2ch13.training.LunaTrainingApp', '--epochs=1')

如果第一个 epoch 看起来耗时特别长(超过 10 到 20 分钟),问题很可能出在:LunaDataset 所需的缓存数据还没有提前准备好。关于这里用到的缓存,请参见 12.5.1 节。第 12 章的练习里曾让你写一个脚本来高效地预填缓存;我们也提供了 prepcache.py 文件,可以完成同样的事,调用方式是 python -m p2ch13.prepcache。由于我们每一章都会复制一份 dsets.py 文件,因此缓存也需要在每一章里分别重新准备一遍。这样做在空间和时间上确实不够高效,但它能使每一章的代码更加自包含。对于你今后的项目,我们建议更充分地复用缓存。

一旦训练启动,我们希望确认计算资源的使用情况是否符合预期。判断瓶颈究竟在数据加载还是计算,一个简单方法是:在脚本开始训练几分钟后(也就是等出现类似 E1 Training 16/7750, done at… 这样的输出之后),同时检查 topnvidia-smi

  • 如果那 8 个 Python worker 进程占用了超过 80% 的 CPU,那么很可能说明缓存还没有准备好(在这个项目里我们之所以能这么判断,是因为我已经确保实现中不存在 CPU 端瓶颈;但这在一般项目里并不一定成立)。
  • 如果 nvidia-smi 报告的 GPU-Util 超过 80%,说明你的 GPU 已经被基本打满。

我们的理想情况其实正是 GPU 被打满;我们希望尽可能榨干这部分算力,以更快地完成每个 epoch。对于单张 NVIDIA GTX 1080 Ti 来说,一个 epoch 应该能在 15 分钟以内完成。由于我们的模型相对简单,它并不需要太多 CPU 预处理,因此 CPU 更容易成为瓶颈。而当你使用更深的模型(或者一般来说计算量更大的模型)时,每个 batch 的处理时间会更长,于是 CPU 就有更多时间去完成预处理,从而在 GPU 等待下一批输入之前做完更多工作。

13.7.1 训练所需的数据

如果训练样本数少于 495,958,或者验证样本数少于 55,107,那么最好花点时间仔细检查一下,确认完整数据是不是都已经在正确位置上。对你未来的项目来说,也要确保数据集实际返回的样本数符合你的预期。

首先,来看一下 data-unversioned/part2/luna 目录的基本结构。终端命令 ls 会列出目录下的内容;-1 参数让每一项单独占一行,-p 参数则会在目录名后面加上 /,方便区分:

$ ls -1p data-unversioned/part2/luna/
subset0/
subset1/
...
subset9/

接下来,确认每个 series 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-l…)上提问,也许会有人帮助你排查清楚。

13.7.2 插曲:tqdm 函数

做深度学习,会伴随着大量等待。这里说的是那种现实世界中的等待:坐在那里、时不时瞄一眼墙上的钟、体会“看着水壶就不烧开”(当然 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...

至少,安静地闪着光标还不会把你的滚动缓冲区炸掉!

从根本上说,在这种等待中,我们真正想回答的问题是:“我现在有时间去接杯水吗?” 以及它的一些后续变体,比如有没有时间:

  • 煮一杯咖啡
  • 去吃顿晚饭
  • 飞去巴黎吃顿晚饭。(如果你住的地方去法国吃饭不需要进机场,那请自动把笑话里的“巴黎”替换成“德州巴黎”,这样梗还能成立。)

为了解决这些重大而紧迫的问题,我们要用到 tqdm 函数(sandbox.ipynb)。

代码清单 13.21 使用 tqdm 跟踪进度的示例

# In[]
from tqdm import tqdm
import time
import random
for _ in tqdm(range(234), desc="Sleeping"):
    time.sleep(random.random())

# Out[]
Sleeping:  16%|█▌        | 37/234 [00:22<01:28,  2.22it/s]
Sleeping:  28%|██▊       | 66/234 [00:38<01:37,  1.72it/s]
Sleeping:  41%|████      | 95/234 [00:54<01:04,  2.17it/s]
Sleeping:  70%|██████▉   | 163/234 [01:31<00:49,  1.44it/s]
Sleeping:  82%|████████▏ | 193/234 [01:44<00:16,  2.43it/s]
Sleeping: 100%|██████████| 234/234 [02:08<00:00,  1.83it/s]

虽然代码清单中展示了多个输出行,以便说明 tqdm 随进度推进的显示效果,但默认情况下,tqdm 提供的是一个动态更新的进度条:在同一行不断刷新文本。它的时间估计也相当准确。即便 random.random() 让每次迭代耗时波动很大,它仍然很早就给出了一个相当不错的估算。

就行为上来说,tqdm 会包裹一个循环或可迭代对象,并实时显示动态进度条,例如下面这一行:

70%|██████▉   | 163/234 [01:31<00:49,  1.44it/s]

它会给出一个百分比及可视化条形、已经完成的迭代次数、总迭代次数、已经耗费的时间,以及预计剩余时间。最后面的 1.44it/s 表示每秒迭代 1.44 次。这是在长时间运行循环中跟踪进度的绝佳方式。

深度学习项目往往非常耗时。知道某件事预计什么时候结束,就意味着你可以更合理地安排这段等待时间;而如果预计完成时间明显超出预期,这本身也可能提示你:某个环节不正常,或者你的方法在现实中根本不划算。

13.8 评估模型:99.7% 的正确率,说明我们已经搞定了,对吧?

先来看一段(经过截取的)训练脚本输出。提醒一下,这是通过命令 python -m p2ch13.training 运行得到的:

E1 Training ----/969, starting
...
E1 LunaTrainingApp
E1 trn      2.4576 loss,  99.7% correct
...
E1 val      0.0172 loss,  99.8% correct
...

训练 1 个 epoch 之后,训练集和验证集上都达到了至少 99.7% 的正确率。哇,A+!现在是不是该击个掌,或者至少满意地点点头微微一笑?我们刚刚把癌症解决了,对吧?嗯,并没有。

再仔细看看第一轮 epoch 的完整输出:

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 个 epoch,情况也只是稍有改善:

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 loss 在下降,而 val_neg loss 却没有对应地上升。这表明网络确实在学到一些东西。可惜的是,它学得非常、非常慢。

更糟糕的是,这种失败模式在现实世界中恰恰是最危险的!我们绝对不希望把一个真正的肿瘤误判成一个无害结构,因为那样就意味着患者可能无法得到本应进行的进一步检查和后续治疗。对于你未来做的所有项目来说,理解“错误分类会带来什么后果”都非常重要,因为这会大幅影响你如何设计、训练和评估模型。我们会在下一章更详细地讨论这一点。

但在进入下一章之前,我们需要先升级一下工具链,让结果更容易被理解。我们当然知道你和任何人一样,都非常喜欢眯着眼看一列列数字,但一张图有时候真的胜过千言万语。我们来把这些指标画出来。

13.9 用 TensorBoard 可视化训练指标

我们会使用一个叫 TensorBoard 的工具,作为一种快速又方便的方法,把训练指标从训练循环中拿出来,变成漂亮的图表。这样一来,我们就能观察这些指标的趋势,而不仅仅是每个 epoch 的瞬时值。当你面对的是可视化曲线时,要判断某个值究竟是偶然离群,还是整体趋势的一部分,会容易得多。

你可能会问:“TensorBoard 不是 TensorFlow 项目的一部分吗?为什么我们在 PyTorch 里用它?” 的确,TensorBoard 起源于 TensorFlow,但它后来已经成为跨不同深度学习框架广泛采用的工具,其中当然也包括 PyTorch。PyTorch 团队和 TensorBoard 团队进行了协作,使得它能与 PyTorch 无缝集成。也就是说,PyTorch 这边已经提供了一套很容易使用的 API,能让我们从几乎任何地方把数据挂进去,快速完成显示。如果你继续深耕深度学习,你大概率会经常看到、也会经常使用 TensorBoard。

如果你一直跟着跑本章示例,那么你的磁盘上现在应该已经有了一些随时可供展示的数据。下面我们就来看如何启动 TensorBoard,并看看它能显示什么。

13.9.1 运行 TensorBoard

默认情况下,我们的训练脚本会把指标数据写到 runs/ 子目录下。用 Bash shell 查看这个目录内容时,你可能会看到类似下面的结果:

$ ls -lA runs/p2ch13/
total 24
drwxrwxr-x 2 elis elis 4096 Sep 15 13:22 2020-01-01_12.↪
↪55.27-trn-dlwpt/                                       #1
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/                                       #2
drwxrwxr-x 2 elis elis 4096 Sep 15 15:14 2020-01-01_13.↪
↪31.23-val-dwlpt/

#1 之前那个只跑了 1 个 epoch 的 run
#2 比较新的那个跑了 10 个 epoch 的训练 run

要安装 TensorBoard,可以执行 pip install tensorboard。如果你系统里已经有别的版本,也没关系。只要确保对应目录已经加入路径,或者直接用类似 ../path/to/tensorboard --logdir runs/ 的方式调用它即可。其实你从哪个目录启动 TensorBoard 并不重要,只要通过 --logdir 参数明确指向数据所在的位置就行。

现在启动 TensorBoard:

$ tensorboard --logdir runs/

TensorBoard 1.14.0 at http://localhost:6006/ (Press CTRL+C to quit)

启动完成后,你就应该可以在浏览器中打开 http://localhost:6006,看到主界面了。图 13.10 展示的就是它大概的样子。

注意:如果训练运行在另一台机器上,而浏览器不在同一台机器上,那么你需要把 localhost 替换成正确的主机名或 IP 地址。

image.png

图 13.10 TensorBoard 主界面,展示了一对训练 run 与验证 run

在浏览器窗口的顶部,你应该能看到一个橙色标题栏。右侧有常见的小部件,比如设置选项;左侧则是我们提供的数据类型入口。你至少应该能看到以下这些标签:

  • Scalars(默认标签)
  • Histograms
  • Precision-recall curves(显示为 PR Curves)

你可能还会看到第二个标签是 Distributions(位于 Scalars 右侧,就像图 13.10 中那样)。我们这里不会使用或讨论它。请确保你当前选中的是 Scalars。

左侧是一组显示控制选项,以及当前已有 run 的列表。Smoothing(平滑)选项在数据特别噪声很大的时候非常有用;它能把曲线抚平,让你更容易看出整体趋势——当然,如果平滑开得太大,也可能把重要趋势给抹掉!原始未平滑的数据仍然会以同色的淡线形式显示在背景中。图 13.11 就展示了这一点,尽管印刷成黑白后可能不太容易看清。

image.png

图 13.11 TensorBoard 侧边栏,Smoothing 设为 0.6,并选择了两个 run 进行显示

根据你运行训练脚本的次数不同,列表里可能会有多个 run 可供选择。若一次渲染的 run 太多,图表会变得非常嘈杂,因此对当前不感兴趣的 run,请毫不犹豫地取消勾选。

如果你想永久删除某个 run,完全可以在 TensorBoard 运行期间,直接把对应数据从磁盘上删掉。你可以这样清理那些已经崩溃、带有 bug、未收敛、或者陈旧到不再值得关注的实验结果。run 的数量通常增长得很快,因此经常修剪一下很有帮助。对特别重要的 run,最好给它们重命名,或者移到更永久的目录中,以免误删。若要同时删除某个训练 run 和对应验证 run,可以执行下面的命令(记得把章节、日期、时间改成你自己想删的那个 run):

$ rm -rf runs/p2ch13/2020-01-01_12.02.15_*

要注意的是:删除某些 run 之后,列表中排在它们后面的 run 会整体上移,从而会被重新分配新的颜色。

好了,终于说到 TensorBoard 的重点:那些漂亮的图!主屏幕的大部分区域现在应该已经被训练和验证指标填满了,如图 13.12 所示。

image.png

图 13.12 TensorBoard 的主显示区域,它清楚展示了:我们在真正的结节样本上的表现糟糕得令人发指

这可比盯着类似 E1 trn_pos 924.34 loss, 0.2% correct (3 of 1215) 这种日志,容易理解多了!不妨花点时间,把鼠标移到曲线上,对照一下 tooltip 里给出的数字,和 training.py 在同一轮训练时打印出的数字。你应该能看到 tooltip 中的 Value 列,与训练日志里对应值是一一对应的。等你完全搞清楚 TensorBoard 到底在显示什么之后,我们再继续往下看:这些数字是如何被写进去的。

13.9.2 在指标记录函数中加入 TensorBoard 支持

我们将使用 torch.utils.tensorboard 模块,把数据写成 TensorBoard 可消费的格式。这样一来,无论是这个项目还是今后的其他项目,我们都能快速、轻松地输出各种指标。TensorBoard 支持同时接收 NumPy 数组和 PyTorch 张量,但既然我们没有理由额外把数据转成 NumPy,这里就全程使用 PyTorch tensor。

第一步,是创建 SummaryWriter 对象(它来自 torch.utils.tensorboard)。我们唯一会传入的参数是 log_dir,其值会初始化成类似 runs/p2ch13/2020-01-01_12.55.27-trn-dlwpt 这样的路径。我们还可以给训练脚本加一个 comment 参数,把 dlwpt 改成更有信息量的名字;想了解更多,可以执行 python -m p2ch13.training --help

我们会创建两个 writer,一个用于训练 run,一个用于验证 run。这两个 writer 会在每一个 epoch 中重复使用。SummaryWriter 初始化时会有一个副作用:它会顺便把这些 log_dir 目录创建出来。这些目录会出现在 TensorBoard 中;如果训练脚本在真正写入任何数据之前就崩溃了(实验阶段这种情况很常见),这些空目录就会变成 UI 中毫无意义的“空 run”,造成干扰。为了尽量少产生这种空白垃圾 run,我们会等到第一次真正要写数据时,才去实例化 SummaryWriter。这个函数由 logMetrics() 调用(training.py:127, initTensorboardWriters)。

代码清单 13.22 初始化训练与验证的 TensorBoard writer

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)

如果你还记得,第一轮 epoch 的训练其实有点混乱:训练循环最前面的那些输出基本上都接近随机。如果我们把第一批数据的指标也记下来,它们就会让整体趋势稍微有点失真。回忆一下图 13.11,TensorBoard 提供了 smoothing 功能,可以一定程度上平滑掉这些噪声。

另一种办法是:干脆完全跳过第一轮训练数据的指标记录。不过我们的模型训练得足够快,因此即使第一轮有噪声,它的结果也依然值得看。你完全可以按自己喜好改变这种行为;而本书第二部分接下来的章节,会继续沿用当前这种“包含第一轮 noisy training epoch”的模式。

提示:如果你做了很多实验,而这些实验常常因为抛异常或很快就把训练脚本杀掉,那么你的 runs/ 目录里很可能会留下不少垃圾 run。别犹豫,直接清掉!

把标量写入 TensorBoard

写标量非常简单。我们可以把前面已经构建好的 metrics_dict 拿出来,把其中每一组 key/value 传给 writer.add_scalartorch.utils.tensorboard.SummaryWriter 提供的 add_scalar 方法(mng.bz/RAqj)签名如下(PyTorch torch/utils/tensorboard/writer.py:267)。

代码清单 13.23 添加指标的方法

def add_scalar(self, tag, scalar_value, global_step=None, walltime=None):
    # ...

参数 tag 告诉 TensorBoard 这个值该画到哪一张图上,scalar_value 则是这个数据点在 Y 轴上的值,而 global_step 则充当 X 轴。

回忆一下,我们在 doTraining 函数中会更新 totalTrainingSamples_count 变量。接下来,我们就把 totalTrainingSamples_count 作为 TensorBoard 图表的 X 轴,也就是把它传给 global_step 参数(training.py:323, LunaTrainingApp.logMetrics)。

代码清单 13.24 把训练指标写入 TensorBoard

for key, value in metrics_dict.items():
  writer.add_scalar(key, value, self.totalTrainingSamples_count)

注意,我们在 key 名称里使用了斜杠,例如 'loss/all',这会使 TensorBoard 按照 / 前面的那部分字符串来对图表进行分组。

文档中建议把 epoch 编号作为 global_step,但那样会带来一些麻烦。通过使用“模型已经看过的训练样本总数”作为 X 轴,我们就能做到:即便以后改变了每个 epoch 中包含的样本数,也仍然能够把新的图和现在的图直接进行比较。如果只是说“这个模型在少一半 epoch 的情况下训练完成”,那其实没什么意义——因为谁知道每个 epoch 是否长了 4 倍!当然,这并不一定是标准做法;在实践中,你会看到很多不同方式来定义 global step。

13.10 为什么模型就是学不会检测结节?

我们的模型显然在学到一些东西——随着 epoch 增加,loss 的趋势线是稳定的,而且结果是可重复的。然而,模型正在学习的东西,与我们真正希望它学到的东西之间,存在着脱节。到底发生了什么?我们用一个简单的比喻来说明这个问题。

假设一位教授给学生出了一份期末考试,其中有 100 道判断题。学生们手上有过去 30 年这位教授历年试卷的旧版本,结果他们发现:每次考试里只有一两道题的答案是 True,其余 98 或 99 道题永远都是 False。

如果这门课不是按曲线评分,而是采用普通标准——比如 90% 以上就是 A——那么想拿 A+ 实在太容易了:把所有题都标成 False 就行!假设今年试卷里仍然只有 1 道题答案是 True。那么,图 13.13 左边那位学生,如果只是机械地把所有题都填成 False,就能拿到 99 分;但这并不表示他真的学会了任何东西(当然,除了“如何通过刷旧卷投机取巧”之外)。而这,基本上就是我们当前模型正在做的事。

image.png

图 13.13 教授给两个知识水平明显不同的学生打了同样的分数。第 9 题是唯一一个答案为 True 的题。

与之相对,图 13.13 右边那位学生,同样也答对了 99% 的题,但他之所以只错一道,是因为他把两道题答成了 True。直觉告诉我们:右边那位学生显然比“全填 False”的学生更懂得材料。因为在只错一道题的前提下,恰好找出那唯一一道 True,本身就很难!可惜的是,不管是老师的打分方式,还是我们当前模型的评估方式,都无法反映出这种直觉。

我们现在面对的是类似情形:对于“这个候选是不是结节?”这个问题,99.7% 的答案其实都是“不是”。于是模型就走了最轻松的那条路——对每个问题一律回答 False。

不过,即便如此,如果我们再仔细回头看模型的数字,会发现训练集和验证集上的 loss 的确在下降!仅仅是这一点——说明我们在癌症检测问题上至少已经抓住了一点点信号——就足以给我们一些希望。下一章的任务,就是把这点潜力真正释放出来。我们会在第 14 章一开始引入一些新的相关术语,然后重新设计一套更合理的“评分方式”,让它不像现在这样那么容易被投机取巧地“刷高分”。

13.11 结论

这一章我们已经走了很远——现在我们已经有了一个模型和一套训练循环,并且能够消费上一章构建出来的数据。我们的指标不仅会输出到控制台,也能够以图形方式展示出来。

虽然当前结果还远远不能投入使用,但实际上,我们离可用已经比表面上看起来更近了一点。在第 14 章中,我们会改进用于追踪进展的那些指标,并利用它们来指导我们做出必要的修改,让模型开始产生更合理的结果。

13.12 练习

实现一个程序:把 LunaDataset 包装进一个 DataLoader,然后迭代它,并计时整个过程需要多长时间。把这些时间与第 10 章练习中的时间做对比。运行脚本时,要注意缓存当前处于什么状态:

  • num_workers=0、1、2 时,会有什么影响?
  • 在不发生内存溢出的前提下,你的机器在某个 batch_sizenum_workers 组合下所能支持的最大值是多少?

noduleInfo_list 的排序顺序反转。观察模型在训练 1 个 epoch 后的行为有何变化。

修改 logMetrics,改变 TensorBoard 中 run 名称与 key 名称的命名方式:

  • 试验不同的斜杠 / 放置方式,看看传给 writer.add_scalar 的 key 如何影响分组
  • 让训练和验证共用同一个 writer,并把 trnval 字符串加到 key 名称中
  • 按照你自己的喜好,定制日志目录和 key 的命名方式

小结

  • DataLoader 可以在多个进程中从任意数据集中加载数据。这使得原本闲置的 CPU 资源可以被用来准备输入数据,持续喂给 GPU。
  • DataLoader 会从数据集中加载多个样本,并将它们整理成一个 batch。PyTorch 模型期望处理的是 batch,而不是单独样本。
  • 通过改变单个样本出现的相对频率,DataLoader 也可以被用来操控任意数据集。
  • 在本书第二部分的大部分内容里,我们会使用 PyTorch 的 torch.optim.SGD(随机梯度下降)优化器,学习率设为 0.001,momentum 设为 0.99。这些值对很多深度学习项目来说,也都是合理的默认值。
  • 我们最初用于分类的模型,与第 8 章中使用的模型非常相似。这样做能让我们从一个有理由相信“足够有效”的模型开始起步。如果后来发现模型设计本身是性能瓶颈,我们完全可以再回头重新设计。
  • 在训练时监控哪些指标,是一件非常重要的事。人们很容易不小心选到一些会误导自己的指标。对于我们当前的数据来说,整体样本正确率并没有什么用。第 14 章会详细讨论:如何评估并选择更好的指标。
  • TensorBoard 可以用来可视化各种各样的指标。这样一来,某些类型的信息——尤其是随训练 epoch 变化的趋势数据——就会变得容易理解得多。