ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

医学图像分类实战:肾脏肿瘤数据预处理、模型微调与评估全流程

医学图像分类实战:肾脏肿瘤数据预处理、模型微调与评估全流程 简介面向医学影像分类与深度学习入门人群这份资源提供了肾脏结节与肿瘤识别的完整图像数据集包含正常、结节、肿瘤三个类别可同时用于YOLOv5分类任务及CNN分类网络训练适合课程设计、论文实验与算法练习。压缩包内数据已按训练集、验证集、测试集分目录存放图片数量依次为2800张、800张、400张并附类别字典JSON文件与可视化脚本打开即可查看样本分布。资源共2000个文件以JPG图片为主另有PY脚本和JSON文件整体151.5MBJSON文件记录了类别名称与索引映射方便训练时读取标签目录结构简洁规范。已有300人学习下载。相比纯图片包这份数据还提供了明确的划分和标注对应关系借助附带脚本可快速预览各类图片便于核对类别平衡情况并开展模型训练与效果对比。1. 医学图像分类不是把图片丢进模型就完事一份肾脏肿瘤数据集的完整落地思路做医学图像分类的都知道拿到一套“划分好的数据【文件夹保存】、类别字典文件”的肾脏结节、肿瘤数据集时最容易踩的坑恰恰是“以为数据已经准备好直接就能训练”。文件夹分好了不代表标签对齐类别字典给了不代表类别平衡更不代表每个病人的切片不会串到验证集里。本文就以这类典型的肾脏影像数据为例把从数据读取、预处理、模型选型、训练参数到评估排查的完整路径拆开讲。适合正在做医学图像分类的算法工程师、影像科室研究生以及想用手头数据集快速跑通 baseline 的人。2. 从文件夹与类别字典还原可训练数据集用 Python 把 labels.json 和目录结构对齐2.1 千千万万别先训练先把数据目录和字典文件长什么样看清楚拿到这类数据第一件事是打开目录结构。常见的封装方式是一个 data 目录下分 train、val、test 三个子集每个子集里再按类别名建子文件夹比如 normal、cyst、tumor。每个子文件夹里放对应的影像切片。类别字典文件则是一个 JSON把类别名和数字 ID 映射起来。先跑一条命令看全貌find data -maxdepth 3 -type d | sort输出一般是这个形态data data/train data/train/normal data/train/cyst data/train/tumor data/val data/val/normal data/val/cyst data/val/tumor data/test ...这里第一层是数据集根目录第二层是 split第三层是类别。如果看到 train 里三类齐全而 val 里少了一类说明划分本身有问题后面训练会出大乱子。接着读类别字典文件import json with open(labels.json, r, encodingutf-8) as f: labels json.load(f) print(labels)常见的有两种格式。一种是{normal: 0, cyst: 1, tumor: 2}一种是{0: normal, 1: cyst, 2: tumor}。两种都见过做数据加载时不能写死方向要写一段兼容代码把它统一起来。注意读取时加utf-8因为类别名如果是中文Windows 下用默认编码大概率会报错。2.2 写一个不靠猜的 Dataset 类从文件夹名映射到数值标签把数据读进 PyTorch 时我一般不会用现成的ImageFolder而是自己写一个 Dataset。原因很简单ImageFolder只按文件夹名排序编 ID和你的 labels.json 顺序不一致时标签就错位了。正确做法是显式读取类别字典然后用文件夹名去匹配。import json import torch from pathlib import Path from PIL import Image class KidneyDataset(torch.utils.data.Dataset): def __init__(self, root_dir, class_dict_path, transformNone): self.root Path(root_dir) self.transform transform self.samples [] with open(class_dict_path, r, encodingutf-8) as f: raw json.load(f) # 兼容两种字典方向统一转成 name - id if all(isinstance(k, str) and isinstance(v, int) for k, v in raw.items()): self.name2idx raw else: self.name2idx {v: int(k) for k, v in raw.items()} for class_name, label_id in self.name2idx.items(): class_dir self.root / class_name if not class_dir.exists(): print(fwarn: {class_dir} not exists) continue for ext in (*.png, *.jpg, *.jpeg, *.bmp): for img_path in class_dir.glob(ext): self.samples.append((str(img_path), label_id)) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label self.samples[idx] image Image.open(img_path).convert(RGB) if self.transform: image self.transform(image) return image, label这段代码的关键点有两个。第一路径用pathlib拼接背后会自动处理 Windows 的\和 Linux 的/不会出现路径分隔符翻车。第二遍历文件夹名时用的是self.name2idx的键而不是os.listdir的顺序保证标签一定来自类别字典文件。2.3 一致性检查类别字典和实际数据对不上后面全是白干数据加载不是写完 Dataset 就完事。我一般会加一段独立的一致性检查代码分别在 train、val、test 上跑一遍统计各文件夹的样本数并和类别字典逐项比对。from collections import Counter def scan_folder_counts(root_dir): root Path(root_dir) counts {} for class_dir in root.iterdir(): if class_dir.is_dir(): num_files len([p for p in class_dir.rglob(*) if p.is_file()]) counts[class_dir.name] num_files return counts train_counts scan_folder_counts(data/train) val_counts scan_folder_counts(data/val) print(train counts:, train_counts) print(val counts:, val_counts) expected set(labels.keys()) if all(isinstance(k, str) for k in labels.keys()) else set(labels.values()) assert set(train_counts.keys()) expected, train 目录类别不匹配 assert set(val_counts.keys()) expected, val 目录类别不匹配这段代码还要配合一个容易忽视的动作把train_counts和val_counts打印出来后肉眼看一眼每个类别的数量级。比如肿瘤类别只有几十张而正常类别有一千张这就是典型的类别不均衡。医学影像数据几乎没有均衡的不均衡不影响数据读取但直接影响后面模型训练策略和评估指标的选择。2.4 三类文件命名习惯漏检和重复全藏在文件名里文件夹里往往还带着一些附加信息文件名可能是patient_001_slice_023.png这种。这类命名隐藏着两个关键信息病人 ID 和切片序号。病人 ID 在划分数据时必须用不能只用文件夹随机切切片序号说明同一个病人有多张连续切片这些切片之间高度相似如果随机划分 train 和 val就会出现数据泄露——模型实际记住了同一个人的不同切片。先写一个检查病人 ID 是否跨 split 的工具import re def extract_patient_ids(root_dir): ids set() for img_path in Path(root_dir).rglob(*.png): m re.search(rpatient_(\d), img_path.name) if m: ids.add(m.group(1)) return ids patient_train extract_patient_ids(data/train) patient_val extract_patient_ids(data/val) overlap patient_train patient_val print(overlap patients:, len(overlap)) if overlap: print(warning: 病人的切片同时出现在 train 和 val)这一步属于“后悔药”级别的检查。大部分医学图像分类项目最后在真实数据上表现崩掉不是因为模型不够好而是因为数据在被切分的那一刻就已经“穿帮”了。宁可晚训练两天也要先把这件事查清楚。3. 选对模型与预处理策略从零训练不如微调预处理不能照搬 ImageNet 流程3.1 为什么用 ImageNet 预训练模型微调而不是从零训练肾脏结节、肿瘤这类医学影像数据集能有个几千张切片就算很可观了而且类别分布通常悬殊。在这种数据量下从头训练一个深度卷积网络几乎必翻车模型会过拟合到训练集验证集性能一塌糊涂还会出现训练 loss 降了但 acc 不涨的怪象。我的默认做法是把 ImageNet 上预训练的 ResNet50 作为主干把最后的全连接层换成自己的分类头然后在医学数据上做两阶段微调。可能有人会质疑医学图像和自然图像差距那么大ImageNet 预训练还有用吗实践下来答案是肯定的。虽然 domain gap 真实存在但预训练模型前期学到的边缘、纹理、形状这些低级特征对医学影像同样有效。真正要调整的是后面的分类头和高层语义特征。import torch.nn as nn import torchvision.models as models def build_model(num_classes): model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, num_classes) return model这里把fc层换了之后输出维度就和类别字典文件里定义的类别数对齐了。如果字典里是三类normal、cyst、tumornum_classes3如果你只需要区分肿瘤和非肿瘤也可以把输入数据集的类别重新归并成两类再设num_classes2。3.2 关键第一步图像预处理必须区分“原始 CT”还是“导出图”这是医学图像分类里最容易出问题、却又经常被一句话带过的环节。肾脏影像数据如果是 DICOM 格式每个像素值是 HU 值范围从 -1000 到 1000 以上如果对方已经帮你导出成 PNG、JPG 之类的图片文件那就只包含常见的图像像素值。文件夹保存的数据绝大多数是后者但训练前必须确认。我先给出一段同时兼容两种情况的预处理参考import numpy as np from PIL import Image def load_and_preprocess(img_path, use_windowFalse, wl-50, ww400): image Image.open(img_path).convert(RGB) arr np.array(image).astype(np.float32) # 如果图像是灰度脏器/CT导出图三个通道相同若是 DICOM 转换来的先做窗宽窗位 if use_window: low wl - ww / 2.0 high wl ww / 2.0 arr np.clip(arr, low, high) arr (arr - low) / (high - low) # 非窗口化路径直接线性缩放到 0~1 if not use_window: arr arr / 255.0 arr np.transpose(arr, (2, 0, 1)) # HWC - CHW return torch.from_numpy(arr).float()这段代码里wl-50, ww400是腹部 CT 常用的软组织窗参数。wl是窗位ww是窗宽整体效果是把 CT 值 -250 到 150 这个范围的软组织差异放大把皮下脂肪和空气压成黑色。如果你能确认数据是 CT 且保留了原始 HU 值use_windowTrue能让肿瘤边界清楚得多如果拿到的是导出好的 JPG直接use_windowFalse做 0~1 缩放即可。3.3 两阶段微调法和一组能用的超参数微调不是解冻整个网络一股脑训。直接全量训练前期 loss 会剧烈震荡尤其是你的数据量只有几百张时。我习惯分两阶段走。第一阶段冻结所有卷积层只训练新换的分类头。这个阶段收敛非常快让分类头先适应医学数据的高层特征分布。第二阶段再解冻最后一部分卷积层使用更低的学习率做细调。import torch.optim as optim # 阶段一冻结全部卷积层 for p in model.parameters(): p.requires_grad False for p in model.fc.parameters(): p.requires_grad True optimizer optim.AdamW(model.fc.parameters(), lr1e-3, weight_decay1e-4) # ... 训练若干轮之后进入阶段二 for p in model.layer4.parameters(): p.requires_grad True optimizer optim.AdamW([ {params: model.fc.parameters(), lr: 1e-3}, {params: model.layer4.parameters(), lr: 1e-4}, ])阶段二里分类头用较大学习率layer4 用较小学习率。为什么 layer4 只用 1e-4因为这个层离分类头最近包含了大量高层语义特征学习率太大会在几轮内把预训练学到的有效特征冲掉。医学数据量小这种破坏通常是不可逆的。另一个值得设置的基础配置是图像尺寸和 batch size。深度网络一般要求输入固定尺寸常见的做法是把图像 resize 到 224×224 再送入网络。batch size 在显存允许时尽量用 32 或更大太小的话 BatchNorm 的统计量不稳验证集表现会抖动。3.4 数据增强要克制医学图像经不起过度扭曲自然图像分类常用的 RandomResizedCrop、随机旋转、颜色抖动到了医学图像这里必须降级使用。原因很简单肾脏结节和肿瘤的尺寸、位置、纹理都有诊断意义把图像随机裁剪到只剩一块肾皮质或者把颜色通道乱抖等于人为制造了一批“假样本”。我一般只用三类增强水平翻转、小幅随机旋转、轻微尺度变化。垂直翻转在腹部 CT 上不建议用因为人体解剖结构天然有上下方向语义。测试阶段则完全不做随机增强只做 resize 和归一化。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop((224, 224), scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(10), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])Normalize 里用的均值方差是 ImageNet 预训练模型的标准参数。虽然医学影像的像素分布与自然图像不同但预训练模型的 BatchNorm 层期望输入分布接近这个范围所以输入端照搬 ImageNet 的均值方差通常比自定义统计量更稳。4. 评估指标必须适配类别不均衡准确率会骗人AUC 和召回率才是正道4.1 先统计类别占比肿瘤比例过低acc 高达 95% 都可能是废的肾脏结节、肿瘤数据集的显著特点是不均衡恶性/肿瘤类别的占比往往只有 5% 到 20%。这意味着如果模型把每个样本都预测为“正常”准确率也能轻松达到 80% 以上。acc 这个指标在医学分类场景里几乎没有参考价值必须换成对少数类敏感的指标。在进入训练之前先跑一段统计代码import numpy as np def compute_class_stats(dataset): labels np.array([dataset.samples[i][1] for i in range(len(dataset))]) counts np.bincount(labels, minlengthlen(dataset.name2idx)) total len(labels) for idx, name in sorted(dataset.name2idx.items(), keylambda x: x[1]): print(f{name}: {counts[idx]} ({counts[idx] / total:.2%})) return counts输出结果里如果“tumor”类别只有一百张而别的类别有几百张就要在训练阶段加入针对性的处理。4.2 用 WeightedRandomSampler 替代简单的 class_weight处理不均衡有两条路改损失函数权重或者改采样方式。我倾向用WeightedRandomSampler因为它直接改变模型每个 epoch 看到的样本分布和小 batch 训练配合得更好。from torch.utils.data import WeightedRandomSampler def make_sampler(dataset): labels np.array([dataset.samples[i][1] for i in range(len(dataset))]) class_counts np.bincount(labels, minlengthlen(dataset.name2idx)) class_prob 1.0 / (class_counts 1e-6) sample_weights class_prob[labels] return WeightedRandomSampler( weightssample_weights, num_sampleslen(sample_weights), replacementTrue, )这条思路的核心是样本越少的类别被抽中的概率越高。replacementTrue表示同一个样本可以在一个 epoch 里重复出现这是处理极端不均衡的必要条件如果设成 False少数类只出现一次加权的意义就没了。4.3 评估指标组合AUC、敏感度、特异度、F1 一个都不能少训练完模型后评估环节不能只打印 acc。用验证集跑完整推理一次性算出核心指标from sklearn.metrics import roc_auc_score, recall_score, precision_score model.eval() y_true [] y_pred [] y_prob [] with torch.no_grad(): for images, labels in val_loader: outputs model(images.cuda()) probs torch.softmax(outputs, dim1) y_true.extend(labels.cpu().numpy()) y_pred.extend(torch.argmax(outputs, dim1).cpu().numpy()) y_prob.extend(probs.cpu().numpy()) y_true np.array(y_true) y_prob np.array(y_prob) y_pred np.array(y_pred) auc roc_auc_score(y_true, y_prob, multi_classovr, averagemacro) recall recall_score(y_true, y_pred, averagemacro) precision precision_score(y_true, y_pred, averagemacro) print(fAUC: {auc:.4f}, Recall: {recall:.4f}, Precision: {precision:.4f})在这套组合里AUC 评估模型的排序能力不依赖阈值是医学图像分类里最常用的标尺Recall 代表少数类被找回来的比例医学场景里漏诊比误诊严重Precision 和 Recall 一起看才能判断模型是在“认真分类”还是“无脑把所有样本都预测成肿瘤来刷召回”。4.4 阈值别停留在 0.5医学分类要求的是高召回不是均衡精度默认的 0.5 分类阈值在均衡数据上好用在不均衡的医学数据上不行。我之前做过一个模型在 0.5 阈值下精确率很高但肿瘤召回率只有 60% 多完全没法用把阈值降到 0.35 之后召回率到了 85%误诊率增加了一些但临床上能接受。阈值调整基本是门玄学没有固定值但可以依据验证集上的召回目标来找。best_score 0 best_threshold 0.5 for t in np.arange(0.2, 0.8, 0.05): pred_t (y_prob[:, 1] t).astype(int) rec recall_score(y_true, pred_t) if rec best_score: best_score rec best_threshold t print(fbest threshold: {best_threshold:.2f}, recall: {best_score:.4f})这段代码只适合二分类场景阈值搜索范围 0.2 到 0.8。搜索的标准完全取决于业务目标如果你宁可误报、不希望漏掉肿瘤就固定一个高召回的下限比如 0.9然后在这基础上挑精确率最高的阈值。这一点在写报告时也要交代清楚——模型不是只有一套精度指标阈值不同模型行为就不同。5. 常见问题排查肿瘤分类最容易翻车的五个现场5.1 验证集 acc 高得离谱模型却什么也没学会现象训练到一半验证集 acc 到了 95% 以上和训练集几乎一样高心里美滋滋。等把模型放到另一批影像上测试准确率直接掉到 60%。原因典型的数据划分问题。如果切分数据时没有按病人 ID 分组同一个病人的几十张连续切片会被同时分到 train 和 val验证集里的样本和训练集高度相似模型等于“背过答案”了。解决检查病人 ID 是否跨 split方法见 2.4 节。发现泄露后按病人 ID 重新划分数据保证同一个病人的所有切片只出现在一个 split 里。这个坑最阴的地方是训练过程看不出任何异常模型表现的每一处都“正常”直到真实使用才暴露。5.2 训练 loss 一直不降或者降得很慢现象loss 在前几个 epoch 里几乎不动一直维持在初始水平附近个别时候还会掉头往上涨。原因通常是学习率太大或者优化器设置不对。另一个隐蔽原因是分类头初始化后和主干输出的尺度不匹配导致开始时的梯度过大后续训练不稳定。解决使用 AdamW 默认参数时分类头学习率先设成 1e-3主干解冻层设成 1e-4。如果 loss 完全不降把整体学习率调低一个数量级再试。还有一个排查技巧看 loss 在第一个 epoch 结束时的数值如果和随机初始化模型对比没有明显下降优先怀疑数据标签错位而不是模型问题。5.3 所有的预测结果都是同一类现象无论测试图片是囊肿还是肿瘤模型输出绝大多数都是“normal”而且 acc 还挺高。原因类别严重不均衡模型学到的不是区分特征而是纯统计规律——预测多数类就能拿到高 acc。自定义的 Dataset 里没有做任何采样平衡训练时每个 epoch 看到的样本分布和真实分布一样少数类被淹没在数据里。解决按 4.2 节的WeightedRandomSampler处理训练集。使用采样器之后如果还出现全预测同一类再改损失函数权重把肿瘤类别的权重调大。这一招组合起来模型才会被迫去关注少数类的细节特征。5.4 验证集表现和训练集表现差一大截现象训练集 acc 接近 99%验证集却只有 80% 出一点头而且两者之间的差距越来越大。原因过拟合通常伴随着训练数据量不足、增强太强或太弱、或者模型参数量过大。医学图像数据量少ResNet50 已经是上限再上 ResNet101 就更容易过拟合。解决给训练加上早停策略观察验证 loss 开始上升时停止训练。同时把冻结层数加多阶段二解开 layer4 而不是整个网络全解冻。另一个有效的操作是将增强强度降低只保留翻转和轻微旋转。如果还是有 gap可以考虑用预训练模型提取特征后只训分类头效果不一定差。5.5 跑第二次结果对不上指标忽高忽低现象训练流程一模一样这次跑 acc 81%下次跑变成 74%甚至同一次训练中验证结果抖动明显。原因随机性来源太多。数据增强的随机性、Sampler 的随机采样、Dropout 的随机丢弃、权重初始化其中任何一个变化都会影响最终表现。解决固定随机种子同时关闭部分随机操作。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsecudnn.benchmark False会牺牲一点训练速度换取结果可复现。这四条配置加在训练脚本开头的main函数里是保证实验可复现的最低要求。否则当你调好一个参数发现结果波动比参数改动带来的收益还大排查起来极其痛苦。6. 再进一步用 Grad-CAM 审视模型的分类依据别让模型靠水印和伪影得分验证集指标漂亮只是一个起点真正的“验收”是看模型到底在看什么。我每次训练完必做的一件事是把验证集图片和对应预测结果喂给一个 Grad-CAM 脚本把模型关注的区域可视化出来叠加在原图上。这一步的目的是确认模型是根据肾实质、结节、肿瘤区域做判断而不是靠图像角落里的扫描仪水印、存储标签或拍摄伪影。import torch import torch.nn.functional as F def gradcam(model, x, target_class): x x.unsqueeze(0).requires_grad_() activations {} def forward_hook(module, input, output): activations[map] output def backward_hook(module, grad_input, grad_output): activations[grad] grad_output[0] h1 model.layer4.register_forward_hook(forward_hook) h2 model.layer4.register_full_backward_hook(backward_hook) out model(x) model.zero_grad() out[0, target_class].backward() h1.remove() h2.remove() grad activations[grad].mean(dim(2, 3), keepdimTrue) cam F.relu(activations[map] * grad).sum(dim1, keepdimTrue) cam F.interpolate(cam, size(224, 224), modebilinear, align_cornersFalse) return cam.squeeze().detach().cpu().numpy()这段代码对每张测试图取 layer4 的输出特征图和对应梯度做加权得到一张热力图。热力图高亮区域集中在肿瘤/结节位置说明模型确实学到了诊断相关特征如果高亮区域集中在图像边缘或背景那就要回到数据层面找原因——可能是数据集本身就带着干扰信息。我自己的习惯是把每类预测结果中正确分类和错误分类的样本各抽三五张把 Grad-CAM 图保存下来和原始影像放在同一张画布里对比。这个过程做一次比调十次学习率都更能帮你理解手头数据的问题所在。希望这整条路径能帮你少走几个弯路把手里的肾脏影像数据用扎实。本文还有配套的精品资源点击获取
返回列表