花了一个多月的时间,打了一个kaggle比赛,这是我第一次做分割任务,还是很有意思的。
比赛地址 Kaggle肠胃分割比赛: UW-Madison GI Tract Image Segmentation
给我最大的感受就是,打比赛和发论文最大的不同在于,发论文首先会从model的角度去看待问题,会先想的是怎么让这个model更强,怎么让这个trick听起来更高大上,然后去编故事发论文。但是打比赛一定是先考虑数据的事,一定是根据对数据的理解然后去想可能有用的trick去尝试提分。
比赛最后是拿了铜牌(本来A榜是银尾的,后来B榜shake到了铜尾,别人都是shake up的,我是shake down,唉,还是对数据理解的不深入啊),然后我是组队参加的,建议大家多组队啊,可以从队友身上学到很多东西。
这篇博客主要是记录下自己打这个比赛自己探索的过程,以及比赛后看了一些top方案重新对数据的反思。
这是一个多标签、3类别、语义分割问题。
训练集共85个病例,每个病例有1-5次MRI扫描,每次扫描会截取 144张 单通道 的图片。比赛方给的数据如下,train下面放的是所有的扫描图像,按 train/casexxx/dayx 每个病例的每次扫描的图片分开存储。train.csv存放训练的mask标签,train.csv总共有三列,分别是:
经过简单的数据探索,我们得到数据集的一些基本特点,这里总结下:
可视化图片和mask(单通道原图)
病例18,day0,切片0060-0069的图片和mask标签可视化长这样:
我是在比赛结束前一个半月开始打的,为了快速的上手这个项目,我的baseline直接用的就是 Discussion 中的项目:UWMGI: Unet [Train] [PyTorch] 和 UWMGI: Unet [Infer] [PyTorch]
amp配置(这里做了几组消融实验才确定这个amp配置):
unet-effb3
320x320
epoch12
lr=1e-3
train_bs=64
fold=5
# 数据增强 直接用的肾小球比赛的数据增强参数
dict(type='RandomFlip', direction='horizental', p=0.5)
dict(type='RandomFlip', direction='vertical', p=0.5)
dict(type='RandomRotate',prob=0.5, degree=90, pad_val=0, seg_pad_val=255),
transforms=[dict(type='ElasticTransform', alpha=1, sigma=50, alpha_affine=50, p=0.2)
dict(type='GridDistortion', num_steps=5, distort_limit=0.05, p=0.2)
dict(type='OpticalDistortion', distort_limit=2, shift_limit=0.5, p=0.2)]
,p=0.5),
0.5bce+0.5dice
lb:0.855
这个trick应该是一个kaggle比赛必涨点的trick了,所以我这个比赛也直接用了这个trick。关于原理不理解的可以看下这篇博文: 【Kaggle比赛常用trick】K折交叉验证、TTA。
不过这个比赛比较特殊,我是这样用的:
# 划分为CFG.n_fold折,打乱数据集顺序进行训练
skf = StratifiedGroupKFold(n_splits=CFG.n_fold, shuffle=True, random_state=CFG.seed)
# 先按病例分group, 保证每一个病例的所有图片只在一折上,防止出现同一病例的图片既在训练集又在验证集上
# 再在group的基础上按照empty进行分层采样,保证每一折有mask和无mask的样本尽量均衡
for fold, (train_idx, val_idx) in enumerate(skf.split(df, df['empty'], groups = df["case"])):
这部分我们主要是参考这个 kaggle: discussion 和 kaggle: discussion,看看大佬的发现,说实话自己不太可能一张张的检测数据,而且这个任务的数据还是 1 channel 的数据,不是很好观察,所以我就直接去 Discussion找,没想到还真有人探索这个问题,Discussion牛皮!
我把大佬们的发现总结了一下,下面这些数据都是有问题的:
mask是错的:CASE 7 –– DAY 0
mask是错的:CASE 43 –– DAY 18 –– Day26
mask是错的:CASE 81 –– DAY 30
缺少mask:Case85 –– Day23 slices 119-124
缺少mask:Case90 –– Day29 slices 115-119
缺少mask:case133 –– Day25 slices 111-113
mask是错的:CASE 138 –– DAY 0
代码:
# 删除脏数据
from tqdm.notebook import tqdm
Case138_Day0 = [i for i in range(76,145)]
Case85_Day23 = [119,120,121,122,123,124]
Case90_Day29 = [115,116,117,118,119]
Case133_Day25 = [111,112,113]
df3 = df1.copy()
Case7 = []
Case43 = []
Case81 = []
Case85 = []
Case90 = []
Case133 = []
Case138 = []
for i,row in tqdm(df1.iterrows(), total=len(df1)):
if row.id.rsplit("_",2)[0]=='case7_day0':
Case7.append(i)
elif row.id.rsplit("_",2)[0]=='case43_day18' or row.id.rsplit("_",2)[0]=='case43_day26':
Case43.append(i)
elif row.id.rsplit("_",2)[0]=='case81_day30':
Case81.append(i)
elif row.id.rsplit("_",2)[0]=='case138_day0':
if int(row.id.rsplit("_",1)[-1]) in Case138_Day0:
Case138.append(i)
print(len(Case7),len(Case43),len(Case138))
df1.drop(index=Case7+Case43+Case81+Case138 ,inplace=True)
df3 = df1.reset_index(drop=True)
删除前后数据对比:
- | 删除前数据 | 删除后数据 |
---|---|---|
总图片数量 | 38496 | 37851 |
空mask图片数量 | 21906 | 21553 |
有mask图片数量 | 16590 | 16298 |
病例数量 | 85 | 85 |
总共删除645张图片,有mask的292张,没mask的353张。
性能提升:lb=0.856
原先的数据是单通道的数据,为了让这种单通道的数据能在2D的网络上跑起来,baseline是将每个图片复制3份,然后直接concat拼接,送入网络进行训练的。
我自己探索数据一星期左右,又看到大佬开源了对2.5D数据的探讨: UWMGI: 2.5D [Train] [PyTorch] 和 UWMGI: 2.5D [Infer] [PyTorch]
2.5D的思路:对于slice3这张训练图片而言,我不再是把slice3复制3份再concat送入网络了,而是将slice1、slice3、slice5这三种图片concat起来,再送入网络中训练,mask还是实验slice3的mask。依次增加模型的深度信息/空间上的信息。
代码:
channels=3
stride=2
for i in range(channels):
df[f'image_path_{i:02}'] = df.groupby(['case','day'])['image_path'].shift(-i*stride).fillna(method="ffill")
df['image_paths'] = df[[f'image_path_{i:02d}' for i in range(channels)]].values.tolist()
几个细节:
2.5D改造后的图片和mask:
效果提升最大,lb=0.869
对比实验:s=1 3channel、s=2 3channel、s=1 5channel
看了看大佬关于数据的理解分析: Data augmentations that make sense
这个比赛的胸腔位置相对固定,肠胃分布也相对固定,所以初步分析数据增强:
最终的实验方案:
CenterCrop # 上下左右各裁剪5%
dict(type='RandomFlip', direction='horizental', p=0.5)
# 三种非刚性变换 描述的是对几何物体大小的改变 形变比较小的
transforms=[dict(type='ElasticTransform', alpha=1, sigma=50, alpha_affine=50, p=0.5) # 弹性变换
dict(type='GridDistortion', num_steps=5, distort_limit=0.05, p=0.5) # 网格失真
dict(type='OpticalDistortion', distort_limit=2, shift_limit=0.5, p=0.5)] # 光学畸变
,p=1)
def CenterCrop(image, crop_ratio=0.9):
height, width, channel = image.shape
xmin, ymin, xmax, ymax = int(width * (1 - crop_ratio) / 2), \
int(height * (1 - crop_ratio) / 2), \
int(width * (1 + crop_ratio) / 2), \
int(height * (1 + crop_ratio) / 2)
crop_image = image[ymin:ymax, xmin:xmax, ...]
extra_info = [height, width, xmin, ymin, xmax, ymax]
return crop_image, extra_info
实验结果:
性能效果,lb=0.873
因为数据集当中有mask的16590张,没有mask的21906张,没有mask的图片比有mask的图片还多,而且有mask的图片当中也有一些背景区域的,所以整个数据集我认为是有正负样本不平衡问题的。所以这里尝试使用focal loss来代替bce。
最后的方案,0.5focal loss+0.5dice loss
focal loss: gamma=1.0,alpha=0.5
lb = 0.875
到此为止,单模总共最高是打到了0.875,从baseline的0.854到现在的单模0.875还是很爽的,到此,比赛也快结束了,下面是尝试模型融合部分。
对齐单模的所有超参,我又跑了effb4、b5、b6、b7,因为我单卡3090,后面4个模型没法bs=64跑了,我就用了梯度累加的方法,把bs降到了32,每两个mini-batch更新一次梯度,具体原理下面会讲。
跑完之后发现可能是因为b6、b7的模型太大太复杂了,而我们本次的数据集真的算小了,所以可能出现过拟合了,b6、b7反而降分了。
b2 | b3 | b4 | b5 | b6 | b7 |
---|---|---|---|---|---|
0.873 | 0.875 | 0.877 | 0.876 | 0.873 | 0.873 |
最终用(b3、b4、b5三个模型)* 5折进行模型融合,代码如下:
for _, (images, ids, h, w) in pbar:
images = images.to(CFG.device, dtype=torch.float) # [b, c, w, h]
size = images.size()
masks = torch.zeros((size[0], 3, size[2], size[3]), device=CFG.device, dtype=torch.float32) # [b, c, w, h]
# cross validation infer
for sub_ckpt_path in ckpt_paths:
model = build_model(CFG, test_flag=True)
model.load_state_dict(torch.load(sub_ckpt_path))
model.eval()
y_preds = model(images) # [b, c, w, h]
y_preds = torch.nn.Sigmoid()(y_preds)
# 每个channel对应位置相加
masks += y_preds/len(ckpt_paths)
# 最后大于0.5的保留
masks = (masks.permute((0, 2, 3, 1))>CFG.thr).to(torch.uint8).cpu().detach().numpy() # [n, h, w, c]
说白了就是讲这15个模型全部进行前向推理,得到15个mask,然后让这15个mask进行element-wise add,再除以15,然后再进行阈值判断,如果>0.5说明属于这个类别。
lb=0.880
使用horizental进行测试增强,代码如下:
for _, (images, ids, h, w) in pbar:
images = images.to(CFG.device, dtype=torch.float) # [b, c, w, h]
size = images.size()
masks = torch.zeros((size[0], 3, size[2], size[3]), device=CFG.device, dtype=torch.float32) # [b, c, w, h]
# cross validation and TTA
total_ckpt_paths = len(ckpt_paths_dict) * CFG.n_fold
for backbone_name, ckpt_paths in ckpt_paths_dict.items():
CFG.backbone = backbone_name
for sub_ckpt_path in ckpt_paths:
model = build_model(CFG, test_flag=True)
model.load_state_dict(torch.load(sub_ckpt_path))
model.eval()
y_preds = model(images) # [b, c, w, h]
y_preds = torch.nn.Sigmoid()(y_preds)
masks += y_preds
if CFG.tta:
# flips = [[-1],[-2],[-2,-1]] # x,y,xy flips as TTA
flips = [[-1]] # x flip as TTA
for f in flips:
images_f = torch.flip(images, f)
y_preds = model(images_f) # [b, c, w, h]
y_preds = torch.flip(y_preds, f)
y_preds = torch.nn.Sigmoid()(y_preds)
masks += y_preds
if CFG.tta:
total_ckpt_paths = len(ckpt_paths_dict) * CFG.n_fold * 2
else:
total_ckpt_paths = len(ckpt_paths_dict) * CFG.n_fold
masks /= total_ckpt_paths
masks = (masks.permute((0, 2, 3, 1))>CFG.thr).to(torch.uint8).cpu().detach().numpy() # [n, h, w, c]
可以看出TTA的原理就是先对原图进行水平翻转,再送进模型,推理得到mask,再对mask进行水平翻转,然后和其他的模型一起做模型融合。最后呢我们的整个程序相当于有30个权重文件进行进行融合。
涨点了0.001,lb=0.881
原来的bs我们一直设置的64,但是effb4之后就无法使用64继续进行训练了(单张3090太伤了,只能找这种曲线救国的方法~),为了继续控制变量进行对比实验,这里我们使用梯度累加的方法。将bs变为32,但是2个batch再更新一次梯度,相当于变相的增大bs。不清楚的可以看看这篇文章:深度学习节省显存的trick之梯度累积。
具体使用方法如下:
计算损失
loss = loss / 2 # 计算当前mini-batch的loss 注意要/2 否则可能发生梯度爆炸
loss.backward() # 反向传播计算梯度 并累加梯度
if step % 2 == 0
optimizer.step() # 每2个mini-batch更新一次参数
optimizer.zero_grad() # 梯度清零
step+=1
因为这个数据集给了四种图片尺寸,266 x 266、360 x 310、276 x 276 、234 x 234,加上每个人的器官的大小都是不一样的,所以我认为多尺度训练可以有利于我们检测不同个体的不同大小的器官。所以探索了很久,设置224x224、256x256、288x288、320x320四种尺度随机进行多尺度训练,但是发现怎么也不涨点,反而还掉0.001-0.002,气死。
但是比赛完了之后,看到一个大佬的多尺度渐进式训练方案:使用 [224, 256], [256, 288], [288, 320],[320, 384]这四个尺度,每隔5个epoch涨一次分辨率,直到涨到[320, 384]。测试使用最大分辨率进行测试。然后他居然涨点了…
我的随机多尺度训练为什么不涨点?
伪标签的思想来自于俄罗斯的第一位kaggle master。
因为这个比赛是有没有mask的数据,而且训练集当中没有mask的数据比有mask的数据还要多,测试集的比例并不知道,但是肯定也是有没用标注的mask的。
我的解决方案是使用focal loss代替BCE去解决它的正负样本不平衡问题。但是我看了大佬的方案后发现,有很多人都是使用两阶段的算法来更精确的解决这个问题的:
3D模型的思路后面有机会再讲吧,现在还不是很了解3D UNet模型。
3D图像分x、y、z轴,增加的z轴信息 / 空间信息 / 深度信息。
其实我们A榜是在银牌尾部的,但是最后B榜就变成铜牌区末尾了,我感觉的主要原因:
经验:
比赛地址: UW-Madison GI Tract Image Segmentation
比赛快速理解: Collection of My Resources/Thoughts For This Competition
数据集理解1: EDA & In Depth Mask Exploration
2.5D数据: UWMGI: 2.5D stride=2 Data
关于这个比赛数据增强的理解:Data augmentations that make sense
3D模型解决方案 :[LB 0.877] A 3D solution with MONAI