596. SenNet + HOA - Hacking the Human Vasculature in 3D | blood-vessel-segmentation
首先,我要向竞赛组织者致以最诚挚的感谢。我参加计算机视觉竞赛的主要原因是出于热爱,尤其是这类医学竞赛。非常高兴能有机会使用如此先进的技术(分辨率令人难以置信)。
起初我认为验证会是一个挑战,因为我们根本没有足够的数据来进行可靠的验证。为了创建无泄漏且与测试集相似的验证方式,我决定以两种方式训练我的模型:
由于测试集是密集标注的,我只想在密集标注的肾脏上计算指标,这排除了kidney_2的参与。
我一直相信数据是关键。因此我竭尽全力利用组织者在人类器官图谱上提供的额外数据集。
最终,在伪标签的帮助下,我决定使用以下数据:
换句话说,在检查了脾脏的伪标注后,我发现它们相当不错,应该可以作为一种良好的正则化方法。
此外,我尝试过使用心脏+大脑+肺部数据。然而,我的模型对肺部的预测还算准确,但对心脏和大脑的预测非常糟糕。所以最终我决定只使用肾脏+脾脏。
我认为有两点很重要:
第一点:不要立刻伪标注所有内容。为了创建完整的伪标注,我运行了一个4步流程:
第二点:不要使用硬标签。换句话说,不要对预测结果进行阈值处理。只需使用软标签(预测值经过sigmoid函数转换到[0,1]范围内)进行训练。
在语义分割中,我一贯使用的基线损失函数是CE + Dice + Focal。这在本次竞赛中效果很好。然而,由于我们使用表面距离指标,我希望更重视掩码的边界。
因此最终我决定对大多数模型使用CE_boundaries + Dice + Focal,对单个模型使用CE_boundaries + Twersky + Focal。
Twersky更关注FN而非FP,但这一点在下一节会详细说明。
class BoundDiceFocalLoss(torch.nn.modules.loss._Loss):
def __init__(self, bound_alpha=1.0, bound_weight, dice_weight, focal_weight):
super().__init__()
self.bound = EdgeEmphasisLoss(alpha=bound_alpha)
self.dice = smp.losses.DiceLoss(mode="binary")
self.focal = smp.losses.FocalLoss(mode="binary")
self.bound_weight = bound_weight
self.dice_weight = dice_weight
self.focal_weight = focal_weight
def forward(self, preds, gt, boundaries):
return (
self.bound_weight * self.bound(preds, gt, boundaries)
+ self.dice_weight * self.dice(preds, gt)
+ self.focal_weight * self.focal(preds, gt)
)
class EdgeEmphasisLoss(nn.Module):
def __init__(self, alpha=1.0):
super(EdgeEmphasisLoss, self).__init__()
self.alpha = alpha
def forward(self, inputs, targets, boundaries):
bce_loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none")
# 应用边界加权
weighted_loss = bce_loss * (1 + self.alpha * boundaries)
# 对batch取平均
return weighted_loss.mean()
在分析初始模型及其错误后,我意识到最大的问题是FN(假阴性),而不是FP(假阳性)。换句话说,我的模型无法识别出某些掩码,主要是较小的那些。
因此,我决定通过将训练裁剪分辨率从512x512提高到1024x1024来增加分辨率。然而,训练了几个小时后我突然意识到:这并没有太大意义。从512x512到1024x512并不能真正提高分辨率(每个像素代表的实际物理尺寸相同),只是增加了上下文信息,而512x512看起来已经足够大。
相反,我决定采用以下方法:
class UnetUpscale(nn.Module):
def __init__(
self, encoder_name, decoder_use_batchnorm, in_channels, classes, upscale_factor, encoder_weights="imagenet"
):
super().__init__()
self.upscale_factor = upscale_factor
self.model = Unet(
encoder_weights=encoder_weights,
encoder_name=encoder_name,
decoder_use_batchnorm=decoder_use_batchnorm,
in_channels=in_channels,
classes=classes,
)
def forward(self, x):
x = torch.nn.functional.interpolate(
x, (x.shape[-2] * self.upscale_factor, x.shape[-1] * self.upscale_factor), mode="bilinear"
)
x = self.model(x)
x = torch.nn.functional.interpolate(
x, (x.shape[-2] // self.upscale_factor, x.shape[-1] // self.upscale_factor), mode="bilinear"
)
return x
这个方法效果非常好,我能够清晰地看到CV和LB上的改进。
我只使用了SMP库中的U-Net模型,但搭配了不同的骨干网络。尝试了很多方法,但最终集成决定采用以下模型:
Maxvit在512x512裁剪上训练,effnet和dpn在512x512分辨率但使用x2插值的情况下训练。从xy、xz和yz三个轴进行裁剪。推理时,我使用相同分辨率的裁剪,重叠区域为crop_size / 2(即256)。换句话说,就是采用滑动窗口方法。
数据增强强度为中等水平:
return A.Compose(
[
A.ShiftScaleRotate(
p=0.7,
shift_limit_x=(-0.1, 0.1),
shift_limit_y=(-0.1, 0.1),
scale_limit=(-0.25, 0.25),
rotate_limit=(-25, 25),
border_mode=cv2.BORDER_CONSTANT,
),
A.RandomBrightnessContrast(
brightness_limit=(-0.25, 0.25),
contrast_limit=(-0.25, 0.25),
p=0.5,
),
A.HorizontalFlip(),
A.VerticalFlip(),
A.OneOf(
[
A.GridDistortion(border_mode=cv2.BORDER_CONSTANT, distort_limit=0.1),
A.ElasticTransform(border_mode=cv2.BORDER_CONSTANT),
],
p=0.2,
),
AT.ToTensorV2(),
],
)
我尝试使用cc3d去除小物体,这对弱模型有改进效果,但对集成模型没有明显提升。
这部分确实很棘手。非常感谢组织者公布测试分辨率。看到组织者在论坛上与参赛者积极互动,我由衷地感到温暖。真的,谢谢你们。
一种方法是不做任何处理:在50um/voxel上训练模型,在63um/voxel上推理。考虑到我使用的基于卷积的骨干网络(除了maxvit)具有一定尺度不变性,并且在验证中使用了尺度增强,这可能有效。
第二种方法是进行重缩放。我认为正确的重缩放方法如下:
if test_kidney == 6:
private_res = 63.08
public_res = 50.0
scale = private_res / public_res
d_original, h_original, w_original = test_kidney_image.shape
test_kidney_image = torch.tensor(test_kidney_image).view(1, 1, d_original, h_original, w_original)
test_kidney_image = test_kidney_image.to(dtype=torch.float32)
test_kidney_image = torch.nn.functional.interpolate(test_kidney_image, (
int(d_original*scale),
int(h_original*scale),
int(w_original*scale),
), mode='trilinear').squeeze().numpy()
...
d_preds, h_preds, w_preds = preds_ensemble.shape
preds_ensemble = preds_ensemble.view(1, 1, d_preds, h_preds, w_preds)
preds_ensemble = preds_ensemble.to(dtype=torch.float32)
preds_ensemble = torch.nn.functional.interpolate(preds_ensemble, (
d_original,
h_original,
w_original,
), mode='trilinear').squeeze()
因此我们采用3D resize而非2D:将图像从63um(私有)重缩放到50um(公开+CV),计算预测结果,然后再重缩放回63um。虽然使用2D方法也可以,但从理论上讲,那样会导致不同的空间和体素分辨率。
这个技巧确实有帮助。举一个数据点(我没有更多数据):相同的集成模型在不使用插值时在私有集上得分为0.634,使用插值后得分为0.670。
说实话,我没想到会有这么大差别。我在本地尝试了以下实验:
所以即使我原本不认为插值那么重要,但它也没有造成负面影响(我担心过插值伪影),因此我在两个最终提交中都使用了它。
两个提交都包含3个模型的集成,每个模型在全部3个轴上进行推理(未使用TTA,因为TTA耗时太长且在CV上帮助不大)。
Maxvit_ce_dice_focal + effnet_v2_s_ce_dice_focal + effnet_v2_m_ce_dice_focal,在kidney_3上训练,在kidney_1上验证。这个方法在CV、Public和Private上表现都不够理想。Maxvit_ce_dice_focal + effnet_v2_s_ce_bounds_dice_focal + dpn_68_ce_bounds_twersky_focal。