Compare commits

..

2 commits

Author SHA1 Message Date
51abbf1150 feat: update script names and enhance iterative evaluation with table generation and plotting 2026-10-08 06:13:27 +08:00
a516408c4e Add iterative pseudo-labeling scripts for MONAI and nnU-Net
- Implemented 09_run_iterative.py for MONAI to orchestrate iterative pseudo-labeling rounds, including training, predicting, and evaluation.
- Created 01_prepare_dataset.py, 02_plan_preprocess.py, 06_pseudo_label.py, 07_train.py, 08_eval.py, and 09_run_iterative.py for nnU-Net to manage dataset preparation, planning, training, evaluation, and iterative pseudo-labeling.
- Enhanced error handling and logging throughout the scripts for better traceability.
- Added functionality to handle negative pseudo-labels and TTA options during inference.
- Included visualization of Dice scores across rounds for both MONAI and nnU-Net.
2026-10-08 06:13:13 +08:00
14 changed files with 198 additions and 155 deletions

View file

@ -243,7 +243,7 @@ config/loss/ckpt recorded in the JSONs), and a no-op
The mirror of A's ablation on the MONAI backbone: C trained with every 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 hyperparameter at C's own default (its original study protocol from
`scripts_monai/02_monai_train.py`, nothing nnU-derived) — BatchNorm `scripts_monai/07_train.py`, nothing nnU-derived) — BatchNorm
`UNet(16→128)` with MONAI default init, LeakyReLU (C's own default act), `UNet(16→128)` with MONAI default init, LeakyReLU (C's own default act),
per-sample 0.5CE+0.5mean-dice loss (WeightedDiceCELoss, no deep per-sample 0.5CE+0.5mean-dice loss (WeightedDiceCELoss, no deep
supervision), AdamW 3e-4 (wd 1e-4) + warmup/cosine, grad clip 5.0, supervision), AdamW 3e-4 (wd 1e-4) + warmup/cosine, grad clip 5.0,
@ -314,7 +314,7 @@ Artifacts: `results/benchmark_full/benchmark_full_C_legacy_std_100ep.json`
0.4002 @100ep record; `--legacy` reverts). Checkpoint conventions: see the 0.4002 @100ep record; `--legacy` reverts). Checkpoint conventions: see the
A and C ablation sections. A and C ablation sections.
- Study entrypoints match the record configs (2026-10-07): `scripts/07_train.py` - 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` (A) was already A-native all-defaults; `scripts_monai/07_train.py`
(C) now trains B-aligned by default (its record config) — `--legacy` reverts (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 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 03/04 read for the Z-score input; in C's B-aligned study mode pseudo rows

View file

@ -95,6 +95,10 @@ Run the full study:
python scripts/09_run_iterative.py --rounds 4 --gpus 3 python scripts/09_run_iterative.py --rounds 4 --gpus 3
``` ```
Per-round test results are saved to `results/round{k}_test.json`, and
`results/iterative_table.jsonl` + `results/iterative_dice.png` are rewritten
after every round's evaluation.
Individual stages are standalone, e.g.: Individual stages are standalone, e.g.:
```bash ```bash
@ -140,12 +144,12 @@ Dataset: `Dataset210_NTUH_T1C_PL`, single channel `t1c`, 2 classes
| # | Script | Purpose | | # | Script | Purpose |
|---|---|---| |---|---|---|
| 01 | `01_nnu_prepare_dataset.py` | Build/rebuild the raw dataset (symlinked **native** `imagesTr`, `labelsTr` 0/1 + `dataset.json`) from a rows jsonl | | 01 | `01_prepare_dataset.py` | Build/rebuild the raw dataset (symlinked **native** `imagesTr`, `labelsTr` 0/1 + `dataset.json`) from a rows jsonl |
| 02 | `02_nnu_plan_preprocess.py` | Plan + preprocess (`--clean`), then write subject-level `splits_final.json` | | 02 | `02_plan_preprocess.py` | Plan + preprocess (`--clean`), then write subject-level `splits_final.json` |
| 03 | `03_nnu_train.py` | Train one round via `NTUHLPLTrainer` (DDP `-num_gpus`), optional warm start | | 06 | `06_pseudo_label.py` | Multi-GPU `nnUNetv2_predict` on native pool volumes + selection gates (physical mm) |
| 04 | `04_nnu_pseudo_label.py` | Multi-GPU `nnUNetv2_predict` on native pool volumes + selection gates (physical mm) | | 07 | `07_train.py` | Train one round via `NTUHLPLTrainer` (DDP `-num_gpus`), optional warm start |
| 05 | `05_nnu_eval_test.py` | Held-out test evaluation on native volumes (probability Dice@0.5 + hard-seg Dice) | | 08 | `08_eval.py` | Held-out test evaluation on native volumes (probability Dice@0.5 + hard-seg Dice) |
| 06 | `06_nnu_run_iterative.py` | Orchestrates rounds 0…K, table + plot | | 09 | `09_run_iterative.py` | Orchestrates rounds 0…K, table + plot |
| — | `trainers/ntuh_pl_trainer.py` | `NTUHLPLTrainer`: env-driven epochs/LR + full-weight warm start | | — | `trainers/ntuh_pl_trainer.py` | `NTUHLPLTrainer`: env-driven epochs/LR + full-weight warm start |
| — | `nnu_common.py` | Shared paths, env, dataset/split helpers, native-grid selection + consistency | | — | `nnu_common.py` | Shared paths, env, dataset/split helpers, native-grid selection + consistency |
@ -210,25 +214,26 @@ across visits.
Full study from the repo root: Full study from the repo root:
```bash ```bash
python scripts_nnu/06_nnu_run_iterative.py --rounds 4 --gpus 3 python scripts_nnu/09_run_iterative.py --rounds 4 --gpus 3
``` ```
Defaults: baseline 250 epochs @ 1e-2; warm-started rounds 75 epochs @ 1e-3. Defaults: baseline 250 epochs @ 1e-2; warm-started rounds 75 epochs @ 1e-3.
Pseudo-label gates match Pipeline A (`--tau-pos 0.95`, `--min-cc-frac 0.2`, Pseudo-label gates match Pipeline A (`--tau-pos 0.95`, `--min-cc-frac 0.2`,
`--neg-frac 0.9`, volume p2–p98). Optional flags: `--no-tta`, `--neg-frac 0.9`, volume p2–p98). Optional flags: `--no-tta`,
`--no-neg-pseudo`, and the gate overrides. Outputs to `results/round{k}_test_nnu.json`, `--no-neg-pseudo`, and the gate overrides. Outputs to `results/round{k}_test_nnu.json`,
`results/iterative_table_nnu.jsonl`, `results/iterative_dice_nnu.png`; per-round `results/iterative_table_nnu.jsonl` (rewritten after every round's evaluation),
`results/iterative_dice_nnu.png`; per-round
checkpoints snapshotted to `runs_nnu/round{k}/best_nnu.pth`. checkpoints snapshotted to `runs_nnu/round{k}/best_nnu.pth`.
Individual stages: Individual stages:
```bash ```bash
python scripts_nnu/01_nnu_prepare_dataset.py --rows <rows.jsonl> python scripts_nnu/01_prepare_dataset.py --rows <rows.jsonl>
python scripts_nnu/02_nnu_plan_preprocess.py --val data/manifests/split_val.jsonl python scripts_nnu/02_plan_preprocess.py --val data/manifests/split_val.jsonl
python scripts_nnu/03_nnu_train.py --gpus 3 --epochs 250 --lr 1e-2 python scripts_nnu/07_train.py --gpus 3 --epochs 250 --lr 1e-2
python scripts_nnu/04_nnu_pseudo_label.py --pool data/manifests/unlabeled_pool.jsonl \ python scripts_nnu/06_pseudo_label.py --pool data/manifests/unlabeled_pool.jsonl \
--out data/pseudo_nnu/round1 --gpus 3 --out data/pseudo_nnu/round1 --gpus 3
python scripts_nnu/05_nnu_eval_test.py --rows data/manifests/split_test.jsonl \ python scripts_nnu/08_eval.py --rows data/manifests/split_test.jsonl \
--out results/round0_test_nnu.json --gpus 3 --out results/round0_test_nnu.json --gpus 3
``` ```
@ -278,11 +283,11 @@ directly comparable to Pipelines A and B.
| # | Script | Purpose | | # | Script | Purpose |
|---|---|---| |---|---|---|
| 01 | `01_monai_build_rows.py` | Assemble a round's training manifest (labeled + accepted pseudo rows, weighted, de-duped by key) | | 01 | `01_build_rows.py` | Assemble a round's training manifest (labeled + accepted pseudo rows, weighted, de-duped by key) |
| 02 | `02_monai_train.py` | DDP training of the MONAI UNet for one round (torchrun), full-weight warm start (same config mode), auto-resume | | 06 | `06_pseudo_label.py` | Sliding-window + TTA prediction of the remaining pool, selection gates + consistency filter |
| 03 | `03_monai_pseudo_label.py` | Sliding-window + TTA prediction of the remaining pool, selection gates + consistency filter | | 07 | `07_train.py` | DDP training of the MONAI UNet for one round (torchrun), full-weight warm start (same config mode), auto-resume |
| 04 | `04_monai_eval_test.py` | Held-out test evaluation (Dice from probability map @0.5) | | 08 | `08_eval.py` | Held-out test evaluation (Dice from probability map @0.5) |
| 05 | `05_monai_run_iterative.py` | Orchestrates rounds 0…K, table + plot | | 09 | `09_run_iterative.py` | Orchestrates rounds 0…K, table + plot |
| — | `monai_common.py` | Network (legacy + B-aligned) / losses / transform chain / inference + distributed helpers | | — | `monai_common.py` | Network (legacy + B-aligned) / losses / transform chain / inference + distributed helpers |
### Running ### Running
@ -290,7 +295,7 @@ directly comparable to Pipelines A and B.
Full study from the repo root: Full study from the repo root:
```bash ```bash
python scripts_monai/05_monai_run_iterative.py --rounds 4 --gpus 3 python scripts_monai/09_run_iterative.py --rounds 4 --gpus 3
``` ```
Defaults: B-aligned protocol (SGD 1e-2 + PolyLR, restarted per round); baseline Defaults: B-aligned protocol (SGD 1e-2 + PolyLR, restarted per round); baseline
@ -300,24 +305,25 @@ pre-2026-10-07 config (baseline 100 epochs @ 3e-4, warm-started rounds
Pipelines A/B (`--tau-pos 0.95`, `--min-cc-frac 0.2`, `--neg-frac 0.9`, volume Pipelines A/B (`--tau-pos 0.95`, `--min-cc-frac 0.2`, `--neg-frac 0.9`, volume
p2–p98). Optional flags: `--no-tta`, `--no-neg-pseudo`, `--legacy`, and the gate p2–p98). Optional flags: `--no-tta`, `--no-neg-pseudo`, `--legacy`, and the gate
overrides. Outputs to overrides. Outputs to
`results/round{k}_test_monai.json`, `results/iterative_table_monai.jsonl`, `results/round{k}_test_monai.json`, `results/iterative_table_monai.jsonl`
(rewritten after every round's evaluation),
`results/iterative_dice_monai.png`; per-round checkpoints in `results/iterative_dice_monai.png`; per-round checkpoints in
`runs_monai/round{k}/best.pt`. `runs_monai/round{k}/best.pt`.
Individual stages: Individual stages:
```bash ```bash
python scripts_monai/01_monai_build_rows.py --train data/manifests/split_train.jsonl \ python scripts_monai/01_build_rows.py --train data/manifests/split_train.jsonl \
--accepted data/pseudo_monai/round1/accepted.jsonl \ --accepted data/pseudo_monai/round1/accepted.jsonl \
--out data/pseudo_monai/round1_rows.jsonl --out data/pseudo_monai/round1_rows.jsonl
torchrun --standalone --nproc_per_node 3 scripts_monai/02_monai_train.py \ torchrun --standalone --nproc_per_node 3 scripts_monai/07_train.py \
--rows data/pseudo_monai/round1_rows.jsonl --val data/manifests/split_val.jsonl \ --rows data/pseudo_monai/round1_rows.jsonl --val data/manifests/split_val.jsonl \
--epochs 30 --batch 3 --ckpt-dir runs_monai/round1 \ --epochs 30 --batch 3 --ckpt-dir runs_monai/round1 \
--pretrained runs_monai/round0/best.pt --pretrained runs_monai/round0/best.pt
torchrun --standalone --nproc_per_node 3 scripts_monai/03_monai_pseudo_label.py \ torchrun --standalone --nproc_per_node 3 scripts_monai/06_pseudo_label.py \
--ckpt runs_monai/round1/best.pt --pool data/manifests/unlabeled_pool.jsonl \ --ckpt runs_monai/round1/best.pt --pool data/manifests/unlabeled_pool.jsonl \
--out data/pseudo_monai/round2 --already data/pseudo_monai/added_keys.jsonl --out data/pseudo_monai/round2 --already data/pseudo_monai/added_keys.jsonl
torchrun --standalone --nproc_per_node 3 scripts_monai/04_monai_eval_test.py \ torchrun --standalone --nproc_per_node 3 scripts_monai/08_eval.py \
--rows data/manifests/split_test.jsonl --ckpt runs_monai/round1/best.pt \ --rows data/manifests/split_test.jsonl --ckpt runs_monai/round1/best.pt \
--out results/round1_test_monai.json --out results/round1_test_monai.json
``` ```

View file

@ -39,6 +39,54 @@ def torchrun(nproc, script, extra):
return f"{tr} --standalone --nproc_per_node {nproc} {script} {extra}" return f"{tr} --standalone --nproc_per_node {nproc} {script} {extra}"
def build_table(args):
"""Scan per-round test results, rewrite results/iterative_table.jsonl + plot.
Called after every round's evaluation so each round's performance is saved
incrementally (survives interrupted runs).
"""
results_dir = f"{ROOT}/results"
table = []
for k in range(args.rounds + 1):
f = os.path.join(results_dir, f"round{k}_test.json")
if not os.path.exists(f):
continue
r = json.load(open(f))
row = {"round": k, "test_dice": round(r["dice"], 4), "n_test": r["n"],
"ckpt_epoch": r.get("epoch")}
pf = os.path.join(DATA, "pseudo", f"round{k}", "summary.json") if k > 0 else None
if pf and os.path.exists(pf):
s = json.load(open(pf))
row.update({"n_pos": s["n_pos"], "n_neg": s["n_neg"],
"n_rej_cons": s["n_rejected_consistency"],
"pos_vol_med_mm3": s["pos_vol_mm3"]["med"]})
sf = os.path.join(ROOT, "runs", f"round{k}", "final.pt")
row["ckpt"] = sf if os.path.exists(sf) else ""
table.append(row)
save_jsonl(table, os.path.join(results_dir, "iterative_table.jsonl"))
if not table:
return table
print(json.dumps(table, indent=1))
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
rs = [t["round"] for t in table]
ds = [t["test_dice"] for t in table]
plt.figure(figsize=(6, 4))
plt.plot(rs, ds, "o-")
plt.xlabel("pseudo-labeling round")
plt.ylabel("holdout tumor Dice")
for x, y in zip(rs, ds):
plt.annotate(f"{y:.3f}", (x, y), textcoords="offset points", xytext=(0, 8), fontsize=8)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(os.path.join(results_dir, "iterative_dice.png"), dpi=150)
except Exception as e: # noqa
print("plot failed:", repr(e))
return table
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--rounds", type=int, default=4) ap.add_argument("--rounds", type=int, default=4)
@ -67,6 +115,7 @@ def main():
f"--rows {train_f} --val {val_f} --epochs {args.base_epochs} --lr {args.lr} " f"--rows {train_f} --val {val_f} --epochs {args.base_epochs} --lr {args.lr} "
f"--batch {args.batch} --ckpt-dir runs/round0"), f"{main_log}") f"--batch {args.batch} --ckpt-dir runs/round0"), f"{main_log}")
run(f"python scripts/08_eval.py --rows {test_f} --ckpt runs/round0/best.pt", f"{main_log}") run(f"python scripts/08_eval.py --rows {test_f} --ckpt runs/round0/best.pt", f"{main_log}")
build_table(args)
for k in range(1, args.rounds + 1): for k in range(1, args.rounds + 1):
pdir = os.path.join(DATA, "pseudo/round" + str(k)) pdir = os.path.join(DATA, "pseudo/round" + str(k))
@ -91,42 +140,10 @@ def main():
f"--lr {args.round_lr} --batch {args.batch} --resume runs/round{k-1}/best.pt " f"--lr {args.round_lr} --batch {args.batch} --resume runs/round{k-1}/best.pt "
f"--ckpt-dir runs/round{k} --val-every 1"), f"{main_log}") f"--ckpt-dir runs/round{k} --val-every 1"), f"{main_log}")
run(f"python scripts/08_eval.py --rows {test_f} --ckpt runs/round{k}/best.pt", f"{main_log}") run(f"python scripts/08_eval.py --rows {test_f} --ckpt runs/round{k}/best.pt", f"{main_log}")
build_table(args)
# ---- report ---- # ---- report ----
table = [] build_table(args)
for k in list(range(args.rounds + 1)):
f = os.path.join(results_dir, f"round{k}_test.json")
if os.path.exists(f):
r = json.load(open(f))
table.append({"round": k, "test_dice": round(r["dice"], 4), "n_test": r["n"],
"ckpt_epoch": r.get("epoch")})
pf = os.path.join(DATA, f"pseudo/round{k}/summary.json") if k > 0 else None
if pf and os.path.exists(pf):
s = json.load(open(pf))
table[-1].update({"n_pos": s["n_pos"], "n_neg": s["n_neg"],
"n_rej_cons": s["n_rejected_consistency"],
"pos_vol_med_mm3": s["pos_vol_mm3"]["med"]})
sf = os.path.join(ROOT, f"runs/round{k}/final.pt")
table[-1]["ckpt"] = sf if os.path.exists(sf) else ""
save_jsonl(table, os.path.join(results_dir, "iterative_table.jsonl"))
print(json.dumps(table, indent=1))
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
rs = [t["round"] for t in table]
ds = [t["test_dice"] for t in table]
plt.figure(figsize=(6, 4))
plt.plot(rs, ds, "o-")
plt.xlabel("pseudo-labeling round")
plt.ylabel("holdout tumor Dice")
for x, y in zip(rs, ds):
plt.annotate(f"{y:.3f}", (x, y), textcoords="offset points", xytext=(0, 8), fontsize=8)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(os.path.join(results_dir, "iterative_dice.png"), dpi=150)
except Exception as e: # noqa
print("plot failed:", repr(e))
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -3,12 +3,12 @@
base = labeled patient-level train split (w=1.0) — the same train/val usage as base = labeled patient-level train split (w=1.0) — the same train/val usage as
Pipeline A: the val split is never trained on (it is the internal-validation Pipeline A: the val split is never trained on (it is the internal-validation
set). Accepted pseudo-label rows (pos + neg) are appended with w=<pseudo-weight> set). Accepted pseudo-label rows (pos + neg) are appended with w=<pseudo-weight>
(negative rows point at the zero mask written by stage 03), de-duplicated by (negative rows point at the zero mask written by stage 06), de-duplicated by
key (first occurrence wins). Every row must point at existing pimg/plabel key (first occurrence wins). Every row must point at existing pimg/plabel
niftis. niftis.
Usage: Usage:
python scripts_monai/01_monai_build_rows.py \ python scripts_monai/01_build_rows.py \
--train data/manifests/split_train.jsonl \ --train data/manifests/split_train.jsonl \
--accepted data/pseudo_monai/round1/accepted.jsonl \ --accepted data/pseudo_monai/round1/accepted.jsonl \
--pseudo-weight 0.3 --out data/pseudo_monai/round1_rows.jsonl --pseudo-weight 0.3 --out data/pseudo_monai/round1_rows.jsonl

View file

@ -1,6 +1,6 @@
"""Pseudo-label the unlabeled pool with the MONAI round model (torchrun; rank = GPU). """Pseudo-label the unlabeled pool with the MONAI round model (torchrun; rank = GPU).
torchrun --standalone --nproc_per_node 3 scripts_monai/03_monai_pseudo_label.py \ torchrun --standalone --nproc_per_node 3 scripts_monai/06_pseudo_label.py \
--ckpt runs_monai/round0/best.pt --pool data/manifests/unlabeled_pool.jsonl \ --ckpt runs_monai/round0/best.pt --pool data/manifests/unlabeled_pool.jsonl \
--out data/pseudo_monai/round1 [--already data/pseudo_monai/added_keys.jsonl] --out data/pseudo_monai/round1 [--already data/pseudo_monai/added_keys.jsonl]
@ -18,8 +18,8 @@ mask for neg), <out>/accepted.jsonl, <out>/summary.json. Per-rank shards are
flushed to <out>/part{rank}.jsonl so interrupted runs resume. flushed to <out>/part{rank}.jsonl so interrupted runs resume.
Usage: Usage:
python scripts_monai/03_monai_pseudo_label.py <same flags, single GPU> python scripts_monai/06_pseudo_label.py <same flags, single GPU>
torchrun --standalone --nproc_per_node 3 scripts_monai/03_monai_pseudo_label.py ... torchrun --standalone --nproc_per_node 3 scripts_monai/06_pseudo_label.py ...
""" """
import argparse import argparse
import os import os

View file

@ -1,6 +1,6 @@
"""MONAI DDP training entrypoint for one pseudo-labeling round (torchrun). """MONAI DDP training entrypoint for one pseudo-labeling round (torchrun).
torchrun --standalone --nproc_per_node 3 scripts_monai/02_monai_train.py \ torchrun --standalone --nproc_per_node 3 scripts_monai/07_train.py \
--rows data/pseudo_monai/round0_rows.jsonl --val data/manifests/split_val.jsonl \ --rows data/pseudo_monai/round0_rows.jsonl --val data/manifests/split_val.jsonl \
--epochs 100 --batch 3 --ckpt-dir runs_monai/round0 \ --epochs 100 --batch 3 --ckpt-dir runs_monai/round0 \
[--pretrained runs_monai/round0/best.pt] [--pretrained runs_monai/round0/best.pt]

View file

@ -1,6 +1,6 @@
"""Held-out test evaluation of a MONAI round model (torchrun; rank = GPU). """Held-out test evaluation of a MONAI round model (torchrun; rank = GPU).
torchrun --standalone --nproc_per_node 3 scripts_monai/04_monai_eval_test.py \ torchrun --standalone --nproc_per_node 3 scripts_monai/08_eval.py \
--rows data/manifests/split_test.jsonl --ckpt runs_monai/round0/best.pt \ --rows data/manifests/split_test.jsonl --ckpt runs_monai/round0/best.pt \
--out results/round0_test_monai.json [--no-tta] --out results/round0_test_monai.json [--no-tta]
@ -12,8 +12,8 @@ Per-rank results are appended to <out>_part{rank}.jsonl for resume; rank 0
merges them into <out> (json) + <out>_per_row.jsonl. merges them into <out> (json) + <out>_per_row.jsonl.
Usage: Usage:
python scripts_monai/04_monai_eval_test.py <same flags, single GPU> python scripts_monai/08_eval.py <same flags, single GPU>
torchrun --standalone --nproc_per_node 3 scripts_monai/04_monai_eval_test.py ... torchrun --standalone --nproc_per_node 3 scripts_monai/08_eval.py ...
""" """
import argparse import argparse
import os import os

View file

@ -1,5 +1,5 @@
"""Orchestrates the MONAI iterative pseudo-labeling study (parallel to """Orchestrates the MONAI iterative pseudo-labeling study (parallel to
scripts/09 and scripts_nnu/06). scripts/09 and scripts_nnu/09).
Round 0: train the MONAI UNet on the labeled patient-level train split (internal Round 0: train the MONAI UNet on the labeled patient-level train split (internal
validation = held-out patient-level val split, subject-disjoint). validation = held-out patient-level val split, subject-disjoint).
@ -15,7 +15,7 @@ Config: rounds train with the B-aligned protocol (C's record benchmark config,
--legacy (pre-2026-10-07 config: AdamW at --base-lr/--round-lr + --legacy (pre-2026-10-07 config: AdamW at --base-lr/--round-lr +
per-sample weighted Dice+CE with pseudo-row down-weighting). per-sample weighted Dice+CE with pseudo-row down-weighting).
Usage: python scripts_monai/05_monai_run_iterative.py [--rounds 4] [--gpus 3] [--legacy] Usage: python scripts_monai/09_run_iterative.py [--rounds 4] [--gpus 3] [--legacy]
""" """
import os import os
import sys import sys
@ -101,7 +101,7 @@ def main():
extra += " --no-neg" extra += " --no-neg"
for f in accepted: for f in accepted:
extra += f" --accepted {f}" extra += f" --accepted {f}"
py("01_monai_build_rows.py", extra) py("01_build_rows.py", extra)
def train(round, rows_f, epochs, lr, warmstart, val_every): def train(round, rows_f, epochs, lr, warmstart, val_every):
extra = (f"--rows {rows_f} --val {val_f} --epochs {epochs} --lr {lr} " extra = (f"--rows {rows_f} --val {val_f} --epochs {epochs} --lr {lr} "
@ -111,7 +111,7 @@ def main():
extra += f" --pretrained {warmstart}" extra += f" --pretrained {warmstart}"
if args.legacy: if args.legacy:
extra += " --legacy" extra += " --legacy"
trrun("02_monai_train.py", extra, os.path.join(logdir, f"train_r{round}.log")) trrun("07_train.py", extra, os.path.join(logdir, f"train_r{round}.log"))
def predict_pool(round, ckpt, added=None): def predict_pool(round, ckpt, added=None):
out = os.path.join(pdir_root, f"round{round}") out = os.path.join(pdir_root, f"round{round}")
@ -123,7 +123,7 @@ def main():
extra += f" --already {added}" extra += f" --already {added}"
if args.no_tta: if args.no_tta:
extra += " --no-tta" extra += " --no-tta"
trrun("03_monai_pseudo_label.py", extra, os.path.join(logdir, f"pseudo_r{round}.log")) trrun("06_pseudo_label.py", extra, os.path.join(logdir, f"pseudo_r{round}.log"))
return os.path.join(out, "accepted.jsonl") return os.path.join(out, "accepted.jsonl")
def eval_test(round, ckpt): def eval_test(round, ckpt):
@ -131,12 +131,52 @@ def main():
extra = f"--rows {test_f} --ckpt {ckpt} --out {out}" extra = f"--rows {test_f} --ckpt {ckpt} --out {out}"
if args.no_tta: if args.no_tta:
extra += " --no-tta" extra += " --no-tta"
trrun("04_monai_eval_test.py", extra, os.path.join(logdir, f"eval_r{round}.log")) trrun("08_eval.py", extra, os.path.join(logdir, f"eval_r{round}.log"))
return out return out
def best(round): def best(round):
return os.path.join(ROOT, "runs_monai", f"round{round}", "best.pt") return os.path.join(ROOT, "runs_monai", f"round{round}", "best.pt")
def build_table():
# Rewrite the per-round table + plot; called after every round's eval so
# each round's performance is saved incrementally (survives interruptions).
table = []
for k in range(args.rounds + 1):
resf = os.path.join(results_dir, f"round{k}_test_monai.json")
if not os.path.exists(resf):
continue
r = json.load(open(resf))
row = {"round": k, "test_dice": round(r["dice"], 4), "n_test": r["n"], "ckpt": best(k)}
pf = os.path.join(pdir_root, f"round{k}", "summary.json")
if k > 0 and os.path.exists(pf):
s = json.load(open(pf))
row.update({"n_pos": s["n_pos"], "n_neg": s["n_neg"],
"n_rej_cons": s["n_rejected_consistency"], "n_error": s["n_error"],
"pos_vol_med_mm3": s["pos_vol_mm3"]["med"]})
table.append(row)
save_jsonl(table, os.path.join(results_dir, "iterative_table_monai.jsonl"))
if not table:
return table
print(json.dumps(table, indent=1))
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
rs = [t["round"] for t in table]
ds = [t["test_dice"] for t in table]
plt.figure(figsize=(6, 4))
plt.plot(rs, ds, "o-")
plt.xlabel("pseudo-labeling round")
plt.ylabel("holdout tumor Dice (MONAI)")
for x, y in zip(rs, ds):
plt.annotate(f"{y:.3f}", (x, y), textcoords="offset points", xytext=(0, 8), fontsize=8)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(os.path.join(results_dir, "iterative_dice_monai.png"), dpi=150)
except Exception as e: # noqa
print("plot failed:", repr(e))
return table
# ---- round 0: baseline ---- # ---- round 0: baseline ----
rows0 = os.path.join(pdir_root, "round0_rows.jsonl") rows0 = os.path.join(pdir_root, "round0_rows.jsonl")
build_rows(rows0, []) build_rows(rows0, [])
@ -144,6 +184,7 @@ def main():
accepted_f = [predict_pool(1, best(0))] accepted_f = [predict_pool(1, best(0))]
save_jsonl(load_jsonl(accepted_f[0]), added_f) save_jsonl(load_jsonl(accepted_f[0]), added_f)
eval_test(0, best(0)) eval_test(0, best(0))
build_table()
for k in range(1, args.rounds + 1): for k in range(1, args.rounds + 1):
rows_f = os.path.join(pdir_root, f"round{k}_rows.jsonl") rows_f = os.path.join(pdir_root, f"round{k}_rows.jsonl")
@ -152,41 +193,10 @@ def main():
accepted_f.append(predict_pool(k + 1, best(k), added_f)) accepted_f.append(predict_pool(k + 1, best(k), added_f))
save_jsonl([r for f in accepted_f for r in load_jsonl(f)], added_f) save_jsonl([r for f in accepted_f for r in load_jsonl(f)], added_f)
eval_test(k, best(k)) eval_test(k, best(k))
build_table()
# ---- report (same layout as scripts/09 and scripts_nnu/06) ---- # ---- report (same layout as scripts/09 and scripts_nnu/09) ----
table = [] build_table()
for k in range(args.rounds + 1):
resf = os.path.join(results_dir, f"round{k}_test_monai.json")
if not os.path.exists(resf):
continue
r = json.load(open(resf))
row = {"round": k, "test_dice": round(r["dice"], 4), "n_test": r["n"], "ckpt": best(k)}
pf = os.path.join(pdir_root, f"round{k}", "summary.json")
if k > 0 and os.path.exists(pf):
s = json.load(open(pf))
row.update({"n_pos": s["n_pos"], "n_neg": s["n_neg"],
"n_rej_cons": s["n_rejected_consistency"], "n_error": s["n_error"],
"pos_vol_med_mm3": s["pos_vol_mm3"]["med"]})
table.append(row)
save_jsonl(table, os.path.join(results_dir, "iterative_table_monai.jsonl"))
print(json.dumps(table, indent=1))
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
rs = [t["round"] for t in table]
ds = [t["test_dice"] for t in table]
plt.figure(figsize=(6, 4))
plt.plot(rs, ds, "o-")
plt.xlabel("pseudo-labeling round")
plt.ylabel("holdout tumor Dice (MONAI)")
for x, y in zip(rs, ds):
plt.annotate(f"{y:.3f}", (x, y), textcoords="offset points", xytext=(0, 8), fontsize=8)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(os.path.join(results_dir, "iterative_dice_monai.png"), dpi=150)
except Exception as e: # noqa
print("plot failed:", repr(e))
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -7,7 +7,7 @@ preprocessing; imagesTr entries are symlinks to the native volumes, labelsTr
entries are symlinks when already 0/1 on the image grid, else nearest-warped entries are symlinks when already 0/1 on the image grid, else nearest-warped
+ binarized copies (label fix-up only). + binarized copies (label fix-up only).
Usage: python scripts_nnu/01_nnu_prepare_dataset.py --rows <rows.jsonl> Usage: python scripts_nnu/01_prepare_dataset.py --rows <rows.jsonl>
""" """
import argparse import argparse
import os import os

View file

@ -2,7 +2,7 @@
subject-level splits_final.json: val = split_val cases, train = the rest subject-level splits_final.json: val = split_val cases, train = the rest
(all base + pseudo cases), same 5-fold content so -f 0 is the one we use. (all base + pseudo cases), same 5-fold content so -f 0 is the one we use.
Usage: python scripts_nnu/02_nnu_plan_preprocess.py --val data/manifests/split_val.jsonl [--npp 8] Usage: python scripts_nnu/02_plan_preprocess.py --val data/manifests/split_val.jsonl [--npp 8]
""" """
import argparse import argparse
import os import os
@ -23,7 +23,7 @@ def main():
run(plan_preprocess_cmd(args.npp), log, env=nnu_env()) run(plan_preprocess_cmd(args.npp), log, env=nnu_env())
val_keys = [r["key"] for r in load_jsonl(args.val)] val_keys = [r["key"] for r in load_jsonl(args.val)]
all_keys = dataset_case_keys() all_keys = dataset_case_keys()
assert all_keys, "raw dataset is empty; run 01_nnu_prepare_dataset.py first" assert all_keys, "raw dataset is empty; run 01_prepare_dataset.py first"
assert set(val_keys) <= set(all_keys), f"val keys not in dataset: {set(val_keys) - set(all_keys)}" assert set(val_keys) <= set(all_keys), f"val keys not in dataset: {set(val_keys) - set(all_keys)}"
n_tr, n_va = write_splits(all_keys, val_keys) n_tr, n_va = write_splits(all_keys, val_keys)
print(f"[nnu:plan] splits_final.json written to {preproc_ds()}: train={n_tr} val={n_va}", flush=True) print(f"[nnu:plan] splits_final.json written to {preproc_ds()}: train={n_tr} val={n_va}", flush=True)

View file

@ -17,7 +17,7 @@ Writes <out>/rows.jsonl, <out>/<key>_label.nii.gz (native grid), <out>/accepted.
<out>/summary.json. <out>/summary.json.
Usage: Usage:
python scripts_nnu/04_nnu_pseudo_label.py --pool data/manifests/unlabeled_pool.jsonl \ python scripts_nnu/06_pseudo_label.py --pool data/manifests/unlabeled_pool.jsonl \
--out data/pseudo_nnu/round1 --gpus 3 --out data/pseudo_nnu/round1 --gpus 3
""" """
import argparse import argparse

View file

@ -4,7 +4,7 @@ Epochs / initial lr / warm-start checkpoint are passed to NTUHLPLTrainer via
NNU_PL_* env vars (see scripts_nnu/trainers/ntuh_pl_trainer.py). NNU_PL_* env vars (see scripts_nnu/trainers/ntuh_pl_trainer.py).
Usage: Usage:
nnUNetv2_train wrapper: python scripts_nnu/03_nnu_train.py \ nnUNetv2_train wrapper: python scripts_nnu/07_train.py \
--gpus 3 --epochs 250 --lr 1e-2 [--warmstart runs_nnu/round0/best_nnu.pth] --gpus 3 --epochs 250 --lr 1e-2 [--warmstart runs_nnu/round0/best_nnu.pth]
""" """
import argparse import argparse

View file

@ -10,7 +10,7 @@ grid, so we score directly there:
Ground truth is the row's native label. Ground truth is the row's native label.
Writes: <out> json + <out>.json -> per_row jsonl alongside. Writes: <out> json + <out>.json -> per_row jsonl alongside.
Usage: python scripts_nnu/05_nnu_eval_test.py --rows data/manifests/split_test.jsonl \ Usage: python scripts_nnu/08_eval.py --rows data/manifests/split_test.jsonl \
--out results/round0_test_nnu.json --gpus 3 --out results/round0_test_nnu.json --gpus 3
""" """
import argparse import argparse

View file

@ -8,7 +8,7 @@ Round k: dataset grows with accepted pseudo-labels (pos + neg) from rounds 1..k;
unlabeled pool (gates identical to scripts/06) and evaluate the model on unlabeled pool (gates identical to scripts/06) and evaluate the model on
the held-out patient-level test split. the held-out patient-level test split.
Usage: python scripts_nnu/06_nnu_run_iterative.py [--rounds 4] [--gpus 3] Usage: python scripts_nnu/09_run_iterative.py [--rounds 4] [--gpus 3]
""" """
import os import os
import sys import sys
@ -71,16 +71,16 @@ def main():
def prepare(rows): def prepare(rows):
f = os.path.join(pdir_root, "current_rows.jsonl") f = os.path.join(pdir_root, "current_rows.jsonl")
save_jsonl(rows, f) save_jsonl(rows, f)
py("01_nnu_prepare_dataset.py", f"--rows {f}") py("01_prepare_dataset.py", f"--rows {f}")
def plan(): def plan():
py("02_nnu_plan_preprocess.py", f"--val {val_f}") py("02_plan_preprocess.py", f"--val {val_f}")
def train(round, epochs, lr, warmstart): def train(round, epochs, lr, warmstart):
extra = f"--gpus {args.gpus} --epochs {epochs} --lr {lr} --log {os.path.join(logdir, f'nnu_train_r{round}.log')}" extra = f"--gpus {args.gpus} --epochs {epochs} --lr {lr} --log {os.path.join(logdir, f'nnu_train_r{round}.log')}"
if warmstart: if warmstart:
extra += f" --warmstart {warmstart}" extra += f" --warmstart {warmstart}"
py("03_nnu_train.py", extra) py("07_train.py", extra)
dst = os.path.join(ROOT, "runs_nnu", f"round{round}", "best_nnu.pth") dst = os.path.join(ROOT, "runs_nnu", f"round{round}", "best_nnu.pth")
os.makedirs(os.path.dirname(dst), exist_ok=True) os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(best_ckpt(), dst) shutil.copy2(best_ckpt(), dst)
@ -95,16 +95,57 @@ def main():
extra += " --no-tta" extra += " --no-tta"
if added: if added:
extra += f" --already {added_f}" extra += f" --already {added_f}"
py("04_nnu_pseudo_label.py", extra) py("06_pseudo_label.py", extra)
return os.path.join(pdir_root, f"round{round}", "accepted.jsonl") return os.path.join(pdir_root, f"round{round}", "accepted.jsonl")
def eval_test(round, out_name): def eval_test(round, out_name):
extra = (f"--rows {test_f} --out {results_dir}/{out_name} --gpus {args.gpus}") extra = (f"--rows {test_f} --out {results_dir}/{out_name} --gpus {args.gpus}")
if args.no_tta: if args.no_tta:
extra += " --no-tta" extra += " --no-tta"
py("05_nnu_eval_test.py", extra) py("08_eval.py", extra)
return os.path.join(results_dir, out_name) return os.path.join(results_dir, out_name)
def build_table():
# Rewrite the per-round table + plot; called after every round's eval so
# each round's performance is saved incrementally (survives interruptions).
table = []
for k in range(args.rounds + 1):
resf = os.path.join(results_dir, f"round{k}_test_nnu.json")
if not os.path.exists(resf):
continue
r = json.load(open(resf))
row = {"round": k, "test_dice": round(r["dice"], 4), "test_dice_hard": round(r["dice_hard"], 4),
"n_test": r["n"], "ckpt": os.path.join(ROOT, "runs_nnu", f"round{k}", "best_nnu.pth")}
pf = os.path.join(pdir_root, f"round{k}", "summary.json")
if k > 0 and os.path.exists(pf):
s = json.load(open(pf))
row.update({"n_pos": s["n_pos"], "n_neg": s["n_neg"],
"n_rej_cons": s["n_rejected_consistency"], "n_error": s["n_error"],
"pos_vol_med_mm3": s["pos_vol_mm3"]["med"]})
table.append(row)
save_jsonl(table, os.path.join(results_dir, "iterative_table_nnu.jsonl"))
if not table:
return table
print(json.dumps(table, indent=1))
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
rs = [t["round"] for t in table]
ds = [t["test_dice"] for t in table]
plt.figure(figsize=(6, 4))
plt.plot(rs, ds, "o-")
plt.xlabel("pseudo-labeling round")
plt.ylabel("holdout tumor Dice (nnU-Net)")
for x, y in zip(rs, ds):
plt.annotate(f"{y:.3f}", (x, y), textcoords="offset points", xytext=(0, 8), fontsize=8)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(os.path.join(results_dir, "iterative_dice_nnu.png"), dpi=150)
except Exception as e: # noqa
print("plot failed:", repr(e))
return table
# ---- round 0: baseline ---- # ---- round 0: baseline ----
prepare(base) prepare(base)
plan() plan()
@ -112,6 +153,7 @@ def main():
accepted_f = [predict_pool(1, None)] accepted_f = [predict_pool(1, None)]
save_jsonl(load_jsonl(accepted_f[0]), added_f) save_jsonl(load_jsonl(accepted_f[0]), added_f)
eval_test(0, "round0_test_nnu.json") eval_test(0, "round0_test_nnu.json")
build_table()
for k in range(1, args.rounds + 1): for k in range(1, args.rounds + 1):
rows = list(base) rows = list(base)
@ -127,42 +169,10 @@ def main():
accepted_f.append(predict_pool(k + 1, added_f)) accepted_f.append(predict_pool(k + 1, added_f))
save_jsonl([r for f in accepted_f for r in load_jsonl(f)], added_f) save_jsonl([r for f in accepted_f for r in load_jsonl(f)], added_f)
eval_test(k, f"round{k}_test_nnu.json") eval_test(k, f"round{k}_test_nnu.json")
build_table()
# ---- report (same layout as scripts/09) ---- # ---- report (same layout as scripts/09) ----
table = [] build_table()
for k in range(args.rounds + 1):
resf = os.path.join(results_dir, f"round{k}_test_nnu.json")
if not os.path.exists(resf):
continue
r = json.load(open(resf))
row = {"round": k, "test_dice": round(r["dice"], 4), "test_dice_hard": round(r["dice_hard"], 4),
"n_test": r["n"], "ckpt": os.path.join(ROOT, "runs_nnu", f"round{k}", "best_nnu.pth")}
pf = os.path.join(pdir_root, f"round{k}", "summary.json")
if k > 0 and os.path.exists(pf):
s = json.load(open(pf))
row.update({"n_pos": s["n_pos"], "n_neg": s["n_neg"],
"n_rej_cons": s["n_rejected_consistency"], "n_error": s["n_error"],
"pos_vol_med_mm3": s["pos_vol_mm3"]["med"]})
table.append(row)
save_jsonl(table, os.path.join(results_dir, "iterative_table_nnu.jsonl"))
print(json.dumps(table, indent=1))
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
rs = [t["round"] for t in table]
ds = [t["test_dice"] for t in table]
plt.figure(figsize=(6, 4))
plt.plot(rs, ds, "o-")
plt.xlabel("pseudo-labeling round")
plt.ylabel("holdout tumor Dice (nnU-Net)")
for x, y in zip(rs, ds):
plt.annotate(f"{y:.3f}", (x, y), textcoords="offset points", xytext=(0, 8), fontsize=8)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(os.path.join(results_dir, "iterative_dice_nnu.png"), dpi=150)
except Exception as e: # noqa
print("plot failed:", repr(e))
if __name__ == "__main__": if __name__ == "__main__":