Skip to content

Commit 36450e0

Browse files
authored
Dev (#85)
* Add CLI for five-fold VS training * Improve vestibular schwannoma parallel training
1 parent dd032ca commit 36450e0

15 files changed

Lines changed: 1310 additions & 185 deletions

research/vestibular_schwannoma/README.md

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ training, inference on new cases, and PACS deployment.
77
## Contents
88

99
- `train_5fold.py`: command-line five-fold training and evaluation.
10+
- `merge_inference_manifests.py`: validate and combine parallel fold subsets for inference.
1011
- `notebooks/01_five_fold_cross_validation.ipynb`: train and compare UNet, DynUNet, and
1112
optional SegMamba models.
1213
- `notebooks/02_inference_new_cases.ipynb`: run one declared model or an explicit ensemble.
@@ -42,9 +43,62 @@ python train_5fold.py --models unet # One model, all five folds
4243
python train_5fold.py --skip-unavailable
4344
```
4445

45-
The default requests four models across five folds for 500 epochs; run
46-
`python train_5fold.py --help` before starting. For interactive inspection and visualizations,
47-
start Jupyter from this directory or `notebooks/`:
46+
The default requests three models across five folds for 500 epochs; run
47+
`python train_5fold.py --help` before starting. A launcher processes its requested models
48+
and folds sequentially. With five GPUs, run one process per fold, assign each process one
49+
visible GPU, and give it a distinct, previously nonexistent `--results-root`:
50+
51+
```bash
52+
CUDA_VISIBLE_DEVICES=0 python train_5fold.py --models unet --folds 1 --results-root cv_results/unet_fold_1 &
53+
CUDA_VISIBLE_DEVICES=1 python train_5fold.py --models unet --folds 2 --results-root cv_results/unet_fold_2 &
54+
CUDA_VISIBLE_DEVICES=2 python train_5fold.py --models unet --folds 3 --results-root cv_results/unet_fold_3 &
55+
CUDA_VISIBLE_DEVICES=3 python train_5fold.py --models unet --folds 4 --results-root cv_results/unet_fold_4 &
56+
CUDA_VISIBLE_DEVICES=4 python train_5fold.py --models unet --folds 5 --results-root cv_results/unet_fold_5 &
57+
wait
58+
```
59+
60+
Inside each process, its assigned physical GPU is exposed to PyTorch as CUDA device 0.
61+
62+
Before starting parallel jobs, populate `preprocessed/` once with a single process;
63+
concurrent first-time cache creation is not supported. When `MLFLOW_TRACKING_URI` is unset,
64+
processes launched on the same machine from the same fastMONAI checkout automatically share
65+
fastMONAI's repository-root SQLite tracking store
66+
(`sqlite:////absolute/path/to/fastMONAI/mlruns.db`). For multiple machines or a central
67+
tracking service, configure the same remote URI in every shell:
68+
69+
```bash
70+
export MLFLOW_TRACKING_URI=http://mlflow.example:5000
71+
```
72+
73+
Do not merge run IDs from independent local MLflow databases: inference must be able to
74+
resolve every run ID through one tracking URI.
75+
76+
Each launcher atomically updates `completed_run_ids.json` after every successful fold, so
77+
completed work remains mergeable if a later fold is interrupted. A subset job intentionally
78+
does not create `inference_run_ids.json`; its completed registry is also rejected by the
79+
inference loader. Combine disjoint completed-fold registries into a new inference-only
80+
results root. The merger requires exactly folds 1-5, verifies that dataset/splits,
81+
preprocessing, model/loss, and training settings match, and rejects missing or overlapping
82+
folds, duplicate MLflow run IDs, and an existing output root:
83+
84+
```bash
85+
python merge_inference_manifests.py \
86+
cv_results/unet_fold_1/completed_run_ids.json \
87+
cv_results/unet_fold_2/completed_run_ids.json \
88+
cv_results/unet_fold_3/completed_run_ids.json \
89+
cv_results/unet_fold_4/completed_run_ids.json \
90+
cv_results/unet_fold_5/completed_run_ids.json \
91+
--model unet \
92+
--output-root cv_results/unet_5fold_merged
93+
```
94+
95+
To replace only fold 1, train it into a new results root and merge that new
96+
`completed_run_ids.json` with registries containing folds 2-5; omit the old fold-1
97+
registry. The replacement is accepted only when its training contract matches, and the
98+
merged `--output-root` must also be new.
99+
100+
Use `cv_results/unet_5fold_merged/inference_run_ids.json` in notebook 02. For interactive
101+
inspection and visualizations, start Jupyter from this directory or `notebooks/`:
48102

49103
```bash
50104
jupyter lab notebooks/01_five_fold_cross_validation.ipynb
@@ -65,10 +119,15 @@ to `workflow/`. Training model configs contain the VS-specific architecture and
65119
model reconstruction, patch inference, metrics, and artifact formats remain fastMONAI
66120
responsibilities.
67121

68-
Training retains weights-only `.pth` checkpoints for warm-starting or further fitting with a
69-
newly initialized optimizer and learning-rate schedule; they are not exact training-resume
70-
checkpoints. Inference and deployment use strict-loaded `.safetensors` artifacts. Generated
71-
data, results, tracking stores, checkpoints, and model bundles are excluded from Git.
122+
Training writes selected fold checkpoints below
123+
`<results-root>/<model>/fold_<n>/checkpoints/`, so different folds and models cannot overwrite
124+
each other. All-data learners are independently scoped below
125+
`<results-root>/<model>/all_data/`, but their final artifacts are stored in MLflow rather than as
126+
a local checkpoint. The fold `.pth` files support warm-starting or further fitting with a newly
127+
initialized optimizer and learning-rate schedule; they are not exact training-resume
128+
checkpoints. Final and best inference artifacts remain isolated in their MLflow runs. Inference
129+
and deployment use strict-loaded `.safetensors` artifacts. Generated data, results, tracking
130+
stores, checkpoints, and model bundles are excluded from Git.
72131

73132
For container preparation and execution, see
74133
[deployment/pacs/README.md](deployment/pacs/README.md).
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env python3
2+
"""Merge parallel fold-training manifests into one inference selection."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import sys
8+
from pathlib import Path
9+
10+
11+
PROJECT_ROOT = Path(__file__).resolve().parent
12+
RESEARCH_ROOT = PROJECT_ROOT.parent
13+
if str(RESEARCH_ROOT) not in sys.path:
14+
sys.path.insert(0, str(RESEARCH_ROOT))
15+
16+
from vestibular_schwannoma.workflow.run_selection import ( # noqa: E402
17+
merge_fold_run_selections,
18+
)
19+
20+
21+
def _parser() -> argparse.ArgumentParser:
22+
parser = argparse.ArgumentParser(
23+
description=(
24+
"Validate and merge disjoint fold inference manifests produced by "
25+
"independently launched training jobs."
26+
)
27+
)
28+
parser.add_argument(
29+
"selection_files",
30+
nargs="+",
31+
type=Path,
32+
help=(
33+
"Source completed_run_ids.json or inference_run_ids.json files to merge."
34+
),
35+
)
36+
parser.add_argument(
37+
"--model",
38+
required=True,
39+
help="Model key whose best fold runs should be merged, for example unet.",
40+
)
41+
parser.add_argument(
42+
"--output-root",
43+
type=Path,
44+
required=True,
45+
help="New directory in which to write the merged inference_run_ids.json.",
46+
)
47+
return parser
48+
49+
50+
def _project_path(path: Path) -> Path:
51+
return path if path.is_absolute() else PROJECT_ROOT / path
52+
53+
54+
def main(argv: list[str] | None = None) -> int:
55+
args = _parser().parse_args(argv)
56+
destination = merge_fold_run_selections(
57+
[_project_path(path) for path in args.selection_files],
58+
model_key=args.model,
59+
output_root=_project_path(args.output_root),
60+
)
61+
print(f"Merged inference run selection: {destination}")
62+
return 0
63+
64+
65+
if __name__ == "__main__":
66+
raise SystemExit(main())

research/vestibular_schwannoma/notebooks/01_five_fold_cross_validation.ipynb

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@
8383
"\n",
8484
"Patch-loading settings depend on available hardware. More queue workers may improve training throughput, while a larger queue increases RAM usage.\n",
8585
"\n",
86-
"`training_seed` initializes training randomness before each independent run while retaining cuDNN performance optimizations. In an all-data run, every case remains in training and the first case by stable `case_id` order is duplicated only for fastai's validation phase. Its metric is an internal monitor, not held-out evaluation."
86+
"`training_seed` initializes training randomness before each independent run while retaining cuDNN performance optimizations. In an all-data run, every case remains in training and the first case by stable `case_id` order is duplicated only for fastai's validation phase. Its metric is an internal monitor, not held-out evaluation.\n",
87+
"\n",
88+
"Every independently launched job must use a distinct, previously nonexistent `RESULTS_ROOT`. Re-run this configuration cell before starting another sweep. After parallel fold subsets finish (or one is interrupted after completing some folds), use `merge_inference_manifests.py` to validate and combine their `completed_run_ids.json` registries. The merger rejects different dataset, split, preprocessing, model, loss, or training contracts. A partial `completed_run_ids.json` registry cannot be used directly for inference."
8789
]
8890
},
8991
{
@@ -105,13 +107,13 @@
105107
" use_tta=True,\n",
106108
" compile_models=True,\n",
107109
" target_spacing=(0.4102, 0.4102, 1.5),\n",
108-
" patch_size=(192, 192, 48),\n",
110+
" patch_size=(256, 256, 48),\n",
109111
" queue_num_workers=4,\n",
110112
" queue_length=300,\n",
111113
")\n",
112114
"\n",
113115
"DATA_CSV = \"data/ml_dataset.csv\"\n",
114-
"RESULTS_RUN = datetime.now(timezone.utc).strftime(\"%Y%m%dT%H%M%SZ\")\n",
116+
"RESULTS_RUN = datetime.now(timezone.utc).strftime(\"%Y%m%dT%H%M%S%fZ\")\n",
115117
"RESULTS_ROOT = Path(\"cv_results\") / RESULTS_RUN\n",
116118
"\n",
117119
"torch.backends.cudnn.benchmark = True\n",
@@ -172,7 +174,9 @@
172174
"source": [
173175
"## 4. Preprocess once to disk\n",
174176
"\n",
175-
"Preprocessing is fold-independent. `preprocess_dataset()` automatically creates a versioned preprocessing cache and a `preprocessing_manifest.json` file. On later runs, fastMONAI uses the manifest to verify that the source files and preprocessing settings are unchanged before reusing the cache. `PatchConfig(preprocessed=True)` prevents preprocessing from being applied twice during training.\n"
177+
"Preprocessing is fold-independent. `preprocess_dataset()` automatically creates a versioned preprocessing cache and a `preprocessing_manifest.json` file. On later runs, fastMONAI uses the manifest to verify that the source files and preprocessing settings are unchanged before reusing the cache. `PatchConfig(preprocessed=True)` prevents preprocessing from being applied twice during training.\n",
178+
"\n",
179+
"Populate a new preprocessing cache with one process before launching parallel training jobs; concurrent first-time cache creation is not supported.\n"
176180
]
177181
},
178182
{
@@ -543,7 +547,8 @@
543547
"- `cv_results/<RESULTS_RUN>/<model>/fold_N/`: metrics and predictions.\n",
544548
"- `cv_results/<RESULTS_RUN>/<model>/cv_summary.csv`: one complete model summary.\n",
545549
"- `cv_results/<RESULTS_RUN>/cv_model_comparison.csv`: cross-model summary.\n",
546-
"- `cv_results/<RESULTS_RUN>/inference_run_ids.json`: exact completed MLflow runs for notebook 02.\n",
550+
"- `cv_results/<RESULTS_RUN>/completed_run_ids.json`: atomically updated MLflow runs for completed folds, including before a later interruption.\n",
551+
"- `cv_results/<RESULTS_RUN>/inference_run_ids.json`: exact fully completed MLflow runs for notebook 02.\n",
547552
"\n",
548553
"MLflow retains final and selected weights-only checkpoints plus strict-loadable Safetensors artifacts. All-data runs produce only final artifacts.\n",
549554
"\n",

research/vestibular_schwannoma/tests/workflow/test_config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def test_defaults_preserve_the_notebook_experiment(self):
1616
self.assertEqual(config.model_keys, ("unet", "dynunet", "segmamba"))
1717
self.assertEqual(config.folds, (1, 2, 3, 4, 5))
1818
self.assertEqual(config.target_spacing, (0.4102, 0.4102, 1.5))
19-
self.assertEqual(config.patch_size, (192, 192, 48))
19+
self.assertEqual(config.patch_size, (256, 256, 48))
2020
self.assertEqual(config.epochs, 500)
2121
self.assertEqual(config.batch_size, 4)
2222
self.assertEqual(config.training_seed, 42)
@@ -44,7 +44,7 @@ def test_invalid_declarations_fail_early(self):
4444
{"model_keys": ("unet",), "epochs": 0},
4545
{"model_keys": ("unet",), "training_seed": -1},
4646
{"model_keys": ("unet",), "training_seed": True},
47-
{"model_keys": ("unet",), "patch_size": (192, 192, 0)},
47+
{"model_keys": ("unet",), "patch_size": (256, 256, 0)},
4848
{
4949
"model_keys": ("unet",),
5050
"foreground_sampling_probability": 0,
@@ -62,7 +62,7 @@ def test_patch_factory_preserves_training_and_inference_contract(self):
6262
config = ExperimentConfig(model_keys=("unet",))
6363
normalization = [ZNormalization(masking_method="foreground")]
6464
patch = make_patch_config(config, normalization)
65-
self.assertEqual(patch.patch_size, [192, 192, 48])
65+
self.assertEqual(patch.patch_size, [256, 256, 48])
6666
self.assertEqual(patch.target_spacing, [0.4102, 0.4102, 1.5])
6767
self.assertEqual(patch.label_probabilities, {0: 0.2, 1: 0.8})
6868
self.assertTrue(patch.preprocessed)

0 commit comments

Comments
 (0)