返回列表

3rd place solution: 3D/2D UNet with Gaussian Heatmap and WBF

653. BYU - Locating Bacterial Flagellar Motors 2025 | byu-locating-bacterial-flagellar-motors-2025

开始: 2025-03-05 结束: 2025-06-04 医学影像分析 数据算法赛
第三名解决方案:3D/2D UNet 结合高斯热力图与 WBF
标题:第三名解决方案:3D/2D UNet 结合高斯热力图与 WBF
作者:Đăng Nguyễn Hồng
发布时间:2025-06-06
竞赛排名:第 3 名 (金牌区)

非常感谢竞赛主办方和 Kaggle 举办了另一场引人入胜的挑战——并向所有参与者表示热烈祝贺!
感谢每一位积极向社区分享见解和 Notebook 的人,特别是 @brendanartley 提供的 外部数据集

和往常一样,我在整个竞赛过程中学到了很多,并有幸进入了金牌区。我很高兴在这里分享一些想法。

简要总结 (TLDR)

  • 我专注于 3D/2.5D/2D UNet 方法。最终提交 PB 86.6 是 5 个模型的集成:3D Resnext50 (2xTTA), 3D Densenet121 (2xTTA), 3D X3D-M (2xTTA), 2D MaxViT (2xTTA)2D CoaT (1xTTA)
  • 3D 模型使用 patch 大小为 (224, 448, 448),而 2D 使用 (3, 896, 896) patch 大小以包含足够的全局上下文。
  • 高斯热力图,stride=16sigma=200A 用于不确定性建模
  • 简单的 FPN 颈部将低级特征融合到 stride-16 特征图中
  • 所有最终选定的模型都是在训练数据集 + 外部数据集的组合上训练的,没有本地验证
  • 重度增强允许训练更多 epoch 并对域/分布变化更鲁棒。
  • 对训练 + 外部数据集进行伪标签,并进行人工审查
  • 将所有断层扫描图重采样到 16A 体素间距 + 滑动窗口 patch 推理
  • 简单的 BCE 损失效果最好
  • 加权框融合 (WBF) 用于集成多个模型/TTA 的预测

无效的方法

  • 其他损失函数:MSE, L1, 加权 BCE, Focal, Tversky, 组合多种损失

目录


验证策略

在训练数据集上进行了简单的基于电机数量的 4 折分层 K 折交叉验证 (4-StratifiedKfold)。验证样本通过 Rotate90+Flip (16 种可能的组合) 进行增强,以增加样本数量,使验证指标更稳定且值得信赖。

实验进度主要使用 F2 分数跟踪,另外使用部分平均精度 (Partial Average Precision),在特定 Recall 处截断,例如 > 0.9。第二个不是二元指标,因此更稳定且对阈值不那么敏感,与 F2 强相关。单折本地 CV 是选择模型和超参数的主要因素。

在所有数据上训练,在 LB 上验证:然而,当我的流水线没有错误且表现更好时(当 CV > 0.96 且 LB > 0.8),CV 就饱和了,与 LB 相关性不好。所以在后期,CV 仅用于指导超参数(主要是学习率)的合理值。在最后一周,所有模型都在所有数据集上训练,没有本地验证,直接提交到 LB。

3D-UNet 方法

数据处理

我将断层扫描图重采样到固定的体素间距 16.0,使用滑动窗口 (patching) 技术,将断层扫描图分割为 224x448x448 (可能重叠) 的 patches 作为模型输入,以获得 16x 下采样的预测热力图,形状为 14x28x28
标签坐标有点 noisy/不确定,而且容忍半径很高,所以使用高热力图分辨率似乎是多余的,会显著增加计算量。半径 1000A == 输入体积中 62.5 个体素 == 预测热力图中 4 个体素。我实验了 8x32x 下采样的预测热力图,发现 8x ~ 16x >> 32x。最终,我对 3D 和 2D 模型都使用 16x 下采样的热力图。

最佳体素间距约为 16.0
对于这个任务,重采样到合适的体素间距很重要。使用小体素间距可以保留非常小的细节,但由于高分辨率计算量大,相反,大体素间距可能会擦除检测所需的所有纹理。实验结果证实非常小的细节/纹理很重要:

  • 尝试了各种目标体素间距 12, 16, 20, 24, 32, 64;发现当间距 > 32 时 CV 显著下降。LB 也在间距 > 20 后下降得更快。我发现 16.0 是最佳平衡值,与 nnUnet 的典型 中值间距 默认实践相匹配(训练数据集的中值间距为 15.6)。
  • 重采样插值模式 (LINEAR, AREA, NEAREST) 也会显著影响性能。所有断层扫描图也有噪声 (高斯噪声?),所以 resize/插值可能会以意想不到的方式擦除内容。

LB/PB 中的平均原始体素间距是 13.1 ???
哈哈,只是我的愚蠢猜测,无法确认。我用 target_spacing=16.0 训练模型,并用不同的原始间距假设提交到 LB。13.1 带来了最高的 LB。鉴于测试样本具有更高的分辨率 (平均 ~400x1400x1050) 且 44.7% 的训练样本具有 voxel_spacing=13.1我安全地假设平均测试体素间距约为 13.1,并在所有实验中重采样到 16.0

我试图在最后一天快速训练一个体素间距预测模型,但失败了,指标约为 MAE=5.1,无法预测任何有用的东西 :D

每个样本主要由负体素主导,所以我试图在训练过程中控制正/负 patches 的比例。对于 3D 方法,正/负样本比例为 20/1。

3D 增强

对于每个 epoch,所有正样本都确保经过由 Rotate90+Flip 组成的所有 16 种变换,然后经过“重度”MONAI 增强流水线。关键点也传递给 MONAI 字典变换,以便我们获得仿射变换后的关键点,以更大的 stride (此处为 16) 渲染热力图。我真的很喜欢 MONAI 的 Lazy Resampling,它使增强和数据加载器变得快速高效。
使用了各种 MONAI 3D 增强:

  • 空间:RandZoom, RandAffined
  • 强度:RandShiftIntensityd, RandStdShiftIntensityd, RandScaleIntensityFixedMeand, RandScaleIntensityd, RandAdjustContrastd, RandHistogramShiftd
  • 降低质量:RandSimulateLowResolutiond
  • Dropout:RandCoarseDropoutKeepKeypoints

尝试了 Mixup 和 Cutmix,但没有带来明显的改进,所以在后来的实验中被忽略。

Albumentations 风格的伪代码
from monai import transforms as T
import monai_custom as CT

T.Compose(
    [
        # =========START OF LAZY RESAMPLING=========
        T.Spacingd(target_spacing=16.0, p=1.0),
        CT.CustomCropBySlicesd(),
        # SPATIAL TRANSFORM
        T.RandZoomd(min_zoom=(0.6, 0.6, 0.6), max_zoom=(1.2, 1.2, 1.2), p=0.4),
        T.OneOf(
            [
                # Affine 1: slight, focus on XY dims
                T.RandAffined(
                    rotate_range=((0, 0), (0, 0), (0, 360)),
                    scale_range=((-0.3, 0.3), (-0.3, 0.3), (-0.3, 0.3)),
                    p=0.67,
                ),
                # Affine2: heavier, focus on all XYZ dims
                T.RandAffined(
                    rotate_range=((-15, 15), (-15, 15), (0, 360)),
                    shear_range=((-0.2, 0.2), (-0.2, 0.2), (-0.2, 0.2)),
                    scale_range=((-0.3, 0.3), (-0.3, 0.3), (-0.3, 0.3)),
                    p=0.33,
                ),
            ],
            p=0.75,
        ),
        # INTENSITY TRANSFORM
        T.OneOf(
            [
                T.Compose(
                    [
                        # mean shift
                        T.OneOf(
                            [
                                T.RandShiftIntensityd(offset=(-40, 80), p=0.5),
                                T.RandStdShiftIntensityd(factors=(-0.7, 1.2), p=0.5),
                            ],
                            p=0.3,
                        ),
                        # std/contrast scale (multiplicative)
                        T.OneOf(
                            [
                                # decrease std/contrast -> harder sample -> higher prob
                                T.RandScaleIntensityFixedMeand(factors=(-0.6, 0), p=0.7),
                                # increase std/contrast -> easier sample -> lower prob
                                T.RandScaleIntensityFixedMeand(factors=(0, 0.8), p=0.3),
                            ],
                            p=0.3,
                        ),
                    ],
                    p=0.4,
                ),
                T.RandScaleIntensityd(factors=(-0.7, 0.7), channel_wise=True, p=0.2),
                T.RandAdjustContrastd(gamma=(0.25, 1.75), p=0.3),
                T.RandHistogramShiftd(num_control_points=(10, 20), p=0.1),
            ],
            p=0.5,
        ),
        # =========END OF LAZY RESAMPLING=========
        # apply Affine Transform to keypoints (normal distributions) as well
        CT.ApplyTransformToNormalDistributionsd(keys=["keypoints"]),
        # with varius interpolation mode pairs, simplified for readability
        T.RandSimulateLowResolutiond(
            zoom_range=(0.6, 0.9), downsample_mode="*", upsample_mode="*", p=0.2
        ),
        CT.RandCoarseDropoutWithKeypointsd(
            keypoints_key="keypoints", holes=3, spatial_size=(30, 30, 30),
            dropout_holes=True, fill_value=(0, 255), max_holes=8,
            max_spatial_size=(125, 125, 125), prob=0.1
        )
    ]
)

3D 目标热力图

对于可以测量真实距离/比例的任务,例如已知体素间距,我更喜欢以真实生活距离测量的固定 sigma。一些自底向上的人体姿态估计进展引入了自适应调整每个关键点热力图的技术。直观地说,这些方法根据每个人的大小调整热力图的 sigma 值,为较大/较小的人分配较大/较小的 sigma (较高/较低的不确定性) (相对于他们的像素计数/框面积)。

我使用 sigma 值为 200A (半径的 0.2 倍)。注意,经过 Spatial 增强如 Spacing, Affine 或 Zoom 后,体素间距会改变,所以用于渲染热力图的 sigma 值不是固定的体素数,而是 0.2 * 1000 / cur_voxel_spacing
自定义实现了 ApplyTransformToPoints 将仿射变换应用于正态分布,结果是另一个正态分布,渲染在目标热力图体积上。因此,每个关键点看起来不像球状,而是旋转的椭球体。

3D 热力图示例

这有意义吗?也许没那么显著。这只是假设电机的可能性在原始断层扫描图中严格遵循 3D 正态分布,这对于这个任务来说不太可能。后来,我决定使用球状高斯热力图,具有对角协方差矩阵和所有 3 个维度 ZYX 的单一 sigma,以使我的模型生活更轻松 (直观上只需要预测均值和尺度)。

3D 建模

尝试了具有各种 3D/2.5D 编码器的 3D-UNet 并用于最终提交。输入为形状 (Z, Y, X) = (224, 448, 448) 的 3D 体积,所有模型预测形状为 (Z/16, Y/16, X/16) = (14, 28, 28) 的输出热力图。
3D-UNet 解码器借用自 segmentation_models_pytorch_3d 并进行调整以输出多尺度热力图用于利用深度监督。由于对于这个任务,最精细的热力图 stride 16 就在最粗的 32 之后 (不够深),实验上深度监督似乎是多余的,后来我只监督 1/16 热力图。

3D 编码器

我主要调整并使用来自 timm3dSlowFast 的模型。默认情况下,像 SlowFast 中的 X3D 和 I3D 这样的视频模型不使用时间池化,保持时间维度不变以尽可能保留时间分辨率。对于这个任务,这是不必要的,我做了更改,使得深度 (视频术语中的时间) 在每个阶段 progressively 下采样 2 倍,与空间下采样同步。现在,所有 3D 模型具有相同的接口,输出至少 4 个 (通常 5 个) 级别的特征图,stride 为 1/4, 1/8, 1/16, 1/32。
如上所述,我们需要高分辨率 (小体素间距) 来保留非常小的细节/纹理。标准 UNet 通过跳过连接利用并融合低级/精细特征到相同的输出级别。然而,输出 stride 为 16 的 UNet 现在实际上不是 U 形,因此没有更短的路径将低级/精细特征 (丰富纹理/定位) 传播到高级/粗输出热力图 (丰富语义)。受此启发,我添加了颈部将低级特征融合到粗输出热力图头,有效地改善了通过网络的信息传播。尝试了两种类型的颈部:

  • 简单 3D FPN 颈部:受 这篇 writeupUnified FPN architecture 启发。简单地插值所有级别的特征以匹配 stride 1/16 的空间形状,将它们全部连接起来,然后传递给 UNet 解码器块
  • 3D PAN (Path Aggregation Network):除了像 FPN 中那样添加自顶向下路径增强以将丰富语义特征融合到 finer scale 的低级特征外,我们现在添加另一个自底向上路径增强以将丰富纹理/细节特征融合到更粗的特征。

很明显,简单 FPN 是实现我们目标的“更直接”的方式。没有在这方面花太多时间,但我的实验结果也证实简单 FPN 通常比 PAN 好。所以我所有的最终模型都额外配备了这个简单 FPN 颈部,如下图所示。

3D UNet FPN 结构

2.5D 编码器

我也在使用 2.5D 编码器,结合 FPN 颈部和 3D UNet 解码器。想法非常简单。首先我们通过 stride 为 2 滑动深度 (Z) 维度采样一系列 2D 3 通道图像,有效地将一个体积视为一批 2D 图像。在 2D 主干的每个阶段,空间分辨率默认下采样 2 倍,我们也通过 AvgPool3d((2, 1, 1))Conv3d(stride=(2, 1, 1)) 将深度 (现在视为 batch) 下采样 2 倍。第一个更好且更容易训练,因为它保持张量统计不变,更好地利用预训练 2D 主干的迁移学习。如果没有使用外部数据集,它显示出良好的分数,显著减少了中间/后期阶段的开销,轻量级并帮助模型有效地“看到”体积的所有 2D 切片。对于仅在中间或后期阶段进行大量计算的 2D 卷积主干 (如 EfficientNet 或 ConvNeXT) 来说没问题,但对于在早期阶段使用 Transformer 的主干来说不行,因为高分辨率和非常大的“假 batch size"

还尝试了在获得 2D 特征后注入 LSTM 层之类的事情,CV 可能会上升,但 LB 只是不变。也许 FPN 颈部和 UNet 解码器中的 Conv3D 更好地融合了切片特征,不再需要额外的混合。

部分结果

我报告单个模型 (单个 checkpoint) 的 LB/PB 分数。这些模型是:

  • 在所有数据上训练 (训练 + 外部)
  • 2xTTA:原始 + 沿 XY 平面旋转 90
  • 阈值是所有预测置信分数的 55% 分位数。

我开始使用外部数据有点晚,所以只提交了它们的最后一个 checkpoint 以获得 LB 分数,调优非常有限。每个模型的运行时间不超过 3 小时。

模型 参数 Private LB Public LB
3D RESNEXT50 49.9M 86.2 85.8
3D DENSENET121 27.6M 85.7 86.0
X3DM + FPN 10.4M 84.6 84.8
X3DM + PAN + MIXUP 6.9M 84.6 84.5
X3DL 12.8M 83.9 85.2
3D RESNET50 70.3M 83.0 85.4
2.5D CONVNEXT_TINY 40.3M 82.4 83.7
2D COAT_LITE_MEDIUM 99.6M 83.3 84.8
2D MAXVIT_TINY 86.1M 82.5 85.4

2D-UNet 方法

2D 方法在我的计划中,但它变得紧急,因为我感觉许多高公有 LB 分数可能使用 2D 方法,特别是 YOLO。在竞赛截止日期前 2 周,我开始研究 2D 建模并从 3D 方法继承了许多细节。与 3D 相比,训练 2D 模型资源效率更高且更快。

数据处理

也使用了相同的目标体素间距 16.0,即我调整每个 2D 切片的大小,使 XY 间距为 16A。当然增强后来可以缩放这个间距,但与 3D 方法一样,每个关键点的 sigma 相应地重新缩放,以便最终 sigma 固定在 200A 的真实距离。
在推理阶段,我将整个体积加载到 RAM 上,然后执行 LINEAR 插值到固定的体素间距 (Z, Y, X) = (32, 16, 16),也就是说沿 Z (深度) 采样更稀疏。这有助于减少推理成本而不会导致性能下降太多。我使用大小为 (896, 896) 的 3 通道图像作为 2D-UNet 模型的输入。

2D 处理流水线

在训练阶段,与 3D 流水线不同 (我将整个体积加载到 RAM 然后使用 MONAI 的 lazy resampling (内部使用 F.grid_sample)),2D 流水线只采样特定的 Z 值 (遵循截断正态分布,scale 为 1 sigma),然后从磁盘加载每个样本附近的 3 张图像 (间距~32A),这非常高效。虽然 2D 方法在达到中等分数方面样本效率更高,但控制正/负样本比例仍然很重要,以便模型可以在所有数据域/分布上看到更多负切片,而不仅仅是“电机邻居”切片。我开始实验负/正比例等于 0.25,后来发现当设置为 0.75 时 CV improvement 相当显著,但没有足够的提交在 LB 上确认。

2D 增强

Albumentations 用于创建增强流水线,能够处理和跟踪关键点缩放,这非常整洁。

2D 增强代码
TARGET_SPACING = 16.0
PATCH_SIZE = (896, 896)

def _byu_get_safe_bbox(params, data, margin_xy=(100, 100)):
    """Get safe bbox to crop which contain at least 1 motor."""
    del params
    if "keypoints" in data and len(data["keypoints"]) > 0:
        kpt = random.choice(data["keypoints"])
        # x, y, angle, scale
        assert len(kpt) == 9
        x, y = kpt[:2]
        scale = kpt[3]
        return [
            x - margin_xy[0] * scale,
            y - margin_xy[1] * scale,
            x + margin_xy[0] * scale,
            y + margin_xy[1] * scale,
        ]
    else:
        return None

augment_transform = A.Compose(
    [
        # FLIP
        A.HorizontalFlip(p=0.5),
        A.VerticalFlip(p=0.5),
        # NOISE
        A.OneOf(
            [
                A.GaussNoise(
                    var_limit=(60, 120),
                    mean=0,
                    per_channel=True,
                    noise_scale_factor=0.5,
                    p=0.6,
                ),
                A.MultiplicativeNoise(
                    multiplier=(0.6, 1.4),
                    per_channel=True,
                    elementwise=True,
                    p=0.4,
                ),
            ],
            p=0.1,
        ),
        # REDUCE QUALITY
        A.OneOf(
            [
                # jitter on float32, implement AdaptiveDownscale based on current resolution
                A.OneOf(
                    [
                        A.Downscale(
                            scale_range=(0.5, 0.9),
                            interpolation_pair={
                                "upscale": cv2.INTER_LANCZOS4,
                                "downscale": cv2.INTER_AREA,
                            },
                            p=0.1,
                        ),
                        A.Downscale(
                            scale_range=(0.5, 0.9),
                            interpolation_pair={
                                "upscale": cv2.INTER_LINEAR,
                                "downscale": cv2.INTER_AREA,
                            },
                            p=0.1,
                        ),
                        A.Downscale(
                            scale_range=(0.5, 0.9),
                            interpolation_pair={
                                "upscale": cv2.INTER_LINEAR,
                                "downscale": cv2.INTER_LINEAR,
                            },
                            p=0.8,
                        ),
                    ],
                    p=0.6,
                ),
                A.ImageCompression(
                    compression_type="jpeg", quality_range=(20, 80), p=0.3
                ),
                A.Posterize(num_bits=(4, 6), p=0.1),
            ],
            p=0.25,
        ),
        # TEXTURE
        A.OneOf(
            [
                # wrong on float32 img
                A.Emboss(alpha=(0.3, 0.6), strength=(0.2, 0.8), p=0.4),
                A.Sharpen(alpha=(0.1, 0.3), lightness=(0.0, 0.4), p=0.5),
                A.CLAHE(clip_limit=4.0, tile_grid_size=(16, 16), p=0.1),
            ],
            p=0.1,
        ),
        # BRIGHTNESS & CONTRAST
        A.OneOf(
            [
                A.OneOf(
                    [
                        A.RandomBrightnessContrast(
                            brightness_limit=(-0.3, 0.0),
                            contrast_limit=(-0.2, 0.0),
                            brightness_by_max=False,
                            p=0.4,
                        ),
                        A.RandomBrightnessContrast(
                            brightness_limit=(0.0, 0.4),
                            contrast_limit=(-0.5, 0.0),
                            brightness_by_max=False,
                            p=0.4,
                        ),
                        A.RandomBrightnessContrast(
                            brightness_limit=(-0.3, 0.0),
                            contrast_limit=(0.0, 0.5),
                            brightness_by_max=False,
                            p=0.1,
                        ),
                        A.RandomBrightnessContrast(
                            brightness_limit=(0.0, 0.3),
                            contrast_limit=(0.0, 0.5),
                            brightness_by_max=False,
                            p=0.1,
                        ),
                    ],
                    p=0.4,
                ),
                A.RandomToneCurve(scale=0.3, per_channel=True, p=0.4),
                A.RandomGamma(gamma_limit=(60, 150), p=0.2),
            ],
            p=0.5,
        ),
        # GEOMETRIC
        A.OneOf(
            [
                # strong rotate
                A.Affine(
                    scale={"x": (0.6, 1.2), "y": (0.6, 1.2)},
                    translate_percent=None,
                    rotate=(0, 360),
                    shear={"x": (-5, 5), "y": (-5, 5)},
                    interpolation=cv2.INTER_LINEAR,
                    cval=128,
                    mode=cv2.BORDER_CONSTANT,
                    fit_output=True,
                    keep_ratio=False,
                    balanced_scale=False,
                    p=0.4,
                ),
                # strong shear
                A.Affine(
                    scale={"x": (0.6, 1.2), "y": (0.6, 1.2)},
                    translate_percent=None,
                    rotate=(0, 360),
                    shear={"x": (-20, 20), "y": (-20, 20)},
                    interpolation=cv2.INTER_LINEAR,
                    cval=128,
                    mode=cv2.BORDER_CONSTANT,
                    fit_output=True,
                    keep_ratio=False,
                    balanced_scale=False,
                    p=0.3,
                ),
                A.Perspective(
                    scale=(0.05, 0.12),
                    keep_size=False,
                    pad_mode=cv2.BORDER_CONSTANT,
                    pad_val=128,
                    fit_output=True,
                    interpolation=cv2.INTER_LINEAR,
                    p=0.3,
                ),
            ],
            p=0.8,
        ),
        # crop go after geometric to prevent too much information loss
        AC.CustomRandomSizedBBoxSafeCrop(
            crop_size=PATCH_SIZE,
            scale=(0.25, 1.0),  # unused
            ratio=(0.25, 1.5),  # unused
            get_bbox_func=partial(
                _byu_get_safe_bbox,
                margin_xy=(
                    1000.0 / TARGET_SPACING,
                    1000.0 / TARGET_SPACING,
                ),
            ),
            retry=10,
            p=1.0,
        ),
        A.PadIfNeeded(
            *PATCH_SIZE,
            position="top_left",
            border_mode=cv2.BORDER_CONSTANT,
            value=128,
            p=1.0,
        ),
    ],
    p=1.0,
)

2D 目标热力图

有很多方法可以渲染目标热力图。我对 4 种带有不同参数的热力图类型进行了扫描,发现最好的 CV 是高斯热力图,当远离 Z 真实坐标时,峰值置信度和半径比例变小,就像我们渲染 3D 高斯热力图然后取其 2D 切片一样。adaptive scaling > min=0, max=1 > segment > point,这支持我们需要更好地建模不确定性。

2D 热力图类型比较

2D 建模

用于 2D 建模的模型改编自 这篇 Google Contrails 解决方案 writeup,非常感谢作者公开他们的源代码。它包含一个图像编码器,一个简单的 FPN 颈部将低级特征融合到 /16 特征图,以及一个带有 Pixel Shuffle 块的 UNet 解码器。
我主要实验了不同类型的 2D 编码器,包括 EfficientNet, ConvNeXt, CoaT 和 MaxViT。毫不奇怪,分层卷积 -Transformer 主干的表现比仅卷积好得多,表明全局上下文很重要,我们需要足够的空间大小来获得足够的上下文。在选定的最终提交中,我使用了 2 个带有 timm 主干的 2D-UNet 模型:maxvit_tiny_tf_512.in1kcoat_lite_medium_384.in1k

外部数据集 & 伪标签

再次,我必须感谢 @brendanartley 提供的 外部数据集,特别是在竞赛早期阶段,以及 @tatamikenn 关于错误量化断层扫描图的 说明

一周前,在确保我的训练/推理流水线足够好且没有错误 (吗?) 之后,我开始深入研究该外部数据集以训练我的最终提交。如果没有外部数据,我有一个简单的 3D/2.5D UNet 集成,PB 0.851 和 LB 0.852

@brendanartley数据集 很好,但似乎不能直接用于我的流水线,因为切片和调整大小到固定形状并丢失了体素间距信息。所以我调整了下载脚本以获得我自己的新版本,通过伪标签 + 人工审查改进它:

  1. 跟踪体素间距和其他元数据
  2. 使用 F.interpolate() 重新间距到 target_spacing=max(16.0, ori_spacing) 以节省磁盘空间并减少未来的数据加载器开销。这产生了一个新的 180GB 外部数据集。
  3. 转换他的标签以适应我的新版本。这可能会丢失一些 Z 坐标的精度。
  4. 使用我训练的模型以 0.05 的低置信阈值对这些数据进行推理。比较模型预测结果与转换后的 GT 以识别 TP/TN/FP/FN 电机。
  5. 对于 TP,简单地信任模型预测结果而不是转换后的 GT 坐标
  6. 使用 CVAT 审查并标记 188 个 FN 和 55 个 FP。标记它很简单,就像一个标记过程,标记者 (我) 只考虑样本是错误还是正确,而不是寻找新的。几乎所有 FN 都是正确的 (?),意味着模型遗漏了太多电机。标记这些混淆的情况很难,但 F2 指标和“相信 AI 模型”惩罚我有偏见,并且可能比平时标记更多的阳性,特别是没有鞭毛的。这可能导致与公有/私有测试不一致,因为主办方 已澄清“超过 80% 包含无鞭毛电机的断层扫描图未标记”
  7. 使用相同的策略,我也尝试纠正训练数据集上的标签错误。
  8. BOOM!只是用之前发现的超参数在所有数据集上重新训练模型

添加这么多外部数据显著改变了游戏。训练更稳定,允许更长时间的训练而不过拟合。虽然需要对新设置进行更多调整,但是,截止日期很快接近最后几天..

CVAT 标注界面

后处理

和往常一样,我不使用固定阈值,因为它在提交到 LB 的模型之间变化很大。相反,只使用 55% 的分位数阈值 (55% 最低阈值样本预测为负,其余 45% 预测为正),它根据模型的置信分布自适应变化,所以只需一次 LB 提交就足以估计最佳 F2 分数。

后处理过程:

  • 我使用 5 个模型结合 TTA,输出 9 个不同的热力图
  • 不使用热力图平均,而是使用 0.2 置信阈值的 NMS 将每个热力图解码为候选关键点列表。
  • 使用 3D 加权框融合 (Weighted Box Fusion) 的版本组合所有 9 个预测以获得最终结果,使用 L2 距离进行聚类,conf_mode='avg'

我以前没有对集成方法进行很多实验,并决定在最后两天使用 WBF,只是相信它可以承受来自单个模型的一些非常强的峰值信号,从而提高 Recall。

感谢您的关注!

同比赛其他方案