简介:这份资源是面向医学影像分析与深度学习入门者的肺结节分类算法代码包,基于Python与Jupyter Notebook实现,聚焦肺部CT扫描中结节的自动检测与分类,可辅助理解早期肺癌诊断的算法流程。压缩包共439个文件,约195.91MB,以397个dcm医学影像文件为主体,另含13个ipynb交互式笔记、9个xml标注、5个csv与5个npy数据文件,以及hdf5权重、py脚本、md说明和png、gif可视化素材,覆盖数据、模型、训练与评估环节。项目经两周调试,前三个代码文件可成功运行,首个代码注释详尽,便于按步骤理解网络结构、预处理与训练逻辑。目前已有2416人学习下载,适合希望掌握CNN医学影像分类、熟悉LIDC-IDRI数据处理与模型评估指标的研究者参考实践。
1. 肺结节分类算法代码:从一份 Jupyter Notebook 说起
影像科医生一天要读上百份胸部 CT,其中肺结节的良恶性判断最耗神。一个 3 毫米的实性结节,可能连着随访三年都没变化,也可能在半年内翻倍。这份「肺结节分类算法代码」就是冲着这个场景来的——它把公开数据集上的结节分类流程,从数据读取、预处理、模型搭建到训练评估,全部塞进了一个 Jupyter Notebook 里。你不需要配 GPU 集群,一台带独显的笔记本就能跑通。适合谁?刚入门医学影像的算法工程师、想复现 baseline 的科研新手,以及需要快速验证想法的从业者。它不是论文级 SOTA 方案,但胜在结构清晰、依赖干净,改起来不头疼。
2. 环境搭建与数据准备:把 CT 切片喂进模型之前
2.1 依赖清单与版本锁定
这份代码的依赖不算多,但版本敏感。我见过太多人栽在numpy和opencv的版本冲突上,所以先把环境钉死。推荐用 conda 建一个独立环境,Python 3.8 或 3.9 都行,别上 3.11,有些医学影像库还没跟上。
conda create -n lung_nodule python=3.9 -y conda activate lung_nodule pip install numpy==1.23.5 pandas==1.5.3 opencv-python==4.7.0.72 pip install scikit-learn==1.2.2 matplotlib==3.7.1 seaborn==0.12.2 pip install torch==1.13.1 torchvision==0.14.1 --index-url https://download.pytorch.org/whl/cu117 pip install jupyter notebook==6.5.4这里torch选 1.13.1 是因为它和torchvision 0.14.1是官方配对,CUDA 11.7 的轮子也稳定。如果你用 CPU 跑,把--index-url那行去掉,直接pip install torch==1.13.1 torchvision==0.14.1就行,但训练速度会慢到让你怀疑人生。opencv-python用来做图像重采样和窗宽窗位调整,scikit-learn负责后面的评估指标计算。
提示:装完 torch 后,在 notebook 里跑
import torch; print(torch.cuda.is_available()),返回True才算 GPU 可用。如果返回False,先检查显卡驱动,别急着改代码。
2.2 数据目录结构与读取逻辑
代码默认的数据目录结构是这样的,你拿到自己的数据后照着摆就行:
data/ ├── train/ │ ├── benign/ │ │ ├── patient_001_slice_03.png │ │ └── ... │ └── malignant/ │ ├── patient_002_slice_07.png │ └── ... ├── val/ │ ├── benign/ │ └── malignant/ └── test/ ├── benign/ └── malignant/每个 PNG 是一张已经裁剪好的结节切片,尺寸统一为 64×64 或 128×128。为什么用 PNG 而不是 DICOM?因为 DICOM 读取需要pydicom,而且不同设备的元数据格式差异大,预处理阶段容易翻车。代码作者显然做了取舍:先把 DICOM 转成 PNG,把窗宽窗位固定到肺窗(WL=-600, WW=1500),这样后续模型只关心纹理和形状,不用再处理灰度映射。
读取逻辑在 notebook 的第二个 cell:
import os import cv2 import numpy as np from torch.utils.data import Dataset, DataLoader class NoduleDataset(Dataset): def __init__(self, root_dir, transform=None): self.root_dir = root_dir self.transform = transform self.samples = [] # 遍历 benign 和 malignant 两个类别 for label, class_name in enumerate(['benign', 'malignant']): class_dir = os.path.join(root_dir, class_name) if not os.path.isdir(class_dir): continue for fname in os.listdir(class_dir): if fname.lower().endswith(('.png', '.jpg')): self.samples.append((os.path.join(class_dir, fname), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label = self.samples[idx] # 以灰度模式读取,保持单通道 img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: raise FileNotFoundError(f"无法读取: {img_path}") # 统一缩放到 128x128,双线性插值 img = cv2.resize(img, (128, 128), interpolation=cv2.INTER_LINEAR) # 归一化到 [0,1] img = img.astype(np.float32) / 255.0 # 增加通道维度: (H,W) -> (1,H,W) img = np.expand_dims(img, axis=0) if self.transform: img = self.transform(img) return img, label这段代码的关键点有三个。第一,label用enumerate从 0 开始,benign是 0,malignant是 1,后面算 AUC 时别搞反。第二,cv2.imread必须加IMREAD_GRAYSCALE,否则读进来是三通道,和模型输入维度对不上。第三,resize到 128×128 是硬编码的,如果你的原始切片是 64×64,改这里的同时也要改模型第一层的输入尺寸,否则会报维度错误。
2.3 数据增强:别把结节转没了
医学影像的数据增强和自然图像不一样。你给猫狗图片做随机旋转 90 度没问题,但给肺结节切片做 90 度旋转,可能把胸膜旁的结节转到一个解剖学上不可能的位置。代码里用的增强策略比较克制:
from torchvision import transforms train_transform = transforms.Compose([ transforms.ToPILImage(), transforms.RandomHorizontalFlip(p=0.5), # 水平翻转,模拟左右肺差异 transforms.RandomAffine(degrees=15, translate=(0.05, 0.05)), # 小角度旋转+平移 transforms.ToTensor(), transforms.Normalize(mean=[0.5], std=[0.5]) # 单通道归一化 ])RandomHorizontalFlip是安全的,因为左右肺的结节在形态学上没有本质区别。RandomAffine的degrees=15限制在 ±15 度,translate限制在 5% 以内,再大就可能把结节移出视野。Normalize的mean和std都设 0.5,是因为输入已经归一化到 [0,1],再减 0.5 除 0.5 就变到 [-1,1],这是 torchvision 的常规操作。
注意:验证集和测试集绝对不要加增强,只用
Resize+ToTensor+Normalize。我见过有人在 val 集上加了随机翻转,结果验证 loss 震荡得厉害,排查了半天才发现是数据管道的问题。
3. 模型搭建与训练:一个轻量 CNN 的完整实现
3.1 网络结构选型:为什么不用 ResNet50
代码里搭的是一个 4 层卷积的轻量网络,参数量大概 120 万。为什么不上 ResNet50?因为肺结节分类的数据集通常不大,公开的 LIDC-IDRI 里标注过的结节也就几千个,用 ResNet50 这种千万级参数的网络,过拟合是必然的。轻量网络加上 Dropout 和 BatchNorm,在小数据集上反而更稳。
import torch.nn as nn import torch.nn.functional as F class NoduleCNN(nn.Module): def __init__(self, num_classes=2): super(NoduleCNN, self).__init__() # 输入: (1, 128, 128) self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1) self.bn3 = nn.BatchNorm2d(128) self.conv4 = nn.Conv2d(128, 128, kernel_size=3, padding=1) self.bn4 = nn.BatchNorm2d(128) self.pool = nn.MaxPool2d(2, 2) self.dropout = nn.Dropout(0.5) # 经过4次池化: 128 -> 64 -> 32 -> 16 -> 8 self.fc1 = nn.Linear(128 * 8 * 8, 256) self.fc2 = nn.Linear(256, num_classes) def forward(self, x): x = self.pool(F.relu(self.bn1(self.conv1(x)))) # 64x64 x = self.pool(F.relu(self.bn2(self.conv2(x)))) # 32x32 x = self.pool(F.relu(self.bn3(self.conv3(x)))) # 16x16 x = self.pool(F.relu(self.bn4(self.conv4(x)))) # 8x8 x = x.view(x.size(0), -1) # 展平 x = self.dropout(F.relu(self.fc1(x))) x = self.fc2(x) return xpadding=1保证卷积后尺寸不变,MaxPool2d(2,2)每次降一半。四次池化后 128 变成 8,所以fc1的输入是128*8*8。如果你把输入改成 64×64,那池化四次后是 4×4,fc1要改成128*4*4。Dropout(0.5)放在全连接层之前,是防止过拟合的关键,别去掉。
3.2 训练循环与学习率调度
训练部分用 Adam 优化器,初始学习率 1e-3,配合ReduceLROnPlateau调度。代码里把训练和验证写在一个循环里,每轮记录 loss 和 accuracy。
import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = NoduleCNN(num_classes=2).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() running_loss = 0.0 correct = 0 total = 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * imgs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return running_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) loss = criterion(outputs, labels) running_loss += loss.item() * imgs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return running_loss / total, correct / totalweight_decay=1e-4是 L2 正则,配合 Dropout 一起压制过拟合。ReduceLROnPlateau的patience=5意思是验证 loss 连续 5 轮不降就砍半学习率,factor=0.5。这个调度器比 StepLR 更省心,不用手动设 milestone。训练循环里loss.item() * imgs.size(0)是为了按样本数加权平均,避免最后一个 batch 尺寸小导致 loss 被低估。
3.3 训练过程监控与早停
代码里没有内置早停,但 notebook 后面用 matplotlib 画了 loss 和 accuracy 曲线。我一般会加一个简单的早停逻辑:
best_val_loss = float('inf') patience_counter = 0 early_stop_patience = 15 for epoch in range(1, 101): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc = validate(model, val_loader, criterion, device) scheduler.step(val_loss) print(f"Epoch {epoch:03d} | Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} | Val Loss: {val_loss:.4f} Acc: {val_acc:.4f}") if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pth') patience_counter = 0 else: patience_counter += 1 if patience_counter >= early_stop_patience: print(f"早停触发于第 {epoch} 轮") breakearly_stop_patience=15比调度器的patience=5大,是因为学习率砍半后 loss 可能还会再降一波,给模型一点缓冲。保存best_model.pth时只存state_dict(),不存整个模型,这样加载时更灵活。
4. 避坑与排查:那些让我重跑整晚的坑
4.1 类别不平衡导致模型全预测为良性
现象:训练完一看混淆矩阵,恶性结节的召回率接近 0,模型把所有样本都判成良性。原因:公开数据集里良性结节数量通常是恶性的 2 到 3 倍,CrossEntropyLoss默认不处理不平衡,模型倾向于预测多数类。解决:在 loss 里加weight参数,或者用WeightedRandomSampler重采样。代码里可以这样改:
# 假设 benign 有 800 例,malignant 有 400 例 class_counts = [800, 400] weights = 1.0 / torch.tensor(class_counts, dtype=torch.float) weights = weights / weights.sum() criterion = nn.CrossEntropyLoss(weight=weights.to(device))4.2 验证集 loss 比训练集还低
现象:训练 loss 0.45,验证 loss 0.38,看着像好事,但测试集准确率只有 60%。原因:验证集太小,或者验证集和训练集分布不一致。我遇到过把同一患者的切片同时分到训练和验证集的情况,模型相当于在“背答案”。解决:按患者 ID 划分数据集,确保同一患者的切片只出现在一个集合里。代码里加一个patient_id字段,用GroupShuffleSplit代替随机划分。
4.3 图像归一化参数不匹配
现象:加载预训练模型时,输入图片的归一化方式和预训练时不一致,导致特征提取失效。原因:这份代码是从头训练,Normalize(mean=[0.5], std=[0.5])没问题。但如果你想换用预训练的 ResNet,它的mean=[0.485, 0.456, 0.406]和std=[0.229, 0.224, 0.225]是针对三通道 RGB 的,单通道灰度图不能直接用。解决:要么把灰度图复制成三通道,要么重新计算单通道的均值和方差。我一般会在训练集上跑一遍np.mean和np.std,用实际数据算。
4.4 GPU 显存溢出但 batch size 已经很小
现象:batch_size=16还是报CUDA out of memory。原因:notebook 里可能残留了之前的模型或张量,Python 的垃圾回收没及时释放。解决:在训练循环前加torch.cuda.empty_cache(),并且确保没有在循环里累积计算图。另外,validate函数里一定要加torch.no_grad(),否则验证阶段也会建计算图,显存直接翻倍。
4.5 保存的模型加载后预测结果全一样
现象:torch.save保存的模型,重新加载后对所有输入输出相同的类别。原因:保存时用了torch.save(model, 'model.pth')保存整个模型,加载时torch.load的类定义和当前代码不一致。解决:统一用state_dict保存和加载。加载时先实例化NoduleCNN,再model.load_state_dict(torch.load('best_model.pth')),最后model.eval()。别忘了eval(),否则 Dropout 和 BatchNorm 还在训练模式,输出会随机。
5. 评估与调优:从 AUC 0.82 到 0.89 的几个动作
5.1 用 ROC 曲线和 AUC 代替准确率
准确率在类别不平衡时是骗人的。代码里用sklearn.metrics算 AUC:
from sklearn.metrics import roc_auc_score, roc_curve, confusion_matrix import matplotlib.pyplot as plt model.eval() all_probs = [] all_labels = [] with torch.no_grad(): for imgs, labels in test_loader: imgs = imgs.to(device) outputs = model(imgs) probs = F.softmax(outputs, dim=1)[:, 1] # 取恶性类别的概率 all_probs.extend(probs.cpu().numpy()) all_labels.extend(labels.numpy()) auc = roc_auc_score(all_labels, all_probs) fpr, tpr, thresholds = roc_curve(all_labels, all_probs) plt.plot(fpr, tpr, label=f'AUC = {auc:.3f}') plt.plot([0, 1], [0, 1], 'k--') plt.xlabel('False Positive Rate') plt.ylabel('True Positive Rate') plt.legend() plt.show()F.softmax(outputs, dim=1)[:, 1]取的是恶性类别的概率,因为malignant的 label 是 1。如果你把类别顺序改了,这里也要跟着改。AUC 0.82 是 baseline,说明模型学到了一些东西,但还不够。
5.2 调整输入尺寸和网络深度
把输入从 128×128 降到 64×64,训练速度翻倍,但 AUC 掉了 0.03。把输入升到 256×256,AUC 涨了 0.02,但显存不够,batch size 只能降到 4。我的经验是:肺结节切片在 128×128 到 192×192 之间性价比最高。网络深度方面,4 层卷积够用,加到 6 层后验证 loss 反而上升,过拟合了。
5.3 用 TTA 做推理增强
测试时增强(TTA)是个几乎零成本的涨点技巧。对每张测试图做水平翻转,分别推理后取平均概率:
def predict_with_tta(model, img_tensor): model.eval() with torch.no_grad(): # 原始图 out1 = F.softmax(model(img_tensor), dim=1) # 水平翻转 img_flip = torch.flip(img_tensor, dims=[3]) out2 = F.softmax(model(img_flip), dim=1) # 平均 return (out1 + out2) / 2这个操作让 AUC 从 0.86 提到了 0.89,代价只是推理时间翻倍。注意torch.flip的dims=[3]是对宽度维度翻转,因为输入张量形状是(N, C, H, W)。
5.4 一个容易忽略的细节:阈值选择
AUC 高不代表分类阈值选得对。默认用 0.5 做阈值,但在医学场景里,漏诊恶性的代价远大于误诊良性。我一般会把阈值往低调,比如 0.35,牺牲一点特异性换灵敏度。具体调多少,看 ROC 曲线上你愿意接受的 FPR。代码里可以这样找最佳阈值:
import numpy as np from sklearn.metrics import f1_score best_thresh = 0.5 best_f1 = 0 for thresh in np.arange(0.1, 0.9, 0.01): preds = (np.array(all_probs) >= thresh).astype(int) f1 = f1_score(all_labels, preds) if f1 > best_f1: best_f1 = f1 best_thresh = thresh print(f"最佳阈值: {best_thresh:.2f}, F1: {best_f1:.3f}")从那以后我每次跑完分类任务,都强制走一遍「看 AUC → 画 ROC → 扫阈值」这三步,不再直接拿 0.5 硬套。这份代码的 notebook 里已经把评估部分写好了,你只需要把数据路径改对,就能复现整个流程。希望帮到你。
本文还有配套的精品资源,点击获取