拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

甲状腺结节图像分类实战:7000张标注数据从基线到避坑

甲状腺结节图像分类实战:7000张标注数据从基线到避坑

简介:本资源为甲状腺结节图像分类数据集,面向医学影像分析、计算机视觉与深度学习方向的研究者及工程师,用于训练和评估正常与结节两类的自动识别模型。压缩包共2000个文件,以1998张jpg图像为主体,另含1个json标注文件和1个py可视化脚本,整体约324.26MB,训练集与测试集已按类别分目录存放,便于直接接入分类网络。运行包内show脚本可快速浏览样本分布与图像质量,json文件则给出两类标签的具体定义。资源同时附有图像分类网络改进与计算机视觉完整项目的延伸链接,方便读者在数据预处理、模型设计、训练测试到部署的全流程中对照实践。目前已有372人学习,适合希望快速验证分类算法或开展医学影像课题的中高级开发者。

1. 甲状腺结节图像分类数据集:7000张已标注数据能跑出什么结果

手里有一份约 7,000 张已标注的甲状腺结节图像分类数据集,第一反应不应该是“拿去训个 ResNet 看看准确率”,而是先搞清楚这批数据的标注粒度、类别分布和图像来源。甲状腺超声图像分类在临床上对应的是 TI-RADS 分级辅助判断,落到机器学习任务上,通常是良恶性二分类,或者更细的 4~6 类分级。7,000 张的量级不算大,但在医学图像领域已经够做一轮完整的迁移学习实验。问题在于,很多团队拿到数据直接切分训练集验证集,跑出一个 95% 的准确率就收工,结果换一台超声设备采集的图像上线就崩。这份数据集的真正价值不在于“有多少张”,而在于标注是否一致、类别是否均衡、是否有患者级别的划分信息。适合谁用?做医学图像入门实验的研究生、需要快速验证分类模型原型的算法工程师、以及想搭建甲状腺结节辅助筛查流程的产品团队。接下来的内容按“先看清数据、再跑通基线、最后避开翻车点”的顺序展开。

2. 拿到 7000 张标注图先做三件事:类别分布、图像尺寸、患者泄漏检查

2.1 类别分布统计与不均衡处理决策

医学图像数据集最常见的问题就是类别不均衡。良性结节在人群中的检出率远高于恶性,如果这份 7,000 张的数据集也是按真实分布采集的,良恶性比例可能达到 3:1 甚至更高。直接训练会导致模型倾向于预测多数类,准确率看起来不错但召回率惨不忍睹。

先跑一段统计脚本,把每个类别的样本数、图像尺寸分布、文件格式全部拉出来:

import os import json from collections import Counter from PIL import Image DATA_ROOT = "thyroid_dataset" # 假设目录结构为 DATA_ROOT/类别名/图片文件 class_counts = {} size_dist = Counter() corrupt_files = [] for cls_name in sorted(os.listdir(DATA_ROOT)): cls_dir = os.path.join(DATA_ROOT, cls_name) if not os.path.isdir(cls_dir): continue files = [f for f in os.listdir(cls_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp'))] class_counts[cls_name] = len(files) for fname in files: fpath = os.path.join(cls_dir, fname) try: with Image.open(fpath) as im: size_dist[im.size] += 1 except Exception as e: corrupt_files.append((fpath, str(e))) print("类别分布:", json.dumps(class_counts, ensure_ascii=False, indent=2)) print("图像尺寸 Top10:", size_dist.most_common(10)) print("损坏文件数:", len(corrupt_files))

这段脚本的逻辑很直接:遍历每个类别目录,统计文件数量,同时用 PIL 打开每张图记录尺寸,顺便捕获损坏文件。参数方面,DATA_ROOT换成你实际的挂载路径即可。如果类别目录名是中文,os.listdir返回的也是中文,不影响后续处理,但建议在生成标签映射时统一转成英文或数字索引。

跑完之后重点看两个信号:第一,最大类与最小类的比值。如果超过 5:1,必须做重采样或损失加权。第二,尺寸分布是否集中。如果大部分图像是 512×512 而少数是 1024×1024,统一缩放到 256×256 或 320×320 是常规操作,但要注意小结节在缩放后可能只剩几个像素,这时候需要考虑裁剪 ROI 而不是整图缩放。

提示:类别不均衡的处理优先级是——先确认标注质量,再做数据增强,最后才考虑加权损失。如果少数类本身标注就有问题,加权只会放大噪声。

2.2 患者级别划分:避免同一患者的图像同时出现在训练和验证集

这是医学图像分类里最容易被忽视、后果最严重的坑。如果同一个患者的多个结节图像被随机切分到训练集和验证集,模型实际上在“背”这个患者的图像特征,验证准确率会虚高 10~20 个百分点。7,000 张图如果来自 2,000 个患者,平均每人 3~4 张,随机切分的泄漏概率非常高。

检查方法是看文件名或附带的元数据里有没有患者 ID。常见做法是文件名包含患者编号,比如P00123_lesion1.png、P00123_lesion2.png。如果有,按患者 ID 做 GroupShuffleSplit:

import re import numpy as np from sklearn.model_selection import GroupShuffleSplit def extract_patient_id(filename): # 根据实际命名规则调整正则 match = re.search(r'(P\d+)', filename) return match.group(1) if match else filename all_files = [] all_labels = [] all_groups = [] for cls_name in sorted(os.listdir(DATA_ROOT)): cls_dir = os.path.join(DATA_ROOT, cls_name) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if fname.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp')): all_files.append(os.path.join(cls_dir, fname)) all_labels.append(cls_name) all_groups.append(extract_patient_id(fname)) gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(all_files, all_labels, groups=all_groups)) print(f"训练集: {len(train_idx)} 张, 验证集: {len(val_idx)} 张") print(f"训练集患者数: {len(set(np.array(all_groups)[train_idx]))}") print(f"验证集患者数: {len(set(np.array(all_groups)[val_idx]))}")

GroupShuffleSplit的核心参数是groups,它保证同一组的样本只会出现在训练集或验证集其中之一。test_size=0.2表示验证集占 20%。random_state固定后结果可复现。如果文件名里没有患者 ID,那就只能退而求其次,用图像感知哈希做近似去重,但效果不如真正的患者级别划分。

注意:如果数据集提供方明确说了“已按患者划分”,仍然建议自己验证一遍。我见过不止一次标注文档写的是患者级别划分,实际文件命名根本区分不出来。

3. 从零跑通甲状腺结节分类基线:迁移学习、数据增强与训练配置

3.1 为什么选 EfficientNet-B0 而不是 ResNet50 做基线

医学图像数据集通常不大,7,000 张图在 ImageNet 预训练模型面前属于小样本场景。ResNet50 参数量 2,500 万,在这种量级上容易过拟合。EfficientNet-B0 参数量约 530 万,在 ImageNet 上的精度与 ResNet50 相当,但推理速度快 3 倍以上,更适合做第一轮基线验证。

另一个选择是 ConvNeXt-Tiny 或 Swin-Tiny,但这两个架构对输入分辨率更敏感,甲状腺超声图像本身分辨率参差不齐,用 EfficientNet 系列容错率更高。如果后续要做模型集成,可以把 ResNet50 和 EfficientNet-B0 的输出做加权平均,但在基线阶段没必要。

迁移学习的标准做法是:冻结 backbone 前几层,只训练分类头和最后几个 stage。具体冻结多少层取决于数据集大小。7,000 张图我一般会先冻结全部 backbone 训练 5 个 epoch 分类头,再解冻最后两个 stage 微调 20 个 epoch。学习率方面,分类头用 1e-3,微调阶段用 1e-4。

3.2 训练脚本与关键参数配置

下面是一个基于 PyTorch 的完整训练脚本,包含数据加载、增强、模型定义和训练循环:

import torch import torch.nn as nn import torchvision.transforms as T from torch.utils.data import Dataset, DataLoader from torchvision.models import efficientnet_b0, EfficientNet_B0_Weights from PIL import Image import os class ThyroidDataset(Dataset): def __init__(self, file_list, label_list, transform=None): self.file_list = file_list self.label_list = label_list self.transform = transform # 构建类别到索引的映射 self.classes = sorted(set(label_list)) self.class_to_idx = {c: i for i, c in enumerate(self.classes)} def __len__(self): return len(self.file_list) def __getitem__(self, idx): img = Image.open(self.file_list[idx]).convert("RGB") label = self.class_to_idx[self.label_list[idx]] if self.transform: img = self.transform(img) return img, label # 训练集增强:超声图像慎用垂直翻转和大幅旋转 train_transform = T.Compose([ T.Resize((256, 256)), T.RandomHorizontalFlip(p=0.5), T.RandomRotation(degrees=10), # 小角度旋转,模拟探头轻微偏移 T.ColorJitter(brightness=0.2, contrast=0.2), # 模拟不同增益设置 T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = T.Compose([ T.Resize((256, 256)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) # 构建 DataLoader train_dataset = ThyroidDataset( [all_files[i] for i in train_idx], [all_labels[i] for i in train_idx], transform=train_transform ) val_dataset = ThyroidDataset( [all_files[i] for i in val_idx], [all_labels[i] for i in val_idx], transform=val_transform ) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) # 模型定义 num_classes = len(train_dataset.classes) model = efficientnet_b0(weights=EfficientNet_B0_Weights.IMAGENET1K_V1) model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes) model = model.cuda() # 第一阶段:冻结 backbone,只训练分类头 for param in model.features.parameters(): param.requires_grad = False criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=25) def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss, correct, total = 0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total @torch.no_grad() def evaluate(model, loader, criterion): model.eval() total_loss, correct, total = 0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.cuda(), labels.cuda() outputs = model(imgs) loss = criterion(outputs, labels) total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total # 训练循环 for epoch in range(25): if epoch == 5: # 解冻最后两个 stage for param in model.features[-2:].parameters(): param.requires_grad = True optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20) train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer) val_loss, val_acc = evaluate(model, val_loader, criterion) scheduler.step() print(f"Epoch {epoch+1:02d} | Train Loss {train_loss:.4f} Acc {train_acc:.4f} | Val Loss {val_loss:.4f} Acc {val_acc:.4f}")

这段代码的关键设计点:数据增强只用了水平翻转、±10° 旋转和轻微颜色抖动。超声图像和自然图像不同,垂直翻转会改变解剖结构的上下关系,大幅旋转会让结节形态失真,所以增强策略要保守。ColorJitter模拟的是不同超声设备的增益和对比度差异,这对提升泛化能力很有帮助。

训练策略分两阶段:前 5 个 epoch 冻结 backbone,学习率 1e-3;第 6 个 epoch 开始解冻最后两个 stage,学习率降到 1e-4。这个策略在 7,000 张量级的数据集上通常能比端到端微调高出 2~3 个百分点。CosineAnnealingLR的T_max分别设为 25 和 20,对应两个阶段的 epoch 数。

提示:如果验证集准确率在第 10 个 epoch 后不再提升,不要急着加 epoch。先检查数据增强是否过强,或者验证集是否存在标注噪声。

3.3 评估指标:为什么准确率不够用

甲状腺结节分类的临床意义在于“不漏诊恶性”。如果模型把恶性预测为良性,患者可能错过进一步穿刺检查。所以评估指标必须包含敏感度(召回率)和特异度,而不是只看准确率。

在验证集上计算混淆矩阵和各类指标:

from sklearn.metrics import classification_report, confusion_matrix @torch.no_grad() def get_predictions(model, loader): model.eval() all_preds, all_labels = [], [] for imgs, labels in loader: imgs = imgs.cuda() outputs = model(imgs) preds = outputs.argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) return all_preds, all_labels preds, labels = get_predictions(model, val_loader) print(confusion_matrix(labels, preds)) print(classification_report(labels, preds, target_names=train_dataset.classes, digits=4))

classification_report会输出每个类别的精确率、召回率和 F1。重点关注恶性类的召回率,如果低于 0.85,需要调整分类阈值或增加恶性样本的权重。混淆矩阵能直观看到误判方向:良性被判为恶性可以接受(进一步检查),恶性被判为良性必须尽量避免。

4. 避坑与排查:甲状腺结节分类数据集落地时的五个血泪教训

4.1 现象:验证集准确率 98%,换一批数据掉到 60%

原因:患者级别泄漏。同一患者的图像同时出现在训练和验证集,模型记住了患者特有的图像特征而非结节本身的病理特征。解决:用 GroupShuffleSplit 按患者 ID 划分,确保训练集和验证集的患者集合完全不相交。如果文件名没有患者 ID,联系数据提供方补充,或者用图像哈希做近似去重。

4.2 现象:模型训练 loss 正常下降,但验证 loss 震荡剧烈

原因:数据增强过强或 batch size 太小。超声图像本身噪声大,如果叠加了垂直翻转、大幅旋转、强颜色抖动,增强后的图像分布和原始分布差异过大。解决:把增强策略缩减到水平翻转 + ±10° 旋转 + 轻微亮度对比度调整,batch size 从 16 提到 32。

4.3 现象:某些类别的 F1 始终为 0

原因:类别极度不均衡,少数类样本数太少,模型直接学会了全部预测多数类。解决:先确认少数类是否真的有足够样本(至少 200 张以上)。如果不够,考虑合并类别(比如把 TI-RADS 4a 和 4b 合并为“可疑恶性”),或者用重采样 + 加权损失。CrossEntropyLoss的weight参数可以传入各类别的权重,权重设为该类样本数的倒数。

4.4 现象:推理时单张图像预测结果与批量预测不一致

原因:推理时的预处理和训练时的验证预处理不一致。常见的是推理时忘了做 Normalize,或者 Resize 的插值方法不同。解决:把验证集的 transform 单独封装成一个函数,推理时直接复用同一个函数,不要重新写一遍。

4.5 现象:GPU 显存够但训练速度极慢

原因:num_workers设为 0 或者数据加载成为瓶颈。超声图像如果是 PNG 格式且分辨率较高,解码耗时明显。解决:num_workers设为 CPU 核心数的 1/4 到 1/2,开启pin_memory=True。如果图像格式是 BMP,考虑提前转成 JPEG 或 LMDB 格式加速读取。

5. 把 7000 张数据的价值榨干:进阶技巧与验证习惯

基线跑通之后,7,000 张数据的上限取决于你怎么用它。我一般会做三件事:第一,用五折交叉验证替代单次划分,每折都按患者 ID 分组,最终报告五折的平均敏感度和特异度,而不是单次验证集的准确率。第二,用 Test Time Augmentation(TTA)做推理增强,对每张验证图像做水平翻转和 ±5° 旋转,把多次预测的概率取平均,通常能提升 1~2 个百分点的召回率。第三,如果数据集中有 TI-RADS 分级标注,不要只做良恶性二分类,尝试序数回归(ordinal regression),把分级信息用起来,模型输出的分级概率分布比二分类概率更有临床参考价值。

验证习惯方面,我坚持每轮实验都保存混淆矩阵和各类别的 PR 曲线,而不是只看一个总体指标。甲状腺结节分类的临床底线是恶性召回率不低于 0.90,在这个约束下再优化特异度。如果某次实验恶性召回率达标但特异度很低,说明模型把太多良性判为恶性,需要调整分类阈值或增加良性样本的多样性。

最后一个习惯:每次换模型架构或调整数据增强策略后,固定用同一个验证集患者列表做对比。我见过太多实验因为验证集换了导致指标不可比,白白浪费几周时间。把验证集的患者 ID 列表存成 JSON 文件,每次实验前加载同一个列表,这个习惯帮我省了无数次后悔药。

希望帮到你。

本文还有配套的精品资源,点击获取

返回列表