
简介面向医学影像入门与深度学习初学者提供肺结节自动检测与分类的完整 Python 工程覆盖从 DICOM 数据读取、预处理、模型构建到训练与评估的完整流程。压缩包共 439 个文件约 195.91 MB以 397 个 dcm 格式的肺部 CT 影像为核心数据另含 13 个 ipynb 交互式笔记、5 个 csv 标注与元数据文件、XML 配置、py 脚本、hdf5 权重文件及 README 文档等目录结构清晰便于按数据集、模型代码、预处理脚本、训练脚本、评估与预测模块分层查阅。该项目基于 LIDC-IDRI 等常用医学影像数据集代码经过两周调试前三个文件可稳定运行首段代码附有详细注释对新手友好。已有 2415 人学习下载适合希望了解医学影像分析、提升 Python 与 TensorFlow/PyTorch 建模能力的开发者。通过阅读和实操可掌握肺结节分类的完整工程实现包括数据标注读取、神经网络设计、训练调参与评估指标计算等技能。1. 肺结节分类代码从 DICOM 到良恶性概率的一条龙实现做医学图像分类的人大概都体会过真正卡住进度的不是模型结构而是数据从 DICOM 到张量这中间一堆没人替你踩平的细节。这份基于 PyTorch 的肺结节分类算法代码把 DICOM 读取、窗宽窗位处理、ROI 裁剪、ResNet 训练、AUC 评估全部串在 Jupyter Notebook 里输入标注好的 CT 切片改几个路径参数就能得到良恶性二分类的概率输出。它适合两类人一是刚接触医学图像分类、想要一份规范实现当基准线的深度学习新手二是需要快速验证新想法、不想反复重写数据管线的算法工程师。下面按我实际拆项目的顺序把选型逻辑、预处理细节、训练参数和踩过的坑逐一讲清楚。2. 技术选型为什么是 2D CNN而不是 LSTM 或 Transformer2.1 三套候选方案的对比与选型理由肺结节分类本质上是一个图像二分类问题输入是包含结节的 CT 切片或从切片上裁剪下来的 ROI 区域输出是良性/恶性的概率。围绕这个任务业界叫得上名的主流路线有三条代码对应的选型决策也主要发生在这三者之间。第一条是传统手工特征加经典分类器。先提 LBP、HOG、灰度共生矩阵这类特征算子把 ROI 转成特征向量再丢给 SVM 或随机森林。这套路线在小样本场景下很稳几十张图也能训练解释性也强——特征维度是肉眼可见的。但问题在于它把「特征设计」这项工作完全推给人工遇到不同扫描设备、不同重建算法的数据特征分布漂移明显往往换一批数据就要重新调特征参数。对现在的结节分类公开数据集来说这条路的上限已经明显低于深度模型。第二条是 LSTM 类序列模型。有同学试过把同一结节的多层切片按 z 轴顺序当成时间序列送进 LSTM希望让模型「看到」结节的立体变化。听起来合理但实际运行中会遇到两个直接问题一是公开数据集里每个结节往往只有标注中心周围少数几层有效切片序列长度短LSTM 学不到有意义的时序模式二是 CT 层间距在不同扫描协议下不一致序列的「时间步」本身物理含义就不可对齐。我在实操中很少看别人用 LSTM 做这种静态图像的分类任务它更适合真正的时序信号放在这里属于结构错配。第三条是 Transformer / ViT 路线。ViT 在自然图像大榜单上表现亮眼但它的收敛前提是海量预训练数据医学影像这种几百到几千张的私有数据集很难喂饱 self-attention常见结果是训练 loss 降得慢val 指标震荡得像心电图。除非有 ImageNet-22K 甚至更大规模医学预训练权重打底否则我不建议在常规硬件上第一版就跑 ViT。这份代码最终选择了 2D CNN 作为骨干在我看来是最务实的决策结节 ROI 尺寸小通常 64×64 到 128×128局部纹理、边缘、钙化形态这些判别信息用卷积核堆叠就能有效提取数据量要求远低于 TransformerImageNet 预训练权重可以直接迁移做微调。一句话概括选型逻辑任务规模和数据规模共同决定模型复杂度2D CNN 是中规模医学图像分类的性价比上限。2.2 网络结构ResNet 骨干加分类头损失函数怎么选代码里的默认骨干是 ResNet18结构上做了一处关键修改把原始的最后全连接层从 1000 维换成 2 维输出对应良性/恶性两个类别。选择 ResNet18 而不是 ResNet50是出于训练成本和过拟合两方面的考虑——肺结节 ROI 小50 层的参数冗余在这种小图上容易出现过拟合而 18 层在显存占用和效果之间更平衡。如果数据量明显偏大超过一万张 ROI再把层数往上加也不迟。import torch.nn as nn from torchvision import models class NoduleClassifier(nn.Module): def __init__(self, num_classes2, in_channels3, pretrainedTrue): super().__init__() self.backbone models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None) # 第一层卷积默认接受 3 通道输入灰度图复制成 3 通道即可复用 ImageNet 权重 self.backbone.fc nn.Linear(self.backbone.fc.in_features, num_classes) def forward(self, x): return self.backbone(x)这段代码里有两个值得留意的参数。in_channels默认是 3对应灰度 CT 切片沿通道维复制三份的常见做法如果想省一点内存也可以把conv1替换成nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse)并加载权重时去掉对应通道但实操里收益不大我一般保持 3 通道。pretrainedTrue表示加载 ImageNet 预训练权重这是迁移学习的标准起点后面会提到它对医学影像的适用边界。损失函数侧代码提供两个选项nn.BCEWithLogitsLoss()和带类别权重的加权版本。前者把最后一层输出直接接 Sigmoid 交叉熵数值稳定适合类别相对均衡的数据。如果数据里恶性样本明显少于良性加权版本的损失函数会给少数类更大的梯度缓解过拟合。我在复现这份代码时默认先用加权版权重按训练集正负样本数量比的倒数设置具体数值在第 4 章训练参数里给出。2.3 代码文件结构一个 Notebook 怎么组织训练闭环拿到项目压缩包后不要急着跑先看文件组织。这套代码按工作流拆成了三个 Jupyter Notebook每个承担独立职责目录如下. ├── 01_preprocess.ipynb # DICOM 读取、ROI 裁剪、数据集划分 ├── 02_train.ipynb # 模型构建、训练循环、指标评估 ├── 03_inference.ipynb # 单张 CT 推理、概率输出、结果保存 ├── data/ │ ├── annotations.csv # 结节标注文件名、坐标、良恶性标签 │ └── ct_slices/ # DICOM 或转好的 PNG 切片 └── models/ └── checkpoint/ # 训练好的权重保存目录01_preprocess.ipynb负责把所有原始数据整理成模型能吃的样子输出归一化后的数组和划分好的文件清单02_train.ipynb是核心包含训练循环、验证逻辑和指标落盘03_inference.ipynb接收单张切片输出预测概率方便做快速验证。每个 cell 上方都有中文注释说明该步做了什么——这套代码在很大程度上也充当了一份示例代码讲解适合照着逐行理解医学图像分类的标准流程。这样的结构安排有一个实际好处预处理和训练解耦数据管线一旦跑通后续调模型结构时不需要重新碰数据反过来清洗数据时也不会污染训练代码。对刚入门的人来说三个 Notebook 之间的衔接就是完整项目的最小闭环比一坨单文件脚本好读得多。3. 数据预处理窗宽窗位、ROI 裁剪与按患者 ID 划分数据3.1 DICOM 读取与 HU 值映射CT 原始文件通常是 DICOM 格式第一步要做的是把像素值还原成真正有物理意义的 CT 值Hounsfield UnitHU。pydicom库是这里的事实标准读取代码的骨架如下import pydicom import numpy as np def load_dicom_hu(path): ds pydicom.dcmread(path) # 原始像素值需要乘 RescaleSlope 再加 RescaleIntercept才能得到 HU hu ds.pixel_array.astype(np.float32) if hasattr(ds, RescaleSlope) and hasattr(ds, RescaleIntercept): hu hu * float(ds.RescaleSlope) float(ds.RescaleIntercept) return hu这里最容易被忽略的是astype(np.float32)这一步。DICOM 原始像素经常是 uint16 或 int16如果不转换成浮点RescaleSlope通常阈值接近 1和RescaleIntercept常见为 -1024的线性变换会被整数运算截断导致 HU 值偏差。还有一个隐藏细节部分扫描协议里RescaleSlope为负值此时必须保留浮点精度否则整张图会出负到离谱的数值喂给模型后训练必崩。拿到 HU 数组后下一步是选择你需要的 2D 切片。多层 CT 读出来通常是三维数组这份代码默认取标注中心所在的轴向切片如果你手里的数据是单层扫描就跳过这一步。无论哪种情况统一输出的都是单张 2D HU 矩阵后续所有操作都基于这个矩阵进行。3.2 窗宽窗位与归一化管线这是整个预处理里最关键的一步也是我看到最多人翻车的地方。原始 HU 值范围大约是 -1024 到 3071直接做 Min-Max 归一化送入网络是错误做法——软组织、骨骼、空气的分布跨度太大模型很难从线性压缩后的灰度里分辨结节的纹理。正确的做法是先把 HU 截断到肺窗lung window的显示范围再做归一化def apply_lung_window(hu, window_width1500, window_level-600): # 肺窗常用窗宽 1500、窗位 -600对应显示范围 [-1350, 150] lower window_level - window_width / 2 upper window_level window_width / 2 clipped np.clip(hu, lower, upper) # 把窗口内数值映射到 [0, 1]窗口外全部变成 0 或 1 normalized (clipped - lower) / (upper - lower) return normalized.astype(np.float32)window_width1500和window_level-600是肺结节的常用参数医学上这个组合能把空气-1000 HU、肺实质-500 HU 左右和软组织0-100 HU的对比度拉满结节边缘的毛刺、分叶特征在这种灰度映射下最为清晰。如果做的是纵隔窗或骨窗任务参数要对应调整为 400/40 和 2000/500 这一档不要一套窗参数走天下。归一化之后按训练集统计量做标准化也是常见操作但这份代码在归一化后直接进入增强管线没有再次做 z-score原因是窗口截断后的数值分布相对稳定再标准化反而可能放大少量超窗像素的影响。增强部分默认使用随机水平翻转、随机旋转 10 度和轻微亮度扰动增强需要在训练过程中随机触发验证集要关掉增强操作避免验证指标失真。ROI 裁剪的坐标来自annotations.csv里的标注框中心点 x、y 坐标或边界框。从完整切片裁出固定尺寸 patch 时要注意坐标轴对应的方向pixel_array的行索引是 y 轴、列索引是 x 轴裁切时先按 y 后按 x顺序反了会出现结节约 1/3 落在补丁外的诡异情况。3.3 按患者 ID 划分数据集防止切片级泄漏这个坑值得单独拿出来写。很多初学者划分数据时直接随机打乱所有切片把同一患者的多张切片同时分进训练集和验证集。由于同一患者不同切片的外观高度相似验证指标会虚高 5-10 个百分点换到真实新患者的 CT 上马上打回原形。正确的做法是把「患者 ID」作为分组依据严格保证一个人的所有切片只出现在一个集合里from sklearn.model_selection import GroupShuffleSplit annotations pd.read_csv(data/annotations.csv) gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(annotations, groupsannotations[patient_id])) train_set annotations.iloc[train_idx] val_set annotations.iloc[val_idx] print(f训练集患者数: {train_set[patient_id].nunique()}, 切片数: {len(train_set)}) print(f验证集患者数: {val_set[patient_id].nunique()}, 切片数: {len(val_set)})这里GroupShuffleSplit的关键参数是groupsannotations[patient_id]它告诉切分器哪些样本属于同一个组同一组的样本不会被拆开。test_size0.2控制验证集比例random_state42固定随机种子保证每次复现得到相同划分。如果你手里的数据没有patient_id字段也要至少有case_id或series_id这类能唯一标识来源实体的字段否则泄漏问题无解。划分完成后建议把划分结果落盘成文件例如train_samples.csv和val_samples.csv。这样做的好处是后续调模型参数时验证集始终保持一致指标可对比如果每次运行都重新划分调参时观察到的指标波动里会混入数据划分的差异你很难判断是模型变好了还是运气变好了。4. 训练与调参损失函数、学习率策略和评估指标怎么配4.1 训练超参数表与默认值复现这份代码时02_train.ipynb开头集中定义了一组超参数我在实际跑通后保留了其中大部分默认值只针对显存做了局部调整。参数表整理如下参数默认值说明img_size128ROI 裁剪尺寸输入 ResNet 前统一缩放batch_size32单卡 16-32 都合理显存不足优先减这个lr1e-4AdamW 初始学习率医学小数据集不宜过大weight_decay1e-4L2 正则强度防过拟合的主要手段epochs30初始训练轮次配合早停会自动截断loss加权 BCE正负样本不均衡时打开加权pos_weight1.5恶性类损失权重按负/正样本比实际计算schedulerReduceLROnPlateau验证 AUC 连续 3 轮不涨则 lr 减半early_stop_patience7连续 7 轮无改善则停止训练学习率的选择在医学图像任务上比自然图像保守得多1e-4 是一个起步即稳的数值ResNet 微调时如果把初始 lr 设成 1e-3很可能前两轮 loss 直接上扬因为 medical 数据的梯度分布和 ImageNet 差异明显。pos_weight的值建议按负样本数 / 正样本数计算而不是手动拍脑袋比如训练集里良性和恶性分别是 1500 和 1000那么权重就是 1.5。4.2 核心训练循环代码与逻辑说明训练循环的骨架保持 PyTorch 标准写法但有几个细节是这份代码里值得抄的每轮训练后计算验证集 AUC 作为调度器监控指标模型保存只认验证 AUC而不是 loss。训练 cell 的浓缩版本如下import torch from torch.cuda.amp import autocast, GradScaler from sklearn.metrics import roc_auc_score scaler GradScaler() best_auc 0.0 patience_counter 0 for epoch in range(epochs): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() # 混合精度训练压低显存占用 with autocast(): outputs model(images).squeeze() loss criterion(outputs, labels.float()) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() model.eval() val_preds, val_labels [], [] with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs torch.sigmoid(model(images).squeeze()) val_preds.extend(outputs.cpu().numpy()) val_labels.extend(labels.cpu().numpy()) val_auc roc_auc_score(val_labels, val_preds) if val_auc best_auc: best_auc val_auc torch.save(model.state_dict(), models/checkpoint/best_model.pth) patience_counter 0 else: patience_counter 1 scheduler.step(val_auc) # 按验证 AUC 调整学习率 if patience_counter early_stop_patience: print(fEarly stop at epoch {epoch}, best AUC{best_auc:.4f}) breakautocast和GradScaler是混精度训练的标准组合在只调用scaler.scale(loss)的情况下梯度回传和优化器更新都必须走scaler.step和scaler.update配对调用漏掉update会让缩放系数永不更新早期 loss 正常、两个 epoch 后梯度消失。另一个值得注意的点是torch.sigmoid放在torch.no_grad()内部推理阶段不参与梯度计算避免额外显存消耗。验证集预测标签用extend而不是append收集很多新手在这里写成append(outputs.cpu())最后roc_auc_score收到的是形状为(batch, 1)的列表嵌套直接报维度错误。养成extend一维浮点数组的习惯可以少调半小时错。4.3 评估指标准确率、敏感度、特异度与 AUC 怎么配合看训练结束后02_train.ipynb会打印一段评估结果我倾向于把四个指标全部拉出来一起看而不是只盯准确率。医疗场景下类别不平衡很常见准确率会骗人数据里 80% 是良性模型无脑全判良性也有 80% 准确率但这个模型在临床上毫无价值。指标计算公式医学含义陷阱Accuracy(TPTN) / 总数总体判断正确率类别不平衡时虚高SensitivityTP / (TPFN)恶性肿瘤被查出的比例漏诊代价高时优先看它SpecificityTN / (TNFP)良性结节被正确排除的比例误报率过高会制造焦虑AUCROC 曲线下面积排序能力正值与负值的区分度阈值无关不受类别比例影响实际使用中我推荐以 AUC 为模型选择指标、以敏感度和特异度为业务可用性指标。这份代码的默认配置和上述判断一致早停逻辑监控 AUC但每次保存最优模型后会补充打印在某个固定阈值默认 0.5下的敏感度和特异度。如果敏感度不满意最直接的办法不是换模型而是把阈值降低到 0.3 附近同时观察特异度是否还能接受——这是权衡漏诊和误报的常见手法。5. 避坑与排查数据泄漏、全黑切片与迁移学习失效的记录5.1 数据与预处理层的坑坑一训练图像全黑或全白模型 loss 不下降。现象是第一个 epoch 结束后准确率停留在类别比例附近loss 几乎水平线可视化训练样本时看到整张图是纯黑或纯白。原因一般是 DICOM 读取跳过了RescaleSlope/RescaleIntercept转换直接把原始像素值塞进归一化有的 CT 是 12-bit 存储像素集中在 0-4095Min-Max 后中间组织全部挤成一个小灰度带肉眼看着灰蒙蒙模型也学不到边缘纹理。解决方法是严格按 3.1 的代码把像素转成 HU再按 3.2 的窗口截断后归一化动手后立竿见影。排查方式是在预处理 cell 里plt.imshow(normalized, cmapgray)每次看一眼样本长什么样再决定是否进入训练。坑二验证 AUC 虚高 0.1 以上换新数据断崖下跌。现象是训练到第 10 轮验证 AUC 就到 0.95你自己在测试集上却只能跑到 0.8。原因几乎可以锁定为数据泄漏——划分训练/验证集时没有按患者 ID 分组。同一患者的相邻切片相似度太高模型在验证集上看到的「新样本」跟训练集高度雷同指标自然漂亮。解决方式就是 3.3 里那套GroupShuffleSplit代码把分组逻辑从文件顶部就固定下来并且把划分结果存盘不随 notebook 重启而改变。血泪经验是哪怕拿到了不错的指标也要先做一次「患者级留出」验证指标回落后再重新评估模型你真正关心的是跨患者泛化能力。坑三annotations.csv与图像文件名的对应关系错位。现象是训练时 loss 能降但混淆矩阵里良性错误率奇高检查标签发现同一张图被赋予了不同标注。原因多数出在用sorted(os.listdir())匹配文件这种排序是字典序slice_10会排在slice_2前面索引对不上文件名。解决方法是不要依赖文件名排序而是为每张切片生成唯一 ID比如patient_id_frame_idx用字典显式建立图像路径与标签的映射关系如果数据集标注本身是从其他格式转换来的转换脚本里补一个「标注条数与文件条数一致」的断言提前暴露缺失或重复。5.2 训练与推理层的坑坑四训练 loss 稳定下降AUC 却停在 0.6 上下。这个现象比较迷惑因为 loss 和 AUC 观察的是不同侧面。两类原因最常见一是类别严重不平衡模型把多数类学透、少数类完全放弃BCE loss 被多数类主导看起来在降但少数类的排序能力没有改善二是增强强度过大比如旋转 30 度、亮度抖动 0.4破坏了结节的真实形态特征模型学到的是增强伪影。解决措施分两步损失函数替换为加权 BCE权重按样本比反比计算并把增强强度调回保守区间旋转 10 度以内、亮度扰动 0.05。调整后 AUC 一般会在两三个 epoch 内看到明显爬升如果还是不动建议把学习率降一半重跑一次。坑五ImageNet 预训练权重在 CT 上效果不如随机初始化。这是一个反直觉现象迁移学习在自然图像上是万能药到了医学影像有时会翻车。原因是 CT 灰度分布与 ImageNet 的 RGB 自然图像差异太大预训练卷积核提取的纹理、颜色特征对肺组织形态帮助有限如果代码还默认冻结前几层模型就只能改后面的层拟合能力大打折扣。解决方法是调整冻结策略只冻结conv1和第一层残差块其余全部参与训练学习率用 1e-4如果数据量实在太少再考虑直接随机初始化全部参数、配合较强的正则对比两者验证 AUC选更优者。从那以后我每次拿到新医学数据集都会训一个随机初始化版本当基线再对比微调版本避免盲目迷信预训练。6. 进阶把分类结果可视化到 CT 原图上的实操训练好模型只是第一步真正让分类结果可解释的是把模型决策区域叠加回 CT 原图。临床上帮你确认模型有没有「看对地方」的最直接手段就是 Grad-CAM 热力图——它高亮的是模型最后卷积层的梯度加权特征区域本质上是模型认为最重要的像素位置。我一般会在03_inference.ipynb里固定加一个可视化 cellimport torch import numpy as np import matplotlib.pyplot as plt from torch.nn import functional as F def grad_cam_visualize(model, image_tensor, target_layer): 返回叠加了 Grad-CAM 热力图的原图RGB 合成target_layer 传最后一个卷积层对象 activations {} def forward_hook(module, input, output): activations[feat] output.detach() def backward_hook(module, grad_input, grad_output): activations[grad] grad_output[0].detach() target_layer.register_forward_hook(forward_hook) target_layer.register_backward_hook(backward_hook) output model(image_tensor.unsqueeze(0)) pred torch.sigmoid(output).item() model.zero_grad() # 对恶性类别的预测概率做反向传播得到梯度 output[0, 1].backward() feats activations[feat][0] # (C, H, W) grads activations[grad][0] # (C, H, W) weights grads.mean(dim(1, 2)) # 每个通道一个重要性标量 cam (weights[:, None, None] * feats).sum(dim0) cam F.relu(cam) cam cam - cam.min() cam cam / (cam.max() 1e-8) cam F.interpolate(cam.unsqueeze(0).unsqueeze(0), sizeimage_tensor.shape[-2:], modebilinear).squeeze().numpy() original image_tensor.permute(1, 2, 0).numpy() # 把归一化图像还原到 [0, 1] 显示范围再叠加热力图 original (original - original.min()) / (original.max() - original.min() 1e-8) plt.imshow(original, cmapgray) plt.imshow(cam, cmapjet, alpha0.4) plt.title(fPredicted Malignancy Probability: {pred:.3f}) plt.axis(off) plt.savefig(output_vis/example_heatmap.png, dpi150, bbox_inchestight)这段代码的机制是注册 hook 捕获目标层的特征图和梯度grads.mean(dim(1, 2))得到每个通道的加权系数与特征图加权求和后经过 ReLU 和归一化就得到一张分辨率与原图对齐的热力图。target_layer传入哪个层直接决定热力图的粒度细节——传最末一层残差块的输出空间分辨率低但语义强传浅层残差块热力图边界更细碎、位置更精确但噪声也更大。我用得最多的是第三个残差块之后的那层平衡了细节和稳定。在复现这套代码的过程中一次印象很深的翻车是某个版本迁移学习模型在验证集 AUC 表现不错但 Grad-CAM 显示模型注意力大量落在 ROI 边缘外和空气区域。这说明模型学到的是标注框的边界伪影而不是结节本身的形态特征于是回看预处理才发现数据划分时混入了未裁剪的整张切片。把 ROI 裁剪修正后同样的训练配置指标虽然下降了 0.02但热力图明显集中在结节内部真实性高了一大截。从那以后我每次训练完都强制走一遍 Grad-CAM 可视化抽查训练集和验证集各几张图像确认模型在看对的地方再决定要不要相信这个权重。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取