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

发布时间:2026/10/27 15:55:53
基于PyTorch的肺结节分类实战:从数据预处理到Grad-CAM可视化

简介:这份资源面向医学影像分析与深度学习入门者,提供一套基于 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.8~3.10 之间,太新的版本有些医学影像库还没跟上。

# 创建独立环境,Python 3.9 兼容性最稳 conda create -n lung_nodule python=3.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, transform=None): # 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] 会把肺实质的细节压没。标准做法是先做窗宽窗位裁剪,把关注区间(肺窗通常 WW=1500, WL=-600)映射到 0~255,再做归一化。

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_width=1500): self.wc = window_center self.ww = window_width def __call__(self, img): img = np.array(img, dtype=np.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_width=1500), T.RandomHorizontalFlip(p=0.5), T.RandomRotation(degrees=10), T.Resize((224, 224)), ]) # 验证集:只做窗变换和缩放,不做随机增强 val_tf = T.Compose([ LungWindow(window_center=-600, window_width=1500), T.Resize((224, 224)), ])

LungWindow里window_center和window_width两个参数是核心。肺窗设 -600/1500 是为了突出肺实质和结节,如果换成纵隔窗(WC=40, WW=400)看的就是软组织,结节反而不明显。unsqueeze(0)补通道维是因为后面模型输入要求[C, H, W]。增强只做水平翻转和 ±10 度旋转,医学图像不能像自然图像那样随便裁剪缩放,解剖结构的位置关系有临床意义,翻转过头可能把左右肺搞反,这个坑我在别的项目里踩过。

3.2 模型定义:迁移学习骨架与分类头改造

从零训一个 CNN 在几百张切片上必然过拟合,代码里用的是迁移学习。骨干网络常见选 ResNet 或 DenseNet,ImageNet 预训练权重加载进来,只改最后的全连接层输出维度。

import torch.nn as nn from torchvision import models def build_model(num_classes=2, pretrained=True): # 加载 ResNet18 预训练权重 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None) # 原始 fc 输出 1000 类,替换成二分类 in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(p=0.5), # 防过拟合 nn.Linear(in_features, num_classes) ) # 输入是单通道灰度图,改第一层卷积 model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) return model

这里有两个关键改动。第一,model.fc换成Dropout + Linear,p=0.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(weight=class_weights) optimizer = Adam(model.parameters(), lr=1e-4)

class_weights的数值不是拍脑袋定的,一般用总样本数 / (类别数 * 该类样本数)算出来。假设良性 300 张、恶性 100 张,那良性权重约 0.67、恶性权重约 2.0,代码里简化成 1:3 也能起到类似效果。lr=1e-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, dim=1)[:, 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(f"AUC: {auc:.4f}") print(classification_report(all_labels, preds, target_names=['benign', 'malignant'])) return auc, cm

torch.no_grad()关掉梯度计算,验证阶段省显存也提速。softmax(dim=1)[:, 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), keepdim=True) cam = (weights * features['feat']).sum(dim=1, keepdim=True) 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 cam

target_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 损失不下降:学习率和权重初始化检查

现象:训练十几个 epoch,loss 在 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_class='ovr')。第二,换骨干:build_model里把resnet18换成densenet121或efficientnet_b0,注意同步改conv1和fc的层名,DenseNet 的分类头叫classifier不叫fc,这个细节不改会直接报AttributeError。第三,加检测:如果要从「整张切片分类」升级到「结节定位」,得在分类头旁边挂一个回归分支输出边界框,损失改成分类损失 + λ * 回归损失,数据标注也要从病例级标签换成框坐标。

验证改造是否成功,我习惯用一个小 checklist 走一遍:先用 10 张图过拟合测试,模型能不能把训练 loss 压到接近 0,压不下去说明网络结构或学习率有问题;再用按病人划分的验证集看 AUC,掉太多说明泛化不行;最后跑 Grad-CAM 看热力图是否落在病灶上,落在背景上说明数据或标签有噪声。这三步走完,基本能判断改造是有效还是白改。

有个习惯我保持了挺久:每次改完模型结构,先不急着跑全量训练,而是拿一个 batch 手动前向一次,打印输入输出 shape,确认维度对得上再开训。早期我不做这一步,经常训到一半才发现fc输入维度算错,白等半小时。从那以后我每次动网络结构都强制走一遍 shape 检查,省下来的时间比想象中多。希望这套骨架能帮到你,少走几个我踩过的弯路。

本文还有配套的精品资源,点击获取