From 236a88764eb7920e21431d208042d3f838e314d0 Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Fri, 3 Jul 2026 15:52:41 +0200 Subject: [PATCH 1/2] feat: patch soft-vote ensembling, surface metrics, fold-learner discovery; bump 0.9.2 - vision_patch: PatchInferenceEngine/patch_inference accept a list of models and soft-vote per-patch probabilities in one sliding-window pass; AMP forward pass uses bfloat16; keep_largest_component skipped for multi-class; empty model-list guarded - vision_metrics: calculate_all_metrics and evaluate_segmentations per-case panel with spacing-aware ASSD/HD95/NSD read from a single ground-truth LabelMap - utils: find_fold_learners discovers per-fold best_learner.pkl from an MLflow experiment; log_metrics_table strips non-finite values before logging - vision_loss: DynUNetDSAdapter adapts DynUNet deep-supervision output to the list DeepSupervisionLoss expects - tutorials 12b/12c updated for cross-validation and soft-vote ensemble inference - bump version 0.9.1 -> 0.9.2 --- fastMONAI/__init__.py | 2 +- fastMONAI/_modidx.py | 11 + fastMONAI/utils.py | 91 +++- fastMONAI/vision_loss.py | 30 +- fastMONAI/vision_metrics.py | 96 +++- fastMONAI/vision_patch.py | 91 ++-- nbs/04_vision_loss_functions.ipynb | 22 + nbs/05_vision_metrics.ipynb | 42 +- nbs/07_utils.ipynb | 422 +++++++++++++++++- nbs/10_vision_patch.ipynb | 168 +++++-- nbs/12b_tutorial_patch_cross_validation.ipynb | 35 +- nbs/12c_tutorial_patch_inference.ipynb | 112 ++++- settings.ini | 2 +- 13 files changed, 999 insertions(+), 125 deletions(-) diff --git a/fastMONAI/__init__.py b/fastMONAI/__init__.py index d69d16e..a2fecb4 100644 --- a/fastMONAI/__init__.py +++ b/fastMONAI/__init__.py @@ -1 +1 @@ -__version__ = "0.9.1" +__version__ = "0.9.2" diff --git a/fastMONAI/_modidx.py b/fastMONAI/_modidx.py index b0d0069..9b5b9c8 100644 --- a/fastMONAI/_modidx.py +++ b/fastMONAI/_modidx.py @@ -125,6 +125,7 @@ 'fastMONAI/utils.py'), 'fastMONAI.utils._to_jsonable': ('utils.html#_to_jsonable', 'fastMONAI/utils.py'), 'fastMONAI.utils.create_mlflow_callback': ('utils.html#create_mlflow_callback', 'fastMONAI/utils.py'), + 'fastMONAI.utils.find_fold_learners': ('utils.html#find_fold_learners', 'fastMONAI/utils.py'), 'fastMONAI.utils.load_patch_variables': ('utils.html#load_patch_variables', 'fastMONAI/utils.py'), 'fastMONAI.utils.load_variables': ('utils.html#load_variables', 'fastMONAI/utils.py'), 'fastMONAI.utils.print_colab_gpu_info': ('utils.html#print_colab_gpu_info', 'fastMONAI/utils.py'), @@ -418,6 +419,12 @@ 'fastMONAI/vision_loss.py'), 'fastMONAI.vision_loss.CustomLoss.decodes': ( 'vision_loss_functions.html#customloss.decodes', 'fastMONAI/vision_loss.py'), + 'fastMONAI.vision_loss.DynUNetDSAdapter': ( 'vision_loss_functions.html#dynunetdsadapter', + 'fastMONAI/vision_loss.py'), + 'fastMONAI.vision_loss.DynUNetDSAdapter.__init__': ( 'vision_loss_functions.html#dynunetdsadapter.__init__', + 'fastMONAI/vision_loss.py'), + 'fastMONAI.vision_loss.DynUNetDSAdapter.forward': ( 'vision_loss_functions.html#dynunetdsadapter.forward', + 'fastMONAI/vision_loss.py'), 'fastMONAI.vision_loss.TverskyFocalLoss': ( 'vision_loss_functions.html#tverskyfocalloss', 'fastMONAI/vision_loss.py'), 'fastMONAI.vision_loss.TverskyFocalLoss.__init__': ( 'vision_loss_functions.html#tverskyfocalloss.__init__', @@ -464,6 +471,8 @@ 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.binary_signed_rve': ( 'vision_metrics.html#binary_signed_rve', 'fastMONAI/vision_metrics.py'), + 'fastMONAI.vision_metrics.calculate_all_metrics': ( 'vision_metrics.html#calculate_all_metrics', + 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.calculate_confusion_metrics': ( 'vision_metrics.html#calculate_confusion_metrics', 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.calculate_dsc': ( 'vision_metrics.html#calculate_dsc', @@ -478,6 +487,8 @@ 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.calculate_surface_metrics': ( 'vision_metrics.html#calculate_surface_metrics', 'fastMONAI/vision_metrics.py'), + 'fastMONAI.vision_metrics.evaluate_segmentations': ( 'vision_metrics.html#evaluate_segmentations', + 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.multi_dice_score': ( 'vision_metrics.html#multi_dice_score', 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.multi_lesion_detection_rate': ( 'vision_metrics.html#multi_lesion_detection_rate', diff --git a/fastMONAI/utils.py b/fastMONAI/utils.py index 12932d7..4b51862 100644 --- a/fastMONAI/utils.py +++ b/fastMONAI/utils.py @@ -2,7 +2,8 @@ # %% auto #0 __all__ = ['set_mlflow_tracking_uri', 'store_variables', 'load_variables', 'store_patch_variables', 'load_patch_variables', - 'print_colab_gpu_info', 'ModelTrackingCallback', 'create_mlflow_callback', 'MLflowUIManager'] + 'print_colab_gpu_info', 'ModelTrackingCallback', 'create_mlflow_callback', 'find_fold_learners', + 'MLflowUIManager'] # %% ../nbs/07_utils.ipynb #ac941446-cf7e-4f5c-ace0-a36fb078022f import pickle @@ -731,10 +732,13 @@ def log_metrics_table(self, df, metrics=None, key='validation_metrics', display= summary_data = [] for metric in metrics: - mean = df[metric].mean() - std = df[metric].std() + # Drop +/-inf (e.g. surface distances on one_empty cases) so they don't poison + # the summary; pandas already skips NaN. Show 'N/A' if nothing finite remains. + col = df[metric].replace([float('inf'), float('-inf')], float('nan')) + mean, std = col.mean(), col.std() + mean_str = f'{mean:.4f}' if not math.isnan(mean) else 'N/A' std_str = f'{std:.4f}' if not math.isnan(std) else 'N/A' - summary_data.append([metric, f'{mean:.4f}', std_str]) + summary_data.append([metric, mean_str, std_str]) fig, ax = plt.subplots(figsize=(5, len(metrics) * 0.8 + 0.5)) ax.axis('off') @@ -881,6 +885,85 @@ def create_mlflow_callback( log_split=log_split, ) +# %% ../nbs/07_utils.ipynb #6e7ed65b +def find_fold_learners(experiment_name, artifact_path="model/best_learner.pkl", + n_folds=None, fold_tag="fold"): + """Discover one trained learner checkpoint per cross-validation fold from an MLflow experiment. + + Reads back what ``ModelTrackingCallback`` logs per run: takes the most recent run for each + fold in ``experiment_name`` and downloads ``artifact_path`` from it, returning + ``{fold: local_path}`` sorted by fold. Folds are identified by each run's ``tags.``, + falling back to a ``_`` run name. Warns if the selected folds span more than one + ``dataset_version`` tag (a sign of a partial retrain). + + Pair it with the soft-vote ensembling in ``patch_inference`` -- but the returned values are + PATHS, so load them first:: + + from fastai.learner import load_learner + paths = find_fold_learners("vs5f_unet") + learners = [load_learner(p) for p in paths.values()] + preds = patch_inference(learner=learners, config=config, file_paths=cases) + + Args: + experiment_name: MLflow experiment to search (e.g. ``"vs5f_unet"``). + artifact_path: run-relative artifact to download per fold (default + ``"model/best_learner.pkl"``, what ``ModelTrackingCallback`` logs). + n_folds: optional expected folds for a missing-fold warning -- an int ``n`` (labels + ``1..n``) or an explicit iterable of labels (use this for 0-indexed folds). ``None`` + makes no assumption and just returns what was found. + fold_tag: run tag naming the fold; also the ``_`` run-name prefix fallback. + + Returns: + ``{fold_int: local_path}`` sorted by fold, or ``{}`` if the experiment is missing or + MLflow is unavailable. + """ + from mlflow.exceptions import MlflowException + try: + _ensure_fastmonai_tracking_uri() # respect a user-configured tracking server + exp = mlflow.get_experiment_by_name(experiment_name) + if exp is None: + print(f"No MLflow experiment {experiment_name!r}. Nothing to load.") + return {} + runs = mlflow.search_runs(experiment_ids=[exp.experiment_id], + order_by=["attributes.start_time DESC"]) + except (ImportError, MlflowException) as e: + print(f"MLflow lookup for {experiment_name!r} skipped: {e}") + return {} + + out, fold_dsv = {}, {} + prefix = f"{fold_tag}_" + for _, row in runs.iterrows(): + fold = row.get(f"tags.{fold_tag}") + # pandas yields NaN (a float, != itself), not None, when the column exists but this + # row lacks the tag -- fall back to the run name instead of dropping the run. + if fold is None or (isinstance(fold, float) and fold != fold): + name = row.get("tags.mlflow.runName") or "" + fold = name[len(prefix):] if name.startswith(prefix) else None + try: + fold = int(fold) + except (TypeError, ValueError): + continue + if fold in out: + continue # newest run already kept for this fold (runs are start_time DESC) + try: + out[fold] = mlflow.artifacts.download_artifacts( + run_id=row["run_id"], artifact_path=artifact_path) + except Exception: + continue # fall through to the next-newest run for this fold + fold_dsv[fold] = row.get("tags.dataset_version") + + print(f"Found fold learners for {sorted(out)} in {experiment_name!r}.") + if n_folds is not None: + expected = range(1, n_folds + 1) if isinstance(n_folds, int) else n_folds + missing = [f for f in expected if f not in out] + if missing: + print(f"WARNING: no checkpoint for folds {missing}; ensemble will use {len(out)} model(s).") + distinct_dsv = {v for v in fold_dsv.values() if isinstance(v, str) and v} + if len(distinct_dsv) > 1: + shown = {f: v[:8] for f, v in sorted(fold_dsv.items()) if isinstance(v, str) and v} + print(f"WARNING: selected folds span multiple dataset_versions {shown} - possible partial retrain.") + return dict(sorted(out.items())) + # %% ../nbs/07_utils.ipynb #a3d2d585 import subprocess import threading diff --git a/fastMONAI/vision_loss.py b/fastMONAI/vision_loss.py index d90f707..0d26721 100644 --- a/fastMONAI/vision_loss.py +++ b/fastMONAI/vision_loss.py @@ -1,7 +1,7 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_vision_loss_functions.ipynb. # %% auto #0 -__all__ = ['CustomLoss', 'TverskyFocalLoss'] +__all__ = ['CustomLoss', 'TverskyFocalLoss', 'DynUNetDSAdapter'] # %% ../nbs/04_vision_loss_functions.ipynb #e8eee6b3-75c3-4468-8645-8f6a42a76bfe import torch @@ -105,3 +105,31 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: total_loss: torch.Tensor = tversky_loss ** self.gamma return total_loss + +# %% ../nbs/04_vision_loss_functions.ipynb #acf08940 +class DynUNetDSAdapter(torch.nn.Module): + """Wrap a MONAI ``DynUNet`` with deep supervision so its output matches what + ``monai.losses.DeepSupervisionLoss`` (used via ``CustomLoss``) expects. + + A ``DynUNet(deep_supervision=True)`` returns a *stacked* ``[B, N, C, ...]`` tensor in + training (a TorchScript workaround), but ``DeepSupervisionLoss`` only unbinds a *list* of + tensors -- a stacked tensor would fall through to the base loss and be scored incorrectly. + This adapter unbinds the stack into that list in training, and passes the plain prediction + through in eval mode (MONAI's ``DynUNet`` docstring prescribes exactly this ``torch.unbind``). + + Note: wrapping registers the network as ``self.model``, so it adds a ``model.`` prefix to + state-dict keys -- relevant when loading raw ``.pth`` checkpoints of a wrapped model. + """ + + def __init__(self, model): + super().__init__() + self.model = model + + def forward(self, x): + out = self.model(x) + # Unbind only in training, and only when the wrapped net actually stacks its + # deep-supervision heads. Gating on `deep_supervision` (not output rank) keeps this + # correct for 2D and 3D alike, and fails loudly if wrapped around a non-DynUNet in training. + if self.model.training and self.model.deep_supervision: + return list(torch.unbind(out, dim=1)) + return out diff --git a/fastMONAI/vision_metrics.py b/fastMONAI/vision_metrics.py index ea3aa2e..5e55645 100644 --- a/fastMONAI/vision_metrics.py +++ b/fastMONAI/vision_metrics.py @@ -5,14 +5,17 @@ 'calculate_confusion_metrics', 'binary_sensitivity', 'multi_sensitivity', 'binary_precision', 'multi_precision', 'calculate_lesion_detection_rate', 'binary_lesion_detection_rate', 'multi_lesion_detection_rate', 'calculate_signed_rve', 'binary_signed_rve', 'multi_signed_rve', - 'calculate_surface_metrics', 'AccumulatedDice', 'AccumulatedMultiDice', 'EMACheckpoint'] + 'calculate_surface_metrics', 'calculate_all_metrics', 'evaluate_segmentations', 'AccumulatedDice', + 'AccumulatedMultiDice', 'EMACheckpoint'] # %% ../nbs/05_vision_metrics.ipynb #8b6a83ac import warnings import torch import numpy as np +import pandas as pd from monai.metrics import compute_hausdorff_distance, compute_dice, get_confusion_matrix, compute_confusion_matrix_metric, compute_average_surface_distance, compute_surface_dice from scipy.ndimage import label as scipy_label +from pathlib import Path from .vision_data import pred_to_binary_mask, batch_pred_to_multiclass_mask from fastai.learner import Metric from fastai.callback.tracker import TrackerCallback @@ -506,6 +509,97 @@ def calculate_surface_metrics(pred, targ, spacing_mm, **nsd, "status": "ok", **base} +# %% ../nbs/05_vision_metrics.ipynb #ed336bbf +def calculate_all_metrics(pred, targ, spacing_mm, *, nsd_tolerances_mm=(0.5, 1.0, 2.0), + binary: bool = True) -> dict: + """Full per-case metric panel for one segmentation vs its ground truth. + + Orchestrates the individual ``calculate_*`` functions into one dict: Dice, sensitivity, + precision, lesion detection rate, signed RVE, and the spacing-aware surface metrics + (ASSD / HD95 / NSD in mm). For a whole dataset, use ``evaluate_segmentations``. + + Args: + pred: predicted mask (binary / argmax label). torch.Tensor or np.ndarray, shaped + [W,H,D], [C,W,H,D] or [B,C,W,H,D] with singleton batch/channel dims. + targ: ground-truth mask, same shape convention, on the SAME voxel grid as ``pred``. + spacing_mm: voxel spacing in mm in array-axis (i,j,k) order matching ``targ``. Pass it + explicitly (read from the GT file); a permuted spacing silently corrupts the + surface distances. + nsd_tolerances_mm: NSD tolerance(s) in mm, forwarded to ``calculate_surface_metrics``. + binary: single-foreground-class segmentation (the only mode supported). The confusion + metrics run on the single label channel -- MONAI keeps a size-1 channel when + ``include_background=False``, so a single-channel input is scored correctly. + + Returns: + dict: ``dsc``, ``sensitivity``, ``precision``, ``ldr``, ``rve``, the ``nsd_tau*_mm`` + values, ``assd_mm``, ``hd95_mm``, ``surface_status`` and ``spacing_mm``. + """ + if not binary: + raise NotImplementedError("calculate_all_metrics supports binary (single foreground " + "class) segmentation only.") + + pred_t = pred if isinstance(pred, torch.Tensor) else torch.as_tensor(np.asarray(pred)) + targ_t = targ if isinstance(targ, torch.Tensor) else torch.as_tensor(np.asarray(targ)) + while pred_t.ndim < 5: pred_t = pred_t.unsqueeze(0) # -> [B,C,W,H,D] + while targ_t.ndim < 5: targ_t = targ_t.unsqueeze(0) + if pred_t.shape[-3:] != targ_t.shape[-3:]: + raise ValueError("pred and targ must share a voxel grid; got spatial shapes " + f"{tuple(pred_t.shape[-3:])} vs {tuple(targ_t.shape[-3:])}.") + pred_t, targ_t = pred_t.float(), targ_t.float() + + sm = calculate_surface_metrics(pred_t, targ_t, spacing_mm, nsd_tolerances_mm=nsd_tolerances_mm) + row = { + "dsc": calculate_dsc(pred_t, targ_t).nanmean().item(), + "sensitivity": calculate_confusion_metrics(pred_t, targ_t, "sensitivity").nanmean().item(), + "precision": calculate_confusion_metrics(pred_t, targ_t, "precision").nanmean().item(), + "ldr": calculate_lesion_detection_rate(pred_t, targ_t).nanmean().item(), + "rve": calculate_signed_rve(pred_t, targ_t).nanmean().item(), + } + row.update({k: sm[k] for k in sm if k.startswith("nsd_tau")}) + row["assd_mm"], row["hd95_mm"] = sm["assd_mm"], sm["hd95_mm"] + row["surface_status"], row["spacing_mm"] = sm["status"], sm["spacing_mm"] + return row + +# %% ../nbs/05_vision_metrics.ipynb #2e526be3 +def evaluate_segmentations(preds, gt_paths, case_ids=None, *, + nsd_tolerances_mm=(0.5, 1.0, 2.0), binary: bool = True): + """Score a batch of predictions against ground-truth masks into a per-case DataFrame. + + For each (prediction, ground-truth) pair, the GT mask AND its voxel spacing are read from a + SINGLE object, so the array and its spacing can never disagree -- reading spacing from a + separately-loaded file is a silent source of axis-permutation errors that corrupt the surface + metrics. Predictions may be tensors (as returned by ``patch_inference``) or paths to saved + masks, so predictions written with ``patch_inference(save_dir=...)`` can be re-scored without + re-running inference. + + Args: + preds: sequence of predicted masks -- torch.Tensor / np.ndarray, or paths to mask files. + gt_paths: sequence of ground-truth mask file paths (one per prediction, same voxel grid). + case_ids: optional row labels; default is each GT file's name stem. + nsd_tolerances_mm: NSD tolerance(s) in mm, forwarded to the surface metrics. + binary: binary (single foreground class) segmentation only. + + Returns: + pandas.DataFrame with one row per case: ``case_id`` plus the columns from + ``calculate_all_metrics``. + """ + import torchio as tio # lazy: only this benchmark helper needs torchio I/O + preds, gt_paths = list(preds), list(gt_paths) + if len(preds) != len(gt_paths): + raise ValueError(f"preds and gt_paths must have equal length; got {len(preds)} and " + f"{len(gt_paths)}.") + + rows = [] + for i, (pred, gt_path) in enumerate(zip(preds, gt_paths)): + gt = tio.LabelMap(gt_path) # single source: .data and .spacing agree + pred_data = tio.LabelMap(pred).data if isinstance(pred, (str, Path)) else pred + case_id = case_ids[i] if case_ids is not None else Path(str(gt_path)).name.split(".")[0] + row = {"case_id": case_id} + row.update(calculate_all_metrics(pred_data, gt.data, gt.spacing, + nsd_tolerances_mm=nsd_tolerances_mm, binary=binary)) + rows.append(row) + return pd.DataFrame(rows) + # %% ../nbs/05_vision_metrics.ipynb #js7clwse6n class AccumulatedDice(Metric): """nnU-Net-style accumulated Dice metric for reliable pseudo dice during training. diff --git a/fastMONAI/vision_patch.py b/fastMONAI/vision_patch.py index f4239db..c55ac90 100644 --- a/fastMONAI/vision_patch.py +++ b/fastMONAI/vision_patch.py @@ -98,7 +98,9 @@ class PatchConfig: Can be int, float, or string (e.g., 'minimum', 'mean'). keep_largest_component: If True, keep only the largest connected component in binary segmentation predictions. Only applies during inference when - return_probabilities=False. Defaults to False. + return_probabilities=False. Defaults to False. Binary-only: for multi-class + output (more than 2 classes) it is skipped with a warning, since it would + otherwise merge all classes into one blob and collapse the labels. binary_threshold: Decision boundary for single-channel (sigmoid) masks; a voxel is foreground when probability >= binary_threshold (matches MONAI AsDiscrete). Only applies when return_probabilities=False and n_classes == 1. Defaults to 0.5. @@ -1009,29 +1011,34 @@ def _normalize_patch_overlap(patch_overlap: 'int | float | list', patch_size: li def _logits_to_probs(logits: torch.Tensor) -> torch.Tensor: """Logits -> probabilities: sigmoid for 1 channel (binary), else softmax over the channel dim. - Upcasts to float32 (logits may be float16 under AMP). Does NOT move to CPU; callers decide + Upcasts to float32 (logits may be bfloat16 under AMP). Does NOT move to CPU; callers decide placement (TTA keeps tensors on-device to flip back; the non-TTA path moves to CPU immediately). """ return torch.sigmoid(logits.float()) if logits.shape[1] == 1 else torch.softmax(logits.float(), dim=1) -def _predict_patch_tta(model, patch_input, amp_context=None) -> torch.Tensor: - """Mirror TTA: average probabilities over the 8 flip combinations. +def _predict_patch_tta(models, patch_input, amp_context=None) -> torch.Tensor: + """Mirror TTA (with optional model ensemble): average probabilities over the 8 flips and all models. - Uses a running sum (2x memory, not 9x). Each pass: flip -> forward -> activate -> flip back -> accumulate. - Returns the averaged probability tensor [B, C, D, H, W] on CPU. amp_context, if given, wraps the forward pass. + models is a single model or a list of models; a list soft-votes (averages probabilities). + Uses a running sum (2x memory, not 9x per model). Each pass: flip -> forward -> activate -> + flip back -> accumulate. Returns the averaged probability tensor [B, C, D, H, W] on CPU. + amp_context, if given, wraps each forward pass. """ if amp_context is None: amp_context = nullcontext() + if not isinstance(models, (list, tuple)): + models = [models] summed_probs = None for axes in _TTA_FLIP_AXES: flipped = torch.flip(patch_input, list(axes)) if axes else patch_input - with amp_context: - logits = model(flipped) - probs = _logits_to_probs(logits) - if axes: - probs = torch.flip(probs, list(axes)) - summed_probs = probs if summed_probs is None else summed_probs + probs - return (summed_probs / len(_TTA_FLIP_AXES)).cpu() + for model in models: + with amp_context: + logits = model(flipped) + probs = _logits_to_probs(logits) + if axes: + probs = torch.flip(probs, list(axes)) + summed_probs = probs if summed_probs is None else summed_probs + probs + return (summed_probs / (len(_TTA_FLIP_AXES) * len(models))).cpu() @dataclass @@ -1053,8 +1060,10 @@ class PatchInferenceEngine: GridAggregator to reconstruct the full volume from predictions. Args: - learner: fastai Learner or PyTorch model (nn.Module). When passing a raw - PyTorch model, load weights first with model.load_state_dict(). + learner: fastai Learner or PyTorch model (nn.Module), or a list of them for + soft-vote ensembling (per-patch probabilities are averaged before argmax/ + threshold). When passing a raw PyTorch model, load weights first with + model.load_state_dict(). config: PatchConfig with inference settings. Preprocessing params (apply_reorder, target_spacing, padding_mode) can be set here for DRY usage. apply_reorder: Whether to reorder to RAS+ orientation. If None, uses config value. @@ -1064,7 +1073,7 @@ class PatchInferenceEngine: read from config.normalization (the default source of truth, set at training time). Provide this only to override the config (e.g. for non-serializable transforms). Accepts both fastMONAI wrappers and raw TorchIO transforms. - amp: If True, use automatic mixed precision (float16) for the forward pass. + amp: If True, use automatic mixed precision (bfloat16) for the forward pass. Only supported on CUDA devices; ignored with a warning on CPU/MPS. Defaults to False. @@ -1090,10 +1099,11 @@ def __init__( # We check for Learner explicitly because some models (e.g., MONAI UNet) have a # .model attribute that is NOT the full model but an internal Sequential. - if isinstance(learner, Learner): - self.model = learner.model - else: - self.model = learner # already a PyTorch model + _learners = list(learner) if isinstance(learner, (list, tuple)) else [learner] + if len(_learners) == 0: + raise ValueError("learner must be a model/Learner or a non-empty list of them.") + self.models = [l.model if isinstance(l, Learner) else l for l in _learners] + self.model = self.models[0] # first model; alias kept for single-model back-compat self.config = config self.sw_batch_size = sw_batch_size @@ -1123,11 +1133,12 @@ def __init__( self._device = _get_default_device() # Set eval mode once at construction (this class is inference-only) - self.model.eval() + for _m in self.models: + _m.eval() # AMP: CUDA-only, matching nnU-Net pattern if amp and self._device.type == 'cuda': - self._amp_context = torch.amp.autocast('cuda', dtype=torch.float16) + self._amp_context = torch.amp.autocast('cuda', dtype=torch.bfloat16) else: if amp and self._device.type != 'cuda': warnings.warn("AMP is only supported on CUDA devices. Ignoring amp=True.") @@ -1182,9 +1193,10 @@ def _prepare_subject(self, img_path: Path | str) -> _PreparedSubject: ) def _run_inference(self, prepared: _PreparedSubject, tta: bool = False) -> torch.Tensor: - """Run the model over all patches and aggregate; returns the raw probability tensor. + """Run the model(s) over all patches and aggregate; returns the raw probability tensor. Must run on the main thread (model forward pass). tta enables mirror test-time augmentation. + With multiple models the per-patch probabilities are averaged (soft voting). """ # inference_mode is slightly faster than no_grad (disables autograd tracking # and view tracking). Safe here since we don't do in-place ops on outputs. @@ -1194,11 +1206,15 @@ def _run_inference(self, prepared: _PreparedSubject, tta: bool = False) -> torch locations = patches_batch[tio.LOCATION] if tta: - probs = _predict_patch_tta(self.model, patch_input, self._amp_context) + probs = _predict_patch_tta(self.models, patch_input, self._amp_context) else: - with self._amp_context: - logits = self.model(patch_input) - probs = _logits_to_probs(logits).cpu() + summed = None + for _m in self.models: + with self._amp_context: + logits = _m(patch_input) + p = _logits_to_probs(logits) + summed = p if summed is None else summed + p + probs = (summed / len(self.models)).cpu() prepared.aggregator.add_batch(probs, locations) @@ -1224,8 +1240,15 @@ def _postprocess( result = output.argmax(dim=0, keepdim=True).float() if not return_probabilities and self.config.keep_largest_component: - from fastMONAI.vision_inference import keep_largest - result = keep_largest(result.squeeze(0)).unsqueeze(0) + if n_classes > 2: + warnings.warn( + f"keep_largest_component is binary-only; skipping it for multi-class output " + f"(n_classes={n_classes}). Set keep_largest_component=False for multi-class " + f"models, or post-process each class separately." + ) + else: + from fastMONAI.vision_inference import keep_largest + result = keep_largest(result.squeeze(0)).unsqueeze(0) if return_probabilities: pred_img = tio.ScalarImage(tensor=result.float(), affine=prepared.input_img.affine) @@ -1281,9 +1304,10 @@ def predict( return result def to(self, device): - """Move engine to device.""" + """Move engine (all models) to device.""" self._device = device - self.model.to(device) + for _m in self.models: + _m.to(device) return self # %% ../nbs/10_vision_patch.ipynb #cell-18 @@ -1347,7 +1371,8 @@ def patch_inference( GPU compute use different hardware. Args: - learner: PyTorch model or fastai Learner. + learner: PyTorch model or fastai Learner, or a list of them to soft-vote + ensemble (per-patch probabilities are averaged before argmax/threshold). config: PatchConfig with inference settings. Preprocessing params (apply_reorder, target_spacing) can be set here for DRY usage. file_paths: List of image paths. @@ -1365,7 +1390,7 @@ def patch_inference( thread for preparation and saving. Holds two subjects in memory simultaneously (current + next). Set to False for memory-constrained environments processing very large volumes. - amp: If True, use automatic mixed precision (float16) for the forward pass. + amp: If True, use automatic mixed precision (bfloat16) for the forward pass. Only supported on CUDA devices; ignored with a warning on CPU/MPS. Returns: diff --git a/nbs/04_vision_loss_functions.ipynb b/nbs/04_vision_loss_functions.ipynb index 1186990..8a1b519 100644 --- a/nbs/04_vision_loss_functions.ipynb +++ b/nbs/04_vision_loss_functions.ipynb @@ -50,6 +50,28 @@ "outputs": [], "source": "#| export\nclass TverskyFocalLoss(_Loss):\n \"\"\"Focal Tversky loss: ``(1 - TI)**gamma`` (TI = Tversky index; see ``monai.losses.TverskyLoss``).\n ``gamma=1`` reduces to the plain Tversky loss.\n \"\"\"\n\n def __init__(\n self,\n include_background: bool = True,\n to_onehot_y: bool = False,\n sigmoid: bool = False,\n softmax: bool = False,\n gamma: float = 2,\n alpha: float = 0.5, \n beta: float = 0.99):\n \"\"\"\n Args:\n include_background: if to calculate loss for the background class.\n to_onehot_y: whether to convert `y` into one-hot format.\n sigmoid: if True, apply a sigmoid function to the prediction.\n softmax: if True, apply a softmax function to the prediction.\n gamma: focal exponent in ``(1 - TI)**gamma``.\n alpha: the weight of false positive in Tversky loss calculation.\n beta: the weight of false negative in Tversky loss calculation.\n \"\"\"\n \n super().__init__()\n self.tversky = TverskyLoss(\n to_onehot_y=to_onehot_y, \n include_background=include_background, \n sigmoid=sigmoid, \n softmax=softmax, \n alpha=alpha, \n beta=beta\n )\n self.gamma = gamma\n\n def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:\n \"\"\"\n Args:\n input: the shape should be [B, C, W, H, D]. The input should be the original logits.\n target: the shape should be[B, C, W, H, D].\n\n Raises:\n ValueError: When number of dimensions for input and target are different.\n \"\"\"\n if len(input.shape) != len(target.shape):\n raise ValueError(\"The number of dimensions for input and target should be the same.\")\n\n # (1 - TI)**gamma; MONAI's TverskyLoss already returns tversky_loss = 1 - TI\n tversky_loss = self.tversky(input, target)\n total_loss: torch.Tensor = tversky_loss ** self.gamma\n\n return total_loss" }, + { + "cell_type": "markdown", + "id": "2eedc7ca", + "metadata": {}, + "source": "## DynUNet deep-supervision adapter" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acf08940", + "metadata": {}, + "outputs": [], + "source": "#| export\nclass DynUNetDSAdapter(torch.nn.Module):\n \"\"\"Wrap a MONAI ``DynUNet`` with deep supervision so its output matches what\n ``monai.losses.DeepSupervisionLoss`` (used via ``CustomLoss``) expects.\n\n A ``DynUNet(deep_supervision=True)`` returns a *stacked* ``[B, N, C, ...]`` tensor in\n training (a TorchScript workaround), but ``DeepSupervisionLoss`` only unbinds a *list* of\n tensors -- a stacked tensor would fall through to the base loss and be scored incorrectly.\n This adapter unbinds the stack into that list in training, and passes the plain prediction\n through in eval mode (MONAI's ``DynUNet`` docstring prescribes exactly this ``torch.unbind``).\n\n Note: wrapping registers the network as ``self.model``, so it adds a ``model.`` prefix to\n state-dict keys -- relevant when loading raw ``.pth`` checkpoints of a wrapped model.\n \"\"\"\n\n def __init__(self, model):\n super().__init__()\n self.model = model\n\n def forward(self, x):\n out = self.model(x)\n # Unbind only in training, and only when the wrapped net actually stacks its\n # deep-supervision heads. Gating on `deep_supervision` (not output rank) keeps this\n # correct for 2D and 3D alike, and fails loudly if wrapped around a non-DynUNet in training.\n if self.model.training and self.model.deep_supervision:\n return list(torch.unbind(out, dim=1))\n return out" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61b58857", + "metadata": {}, + "outputs": [], + "source": "#| hide\n# DynUNetDSAdapter: train -> list (for DeepSupervisionLoss), eval -> plain tensor.\n# Uses a minimal stand-in that stacks heads on dim=1 like DynUNet(deep_supervision=True),\n# so the check is dimension-agnostic (the reason we gate on `deep_supervision`, not ndim).\nfrom fastcore.test import test_eq\n\nclass _StubDS(torch.nn.Module):\n def __init__(self, n_heads=3):\n super().__init__()\n self.deep_supervision = True\n self.n_heads = n_heads\n def forward(self, x):\n return torch.stack([x] * self.n_heads, dim=1) if self.training else x\n\n# 3D\n_ad = DynUNetDSAdapter(_StubDS(n_heads=3))\n_x = torch.rand(2, 2, 8, 8, 8)\n_ad.train()\n_out = _ad(_x)\ntest_eq(isinstance(_out, list), True)\ntest_eq(len(_out), 3)\ntest_eq(_out[0].shape, _x.shape)\n_ad.eval()\ntest_eq(isinstance(_ad(_x), torch.Tensor), True)\ntest_eq(_ad(_x).shape, _x.shape)\n\n# 2D: the deep-supervision stack is 5-D, not 6-D; gating on the flag still unbinds correctly\n# (an `out.ndim == 6` check would silently fail here).\n_ad2 = DynUNetDSAdapter(_StubDS(n_heads=2)); _ad2.train()\ntest_eq(len(_ad2(torch.rand(2, 2, 8, 8))), 2)" + }, { "cell_type": "code", "execution_count": null, diff --git a/nbs/05_vision_metrics.ipynb b/nbs/05_vision_metrics.ipynb index 0354cd3..2d39073 100644 --- a/nbs/05_vision_metrics.ipynb +++ b/nbs/05_vision_metrics.ipynb @@ -16,17 +16,7 @@ "id": "8b6a83ac", "metadata": {}, "outputs": [], - "source": [ - "#| export\n", - "import warnings\n", - "import torch\n", - "import numpy as np\n", - "from monai.metrics import compute_hausdorff_distance, compute_dice, get_confusion_matrix, compute_confusion_matrix_metric, compute_average_surface_distance, compute_surface_dice\n", - "from scipy.ndimage import label as scipy_label\n", - "from fastMONAI.vision_data import pred_to_binary_mask, batch_pred_to_multiclass_mask\n", - "from fastai.learner import Metric\n", - "from fastai.callback.tracker import TrackerCallback" - ] + "source": "#| export\nimport warnings\nimport torch\nimport numpy as np\nimport pandas as pd\nfrom monai.metrics import compute_hausdorff_distance, compute_dice, get_confusion_matrix, compute_confusion_matrix_metric, compute_average_surface_distance, compute_surface_dice\nfrom scipy.ndimage import label as scipy_label\nfrom pathlib import Path\nfrom fastMONAI.vision_data import pred_to_binary_mask, batch_pred_to_multiclass_mask\nfrom fastai.learner import Metric\nfrom fastai.callback.tracker import TrackerCallback" }, { "cell_type": "markdown", @@ -688,6 +678,36 @@ " \"status\": \"ok\", **base}" ] }, + { + "cell_type": "markdown", + "id": "383333ca", + "metadata": {}, + "source": "## Full metric panel (per-case and batch)" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed336bbf", + "metadata": {}, + "outputs": [], + "source": "#| export\ndef calculate_all_metrics(pred, targ, spacing_mm, *, nsd_tolerances_mm=(0.5, 1.0, 2.0),\n binary: bool = True) -> dict:\n \"\"\"Full per-case metric panel for one segmentation vs its ground truth.\n\n Orchestrates the individual ``calculate_*`` functions into one dict: Dice, sensitivity,\n precision, lesion detection rate, signed RVE, and the spacing-aware surface metrics\n (ASSD / HD95 / NSD in mm). For a whole dataset, use ``evaluate_segmentations``.\n\n Args:\n pred: predicted mask (binary / argmax label). torch.Tensor or np.ndarray, shaped\n [W,H,D], [C,W,H,D] or [B,C,W,H,D] with singleton batch/channel dims.\n targ: ground-truth mask, same shape convention, on the SAME voxel grid as ``pred``.\n spacing_mm: voxel spacing in mm in array-axis (i,j,k) order matching ``targ``. Pass it\n explicitly (read from the GT file); a permuted spacing silently corrupts the\n surface distances.\n nsd_tolerances_mm: NSD tolerance(s) in mm, forwarded to ``calculate_surface_metrics``.\n binary: single-foreground-class segmentation (the only mode supported). The confusion\n metrics run on the single label channel -- MONAI keeps a size-1 channel when\n ``include_background=False``, so a single-channel input is scored correctly.\n\n Returns:\n dict: ``dsc``, ``sensitivity``, ``precision``, ``ldr``, ``rve``, the ``nsd_tau*_mm``\n values, ``assd_mm``, ``hd95_mm``, ``surface_status`` and ``spacing_mm``.\n \"\"\"\n if not binary:\n raise NotImplementedError(\"calculate_all_metrics supports binary (single foreground \"\n \"class) segmentation only.\")\n\n pred_t = pred if isinstance(pred, torch.Tensor) else torch.as_tensor(np.asarray(pred))\n targ_t = targ if isinstance(targ, torch.Tensor) else torch.as_tensor(np.asarray(targ))\n while pred_t.ndim < 5: pred_t = pred_t.unsqueeze(0) # -> [B,C,W,H,D]\n while targ_t.ndim < 5: targ_t = targ_t.unsqueeze(0)\n if pred_t.shape[-3:] != targ_t.shape[-3:]:\n raise ValueError(\"pred and targ must share a voxel grid; got spatial shapes \"\n f\"{tuple(pred_t.shape[-3:])} vs {tuple(targ_t.shape[-3:])}.\")\n pred_t, targ_t = pred_t.float(), targ_t.float()\n\n sm = calculate_surface_metrics(pred_t, targ_t, spacing_mm, nsd_tolerances_mm=nsd_tolerances_mm)\n row = {\n \"dsc\": calculate_dsc(pred_t, targ_t).nanmean().item(),\n \"sensitivity\": calculate_confusion_metrics(pred_t, targ_t, \"sensitivity\").nanmean().item(),\n \"precision\": calculate_confusion_metrics(pred_t, targ_t, \"precision\").nanmean().item(),\n \"ldr\": calculate_lesion_detection_rate(pred_t, targ_t).nanmean().item(),\n \"rve\": calculate_signed_rve(pred_t, targ_t).nanmean().item(),\n }\n row.update({k: sm[k] for k in sm if k.startswith(\"nsd_tau\")})\n row[\"assd_mm\"], row[\"hd95_mm\"] = sm[\"assd_mm\"], sm[\"hd95_mm\"]\n row[\"surface_status\"], row[\"spacing_mm\"] = sm[\"status\"], sm[\"spacing_mm\"]\n return row" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e526be3", + "metadata": {}, + "outputs": [], + "source": "#| export\ndef evaluate_segmentations(preds, gt_paths, case_ids=None, *,\n nsd_tolerances_mm=(0.5, 1.0, 2.0), binary: bool = True):\n \"\"\"Score a batch of predictions against ground-truth masks into a per-case DataFrame.\n\n For each (prediction, ground-truth) pair, the GT mask AND its voxel spacing are read from a\n SINGLE object, so the array and its spacing can never disagree -- reading spacing from a\n separately-loaded file is a silent source of axis-permutation errors that corrupt the surface\n metrics. Predictions may be tensors (as returned by ``patch_inference``) or paths to saved\n masks, so predictions written with ``patch_inference(save_dir=...)`` can be re-scored without\n re-running inference.\n\n Args:\n preds: sequence of predicted masks -- torch.Tensor / np.ndarray, or paths to mask files.\n gt_paths: sequence of ground-truth mask file paths (one per prediction, same voxel grid).\n case_ids: optional row labels; default is each GT file's name stem.\n nsd_tolerances_mm: NSD tolerance(s) in mm, forwarded to the surface metrics.\n binary: binary (single foreground class) segmentation only.\n\n Returns:\n pandas.DataFrame with one row per case: ``case_id`` plus the columns from\n ``calculate_all_metrics``.\n \"\"\"\n import torchio as tio # lazy: only this benchmark helper needs torchio I/O\n preds, gt_paths = list(preds), list(gt_paths)\n if len(preds) != len(gt_paths):\n raise ValueError(f\"preds and gt_paths must have equal length; got {len(preds)} and \"\n f\"{len(gt_paths)}.\")\n\n rows = []\n for i, (pred, gt_path) in enumerate(zip(preds, gt_paths)):\n gt = tio.LabelMap(gt_path) # single source: .data and .spacing agree\n pred_data = tio.LabelMap(pred).data if isinstance(pred, (str, Path)) else pred\n case_id = case_ids[i] if case_ids is not None else Path(str(gt_path)).name.split(\".\")[0]\n row = {\"case_id\": case_id}\n row.update(calculate_all_metrics(pred_data, gt.data, gt.spacing,\n nsd_tolerances_mm=nsd_tolerances_mm, binary=binary))\n rows.append(row)\n return pd.DataFrame(rows)" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2fcc148b", + "metadata": {}, + "outputs": [], + "source": "#| hide\nimport tempfile\nfrom pathlib import Path as _Path\nimport torchio as tio\nfrom fastcore.test import test_eq, test_close, test_fail\n\n# calculate_all_metrics: a perfect prediction scores DSC ~1 and ASSD 0.\n_cube = np.zeros((16, 16, 16), np.float32); _cube[4:12, 4:12, 4:12] = 1.0\n_m = calculate_all_metrics(_cube, _cube, spacing_mm=(1.0, 1.0, 1.0))\ntest_close(_m[\"dsc\"], 1.0, eps=1e-4)\ntest_close(_m[\"assd_mm\"], 0.0, eps=1e-6)\ntest_eq(_m[\"sensitivity\"] > 0.99, True)\ntest_eq(_m[\"precision\"] > 0.99, True)\n\n# guards: multiclass not supported; mismatched grids raise loudly.\ntest_fail(lambda: calculate_all_metrics(_cube, _cube, (1, 1, 1), binary=False))\ntest_fail(lambda: calculate_all_metrics(_cube, np.zeros((8, 8, 8), np.float32), (1, 1, 1)))\n\n# evaluate_segmentations: pred as tensor AND as path; spacing read from the GT object.\nwith tempfile.TemporaryDirectory() as _d:\n _gt = _Path(_d) / \"case_seg.nii.gz\"\n tio.LabelMap(tensor=torch.from_numpy(_cube)[None], affine=np.diag([1., 1., 2., 1.])).save(_gt)\n _df = evaluate_segmentations([torch.from_numpy(_cube)[None], str(_gt)], [str(_gt), str(_gt)])\n test_eq(len(_df), 2)\n test_eq(\"case_id\" in _df.columns, True)\n test_close(_df[\"dsc\"].iloc[0], 1.0, eps=1e-4)\n test_eq(tuple(_df[\"spacing_mm\"].iloc[0]), (1.0, 1.0, 2.0)) # anisotropic spacing from GT" + }, { "cell_type": "markdown", "id": "1jvtp79rnxf", diff --git a/nbs/07_utils.ipynb b/nbs/07_utils.ipynb index 71661f0..5387a52 100644 --- a/nbs/07_utils.ipynb +++ b/nbs/07_utils.ipynb @@ -288,7 +288,405 @@ "id": "030876d2", "metadata": {}, "outputs": [], - "source": "#| export\nclass ModelTrackingCallback(Callback):\n \"\"\"\n A FastAI callback for comprehensive MLflow experiment tracking.\n \n This callback automatically logs hyperparameters, metrics, model artifacts,\n and configuration to MLflow during training. If a checkpoint callback\n (SaveModelCallback, EMACheckpoint, or any TrackerCallback with fname) is\n present, the best model checkpoint will also be logged as an artifact.\n \n Supports auto-managed runs when created via `create_mlflow_callback()`.\n \"\"\"\n \n def __init__(\n self, \n model_name: str, \n loss_function: str, \n item_tfms: list[Any],\n size: list[int], \n target_spacing: list[float], \n apply_reorder: bool,\n experiment_name: str = None,\n run_name: str = None,\n auto_start: bool = False,\n patch_config: dict = None,\n extra_params: dict = None,\n extra_tags: dict = None,\n dataset_version: str = None,\n log_split: bool = True\n ):\n \"\"\"\n Initialize the MLflow tracking callback.\n \n Args:\n model_name: Name/identifier for the model being tracked\n loss_function: Name of the loss function being used\n item_tfms: List of item transforms\n size: Model input dimensions\n target_spacing: Resampling dimensions\n apply_reorder: Whether reordering augmentation is applied\n experiment_name: MLflow experiment name (used with auto_start)\n run_name: MLflow run name (auto-generated if None)\n auto_start: If True, auto-starts/stops MLflow run\n patch_config: Patch configuration dict for logging (from MedPatchDataLoaders)\n extra_params: Additional parameters to log\n extra_tags: MLflow tags to set on the run\n dataset_version: Dataset version hash string for tracking\n log_split: If True, auto-logs train/val split CSV when dls has split_df\n \"\"\"\n _ensure_fastmonai_tracking_uri()\n self.model_name = model_name\n self.loss_function = loss_function\n self.item_tfms = item_tfms\n self.size = size\n self.target_spacing = target_spacing\n self.apply_reorder = apply_reorder\n \n self.experiment_name = experiment_name\n self.run_name = run_name\n self.auto_start = auto_start\n self.patch_config = patch_config\n self.extra_params = extra_params or {}\n self.extra_tags = extra_tags or {}\n self.dataset_version = dataset_version\n self.log_split = log_split\n self._auto_started = False\n self.run_id = None\n \n self.config = self._build_config()\n \n def extract_all_params(self, tfm):\n \"\"\"\n Extract all parameters from a transform object for detailed logging.\n \n Args:\n tfm: Transform object to extract parameters from\n \n Returns:\n dict: Dictionary with 'name' and 'params' keys containing transform details\n \"\"\"\n class_name = tfm.__class__.__name__\n params = {}\n \n for key, value in tfm.__dict__.items():\n if not key.startswith('_') and key != '__signature__':\n if hasattr(value, '__dict__') and hasattr(value, 'target_shape'):\n params['target_shape'] = value.target_shape\n elif hasattr(value, '__dict__') and not key.startswith('_'):\n nested_params = {k: v for k, v in value.__dict__.items() \n if not k.startswith('_') and isinstance(v, (int, float, str, bool, tuple, list))}\n params.update(nested_params)\n elif isinstance(value, (int, float, str, bool, tuple, list)):\n params[key] = value\n \n return {\n 'name': class_name,\n 'params': params\n }\n \n def _build_config(self) -> dict[str, Any]:\n \"\"\"Build configuration dictionary from initialization parameters.\"\"\"\n transform_details = [self.extract_all_params(tfm) for tfm in self.item_tfms]\n \n return {\n \"model_name\": self.model_name,\n \"loss_function\": self.loss_function,\n \"transform_details\": transform_details,\n \"size\": self.size,\n \"target_spacing\": self.target_spacing,\n \"apply_reorder\": self.apply_reorder,\n }\n \n def _extract_training_params(self) -> dict[str, Any]:\n \"\"\"Extract training hyperparameters from the learner.\"\"\"\n params = {}\n \n params[\"epochs\"] = self.learn.n_epoch\n params[\"learning_rate\"] = float(self.learn.lr)\n params[\"optimizer\"] = self.learn.opt_func.__name__\n params[\"batch_size\"] = self.learn.dls.bs\n \n params[\"loss_function\"] = self.config[\"loss_function\"]\n params[\"size\"] = self.config[\"size\"]\n params[\"target_spacing\"] = self.config[\"target_spacing\"]\n params[\"apply_reorder\"] = self.config[\"apply_reorder\"]\n \n params[\"transformations\"] = json.dumps(\n self.config[\"transform_details\"], \n indent=2, \n separators=(',', ': ')\n )\n \n if self.patch_config:\n for key, value in self.patch_config.items():\n if value is not None:\n params[f\"patch_{key}\"] = value if isinstance(value, (int, float, str, bool)) else str(value)\n \n return params\n \n def _extract_epoch_metrics(self) -> dict[str, float]:\n \"\"\"Extract metrics from the current epoch.\"\"\"\n recorder = self.learn.recorder\n \n # Skip 'epoch' and 'time'\n metric_names = recorder.metric_names[2:]\n raw_metric_values = recorder.log[2:]\n \n metrics = {}\n for name, val in zip(metric_names, raw_metric_values):\n if val is None:\n continue # Skip None values during inference\n if isinstance(val, torch.Tensor):\n if val.numel() == 1:\n metrics[name] = float(val)\n else:\n val_list = val.tolist() if hasattr(val, 'tolist') else list(val)\n for i, class_score in enumerate(val_list):\n metrics[f\"{name}_class_{i+1}\"] = float(class_score)\n metrics[f\"{name}_mean\"] = float(torch.mean(val))\n else:\n metrics[name] = float(val)\n \n if len(recorder.log) >= 2:\n if recorder.log[1] is not None:\n metrics['train_loss'] = float(recorder.log[1])\n if len(recorder.log) >= 3 and recorder.log[2] is not None:\n metrics['valid_loss'] = float(recorder.log[2])\n \n return metrics\n \n def _save_model_artifacts(self, temp_dir: Path) -> None:\n \"\"\"Save model weights, learner, and configuration as artifacts.\"\"\"\n import shutil\n\n # Save final epoch weights (without optimizer state to reduce file size)\n weights_path = temp_dir / \"final_weights\"\n self.learn.save(str(weights_path), with_opt=False)\n weights_file = f\"{weights_path}.pth\"\n if os.path.exists(weights_file):\n mlflow.log_artifact(weights_file, \"model\")\n\n # Auto-detect checkpoint callback (SaveModelCallback, EMACheckpoint, etc.)\n from fastai.callback.tracker import TrackerCallback\n best_model_cb = None\n for cb in self.learn.cbs:\n if isinstance(cb, TrackerCallback) and hasattr(cb, 'fname'):\n best_model_path = self.learn.path / self.learn.model_dir / f'{cb.fname}.pth'\n if best_model_path.exists():\n best_weights_dest = temp_dir / \"best_weights.pth\"\n shutil.copy2(str(best_model_path), str(best_weights_dest))\n mlflow.log_artifact(str(best_weights_dest), \"model\")\n print(f\"Logged best model weights: best_weights.pth\")\n best_model_cb = cb\n break\n\n # Remove ModelTrackingCallback before exporting learners\n original_cbs = self.learn.cbs.copy()\n filtered_cbs = L([cb for cb in self.learn.cbs\n if not isinstance(cb, ModelTrackingCallback)])\n self.learn.cbs = filtered_cbs\n\n # Export before loading best weights\n final_learner_path = temp_dir / \"final_learner.pkl\"\n self.learn.export(str(final_learner_path))\n mlflow.log_artifact(str(final_learner_path), \"model\")\n print(\"Logged final epoch learner: final_learner.pkl\")\n\n if best_model_cb is not None:\n self.learn.load(best_model_cb.fname)\n print(f\"Loaded best model weights ({best_model_cb.fname}) for best learner export\")\n\n best_learner_path = temp_dir / \"best_learner.pkl\"\n self.learn.export(str(best_learner_path))\n mlflow.log_artifact(str(best_learner_path), \"model\")\n print(\"Logged best epoch learner: best_learner.pkl\")\n\n self.learn.cbs = original_cbs\n\n config_path = temp_dir / \"inference_settings.json\"\n store_variables(config_path, self.size, self.apply_reorder, self.target_spacing)\n mlflow.log_artifact(str(config_path), \"config\")\n \n def _log_split_df(self) -> None:\n \"\"\"Log train/validation split as CSV artifact if available.\"\"\"\n dls = self.learn.dls\n if not hasattr(dls, 'split_df'):\n return\n try:\n split_df = dls.split_df\n cols = []\n img_col = getattr(dls, '_img_col', None)\n mask_col = getattr(dls, '_mask_col', None)\n if img_col and img_col in split_df.columns:\n cols.append(img_col)\n if mask_col and mask_col in split_df.columns:\n cols.append(mask_col)\n cols.append('is_valid')\n split_df = split_df[cols]\n\n mlflow.log_text(split_df.to_csv(index=False), \"data/train_val_split.csv\")\n print(f\"Logged train/val split ({len(split_df)} rows) to MLflow artifacts\")\n except Exception as e:\n print(f\"Warning: Could not log train/val split: {e}\")\n\n def before_fit(self) -> None:\n \"\"\"Log hyperparameters before training starts. Auto-start run if configured.\"\"\"\n if self.auto_start:\n if self.experiment_name:\n mlflow.set_experiment(self.experiment_name)\n self.run_name = self.run_name or f\"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n mlflow.start_run(run_name=self.run_name)\n self._auto_started = True\n \n if self.dataset_version is not None:\n mlflow.set_tag(\"dataset_version\", self.dataset_version)\n \n if self.extra_tags:\n mlflow.set_tags(self.extra_tags)\n \n params = self._extract_training_params()\n params.update(self.extra_params)\n mlflow.log_params(params)\n\n if self.log_split:\n self._log_split_df()\n \n def after_epoch(self) -> None:\n \"\"\"Log metrics after each epoch.\"\"\"\n metrics = self._extract_epoch_metrics()\n if metrics:\n mlflow.log_metrics(metrics, step=self.learn.epoch)\n \n def after_fit(self) -> None:\n \"\"\"Log model artifacts after training completion.\"\"\"\n print(\"\\nTraining finished. Logging model artifacts to MLflow...\")\n \n with tempfile.TemporaryDirectory() as temp_dir:\n temp_path = Path(temp_dir)\n \n self._save_model_artifacts(temp_path)\n \n self.run_id = mlflow.active_run().info.run_id\n print(f\"MLflow run completed. Run ID: {self.run_id}\")\n \n if self._auto_started:\n mlflow.end_run()\n self._auto_started = False\n\n def log_metrics(self, metrics: dict) -> None:\n \"\"\"Log additional metrics to the completed MLflow run.\n\n Reopens the run to log metrics, then closes it. Useful for\n post-training evaluation (e.g., validation set patch inference results).\n NaN values are silently skipped (MLflow rejects them).\n\n Args:\n metrics: Dictionary of metric name -> value pairs to log.\n \"\"\"\n if self.run_id is None:\n raise RuntimeError(\"No MLflow run found. Train the model first.\")\n\n import math\n valid = {k: v for k, v in metrics.items()\n if not (isinstance(v, float) and math.isnan(v))}\n\n if valid:\n with mlflow.start_run(run_id=self.run_id):\n mlflow.log_metrics(valid)\n print(f\"Logged {len(valid)} metric(s) to MLflow run {self.run_id}\")\n\n def log_metrics_table(self, df, metrics=None, key='validation_metrics', display=False):\n \"\"\"Log a summary table of metrics as an image to the MLflow Images tab.\n\n Creates a styled matplotlib table with Metric, Mean, and Std columns\n from the given DataFrame and logs it via mlflow.log_image() so it\n appears in the MLflow UI Images tab.\n\n Args:\n df: DataFrame containing per-sample metric values.\n metrics: List of column names in df to summarize. If None,\n all numeric columns are used automatically.\n key: Image key for MLflow Images tab.\n display: If True, display the table inline in the notebook.\n \"\"\"\n import math\n import io\n import matplotlib.pyplot as plt\n from PIL import Image\n\n if self.run_id is None:\n raise RuntimeError(\"No MLflow run found. Train the model first.\")\n\n if metrics is None:\n metrics = df.select_dtypes(include='number').columns.tolist()\n\n summary_data = []\n for metric in metrics:\n mean = df[metric].mean()\n std = df[metric].std()\n std_str = f'{std:.4f}' if not math.isnan(std) else 'N/A'\n summary_data.append([metric, f'{mean:.4f}', std_str])\n\n fig, ax = plt.subplots(figsize=(5, len(metrics) * 0.8 + 0.5))\n ax.axis('off')\n\n table = ax.table(\n cellText=summary_data,\n colLabels=['Metric', 'Mean', 'Std'],\n cellLoc='center',\n loc='center'\n )\n table.auto_set_font_size(False)\n table.set_fontsize(14)\n table.scale(1.5, 2.2)\n\n for col in range(3):\n table[0, col].set_facecolor('#4472C4')\n table[0, col].set_text_props(color='white', fontweight='bold')\n\n fig.tight_layout()\n\n buf = io.BytesIO()\n fig.savefig(buf, format='png', bbox_inches='tight', dpi=250)\n buf.seek(0)\n pil_image = Image.open(buf)\n\n with mlflow.start_run(run_id=self.run_id):\n mlflow.log_image(pil_image, key=key)\n\n buf.close()\n if display: plt.show()\n plt.close(fig)\n print(f\"Logged metrics table to MLflow run {self.run_id}\")\n\n def log_dataframe(self, df, artifact_name='validation_results.csv', artifact_path='results'):\n \"\"\"Log a DataFrame as a CSV artifact to the MLflow run.\n\n Args:\n df: DataFrame to save.\n artifact_name: Filename for the CSV.\n artifact_path: Artifact subdirectory in MLflow.\n \"\"\"\n if self.run_id is None:\n raise RuntimeError(\"No MLflow run found. Train the model first.\")\n\n with tempfile.NamedTemporaryFile(suffix='.csv', delete=False, mode='w') as f:\n df.to_csv(f, index=False)\n temp_path = f.name\n\n with mlflow.start_run(run_id=self.run_id):\n mlflow.log_artifact(temp_path, artifact_path)\n\n os.remove(temp_path)\n print(f\"Logged DataFrame ({len(df)} rows) to MLflow run {self.run_id}\")" + "source": [ + "#| export\n", + "class ModelTrackingCallback(Callback):\n", + " \"\"\"\n", + " A FastAI callback for comprehensive MLflow experiment tracking.\n", + " \n", + " This callback automatically logs hyperparameters, metrics, model artifacts,\n", + " and configuration to MLflow during training. If a checkpoint callback\n", + " (SaveModelCallback, EMACheckpoint, or any TrackerCallback with fname) is\n", + " present, the best model checkpoint will also be logged as an artifact.\n", + " \n", + " Supports auto-managed runs when created via `create_mlflow_callback()`.\n", + " \"\"\"\n", + " \n", + " def __init__(\n", + " self, \n", + " model_name: str, \n", + " loss_function: str, \n", + " item_tfms: list[Any],\n", + " size: list[int], \n", + " target_spacing: list[float], \n", + " apply_reorder: bool,\n", + " experiment_name: str = None,\n", + " run_name: str = None,\n", + " auto_start: bool = False,\n", + " patch_config: dict = None,\n", + " extra_params: dict = None,\n", + " extra_tags: dict = None,\n", + " dataset_version: str = None,\n", + " log_split: bool = True\n", + " ):\n", + " \"\"\"\n", + " Initialize the MLflow tracking callback.\n", + " \n", + " Args:\n", + " model_name: Name/identifier for the model being tracked\n", + " loss_function: Name of the loss function being used\n", + " item_tfms: List of item transforms\n", + " size: Model input dimensions\n", + " target_spacing: Resampling dimensions\n", + " apply_reorder: Whether reordering augmentation is applied\n", + " experiment_name: MLflow experiment name (used with auto_start)\n", + " run_name: MLflow run name (auto-generated if None)\n", + " auto_start: If True, auto-starts/stops MLflow run\n", + " patch_config: Patch configuration dict for logging (from MedPatchDataLoaders)\n", + " extra_params: Additional parameters to log\n", + " extra_tags: MLflow tags to set on the run\n", + " dataset_version: Dataset version hash string for tracking\n", + " log_split: If True, auto-logs train/val split CSV when dls has split_df\n", + " \"\"\"\n", + " _ensure_fastmonai_tracking_uri()\n", + " self.model_name = model_name\n", + " self.loss_function = loss_function\n", + " self.item_tfms = item_tfms\n", + " self.size = size\n", + " self.target_spacing = target_spacing\n", + " self.apply_reorder = apply_reorder\n", + " \n", + " self.experiment_name = experiment_name\n", + " self.run_name = run_name\n", + " self.auto_start = auto_start\n", + " self.patch_config = patch_config\n", + " self.extra_params = extra_params or {}\n", + " self.extra_tags = extra_tags or {}\n", + " self.dataset_version = dataset_version\n", + " self.log_split = log_split\n", + " self._auto_started = False\n", + " self.run_id = None\n", + " \n", + " self.config = self._build_config()\n", + " \n", + " def extract_all_params(self, tfm):\n", + " \"\"\"\n", + " Extract all parameters from a transform object for detailed logging.\n", + " \n", + " Args:\n", + " tfm: Transform object to extract parameters from\n", + " \n", + " Returns:\n", + " dict: Dictionary with 'name' and 'params' keys containing transform details\n", + " \"\"\"\n", + " class_name = tfm.__class__.__name__\n", + " params = {}\n", + " \n", + " for key, value in tfm.__dict__.items():\n", + " if not key.startswith('_') and key != '__signature__':\n", + " if hasattr(value, '__dict__') and hasattr(value, 'target_shape'):\n", + " params['target_shape'] = value.target_shape\n", + " elif hasattr(value, '__dict__') and not key.startswith('_'):\n", + " nested_params = {k: v for k, v in value.__dict__.items() \n", + " if not k.startswith('_') and isinstance(v, (int, float, str, bool, tuple, list))}\n", + " params.update(nested_params)\n", + " elif isinstance(value, (int, float, str, bool, tuple, list)):\n", + " params[key] = value\n", + " \n", + " return {\n", + " 'name': class_name,\n", + " 'params': params\n", + " }\n", + " \n", + " def _build_config(self) -> dict[str, Any]:\n", + " \"\"\"Build configuration dictionary from initialization parameters.\"\"\"\n", + " transform_details = [self.extract_all_params(tfm) for tfm in self.item_tfms]\n", + " \n", + " return {\n", + " \"model_name\": self.model_name,\n", + " \"loss_function\": self.loss_function,\n", + " \"transform_details\": transform_details,\n", + " \"size\": self.size,\n", + " \"target_spacing\": self.target_spacing,\n", + " \"apply_reorder\": self.apply_reorder,\n", + " }\n", + " \n", + " def _extract_training_params(self) -> dict[str, Any]:\n", + " \"\"\"Extract training hyperparameters from the learner.\"\"\"\n", + " params = {}\n", + " \n", + " params[\"epochs\"] = self.learn.n_epoch\n", + " params[\"learning_rate\"] = float(self.learn.lr)\n", + " params[\"optimizer\"] = self.learn.opt_func.__name__\n", + " params[\"batch_size\"] = self.learn.dls.bs\n", + " \n", + " params[\"loss_function\"] = self.config[\"loss_function\"]\n", + " params[\"size\"] = self.config[\"size\"]\n", + " params[\"target_spacing\"] = self.config[\"target_spacing\"]\n", + " params[\"apply_reorder\"] = self.config[\"apply_reorder\"]\n", + " \n", + " params[\"transformations\"] = json.dumps(\n", + " self.config[\"transform_details\"], \n", + " indent=2, \n", + " separators=(',', ': ')\n", + " )\n", + " \n", + " if self.patch_config:\n", + " for key, value in self.patch_config.items():\n", + " if value is not None:\n", + " params[f\"patch_{key}\"] = value if isinstance(value, (int, float, str, bool)) else str(value)\n", + " \n", + " return params\n", + " \n", + " def _extract_epoch_metrics(self) -> dict[str, float]:\n", + " \"\"\"Extract metrics from the current epoch.\"\"\"\n", + " recorder = self.learn.recorder\n", + " \n", + " # Skip 'epoch' and 'time'\n", + " metric_names = recorder.metric_names[2:]\n", + " raw_metric_values = recorder.log[2:]\n", + " \n", + " metrics = {}\n", + " for name, val in zip(metric_names, raw_metric_values):\n", + " if val is None:\n", + " continue # Skip None values during inference\n", + " if isinstance(val, torch.Tensor):\n", + " if val.numel() == 1:\n", + " metrics[name] = float(val)\n", + " else:\n", + " val_list = val.tolist() if hasattr(val, 'tolist') else list(val)\n", + " for i, class_score in enumerate(val_list):\n", + " metrics[f\"{name}_class_{i+1}\"] = float(class_score)\n", + " metrics[f\"{name}_mean\"] = float(torch.mean(val))\n", + " else:\n", + " metrics[name] = float(val)\n", + " \n", + " if len(recorder.log) >= 2:\n", + " if recorder.log[1] is not None:\n", + " metrics['train_loss'] = float(recorder.log[1])\n", + " if len(recorder.log) >= 3 and recorder.log[2] is not None:\n", + " metrics['valid_loss'] = float(recorder.log[2])\n", + " \n", + " return metrics\n", + " \n", + " def _save_model_artifacts(self, temp_dir: Path) -> None:\n", + " \"\"\"Save model weights, learner, and configuration as artifacts.\"\"\"\n", + " import shutil\n", + "\n", + " # Save final epoch weights (without optimizer state to reduce file size)\n", + " weights_path = temp_dir / \"final_weights\"\n", + " self.learn.save(str(weights_path), with_opt=False)\n", + " weights_file = f\"{weights_path}.pth\"\n", + " if os.path.exists(weights_file):\n", + " mlflow.log_artifact(weights_file, \"model\")\n", + "\n", + " # Auto-detect checkpoint callback (SaveModelCallback, EMACheckpoint, etc.)\n", + " from fastai.callback.tracker import TrackerCallback\n", + " best_model_cb = None\n", + " for cb in self.learn.cbs:\n", + " if isinstance(cb, TrackerCallback) and hasattr(cb, 'fname'):\n", + " best_model_path = self.learn.path / self.learn.model_dir / f'{cb.fname}.pth'\n", + " if best_model_path.exists():\n", + " best_weights_dest = temp_dir / \"best_weights.pth\"\n", + " shutil.copy2(str(best_model_path), str(best_weights_dest))\n", + " mlflow.log_artifact(str(best_weights_dest), \"model\")\n", + " print(f\"Logged best model weights: best_weights.pth\")\n", + " best_model_cb = cb\n", + " break\n", + "\n", + " # Remove ModelTrackingCallback before exporting learners\n", + " original_cbs = self.learn.cbs.copy()\n", + " filtered_cbs = L([cb for cb in self.learn.cbs\n", + " if not isinstance(cb, ModelTrackingCallback)])\n", + " self.learn.cbs = filtered_cbs\n", + "\n", + " # Export before loading best weights\n", + " final_learner_path = temp_dir / \"final_learner.pkl\"\n", + " self.learn.export(str(final_learner_path))\n", + " mlflow.log_artifact(str(final_learner_path), \"model\")\n", + " print(\"Logged final epoch learner: final_learner.pkl\")\n", + "\n", + " if best_model_cb is not None:\n", + " self.learn.load(best_model_cb.fname)\n", + " print(f\"Loaded best model weights ({best_model_cb.fname}) for best learner export\")\n", + "\n", + " best_learner_path = temp_dir / \"best_learner.pkl\"\n", + " self.learn.export(str(best_learner_path))\n", + " mlflow.log_artifact(str(best_learner_path), \"model\")\n", + " print(\"Logged best epoch learner: best_learner.pkl\")\n", + "\n", + " self.learn.cbs = original_cbs\n", + "\n", + " config_path = temp_dir / \"inference_settings.json\"\n", + " store_variables(config_path, self.size, self.apply_reorder, self.target_spacing)\n", + " mlflow.log_artifact(str(config_path), \"config\")\n", + " \n", + " def _log_split_df(self) -> None:\n", + " \"\"\"Log train/validation split as CSV artifact if available.\"\"\"\n", + " dls = self.learn.dls\n", + " if not hasattr(dls, 'split_df'):\n", + " return\n", + " try:\n", + " split_df = dls.split_df\n", + " cols = []\n", + " img_col = getattr(dls, '_img_col', None)\n", + " mask_col = getattr(dls, '_mask_col', None)\n", + " if img_col and img_col in split_df.columns:\n", + " cols.append(img_col)\n", + " if mask_col and mask_col in split_df.columns:\n", + " cols.append(mask_col)\n", + " cols.append('is_valid')\n", + " split_df = split_df[cols]\n", + "\n", + " mlflow.log_text(split_df.to_csv(index=False), \"data/train_val_split.csv\")\n", + " print(f\"Logged train/val split ({len(split_df)} rows) to MLflow artifacts\")\n", + " except Exception as e:\n", + " print(f\"Warning: Could not log train/val split: {e}\")\n", + "\n", + " def before_fit(self) -> None:\n", + " \"\"\"Log hyperparameters before training starts. Auto-start run if configured.\"\"\"\n", + " if self.auto_start:\n", + " if self.experiment_name:\n", + " mlflow.set_experiment(self.experiment_name)\n", + " self.run_name = self.run_name or f\"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n", + " mlflow.start_run(run_name=self.run_name)\n", + " self._auto_started = True\n", + " \n", + " if self.dataset_version is not None:\n", + " mlflow.set_tag(\"dataset_version\", self.dataset_version)\n", + " \n", + " if self.extra_tags:\n", + " mlflow.set_tags(self.extra_tags)\n", + " \n", + " params = self._extract_training_params()\n", + " params.update(self.extra_params)\n", + " mlflow.log_params(params)\n", + "\n", + " if self.log_split:\n", + " self._log_split_df()\n", + " \n", + " def after_epoch(self) -> None:\n", + " \"\"\"Log metrics after each epoch.\"\"\"\n", + " metrics = self._extract_epoch_metrics()\n", + " if metrics:\n", + " mlflow.log_metrics(metrics, step=self.learn.epoch)\n", + " \n", + " def after_fit(self) -> None:\n", + " \"\"\"Log model artifacts after training completion.\"\"\"\n", + " print(\"\\nTraining finished. Logging model artifacts to MLflow...\")\n", + " \n", + " with tempfile.TemporaryDirectory() as temp_dir:\n", + " temp_path = Path(temp_dir)\n", + " \n", + " self._save_model_artifacts(temp_path)\n", + " \n", + " self.run_id = mlflow.active_run().info.run_id\n", + " print(f\"MLflow run completed. Run ID: {self.run_id}\")\n", + " \n", + " if self._auto_started:\n", + " mlflow.end_run()\n", + " self._auto_started = False\n", + "\n", + " def log_metrics(self, metrics: dict) -> None:\n", + " \"\"\"Log additional metrics to the completed MLflow run.\n", + "\n", + " Reopens the run to log metrics, then closes it. Useful for\n", + " post-training evaluation (e.g., validation set patch inference results).\n", + " NaN values are silently skipped (MLflow rejects them).\n", + "\n", + " Args:\n", + " metrics: Dictionary of metric name -> value pairs to log.\n", + " \"\"\"\n", + " if self.run_id is None:\n", + " raise RuntimeError(\"No MLflow run found. Train the model first.\")\n", + "\n", + " import math\n", + " valid = {k: v for k, v in metrics.items()\n", + " if not (isinstance(v, float) and math.isnan(v))}\n", + "\n", + " if valid:\n", + " with mlflow.start_run(run_id=self.run_id):\n", + " mlflow.log_metrics(valid)\n", + " print(f\"Logged {len(valid)} metric(s) to MLflow run {self.run_id}\")\n", + "\n", + " def log_metrics_table(self, df, metrics=None, key='validation_metrics', display=False):\n", + " \"\"\"Log a summary table of metrics as an image to the MLflow Images tab.\n", + "\n", + " Creates a styled matplotlib table with Metric, Mean, and Std columns\n", + " from the given DataFrame and logs it via mlflow.log_image() so it\n", + " appears in the MLflow UI Images tab.\n", + "\n", + " Args:\n", + " df: DataFrame containing per-sample metric values.\n", + " metrics: List of column names in df to summarize. If None,\n", + " all numeric columns are used automatically.\n", + " key: Image key for MLflow Images tab.\n", + " display: If True, display the table inline in the notebook.\n", + " \"\"\"\n", + " import math\n", + " import io\n", + " import matplotlib.pyplot as plt\n", + " from PIL import Image\n", + "\n", + " if self.run_id is None:\n", + " raise RuntimeError(\"No MLflow run found. Train the model first.\")\n", + "\n", + " if metrics is None:\n", + " metrics = df.select_dtypes(include='number').columns.tolist()\n", + "\n", + " summary_data = []\n", + " for metric in metrics:\n", + " # Drop +/-inf (e.g. surface distances on one_empty cases) so they don't poison\n", + " # the summary; pandas already skips NaN. Show 'N/A' if nothing finite remains.\n", + " col = df[metric].replace([float('inf'), float('-inf')], float('nan'))\n", + " mean, std = col.mean(), col.std()\n", + " mean_str = f'{mean:.4f}' if not math.isnan(mean) else 'N/A'\n", + " std_str = f'{std:.4f}' if not math.isnan(std) else 'N/A'\n", + " summary_data.append([metric, mean_str, std_str])\n", + "\n", + " fig, ax = plt.subplots(figsize=(5, len(metrics) * 0.8 + 0.5))\n", + " ax.axis('off')\n", + "\n", + " table = ax.table(\n", + " cellText=summary_data,\n", + " colLabels=['Metric', 'Mean', 'Std'],\n", + " cellLoc='center',\n", + " loc='center'\n", + " )\n", + " table.auto_set_font_size(False)\n", + " table.set_fontsize(14)\n", + " table.scale(1.5, 2.2)\n", + "\n", + " for col in range(3):\n", + " table[0, col].set_facecolor('#4472C4')\n", + " table[0, col].set_text_props(color='white', fontweight='bold')\n", + "\n", + " fig.tight_layout()\n", + "\n", + " buf = io.BytesIO()\n", + " fig.savefig(buf, format='png', bbox_inches='tight', dpi=250)\n", + " buf.seek(0)\n", + " pil_image = Image.open(buf)\n", + "\n", + " with mlflow.start_run(run_id=self.run_id):\n", + " mlflow.log_image(pil_image, key=key)\n", + "\n", + " buf.close()\n", + " if display: plt.show()\n", + " plt.close(fig)\n", + " print(f\"Logged metrics table to MLflow run {self.run_id}\")\n", + "\n", + " def log_dataframe(self, df, artifact_name='validation_results.csv', artifact_path='results'):\n", + " \"\"\"Log a DataFrame as a CSV artifact to the MLflow run.\n", + "\n", + " Args:\n", + " df: DataFrame to save.\n", + " artifact_name: Filename for the CSV.\n", + " artifact_path: Artifact subdirectory in MLflow.\n", + " \"\"\"\n", + " if self.run_id is None:\n", + " raise RuntimeError(\"No MLflow run found. Train the model first.\")\n", + "\n", + " with tempfile.NamedTemporaryFile(suffix='.csv', delete=False, mode='w') as f:\n", + " df.to_csv(f, index=False)\n", + " temp_path = f.name\n", + "\n", + " with mlflow.start_run(run_id=self.run_id):\n", + " mlflow.log_artifact(temp_path, artifact_path)\n", + "\n", + " os.remove(temp_path)\n", + " print(f\"Logged DataFrame ({len(df)} rows) to MLflow run {self.run_id}\")" + ] }, { "cell_type": "code", @@ -298,6 +696,28 @@ "outputs": [], "source": "#| export\ndef create_mlflow_callback(\n learn,\n experiment_name: str = None,\n run_name: str = None,\n auto_start: bool = True,\n model_name: str = None,\n extra_params: dict = None,\n extra_tags: dict = None,\n dataset_version: str = None,\n log_split: bool = True,\n) -> ModelTrackingCallback:\n \"\"\"Create MLflow tracking callback with auto-extracted configuration.\n \n This factory function automatically extracts configuration from the Learner,\n eliminating the need to manually specify parameters like size, transforms,\n loss function, etc.\n \n Auto-extracts from Learner:\n - Preprocessing: apply_reorder, target_spacing, size/patch_size\n - Transforms: item_tfms or pre_patch_tfms\n - Training: loss_func, model architecture\n \n Args:\n learn: fastai Learner instance\n experiment_name: MLflow experiment name. If None, uses model name.\n run_name: MLflow run name. If None, auto-generates with timestamp.\n auto_start: If True, auto-starts/stops MLflow run in before_fit/after_fit.\n model_name: Override the auto-extracted model name (used as the experiment name when experiment_name is None).\n extra_params: Additional parameters to log (e.g., {'dropout': 0.5}).\n extra_tags: MLflow tags to set on the run.\n dataset_version: Dataset version hash string for tracking.\n log_split: If True, auto-logs train/val split CSV when dls has split_df.\n \n Returns:\n ModelTrackingCallback ready to use with learn.fit()\n \n Example:\n >>> # Instead of this (6 manual params):\n >>> # mlflow_callback = ModelTrackingCallback(\n >>> # model_name=f\"{task}_{model._get_name()}\",\n >>> # loss_function=loss_func.loss_func._get_name(),\n >>> # item_tfms=item_tfms,\n >>> # size=size,\n >>> # target_spacing=target_spacing,\n >>> # apply_reorder=True,\n >>> # )\n >>> # with mlflow.start_run(run_name=\"training\"):\n >>> # learn.fit_one_cycle(30, lr, cbs=[mlflow_callback])\n >>> \n >>> # Do this (zero manual params):\n >>> callback = create_mlflow_callback(learn, experiment_name=\"Task02_Heart\")\n >>> learn.fit_one_cycle(30, lr, cbs=[callback, save_best])\n \"\"\"\n _ensure_fastmonai_tracking_uri()\n\n if _detect_patch_workflow(learn.dls):\n config = _extract_patch_config(learn)\n else:\n config = _extract_standard_config(learn)\n \n _model_name = model_name or _extract_model_name(learn)\n _loss_name = _extract_loss_name(learn)\n \n if experiment_name is None:\n experiment_name = _model_name\n \n if config['size'] is None:\n raise ValueError(\n \"Could not auto-extract 'size'. Either:\\n\"\n \"1. Add PadOrCrop to item_tfms, or\\n\"\n \"2. Use MedPatchDataLoaders with PatchConfig, or\\n\"\n \"3. Use ModelTrackingCallback directly with manual params\"\n )\n \n return ModelTrackingCallback(\n model_name=_model_name,\n loss_function=_loss_name,\n item_tfms=config['item_tfms'],\n size=config['size'],\n target_spacing=config['target_spacing'],\n apply_reorder=config['apply_reorder'],\n experiment_name=experiment_name,\n run_name=run_name,\n auto_start=auto_start,\n patch_config=config['patch_config'],\n extra_params=extra_params,\n extra_tags=extra_tags,\n dataset_version=dataset_version,\n log_split=log_split,\n )" }, + { + "cell_type": "markdown", + "id": "c5023d19", + "metadata": {}, + "source": "## Locating cross-validation fold checkpoints" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e7ed65b", + "metadata": {}, + "outputs": [], + "source": "#| export\ndef find_fold_learners(experiment_name, artifact_path=\"model/best_learner.pkl\",\n n_folds=None, fold_tag=\"fold\"):\n \"\"\"Discover one trained learner checkpoint per cross-validation fold from an MLflow experiment.\n\n Reads back what ``ModelTrackingCallback`` logs per run: takes the most recent run for each\n fold in ``experiment_name`` and downloads ``artifact_path`` from it, returning\n ``{fold: local_path}`` sorted by fold. Folds are identified by each run's ``tags.``,\n falling back to a ``_`` run name. Warns if the selected folds span more than one\n ``dataset_version`` tag (a sign of a partial retrain).\n\n Pair it with the soft-vote ensembling in ``patch_inference`` -- but the returned values are\n PATHS, so load them first::\n\n from fastai.learner import load_learner\n paths = find_fold_learners(\"vs5f_unet\")\n learners = [load_learner(p) for p in paths.values()]\n preds = patch_inference(learner=learners, config=config, file_paths=cases)\n\n Args:\n experiment_name: MLflow experiment to search (e.g. ``\"vs5f_unet\"``).\n artifact_path: run-relative artifact to download per fold (default\n ``\"model/best_learner.pkl\"``, what ``ModelTrackingCallback`` logs).\n n_folds: optional expected folds for a missing-fold warning -- an int ``n`` (labels\n ``1..n``) or an explicit iterable of labels (use this for 0-indexed folds). ``None``\n makes no assumption and just returns what was found.\n fold_tag: run tag naming the fold; also the ``_`` run-name prefix fallback.\n\n Returns:\n ``{fold_int: local_path}`` sorted by fold, or ``{}`` if the experiment is missing or\n MLflow is unavailable.\n \"\"\"\n from mlflow.exceptions import MlflowException\n try:\n _ensure_fastmonai_tracking_uri() # respect a user-configured tracking server\n exp = mlflow.get_experiment_by_name(experiment_name)\n if exp is None:\n print(f\"No MLflow experiment {experiment_name!r}. Nothing to load.\")\n return {}\n runs = mlflow.search_runs(experiment_ids=[exp.experiment_id],\n order_by=[\"attributes.start_time DESC\"])\n except (ImportError, MlflowException) as e:\n print(f\"MLflow lookup for {experiment_name!r} skipped: {e}\")\n return {}\n\n out, fold_dsv = {}, {}\n prefix = f\"{fold_tag}_\"\n for _, row in runs.iterrows():\n fold = row.get(f\"tags.{fold_tag}\")\n # pandas yields NaN (a float, != itself), not None, when the column exists but this\n # row lacks the tag -- fall back to the run name instead of dropping the run.\n if fold is None or (isinstance(fold, float) and fold != fold):\n name = row.get(\"tags.mlflow.runName\") or \"\"\n fold = name[len(prefix):] if name.startswith(prefix) else None\n try:\n fold = int(fold)\n except (TypeError, ValueError):\n continue\n if fold in out:\n continue # newest run already kept for this fold (runs are start_time DESC)\n try:\n out[fold] = mlflow.artifacts.download_artifacts(\n run_id=row[\"run_id\"], artifact_path=artifact_path)\n except Exception:\n continue # fall through to the next-newest run for this fold\n fold_dsv[fold] = row.get(\"tags.dataset_version\")\n\n print(f\"Found fold learners for {sorted(out)} in {experiment_name!r}.\")\n if n_folds is not None:\n expected = range(1, n_folds + 1) if isinstance(n_folds, int) else n_folds\n missing = [f for f in expected if f not in out]\n if missing:\n print(f\"WARNING: no checkpoint for folds {missing}; ensemble will use {len(out)} model(s).\")\n distinct_dsv = {v for v in fold_dsv.values() if isinstance(v, str) and v}\n if len(distinct_dsv) > 1:\n shown = {f: v[:8] for f, v in sorted(fold_dsv.items()) if isinstance(v, str) and v}\n print(f\"WARNING: selected folds span multiple dataset_versions {shown} - possible partial retrain.\")\n return dict(sorted(out.items()))" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "175c00b4", + "metadata": {}, + "outputs": [], + "source": "#| hide\n# find_fold_learners: graceful {} on a missing experiment, and correct fold parsing --\n# NaN tag falls back to the run name (not dropped), 0-indexed labels, newest-run-per-fold dedup.\nfrom unittest.mock import patch, MagicMock\nimport pandas as pd\nfrom fastcore.test import test_eq\n\nwith patch.object(mlflow, \"get_experiment_by_name\", return_value=None):\n test_eq(find_fold_learners(\"no_such_experiment\"), {})\n\n_runs = pd.DataFrame([\n {\"run_id\": \"r_new0\", \"tags.fold\": \"0\", \"tags.mlflow.runName\": \"fold_0\", \"tags.dataset_version\": \"dv\"},\n {\"run_id\": \"r_old0\", \"tags.fold\": \"0\", \"tags.mlflow.runName\": \"fold_0\", \"tags.dataset_version\": \"dv\"},\n {\"run_id\": \"r_nan1\", \"tags.fold\": float(\"nan\"), \"tags.mlflow.runName\": \"fold_1\", \"tags.dataset_version\": \"dv\"},\n])\n_exp = MagicMock(); _exp.experiment_id = \"1\"\nwith patch.object(mlflow, \"get_experiment_by_name\", return_value=_exp), \\\n patch.object(mlflow, \"search_runs\", return_value=_runs), \\\n patch.object(mlflow.artifacts, \"download_artifacts\",\n side_effect=lambda run_id, artifact_path: f\"/dl/{run_id}\"):\n _got = find_fold_learners(\"exp\", n_folds=[0, 1])\ntest_eq(_got, {0: \"/dl/r_new0\", 1: \"/dl/r_nan1\"}) # dedup to newest; NaN-tag fold recovered via name" + }, { "cell_type": "code", "execution_count": null, diff --git a/nbs/10_vision_patch.ipynb b/nbs/10_vision_patch.ipynb index c8ed385..4e2fecb 100644 --- a/nbs/10_vision_patch.ipynb +++ b/nbs/10_vision_patch.ipynb @@ -197,7 +197,9 @@ " Can be int, float, or string (e.g., 'minimum', 'mean').\n", " keep_largest_component: If True, keep only the largest connected component\n", " in binary segmentation predictions. Only applies during inference when\n", - " return_probabilities=False. Defaults to False.\n", + " return_probabilities=False. Defaults to False. Binary-only: for multi-class\n", + " output (more than 2 classes) it is skipped with a warning, since it would\n", + " otherwise merge all classes into one blob and collapse the labels.\n", " binary_threshold: Decision boundary for single-channel (sigmoid) masks; a voxel is\n", " foreground when probability >= binary_threshold (matches MONAI AsDiscrete). Only\n", " applies when return_probabilities=False and n_classes == 1. Defaults to 0.5.\n", @@ -1327,29 +1329,34 @@ "def _logits_to_probs(logits: torch.Tensor) -> torch.Tensor:\n", " \"\"\"Logits -> probabilities: sigmoid for 1 channel (binary), else softmax over the channel dim.\n", "\n", - " Upcasts to float32 (logits may be float16 under AMP). Does NOT move to CPU; callers decide\n", + " Upcasts to float32 (logits may be bfloat16 under AMP). Does NOT move to CPU; callers decide\n", " placement (TTA keeps tensors on-device to flip back; the non-TTA path moves to CPU immediately).\n", " \"\"\"\n", " return torch.sigmoid(logits.float()) if logits.shape[1] == 1 else torch.softmax(logits.float(), dim=1)\n", "\n", "\n", - "def _predict_patch_tta(model, patch_input, amp_context=None) -> torch.Tensor:\n", - " \"\"\"Mirror TTA: average probabilities over the 8 flip combinations.\n", + "def _predict_patch_tta(models, patch_input, amp_context=None) -> torch.Tensor:\n", + " \"\"\"Mirror TTA (with optional model ensemble): average probabilities over the 8 flips and all models.\n", "\n", - " Uses a running sum (2x memory, not 9x). Each pass: flip -> forward -> activate -> flip back -> accumulate.\n", - " Returns the averaged probability tensor [B, C, D, H, W] on CPU. amp_context, if given, wraps the forward pass.\n", + " models is a single model or a list of models; a list soft-votes (averages probabilities).\n", + " Uses a running sum (2x memory, not 9x per model). Each pass: flip -> forward -> activate ->\n", + " flip back -> accumulate. Returns the averaged probability tensor [B, C, D, H, W] on CPU.\n", + " amp_context, if given, wraps each forward pass.\n", " \"\"\"\n", " if amp_context is None: amp_context = nullcontext()\n", + " if not isinstance(models, (list, tuple)):\n", + " models = [models]\n", " summed_probs = None\n", " for axes in _TTA_FLIP_AXES:\n", " flipped = torch.flip(patch_input, list(axes)) if axes else patch_input\n", - " with amp_context:\n", - " logits = model(flipped)\n", - " probs = _logits_to_probs(logits)\n", - " if axes:\n", - " probs = torch.flip(probs, list(axes))\n", - " summed_probs = probs if summed_probs is None else summed_probs + probs\n", - " return (summed_probs / len(_TTA_FLIP_AXES)).cpu()\n", + " for model in models:\n", + " with amp_context:\n", + " logits = model(flipped)\n", + " probs = _logits_to_probs(logits)\n", + " if axes:\n", + " probs = torch.flip(probs, list(axes))\n", + " summed_probs = probs if summed_probs is None else summed_probs + probs\n", + " return (summed_probs / (len(_TTA_FLIP_AXES) * len(models))).cpu()\n", "\n", "\n", "@dataclass\n", @@ -1371,8 +1378,10 @@ " GridAggregator to reconstruct the full volume from predictions.\n", " \n", " Args:\n", - " learner: fastai Learner or PyTorch model (nn.Module). When passing a raw\n", - " PyTorch model, load weights first with model.load_state_dict().\n", + " learner: fastai Learner or PyTorch model (nn.Module), or a list of them for\n", + " soft-vote ensembling (per-patch probabilities are averaged before argmax/\n", + " threshold). When passing a raw PyTorch model, load weights first with\n", + " model.load_state_dict().\n", " config: PatchConfig with inference settings. Preprocessing params (apply_reorder,\n", " target_spacing, padding_mode) can be set here for DRY usage.\n", " apply_reorder: Whether to reorder to RAS+ orientation. If None, uses config value.\n", @@ -1382,7 +1391,7 @@ " read from config.normalization (the default source of truth, set at training time).\n", " Provide this only to override the config (e.g. for non-serializable transforms).\n", " Accepts both fastMONAI wrappers and raw TorchIO transforms.\n", - " amp: If True, use automatic mixed precision (float16) for the forward pass.\n", + " amp: If True, use automatic mixed precision (bfloat16) for the forward pass.\n", " Only supported on CUDA devices; ignored with a warning on CPU/MPS.\n", " Defaults to False.\n", " \n", @@ -1408,10 +1417,11 @@ " \n", " # We check for Learner explicitly because some models (e.g., MONAI UNet) have a\n", " # .model attribute that is NOT the full model but an internal Sequential.\n", - " if isinstance(learner, Learner):\n", - " self.model = learner.model\n", - " else:\n", - " self.model = learner # already a PyTorch model\n", + " _learners = list(learner) if isinstance(learner, (list, tuple)) else [learner]\n", + " if len(_learners) == 0:\n", + " raise ValueError(\"learner must be a model/Learner or a non-empty list of them.\")\n", + " self.models = [l.model if isinstance(l, Learner) else l for l in _learners]\n", + " self.model = self.models[0] # first model; alias kept for single-model back-compat\n", " \n", " self.config = config\n", " self.sw_batch_size = sw_batch_size\n", @@ -1441,11 +1451,12 @@ " self._device = _get_default_device()\n", "\n", " # Set eval mode once at construction (this class is inference-only)\n", - " self.model.eval()\n", + " for _m in self.models:\n", + " _m.eval()\n", "\n", " # AMP: CUDA-only, matching nnU-Net pattern\n", " if amp and self._device.type == 'cuda':\n", - " self._amp_context = torch.amp.autocast('cuda', dtype=torch.float16)\n", + " self._amp_context = torch.amp.autocast('cuda', dtype=torch.bfloat16)\n", " else:\n", " if amp and self._device.type != 'cuda':\n", " warnings.warn(\"AMP is only supported on CUDA devices. Ignoring amp=True.\")\n", @@ -1500,9 +1511,10 @@ " )\n", "\n", " def _run_inference(self, prepared: _PreparedSubject, tta: bool = False) -> torch.Tensor:\n", - " \"\"\"Run the model over all patches and aggregate; returns the raw probability tensor.\n", + " \"\"\"Run the model(s) over all patches and aggregate; returns the raw probability tensor.\n", "\n", " Must run on the main thread (model forward pass). tta enables mirror test-time augmentation.\n", + " With multiple models the per-patch probabilities are averaged (soft voting).\n", " \"\"\"\n", " # inference_mode is slightly faster than no_grad (disables autograd tracking\n", " # and view tracking). Safe here since we don't do in-place ops on outputs.\n", @@ -1512,11 +1524,15 @@ " locations = patches_batch[tio.LOCATION]\n", "\n", " if tta:\n", - " probs = _predict_patch_tta(self.model, patch_input, self._amp_context)\n", + " probs = _predict_patch_tta(self.models, patch_input, self._amp_context)\n", " else:\n", - " with self._amp_context:\n", - " logits = self.model(patch_input)\n", - " probs = _logits_to_probs(logits).cpu()\n", + " summed = None\n", + " for _m in self.models:\n", + " with self._amp_context:\n", + " logits = _m(patch_input)\n", + " p = _logits_to_probs(logits)\n", + " summed = p if summed is None else summed + p\n", + " probs = (summed / len(self.models)).cpu()\n", "\n", " prepared.aggregator.add_batch(probs, locations)\n", "\n", @@ -1542,8 +1558,15 @@ " result = output.argmax(dim=0, keepdim=True).float()\n", "\n", " if not return_probabilities and self.config.keep_largest_component:\n", - " from fastMONAI.vision_inference import keep_largest\n", - " result = keep_largest(result.squeeze(0)).unsqueeze(0)\n", + " if n_classes > 2:\n", + " warnings.warn(\n", + " f\"keep_largest_component is binary-only; skipping it for multi-class output \"\n", + " f\"(n_classes={n_classes}). Set keep_largest_component=False for multi-class \"\n", + " f\"models, or post-process each class separately.\"\n", + " )\n", + " else:\n", + " from fastMONAI.vision_inference import keep_largest\n", + " result = keep_largest(result.squeeze(0)).unsqueeze(0)\n", "\n", " if return_probabilities:\n", " pred_img = tio.ScalarImage(tensor=result.float(), affine=prepared.input_img.affine)\n", @@ -1599,9 +1622,10 @@ " return result\n", " \n", " def to(self, device):\n", - " \"\"\"Move engine to device.\"\"\"\n", + " \"\"\"Move engine (all models) to device.\"\"\"\n", " self._device = device\n", - " self.model.to(device)\n", + " for _m in self.models:\n", + " _m.to(device)\n", " return self" ] }, @@ -1702,7 +1726,8 @@ " GPU compute use different hardware.\n", "\n", " Args:\n", - " learner: PyTorch model or fastai Learner.\n", + " learner: PyTorch model or fastai Learner, or a list of them to soft-vote\n", + " ensemble (per-patch probabilities are averaged before argmax/threshold).\n", " config: PatchConfig with inference settings. Preprocessing params (apply_reorder,\n", " target_spacing) can be set here for DRY usage.\n", " file_paths: List of image paths.\n", @@ -1720,7 +1745,7 @@ " thread for preparation and saving. Holds two subjects in memory\n", " simultaneously (current + next). Set to False for memory-constrained\n", " environments processing very large volumes.\n", - " amp: If True, use automatic mixed precision (float16) for the forward pass.\n", + " amp: If True, use automatic mixed precision (bfloat16) for the forward pass.\n", " Only supported on CUDA devices; ignored with a warning on CPU/MPS.\n", "\n", " Returns:\n", @@ -2127,6 +2152,83 @@ "test_fail(lambda: PatchConfig(patch_size=[96, 96, 96], normalization=[tio.ZNormalization()]))" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "ensemble-soft-voting-tests", + "metadata": {}, + "outputs": [], + "source": [ + "# Ensemble (soft-voting): PatchInferenceEngine and patch_inference accept a list of models.\n", + "# Per-patch probabilities are averaged, then a single argmax/threshold is applied.\n", + "import tempfile, os, nibabel as nib\n", + "from monai.networks.nets import UNet\n", + "from monai.networks.layers import Norm\n", + "\n", + "_ens_data = np.random.randn(32, 32, 32).astype(np.float32)\n", + "_ens_nii = nib.Nifti1Image(_ens_data, np.eye(4))\n", + "\n", + "def _mk_ens_unet(out_channels=2):\n", + " return UNet(spatial_dims=3, in_channels=1, out_channels=out_channels,\n", + " channels=(16, 32), strides=(2,), num_res_units=1, norm=Norm.INSTANCE).eval()\n", + "\n", + "with tempfile.TemporaryDirectory() as tmpdir:\n", + " img_path = os.path.join(tmpdir, 'ens_img.nii.gz')\n", + " nib.save(_ens_nii, img_path)\n", + "\n", + " _cfg = PatchConfig(patch_size=[32, 32, 32])\n", + " _m1 = _mk_ens_unet()\n", + "\n", + " # A single-element list is stored as .models; .model aliases the first model (back-compat)\n", + " _eng_list = PatchInferenceEngine([_m1], _cfg, apply_reorder=False)\n", + " test_eq(len(_eng_list.models), 1)\n", + " assert _eng_list.model is _m1\n", + "\n", + " # Ensembling a model with itself must equal single-model inference (identical probs -> same argmax)\n", + " _pred_single = PatchInferenceEngine(_m1, _cfg, apply_reorder=False).predict(img_path)\n", + " _pred_dup = PatchInferenceEngine([_m1, _m1], _cfg, apply_reorder=False).predict(img_path)\n", + " assert torch.equal(_pred_single, _pred_dup), \"Ensembling identical models should equal single-model output\"\n", + "\n", + " # A list of two different models runs and returns a valid label mask (labels subset of {0, 1})\n", + " _m2 = _mk_ens_unet()\n", + " _eng_ens = PatchInferenceEngine([_m1, _m2], _cfg, apply_reorder=False)\n", + " test_eq(len(_eng_ens.models), 2)\n", + " _pred_ens = _eng_ens.predict(img_path)\n", + " test_eq(_pred_ens.shape, _pred_single.shape)\n", + " assert set(torch.unique(_pred_ens).tolist()).issubset({0, 1})\n", + "\n", + " # Ensemble + TTA runs and returns the same shape\n", + " _pred_ens_tta = _eng_ens.predict(img_path, tta=True)\n", + " test_eq(_pred_ens_tta.shape, _pred_single.shape)\n", + "\n", + " # patch_inference accepts a list of models (batch ensemble)\n", + " _batch = patch_inference([_m1, _m2], _cfg, [img_path], apply_reorder=False, progress=False)\n", + " test_eq(len(_batch), 1)\n", + " test_eq(_batch[0].shape, _pred_single.shape)\n", + "\n", + " # Ensembling identical models via patch_inference equals the single-model result\n", + " _batch_dup = patch_inference([_m1, _m1], _cfg, [img_path], apply_reorder=False, progress=False)\n", + " assert torch.equal(_batch_dup[0], _pred_single)\n", + "\n", + " # Multi-class: keep_largest_component is binary-only, so it is skipped (with a warning) for >2 classes\n", + " import warnings as _warnings\n", + " _mc = _mk_ens_unet(out_channels=3)\n", + " _mc_klc = PatchConfig(patch_size=[32, 32, 32], keep_largest_component=True)\n", + " _mc_off = PatchConfig(patch_size=[32, 32, 32], keep_largest_component=False)\n", + " _p_off = PatchInferenceEngine([_mc, _mc], _mc_off, apply_reorder=False).predict(img_path)\n", + " with _warnings.catch_warnings(record=True) as _w:\n", + " _warnings.simplefilter(\"always\")\n", + " _p_klc = PatchInferenceEngine([_mc, _mc], _mc_klc, apply_reorder=False).predict(img_path)\n", + " assert torch.equal(_p_klc, _p_off), \"keep_largest_component must be skipped for multi-class output\"\n", + " assert any(\"binary-only\" in str(_wi.message) for _wi in _w), \"expected a binary-only warning for multi-class\"\n", + " assert set(torch.unique(_p_klc).tolist()).issubset({0, 1, 2})\n", + "\n", + "# An empty model list is rejected\n", + "test_fail(lambda: PatchInferenceEngine([], PatchConfig(patch_size=[32, 32, 32])))\n", + "\n", + "print(\"Ensemble (soft-voting) tests passed!\")" + ] + }, { "cell_type": "markdown", "id": "cell-19", diff --git a/nbs/12b_tutorial_patch_cross_validation.ipynb b/nbs/12b_tutorial_patch_cross_validation.ipynb index 1dca431..9d79fa9 100644 --- a/nbs/12b_tutorial_patch_cross_validation.ipynb +++ b/nbs/12b_tutorial_patch_cross_validation.ipynb @@ -440,8 +440,8 @@ "> **Choosing a fold strategy.** Plain `KFold` is fine when subjects are independent and\n", "> roughly homogeneous. Real datasets often need:\n", ">\n", - "> - **`StratifiedKFold`** to balance a property across folds, for example binning tumour volume\n", - "> into quartiles so each fold has a similar tumour-size distribution (as the VS dataset does).\n", + "> - **`StratifiedKFold`** to balance a property across folds, for example binning tumor volume\n", + "> into quartiles so each fold has a similar tumor-size distribution (as the VS dataset does).\n", "> - **`GroupKFold`** to keep all scans from the same patient in the same fold, preventing\n", "> leakage when a subject contributes multiple volumes." ] @@ -451,21 +451,7 @@ "id": "c24-fn-header", "metadata": {}, "source": [ - "### Define the per-fold train-and-evaluate function\n", - "\n", - "`train_one_fold` mirrors `research/vs_seg/patch_based_dev/train_cv.py`. For one fold it:\n", - "\n", - "1. Splits the data with `valid_col='is_val'` (the held-out fold is validation).\n", - "2. Builds a fresh UNet, loss, and `Learner` (no weights carried over between folds).\n", - "3. Trains with `EMACheckpoint` saving the best smoothed-Dice weights, logging to a per-fold MLflow run.\n", - "4. Runs full-volume `patch_inference` (with TTA) on the held-out fold and computes the metric suite.\n", - "5. Frees GPU memory before the next fold.\n", - "\n", - "`patch_inference` returns each prediction in its volume's original voxel space (it resizes back from the resampled\n", - "grid), and the ground truth is loaded natively too, so surface metrics use the per-case voxel\n", - "spacing read from the GT file (`tio.LabelMap(path).spacing`),\n", - "mirroring `train_cv.py`. The cohort spacing is non-uniform, so a single fixed spacing would give\n", - "wrong HD95/ASSD millimetres." + "### Define the per-fold train-and-evaluate function" ] }, { @@ -2865,10 +2851,7 @@ "**Part 2, final model (deploy)**\n", "\n", "5. **Train on all data**: one model on every subject (a small duplicated nominal-val subset for monitoring only).\n", - "6. **Export**: `learn.export(...)` + `store_patch_variables(...)` for inference (see [12c](12c_tutorial_patch_inference.ipynb)).\n", - "\n", - "In short: cross-validate to estimate, retrain on all data to deploy. The CV mean and std is your\n", - "reported performance; the all-data model is what you ship." + "6. **Export**: `learn.export(...)` + `store_patch_variables(...)` for inference (see [12c](12c_tutorial_patch_inference.ipynb))." ] }, { @@ -2897,6 +2880,16 @@ "source": [ "mlflow_ui.stop()" ] + }, + { + "cell_type": "markdown", + "id": "references-12b", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "- Isensee, F., et al. (2021). nnU-Net: a self-configuring method for deep learning-based biomedical image segmentation. *Nature Methods*. https://doi.org/10.1038/s41592-020-01008-z" + ] } ], "metadata": { diff --git a/nbs/12c_tutorial_patch_inference.ipynb b/nbs/12c_tutorial_patch_inference.ipynb index 76bfa88..fa0a386 100644 --- a/nbs/12c_tutorial_patch_inference.ipynb +++ b/nbs/12c_tutorial_patch_inference.ipynb @@ -431,6 +431,99 @@ "print(f\"Saved to: predictions/patch_heart/\")" ] }, + { + "cell_type": "markdown", + "id": "cell-ensemble-header", + "metadata": {}, + "source": [ + "## Optional: ensemble the 5 cross-validation models\n", + "\n", + "The cross-validation notebook ([12b](12b_tutorial_patch_cross_validation.ipynb)) trains one model per fold. Instead of deploying only the single final model, you can run all five fold models together as an **ensemble** and average their predictions. This is the standard nnU-Net-style approach: it usually gives a small accuracy gain and more robust, better-calibrated masks, at the cost of running inference once per model.\n", + "\n", + "fastMONAI uses **soft voting**. Each model runs the full sliding-window pass, the per-patch class probabilities are averaged across the models, and the final label map comes from a single `argmax` on the averaged probabilities (never a vote on discrete masks). Averaging happens inside `PatchInferenceEngine`, so you just pass a **list of models** instead of one. Because each volume is preprocessed once and every model runs on the shared patches, memory and preprocessing stay close to single-model inference.\n", + "\n", + "> **Prerequisite**: this section uses the per-fold checkpoints `models/best_fold_1.pth ... best_fold_5.pth` saved by 12b's cross-validation part. Run that first if you skipped it." + ] + }, + { + "cell_type": "markdown", + "id": "cell-ensemble-load-header", + "metadata": {}, + "source": [ + "### Load the five fold models\n", + "\n", + "12b's `EMACheckpoint` saved each fold's best weights as `models/best_fold_{i}.pth` (weights only, not an exported learner). We rebuild each model from the final learner's architecture (the folds use the identical UNet) and load each fold's weights into it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-ensemble-load", + "metadata": {}, + "outputs": [], + "source": [ + "from copy import deepcopy\n", + "\n", + "# The 5 CV fold checkpoints from 12b (Part 1). EMACheckpoint saved each fold's best weights\n", + "# as models/best_fold_{i}.pth (weights only), so we rebuild each model from the final learner's\n", + "# architecture (identical UNet) and load each fold's weights into it.\n", + "fold_paths = [Path(f'models/best_fold_{i}.pth') for i in range(1, 6)]\n", + "\n", + "if all(p.exists() for p in fold_paths):\n", + " fold_models = []\n", + " for p in fold_paths:\n", + " m = deepcopy(learn.model)\n", + " m.load_state_dict(torch.load(p, map_location=device))\n", + " m.to(device).eval()\n", + " fold_models.append(m)\n", + " print(f\"Loaded {len(fold_models)} fold models for ensembling\")\n", + "else:\n", + " fold_models = None\n", + " missing = [str(p) for p in fold_paths if not p.exists()]\n", + " print(\"Fold checkpoints not found:\", missing)\n", + " print(\"Run 12b's cross-validation section first. Skipping the ensemble demo.\")\n", + "\n", + "# Alternative: load each fold's exported learner from its MLflow run instead of local weights\n", + "# (each CV fold run logs model/best_learner.pkl), mirroring the single-model MLflow option above:\n", + "# fold_models = [load_learner(fold_pkl).model for fold_pkl in fold_learner_pkls]" + ] + }, + { + "cell_type": "markdown", + "id": "cell-ensemble-run-header", + "metadata": {}, + "source": [ + "### Run the ensemble\n", + "\n", + "Pass the list of fold models to `patch_inference` (or `PatchInferenceEngine`) exactly like a single model. Predictions are written to a separate folder so they sit alongside the single-model outputs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-ensemble-run", + "metadata": {}, + "outputs": [], + "source": [ + "if fold_models is not None:\n", + " # Pass the list of models: the engine averages their softmax probabilities per patch\n", + " # (soft voting), then applies a single argmax + keep_largest_component, as in training.\n", + " ensemble_predictions = patch_inference(\n", + " fold_models,\n", + " config=patch_config,\n", + " file_paths=test_imgs,\n", + " save_dir='predictions/patch_heart_ensemble',\n", + " progress=True\n", + " )\n", + " print(f\"\\nEnsemble: generated {len(ensemble_predictions)} predictions\")\n", + " print(\"Saved to: predictions/patch_heart_ensemble/\")\n", + "\n", + " # Single-image ensemble: same call, just pass the list to PatchInferenceEngine\n", + " ensemble_engine = PatchInferenceEngine(fold_models, config=patch_config, sw_batch_size=4)\n", + " pred_ens = ensemble_engine.predict(test_imgs[0])\n", + " print(f\"Ensemble prediction shape: {pred_ens.shape}, unique values: {torch.unique(pred_ens).tolist()}\")" + ] + }, { "cell_type": "markdown", "id": "cell-visualize-header", @@ -482,24 +575,7 @@ "1. **`load_patch_variables()`**: Load the configuration saved during training so preprocessing matches exactly\n", "2. **Load the exported learner**: `load_learner('models/final_learner.pkl')` loads the all-data model exported by the cross-validation notebook, and we pass its `learn.model` to `PatchInferenceEngine`\n", "3. **`PatchInferenceEngine`**: Sliding-window inference with GridSampler and GridAggregator\n", - "4. **`patch_inference()`**: Batch inference with NIfTI output, then qualitative visualization with TorchIO's `Subject.plot`\n", - "\n", - "**Key points**:\n", - "- This is the deployment step. The final model was trained on all labeled data, so there is no held-out ground truth here and we report no metric. The generalization estimate comes from the cross-validation in the [prerequisite notebook](12b_tutorial_patch_cross_validation.ipynb).\n", - "- Preprocessing parameters (`normalization`, `apply_reorder`, `target_spacing`) are carried in the saved config and applied automatically, so they match the values used in training\n", - "- `PatchInferenceEngine` also accepts a raw PyTorch model, not only a Learner (here we pass `learn.model`)\n", - "- Hann windowing (`aggregation_mode='hann'`) produces smooth boundaries between patches\n", - "- `keep_largest_component=True` can clean up small spurious predictions\n", - "\n", - "**When to use patch-based inference**:\n", - "- Large images that don't fit in GPU memory\n", - "- Variable-sized inputs (no resizing needed)\n", - "- Memory-constrained environments\n", - "\n", - "**Tradeoffs**:\n", - "- Slower than full-image inference (multiple forward passes)\n", - "- Overlap and aggregation add computational overhead\n", - "- Enables processing of arbitrarily large volumes" + "4. **`patch_inference()`**: Batch inference with NIfTI output, then qualitative visualization with TorchIO's `Subject.plot`" ] } ], diff --git a/settings.ini b/settings.ini index 756474d..dc8fd3c 100644 --- a/settings.ini +++ b/settings.ini @@ -5,7 +5,7 @@ ### Python Library ### lib_name = fastMONAI min_python = 3.10 -version = 0.9.1 +version = 0.9.2 ### OPTIONAL ### requirements = fastai==2.8.7 monai==1.6.0 torchio==1.2.1 xlrd>=1.2.0 scikit-image==0.26.0 imagedata==3.9.6 mlflow==3.14.0 huggingface-hub gdown plum-dispatch From 3cd3a7602499655224d8bc5334d5cdcc9752ea2d Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Fri, 3 Jul 2026 15:52:41 +0200 Subject: [PATCH 2/2] research: add vestibular_schwannoma 5-fold CV and ensemble inference workflow - 01_five_fold_cross_validation.ipynb: 5-fold cross-validation over UNet, DynUNet and a SegMamba fork on the patch-based workflow - 02_inference_new_cases.ipynb: 5-fold soft-vote ensemble inference on new cases - ml_dataset.csv (346 CE-T1w cases with pre-assigned folds), README, inference config JSON - gitignore: preprocessed/ and cv_results_*/ notebook run byproducts --- .gitignore | 4 + .../01_five_fold_cross_validation.ipynb | 771 ++++++++++++++++++ .../02_inference_new_cases.ipynb | 299 +++++++ research/vestibular_schwannoma/README.md | 29 + .../inference_patch_config.json | 1 + research/vestibular_schwannoma/ml_dataset.csv | 347 ++++++++ 6 files changed, 1451 insertions(+) create mode 100644 research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb create mode 100644 research/vestibular_schwannoma/02_inference_new_cases.ipynb create mode 100644 research/vestibular_schwannoma/README.md create mode 100644 research/vestibular_schwannoma/inference_patch_config.json create mode 100644 research/vestibular_schwannoma/ml_dataset.csv diff --git a/.gitignore b/.gitignore index 254d9f1..0fbd8a8 100644 --- a/.gitignore +++ b/.gitignore @@ -161,6 +161,10 @@ models/ cv_summary.csv patch_config.json +# Patch-workflow run byproducts (regenerated by the notebooks; e.g. research/*) +preprocessed/ +cv_results_*/ + # Claude - share skills/settings, keep local config private .claude/settings.local.json .claude/*.local.* diff --git a/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb b/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb new file mode 100644 index 0000000..c7433a3 --- /dev/null +++ b/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb @@ -0,0 +1,771 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b1e2871e", + "metadata": {}, + "source": [ + "# Five-Fold Cross-Validation for Vestibular Schwannoma Segmentation\n", + "\n", + "This notebook runs 5-fold cross-validation for three 3D segmentation models on a\n", + "vestibular schwannoma (VS) MRI segmentation task, using the patch-based workflow in\n", + "fastMONAI, built on fastai, MONAI, and TorchIO.\n", + "\n", + "## The task\n", + "\n", + "Vestibular Schwannoma is a benign tumor arising from the vestibulocochlear nerve that can\n", + "cause hearing loss and brainstem compression ([Kujawa et al., 2024](https://doi.org/10.3389/fncom.2024.1365727)).\n", + "For small- to medium-sized lesions, guidelines recommend either upfront radiosurgery or\n", + "observation until radiographic tumor growth is evident ([Dhayalan et al., 2023](https://doi.org/10.1001/jama.2023.12222)).\n", + "Accurate, reproducible delineation of the tumor on Contrast-Enhanced T1-weighted (CE-T1w)\n", + "MRI supports treatment planning and volumetric follow-up. We treat it as a binary 3D\n", + "segmentation problem: for every voxel, decide tumor (1) or background (0).\n", + "\n", + "## The dataset\n", + "\n", + "We use 346 CE-T1w cases pooled from the Queen Square cohort\n", + "([Shapey et al., 2021](https://doi.org/10.1038/s41597-021-01064-w)) and the public crossMoDA\n", + "challenge data ([Dorent et al., 2023](https://doi.org/10.1016/j.media.2022.102628)). Each\n", + "case has a paired ground-truth tumor mask. The tumors are small relative to the field of\n", + "view, which is why we train on patches sampled around the lesion rather than on whole\n", + "volumes.\n", + "\n", + "## What five-fold cross-validation buys us\n", + "\n", + "Cross-validation gives a more honest estimate of how a model generalizes than a single\n", + "train/validation split. The 346 cases are partitioned into five folds. We train five times\n", + "per model; each run holds out one fold for validation and trains on the other four.\n", + "Averaging the held-out scores across the five folds uses every case for validation exactly\n", + "once and reports a mean and a spread rather than a single lucky (or unlucky) number.\n", + "\n", + "The fold assignment is fixed ahead of time in the `fold` column of the dataset CSV\n", + "(values 1..5). Using a pre-assigned column, rather than reshuffling here, means every model\n", + "sees the exact same folds, so their scores are directly comparable, and the split is\n", + "reproducible across notebooks and machines. The CSV also carries a `split` column, but the\n", + "cross-validation intentionally ignores it: all 346 cases participate in CV.\n", + "\n", + "## The three models\n", + "\n", + "- **UNet** (MONAI built-in): the classic encoder-decoder with skip connections. A strong,\n", + " well-understood convolutional baseline.\n", + "- **DynUNet** (MONAI built-in): a dynamic, nnU-Net-style UNet\n", + " ([Isensee et al., 2024](https://doi.org/10.1007/978-3-031-72114-4_47)) with residual blocks\n", + " and deep supervision (auxiliary losses at several decoder resolutions), which tends to\n", + " train more stably on medical data.\n", + "- **SegMamba** (our fork): a state-space (Mamba) backbone for 3D segmentation. It replaces\n", + " the quadratic-cost attention of a transformer with a linear-time selective scan, aiming to\n", + " capture long-range 3D context efficiently.\n", + "\n", + "All three are trained through an identical data, loss, metric, and evaluation pipeline, so\n", + "any difference in scores reflects the model rather than the plumbing.\n", + "\n", + "Despite the high accuracy reported for automated VS segmentation, its real-world utility and\n", + "computational cost remain underexplored, and an apparent performance plateau raises the\n", + "question of whether newer architectures offer practical gains over a standard CNN\n", + "([Connor et al., 2025](https://doi.org/10.5152/iao.2025.241693); [Häußler et al., 2025](https://doi.org/10.1002/lary.31979)).\n", + "Comparing a standard convolutional baseline (UNet) against an nnU-Net-style model (DynUNet)\n", + "and a state-space model (SegMamba) on the same data is a controlled way to probe that\n", + "question. References cited throughout are listed at the end of this notebook." + ] + }, + { + "cell_type": "markdown", + "id": "65895c58", + "metadata": {}, + "source": [ + "## 1. Environment setup\n", + "\n", + "We import the fastMONAI patch API, MONAI networks and losses, and the exact metric\n", + "functions used during evaluation. Everything runs in a conda environment with fastMONAI and\n", + "its dependencies installed. We `chdir` into the project folder early so that the relative\n", + "image and mask paths in the CSV (`../nii_data/...`) resolve correctly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af9ad685", + "metadata": {}, + "outputs": [], + "source": "import os\nimport gc\nimport json\nimport traceback\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torchio as tio\n\nfrom monai.losses import DiceCELoss, DeepSupervisionLoss\nfrom monai.networks.layers import Norm\nfrom monai.networks.nets import UNet, DynUNet\n\nfrom fastMONAI.vision_all import *\n\n# SegMamba is an optional fork; UNet and DynUNet need none of it. Import it once here so a\n# missing fork is reported now, with the install command, instead of mid-sweep. (A fork that\n# imports but whose CUDA kernels are broken surfaces later, when the model is built, and is\n# caught by the driver's try/except.)\n_SEGMAMBA_INSTALL_HINT = (\n \" GPU (training): pip install 'segmamba-v2[gpu] @ git+https://github.com/skaliy/SegMamba-V2.git'\\n\"\n \" CPU (inference): pip install 'segmamba-v2[cpu] @ git+https://github.com/skaliy/SegMamba-V2.git'\"\n)\ntry:\n from models_segmamba.segmambav2 import SegMamba\n SEGMAMBA_AVAILABLE = True\nexcept ImportError as exc:\n SegMamba, SEGMAMBA_AVAILABLE = None, False\n print(f\"[segmamba] fork not available ({exc}); UNet and DynUNet still run. To enable it:\\n\"\n + _SEGMAMBA_INSTALL_HINT)\n\n# Resolve data paths against the repo root so they work wherever the kernel started\n# (VS Code launches at the workspace root; plain Jupyter, in the notebook folder).\nimport fastMONAI\nREPO_ROOT = Path(fastMONAI.__file__).resolve().parent.parent\nos.chdir(REPO_ROOT / \"research\" / \"vestibular_schwannoma\")" + }, + { + "cell_type": "markdown", + "id": "ba1a63fd", + "metadata": {}, + "source": [ + "## 2. Run configuration\n", + "\n", + "The knobs below control the size of the run. A full paper run is\n", + "`3 models x 5 folds x 500 epochs`, which takes on the order of days on a single modern GPU.\n", + "\n", + "To smoke-test the pipeline end to end in a few minutes instead, shrink the sweep, for\n", + "example:\n", + "\n", + "- `MODELS_TO_RUN = [\"unet\"]`\n", + "- `FOLDS_TO_RUN = [1]`\n", + "- `EPOCHS = 2`\n", + "\n", + "The demo exercises every stage (preprocessing, patch loading, training, sliding-window\n", + "evaluation, metric computation, aggregation) without producing publication-quality models.\n", + "`TARGET_SPACING` and `PATCH_SIZE` are part of the shared preprocessing contract and must\n", + "match the inference notebook, so they are not casual knobs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dc9dd6a5", + "metadata": {}, + "outputs": [], + "source": [ + "# =============================== RUN KNOBS ===============================\n", + "# Which models and folds to run, and for how long. Shrink these for a quick\n", + "# pipeline smoke test (see the note above); keep the defaults for a full run.\n", + "MODELS_TO_RUN = [\"unet\", \"dynunet\", \"segmamba\"]\n", + "FOLDS_TO_RUN = [1, 2, 3, 4, 5]\n", + "\n", + "EPOCHS = 500 # paper setting; set to e.g. 2 for a quick pipeline demo\n", + "BS = 4 # patches per training batch\n", + "LR = 1e-3 # maximum learning rate for fit_one_cycle\n", + "USE_TTA = True # 8-flip test-time augmentation during evaluation\n", + "# ========================================================================\n", + "\n", + "# Shared preprocessing contract. These MUST match 02_inference_new_cases.ipynb.\n", + "TARGET_SPACING = [0.4102, 0.4102, 1.5] # resample target, voxel size in mm\n", + "PATCH_SIZE = [192, 192, 48] # patch shape in voxels\n", + "\n", + "DATA_CSV = \"ml_dataset.csv\"\n", + "\n", + "# cuDNN autotuner: a fixed patch size means the fastest kernels are found once and reused.\n", + "torch.backends.cudnn.benchmark = True\n", + "\n", + "print(f\"Models: {MODELS_TO_RUN}\")\n", + "print(f\"Folds : {FOLDS_TO_RUN}\")\n", + "print(f\"Epochs: {EPOCHS} | Batch size: {BS} | LR: {LR} | TTA: {USE_TTA}\")\n", + "print(f\"Target spacing: {TARGET_SPACING} | Patch size: {PATCH_SIZE}\")" + ] + }, + { + "cell_type": "markdown", + "id": "615d402a", + "metadata": {}, + "source": [ + "## 3. Dataset and folds\n", + "\n", + "The dataset CSV lists one row per case, with the raw image and mask paths, the pre-assigned\n", + "`fold` (1..5), and a `split` column. Cross-validation defines the validation set for each\n", + "fold inside `train_one_fold` as `is_val = (fold == fold_num)`; the held-out fold is the\n", + "validation set and the other four folds are training. All 346 cases take part in every\n", + "sweep, so the `split` column is deliberately not used here." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73034f5e", + "metadata": {}, + "outputs": [], + "source": [ + "train_df = pd.read_csv(DATA_CSV)\n", + "print(f\"Total cases: {len(train_df)}\")\n", + "\n", + "print(\"\\nCases per fold (each fold is the validation set when held out):\")\n", + "print(train_df[\"fold\"].value_counts().sort_index().to_string())\n", + "\n", + "print(\"\\n'split' column (present in the CSV but IGNORED by cross-validation):\")\n", + "print(train_df[\"split\"].value_counts().to_string())\n", + "\n", + "train_df[[\"case_id\", \"t1_img_path\", \"t1_seg_path\", \"fold\", \"split\"]].head()" + ] + }, + { + "cell_type": "markdown", + "id": "e3e3534f", + "metadata": {}, + "source": "## 4. Preprocess once to disk\n\nPreprocessing (RAS+ reorientation, resampling to `TARGET_SPACING`, and foreground-masked\nZ-normalization) is **fold-independent**: it depends only on each raw volume, not on which\nfold that volume lands in. The original per-model scripts repeated it inside every fold; we\nhoist it out and run it a single time over all 346 cases. This is faithful to the pipeline\nbut avoids four redundant passes per model.\n\n`preprocess_dataset` writes the processed volumes under `preprocessed/` and adds the columns\n`t1_img_path_preprocessed` and `t1_seg_path_preprocessed` to `train_df` in place. With\n`skip_existing=True` it is effectively idempotent: re-running reuses the cache instead of\nrecomputing, so you can safely point it at an existing `preprocessed/` folder. During\ntraining, `PatchConfig(preprocessed=True)` then skips reorder, resample, and normalization\nbecause the inputs are already prepared." + }, + { + "cell_type": "markdown", + "id": "abd10b73", + "metadata": {}, + "source": [ + "### Intensity normalization\n", + "\n", + "`ZNormalization(masking_method=\"foreground\")` applies a Z-score: it subtracts a mean and\n", + "divides by a standard deviation. These are computed from foreground voxels, meaning voxels\n", + "with intensity above zero (read from the image, not the tumor mask), and then applied to the\n", + "whole volume. Restricting the statistics to the foreground stops the large zero-valued\n", + "background from dominating them and washing out tissue contrast." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2285c0be", + "metadata": {}, + "outputs": [], + "source": [ + "# Single source of truth for pre-patch / pre-inference intensity normalization. The same\n", + "# list is reused when building the training DataLoaders and when running sliding-window\n", + "# inference, guaranteeing identical preprocessing on both sides.\n", + "pre_patch_tfms = [ZNormalization(masking_method=\"foreground\")]\n", + "\n", + "preprocess_dataset(\n", + " train_df,\n", + " img_col=\"t1_img_path\",\n", + " mask_col=\"t1_seg_path\",\n", + " output_dir=\"preprocessed\",\n", + " target_spacing=TARGET_SPACING,\n", + " transforms=pre_patch_tfms,\n", + " max_workers=32,\n", + ")\n", + "\n", + "print(\"Added columns:\", [c for c in train_df.columns if c.endswith(\"_preprocessed\")])\n", + "train_df[[\"t1_img_path\", \"t1_img_path_preprocessed\"]].head()" + ] + }, + { + "cell_type": "markdown", + "id": "23c25507", + "metadata": {}, + "source": "## 5. Shared pipeline building blocks\n\nEvery model is trained through the same two helpers, so differences in results come from the\nnetwork, not the data pipeline.\n\n`create_patch_config()` returns a `PatchConfig`, the single source of truth for how patches\nare sampled and stitched back together:\n\n- The volumes are large and the tumor is tiny, so we train on **192 x 192 x 48** patches\n rather than whole scans.\n- A **label sampler** with `label_probabilities={0: 0.2, 1: 0.8}` draws 80% of patches\n centered on tumor voxels. Without this bias the network would rarely see foreground.\n- `samples_per_volume=4`: four patches are drawn each time a volume is visited.\n- At evaluation time patches overlap by 50% and are blended with a **Hann window**\n (`aggregation_mode=\"hann\"`) for seamless reconstruction.\n- `preprocessed=True` tells the loader the volumes on disk are already reoriented, resampled,\n and normalized, so it does not repeat that work.\n- `normalization=pre_patch_tfms` records the intensity normalization (foreground\n Z-normalization) on the config as the single source of truth. The volumes on disk are\n already normalized, so training does not re-apply it under `preprocessed=True`; keeping it\n on the config documents the choice, logs it to the MLflow run, and lets inference rebuild the\n identical transform.\n- `keep_largest_component=True` post-processes predictions to the single largest connected\n region, which suits a solitary VS tumor." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42d6caca", + "metadata": {}, + "outputs": [], + "source": "def create_patch_config():\n \"\"\"Patch sampling and aggregation settings shared by all three models.\"\"\"\n return PatchConfig(\n patch_size=PATCH_SIZE,\n samples_per_volume=4,\n sampler_type=\"label\",\n label_probabilities={0: 0.2, 1: 0.8},\n patch_overlap=0.5,\n keep_largest_component=True,\n target_spacing=TARGET_SPACING,\n preprocessed=True,\n normalization=pre_patch_tfms,\n aggregation_mode=\"hann\",\n queue_num_workers=16,\n queue_length=1200,\n )\n\n\npatch_config = create_patch_config()\npatch_config" + }, + { + "cell_type": "markdown", + "id": "318eea6e", + "metadata": {}, + "source": [ + "### GPU augmentation\n", + "\n", + "`create_gpu_augmentation()` returns a `GpuPatchAugmentation` that applies spatial and\n", + "intensity augmentation to each batch directly on the GPU, which keeps the input pipeline\n", + "from becoming the bottleneck. The transforms and probabilities follow nnU-Net conventions:\n", + "moderate random affine, anisotropy, flips, gamma, intensity scaling, noise, and blur.\n", + "Affine translations are specified in voxels, so the requested millimeter shifts are divided\n", + "by the target spacing. Augmentation is applied to the training set only." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9aa931a", + "metadata": {}, + "outputs": [], + "source": [ + "def create_gpu_augmentation():\n", + " \"\"\"nnU-Net-inspired GPU-batched augmentation applied to training patches.\"\"\"\n", + " ts = TARGET_SPACING\n", + " return GpuPatchAugmentation(\n", + " affine={\n", + " \"scales\": (0.7, 1.4),\n", + " \"degrees\": (5, 5, 30),\n", + " \"translation\": (25 / ts[0], 25 / ts[1], 5 / ts[2]),\n", + " \"default_pad_value\": 0.0,\n", + " \"p\": 0.2,\n", + " },\n", + " anisotropy={\"axes\": (0, 1, 2), \"downsampling\": (2, 4), \"p\": 0.25},\n", + " flip={\"axes\": (0, 1, 2), \"p\": 0.5},\n", + " gamma={\"log_gamma\": (-0.3, 0.3), \"p\": 0.3},\n", + " intensity_scale={\"scale_range\": (0.75, 1.25), \"p\": 0.1},\n", + " noise={\"std\": 0.1, \"p\": 0.1},\n", + " blur={\"std\": (0.5, 1.0), \"p\": 0.2},\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "6e13dc69", + "metadata": {}, + "source": "## 6. Model definitions\n\nThe three models are registered in a single `MODELS` dictionary. Each entry provides a\n`make_model` factory, a `make_loss` factory, and the exact experiment name, checkpoint\nfilename, and results directory that the sibling inference notebook expects:\n\n| key | experiment | checkpoint | results dir |\n|-----|------------|-----------|-------------|\n| `unet` | `vs5f_unet` | `best_unet` | `cv_results_unet` |\n| `dynunet` | `vs5f_dynunet` | `best_dynunet` | `cv_results_dynunet` |\n| `segmamba` | `vs5f_segmamba` | `best_segmamba` | `cv_results_segmamba` |\n\nUNet and SegMamba use a Dice + cross-entropy loss. DynUNet emits predictions at several\ndecoder depths, so it is wrapped in `DynUNetDSAdapter` (which turns the stacked output into\nthe list that `DeepSupervisionLoss` expects) and trained with a deep-supervision loss. UNet\nand DynUNet are compiled with `torch.compile`; SegMamba is not, because it relies on custom\nCUDA kernels.\n\n### Install SegMamba\n\n**UNet and DynUNet need nothing extra** beyond fastMONAI: they are MONAI built-ins.\n\n**SegMamba requires our fork** (`models_segmamba`, imported as `from\nmodels_segmamba.segmambav2 import SegMamba`; default backend `mamba_ssm`, 138.77M\nparameters). Install it from GitHub with the `[gpu]` extra, which is the one command you\nrun for this notebook: it brings the base package plus the CUDA Mamba kernels that the\ndefault `mamba_ssm` backend needs:\n\n pip install \"segmamba-v2[gpu] @ git+https://github.com/skaliy/SegMamba-V2.git\"\n\nThe kernels (`mamba-ssm`, `causal-conv1d`) sit behind the `[gpu]` extra rather than in the\nbase install because they compile CUDA extensions and only build where the CUDA toolchain is\npresent; keeping them optional lets the package install on machines without a GPU. For a\nCPU-only box (inference/dev; CPU training is not practical) use `[cpu]` instead, which pulls\nin transformers for the `mamba_backend=\"mambamixer\"` path:\n\n pip install \"segmamba-v2[cpu] @ git+https://github.com/skaliy/SegMamba-V2.git\"\n\nCaveat: `mamba-ssm` and `causal-conv1d` are CUDA-only and are compiled against the active\ntorch build. A torch version bump can break `causal-conv1d` with an \"undefined symbol\" ABI\nerror; reinstall it against the current torch with `--no-build-isolation --force-reinstall`.\n\nSegMamba is imported once, up front, in the setup cell (section 1) inside a `try/except`. If\nthe fork is not importable, `SEGMAMBA_AVAILABLE` is set to `False`, the install command is\nprinted, and the next cell skips SegMamba while UNet and DynUNet still run. A fork that imports\nbut whose CUDA kernels are broken (the \"undefined symbol\" case above) is not caught here,\nbecause the import does not load the kernels; it surfaces later, when the model is built, where\nthe driver's per-(model, fold) `try/except` skips just that run." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1cea942f", + "metadata": {}, + "outputs": [], + "source": [ + "MODELS = {}\n", + "\n", + "def make_unet():\n", + " model = UNet(\n", + " spatial_dims=3,\n", + " in_channels=1,\n", + " out_channels=2,\n", + " channels=(64, 128, 256, 512, 1024),\n", + " strides=(2, 2, 2, 2),\n", + " num_res_units=4,\n", + " norm=Norm.INSTANCE,\n", + " act=(\"LEAKYRELU\", {\"negative_slope\": 0.01, \"inplace\": True}),\n", + " )\n", + " return torch.compile(model)\n", + "\n", + "\n", + "def make_unet_loss():\n", + " return CustomLoss(loss_func=DiceCELoss(\n", + " to_onehot_y=True, softmax=True, include_background=False, batch=True\n", + " ))\n", + "\n", + "\n", + "MODELS[\"unet\"] = dict(\n", + " make_model=make_unet, make_loss=make_unet_loss,\n", + " experiment=\"vs5f_unet\", best_fname=\"best_unet\", results_dir=\"cv_results_unet\",\n", + ")\n", + "\n", + "def make_dynunet():\n", + " dynunet = DynUNet(\n", + " spatial_dims=3,\n", + " in_channels=1,\n", + " out_channels=2,\n", + " kernel_size=[[3, 3, 3], [3, 3, 3], [3, 3, 3], [3, 3, 3], [3, 3, 3]],\n", + " strides=[[1, 1, 1], [2, 2, 2], [2, 2, 2], [2, 2, 2], [2, 2, 2]],\n", + " upsample_kernel_size=[[2, 2, 2], [2, 2, 2], [2, 2, 2], [2, 2, 2]],\n", + " filters=[64, 128, 256, 512, 1024],\n", + " res_block=True,\n", + " deep_supervision=True,\n", + " deep_supr_num=3,\n", + " )\n", + " model = DynUNetDSAdapter(dynunet)\n", + " return torch.compile(model)\n", + "\n", + "\n", + "def make_dynunet_loss():\n", + " base_loss = DiceCELoss(\n", + " to_onehot_y=True, softmax=True, include_background=False, batch=True\n", + " )\n", + " return CustomLoss(loss_func=DeepSupervisionLoss(base_loss, weight_mode=\"exp\"))\n", + "\n", + "\n", + "MODELS[\"dynunet\"] = dict(\n", + " make_model=make_dynunet, make_loss=make_dynunet_loss,\n", + " experiment=\"vs5f_dynunet\", best_fname=\"best_dynunet\", results_dir=\"cv_results_dynunet\",\n", + ")\n", + "\n", + "# SegMamba (optional fork): registered only if it imported cleanly in the setup cell. Not\n", + "# wrapped in torch.compile because it relies on custom CUDA kernels.\n", + "if SEGMAMBA_AVAILABLE:\n", + "\n", + " def make_segmamba():\n", + " return SegMamba(\n", + " in_chans=1,\n", + " out_chans=2,\n", + " depths=[2, 2, 2, 2],\n", + " feat_size=[48, 96, 192, 384],\n", + " hidden_size=768,\n", + " )\n", + "\n", + " def make_segmamba_loss():\n", + " return CustomLoss(loss_func=DiceCELoss(\n", + " to_onehot_y=True, softmax=True, include_background=False, batch=True\n", + " ))\n", + "\n", + " MODELS[\"segmamba\"] = dict(\n", + " make_model=make_segmamba, make_loss=make_segmamba_loss,\n", + " experiment=\"vs5f_segmamba\", best_fname=\"best_segmamba\",\n", + " results_dir=\"cv_results_segmamba\",\n", + " )\n", + "\n", + "print(\"Registered models:\", list(MODELS))\n", + "\n", + "# If segmamba was requested but is unavailable, note it (the setup cell already printed the\n", + "# install/fix command). A UNet/DynUNet-only run stays quiet.\n", + "if \"segmamba\" in MODELS_TO_RUN and \"segmamba\" not in MODELS:\n", + " print(\"\\n[segmamba] requested but its fork is not importable, so it is skipped \"\n", + " \"(see the install hint from the setup cell above).\")" + ] + }, + { + "cell_type": "markdown", + "id": "c0a9ce0a", + "metadata": {}, + "source": [ + "## 7. Train one fold\n", + "\n", + "`train_one_fold(model_key, fold_num, train_df, patch_config)` runs a single training job. It\n", + "marks the held-out fold as validation (`is_val = fold == fold_num`), builds the patch\n", + "DataLoaders from the **preprocessed** columns, constructs the model and loss from the\n", + "registry, and trains with `fit_one_cycle`. Training uses:\n", + "\n", + "- `AccumulatedDice(n_classes=2)` as the monitored metric (nnU-Net-style pseudo-Dice\n", + " accumulated over the whole validation set, more stable than per-batch Dice).\n", + "- `EMACheckpoint` to save the best weights by the exponential moving average of that metric,\n", + " under the per-model checkpoint name so models do not overwrite each other.\n", + "- `create_mlflow_callback` to log parameters, metrics, the split, and artifacts to the\n", + " per-model MLflow experiment, tagged with the dataset fingerprint.\n", + "- `.to_bf16()` for bfloat16 mixed-precision training.\n", + "\n", + "At the end it calls `evaluate_fold` (defined in the next section) and frees GPU memory. The\n", + "function is defined here but only executed later by the driver loop, so it can safely refer\n", + "to `evaluate_fold` from the following cell." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5384dd04", + "metadata": {}, + "outputs": [], + "source": "def train_one_fold(model_key, fold_num, train_df, patch_config):\n \"\"\"Train one model on one fold, then evaluate the held-out fold.\"\"\"\n entry = MODELS[model_key]\n print(f\"\\n{'='*60}\")\n print(f\" {model_key.upper()} | FOLD {fold_num}\")\n print(f\"{'='*60}\\n\")\n\n # Fold membership: the held-out fold becomes the validation set. All 346 cases\n # participate; the CSV 'split' column is not used here.\n train_df = train_df.copy()\n train_df[\"is_val\"] = train_df[\"fold\"] == fold_num\n\n # Content fingerprint of the label set, logged to MLflow for reproducibility.\n med_dataset = MedDataset(\n img_list=train_df.t1_seg_path.tolist(), dtype=MedMask, max_workers=32\n )\n\n gpu_aug = create_gpu_augmentation()\n\n # Normalization lives on patch_config (single source of truth). The preprocessed columns\n # are already reoriented, resampled, and Z-normalized, so preprocessed=True skips re-applying\n # it here; the config still carries the spec for logging and inference parity.\n dls = MedPatchDataLoaders.from_df(\n df=train_df,\n img_col=\"t1_img_path_preprocessed\",\n mask_col=\"t1_seg_path_preprocessed\",\n valid_col=\"is_val\",\n patch_config=patch_config,\n gpu_augmentation=gpu_aug,\n bs=BS,\n )\n\n print(f\"[{model_key} fold {fold_num}] Train: {len(dls.train.subjects_dataset)}, \"\n f\"Val: {len(dls.valid.subjects_dataset)}\")\n\n model = entry[\"make_model\"]()\n loss_func = entry[\"make_loss\"]()\n\n learn = Learner(dls, model, loss_func=loss_func,\n metrics=[AccumulatedDice(n_classes=2)]).to_bf16()\n\n save_best = EMACheckpoint(\n monitor=\"accumulated_dice\", momentum=0.9,\n comp=np.greater, fname=entry[\"best_fname\"], with_opt=False,\n )\n\n mlflow_cb = create_mlflow_callback(\n learn,\n experiment_name=entry[\"experiment\"],\n run_name=f\"fold_{fold_num}\",\n extra_tags={\"fold\": str(fold_num)},\n dataset_version=med_dataset.fingerprint,\n )\n\n learn.fit_one_cycle(EPOCHS, LR, cbs=[mlflow_cb, save_best])\n\n results_df = evaluate_fold(\n learn, patch_config, dls, pre_patch_tfms, fold_num,\n tta=USE_TTA, mlflow_cb=mlflow_cb, results_dir=Path(entry[\"results_dir\"]),\n )\n\n # Release GPU memory before the next (model, fold).\n del learn, model, dls, gpu_aug\n torch.cuda.empty_cache()\n gc.collect()\n\n return results_df" + }, + { + "cell_type": "markdown", + "id": "26832b09", + "metadata": {}, + "source": [ + "## 8. Evaluate a fold\n", + "\n", + "After training, we score the held-out fold on the **raw** validation images (not the cached\n", + "preprocessed copies), re-applying the identical `ZNormalization` through\n", + "`pre_inference_tfms`. This mirrors real inference on unseen data end to end.\n", + "\n", + "For each case we compute Dice, sensitivity, precision, lesion-detection rate, and signed\n", + "relative volume error, plus spacing-aware surface metrics (ASSD, HD95, and Normalized Surface\n", + "Dice at 0.5/1.0/2.0 mm) via `calculate_surface_metrics`. Voxel spacing is read **per case**\n", + "from the ground-truth file, because the cohort spacing is not uniform and a single latched\n", + "affine would corrupt every distance. Per-case scores go to `results.csv`, provenance to\n", + "`benchmark_meta.json`, and finite-mean aggregates to MLflow." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02f51121", + "metadata": {}, + "outputs": [], + "source": [ + "def evaluate_fold(learn, patch_config, dls, pre_patch_tfms, fold_num, tta, mlflow_cb, results_dir):\n", + " results_dir = Path(results_dir)\n", + " fold_dir = results_dir / f\"fold_{fold_num}\"\n", + " pred_dir = fold_dir / \"predictions\"\n", + " pred_dir.mkdir(parents=True, exist_ok=True)\n", + "\n", + " learn.cuda()\n", + "\n", + " val_df = dls._valid_source_df\n", + " val_img_paths = val_df[\"t1_img_path\"].tolist()\n", + " val_mask_paths = val_df[\"t1_seg_path\"].tolist()\n", + "\n", + " print(f\"[Fold {fold_num}] Running inference on {len(val_img_paths)} validation images...\")\n", + " predictions = patch_inference(\n", + " learner=learn,\n", + " config=patch_config,\n", + " file_paths=val_img_paths,\n", + " pre_inference_tfms=pre_patch_tfms,\n", + " save_dir=str(pred_dir),\n", + " progress=True,\n", + " tta=tta,\n", + " )\n", + "\n", + " # Per-case metric table via the library helper: it reads each GT mask's data AND spacing\n", + " # from one object (so the array and its spacing can't disagree) and runs the full panel.\n", + " results_df = evaluate_segmentations(predictions, val_mask_paths,\n", + " case_ids=val_df[\"case_id\"].tolist())\n", + " results_df.insert(1, \"image\", [Path(p).name for p in val_img_paths])\n", + " if results_df[\"spacing_mm\"].astype(str).nunique() == 1:\n", + " print(f\"[Fold {fold_num}] WARNING: spacing_mm is constant across the fold \"\n", + " f\"({results_df['spacing_mm'].iloc[0]}); verify per-case derivation if unexpected.\")\n", + " results_df.to_csv(fold_dir / \"results.csv\", index=False)\n", + "\n", + " # Benchmark provenance (arxiv:2410.02630 transparency): metric implementation,\n", + " # NSD tolerances, and per-case spacing/empty-status used for the surface metrics.\n", + " from fastMONAI.vision_metrics import _SURFACE_DISTANCE_SOURCE\n", + " with open(fold_dir / \"benchmark_meta.json\", \"w\") as f:\n", + " json.dump({\n", + " \"surface_distance_source\": _SURFACE_DISTANCE_SOURCE,\n", + " \"nsd_tolerances_mm\": [0.5, 1.0, 2.0], # evaluate_segmentations defaults\n", + " \"nsd_headline_tau_mm\": 1.0,\n", + " \"status_counts\": results_df[\"surface_status\"].value_counts().to_dict(),\n", + " \"per_case\": [{\"case_id\": r[\"case_id\"], \"spacing_mm\": list(r[\"spacing_mm\"]),\n", + " \"surface_status\": r[\"surface_status\"]}\n", + " for r in results_df.to_dict(\"records\")],\n", + " }, f, indent=2)\n", + "\n", + " # inf-safe means: one_empty cases score assd_mm/hd95_mm = inf, which would poison a plain\n", + " # mean (and MLflow rejects non-finite values). Replacing inf with NaN lets pandas skip it.\n", + " numeric = results_df.select_dtypes(include=\"number\").replace([np.inf, -np.inf], np.nan)\n", + " mlflow_cb.log_metrics_table(results_df, display=False)\n", + " mlflow_cb.log_metrics({f\"val_{m}\": numeric[m].mean() for m in numeric.columns})\n", + " mlflow_cb.log_dataframe(results_df)\n", + "\n", + " print(f\"[Fold {fold_num}] Results saved to {fold_dir / 'results.csv'}\")\n", + " print(f\"[Fold {fold_num}] DSC: {results_df['dsc'].mean():.4f} +/- {results_df['dsc'].std():.4f}\")\n", + " return results_df" + ] + }, + { + "cell_type": "markdown", + "id": "f7261d2c", + "metadata": {}, + "source": [ + "## 9. Cross-validation driver\n", + "\n", + "The driver sweeps `MODELS_TO_RUN x FOLDS_TO_RUN`, calling `train_one_fold` for each pair.\n", + "Each pair is wrapped in try/except: if one job fails (for example an out-of-memory error on\n", + "one fold) the error is printed and the sweep continues with the next pair rather than\n", + "aborting the whole run. Unregistered model keys (for example `segmamba` without the fork)\n", + "are skipped with a message.\n", + "\n", + "This is the long-running cell. For the full paper configuration it trains up to 15 models\n", + "and can take days. Reduce the knobs in the configuration cell for a quick demo." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7593854", + "metadata": {}, + "outputs": [], + "source": [ + "# Resilient sweep: a failure on one (model, fold) is logged and skipped so it does not abort\n", + "# the rest of the run. Re-running is safe; a completed fold simply overwrites its outputs.\n", + "for model_key in MODELS_TO_RUN:\n", + " if model_key not in MODELS:\n", + " print(f\"[skip] '{model_key}' is not registered (see the model definitions cell above).\")\n", + " continue\n", + "\n", + " Path(MODELS[model_key][\"results_dir\"]).mkdir(parents=True, exist_ok=True)\n", + "\n", + " for fold_num in FOLDS_TO_RUN:\n", + " try:\n", + " train_one_fold(model_key, fold_num, train_df, patch_config)\n", + " except Exception as exc:\n", + " print(f\"[FAILED] {model_key} fold {fold_num}: {type(exc).__name__}: {exc}\")\n", + " traceback.print_exc()\n", + " torch.cuda.empty_cache()\n", + " gc.collect()\n", + " continue\n", + "\n", + "print(\"\\nSweep complete.\")" + ] + }, + { + "cell_type": "markdown", + "id": "69415c7b", + "metadata": {}, + "source": [ + "## 10. Aggregate per-model results\n", + "\n", + "For each model, `aggregate_results` concatenates the per-fold `results.csv` files into a\n", + "single `cv_summary.csv` inside that model's results directory, then prints a per-metric\n", + "mean/standard-deviation summary and the per-fold DSC breakdown. Surface distances in\n", + "millimeters (ASSD, HD95) are averaged over **finite values only**: a case where exactly one\n", + "of prediction or ground truth is empty scores `+inf` by design, and a plain mean would be\n", + "dominated by it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9529920", + "metadata": {}, + "outputs": [], + "source": [ + "def aggregate_results(results_dir):\n", + " \"\"\"Concatenate a model's per-fold results.csv into cv_summary.csv and print a summary.\n", + "\n", + " Returns the combined DataFrame, or None if no fold results exist yet (notebook-safe:\n", + " it does not exit the process).\n", + " \"\"\"\n", + " results_dir = Path(results_dir)\n", + " all_results = []\n", + " for fold_dir in sorted(results_dir.glob(\"fold_*\")):\n", + " csv_path = fold_dir / \"results.csv\"\n", + " if csv_path.exists():\n", + " df = pd.read_csv(csv_path)\n", + " df[\"fold\"] = int(fold_dir.name.split(\"_\")[1])\n", + " all_results.append(df)\n", + "\n", + " if not all_results:\n", + " print(f\"No fold results found in {results_dir}. Run training first.\")\n", + " return None\n", + "\n", + " combined = pd.concat(all_results, ignore_index=True)\n", + " combined.to_csv(results_dir / \"cv_summary.csv\", index=False)\n", + "\n", + " metrics = [\"dsc\", \"sensitivity\", \"precision\", \"ldr\", \"rve\",\n", + " \"assd_mm\", \"hd95_mm\", \"nsd_tau0.5_mm\", \"nsd_tau1.0_mm\", \"nsd_tau2.0_mm\"]\n", + " inf_excluded = {\"assd_mm\", \"hd95_mm\"} # one_empty -> inf must not poison the mean\n", + " print(f\"\\n{'='*60}\")\n", + " print(f\" CROSS-VALIDATION SUMMARY: {results_dir.name}\")\n", + " print(f\"{'='*60}\")\n", + " print(f\" Folds completed: {sorted(combined['fold'].unique())}\")\n", + " print(f\" Total subjects: {len(combined)}\\n\")\n", + " print(f\" {'Metric':<15} {'Mean':>10} {'Std':>10}\")\n", + " print(f\" {'-'*35}\")\n", + " for m in metrics:\n", + " if m not in combined.columns:\n", + " continue\n", + " col = combined[m]\n", + " note = \"\"\n", + " if m in inf_excluded:\n", + " mask = np.isfinite(col)\n", + " n_skip = int((~mask).sum())\n", + " col = col[mask]\n", + " if n_skip:\n", + " note = f\" ({n_skip} non-finite excl.)\"\n", + " print(f\" {m:<15} {col.mean():>10.4f} {col.std():>10.4f}{note}\")\n", + "\n", + " if \"surface_status\" in combined.columns:\n", + " print(f\"\\n Surface-metric status counts:\")\n", + " for status, n in combined[\"surface_status\"].value_counts().items():\n", + " print(f\" {status:<12} {n}\")\n", + "\n", + " print(f\"\\n Per-fold DSC:\")\n", + " for fold_num, group in combined.groupby(\"fold\"):\n", + " print(f\" Fold {fold_num}: {group['dsc'].mean():.4f} +/- {group['dsc'].std():.4f}\")\n", + "\n", + " print(f\"\\n Results saved to {results_dir / 'cv_summary.csv'}\")\n", + " return combined" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f3f1952b", + "metadata": {}, + "outputs": [], + "source": [ + "# Aggregate each model that ran; collect the combined per-case tables for the comparison.\n", + "cv_combined = {}\n", + "for model_key in MODELS_TO_RUN:\n", + " entry = MODELS.get(model_key)\n", + " if entry is None:\n", + " print(f\"[skip] '{model_key}' is not registered.\")\n", + " continue\n", + " combined = aggregate_results(Path(entry[\"results_dir\"]))\n", + " if combined is not None:\n", + " cv_combined[model_key] = combined" + ] + }, + { + "cell_type": "markdown", + "id": "42ffb314", + "metadata": {}, + "source": [ + "## 11. Cross-model comparison\n", + "\n", + "This is the headline table and it is net-new relative to the per-model scripts. For every\n", + "model that produced results, we pool all held-out cases across folds and report the mean and\n", + "standard deviation of each key metric, save the numbers to `cv_model_comparison.csv`, and\n", + "display a compact `mean +/- std` view. The same inf-safe averaging is applied here, so the\n", + "millimeter surface metrics ignore non-finite per-case scores (and NaN, e.g. RVE on an empty\n", + "ground truth, is skipped the same way)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "483d623d", + "metadata": {}, + "outputs": [], + "source": [ + "# Headline metrics for the cross-model table. Surface distances in mm (assd/hd95) can be\n", + "# +inf for 'one_empty' cases (exactly one of prediction/ground truth empty). pandas .mean()/\n", + "# .std() already skip NaN but not inf, so we replace inf with NaN first; the finite-only mean\n", + "# and sample std (ddof=1, the pandas default) then match the per-model summary above.\n", + "KEY_METRICS = [\"dsc\", \"sensitivity\", \"precision\", \"ldr\", \"rve\",\n", + " \"assd_mm\", \"hd95_mm\", \"nsd_tau1.0_mm\"]\n", + "\n", + "rows = []\n", + "for model_key, combined in cv_combined.items():\n", + " row = {\"model\": model_key,\n", + " \"folds\": sorted(combined[\"fold\"].unique().tolist()),\n", + " \"n_cases\": int(len(combined))}\n", + " for m in KEY_METRICS:\n", + " if m in combined.columns:\n", + " finite = combined[m].replace([np.inf, -np.inf], np.nan) # drop inf; pandas skips NaN\n", + " row[f\"{m}_mean\"] = float(finite.mean())\n", + " row[f\"{m}_std\"] = float(finite.std())\n", + " rows.append(row)\n", + "\n", + "if rows:\n", + " comparison_df = pd.DataFrame(rows).set_index(\"model\")\n", + " comparison_df.to_csv(\"cv_model_comparison.csv\")\n", + " print(\"Saved cross-model comparison to cv_model_comparison.csv\\n\")\n", + "\n", + " # Compact 'mean +/- std' view of the headline metrics.\n", + " pretty = pd.DataFrame(index=comparison_df.index)\n", + " for m in KEY_METRICS:\n", + " mc, sc = f\"{m}_mean\", f\"{m}_std\"\n", + " if mc in comparison_df.columns:\n", + " pretty[m] = [f\"{mu:.4f} +/- {sd:.4f}\"\n", + " for mu, sd in zip(comparison_df[mc], comparison_df[sc])]\n", + " display(pretty)\n", + "else:\n", + " print(\"No per-model results available yet. Run the driver loop first.\")" + ] + }, + { + "cell_type": "markdown", + "id": "4b709ef9", + "metadata": {}, + "source": [ + "## 12. Viewing runs and next steps\n", + "\n", + "Every fold logs to MLflow (metrics, parameters, the train/validation split, and model\n", + "artifacts) under the per-model experiment names `vs5f_unet`, `vs5f_dynunet`, and\n", + "`vs5f_segmamba`. To browse them, launch the fastMONAI MLflow UI from a notebook cell:\n", + "\n", + " mlflow_ui = MLflowUIManager()\n", + " mlflow_ui.start_ui() # opens http://localhost:5001\n", + "\n", + "The UI is tied to the kernel and is reaped when the interpreter exits, so restarting the\n", + "notebook leaves no orphaned server holding the port.\n", + "\n", + "Artifacts written to disk:\n", + "\n", + "- `preprocessed/` - reoriented, resampled, normalized volumes (shared by all folds).\n", + "- `cv_results_/fold_N/` - per-fold `results.csv`, `benchmark_meta.json`, and NIfTI\n", + " predictions.\n", + "- `cv_results_/cv_summary.csv` - all folds concatenated for one model.\n", + "- `cv_model_comparison.csv` - the headline mean +/- std table across models.\n", + "\n", + "The best checkpoint for each model is saved by `EMACheckpoint` as `best_unet`,\n", + "`best_dynunet`, and `best_segmamba`. To run any trained model on brand-new cases, see the\n", + "sibling notebook `02_inference_new_cases.ipynb`, which reuses the same `TARGET_SPACING`,\n", + "`PATCH_SIZE`, and `ZNormalization(masking_method=\"foreground\")` contract so that inference\n", + "preprocessing matches training exactly.\n", + "\n", + "To deploy the five folds together as a soft-vote ensemble, pass the list of fold learners to `patch_inference` (or `PatchInferenceEngine`): it averages their per-patch probabilities in a single sliding-window pass, which usually beats any single fold. Notebook 02 does exactly this." + ] + }, + { + "cell_type": "markdown", + "id": "6099e9bd", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "- Connor, S., et al. (2025). The Real-World Impact of Vestibular Schwannoma Fully Automated Volume Measures on the Evaluation of Size Change and Clinical Management Outcomes in a Multidisciplinary Meeting Setting. *Journal of International Advanced Otology*. https://doi.org/10.5152/iao.2025.241693\n", + "- Dhayalan, D., et al. (2023). Upfront Radiosurgery vs a Wait-and-Scan Approach for Small- or Medium-Sized Vestibular Schwannoma: The V-REX Randomized Clinical Trial. *JAMA*. https://doi.org/10.1001/jama.2023.12222\n", + "- Dorent, R., et al. (2023). CrossMoDA 2021 challenge: Benchmark of cross-modality domain adaptation techniques for vestibular schwannoma and cochlea segmentation. *Medical Image Analysis*. https://doi.org/10.1016/j.media.2022.102628\n", + "- Häußler, S. M., et al. (2025). Automatic Segmentation of Vestibular Schwannoma From MRI Using Two Cascaded Deep Learning Networks. *The Laryngoscope*. https://doi.org/10.1002/lary.31979\n", + "- Isensee, F., et al. (2024). nnU-Net Revisited: A Call for Rigorous Validation in 3D Medical Image Segmentation. *MICCAI 2024*. https://doi.org/10.1007/978-3-031-72114-4_47\n", + "- Kujawa, A., et al. (2024). Deep learning for automatic segmentation of vestibular schwannoma: a retrospective study from multi-center routine MRI. *Frontiers in Computational Neuroscience*. https://doi.org/10.3389/fncom.2024.1365727\n", + "- Shapey, J., et al. (2021). Segmentation of vestibular schwannoma from MRI, an open annotated dataset and baseline algorithm. *Scientific Data*. https://doi.org/10.1038/s41597-021-01064-w" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/research/vestibular_schwannoma/02_inference_new_cases.ipynb b/research/vestibular_schwannoma/02_inference_new_cases.ipynb new file mode 100644 index 0000000..3791831 --- /dev/null +++ b/research/vestibular_schwannoma/02_inference_new_cases.ipynb @@ -0,0 +1,299 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cfd2f9a8", + "metadata": {}, + "source": "# Inference on New Vestibular Schwannoma Cases\n\nThis notebook segments new vestibular schwannoma (VS) MRI scans using the models trained in `01_five_fold_cross_validation.ipynb` and saves the predicted tumor masks as NIfTI files. The audience is students and researchers, so every step is explained.\n\nIt runs a **5-fold soft-voting ensemble**: all five cross-validation models for the chosen architecture are run on the scan, their class probabilities are averaged, and the averaged map is decoded into a mask. This is the standard way to deploy a cross-validation and usually beats any single fold.\n\n## What this notebook does\n\n1. Locates the five fold checkpoints for a model (UNet, DynUNet, or SegMamba) trained in notebook 01.\n2. Rebuilds the exact preprocessing used at training time.\n3. Runs sliding-window patch inference with each fold and averages the probabilities (soft voting).\n4. Saves and visualizes the predicted mask, and optionally scores it against ground truth.\n\n## Sliding-window patch inference\n\nVS scans are large 3D volumes, too big to feed to a 3D network in one piece. Instead we use **patch-based inference**:\n\n- A `GridSampler` slides a fixed-size window (the patch) across the volume with overlap, producing many small patches.\n- The network predicts on each patch.\n- A `GridAggregator` stitches the patch predictions back into a full-volume prediction. We use **Hann-window** aggregation, which weights each patch by a smooth cosine taper so overlapping patches blend without visible seams at the patch borders.\n\nfastMONAI wraps this in `PatchInferenceEngine` and the `patch_inference` helper.\n\n## Soft-voting ensemble\n\nA 5-fold cross-validation produces five models, each trained on a different 4/5 of the data. To turn them into one predictor we combine them by **soft voting**: run all five, apply softmax to each output, average the five probability maps, and only then take the argmax. Averaging probabilities (not the decoded masks) is the correct way to combine multi-class segmentation models, and it gives a small, reliable accuracy gain over any single fold. The engine returns each fold's probability map already resampled and reoriented into the input's original space, so the five maps are voxel-aligned and can be averaged directly.\n\n## The one rule that matters most: preprocessing parity\n\nA segmentation model produces correct output only when the data it sees at inference is preprocessed **identically** to the data it saw during training. Three settings must match exactly:\n\n- **`apply_reorder`**: reorientation to RAS+ canonical orientation.\n- **`target_spacing`**: the voxel spacing the volume is resampled to.\n- **`normalization`**: the intensity normalization (here, foreground Z-normalization).\n\nA mismatch does not raise an error. It silently produces wrong predictions, for example masks that are shifted, mirrored, or rotated. This notebook reuses the same `PatchConfig` and normalization as training so the two pipelines stay in lockstep." + }, + { + "cell_type": "markdown", + "id": "106beda7", + "metadata": {}, + "source": [ + "## 1. Environment setup\n", + "\n", + "We import the fastMONAI public API and pin the working directory to the project folder so that the relative dataset paths used here (for example `../nii_data/...`) resolve the same way they do during training.\n", + "\n", + "`from fastMONAI.vision_all import *` brings in everything we need for inference: `PatchConfig`, `patch_inference`, `ZNormalization`, `store_patch_variables` / `load_patch_variables`, `MedImage`, `MedMask`, and the metric functions. `load_learner` comes from fastai and is used to load an exported model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44c21482", + "metadata": {}, + "outputs": [], + "source": "import os\nfrom pathlib import Path\n\nimport numpy as np\nimport torch\nimport torchio as tio\n\nfrom fastMONAI.vision_all import *\nfrom fastai.learner import load_learner\n\n# Resolve data paths against the repo root so they work wherever the kernel started\n# (VS Code launches at the workspace root; plain Jupyter, in the notebook folder).\nimport fastMONAI\nREPO_ROOT = Path(fastMONAI.__file__).resolve().parent.parent\nos.chdir(REPO_ROOT / \"research\" / \"vestibular_schwannoma\")\n\nimport fastai, monai # fastMONAI already imported above\nprint(\"fastMONAI:\", fastMONAI.__version__)\nprint(\"fastai: \", fastai.__version__)\nprint(\"MONAI: \", monai.__version__)\nprint(\"TorchIO: \", tio.__version__)\nprint(\"PyTorch: \", torch.__version__)\nprint(\"CUDA available:\", torch.cuda.is_available())\nif torch.cuda.is_available():\n print(\"GPU:\", torch.cuda.get_device_name(0))" + }, + { + "cell_type": "markdown", + "id": "a29cda31", + "metadata": {}, + "source": "## 2. Configuration\n\nEverything you would normally change lives in the cell below. Treat this notebook as a **template**: pick the architecture with `MODEL_KEY`, list the scans in `NEW_CASES`, and run top to bottom. The notebook always soft-votes the five cross-validation folds for the chosen model.\n\n| Knob | Meaning |\n| --- | --- |\n| `MODEL_KEY` | Which architecture to load: `\"unet\"`, `\"dynunet\"`, or `\"segmamba\"`. Must match a trained CV experiment. |\n| `FOLD_LEARNER_PATHS` | Optional `{fold: path/to/best_learner.pkl}`. Leave empty to auto-discover the five folds from MLflow. |\n| `NEW_CASES` | List of NIfTI image paths to segment. These are **raw** scans; the pipeline preprocesses them for you. |\n| `OUTPUT_DIR` | Where predicted masks are written as NIfTI. |\n| `USE_TTA` | 8-flip test-time augmentation. More robust, roughly 8x slower. |\n| `USE_AMP` | Automatic mixed precision (float16) forward pass. Faster on CUDA, ignored on CPU. |\n\n`TARGET_SPACING` and `PATCH_SIZE` are part of the shared contract with training and must not be changed independently of notebook 01." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e2b4255", + "metadata": {}, + "outputs": [], + "source": "# --- Which model ---------------------------------------------------------------\nMODEL_KEY = \"unet\" # \"unet\" | \"dynunet\" | \"segmamba\" (must match a trained CV run)\n\n# Ensemble checkpoints: {fold_number: path/to/best_learner.pkl}. Leave empty to\n# auto-discover the five folds from the MLflow experiment \"vs5f_\" below.\nFOLD_LEARNER_PATHS = {}\n\n# --- What to segment and where to write it -------------------------------------\nNEW_CASES = [\n \"../nii_data/queen_square_data/vs_gk_1/vs_gk_1_t1_refT1.nii.gz\",\n]\nOUTPUT_DIR = \"inference_predictions\"\n\n# --- Inference options ---------------------------------------------------------\nUSE_TTA = True # 8-flip test-time augmentation (more robust, ~8x slower)\nUSE_AMP = True # mixed precision on CUDA (ignored on CPU)\n\n# --- Shared contract with training notebook 01 (do not change in isolation) ----\nTARGET_SPACING = [0.4102, 0.4102, 1.5]\nPATCH_SIZE = [192, 192, 48]\n\nprint(f\"Model: {MODEL_KEY} | mode: 5-fold soft-voting ensemble\")\nprint(f\"Cases to segment: {len(NEW_CASES)}\")" + }, + { + "cell_type": "markdown", + "id": "e4287608", + "metadata": {}, + "source": "### Locate the fold checkpoints\n\nNotebook 01 logs `best_learner.pkl` as an MLflow artifact for every fold, under experiments named `vs5f_unet`, `vs5f_dynunet`, and `vs5f_segmamba` (one run per fold, tagged with its fold number). The helper below finds one checkpoint per fold for `MODEL_KEY` and returns `{fold: local_path}`. It is best-effort and never raises, so it is safe to run before any training exists.\n\nIf you already have the paths, set `FOLD_LEARNER_PATHS` in the configuration cell and this lookup is skipped." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "712e4005", + "metadata": {}, + "outputs": [], + "source": "# find_fold_learners now lives in the library (fastMONAI.utils) and is imported via\n# `from fastMONAI.vision_all import *` above; the experiment name is passed explicitly\n# (it also respects a user-configured MLflow tracking server, unlike the old inline copy).\n\nif not FOLD_LEARNER_PATHS:\n FOLD_LEARNER_PATHS = find_fold_learners(f\"vs5f_{MODEL_KEY}\")\n\nprint(\"Fold checkpoints:\", {k: str(v) for k, v in sorted(FOLD_LEARNER_PATHS.items())} or \"(none)\")" + }, + { + "cell_type": "markdown", + "id": "b59bde21", + "metadata": {}, + "source": [ + "## 3. Build the inference configuration\n", + "\n", + "`PatchConfig` is the single object that describes how patches are sampled, how they are aggregated, and (critically) how the raw input is preprocessed. We build the **exact same** config that training used. The values below are the shared contract with notebook 01.\n", + "\n", + "A few points worth understanding:\n", + "\n", + "- **`preprocessed=True`** only affects *training* (it tells the training pipeline that the data on disk was already reordered, resampled, and normalized, so it should not do it again). At *inference* the engine always reorders, resamples, and normalizes the raw input for you, so we point it at the original scans.\n", + "- **`patch_overlap=0.5`** means neighboring windows overlap by half a patch. More overlap gives smoother, more accurate borders at the cost of speed.\n", + "- **`aggregation_mode=\"hann\"`** blends overlapping patches with a Hann taper (smoothest transitions).\n", + "- **`keep_largest_component=True`** keeps only the largest connected foreground blob, a simple and effective post-processing step for a single-tumor task like VS.\n", + "\n", + "The pre-inference normalization is defined separately as `pre_inference_tfms` and passed to `patch_inference`. It must be the same transform training used: foreground `ZNormalization`. Passing it explicitly makes the parity obvious and overrides whatever is stored on the config." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25f30026", + "metadata": {}, + "outputs": [], + "source": [ + "# Pre-inference intensity normalization. MUST match training exactly.\n", + "pre_inference_tfms = [ZNormalization(masking_method=\"foreground\")]\n", + "\n", + "# The same PatchConfig used for training in notebook 01.\n", + "patch_config = PatchConfig(\n", + " patch_size=PATCH_SIZE,\n", + " samples_per_volume=4,\n", + " sampler_type=\"label\",\n", + " label_probabilities={0: 0.2, 1: 0.8},\n", + " patch_overlap=0.5,\n", + " keep_largest_component=True,\n", + " target_spacing=TARGET_SPACING,\n", + " preprocessed=True, # only affects training; inference always preprocesses raw input\n", + " aggregation_mode=\"hann\",\n", + " queue_num_workers=16,\n", + " queue_length=1200,\n", + ")\n", + "print(patch_config)" + ] + }, + { + "cell_type": "markdown", + "id": "aec89045", + "metadata": {}, + "source": [ + "### Persisting and reloading the config\n", + "\n", + "To guarantee parity across machines and over time, the patch config can be written to a small JSON file with `store_patch_variables(...)` and read back with `load_patch_variables(...)`. The JSON captures `patch_size`, `target_spacing`, `apply_reorder`, the sampler settings, and the `normalization` spec, so an inference run can reconstruct the training config without re-typing it. This is the recommended way to ship a config alongside a checkpoint.\n", + "\n", + "The cell below writes the current config out and reads it back to confirm the round-trip. An inference run on another machine would simply start from `load_patch_variables(...)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39b66065", + "metadata": {}, + "outputs": [], + "source": [ + "CONFIG_JSON = \"inference_patch_config.json\"\n", + "\n", + "# Coerce the normalization transforms to a JSON-serializable spec. PatchConfig does this\n", + "# coercion in __post_init__, so we build a throwaway config to obtain the spec list.\n", + "norm_specs = PatchConfig(patch_size=PATCH_SIZE, normalization=pre_inference_tfms).normalization\n", + "\n", + "# Persist the config so any inference run can reconstruct it verbatim.\n", + "store_patch_variables(\n", + " CONFIG_JSON,\n", + " patch_size=patch_config.patch_size,\n", + " patch_overlap=patch_config.patch_overlap,\n", + " aggregation_mode=patch_config.aggregation_mode,\n", + " apply_reorder=patch_config.apply_reorder,\n", + " target_spacing=patch_config.target_spacing,\n", + " sampler_type=patch_config.sampler_type,\n", + " label_probabilities=patch_config.label_probabilities,\n", + " samples_per_volume=patch_config.samples_per_volume,\n", + " queue_length=patch_config.queue_length,\n", + " queue_num_workers=patch_config.queue_num_workers,\n", + " keep_largest_component=patch_config.keep_largest_component,\n", + " normalization=norm_specs,\n", + ")\n", + "\n", + "# Read it back to confirm the round-trip (inference elsewhere would start here).\n", + "reloaded = load_patch_variables(CONFIG_JSON)\n", + "print(\"Persisted config to\", CONFIG_JSON)\n", + "print(\"target_spacing:\", reloaded[\"target_spacing\"])\n", + "print(\"patch_size: \", reloaded[\"patch_size\"])\n", + "print(\"normalization: \", reloaded[\"normalization\"])" + ] + }, + { + "cell_type": "markdown", + "id": "20f7b65d", + "metadata": {}, + "source": "## 4. Load the models\n\nWe load the five fold learners for `MODEL_KEY` into a list. Each exported learner carries its own trained weights and preprocessing, so no architecture code is needed. `load_learner` uses Python `pickle`, which can execute arbitrary code, so only load checkpoints you trust.\n\nThe list of learners is what the inference engine soft-votes in the next section." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ce981f2", + "metadata": {}, + "outputs": [], + "source": "def _load_learner(pkl_path):\n \"\"\"Load an exported fastai learner onto GPU if available, else CPU; set eval mode.\"\"\"\n learn = load_learner(pkl_path, cpu=not torch.cuda.is_available())\n learn.model.eval()\n return learn\n\n\n# Load the five fold learners for MODEL_KEY into a list. Each exported learner carries its\n# own trained weights and preprocessing, so no architecture code is needed here. load_learner\n# uses Python pickle, which can execute arbitrary code, so only load checkpoints you trust.\nassert FOLD_LEARNER_PATHS, (\n \"No fold checkpoints found. Train the folds with notebook 01, or set \"\n \"FOLD_LEARNER_PATHS = {1: '.../best_learner.pkl', ...} in the config cell.\")\n\npredictors = []\nfor fold in sorted(FOLD_LEARNER_PATHS):\n predictors.append(_load_learner(FOLD_LEARNER_PATHS[fold]))\n print(f\"Loaded fold {fold}: {FOLD_LEARNER_PATHS[fold]}\")\nprint(f\"Ensemble ready: {len(predictors)} fold model(s) for '{MODEL_KEY}'.\")" + }, + { + "cell_type": "markdown", + "id": "ceaa05d1", + "metadata": {}, + "source": "### A note on SegMamba\n\nSegMamba is not part of MONAI; it comes from our fork **skaliy/SegMamba-V2**. Install it into this environment before selecting `MODEL_KEY = \"segmamba\"`:\n\n```bash\npip install -e /home/sathiesh/ml_projects/SegMamba-V2 # base (import path: models_segmamba)\npip install -e \"/home/sathiesh/ml_projects/SegMamba-V2[gpu]\" # adds mamba-ssm + causal-conv1d for CUDA\n```\n\nThe model is imported as `from models_segmamba.segmambav2 import SegMamba`. It supports two Mamba backends:\n\n- **`mamba_backend=\"mamba_ssm\"`** (default): the CUDA kernel used for training and fast GPU inference.\n- **`mamba_backend=\"mambamixer\"`**: a pure-PyTorch backend (needs `transformers`) that runs on CPU. It is weight-compatible with a `mamba_ssm`-trained checkpoint, so the same `.pth` loads with `strict=True` and no retraining.\n\nUNet and DynUNet need no fork and run on CPU or GPU as-is. One environment caveat: `causal-conv1d` and `mamba-ssm` must be compiled against the active PyTorch build, otherwise the import fails with an \"undefined symbol\" ABI error. On CPU we sidestep this with a small `_force_cpu_mamba_backend()` helper (defined in the section 8 cell below, mirroring `infer_segmamba_cpu.py`) that tells `transformers` not to probe the CUDA kernels. Section 8 shows the CPU path end to end." + }, + { + "cell_type": "markdown", + "id": "37ea9e87", + "metadata": {}, + "source": "## 5. Run inference\n\nEnsembling is built into fastMONAI: pass a **list of learners** to `patch_inference` and it\nsoft-votes them, averaging each patch's class probabilities across the models before taking the\nargmax, all in a single sliding-window pass. We pass the list of fold learners as\n`learner = predictors`.\n\nFor each scan `patch_inference` reorders, resamples, and normalizes the raw input exactly as in\ntraining, slides the patch grid, aggregates with the Hann window (soft-voting per patch across the\nfolds), resamples the prediction back to the input's original grid, and applies the training-time\npost-processing (argmax and keep-largest-component). Predictions are written to `OUTPUT_DIR` as\n`_pred.nii.gz` and returned as in-memory masks in `predictions` for the sections below.\n\nTwo options:\n\n- **`tta=USE_TTA`**: each patch is run through 8 axis-flip combinations and the probabilities\n averaged (combined with the fold averaging). Tighter borders, roughly 8x compute. Impractical on CPU.\n- **`amp=USE_AMP`**: float16 forward pass on CUDA. Faster and lighter; ignored on CPU.\n\nTo obtain the averaged probability map instead of a mask, pass `return_probabilities=True`." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "65abc50b", + "metadata": {}, + "outputs": [], + "source": "# Ensembling is a fastMONAI feature: a list of learners makes patch_inference soft-vote them\n# (per-patch probabilities averaged before argmax) in one sliding-window pass. Post-processing\n# (argmax, keep-largest-component) is applied to the averaged map.\nlearners = predictors\n\nPath(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)\npredictions = patch_inference(\n learner=learners,\n config=patch_config,\n file_paths=NEW_CASES,\n pre_inference_tfms=pre_inference_tfms,\n save_dir=OUTPUT_DIR,\n progress=True,\n tta=USE_TTA,\n amp=USE_AMP,\n)\n\n\ndef _pred_filename(input_path):\n \"\"\"Name patch_inference writes for a prediction: '_pred.nii[.gz]'. Mirrors the\n engine's saver, stripping only the trailing extension so '.nii' and '.nii.gz' inputs\n both map to the right output name (used again when we reload the mask to visualize).\"\"\"\n p = Path(input_path)\n if p.suffix == \".gz\" and p.stem.endswith(\".nii\"):\n return f\"{p.stem[:-4]}_pred.nii.gz\"\n if p.suffix == \".nii\":\n return f\"{p.stem}_pred.nii\"\n return f\"{p.stem}_pred.nii.gz\"\n\n\nprint(f\"\\nWrote {len(predictions)} prediction(s) to {OUTPUT_DIR}/ \"\n f\"(soft-vote ensemble of {len(predictors)} folds).\")\nfor p in NEW_CASES:\n print(f\" {p} -> {OUTPUT_DIR}/{_pred_filename(p)}\")" + }, + { + "cell_type": "markdown", + "id": "691d43d1", + "metadata": {}, + "source": [ + "## 6. Visualize a prediction\n", + "\n", + "Now we inspect a result qualitatively. `vision_plot` is imported separately (it is not part of `vision_all`). We load the input scan as a `MedImage` and its predicted mask as a `MedMask`.\n", + "\n", + "Both are read with fastMONAI's default loader, which does not reorder or resample, so they share the input's original voxel grid and line up slice for slice. We pick the axial slice where the predicted tumor is largest with `find_max_slice`, then show the input, the mask, and an overlay. `voxel_size=TARGET_SPACING` sets only the display aspect ratio; it does not alter the data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff90e7e6", + "metadata": {}, + "outputs": [], + "source": "from fastMONAI.vision_plot import * # fastMONAI plotting; imported separately from vision_all\nfrom torchio.visualization import rotate\nimport matplotlib.pyplot as plt\n\nidx = 0\nimg_fn = NEW_CASES[idx]\n# Same name patch_inference used when saving (see _pred_filename in the run-inference cell),\n# so the mask reloads correctly for both .nii and .nii.gz inputs.\npred_fn = Path(OUTPUT_DIR) / _pred_filename(img_fn)\n\n# Default fastMONAI loader: no reorder, no resample, so input and mask share the\n# input's original voxel grid and align slice for slice.\nimg = MedImage.create(img_fn)\npred_mask = MedMask.create(pred_fn)\n\nplane = 2 # 0 = sagittal, 1 = coronal, 2 = axial\nsl = int(find_max_slice(pred_mask.data[0].cpu().numpy(), plane))\nprint(f\"Predicted foreground voxels: {int(pred_mask.data.sum())}\")\nprint(f\"Showing plane={plane} (axial), slice={sl}\")\n\n\ndef _disp_slice(vol3d, i, plane, vsize):\n \"\"\"Replicate fastMONAI's show slicing so the overlay lines up with .show().\"\"\"\n sr, sa, ss = vsize\n ops = {0: (vol3d[i, :, :], ss / sa),\n 1: (vol3d[:, i, :], ss / sr),\n 2: (vol3d[:, :, i], sa / sr)}\n sl2d, aspect = ops[plane]\n return rotate(sl2d, radiological=True, n=1), aspect\n\n\nfig, axes = plt.subplots(1, 3, figsize=(15, 5))\nimg.show(ctx=axes[0], anatomical_plane=plane, slice_index=sl, voxel_size=TARGET_SPACING)\naxes[0].set_title(\"Input T1\")\npred_mask.show(ctx=axes[1], anatomical_plane=plane, slice_index=sl, voxel_size=TARGET_SPACING)\naxes[1].set_title(\"Predicted mask\")\n\nimg_slice, aspect = _disp_slice(img.data[0].cpu().numpy(), sl, plane, TARGET_SPACING)\nmsk_slice, _ = _disp_slice(pred_mask.data[0].cpu().numpy(), sl, plane, TARGET_SPACING)\naxes[2].imshow(img_slice, cmap=\"gray\", aspect=aspect)\naxes[2].imshow(np.ma.masked_where(msk_slice == 0, msk_slice),\n cmap=\"autumn\", alpha=0.5, aspect=aspect)\naxes[2].set_title(\"Overlay\")\naxes[2].axis(\"off\")\nplt.tight_layout()\nplt.show()" + }, + { + "cell_type": "markdown", + "id": "95c532c4", + "metadata": {}, + "source": [ + "## 7. Optional: validate against ground truth\n", + "\n", + "If a case has an expert segmentation, we can score the prediction with the **same metric functions** notebook 01 uses in its cross-validation, so the numbers here are directly comparable. This cell is optional: it only runs when you set `GT_PATH` to a mask file.\n", + "\n", + "The metrics:\n", + "\n", + "- **DSC** (Dice) and **sensitivity** / **precision**: overlap-based agreement.\n", + "- **LDR** (lesion detection rate) and **signed RVE** (relative volume error).\n", + "- **Surface metrics in millimeters** via `calculate_surface_metrics`: ASSD, HD95, and NSD. These are spacing-aware, so we read the per-case voxel spacing straight from the ground-truth file with `tio.LabelMap(GT_PATH).spacing`. This matters because the VS cohort has non-uniform spacing, and using one global spacing would bias the surface distances.\n", + "\n", + "The prediction and ground truth are compared as 5D tensors `[batch, channel, X, Y, Z]`, matching the metric API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed2780b4", + "metadata": {}, + "outputs": [], + "source": [ + "# Set to a ground-truth mask to score the first case, e.g.\n", + "# GT_PATH = \"../nii_data/queen_square_data/vs_gk_1/vs_gk_1_seg_refT1.nii.gz\"\n", + "GT_PATH = None\n", + "\n", + "if GT_PATH:\n", + " # Score the first prediction with the library's per-case panel (loads the GT mask's\n", + " # data and spacing from one object).\n", + " row = evaluate_segmentations([predictions[0]], [GT_PATH]).iloc[0]\n", + " print(f\"DSC: {row['dsc']:.4f}\")\n", + " print(f\"Sensitivity: {row['sensitivity']:.4f}\")\n", + " print(f\"Precision: {row['precision']:.4f}\")\n", + " print(f\"LDR: {row['ldr']:.4f}\")\n", + " print(f\"Signed RVE: {row['rve']:.4f}\")\n", + " print(f\"ASSD (mm): {row['assd_mm']}\")\n", + " print(f\"HD95 (mm): {row['hd95_mm']}\")\n", + " print(f\"NSD tau=1mm: {row['nsd_tau1.0_mm']}\")\n", + " print(f\"Spacing (mm): {row['spacing_mm']} | status: {row['surface_status']}\")\n", + "else:\n", + " print(\"GT_PATH is None; skipping ground-truth evaluation.\")" + ] + }, + { + "cell_type": "markdown", + "id": "df20f031", + "metadata": {}, + "source": [ + "## 8. Running SegMamba on CPU\n", + "\n", + "When no CUDA GPU is available, SegMamba can still run through the exact same `patch_inference` pipeline by switching to the `mambamixer` backend. This mirrors `research/vs_seg/infer_segmamba_cpu.py` and `research/vs_seg/SEGMAMBA_CPU.md`.\n", + "\n", + "The key points:\n", + "\n", + "- Construct with `mamba_backend=\"mambamixer\"` (pure PyTorch, needs `transformers`). No CUDA and no `mamba_ssm` required.\n", + "- A checkpoint trained with the default `mamba_ssm` backend loads into the `mambamixer` model unchanged (`strict=True`), so no retraining is needed. Correctness was verified decision-identical to GPU on a real case (Dice 1.0).\n", + "- It is slow: roughly 11 minutes per volume on CPU, and TTA is impractical there, so keep `tta=False`.\n", + "\n", + "The cell below is a self-contained CPU inference path. It is guarded so it does nothing unless `RUN_SEGMAMBA_CPU` is set to `True` and a checkpoint is present." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1eff224c", + "metadata": {}, + "outputs": [], + "source": "def _force_cpu_mamba_backend():\n \"\"\"Make transformers' MambaMixer take its pure-PyTorch path instead of probing CUDA\n kernels. A no-op on a clean CPU-only box; needed only when the environment has a\n broken or version-mismatched causal_conv1d / mamba_ssm build (the \"undefined symbol\"\n ABI error). Mirrors research/vs_seg/infer_segmamba_cpu.py.\"\"\"\n import sys\n import transformers # noqa: F401\n for modname in (\"transformers.utils.import_utils\", \"transformers.utils\"):\n mod = sys.modules.get(modname)\n if mod is not None:\n for fn in (\"is_causal_conv1d_available\", \"is_mamba_ssm_available\"):\n if hasattr(mod, fn):\n setattr(mod, fn, (lambda *a, **k: False))\n\n\nRUN_SEGMAMBA_CPU = False\nSEGMAMBA_WEIGHTS = \"models/best_segmamba.pth\"\n\nif RUN_SEGMAMBA_CPU:\n _force_cpu_mamba_backend() # skips the mismatched CUDA kernel probe (defined just above)\n from models_segmamba.segmambav2 import SegMamba\n\n # Pure-PyTorch Mamba backend: runs on CPU, no mamba_ssm / causal_conv1d needed.\n seg_model = SegMamba(\n in_chans=1, out_chans=2, depths=[2, 2, 2, 2],\n feat_size=[48, 96, 192, 384], hidden_size=768,\n mamba_backend=\"mambamixer\",\n )\n # A mamba_ssm-trained checkpoint loads strict into the mambamixer model unchanged.\n from torch.nn.modules.utils import consume_prefix_in_state_dict_if_present\n sd = torch.load(SEGMAMBA_WEIGHTS, map_location=\"cpu\")\n sd = sd[\"model\"] if isinstance(sd, dict) and \"model\" in sd else sd\n consume_prefix_in_state_dict_if_present(sd, \"_orig_mod.\") # official torch helper\n seg_model.load_state_dict(sd, strict=True)\n seg_model.eval() # stays on CPU\n\n cpu_preds = patch_inference(\n learner=seg_model,\n config=patch_config,\n file_paths=NEW_CASES,\n pre_inference_tfms=pre_inference_tfms,\n save_dir=OUTPUT_DIR,\n progress=True,\n tta=False, # TTA is impractical on CPU\n )\n print(f\"SegMamba CPU: {len(cpu_preds)} prediction(s) written to {OUTPUT_DIR}/\")\nelse:\n print(\"Set RUN_SEGMAMBA_CPU = True (with a SegMamba checkpoint) to run this path.\")" + }, + { + "cell_type": "markdown", + "id": "53b15733", + "metadata": {}, + "source": "## Recap and pointers\n\n- **This notebook runs a 5-fold soft-voting ensemble.** Each fold's probability map is produced in the input's original space, the five are averaged, and only then decoded (argmax + keep-largest-component). This usually beats any single fold.\n- **Preprocessing parity is the rule that governs correctness.** The prediction is valid only because inference reused the training `apply_reorder`, `target_spacing`, and foreground `ZNormalization`. When in doubt, load an exported learner (which carries its own preprocessing) or ship a `PatchConfig` JSON via `store_patch_variables` / `load_patch_variables`.\n- **Patch inference** slides an overlapping grid, predicts per patch, and blends with a Hann window, so arbitrarily large volumes fit in memory.\n- **TTA** trades roughly 8x compute for tighter borders; **AMP** speeds up the GPU forward pass. Neither changes the required preprocessing.\n- **Model choice is orthogonal to this notebook.** UNet and DynUNet run anywhere; SegMamba needs the fork and prefers a GPU, with a documented CPU fallback.\n\nPointers:\n\n- Training and the checkpoints this notebook consumes: `01_five_fold_cross_validation.ipynb`.\n- An earlier whole-volume ensemble reference: `research/vs_seg/10_ensemble_inference_verification_unet.ipynb`.\n- SegMamba CPU details: `research/vs_seg/infer_segmamba_cpu.py` and `research/vs_seg/SEGMAMBA_CPU.md`.\n- The inference engine and config: `fastMONAI/vision_patch.py` (`patch_inference`, `PatchInferenceEngine`, `PatchConfig`)." + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/research/vestibular_schwannoma/README.md b/research/vestibular_schwannoma/README.md new file mode 100644 index 0000000..e9407a7 --- /dev/null +++ b/research/vestibular_schwannoma/README.md @@ -0,0 +1,29 @@ +# Vestibular Schwannoma Segmentation (CE-T1w) + +This folder reproduces our fastMONAI results for segmenting vestibular schwannoma (VS) in +contrast-enhanced T1-weighted (CE-T1w) MRI. The task is binary 3D segmentation: for every +voxel, tumor (1) or background (0), trained and evaluated with the patch-based workflow. + +**Status: under development.** Downloading the data and preprocessing it into the layout the +notebooks expect are not part of this folder yet and will be added. + +## Notebooks + +- `01_five_fold_cross_validation.ipynb` - five-fold cross-validation comparing three models + (UNet, DynUNet, and a SegMamba fork) on the same fixed folds. Each fold is trained and then + evaluated with sliding-window inference, and the notebook ends with a cross-model summary. +- `02_inference_new_cases.ipynb` - runs a soft-vote ensemble of the five + folds, on new cases, reusing the exact preprocessing contract from training. + +## Data + +`ml_dataset.csv` has one row per case: image and mask paths (relative to this folder, under +`../nii_data/...`), a pre-assigned `fold` (1..5), and a `split` column. The scans themselves +are not included here yet. The steps that download the data and build `../nii_data/` and this +CSV will be added (see Status). + +## Requirements + +fastMONAI and its dependencies. UNet and DynUNet are MONAI built-ins and need nothing extra. +SegMamba needs our fork; see "Install SegMamba" in +`01_five_fold_cross_validation.ipynb` for the one-line GitHub install. diff --git a/research/vestibular_schwannoma/inference_patch_config.json b/research/vestibular_schwannoma/inference_patch_config.json new file mode 100644 index 0000000..366f441 --- /dev/null +++ b/research/vestibular_schwannoma/inference_patch_config.json @@ -0,0 +1 @@ +{"patch_size": [192, 192, 48], "patch_overlap": 0.5, "aggregation_mode": "hann", "apply_reorder": true, "target_spacing": [0.4102, 0.4102, 1.5], "sampler_type": "label", "label_probabilities": {"0": 0.2, "1": 0.8}, "samples_per_volume": 4, "queue_length": 1200, "queue_num_workers": 16, "keep_largest_component": true, "normalization": [{"name": "ZNormalization", "masking_method": "foreground", "channel_wise": true}]} \ No newline at end of file diff --git a/research/vestibular_schwannoma/ml_dataset.csv b/research/vestibular_schwannoma/ml_dataset.csv new file mode 100644 index 0000000..b208ca6 --- /dev/null +++ b/research/vestibular_schwannoma/ml_dataset.csv @@ -0,0 +1,347 @@ +case_id,volume_mm3,volume_category_quartile,equiv_sphere_diameter_mm,voxel_count,t1_img_path,t1_seg_path,fold,quartile_label,volume_range,is_small,is_large,split +vs_gk_193,4362.997055053711,Q4_Large,20.273501607947463,17290,../nii_data/queen_square_data/vs_gk_193/vs_gk_193_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_193/vs_gk_193_seg_refT1.nii.gz,1,3,L,0,1,train +vs_gk_115,1491.090202331543,Q3_Medium_Large,14.174304711317058,5909,../nii_data/queen_square_data/vs_gk_115/vs_gk_115_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_115/vs_gk_115_seg_refT1.nii.gz,5,2,M,0,0,train +vs_gk_43,329.05426025390625,Q1_Small,8.565570590758327,1304,../nii_data/queen_square_data/vs_gk_43/vs_gk_43_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_43/vs_gk_43_seg_refT1.nii.gz,3,0,XS,1,0,train +vs_gk_213,488.7868881225586,Q1_Small,9.77327925827696,1937,../nii_data/queen_square_data/vs_gk_213/vs_gk_213_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_213/vs_gk_213_seg_refT1.nii.gz,1,0,XS,1,0,train +vs_gk_87,1049.995994567871,Q2_Medium_Small,12.610423542945606,4161,../nii_data/queen_square_data/vs_gk_87/vs_gk_87_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_87/vs_gk_87_seg_refT1.nii.gz,1,1,M,0,0,train +vs_gk_53,472.3846435546875,Q1_Small,9.662712313674025,2808,../nii_data/queen_square_data/vs_gk_53/vs_gk_53_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_53/vs_gk_53_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_183,416.6170120239258,Q1_Small,9.266437321597284,1651,../nii_data/queen_square_data/vs_gk_183/vs_gk_183_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_183/vs_gk_183_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_201,945.5263137817384,Q2_Medium_Small,12.177505445538696,3747,../nii_data/queen_square_data/vs_gk_201/vs_gk_201_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_201/vs_gk_201_seg_refT1.nii.gz,1,1,S,0,0,train +vs_gk_148,461.2815856933594,Q1_Small,9.58640630369382,1828,../nii_data/queen_square_data/vs_gk_148/vs_gk_148_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_148/vs_gk_148_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_5,2076.2718200683594,Q3_Medium_Large,15.828090658010616,8228,../nii_data/queen_square_data/vs_gk_5/vs_gk_5_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_5/vs_gk_5_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_109,4616.348648071289,Q4_Large,20.658557486415383,18294,../nii_data/queen_square_data/vs_gk_109/vs_gk_109_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_109/vs_gk_109_seg_refT1.nii.gz,1,3,L,0,1,train +vs_gk_161,1103.744888305664,Q2_Medium_Small,12.822027214229196,4374,../nii_data/queen_square_data/vs_gk_161/vs_gk_161_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_161/vs_gk_161_seg_refT1.nii.gz,3,1,M,0,0,train +vs_gk_129,852.6643753051758,Q2_Medium_Small,11.7650334244872,3379,../nii_data/queen_square_data/vs_gk_129/vs_gk_129_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_129/vs_gk_129_seg_refT1.nii.gz,4,1,S,0,0,train +vs_gk_78,4024.101448059082,Q4_Large,19.734377556379627,15947,../nii_data/queen_square_data/vs_gk_78/vs_gk_78_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_78/vs_gk_78_seg_refT1.nii.gz,4,3,L,0,1,train +vs_gk_199,3923.921585083008,Q4_Large,19.5692372821121,15550,../nii_data/queen_square_data/vs_gk_199/vs_gk_199_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_199/vs_gk_199_seg_refT1.nii.gz,1,3,L,0,1,train +vs_gk_164,4833.36296081543,Q4_Large,20.97733252077008,19154,../nii_data/queen_square_data/vs_gk_164/vs_gk_164_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_164/vs_gk_164_seg_refT1.nii.gz,2,3,L,0,1,train +vs_gk_91,1748.7316131591797,Q3_Medium_Large,14.94771629043457,6930,../nii_data/queen_square_data/vs_gk_91/vs_gk_91_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_91/vs_gk_91_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_18,49.62730407714844,Q1_Small,4.559396418303071,295,../nii_data/queen_square_data/vs_gk_18/vs_gk_18_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_18/vs_gk_18_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_61,714.6331787109375,Q2_Medium_Small,11.09246799760526,2832,../nii_data/queen_square_data/vs_gk_61/vs_gk_61_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_61/vs_gk_61_seg_refT1.nii.gz,3,1,S,0,0,train +crossmoda2022_etz_82,9914.017788648604,Q4_Large,26.6532613104477,9822,../nii_data/tilburg_data/crossmoda2022_etz_82/crossmoda2022_etz_82_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_82/crossmoda2022_etz_82_Label.nii.gz,1,3,XL,0,1,train +vs_gk_177,3506.5475463867188,Q4_Large,18.84923388296519,13896,../nii_data/queen_square_data/vs_gk_177/vs_gk_177_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_177/vs_gk_177_seg_refT1.nii.gz,2,3,L,0,1,train +vs_gk_125,2606.190490722656,Q3_Medium_Large,17.074022307792454,10328,../nii_data/queen_square_data/vs_gk_125/vs_gk_125_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_125/vs_gk_125_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_221,623.0329513549805,Q1_Small,10.59670387312672,2469,../nii_data/queen_square_data/vs_gk_221/vs_gk_221_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_221/vs_gk_221_seg_refT1.nii.gz,5,0,S,1,0,train +vs_gk_71,2094.188117980957,Q3_Medium_Large,15.873487571396822,8299,../nii_data/queen_square_data/vs_gk_71/vs_gk_71_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_71/vs_gk_71_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_33,1216.541862487793,Q2_Medium_Small,13.244720926711796,4821,../nii_data/queen_square_data/vs_gk_33/vs_gk_33_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_33/vs_gk_33_seg_refT1.nii.gz,4,1,M,0,0,train +crossmoda2022_etz_86,2706.1180114746094,Q3_Medium_Large,17.28951071786434,2681,../nii_data/tilburg_data/crossmoda2022_etz_86/crossmoda2022_etz_86_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_86/crossmoda2022_etz_86_Label.nii.gz,5,2,L,0,0,train +vs_gk_218,2993.283462524414,Q3_Medium_Large,17.880640628067994,11862,../nii_data/queen_square_data/vs_gk_218/vs_gk_218_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_218/vs_gk_218_seg_refT1.nii.gz,4,2,L,0,0,train +vs_gk_86,570.4616546630859,Q1_Small,10.28985555452946,3391,../nii_data/queen_square_data/vs_gk_86/vs_gk_86_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_86/vs_gk_86_seg_refT1.nii.gz,1,0,S,1,0,train +vs_gk_25,998.2658386230468,Q2_Medium_Small,12.39983374915763,3956,../nii_data/queen_square_data/vs_gk_25/vs_gk_25_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_25/vs_gk_25_seg_refT1.nii.gz,2,1,S,0,0,train +crossmoda2022_etz_56,564.2372131347656,Q1_Small,10.252293592820104,559,../nii_data/tilburg_data/crossmoda2022_etz_56/crossmoda2022_etz_56_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_56/crossmoda2022_etz_56_Label.nii.gz,1,0,S,1,0,train +vs_gk_171,1517.838478088379,Q3_Medium_Large,14.25855927229834,6015,../nii_data/queen_square_data/vs_gk_171/vs_gk_171_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_171/vs_gk_171_seg_refT1.nii.gz,4,2,M,0,0,train +crossmoda2022_etz_51,1179.9522399902344,Q2_Medium_Small,13.11058092618194,1169,../nii_data/tilburg_data/crossmoda2022_etz_51/crossmoda2022_etz_51_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_51/crossmoda2022_etz_51_Label.nii.gz,1,1,M,0,0,train +vs_gk_95,1458.790397644043,Q2_Medium_Small,14.071209352272732,5781,../nii_data/queen_square_data/vs_gk_95/vs_gk_95_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_95/vs_gk_95_seg_refT1.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_104,723.7174987792969,Q2_Medium_Small,11.139272214395223,717,../nii_data/tilburg_data/crossmoda2022_etz_104/crossmoda2022_etz_104_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_104/crossmoda2022_etz_104_Label.nii.gz,4,1,S,0,0,train +vs_gk_238,1182.7280044555664,Q2_Medium_Small,13.120853484711036,4687,../nii_data/queen_square_data/vs_gk_238/vs_gk_238_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_238/vs_gk_238_seg_refT1.nii.gz,2,1,M,0,0,train +vs_gk_248,2822.4477767944336,Q3_Medium_Large,17.53378852526643,11185,../nii_data/queen_square_data/vs_gk_248/vs_gk_248_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_248/vs_gk_248_seg_refT1.nii.gz,5,2,L,0,0,train +vs_gk_136,287.1654510498047,Q1_Small,8.185486981224278,1707,../nii_data/queen_square_data/vs_gk_136/vs_gk_136_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_136/vs_gk_136_seg_refT1.nii.gz,3,0,XS,1,0,train +vs_gk_187,4140.935897827148,Q4_Large,19.923545613747105,16410,../nii_data/queen_square_data/vs_gk_187/vs_gk_187_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_187/vs_gk_187_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_19,6827.875900268555,Q4_Large,23.537608351648924,27058,../nii_data/queen_square_data/vs_gk_19/vs_gk_19_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_19/vs_gk_19_seg_refT1.nii.gz,2,3,XL,0,1,train +vs_gk_185,712.6144409179688,Q2_Medium_Small,11.082013261912692,2824,../nii_data/queen_square_data/vs_gk_185/vs_gk_185_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_185/vs_gk_185_seg_refT1.nii.gz,3,1,S,0,0,train +vs_gk_123,1320.2545166015625,Q2_Medium_Small,13.610884433708874,5232,../nii_data/queen_square_data/vs_gk_123/vs_gk_123_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_123/vs_gk_123_seg_refT1.nii.gz,3,1,M,0,0,train +crossmoda2022_etz_76,8315.180969238281,Q4_Large,25.13570251233125,8238,../nii_data/tilburg_data/crossmoda2022_etz_76/crossmoda2022_etz_76_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_76/crossmoda2022_etz_76_Label.nii.gz,4,3,XL,0,1,train +vs_gk_103,585.1816177368164,Q1_Small,10.377610130983658,2319,../nii_data/queen_square_data/vs_gk_103/vs_gk_103_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_103/vs_gk_103_seg_refT1.nii.gz,3,0,S,1,0,train +vs_gk_56,2793.428421020508,Q3_Medium_Large,17.47348943417387,11070,../nii_data/queen_square_data/vs_gk_56/vs_gk_56_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_56/vs_gk_56_seg_refT1.nii.gz,2,2,L,0,0,train +crossmoda2022_etz_70,456.2333402633667,Q1_Small,9.551306878431458,452,../nii_data/tilburg_data/crossmoda2022_etz_70/crossmoda2022_etz_70_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_70/crossmoda2022_etz_70_Label.nii.gz,3,0,XS,1,0,train +crossmoda2022_etz_78,1496.8915984630585,Q3_Medium_Large,14.19266361431514,1483,../nii_data/tilburg_data/crossmoda2022_etz_78/crossmoda2022_etz_78_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_78/crossmoda2022_etz_78_Label.nii.gz,5,2,M,0,0,train +crossmoda2022_etz_97,6199.543762207031,Q4_Large,22.7922413942113,6142,../nii_data/tilburg_data/crossmoda2022_etz_97/crossmoda2022_etz_97_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_97/crossmoda2022_etz_97_Label.nii.gz,1,3,XL,0,1,train +vs_gk_172,3261.5232467651367,Q3_Medium_Large,18.39955355011357,12925,../nii_data/queen_square_data/vs_gk_172/vs_gk_172_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_172/vs_gk_172_seg_refT1.nii.gz,2,2,L,0,0,train +vs_gk_154,1964.736557006836,Q3_Medium_Large,15.539435370546078,7786,../nii_data/queen_square_data/vs_gk_154/vs_gk_154_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_154/vs_gk_154_seg_refT1.nii.gz,4,2,M,0,0,train +crossmoda2022_etz_102,1323.2826232910156,Q2_Medium_Small,13.621282363217889,1311,../nii_data/tilburg_data/crossmoda2022_etz_102/crossmoda2022_etz_102_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_102/crossmoda2022_etz_102_Label.nii.gz,2,1,M,0,0,train +vs_gk_50,4519.196891784668,Q4_Large,20.512607980551536,17909,../nii_data/queen_square_data/vs_gk_50/vs_gk_50_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_50/vs_gk_50_seg_refT1.nii.gz,5,3,L,0,1,train +vs_gk_24,3657.448196411133,Q4_Large,19.11583120878609,14494,../nii_data/queen_square_data/vs_gk_24/vs_gk_24_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_24/vs_gk_24_seg_refT1.nii.gz,4,3,L,0,1,train +crossmoda2022_etz_49,2532.503570318222,Q3_Medium_Large,16.91156560347764,2509,../nii_data/tilburg_data/crossmoda2022_etz_49/crossmoda2022_etz_49_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_49/crossmoda2022_etz_49_Label.nii.gz,3,2,L,0,0,train +vs_gk_100,1474.4356155395508,Q3_Medium_Large,14.121334163937185,5843,../nii_data/queen_square_data/vs_gk_100/vs_gk_100_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_100/vs_gk_100_seg_refT1.nii.gz,4,2,M,0,0,train +crossmoda2022_etz_46,1907.7072143554688,Q3_Medium_Large,15.387605135373947,1890,../nii_data/tilburg_data/crossmoda2022_etz_46/crossmoda2022_etz_46_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_46/crossmoda2022_etz_46_Label.nii.gz,4,2,M,0,0,train +vs_gk_241,4279.471778869629,Q4_Large,20.143295081650127,16959,../nii_data/queen_square_data/vs_gk_241/vs_gk_241_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_241/vs_gk_241_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_44,1623.065185546875,Q3_Medium_Large,14.58072480906109,6432,../nii_data/queen_square_data/vs_gk_44/vs_gk_44_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_44/vs_gk_44_seg_refT1.nii.gz,4,2,M,0,0,train +crossmoda2022_etz_66,4598.6846923828125,Q4_Large,20.632174566663952,4556,../nii_data/tilburg_data/crossmoda2022_etz_66/crossmoda2022_etz_66_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_66/crossmoda2022_etz_66_Label.nii.gz,4,3,L,0,1,train +vs_gk_166,2253.9207458496094,Q3_Medium_Large,16.267219977145203,8932,../nii_data/queen_square_data/vs_gk_166/vs_gk_166_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_166/vs_gk_166_seg_refT1.nii.gz,3,2,M,0,0,train +crossmoda2022_etz_100,373.4664916992188,Q1_Small,8.9347898029244,370,../nii_data/tilburg_data/crossmoda2022_etz_100/crossmoda2022_etz_100_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_100/crossmoda2022_etz_100_Label.nii.gz,5,0,XS,1,0,train +crossmoda2022_etz_41,6595.216369628906,Q4_Large,23.267165298570287,6534,../nii_data/tilburg_data/crossmoda2022_etz_41/crossmoda2022_etz_41_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_41/crossmoda2022_etz_41_Label.nii.gz,2,3,XL,0,1,train +vs_gk_250,6295.181465148926,Q4_Large,22.908845876084687,24947,../nii_data/queen_square_data/vs_gk_250/vs_gk_250_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_250/vs_gk_250_seg_refT1.nii.gz,5,3,XL,0,1,train +crossmoda2022_etz_59,7236.165618896484,Q4_Large,23.99771872260159,7169,../nii_data/tilburg_data/crossmoda2022_etz_59/crossmoda2022_etz_59_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_59/crossmoda2022_etz_59_Label.nii.gz,5,3,XL,0,1,train +vs_gk_116,989.1815185546876,Q2_Medium_Small,12.362105827068188,3920,../nii_data/queen_square_data/vs_gk_116/vs_gk_116_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_116/vs_gk_116_seg_refT1.nii.gz,5,1,S,0,0,train +vs_gk_157,6971.963310241699,Q4_Large,23.70202709347249,27629,../nii_data/queen_square_data/vs_gk_157/vs_gk_157_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_157/vs_gk_157_seg_refT1.nii.gz,4,3,XL,0,1,train +vs_gk_46,1432.294464111328,Q2_Medium_Small,13.98549676143428,5676,../nii_data/queen_square_data/vs_gk_46/vs_gk_46_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_46/vs_gk_46_seg_refT1.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_16,1466.6116209030151,Q3_Medium_Large,14.096311878054504,1453,../nii_data/tilburg_data/crossmoda2022_etz_16/crossmoda2022_etz_16_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_16/crossmoda2022_etz_16_Label.nii.gz,4,2,M,0,0,train +vs_gk_146,864.8609161376953,Q2_Medium_Small,11.820863879940877,5141,../nii_data/queen_square_data/vs_gk_146/vs_gk_146_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_146/vs_gk_146_seg_refT1.nii.gz,2,1,S,0,0,train +vs_gk_152,1029.3039321899414,Q2_Medium_Small,12.527036318223042,4079,../nii_data/queen_square_data/vs_gk_152/vs_gk_152_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_152/vs_gk_152_seg_refT1.nii.gz,1,1,M,0,0,train +vs_gk_107,3661.738014221192,Q4_Large,19.123301936012748,14511,../nii_data/queen_square_data/vs_gk_107/vs_gk_107_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_107/vs_gk_107_seg_refT1.nii.gz,1,3,L,0,1,train +vs_gk_246,5956.285858154297,Q4_Large,22.490147483202502,23604,../nii_data/queen_square_data/vs_gk_246/vs_gk_246_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_246/vs_gk_246_seg_refT1.nii.gz,3,3,XL,0,1,train +vs_gk_211,1261.4587783813477,Q2_Medium_Small,13.405761243272607,4999,../nii_data/queen_square_data/vs_gk_211/vs_gk_211_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_211/vs_gk_211_seg_refT1.nii.gz,3,1,M,0,0,train +crossmoda2022_etz_33,232.1554219722748,Q1_Small,7.625355042400001,230,../nii_data/tilburg_data/crossmoda2022_etz_33/crossmoda2022_etz_33_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_33/crossmoda2022_etz_33_Label.nii.gz,2,0,XS,1,0,train +crossmoda2022_etz_42,1285.9359741210938,Q2_Medium_Small,13.491914319310446,1274,../nii_data/tilburg_data/crossmoda2022_etz_42/crossmoda2022_etz_42_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_42/crossmoda2022_etz_42_Label.nii.gz,4,1,M,0,0,train +vs_gk_184,496.3571548461914,Q1_Small,9.823476745181974,1967,../nii_data/queen_square_data/vs_gk_184/vs_gk_184_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_184/vs_gk_184_seg_refT1.nii.gz,3,0,XS,1,0,train +vs_gk_70,2534.0206146240234,Q3_Medium_Large,16.914941771562404,10042,../nii_data/queen_square_data/vs_gk_70/vs_gk_70_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_70/vs_gk_70_seg_refT1.nii.gz,2,2,L,0,0,train +vs_gk_215,840.2996063232422,Q2_Medium_Small,11.70788674253976,3330,../nii_data/queen_square_data/vs_gk_215/vs_gk_215_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_215/vs_gk_215_seg_refT1.nii.gz,4,1,S,0,0,train +crossmoda2022_etz_63,944.77006816864,Q2_Medium_Small,12.174257997973584,936,../nii_data/tilburg_data/crossmoda2022_etz_63/crossmoda2022_etz_63_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_63/crossmoda2022_etz_63_Label.nii.gz,2,1,S,0,0,train +vs_gk_204,883.1977844238281,Q2_Medium_Small,11.90382269539906,3500,../nii_data/queen_square_data/vs_gk_204/vs_gk_204_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_204/vs_gk_204_seg_refT1.nii.gz,1,1,S,0,0,train +crossmoda2022_etz_32,941.741180419922,Q2_Medium_Small,12.16123403994057,933,../nii_data/tilburg_data/crossmoda2022_etz_32/crossmoda2022_etz_32_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_32/crossmoda2022_etz_32_Label.nii.gz,1,1,S,0,0,train +vs_gk_21,4263.57421875,Q4_Large,20.118321080559785,16896,../nii_data/queen_square_data/vs_gk_21/vs_gk_21_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_21/vs_gk_21_seg_refT1.nii.gz,1,3,L,0,1,train +crossmoda2022_etz_35,2664.7367191314697,Q3_Medium_Large,17.200928662067007,2640,../nii_data/tilburg_data/crossmoda2022_etz_35/crossmoda2022_etz_35_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_35/crossmoda2022_etz_35_Label.nii.gz,1,2,L,0,0,train +vs_gk_83,4065.485572814941,Q4_Large,19.801796847259794,16111,../nii_data/queen_square_data/vs_gk_83/vs_gk_83_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_83/vs_gk_83_seg_refT1.nii.gz,2,3,L,0,1,train +crossmoda2022_etz_37,1298.0484008789062,Q2_Medium_Small,13.534142808499274,1286,../nii_data/tilburg_data/crossmoda2022_etz_37/crossmoda2022_etz_37_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_37/crossmoda2022_etz_37_Label.nii.gz,3,1,M,0,0,train +crossmoda2022_etz_60,181.6864013671875,Q1_Small,7.027075185139094,180,../nii_data/tilburg_data/crossmoda2022_etz_60/crossmoda2022_etz_60_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_60/crossmoda2022_etz_60_Label.nii.gz,4,0,XS,1,0,train +vs_gk_181,2562.282943725586,Q3_Medium_Large,16.977594419681754,10154,../nii_data/queen_square_data/vs_gk_181/vs_gk_181_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_181/vs_gk_181_seg_refT1.nii.gz,4,2,L,0,0,train +crossmoda2022_etz_7,7760.030825614929,Q4_Large,24.56338836008172,7688,../nii_data/tilburg_data/crossmoda2022_etz_7/crossmoda2022_etz_7_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_7/crossmoda2022_etz_7_Label.nii.gz,5,3,XL,0,1,train +vs_gk_223,2321.2961196899414,Q3_Medium_Large,16.427720595154465,9199,../nii_data/queen_square_data/vs_gk_223/vs_gk_223_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_223/vs_gk_223_seg_refT1.nii.gz,2,2,M,0,0,train +crossmoda2022_etz_6,7900.330352783203,Q4_Large,24.71053850073288,7827,../nii_data/tilburg_data/crossmoda2022_etz_6/crossmoda2022_etz_6_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_6/crossmoda2022_etz_6_Label.nii.gz,2,3,XL,0,1,train +vs_gk_99,3122.735023498535,Q3_Medium_Large,18.13477495858989,12375,../nii_data/queen_square_data/vs_gk_99/vs_gk_99_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_99/vs_gk_99_seg_refT1.nii.gz,5,2,L,0,0,train +vs_gk_106,1033.341407775879,Q2_Medium_Small,12.5433941741591,4095,../nii_data/queen_square_data/vs_gk_106/vs_gk_106_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_106/vs_gk_106_seg_refT1.nii.gz,2,1,M,0,0,train +crossmoda2022_etz_62,3503.5194396972656,Q4_Large,18.843806519036328,3471,../nii_data/tilburg_data/crossmoda2022_etz_62/crossmoda2022_etz_62_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_62/crossmoda2022_etz_62_Label.nii.gz,5,3,L,0,1,train +vs_gk_110,4954.487228393555,Q4_Large,21.1511197290988,19634,../nii_data/queen_square_data/vs_gk_110/vs_gk_110_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_110/vs_gk_110_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_229,301.29661560058594,Q1_Small,8.317609819626888,1194,../nii_data/queen_square_data/vs_gk_229/vs_gk_229_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_229/vs_gk_229_seg_refT1.nii.gz,1,0,XS,1,0,train +vs_gk_121,1453.743553161621,Q2_Medium_Small,14.054963642102832,5761,../nii_data/queen_square_data/vs_gk_121/vs_gk_121_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_121/vs_gk_121_seg_refT1.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_87,682.3333740234375,Q2_Medium_Small,10.922766856228415,676,../nii_data/tilburg_data/crossmoda2022_etz_87/crossmoda2022_etz_87_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_87/crossmoda2022_etz_87_Label.nii.gz,3,1,S,0,0,train +vs_gk_15,1294.2632675170898,Q2_Medium_Small,13.520974729734988,5129,../nii_data/queen_square_data/vs_gk_15/vs_gk_15_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_15/vs_gk_15_seg_refT1.nii.gz,1,1,M,0,0,train +vs_gk_149,6185.160255432129,Q4_Large,22.774601052059094,24511,../nii_data/queen_square_data/vs_gk_149/vs_gk_149_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_149/vs_gk_149_seg_refT1.nii.gz,1,3,XL,0,1,train +vs_gk_212,2681.640815734864,Q3_Medium_Large,17.237224115199634,10627,../nii_data/queen_square_data/vs_gk_212/vs_gk_212_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_212/vs_gk_212_seg_refT1.nii.gz,2,2,L,0,0,train +vs_gk_92,1284.6742630004885,Q2_Medium_Small,13.48750029219463,5091,../nii_data/queen_square_data/vs_gk_92/vs_gk_92_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_92/vs_gk_92_seg_refT1.nii.gz,4,1,M,0,0,train +vs_gk_102,1277.8610229492188,Q2_Medium_Small,13.463614468373368,5064,../nii_data/queen_square_data/vs_gk_102/vs_gk_102_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_102/vs_gk_102_seg_refT1.nii.gz,1,1,M,0,0,train +vs_gk_210,5464.723205566406,Q4_Large,21.853609696080664,21656,../nii_data/queen_square_data/vs_gk_210/vs_gk_210_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_210/vs_gk_210_seg_refT1.nii.gz,4,3,XL,0,1,train +vs_gk_122,941.741180419922,Q2_Medium_Small,12.16123403994057,3732,../nii_data/queen_square_data/vs_gk_122/vs_gk_122_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_122/vs_gk_122_seg_refT1.nii.gz,3,1,S,0,0,train +vs_gk_128,511.497688293457,Q1_Small,9.92236086195688,2027,../nii_data/queen_square_data/vs_gk_128/vs_gk_128_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_128/vs_gk_128_seg_refT1.nii.gz,4,0,S,1,0,train +crossmoda2022_etz_4,1107.27781021595,Q2_Medium_Small,12.835693106024005,1097,../nii_data/tilburg_data/crossmoda2022_etz_4/crossmoda2022_etz_4_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_4/crossmoda2022_etz_4_Label.nii.gz,1,1,M,0,0,train +vs_gk_159,2462.607765197754,Q3_Medium_Large,16.754528378053248,9759,../nii_data/queen_square_data/vs_gk_159/vs_gk_159_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_159/vs_gk_159_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_242,658.6132049560547,Q1_Small,10.794700663331165,2610,../nii_data/queen_square_data/vs_gk_242/vs_gk_242_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_242/vs_gk_242_seg_refT1.nii.gz,5,0,S,1,0,train +crossmoda2022_etz_77,324.0076071023941,Q1_Small,8.521555198356914,321,../nii_data/tilburg_data/crossmoda2022_etz_77/crossmoda2022_etz_77_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_77/crossmoda2022_etz_77_Label.nii.gz,5,0,XS,1,0,train +crossmoda2022_etz_68,220.0422894954681,Q1_Small,7.490356891149099,218,../nii_data/tilburg_data/crossmoda2022_etz_68/crossmoda2022_etz_68_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_68/crossmoda2022_etz_68_Label.nii.gz,5,0,XS,1,0,train +vs_gk_224,538.245964050293,Q1_Small,10.092390665442052,2133,../nii_data/queen_square_data/vs_gk_224/vs_gk_224_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_224/vs_gk_224_seg_refT1.nii.gz,2,0,S,1,0,train +vs_gk_236,691.4176940917969,Q2_Medium_Small,10.97102713028818,2740,../nii_data/queen_square_data/vs_gk_236/vs_gk_236_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_236/vs_gk_236_seg_refT1.nii.gz,4,1,S,0,0,train +vs_gk_10,7925.05989074707,Q4_Large,24.7362945421496,31406,../nii_data/queen_square_data/vs_gk_10/vs_gk_10_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_10/vs_gk_10_seg_refT1.nii.gz,1,3,XL,0,1,train +vs_gk_36,3297.860527038574,Q3_Medium_Large,18.46763237660716,13069,../nii_data/queen_square_data/vs_gk_36/vs_gk_36_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_36/vs_gk_36_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_153,716.9042587280273,Q2_Medium_Small,11.104206067736538,2841,../nii_data/queen_square_data/vs_gk_153/vs_gk_153_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_153/vs_gk_153_seg_refT1.nii.gz,4,1,S,0,0,train +vs_gk_249,1920.8290100097656,Q3_Medium_Large,15.422804777078156,7612,../nii_data/queen_square_data/vs_gk_249/vs_gk_249_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_249/vs_gk_249_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_150,2940.2915954589844,Q3_Medium_Large,17.774494568271958,11652,../nii_data/queen_square_data/vs_gk_150/vs_gk_150_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_150/vs_gk_150_seg_refT1.nii.gz,5,2,L,0,0,train +vs_gk_75,2706.1180114746094,Q3_Medium_Large,17.28951071786434,10724,../nii_data/queen_square_data/vs_gk_75/vs_gk_75_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_75/vs_gk_75_seg_refT1.nii.gz,3,2,L,0,0,train +crossmoda2022_etz_95,1431.283742904663,Q2_Medium_Small,13.982206291775054,1418,../nii_data/tilburg_data/crossmoda2022_etz_95/crossmoda2022_etz_95_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_95/crossmoda2022_etz_95_Label.nii.gz,4,1,M,0,0,train +vs_gk_82,6807.688522338867,Q4_Large,23.51438826273441,26978,../nii_data/queen_square_data/vs_gk_82/vs_gk_82_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_82/vs_gk_82_seg_refT1.nii.gz,1,3,XL,0,1,train +crossmoda2022_etz_22,6279.283905029297,Q4_Large,22.88954530675377,6221,../nii_data/tilburg_data/crossmoda2022_etz_22/crossmoda2022_etz_22_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_22/crossmoda2022_etz_22_Label.nii.gz,3,3,XL,0,1,train +vs_gk_20,408.45794677734375,Q1_Small,9.205546495160444,2428,../nii_data/queen_square_data/vs_gk_20/vs_gk_20_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_20/vs_gk_20_seg_refT1.nii.gz,4,0,XS,1,0,train +crossmoda2022_etz_69,5811.944046735764,Q4_Large,22.306988022351508,5758,../nii_data/tilburg_data/crossmoda2022_etz_69/crossmoda2022_etz_69_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_69/crossmoda2022_etz_69_Label.nii.gz,2,3,XL,0,1,train +crossmoda2022_etz_90,1040.6593322753906,Q2_Medium_Small,12.572934516409338,1031,../nii_data/tilburg_data/crossmoda2022_etz_90/crossmoda2022_etz_90_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_90/crossmoda2022_etz_90_Label.nii.gz,3,1,M,0,0,train +vs_gk_233,7948.780059814453,Q4_Large,24.7609490211777,31500,../nii_data/queen_square_data/vs_gk_233/vs_gk_233_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_233/vs_gk_233_seg_refT1.nii.gz,5,3,XL,0,1,train +crossmoda2022_etz_0,2327.6046752929688,Q3_Medium_Large,16.442588933401527,2306,../nii_data/tilburg_data/crossmoda2022_etz_0/crossmoda2022_etz_0_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_0/crossmoda2022_etz_0_Label.nii.gz,4,2,M,0,0,train +vs_gk_165,588.4620666503906,Q1_Small,10.396965833176978,2332,../nii_data/queen_square_data/vs_gk_165/vs_gk_165_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_165/vs_gk_165_seg_refT1.nii.gz,2,0,S,1,0,train +crossmoda2022_etz_54,608.6507384777069,Q1_Small,10.51452949608094,603,../nii_data/tilburg_data/crossmoda2022_etz_54/crossmoda2022_etz_54_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_54/crossmoda2022_etz_54_Label.nii.gz,1,0,S,1,0,train +vs_gk_190,437.81375885009766,Q1_Small,9.420998351400852,1735,../nii_data/queen_square_data/vs_gk_190/vs_gk_190_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_190/vs_gk_190_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_155,275.22125244140625,Q1_Small,8.070388620345998,1636,../nii_data/queen_square_data/vs_gk_155/vs_gk_155_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_155/vs_gk_155_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_47,4593.890190124512,Q4_Large,20.62500183418161,18205,../nii_data/queen_square_data/vs_gk_47/vs_gk_47_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_47/vs_gk_47_seg_refT1.nii.gz,5,3,L,0,1,train +crossmoda2022_etz_79,542.0310974121094,Q1_Small,10.115993161749314,537,../nii_data/tilburg_data/crossmoda2022_etz_79/crossmoda2022_etz_79_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_79/crossmoda2022_etz_79_Label.nii.gz,1,0,S,1,0,train +vs_gk_57,807.9998016357422,Q2_Medium_Small,11.55591159149698,3202,../nii_data/queen_square_data/vs_gk_57/vs_gk_57_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_57/vs_gk_57_seg_refT1.nii.gz,3,1,S,0,0,train +vs_gk_194,5694.1022872924805,Q4_Large,22.155193496333908,22565,../nii_data/queen_square_data/vs_gk_194/vs_gk_194_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_194/vs_gk_194_seg_refT1.nii.gz,2,3,XL,0,1,train +vs_gk_176,1000.5369186401368,Q2_Medium_Small,12.409229938973828,3965,../nii_data/queen_square_data/vs_gk_176/vs_gk_176_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_176/vs_gk_176_seg_refT1.nii.gz,5,1,M,0,0,train +vs_gk_140,6910.8964920043945,Q4_Large,23.632622732743336,27387,../nii_data/queen_square_data/vs_gk_140/vs_gk_140_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_140/vs_gk_140_seg_refT1.nii.gz,3,3,XL,0,1,train +vs_gk_237,1390.9103393554688,Q2_Medium_Small,13.849481177804895,5512,../nii_data/queen_square_data/vs_gk_237/vs_gk_237_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_237/vs_gk_237_seg_refT1.nii.gz,2,1,M,0,0,train +vs_gk_31,352.0174026489258,Q1_Small,8.76035765548068,1395,../nii_data/queen_square_data/vs_gk_31/vs_gk_31_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_31/vs_gk_31_seg_refT1.nii.gz,1,0,XS,1,0,train +crossmoda2022_etz_98,1044.696807861328,Q2_Medium_Small,12.58917339079081,1035,../nii_data/tilburg_data/crossmoda2022_etz_98/crossmoda2022_etz_98_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_98/crossmoda2022_etz_98_Label.nii.gz,5,1,M,0,0,train +vs_gk_203,2079.2999267578125,Q3_Medium_Large,15.83578166537156,8240,../nii_data/queen_square_data/vs_gk_203/vs_gk_203_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_203/vs_gk_203_seg_refT1.nii.gz,5,2,M,0,0,train +vs_gk_23,594.6865081787109,Q1_Small,10.433495212733405,3535,../nii_data/queen_square_data/vs_gk_23/vs_gk_23_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_23/vs_gk_23_seg_refT1.nii.gz,1,0,S,1,0,train +vs_gk_98,2166.862678527832,Q3_Medium_Large,16.055022642333196,8587,../nii_data/queen_square_data/vs_gk_98/vs_gk_98_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_98/vs_gk_98_seg_refT1.nii.gz,3,2,M,0,0,train +crossmoda2022_etz_9,1066.9039316177368,Q2_Medium_Small,12.677751391374624,1057,../nii_data/tilburg_data/crossmoda2022_etz_9/crossmoda2022_etz_9_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_9/crossmoda2022_etz_9_Label.nii.gz,1,1,M,0,0,train +vs_gk_11,6224.273300170898,Q4_Large,22.822506729575544,24666,../nii_data/queen_square_data/vs_gk_11/vs_gk_11_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_11/vs_gk_11_seg_refT1.nii.gz,4,3,XL,0,1,train +crossmoda2022_etz_21,434.0287792682648,Q1_Small,9.39377099004822,430,../nii_data/tilburg_data/crossmoda2022_etz_21/crossmoda2022_etz_21_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_21/crossmoda2022_etz_21_Label.nii.gz,2,0,XS,1,0,train +crossmoda2022_etz_40,1098.193359375,Q2_Medium_Small,12.800493998233376,1088,../nii_data/tilburg_data/crossmoda2022_etz_40/crossmoda2022_etz_40_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_40/crossmoda2022_etz_40_Label.nii.gz,4,1,M,0,0,train +vs_gk_9,3585.2783203125,Q4_Large,18.98926189584605,14208,../nii_data/queen_square_data/vs_gk_9/vs_gk_9_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_9/vs_gk_9_seg_refT1.nii.gz,2,3,L,0,1,train +crossmoda2022_etz_67,3602.4337624311447,Q4_Large,19.01950138566732,3569,../nii_data/tilburg_data/crossmoda2022_etz_67/crossmoda2022_etz_67_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_67/crossmoda2022_etz_67_Label.nii.gz,3,3,L,0,1,train +vs_gk_188,3117.183494567871,Q3_Medium_Large,18.124022050667904,12353,../nii_data/queen_square_data/vs_gk_188/vs_gk_188_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_188/vs_gk_188_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_225,5597.2028732299805,Q4_Large,22.028798413402235,22181,../nii_data/queen_square_data/vs_gk_225/vs_gk_225_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_225/vs_gk_225_seg_refT1.nii.gz,3,3,XL,0,1,train +vs_gk_158,3383.909225463867,Q3_Medium_Large,18.626876167903696,13410,../nii_data/queen_square_data/vs_gk_158/vs_gk_158_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_158/vs_gk_158_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_226,1957.9233169555664,Q3_Medium_Large,15.52145220952328,7759,../nii_data/queen_square_data/vs_gk_226/vs_gk_226_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_226/vs_gk_226_seg_refT1.nii.gz,4,2,M,0,0,train +vs_gk_118,768.1297302246094,Q2_Medium_Small,11.3626243947309,3044,../nii_data/queen_square_data/vs_gk_118/vs_gk_118_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_118/vs_gk_118_seg_refT1.nii.gz,4,1,S,0,0,train +vs_gk_3,3212.0641708374023,Q3_Medium_Large,18.306073296305403,12729,../nii_data/queen_square_data/vs_gk_3/vs_gk_3_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_3/vs_gk_3_seg_refT1.nii.gz,4,2,L,0,0,train +vs_gk_239,517.5539016723633,Q1_Small,9.961368087087342,2051,../nii_data/queen_square_data/vs_gk_239/vs_gk_239_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_239/vs_gk_239_seg_refT1.nii.gz,5,0,S,1,0,train +vs_gk_88,3466.425132751465,Q4_Large,18.777066059704747,13737,../nii_data/queen_square_data/vs_gk_88/vs_gk_88_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_88/vs_gk_88_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_119,5183.361625671387,Q4_Large,21.47192404840324,20541,../nii_data/queen_square_data/vs_gk_119/vs_gk_119_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_119/vs_gk_119_seg_refT1.nii.gz,2,3,XL,0,1,train +vs_gk_144,1342.4606323242188,Q2_Medium_Small,13.686770304371368,5320,../nii_data/queen_square_data/vs_gk_144/vs_gk_144_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_144/vs_gk_144_seg_refT1.nii.gz,1,1,M,0,0,train +crossmoda2022_etz_61,742.8945426940918,Q2_Medium_Small,11.23680511009344,736,../nii_data/tilburg_data/crossmoda2022_etz_61/crossmoda2022_etz_61_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_61/crossmoda2022_etz_61_Label.nii.gz,3,1,S,0,0,train +crossmoda2022_etz_58,1211.2436771392822,Q2_Medium_Small,13.22546549832516,1200,../nii_data/tilburg_data/crossmoda2022_etz_58/crossmoda2022_etz_58_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_58/crossmoda2022_etz_58_Label.nii.gz,1,1,M,0,0,train +vs_gk_58,2772.231674194336,Q3_Medium_Large,17.429180449916725,10986,../nii_data/queen_square_data/vs_gk_58/vs_gk_58_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_58/vs_gk_58_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_189,1072.2021102905271,Q2_Medium_Small,12.69870239114932,4249,../nii_data/queen_square_data/vs_gk_189/vs_gk_189_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_189/vs_gk_189_seg_refT1.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_36,1555.438387989998,Q3_Medium_Large,14.375337959007098,1541,../nii_data/tilburg_data/crossmoda2022_etz_36/crossmoda2022_etz_36_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_36/crossmoda2022_etz_36_Label.nii.gz,1,2,M,0,0,train +vs_gk_147,1983.914566040039,Q3_Medium_Large,15.58983245836877,7862,../nii_data/queen_square_data/vs_gk_147/vs_gk_147_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_147/vs_gk_147_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_230,199.3503570556641,Q1_Small,7.247798851660776,1185,../nii_data/queen_square_data/vs_gk_230/vs_gk_230_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_230/vs_gk_230_seg_refT1.nii.gz,4,0,XS,1,0,train +crossmoda2022_etz_43,1315.206740140915,Q2_Medium_Small,13.59351595209355,1303,../nii_data/tilburg_data/crossmoda2022_etz_43/crossmoda2022_etz_43_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_43/crossmoda2022_etz_43_Label.nii.gz,1,1,M,0,0,train +vs_gk_85,174.78904724121094,Q1_Small,6.937002711498812,1039,../nii_data/queen_square_data/vs_gk_85/vs_gk_85_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_85/vs_gk_85_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_34,497.3665237426758,Q1_Small,9.830131092175527,1971,../nii_data/queen_square_data/vs_gk_34/vs_gk_34_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_34/vs_gk_34_seg_refT1.nii.gz,3,0,XS,1,0,train +crossmoda2022_etz_30,261.4265441894531,Q1_Small,7.933235620745557,259,../nii_data/tilburg_data/crossmoda2022_etz_30/crossmoda2022_etz_30_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_30/crossmoda2022_etz_30_Label.nii.gz,1,0,XS,1,0,train +vs_gk_49,1952.1194458007808,Q3_Medium_Large,15.506100285821578,7736,../nii_data/queen_square_data/vs_gk_49/vs_gk_49_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_49/vs_gk_49_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_191,661.6413116455078,Q1_Small,10.811218976263978,2622,../nii_data/queen_square_data/vs_gk_191/vs_gk_191_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_191/vs_gk_191_seg_refT1.nii.gz,5,0,S,1,0,train +vs_gk_77,199.6026992797852,Q1_Small,7.250855705187132,791,../nii_data/queen_square_data/vs_gk_77/vs_gk_77_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_77/vs_gk_77_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_143,1578.1482696533203,Q3_Medium_Large,14.44496166834592,6254,../nii_data/queen_square_data/vs_gk_143/vs_gk_143_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_143/vs_gk_143_seg_refT1.nii.gz,2,2,M,0,0,train +vs_gk_54,1592.2794342041016,Q3_Medium_Large,14.487948309973207,6310,../nii_data/queen_square_data/vs_gk_54/vs_gk_54_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_54/vs_gk_54_seg_refT1.nii.gz,2,2,M,0,0,train +crossmoda2022_etz_85,248.3048658370972,Q1_Small,7.79822094808764,246,../nii_data/tilburg_data/crossmoda2022_etz_85/crossmoda2022_etz_85_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_85/crossmoda2022_etz_85_Label.nii.gz,5,0,XS,1,0,train +vs_gk_72,3547.931671142578,Q4_Large,18.92309668379365,14060,../nii_data/queen_square_data/vs_gk_72/vs_gk_72_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_72/vs_gk_72_seg_refT1.nii.gz,2,3,L,0,1,train +vs_gk_175,672.7443695068359,Q1_Small,10.871358473206469,2666,../nii_data/queen_square_data/vs_gk_175/vs_gk_175_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_175/vs_gk_175_seg_refT1.nii.gz,3,0,S,1,0,train +vs_gk_131,1472.6692199707031,Q3_Medium_Large,14.115692721769218,5836,../nii_data/queen_square_data/vs_gk_131/vs_gk_131_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_131/vs_gk_131_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_65,434.5333099365234,Q1_Small,9.397409466367773,1722,../nii_data/queen_square_data/vs_gk_65/vs_gk_65_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_65/vs_gk_65_seg_refT1.nii.gz,5,0,XS,1,0,train +vs_gk_137,1936.978912353516,Q3_Medium_Large,15.46590803639474,7676,../nii_data/queen_square_data/vs_gk_137/vs_gk_137_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_137/vs_gk_137_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_200,626.0610580444336,Q1_Small,10.61384373942733,2481,../nii_data/queen_square_data/vs_gk_200/vs_gk_200_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_200/vs_gk_200_seg_refT1.nii.gz,1,0,S,1,0,train +vs_gk_120,3235.027313232422,Q3_Medium_Large,18.34959330621269,12820,../nii_data/queen_square_data/vs_gk_120/vs_gk_120_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_120/vs_gk_120_seg_refT1.nii.gz,5,2,L,0,0,train +crossmoda2022_etz_23,3700.340693235397,Q4_Large,19.19026755502384,3666,../nii_data/tilburg_data/crossmoda2022_etz_23/crossmoda2022_etz_23_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_23/crossmoda2022_etz_23_Label.nii.gz,3,3,L,0,1,train +crossmoda2022_etz_80,6295.433807373047,Q4_Large,22.909151972205564,6237,../nii_data/tilburg_data/crossmoda2022_etz_80/crossmoda2022_etz_80_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_80/crossmoda2022_etz_80_Label.nii.gz,2,3,XL,0,1,train +vs_gk_104,5808.665657043457,Q4_Large,22.30279293984266,23019,../nii_data/queen_square_data/vs_gk_104/vs_gk_104_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_104/vs_gk_104_seg_refT1.nii.gz,2,3,XL,0,1,train +crossmoda2022_etz_20,2566.8214659690857,Q3_Medium_Large,16.98761253530319,2543,../nii_data/tilburg_data/crossmoda2022_etz_20/crossmoda2022_etz_20_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_20/crossmoda2022_etz_20_Label.nii.gz,3,2,L,0,0,train +vs_gk_29,3349.7589111328125,Q3_Medium_Large,18.564003555904936,19912,../nii_data/queen_square_data/vs_gk_29/vs_gk_29_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_29/vs_gk_29_seg_refT1.nii.gz,1,2,L,0,0,train +crossmoda2022_etz_15,2222.6303100585938,Q3_Medium_Large,16.191591437438603,2202,../nii_data/tilburg_data/crossmoda2022_etz_15/crossmoda2022_etz_15_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_15/crossmoda2022_etz_15_Label.nii.gz,2,2,M,0,0,train +vs_gk_37,2148.441696166992,Q3_Medium_Large,16.009397331997317,8514,../nii_data/queen_square_data/vs_gk_37/vs_gk_37_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_37/vs_gk_37_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_244,767.8773880004883,Q2_Medium_Small,11.36137999384539,3043,../nii_data/queen_square_data/vs_gk_244/vs_gk_244_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_244/vs_gk_244_seg_refT1.nii.gz,3,1,S,0,0,train +vs_gk_151,1531.9696426391602,Q3_Medium_Large,14.302671995707556,6071,../nii_data/queen_square_data/vs_gk_151/vs_gk_151_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_151/vs_gk_151_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_96,571.5551376342773,Q1_Small,10.296426022556291,2265,../nii_data/queen_square_data/vs_gk_96/vs_gk_96_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_96/vs_gk_96_seg_refT1.nii.gz,3,0,S,1,0,train +crossmoda2022_etz_93,1266.7612564563751,Q2_Medium_Small,13.424518465529584,1255,../nii_data/tilburg_data/crossmoda2022_etz_93/crossmoda2022_etz_93_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_93/crossmoda2022_etz_93_Label.nii.gz,5,1,M,0,0,train +vs_gk_81,10030.351066589355,Q4_Large,26.757107931886537,39749,../nii_data/queen_square_data/vs_gk_81/vs_gk_81_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_81/vs_gk_81_seg_refT1.nii.gz,5,3,XL,0,1,train +vs_gk_167,2245.341110229492,Q3_Medium_Large,16.246553133308943,8898,../nii_data/queen_square_data/vs_gk_167/vs_gk_167_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_167/vs_gk_167_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_133,1221.0840225219729,Q2_Medium_Small,13.261184238782567,4839,../nii_data/queen_square_data/vs_gk_133/vs_gk_133_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_133/vs_gk_133_seg_refT1.nii.gz,5,1,M,0,0,train +vs_gk_220,5447.05924987793,Q4_Large,21.830038031840537,21586,../nii_data/queen_square_data/vs_gk_220/vs_gk_220_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_220/vs_gk_220_seg_refT1.nii.gz,4,3,XL,0,1,train +vs_gk_195,150.64830780029297,Q1_Small,6.601691288768374,597,../nii_data/queen_square_data/vs_gk_195/vs_gk_195_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_195/vs_gk_195_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_163,272.52960205078125,Q1_Small,8.043993047921813,1080,../nii_data/queen_square_data/vs_gk_163/vs_gk_163_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_163/vs_gk_163_seg_refT1.nii.gz,4,0,XS,1,0,train +crossmoda2022_etz_18,591.4901733398438,Q1_Small,10.41476889163409,586,../nii_data/tilburg_data/crossmoda2022_etz_18/crossmoda2022_etz_18_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_18/crossmoda2022_etz_18_Label.nii.gz,4,0,S,1,0,train +crossmoda2022_etz_94,376.4949096441269,Q1_Small,8.958875379621936,373,../nii_data/tilburg_data/crossmoda2022_etz_94/crossmoda2022_etz_94_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_94/crossmoda2022_etz_94_Label.nii.gz,2,0,XS,1,0,train +crossmoda2022_etz_89,3231.9992065429688,Q3_Medium_Large,18.343866216793817,3202,../nii_data/tilburg_data/crossmoda2022_etz_89/crossmoda2022_etz_89_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_89/crossmoda2022_etz_89_Label.nii.gz,5,2,L,0,0,train +vs_gk_192,1182.2233200073242,Q2_Medium_Small,13.118986943184364,4685,../nii_data/queen_square_data/vs_gk_192/vs_gk_192_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_192/vs_gk_192_seg_refT1.nii.gz,2,1,M,0,0,train +vs_gk_59,2304.8938751220703,Q3_Medium_Large,16.388936423601013,9134,../nii_data/queen_square_data/vs_gk_59/vs_gk_59_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_59/vs_gk_59_seg_refT1.nii.gz,4,2,M,0,0,train +vs_gk_26,2307.16495513916,Q3_Medium_Large,16.394317490997732,9143,../nii_data/queen_square_data/vs_gk_26/vs_gk_26_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_26/vs_gk_26_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_245,3645.5881118774414,Q4_Large,19.095146397723283,14447,../nii_data/queen_square_data/vs_gk_245/vs_gk_245_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_245/vs_gk_245_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_13,741.1291122436523,Q2_Medium_Small,11.22789692197804,2937,../nii_data/queen_square_data/vs_gk_13/vs_gk_13_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_13/vs_gk_13_seg_refT1.nii.gz,3,1,S,0,0,train +crossmoda2022_etz_19,11454.318237304688,Q4_Large,27.967707842875583,11348,../nii_data/tilburg_data/crossmoda2022_etz_19/crossmoda2022_etz_19_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_19/crossmoda2022_etz_19_Label.nii.gz,3,3,XL,0,1,train +vs_gk_234,10988.494491577148,Q4_Large,27.583319134244416,43546,../nii_data/queen_square_data/vs_gk_234/vs_gk_234_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_234/vs_gk_234_seg_refT1.nii.gz,4,3,XL,0,1,train +vs_gk_169,854.4307708740234,Q2_Medium_Small,11.77315204239993,3386,../nii_data/queen_square_data/vs_gk_169/vs_gk_169_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_169/vs_gk_169_seg_refT1.nii.gz,5,1,S,0,0,train +vs_gk_67,2708.389091491699,Q3_Medium_Large,17.294346043198335,10733,../nii_data/queen_square_data/vs_gk_67/vs_gk_67_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_67/vs_gk_67_seg_refT1.nii.gz,1,2,L,0,0,train +crossmoda2022_etz_24,3614.550018310547,Q4_Large,19.04080062262961,3581,../nii_data/tilburg_data/crossmoda2022_etz_24/crossmoda2022_etz_24_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_24/crossmoda2022_etz_24_Label.nii.gz,4,3,L,0,1,train +crossmoda2022_etz_27,1038.640103816986,Q2_Medium_Small,12.564797346607545,1029,../nii_data/tilburg_data/crossmoda2022_etz_27/crossmoda2022_etz_27_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_27/crossmoda2022_etz_27_Label.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_3,423.9347863197327,Q1_Small,9.320376954523017,420,../nii_data/tilburg_data/crossmoda2022_etz_3/crossmoda2022_etz_3_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_3/crossmoda2022_etz_3_Label.nii.gz,1,0,XS,1,0,train +vs_gk_60,255.87501525878903,Q1_Small,7.876677902481717,1521,../nii_data/queen_square_data/vs_gk_60/vs_gk_60_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_60/vs_gk_60_seg_refT1.nii.gz,5,0,XS,1,0,train +vs_gk_35,1832.0045471191409,Q3_Medium_Large,15.181312088183317,7260,../nii_data/queen_square_data/vs_gk_35/vs_gk_35_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_35/vs_gk_35_seg_refT1.nii.gz,2,2,M,0,0,train +vs_gk_198,1377.788543701172,Q2_Medium_Small,13.805791672427349,5460,../nii_data/queen_square_data/vs_gk_198/vs_gk_198_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_198/vs_gk_198_seg_refT1.nii.gz,3,1,M,0,0,train +vs_gk_93,121.79718017578124,Q1_Small,6.150062610731983,724,../nii_data/queen_square_data/vs_gk_93/vs_gk_93_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_93/vs_gk_93_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_135,1079.520034790039,Q2_Medium_Small,12.72752703367157,4278,../nii_data/queen_square_data/vs_gk_135/vs_gk_135_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_135/vs_gk_135_seg_refT1.nii.gz,2,1,M,0,0,train +vs_gk_170,255.622673034668,Q1_Small,7.874087741853501,1013,../nii_data/queen_square_data/vs_gk_170/vs_gk_170_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_170/vs_gk_170_seg_refT1.nii.gz,3,0,XS,1,0,train +vs_gk_69,2212.53662109375,Q3_Medium_Large,16.16704380816796,8768,../nii_data/queen_square_data/vs_gk_69/vs_gk_69_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_69/vs_gk_69_seg_refT1.nii.gz,2,2,M,0,0,train +vs_gk_142,9984.92946624756,Q4_Large,26.716657708303224,39569,../nii_data/queen_square_data/vs_gk_142/vs_gk_142_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_142/vs_gk_142_seg_refT1.nii.gz,2,3,XL,0,1,train +vs_gk_52,2546.890068054199,Q3_Medium_Large,16.943528565199635,10093,../nii_data/queen_square_data/vs_gk_52/vs_gk_52_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_52/vs_gk_52_seg_refT1.nii.gz,5,2,L,0,0,train +vs_gk_222,346.9705581665039,Q1_Small,8.718290463882607,1375,../nii_data/queen_square_data/vs_gk_222/vs_gk_222_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_222/vs_gk_222_seg_refT1.nii.gz,5,0,XS,1,0,train +vs_gk_41,540.2647018432617,Q1_Small,10.104992383748735,2141,../nii_data/queen_square_data/vs_gk_41/vs_gk_41_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_41/vs_gk_41_seg_refT1.nii.gz,5,0,S,1,0,train +crossmoda2022_etz_92,3745.766647696495,Q4_Large,19.2684759538078,3711,../nii_data/tilburg_data/crossmoda2022_etz_92/crossmoda2022_etz_92_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_92/crossmoda2022_etz_92_Label.nii.gz,4,3,L,0,1,train +vs_gk_79,290.0253295898437,Q1_Small,8.212570339066223,1724,../nii_data/queen_square_data/vs_gk_79/vs_gk_79_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_79/vs_gk_79_seg_refT1.nii.gz,5,0,XS,1,0,train +vs_gk_28,457.74879455566406,Q1_Small,9.561870604404854,1814,../nii_data/queen_square_data/vs_gk_28/vs_gk_28_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_28/vs_gk_28_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_48,2029.0838241577148,Q3_Medium_Large,15.707260826367737,8041,../nii_data/queen_square_data/vs_gk_48/vs_gk_48_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_48/vs_gk_48_seg_refT1.nii.gz,2,2,M,0,0,train +vs_gk_162,624.547004699707,Q1_Small,10.605280731483068,2475,../nii_data/queen_square_data/vs_gk_162/vs_gk_162_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_162/vs_gk_162_seg_refT1.nii.gz,3,0,S,1,0,train +vs_gk_141,77.72140502929688,Q1_Small,5.294772935246936,462,../nii_data/queen_square_data/vs_gk_141/vs_gk_141_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_141/vs_gk_141_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_228,7100.405502319336,Q4_Large,23.84669387009796,28138,../nii_data/queen_square_data/vs_gk_228/vs_gk_228_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_228/vs_gk_228_seg_refT1.nii.gz,5,3,XL,0,1,train +vs_gk_17,5914.9017333984375,Q4_Large,22.4379393888014,23440,../nii_data/queen_square_data/vs_gk_17/vs_gk_17_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_17/vs_gk_17_seg_refT1.nii.gz,1,3,XL,0,1,train +crossmoda2022_etz_11,5564.650726318359,Q4_Large,21.98601041464094,5513,../nii_data/tilburg_data/crossmoda2022_etz_11/crossmoda2022_etz_11_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_11/crossmoda2022_etz_11_Label.nii.gz,3,3,XL,0,1,train +vs_gk_232,1148.4094619750977,Q2_Medium_Small,12.992699077625286,4551,../nii_data/queen_square_data/vs_gk_232/vs_gk_232_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_232/vs_gk_232_seg_refT1.nii.gz,5,1,M,0,0,train +vs_gk_139,638.1734848022461,Q1_Small,10.681855807361211,2529,../nii_data/queen_square_data/vs_gk_139/vs_gk_139_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_139/vs_gk_139_seg_refT1.nii.gz,1,0,S,1,0,train +vs_gk_247,1275.0852584838867,Q2_Medium_Small,13.45385886525046,5053,../nii_data/queen_square_data/vs_gk_247/vs_gk_247_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_247/vs_gk_247_seg_refT1.nii.gz,5,1,M,0,0,train +vs_gk_132,428.9817810058594,Q1_Small,9.357217877180432,1700,../nii_data/queen_square_data/vs_gk_132/vs_gk_132_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_132/vs_gk_132_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_111,96.8994140625,Q1_Small,5.698678761879643,576,../nii_data/queen_square_data/vs_gk_111/vs_gk_111_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_111/vs_gk_111_seg_refT1.nii.gz,3,0,XS,1,0,train +crossmoda2022_etz_99,3913.325984716416,Q4_Large,19.55160740814026,3877,../nii_data/tilburg_data/crossmoda2022_etz_99/crossmoda2022_etz_99_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_99/crossmoda2022_etz_99_Label.nii.gz,5,3,L,0,1,train +crossmoda2022_etz_39,6452.895355224609,Q4_Large,23.09858272100477,6393,../nii_data/tilburg_data/crossmoda2022_etz_39/crossmoda2022_etz_39_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_39/crossmoda2022_etz_39_Label.nii.gz,1,3,XL,0,1,train +vs_gk_84,575.5926132202148,Q1_Small,10.320613868942852,2281,../nii_data/queen_square_data/vs_gk_84/vs_gk_84_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_84/vs_gk_84_seg_refT1.nii.gz,5,0,S,1,0,train +crossmoda2022_etz_71,5034.732055664063,Q4_Large,21.26469934795613,4988,../nii_data/tilburg_data/crossmoda2022_etz_71/crossmoda2022_etz_71_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_71/crossmoda2022_etz_71_Label.nii.gz,4,3,XL,0,1,val +vs_gk_4,765.6063079833984,Q2_Medium_Small,11.35016809841078,3034,../nii_data/queen_square_data/vs_gk_4/vs_gk_4_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_4/vs_gk_4_seg_refT1.nii.gz,2,1,S,0,0,val +vs_gk_76,7529.134941101074,Q4_Large,24.3173074761068,29837,../nii_data/queen_square_data/vs_gk_76/vs_gk_76_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_76/vs_gk_76_seg_refT1.nii.gz,5,3,XL,0,1,val +vs_gk_174,302.5583267211914,Q1_Small,8.329203938091256,1199,../nii_data/queen_square_data/vs_gk_174/vs_gk_174_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_174/vs_gk_174_seg_refT1.nii.gz,2,0,XS,1,0,val +vs_gk_16,869.318962097168,Q2_Medium_Small,11.841139843999011,3445,../nii_data/queen_square_data/vs_gk_16/vs_gk_16_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_16/vs_gk_16_seg_refT1.nii.gz,3,1,S,0,0,val +crossmoda2022_etz_101,325.01678466796875,Q1_Small,8.530393313140182,322,../nii_data/tilburg_data/crossmoda2022_etz_101/crossmoda2022_etz_101_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_101/crossmoda2022_etz_101_Label.nii.gz,2,0,XS,1,0,val +vs_gk_64,2473.963165283203,Q3_Medium_Large,16.78024132259808,9804,../nii_data/queen_square_data/vs_gk_64/vs_gk_64_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_64/vs_gk_64_seg_refT1.nii.gz,4,2,M,0,0,val +vs_gk_243,750.2134323120117,Q2_Medium_Small,11.273585713902063,2973,../nii_data/queen_square_data/vs_gk_243/vs_gk_243_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_243/vs_gk_243_seg_refT1.nii.gz,3,1,S,0,0,val +vs_gk_173,6871.783447265625,Q4_Large,23.5879544736398,27232,../nii_data/queen_square_data/vs_gk_173/vs_gk_173_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_173/vs_gk_173_seg_refT1.nii.gz,5,3,XL,0,1,val +vs_gk_180,540.5170440673828,Q1_Small,10.106565390060531,2142,../nii_data/queen_square_data/vs_gk_180/vs_gk_180_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_180/vs_gk_180_seg_refT1.nii.gz,3,0,S,1,0,val +vs_gk_112,99.25460815429688,Q1_Small,5.744479522235329,590,../nii_data/queen_square_data/vs_gk_112/vs_gk_112_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_112/vs_gk_112_seg_refT1.nii.gz,4,0,XS,1,0,val +crossmoda2022_etz_83,704.5394065380096,Q2_Medium_Small,11.039995311958291,698,../nii_data/tilburg_data/crossmoda2022_etz_83/crossmoda2022_etz_83_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_83/crossmoda2022_etz_83_Label.nii.gz,2,1,S,0,0,val +crossmoda2022_etz_38,3650.886005043984,Q4_Large,19.10439182690344,3617,../nii_data/tilburg_data/crossmoda2022_etz_38/crossmoda2022_etz_38_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_38/crossmoda2022_etz_38_Label.nii.gz,4,3,L,0,1,val +vs_gk_138,4113.68293762207,Q4_Large,19.87974141464836,16302,../nii_data/queen_square_data/vs_gk_138/vs_gk_138_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_138/vs_gk_138_seg_refT1.nii.gz,1,3,L,0,1,val +vs_gk_202,3160.081672668457,Q3_Medium_Large,18.206783513024845,12523,../nii_data/queen_square_data/vs_gk_202/vs_gk_202_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_202/vs_gk_202_seg_refT1.nii.gz,5,2,L,0,0,val +crossmoda2022_etz_14,1582.6904296875,Q3_Medium_Large,14.458806688004806,1568,../nii_data/tilburg_data/crossmoda2022_etz_14/crossmoda2022_etz_14_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_14/crossmoda2022_etz_14_Label.nii.gz,3,2,M,0,0,val +vs_gk_182,197.07927703857425,Q1_Small,7.22017037949382,781,../nii_data/queen_square_data/vs_gk_182/vs_gk_182_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_182/vs_gk_182_seg_refT1.nii.gz,4,0,XS,1,0,val +crossmoda2022_etz_28,2344.767823457718,Q3_Medium_Large,16.48290445339813,2323,../nii_data/tilburg_data/crossmoda2022_etz_28/crossmoda2022_etz_28_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_28/crossmoda2022_etz_28_Label.nii.gz,2,2,M,0,0,val +vs_gk_51,456.4870834350586,Q1_Small,9.553077266168753,1809,../nii_data/queen_square_data/vs_gk_51/vs_gk_51_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_51/vs_gk_51_seg_refT1.nii.gz,3,0,XS,1,0,val +crossmoda2022_etz_57,270.5108642578125,Q1_Small,8.02408209261212,268,../nii_data/tilburg_data/crossmoda2022_etz_57/crossmoda2022_etz_57_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_57/crossmoda2022_etz_57_Label.nii.gz,2,0,XS,1,0,val +vs_gk_217,5387.254142761231,Q4_Large,21.749850718596942,21349,../nii_data/queen_square_data/vs_gk_217/vs_gk_217_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_217/vs_gk_217_seg_refT1.nii.gz,1,3,XL,0,1,val +vs_gk_179,2641.7707443237305,Q3_Medium_Large,17.151370741808194,10469,../nii_data/queen_square_data/vs_gk_179/vs_gk_179_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_179/vs_gk_179_seg_refT1.nii.gz,5,2,L,0,0,val +vs_gk_214,955.3676605224608,Q2_Medium_Small,12.219608857834675,3786,../nii_data/queen_square_data/vs_gk_214/vs_gk_214_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_214/vs_gk_214_seg_refT1.nii.gz,4,1,S,0,0,val +crossmoda2022_etz_29,317.9515027999878,Q1_Small,8.468128115577644,315,../nii_data/tilburg_data/crossmoda2022_etz_29/crossmoda2022_etz_29_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_29/crossmoda2022_etz_29_Label.nii.gz,3,0,XS,1,0,val +crossmoda2022_etz_64,2595.090804219246,Q3_Medium_Large,17.04974856646933,2571,../nii_data/tilburg_data/crossmoda2022_etz_64/crossmoda2022_etz_64_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_64/crossmoda2022_etz_64_Label.nii.gz,4,2,L,0,0,val +vs_gk_74,1677.5711059570312,Q3_Medium_Large,14.742148163135614,6648,../nii_data/queen_square_data/vs_gk_74/vs_gk_74_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_74/vs_gk_74_seg_refT1.nii.gz,5,2,M,0,0,val +crossmoda2022_etz_13,2696.0243225097656,Q3_Medium_Large,17.267987590027587,2671,../nii_data/tilburg_data/crossmoda2022_etz_13/crossmoda2022_etz_13_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_13/crossmoda2022_etz_13_Label.nii.gz,2,2,L,0,0,val +crossmoda2022_etz_47,1370.7229614257812,Q2_Medium_Small,13.782151571944448,1358,../nii_data/tilburg_data/crossmoda2022_etz_47/crossmoda2022_etz_47_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_47/crossmoda2022_etz_47_Label.nii.gz,3,1,M,0,0,val +vs_gk_45,2212.53662109375,Q3_Medium_Large,16.16704380816796,8768,../nii_data/queen_square_data/vs_gk_45/vs_gk_45_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_45/vs_gk_45_seg_refT1.nii.gz,3,2,M,0,0,val +vs_gk_108,7490.526580810547,Q4_Large,24.27567096542248,29684,../nii_data/queen_square_data/vs_gk_108/vs_gk_108_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_108/vs_gk_108_seg_refT1.nii.gz,4,3,XL,0,1,val +vs_gk_117,999.0228652954102,Q2_Medium_Small,12.402967394413388,3959,../nii_data/queen_square_data/vs_gk_117/vs_gk_117_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_117/vs_gk_117_seg_refT1.nii.gz,4,1,S,0,0,val +vs_gk_42,444.122314453125,Q1_Small,9.466032488701732,1760,../nii_data/queen_square_data/vs_gk_42/vs_gk_42_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_42/vs_gk_42_seg_refT1.nii.gz,1,0,XS,1,0,val +crossmoda2022_etz_26,916.5054426193236,Q2_Medium_Small,12.05162127313325,908,../nii_data/tilburg_data/crossmoda2022_etz_26/crossmoda2022_etz_26_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_26/crossmoda2022_etz_26_Label.nii.gz,1,1,S,0,0,val +crossmoda2022_etz_52,5108.415985107422,Q4_Large,21.36793452679421,5061,../nii_data/tilburg_data/crossmoda2022_etz_52/crossmoda2022_etz_52_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_52/crossmoda2022_etz_52_Label.nii.gz,5,3,XL,0,1,val +crossmoda2022_etz_55,3197.6799087524414,Q3_Medium_Large,18.27870632516928,3168,../nii_data/tilburg_data/crossmoda2022_etz_55/crossmoda2022_etz_55_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_55/crossmoda2022_etz_55_Label.nii.gz,1,2,L,0,0,val +vs_gk_7,5528.313446044922,Q4_Large,21.938049505192545,21908,../nii_data/queen_square_data/vs_gk_7/vs_gk_7_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_7/vs_gk_7_seg_refT1.nii.gz,2,3,XL,0,1,val +vs_gk_113,606.6307067871094,Q1_Small,10.502884490657486,2404,../nii_data/queen_square_data/vs_gk_113/vs_gk_113_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_113/vs_gk_113_seg_refT1.nii.gz,5,0,S,1,0,val +crossmoda2022_etz_1,357.31658935546875,Q1_Small,8.804097684161087,354,../nii_data/tilburg_data/crossmoda2022_etz_1/crossmoda2022_etz_1_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_1/crossmoda2022_etz_1_Label.nii.gz,2,0,XS,1,0,val +vs_gk_231,2665.4909133911133,Q3_Medium_Large,17.202551288655922,10563,../nii_data/queen_square_data/vs_gk_231/vs_gk_231_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_231/vs_gk_231_seg_refT1.nii.gz,4,2,L,0,0,val +vs_gk_14,1294.0109252929688,Q2_Medium_Small,13.520095945465664,5128,../nii_data/queen_square_data/vs_gk_14/vs_gk_14_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_14/vs_gk_14_seg_refT1.nii.gz,5,1,M,0,0,val +crossmoda2022_etz_84,334.1001182794571,Q1_Small,8.609131385069263,331,../nii_data/tilburg_data/crossmoda2022_etz_84/crossmoda2022_etz_84_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_84/crossmoda2022_etz_84_Label.nii.gz,2,0,XS,1,0,val +vs_gk_145,1082.295799255371,Q2_Medium_Small,12.738426439338324,4289,../nii_data/queen_square_data/vs_gk_145/vs_gk_145_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_145/vs_gk_145_seg_refT1.nii.gz,3,1,M,0,0,val +vs_gk_205,499.3852615356445,Q1_Small,9.843412825730828,1979,../nii_data/queen_square_data/vs_gk_205/vs_gk_205_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_205/vs_gk_205_seg_refT1.nii.gz,5,0,XS,1,0,val +vs_gk_235,576.6019821166992,Q1_Small,10.326643146709143,2285,../nii_data/queen_square_data/vs_gk_235/vs_gk_235_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_235/vs_gk_235_seg_refT1.nii.gz,1,0,S,1,0,val +vs_gk_114,4888.625907897949,Q4_Large,21.05697884952108,19373,../nii_data/queen_square_data/vs_gk_114/vs_gk_114_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_114/vs_gk_114_seg_refT1.nii.gz,2,3,L,0,1,val +vs_gk_62,3326.8798828125,Q3_Medium_Large,18.52164259520501,13184,../nii_data/queen_square_data/vs_gk_62/vs_gk_62_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_62/vs_gk_62_seg_refT1.nii.gz,4,2,L,0,0,val +vs_gk_38,6724.920272827148,Q4_Large,23.418703020138874,26650,../nii_data/queen_square_data/vs_gk_38/vs_gk_38_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_38/vs_gk_38_seg_refT1.nii.gz,4,3,XL,0,1,val +vs_gk_207,3874.7148513793945,Q4_Large,19.48709227747513,15355,../nii_data/queen_square_data/vs_gk_207/vs_gk_207_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_207/vs_gk_207_seg_refT1.nii.gz,3,3,L,0,1,val +vs_gk_186,2341.231155395508,Q3_Medium_Large,16.47461310008943,9278,../nii_data/queen_square_data/vs_gk_186/vs_gk_186_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_186/vs_gk_186_seg_refT1.nii.gz,2,2,M,0,0,val +vs_gk_206,4790.717124938965,Q4_Large,20.915454284493272,18985,../nii_data/queen_square_data/vs_gk_206/vs_gk_206_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_206/vs_gk_206_seg_refT1.nii.gz,1,3,L,0,1,val +vs_gk_105,907.6749801635742,Q2_Medium_Small,12.012790800714887,3597,../nii_data/queen_square_data/vs_gk_105/vs_gk_105_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_105/vs_gk_105_seg_refT1.nii.gz,1,1,S,0,0,val +crossmoda2022_etz_103,8508.979797363281,Q4_Large,25.329480933820925,8430,../nii_data/tilburg_data/crossmoda2022_etz_103/crossmoda2022_etz_103_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_103/crossmoda2022_etz_103_Label.nii.gz,5,3,XL,0,1,val +vs_gk_8,1063.8748168945312,Q2_Medium_Small,12.665741948843069,4216,../nii_data/queen_square_data/vs_gk_8/vs_gk_8_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_8/vs_gk_8_seg_refT1.nii.gz,2,1,M,0,0,val +vs_gk_30,7943.228530883789,Q4_Large,24.755183224927148,31478,../nii_data/queen_square_data/vs_gk_30/vs_gk_30_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_30/vs_gk_30_seg_refT1.nii.gz,5,3,XL,0,1,val +crossmoda2022_etz_44,2093.4310913085938,Q3_Medium_Large,15.871574642049378,2074,../nii_data/tilburg_data/crossmoda2022_etz_44/crossmoda2022_etz_44_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_44/crossmoda2022_etz_44_Label.nii.gz,4,2,M,0,0,val +crossmoda2022_etz_12,802.4482727050781,Q2_Medium_Small,11.5293849899612,795,../nii_data/tilburg_data/crossmoda2022_etz_12/crossmoda2022_etz_12_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_12/crossmoda2022_etz_12_Label.nii.gz,5,1,S,0,0,val +vs_gk_89,3377.3483276367188,Q3_Medium_Large,18.614830129811985,13384,../nii_data/queen_square_data/vs_gk_89/vs_gk_89_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_89/vs_gk_89_seg_refT1.nii.gz,4,2,L,0,0,val +crossmoda2022_etz_48,296.75445556640625,Q1_Small,8.275600973042236,294,../nii_data/tilburg_data/crossmoda2022_etz_48/crossmoda2022_etz_48_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_48/crossmoda2022_etz_48_Label.nii.gz,3,0,XS,1,0,val +vs_gk_94,683.0904006958008,Q2_Medium_Small,10.926804848040332,2707,../nii_data/queen_square_data/vs_gk_94/vs_gk_94_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_94/vs_gk_94_seg_refT1.nii.gz,1,1,S,0,0,val +crossmoda2022_etz_45,2895.879364013672,Q3_Medium_Large,17.684547340366127,2869,../nii_data/tilburg_data/crossmoda2022_etz_45/crossmoda2022_etz_45_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_45/crossmoda2022_etz_45_Label.nii.gz,5,2,L,0,0,val +crossmoda2022_etz_96,1702.8069372177124,Q3_Medium_Large,14.815702979805238,1687,../nii_data/tilburg_data/crossmoda2022_etz_96/crossmoda2022_etz_96_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_96/crossmoda2022_etz_96_Label.nii.gz,1,2,M,0,0,val +crossmoda2022_etz_88,1021.4813232421876,Q2_Medium_Small,12.495220834205282,1012,../nii_data/tilburg_data/crossmoda2022_etz_88/crossmoda2022_etz_88_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_88/crossmoda2022_etz_88_Label.nii.gz,1,1,M,0,0,val +crossmoda2022_etz_65,3536.82861328125,Q4_Large,18.903336458340867,3504,../nii_data/tilburg_data/crossmoda2022_etz_65/crossmoda2022_etz_65_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_65/crossmoda2022_etz_65_Label.nii.gz,2,3,L,0,1,val +crossmoda2022_etz_53,461.2815856933594,Q1_Small,9.58640630369382,457,../nii_data/tilburg_data/crossmoda2022_etz_53/crossmoda2022_etz_53_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_53/crossmoda2022_etz_53_Label.nii.gz,4,0,XS,1,0,val +crossmoda2022_etz_74,3713.468170166016,Q4_Large,19.21293415920296,3679,../nii_data/tilburg_data/crossmoda2022_etz_74/crossmoda2022_etz_74_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_74/crossmoda2022_etz_74_Label.nii.gz,5,3,L,0,1,val +crossmoda2022_etz_8,520.8343505859375,Q1_Small,9.98237006056808,516,../nii_data/tilburg_data/crossmoda2022_etz_8/crossmoda2022_etz_8_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_8/crossmoda2022_etz_8_Label.nii.gz,3,0,S,1,0,val +vs_gk_197,1938.240623474121,Q3_Medium_Large,15.4692653733726,7681,../nii_data/queen_square_data/vs_gk_197/vs_gk_197_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_197/vs_gk_197_seg_refT1.nii.gz,2,2,M,0,0,val +vs_gk_126,4501.28059387207,Q4_Large,20.485464755789657,17838,../nii_data/queen_square_data/vs_gk_126/vs_gk_126_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_126/vs_gk_126_seg_refT1.nii.gz,3,3,L,0,1,val +crossmoda2022_etz_50,960.919189453125,Q2_Medium_Small,12.24323206205572,952,../nii_data/tilburg_data/crossmoda2022_etz_50/crossmoda2022_etz_50_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_50/crossmoda2022_etz_50_Label.nii.gz,5,1,S,0,0,val +vs_gk_55,1395.2001571655271,Q2_Medium_Small,13.863704662701313,5529,../nii_data/queen_square_data/vs_gk_55/vs_gk_55_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_55/vs_gk_55_seg_refT1.nii.gz,2,1,M,0,0,val +vs_gk_12,1706.842803955078,Q3_Medium_Large,14.827398771366973,6764,../nii_data/queen_square_data/vs_gk_12/vs_gk_12_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_12/vs_gk_12_seg_refT1.nii.gz,5,2,M,0,0,test +vs_gk_2,1181.466293334961,Q2_Medium_Small,13.116186134614368,4682,../nii_data/queen_square_data/vs_gk_2/vs_gk_2_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_2/vs_gk_2_seg_refT1.nii.gz,4,1,M,0,0,test +vs_gk_134,3417.218399047852,Q3_Medium_Large,18.68779399054685,13542,../nii_data/queen_square_data/vs_gk_134/vs_gk_134_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_134/vs_gk_134_seg_refT1.nii.gz,2,2,L,0,0,test +vs_gk_32,3636.503791809082,Q4_Large,19.07927235114296,14411,../nii_data/queen_square_data/vs_gk_32/vs_gk_32_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_32/vs_gk_32_seg_refT1.nii.gz,4,3,L,0,1,test +vs_gk_66,7473.619651794434,Q4_Large,24.25739293463497,29617,../nii_data/queen_square_data/vs_gk_66/vs_gk_66_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_66/vs_gk_66_seg_refT1.nii.gz,4,3,XL,0,1,test +vs_gk_27,1367.694854736328,Q2_Medium_Small,13.771995229598543,5420,../nii_data/queen_square_data/vs_gk_27/vs_gk_27_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_27/vs_gk_27_seg_refT1.nii.gz,4,1,M,0,0,test +vs_gk_1,3669.055938720703,Q4_Large,19.13603266279162,14540,../nii_data/queen_square_data/vs_gk_1/vs_gk_1_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_1/vs_gk_1_seg_refT1.nii.gz,5,3,L,0,1,test +crossmoda2022_etz_5,333.0917358398437,Q1_Small,8.600461283818992,330,../nii_data/tilburg_data/crossmoda2022_etz_5/crossmoda2022_etz_5_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_5/crossmoda2022_etz_5_Label.nii.gz,4,0,XS,1,0,test +crossmoda2022_etz_81,2027.8242684602733,Q3_Medium_Large,15.70401005464464,2009,../nii_data/tilburg_data/crossmoda2022_etz_81/crossmoda2022_etz_81_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_81/crossmoda2022_etz_81_Label.nii.gz,5,2,M,0,0,test +vs_gk_196,792.3545837402344,Q2_Medium_Small,11.480839636081102,3140,../nii_data/queen_square_data/vs_gk_196/vs_gk_196_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_196/vs_gk_196_seg_refT1.nii.gz,5,1,S,0,0,test +vs_gk_101,218.023681640625,Q1_Small,7.467381661778542,864,../nii_data/queen_square_data/vs_gk_101/vs_gk_101_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_101/vs_gk_101_seg_refT1.nii.gz,4,0,XS,1,0,test +crossmoda2022_etz_34,63.590240478515625,Q1_Small,4.952189804235541,63,../nii_data/tilburg_data/crossmoda2022_etz_34/crossmoda2022_etz_34_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_34/crossmoda2022_etz_34_Label.nii.gz,3,0,XS,1,0,test +crossmoda2022_etz_31,472.3846435546875,Q1_Small,9.662712313674025,468,../nii_data/tilburg_data/crossmoda2022_etz_31/crossmoda2022_etz_31_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_31/crossmoda2022_etz_31_Label.nii.gz,1,0,XS,1,0,test +vs_gk_63,711.8574142456055,Q2_Medium_Small,11.07808764583436,2821,../nii_data/queen_square_data/vs_gk_63/vs_gk_63_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_63/vs_gk_63_seg_refT1.nii.gz,2,1,S,0,0,test +vs_gk_240,3031.387138366699,Q3_Medium_Large,17.956192823367203,12013,../nii_data/queen_square_data/vs_gk_240/vs_gk_240_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_240/vs_gk_240_seg_refT1.nii.gz,3,2,L,0,0,test +vs_gk_6,3484.0890884399414,Q4_Large,18.80890630440923,13807,../nii_data/queen_square_data/vs_gk_6/vs_gk_6_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_6/vs_gk_6_seg_refT1.nii.gz,1,3,L,0,1,test +crossmoda2022_etz_25,913.4774488210678,Q2_Medium_Small,12.038334391988109,905,../nii_data/tilburg_data/crossmoda2022_etz_25/crossmoda2022_etz_25_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_25/crossmoda2022_etz_25_Label.nii.gz,2,1,S,0,0,test +vs_gk_22,362.6998901367188,Q1_Small,8.848091407459945,2156,../nii_data/queen_square_data/vs_gk_22/vs_gk_22_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_22/vs_gk_22_seg_refT1.nii.gz,5,0,XS,1,0,test +vs_gk_80,1767.6572799682615,Q3_Medium_Large,15.00144686131186,7005,../nii_data/queen_square_data/vs_gk_80/vs_gk_80_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_80/vs_gk_80_seg_refT1.nii.gz,2,2,M,0,0,test +vs_gk_178,741.6337966918945,Q2_Medium_Small,11.230444952684367,2939,../nii_data/queen_square_data/vs_gk_178/vs_gk_178_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_178/vs_gk_178_seg_refT1.nii.gz,4,1,S,0,0,test +vs_gk_127,4874.494743347168,Q4_Large,21.036670020366824,19317,../nii_data/queen_square_data/vs_gk_127/vs_gk_127_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_127/vs_gk_127_seg_refT1.nii.gz,3,3,L,0,1,test +vs_gk_216,575.3402709960938,Q1_Small,10.319105448303285,2280,../nii_data/queen_square_data/vs_gk_216/vs_gk_216_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_216/vs_gk_216_seg_refT1.nii.gz,1,0,S,1,0,test +vs_gk_90,349.2416381835937,Q1_Small,8.737270836847156,1384,../nii_data/queen_square_data/vs_gk_90/vs_gk_90_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_90/vs_gk_90_seg_refT1.nii.gz,5,0,XS,1,0,test +crossmoda2022_etz_72,700.5026760101318,Q2_Medium_Small,11.018869986757547,694,../nii_data/tilburg_data/crossmoda2022_etz_72/crossmoda2022_etz_72_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_72/crossmoda2022_etz_72_Label.nii.gz,2,1,S,0,0,test +crossmoda2022_etz_91,3913.323211669922,Q4_Large,19.551602789943413,3877,../nii_data/tilburg_data/crossmoda2022_etz_91/crossmoda2022_etz_91_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_91/crossmoda2022_etz_91_Label.nii.gz,2,3,L,0,1,test +vs_gk_209,6967.168807983398,Q4_Large,23.696592685370923,27610,../nii_data/queen_square_data/vs_gk_209/vs_gk_209_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_209/vs_gk_209_seg_refT1.nii.gz,1,3,XL,0,1,test +crossmoda2022_etz_17,369.4291906356812,Q1_Small,8.902477035335542,366,../nii_data/tilburg_data/crossmoda2022_etz_17/crossmoda2022_etz_17_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_17/crossmoda2022_etz_17_Label.nii.gz,1,0,XS,1,0,test +crossmoda2022_etz_75,2407.3448181152344,Q3_Medium_Large,16.628250547680228,2385,../nii_data/tilburg_data/crossmoda2022_etz_75/crossmoda2022_etz_75_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_75/crossmoda2022_etz_75_Label.nii.gz,3,2,M,0,0,test +crossmoda2022_etz_2,3446.989489197731,Q4_Large,18.741907018201733,3415,../nii_data/tilburg_data/crossmoda2022_etz_2/crossmoda2022_etz_2_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_2/crossmoda2022_etz_2_Label.nii.gz,1,3,L,0,1,test +vs_gk_40,1892.061996459961,Q3_Medium_Large,15.345424734626713,7498,../nii_data/queen_square_data/vs_gk_40/vs_gk_40_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_40/vs_gk_40_seg_refT1.nii.gz,1,2,M,0,0,test +vs_gk_156,6305.2751541137695,Q4_Large,22.921083346328373,24987,../nii_data/queen_square_data/vs_gk_156/vs_gk_156_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_156/vs_gk_156_seg_refT1.nii.gz,3,3,XL,0,1,test +crossmoda2022_etz_73,1797.6834568977356,Q3_Medium_Large,15.08591071992531,1781,../nii_data/tilburg_data/crossmoda2022_etz_73/crossmoda2022_etz_73_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_73/crossmoda2022_etz_73_Label.nii.gz,5,2,M,0,0,test +crossmoda2022_etz_10,1030.565278172493,Q2_Medium_Small,12.532151256091517,1021,../nii_data/tilburg_data/crossmoda2022_etz_10/crossmoda2022_etz_10_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_10/crossmoda2022_etz_10_Label.nii.gz,2,1,M,0,0,test +vs_gk_68,601.8362045288086,Q1_Small,10.475141443974136,2385,../nii_data/queen_square_data/vs_gk_68/vs_gk_68_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_68/vs_gk_68_seg_refT1.nii.gz,1,0,S,1,0,test +vs_gk_124,1387.3775482177734,Q2_Medium_Small,13.83774574448326,5498,../nii_data/queen_square_data/vs_gk_124/vs_gk_124_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_124/vs_gk_124_seg_refT1.nii.gz,4,1,M,0,0,test