简介:这份资源是一篇发表于《河北省科学院学报》2020年第3期的中文综述文章,系统梳理了基于深度学习的医疗影像识别技术,适合医疗AI研究人员、深度学习初学者及智能医学诊断方向的学生阅读。全文从二维与高维医疗影像两个维度回顾技术发展历程,重点讲解卷积神经网络、全卷积神经网络、深度对抗网络、RGCNN及三维卷积神经网络等典型模型,并结合影像增强、检测、分割与识别四类应用场景给出案例和准确率数据。资源为PDF格式,共1个文件,压缩包大小约1.21MB,内容精炼且便于检索,目前已有213人学习。文中还分析了医疗数据标注困难、模型解释性不足、数据不平衡等挑战,并对多模态融合、可解释性提升和隐私保护等未来方向做出展望,可帮助读者快速建立该领域从模型原理到落地问题的整体认知。
1. 这份综述在讲什么:把「识别」拆开才有阅读价值
有次一位做影像组学的同事发来一份 PDF,标题写着《基于深度学习的医疗影像识别技术研究综述.pdf》。这类综述通常从 CNN 胜出讲起,把分类、分割、检测模型依次扫一遍,读完感觉理论很完整,动手却不知道先跑通哪一步。我习惯把标题里的“识别”拆成三个递进的问题:病灶有没有、病灶在哪里、病灶是哪一类。拆完之后,综述的结构变成三个工具箱,每个都能给出可操作的最小实验路径。这篇博文就按这个顺序展开,从任务界定、模型选型、训练调参到评估验收与落地边界。适合有 Python 和 PyTorch 基础、打算复现影像识别实验的研究生或算法工程师。
2. 医疗影像识别不是单一任务:分类、分割与检测的选型逻辑
医疗影像与自然影像最大的差异,是病灶往往只占整幅图像的很小比例,而且 CT 的密度值、MR 的相对信号、病理切片的超大分辨率各自有完全不同的数据分布。所以综述里如果只写一句「深度学习在医疗影像识别中表现优越」,那等于什么都没说。动手之前,先把任务类型定死,再谈网络结构和损失函数。
2.1 三类任务决定三类评测口径
分类任务关心「有没有」或「到什么程度」,输出是一个概率向量。最常见做法是拿 2D 切片输入 ResNet/EfficientNet 系列的卷积网络,用交叉熵训练;处理 CT 序列时,则把相邻切片叠成 3D 体积后交给 3D-CNN。分割任务把每一个像素标成背景或结构,输出和输入同尺寸的 mask,U-Net 家族的编码器-解码器结构因此成为绝对主线。检测任务要在全景影像里同时回答「病灶在哪」和「病灶多大」,RetinaNet、Faster R-CNN 的变体在肺结节和骨折检测里更常见。
这三类任务不能互相替代。分类网络能给出「有结节」的结论,但回答不了边界和形态;分割网络在没有候选区域时,也不知道该把注意力放在哪;检测网络虽然找到 ROI,却常常不提供精细边缘。因此我拿到一份医疗影像识别综述时,第一件事不是看模型,而是看实验里到底在哪个任务上做了评估。任务一变,损失函数、数据划分方式和指标口径都要整套换掉。
2.2 CNN 为什么仍是主干,ViT 与 EfficientNetV2 只能补位
不少综述会把 Vision Transformer 列成重要分支,但医疗影像能拿到的标注样本通常只有几千到几万例,远低于 ViT 需要的大规模预训练数据量。CNN 的局部连接和平移等变性在这种小数据场景下更可靠,用 2D U-Net 直接训练 CT 切片,经常能打过用 ImageNet 预训练的 ViT。ViT 更适合「大批量 X 光片 + 大规模公开数据」的场景,比如胸部平片的多标签分类;EfficientNetV2 则适合作为分割模型的编码器后端,用复合缩放平衡分辨率、深度和宽度。
选择模型时不要看谁的名字新,而要看预训练权重能否覆盖目标模态:X 光、CT、MRI 的图像特征差异很大,用自然图像权重冷启动通常需要更长的微调。我一般固定两个候选网络起步,一个是轻量 ResNet-18/34 做二分类可行性验证,一个是带预训练编码器的 U-Net 做分割主实验。流程跑通之后再换高精度模型,避免一开始就被显存和调参拖住。
2.3 数据链路是绕不过去的第一个坑:DICOM 如何变成可训练矩阵
2.3.1 最小 DICOM 序列加载脚本:按物理位置排序,而不是按文件名
from pathlib import Path import numpy as np import pydicom def load_dicom_series(dicom_dir: Path) -> np.ndarray: files = [ f for f in Path(dicom_dir).iterdir() if f.suffix.lower() in {".dcm", ".ima", ""} and f.is_file() ] slices = [] for f in files: dcm = pydicom.dcmread(str(f)) if not hasattr(dcm, "SliceLocation"): continue # CT 原始像素值需要按斜率与截距转成 HU(亨氏单位) slope = float(getattr(dcm, "RescaleSlope", 1.0)) intercept = float(getattr(dcm, "RescaleIntercept", 0.0)) arr = dcm.pixel_array.astype(np.float32) * slope + intercept slices.append((float(dcm.SliceLocation), arr)) slices.sort(key=lambda item: item[0]) if not slices: raise ValueError(f"{dicom_dir} 下没有可读 DICOM 序列") return np.stack([item[1] for item in slices])这段代码先收集目录下所有候选文件,逐个读取后跳过缺少SliceLocation的定位像;RescaleSlope和RescaleIntercept是把 CT 存储值映射到真实密度单位的两个 tag,缺失时用 1 和 0 兜底,可以兼容超声等无标定模态。最后按物理位置排序并堆叠成(D, H, W)的 3D 数组,这个数组才是后续裁剪 patch、做增强和输入模型的最小单元。
需要注意,一个目录里可能混有多个扫描序列,仅按文件名或SliceLocation排序会串层。正确做法是先按SeriesInstanceUID分组,再对组内按SliceLocation排序。
提示:如果同一病例有平扫和增强两套序列,务必先分开加载,否则训练时会混入两种完全不连续的密度分布。
3. 深度学习训练与调参:把医疗影像识别跑成可复现实验
医疗影像实验最值的投入不在网络结构,而在超参和损失函数。常见错误是只记最终准确率,不记归一化窗口、patch 尺寸和早停策略,结果换一台机器或换一个数据集,分数立刻漂移。下面按超参模板、分割损失、训练循环三步展开。
3.1 超参模板:先有一张能复现的表
| 参数 | 推荐起点 | 调整信号 |
|---|---|---|
| 输入 patch | 2D 用 256×256,3D 用 128×128×32 | 显存不足先降 batch,再考虑降 patch |
| 体素归一化 | HU 裁剪到 [-1000, 400] 后缩放至 [-1,1] | 边缘不清晰时适当放宽窗宽 |
| batch size | 2D 8~16,3D 2~4 | 过拟合时减小,震荡时加大 |
| 优化器 | AdamW(lr=1e-4) 或 SGD(lr=0.01) | 曲线长时间不降时降 lr 十倍 |
| warmup | 5~10 个 epoch | 前段 loss 爆炸时延长 |
| weight decay | 1e-4 | 训练 Dice 与验证 Dice 落差大时调大 |
| 混合精度 | 开启 AMP | 出现 NaN 时关闭再定位问题 |
| 早停 patience | 20 个 epoch | 验证曲线仍上升时改为 30 |
这张表对分类和分割都适用,唯一要替换的是损失函数。分类任务可以继续用交叉熵;分割任务必须在交叉熵之外引入区域层面的约束,否则模型会把大量背景像素学得很好,病灶区域反而被平滑掉。
3.2 Dice Loss 为什么是分割默认损失:代码级拆解
import torch import torch.nn.functional as F def dice_loss(pred, target, smooth=1.0): # pred 是网络未过 sigmoid 的 logits,输出 shape (N, 1, H, W) pred = torch.sigmoid(pred) N = pred.size(0) pred_flat = pred.view(N, -1) target_flat = target.view(N, -1) intersect = (pred_flat * target_flat).sum(dim=1) union = pred_flat.sum(dim=1) + target_flat.sum(dim=1) per_sample_score = (2 * intersect + smooth) / (union + smooth) return 1 - per_sample_score.mean()pred在进入损失函数前不套sigmoid,让数值计算交给更稳定的实现;smooth防止背景完全为 0 时除零。按样本计算 Dice 再取平均,比把所有 batch 元素拼起来算一个整体 Dice 更稳定,否则大尺寸图像会主导梯度方向。当病灶只占图像 1% 时,交叉熵的梯度几乎全部来自背景,Dice Loss 则直接优化前景区域的匹配程度,所以分割任务默认从它起步。实际训练里我更常用0.5 * dice_loss + 0.5 * BCEWithLogits,让像素级语义信息不被完全丢弃。
3.3 训练循环:早停与最佳模型快照怎么配合
best_val = -1.0 patience = 20 wait = 0 for epoch in range(max_epochs): model.train() for x, y in train_loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() out = model(x) loss = dice_loss(out, y) + 0.5 * F.binary_cross_entropy_with_logits(out, y) loss.backward() # 3D U-Net 参数多,梯度裁剪能防偶发 NaN torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() val_score = evaluate(model, val_loader, metric="dice") if val_score > best_val: best_val = val_score torch.save(model.state_dict(), "best.pt") wait = 0 else: wait += 1 if wait >= patience: breakclip_grad_norm_把梯度范数限制在 1.0,避免 CT 图像中极端像素值引发梯度爆炸。验证指标用 Dice 而不是组合 loss,因为早停要盯最终目标,不是盯中间数值。另一个常见问题是保存最后一个 epoch 的权重而不是best.pt,在早停结束后验证集 Dice 通常已回落一到三个点。
4. 复现综述实验的坑:评估口径比模型结构更影响结论
综述里的对比表看着差距很大,但如果不问清数据划分维度,这些数字完全不能直接采信。医疗影像数据天然具有「同一患者多张图像」的结构,评估时最容易被整体准确率误导。
4.1 patient-level split:同一患者的切片不能同时出现在训练和测试
一个胸部 CT 病例往往有上百张切片,相邻层之间高度相似。如果按切片随机划分训练集和测试集,同一患者的不同层会同时出现在两边,相当于让模型见过测试目标的近亲,分割指标虚高是必然的。这也是很多综述里分数很高、换到外部数据立刻掉下来的主要原因。
import random def split_records_by_patient(records, val_fraction=0.15, seed=0): # records 结构: [(patient_id, file_path, label), ...] patients = sorted({r[0] for r in records}) rng = random.Random(seed) rng.shuffle(patients) cut = int(len(patients) * val_fraction) val_ids = set(patients[:cut]) train, val = [], [] for r in records: if r[0] in val_ids: val.append(r) else: train.append(r) return train, valval_fraction按患者数量计算,而不是按切片数量计算。病例有 100 张切片和 20 张切片两种规模时,按病例划分能保证不同长度的样本不会带来分组偏差。固定seed后,每次划分结果一致,报告里可以注明这一行,读者才能复现你的实验。
4.2 指标组合:Dice、Sensitivity 与 Hausdorff 距离分别回答什么问题
| 指标 | 作用 | 容易被误读的场景 |
|---|---|---|
| Dice / IoU | 衡量分割区域重叠程度 | 病灶体积大时,明显偏移仍可能拿到高分 |
| Sensitivity / Recall | 表示漏检比例 | 调高它可能带来大量假阳性,需要配合 Precision 看 |
| 95% Hausdorff Distance | 捕捉分割边缘的最大偏差 | 对孤立噪声点敏感,95% 分位数更稳 |
| AUC | 分类阈值无关的排序能力 | 类别极不均衡时不能替代校准曲线 |
医疗场景最怕漏掉病灶,于是很多人只盯 Sensitivity,结果模型变得「宁可多画也不漏」,Dice 立刻下滑。一个可接受的分割实验通常同时报告 Dice 和 95% Hausdorff,Dice 反映整体重叠,Hausdorff 反映最大边界误差,两个指标组合起来才能描述「这张 mask 到底贴不贴边」。分类任务不要只报 Accuracy,病灶占比 1% 的数据集里,全预测阴性也能有 99% 的准确率。
4.3 多随机种子报告是底线
复现综述实验时,固定 3 到 5 个随机种子训练全套模型,报告均值和标准差,不要只挑最好的一次展示。不同算法之间的分数差距如果小于标准差,基本可以判定两者没有显著差异。我还会把训练集、验证集、测试集的 patient ID 列表存成 JSON,嵌进实验目录,这样后续任何一次代码改动都能追溯到是哪份数据划分产生了当前结果。
5. 进阶应用:用 Grad-CAM 验收模型,再把模型装回真实影像流程
5.1 Grad-CAM 可以当「验尸工具」用
训练完分类模型后,第一件事不是看 AUC,而是看热图是否落在病灶位置。如果模型依赖图像角落的水印、扫描仪伪影或者被固定的边缘区域,那它学到的不是医学特征,只是数据集偏置。
import torch import torch.nn.functional as F def gradcam_2d(model, x, target_layer): act, grad = {}, {} def forward_hook(module, inp, out): act["value"] = out def backward_hook(module, grad_in, grad_out): grad["value"] = grad_out[0] h1 = target_layer.register_forward_hook(forward_hook) h2 = target_layer.register_full_backward_hook(backward_hook) logits = model(x) pred = logits.argmax(dim=1) model.zero_grad() logits[0, pred].backward() h1.remove() h2.remove() a = act["value"].detach() g = grad["value"].detach() weights = g.mean(dim=(2, 3), keepdim=True) cam = (weights * a).sum(dim=1, keepdim=True) cam = F.relu(cam) cam = F.interpolate(cam, size=x.shape[-2:], mode="bilinear", align_corners=False) cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) return cam.squeeze().cpu().numpy()target_layer通常选最后一个卷积模块,比如 ResNet 的layer4[-1]。weights是每个通道梯度对空间维度求平均得到的权重,再和正向激活加权求和,得到类别对应的空间位置热度。把热图叠回原图后,如果高亮区域始终偏离肉眼可辨的病灶,就要回去检查数据标注或者预处理流程。对分割模型来说,这个思路同样有效,只是可视化对象从分类激活变成预测 mask 与 ground truth 的差异区域。
5.2 从综述到真实影像流程,工程边界在哪里
综述里网络输出直接展示成彩图,真实环境里还有三件事要做。第一,推理阶段必须使用与训练完全一致的归一化参数,CT 的窗宽窗位一旦变化,模型分数可能断崖式下跌。第二,分割输出要做连通域后处理,把小于设定体素阈值的孤立块过滤掉,避免把噪声当成病灶。第三,部署时用 ONNX 或 TensorRT 导出模型,用 GPU 做推理,同时保留 CPU 兜底路径。还有一点必须遵守:病人影像不能随意拷出院内环境,科研项目应先确认数据使用授权和伦理审批。整个项目推进顺序应当是先在一个二十例的小验证集上做 Grad-CAM 检查,确认模型看过真正病灶,再进入大规模训练与多中心验证。这一步的取舍,往往比再调三个月学习率更接近临床可用。
本文还有配套的精品资源,点击获取