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_intervalif (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的
    • 存储验证集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中,稀有类别的样本有更高的概率被选中,从而帮助模型更好地学习这些类别的特征。
    
    # 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()
        ...
    
    ❤♥注意:DataLoader的sampler选项和shuffle选项是互斥的,这意味着你不能同时使用它们。如果你指定了sampler,就不能再使用shuffle。因为sampler已经决定了样本的选择顺序,而shuffle会随机打乱样本顺序,这两者不能同时使用。

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()
    
  • 获取数据部分
    
    # 修改了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)  
    
    train_files的结果为

    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)
  • 10折交叉验证

  • 测试代码里的评价指标:添加auc

  • 整理训练和测试代码

    • 封装wandb初始化
    • 载入封装好的options
posted @ 2024-08-27 13:27  Elina-Chang  阅读(197)  评论(0)    收藏  举报