简介:这份机器学习大作业提供了一套完整的3D医学影像分类实现,包含Python源代码与配套文档说明,适合计算机、电子信息工程、数学等专业学生用于课程设计、期末大作业或毕业设计。项目以CSV文件组织训练与验证标签,以大量NPZ文件保存预处理后的影像样本,配合Python脚本完成数据读取、模型训练和结果预测,README文档则对整体流程与目录结构做了说明,便于上手。压缩包共596个文件,整体约446MB,文件类型以NPZ影像数据为主,另有PY源码、CSV标注和MD说明,模块划分清楚,可按阶段复用。代码采用参数化编程,注释详细,并附带运行结果,经测试可直接运行;已有360人学习下载。读者可借此快速搭建医学影像分类基线方案,掌握三维数据处理和神经网络训练的基本流程,也可替换参数和数据完成自己的大作业。
1. 3D医学影像分类作业:先解决“数据怎么读”再做模型
我见过不少同学拿到这类机器学习大作业,第一反应是找论文、抄模型结构,结果卡在第一步:打开candidate409.npz这类文件,既不知道里面存的是什么,也不知道train_val.csv、0617.csv和提交样例之间是什么关系。3D医学影像分类和常规的图片分类不一样,它不只考验你懂不懂卷积网络,更考验你能不能把一个非标准的医学数据格式,安全地转换模型能吃的张量,再按指定格式交回结果。这套资源刚好把“数据文件 + 训练代码 + 文档说明”凑齐了,适合计算机、电子信息、数学等专业的学生直接拿来做课程设计、期末大作业或毕业设计里的算法部分。这篇方案文就按“数据怎么读 → 模型怎么搭 → 训练怎么调 → 答辩怎么讲”的顺序,把整份东西拆开讲清楚。
2. 总体方案设计:从npz体数据到提交CSV的完整流程
2.1 数据集结构与三类文件的角色划分
拿到的资源里,文件角色其实很清楚:candidate*.npz是一组候选病变区域的三维体数据,train_val.csv是训练和验证用的标签表,sampleSubmission.csv是提交格式样例,0617.csv一般是预测结果或补充测试表。别急着写模型,先在 README 里确认这三类文件能不能对上号。
| 文件 | 角色 | 典型内容 |
|---|---|---|
| candidate*.npz | 三维图像数据 | 每个文件对应一个候选区域,内部可能是多张CT切片组成的序列 |
| train_val.csv | 标签和划分 | 记录每个候选ID对应的类别标签,可能是二分类(阳性/阴性) |
| 0617.csv | 预测或补充集 | 需要预测的样本ID列表,行数要和提交文件一致 |
| sampleSubmission.csv | 格式模板 | 固定列名,通常是ID + 预测概率 |
我一般会先写一个三行代码,把candidate409.npz读进来看shape。这一步往往能避免后面所有维度混乱的问题。你不要假设它一定是(深度, 高, 宽),很多医学数据是(高, 宽, 深度)甚至带通道维,不确认就写进网络,维度报错能让你排一晚上。
2.2 3D体数据的预处理与切片化处理
体数据不能直接喂给全连接层或普通2D卷积,但也不是非得用3D卷积。对于大作业体量,我更推荐先把三维体数据沿轴向切成2D切片序列,再用2D网络提取每张切片的特征,最后做融合。这样训练速度快、显存压力小,而且可视化切片做报告时也非常直观。
预处理通常分四步:一是把体素值裁剪到CT的窗宽窗位范围(比如 [-1000, 400] 之类的区间,具体看README),消除无关组织;二是归一化到 [0,1] 或标准化;三是统一每个candidate的切片数量,不够就补零,多了就均匀采样;四是把每张切片缩放到统一分辨率,常见是 64×64 或 128×128。这三步做完,数据管道就稳定了。
2.3 模型选型:为什么用2D卷积处理3D数据
这个项目场景下,用2D CNN处理3D数据不是偷懒,而是合理选择。一个candidate往往只有几十张切片,用3D卷积网络,参数量大、容易过拟合,而且大作业答辩时很难把3D卷积的中间特征讲清楚。切成2D切片后,用ResNet或VGG提取每张切片的特征向量,再做逐元素平均或取最大值,最后接一个全连接分类头。这种“2.5D”做法在很多医学影像竞赛里都是性价比很高的基线,跑通快、效果稳定。
2.4 训练验证集划分与提交格式对齐
划分数据时要按candidateID而不是按单张切片来切,否则同一个样本的切片会同时出现在训练集和验证集,造成数据泄露。我用StratifiedKFold按标签分层划分,保证正负样本比例在训练和验证里一致。同时,预测阶段输出的行数、列名必须和sampleSubmission.csv严格对齐,不要自己改列名,也不要改变行顺序。
3. 模型实现:用PyTorch搭建切片级特征聚合分类器
3.1 数据读取器DataLoader的实现
先写一个数据读取器,把npz文件和csv标签关联起来。代码实现的基本逻辑是:读CSV拿到候选ID和标签,再根据ID找到npz文件,加载后切片、预处理、缓存。
import numpy as np import torch from torch.utils.data import Dataset class CandidateDataset(Dataset): def __init__(self, csv_path, npz_dir, label_col="label", target_size=64, max_slices=32): # 读取标签表,按候选ID关联文件 self.df = pd.read_csv(csv_path) self.npz_dir = npz_dir self.target_size = target_size self.max_slices = max_slices def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] cand_id = row["id"] # 列名以README为准 label = row.get("label", 0) # 加载npz文件,取第一个数组 data = np.load(f"{self.npz_dir}/{cand_id}.npz") vol = data[data.files[0]].astype(np.float32) # 可能是 (D, H, W) # 统一切片数量:均匀采样或补零 slices = self._fixed_slices(vol, self.max_slices) # 裁剪窗宽、缩放分辨率,得到 (max_slices, 1, target_size, target_size) tensor = self._preprocess(slices) return tensor, torch.tensor(label, dtype=torch.float32) def _fixed_slices(self, vol, max_slices): d = vol.shape[0] if d >= max_slices: idx = np.linspace(0, d - 1, max_slices).astype(int) return vol[idx] else: pad = np.zeros((max_slices - d, *vol.shape[1:]), dtype=np.float32) return np.concatenate([vol, pad], axis=0)这段代码里几个点容易出错:data.files[0]并不总是唯一的数组,有的npz里还附带spacing或origin等元数据,最好先打印data.files看一眼.npz里的前几个数组名再决定取哪个。label也不一定叫这个名字,train_val.csv里可能是class或y,先df.columns输出确认。_preprocess里要把每个切片用cv2.resize或torch.nn.functional.interpolate统一到target_size,并且加上通道维度。
3.2 2D基干网络与聚合层
模型部分我建议直接用torchvision里的resnet18做特征提取器,把最后一层全连接去掉,得到一个512维特征。每张切片过一遍网络,得到(batch, 32, 512)的特征序列,然后沿切片维度做平均池化,压成(batch, 512),再接一个全连接输出二分类 logit。
import torch.nn as nn import torchvision.models as models class SliceAggregationModel(nn.Module): def __init__(self, base_model="resnet18", num_classes=1): super().__init__() # 加载预训练模型,去掉最后的全连接层 weights = models.ResNet18_Weights.DEFAULT self.backbone = models.resnet18(weights=weights) self.backbone.fc = nn.Identity() # 输出 512 维特征 self.classifier = nn.Sequential( nn.Dropout(0.3), nn.Linear(512, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, num_classes) ) def forward(self, x): # x: (batch, max_slices, 3, H, W) batch, slices = x.shape[0], x.shape[1] # 把切片维度合并到batch,过backbone x = x.view(batch * slices, *x.shape[2:]) feats = self.backbone(x) # (batch*slices, 512) feats = feats.view(batch, slices, -1) feats = feats.mean(dim=1) # 平均聚合,也可用max return self.classifier(feats)为什么用平均池化而不是把32张切片直接拼接?因为不同candidate的切片数可能不同,平均池化天然支持变长输入,而且保留每张切片对决策的贡献。如果你发现平均池化把关键特征稀释了,可以换成max(dim=1)或加权注意力,但作为课设基线,平均池化已经够用。
3.3 训练主循环与参数配置
训练时要注意3D数据的batch组织方式:一个batch里是多个candidate,每个candidate内部包含32张切片,实际显存开销是batch_size × max_slices × 3 × 64 × 64。所以batch_size别设太大,我一般从8开始试,显存不够就降到4。
from torch.utils.data import DataLoader from torch.optim import AdamW from torch.nn import BCEWithLogitsLoss dataset = CandidateDataset("train_val.csv", "./npz_files") loader = DataLoader(dataset, batch_size=8, shuffle=True, num_workers=4) model = SliceAggregationModel() optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) criterion = BCEWithLogitsLoss() for epoch in range(20): model.train() for batch_idx, (x, y) in enumerate(loader): x = x.unsqueeze(2).repeat(1, 1, 3, 1, 1) # 灰度图转三通道,适配ImageNet预训练 y = y.unsqueeze(-1) pred = model(x) loss = criterion(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() if batch_idx % 10 == 0: print(f"Epoch {epoch} | Batch {batch_idx} | Loss {loss.item():.4f}")x.unsqueeze(2).repeat(1, 1, 3, 1, 1)这一步是把单通道灰度图复制成三通道,因为resnet18的weights=DEFAULT要求3通道输入。如果你的机器没网下载不了预训练权重,就把weights=None,从头训练,但收敛会慢很多,需要把学习率调到5e-4左右。BCEWithLogitsLoss自动处理 sigmoid,所以推理时记得用torch.sigmoid(pred)得到概率,再和sampleSubmission对齐。
4. 训练策略与性能调优:从基线到稳定过线
4.1 类别不平衡处理与采样策略
医学影像里正样本比例往往偏低,有时只有 10% 左右。直接拿原始分布训练,模型会学成“永远预测负样本”,验证集准确率看着不低,但阳性类别完全无召回。我一般先统计train_val.csv里标签分布,如果正负比小于 1:3,就用WeightedRandomSampler让每个epoch正样本被抽到的次数接近负样本。
from torch.utils.data import WeightedRandomSampler labels = df["label"].values counts = np.bincount(labels.astype(int)) weights = 1.0 / counts[labels.astype(int)] sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) loader = DataLoader(dataset, batch_size=8, sampler=sampler)除了采样,BCEWithLogitsLoss还可以直接传pos_weight,告诉模型正样本分类错误更严重。比如正负比 1:9,就设pos_weight=torch.tensor([9.0])。这两个手段同时用,比只用一个要稳得多,但注意不要过度,否则模型会倾向于把低置信度的负样本也判成正样本,拉低精确率。
4.2 学习率与正则化的实际调节
预训练ResNet微调时,最忌讳一上来就用大的学习率把预训练权重冲掉。常规做法是分两层设置学习率:backbone 用lr=1e-5,分类头用lr=1e-4。这样既保留底层视觉特征,又能让分类头快速适配当前数据。
调度器方面,我习惯用CosineAnnealingLR配合前几个epoch的线性 warmup。前 3 个 epoch 从1e-6线性升到目标学习率,然后余弦退火到接近零。大作业不用追求指标极致,但学习率曲线好看、loss平滑下降,答辩时讲起来很有说服力。
4.3 推理阶段:概率输出如何对齐sampleSubmission
训练完保存的是模型权重,推理时要走完整的数据处理管道:读测试CSV → 加载npz → 固定切片数 → 缩放 → 模型前向 → sigmoid概率。最容易翻车的是测试集里有些candidate的切片数比训练时更少,或者某些candidate文件缺失。
model.eval() preds = [] with torch.no_grad(): for idx in test_df["id"]: data = np.load(f"{npz_dir}/{idx}.npz") vol = data[data.files[0]].astype(np.float32) # 和训练时完全一致的预处理 slices = fixed_slices(vol, max_slices=32) tensor = preprocess(slices).unsqueeze(0).unsqueeze(2).repeat(1, 1, 3, 1, 1) prob = torch.sigmoid(model(tensor)).item() preds.append(prob) sub = pd.DataFrame({"id": test_df["id"], "probability": preds}) sub.to_csv("submission.csv", index=False)输出前用一行代码检查行数:assert len(sub) == len(sampleSubmission),再检查列名:assert list(sub.columns) == list(sampleSubmission.columns)。这两个断言过了,提交基本就不会被判定为格式错误。
5. 实战避坑:最容易翻车的五个点及排查方法
5.1 npz文件读取报维度错误
现象:np.load(...)[data.files[0]]时报AxisError: axis 0 is out of bounds,或者后续卷积时报通道数不是3的维度错误。原因:不同candidate的体数据大小并不一致,有些是(D, H, W),有些可能是(H, W, D),甚至有candidate内部还有(1, D, H, W)的多余维度。解决:用一行print(vol.shape)把全部candidate的shape统计一遍,取众数作为标准顺序;如果存在转置,加一个维度轴判断,if vol.shape[0] < 10: vol = vol.transpose(2, 0, 1)这一类规则统一。
5.2 train_val.csv 与 npz 的ID对不上
现象:训练时大量FileNotFoundError,或者读出来的标签数量远小于csv行数。原因:csv里ID可能是字符串带空格,npz文件名里ID是纯数字,或者csv里有个别行对应不上文件。解决:先把csv的ID列转成统一的字符串格式,df["id"] = df["id"].astype(str).str.strip(),再用集合差找出缺失的ID:set(df["id"]) - set(valid_ids)。这也是一个可以写进文档说明里的数据质量检查步骤。
5.3 训练loss不下降或直接NaN
现象:训练几轮后loss一直是nan,或准确率卡在50%上下。原因:大概率是数据没归一化,灰度值直接进了网络;或者学习率太大。解决:确认输入数据经过了窗宽裁剪和标准化,把vol范围缩到[0,1]或均值0方差1;调小学习率到1e-4以下;如果还出现nan,在optimizer.step()前加一句torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0),防止梯度爆炸。
5.4 提交文件列名或行数不对
现象:评分系统提示格式错误,或本地检查时len(preds) != len(sampleSubmission)。原因:测试集有些ID在预测时因为异常被过滤掉了,或者写了index=False但把索引也输出进去了。解决:推理时逐行处理而非按batch整体预测,遇到异常文件用try捕获并填入默认概率0.5;提交前必须执行两个assert检查行数和列名。
5.5 CPU内存或显存爆掉
现象:加载全部npz后内存占用十几G,训练时CUDA out of memory。原因:在预处理阶段把全部体数据一次性读进内存列表;或者DataLoader的num_workers开太大。解决:保留CandidateDataset的惰性加载,不缓存所有体数据;num_workers设2到4就够;训练时把batch_size降到4,切片数从32减到16,显存会明显下降,精度损失通常可接受。
6. 验收与答辩:用一份报告把过程和结果讲清楚
6.1 验收的五个检查项
课程设计验收不是只看准确率,更看重完整性和可复现性。我建议按下面这份自查表逐项过一遍:一是数据管道可复现,从csv和npz能完整跑出训练数据,别人按README操作能复现;二是代码注释完整,每个函数说明输入输出,关键参数在README或docstring里写清楚;三是指标报告不只是准确率,还要有AUC、召回率、精确率,因为医学影像本身类别不平衡;四是可视化结果,选两个典型的正确分类和两个错误分类样本,把切片序列和对应概率截图保存;五是文档说明里写清楚运行环境,Python版本、PyTorch版本、CUDA版本都要列出来。
6.2 答辩汇报的结构与演示顺序
答辩时间通常只有五到十分钟,不要从模型结构讲起,要按“问题 → 数据 → 难点 → 方案 → 结果”的顺序讲。第一步用两句话说明任务,比如“给定CT候选区域的3D体数据,预测该区域是否为病变”;第二步展示一个candidate的切片序列,强调这是3D数据,常规模型不能直接处理;第三步讲我如何将3D体数据切片化并用2D CNN提取特征;第四步展示训练曲线和最终指标;最后演示一次完整的推理过程,从npz输入到submission输出。
演示时有一个技巧:在线跑一次python train.py或python predict.py,让导师看着进度条从0走到100%,这比任何截图都有说服力。如果没有GPU环境,提前准备好一个已经跑完的结果目录,包括训练日志、模型权重、提交文件,打开终端快速过一遍,再指向结果文件给导师查看。
我做这类项目最深的教训是:模型调参的时间和调数据处理的时间差不多长,但数据处理的问题稍不留神就会吃掉一整晚。从那以后我每次交大作业,都强制自己先写数据检查和格式对齐的代码,再碰模型结构,“先读通数据,再讨论网络”成了一种习惯。希望这篇方案能帮你把数据、代码和答辩串成一条线,少走一点弯路。
本文还有配套的精品资源,点击获取