简介:面向医学影像人工智能与深度学习分割方向的胰腺病变图像分割数据集,包含明确的训练集与测试集划分,提供像素级两类分割标注,即背景与病变区域,适合医学图像分割入门,也可用于不同分割网络的性能对比实验。数据集目录采用images与masks两组分别存放原始图像和对应标签掩膜,训练集约两百余张、测试集约五十张,原图与掩膜一一对应,划分干净,省去自行整理样本的繁琐环节。整个7z压缩包共五百三十三个文件,绝大部分为PNG图像格式,另含一个文本说明文档与一个Python可视化脚本,包体大小仅二十二点五八MB,下载与部署十分轻便。可视化脚本能够随机抽取一张样本,在本地输出原始图像、真实标签掩膜以及标签叠加在原图上的效果图,便于快速核查标注质量和分割效果。目前已有六百零五人学习下载,整套数据可作为胰腺病变分割任务的基础训练集,支撑算法调试、对比分析与教学演示。
1. 胰腺病变分割数据集:为什么说它是医学图像分割里最“难啃的骨头”
胰腺病变图像分割数据集,简单说就是一组带像素级标签的医学影像,标注目标通常分两类:胰腺实质和胰腺病变(肿瘤、囊肿等),数据按训练集、测试集和对应标签组织好,拿到的第一件事不是急着喂给模型,而是先搞明白采集模态、标注协议和类别分布。这类数据集在医学图像分割里属于高难度场景,因为胰腺在腹腔CT中体积占比极小、与周围组织对比度低,病变区域更是只占几十到几百个体素——比肺结节、肝脏肿瘤这类目标难得多。
这套数据最适合正在做腹部CT分割、胰腺癌辅助诊断或影像组学提取的团队,也适合拿来做2D/3D分割网络的基线验证。很多人拿到数据集直接开训,结果Dice系数上不去,不是模型问题,而是数据本身的门道没摸清。这篇文章就按“拿到数据集后每一步该做什么”来写,从目录核查、预处理、训练到评估和踩坑,尽量把可复现的命令和参数都放出来。
2. 拿到胰腺数据集先做三件事:目录解析、标签核查与模态确认
2.1 数据集目录长什么样:从训练集、测试集与标签的命名规则说起
医学图像分割数据集的组织方式不像通用CV数据集那么统一,常见的是两种结构。第一种是MSD风格,训练集和测试集分开,图像与标签各占一个子目录;第二种是把所有图像放一起,另用一个split清单文件(如JSON或TXT)指明哪些进训练集、哪些进测试集。拿到压缩包后先列一下目录,不要急着写训练脚本。
# 先看整体目录结构 tree -L 2 dataset/ # 常见输出: # dataset/ # ├── imagesTr/ # 训练图像 # ├── labelsTr/ # 训练标签 # ├── imagesTs/ # 测试图像 # └── dataset.json # 模态、类别和划分说明这个命令的意义在于确认两件事:一是训练集图像和标签是否成对,二是测试集是否带标签。部分数据集为了考核公平性,公开的测试集不带标签,需要自行预测后提交到评测平台,这种数据集要另外规划验证集划分。如果目录里没有任何说明文件,建议先用下面的Python脚本遍历一遍文件名匹配度。
import os from pathlib import Path img_dir = Path("dataset/imagesTr") label_dir = Path("dataset/labelsTr") imgs = sorted([p.name for p in img_dir.glob("*.nii.gz")]) labels = sorted([p.name for p in label_dir.glob("*.nii.gz")]) img_ids = [name.split(".")[0] for name in imgs] label_ids = [name.split(".")[0] for name in labels] # 检查是否一一对应 missing_label = set(img_ids) - set(label_ids) missing_img = set(label_ids) - set(img_ids) # 打印缺失情况 print("缺标签的图像数:", len(missing_label)) print("缺图像的标签数:", len(missing_img)) # 如果有缺失,把具体文件名列出来 if missing_label: print(list(missing_label)[:5])这段脚本做的是最基础的ID对齐检查。命名往往包含患者编号、模态和序列信息,比如“pancreas_001_CT.nii.gz”,分割符和编号规则需要全量自查一遍,因为数据打包时偶尔会出现文件名错位,一旦训练时图像和标签错配,模型学到的全是噪声。
2.2 用Python核查标签:统计类别分布和空掩码
胰腺病变数据集的标签通常不是单类别,典型协议是0表示背景,1表示胰腺实质,2表示病变区域。有的数据集把胰腺和病变合并成一个前景,有的则分开,这直接决定模型输出通道数。拿到标签后,第一件事是逐体素统计标签值分布,确认到底有几个类别。
import nibabel as nib import numpy as np from pathlib import Path label_dir = Path("dataset/labelsTr") class_counts = {} empty_cases = [] for label_path in sorted(label_dir.glob("*.nii.gz")): label = nib.load(str(label_path)).get_fdata() label = label.astype(np.uint8) unique, counts = np.unique(label, return_counts=True) # 记录每个文件的类别分布 for cls, count in zip(unique, counts): class_counts.setdefault(int(cls), 0) class_counts[int(cls)] += int(count) # 检查全零标签(没有前景) if len(unique) == 1 and unique[0] == 0: empty_cases.append(label_path.name) print("类别统计:", class_counts) print("空掩码文件数:", len(empty_cases)) if empty_cases: print(empty_cases[:10])这段代码是必做的,而且最好把结果保存下来。胰腺数据里经常出现两种情况:一是某个病例的标签文件是空的(标注遗漏),二是病变类别的体素数极少甚至为0(该病例没有病变)。如果训练集里包含大量“只有胰腺没有病变”的样本,Dice Loss对病变通道的梯度会被稀释,模型倾向把病变区域全预测成背景。
统计完类别后,还需要检查标签和图像的shape是否一致。NIfTI文件的shape不匹配通常发生在预处理阶段——原始图像和标签分别做了不同的重采样。如果出现shape不一致,要么用SimpleITK的ResampleImageFilter统一处理,要么直接用nibabel的shape属性对比:
for img_path, label_path in zip(imgs, labels): img_shape = nib.load(str(img_path)).get_fdata().shape lbl_shape = nib.load(str(label_path)).get_fdata().shape if img_shape != lbl_shape: print("shape不匹配:", img_path, img_shape, lbl_shape)这一步检查越早做越好。我曾见过一个数据集,99%的文件shape一致,只有2个病例的标签是从旧版本标注软件导出的,shape差了1个体素,模型训练时NLLLoss直接报错。与其等报错再排查,不如在预处理阶段一次性筛掉。
2.3 模态和文件格式判断:CT还是MRI,直接影响预处理策略
胰腺分割数据集的成像模态绝大多数是CT,少部分是MRI(T1加权、T2加权或MRCP)。这两种模态的预处理逻辑完全不同。CT是定量成像,亨氏单位(HU)有物理意义,需要做窗宽窗位裁剪来突出软组织;MRI没有统一量纲,不同采集参数下同一组织的信号强度差异很大,通常做z-score归一化即可。
判断模态可以看两个地方:一是dataset.json或README里的modality字段,二是图像本身的数值范围。CT图像的体素值通常覆盖-1024到3000的范围,MRI图像一般是0到1000多的相对信号。快速判断脚本如下:
import nibabel as nib import numpy as np img = nib.load("dataset/imagesTr/pancreas_001.nii.gz") data = img.get_fdata() print("数值范围:", data.min(), data.max()) print("体素间距:", img.header.get_zooms()) print("数据形状:", data.shape)数值范围出来之后,如果看到负值超过-500,基本可以断定是CT。体素间距也要记录,CT图像常见的间距是0.5-1.0mm的层面内分辨率、1.0-5.0mm的层厚。层厚如果太大(比如5mm),以后训练3D模型时切片间的插值误差会放大,需要重采样到各向同性或接近各向同性的间距。
标签文件同样要检查体素间距和方向信息。有些标注工具会把标签存成和原图不同方向(比如RAS和LPS互换),导致可视化时横竖颠倒。这个不一定会影响训练效果,但一定会影响你写论文时的示意图质量。排查方向用img.affine矩阵检查,方向差异大时用SimpleITK的DICOMOrientation纠正,或者直接放弃不一致的样本。
3. 预处理与数据增强:让胰腺和病变在模型眼里“露出来”
3.1 窗宽窗位裁剪:把胰腺从腹腔CT里“捞”出来的第一步
CT图像的HU值范围太大,直接把原始数值输入网络,大部分灰度会被背景(空气、骨骼)占据,胰腺这种软组织在模型眼里几乎是透明的。所以第一步是窗宽窗位(WW/WL)裁剪。胰腺CT扫描的常规窗位是30-50 HU,窗宽是200-350 HU。实际操作中我一般用WL=40、WW=300,把范围之外的体素截断。
import numpy as np import nibabel as nib def apply_window(image, window_width=300, window_level=40): """CT窗宽窗位裁剪""" lower = window_level - window_width / 2.0 upper = window_level + window_width / 2.0 image = np.clip(image, lower, upper) # 线性映射到[0, 1],便于后续归一化 image = (image - lower) / (upper - lower) return image.astype(np.float32) img = nib.load("dataset/imagesTr/pancreas_001.nii.gz").get_fdata() img_w = apply_window(img, 300, 40) # 保存为npy或nii.gz供训练用这里的参数不是死的。如果数据集里的病灶在增强CT上呈现低密度囊变,可以适当调窄窗宽;如果关注的是钙化或出血灶,窗宽需要放大。胰腺和周围组织(十二指肠、胃、脾脏)之间的分界很多时候只有十几HU的差异,窗宽太大会把软组织对比度压没,窗宽太小又会引入噪声。建议先可视化几张图,肉眼确认胰腺边界清晰后再批量处理。
注意窗宽窗位裁剪只适用于CT模态。如果是MRI数据集,跳过这一步,直接做z-score归一化,否则反而会破坏信号分布。
3.2 重采样与ROI裁剪:体素间距统一是3D分割的命门
CT设备采集的层厚和像素间距不同,同一个数据集里可能有0.7mm和3.0mm两种层厚的数据。如果不做重采样,模型看到的胰腺大小差异极大,泛化能力会明显下降。医学图像分割数据集里最常见的预处理流程是:把所有图像重采样到固定的目标间距,然后裁剪到包含目标的ROI区域。
import SimpleITK as sitk def resample_to_spacing(itk_image, target_spacing=(1.0, 1.0, 1.5)): """重采样到目标体素间距""" resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize([int(round(sz * sp / target_spacing[i])) for i, (sz, sp) in enumerate(zip(itk_image.GetSize(), itk_image.GetSpacing()))]) resampler.SetOutputDirection(itk_image.GetDirection()) resampler.SetOutputOrigin(itk_image.GetOrigin()) resampler.SetInterpolator(sitk.sitkLinear) return resampler.Execute(itk_image)重采样时图像用线性插值,标签必须用最近邻插值,否则标签边缘会出现模糊的中间值(比如0.5),导致类别数变多或边界无法确定。很多新手在这翻车——图像和标签用同一套插值,训练时Dice莫名其妙的下降,查了一天发现标签被插值污染了。记住这条规则:图像线性插值,标签最近邻插值。
重采样之后还需要做ROI裁剪。胰腺在腹腔中的位置相对固定,但不同人群体型差异大,把整个腹部图像直接喂给模型,计算量浪费在背景上且类别不平衡更严重。常见做法是找胰腺和病变包围盒,向外扩一定边距(比如10-15mm)后裁剪;推理时再映射回原始坐标。
3.3 数据增强:小目标分割必须靠增强“制造”多样性
胰腺和病变区域在整幅图像中占比太小——胰腺约占腹部体积的1%左右,病变通常只有胰腺体积的十分之一。没有足够的数据增强,模型很容易陷入“全预测为背景”的局部最优。增强策略要针对小目标设计,不能直接套用自然图像分割的通用增强。
import random import numpy as np from scipy.ndimage import rotate, zoom, gaussian_filter def augment_pair(image, label): """图像-标签同步增强""" # 随机旋转(角度范围不宜过大,胰腺解剖方向相对固定) angle = random.uniform(-10, 10) image = rotate(image, angle, axes=(1, 0), reshape=False, order=1, mode='constant') label = rotate(label, angle, axes=(1, 0), reshape=False, order=0, mode='constant') # 随机缩放(0.9~1.1倍) factor = random.uniform(0.9, 1.1) image = zoom(image, (1, factor, factor), order=1) label = zoom(label, (1, factor, factor), order=0) # 高斯噪声(模拟低剂量CT) if random.random() < 0.3: noise = np.random.normal(0, 0.01, image.shape).astype(np.float32) image = np.clip(image + noise, 0, 1) return image, label旋转角度限定在±10度,因为胰腺在解剖结构中的朝向相对固定,大幅旋转会生成解剖学上不合理的样本。缩放放在轴向平面(1, factor, factor),层间方向不做缩放是因为层厚和插值精度不同,容易引入伪影。弹性形变对胰腺这种软组织很有效,但ElasticDeformation计算量大,建议在数据量不足时再启用。
增强的强度要不要做随机化?我的经验是概率性启用而不是每次全用,这样能让模型看到“增强过”和“没增强”两种分布,收敛更稳。另外,病变区域如果特别小(比如小于100个体素),单纯的几何增强可能不够,可以考虑过采样包含病变的切片,把有效样本比例提上来。
4. 训练与验证:用UNet跑通胰腺分割的最小可行方案
4.1 2D还是3D:小数据集下的架构选型逻辑
胰腺CT数据通常是三维体积,但直接用3D网络对显存和训练时间要求很高。2D方案把CT体积按切片拆开,用UNet逐层分割,然后按切片堆叠回3D。3D方案保持空间连续性,但对标注质量和数据量要求更高——训练集少于50例时3D网络很容易过拟合。
我的建议是:数据量少于80例先跑2D;超过150例且显存允许,再换3D。2D方案的另一个好处是可以借用预训练权重(比如在ImageNet或在其他医学分割任务上预训练的编码器),冷启动更快。标题里这个数据集同时含训练集和测试集,如果训练集规模不大,2D UNet是最稳妥的起点,不要一上来就追Swin-UNet、UNETR这些大模型。
4.2 损失函数与评价指标:Dice Loss不是唯一答案
胰腺病变分割的类别不均衡极其严重。普通交叉熵损失会被背景主导,模型几乎学不到前景信息。Dice Loss对小目标敏感,但训练初期梯度不稳定。组合损失(Combo Loss)更实用:用Cross Entropy保持梯度稳定,用Dice Loss拉高前景区域的关注度。
import torch import torch.nn as nn import torch.nn.functional as F class ComboLoss(nn.Module): """交叉熵 + Dice 组合损失""" def __init__(self, weights=(1.0, 1.0), num_classes=3): super().__init__() self.ce_weight, self.dice_weight = weights self.num_classes = num_classes def forward(self, logits, targets): # logits: (B, C, H, W), targets: (B, H, W) ce = F.cross_entropy(logits, targets) # 计算每个类别的Dice probs = F.softmax(logits, dim=1) target_onehot = F.one_hot(targets, num_classes=self.num_classes).permute(0, 3, 1, 2).float() smooth = 1e-5 intersection = (probs * target_onehot).sum(dim=(0, 2, 3)) union = probs.sum(dim=(0, 2, 3)) + target_onehot.sum(dim=(0, 2, 3)) dice = (2 * intersection + smooth) / (union + smooth) dice_loss = 1 - dice.mean() return self.ce_weight * ce + self.dice_weight * dice_loss组合系数上,CE和Dice可以都设为1.0,也可以让Dice更大一些(如1.2)来强调前景。如果你发现模型训练后期Dice不再上升,往往是CE项在拉低边界精度而Dice项在推动整体重合——这时把CE权重降到0.5,让Dice主导收敛方向。
损失函数是越复杂越好吗?不一定。Focal Loss处理小目标也有效,但有两个超参需要调,多一层调参成本。对胰腺这个场景,Combo Loss是性价比最高的起点。
4.3 训练脚本与关键超参:批量大小、学习率与早停策略
部署一个2D UNet的最小训练脚本,重点在于数据加载、采样策略和验证逻辑。胰腺CT切片大量是纯背景切片(胰腺只出现在部分层),训练时做采样均衡很有必要。
import torch from torch.utils.data import Dataset, DataLoader import nibabel as nib import numpy as np class Pancreas2DDataset(Dataset): """按切片级别加载的胰腺CT数据集""" def __init__(self, image_paths, label_paths, phase="train"): self.image_paths = image_paths self.label_paths = label_paths self.phase = phase # 记录哪些切片包含前景 self.valid_slices = self._collect_valid_slices() def _collect_valid_slices(self): valid = [] for img_path, label_path in zip(self.image_paths, self.label_paths): label = nib.load(str(label_path)).get_fdata() label = label.astype(np.uint8) for z in range(label.shape[2]): if label[:, :, z].max() > 0: # 包含前景的切片 valid.append((img_path, label_path, z)) return valid def __len__(self): if self.phase == "train": return len(self.valid_slices) * 4 # 4倍过采样 return sum(nib.load(str(p)).get_fdata().shape[2] for p in self.label_paths) def __getitem__(self, idx): if self.phase == "train": img_path, label_path, z = self.valid_slices[idx % len(self.valid_slices)] else: # 测试阶段按顺序遍历全部切片 pass # 读完图像和标签后返回对应切片 return image_slice, label_slice这段代码的思路是:训练阶段只在“包含前景的切片”里采样,并且把前景切片重复4次凑成完整epoch,这样模型每个batch都有充分的胰腺和病变样本。测试/验证阶段遍历全部切片,保证评估结果覆盖全部层面。这个采样策略对提升Dice效果明显,属于必做的工程改造。
超参数方面,2D UNet在胰腺分割上有一组比较稳的起点:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 256×256或512×512 | 根据显存和胰腺大小选择 |
| 批量大小 | 8-16(2D) | 3D下减半 |
| 学习率 | 1e-4(AdamW) | 用warmup+余弦退火 |
| 训练轮数 | 100-200 | 配Early Stopping |
| 损失函数 | CE+Dice(1:1) | 后期可调Dice权重 |
| 优化器 | AdamW | weight_decay=1e-5 |
训练时务必保存每个epoch的验证Dice,不能只看训练loss。胰腺分割的loss曲线经常出现“看起来在降,但Dice不动”的情况,因为类别不平衡下loss主要由背景主导。用模型预测后把前景区域单独可视化,确认边界是否贴合,比只盯loss数字可靠得多。
4.4 测试集评估:Dice、IoU、HD95一个都不能少
测试集评估不能只看Dice系数,医学图像分割论文里通常要报告三个指标:Dice(体素重合度)、IoU(交并比)、HD95(95%豪斯多夫距离,衡量边界偏差)。Dice对体积大的目标有偏向——当病变体积很小时,即使边界错位几个体素,Dice下降也不明显,而HD95能捕捉到这种边界误差。
import numpy as np from scipy.ndimage import distance_transform_edt def compute_metrics(pred, gt, voxel_spacing=(1.0, 1.0, 1.5)): """计算Dice、IoU、HD95""" pred_bin = (pred > 0).astype(np.uint8) gt_bin = (gt > 0).astype(np.uint8) # Dice intersection = (pred_bin & gt_bin).sum() dice = 2 * intersection / (pred_bin.sum() + gt_bin.sum() + 1e-5) # IoU union = (pred_bin | gt_bin).sum() iou = intersection / (union + 1e-5) # HD95 if pred_bin.sum() == 0 or gt_bin.sum() == 0: hd95 = float('nan') else: # 用欧几里得距离变换计算边界距离 pred_dist = distance_transform_edt(1 - pred_bin, sampling=voxel_spacing) gt_dist = distance_transform_edt(1 - gt_bin, sampling=voxel_spacing) # 只取边界点 pred_boundary = pred_bin & (pred_dist > 0) gt_boundary = gt_bin & (gt_dist > 0) pred_to_gt = pred_dist[gt_boundary > 0] gt_to_pred = gt_dist[pred_boundary > 0] if len(pred_to_gt) == 0 or len(gt_to_pred) == 0: hd95 = float('nan') else: hd95 = np.percentile(np.concatenate([pred_to_gt, gt_to_pred]), 95) return dice, iou, hd95注意HD95只有在分割结果不为空时才有效。如果预测全背景,HD95应记为NaN而不是0——做统计分析时把NaN样本单独标记。这三个指标建议按类别分别计算,胰腺实质和病变分开报告,因为大多数数据集里病变的Dice天然低于胰腺实质,混在一起会掩盖问题。
5. 胰腺分割数据集的五个常见坑:现象、原因与解决
5.1 训练Loss正常下降但分割结果全是背景
现象:训练收敛,验证集上Loss在降,但预测结果只有背景,胰腺区域完全没有预测出来。
原因:类别极度不平衡时,模型学会了“全部预测为背景”来最小化Loss。交叉熵项在背景类别上太容易得到低损失,Dice Loss权重不足时拉不回来。
解决:把损失函数改成Dice主导(Dice权重从1.0提高到2.0或3.0),同时在训练中强制监控前景类的Dice。如果前景Dice一直接近0,说明模型根本没有学到前景特征,需要检查是否有标签错位、预处理窗宽窗位不合理等问题。
提示:训练阶段不要只观察总Loss,要按类别打印Dice。背景类Dice永远是1.0附近,只有前景Dice才是有效信号。
5.2 训练集Dice高但测试集Dice差距大
现象:训练集Dice能到0.85,测试集只有0.55,差距明显。
原因:最常见的是数据划分泄露——同一个患者的多个序列被拆到了训练和测试两个集合里。胰腺CT数据集经常包含同一患者的平扫和增强扫描,如果按文件名而不是按患者ID划分,模型等于见过测试图像的内容。
解决:先检查患者ID的粒度,确保同一个患者的所有序列只出现在一个集合里。如果数据集没有提供患者ID字段,用文件名前缀作为近似ID,按前缀进行分层划分。跨设备、跨中心的数据差异也会造成这个现象,重采样和归一化可以缓解,但不能完全消除。
5.3 标签与图像尺寸不一致导致训练报错
现象:训练到一半报错size mismatch或IndexError,检查后发现部分样本的标签shape与图像不一致。
原因:数据集来源混杂,不同病例的标签可能是不同标注软件导出的,有的标签Resize过,有的没有。只统计了整体文件数量一致就直接开训,没有逐个核对shape。
解决:写一个预处理巡检脚本,遍历全部样本对比image.shape和label.shape。不一致的样本单独重采样(标签始终用最近邻插值)。如果只有一个或两个坏样本,直接丢弃比修复更稳定——修复过程中可能出现方向矩阵错位的问题。
5.4 数据增强把标签插值出了“新类别”
现象:训练发现标签类别数不是3,而是包含4、5等中间值,损失函数计算时直接报错。
原因:数据增强里图像和标签用了同一种插值方法。线性插值会把标签数值在1和2之间平滑出1.5这种非法值,softmax交叉熵在计算时直接崩掉。
解决:所有涉及标签的变换强制用order=0最近邻插值。这个问题最常出现在随机旋转和Resize里,写增强函数时务必分开设置图像和标签的interpolation参数。建议在数据加载后加一个断言,检查标签值集合是否等于数据集应有的类别集合。
5.5 后处理缺失导致假阳性太多
现象:分割结果在胰腺区域外观合理,但在脾脏、肾脏边缘出现大量小块假阳性,最终Dice和HD95都被拉低。
原因:模型对边界软组织区分能力不足,产生零散的孤立预测区域。医学图像分割竞赛的提交结果通常会做后处理——去掉面积小于阈值的连通区域,填充内部的空洞。
解决:预测完成后加一步连通域分析。胰腺在单张CT切片上是连续区域,病变也是连通的(肿块不会分布成满天星状)。用scipy.ndimage.label找到所有连通区域,只保留体积大于最小阈值的区域。
from scipy.ndimage import label, generate_binary_structure def remove_small_regions(pred_mask, min_voxels=50, structure_ndim=3): """去除小于min_voxels的连通区域""" struct = generate_binary_structure(rank=3, connectivity=1) labeled, num_features = label(pred_mask, structure=struct) # 统计每个连通区域的大小 sizes = np.bincount(labeled.ravel()) keep_ids = np.where(sizes >= min_voxels)[0] keep_ids = keep_ids[keep_ids != 0] # 去掉背景 mask = np.isin(labeled, keep_ids) return mask.astype(np.uint8)min_voxels参数怎么定?先可视化几个病例的预测结果,统计非胰腺假阳性区域的大小分布,取比最大假阳性区域略大的值。如果目标是检测小病变,阈值不能设太大,否则微小病灶会被当成噪声滤掉——这一步需要手动调几次才能找到平衡点。
6. 把数据集价值榨干:从单一模型到稳定可复现的实验基线
6.1 固定随机种子与分层交叉验证
医学图像分割数据集的样本量通常不大,几十到几百例不等。单次划分的结果偶然性太强,直接用固定训练集/测试集得出的指标不能完全反映模型水平。推荐做5折交叉验证——把训练集按患者ID分层成5份,每次用4份训练、1份验证,最后报告5折的平均Dice和标准差。
交叉验证的关键是随机种子要固定,并且数据增强内部的随机数也固定。用PyTorch的话,在训练脚本开头设置torch.manual_seed(42)、np.random.seed(42)还不够,DataLoader的worker随机数也要固定。我一般把随机种子写进实验配置里,作为可复现实验的一部分记录到日志中,否则隔一周重跑同样代码,结果就变了。
6.2 从单模型到集成:平均预测降低边界不确定性
胰腺分割模型单跑的边界往往不够干净,尤其是胰腺与十二指肠等周边组织的分界区域。每次训练用不同的随机种子跑5-10个模型,推理时对预测概率做平均,再取argmax得到最终分割结果。集成虽然增加了训练成本,但Dice一般能提升1-3个百分点,HD95能改善更多——因为多个模型的误差在不同位置,平均后边界更稳定。
如果你连训练多个模型的成本也没有,还有一个轻量技巧:在推理阶段做三次翻转增强(水平翻转、上下翻转、水平+上下翻转),把三个预测概率平均。这个做法在2D分割里几乎是零成本的性能提升,尤其在胰腺这种左右结构基本对称的器官上效果明显。
6.3 胰腺和病变分开评估,再谈模型好不好用
一个容易被忽视的细节:胰腺病变数据集的评估要分开看。胰腺实质的分割Dice通常0.8以上算合格,而病变区域因为体积小、边界不规则,Dice 0.6以上已经算不错。如果合并报告,模型对正常胰腺分割得很好但病变完全没分割出来,总Dice也会被拉高到看似可用的水平,掩盖了实际缺陷。
我的习惯是每次实验输出一张按类别拆分的指标表,包含胰腺Dice、病变Dice、病变HD95。当病变Dice明显偏低时,优先检查训练集里包含病变的样本比例——如果病变样本太少,需要过采样增强或改用patch-based训练,把病变区域所在的子块放大后输入模型。把这两类指标拆开看,做的才是有诊断意义的分割,而不是自欺欺人的总指标。
用了这么长时间做医学图像分割,最大的教训就是:数据集质量决定了模型上限,预处理和评估方式决定了能不能看见真实水平。胰腺数据集尤其如此,它不给你侥幸的空间——背景大、目标小、边界模糊,每一步偷懒都会在Dice上体现出来。把这套流程跑通,你已经有了一份可复现、可对比、能支撑论文结论的基线。希望帮到你。
本文还有配套的精品资源,点击获取