甲状腺结节图像分类实战:7000张标注数据的迁移学习与避坑指南
简介本资源为甲状腺结节图像分类数据集面向医学影像分析、计算机视觉与深度学习方向的研究者及工程师用于训练和评估正常与结节两类的自动识别模型。压缩包共2000个文件以1998张jpg图像为主体另含1个json标注文件和1个py可视化脚本整体约324.26MB已按训练集与测试集划分各类别图片分目录存放便于直接接入分类网络。运行包内show脚本可快速预览样本分布与图像质量json文件则给出类别定义与标注对应关系。资源同时附有图像分类网络改进与计算机视觉完整项目的延伸链接方便读者在数据预处理、模型设计、训练测试到部署的全流程中参考优化思路。目前已有372人学习下载适合作为医疗影像分类任务的练手与对比基准。1. 甲状腺结节图像分类数据集7000 张已标注数据能跑出什么结果手里有一份约 7,000 张、已完成标注的甲状腺结节图像分类数据集这件事本身就值得认真对待。甲状腺超声图像在临床上属于高噪声、低对比度的典型场景结节边界模糊、钙化点细小、不同设备成像差异大公开可用的标注数据一直不算充裕。7,000 张这个量级如果类别划分合理、标注质量过关足够支撑一个从数据清洗到模型微调的完整闭环也足够让一个团队在两周内判断出「这条路值不值得继续投入」。这份数据能解决的问题很具体结节良恶性二分类、TI-RADS 分级辅助、或者更细的结节亚型区分。适合的人群也很明确——做医学影像算法落地的工程师、需要快速验证分类方案的研究者、以及想拿真实标注数据练手但不想从零标注的开发者。真正决定成败的不是模型选得多新而是你有没有先把这批图像的标注结构、类别分布和图像质量摸清楚。下面按「先看懂数据、再跑通基线、最后避坑」的顺序展开。2. 先摸清数据底细7,000 张标注图像的类别分布与质量核查拿到一份标注数据集最忌讳的就是直接ImageFolder一把梭开始训练。甲状腺结节图像有几个天然特点阳性样本往往集中在某些设备或某些采集批次阴性样本里混着大量正常腺体组织两者在灰度分布上可能高度重叠。不先做分布核查训练出来的模型很可能只是学会了「分辨设备」而不是「分辨结节」。2.1 目录结构与标注格式的三种常见形态已标注的甲状腺结节分类数据集落地时通常以三种形态出现先确认你手里是哪一种后面的读取代码完全不同。形态典型结构标注载体读取方式文件夹分类train/benign/、train/malignant/目录名即标签ImageFolder直接读CSV 索引图像统一存放 labels.csv文件名到标签的映射pandas 读表后自定义 Dataset多标签 JSON图像 annotations.json含类别、位置、TI-RADS 等解析 JSON 后按需取字段我一般会先跑一段统计脚本把类别数、每类样本量、图像尺寸分布一次性打出来。这一步花十分钟能省掉后面几小时的返工。import os from collections import Counter from PIL import Image root thyroid_dataset split_info {} for split in [train, val, test]: split_dir os.path.join(root, split) if not os.path.isdir(split_dir): continue counter Counter() sizes Counter() for cls in os.listdir(split_dir): cls_dir os.path.join(split_dir, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if not fname.lower().endswith((.png, .jpg, .jpeg, .bmp)): continue counter[cls] 1 with Image.open(os.path.join(cls_dir, fname)) as im: sizes[im.size] 1 split_info[split] {counts: dict(counter), sizes: dict(sizes)} for split, info in split_info.items(): print(split, 类别分布:, info[counts]) print(split, 尺寸 Top5:, info[sizes].most_common(5))这段脚本做三件事遍历每个 split 下的类别目录、统计每类图像数量、统计图像原始尺寸分布。counter用来暴露类别不平衡sizes用来判断是否需要统一 resize。如果发现某一类只有几百张而另一类有三千张那后面必须上加权采样或 focal loss否则模型会偏向多数类。尺寸分布如果集中在两三种分辨率说明数据来源相对统一预处理可以简化如果尺寸五花八门就要考虑是否保留长宽比做 padding。2.2 标注质量核查三类必须提前发现的脏数据标注数据不等于干净数据。甲状腺结节图像里我踩过最多的三类脏数据是重复图像、标签矛盾、以及边界样本被硬塞进某一类。重复图像用感知哈希pHash查最快。同一张图被不同文件名重复收录会让训练集和验证集产生泄漏验证指标虚高上线就翻车。标签矛盾指的是同一张图或几乎相同的图在两个类别里都出现这种必须人工裁决。边界样本则是那些良恶性特征都不明显的结节标注者本身可能就犹豫这类样本如果占比高会显著拉低模型上限。import imagehash from PIL import Image import os def find_duplicates(folder, hash_size8, threshold5): hashes {} dup_pairs [] for fname in os.listdir(folder): path os.path.join(folder, fname) try: with Image.open(path) as im: h imagehash.phash(im, hash_sizehash_size) except Exception: continue for exist_h, exist_name in hashes.items(): if h - exist_h threshold: dup_pairs.append((exist_name, fname, h - exist_h)) hashes[h] fname return dup_pairs dups find_duplicates(thyroid_dataset/train) print(f发现疑似重复对: {len(dups)}) for a, b, dist in dups[:20]: print(a, -, b, 距离, dist)hash_size8生成 64 位哈希threshold5表示汉明距离小于等于 5 视为疑似重复。阈值调小更严格调大更宽松。实际用的时候我会把距离在 3 以内的直接判重3 到 8 之间的拉出来人工看。这一步做完通常能从 7,000 张里揪出几十到上百张重复或近重复图对最终指标的诚实度影响很大。注意查重一定要在划分训练/验证集之前做否则重复图会跨 split 泄漏验证集准确率能虚高十几个点。3. 从零跑通基线用迁移学习在 7,000 张图上拿到可信指标数据摸清之后下一步是尽快跑出一个可信的基线。医学图像数据量普遍不大7,000 张从头训一个 CNN 基本没戏迁移学习是标准答案。选 backbone 的时候不要迷信最新ResNet50 和 EfficientNet-B0 在这个量级上依然是性价比最高的起点前者稳、后者省显存。3.1 数据划分与增强策略分层抽样加医学图像专用增强划分不能随机切。甲状腺结节数据里良恶性比例往往不均随机切会导致验证集类别分布抖动。用分层抽样stratified split保证每个 split 的类别比例一致是基本操作。import os import shutil import random from sklearn.model_selection import train_test_split random.seed(42) src thyroid_dataset/all classes [benign, malignant] paths, labels [], [] for idx, cls in enumerate(classes): cls_dir os.path.join(src, cls) for fname in os.listdir(cls_dir): paths.append(os.path.join(cls_dir, fname)) labels.append(idx) train_p, temp_p, train_y, temp_y train_test_split( paths, labels, test_size0.3, stratifylabels, random_state42 ) val_p, test_p, val_y, test_y train_test_split( temp_p, temp_y, test_size0.5, stratifytemp_y, random_state42 ) def dump(items, targets, split): for p, y in zip(items, targets): dst os.path.join(split, split, classes[y]) os.makedirs(dst, exist_okTrue) shutil.copy(p, dst) dump(train_p, train_y, train) dump(val_p, val_y, val) dump(test_p, test_y, test) print(len(train_p), len(val_p), len(test_p))stratifylabels是关键参数它保证切分后每类的比例和原始一致。test_size0.3先切出 30% 做临时集再从临时集里对半分成验证和测试最终得到 70/15/15 的划分。random_state42固定随机种子保证可复现。切完之后打印三个集合的大小确认没有某一类在验证集里只剩个位数。增强策略上甲状腺超声图像和自然图像差别很大。水平翻转、小角度旋转、亮度对比度微调是安全的垂直翻转要谨慎因为超声探头方向有解剖学含义强烈的颜色抖动基本没用因为图像本身是灰度的。我一般用 torchvision 的RandomHorizontalFlip、RandomRotation(10)、ColorJitter(brightness0.1, contrast0.1)三件套够用且不容易引入伪影。3.2 训练脚本与关键超参batch size、学习率、冻结策略怎么定基线训练用 PyTorch 写一个最小闭环。backbone 用 torchvision 自带的 ResNet50 预训练权重替换最后的全连接层前几轮先冻结卷积层只训分类头再解冻做小学习率微调。import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms, models device torch.device(cuda if torch.cuda.is_available() else cpu) train_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.1, contrast0.1), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) eval_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_ds datasets.ImageFolder(split/train, transformtrain_tf) val_ds datasets.ImageFolder(split/val, transformeval_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4) model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) for p in model.parameters(): p.requires_grad False model.fc nn.Linear(model.fc.in_features, 2) model model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.fc.parameters(), lr1e-3, weight_decay1e-4) for epoch in range(5): model.train() for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() loss criterion(model(x), y) loss.backward() optimizer.step() print(fepoch {epoch} done)batch_size32在 224 分辨率、单张 12G 显存卡上比较稳显存不够就降到 16 并同步把学习率减半。lr1e-3是只训分类头时的常用值解冻卷积层后要降到1e-4甚至1e-5否则预训练权重会被快速破坏。weight_decay1e-4抑制过拟合。冻结策略上先冻结训 5 轮让分类头收敛再解冻最后两个 stage 微调 10 到 15 轮是医学图像上比较稳的节奏。评估阶段不要只看 accuracy。甲状腺结节分类里恶性样本漏检的代价远高于误检所以 recall 和 AUC 比 accuracy 更值得盯。用sklearn.metrics的classification_report和roc_auc_score各跑一遍把混淆矩阵打出来看假阴性有多少。3.3 类别不平衡的处理加权损失与采样器的取舍如果第 2 章统计出来良恶性比例超过 2:1就要处理不平衡。两条路改损失函数或者改采样方式。CrossEntropyLoss(weight...)给少数类更高权重实现简单WeightedRandomSampler让少数类被采到的概率更高效果通常更直接但会改变每个 epoch 的有效样本数。from torch.utils.data import WeightedRandomSampler import numpy as np targets [y for _, y in train_ds.samples] class_count np.bincount(targets) class_weight 1.0 / class_count sample_weight [class_weight[t] for t in targets] sampler WeightedRandomSampler(sample_weight, num_sampleslen(sample_weight), replacementTrue) train_loader DataLoader(train_ds, batch_size32, samplersampler, num_workers4)class_weight取类别频次的倒数replacementTrue允许重复采样少数类。这样每个 epoch 里两类样本大致均衡。代价是少数类被反复采样过拟合风险上升所以配合早停和验证集监控一起用。我的习惯是比例在 2:1 以内用加权损失超过 3:1 才上采样器两者不叠加避免矫枉过正。4. 避坑与排查甲状腺结节分类训练里最容易翻车的五件事这一章全是血泪经验。甲状腺结节图像分类看起来是个标准二分类任务但医学图像的坑和自然图像完全不是一个量级。下面五条是我和身边同行反复踩过的按「现象 → 原因 → 解决」写清楚。4.1 验证集 AUC 0.95测试集掉到 0.7现象训练时验证集 AUC 一路涨到 0.95 以上换测试集一跑只有 0.7 出头差距大得离谱。原因最常见的是数据泄漏。同一患者的多个切面图被分到了训练和验证集模型记住了这个患者的纹理特征验证集等于开卷考试。其次是重复图像没查干净近重复图跨了 split。解决划分必须按患者 ID 分组而不是按图像随机切。如果数据里没有患者 ID就用感知哈希把近重复图聚成簇整簇一起分到同一个 split。这一步做完验证和测试的差距通常会收敛到 3 个点以内。4.2 模型把图像边缘的标尺和文字当成了特征现象模型在内部测试集上表现很好但换一批新设备采集的图就崩了混淆矩阵显示大量误判。原因超声图像角落常有设备自带的标尺、参数文字、时间戳。这些内容在不同设备上完全不同模型很容易学到「某类结节恰好都来自某台设备」这种伪相关。解决预处理阶段做边缘裁剪把四周 5% 到 10% 的边框去掉或者用固定 ROI 掩膜遮住角落区域。裁剪比例根据实际图像里标尺的位置定宁可多裁一点也别让文字进模型。裁完再训练跨设备泛化会明显改善。4.3 学习率没降解冻后模型直接发散现象冻结阶段 loss 稳步下降一解冻卷积层loss 突然飙升甚至变成 nan。原因解冻后所有参数一起更新用的还是训分类头时的1e-3预训练权重被大梯度冲垮。解决解冻时把学习率降到原来的十分之一到百分之一并且用分层学习率——卷积层用小学习率分类头用大学习率。PyTorch 里给optimizer传不同param_group即可。另外解冻后前两轮可以先用 warmup 把学习率从极小值线性升上来稳定性更好。4.4 图像灰度范围不统一归一化参数用错现象训练 loss 震荡收敛慢同一份代码换个数据集就正常。原因不同来源的超声图像位深不一样有的是 8 位灰度有的是 16 位直接ToTensor()后数值范围差异巨大。用 ImageNet 的均值方差去归一化灰度图本身就不太匹配。解决先统计整个训练集的像素均值和标准差用实际统计值做归一化而不是照搬 ImageNet 的[0.485, 0.456, 0.406]。如果图像是 16 位先线性映射到 0 到 255 再处理。这一步在医学图像上经常被忽略但影响不小。4.5 早停看 accuracy错过了最佳 recall 点现象训练到后期 accuracy 还在微涨但恶性类的 recall 已经开始下降最终模型漏检偏多。原因早停指标选了 accuracy而类别不平衡时 accuracy 会被多数类主导少数类的表现被掩盖。解决早停和模型选择都改用验证集的 AUC 或恶性类 recall。保存 checkpoint 时按这个指标存最优而不是按最后一个 epoch。临床上漏检一个恶性结节的代价远高于多报一个良性指标选择要跟业务代价对齐。5. 把 7,000 张数据的价值榨干难例挖掘与置信度校准的实操技巧基线跑通、坑也避过之后真正拉开差距的是怎么把这 7,000 张数据的价值用到极致。数据量固定能提升的空间主要在难例利用和输出可信度上。这里讲两个我实际用过、收益明确的技巧。第一个是难例挖掘hard example mining。做法是用基线模型对训练集全量推理把预测概率在 0.4 到 0.6 之间的样本挑出来这批就是模型最犹豫的边界样本。对这批样本做两件事——人工复核标签以及在下轮训练里给它们更高的采样权重。甲状腺结节里那些良恶性特征都不典型的样本往往就藏在这个区间。我做过一次从 7,000 张里挑出约 400 张高不确定性样本复核后发现其中约 15% 的标签确实存疑修正后重训验证集 AUC 提升了约 2 个点。代码上就是在WeightedRandomSampler的sample_weight里给这批样本乘一个大于 1 的系数。import torch import numpy as np model.eval() uncertain_idx [] with torch.no_grad(): for i, (x, y) in enumerate(train_ds): x x.unsqueeze(0).to(device) prob torch.softmax(model(x), dim1)[0, 1].item() if 0.4 prob 0.6: uncertain_idx.append(i) print(f高不确定性样本数: {len(uncertain_idx)}) base_weight [class_weight[t] for t in targets] for i in uncertain_idx: base_weight[i] * 2.0 sampler WeightedRandomSampler(base_weight, num_sampleslen(base_weight), replacementTrue)0.4到0.6这个区间可以按需调整区间越窄挑出的样本越少但越难。base_weight[i] * 2.0是给难例加权倍数在 1.5 到 3 之间试太高会让模型过拟合这几百张图。第二个是置信度校准。医学场景里模型输出「恶性概率 0.73」这种数字如果没校准过这个 0.73 是没有实际意义的。用温度缩放temperature scaling在验证集上拟合一个温度参数 T把 softmax 输出重新标定让预测概率和真实频率对齐。做法很简单在验证集上以最小化 NLL 为目标优化一个标量 T然后推理时把 logits 除以 T 再过 softmax。校准后概率阈值卡在 0.5 时模型说「恶性」的样本里实际恶性比例会更接近 50%下游做阈值决策时心里有底。技巧输入关键参数预期收益难例挖掘基线模型 训练集概率区间、加权倍数AUC 1~2 点温度缩放验证集 logits温度 T概率可信度提升分层学习率解冻阶段卷积层 lr 比例收敛更稳最后说个我自己的习惯每次拿到一份新的医学图像数据集我都会先花半天只做数据核查不碰模型。这半天里把类别分布、重复图、尺寸、灰度范围、患者分组全部过一遍写成一份简短的核查记录。看起来慢但后面每一次训练的可信度都建立在这份记录上。7,000 张标注数据不算多但用对了足够支撑一个能拿出手的分类方案。希望帮到你。本文还有配套的精品资源点击获取