尧图精选

超声图像CNN分类实践:甲状腺结节良恶性诊断的完整工程流程

🕒 发布时间:2026/9/18 13:55:36 📁 来源:尧图网络
简介这是一份面向医学影像处理、深度学习与机器学习研究者的学术文献聚焦基于卷积神经网络的甲状腺结节超声图像良恶性分类问题。资源为PDF格式共1个文件大小约2.87MB内容源自《中国医学装备》2020年3月第17卷第3期包含摘要、关键词、研究背景、实验方法、结果与结论等完整论文结构。研究采用迁移学习对VGG19、Inception V3和DenseNet 161三种卷积神经网络模型进行训练与调整并围绕准确率、参数数量、训练效率、显存占用等指标比较分类效果实验显示DenseNet 161准确率达92.91%收敛速度更快、泛化性能更好同时文中对CNN基本结构、深度学习机制、数据建模流程以及甲状腺结节超声图像分类任务作了系统梳理。对希望了解深度学习如何辅助甲状腺结节诊断、选择合适模型以及开展医学图像建模的读者这份文献能提供完整的实验思路与结果参考。已有229人学习适合相关方向研究者、临床超声工作者和机器学习学习者阅读。1. 甲状腺结节超声良恶性分类一张灰度图背后的 CNN 工程彩超机器上一个结节是良性还是恶性结论往往取决于操作医生的手——探头的切面、仪器的增益、经验里的宽度。同一个结节不同医院可能给出 3 类也可能给出 4a 类而这个分级直接决定患者是“半年随访”还是“当天穿刺”。问题在于超声图像灰度动态范围窄、斑点噪声重、组织纹理辨识度低靠人工经验总结规则很难稳定复现。卷积神经网络恰好擅长从原始像素里自动学习纹理与边界特征把“特征设计”从人转移到标注数据上剩下的事是围绕这个模型搭出一条能跑通、能复现、能解释的工程链路DICOM 数据怎么预处理、小模型怎么当基线、类不均衡怎么处理、用什么指标评价、最后怎么用 Grad-CAM 把黑盒结论拉回临床视角。这些内容适合正在做医学影像 AI 落地、被超声数据质量和标签噪声折磨的工程技术人员。2. 输入处理与结构选型把超声图像做成 CNN 能学懂的灰度张量2.1 DICOM 转灰度的预处理ROI 截取、窗宽窗位与归一化甲状腺超声最常见的数据载体是 DICOM 单帧图像但直接拿整张图灌进网络多半会翻车。超声 DICOM 的像素数组之外往往还叠着测量标尺、探头频率、医院水印甚至心电图曲线这些和结节本身毫无关系的环境特征会成为模型捡捷径的“伪标签”。训练时如果这些标记和标注类别存在偶然相关验证集指标会虚高换一家医院的数据立刻崩。我一般会先做三件事ROI 截取、窗宽窗位映射、尺寸归一化。ROI 以医生标注的结节中心为基准向外扩 1.2 倍边界后裁剪如果标注只有框没有中心就取框中心点。没有标注框时优先取整幅图的中心区域因为扫查时操作者通常会把结节放在视野中央。下面的代码把一个 DICOM 文件转成可直接输入 CNN 的单通道灰度张量import pydicom import numpy as np import cv2 def load_ultrasound_gray(dicom_path, target_size(224, 224)): ds pydicom.dcmread(dicom_path) img ds.pixel_array.astype(np.float32) # 超声 DICOM 一般带 Window Center / Window Width # 直接线性映射比简单 min-max 更贴近医生看到的灰度范围 if hasattr(ds, WindowCenter) and hasattr(ds, WindowWidth): wc float(ds.WindowCenter[0] if isinstance(ds.WindowCenter, pydicom.multival.MultiValue) else ds.WindowCenter) ww float(ds.WindowWidth[0] if isinstance(ds.WindowWidth, pydicom.multival.MultiValue) else ds.WindowWidth) lo wc - ww / 2.0 hi wc ww / 2.0 img np.clip((img - lo) / (hi - lo 1e-6), 0, 1) else: img (img - img.min()) / (img.max() - img.min() 1e-6) # INTER_AREA 在下采样时抗混叠避免高频噪声变成虚假纹理 img cv2.resize(img, target_size, interpolationcv2.INTER_AREA) # 输出 [1, H, W]对应 PyTorch 的 NCHW 格式里 C1 return np.expand_dims(img, axis0).astype(np.float32)这段代码的关键参数在两个地方一是WindowCenter/WindowWidth不同超声设备的 VOI LUT 值差异很大遇到缺失标签的情况就退回 min-max 归一化二是target_size的选择甲状腺结节原图分辨率通常在 800×600 左右直接压到 224 会丢失微钙化等细小特征优先保留裁剪 ROI 的原始比例不要做破坏长宽比的拉伸。部分设备导出的不是 DICOM 而是 PNG/JPG则直接按灰度图读取跳过窗宽窗位逻辑。2.2 卷积层从超声纹理里学什么局部连接、权值共享与感受野CNN 卷积神经网络处理超声图像的核心逻辑并不复杂卷积核在整张图上滑动每次只对局部邻域做加权求和这就是“局部连接”同一个卷积核在所有位置复用同一组权重这就是“权值共享”。李宏毅在 CNN 课程里用一张经典的分层结构图把“卷积池化全连接”讲得很直观从图像到分类向量逐层压缩这套视角放在超声图上同样成立。甲状腺恶性结节在 B 超图像上的典型表型是低回声、形态不规则、边界模糊、纵横比大于 1、内部可见微钙化。这些特征换算成图像信号分别是局部灰度低于周围、边缘梯度方向混乱、结节与正常组织之间灰度过渡平缓、以及 12 像素尺度的点状强回声。第一层卷积核如果设成 7×7 或 11×11感受野太大微钙化和细小纹理直接被模糊掉用 3×3 或 5×5 的核反而能让第一层就盯住细颗粒纹理。池化层做下采样换来的是特征对结节位置轻微偏移不敏感代价是空间分辨率下降。这正是为什么后面需要额外的上采样层做可视化解释而不是直接看最后一层特征。2.3 LeNet-5 作为基线结构小网络在小样本上的优势医学影像数据集普遍不大甲状腺超声公开数据集通常只有几千张单中心自采数据能上万的已经是少数。在这种数据规模下直接上 ResNet-50 甚至 EfficientNet 只会更快过拟合验证集 loss 震荡得像心电图的 P 波。常见做法是先跑一个结构借鉴 LeNet-5 的小网络作为基线确认 pipeline 通不通、指标有多高再决定是否引入迁移学习。LeNet-5 的原始输入是 32×32直接套用到 224×224 会导致第一个全连接层参数量爆炸。保留它的“卷积-池化-卷积-池化-卷积-全连接”骨架在最后一个卷积层后加自适应池化就能让全连接层与输入尺寸解耦。一个小型基线结构的关键参数如下层操作输出尺寸作用输入灰度图224×224×1单通道灰度无颜色信息C1Conv 3×3×6, pad1224×224×6捕捉细纹理与灰度对比P1MaxPool 2×2112×112×6降低分辨率增强位置鲁棒性C2Conv 3×3×16, pad1112×112×16组合低级纹理为局部模式P2MaxPool 2×256×56×16继续压缩空间尺寸C3Conv 3×3×32, pad156×56×32捕捉更大范围的形态特征GAPAdaptiveAvgPool4×4×32固定输出尺寸衔接全连接FCLinear ReLU Dropout64特征聚合与非线性映射OutLinear2二分类 logits这个结构的参数量只有几十万单卡 CPU 也能在十几分钟内跑完一轮训练。BatchNorm 放在每次卷积之后在小批量样本上能明显缓解初始化敏感的问题Dropout 加在全连接之前专门抑制小数据下的全连接层过拟合。2.4 为什么单帧 B 超图不直接上 3D 卷积神经网络很多刚接触超声 AI 的同事会问既然有 3D 卷积神经网络为什么不直接上回答这个问题要先搞清楚 3D 卷积多出来的维度用来干什么。3D CNN 的卷积核是三维的第三个维度通常代表时间视频帧或空间深度CT 层片它学的是“相邻帧/相邻层之间的连续变化”。单张静态 B 超图只有一个空间平面没有前后帧信息把它强行 reshape 成三维张量第三维只是重复的灰度3D 卷积除了增加参数量和计算开销提供不了任何额外判别信息。真正需要 3D CNN 的甲状腺场景是剪切波弹性成像的动态序列、超声造影的时间强度曲线、或者三维容积探头的连续帧数据。这些数据里帧间的运动或血流灌注时序本身就携带良恶性信息例如恶性结节在弹性成像中表现为“硬环征”这种动态特征只有跨帧建模才能捕获。静态图分类任务里把卷积核选型和输入尺寸讨论清楚比追逐三维模型更实际。3. 用 PyTorch 复现良恶性二分类的最小完整工程3.1 按患者划分数据集先堵住数据泄露这个大坑分类任务开始之前数据划分方式比模型结构更容易毁掉整个实验。超声检查的操作习惯是一个病人在一次检查里医生会从不同切面扫出少则三四张、多则十几张结节图这些图像共享同一个病理金标准。如果按图像随机划分训练集和测试集同一个结节的多张图同时出现在两侧模型相当于拿着“近亲”参加考试验证 AUC 可以虚高到 0.98一旦换一批新病人性能立刻掉回 0.8 附近。正确做法是按患者或按结节实例划分同一个病人的全部图像必须落在同一个集合里。scikit-learn 的GroupShuffleSplit或GroupKFold可以直接传入 patient_id 作为 group。划分比例按数据量调整我一般用训练 70%、验证 15%、测试 15%数据量低于 1000 张时改五折交叉验证报告各折均值和标准差。划分时还要保证测试集里不出现训练集病人的任何图像。数据目录建议按以下结构组织后续写 DataLoader 不用改路径逻辑data/ train/ benign/ # 良性结节图像 malignant/ # 恶性结节图像 valid/ benign/ malignant/ test/ benign/ malignant/注意这里文件名只代表类别不用在文件名里编码患者 ID。患者分组信息单独维护在一个 CSV 里训练时按分组逻辑读取而不是依赖文件目录。3.2 模型定义与训练循环核心代码和参数含义模型定义沿用 2.3 的结构用 PyTorch 写出来长这样import torch import torch.nn as nn class ThyroidCNN(nn.Module): def __init__(self, num_classes2, dropout0.3): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 6, kernel_size3, padding1), nn.BatchNorm2d(6), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(6, 16, kernel_size3, padding1), nn.BatchNorm2d(16), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d((4, 4)), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(32 * 4 * 4, 64), nn.ReLU(inplaceTrue), nn.Dropout(dropout), nn.Linear(64, num_classes), ) def forward(self, x): return self.classifier(self.features(x))几个参数值得说明。第一个卷积层输入通道是 1 而不是 3因为超声图没有颜色信息用三通道只会让模型多学三份相同的特征增加冗余计算。padding1配合kernel_size3保持特征图尺寸不缩水这样池化前的特征图能保留更多边缘信息。AdaptiveAvgPool2d((4, 4))是这套结构里最关键的一层它把最后一个卷积层的输出统一池化成 4×4之后全连接层的输入维度固定为 32×4×4不管输入图像是 128 还是 512网络都能跑省去了手动计算 flatten 后维度的麻烦。训练循环可以压缩成下面这个核心片段def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) preds logits.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total训练超参按经验可以这样设初始学习率 1e-3优化器用 Adamweight_decay1e-4batch_size在 1632 之间显存不够就降到 8但不要低于 4否则 BatchNorm 的统计量抖动太厉害训练 3050 个 epoch验证集 loss 连续 10 个 epoch 不下降就早停保存验证集指标最优的 checkpoint而不是最后一个 epoch 的权重。3.3 评估指标敏感度、特异度、AUC 比准确率更重要医学二分类任务里准确率是最容易误导人的指标。假如测试集良性 800 张、恶性 200 张模型把所有图都判成良性准确率仍有 80%但这个模型在临床上毫无意义。评估时至少要看四类指标敏感度sensitivity恶性召回率、特异度specificity良性识别率、AUC 和混淆矩阵。from sklearn.metrics import confusion_matrix, roc_auc_score def evaluate(model, test_loader, device): model.eval() y_true, y_prob [], [] with torch.no_grad(): for images, labels in test_loader: images images.to(device) prob torch.softmax(model(images), dim1)[:, 1] y_prob.append(prob.cpu()) y_true.append(labels.cpu()) y_true torch.cat(y_true).numpy() y_prob torch.cat(y_prob).numpy() tn, fp, fn, tp confusion_matrix(y_true, y_prob 0.5).ravel() sensitivity tp / (tp fn) specificity tn / (tn fp) auc roc_auc_score(y_true, y_prob) return {sensitivity: sensitivity, specificity: specificity, auc: auc}注意这里torch.softmax的第二个参数是类别维[:, 1]取恶性类别的概率作为排序分数。用 0.5 作为默认阈值只是为了快速看混淆矩阵实际临床阈值要在验证集上用约登指数Youdens J重新计算这一点在第 5 章展开。敏感度在甲状腺结节场景里优先于所有其他指标——假阴性意味着把恶性结节放回“随访”队列代价远远高于把良性结节误判为可疑以后做穿刺AUC 因为与阈值无关适合在调参阶段比较不同模型的排序能力但不能直接代表某一固定阈值下的临床表现。4. 小样本与类别不平衡下的训练策略降低漏诊比过拟合更迫切4.1 类不平衡加权交叉熵代替默认损失甲状腺结节公开数据里良性样本通常比恶性多出一倍以上医院自采数据中这个比例可能到 3:1 甚至 5:1。不做处理的模型会在训练中偏向多数类表现就是特异度尚可、敏感度偏低。直接在损失函数上加权是最直接的手段import torch import torch.nn as nn def build_weighted_criterion(n_benign, n_malignant, device): counts torch.tensor([n_benign, n_malignant], dtypetorch.float) # 反比加权样本数少的类别获得更大的权重 class_weights counts.max() / counts return nn.CrossEntropyLoss(weightclass_weights.to(device))以良性 800、恶性 200 为例权重分别是 1.0 和 4.0意味着每把一个恶性样本分错损失相当于分错 4 个良性样本。这个加权只发生在训练阶段验证和测试时不需要也不应该改阈值。如果加权之后敏感度上去了但特异度掉得厉害说明权重给得过大可以把恶性权重从 4 降到 2.5 再试。另一种处理方式是 Focal Loss它在加权基础上进一步压低易分类样本的梯度贡献适合难例集中在少数边界样本上的场景但需要多调一个 gamma 超参小数据上收益不一定比加权交叉熵明显。4.2 数据增强哪些操作适合甲状腺超声哪些要避开数据增强是在标注样本有限的情况下扩大有效数据分布的手段但医学图像增强不是随便叠几个 torchvision 变换就行。甲状腺超声的纹理和灰度有明确的物理含义增强方案要围绕“探头角度变化、设备参数差异、患者体型差异”来设计而不是为了凑多样性。增强操作推荐参数设计理由水平翻转p0.5左右叶对称良恶性特征不因方位改变小角度旋转±10°, fillnearest模拟探头角度变化过大旋转会扭曲形态亮度对比度扰动brightness 0.9~1.1, contrast 0.9~1.1模拟不同设备增益差异高斯噪声σ0.005~0.01模拟超声斑点噪声波动随机裁剪缩放scale 0.85~1.0, 保持长宽比模拟结节在视野中的大小变化有两个操作要尤其谨慎。随机遮挡RandomErasing能提升自然图像分类但它可能恰好盖住微钙化灶或结节边缘——这些恰恰是恶性判断的关键特征同理大幅度的仿射变换会破坏“纵横比 1”这个重要诊断指标。另外增强只在训练集启用验证集和测试集统一走 resize 归一化否则验证集的指标会被人为扰乱无法反映模型在真实采图流程下的表现。4.3 迁移学习与超参数两个容易翻车的细节当数据量不足以从头训练大网络时常见做法是用 ImageNet 预训练权重做迁移学习。但超声图像是单通道灰度而 ImageNet 预训练模型的第一层输入是三通道 RGB。处理方式有两种一是把灰度图复制三个通道输入二是把预训练第一层卷积的权重对三个通道取平均。前者实现简单是大多数项目的默认方案images images.repeat(1, 3, 1, 1) # 把 [B,1,H,W] 复制成 [B,3,H,W]这里有个容易忽略的细节repeat之后三个通道完全相同第一层卷积对三个通道的梯度也会完全一致相当于权重的更新方向被三倍放大。因此迁移学习的初始学习率要下调到 1e-4 量级让 pre-trained 权重只做微调不做大幅更新。更稳妥的做法是冻结前两层只训练后面 stage 和分类头等损失曲线进入平台期再解冻所有层做细粒度调优。超参数调优时学习率调度器和早停配合比盲目堆 epoch 更有效。ReduceLROnPlateau配合patience5当验证集 loss 连续 5 个 epoch 不下降时学习率减半早停的 patience 设 10。batch size 在这个数据量级上不用追求大16 和 32 对最终指标影响不大但会影响 BatchNorm 的稳定性建议固定一个值后优先调学习率和 dropout。5. 把概率翻译回临床语言TI-RADS 对比与 Grad-CAM 验证5.1 用验证集重算阈值再映射到 TI-RADS 分级模型的输出是 0 到 1 之间的恶性概率直接拿 0.5 当分界线在临床上站不住脚。我一般会在验证集上计算约登指数 J sensitivity specificity - 1取 J 最大值对应的概率作为最优阈值然后把这个阈值映射到 TI-RADS 分级。以一组典型结果为例恶性概率区间建议对应分级临床建议 0.15TI-RADS 2~3常规随访0.15~0.35TI-RADS 3~4a短期随访或复查0.35~0.65TI-RADS 4a~4b考虑细针穿刺 0.65TI-RADS 4b~5优先穿刺或手术阈值映射表必须用验证集而非测试集来确定否则等于用测试集调参指标会乐观偏倚。落地时把模型输出的概率和历史医生的 TI-RADS 报告做配对比较重点关注模型误判的样本是否恰好是医生之间分歧最大的边界病例——如果模型在医生也犹豫的结节上犯错说明它学的是图像特征而不是标签噪声如果模型自信地错在医生都一致认为典型的病例上就要回头查数据标注质量。5.2 用 Grad-CAM 确认模型注意力落在结节上模型指标再漂亮也需要回答“它到底看了哪里”。一个偷懒的模型可能靠识别图像角落的设备厂商 logo 来分类这种情况下指标越好越危险。Grad-CAM 通过最后一个卷积层的梯度加权激活图给出模型决策时关注的区域实现并不复杂def grad_cam(model, input_tensor, target_layer, target_class1): activations {} gradients {} def forward_hook(module, input, output): activations[value] output def backward_hook(module, grad_input, grad_output): gradients[value] grad_output[0] handle_f target_layer.register_forward_hook(forward_hook) handle_b target_layer.register_full_backward_hook(backward_hook) logits model(input_tensor.unsqueeze(0)) model.zero_grad() logits[0, target_class].backward() act activations[value].squeeze(0) # [C, H, W] grad gradients[value].squeeze(0) # [C, H, W] weights grad.mean(dim(1, 2), keepdimTrue) # 全局平均梯度 cam (weights * act).sum(dim0) # 加权求和 cam torch.relu(cam) # 只保留正向贡献 handle_f.remove() handle_b.remove() return cam代码里的target_layer选择最后一个卷积层在这个模型里对应model.features[-4]三个 Conv-BN-ReLU 块中最后一个卷积层。register_full_backward_hook取的是梯度输出PyTorch 较新版本里register_backward_hook已被废弃直接使用register_full_backward_hook更稳妥。生成的 cam 尺寸在 14×14 左右需要插值回原图尺寸并与原图做热力图叠加。使用 Grad-CAM 时注意两点输入图像的归一化参数必须和训练时完全一致否则热力位置会出现偏移target_class置为 1看的是模型认为“恶性”的证据分布。合理的可视化结果是热力集中在结节边缘、内部低回声区和钙化点周围如果热力落在图像角落、水印或探头标记上说明数据预处理阶段的 ROI 截取没有做干净模型在走捷径。这种可视化检查应该作为模型上线前的固定步骤比多看几条训练日志更能暴露问题。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →