肺结节分类算法实战:3D卷积、Focal Loss与数据避坑

发布时间:2026/10/11 16:18:39
肺结节分类算法实战:3D卷积、Focal Loss与数据避坑
简介一套基于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 先锁任务二分类、多分类还是回归很多初学者拿到数据先问“用什么网络”我一般会先反问“你要输出什么”。肺结节分析常见有三条技术路线回归任务直接预测恶性概率分值01连续值。优点是不丢失医生评分的粒度缺点是输出不好解释临床接受度低。三分类任务良性炎性恶性。炎性结节在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, keylambda 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, target1.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), order1)这段代码完成两个关键动作排序和重采样。ImagePatientPosition记录每张切片的物理坐标不排序直接堆叠会让三维结构错位这是新手最容易忽略的坑。重采样时zoom的order1表示线性插值比order3更快对结节这种小目标差别不大如果想保留边缘细节可以提高到order2代价是计算变慢。裁剪阶段需要先拿到结节中心的体素坐标。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, augmentFalse): 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 是 (D32, H64, W64)值域已经归一化到 [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, axis1).copy() x torch.from_numpy(patch).unsqueeze(0) # (1, D, H, W) y torch.tensor(self.labels[idx], dtypetorch.long) return x, y逻辑说明__getitem__读入的npy是预处理完成的单通道三维数组第一维是深度轴。数据增强只做轴内旋转90度和水平翻转不碰深度轴因为CT的z轴方向有解剖语义头侧脚侧随意翻转会造成方向错误。返回时unsqueeze(0)把单通道补上形状从(D,H,W)变成(1,D,H,W)正好匹配3D卷积的输入要求。训练时组batch注意一个参数num_workers。结节patch很小读取很快瓶颈在数据增强和传输通常设48就行太高反而会因为进程切换变慢。如果你在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, stride1): super().__init__() self.conv1 nn.Conv3d(in_ch, out_ch, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm3d(out_ch) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv3d(out_ch, out_ch, kernel_size3, stride1, padding1, biasFalse) 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_size1, stridestride, biasFalse), 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网络中通常包含两次stride2的下采样最终特征图是16×16×8。参数说明kernel_size3是3D卷积的常用尺寸过大会让参数量爆炸。输入in_ch1单通道CT输出通道我用的第一层是32后续逐层加倍到64和128。这个规模在单卡12GB显存上可以训练比直接套用2D ResNet50的通道数小得多也更快收敛。3.3 训练参数学习率、batch size与L2正则化训练参数的选择比网络结构更影响最终结果。我一般用SGD配动量学习率初始0.01每20轮乘以0.1batch size在32左右。这里补充一个容易被忽略的点PyTorch里做L2正则化不需要手动往loss里加惩罚项直接在优化器里设weight_decay就行。参数推荐值说明输入尺寸32×64×64深度32层空间64×64batch size1632取决于GPU显存见第5章避坑初始学习率0.01SGD配合Cosine退火更稳动量和weight_decay0.9 / 1e-4L2正则化强度过大会欠拟合epoch60100小数据集100轮收敛充分如果换成AdamW学习率要降到1e-4到3e-4weight_decay提到5e-4。这是因为Adam系优化器自带二阶动量缩放正则化作用方式和SGD不同。代码里体现为这样一行optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-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, alpha0.75, gamma2.0): super().__init__() self.alpha alpha self.gamma gamma def forward(self, logits, targets): ce F.cross_entropy(logits, targets, reductionnone) 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轮几乎不变说明gamma2.0太大或学习率太小可以先把gamma降到1.0试跑再逐步加回去。4.3 数据增强的边界旋转、翻转和尺度扰动肺结节数据量少增强是必要的但增强过头会毁掉病灶信号。我的经验分三类轴内旋转旋转角度控制在±15度以内。CT影像没有方向性但这个角度不会把血管断面和结节纹理彻底扭曲。随机翻转只沿横断面内的水平轴翻转。垂直轴翻转会改变肺部左右解剖位置虽然网络理论上能学习到不变量但小数据集上学不到。尺度扰动尺度因子0.91.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 OOM3D卷积的中间特征图比2D大一个维度12GB显卡很容易爆显存。网上很多示例代码默认输入是128×128×128这个尺寸在生产环境中不现实。解决路径有三条输入patch从64×64×32裁剪到48×48×24batch 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, axis1)翻转的是height轴这没有问题问题出在之前做了一次轴内90度旋转两次组合后病灶被移到了边界附近后续裁剪时没重新居中。解决方法是先裁剪再增强增强过程中的几何变换必须围绕patch中心。5.5 现象验证指标虚高测试集上直接崩同一个病人的多个结节patch被同时分到训练集和测试集模型实际上已经“见过”这个病人的解剖特征验证AUC虚高到0.95部署后打回原形。这就是典型的数据泄漏。解决方法是病人级别的分组划分先按病人ID把数据分成训练、验证、测试三组再对组内结节生成patch。列出划分前后的病人ID清单检查交集是否为空。6. 学会和医生视角对齐用ROC与Grad-CAM把输出变成结论模型训练完不是终点临床或实验报告需要的是“这个结节恶性概率是多少、为什么这样判”。最后一章讲两个收尾技巧阈值选择和可视化归因。6.1 用ROC曲线和Youden指数选阈值模型输出是01的恶性概率默认阈值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_class1): 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), keepdimTrue) cam (features[0] * weights).sum(dim1, keepdimTrue).relu() cam F.interpolate(cam, sizex.shape[2:], modetrilinear, align_cornersFalse)[0, 0].cpu().detach().numpy() return cam.max(axis0) # 沿深度投影方便叠加在单张切片上这个简化版本抓住了Grad-CAM的骨架挂在最后一个下采样层后取特征反向传播拿到输入梯度再用梯度均值加权特征图。trilinear插值必须和3D输入对齐否则空间位置对不上。可视化时我会取深度方向投影的最大值叠加在结节中心那一层切片的灰度图上。6.3 把可视化结果整理成结构化结论临床场景不会看训练曲线他们要的是几行结论。最稳妥的做法是把模型输出、阈值和热力图位置写成一个字典或CSV字段。字段包括结节ID、恶性概率、判定类别、模型关注区域是否与结节框重叠。如果Grad-CAM的高亮区域和结节标注框IoU低于0.3说明模型学偏了回到第5.3节检查数据划分。这个检查步骤比单看AUC更能暴露问题。我最早跑结节分类时注意力全在准确率上后来发现临床最怕的不是把良性判成恶性而是漏掉任何一个可疑结节。把输出从数值改成“这个结节为什么被判恶性”之后整个方案才算真正闭环。现在每次训练完我都会先画ROC、再出三张Grad-CAM图这两步跑完才敢把结果拿出去。希望帮到你。本文还有配套的精品资源点击获取