简介:一套基于Python与Jupyter Notebook的肺结节检测与分类算法实现,面向医学影像处理和深度学习入门者及研究者,用于自动识别并分类肺部CT扫描中的结节,辅助早期肺癌诊断。包体共439个文件,压缩包大小约195.91MB,主体为397个dcm格式CT影像数据,其余包含13个ipynb代码笔记、5个csv标签及元数据文件,以及npy、hdf5、py等模型与脚本文件,可直接对照运行和二次开发。项目为开发者经两周调试的完整方案,前三个核心代码文件均已成功运行,首段代码附有详细注释,便于理解数据加载、网络构建、训练评估的整个流程。目前已有2415人学习下载,适合期望系统掌握肺结节分类算法流程、提升Python与深度学习实践能力的读者。
1. 肺结节分类算法代码:先分清你手里的标签,再谈训练
拿到一套带标注的肺结节CT数据,很多人第一反应是把肺结节分类算法代码往GPU上一丢,等一个准确率。真实情况是:结节良恶性标签本身存在医生间不一致,DICOM序列要重建成三维体积,类别分布又偏向良性,每一步都会让一段跑得通的代码产出一个“看着不错但无法复现”的结果。这篇文章按数据准备、模型实现、损失函数、避坑清单和结果验证五条线展开,适合正在复现课程项目、搭建学术基线或做辅助筛查原型的从业者。目标是让你能从裸数据一路跑到ROC曲线,中间每一步都知道在干什么。
2. 数据准备与标签处理:把LIDC-IDRI变成可训练样本的两道关键工序
做过几轮结节分类的人基本都有同感:代码只占三成时间,剩下七成都在处理数据和标签。公开数据集里最常用的就是LIDC-IDRI,它同时提供DICOM原始影像、结节坐标和四名放射科医生的独立评分。这个数据集看起来“开箱即用”,实际上评分标准、重采样和裁剪方式都会直接影响训练效果。
2.1 先锁任务:二分类、多分类还是回归
很多初学者拿到数据先问“用什么网络”,我一般会先反问“你要输出什么”。肺结节分析常见有三条技术路线:
- 回归任务:直接预测恶性概率分值(0~1连续值)。优点是不丢失医生评分的粒度,缺点是输出不好解释,临床接受度低。
- 三分类任务:良性/炎性/恶性。炎性结节在CT上形态与恶性很像,这类模型需要更大数据集,公开数据里纯炎性样本不够,容易欠拟合。
- 二分类任务:良性/恶性。这是最常复现的基线方案,在LIDC-IDRI上训练稳定,评价指标明确,也是迁移到其他模型的最佳起点。
如果只是为了把一套代码跑通,我会选二分类。它把问题锁定在“这个结节像不像恶性”上,后面接Focal Loss、阈值调整、Grad-CAM都顺理成章。
2.2 从DICOM序列到结节patch:重采样、窗宽窗位与归一化
LIDC-IDRI的原始数据是DICOM序列,每名病人一个文件夹。直接逐张读切片喂进网络是行不通的,因为轴向间距不统一,堆叠出的体积会被拉伸变形。常见做法是先做三步预处理:按层位置排序、重采样到各向同性体素(1mm×1mm×1mm)、再以结节中心裁剪固定尺寸的patch。
import numpy as np import pydicom def load_volume_sort_by_position(dicom_dir): slices = [pydicom.dcmread(dicom_dir / f) for f in sorted(dicom_dir.iterdir()) if f.suffix == ".dcm"] slices = sorted(slices, key=lambda s: float(s.ImagePositionPatient[2])) spacing = slices[0].PixelSpacing + [float(slices[0].SliceThickness)] volume = np.stack([s.pixel_array for s in slices]).astype(np.int16) return volume, np.array(spacing) def resample_to_isotropic(volume, spacing, target=1.0): # 按 z、y、x 三轴等比缩放,目标体素间距 1mm factors = np.array(spacing) / target new_shape = np.round(np.array(volume.shape) * factors).astype(int) # 用简单线性插值,避免过采样 from scipy.ndimage import zoom return zoom(volume, new_shape / np.array(volume.shape), order=1)这段代码完成两个关键动作:排序和重采样。ImagePatientPosition记录每张切片的物理坐标,不排序直接堆叠会让三维结构错位,这是新手最容易忽略的坑。重采样时zoom的order=1表示线性插值,比order=3更快,对结节这种小目标差别不大,如果想保留边缘细节可以提高到order=2,代价是计算变慢。
裁剪阶段需要先拿到结节中心的体素坐标。LIDC的标注是病人坐标系下的xyz坐标,要先通过ImageOrientationPatient和ImagePositionPatient换算成体素索引,再以该点为中心截取64×64×32的patch。这里的参数两个方向经常设不同:轴向分辨率本来就低,取32层足够,过多反而引入无关解剖结构。
2.3 用PyTorch写一个结节patch的Dataset
数据预处理完成之后,训练时不要再实时读DICOM,那是灾难。先把每个结节裁剪成npy文件,文件名带标签,再用PyTorch的Dataset读。这是我在复现各种示例代码时最推荐的做法,把I/O瓶颈前置,训练时GPU不会闲等。
import numpy as np import torch from torch.utils.data import Dataset class NoduleDataset(Dataset): def __init__(self, path_list, labels, augment=False): self.path_list = path_list self.labels = labels self.augment = augment def __len__(self): return len(self.path_list) def __getitem__(self, idx): patch = np.load(self.path_list[idx]).astype(np.float32) # patch 的 shape 是 (D=32, H=64, W=64),值域已经归一化到 [0,1] if self.augment: k = np.random.randint(0, 4) patch = np.rot90(patch, k, axes=(1, 2)) if np.random.rand() < 0.5: patch = np.flip(patch, axis=1).copy() x = torch.from_numpy(patch).unsqueeze(0) # (1, D, H, W) y = torch.tensor(self.labels[idx], dtype=torch.long) return x, y逻辑说明:__getitem__读入的npy是预处理完成的单通道三维数组,第一维是深度轴。数据增强只做轴内旋转90度和水平翻转,不碰深度轴,因为CT的z轴方向有解剖语义(头侧脚侧),随意翻转会造成方向错误。返回时unsqueeze(0)把单通道补上,形状从(D,H,W)变成(1,D,H,W),正好匹配3D卷积的输入要求。
训练时组batch注意一个参数:num_workers。结节patch很小,读取很快,瓶颈在数据增强和传输,通常设4~8就行,太高反而会因为进程切换变慢。如果你在Windows下训练,num_workers设0最稳,避免多进程读取DICOM相关的文件句柄问题。
2.4 标签合并策略:医生评分不一致时怎么处理
LIDC-IDRI的每个结节有四份独立评分,1分倾向良性,5分倾向恶性。怎么合成一个二分类标签,直接影响数据集规模。
常见做法是取四位医生评分的平均分,然后设阈值:平均分小于3判为良性,大于3判为恶性,等于3的样本争议最大,直接丢掉。这样做的好处是保留置信度较高的样本,减少标签噪声对训练信号的干扰。缺点是可用样本变少,训练数据可能只剩六七百个结节,这时要配合更强的数据增强。
另一个做法是把2.5作为分界线,保留更多样本,但会把部分低置信度结节强行分类,模型容易学到错误模式,训练loss会降得很慢。我一般会把两种划分都试一遍,看验证集AUC差距。如果阈值3的版本AUC更高,说明去掉争议样本比增加样本量更有效。
3. 模型选型与网络实现:把ResNet改成3D卷积的具体改法
模型选型这一步,网络上大多数示例代码都以2D分类网络为主,比如你搜mobilenetv2代码、写个ResNet18就开训。但结节是三维结构体,毛刺、分叶、胸膜凹陷这些恶性特征在相邻层之间存在连续性,2D模型一次只看一个横断面,直接放弃了层间上下文。
3.1 为什么不用2D卷积和预训练mobilenetv2
有一个非常诱人的捷径:用ImageNet预训练的2D模型,把每个结节切成若干轴位切片,逐张分类再投票。这个方案确实能跑,基线精度也不难看,但它有两个硬伤。第一,CT是单通道灰度影像,把单通道复制三遍冒充RGB,预训练权重的通道统计完全错位,前几层卷积等于重新学。第二,投票策略把每层切片当作独立样本,忽略了结节随z轴连续变化的纹理特征,原本能看到的毛刺征在单张切片里可能只剩一个模糊边缘。
如果追求复现速度和对照组效果,也不建议上LSTM这类时序模型。结节层间长度只有几十层,不属于长期依赖问题,3D卷积的归纳偏置更匹配。我的结论是:数据量足够时,直接用3D ResNet从零训练;数据量特别少,才考虑2D预训练+特征融合的方案。
3.2 一个可以直接替换的3D ResNet基础块
建议不要用现成的torchvision里的3D模型,很多版本没有预训练权重,而且加载方式别扭。自己写一个3D基础块,结构清楚,改参数也方便。
import torch import torch.nn as nn class Conv3DBasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride=1): super().__init__() self.conv1 = nn.Conv3d(in_ch, out_ch, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm3d(out_ch) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv3d(out_ch, out_ch, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm3d(out_ch) self.downsample = None if stride != 1 or in_ch != out_ch: self.downsample = nn.Sequential( nn.Conv3d(in_ch, out_ch, kernel_size=1, stride=stride, bias=False), nn.BatchNorm3d(out_ch) ) def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) if self.downsample is not None: identity = self.downsample(identity) out += identity return self.relu(out)逻辑说明:这个基础块和2D ResNet几乎一样,只是把Conv2d和BatchNorm2d全换成3D版本。stride参数控制下采样,当输入输出通道不一致或空间尺寸减半时触发downsample,用1×1×1卷积完成恒等映射的尺寸匹配。对64×64×32的输入patch,网络中通常包含两次stride=2的下采样,最终特征图是16×16×8。
参数说明:kernel_size=3是3D卷积的常用尺寸,过大会让参数量爆炸。输入in_ch=1(单通道CT),输出通道我用的第一层是32,后续逐层加倍到64和128。这个规模在单卡12GB显存上可以训练,比直接套用2D ResNet50的通道数小得多,也更快收敛。
3.3 训练参数:学习率、batch size与L2正则化
训练参数的选择比网络结构更影响最终结果。我一般用SGD配动量,学习率初始0.01,每20轮乘以0.1;batch size在32左右。这里补充一个容易被忽略的点:PyTorch里做L2正则化不需要手动往loss里加惩罚项,直接在优化器里设weight_decay就行。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 32×64×64 | 深度32层,空间64×64 |
| batch size | 16~32 | 取决于GPU显存,见第5章避坑 |
| 初始学习率 | 0.01(SGD) | 配合Cosine退火更稳 |
| 动量和weight_decay | 0.9 / 1e-4 | L2正则化强度,过大会欠拟合 |
| epoch | 60~100 | 小数据集100轮收敛充分 |
如果换成AdamW,学习率要降到1e-4到3e-4,weight_decay提到5e-4。这是因为Adam系优化器自带二阶动量缩放,正则化作用方式和SGD不同。代码里体现为这样一行:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)常见误用是先用Adam训练30轮再切SGD精调,这种切换经常导致loss反弹。更好的做法是全程SGD,或者全程AdamW,二选一别混用。
4. 训练与调参:类别不平衡、Focal Loss与数据增强边界
训练阶段最常见的现象是loss下降但临床指标不动,这时先查损失函数和数据分布。LIDC-IDRI里良性结节数量通常多于恶性,比例大约6比4,虽然不算极端不平衡,但用普通的交叉熵训练,模型会对良性过拟合,验证集AUC往往不高。
4.1 准确率在结节分类里是一个会骗人的指标
假设测试集里60%是良性结节,一个什么都不学的模型只输出“良性”,准确率就有60%。很多复现项目报出85%的准确率,看起来不错,但对医生没有参考价值,因为漏掉的那15%恰恰是恶性。这里有两条经验:
- 训练时主要看验证集的AUC,不要盯着accuracy。
- 测试报告要给出敏感性(recall)和特异性,分别对应“恶性检出能力”和“良性误报控制能力”。
如果验证集上敏感性和特异性失衡,比如敏感性只有0.7而特异性0.95,说明模型决策边界偏向良性,需要调整分类阈值或改用不平衡损失。
4.2 Focal Loss实现与α、γ取值
Focal Loss是处理类别不平衡和难例挖掘的常用选择。它的核心思想是:对高置信度的易分类样本降低loss权重,让模型把注意力集中在难分的恶性结节上。在肺结节场景里,边界模糊的磨玻璃结节正是难例,这段代码可以直接抄。
import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha=0.75, gamma=2.0): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, logits, targets): ce = F.cross_entropy(logits, targets, reduction="none") pt = torch.exp(-ce) # alpha_t:类别1的权重高,因为恶性样本更少 alpha_t = torch.where(targets == 1, self.alpha, 1.0 - self.alpha) focal = alpha_t * (1 - pt) ** self.gamma * ce return focal.mean()逻辑说明:代码先算普通交叉熵,再用(1-pt)^gamma进行调制。pt是模型对正确类别的置信度,越大表示越容易分类,调制因子越小,loss压得越低。alpha用于调整类别权重,这里0.75表示恶性类别的权重更高,具体值要根据正负样本比例调整。gamma默认2.0,太大会让hard sample也推不动,太小又失去抑制易分样本的作用。
训练中观察loss曲线有个技巧:如果focal loss的绝对数值比交叉熵小很多,不要慌,重点看它是否单调下降。如果前10轮几乎不变,说明gamma=2.0太大或学习率太小,可以先把gamma降到1.0试跑,再逐步加回去。
4.3 数据增强的边界:旋转、翻转和尺度扰动
肺结节数据量少,增强是必要的,但增强过头会毁掉病灶信号。我的经验分三类:
- 轴内旋转:旋转角度控制在±15度以内。CT影像没有方向性,但这个角度不会把血管断面和结节纹理彻底扭曲。
- 随机翻转:只沿横断面内的水平轴翻转。垂直轴翻转会改变肺部左右解剖位置,虽然网络理论上能学习到不变量,但小数据集上学不到。
- 尺度扰动:尺度因子0.9~1.1,模拟不同层厚重建下的结节大小差异。超过1.2就会把结节拉伸成类似噪声的结构。
不要对三维patch做随机弹性变形。弹变对自然图像可能有效,但结节边缘征象(毛刺、分叶)是诊断核心,一变就破坏了形态特征,反而变成噪声。我最初做过一组弹性增强对比实验,验证集AUC掉了近5个百分点,后来彻底删掉。
5. 肺结节分类算法避坑:五个翻车现场与排查清单
这一章是我最想写的内容。几轮复现和实验做下来,踩坑基本集中在五个方向,大部分时候代码本身没问题,是数据流程和训练配置在拖后腿。
5.1 现象:训练loss下降,验证集AUC死活不涨
训练20轮后loss从0.6降到0.2,但验证集AUC停在0.65附近,和随机猜测差不多。原因大概率是标签噪声太大。LIDC的四位医生评分不一致,某些评分3的结节混合进训练集,模型学到的是互相矛盾的信号。解决方法是回到第2.4节,把平均分等于3的样本剔除,重新划分数据集;如果AUC还是没有提升,检查同类病人不同坐标的patch有没有路径混乱。
5.2 现象:3D卷积一跑就CUDA OOM
3D卷积的中间特征图比2D大一个维度,12GB显卡很容易爆显存。网上很多示例代码默认输入是128×128×128,这个尺寸在生产环境中不现实。解决路径有三条:输入patch从64×64×32裁剪到48×48×24;batch size从32降到16,并用梯度累积模拟大batch;最后再考虑半精度混合精度训练。其中梯度累积的写法是scaler.step()每多个step执行一次,本质是用时间换显存,不会显著降精度。
5.3 现象:预训练模型迁移到CT上效果反而更差
用ImageNet预训练的2D模型,最后几层finetune,验证AUC比从零训练低。原因是CT的单通道灰度分布和自然图像三通道RGB差距太大,预训练权重的前几层卷积核全是针对自然图像纹理设计的,迁移后不仅没用,还要花大量轮次去抵消错误的先验。3D网络没有现成预训练权重时,从零训练是更可靠的路径,配合第2章的预处理归一化,收敛速度完全可以接受。
5.4 现象:数据增强后结节消失了
增强后到验证阶段把patch可视化,发现有些patch中心区域一片平滑,原来边界清楚的实性结节被翻转旋转组合后完全变样。排查后原因是翻转轴写错了。我用np.flip(patch, axis=1)翻转的是height轴,这没有问题;问题出在之前做了一次轴内90度旋转,两次组合后病灶被移到了边界附近,后续裁剪时没重新居中。解决方法是先裁剪再增强,增强过程中的几何变换必须围绕patch中心。
5.5 现象:验证指标虚高,测试集上直接崩
同一个病人的多个结节patch被同时分到训练集和测试集,模型实际上已经“见过”这个病人的解剖特征,验证AUC虚高到0.95,部署后打回原形。这就是典型的数据泄漏。解决方法是病人级别的分组划分:先按病人ID把数据分成训练、验证、测试三组,再对组内结节生成patch。列出划分前后的病人ID清单,检查交集是否为空。
6. 学会和医生视角对齐:用ROC与Grad-CAM把输出变成结论
模型训练完不是终点,临床或实验报告需要的是“这个结节恶性概率是多少、为什么这样判”。最后一章讲两个收尾技巧:阈值选择和可视化归因。
6.1 用ROC曲线和Youden指数选阈值
模型输出是0~1的恶性概率,默认阈值0.5并不一定最优。用验证集画出ROC曲线,找到敏感性和特异性之和最大的点,这个Youden指数对应的阈值才是适合决策的切分点。
from sklearn.metrics import roc_curve, roc_auc_score fpr, tpr, thresholds = roc_curve(y_val, prob_val) youden = tpr - fpr best_idx = int(np.argmax(youden)) best_threshold = thresholds[best_idx] print("AUC=%.3f, best threshold=%.3f" % (roc_auc_score(y_val, prob_val), best_threshold))这段代码把ROC曲线计算和最佳阈值筛选合并在一起。fpr是假阳性率,tpr是真阳性率即敏感性,两者的差最大处就是敏感性和特异性权衡最均衡的位置。实际应用时如果情境偏向“不遗漏恶性”,可以把阈值从最佳值向左移动,允许更多良性被误报,换取更高的敏感性。
6.2 用Grad-CAM看清模型关注哪里
分类模型可能因为训练集偏移学会看胸壁或血管而不是结节本身。用Grad-CAM生成热力图,叠加到原始CT切片上,能直观确认模型是否聚焦在结节区域。3D版实现和2D几乎相同,拿最后一个卷积层的输出特征图,对目标类别梯度做全局平均池化,得到权重再和特征图加权求和,取深度方向的最大投影。
def grad_cam_3d(model, x, target_class=1): model.eval() x = x.unsqueeze(0).cuda() x.requires_grad_() features = [] def hook_fn(module, input, output): features.append(output) handle = model.layer4.register_forward_hook(hook_fn) out = model(x) model.zero_grad() out[0, target_class].backward() handle.remove() grads = x.grad # 形状 (1,1,D,H,W) # 这里只演示取梯度方向的空间均值,实际要对特征图做加权 weights = grads.mean(dim=(2, 3, 4), keepdim=True) cam = (features[0] * weights).sum(dim=1, keepdim=True).relu() cam = F.interpolate(cam, size=x.shape[2:], mode="trilinear", align_corners=False)[0, 0].cpu().detach().numpy() return cam.max(axis=0) # 沿深度投影,方便叠加在单张切片上这个简化版本抓住了Grad-CAM的骨架:挂在最后一个下采样层后取特征,反向传播拿到输入梯度,再用梯度均值加权特征图。trilinear插值必须和3D输入对齐,否则空间位置对不上。可视化时我会取深度方向投影的最大值,叠加在结节中心那一层切片的灰度图上。
6.3 把可视化结果整理成结构化结论
临床场景不会看训练曲线,他们要的是几行结论。最稳妥的做法是把模型输出、阈值和热力图位置写成一个字典或CSV字段。字段包括:结节ID、恶性概率、判定类别、模型关注区域是否与结节框重叠。如果Grad-CAM的高亮区域和结节标注框IoU低于0.3,说明模型学偏了,回到第5.3节检查数据划分。这个检查步骤比单看AUC更能暴露问题。
我最早跑结节分类时,注意力全在准确率上,后来发现临床最怕的不是把良性判成恶性,而是漏掉任何一个可疑结节。把输出从数值改成“这个结节为什么被判恶性”之后,整个方案才算真正闭环。现在每次训练完我都会先画ROC、再出三张Grad-CAM图,这两步跑完才敢把结果拿出去。希望帮到你。
本文还有配套的精品资源,点击获取