简介:这份PDF文献聚焦深度学习在甲状腺超声影像中的自动识别应用,面向医学影像研究、人工智能辅助诊断方向的科研人员与临床医师,帮助解决结节位置、大小及良恶性判断耗时费力的问题。资源包仅含1个PDF文件,大小约585KB,内容为期刊论文全文,涵盖研究背景、资料与方法、结果与结论等完整章节,便于直接阅读与引用。文中基于2013年1月至2018年1月共6321张甲状腺图像展开实验,其中3200张确诊图像用于模型训练,3121张未确诊图像用于验证并交由4名临床医师诊断对比,结果显示深度学习方法在阳性预期率、阴性预期率、诊断敏感性、诊断效率及诊断特异性等指标上均超过超声医师,阳性预期率高出10.00%,诊断效率高出10.24%。目前已有306人学习,适合作为医学影像深度学习课题的参考文献与专业指导材料。
1. 超声科走廊里的那个下午:为什么甲状腺结节识别值得用深度学习重做一遍
超声科医生一天看几十份甲状腺片子,TI-RADS 分级要一条条对着结节边界、回声、钙化点去抠,眼睛酸、手也僵。更麻烦的是,不同年资的医生对同一个结节的判断可能差出一整个等级——这不是水平问题,是超声图像本身的颗粒噪声、声影和操作者手法带来的天然不确定性。基于深度学习的甲状腺结节自动识别方法在超声图像中的应用,要解决的就是这件事:把「看图找结节、判断良恶性倾向」这个重复劳动交给模型先跑一遍,医生只做复核和决策。它适合三类人:想入门医学图像方向的深度学习工程师、手里有超声数据集想做点东西的算法同学、以及希望把 AI 辅助诊断接进科室流程的技术负责人。这篇不聊虚的,从数据怎么整理、模型怎么选、训练怎么调,一路讲到部署时那些让人翻车的坑。
2. 数据先行:甲状腺超声图像从拿到手到能喂给模型
2.1 超声图像和自然图像到底差在哪
很多人第一次拿甲状腺超声数据训练模型,会习惯性套用 ImageNet 那套预处理,结果 mAP 掉得莫名其妙。原因在于超声成像的物理机制和自然光成像完全不同。超声图像是回波信号重建出来的灰度图,存在斑点噪声(speckle noise),这种噪声不是高斯白噪声,而是乘性噪声,跟信号本身耦合在一起。结节边界往往不是一条清晰的线,而是灰度渐变带,良恶性结节的形态差异有时候就藏在那几个像素的灰度跳变里。
另一个容易被忽略的点是超声图像的动态范围。自然图像是 8bit 三通道,超声图像常见的是 8bit 单通道灰度,但原始射频数据可能是 12bit 甚至更高。如果拿到的是已经压缩过的 JPEG,高频细节已经丢了,这时候再去做超分辨率或者锐化,基本是自欺欺人。我一般会先确认数据来源:是设备直接导出的 DICOM,还是截图保存的 PNG。DICOM 里带 PatientID、StudyDate 这些元信息,做数据划分时能按患者维度切分,避免同一个人的不同切面同时出现在训练集和验证集里——这个坑后面会细说。
2.2 标注格式的转换与清洗脚本
拿到手的标注常见三种:XML(PASCAL VOC 风格)、JSON(LabelMe 或 COCO 风格)、以及 CSV 里写结节坐标和良恶性标签。不管哪种,最终要统一成模型能吃的格式。下面这段脚本把 LabelMe 的 JSON 转成 YOLO 格式的 txt,同时做一轮基础清洗。
import json import os from pathlib import Path # 输入:LabelMe 标注目录;输出:YOLO 格式 labels 目录 def labelme_to_yolo(json_dir, out_dir, class_map): out_path = Path(out_dir) out_path.mkdir(parents=True, exist_ok=True) for jf in Path(json_dir).glob("*.json"): with open(jf, "r", encoding="utf-8") as f: data = json.load(f) img_w = data["imageWidth"] img_h = data["imageHeight"] lines = [] for shape in data["shapes"]: label = shape["label"] if label not in class_map: continue # 跳过未定义类别,比如 "ignore" 或 "artifacts" points = shape["points"] xs = [p[0] for p in points] ys = [p[1] for p in points] # 转成 YOLO 的归一化中心点 + 宽高 x_center = (min(xs) + max(xs)) / 2.0 / img_w y_center = (min(ys) + max(ys)) / 2.0 / img_h w = (max(xs) - min(xs)) / img_w h = (max(ys) - min(ys)) / img_h # 过滤掉宽或高小于 0.01 的框,多半是误标 if w < 0.01 or h < 0.01: continue lines.append(f"{class_map[label]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") if lines: with open(out_path / (jf.stem + ".txt"), "w") as f: f.write("\n".join(lines)) # 类别映射:0 良性结节,1 恶性结节,2 钙化点(如果标注了) class_map = {"benign": 0, "malignant": 1, "calcification": 2} labelme_to_yolo("./annotations", "./labels", class_map)这段代码的逻辑很直白:读 JSON、取多边形外接矩形、归一化、过滤异常框。参数上要注意class_map必须和后续训练配置里的names顺序一致,否则模型学出来的类别会错位。w < 0.01这个阈值不是拍脑袋,甲状腺结节在 512×512 图像里通常占 50 到 200 像素宽,小于 5 像素的框基本是标注噪声。清洗完记得抽查几张可视化,确认框的位置没偏。
2.3 按患者维度切分数据集:一个被低估的细节
同一个患者的甲状腺超声通常有多个切面,横切、纵切、左右叶各几张。如果随机按图像切分,同一个患者的图像可能同时出现在训练集和测试集里,模型会「记住」这个患者的结节特征,测试指标虚高。正确做法是按患者 ID 分组,用GroupShuffleSplit或者自己写分组逻辑。
import pandas as pd from sklearn.model_selection import GroupShuffleSplit # df 至少包含 image_path, label, patient_id 三列 df = pd.read_csv("dataset.csv") gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(gss.split(df, groups=df["patient_id"])) train_df = df.iloc[train_idx] test_df = df.iloc[test_idx] # 再从 train 里切验证集,同样按患者分组 gss_val = GroupShuffleSplit(n_splits=1, test_size=0.15, random_state=42) tr_idx, val_idx = next(gss_val.split(train_df, groups=train_df["patient_id"])) train_final = train_df.iloc[tr_idx] val_final = train_df.iloc[val_idx] print(f"训练集患者数: {train_final['patient_id'].nunique()}, 图像数: {len(train_final)}") print(f"验证集患者数: {val_final['patient_id'].nunique()}, 图像数: {len(val_final)}") print(f"测试集患者数: {test_df['patient_id'].nunique()}, 图像数: {len(test_df)}")参数上test_size=0.2和0.15是常见比例,但如果你的数据集患者总数少于 200,测试集至少留 30 个患者,否则指标波动会很大。这个步骤多花十分钟,能省掉后面调参时「为什么验证集涨了测试集没涨」的困惑。
3. 模型选型与训练:从 U-Net 分割到分类头的两阶段方案
3.1 为什么我倾向于先分割再分类
甲状腺结节识别有两个子任务:定位结节区域(分割或检测),判断良恶性(分类)。端到端检测模型比如 YOLOv8 可以同时输出框和类别,但在超声图像上直接做检测有个问题:结节边界模糊,检测框的回归损失很难收敛到很准的位置,而分类又依赖框内的纹理特征。我试过直接上 YOLO,mAP50 能到 0.7 左右,但恶性结节的召回率偏低,漏检的往往是那些边界不清、回声不均匀的病例——恰恰是最需要提醒医生的。
两阶段方案更稳:第一阶段用 U-Net 或 DeepLabV3+ 做分割,把结节区域抠出来;第二阶段把分割掩码对应的图像区域裁出来,送进分类网络(ResNet50、EfficientNet-B3 都行)判断良恶性。分割阶段即使边界不那么完美,只要覆盖了结节主体,分类阶段仍然能学到有用的特征。而且分割掩码可视化出来,医生能直接看到模型「看」的是哪块区域,信任感会强很多。
3.2 U-Net 分割训练的关键参数
下面是一个基于 PyTorch 的 U-Net 训练循环核心片段,重点看损失函数和优化器设置。
import torch import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR # 假设 unet 是已经定义好的模型,train_loader 返回 (image, mask) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = UNet(in_channels=1, out_channels=1).to(device) # 超声分割常用 Dice + BCE 组合损失 bce = nn.BCEWithLogitsLoss() def dice_loss(pred, target, smooth=1e-6): pred = torch.sigmoid(pred) intersection = (pred * target).sum() return 1 - (2. * intersection + smooth) / (pred.sum() + target.sum() + smooth) def combined_loss(pred, target): return 0.5 * bce(pred, target) + 0.5 * dice_loss(pred, target) optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6) for epoch in range(50): model.train() for img, mask in train_loader: img, mask = img.to(device), mask.to(device) optimizer.zero_grad() pred = model(img) loss = combined_loss(pred, mask) loss.backward() optimizer.step() scheduler.step() # 每个 epoch 后在验证集上算 Dice 系数 model.eval() val_dice = evaluate_dice(model, val_loader, device) print(f"Epoch {epoch+1}, Val Dice: {val_dice:.4f}")损失函数用 BCE 和 Dice 各占一半,是因为超声分割里前景(结节)和背景比例悬殊,纯 BCE 会让模型倾向于全预测背景。Dice 损失直接优化重叠度,对小目标更友好。学习率1e-4配合AdamW是比较稳的起点,如果训练 loss 震荡厉害,降到5e-5。CosineAnnealingLR的T_max设成总 epoch 数,让学习率平滑降到接近零。验证指标只看 Dice,不要只看 loss,loss 低不代表分割边界准。
3.3 分类头的迁移学习与类别不平衡处理
分割完成后,把掩码区域裁出来做分类。这里有个细节:裁出来的区域要往外扩 10 到 20 像素,把结节边缘的包膜和周围组织也带进去,因为良恶性的判断有时候依赖边缘是否规则、有没有向外浸润。
import torchvision.models as models import torch.nn as nn class ThyroidClassifier(nn.Module): def __init__(self, num_classes=2, freeze_backbone=True): super().__init__() self.backbone = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) if freeze_backbone: for param in self.backbone.parameters(): param.requires_grad = False # 替换最后的全连接层 in_features = self.backbone.fc.in_features self.backbone.fc = nn.Sequential( nn.Dropout(0.5), nn.Linear(in_features, 256), nn.ReLU(), nn.Linear(256, num_classes) ) def forward(self, x): return self.backbone(x) # 类别不平衡:恶性样本通常少于良性,用 WeightedRandomSampler from torch.utils.data import WeightedRandomSampler class_counts = [800, 200] # 良性 800,恶性 200 weights = [1.0 / c for c in class_counts] sample_weights = [weights[label] for _, label in train_dataset] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)freeze_backbone=True先冻结主干训练分类头,跑 10 个 epoch 后再解冻最后两个 stage 做微调,这样比一上来就全量微调更稳,不容易过拟合小数据集。Dropout(0.5)在医学图像分类里几乎是标配,因为样本量通常不大。WeightedRandomSampler让每个 batch 里良恶性比例接近 1:1,避免模型偏向多数类。如果恶性样本实在太少(少于 50 例),考虑用数据增强里的 RandAugment 或者 MixUp,但 MixUp 在医学图像上要慎用,两张不同结节的图叠在一起,语义上说不通。
4. 避坑与排查:那些让模型指标突然崩掉的瞬间
4.1 验证集 Dice 很高但测试集一塌糊涂
现象:训练时验证集 Dice 稳定在 0.85 以上,换到测试集掉到 0.6。原因:数据泄漏,同一个患者的图像同时进了训练和验证。解决:回到 2.3 节,按患者 ID 分组切分,重新跑一遍。如果已经切分正确还是掉,检查测试集的图像来源是不是不同设备或不同年份,域偏移会导致性能下降,这时候需要在训练时加入更强的颜色抖动或灰度归一化。
4.2 模型把钙化点当成结节
现象:分割掩码里出现很多小圆点,分类时把钙化点区域判成恶性。原因:标注时钙化点被单独标了一类,但分割模型只有前景背景两个通道,钙化点被当成前景学进去了。解决:要么在分割阶段把钙化点也标成前景(因为钙化点确实是结节的一部分),要么在分类阶段把钙化点的位置信息作为额外特征输入。我一般选择前者,简化流程。
4.3 训练 loss 不降反升,或者变成 NaN
现象:前几个 epoch 正常,突然 loss 变成 NaN。原因:学习率太大,或者超声图像像素值没有归一化到 [0,1]。超声 DICOM 的像素值范围可能是 0 到 4095,直接送进网络,梯度爆炸。解决:在 Dataset 的__getitem__里做img = (img - img.min()) / (img.max() - img.min() + 1e-8),然后img = (img - 0.5) / 0.5归一化到 [-1,1]。学习率从1e-4降到1e-5再试。
4.4 推理时显存溢出
现象:训练时 batch size 16 没问题,推理时单张图就 OOM。原因:推理时没有用torch.no_grad(),或者输入图像尺寸比训练时大。解决:推理代码包在with torch.no_grad():里,并且把输入 resize 到训练时的尺寸。如果显存还是不够,用torch.cuda.empty_cache()清理缓存,或者换用 ONNX Runtime 做推理,显存占用会低不少。
4.5 模型对某个设备的数据完全失效
现象:在 A 设备的数据上训练,B 设备的测试集上 Dice 只有 0.3。原因:不同超声设备的增益、动态范围、探头频率不同,图像灰度分布差异大。解决:在训练时加入直方图均衡化或者 CLAHE,把不同设备的灰度分布拉齐。更彻底的做法是做域自适应,但那个复杂度高,一般项目用 CLAHE 就能缓解不少。
5. 从训练到落地:模型导出、推理加速与一个验证技巧
训练完的模型要进科室,不能每次都开个 Jupyter Notebook 跑。常见做法是导出成 ONNX,然后用 ONNX Runtime 或者 TensorRT 做推理。导出的时候注意输入输出的名字和动态轴设置。
import torch.onnx model.eval() dummy_input = torch.randn(1, 1, 512, 512).to(device) torch.onnx.export( model, dummy_input, "thyroid_unet.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=13 )opset_version=13兼容性比较好,dynamic_axes让 batch 维度可变,推理时想一次跑多张也行。导出后一定要用onnxruntime跑一遍验证,对比 PyTorch 和 ONNX 的输出差异,如果 max diff 超过 1e-3,检查有没有不支持的算子。
推理加速方面,如果科室的机器有 NVIDIA 显卡,用 TensorRT 能把 U-Net 的推理时间从 200ms 压到 50ms 以内。但 TensorRT 对动态 shape 支持有限,如果输入尺寸固定,可以在导出 ONNX 时把 batch 和尺寸都固定死,换性能。
最后分享一个验证技巧:不要只看 Dice 和 AUC,找 10 个模型预测置信度在 0.5 附近的病例,让医生盲评。这些「模棱两可」的病例最能暴露模型和医生判断的差异。如果模型在这些病例上的表现和医生差距很大,说明模型学到的特征和医生的诊断逻辑不一致,可能需要调整标注标准或者引入医生标注的 TI-RADS 分级作为辅助标签。我自己踩过的坑是:早期版本模型在边界清晰的结节上表现很好,但边界模糊的病例几乎全错,后来在训练时专门对边界模糊的样本做了过采样,才把召回率拉上来。做医学图像,指标好看是其次,漏掉一个恶性结节才是真的后悔药没处买。希望帮到你。
本文还有配套的精品资源,点击获取