575. RSNA 2023 Abdominal Trauma Detection | rsna-2023-abdominal-trauma-detection
我使用了高效的数据预处理流程和小型的多任务模型,采用单阶段框架。我没有使用图像级标签和分割掩码,因为我忘记它们已经给出 🤦♂️。
我的2D和3D数据集管道是分开的,因为这部分可以通过非阻塞IO非常快速地并行运行。我可以在大约20分钟内将所有训练DICOM导出为png。
我将训练集中的许多不同CT扫描保存为视频并进行检查。我注意到它们在z维度上的起点和终点都不同。有些从肩膀开始,到大腿之前结束;有些从肺部开始,到股骨中部附近结束。
我研究了解剖结构并决定定位ROI。我在轴向平面上手动标注了最大轮廓周围的边界框。我将肝脏之前的切片标记为"upper",股骨头之后的切片标记为"lower"。这两个位置之间的切片标记为"abdominal"。我训练了一个YOLOv8 nano模型,它在所有类别上都能轻松达到0.99x mAP@50。我丢弃了预测为"upper"和"lower"的切片,并使用预测为"abdominal"的切片和预测的边界框进行裁剪。
最终,我放弃了这种方法,因为它太慢了,而且完全没有提高我的整体分数。在我最新的3D管道中,我使用轻量级定位,只需在轴向平面上裁剪最大轮廓并保留z维度上的所有切片。
我通过计算z轴上所有黑色垂直线及其差异来丢弃这些切片。正常切片有0-5条全黑垂直线。如果全黑垂直线数量突然增加或减少,则该切片为部分切片。
# 通过计算所有零垂直线的和来查找部分切片
if scan.shape[0] != 1:
scan_all_zero_vertical_line_transitions = np.diff(np.all(scan == 0, axis=1).sum(axis=1))
# 启发式地选择z轴上的高和低转换并丢弃它们
slices_with_all_zero_vertical_lines = (scan_all_zero_vertical_line_transitions > 5) | (scan_all_zero_vertical_line_transitions < -5)
slices_with_all_zero_vertical_lines = np.append(slices_with_all_zero_vertical_lines, slices_with_all_zero_vertical_lines[-1])
scan = scan[~slices_with_all_zero_vertical_lines]
del scan_all_zero_vertical_line_transitions, slices_with_all_zero_vertical_lines
我没有对每张图像分别进行此操作,因为这会破坏切片的对齐。我计算了每个切片的边界框,并通过取起点最小值和终点最大值来计算最大边界框。
# 裁剪最大轮廓
largest_contour_bounding_boxes = np.array([dicom_utilities.get_largest_contour(image) for image in scan])
largest_contour_bounding_box = [
int(largest_contour_bounding_boxes[:, 0].min()),
int(largest_contour_bounding_boxes[:, 1].min()),
int(largest_contour_bounding_boxes[:, 2].max()),
int(largest_contour_bounding_boxes[:, 3].max()),
]
scan = scan[
:,
largest_contour_bounding_box[1]:largest_contour_bounding_box[3] + 1,
largest_contour_bounding_box[0]:largest_contour_bounding_box[2] + 1,
]
# 沿xz、yz和xy平面裁剪非零切片
mmin = np.array((scan > 0).nonzero()).min(axis=1)
mmax = np.array((scan > 0).nonzero()).max(axis=1)
scan = scan[
mmin[0]:mmax[0] + 1,
mmin[1]:mmax[1] + 1,
mmin[2]:mmax[2] + 1
]
总之,这些numpy数组用作模型输入。我未能在此阶段受益于并行执行。
我使用多标签分层分组k折交叉验证。通过按患者级别分割可以实现分组功能。我将独热编码的类转换为序数编码的单个列,并创建了患者扫描次数的另一列。我将数据集分为5折,使用5个序数编码的目标列+患者扫描次数列进行分层。
我尝试了许多不同的模型、头部和颈部,但两个简单模型表现最佳。
这个模型非常简单,类似于MIL方法,讽刺的是它是我表现最好的模型。架构如下:
这个模型类似于在之前的比赛中其他人使用的模型。架构如下:
我对肠道和渗漏头使用BCEWithLogitsLoss,对肾脏、肝脏和脾脏权重使用CrossEntropyLoss。我做的唯一修改是实现完全相同的样本权重:
class SampleWeightedBCEWithLogitsLoss(_WeightedLoss):
def __init__(self, weight=None, reduction='mean'):
super(SampleWeightedBCEWithLogitsLoss, self).__init__(weight=weight, reduction=reduction)
self.weight = weight
self.reduction = reduction
def forward(self, inputs, targets, sample_weights):
loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction='none', weight=self.weight)
loss = loss * sample_weights
if self.reduction == 'mean':
loss = loss.mean()
elif self.reduction == 'sum':
loss = loss.sum()
return loss
class SampleWeightedCrossEntropyLoss(_WeightedLoss):
def __init__(self, weight=None, reduction='mean'):
super(SampleWeightedCrossEntropyLoss, self).__init__(weight=weight, reduction=reduction)
self.weight = weight
self.reduction = reduction
def forward(self, inputs, targets, sample_weights):
loss = F.cross_entropy(inputs, targets, reduction='none', weight=self.weight)
loss = loss * sample_weights
if self.reduction == 'mean':
loss = loss.mean()
elif self.reduction == 'sum':
loss = loss.sum()
return loss
最终损失计算为每个头损失的总和,并对其调用backward。
训练变换包括:
测试变换包括:
根据模型大小使用2或4的批量大小。使用余弦退火学习率调度,以较小的基数和最大学习率探索不同区域。还使用AMP加速训练和正则化。
最终集成使用2个MIL-like模型(efficientnetb0和densenet121)和2个RNN模型(efficientnetb0和efficientnetv2t)。
由于模型使用随机裁剪增强进行训练,测试时输入为中心裁剪。应用4倍TTA(xyz、xy、xz和yz翻转)并平均预测结果。
5折的预测被平均,然后使用sigmoid或softmax函数激活。
不同目标使用4个模型的不同权重。这些权重通过最小化OOF分数找到。
我按patient_id聚合扫描级别预测并取最大预测值。
我还使用不同的乘数缩放损伤目标预测,这些乘数也通过最小化OOF分数设置。
我的最终集成分数为0.3859,各目标分数如下表所示。我真的很喜欢我的OOF分数与LB分数几乎完美相关的结果。由于稳定的交叉验证,我选择了具有最佳OOF、公共和私有LB分数的提交。
| 肠道 | 渗漏 | 肾脏 | 肝脏 | 脾脏 | 任意 |
|---|---|---|---|---|---|
| 0.1282 | 0.5070 | 0.2831 | 0.4186 | 0.4736 | 0.5050 |