
简介这份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 标注格式的转换与清洗脚本拿到手的标注常见三种XMLPASCAL VOC 风格、JSONLabelMe 或 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(parentsTrue, exist_okTrue) for jf in Path(json_dir).glob(*.json): with open(jf, r, encodingutf-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_splits1, test_size0.2, random_state42) train_idx, test_idx next(gss.split(df, groupsdf[patient_id])) train_df df.iloc[train_idx] test_df df.iloc[test_idx] # 再从 train 里切验证集同样按患者分组 gss_val GroupShuffleSplit(n_splits1, test_size0.15, random_state42) tr_idx, val_idx next(gss_val.split(train_df, groupstrain_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_size0.2和0.15是常见比例但如果你的数据集患者总数少于 200测试集至少留 30 个患者否则指标波动会很大。这个步骤多花十分钟能省掉后面调参时「为什么验证集涨了测试集没涨」的困惑。3. 模型选型与训练从 U-Net 分割到分类头的两阶段方案3.1 为什么我倾向于先分割再分类甲状腺结节识别有两个子任务定位结节区域分割或检测判断良恶性分类。端到端检测模型比如 YOLOv8 可以同时输出框和类别但在超声图像上直接做检测有个问题结节边界模糊检测框的回归损失很难收敛到很准的位置而分类又依赖框内的纹理特征。我试过直接上 YOLOmAP50 能到 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_channels1, out_channels1).to(device) # 超声分割常用 Dice BCE 组合损失 bce nn.BCEWithLogitsLoss() def dice_loss(pred, target, smooth1e-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(), lr1e-4, weight_decay1e-5) scheduler CosineAnnealingLR(optimizer, T_max50, eta_min1e-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(fEpoch {epoch1}, Val Dice: {val_dice:.4f})损失函数用 BCE 和 Dice 各占一半是因为超声分割里前景结节和背景比例悬殊纯 BCE 会让模型倾向于全预测背景。Dice 损失直接优化重叠度对小目标更友好。学习率1e-4配合AdamW是比较稳的起点如果训练 loss 震荡厉害降到5e-5。CosineAnnealingLR的T_max设成总 epoch 数让学习率平滑降到接近零。验证指标只看 Dice不要只看 lossloss 低不代表分割边界准。3.3 分类头的迁移学习与类别不平衡处理分割完成后把掩码区域裁出来做分类。这里有个细节裁出来的区域要往外扩 10 到 20 像素把结节边缘的包膜和周围组织也带进去因为良恶性的判断有时候依赖边缘是否规则、有没有向外浸润。import torchvision.models as models import torch.nn as nn class ThyroidClassifier(nn.Module): def __init__(self, num_classes2, freeze_backboneTrue): super().__init__() self.backbone models.resnet50(weightsmodels.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_sampleslen(sample_weights), replacementTrue)freeze_backboneTrue先冻结主干训练分类头跑 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_version13 )opset_version13兼容性比较好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 分级作为辅助标签。我自己踩过的坑是早期版本模型在边界清晰的结节上表现很好但边界模糊的病例几乎全错后来在训练时专门对边界模糊的样本做了过采样才把召回率拉上来。做医学图像指标好看是其次漏掉一个恶性结节才是真的后悔药没处买。希望帮到你。本文还有配套的精品资源点击获取