简介:面向医学影像分析与深度学习入门者的肺结节分类算法完整实现,围绕肺部CT扫描结节的自动识别与良恶性分类展开,可辅助早期肺癌诊断场景。压缩包共439个文件,约195.91MB,主体为397个dcm医学影像数据,配合13个ipynb交互式代码、Python脚本及模型权重、CSV标签文件等,覆盖从LIDC-IDRI数据处理、模型训练到评估预测的完整流程。项目附带详细注释和Jupyter Notebook示例,前三个代码文件已调试通过,便于逐步复现;目录中还包含README与可视化图表,方便快速上手。已有2415人学习下载。通过该包可掌握肺结节检测分类的算法链路,包括DICOM图像预处理、卷积神经网络构建、训练调参与结果可视化,同时熟悉医学影像竞赛中常用的数据组织方式与模型部署思路,适合希望从实际项目入手提升Python与深度学习能力的研究者。
1. 肺结节分类算法代码:下载前先搞清楚它到底能不能让你少走弯路
做肺结节分类实验的人,很多都经历过这种局面:从网上找到一个声称“可直接运行”的肺结节分类算法代码,解压后发现要么缺数据,要么缺少预处理脚本,要么跑出来的准确率低得没法用。我拆过不少这类资源,结论是:真正能落地的肺结节分类代码,核心不在模型结构多新奇,而在它把数据准备、标注处理、训练评估这些脏活累活做到了什么程度。这份资源是完整的 Python + Jupyter Notebook 实现,从 LIDC-IDRI 数据解析、结节 ROI 裁剪,到 MobileNetV2 训练与混淆矩阵评估一整套流程都有,适合有 Python 基础的医学影像算法工程师、研究生和想复现结直肠癌之外另一个经典二分类任务的从业者。接下来我按自己复现时的顺序,把每一步怎么跑通、参数怎么设、坑在哪都拆开讲。
2. 数据准备:LIDC-IDRI 结节裁剪、标注归一化与目录划分
2.1 结节图像从哪里来:数据集结构与坐标解析
这份代码面向的是 LIDC-IDRI 数据集,这也是公开肺结节研究用得最多的数据集。它包含 CT 扫描的 DICOM 文件和放射科医生标注的 XML 文件。很多新手一上来就跳过解析直接训练,结果把整张 CT 切片丢进网络,模型学到的全是背景干扰。正确做法是先解析 XML,拿到每个结节的中心坐标、直径,再从原始 CT 切片上裁剪出以结节为中心的 ROI 图像。
代码包里一般会有一个parse_lidc.py,常见做法是遍历目录下的 XML,读取unblindedReadNode中的imageZposition、xCoord、yCoord。这里要注意,LIDC 里结节坐标是基于某个参考系的,需要先拿到 DICOM 的ImagePositionPatient和PixelSpacing做换算,不能直接用像素坐标去切。
import xml.etree.ElementTree as ET import pydicom import numpy as np def parse_nodule_xml(xml_path): tree = ET.parse(xml_path) root = tree.getroot() nodules = [] for reading_sessions in root.iter('readingSession'): for nodule in reading_sessions.iter('unblindedReadNodule'): nodule_id = nodule.get('noduleID') # 取第一个特征,通常是结节中心位置 for roi in nodule.iter('roi'): x = int(float(roi.find('xCoord').text)) y = int(float(roi.find('yCoord').text)) z = float(roi.find('imageZposition').text) nodules.append({'id': nodule_id, 'x': x, 'y': y, 'z': z}) break # 只取第一个 ROI 作为近似中心 return nodules def load_dicom_slice(dicom_path): ds = pydicom.dcmread(dicom_path) # 重点:窗宽窗位会影响图像显示范围,稍后说明 image = ds.pixel_array.astype(np.float32) return image, ds这段代码把 XML 里每个结节的 ROI 坐标提出来,并读取对应 DICOM。细心的读者应该注意到break只取了第一个 ROI,因为多个放射科医生标注存在,常见做法是用所有医生标注的坐标取平均作为最终中心,这里有简化。实际复现时建议收集同一结节的所有 ROI 坐标,求 x、y、z 的中位数,能明显减少单个医生标注偏差带来的影响。
2.2 用 Python 把 DICOM 转成 PNG 并裁剪 ROI
拿到了坐标,下一步是裁剪。一个最常见的坑是直接对原始pixel_array做裁剪,然后存成 PNG,出来的图像全是黑的或有噪点。原因是 CT 原始值是 Hounsfield Unit(HU),范围通常在 -1024 到 3071,直接存成 8 位 PNG 会丢失几乎全部信息。
需要先做窗宽窗位处理,把感兴趣的 HU 范围映射到 0-255。肺结节一般用肺窗,窗位 -600,窗宽 1500。代码里会有一个clip_to_window的函数。
def apply_window(image, window_width=1500, window_level=-600): # 窗宽窗位原理:只保留 [level - width/2, level + width/2] 范围 lower = window_level - window_width / 2.0 upper = window_level + window_width / 2.0 image_clipped = np.clip(image, lower, upper) # 线性映射到 0-255 image_norm = (image_clipped - lower) / (upper - lower) * 255.0 return image_norm.astype(np.uint8) def crop_nodule(dicom_path, x, y, size=64): image, ds = load_dicom_slice(dicom_path) image_wl = apply_window(image) # size 取偶数,保证裁剪后能下采样 half = size // 2 x1 = max(0, x - half) x2 = min(image_wl.shape[0], x + half) y1 = max(0, y - half) y2 = min(image_wl.shape[1], y + half) crop = image_wl[x1:x2, y1:y2] # 如果 RO在边界,需要 padding 到 size x size if crop.shape[0] != size or crop.shape[1] != size: padded = np.zeros((size, size), dtype=np.uint8) padded[:crop.shape[0], :crop.shape[1]] = crop crop = padded return crop这里的参数size=64是结节裁剪的常见尺寸。因为原始 ROI 中大结节有几十毫米,小结节只有几毫米,像素尺寸取决于 CT 的重建分辨率。我一般会先统一缩放到 64×64,后面送入网络前再 resize 到 224×224,这样既能保留结节局部纹理,又不会因为直接切太大引入过多背景。apply_window里把 CT 值映射到 0-255,避开了直接保存 PNG 颜色失真的问题。
2.3 划分训练/验证/测试集,生成 CSV 清单
数据准备的最后一步是划分数据集。这步看着简单,却最容易埋雷。很多人直接把所有裁剪好的图片train_test_split,但同一个病人的多个切片可能同时出现在训练集和测试集,造成数据泄漏,模型评估会虚高。正确做法是按病人维度划分。
import pandas as pd from sklearn.model_selection import GroupShuffleSplit df = pd.read_csv('nodule_crops.csv') # 每行包含 patient_id, image_path, label, slice_index # 按 patient_id 分组划分,保证同一病人的图像不跨集 gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=df['patient_id'])) train_df = df.iloc[train_idx] val_df = df.iloc[val_idx] # 再从训练集中切一部分做验证(或者单独留一个 val 集也可) gss2 = GroupShuffleSplit(n_splits=1, test_size=0.1, random_state=42) train_idx2, val_idx2 = next(gss2.split(train_df, groups=train_df['patient_id'])) train_df = train_df.iloc[train_idx2] val_df = train_df.iloc[val_idx2] train_df.to_csv('train.csv', index=False) val_df.to_csv('val.csv', index=False)这里GroupShuffleSplit是关键,groups参数传的是patient_id。不少代码仓库为了图省事用的是普通train_test_split,打印出来的准确率 98%,一拿到外部数据就掉到 80%,原因往往就是这里。CSV 清单里除了图片路径和标签,我还会额外记录结节的直径、良恶性评分,这些字段在后面做分层抽样和加权损失时都用得上。这一步做完,数据才算真正能喂给模型。
3. 模型选型:为什么用 MobileNetV2 而不是 VGG 或 ResNet
3.1 3D 还是 2D:先学会走再跑
肺结节分类有一个绕不开的选择:用 3D 卷积还是 2D 卷积。3D 模型能利用结节在相邻切片间的空间信息,理论上更好,但代价是显存占用和训练时间成倍增加,而且代码复杂度高,对新手不友好。这份代码用的是 2D 单切片输入,即每个结节取中心切片或中心切片加相邻两层作为三通道输入。
为什么先选 2D?因为医学影像分类的很多实际问题里,2D 模型在预处理得当的前提下,AUC 已经能做到 0.9 左右,足以支撑实验验证。先把 2D 流程跑通,再扩展到 2.5D 或 3D,是性价比最高的路径。这份资源的出发点也是让使用者先建立完整基线,不要一上来就陷入 3D 网络的训练地狱。
3.2 修改 MobileNetV2:替换输入通道与全连接层
选 MobileNetV2 的原因很实际:参数少、推理快、在 ImageNet 上预训练过的权重容易获取,而且它比 VGG16 小一个数量级,在普通单卡上就能训。肺结节 ROI 是灰度图像,不是 RGB,所以需要修改输入通道。常见做法是把第一层卷积的输入从 3 改为 1,但这样会丢掉预训练权重里的通道信息;另一种做法是把单通道图复制成 3 通道,保留全部预训练权重,实际效果更好,代码也更简单。
import torch import torch.nn as nn from torchvision.models import mobilenet_v2, MobileNet_V2_Weights def build_model(num_classes=2, pretrained=True): if pretrained: model = mobilenet_v2(weights=MobileNet_V2_Weights.IMAGENET1K_V1) else: model = mobilenet_v2(weights=None) # 输入是 224x224 的 3 通道图(单通道复制三次) in_features = model.classifier[1].in_features model.classifier[1] = nn.Linear(in_features, num_classes) return model代码中用的是mobilenet_v2,修改了最后的分类器,把原来 1000 类输出改成 2 类。为什么不改第一层输入为 1 通道?因为预训练权重中第一层是 3 通道卷积核,强行改成 1 通道要么重新初始化,要么做权重平均,都会削弱预训练效果。我为这类任务做迁移学习时,习惯在数据加载阶段用image.convert('RGB')把灰度图转成三通道,这样模型结构完全不用动。
3.3 数据增强与归一化的参数坑
训练前还要搞清楚两个参数体系:ImageNet 的归一化参数和医学图像的真实数值范围。MobileNetV2 预训练权重是在 ImageNet 上训的,输入需要先归一化到mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225]。有些人把裁剪后的 PNG 直接读进来送进网络,完全不归一化,跑到后期 loss 振荡,就是吃了这个亏。
而我们的裁剪图像来自窗宽窗位映射,像素值已经落在 0-255 范围,但分布和自然图像完全不同,不能直接套用 ImageNet 的统计值。常见的折中方案是:先缩放到 0-1,再按 ImageNet 的 mean/std 做标准化。这样做虽然不完全匹配医学图像的统计特性,但能最大程度兼容预训练权重,实际效果最稳。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里的RandomRotation只加了 10 度,因为肺结节不存在方向性,旋转不变性可以通过数据增强引入,但如果旋转角度太大,会把边界裁剪变成黑色区域,反而引入伪影。翻转只用了水平翻转,没有用垂直翻转,原因是 CT 扫描的上下方向在解剖学上有意义,垂直翻转会生成不真实的样本,这是医学影像和自然图像增强的一个典型差异。验证集不设增强,只做必要归一化。
4. 训练与评估:Jupyter Notebook 里的完整训练循环与混淆矩阵
4.1 训练参数设定:学习率、Batch Size、Epochs
Jupyter Notebook 里组织训练循环,要避免的是把所有逻辑堆在一个单元格里。我习惯把数据集封装、训练函数、评估函数拆到不同单元格,方便单独调试。先说参数,这套代码的默认设置是:batch size 32,初始学习率 0.001,优化器 Adam,epoch 30。如果你用的是 8GB 显存的卡,batch size 32 正好,显存不够就降到 16,同时学习率也按比例降到 0.0005。
学习率这里有个经验:迁移学习阶段用 0.001 没问题,如果是从零训练 MobileNetV2,或者做了太多自定义层,建议降到 0.0001。Adam 的默认weight_decay是 0,但我在肺结节分类里会设置weight_decay=1e-4,能有效抑制过拟合,因为医学数据集普遍样本量小。
model = build_model(num_classes=2, pretrained=True) model.train() optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) criterion = nn.CrossEntropyLoss() scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1) for epoch in range(30): running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() print(f'Epoch {epoch+1}: loss = {running_loss/len(train_loader):.4f}')代码里StepLR每 10 个 epoch 把学习率乘以 0.1。这个策略比较保险,前期快速收敛,后期稳定。你也可以用ReduceLROnPlateau,它根据验证 loss 是否下降自动调整学习率,对医学小数据集更友好,我在自己项目里更倾向后者。注意scheduler.step()的位置要放在每个 epoch 结束后,而不是每个 batch 后。
4.2 训练过程监控与早停
训练肺结节分类模型最怕过拟合,尤其是样本量只有几千时。判断过拟合不能只看训练 loss,要同时看验证集 loss 和 AUC。Notebook 里可以在每个 epoch 后跑一次验证,记录指标,然后判断是否早停。
best_val_loss = float('inf') best_model_path = 'best_model.pth' patience = 5 early_stop_counter = 0 for epoch in range(30): # 训练代码省略... val_loss = 0.0 model.eval() with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) val_loss += loss.item() val_loss /= len(val_loader) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), best_model_path) early_stop_counter = 0 else: early_stop_counter += 1 if early_stop_counter >= patience: print('Early stopping triggered') break这是一个标准的早停实现,patience=5表示连续 5 个 epoch 验证 loss 不下降就停止。保存最优权重的时间点很关键,很多人只在训练结束后保存最后一次权重,如果最后几个 epoch 已经过拟合,保存的模型就是坏的。best_model_path在验证 loss 最低时保存,训练结束后直接加载它做测试。这套逻辑放在 Notebook 里好处是能直观看到每个 epoch 的 loss 变化,我一般会在同一单元格里画一个 loss 曲线,来判断模型是不是收敛太慢或抖动。
4.3 评估指标:敏感度、特异度、F1 与 ROC-AUC
模型训完后,仅看准确率远远不够。肺结节分类中,正样本(恶性结节)往往只占 20%-30%,准确率会被多数类别抬高,容易产生“什么都预测良性也能有 75% 准确率”的错觉。正确评估要同时看敏感度(召回率)、特异度、F1 和 AUC。
from sklearn.metrics import roc_auc_score, confusion_matrix, f1_score def evaluate_model(model, test_loader, device): model.eval() all_preds = [] all_labels = [] all_probs = [] with torch.no_grad(): for images, labels in test_loader: images = images.to(device) outputs = model(images) probs = torch.softmax(outputs, dim=1) _, preds = torch.max(outputs, 1) all_probs.extend(probs[:, 1].cpu().numpy()) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) tn, fp, fn, tp = cm.ravel() sensitivity = tp / (tp + fn) specificity = tn / (tn + fp) f1 = f1_score(all_labels, all_preds) auc = roc_auc_score(all_labels, all_probs) return {'sensitivity': sensitivity, 'specificity': specificity, 'f1': f1, 'auc': auc, 'confusion_matrix': cm}这里torch.softmax(outputs, dim=1)把输出转成概率,取第 1 列作为恶性概率。评估时不能直接用outputs里的 logits 去算 AUC,因为 logits 的范围和概率不同,但roc_auc_score对输入分数的单调性要求不高,理论上也可以,不过用 softmax 后更稳妥。AUC 是最重要的筛查指标,我一般要求测试集 AUC ≥ 0.9 才算合格;如果 AUC 在 0.8 左右,优先检查数据泄漏和预处理,而不是换更大的模型。
5. 避坑指南:肺结节分类最常见的五个翻车现场
5.1 结节类别不平衡与加权损失
现象:训练 loss 一直在降,验证时敏感度只有 30%,但准确率 80% 以上。
原因:数据集中良性结节占多数,模型把所有样本都预测成良性就能拿到低 loss。
解决:使用加权 CrossEntropyLoss,让少数类样本的梯度权重更大。
class_counts = train_df['label'].value_counts().sort_index().values weights = 1.0 / class_counts weights = torch.tensor(weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=weights)这段代码计算出每个类别的权重,少数类权重高。我之前遇到过权重计算方式错误,比如直接用class_counts / total,结果多数类权重反而更大,效果更差。正确做法是取倒数再归一化,或者直接用sklearn.utils.class_weight.compute_class_weight。用加权 loss 后,敏感度通常会明显上升。
5.2 数据泄漏:同一个结节的不同切片同时出现在训练和测试集
现象:测试集 AUC 0.97,换成外部数据集 AUC 只有 0.75,模型完全无法泛化。
原因:裁剪时保存了同一结节的多个切片,不同切片在按随机方式划分时被分到了不同集合,模型实际上记住了病人的特征。
解决:严格按patient_id分组划分,并且从测试集中排除与训练集同一病人的所有切片。我在实际项目中还会做一个额外的校验:训练集和测试集的 patient_id 做交集,检查是否为空。
assert len(set(train_df['patient_id']) & set(test_df['patient_id'])) == 0这个断言看起来简单,但能治本。我见过不少代码仓库因为划分配置里漏了groups参数,导致结果虚高,等部署到真实临床数据时才暴露问题。做医学影像项目,数据泄漏是头号敌人,比模型选型影响大得多。
5.3 DICOM 窗宽窗位没设置导致图像全是黑的
现象:裁剪出来的 PNG 看起来是黑乎乎一片,输入网络后训练 loss 不下降。
原因:CT 原始数值范围非常大,直接保存成 8 位 PNG 时大部分灰度值被截断,丢掉了软组织信息。
解决:在裁剪前必须做窗宽窗位处理。肺结节用肺窗window_width=1500, window_level=-600,如果要看纵隔信息则用纵隔窗window_width=400, window_level=40。如果目标是结节本身,还可以尝试把两种窗的图像叠加成双通道输入。
我在复现时发现有些代码库直接调用了pydicom的pixel_array但不做apply_window,导致数据质量受损。建议在数据准备阶段生成一张预览图,人工检查裁剪结果,确认结节边缘清晰、血管纹理可见,再进入训练。这一步肉眼检查比任何代码调试都重要。
5.4 归一化参数在验证集上直接用训练集的
现象:训练集准确率高,验证集准确率明显低于训练集,且不是过拟合的单调趋势。
原因:验证集和测试集使用了不同的归一化统计量,或者错误地对每个 batch 独立计算 mean/std,导致数据分布不一致。
解决:所有数据集的归一化参数必须在训练集上计算,并固定为全局常量。如果数据是从 PNG 读入,标准做法是固定 mean/std,如 ImageNet 的数值,避免在线计算。
mean = [0.485, 0.456, 0.406] std = [0.229, 0.224, 0.225] # 不要在 Dataset.__getitem__ 里重新计算 mean/std我在自己的代码里会把mean和std写死成一个常量元组,而不是每次动态计算。有些人的 Dataset 类中用了image / 255后再减去数据集均值,如果数据集均值来自全量数据,哪怕差 0.01,验证集结果也会受影响。保持简单:ToTensor()后再用固定的Normalize。
5.5 模型输出是概率还是 logits:阈值调错
现象:AUC 很高,但 F1 很低,敏感度和特异度无法平衡。
原因:直接用了torch.max(outputs, 1)输出类别,而忽略了这个默认阈值 0.5 不一定是最优的。
解决:在验证集上搜索最佳阈值,用 Youden's Index 或最大化 F1 的阈值作为最终分类阈值。
from sklearn.metrics import precision_recall_curve probs_val = np.array(all_probs_val) labels_val = np.array(all_labels_val) precision, recall, thresholds = precision_recall_curve(labels_val, probs_val) f1_scores = 2 * (precision * recall) / (precision + recall + 1e-9) best_threshold = thresholds[np.argmax(f1_scores)]这里搜出来的是验证集上的最优阈值,测试时用这个阈值而不是 0.5。这是很多代码库最后一步没做对的地方:拿 AUC 高的模型直接部署,实际效果却差一截。记住:AUC 衡量的是排序能力,阈值决定的是实际分类行为。如果best_threshold偏离 0.5 很多,说明训练集和验证集分布存在偏差,要回头检查数据划分。
6. 微调与部署:用迁移学习在新数据集上复现与验证
6.1 冻结前几层进行微调的脚本
当你拿到自己的新数据集,比如来自其他医院的 CT,直接套用训练好的权重往往不够,因为数据分布有差异。这时候需要微调。常见的做法是冻结 MobileNetV2 的前几层特征提取层,只训练后半部分和分类头。冻结层数取决于新数据量:只有几百张图就冻结到features[:-3],数据量稍大可以冻结到前一半。
def freeze_layers(model, freeze_upto=10): # 按索引冻结 features 模块中的层 for i, layer in enumerate(model.features): if i < freeze_upto: for param in layer.parameters(): param.requires_grad = False return model model = build_model(num_classes=2, pretrained=True) model = freeze_layers(model, freeze_upto=10) # 只训练需要梯度的层 params_to_update = [p for p in model.parameters() if p.requires_grad] optimizer = torch.optim.Adam(params_to_update, lr=0.0001)这段代码把流程可视化了。freeze_upto=10是一个经验值,MobileNetV2 的 features 模块一共 19 层,前 10 层捕获的是边缘、纹理等底层特征,医学图像和自然图像在这些底层特征上有相似性,所以冻结它们能防止在小数据集上破坏预训练信息。后面的高层特征更偏向特定任务,需要重新学习。
6.2 用混淆矩阵和病例级预测做最终验证
微调结束后的最后一步,是评估模型在病例级别的性能,而不是切片级别。因为临床上一个病人可能有多张切片,模型对每个切片输出一个概率,最终要汇总成这个病例是否为恶性。常见的汇总方式是取最大值、平均值或中位数。我经验里取最大值更符合临床逻辑——只要有一个切片被识别为恶性,医生就会警惕。
def patient_level_prediction(df_test, patient_col='patient_id', prob_col='malignancy_prob'): df_grouped = df_test.groupby(patient_col)[prob_col].max().reset_index() # 假设阈值已通过验证集确定 df_grouped['pred_label'] = (df_grouped[prob_col] >= best_threshold).astype(int) return df_grouped用groupby(...).max()把切片级概率汇总成病例级概率。这一步很重要,因为评估模型最终还是要看它能帮医生找出多少恶性病例。我曾经把一个切片级 AUC 0.93 的模型测试到病例级,发现只有一个病例被漏掉了,但那个病例有 6 张切片都接近阈值,通过调整阈值和取均值能救回来。从那以后,我每次训练完都会强制走一遍病例级评估流程,看看混淆矩阵中假阴性的具体病例是什么特征,再决定要不要调阈值。切片级指标好看不代表临床可用,把这一步养成习惯,能帮你避开很多表面光鲜的模型陷阱。
这份资源把数据解析、裁剪、模型训练、评估、微调的完整链路都做成了可改的 Notebook 细胞,你可以直接替换数据集路径跑通,再按自己的数据分布调参数。希望帮到你。
本文还有配套的精品资源,点击获取