资讯动态

基于PyTorch的肺结节分类实战:从数据预处理到Grad-CAM可视化

发布时间:2026/10/27 15:55:44 来源:尧图企业网站定制
简介这份资源面向医学影像分析与深度学习入门者提供一套基于 Python 与 Jupyter Notebook 的肺结节检测分类完整代码方案用于从肺部 CT 序列中自动识别并分类结节可辅助理解早期肺癌筛查的算法流程。压缩包共 439 个文件、约 195.91MB主体为 397 个 dcm 医学影像文件配合 13 个 ipynb 交互式笔记、若干 csv 标签与提交文件、xml 标注及 npy 数据另含模型权重占位、说明文档与示例图片覆盖数据、训练、评估到预测的完整链路。已有 2421 人学习下载。项目经两周调试前三个代码文件可成功运行首个文件注释详尽读者可据此理清数据预处理、CNN 模型构建、训练循环与精度召回率等指标评估的实现思路适合作为医学影像项目的上手参考与二次开发基础。1. 从一张 CT 切片说起这套肺结节分类代码到底解决什么问题影像科医生看一套胸部 CT动辄两三百张切片要在里面找出几个毫米级的肺结节靠的是经验和眼力漏诊风险始终存在。这套肺结节分类算法代码做的就是把这个「找结节」的动作交给模型输入一张切片或一个结节候选区域输出良性/恶性或结节/非结节的概率。它面向的是想入门医学影像深度学习、又不想从零搭环境的开发者——Python Jupyter Notebook 的组合意味着你不需要配 GPU 集群一台带独显的机器甚至 Colab 就能跑通全流程。代码里覆盖了数据读取、预处理、模型定义、训练循环和评估指标几个关键环节适合拿来当医学影像分类的骨架工程二次开发成自己的检测或分割任务。2. 环境与数据准备把 Notebook 跑起来的第一步2.1 依赖清单与虚拟环境搭建医学影像项目的依赖比普通 CV 项目多一层DICOM 解析、窗宽窗位处理、医学图像增强都要单独装库。我一般不会直接在 base 环境里 pip install血泪经验是版本冲突能把人折腾一下午。常见做法是 conda 建一个独立环境Python 版本锁在 3.83.10 之间太新的版本有些医学影像库还没跟上。# 创建独立环境Python 3.9 兼容性最稳 conda create -n lung_nodule python3.9 -y conda activate lung_nodule # 核心依赖深度学习框架 医学影像处理 可视化 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy pandas matplotlib scikit-learn pip install pydicom SimpleITK opencv-python pip install jupyter notebook ipywidgets这里几个包的分工要说清楚pydicom负责读原始 DICOM 文件拿到像素矩阵和元数据SimpleITK处理 NIfTI 格式和重采样opencv-python做常规图像变换scikit-learn主要用来算 AUC、混淆矩阵这些评估指标。torch的 CUDA 版本按你显卡驱动选没有独显就装 CPU 版训练慢但能跑通逻辑。装完之后在 Notebook 里跑一句import torch; print(torch.cuda.is_available())返回 True 说明 GPU 通了。这一步翻车的概率不低驱动版本和 CUDA 版本对不上是常态报错信息里会明确写CUDA driver version is insufficient照着降 torch 版本就行。2.2 数据集目录结构与加载逻辑这类项目的数据组织方式直接决定后面代码怎么写。医学影像分类常见两种布局一种是按类别分文件夹benign/、malignant/另一种是每个病例一个文件夹、标签放在 CSV 里。代码里通常用后者因为一个病例可能对应多张切片标签是病例级的。import os import pandas as pd from torch.utils.data import Dataset, DataLoader from PIL import Image class NoduleDataset(Dataset): def __init__(self, csv_file, img_dir, transformNone): # CSV 至少包含 filename 和 label 两列 self.meta pd.read_csv(csv_file) self.img_dir img_dir self.transform transform def __len__(self): return len(self.meta) def __getitem__(self, idx): row self.meta.iloc[idx] img_path os.path.join(self.img_dir, row[filename]) # 医学图像常见是 16 位灰度转 8 位再进网络 image Image.open(img_path).convert(L) label int(row[label]) if self.transform: image self.transform(image) return image, label这段Dataset是整个训练的入口。csv_file里filename对应切片文件名label是 0/1 二分类标签。convert(L)把图像转成单通道灰度因为肺结节切片本身就是灰度图强行转 RGB 反而多两个冗余通道。transform留成外部注入训练集和验证集用不同的增强策略这个设计在后面的训练脚本里会体现。数据量小是这类项目的通病几百到几千张切片很常见。我一般会先跑一遍len(dataset)和meta[label].value_counts()确认正负样本比例。如果恶性样本只占 10%后面训练必须加类别权重否则模型会偷懒全预测成良性准确率看着 90% 其实毫无意义。3. 核心模块拆解从预处理到模型前向的完整链路3.1 预处理与数据增强窗宽窗位和几何变换医学影像和自然图像最大的区别在预处理。CT 值的范围是 -1000 到 3000 左右HU 单位直接归一化到 [0,1] 会把肺实质的细节压没。标准做法是先做窗宽窗位裁剪把关注区间肺窗通常 WW1500, WL-600映射到 0255再做归一化。import numpy as np import torchvision.transforms as T from torchvision.transforms import functional as F class LungWindow: 把 HU 值按肺窗裁剪并归一化到 [0,1] def __init__(self, window_center-600, window_width1500): self.wc window_center self.ww window_width def __call__(self, img): img np.array(img, dtypenp.float32) low self.wc - self.ww / 2 high self.wc self.ww / 2 img np.clip(img, low, high) img (img - low) / (high - low) # 映射到 [0,1] return torch.from_numpy(img).unsqueeze(0) # 增加通道维 # 训练集窗变换 随机翻转 轻微旋转 train_tf T.Compose([ LungWindow(window_center-600, window_width1500), T.RandomHorizontalFlip(p0.5), T.RandomRotation(degrees10), T.Resize((224, 224)), ]) # 验证集只做窗变换和缩放不做随机增强 val_tf T.Compose([ LungWindow(window_center-600, window_width1500), T.Resize((224, 224)), ])LungWindow里window_center和window_width两个参数是核心。肺窗设 -600/1500 是为了突出肺实质和结节如果换成纵隔窗WC40, WW400看的就是软组织结节反而不明显。unsqueeze(0)补通道维是因为后面模型输入要求[C, H, W]。增强只做水平翻转和 ±10 度旋转医学图像不能像自然图像那样随便裁剪缩放解剖结构的位置关系有临床意义翻转过头可能把左右肺搞反这个坑我在别的项目里踩过。3.2 模型定义迁移学习骨架与分类头改造从零训一个 CNN 在几百张切片上必然过拟合代码里用的是迁移学习。骨干网络常见选 ResNet 或 DenseNetImageNet 预训练权重加载进来只改最后的全连接层输出维度。import torch.nn as nn from torchvision import models def build_model(num_classes2, pretrainedTrue): # 加载 ResNet18 预训练权重 model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None) # 原始 fc 输出 1000 类替换成二分类 in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(p0.5), # 防过拟合 nn.Linear(in_features, num_classes) ) # 输入是单通道灰度图改第一层卷积 model.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) return model这里有两个关键改动。第一model.fc换成Dropout Linearp0.5是医学影像小数据集上的常用值训练时随机丢一半神经元验证时自动关闭。第二conv1从 3 通道改成 1 通道因为输入是灰度图。注意改conv1之后ImageNet 预训练的第一层权重就用不上了PyTorch 会重新初始化这一层这是正常现象后面微调阶段会重新学到适合灰度图的滤波器。如果不想丢预训练权重也可以把灰度图复制成 3 通道但那样计算量翻三倍小数据集上不划算。3.3 训练循环与损失函数类别不平衡下的加权策略训练循环本身不复杂难点在损失函数。前面提过正负样本可能失衡代码里用带权重的交叉熵。import torch from torch.optim import Adam from sklearn.metrics import roc_auc_score def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) return total_loss / len(loader.dataset) # 按样本比例设置类别权重恶性样本少则权重高 class_weights torch.tensor([1.0, 3.0]).to(device) # 假设良性:恶性 3:1 criterion nn.CrossEntropyLoss(weightclass_weights) optimizer Adam(model.parameters(), lr1e-4)class_weights的数值不是拍脑袋定的一般用总样本数 / (类别数 * 该类样本数)算出来。假设良性 300 张、恶性 100 张那良性权重约 0.67、恶性权重约 2.0代码里简化成 1:3 也能起到类似效果。lr1e-4是微调预训练模型的保守学习率太大容易把预训练权重冲垮。每个 epoch 结束后记得在验证集上算 AUC医学分类任务里 AUC 比准确率更能反映模型区分能力尤其是样本不平衡的时候。4. 评估与可视化怎么判断模型是真的学到了东西4.1 指标计算AUC、敏感度与混淆矩阵医学影像分类不能只看准确率。一个全预测良性的模型在 90% 良性的数据集上准确率也是 90%但恶性一个都没抓到临床上等于废了。所以评估要同时看敏感度召回率和特异度。from sklearn.metrics import confusion_matrix, roc_auc_score, classification_report def evaluate(model, loader, device): model.eval() all_probs, all_labels [], [] with torch.no_grad(): for images, labels in loader: images images.to(device) outputs model(images) probs torch.softmax(outputs, dim1)[:, 1] # 取恶性概率 all_probs.extend(probs.cpu().numpy()) all_labels.extend(labels.numpy()) auc roc_auc_score(all_labels, all_probs) preds [1 if p 0.5 else 0 for p in all_probs] cm confusion_matrix(all_labels, preds) print(fAUC: {auc:.4f}) print(classification_report(all_labels, preds, target_names[benign, malignant])) return auc, cmtorch.no_grad()关掉梯度计算验证阶段省显存也提速。softmax(dim1)[:, 1]取的是恶性类别的概率阈值 0.5 是默认值实际部署时这个阈值可以调——宁可多报几个假阳性让医生复核也别漏掉真恶性所以临床上常把阈值降到 0.3 左右提高敏感度。classification_report会直接打出 precision、recall、f1恶性类的 recall 就是敏感度这个数字比整体准确率重要得多。4.2 可视化Grad-CAM 看模型关注哪里模型说这张是恶性你得知道它凭什么这么判断。Grad-CAM 把最后一层卷积的梯度加权回原图生成热力图能看出模型关注的是结节区域还是背景噪声。import cv2 def grad_cam(model, image_tensor, target_layer, device): model.eval() image_tensor image_tensor.unsqueeze(0).to(device) # 注册钩子拿特征图和梯度 features, grads {}, {} target_layer.register_forward_hook(lambda m, i, o: features.update({feat: o})) target_layer.register_full_backward_hook(lambda m, gi, go: grads.update({grad: go[0]})) output model(image_tensor) model.zero_grad() output[0, 1].backward() # 对恶性类别反传 weights grads[grad].mean(dim(2, 3), keepdimTrue) cam (weights * features[feat]).sum(dim1, keepdimTrue) cam torch.relu(cam).squeeze().cpu().detach().numpy() cam cv2.resize(cam, (224, 224)) cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) return camtarget_layer一般传model.layer4[-1]也就是 ResNet 最后一个残差块那里特征图分辨率低但语义强。output[0, 1].backward()只对恶性类反传所以热力图反映的是「支持恶性判断的区域」。relu过滤掉负贡献resize回原图尺寸后叠加到 CT 上就能看。如果热力图高亮在肺外区域或者图像边缘说明模型学偏了多半是数据里有伪影或者标签噪声这时候得回去查数据而不是调模型。5. 避坑与排查这几个坑我替你踩过了5.1 显存溢出batch_size 和图像尺寸的取舍现象训练第一个 batch 就报CUDA out of memory。原因通常是batch_size设太大或者输入分辨率 224 以上。解决先把batch_size降到 8 甚至 4还不行就把Resize改成 128。医学影像切片信息密度高128 分辨率在结节分类上往往够用没必要死磕 224。另外验证阶段记得torch.no_grad()不然显存会持续累积。5.2 损失不下降学习率和权重初始化检查现象训练十几个 epochloss 在 0.69 附近晃二分类随机水平。原因多半是学习率太大把预训练权重冲垮了或者conv1改单通道后没做 warmup。解决把lr降到 1e-5 先跑几个 epoch 看 loss 是否下降确认模型能学之后再调回 1e-4。还有一种可能是标签和图像没对齐CSV 里文件名顺序和实际文件对不上这种错误最隐蔽建议随机抽 5 张图matplotlib显示出来人工核对标签。5.3 验证集指标虚高数据泄漏排查现象验证集 AUC 0.98换一批数据掉到 0.6。原因通常是同一个病人的切片同时出现在训练集和验证集里模型记住了病人特征而不是结节特征。解决按病人 ID 划分数据集而不是按切片随机划分。代码里csv_file要带patient_id列用GroupShuffleSplit或者手动按 ID 切分保证同一个病人的所有切片只出现在一个集合里。5.4 Notebook 内核崩溃内存泄漏定位现象跑着跑着 Jupyter 提示 kernel died。原因一般是DataLoader的num_workers设太高或者图像没及时释放。解决num_workers设成 0 或 2 先排除多进程问题matplotlib画完图记得plt.close()大数组用完del掉再gc.collect()。Notebook 里反复执行同一个 cell 很容易残留变量调试阶段养成重启内核再全量跑的习惯。6. 二次开发把分类骨架改成你自己的任务拿到这套代码最值钱的不是它现在的二分类能力而是那套「数据加载 → 窗变换 → 迁移学习 → 加权损失 → AUC 评估」的骨架。我一般会按三个方向改。第一换任务把num_classes改成 3 或 4做良性/恶性/不确定的多分类损失函数换成带权重的CrossEntropyLoss即可评估部分加一个多分类的roc_auc_score(multi_classovr)。第二换骨干build_model里把resnet18换成densenet121或efficientnet_b0注意同步改conv1和fc的层名DenseNet 的分类头叫classifier不叫fc这个细节不改会直接报AttributeError。第三加检测如果要从「整张切片分类」升级到「结节定位」得在分类头旁边挂一个回归分支输出边界框损失改成分类损失 λ * 回归损失数据标注也要从病例级标签换成框坐标。验证改造是否成功我习惯用一个小 checklist 走一遍先用 10 张图过拟合测试模型能不能把训练 loss 压到接近 0压不下去说明网络结构或学习率有问题再用按病人划分的验证集看 AUC掉太多说明泛化不行最后跑 Grad-CAM 看热力图是否落在病灶上落在背景上说明数据或标签有噪声。这三步走完基本能判断改造是有效还是白改。有个习惯我保持了挺久每次改完模型结构先不急着跑全量训练而是拿一个 batch 手动前向一次打印输入输出 shape确认维度对得上再开训。早期我不做这一步经常训到一半才发现fc输入维度算错白等半小时。从那以后我每次动网络结构都强制走一遍 shape 检查省下来的时间比想象中多。希望这套骨架能帮到你少走几个我踩过的弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑