简介:本资源为甲状腺结节图像分类数据集,面向医学影像分析、计算机视觉与深度学习方向的研究者及工程师,用于训练和评估正常与结节两类的自动识别模型。压缩包共2000个文件,以1998张jpg图像为主体,另含1个py可视化脚本与1个json标注文件,整体约324.26MB,已划分训练集与测试集,便于独立开展模型训练与性能评估。运行show脚本可直观查看样本分布与图像内容,json文件则给出两类标签的具体定义。目前已有372人学习下载。借助该数据集,读者可完成从数据预处理、分类网络搭建到训练测试的完整流程,并结合作者提供的图像分类网络改进与计算机视觉项目链接,进一步优化识别精度,为甲状腺结节的辅助诊断研究提供可复用的实验基础。
1. 甲状腺结节图像分类数据集:7000 张已标注数据能跑出什么结果
超声科医生一天看几十份甲状腺片子,良恶性结节的判断高度依赖经验,不同年资的医生读同一张图,结论可能差出不少。这份约 7000 张的甲状腺结节图像分类数据集,就是冲着这个场景来的——它把结节图像按类别整理好、标注完,直接能喂给分类模型训练。适合谁用?做医学图像分类的算法工程师、想验证自己模型在真实医学数据上表现的 researcher、以及需要快速搭一个甲状腺结节辅助筛查原型的团队。它不是那种几百张的玩具集,7000 张的量级足够你跑出有统计意义的指标,也足够暴露过拟合、类别不均衡这些真实问题。下面我从数据怎么读、模型怎么搭、坑在哪,一步步拆开讲。
2. 数据集的读取与预处理:从文件夹到 Tensor 的完整链路
2.1 目录结构与标注格式的确认
拿到数据集第一件事不是写模型,是搞清楚文件怎么组织的。常见做法是按类别分文件夹,比如benign/和malignant/两个目录,每个目录下是若干.jpg或.png。也有一种是用一个labels.csv记录文件名和标签。你得先确认是哪一种,因为后面Dataset类的写法完全不同。
我一般会先跑一段脚本把目录结构和文件数量摸清楚,避免训练到一半发现某个类别少了几百张。
import os from collections import Counter data_root = "./thyroid_dataset" # 统计每个子目录下的文件数量 for split in os.listdir(data_root): split_path = os.path.join(data_root, split) if not os.path.isdir(split_path): continue for cls in os.listdir(split_path): cls_path = os.path.join(split_path, cls) if not os.path.isdir(cls_path): continue files = [f for f in os.listdir(cls_path) if f.lower().endswith((".jpg", ".png", ".jpeg"))] print(f"{split}/{cls}: {len(files)} 张")这段代码遍历train/val/test下每个类别文件夹,统计图像数量。参数上注意endswith里把常见后缀都列上,有些数据集混着.jpeg和.png,只写.jpg会漏文件。跑完你就能看到类别分布,如果 benign 和 malignant 比例超过 3:1,后面训练就得考虑加权采样。
2.2 图像尺寸统一与归一化参数选择
医学超声图像尺寸往往不统一,有的 512×512,有的 800×600。直接 resize 到 224×224 是最省事的做法,但会损失细节。我的经验是:如果显存够,用 320×320 或 384×384,结节边缘的毛刺征、微钙化这些关键特征保留得更好。
归一化参数不要用 ImageNet 的均值方差直接套。超声图像是灰度图,像素分布和自然图像差很远。常见做法是先在自己的训练集上算一遍均值和标准差:
import torch from torchvision import transforms from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder # 先做一次粗略统计 tmp_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=3), # 灰度转三通道 transforms.Resize((224, 224)), transforms.ToTensor(), ]) tmp_dataset = ImageFolder(root="./thyroid_dataset/train", transform=tmp_transform) loader = DataLoader(tmp_dataset, batch_size=64, shuffle=False) mean = 0.0 std = 0.0 n = 0 for imgs, _ in loader: mean += imgs.mean(dim=[0, 2, 3]) std += imgs.std(dim=[0, 2, 3]) n += 1 mean /= n std /= n print(f"mean: {mean.tolist()}") print(f"std: {std.tolist()}")这里把灰度图转成三通道是为了兼容预训练模型。算出来的 mean 和 std 填进正式的训练 transform 里。注意ImageFolder要求每个类别一个子文件夹,如果你的数据是 csv 标注,得自己写Dataset子类,逻辑类似,只是__getitem__里从 csv 读标签。
2.3 数据增强策略:别把结节特征增没了
增强是双刃剑。水平翻转、小角度旋转、亮度微调这些对超声图像是安全的。但垂直翻转要慎重——甲状腺解剖结构有上下关系,垂直翻转可能造出解剖上不合理的图。颜色抖动也别太猛,超声是灰度图,色相变化没意义,亮度和对比度微调就够了。
train_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=3), transforms.Resize((320, 320)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), # 小角度旋转 transforms.ColorJitter(brightness=0.2, contrast=0.2), # 只调亮度和对比度 transforms.ToTensor(), transforms.Normalize(mean=mean, std=std), ]) val_transform = transforms.Compose([ transforms.Grayscale(num_output_channels=3), transforms.Resize((320, 320)), transforms.ToTensor(), transforms.Normalize(mean=mean, std=std), ])验证集和测试集绝对不能用增强,只用 resize 和归一化。这个坑我见过太多次,有人把RandomHorizontalFlip也加到 val 上,指标看着高,实际部署就翻车。RandomRotation的 degrees 别超过 15,再大结节形态就失真了。
3. 分类模型选型与训练:从 ResNet 到 EfficientNet 的取舍
3.1 backbone 选择:为什么我不建议从零训
7000 张图在医学图像里不算小,但要从零训练一个 CNN,还是容易过拟合。常见做法是用 ImageNet 预训练权重做迁移学习。ResNet50 是最稳的 baseline,EfficientNet-B0 参数量小、推理快,适合后面要部署到边缘设备的场景。DenseNet121 在医学图像上表现一直不错,因为它的特征复用机制对纹理细节敏感。
选哪个?如果只是验证数据集质量,ResNet50 足够。如果要冲指标,EfficientNet-B3 或 B4 值得试。但注意,预训练权重是在自然图像上学的,超声图像和自然图像域差距大,所以 fine-tune 时学习率要小,通常设 1e-4 到 1e-5,而不是从头训的 1e-2。
import torch.nn as nn from torchvision import models def build_model(arch="resnet50", num_classes=2, pretrained=True): if arch == "resnet50": model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT if pretrained else None) in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.5), # 防过拟合 nn.Linear(in_features, num_classes) ) elif arch == "efficientnet_b0": model = models.efficientnet_b0(weights=models.EfficientNet_B0_Weights.DEFAULT if pretrained else None) in_features = model.classifier[1].in_features model.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(in_features, num_classes) ) return model关键改动在分类头:加 Dropout 再接 Linear。医学数据量不大,分类头不加 Dropout 很容易过拟合。num_classes=2对应良恶性二分类,如果数据集有更多细分类型,改这个数就行。
3.2 训练循环与类别不均衡处理
类别不均衡是医学数据集的常态。7000 张里如果良性 5000、恶性 2000,模型会倾向于全预测良性,准确率看着有 70%,但恶性召回率惨不忍睹。解决办法有两个:一是WeightedRandomSampler过采样少数类,二是在 loss 里加 class weight。
import torch import torch.nn as nn from torch.utils.data import WeightedRandomSampler from collections import Counter # 假设 train_dataset 是 ImageFolder targets = [s[1] for s in train_dataset.samples] class_counts = Counter(targets) print("类别分布:", class_counts) # 计算每个样本的权重 class_weights = {c: 1.0 / count for c, count in class_counts.items()} sample_weights = [class_weights[t] for t in targets] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True) train_loader = DataLoader(train_dataset, batch_size=32, sampler=sampler, num_workers=4) # loss 里也加权重 weight_tensor = torch.tensor([class_weights[0], class_weights[1]], dtype=torch.float32).cuda() criterion = nn.CrossEntropyLoss(weight=weight_tensor)WeightedRandomSampler让每个 batch 里少数类样本出现频率更高,replacement=True表示有放回采样。loss 里的 weight 是双保险。注意class_weights用的是倒数,不是直接的比例,这样少数类权重更大。训练时观察验证集上每个类别的召回率,而不是只看总体准确率。
3.3 学习率调度与早停
迁移学习的学习率调度和从头训不一样。常见做法是 warmup 几个 epoch,然后 cosine 退火。早停看验证集 loss,连续 5 个 epoch 不降就停。
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2) best_val_loss = float("inf") patience = 5 counter = 0 for epoch in range(50): model.train() for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() val_loss = 0.0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.cuda(), labels.cuda() outputs = model(imgs) val_loss += criterion(outputs, labels).item() val_loss /= len(val_loader) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), "best_model.pth") counter = 0 else: counter += 1 if counter >= patience: print(f"Early stop at epoch {epoch}") breakAdamW的 weight_decay 设 1e-4,比 SGD 的 5e-4 小,因为 Adam 本身有自适应。CosineAnnealingWarmRestarts的T_0=10表示每 10 个 epoch 重启一次学习率,T_mult=2让每次重启周期翻倍。早停的 patience 设 5,别设太小,医学数据收敛慢。
4. 评估指标与结果解读:准确率会骗人,看 AUC 和召回
4.1 为什么不能只看 accuracy
良恶性二分类,如果测试集里良性占 80%,模型全预测良性就有 80% 准确率。但这个模型在临床上毫无价值,因为它漏掉所有恶性结节。所以必须看混淆矩阵、敏感度(召回率)、特异度和 AUC。
from sklearn.metrics import classification_report, roc_auc_score, confusion_matrix import numpy as np model.eval() all_probs = [] all_labels = [] with torch.no_grad(): for imgs, labels in test_loader: imgs = imgs.cuda() outputs = model(imgs) probs = torch.softmax(outputs, dim=1)[:, 1] # 恶性概率 all_probs.extend(probs.cpu().numpy()) all_labels.extend(labels.numpy()) all_probs = np.array(all_probs) all_labels = np.array(all_labels) # 默认阈值 0.5 preds = (all_probs >= 0.5).astype(int) print(confusion_matrix(all_labels, preds)) print(classification_report(all_labels, preds, target_names=["良性", "恶性"])) print(f"AUC: {roc_auc_score(all_labels, all_probs):.4f}")classification_report会输出每个类别的 precision、recall、f1。重点看恶性类的 recall,这个值低于 0.85 就要警惕。AUC 衡量的是排序能力,不受阈值影响,一般医学图像分类 AUC 能到 0.90 以上算不错。
4.2 阈值调整:0.5 不是金标准
默认 0.5 的阈值在医学场景往往不合适。如果漏诊恶性结节的代价远大于误诊良性,应该把阈值调低,比如 0.35,让更多样本被判为恶性,提高召回率,牺牲一点特异度。
from sklearn.metrics import roc_curve fpr, tpr, thresholds = roc_curve(all_labels, all_probs) # 找约登指数最大的阈值 youden = tpr - fpr best_threshold = thresholds[np.argmax(youden)] print(f"最佳阈值: {best_threshold:.4f}") preds_adjusted = (all_probs >= best_threshold).astype(int) print(classification_report(all_labels, preds_adjusted, target_names=["良性", "恶性"]))约登指数最大化的是敏感度加特异度减一。实际临床中,阈值定多少还要和医生商量,取决于他们能接受多少假阳性。这个数据集的好处是量够,你可以划出一部分做阈值校准,不占用测试集。
4.3 可视化:Grad-CAM 看模型到底在看哪
模型指标好不代表它学对了特征。有可能它看的是图像角落的标记,而不是结节本身。Grad-CAM 能把模型关注区域画出来,直观判断。
from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image target_layers = [model.layer4[-1]] # ResNet50 最后一层 cam = GradCAM(model=model, target_layers=target_layers) # 取一张测试图 input_tensor = imgs[0:1].cuda() grayscale_cam = cam(input_tensor=input_tensor)[0] # 叠加到原图 rgb_img = imgs[0].permute(1, 2, 0).cpu().numpy() rgb_img = (rgb_img - rgb_img.min()) / (rgb_img.max() - rgb_img.min()) visualization = show_cam_on_image(rgb_img, grayscale_cam, use_rgb=True)热力图如果集中在结节区域,说明模型学对了。如果散在图像边缘或均匀分布,说明模型没抓住重点,可能需要检查标注质量或调整预处理。这个步骤在论文里也常用来增加说服力。
5. 避坑与常见问题:标注噪声、域偏移、过拟合
5.1 标注噪声:7000 张里有多少错标
现象:训练集准确率很快到 99%,验证集卡在 80% 上不去。原因:标注里有错标或边界样本,模型把噪声也学了。解决:先用一个简单模型跑一遍训练集,把 loss 最高的那批样本挑出来人工复查。常见做法是看 loss 排序前 5% 的图,往往能发现一批标反的。
5.2 域偏移:训练集和测试集来自不同设备
现象:交叉验证指标很好,换一批数据就崩。原因:不同超声设备、不同参数设置的图像分布不同。解决:训练时加更强的增强,比如模拟不同对比度和噪声;或者在测试时做直方图匹配,把测试图对齐到训练集分布。如果数据集本身分了不同来源,按来源分层划分训练测试集。
5.3 过拟合:训练 loss 降,验证 loss 升
现象:训练 20 个 epoch 后验证 loss 开始上升。原因:模型容量太大或数据量相对不足。解决:加 Dropout、weight_decay,减小模型(ResNet18 换 ResNet50),或者冻结 backbone 前几层只训后面。早停是最直接的后悔药。
5.4 图像尺寸不一致导致训练中断
现象:RuntimeError: stack expects each tensor to be equal size。原因:某些图像通道数或尺寸异常,比如灰度图是单通道,或者有损坏文件。解决:在Dataset的__getitem__里加 try-except,遇到坏图返回一个占位图或跳过。预处理阶段统一转成三通道。
5.5 显存不够:batch size 调不下去
现象:CUDA out of memory。原因:图像分辨率太高或模型太大。解决:用梯度累积模拟大 batch,或者用混合精度训练。torch.cuda.amp能省不少显存,对指标几乎没影响。
scaler = torch.cuda.amp.GradScaler() for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() optimizer.zero_grad() with torch.cuda.amp.autocast(): outputs = model(imgs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度训练把部分计算转成 float16,显存占用能降 30% 到 50%。注意 loss 要经过 scaler,不然梯度会下溢。
6. 进阶技巧:用交叉验证和模型集成把 AUC 再推一截
单次划分训练测试集,指标波动可能很大。7000 张数据做 5 折交叉验证,每折都训一个模型,最后把 5 个模型的预测概率平均,AUC 通常能比单模型高 1 到 2 个点。这个提升在医学场景很值,因为数据量不算特别大,单模型方差高。
from sklearn.model_selection import StratifiedKFold import numpy as np skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) all_labels = np.array([s[1] for s in dataset.samples]) all_indices = np.arange(len(all_labels)) oof_probs = np.zeros(len(all_labels)) # out-of-fold 预测 for fold, (train_idx, val_idx) in enumerate(skf.split(all_indices, all_labels)): train_subset = torch.utils.data.Subset(dataset, train_idx) val_subset = torch.utils.data.Subset(dataset, val_idx) train_loader = DataLoader(train_subset, batch_size=32, sampler=sampler, num_workers=4) val_loader = DataLoader(val_subset, batch_size=32, shuffle=False, num_workers=4) model = build_model("resnet50", num_classes=2).cuda() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) criterion = nn.CrossEntropyLoss(weight=weight_tensor) # 训练若干 epoch(省略训练循环,同第 3 章) # ... # 预测验证折 model.eval() fold_probs = [] with torch.no_grad(): for imgs, _ in val_loader: imgs = imgs.cuda() outputs = model(imgs) probs = torch.softmax(outputs, dim=1)[:, 1] fold_probs.extend(probs.cpu().numpy()) oof_probs[val_idx] = fold_probs # 整体 AUC from sklearn.metrics import roc_auc_score print(f"5 折 OOF AUC: {roc_auc_score(all_labels, oof_probs):.4f}")StratifiedKFold保证每折里类别比例一致。oof_probs存的是每个样本在它没参与训练的那折里的预测概率,这样得到的 AUC 是无偏估计。如果 5 折 AUC 方差大,说明数据分布不稳定,可能需要检查标注一致性。
模型集成还有一招:不同 backbone 各训一个,ResNet50 + EfficientNet-B0 + DenseNet121,预测概率加权平均。权重可以按各自验证集 AUC 分配。这个做法在 Kaggle 医学图像比赛里很常见,代价是推理时间翻倍,看你能不能接受。
最后说个血泪经验:每次改完预处理或增强策略,一定重新跑一遍验证集,别凭感觉觉得“这次肯定更好”。我有一次把旋转角度从 10 调到 30,训练集指标涨了,测试集 AUC 掉了 3 个点,回头查才发现大角度旋转把一些结节的形态特征转没了。从那以后我每次调增强参数都强制走一遍完整评估,不敢偷懒。希望这份数据集和上面的流程能帮你少走点弯路。
本文还有配套的精品资源,点击获取