PyTorch 深度学习(GPT 重译)(五)(2)https://developer.aliyun.com/article/1485249
13.4 更新用于分割的模型
现在是按照图 13.8 中的步骤 2A 进行操作的时候了。我们已经对分割理论和 U-Net 的历史有了足够的了解;现在我们想要更新我们的代码,从模型开始。我们不再只输出一个给出真或假的二进制分类,而是集成一个 U-Net,以获得一个能够为每个像素输出概率的模型:也就是执行分割。我们不打算从头开始实现自定义 U-Net 分割模型,而是打算从 GitHub 上的一个开源存储库中适用一个现有的实现。
github.com/jvanvugt/pytorch-unet 上的 U-Net 实现似乎很好地满足我们的需求。它是 MIT 许可的(版权 2018 Joris),包含在一个单独的文件中,并且有许多参数选项供我们调整。该文件包含在我们的代码存储库中的 util/unet.py 中,同时附有原始存储库的链接和使用的完整许可证文本。
注意 虽然对于个人项目来说这不是太大问题,但重要的是要注意你为项目使用的开源软件附带的许可条款。MIT 许可证是最宽松的开源许可证之一,但它仍对使用 MIT 许可的代码的用户有要求!还要注意,即使作者在公共论坛上发布他们的作品,他们仍保留版权(是的,即使在 GitHub 上也是如此),如果他们没有包含许可证,这并不意味着该作品属于公共领域。恰恰相反!这意味着你没有任何使用代码的许可,就像你没有权利从图书馆借来的书中全文复制一样。
我们建议花一些时间检查代码,并根据你到目前为止建立的知识,识别体系结构中反映在代码中的构建模块。你能发现跳跃连接吗?对你来说一个特别有价值的练习是通过查看代码绘制显示模型布局的图表。
现在我们找到了一个符合要求的 U-Net 实现,我们需要调整它以使其适用于我们的需求。一般来说,留意可以使用现成解决方案的情况是一个好主意。重要的是要了解存在哪些模型,它们是如何实现和训练的,以及是否可以拆解和应用到我们当前正在进行的项目中。虽然这种更广泛的知识是随着时间和经验而来的,但现在开始建立这个工具箱是一个好主意。
13.4.1 将现成模型调整为我们的项目
现在我们将对经典 U-Net 进行一些更改,并在此过程中加以证明。对你来说一个有用的练习是比较原始模型和经过调整后的模型的结果,最好一次删除一个以查看每个更改的影响(这在研究领域也称为消融研究)。
图 13.8 本章大纲,重点关注我们分割模型所需的更改
首先,我们将通过批量归一化将输入传递。这样,我们就不必在数据集中自己归一化数据;更重要的是,我们将获得在单个批次上估计的归一化统计数据(读取均值和标准差)。这意味着当某个批次由于某种原因变得单调时–也就是说,当所有馈送到网络中的 CT 裁剪中没有什么可见时–它将被更强烈地缩放。每个时期随机选择批次中的样本将最大程度地减少单调样本最终进入全单调批次的机会,从而过度强调这些单调样本。
其次,由于输出值是不受限制的,我们将通过一个 nn.Sigmoid 层将输出传递以将输出限制在 [0, 1] 范围内。第三,我们将减少模型允许使用的总深度和滤波器数量。虽然这有点超前,但使用标准参数的模型容量远远超过我们的数据集大小。这意味着我们不太可能找到一个与我们确切需求匹配的预训练模型。最后,尽管这不是一种修改,但重要的是要注意我们的输出是单通道,输出的每个像素表示模型估计该像素是否属于结节的概率。
通过实现一个具有三个属性的模型来简单地包装 U-Net:分别是我们想要添加的两个特征和 U-Net 本身–我们可以像在这里处理任何预构建模块一样对待。我们还将把收到的任何关键字参数传递给 U-Net 构造函数。
列表 13.1 model.py:17,class UNetWrapper
class UNetWrapper(nn.Module): def __init__(self, **kwargs): # ❶ super().__init__() self.input_batchnorm = nn.BatchNorm2d(kwargs['in_channels']) # ❷ self.unet = UNet(**kwargs) # ❸ self.final = nn.Sigmoid() self._init_weights() # ❹
❶ kwarg 是一个包含传递给构造函数的所有关键字参数的字典。
❷ BatchNorm2d 要求我们指定输入通道的数量,我们从关键字参数中获取。
❸ U-Net:这里包含的是一个小细节,但它确实在发挥作用。
❹ 就像第十一章中的分类器一样,我们使用我们自定义的权重初始化。该函数已复制,因此我们不会再次显示代码。
forward方法是一个同样简单的序列。我们可以使用nn.Sequential的实例,就像我们在第八章中看到的那样,但为了代码的清晰度和堆栈跟踪的清晰度,我们在这里明确说明。
第 13.2 节 model.py:50, UNetWrapper.forward
def forward(self, input_batch): bn_output = self.input_batchnorm(input_batch) un_output = self.unet(bn_output) fn_output = self.final(un_output) return fn_output
请注意,我们在这里使用nn.BatchNorm2d。这是因为 U-Net 基本上是一个二维分割模型。我们可以调整实现以使用 3D 卷积,以便跨切片使用信息。直接实现的内存使用量将大大增加:也就是说,我们将不得不分割 CT 扫描。此外,Z 方向的像素间距比平面方向大得多,这使得结节不太可能跨越多个切片存在。这些考虑因素使得我们的目的不太吸引人的完全 3D 方法。相反,我们将调整我们的 3D 数据,一次对一个切片进行分割,提供相邻切片的上下文(例如,随着相邻切片的出现,检测到明亮的块确实是血管变得更容易)。由于我们仍然坚持以 2D 形式呈现数据,我们将使用通道来表示相邻切片。我们对第三维的处理类似于我们在第七章中将全连接模型应用于图像的方式:模型将不得不重新学习我们沿轴向丢弃的邻接关系,但对于模型来说这并不困难,尤其是考虑到由于目标结构的小尺寸而给出的上下文切片数量有限。
13.5 更新用于分割的数据集
本章的源数据保持不变:我们正在使用 CT 扫描和有关它们的注释数据。但是我们的模型期望输入和输出的形式与以前不同。正如我们在图 13.9 的第 2B 步骤中所暗示的,我们以前的数据集生成了 3D 数据,但现在我们需要生成 2D 数据。
图 13.9 本章概述,重点关注我们分割数据集所需的变化
原始 U-Net 实现没有使用填充卷积,这意味着虽然输出分割地图比输入小,但输出的每个像素都具有完全填充的感受野。用于确定该输出像素的所有输入像素都没有填充、虚构或不完整。因此,原始 U-Net 的输出将完全平铺,因此它可以与任何大小的图像一起使用(除了输入图像的边缘,那里将缺少一些上下文)。
对于我们的问题采用相同的像素完美方法存在两个问题。第一个与卷积和下采样之间的交互有关,第二个与我们的数据性质是三维的有关。
13.5.1 U-Net 具有非常具体的输入尺寸要求
第一个问题是 U-Net 的输入和输出补丁的大小非常具体。为了使每个卷积线的两个像素损失在下采样之前和之后对齐(特别是考虑到在较低分辨率处进一步卷积收缩),只有某些输入尺寸才能起作用。U-Net 论文使用了 572×572 的图像补丁,导致了 388×388 的输出地图。输入图像比我们的 512×512 CT 切片大,输出则小得多!这意味着靠近 CT 扫描切片边缘的任何结节都不会被分割。尽管在处理非常大的图像时这种设置效果很好,但对于我们的用例来说并不理想。
我们将通过将 U-Net 构造函数的padding标志设置为True来解决这个问题。这意味着我们可以使用任何大小的输入图像,并且我们将得到相同大小的输出。我们可能会在图像边缘附近失去一些保真度,因为位于那里的像素的感受野将包括已被人为填充的区域,但这是我们决定接受的妥协。
13.5.2 3D 与 2D 数据的 U-Net 权衡
第二个问题是我们的 3D 数据与 U-Net 的 2D 预期输入不完全对齐。简单地将我们的 512×512×128 图像输入到转换为 3D 的 U-Net 类中是行不通的,因为我们会耗尽 GPU 内存。每个图像是 29×29×27,每个体素 22 字节。U-Net 的第一层是 64 个通道,或 26。这是 9 + 9 + 7 + 2 + 6 的指数= 33,或 8 GB 仅用于第一个卷积层。有两个卷积层(16 GB);然后每次下采样都会减半分辨率但加倍通道,这是第一个下采样后每层另外 2 GB(记住,减半分辨率会导致数据减少八分之一,因为我们处理的是 3D 数据)。因此,甚至在我们到达第二次下采样之前,我们就已经达到了 20 GB,更不用说模型上采样端或处理自动梯度的任何内容了。
注意 有许多巧妙和创新的方法可以解决这些问题,我们绝不认为这是唯一可行的方法。⁶ 我们认为这种方法是在这本书中我们项目所需的水平上完成工作的最简单方法之一。我们宁愿保持简单,这样我们就可以专注于基本概念;聪明的东西可以在你掌握基础知识后再来。
如预期的那样,我们不会尝试在 3D 中进行操作,而是将每个切片视为一个 2D 分割问题,并通过提供相邻切片作为单独的通道来绕过第三维中的上下文问题。我们的主要通道不再是我们从照片图像中熟悉的“红色”,“绿色”和“蓝色”通道,而是“上面两个切片”,“上面一个切片”,“我们实际分割的切片”,“下面一个切片”等。
然而,这种方法并非没有权衡。当表示为通道时,我们失去了切片之间的直接空间关系,因为所有通道将被卷积核线性组合,没有它们相隔一两个切片,上下的概念。我们还失去了来自真正的 3D 分割的深度维度中更广泛的感受野。由于 CT 切片通常比行和列的分辨率厚,我们获得的视野比起初看起来要宽一些,这应该足够了,考虑到结节通常跨越有限数量的切片。
要考虑的另一个方面,对于当前和完全 3D 方法都相关的是,我们现在忽略了确切的切片厚度。这是我们的模型最终将不得不学会对抗的东西,通过呈现具有不同切片间距的数据。
一般来说,没有一个简单的流程图或经验法则可以提供关于做出哪些权衡或给定一组妥协是否太多的标准答案。然而,仔细的实验至关重要,系统地测试假设之后的假设可以帮助缩小哪些变化和方法对手头问题有效的范围。虽然在等待最后一组结果计算时进行一连串的更改很诱人,但要抵制这种冲动。
这一点非常重要:不要同时测试多个修改。有很高的机会其中一个改变会与另一个产生不良互动,你将没有坚实的证据表明任何一个值得进一步调查。说了这么多,让我们开始构建我们的分割数据集。
13.5.3 构建地面真实数据
我们需要解决的第一件事是我们的人工标记的训练数据与我们希望从模型中获得的实际输出之间存在不匹配。我们有注释点,但我们想要一个逐体素掩模,指示任何给定的体素是否属于结节。我们将不得不根据我们拥有的数据构建该掩模,然后进行一些手动检查,以确保构建掩模的例程表现良好。
在规模上验证这些手动构建的启发式方法可能会很困难。当涉及确保每个结节都得到适当处理时,我们不会尝试做任何全面的工作。如果我们有更多资源,像“与(或支付)某人合作创建和/或手动验证所有内容”这样的方法可能是一个选择,但由于这不是一个资金充足的努力,我们将依靠检查少量样本并使用非常简单的“输出看起来合理吗?”方法。
为此,我们将设计我们的方法和我们的 API,以便轻松调查我们的算法正在经历的中间步骤。虽然这可能导致稍微笨重的函数调用返回大量中间值的元组,但能够轻松获取结果并在笔记本中绘制它们使得这种笨重值得。
边界框
我们将从将我们拥有的结节位置转换为覆盖整个结节的边界框开始(请注意,我们只会为实际结节这样做)。如果我们假设结节位置大致位于肿块中心,我们可以沿着所有三个维度从该点向外追踪,直到遇到低密度的体素,表明我们已经到达了主要充满空气的正常肺组织。让我们在图 13.10 中遵循这个算法。
图 13.10 围绕肺结节找到边界框的算法
我们从我们的搜索起点(图中的 O)开始在注释的结节中心的体素处。然后我们检查沿着列轴的原点相邻体素的密度,用问号(?)标记。由于两个检查的体素都包含密集组织,显示为浅色,我们继续我们的搜索。在将列搜索距离增加到 2 后,我们发现左侧的体素密度低于我们的阈值,因此我们在 2 处停止搜索。
接下来,我们在行方向上执行相同的搜索。同样,我们从原点开始,这次我们向上下搜索。当我们的搜索距离变为 3 时,在上下搜索位置都遇到了低密度的体素。我们只需要一个就可以停止我们的搜索!
我们将跳过在第三维度中显示搜索。我们最终的边界框宽度为五个体素,高度为七个体素。这是在代码中的索引方向的样子。
代码清单 13.3 dsets.py:131,Ct.buildAnnotationMask
center_irc = xyz2irc( candidateInfo_tup.center_xyz, # ❶ self.origin_xyz, self.vxSize_xyz, self.direction_a, ) ci = int(center_irc.index) # ❷ cr = int(center_irc.row) cc = int(center_irc.col) index_radius = 2 try: while self.hu_a[ci + index_radius, cr, cc] > threshold_hu and \ self.hu_a[ci - index_radius, cr, cc] > threshold_hu: # ❸ index_radius += 1 except IndexError: # ❹ index_radius -= 1
❶ 这里的 candidateInfo_tup 与我们之前看到的相同:由 getCandidateInfoList 返回。
❷ 获取中心体素的索引,这是我们的起点
❸ 先前描述的搜索
❹ 超出张量大小的索引的安全网
我们首先获取中心数据,然后在while循环中进行搜索。作为一个轻微的复杂性,我们的搜索可能超出张量的边界。我们对这种情况并不太担心,也很懒,所以我们只捕获索引异常。
请注意,当密度降低到阈值以下时,我们停止增加非常粗略的radius值,因此我们的边界框应包含低密度组织的一个体素边界(至少在一侧;由于结节可能与肺壁等密度较高的组织相邻,当我们在任一侧遇到空气时,我们必须停止搜索)。由于我们将center_index + index_radius和center_index - index_radius与该阈值进行比较,因此该一个体素边界仅存在于最接近结节位置的边缘。这就是为什么我们需要这些位置相对居中。由于一些结节与肺和肌肉或骨骼等密度较高的组织之间的边界相邻,我们不能独立追踪每个方向,因为一些边缘最终会远离实际结节。
然后,我们使用row_radius和col_radius重复相同的半径扩展过程(为简洁起见,此代码被省略)。完成后,我们可以将边界框掩码数组中的一个框设置为True(我们很快就会看到boundingBox_ary的定义;这并不令人惊讶)。
好的,让我们将所有这些封装在一个函数中。我们遍历所有结节。对于每个结节,我们执行之前显示的搜索(我们在代码清单 13.4 中省略了)。然后,在一个布尔张量boundingBox_a中,我们标记我们找到的边界框。
循环结束后,我们通过取边界框掩码和密度高于-700 HU(或 0.3 g/cc)的组织之间的交集来进行一些清理。这将剪裁掉我们的盒子的角(至少是那些不嵌入在肺壁中的盒子),使其更符合结节的轮廓。
代码清单 13.4 dsets.py:127,Ct.buildAnnotationMask
def buildAnnotationMask(self, positiveInfo_list, threshold_hu = -700): boundingBox_a = np.zeros_like(self.hu_a, dtype=np.bool) # ❶ for candidateInfo_tup in positiveInfo_list: # ❷ # ... line 169 boundingBox_a[ ci - index_radius: ci + index_radius + 1, cr - row_radius: cr + row_radius + 1, cc - col_radius: cc + col_radius + 1] = True # ❸ mask_a = boundingBox_a & (self.hu_a > threshold_hu) # ❹ return mask_a
❶ 从与 CT 相同大小的全 False 张量开始
❷ 遍历结节。作为我们只查看结节的提醒,我们称之为 positiveInfo_list。
❸ 在获取结节半径后(搜索本身被省略了),我们标记边界框。
❹ 将掩码限制为高于我们密度阈值的体素
让我们看一下图 13.11,看看这些掩码在实践中是什么样子。完整彩色图像可以在 p2ch13_explore_data.ipynb 笔记本中找到。
图 13.11 ct.positive_mask中突出显示的三个结节,白色标记
右下角的结节掩码展示了我们矩形边界框方法的局限性,包括部分肺壁。这当然是我们可以修复的问题,但由于我们还没有确信这是我们时间和注意力的最佳利用方式,所以我们暂时让它保持原样。接下来,我们将继续将此掩码添加到我们的 CT 类中。
在 CT 初始化期间调用掩码创建
现在我们可以将结节信息元组列表转换为与 CT 形状相同的二进制“这是一个结节吗?”掩码,让我们将这些掩码嵌入到我们的 CT 对象中。首先,我们将我们的候选人筛选为仅包含结节的列表,然后我们将使用该列表构建注释掩码。最后,我们将收集具有至少一个结节掩码体素的唯一数组索引集。我们将使用这些数据来塑造我们用于验证的数据。
代码清单 13.5 dsets.py:99,Ct.__init__
def __init__(self, series_uid): # ... line 116 candidateInfo_list = getCandidateInfoDict()[self.series_uid] self.positiveInfo_list = [ candidate_tup for candidate_tup in candidateInfo_list if candidate_tup.isNodule_bool # ❶ ] self.positive_mask = self.buildAnnotationMask(self.positiveInfo_list) self.positive_indexes = (self.positive_mask.sum(axis=(1,2)) # ❷ .nonzero()[0].tolist()) # ❸
❶ 用于结节的过滤器
❷ 给出一个 1D 向量(在切片上)中每个切片中标记的掩码体素数量
❸ 获取具有非零计数的掩码切片的索引,我们将其转换为列表
敏锐的眼睛可能已经注意到了getCandidateInfoDict函数。定义并不令人惊讶;它只是getCandidateInfoList函数中相同信息的重新表述,但是预先按series_uid分组。
代码清单 13.6 dsets.py:87
@functools.lru_cache(1) # ❶ def getCandidateInfoDict(requireOnDisk_bool=True): candidateInfo_list = getCandidateInfoList(requireOnDisk_bool) candidateInfo_dict = {} for candidateInfo_tup in candidateInfo_list: candidateInfo_dict.setdefault(candidateInfo_tup.series_uid, []).append(candidateInfo_tup) # ❷ return candidateInfo_dict
❶ 这对于避免 Ct init 成为性能瓶颈很有用。
❷ 获取字典中系列 UID 的候选人列表,如果找不到,则默认为一个新的空列表。然后将当前的 candidateInfo_tup 附加到其中。
缓存掩模的块以及 CT
在早期章节中,我们缓存了围绕结节候选项中心的 CT 块,因为我们不想每次想要 CT 的小块时都读取和解析整个 CT 的数据。我们希望对我们的新的 positive _mask 也做同样的处理,因此我们还需要从我们的 Ct.getRawCandidate 函数中返回它。这需要额外的一行代码和对 return 语句的编辑。
列表 13.7 dsets.py:178, Ct.getRawCandidate
def getRawCandidate(self, center_xyz, width_irc): center_irc = xyz2irc(center_xyz, self.origin_xyz, self.vxSize_xyz, self.direction_a) slice_list = [] # ... line 203 ct_chunk = self.hu_a[tuple(slice_list)] pos_chunk = self.positive_mask[tuple(slice_list)] # ❶ return ct_chunk, pos_chunk, center_irc # ❷
❶ 新添加的
❷ 这里返回了新值
这将通过 getCtRawCandidate 函数缓存到磁盘,该函数打开 CT,获取指定的原始候选项,包括结节掩模,并在返回 CT 块、掩模和中心信息之前剪裁 CT 值。
列表 13.8 dsets.py:212
@raw_cache.memoize(typed=True) def getCtRawCandidate(series_uid, center_xyz, width_irc): ct = getCt(series_uid) ct_chunk, pos_chunk, center_irc = ct.getRawCandidate(center_xyz, width_irc) ct_chunk.clip(-1000, 1000, ct_chunk) return ct_chunk, pos_chunk, center_irc
prepcache 脚本为我们预先计算并保存所有这些值,帮助保持训练速度。
清理我们的注释数据
我们在本章还要处理的另一件事是对我们的注释数据进行更好的筛选。事实证明,candidates.csv 中列出的几个候选项出现了多次。更有趣的是,这些条目并不是彼此的完全重复。相反,原始的人类注释在输入文件之前并没有经过充分的清理。它们可能是关于同一结节在不同切片上的注释,这甚至可能对我们的分类器有益。
在这里我们将进行一些简化,并提供一个经过清理的 annotation.csv 文件。为了完全了解这个清理文件的来源,您需要知道 LUNA 数据集源自另一个名为肺部图像数据库协会图像集(LIDC-IDRI)的数据集,并包含来自多名放射科医生的详细注释信息。我们已经完成了获取原始 LIDC 注释、提取结节、去重并将它们保存到文件 /data/part2/luna/annotations_with_malignancy.csv 的工作。
有了那个文件,我们可以更新我们的 getCandidateInfoList 函数,从我们的新注释文件中提取结节。首先,我们遍历实际结节的新注释。使用 CSV 读取器,¹⁰我们需要将数据转换为适当的类型,然后将它们放入我们的 CandidateInfoTuple 数据结构中。
列表 13.9 dsets.py:43, def getCandidateInfoList
candidateInfo_list = [] with open('data/part2/luna/annotations_with_malignancy.csv', "r") as f: for row in list(csv.reader(f))[1:]: # ❶ series_uid = row[0] annotationCenter_xyz = tuple([float(x) for x in row[1:4]]) annotationDiameter_mm = float(row[4]) isMal_bool = {'False': False, 'True': True}[row[5]] candidateInfo_list.append( # ❷ CandidateInfoTuple( True, # ❸ True, # ❹ isMal_bool, annotationDiameter_mm, series_uid, annotationCenter_xyz, ) )
❶ 对于注释文件中表示一个结节的每一行,…
❷ … 我们向我们的列表添加一条记录。
❸ isNodule_bool
❹ hasAnnotation_bool
类似地,我们像以前一样遍历 candidates.csv 中的候选项,但这次我们只使用非结节。由于这些不是结节,结节特定信息将只填充为 False 和 0。
列表 13.10 dsets.py:62, def getCandidateInfoList
with open('data/part2/luna/candidates.csv', "r") as f: for row in list(csv.reader(f))[1:]: # ❶ series_uid = row[0] # ... line 72 if not isNodule_bool: # ❷ candidateInfo_list.append( # ❸ CandidateInfoTuple( False, # ❹ False, # ❺ False, # ❻ 0.0, series_uid, candidateCenter_xyz, ) )
❶ 对于候选文件中的每一行…
❷ … 但只有非结节(我们之前有其他的)…
❸ … 我们添加一个候选记录。
❹ isNodule_bool
❺ hasAnnotation_bool
❻ isMal_bool
除了添加hasAnnotation_bool和isMal_bool标志(我们在本章不会使用),新的注释将插入并可像旧的一样使用。
注意 您可能会想知道为什么我们到现在才讨论 LIDC。事实证明,LIDC 已经围绕基础数据集构建了大量工具,这些工具是特定于 LIDC 的。您甚至可以从 PyLIDC 获取现成的掩模。这些工具呈现了一个有些不切实际的图像,说明了给定数据集可能具有的支持类型,因为 LIDC 的支持异常充分。我们对 LUNA 数据所做的工作更具典型性,并提供更好的学习,因为我们花时间操纵原始数据,而不是学习别人设计的 API。
13.5.4 实现 Luna2dSegmentationDataset
与之前的章节相比,我们在本章将采用不同的方法来进行训练和验证集的划分。我们将有两个类:一个作为适用于验证数据的通用基类,另一个作为基类的子类,用于训练集,具有随机化和裁剪样本。
尽管这种方法在某些方面有些复杂(例如,类并不完全封装),但实际上简化了选择随机训练样本等逻辑。它还非常清楚地显示了哪些代码路径影响训练和验证,哪些是仅与训练相关的。如果没有这一点,我们发现一些逻辑可能会以难以跟踪的方式嵌套或交织在一起。这很重要,因为我们的训练数据与验证数据看起来会有很大不同!
注意 其他类别的安排也是可行的;例如,我们考虑过完全分开两个独立的Dataset子类。标准软件工程设计原则适用,因此尽量保持结构相对简单,尽量不要复制粘贴代码,但不要发明复杂的框架来防止重复三行代码。
我们生成的数据将是具有多个通道的二维 CT 切片。额外的通道将保存相邻的 CT 切片。回想图 4.2,这里显示为图 13.12;我们可以看到每个 CT 扫描切片都可以被视为二维灰度图像。
图 13.12 CT 扫描的每个切片代表空间中的不同位置。
我们如何组合这些切片取决于我们。对于我们分类模型的输入,我们将这些切片视为数据的三维数组,并使用三维卷积来处理每个样本。对于我们的分割模型,我们将把每个切片视为单个通道,生成一个多通道的二维图像。这样做意味着我们将每个 CT 扫描切片都视为 RGB 图像的颜色通道,就像我们在图 4.1 中看到的那样,这里重复显示为图 13.13。CT 的每个输入切片将被堆叠在一起,并像任何其他二维图像一样被消耗。我们堆叠的 CT 图像的通道不会对应颜色,但是二维卷积并不要求输入通道是颜色,所以这样做没问题。
图 13.13 摄影图像的每个通道代表不同的颜色。
对于验证,我们需要为每个具有正面掩模条目的 CT 切片生成一个样本,对于我们拥有的每个验证 CT。由于不同的 CT 扫描可能具有不同的切片计数,我们将引入一个新函数,将每个 CT 扫描及其正面掩模的大小缓存到磁盘上。我们需要这样做才能快速构建完整的验证集大小,而无需在Dataset初始化时加载每个 CT。我们将继续使用与之前相同的缓存装饰器。填充这些数据也将在 prepcache.py 脚本中进行,我们必须在开始任何模型训练之前运行一次。
列表 13.11 dsets.py:220
@raw_cache.memoize(typed=True) def getCtSampleSize(series_uid): ct = Ct(series_uid) return int(ct.hu_a.shape[0]), ct.positive_indexes
Luna2dSegmentationDataset.__init__方法的大部分处理与我们之前看到的类似。我们有一个新的contextSlices_count参数,以及类似于我们在第十二章介绍的augmentation_dict。
指示这是否应该是训练集还是验证集的标志处理需要有所改变。由于我们不再对单个结节进行训练,我们将不得不将整个系列列表作为一个整体划分为训练集和验证集。这意味着整个 CT 扫描以及其中包含的所有结节候选者将分别位于训练集或验证集中。
列表 13.12 dsets.py:242, .__init__
if isValSet_bool: assert val_stride > 0, val_stride self.series_list = self.series_list[::val_stride] # ❶ assert self.series_list elif val_stride > 0: del self.series_list[::val_stride] # ❷ assert self.series_list
❶ 从包含所有系列的系列列表开始,我们仅保留每个val_stride元素,从 0 开始。
❷ 如果我们在训练中,我们会删除每个val_stride元素。
谈到验证,我们将有两种不同的模式可以验证我们的训练。首先,当fullCt_bool为True时,我们将使用 CT 中的每个切片作为我们的数据集。当我们评估端到端性能时,这将非常有用,因为我们需要假装我们对 CT 没有任何先前信息。我们将在训练期间使用第二种模式进行验证,即当我们限制自己只使用具有阳性掩模的 CT 切片时。
由于我们现在只想考虑特定的 CT 序列,我们循环遍历我们想要的序列 UID,并获取总切片数和有趣切片的列表。
列表 13.13 dsets.py:250, .__init__
self.sample_list = [] for series_uid in self.series_list: index_count, positive_indexes = getCtSampleSize(series_uid) if self.fullCt_bool: self.sample_list += [(series_uid, slice_ndx) # ❶ for slice_ndx in range(index_count)] else: self.sample_list += [(series_uid, slice_ndx) # ❷ for slice_ndx in positive_indexes]
❶ 在这里,我们通过使用范围扩展样本列表中的每个 CT 切片…
❷ … 而在这里我们只取有趣的切片。
以这种方式进行将保持我们的验证相对快速,并确保我们获得真阳性和假阴性的完整统计数据,但我们假设其他切片的假阳性和真阴性统计数据与我们在验证期间评估的统计数据相对类似。
一旦我们有了要使用的series_uid值集合,我们可以将我们的candidateInfo_list过滤为仅包含series_uid包含在该系列集合中的结节候选者。此外,我们将创建另一个仅包含阳性候选者的列表,以便在训练期间,我们可以将它们用作我们的训练样本。
列表 13.14 dsets.py:261, .__init__
self.candidateInfo_list = getCandidateInfoList() # ❶ series_set = set(self.series_list) # ❷ self.candidateInfo_list = [cit for cit in self.candidateInfo_list if cit.series_uid in series_set] # ❸ self.pos_list = [nt for nt in self.candidateInfo_list if nt.isNodule_bool] # ❹
❶ 这是缓存的。
❷ 创建一个集合以加快查找速度。
❸ 过滤掉不在我们集合中的系列的候选者
❹ 对于即将到来的数据平衡,我们需要一个实际结节的列表。
我们的__getitem__实现也会更加复杂,通过将大部分逻辑委托给一个函数,使得检索特定样本变得更容易。在其核心,我们希望以三种不同形式检索我们的数据。首先,我们有 CT 的完整切片,由series_uid和ct_ndx指定。其次,我们有围绕结节的裁剪区域,这将用于训练数据(我们稍后会解释为什么我们不使用完整切片)。最后,DataLoader将通过整数ndx请求样本,数据集将根据是训练还是验证来返回适当的类型。
基类或子类__getitem__函数将根据需要从整数ndx转换为完整切片或训练裁剪。如前所述,我们的验证集的__getitem__只是调用另一个函数来执行真正的工作。在此之前,它将索引包装到样本列表中,以便将 epoch 大小(由数据集长度给出)与实际样本数量分离。
列表 13.15 dsets.py:281, .__getitem__
def __getitem__(self, ndx): series_uid, slice_ndx = self.sample_list[ndx % len(self.sample_list)] # ❶ return self.getitem_fullSlice(series_uid, slice_ndx)
❶ 模运算进行包装。
这很容易,但我们仍然需要实现getItem_fullSlice方法中的有趣功能。
列表 13.16 dsets.py:285, .getitem_fullSlice
def getitem_fullSlice(self, series_uid, slice_ndx): ct = getCt(series_uid) ct_t = torch.zeros((self.contextSlices_count * 2 + 1, 512, 512)) # ❶ start_ndx = slice_ndx - self.contextSlices_count end_ndx = slice_ndx + self.contextSlices_count + 1 for i, context_ndx in enumerate(range(start_ndx, end_ndx)): context_ndx = max(context_ndx, 0) # ❷ context_ndx = min(context_ndx, ct.hu_a.shape[0] - 1) ct_t[i] = torch.from_numpy(ct.hu_a[context_ndx].astype(np.float32)) ct_t.clamp_(-1000, 1000) pos_t = torch.from_numpy(ct.positive_mask[slice_ndx]).unsqueeze(0) return ct_t, pos_t, ct.series_uid, slice_ndx
❶ 预先分配输出
❷ 当我们超出 ct_a 的边界时,我们复制第一个或最后一个切片。
将函数分割成这样可以让我们始终向数据集询问特定切片(或裁剪的训练块,我们将在下一节中看到)通过序列 UID 和位置索引。仅对于整数索引,我们通过__getitem__进行,然后从(打乱的)列表中获取样本。
除了ct_t和pos_t之外,我们返回的元组的其余部分都是我们包含用于调试和显示的信息。我们在训练中不需要任何这些信息。
13.5.5 设计我们的训练和验证数据
在我们开始实现训练数据集之前,我们需要解释为什么我们的训练数据看起来与验证数据不同。我们将不再使用完整的 CT 切片,而是将在我们的正候选项周围(实际上是结节候选项)训练 64×64 的裁剪。这些 64×64 的补丁将随机从以结节为中心的 96×96 裁剪中取出。我们还将在两个方向上包括三个切片的上下文作为我们 2D 分割的附加“通道”。
我们这样做是为了使训练更加稳定,收敛更快。我们之所以知道这样做是因为我们尝试在整个 CT 切片上进行训练,但我们发现结果令人不满意。经过一些实验,我们发现 64×64 的半随机裁剪方法效果不错,所以我们决定在书中使用这种方法。当你在自己的项目上工作时,你需要为自己做这种实验!
我们认为整个切片训练不稳定主要是由于类平衡问题。由于每个结节与整个 CT 切片相比非常小,我们又回到了上一章中摆脱的类似于大海捞针的情况,其中我们的正样本被负样本淹没。在这种情况下,我们谈论的是像素而不是结节,但概念是相同的。通过在裁剪上进行训练,我们保持了正像素数量不变,并将负像素数量减少了几个数量级。
因为我们的分割模型是像素到像素的,并且接受任意大小的图像,所以我们可以在具有不同尺寸的样本上进行训练和验证。验证使用相同的卷积和相同的权重,只是应用于更大的像素集(因此需要填充边缘数据的像素较少)。
这种方法的一个缺点是,由于我们的验证集包含数量级更多的负像素,我们的模型在验证期间将有很高的假阳性率。我们的分割模型有很多机会被欺骗!并且我们还将追求高召回率。我们将在第 13.6.3 节中更详细地讨论这一点。
13.5.6 实现 TrainingLuna2dSegmentationDataset
有了这个,让我们回到代码。这是训练集的__getitem__。它看起来就像验证集的一个,只是现在我们从pos_list中采样,并使用候选信息元组调用getItem_trainingCrop,因为我们需要系列和确切的中心位置,而不仅仅是切片。
代码清单 13.17 dsets.py:320,.__getitem__
def __getitem__(self, ndx): candidateInfo_tup = self.pos_list[ndx % len(self.pos_list)] return self.getitem_trainingCrop(candidateInfo_tup)
要实现getItem_trainingCrop,我们将使用一个类似于分类训练中使用的getCtRawCandidate函数。在这里,我们传入一个不同尺寸的裁剪,但该函数除了现在返回一个包含ct.positive_mask裁剪的额外数组外,没有改变。
我们将我们的pos_a限制在我们实际分割的中心切片上,然后构建我们的 96×96 给定的裁剪的 64×64 随机裁剪。一旦我们有了这些,我们返回一个与我们的验证数据集相同项目的元组。
代码清单 13.18 dsets.py:324,.getitem_trainingCrop
def getitem_trainingCrop(self, candidateInfo_tup): ct_a, pos_a, center_irc = getCtRawCandidate( # ❶ candidateInfo_tup.series_uid, candidateInfo_tup.center_xyz, (7, 96, 96), ) pos_a = pos_a[3:4] # ❷ row_offset = random.randrange(0,32) # ❸ col_offset = random.randrange(0,32) ct_t = torch.from_numpy(ct_a[:, row_offset:row_offset+64, col_offset:col_offset+64]).to(torch.float32) pos_t = torch.from_numpy(pos_a[:, row_offset:row_offset+64, col_offset:col_offset+64]).to(torch.long) slice_ndx = center_irc.index return ct_t, pos_t, candidateInfo_tup.series_uid, slice_ndx
❶ 获取带有一点额外周围的候选项
❷ 保留第三维度的一个元素切片,这将是(单一的)输出通道。
❸ 使用 0 到 31 之间的两个随机数,我们裁剪 CT 和掩模。
你可能已经注意到我们的数据集实现中缺少数据增强。这一次我们将以稍有不同的方式处理:我们将在 GPU 上增强我们的数据。
13.5.7 在 GPU 上进行数据增强
在训练深度学习模型时的一个关键问题是避免训练管道中的瓶颈。嗯,这并不完全正确–总会有一个瓶颈。[¹²]
一些常见的瓶颈出现在以下情况:
- 在数据加载管道中,无论是在原始 I/O 中还是在将数据解压缩后。我们使用
diskcache库来解决这个问题。 - 在加载数据的 CPU 预处理中。这通常是数据归一化或增强。
- 在 GPU 上的训练循环中。这通常是我们希望瓶颈出现的地方,因为 GPU 的总体深度学习系统成本通常高于存储或 CPU。
- 瓶颈通常不太常见,有时可能是 CPU 和 GPU 之间的内存带宽。这意味着与发送的数据大小相比,GPU 的工作量并不大。
由于 GPU 在处理适合 GPU 的任务时可以比 CPU 快 50 倍,因此在 CPU 使用率变高时,通常有意义将这些任务从 CPU 移动到 GPU。特别是如果数据在此处理过程中被扩展;通过首先将较小的输入移动到 GPU,扩展的数据保持在 GPU 本地,使用的内存带宽较少。
在我们的情况下,我们将数据增强移到 GPU 上。这将使我们的 CPU 使用率较低,GPU 将轻松地承担额外的工作量。与其让 GPU 空闲等待 CPU 努力完成增强过程,不如让 GPU 忙于少量额外工作。
我们将通过使用第二个模型来实现这一点,这个模型与本书中迄今为止看到的所有nn.Module的子类类似。主要区别在于我们不感兴趣通过模型反向传播梯度,并且forward方法将执行完全不同的操作。由于我们在本章中处理的是 2D 数据,因此实际增强例程将进行一些轻微修改,但除此之外,增强将与我们在第十二章中看到的非常相似。该模型将消耗张量并产生不同的张量,就像我们实现的其他模型一样。
我们模型的__init__接受相同的数据增强参数–flip,offset等–这些参数在上一章中使用过,并将它们分配给self。
列表 13.19 model.py:56,class SegmentationAugmentation
class SegmentationAugmentation(nn.Module): def __init__( self, flip=None, offset=None, scale=None, rotate=None, noise=None ): super().__init__() self.flip = flip self.offset = offset # ... line 64
我们的增强forward方法接受输入和标签,并调用构建transform_t张量,然后驱动我们的affine_grid和grid_sample调用。这些调用应该在第十二章中感到非常熟悉。
列表 13.20 model.py:68,SegmentationAugmentation.forward
def forward(self, input_g, label_g): transform_t = self._build2dTransformMatrix() transform_t = transform_t.expand(input_g.shape[0], -1, -1) # ❶ transform_t = transform_t.to(input_g.device, torch.float32) affine_t = F.affine_grid(transform_t[:,:2], # ❷ input_g.size(), align_corners=False) augmented_input_g = F.grid_sample(input_g, affine_t, padding_mode='border', align_corners=False) augmented_label_g = F.grid_sample(label_g.to(torch.float32), affine_t, padding_mode='border', align_corners=False) # ❸ if self.noise: noise_t = torch.randn_like(augmented_input_g) noise_t *= self.noise augmented_input_g += noise_t return augmented_input_g, augmented_label_g > 0.5 # ❹
PyTorch 深度学习(GPT 重译)(五)(4)https://developer.aliyun.com/article/1485251