longitudinal/BENCHMARKING.md

221 lines
No EOL
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Pipeline benchmark (A / B / C)
Benchmark of the three segmentation pipelines — A in-house Unet3D, B nnU-Net v2
(NTUHLPLTrainer), C MONAI UNet — driven by `scripts/benchmark_pipelines.py`
(DICE, training time, inference time, params, VRAM). Protocol details live in
the script docstring; this file records what has been run and what the machine
currently supports.
## Data chain status (2026-10-04)
The M6 labeled set was fully rebuilt on 2026-10-04 (see
`scripts/02_build_m6_dataset.py`): T1c fixed, best-of-ANTs
(`ants_rigid` / `ants_affine` / `ants_syn`, winner by Mattes MI), TV warped
onto the T1c grid with SMD+linear. After the repair pass:
- `data/manifests/m6_labeled.jsonl`: **1312** volumes (was 1094 pre-repair),
`m6_unlabeled.jsonl`: **229**
- all 1541 m6 processed volumes (`data/proc/<key>{,_label}.nii.gz`, 1mm
cropped/normalized) regenerated, 0 errors
- patient-level splits rebuilt (`scripts/05_build_splits.py`, seed 0):
**4200** labeled rows (ntuh + m6) → train 3570 / val 211 / test 419
volumes, 3893 subjects; unlabeled pool 13280
- tumor volume stats: median 3116 mm3, p95 24.8k, max 3.2M, zero_frac 0.12%
## Dataset exclusions (2026-10-04)
Row-level curation lives in `src/exclusions.py` and is applied at the **first
stage** by `scripts/make_tv_slices.py` (right after the rows are fetched from
the sources, before any split): it classifies every labeled volume and writes
its TV-slice QA figure to `data/tv_slices/inclusion/` (kept) or
`data/tv_slices/exclusion/` (excluded, reason in the title). `05_build_splits`
re-applies the same rules **after** split assignment (kept rows retain their
split; the subject-level shuffle boundaries are unchanged):
- total TV > 1e6 mm3: **1** row (ntuh_6019528, label outlines the full skull)
- single target (1 connected component) with TV < 100 mm3: **109** rows
- non-brain images: **33** rows (all ntuh — spine/neck/torso CyberKnife
cases whose series names are indistinguishable from brain T1c; identified
by visual review of the TV-slice figures, now in `data/tv_slices/exclusion/`)
Audit trail: `data/manifests/excluded.jsonl` (143 rows, reason + split);
pre-exclusion manifests in `data/manifests/backup_20261004/`. New dataset:
**4057** labeled rows → train 3447 / val 205 / test 405 (3771 subjects);
unlabeled pool unchanged (13280); volume stats now median 3212 mm3, p95
23.4k, max 163k (m6 meningioma), zero_frac still 0.12% (5 rows, e.g.
ntuh_3436472 ×3).
After the initial 39-row non-brain list, expert inspection overturned 6
(skull-base / posterior-fossa brain targets that look like cervical spine at
the TV-max slice: ntuh_5902230, ntuh_5231609, ntuh_5083686, ntuh_5157194,
ntuh_5303758, ntuh_6373987). The remaining 33 were re-audited at the
mid-cranial slice (widest head-mask axial) — all still non-brain. Review
protocol now recorded in `src/exclusions.py`: classify at full resolution
on the mid-cranial slice, not just the TV-max slice.
## Smoke run (2026-10-04)
Small/fast end-to-end check that all three pipelines run on the rebuilt data
(1 epoch, 24 train rows, 3 eval rows, TTA on, GPU 0):
```
python scripts/benchmark_pipelines.py --gpu 0 --max-rows 24 --nnu-cases 24 \
--max-eval-rows 3 --train-epochs 1 --out results/benchmark_pipelines_smoke.json
```
| pipeline | wall | s/epoch | tr VRAM | s/vol (TTA) | in VRAM | params | weights | dice (1 epoch) |
|---|---|---|---|---|---|---|---|---|
| A Unet3D(16, depth 4) | 17 s | 2.5 (8 steps) | 2.0 GB | 4.12 | 0.5 GB | 1.46 M | 5.9 MB | 0.0000 |
| B nnU-Net 3d_fullres | 285 s (incl. 83 s plan&preprocess, 4 s raw build) | 73.3 | 9.8 GB | 34.73 | 1.8 GB | 30.8 M | 246 MB | 0.0000 |
| C MONAI UNet(16→128) | 5 s | 2.5 (8 steps) | 1.7 GB | 0.33 | 0.5 GB | 1.19 M | 4.8 MB | 0.0005 |
DICE ≈ 0 is expected (models trained for a single epoch on 24 volumes); the
point of the run was validating the full chain — new m6 native/processed
manifests, split membership, B's `ntuh + m6_labeled` native join, nnU-Net
isolated dataset 221 build, inference + dice scoring. All three passed.
Eval rows for the smoke run were the first three `split_test` volumes (all
ntuh). Artifacts: `results/benchmark_pipelines_smoke.json`,
`_per_row.jsonl`, `_bench.png`.
### Environment fixes made during the check
- `monai` was missing from `longitudinal-313` (pipeline C failed with
ModuleNotFoundError) — installed `pip install monai==1.6.1 --no-deps`
(torch 2.14+cu126 untouched); `import monai` verified.
## Full-dataset 10-epoch run (2026-10-05, updated dataset)
`--train-epochs 10`, default budgets on the current curated splits: **2750**
train rows = 80% of split_train (3438 labeled), **40** eval rows = 10% of
split_test (404), TTA on. The 2026-10-04 interim run (pre-exclusion budget
2856/42: A 0.0008, C 0.2119) is superseded; its JSONs are kept in
`results/backup_20261004/` (smoke B checkpoint in
`nnu/results/backup_20261004_Dataset221_smoke/`).
```
python scripts/benchmark_pipelines.py --pipelines C --gpu 2 --train-epochs 10 \
--out results/benchmark_full_C.json # done
python scripts/benchmark_pipelines.py --pipelines A --gpu 0 --train-epochs 10 \
--a-fg-ce-weight 500 --out results/benchmark_full_A.json # done
# A baseline (uniform crops) on this dataset: dice 0.0026, see table;
# checkpoint runs_bench/A/best_baseline_10ep_uniform_crop.pt
NNU_ROOT=/home/xfr/longitudinal_nnu BENCH_B_DIR=/home/xfr/longitudinal_benchB \
python scripts/benchmark_pipelines.py --pipelines B --gpu 1 --train-epochs 10 \
--out results/benchmark_full_B.json # done (local-disk NNU_ROOT, see below)
```
| pipeline | train s/epoch | wall | tr VRAM | s/vol (TTA) | in VRAM | params | weights | dice @10ep |
|---|---|---|---|---|---|---|---|---|
| C MONAI UNet(16→128) | 256.7 (916 steps) | 43 min | 0.4 GB | 0.37 ± 0.06 | 0.5 GB | 1.19 M | 14.3 MB | **0.2162** |
| A Unet3D(16, depth 4), improved (fg crops + CE w=500) | 232.3 (916 steps) | 41 min | 2.0 GB | 3.60 ± 0.76 | 0.5 GB | 1.46 M | 17.6 MB | **0.0369** |
| A baseline (uniform crops, unweighted loss) | 216.6 (916 steps) | 45 min | 2.0 GB | 3.33 ± 0.86 | 0.5 GB | 1.46 M | 17.6 MB | **0.0026** |
| B nnU-Net 3d_fullres | 331.5 (incl. 275-case val/epoch) | ~2.5 h (raw 6 m, prep 62 m, train 55 m, infer 14 m) | 9.3 GB | 21.84 | 1.8 GB | 30.8 M | 246 MB | **0.1424** (hard 0.1424) |
C: 25/39 timed test vols with dice > 0.001 (max 0.7392); loss 0.403 → 0.182.
10 epochs is far below convergence for these sparse targets (median tumor
3.2 k mm³); A/C numbers at this budget are intermediate. Checkpoints at
`runs_bench/{A,C}/best.pt` (extend with `--resume --train-epochs <total>`).
### A: why dice ≈ 0, and the fix (2026-10-05)
The A baseline predicts all-background (38/39 test rows dice exactly 0.0).
Root-cause work:
1. **Uniform random 96³ crops starve the loss of foreground.** A 96³ patch is
885 k voxels; the median tumor is ~4.6 k mm³ (0.02–0.05% of a head volume).
Simulated uniform crops contain the whole blob in only 8–37% of patches and
a *median* crop often has 0 tumor voxels. Pipeline C's
`RandCropByPosNegLabeld(pos=1, neg=1)` centers half its patches on the
tumor — ~4× more foreground supervision per epoch.
2. **Fg-aware crops alone are not enough: A under-confides.** With the crop
protocol fixed (`src/dataset.py`), A's loss matches C's at every epoch
(0.193 vs 0.182 @10ep) yet p_tumor maxes at 0.43 (C: ~1.0): it localizes
(top-k dice 0.48 on best rows) but never crosses the 0.5 threshold, so
dice@0.5 stays 0.
3. **Fix: class-weighted CE** — tumor-class weight `w` in the CE part of the
per-sample loss (`--a-fg-ce-weight`, `src/losses.py`). 3-epoch probes on
this dataset: w=20 → pmax median 0.33, dice 0.0; w=100 → 0.41, dice
0.0002; w=500 → 0.62, dice 0.0188 (34/39 rows > 0.5). Official 10-epoch
w=500 run: **dice 0.0369** (vs 0.0026 baseline, 14×; 8/39 rows > 0.001,
best 0.555, pmax median 0.55, 29/39 rows > 0.5) — A now segments, but
still trails C (0.2162) at this budget; its loss is still descending at
epoch 10 (0.78 → 0.51), i.e. under-converged vs C (0.41 → 0.18), so the
gap should narrow with more epochs.
Changes: `src/dataset.py` (pos/neg crop sampling `pos_neg=0.5`, per-worker
RNG reseed), `src/losses.py` (`soft_dice_fg`, `fg_ce_weight`), benchmark flags
`--a-fg-dice` / `--a-fg-ce-weight`, per-row `pmax` in A's `dice_per_row`,
full traceback in failure JSONs. A/B probes: `results/bench_A_*probe*.json`.
### B: CIFS failures and local-disk NNU_ROOT (2026-10-05)
The CIFS `/mnt/b4` mount dropped I/O repeatedly on 2026-10-05 (NAS flapping:
`OSError [Errno 112] Host is down`, `RuntimeError: Error while setting the
slice`, one silent `rmtree` failure that made the raw rebuild collide with
stale entries → `FileExistsError`). Four B attempts died (07:52 plan&prep;
11:12 raw EEXIST; ~12:40 / ~13:50 / ~14:00 plan&prep). Fixes:
- `nnu_common.rmtree_verified()` + defensive per-link unlink in
`make_raw_dataset` (EEXIST on rebuild)
- failure JSONs now carry a full `traceback` key
- plan step retries raised 2 → 5 (`N.run(..., retries=5)`)
- **B's nnU-Net tree moved off CIFS**: `nnu_common.nnu_root()` honors
`NNU_ROOT` (set to `/home/xfr/longitudinal_nnu`, local ZFS ~1.8 T free) and
the benchmark's B pred I/O honors `BENCH_B_DIR`
(`/home/xfr/longitudinal_benchB`). CIFS is then touched only for the
one-time raw image reads from `/mnt/pve` and small log/JSON appends.
The pre-`NNU_ROOT` attempt's half-built preprocessed tree under
`nnu/preprocessed/` is stale — the local tree is the one being used.
The local-disk run succeeded on attempt 1 (2026-10-05 14:07→16:38 UTC):
raw build 6.3 min, plan&preprocess 62 min (140 GB preprocessed), 10 epochs
in 55 min (epoch times 101–167 s train + 275-case validation —
`nnUNet_compile is enabled`, vs the Oct-4 eager-mode pace of ~2.4 h/epoch),
inference 21.8 s/vol TTA. Val pseudo dice 0.091 → 0.459, best EMA 0.285;
test dice 0.1424 (29/39 rows > 0.001, max 0.675). Checkpoints in
`/home/xfr/longitudinal_nnu/results/Dataset221_T1C_BENCH/...fold_0/`
(copy to `nnu/results/` for the record if local space is reclaimed).
## Resuming training (`--resume`)
All three pipelines can continue from their latest benchmark checkpoint:
```
python scripts/benchmark_pipelines.py --pipelines "A B C" --resume \
--train-epochs 200 --nnu-skip-prep
```
- `--train-epochs` is the **total budget** (not epochs added). Each pipeline
trains from where its checkpoint left off up to that total.
- **A / C**: load `runs_bench/{A,C}/best.pt` (model + optimizer + scheduler
state — checkpoints written before 2026-10-04 carry model + epoch only and
fall back to fresh optimizer/scheduler) and run the remaining epochs; the
run is a no-op train (eval only) when the checkpoint already reached the
total.
- **B**: appends nnU-Net's `--c` flag (continue `fold_0` from
`checkpoint_latest.pth`, native optimizer + LR-schedule restore) and
`NNUNet_pl_EPOCHS = --train-epochs`. Pair with `--nnu-skip-prep` so the
preprocessed tree isn't `--clean`ed; the isolated Dataset221 is rebuilt
otherwise and a finished run's `checkpoint_latest.pth` is gone, so a
completed run + a bigger budget retrains B from scratch (interrupted runs
keep `checkpoint_latest.pth` and resume correctly).
- Verified 2026-10-04: A/C resumed epoch 1 → 3 (model restored, fresh
opt/sched for the old-format ckpt), re-run at 3/3 trained 0 epochs, B
continued from epoch 1 with LR restored mid-decay (0.00536 @ start of
epoch 2).
## Readiness notes for a full run
- Budget knobs: `--train-epochs` (default 100), `--max-rows` (default 80% of
split_train = 2750 for A/C and for B's `--nnu-cases` on the current curated
splits; was 2856 pre-exclusion), `--max-eval-rows` (default 10% of
split_test = 40), `--no-tta`.
- `--skip-train` is only usable for **A** (`runs/round0/best.pt`, trained
2026-09-25 on the *previous* split — a few patients moved between
train/test in the rebuild, so its test dice carries a small leakage
caveat). No study checkpoints exist for B (Dataset210) or C
(`runs_monai/round0/best.pt`); they must be trained.
- GPUs: 3× 80 GB available; the benchmark uses one (`--gpu`).
- B's one-time cost scales with `--nnu-cases` (plan_and_preprocess ≈ 3.5 s/case
at 24 cases with 8 preprocess procs, plus training time at the planned
batch/patch).