558. Vesuvius Challenge - Ink Detection | vesuvius-challenge-ink-detection
这场比赛的结局非常紧张,我们虽然长时间保持第一,但我始终担心会有变数。我们试图采取非常保守的策略,确保不会过度针对排行榜进行优化。尽管如此,当公共排行榜样本如此之少,而本地验证又不够稳定时,总会存在某种程度的不确定性。
从整体来看,我们的成功主要归功于以下几点:
我们最初从2.5D starter代码开始,并逐步演进。最终我们确定只取中间16层,但我们尝试了许多其他方法但未见成效(这部分我留到另一篇文章再讲)。初始代码配置为针对图像的所有裁剪区域进行训练,但我们基于裁剪区域是否空白进行了简单过滤。这大大减少了训练时间,因为许多裁剪区域完全没有数据。
早期进展甚微,随后我们进行了简单的消融实验来观察裁剪大小对性能的影响,发现其中存在良好的扩展潜力。这似乎是我们早期观察到的最明显的规律之一:128被512超越,512又被1024超越。

x、y方向的上下文信息似乎非常重要,因为裁剪区域可以完整查看一个字母甚至多个字母,从而更完整地提取它们。较小的裁剪区域产生的输出更加零散。这种方法的一个好处是它实际上完全没有增加训练时间。如果使用1024×1024的裁剪区域,相比128×128,需要处理的裁剪区域数量大大减少,因此每个epoch的时间和整体收敛时间几乎完全相同,这与一些其他竞赛不同(在那里增加尺寸会使计算时间变长)。
我们最初采用2.5D方法,但很快发现这并非最优方案,因为模型在学习哪些层含有墨水,而我们知道这在不同的碎片之间是变化的。我们希望找到一个深度不变的解决方案,能够在任意层检测墨水。一种方法是在3D体积的每个切片上训练一个简单的2D模型,但由于数据标注的方式(最初只有2D标注),如果我们告诉模型某些层有墨水而实际上没有,或反之,就会给模型提供错误的信号。我们就如何处理这个问题进行了多次讨论:1D卷积、最大池化、使用尺寸为(8, 1, 1)的3D卷积使其仅真正关注深度模式。我们发现性能最好的方法是使用强大的3D模型,输出一个新的具有多个通道的3D体积,然后沿深度轴进行最大值池化。
例如,我们在这方面最早的方法是一个简单的3DCNN。输入形状为(batch_size, 1, 16, 1024, 1024)。我们在其上应用4层3D卷积,滤波器数量逐渐增加,最终得到输出体积(batch_size, 16, 16, 1024, 1024)。此时我们只需在z维度上取最大值,将其压缩为(batch_size, 16, 1024, 1024),这样我们的16个深度维度就变成了16个特征维度。仅此方法本身已经不错,但再通过一个强大的2D分割模型后效果显著提升。我们在此过程中大量依赖segformer,正如其他人提到的,b3 backbone表现优异,但我们也发现更大的模型在排行榜上表现更好,即使它们在本地验证上并未显著超越。
我们迭代了这个设计,因为它似乎满足了我们想要的特质:一个应用于3D体积的强有力2D分割器,且对深度不变。我们尝试了不同的第一阶段3D方法,发现更强大的3D模型产生了更好的结果,逐步演进到3D UNet,最终到3D UNETR。有时结果并不明确是否更好,但我们发现一个明显趋势:理论更好的模型在排行榜上也表现更好。我们最好的单个模型是UNETR第一阶段,将32个通道传入b5 segformer,在公共排行榜上得分0.82,私有排行榜上0.67。我们在3D和2D阶段之间的通道上应用了少量dropout。


我一直想知道segformer的优势是否并非因为它本身是最佳模型,而是因为它以较低分辨率进行预测。我们输入1024×1024,它输出256×256的分割图,我们仅用简单的conv2d转置进行上采样。我相信第二名的解决方案验证了这一点:较低分辨率确实有帮助,因为它进行更粗略的分类,相比试图更精确地获取每个像素,任务变得更简单。
我们最终的最佳解决方案融合了9个不同的模型:
| 第一阶段 | 第二阶段 | 分辨率 | 公共分数 |
|---|---|---|---|
| 3d unet(16通道) | segformer b3 | 1024 | .78 |
| 3d unet(16通道) | segformer b3 | 512 | .77 |
| 3d unet(16通道带SWA) | segformer b3 | 512 | .78 |
| 3d cnn(32通道) | segformer b3 | 1024 | .78 |
| 3d cnn(32通道) | segformer b5 | 1024 | .77 |
| 3d cnn(64通道) | segformer b3 | 1024 | .78 |
| 3d unet(32通道) | segformer b5 | 1024 | .79 |
| 3d unetr(32通道) | segformer b5 | 512 | .82 |
| 3d unetr 多分类(32通道) | segformer b5 | 512 | ? |
我们后期尝试的一个方向是多分类输出。不再使用二分类,而是预测"无-掩码-墨水"三类。结果实际上产生了更干净的输出,在大多数模型中,只要有纸莎草的地方就有一些噪声,而这种方法似乎大大降低了噪声。不过我们未能充分探索以确认其是否真的有效。
我们的训练流程相对标准,使用现有的adamW优化器、dice+bce损失和hyperparameters,修复了一些设置min-lr的小bug,并继续使用渐进式warmup学习率调度。我们添加了随机权重平均(SWA)来获得更宽的极小值,而不是需要选择特定检查点,因为我们发现这些检查点相当不一致。我们发现对片段1进行验证并不完美,但至少具有方向性指导意义。一旦在fold 1的本地验证中找到有效方法,我们就会提交到排行榜进行双重验证,然后使用所有fold继续训练若干个epoch。我们会提交并评估新检查点(在全部fold上训练),通常能提升约0.04分。有时我们从一开始就在所有片段上训练,而不是先训练片段2、3,后期再加入1,但我们没有对此进行充分评估。
我们尝试了许多不同的增强方式,主要来自albumentations,一些自定义增强,以及一些3D包的增强,但最终在花哨的增强方法中未能找到显著的alpha。我们最佳模型使用了以下增强:

整体来看,旋转和翻转似乎至关重要,其他因素基本无影响。旋转似乎很重要,但我们并未确认测试集本身是否经过旋转,我们只是知道它重要,因此试图使模型尽可能保持旋转不变性。
我们最终得到了大量模型检查点,需要筛选出我们认为性能最佳的组合,在运行时间和吞吐量之间进行权衡。我们尝试了许多不同组合:更重的TTA(包含所有旋转和翻转)、更多模型、更小的步幅。最终获胜方案是4倍旋转TTA,配合1/4裁剪步幅窗口,以及尽可能多的优质模型。将步幅减半额外带来了0.01的提升,但增加更多模型的效果更为显著,而翻转似乎完全没有帮助。如果仅使用正确的方向而非TTA,我们可能做得更好,但对旋转不变的模型似乎同样强大。
我们的一大难点是如何最佳地融合预测结果。由于我们的步幅方法,每个模型的每个像素被预测了4次,又因为TTA再乘以4。使用多个模型实际上为每个像素的预测聚合提供了大量选择。我们本地尝试了很多方法,但最终效果最好的是:对每个模型的所有约16次预测进行逐像素平均,然后对该平均信号应用sigmoid,再将这些概率值平均。这在Kaggle系统上给我们带来了严峻的内存限制,因此我们必须更高效地存储数据并密集累积,而不是简单地创建大型数组并在最后平均。
我们长期纠结的一个问题是预测的校准。正如其他帖子中讨论的,决定阈值对获得良好结果至关重要,选择错误的阈值可能会给模型性能带来非常误导的信号。在训练和评估过程中,我们持续监控不同阈值下的AUC、精确率、召回率和f0.5分数。一些模型在0.5阈值下校准良好,但许多并非如此。

因此,我们知道即使一个优秀的模型,如果阈值设置不正确,在排行榜上也可能表现糟糕。我们考虑过使用一些人采用的百分位法,但甚至没有为此提交一次尝试,因为如果墨水分布与预期不符,这种方法似乎风险太大。最终我们所依赖的是:预测结果在平均后会变得校准。我们在本地验证和排行榜上都发现了这一点。单个模型的最优阈值范围可能很宽,但在平均多个模型的预测后,几乎都集中在0.5附近。我们最后一次提交其实想大胆尝试0.55,但在公共排行榜上表现更差(仅提前30分钟完成,险些未能选中)。不过它在私有排行榜上的表现确实略高一些。我认为我们在模型融合和最优阈值选择方面其实还有很大提升空间。
我们从早期的云分割竞赛中借鉴了一个小"魔法函数"来清理预测结果。在所有处理完成后并二值化后,我们使用cv2.connectedcomponents查找连通区域,如果它们超过特定大小就将其移除。这会清理掉过小的部分,即那些小斑点噪声。我们发现本地最优策略是先将阈值设得较低,然后清理多余部分(25000像素以下),但我们提交时只清理了10k像素以下的部分,并未尝试更激进的清理。
处理前

处理后
