MONAI 3D multi-label classification
环境配置
- 首次环境配置❌
conda create -n monai python+pip install monai+pip install tensorboard(与tensorboad冲突)conda env remove --name monai
- 二次环境配置❌
conda create -n monai python=3.11+pip install monai(新torch版本2.2.1与旧cuda版本11.6不兼容)conda env remove --name monai
- 三次环境配置【完全参照此处】❌
conda create -n monai python=3.8+pip install "monai-weekly[tqdm, nibabel, gdown, ignite]"(然鹅,这是weekly最新版本的monai🤦)- 一看torch版本还是2.2.1,直接停止install
- 四次环境配置❌
- 安装低版本monai,但不知道为什么,torch版本还是2.2.1,直接停止install
- 换服务器(☞54)🌼❀🎉
- 服务器222的CUDA version是11.6
- 服务器54的CUDA version是12.0
-
conda create -n monai python=3.11+pip install "monai-weekly[tqdm, nibabel, gdown, ignite]"+pip install tensorboard+pip install matplotlib
- 成功✌
Version 0.1.0:实现基本功能
- 训练代码
- 添加超参
import argparse # 添加超参 def options(): parser = argparse.ArgumentParser() parser.add_argument('--gpu_id', type=int, default=0) parser.add_argument('--train_data_folder', type=str, default=r"/homes/xchang/Data/WuHuKidney/3d_classification/train") parser.add_argument('--val_data_folder', type=str, default=r"/homes/xchang/Data/WuHuKidney/3d_classification/val") parser.add_argument('--architecture', type=str, default="monai.DenseNet121") parser.add_argument('--class_number', type=int, default=3) parser.add_argument('--batch_size', type=int, default=9) parser.add_argument('--spatial_size_h',type=int, default=256) parser.add_argument('--spatial_size_w',type=int, default=256) parser.add_argument('--spatial_size_d',type=int, default=50) parser.add_argument('--lr', type=float, default=1e-5) parser.add_argument('--num_epochs', type=int, default=5) parser.add_argument("--val_interval", type=int, default=1) parser.add_argument("--optimizer", default="SGD", help="choose which optimizer, egs. Adam, SGD") parser.add_argument('--seed', type=int, default=1995, help="random seed for python, numpy and pytorch") parser.add_argument('--num_workers', type=int, default=4, help="threads for loading data") parser.add_argument('--checkpoints_save_dir', type=str, default="./monai_dl/checkpoints") args = parser.parse_args() return args args = options() - 固定随机数种子
import random import os import torch import numpy as np def seed_everything(seed, worker_id): np.random.seed(seed) # numpy module: numpy randomness. random.seed(seed) # python random module: python randomness. torch.manual_seed(seed) # pytorch randomness torch.cuda.manual_seed(seed) # gpu randomness torch.cuda.manual_seed_all(seed) # gpu randomness - multi gpu, if you are using multi-GPU. os.environ['PYTHONHASHSEED'] = str(seed) # python hash seed # os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':4096:8' # cuda的确定性:设置以下环境变量来保证cuda的结果可复现 # torch.set_deterministic(True) # 避免原子操作,让模型把某不确定算法转成对应的确定性算法来执行。 # torch.backends.cudnn.enabled = False torch.backends.cudnn.deteministic = True # cuDNN randomness - might make computaion slow # torch.backends.cudnn.benchmark = False # 禁用benchmark,保证可复现,但会损失一定的性能;= True:恢复benchmark,提升效果 # Data loader randomness in multi process # worker_seed = torch.initial_seed() % 2**32 # np.random.seed(worker_seed) # random.seed(worker_seed) seed_everything(args.seed, args.num_workers) - 添加 MONAI transform
# Define transforms for image train_transforms = Compose( [ LoadImaged(keys=["img"], ensure_channel_first=True), ScaleIntensityd(keys=["img"]), # 默认归一化方式(data-data.min)/(data.max-data.min) Resized(keys=["img"], spatial_size=(args.spatial_size_h, args.spatial_size_w, args.spatial_size_d)), RandRotate90d(keys=["img"], prob=0.8, spatial_axes=[0, 1]), ] ) val_transforms = Compose( [ LoadImaged(keys=["img"], ensure_channel_first=True), ScaleIntensityd(keys=["img"]), Resized(keys=["img"], spatial_size=(args.spatial_size_h, args.spatial_size_w, args.spatial_size_d)), ] ) - 添加多种auc计算方式("micro"、"macro"、"weighted")
micro_auc_result = auc_metric.aggregate(average="micro") macro_auc_result = auc_metric.aggregate(average="macro") weighted_auc_result = auc_metric.aggregate(average="weighted") - wandb可视化
import wandb # 初始化wandb wandb.init( # set the wandb project where this run will be logged project="TB-3D-classification", # track hyperparameters and run metadata config={ "class_number": args.class_number, "learning_rate": args.lr, "architecture": args.architecture, "optimizer": args.optimizer, "epochs": args.num_epochs, "batch_size": args.batch_size, "spatial_size": (args.spatial_size_h, args.spatial_size_w, args.spatial_size_d) } ) # log curves to wandb for epoch in range(args.num_epochs): for batch_data in train_loader: train_loss = loss_function(outputs, labels) train_average_loss += train_loss.item() wandb.log({"train_loss_step": train_loss.item()}) train_average_loss /= step if (epoch + 1) % args.val_interval == 0: model.eval() val_average_loss = loss_function(y_pred, y) acc_metric = acc_value.sum().item() / len(acc_value) micro_auc_result = auc_metric.aggregate(average="micro") macro_auc_result = auc_metric.aggregate(average="macro") weighted_auc_result = auc_metric.aggregate(average="weighted") wandb.log({"train_loss_epoch": train_average_loss, "val_loss_epoch": val_average_loss.item(), "val_acc_epoch": acc_metric, "val_auc_epoch (micro)": micro_auc_result, "val_auc_epoch (macro)": macro_auc_result, "val_auc_epoch (weighted)": weighted_auc_result}) # [optional] finish the wandb run, necessary in notebooks wandb.finish()
- 添加超参
- 测试代码
- 添加超参、固定随机数种子
import argparse def options(): # 添加超参 parser = argparse.ArgumentParser() parser.add_argument('--gpu_id', type=int, default=0) parser.add_argument('--test_data_folder', type=str, default="/homes/xchang/Data/WuHuKidney/3d_classification/test") parser.add_argument('--architecture', type=str, default="monai.DenseNet121") parser.add_argument('--class_number', type=int, default=3) parser.add_argument('--batch_size', type=int, default=9) parser.add_argument('--spatial_size_h', type=int, default=256) parser.add_argument('--spatial_size_w', type=int, default=256) parser.add_argument('--spatial_size_d', type=int, default=50) parser.add_argument('--lr', type=float, default=1e-5) parser.add_argument('--num_epochs', type=int, default=5) parser.add_argument("--optimizer", default="SGD", help="choose which optimizer, egs. Adam, SGD") parser.add_argument('--seed', type=int, default=1995, help='random seed for python, numpy and pytorch') parser.add_argument('--num_workers', type=int, default=4, help='# threads for loading data') parser.add_argument('--checkpoints_save_dir', type=str, default="./classification/monai_dl/checkpoints/small_sample") parser.add_argument('--selected_epoch', type=int, default=0) parser.add_argument('--output_dir', type=str, default="./output") parser.add_argument('--label', type=str, default='["DR-TB", "DS-TB", "MDR-TB", "XDR-TB"]') args = parser.parse_args() return args args = options() seed_everything(args.seed, args.num_workers) - 添加 MONAI transform
# Define transforms for image test_transforms = Compose( [ LoadImaged(keys=["img"], ensure_channel_first=True), ScaleIntensityd(keys=["img"]), Resized(keys=["img"], spatial_size=(args.spatial_size_h, args.spatial_size_w, args.spatial_size_d)), EnsureTyped(keys=["img", "label"]) ] ) - 绘制混淆矩阵
import numpy as np import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import confusion_matrix import wandb import os def plot_and_log_confusion_matrix(y_true, y_pred, label_map, class_number, output_dir, wandb_project_name, run_name="confusion_matrix"): """ 计算并绘制混淆矩阵,并上传到wandb Parameters: y_true (array-like): 真实标签 y_pred (array-like): 预测标签 label_map (str): 标签映射的字符串表示形式 class_number (int): 类别数量 output_dir (str): 混淆矩阵图像的保存路径 wandb_project_name (str): wandb项目名称 run_name (str): wandb运行名称 """ # 将标签转换为一维数组 y_true = np.array(y_true).flatten() y_pred = np.array(y_pred).flatten() # 计算混淆矩阵 cm = confusion_matrix(y_true, y_pred) # 绘制混淆矩阵 plt.figure(figsize=(8, 6)) labels = [f'{eval(label_map)[i]}' for i in range(class_number)] sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=labels, yticklabels=labels, annot_kws={"size": 14}) plt.xlabel('Predicted', fontsize=14, labelpad=16) plt.ylabel('True', fontsize=14, labelpad=16) plt.title('Confusion Matrix', fontsize=16, pad=20) plt.xticks(fontsize=14) plt.yticks(fontsize=14) # 调整 colorbar 图例的字体大小 cbar = plt.gcf().axes[-1] cbar.tick_params(labelsize=14) # 调整图形与边缘之间的距离 plt.subplots_adjust(left=0.15, right=0.95, top=0.85, bottom=0.15) # 保存混淆矩阵图像 os.makedirs(output_dir, exist_ok=True) cm_path = os.path.join(output_dir, "confusion_matrix.png") plt.savefig(cm_path, dpi=600) plt.close() # 上传图像到wandb wandb.init(project=wandb_project_name, name=run_name) wandb.log({"confusion_matrix": wandb.Image(cm_path)}) # 结束wandb run wandb.finish()
- 添加超参、固定随机数种子
- 其他
-
明确 val_accuracy 来源
- 一个epoch中所有验证集样本,预测结果与真实标签之间算accuracy的算术平均和
acc_metric = acc_value.sum().item() / len(acc_value) - 每隔val_interval
if (epoch + 1) % val_interval == 0:算一次(初始设置为1,也就是每个epoch算一次) - 注意:
val_loader = DataLoader(val_ds, batch_size=batch_size, num_workers=4, pin_memory=torch.cuda.is_available())val_loader是没有做shuffle的
- 一个epoch中所有验证集样本,预测结果与真实标签之间算accuracy的算术平均和
-
存储验证集acc最高的模型时指明Epoch
for epoch in range(args.num_epochs): if acc_metric > best_metric: torch.save(model.state_dict(), f"{args.checkpoints_save_dir}/best_metric_model_Epoch={epoch}.pth") -
整理了超参,划分成base_options,train_options,test_options进行封装(见 train.py 和 test.py)
-
Version 0.2.0:增加数据不平衡时的解决方案
- 增加了采样策略,解决数据不平衡
⭐在MONAI中使用WeightedRandomSampler解决数据不平衡问题。这个方法确保在每个训练batch中,稀有类别的样本有更高的概率被选中,从而帮助模型更好地学习这些类别的特征。
❤♥注意:DataLoader的sampler选项和shuffle选项是互斥的,这意味着你不能同时使用它们。如果你指定了sampler,就不能再使用shuffle。因为sampler已经决定了样本的选择顺序,而shuffle会随机打乱样本顺序,这两者不能同时使用。# create class WeightedRandomSampler to solve data imbalance among multi-classes labels = [item["label"] for item in train_files] # print("取出的labels: ", labels) # 计算每个类别的样本数量,并计算每个类别的权重。类别权重是样本数量的倒数,这样稀有类别(类别1)的权重会较高。 class_counts = [labels.count(c) for c in set(labels)] class_weights = 1. / torch.tensor(class_counts, dtype=torch.float) # 根据类别权重为每个样本分配权重。稀有类别的样本会得到较高的权重。 sample_weights = class_weights[labels] # 使用 WeightedRandomSampler 创建一个采样器,这个采样器会根据样本权重来进行采样。 sampler = WeightedRandomSampler(weights=sample_weights, num_samples=len(sample_weights), replacement=True) # replacement=True:意味着在采样时样本可以被重复选择。根据你的需求,你可以将其设置为False,但这可能会影响数据平衡效果。 # print(sample_weights) # print(len(sample_weights)) # create a training data loader train_ds = monai.data.Dataset(data=train_files, transform=train_transforms) # 使用上面创建的采样器 sampler 来创建 DataLoader。DataLoader 会在每次迭代时使用采样器来选择样本。 train_loader = DataLoader(train_ds, sampler=sampler, batch_size=args.batch_size, shuffle=False, num_workers=4, pin_memory=torch.cuda.is_available()) # num_workers 和 pin_memory:这些参数用于加速数据加载。如果你在CPU上运行,pin_memory参数可以省略或设置为False。 # 使用这个 data_loader 来训练你的模型。由于使用了 WeightedRandomSampler,DataLoader 在每个 batch 中选择样本时会考虑样本的权重,从而在一定程度上平衡了类别分布。 for epoch in range(args.num_epochs): for batch_data in train_loader: ... inputs, labels = batch_data["img"].to(device), batch_data["label"].to(device) optimizer.zero_grad() outputs = model(inputs) loss = loss_function(outputs, labels) loss.backward() optimizer.step() ...
Version 0.3.0:添加双通道输入功能
- 超参部分
# 新增超参 def options(): # 添加超参 parser = argparse.ArgumentParser() parser.add_argument('--train_data1_folder', type=str, default=r"/homes/xchang/Data/WuHuKidney/3d_classification/train") parser.add_argument('--train_data2_folder', type=str, default=r"/homes/xchang/Data/WuHuKidney/3d_classification/train") parser.add_argument('--val_data1_folder', type=str, default=r"/homes/xchang/Data/WuHuKidney/3d_classification/train") parser.add_argument('--val_data2_folder', type=str, default=r"/homes/xchang/Data/WuHuKidney/3d_classification/val") parser.add_argument('--in_channels', type=int, default=2) args = parser.parse_args() return args args = options() - 获取数据部分
train_files的结果为# 修改了make_dataset函数 train_files, _, _, _, _, _= make_dataset(directory1=args.train_data1_folder, directory2=args.train_data2_folder, extensions=extensions) val_files, _, _, _, _, _= make_dataset(directory1=args.val_data1_folder, directory2=args.val_data2_folder, extensions=extensions)

val_files的结果为

- transform部分
⭐重点关注ConcatImaged这一步;Orientationd、Rotate90d、Flipd旨在统一图像的方位(具体参数含义可询问ChatGPT)# 自定义了一个类完成concatenate操作(将两个图像在指定维度上拼接起来,并将结果存储在数据字典中的一个新键下。) class ConcatImaged(MapTransform): def __init__(self, keys, output_key): super().__init__(keys) self.output_key = output_key def __call__(self, data): image1 = data[self.keys[0]] image2 = data[self.keys[1]] ret = torch.cat([image1, image2], dim=0) data.update({self.output_key: MetaTensor(ret, meta=image1.meta)}) return data # Define transforms for image train_transforms = Compose( [ LoadImaged(keys=["img1", "img2"], ensure_channel_first=True), Orientationd(keys=["img1", "img2"], axcodes="RAS"), # 调整图像方向为 LPS 或者 RAS Rotate90d(keys=["img1", "img2"], spatial_axes=[0, 1]), Flipd(keys=["img1", "img2"], spatial_axis=1), ScaleIntensityd(keys=["img1", "img2"]), Resized(keys=["img1", "img2"], spatial_size=(args.spatial_size_h, args.spatial_size_w, args.spatial_size_d)), ConcatImaged(keys=['img1','img2'], output_key='img'), DeleteItemsd(keys=['img1', 'img2']) ] ) val_transforms = Compose( [ LoadImaged(keys=["img1", "img2"], ensure_channel_first=True), Orientationd(keys=["img1", "img2"], axcodes="RAS"), # 调整图像方向为 LPS Rotate90d(keys=["img1", "img2"], spatial_axes=[0, 1]), Flipd(keys=["img1", "img2"], spatial_axis=1), ScaleIntensityd(keys=["img1", "img2"]), Resized(keys=["img1", "img2"], spatial_size=(args.spatial_size_h, args.spatial_size_w, args.spatial_size_d)), ConcatImaged(keys=['img1','img2'], output_key='img'), DeleteItemsd(keys=['img1', 'img2']) ] ) - 网络初始化部分的in_channels
model = monai.networks.nets.DenseNet121(spatial_dims=3, in_channels=args.in_channels, out_channels=args.class_number).to(device)
Version 0.4.0:添加数据检查功能
- wandb可视化
# 将 3D 图像转换为一组 2D 切片 def log_3d_images_as_slices(datas, labels, predictions, epoch): slices = [] for i, data in enumerate(datas): # 获取3D图像的所有切片 data_no = datas.shape[0] for j in range(data_no): image = np.squeeze(data)[j] for z in range(image.shape[-1]): slice_image = image[..., z] # slice_image = np.transpose(slice_image) slices.append(wandb.Image(slice_image, caption=f"Label: {labels[i]}, Prediction: {predictions[i]}")) wandb.log({f"train_slices_epoch_{epoch}": slices}) # 可视化训练样本和预测结果 if epoch % 1 == 0: # 每个epoch记录一次 images_to_log = inputs.cpu().detach().numpy()[:1] # 只记录前3个样本 labels_to_log = labels.cpu().detach().numpy()[:1] predictions_to_log = outputs.cpu().detach().numpy()[:1] log_3d_images_as_slices(images_to_log, labels_to_log, predictions_to_log, epoch + 1) # 可视化验证样本和预测结果 if (epoch + 1) % args.val_interval == 0: val_images_to_log = val_images.cpu().detach().numpy()[:3] # 只记录前3个样本 val_labels_to_log = val_labels.cpu().detach().numpy()[:3] val_predictions_to_log = y_pred.cpu().detach().numpy()[:3] log_3d_images_as_slices(val_images_to_log, val_labels_to_log, val_predictions_to_log, epoch + 1)
待完成事项
-
添加continue training功能
- 添加save checkpoint per epoch(√)
for epoch in range(num_epochs): torch.save(model.state_dict(), os.path.join(args.checkpoints_save_dir, f"Epoch={epoch}_model_BatchSize={args.batch_size}.pth")) - 如何添加continue training功能暂时还没想好(结合test.py)
- 添加save checkpoint per epoch(√)
-
10折交叉验证
-
测试代码里的评价指标:添加auc
-
整理训练和测试代码
- 封装wandb初始化
- 载入封装好的options

浙公网安备 33010602011771号