多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于CNN的甲状腺超声AI辅助诊断:6321张图像的工程落地与复现

基于CNN的甲状腺超声AI辅助诊断:6321张图像的工程落地与复现 简介这份PDF文献聚焦深度学习在甲状腺超声图像中的结节自动识别方法面向医学影像研究、人工智能辅助诊断方向的科研人员与临床医师帮助解决超声判读耗时长、低年资医师识别能力不足等实际问题。资源包共1个文件为PDF格式大小约585KB内容完整收录了研究论著全文涵盖摘要、资料与方法、结果与结论等标准学术结构。文中基于2013年1月至2018年1月共6321张甲状腺图像展开实验其中3200张确诊图像用于模型训练3121张未确诊图像用于验证并交由4名临床医师对照诊断系统比较了深度学习与超声医师在阳性预期率、阴性预期率、诊断敏感性、诊断效率及诊断特异性等指标上的差异。目前已有306人学习适合作为医学影像深度学习课题的参考文献与专业指导材料也可为相关算法复现和临床辅助诊断研究提供数据规模、评价指标与实验设计方面的参考。1. 甲状腺超声 AI 辅助诊断6321 张图像背后的工程落地逻辑超声科医生一天看几十份甲状腺片子每份反复扫查、测量、判断良恶性十来分钟就没了。这份 2019 年发表在《中国医疗设备》上的研究用 6321 张真实甲状腺超声图像训练了一个 CNN 模型把单张图像的诊断时间压到 0.10±0.02 秒阳性预期率 98.62%、诊断效率 98.43%全面超过高年资和低年资超声医师。它解决的不是AI 能不能看病这种虚问题而是一个很具体的工程问题如何用迁移学习 数据增强在小样本医学图像上把结节识别做到可辅助诊断的精度。适合医学影像方向的研究生、做医疗 AI 落地的算法工程师以及想复现一套完整数据标注→预处理→CNN 训练→临床对比验证流程的从业者。下面我按这份 PDF 里的技术路线把能抄作业的部分拆开讲。2. 数据管线拆解从 6321 张原始超声图到 225×225 训练集2.1 数据集划分与标注规范这份研究的原始数据来自飞利浦 IU22 和 GE E9 两台设备时间跨度 2013 年 1 月到 2018 年 1 月共 6321 张。划分逻辑很清晰数据用途数量说明多发结节训练2000 张病理确诊含病灶范围标注单发结节训练1200 张病理确诊含病灶范围标注验证集3121 张未参与训练其中结节 2900 张、正常/良性病变 221 张标注环节由超声医师以病理为标准把图像分成良性和恶性两大类同时对病灶范围做精准标注。这里有个容易翻车的地方原文明确说本文所称甲状腺结节未区分良恶性也就是说模型输出的是有无结节的二分类不是良恶性四分类。很多复现的人一上来就做良恶性分类标注成本翻几倍不说样本量根本撑不住。2.2 预处理裁剪、去噪、正则化到统一尺寸预处理三步走——裁剪、去噪、正则化最终统一为 225×225 像素。这个尺寸不是随便定的后面 CNN 第一层卷积输出 111×111反推输入就是 225 左右。用 Python 实现这套预处理import cv2 import numpy as np def preprocess_thyroid_image(img_path, target_size(225, 225)): # 读取为灰度图超声图像本身就是灰阶 img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 裁剪去掉超声图像四周的黑色边框和仪器标注区域 # 常见做法是先阈值化找到有效成像区域再裁 _, mask cv2.threshold(img, 15, 255, cv2.THRESH_BINARY) coords cv2.findNonZero(mask) x, y, w, h cv2.boundingRect(coords) img img[y:yh, x:xw] # 去噪超声图像斑点噪声严重用中值滤波保边 img cv2.medianBlur(img, 3) # 正则化归一化到 [0,1]消除不同设备间的灰度差异 img img.astype(np.float32) / 255.0 # 统一尺寸 img cv2.resize(img, target_size, interpolationcv2.INTER_AREA) return img逻辑说明cv2.threshold那一步是为了自动找到有效成像区域超声图四周通常有大片黑色背景和设备参数文字不裁掉会引入噪声。medianBlur核大小选 3 是因为超声斑点噪声颗粒细核太大反而把小结节边缘抹掉。归一化用/255.0是最简单的 min-max 归一化如果不同设备灰度分布差异大可以换成 CLAHE 做自适应直方图均衡。参数说明target_size设 225×225 是跟原文对齐实际训练时如果显存够可以上 448×448 保留更多细节但要注意小结节在低分辨率下可能只剩几个像素。INTER_AREA适合缩小INTER_CUBIC适合放大别搞反。2.3 数据增强旋转 mixup 构造训练集原文的训练集构建方法是从采集数据中随机选结节和无结节图像然后分别用旋转和 mixup 方法扩充。旋转好理解mixup 是把两张图按比例线性叠加import numpy as np def mixup_data(x1, y1, x2, y2, alpha0.2): mixup: 两张图像按 lam 比例混合标签同步混合 lam np.random.beta(alpha, alpha) mixed_x lam * x1 (1 - lam) * x2 # 标签做 one-hot 后按同样比例混合 mixed_y lam * y1 (1 - lam) * y2 return mixed_x, mixed_y def random_rotate(img, label, max_angle15): 小角度旋转超声探头方向变化有限别转太大 angle np.random.uniform(-max_angle, max_angle) h, w img.shape[:2] M cv2.getRotationMatrix2D((w//2, h//2), angle, 1.0) rotated cv2.warpAffine(img, M, (w, h), borderModecv2.BORDER_REFLECT) return rotated, label逻辑说明mixup 的alpha0.2是常见起点值越小混合比例越极端偏向某一张图值越大越接近 50:50。医学图像里结节位置和形态是判别关键mixup 太强会让模型学不到明确边界所以 alpha 别超过 0.4。旋转角度控制在 ±15° 以内因为超声探头扫查角度变化本身有限转 90° 出来的图解剖结构就不对了。参数说明旋转的borderMode用BORDER_REFLECT而不是补零补零会在图像边缘引入人工黑边模型可能把黑边当成特征。如果做 mixup建议只在训练时开验证和测试阶段关掉否则指标会虚高。3. CNN 结构复现三层卷积 迁移学习的参数怎么定3.1 网络结构逐层拆解原文给的 CNN 结构很明确输入 225×225第一层卷积输出 111×111×96第二层 55×55×256第三层 13×13×384最后全连接分类。反推一下卷积核和步长225 → 111如果用 3×3 卷积、stride2、padding1输出是 (2252-3)/21 113接近 111。如果用 5×5、stride2、padding0输出 (225-5)/21 111正好。所以第一层大概率是 5×5 卷积核、stride2。111 → 553×3、stride2、padding1输出 (1112-3)/21 56接近 55。用 3×3、stride2、padding0 得 (111-3)/21 55正好。55 → 13这个跨度大可能是池化加卷积组合。55 经过一次 2×2 最大池化变 27再 3×3 卷积 stride2 得 13。用 PyTorch 搭一个对齐的结构import torch import torch.nn as nn class ThyroidCNN(nn.Module): def __init__(self, num_classes2): super().__init__() self.features nn.Sequential( # 225 - 111, 96通道 nn.Conv2d(1, 96, kernel_size5, stride2, padding0), nn.BatchNorm2d(96), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 111 - 55 # 55 - 55, 256通道 nn.Conv2d(96, 256, kernel_size3, stride1, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 55 - 27 # 27 - 13, 384通道 nn.Conv2d(256, 384, kernel_size3, stride2, padding1), nn.BatchNorm2d(384), nn.ReLU(inplaceTrue), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), # 13x13 - 1x1 nn.Flatten(), nn.Linear(384, 128), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return x逻辑说明原文没给全连接层的具体维度这里用AdaptiveAvgPool2d(1)把 13×13×384 压成 384 维再进全连接是常见做法比直接 flatten 成 13×13×38464896 维参数量小得多。BatchNorm2d加在卷积后激活前能加速收敛医学图像数据集小的时候尤其明显。Dropout(0.5)防过拟合如果训练集只有几千张这个不能省。参数说明输入通道设 1 是因为超声图是灰度图如果你用 RGB 三通道预处理改成 3。num_classes2对应有结节/无结节如果做良恶性分类改成 3 或 4。学习率建议从 1e-4 起步用 Adam 优化器batch size 根据显存调到 16 或 32。3.2 迁移学习ImageNet 预训练 甲状腺数据微调原文明确说了首先把该模型在 ImageNet 数据集上进行了预训练然后在甲状腺数据集上进行参数调整训练。这是小样本医学图像的标准操作。ImageNet 是 RGB 三通道我们的输入是单通道第一层卷积权重需要处理一下import torchvision.models as models import torch def load_pretrained_weights(model, num_classes2): # 以 ResNet 为例实际可以用任意 ImageNet 预训练模型 resnet models.resnet18(pretrainedTrue) # 第一层卷积从 3 通道改成 1 通道对 RGB 权重求均值 old_conv resnet.conv1.weight.data # [64, 3, 7, 7] new_conv torch.mean(old_conv, dim1, keepdimTrue) # [64, 1, 7, 7] resnet.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3) resnet.conv1.weight.data new_conv # 替换最后的全连接层 resnet.fc nn.Linear(resnet.fc.in_features, num_classes) return resnet逻辑说明把 RGB 三通道权重按通道求均值变成单通道比随机初始化第一层要好因为底层边缘、纹理特征在灰度图上同样适用。微调策略上常见做法是前几层冻结、只训练后面几层或者全部解冻但用很小的学习率1e-5 级别。原文没写具体冻结策略我一般会先冻结卷积层训练分类头 5 个 epoch再解冻全部微调 20 个 epoch。参数说明pretrainedTrue在 torchvision 新版本里改成了weightsIMAGENET1K_V1注意版本差异。如果显存不够可以把 ResNet18 换成更小的自定义 CNN但迁移学习的效果会打折扣。4. 训练与验证指标计算和医师对比的工程细节4.1 五个评价指标的计算方式原文用了阳性预期率、阴性预期率、诊断敏感性、诊断效率、诊断特异性五个指标。这些指标本质上是混淆矩阵的衍生指标计算公式含义阳性预期率TP/(TPFP)预测有结节的里面真的是结节阴性预期率TN/(TNFN)预测无结节的里面真的无结节诊断敏感性TP/(TPFN)真有结节的被找出来诊断特异性TN/(TNFP)真无结节的被排除诊断效率(TPTN)/(TPTNFPFN)整体准确率用 sklearn 一行算完from sklearn.metrics import confusion_matrix def compute_metrics(y_true, y_pred): tn, fp, fn, tp confusion_matrix(y_true, y_pred).ravel() ppv tp / (tp fp) if (tp fp) 0 else 0 npv tn / (tn fn) if (tn fn) 0 else 0 sensitivity tp / (tp fn) if (tp fn) 0 else 0 specificity tn / (tn fp) if (tn fp) 0 else 0 efficiency (tp tn) / (tp tn fp fn) return { 阳性预期率: round(ppv * 100, 2), 阴性预期率: round(npv * 100, 2), 诊断敏感性: round(sensitivity * 100, 2), 诊断特异性: round(specificity * 100, 2), 诊断效率: round(efficiency * 100, 2) }逻辑说明confusion_matrix返回的 ravel 顺序是 tn, fp, fn, tp别搞错。原文表 1 里机器学习组的诊断特异性只有 84.13%明显低于其他指标原因是验证集里正常图像只有 221 张样本不均衡导致特异性被拉低。这是医学 AI 论文里很常见的坑复现时如果验证集正常样本太少特异性指标参考价值有限。参数说明如果要做置信区间可以用 bootstrap 重采样 1000 次算 95% CI。诊断时间统计用time.perf_counter()包住推理代码跑 100 次取均值和标准差。4.2 与医师对比的实验设计原文选了 4 名超声医师分高年资中级资格证 超过 10000 例检查和低年资工作小于 3 年 不到 3000 例检查两组分别独立对屏幕上的测试集图像做识别记录每张图的诊断时间。这个实验设计有几个关键点医师看的是电脑屏幕展示的图像不是实时扫查所以排除了手法差异每张图记录时间高年资 10 分钟、低年资 15 分钟是总时间还是单张时间原文表述是诊断时间分别为 15 min 和 10 min结合上下文应该是整个测试集的平均或总耗时复现时建议明确记录单张平均时间。模型推理时间 0.10±0.02 秒这个数字是在什么硬件上跑的原文没写。复现时如果用自己的 GPU要注明型号否则跟医师的分钟级对比没有意义。CPU 推理和 GPU 推理差距可能几十倍。5. 避坑与排查复现这套甲状腺 CNN 时最容易翻车的五个点5.1 现象模型在训练集上准确率 99%验证集只有 70%原因数据泄露。训练集和验证集如果来自同一批连续帧图像相邻帧之间高度相似模型记住了患者特征而不是结节特征。原文验证集是未用来进行模型训练的图像但没说明是否按患者划分。解决按患者 ID 划分训练/验证集同一患者的图像只能出现在一个集合里。如果原始数据没有患者 ID至少按检查日期做时间切分用早期数据训练、后期数据验证。5.2 现象诊断特异性始终上不去卡在 80% 左右原因验证集类别不均衡。原文 3121 张验证图里结节 2900 张、正常 221 张比例约 13:1。模型偏向预测有结节导致正常图像被误判。解决训练时用加权交叉熵损失正常样本权重设为结节样本的 5-10 倍。或者用 focal loss 让模型关注难分类样本。验证时不要只看准确率重点看特异性和 AUC。5.3 现象换一台超声设备的图像模型性能断崖式下降原因不同设备飞利浦 IU22 vs GE E9的灰度分布、斑点噪声模式、图像尺寸都不同。原文数据来自两台设备但没做设备间的域适应分析。解决预处理阶段加 CLAHE 做直方图标准化或者用 CycleGAN 做设备间的风格迁移。更简单的做法是训练时把设备型号作为辅助标签让模型学设备无关的特征。5.4 现象mixup 增强后模型收敛变慢甚至不收敛原因mixup 的 alpha 设太大或者对标签也做了混合但用的是硬标签。医学图像分类通常用软标签配合 mixup如果标签是 one-hot 硬标签混合后语义不明确。解决alpha 从 0.1 开始试标签用 label smoothing 转成软标签再混合。另外 mixup 只对训练集做验证集保持原始分布。5.5 现象推理时间远大于论文里的 0.10 秒原因论文里的 0.10 秒大概率是 GPU 上的 batch 推理时间除以 batch size不是单张 CPU 推理。另外图像预处理裁剪、去噪、resize的时间可能没算进去。解决测推理时间时把预处理也包进去用torch.no_grad()关掉梯度计算开model.eval()关掉 dropout 和 batchnorm 的训练模式。如果部署到 CPU考虑用 ONNX Runtime 或 TensorRT 加速。6. 从论文到产品把 CNN 模型封装成可调用的诊断接口论文里的模型跑通只是第一步真正要在超声科用起来得封装成一个医生点一下就能出结果的接口。我一般会用 FastAPI 把模型包成 HTTP 服务输入是超声图像文件输出是结节有无、置信度和推理耗时。from fastapi import FastAPI, UploadFile import torch import numpy as np import cv2 import time app FastAPI() model ThyroidCNN(num_classes2) model.load_state_dict(torch.load(thyroid_cnn.pth, map_locationcpu)) model.eval() app.post(/predict) async def predict(file: UploadFile): start time.perf_counter() # 读取并预处理 contents await file.read() img cv2.imdecode(np.frombuffer(contents, np.uint8), cv2.IMREAD_GRAYSCALE) img preprocess_thyroid_image(img) # 复用前面的预处理函数 tensor torch.from_numpy(img).unsqueeze(0).unsqueeze(0).float() # 推理 with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1) pred torch.argmax(prob, dim1).item() elapsed time.perf_counter() - start return { has_nodule: bool(pred), confidence: round(prob[0][pred].item(), 4), inference_time_ms: round(elapsed * 1000, 2) }逻辑说明torch.no_grad()必须加否则推理时也会建计算图显存占用和耗时都翻倍。model.eval()让 BatchNorm 用 running mean/var 而不是当前 batch 的统计量单张推理时尤其重要。返回结果里带上推理耗时方便跟论文里的 0.10 秒做对比。参数说明map_locationcpu是为了在没有 GPU 的机器上也能加载。如果部署到 GPU去掉这个参数并加.cuda()。置信度用 softmax 后的概率如果要做阈值调整比如置信度低于 0.7 就转人工复核在返回前加个判断。验证这套接口是否跟论文指标对齐我会用验证集跑一遍对比混淆矩阵。如果阳性预期率跟论文的 98.62% 差超过 2 个百分点先检查预处理是否一致——尤其是裁剪和归一化这两步对结果影响最大。另一个容易忽略的点是图像方向超声图像有横向和纵向两种扫查方向如果训练集里纵向占多数测试时来一张横向的模型可能直接懵掉。我一般会在预处理里加一步方向检测或者训练时把横向图像旋转 90° 统一方向。从那以后我每次复现医学影像论文都强制走一遍预处理可视化→单张推理→批量指标→接口封装四步中间任何一步指标对不上就停下来查绝不带着疑问往下走。希望帮到你。本文还有配套的精品资源点击获取
返回列表