# 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 (2026-10-04 curation) M6 labeled set rebuilt 2026-10-04 (`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 → 1312 labeled volumes, all 1541 processed (`data/proc/{,_label}.nii.gz`, 1 mm). Row-level curation lives in `src/exclusions.py` (applied at `scripts/make_tv_slices.py`, re-applied after split by `05_build_splits`): total TV > 1e6 mm3 (1 row), single-component TV < 100 mm3 (109), non-brain images (33, all ntuh spine/neck/torso; 6 skull-base/posterior-fossa cases kept after expert review — protocol: classify at the mid-cranial slice, not just the TV-max slice). Audit trail: `data/manifests/excluded.jsonl` (143 rows); pre-exclusion manifests in `data/manifests/backup_20261004/`. Current splits (seed 0): **4057** labeled rows → train 3447 / val 205 / test 405 (3771 subjects); unlabeled pool 13280. Tumor volume: median 3212 mm3, p95 23.4k, max 163k (m6 meningioma), zero_frac 0.12%. Benchmark budget: **2750** train rows (80% of split_train), **40** eval rows (10% of split_test), TTA on. ## Environment - `monai==1.6.1` in `longitudinal-313` (pip `--no-deps`, torch untouched). - **B's nnU-Net tree is on local disk, not CIFS**: `nnu_common.nnu_root()` honors `NNU_ROOT=/home/xfr/longitudinal_nnu` (local ZFS) and B's pred I/O honors `BENCH_B_DIR=/home/xfr/longitudinal_benchB` — the 2026-10-05 CIFS flapping (`OSError [Errno 112]`, stale `rmtree` → `FileExistsError`) had killed four B attempts. CIFS is then touched only for the one-time raw reads from `/mnt/pve` and small log/JSON appends. Hardening: `nnu_common.rmtree_verified()`, plan retries 2→5, full `traceback` in failure JSONs. ## Headline correction: A's low dice was an inference TTA bug (2026-10-06) A's persistent low test dice (pmax median ~0.25, "under-confidence state") in the 2026-10-05/06 10–100-epoch runs was a bug in `src/training.sliding_window_probs`, not a training problem: the old code flipped the **whole volume** and windowed at the **original grid positions**, so each region averaged its own model response with its mirror regions' responses — for an off-mirror target the four-view average collapses to ~1/4 of the identity-view probability. C's flip→window→flip-back scheme was already correct, hence unaffected. Evidence (A 100ep model): p = 0.998 at the tumor voxel with TTA off (top-10 activations 10/10 inside the tumor, mean p inside 0.91 vs 0.004 outside), pmax = 0.2498 with TTA on; a BN→InstanceNorm conversion changed nothing (0.2498 → 0.2499). Fix: flip the **patch** (`patch = tf(vol[sl])`). All A checkpoints re-scored on 40 held-out rows (`results/benchmark_full/A_reeval_fixed_tta.json`): | A checkpoint | dice (old TTA) | dice (fixed) | pmax median | rows > 0.001 | |---|---|---|---|---| | 100ep nn-loss (AdamW, BN) | 0.0047 | **0.5313** | 0.9994 | 30/40 | | 10ep baseline, uniform crops, std loss | 0.0026 | **0.5102** | 1.0000 | 31/40 | | 20ep nn-loss (AdamW, BN) | 0.0000 | 0.3644 | 0.9532 | 26/40 | | 20ep std-loss + CE w=500 | 0.0076 | 0.1466 | 0.9982 | 33/40 | The 2026-10-05 fg-crop / CE-weight / loss-parity investigations were chasing a symptom of this eval bug — the plain uniform-crop 10ep baseline (0.5102) already beats everything else on record at that budget. (Caveat: that checkpoint predates the current split by a few moved patients — same leakage caveat as `--skip-train`.) ## What pipeline B actually does (2026-10-06 audit) From the installed nnunetv2 2.8.1 source + B's `fold_0/debug.json`: - **Loss**: `DC_and_CE_loss({'batch_dice': True, 'smooth': 1e-5, 'do_bg': False})` = CE + tumor-class-only batch-level soft dice, `loss = CE − dice` (`src.losses.b_dc_and_ce` matches nnunetv2's classes to max abs diff 0.0). - **Deep supervision**: 4 seg outputs at 1x/1/2/1/4, weights 4/7, 2/7, 1/7 (raw [1, 1/2, 1/4, 1/8] with the bottleneck scale zeroed, renormalized), nearest-neighbor downsampled targets. - **Optimizer**: SGD lr 1e-2, momentum 0.99, nesterov, weight_decay 3e-5; PolyLR exponent 0.9 on the global epoch index; grad clip 12. - **Network**: PlainConvUNet, InstanceNorm3d(affine, eps 1e-5), LeakyReLU(0.01), conv bias, init kaiming_normal_(a=0.01) + zero bias. - **Data**: resampled to spacing [1.0, 0.586, 0.586], ZScore (no mask), patch 64×192×192, batch 2, 250 iterations/epoch, 33% fg patch oversampling. - **Augmentation** (per patch): rotation ±30° p=0.2/axis; zoom 0.7–1.4 p=0.2; Gaussian noise p=0.1; blur p=0.2; brightness/contrast p=0.15; low-resolution sim p=0.25; gamma p=0.1 + p=0.3; mirror p=0.5; no elastic. - **"EMA"**: an EMA (0.9/0.1) of the per-epoch val pseudo-dice, used only to select `checkpoint_best.pth` — not a weight EMA. - **Epoch semantics**: B's epoch = 250 iters × batch 2 = 500 patches; A/C's epoch = full pass = 2750 patches (B @100ep ≈ A/C @~18ep in data seen). ## B extended to 100 epochs (2026-10-06, complete 14:59 UTC) Staged extension from the 20ep checkpoint via `scripts/run_b_100ep_stages.sh`: `--resume --nnu-skip-prep --train-epochs 50` then `100`, scoring `checkpoint_final.pth` (the last-trained model, A/C-comparable) via `--nnu-ckpt`; stage 3 additionally scored `checkpoint_best.pth` with `--skip-train`. | dice @20 | dice @50 | dice @100 (final) | dice @100 (best) | |---|---|---|---| | 0.2927 | 0.3333 | **0.3912** | 0.3912 (== final) | B improved monotonically; 32/39 rows > 0.001 @100 (vs 29/39 @20); 138.5 s/epoch stage pace. Artifacts: `results/benchmark_full/ benchmark_full_B_{50ep,100ep,100ep_best}.json`. ## B-aligned A/C (C's default; A's opt-in `--b-aligned`) Training C with `benchmark_pipelines.py` (no config flags) — and training A with `--b-aligned` — uses B's full protocol on top of the backbone (A: `Unet3D(16,4)` + LeakyReLU; C: MONAI `UNet(16→128)`), keeping the benchmark data budget (2750 train / 40 eval rows, 96³, batch 3, bf16): InstanceNorm3d(affine) + kaiming-normal(0.01) init, B's true loss (`b_dc_and_ce`) with deep supervision (4/7, 2/7, 1/7 on 1x/1/2/1/4, NN downsampled targets), SGD 1e-2 (0.99, nesterov, wd 3e-5) + PolyLR(0.9), B's augmentation + Z-score + 33% fg sampling (`src/augment_b.py`, patch-level, shared by A and C), grad clip 12, and B-style per-epoch val pseudo-dice (50 val patches, EMA 0.9/0.1) selecting `runs_bench/{A,C}/best_ema.pt`. Code: `src/unet3d.py` (norm/deep-sup/init), `src/losses.py` (b_dc_and_ce, deep_sup_loss), `src/dataset.py` (zscore/b_aug), `scripts_monai/monai_common.py`, plus the TTA fix above. A's default is its own A-native config (see the ablation section below); opt-outs: `--legacy` (pre-2026-10-07 config, approx nn-loss default; `best_legacy.pt`), `--b-loss-only` (B's loss, rest legacy; `best_bloss.pt`). From-scratch 100-epoch runs (40 held-out test rows, TTA on; A/C epoch = full pass, B epoch = 500 patches): | run (100ep, from scratch) | dice @5 | @10 | @20 | @50 | @100 | |---|---|---|---|---|---| | **A B-aligned** | 0.2017 | 0.3176 | 0.4368 | 0.4998 | **0.5417** | | **A all-default** (`--legacy --std-loss`) | 0.3161 | 0.4578 | 0.4249 | 0.5089 | **0.5508** (A record; see ablation below) | | **C B-aligned** | 0.0587 | 0.1748 | 0.4073 | 0.4396 | **0.4002** | | **C all-default** (`--legacy --std-loss`) | 0.2025 | 0.2209 | 0.0974 | 0.2872 | **0.2083** (see C ablation below) | | C b-loss-only (true B loss, rest legacy) | 0.2051 | 0.3267 | 0.2824 | 0.1710 | 0.2523 | | A legacy (BN, AdamW; re-scored, fixed TTA) | — | — | 0.3644 | — | 0.5313 | | C legacy (BN, AdamW, approx nn-loss) | 0.1889 | 0.2562 | 0.3358 | 0.1798 | 0.2649 | | B nnU-Net (reference) | — | — | 0.2927 | 0.3333 | 0.3912 | - **Both B-aligned models beat B (and their legacy counterparts) at every point ≥20ep.** A: 0.5417 vs 0.5313 legacy — the TTA fix was the big A win, the B protocol adds a smaller one on top. C: 0.4002 vs 0.2649 legacy (+0.14) and the legacy mid-training collapse is GONE — monotone 0.4073 → 0.4396 → 0.4002 (legacy: 0.3358 → 0.1798 → 0.2649). C's slow start (0.0587 @5) is the SGD/PolyLR + instance-norm warm-up. - **A 100→200ep extension** (`--resume --train-epochs 200 --eval-at "150 200"`, complete 2026-10-07 05:14 UTC, 139.9 s/epoch): @150 0.5072, @200 0.5397 — no improvement over the @100 B-aligned (0.5417); A is converged. The resume restarts PolyLR at a higher LR over the 200-ep total, costing a @150 dip; val pseudo-dice EMA kept rising (0.3622 @200) while test dice plateaued — the val-EMA proxy over-estimates later epochs. The @200 final save overwrote `best_bal.pt`/`best.pt` with the @200 model; it is 0.002 from the @100 B-aligned (within noise), so the @200 weights on disk are the best A B-aligned model. - **The loss alone does NOT fix C's instability**: b-loss-only (B's exact loss, legacy BN/AdamW/no deep-sup/no B aug) repeats the legacy dip — statistically the same trajectory as C-legacy. The full package (instance norm + deep supervision + SGD/PolyLR + B augmentation + Z-score) is what stabilizes C. - **B's best-checkpoint heuristic does not transfer**: for the B-aligned models the EMA-selected ckpt is WORSE than the final model — A's EMA-best happens to be epoch 100 (= final), but C's EMA-best (epoch 97, val-ema 0.2442) scores 0.2829 on test vs 0.4002 for the epoch-100 model. Use the final checkpoint for A/C. - pmax saturation is immediate under B-aligned training (A: 0.999 @50; no under-confidence at any point — the old pmax ~0.25 was purely the TTA bug). - Pace: A-bal ~150 s/epoch, C-bal ~175 s/epoch (patch-level B augmentation; the initial volume-level C attempt ran ~600 s/epoch and was restarted — B's SpatialTransform acts on the sampled patch, so patch-level is the faithful and fast variant). Artifacts: `results/benchmark_full/benchmark_full_{A,C}_bal_100ep.json` (+ `_per_row.jsonl`, progress PNGs), `benchmark_full_A_bal_200ep.json`; checkpoints `runs_bench/C/best.pt` (C @100 B-aligned 0.4002, C's record) / `best_ema.pt` (EMA-selected); A's B-aligned models: `runs_bench/A/ best_bal.pt` (@200, 0.5397 — the best A B-aligned model) / `best_ema_bal.pt`. ## A ablation: all-default config — now A's default (2026-10-07) With the TTA fixed, A B-aligned (0.5417 @100ep) vs A legacy (0.5313 @100ep, approx nn-loss) differs by many components at once. This run trained A with **every hyperparameter at A's default, none nnU-derived** — the fully A-native baseline, whose mid-training trajectory was never recorded (the old 100ep legacy run's @5/@10/@50 evals were scored with the buggy TTA): BatchNorm + PyTorch default init (no kaiming), ReLU, per-sample 0.5CE+0.5mean-dice loss (no deep supervision — the `--legacy` default loss is the nnU-style approx `nn_dc_and_ce`, so the run used `--std-loss` to make the loss A-native too), AdamW 3e-4 (wd 1e-4) + warmup/cosine, grad clip 5.0, percentile [0,1] input (no Z-score), legacy augmentation (flip/rot90/brightness/noise), 50% fg-centered crops (not B's 33%). Benchmark constants unchanged for comparability: 2750 train / 40 eval rows, batch 3, 96³, bf16, TTA on, eval at 5/10/20/50. (Run as `--legacy --std-loss`; since the promotion below it is A's plain default.) **Complete 2026-10-07 ~09:45 UTC** (persistent background process on GPU 0, shared with co-tenant jobs; 125.1 s/epoch, train VRAM 1.98 GB, 2.78 s/vol TTA; log `logs/bench_A_legacy_std_100ep.log`): | dice (40 held-out rows, TTA on) | @5 | @10 | @20 | @50 | @100 | |---|---|---|---|---|---| | **A all-default** (`--legacy --std-loss`) | 0.3161 | 0.4578 | 0.4249 | 0.5089 | **0.5508** (A record) | | A B-aligned | 0.2017 | 0.3176 | 0.4368 | 0.4998 | 0.5417 | - **A's own defaults beat B's protocol at every eval point** — final 0.5508 (30/40 rows > 0.001, pmax median 0.9989) vs 0.5417 B-aligned; 0.5508 is the best result on record for any pipeline (B 0.3912, C 0.4002). Loss 0.386 → 0.161, flat from ~epoch 40. The earlier "B protocol adds +0.01 for A" (0.5417 vs 0.5313) was against the legacy config with the nnU-style approx loss: the only difference between the 0.5313 model and this run is the loss, so A's per-sample 0.5CE+0.5dice alone is worth +0.0195 — and the full B package (IN + deep sup + SGD/PolyLR + B aug + Z-score + 33% fg) nets −0.009 on this backbone. Contrast C, where the same B package was worth +0.14 (0.4002 vs 0.2649) and remains C's record and default. - pmax saturation is immediate (0.81 @5 → 0.9998 @10) — no under-confidence at any point; with the TTA fix, A's A-native config is the best A result on record. - Checkpoint: the 100ep all-default model (17.6 MB). The prior legacy 100ep model (0.5313) had already been lost to a smoke-run overwrite; its number is recorded in `results/benchmark_full/A_reeval_fixed_tta.json`. **Promoted to A's default (2026-10-07).** Running `benchmark_pipelines.py` with no config flags now trains A with this A-native config and writes `runs_bench/A/best.pt` (the 0.5508 model is there); `--b-aligned` opts A into B's protocol (`best_bal.pt`), `--legacy` gives the pre-2026-10-07 A config (approx nn-loss; `best_legacy.pt`), `--b-loss-only` the B-loss ablation (`best_bloss.pt`), and deviating A knobs (`--a-leaky`, `--a-fg-*`) write `best_alt.pt` so the record checkpoint is never clobbered. C's default is unchanged (B-aligned, `runs_bench/C/best.pt`). Verified: all four A modes + C `--legacy` smoke-tested (1 epoch each, config/loss/ckpt recorded in the JSONs), and a no-op `--resume --train-epochs 100` on the new default re-scored 0.5508 exactly (30/40 rows, pmax 0.9989). ## C ablation: all-default config (2026-10-07) The mirror of A's ablation on the MONAI backbone: C trained with every hyperparameter at C's own default (its original study protocol from `scripts_monai/02_monai_train.py`, nothing nnU-derived) — BatchNorm `UNet(16→128)` with MONAI default init, LeakyReLU (C's own default act), per-sample 0.5CE+0.5mean-dice loss (WeightedDiceCELoss, no deep supervision), AdamW 3e-4 (wd 1e-4) + warmup/cosine, grad clip 5.0, percentile [0,1] input (no Z-score), legacy augmentation (flip/rot90/brightness/noise), 50% fg-centered crops. Benchmark constants unchanged: 2750 train / 40 eval rows, batch 3, 96³, bf16, TTA on, eval at 5/10/20/50. Run as `--legacy --std-loss` (C's default remains B-aligned). **Complete 2026-10-07 ~18:31 UTC** (persistent background process on GPU 0; 254.3 s/epoch, train VRAM 0.48 GB, 0.39 s/vol TTA; 1-epoch smoke first, config recorded in `benchmark_full_C_legacy_std_smoke1.json`): | dice (40 held-out rows, TTA on) | @5 | @10 | @20 | @50 | @100 | |---|---|---|---|---|---| | **C all-default** (`--legacy --std-loss`) | 0.2025 | 0.2209 | 0.0974 | 0.2872 | **0.2083** | | C B-aligned (record) | 0.0587 | 0.1748 | 0.4073 | 0.4396 | **0.4002** | | C legacy (approx nn-loss) | 0.1889 | 0.2562 | 0.3358 | 0.1798 | 0.2649 | | C b-loss-only | 0.2051 | 0.3267 | 0.2824 | 0.1710 | 0.2523 | - **The non-B package is unstable for C whatever loss it carries**: this run dips @10→@20 (0.2209 → 0.0974) and ends 0.2083 — the worst of the three legacy-family C configs (nn-loss 0.2649, b-loss-only 0.2523) — even though the TRAIN loss is smooth (0.35 → 0.15, no collapse; min 0.153 @88): the test dice oscillates, the same family as C-legacy's 0.3358 → 0.1798 @20→@50 dip. B-aligned beats it at every point ≥20ep (+0.31 @20, +0.15 @50, +0.19 @100). - **Mirror image of A**: on A's backbone the all-default config is A's best (0.5508; every A variant sits in 0.53–0.55), on C's backbone it is C's worst (0.2083; every non-B C variant sits in 0.21–0.27). The B package (instance norm + deep supervision + SGD/PolyLR + B augmentation + Z-score) is not a preference — it is what stabilizes the MONAI UNet. And A's winning loss switch (per-sample 0.5CE+0.5dice, +0.0195 for A) does not transfer: for C it is the worst legacy-family result. - Checkpoint: the @100 model (14.4 MB) is `runs_bench/C/best_legacy.pt` — it overwrote the prior C legacy nn-loss model (0.2649; number recorded in `benchmark_full_C_nnloss_100ep.json`). C's record B-aligned model (`best.pt`, 0.4002) is untouched. Artifacts: `results/benchmark_full/benchmark_full_C_legacy_std_100ep.json` (+ `_per_row.jsonl`, progress PNG). ## Resuming training (`--resume`) `--train-epochs` is the **total budget** (not epochs added). - **A / C**: resume from the latest checkpoint of the **same config mode** (model + optimizer + scheduler when present; A's B-aligned PolyLR is stateless — the LR is recomputed from the epoch index over the new total, same staged convention as B's extensions). A: `best.pt` (A-native default) / `best_bal.pt` (`--b-aligned`) / `best_legacy.pt` (`--legacy`) / `best_bloss.pt` (`--b-loss-only`); C: `best.pt` (B-aligned default) / `best_legacy.pt` / `best_bloss.pt`. Checkpoints written before 2026-10-04 carry model + epoch only and fall back to fresh optimizer/scheduler. A run is a no-op train (eval only) when the checkpoint already reached the total. - **B**: appends nnU-Net's `--c` (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. Completed runs delete `checkpoint_latest.pth` — copy `checkpoint_final.pth` → `checkpoint_latest.pth` first to resume (same state dict; used for B's 20→50→100ep stages). ## Notes - Per-pipeline defaults (since 2026-10-07): A = A-native all-defaults (the 0.5508 @100ep record; B's protocol is `--b-aligned`), C = B-aligned (the 0.4002 @100ep record; `--legacy` reverts). Checkpoint conventions: see the A and C ablation sections. - Study entrypoints match the record configs (2026-10-07): `scripts/07_train.py` (A) was already A-native all-defaults; `scripts_monai/02_monai_train.py` (C) now trains B-aligned by default (its record config) — `--legacy` reverts to the pre-2026-10-07 config. C checkpoints carry a `b_aligned` marker that 03/04 read for the Z-score input; in C's B-aligned study mode pseudo rows enter at full weight (row "w" and `--lr` unused — the same treatment Pipeline B's study gives them; per-sample down-weighting is `--legacy` only). - GPUs: 3× 80 GB available; the benchmark uses one (`--gpu`). - Budget knobs: `--train-epochs` (default 100), `--max-rows` (default 80% of split_train = 2750, also B's `--nnu-cases`), `--max-eval-rows` (default 10% of split_test = 40), `--no-tta`. - `--skip-train` is A only: `runs/round0/best.pt` (trained 2026-09-25 on the previous split — a few patients moved between train/test, small leakage caveat). - B's one-time cost scales with `--nnu-cases` (plan_and_preprocess ≈ 3.5 s/case at 24 cases with 8 preprocess procs). - Historical runs (10/20-epoch legacy A/B/C, nn-loss experiments, smoke run) remain in `results/benchmark_full/` and `results/backup_20261004/`; superseded analyses (fg-crop/CE-weight, loss parity) are omitted from this file — see the TTA-bug section for the correction.