简介:面向机器学习课程设计与大作业场景,这份3D医学影像分类实现包覆盖数据预处理、模型训练到结果评估的完整流程,适合计算机、电子信息及数学类专业学生完成期末项目或毕业设计。压缩包共596个文件,主体为582个npz格式的影像特征与样本数据,配合8个Python源码文件实现分类算法,另有csv标注文件、pyc缓存及md文档说明,整体约446MB,结构清晰,便于按模块复现。已有360人学习下载。除完整源代码外,资料附带运行结果与详细注释,采用参数化编程,可灵活调整模型参数和数据路径,遇到问题还可向作者咨询,能有效降低复现门槛,帮助读者快速掌握3D医学影像分类的项目实现思路。
1. 机器学习大作业选3D医学影像分类:一份能直接跑通的候选块方案
机器学习大作业选课题有个反直觉的点:2D图像分类看着简单,却难出新意;3D医学影像分类看着复杂,反而容易做成完整交付方案。原因很简单,这类任务的数据已被整理成固定格式,模型也有成熟的3D CNN套路可用,真正考验人的是把数据读到内存、把标签对上、把提交文件生成对。这套3D医学影像分类方案就围绕这三件事组织:候选块npz存三维影像,csv记录标签,sampleSubmission定义提交格式。适合三类人:计算机、电子信息专业做期末大作业和课程设计的同学,数学专业想快速跑通深度学习实验基准的同学,以及毕设想以医学影像分类为方向的开发者。下面按数据方案、总体设计、关键实现、避坑、验收答辩的顺序,把整套方案讲透。
2. 需求分析与数据组织:npz候选块与三个csv各自的角色
拿到这类交付资源,我的习惯是先翻README.md,再读csv,最后才动npz。README会写明依赖库和启动顺序,是这套方案的入口;train_val.csv、0617.csv、sampleSubmission.csv三个表决定了数据怎么切分、模型怎么评估、结果怎么提交;candidate开头的npz文件则是真正的三位影像内容。如果把数据语义搞反,后面的模型搭建全是在错误地基上盖楼。
2.1 文件清单与字段语义:npz是黑匣子,csv才是说明书
先在代码层面做一次数据摸查,不急着写任何训练逻辑。用下面的脚本分别打开npz和csv,把内部结构全部打印出来:
import numpy as np import pandas as pd # 查看npz内部有哪些键,以及每个键对应的数组形状 data = np.load('candidate409.npz', allow_pickle=True) print(data.files) # 可能输出 ['images'] 或 ['data'] 等键名 for key in data.files: arr = data[key] print(key, arr.shape, arr.dtype) # 查看标注表结构和类别分布 train_val = pd.read_csv('train_val.csv') print(train_val.head()) print(train_val.info()) if 'label' in train_val.columns: print(train_val['label'].value_counts()) else: print(train_val.iloc[:, -1].value_counts())这段代码解决的问题很直接:npz是二进制黑匣子,不打印内部键名就不知道取哪个字段。有的资源里键名叫images,有的叫data,还有的只有一个匿名键;先打印files再写索引,能避免后面几十处地方重复踩KeyError。csv同理,先确认哪一列是样本ID、哪一列是类别标签,再确认正负样本比例,这一步直接影响评估口径的选型。
对这份资源而言,train_val.csv负责训练和验证样本的标注,0617.csv通常是与train_val结构一致的补充标注文件,可以当作额外验证集或者增量训练数据;sampleSubmission.csv是提交模板,里面是待填写的样本ID与预测概率列。落笔之前把三张表的列名对齐,尤其要确认ID的统一格式,是纯数字candidate_id还是带前缀的文件名,会给后面关联npz省下大量麻烦。
2.2 候选块预处理:尺寸统一与归一化的先后顺序
npz里的三维块未必是同一尺寸,不同候选结节可能来自不同大小的检测窗口,直接塞进网络会报维度错误。统一的稳妥做法是缩放到48×48×48,既保留足够的空间信息,又不至于让三维卷积的显存开销失控。这一步推荐用scipy的zoom插值:
import numpy as np from scipy.ndimage import zoom def load_and_unify(npz_path, target_size=(48, 48, 48)): data = np.load(npz_path, allow_pickle=True) # 单键文件直接用第一个键,多键文件按实际字段名匹配 key = data.files[0] if len(data.files) == 1 else 'images' vol = data[key].astype(np.float32) # 按目标尺寸计算三个轴的缩放系数 factors = [t / s for t, s in zip(target_size, vol.shape)] vol = zoom(vol, factors, order=3) # 三次插值保留边缘结构 return volorder=3的三次插值比线性插值更平滑,对结节边缘这类判别区域更友好;如果追求更快速度可以降到order=1,但基线实验不要省这点精度。缩放完成后还要做归一化,这里有个容易忽略的顺序问题:先统一尺寸、再做归一化,而不是反过来。先缩放会让原本分布在某个区间的体素值被插值稀释,后续算出来的均值和方差会漂移。
归一化策略取决于npz内部数值的物理含义。如果体素范围已经是0到1,直接做z-score即可;如果是原始CT值或0到255的灰度,就要先统计min和max再决定用均值方差归一化还是固定窗宽。按通常做法,我会先对训练集全部样本做一次分布统计,把min、max、mean、std打印出来,再选择归一化方式,而不是凭感觉套一个公式跑到底。
2.3 评估口径:为什么AUC比准确率更能反映模型好坏
医学影像二分类任务里,阳性结节通常只占少数,准确率会被多数类拉高,看起来95%很漂亮,实际对阳性样本的识别能力可能很弱。因此验收标准应该用AUC,它不依赖阈值、对类别不平衡更鲁棒,也是这类任务最通用的排序指标。划分数据时要保证训练集和验证集正负比例一致:
from sklearn.model_selection import train_test_split # stratify保证划分后正负样本比例与原始数据一致 train_df, val_df = train_test_split( train_val, test_size=0.2, stratify=train_val['label'], random_state=42, )stratify参数是分层划分的关键,不写的话随机切分可能把少量正样本全分到验证集,导致训练时根本学不到阳性特征。0617.csv先检查列结构是否与train_val一致,一致的话可以留在一边做二次验证,用来回答答辩时“你的模型泛化性怎么样”这类问题。还有一种情况要提前排查:train_val和0617.csv是否包含同一个candidate_id,若有交集必须去重,否则验证集会泄漏训练信息,后面的指标全是虚高,这个问题在避坑章节会专门展开。
3. 总体架构设计:四段式pipeline与参数化配置
数据摸清之后,下一步不是直接写网络,而是先把整套流程拆成模块。很多同学做课设翻车,不是模型不会搭,是流程乱成一团:训练脚本和预处理代码耦合在一起,换个数据路径要改七八处,实验做了一半想换参数,只能全部重跑。这套方案采用四段式结构,把每个阶段彻底分开。
3.1 四段职责划分:数据层、预处理层、模型层、提交层
| 层 | 核心职责 | 主要产物 |
|---|---|---|
| 数据层 | 读取csv、关联npz路径、划分训练验证集 | 样本索引表、DataLoader输入 |
| 预处理层 | 尺寸统一、归一化、数据增强 | 固定shape的Tensor样本 |
| 模型层 | 3D CNN定义、训练循环、指标评估 | 训练日志、最优权重文件 |
| 提交层 | 读取模板、写入概率、校验格式 | submission_final.csv |
四个层之间的依赖方向是单向的:数据层只产出索引和标签,预处理层不关心模型结构,模型层不关心文件路径,提交层只吃模型权重和模板。任何一层要换实现,比如把3D CNN换成本地预训练的特征提取器,或者把npz换成其他格式,只需要改对应模块,其他层不动。这种解耦带来的直接好处是:同一套代码可以快速复现不同实验配置,而不需要“重写一份脚本”。
3.2 参数化配置:改一个yaml就能跑一组新实验
参数化编程是这套资源强调的特点,落到实处就是弄一个独立配置文件,把容易变的值全部抽出来:
data: train_csv: train_val.csv extra_csv: 0617.csv submission: sampleSubmission.csv npz_dir: ./npz preprocess: image_size: 48 normalize: zscore model: base_channels: 16 num_classes: 1 train: batch_size: 8 epochs: 30 lr: 0.001 device: cuda读取端用yaml库加载成字典,训练脚本只从配置取参数:
import yaml def load_config(config_path='config.yaml'): with open(config_path, 'r', encoding='utf-8') as f: return yaml.safe_load(f)这个文件的价值在实验管理上:想换batch_size,改train.batch_size即可;想换输入尺寸,改preprocess.image_size,预处理和模型输入会跟随数据加载器自动变化;想对比不同学习率,复制一份config.yaml改成另一组值,跑出来的结果与旧实验完全隔离。我一般会把config.yaml和训练日志、最优权重放在同一个实验目录下,这样过一个月回来看,还能知道当时的实验条件,不至于对着一个裸权重文件发愁。
3.3 训练到提交的编排流程:每一步的输入输出是什么
整体编排在main函数里按固定顺序执行,每个函数只做一件事:
def main(): cfg = load_config('config.yaml') # 数据层:读表、关联npz路径、分层划分 full_df = load_all_csv(cfg['data']) train_df, val_df = split_with_stratify(full_df) # 预处理层:构建按需读取的Dataset和DataLoader train_loader = build_loader(train_df, cfg, shuffle=True) val_loader = build_loader(val_df, cfg, shuffle=False) # 模型层:初始化网络并训练 model = build_model(cfg['model']) train(model, train_loader, val_loader, cfg['train']) # 提交层:用最优权重生成提交文件 make_submission(model, cfg['data']['submission'])把生成提交文件独立成最后一步,是因为它的输出直接决定作业能不能完成闭环。训练中途打印的指标再好看,模板没填对、ID顺序搞乱,提交结果都是废的。在这个编排里,make_submission只读取模板、按模板顺序写入预测概率、最后做一次空值校验,三件事,边界非常清晰。整套pipeline跑通之后,才算真正把“数据读到内存、标签对上、提交文件生成对”三个交付目标全部落地。
4. 关键实现:3D CNN分类模型搭建与训练细节
流程骨架搭好后,核心就是把网络和数据加载器填进去。这里给出一套能跑通最小闭环的实现:数据加载器按需读npz,模型用三个Conv3d块组成基线,训练循环以验证集AUC为保存权重的依据,最后生成提交文件。这套实现覆盖了从文件到预测结果的全链路。
4.1 数据加载器:按需读取npz,不一次性进内存
数据加载器决定训练能否稳定跑完。最容易翻车的写法是把所有npz读进一个list,样本数量上来后内存直接被占满,训练刚开始就被系统杀掉。正确做法是继承Dataset,在__getitem__里按索引读取:
import os import torch from torch.utils.data import Dataset class CandidateDataset(Dataset): def __init__(self, df, npz_dir, image_size): self.df = df.reset_index(drop=True) self.npz_dir = npz_dir self.image_size = image_size def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] # 样本主键与文件名对应,例如 candidate409.npz npz_path = os.path.join(self.npz_dir, f"candidate{row['candidate_id']}.npz") vol = load_and_unify(npz_path, (self.image_size,) * 3) vol = normalize_volume(vol) # z-score或min-max,按config选择 label = torch.tensor([row['label']], dtype=torch.float32) return torch.from_numpy(vol).unsqueeze(0), label每次按索引加载一个样本,内存里只保留当前batch的数据,这是三维影像任务的基本功。batch_size设为8比较稳妥,48×48×48的输入在单卡上不会太吃力;如果显存只有4G,可以降到4,同时把num_workers设成2到4提升数据读取并发。load_and_unify复用2.2节里的函数,normalize_volume在预处理层实现,保持加载器代码简洁。
4.2 模型结构:三个Conv3d块加分类头,作为基线
医学影像分类的基线不需要一上来就上3D ResNet这类重模型,先把一个简单的3D CNN跑通,确认数据流没问题,再逐步加深才合理。我常用这样的结构:
import torch.nn as nn class Net3D(nn.Module): def __init__(self, in_channels=1, base_channels=16, num_classes=1): super().__init__() self.block1 = self._make_block(in_channels, base_channels) self.block2 = self._make_block(base_channels, base_channels * 2) self.block3 = self._make_block(base_channels * 2, base_channels * 4) self.pool = nn.AdaptiveAvgPool3d(1) self.head = nn.Linear(base_channels * 4, num_classes) def _make_block(self, cin, cout): return nn.Sequential( nn.Conv3d(cin, cout, kernel_size=3, padding=1), nn.BatchNorm3d(cout), nn.ReLU(inplace=True), nn.MaxPool3d(kernel_size=2, stride=2), ) def forward(self, x): x = self.block1(x) x = self.block2(x) x = self.block3(x) x = self.pool(x) return self.head(x.view(x.size(0), -1))输入48×48×48经过三层2倍池化后变成6×6×6,通道数从16扩到64,特征图尺寸缩小但语义信息加深,这是卷积网络提取三维特征的常规路径。BatchNorm3d放在卷积和激活之间,能显著缓解训练初期梯度不稳定;AdaptiveAvgPool3d把任意尺寸的特征图压成1×1×1,保证最后的全连接层输入维度固定。head层不接sigmoid,训练时配合BCEWithLogitsLoss在内部做数值稳定的二分类计算,推理时再取sigmoid得到概率。
4.3 训练循环与提交生成:保存验证AUC最优权重
训练循环的核心不是把epoch跑完,而是把验证集AUC最高的那一轮权重留下来。很多经验不足的写法是保存最后一个epoch的权重,如果后面几轮过拟合,提交效果反而更差:
import torch import torch.nn as nn from sklearn.metrics import roc_auc_score criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=cfg['train']['lr']) best_auc = 0.0 for epoch in range(cfg['train']['epochs']): model.train() for x, y in train_loader: x, y = x.to(device), y.to(device) pred = model(x) loss = criterion(pred, y) optimizer.zero_grad() loss.backward() optimizer.step() # 验证集AUC作为保存依据 model.eval() preds, labels = [], [] with torch.no_grad(): for x, y in val_loader: x = x.to(device) preds.append(torch.sigmoid(model(x)).cpu().numpy()) labels.append(y.numpy()) auc = roc_auc_score(labels, preds) print(f"epoch {epoch+1}, val_auc={auc:.4f}") if auc > best_auc: best_auc = auc torch.save(model.state_dict(), 'best_model.pth')学习率1e-3配合Adam是三维小数据集上比较通用的起点,如果训练loss震荡不下,优先把lr降到1e-4再试。训练结束后生成提交文件,顺序必须以sampleSubmission模板为准:
import pandas as pd def make_submission(model, cfg): sub = pd.read_csv(cfg['data']['submission']) model.load_state_dict(torch.load('best_model.pth')) model.eval() probs = [] for pid in sub['id']: npz_path = f"candidate{pid}.npz" vol = load_and_unify(npz_path, (cfg['preprocess']['image_size'],) * 3) vol = normalize_volume(vol) x = torch.from_numpy(vol).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): probs.append(torch.sigmoid(model(x)).item()) sub['probability'] = probs assert sub['probability'].notna().all(), "提交文件存在空值!" sub.to_csv('submission_final.csv', index=False)按模板id逐个计算概率,而不是对预测结果排序后直接覆盖,这个顺序错了就全错。最后的notna校验是提交前的最后一道防线,存盘前只要有一行空值就抛异常,避免把残缺文件交出去。跑通这一整个闭环,模型的训练、评估、提交三件事才算全部落地。
5. 避坑指南:3D影像分类最容易翻车的五个点
三维医学影像分类的坑比2D任务多一层,因为数据格式不透明、内存开销大、标注不平衡又常见。下面五个点按数据读入、预处理与训练、验证与提交三个阶段梳理,每条都是实际项目中踩过的真实教训。
5.1 数据读入阶段:npz字段名是黑匣子
现象:按资料里描述的字段名取数,一运行就报KeyError,或者打印出来的数组shape和预期完全对不上。
原因:npz是压缩二进制容器,内部键名不是标准化的,不同来源的文件可能叫images、data、volume,甚至只有一个匿名键。直接用写死的键名索引,等于把代码押在一个没验证过的假设上。
解决:在写任何数据处理逻辑之前,先执行2.1节里的files打印脚本,把每个键对应的shape和dtype确认一遍。我的习惯是把这个探查代码直接放在工程入口处,每位拿到项目的人第一眼就能看到数据结构长什么样,后面所有字段索引都从探查结果里来,不再靠猜。
5.2 一次性预读所有npz导致内存被杀
现象:代码在本地小规模测试没问题,一旦加载全部样本,训练刚开始几分钟系统内存就暴涨,甚至直接被操作系统终止。
原因:为了省事把所有npz提前load进list,再拼成一个大数组。48×48×48的float32数组单个约0.44MB,几千个样本累加就是几个GB,加上PyTorch的显存开销和DataLoader的预取缓冲,内存很容易被吃满。
解决:把Dataset改成按需读取,每个__getitem__只load当前样本,处理完就释放;再配合num_workers控制预取线程数。如果还想再省一点,数据集加载顺序用shuffle打乱即可,不需要一次把全部数据搬进内存。
5.3 归一化顺序错乱导致指标像玄学
现象:同样的模型结构,今天跑AUC 0.85,明天换一种归一化方式掉到0.7,怎么看都觉得代码没问题。
原因:先对原始体素做归一化,再做resize插值,插值过程会把归一化后的分布重新扰动;反过来先resize再归一化,统计量才是真实反映缩放后数据的。另一个常见原因是没先统计训练集分布就套用固定均值,导致归一化后数据偏移。
解决:统一顺序为“探查分布 → resize → 归一化”。先用一行代码统计训练集所有样本的min、max、mean、std,再决定用z-score还是固定窗宽,并把统计结果随config一起保存,保证实验可复现。
5.4 类别不平衡时准确率骗人
现象:训练日志里准确率一路涨到0.93,AUC却只有0.55,模型对正样本几乎没有区分能力。
原因:正负样本比例悬殊,模型只要全预测成多数类就能拿到高准确率;此时用准确率做保存权重的依据,选出来的最优权重恰恰是最“偷懒”的那个。
解决:所有评估和模型保存都改用AUC,训练集划分时设置stratify保证正负比例一致;如果类别差异特别大,可以给loss加类别权重,或者对少数类做加权采样,让每个batch里都有足够多的阳性样本参与梯度计算。
5.5 验证集泄漏与提交模板错位
现象:验证集AUC很高,换到0617.csv上评估明显变差;或者提交后返回的结果异常低,检查发现概率行和样本ID对不上。
原因:train_val与0617.csv存在重合的candidate_id,同一个样本既参与训练又参与验证,指标自然虚高;提交阶段如果对预测结果重新排序后再写入模板,ID顺序错位会让整份答案作废。
解决:数据处理阶段先对两份标注表做candidate_id交集检查,有交集就放在同一份训练集里,0617.csv仅用作最终独立验证;生成提交文件时严格用模板的id逐行计算,存盘前断言没有NaN并核对行数等于模板行数。这两步不涉及任何调参技巧,纯粹是流程纪律,但几乎决定整个项目验收能否通过。
6. 验收与答辩:把训练日志和指标讲成加分项
6.1 验收清单:先证明可复现,再谈指标
| 验收项 | 判定标准 | 操作建议 |
|---|---|---|
| 环境复现 | README中的依赖安装后可直接运行 | 用干净环境跑通一次main |
| 训练闭环 | 训练能完整跑完一个epoch且内存稳定 | 观察系统监控,确认无OOM |
| 指标留档 | 保存每个epoch的AUC与最优权重 | 训练日志、config.yaml、best_model.pth放同一目录 |
| 提交合规 | submission_final.csv无空值、行数等于模板 | 运行notna断言并核对长度 |
| 独立验证 | 0617.csv上的评估结果合理 | 训练结束后加载最优权重重新评估 |
这份资源交付时自带README和运行结果,第一轮验收就按清单逐项核对,能避免答辩时被发现训练日志缺失或者权重文件与代码不匹配。
6.2 答辩技巧:讲一个修复过程,胜过背十层网络结构
答辩时常规流程是讲模型结构,但这个项目真正体现工程能力的地方在数据组织与验证策略。可以准备两件素材:一是把npz字段探查和尺寸统一的过程讲清楚,说明你拿到黑匣子数据的第一反应是先看结构再写代码;二是讲一次参数调整,比如lr从1e-2发散、降到1e-3后loss收敛,这类细节比复述Conv3d的padding更让人信服。再补充0617.csv作独立验证的设计,证明你考虑过泛化性而不是只盯着训练集指标。
从那次把验证集混进去导致答辩被追问翻车以后,我每次拿到这种交付型资源,都会强制自己先把训练、验证、提交三组数据分开,再写训练代码,最后核对提交模板顺序,整套流程走一遍才觉得踏实。希望你拿到这套方案后,也按这个顺序跑通一次,把每个环节的产物都留档,答辩时自然有底气。祝顺利。
本文还有配套的精品资源,点击获取