From 023fab33769228eea728a8c71ec836f40a93c7ff Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Tue, 21 Jul 2026 13:17:19 -0400 Subject: [PATCH] ENH: Removed low-priority MLP training tutorial --- .../tutorial_09_byod_train_physicsnemo_mlp.py | 167 ------------------ .../tutorial_10_byod_eval_physicsnemo_mlp.py | 129 -------------- 2 files changed, 296 deletions(-) delete mode 100644 tutorials/tutorial_09_byod_train_physicsnemo_mlp.py delete mode 100644 tutorials/tutorial_10_byod_eval_physicsnemo_mlp.py diff --git a/tutorials/tutorial_09_byod_train_physicsnemo_mlp.py b/tutorials/tutorial_09_byod_train_physicsnemo_mlp.py deleted file mode 100644 index 4f11200..0000000 --- a/tutorials/tutorial_09_byod_train_physicsnemo_mlp.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Tutorial 9 (MLP): Train a PhysicsNeMo fully connected model for cardiac mesh stages. - -Second stage of the cardiac 4D deep-learning pipeline (Tutorials 8 -> 9 -> 10). -This tutorial is a thin driver over the reusable -:class:`physiotwin4d.WorkflowTrainPhysicsNeMoMLP` workflow: it discovers the -per-time-point SSM surfaces produced by Tutorial 8 -(``tutorial_08_byod_fit_model_to_patients.py``), writes one JSON manifest per -subject, splits the subjects into train / validation / held-out test, trains a -FullyConnected (MLP) model, and evaluates the held-out test subjects with -:class:`physiotwin4d.WorkflowInferPhysicsNeMoMLP`. - -The companion Tutorial 9 (``tutorial_09_byod_train_physicsnemo_mgn.py``) -solves the same task with a MeshGraphNet so the two architectures can be compared -directly; both map a surface point ``(x, y, z, pca_c1 ... pca_cN, stage)`` to its -displacement from the subject's SSM reference surface (the Option B convention), -where the coordinates come from the shared PCA mean shape. - -Batch size note ---------------- -Because the workflow streams samples from disk, ``batch_size`` counts -``(subject, phase)`` samples per step (not individual points as the original -inline trainer did); the MLP shuffles points *within* each batch to keep -gradient mixing. - -Bring Your Own Data -------------------- -The path constants below point at a local ``D:/PhysioTwin4D/`` layout produced by -Tutorial 8, not at the repository ``data/`` directory. Edit them to match your -own data location. Run Tutorial 8 first. - -Extra Install Required ----------------------- -PhysicsNeMo must be installed (requires Python >= 3.11):: - - pip install "physiotwin4d[physicsnemo]" -""" - -from __future__ import annotations - -import json -import logging -from pathlib import Path -from typing import Any, Optional - -from physiotwin4d import WorkflowInferPhysicsNeMoMLP, WorkflowTrainPhysicsNeMoMLP - - -def _gating_stage_from_filename(mesh_file: Path) -> float: - """Extract the normalized cardiac stage [0, 1] from a ``g0TT`` filename stem.""" - for part in mesh_file.stem.split("_"): - if part.startswith("g") and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse gating percentage from filename: {mesh_file}") - - -def _write_subject_manifest(subject_dir: Path, manifests_dir: Path) -> Optional[Path]: - """Write a per-subject manifest JSON; return its path (or None if incomplete). - - A subject needs a reference SSM surface, a PCA coefficient file, and at least - two gated-phase surfaces. Stages are parsed from the ``g0TT`` phase filenames - and written explicitly into the manifest (the workflow itself never parses - filenames). - """ - sid = subject_dir.name - ref_file = subject_dir / f"{sid}_ssm_surface.vtp" - pca_file = subject_dir / f"{sid}_ssm_pca_coefficients.json" - phase_files = sorted(subject_dir.glob(f"{sid}_g0*_ssm_surface.vtp")) - if not ref_file.exists() or not pca_file.exists() or len(phase_files) < 2: - return None - - manifest = { - "subject_id": sid, - "reference_surface": str(ref_file), - "pca_coefficients": str(pca_file), - "phases": [ - {"surface": str(pf), "stage": _gating_stage_from_filename(pf)} - for pf in phase_files - ], - } - manifests_dir.mkdir(parents=True, exist_ok=True) - manifest_path = manifests_dir / f"{sid}_manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") - return manifest_path - - -if __name__ == "__main__": - TUTORIALS_DIR = Path(__file__).resolve().parent - FITTED_MESHES_DIR = Path("D:/PhysioTwin4D/duke_data/fitted_kcl_meshes") - PCA_MEAN_VTU = Path("D:/PhysioTwin4D/kcl-heart-pca/pca-vol-kcl/pca_mean.vtu") - OUTPUT_DIR = TUTORIALS_DIR / "output" / "tutorial_09_byod_mlp" - MANIFESTS_DIR = OUTPUT_DIR / "manifests_mlp" - - EPOCHS = 10000 - BATCH_SIZE_SAMPLES = 32 # (subject, phase) samples per step; points shuffled within - LEARNING_RATE = 1.0e-3 - LAYER_SIZE = 512 - NUM_LAYERS = 6 - - # Explicit held-out splits; every other discovered subject is used for training. - TEST_SUBJECTS = ["pm0027"] - VAL_SUBJECTS: list[str] = [] - LOG_LEVEL = logging.INFO - - def run_tutorial() -> dict[str, Any]: - """Discover subjects, train an MLP, and evaluate the test split.""" - logging.basicConfig(level=LOG_LEVEL) - - manifests: dict[str, Path] = {} - for subject_dir in sorted(FITTED_MESHES_DIR.glob("pm????")): - manifest_path = _write_subject_manifest(subject_dir, MANIFESTS_DIR) - if manifest_path is not None: - manifests[subject_dir.name] = manifest_path - - if len(manifests) < 3: - raise RuntimeError( - f"Found only {len(manifests)} valid subject(s); need at least 3 " - "for a train / val / test split." - ) - - unknown = [s for s in TEST_SUBJECTS + VAL_SUBJECTS if s not in manifests] - if unknown: - raise ValueError(f"Split subjects not found: {unknown}") - - test_manifests = [manifests[s] for s in TEST_SUBJECTS] - val_manifests = [manifests[s] for s in VAL_SUBJECTS] - train_manifests = [ - p - for sid, p in manifests.items() - if sid not in TEST_SUBJECTS and sid not in VAL_SUBJECTS - ] - logging.info( - "Subject split - train: %d, val: %d, test: %d", - len(train_manifests), - len(val_manifests), - len(test_manifests), - ) - - trainer = WorkflowTrainPhysicsNeMoMLP( - train_manifests=train_manifests, - val_manifests=val_manifests, - pca_mean_mesh=PCA_MEAN_VTU, - output_directory=OUTPUT_DIR, - log_level=LOG_LEVEL, - ) - trainer.set_epochs(EPOCHS) - trainer.set_batch_size(BATCH_SIZE_SAMPLES) - trainer.set_learning_rate(LEARNING_RATE) - trainer.set_layer_size(LAYER_SIZE) - trainer.set_num_layers(NUM_LAYERS) - train_result = trainer.process() - - # Evaluate from the directory training actually used: when resuming, - # training writes to a fresh sibling directory rather than OUTPUT_DIR. - model_directory = train_result["output_directory"] - infer = WorkflowInferPhysicsNeMoMLP( - model_directory=model_directory, log_level=LOG_LEVEL - ) - eval_outputs: dict[str, Any] = {} - for sid in TEST_SUBJECTS: - eval_outputs[sid] = infer.predict( - manifests[sid], output_directory=model_directory / "eval_mlp" / sid - ) - - return {"training": train_result, "evaluation": eval_outputs} - - tutorial_results = run_tutorial() diff --git a/tutorials/tutorial_10_byod_eval_physicsnemo_mlp.py b/tutorials/tutorial_10_byod_eval_physicsnemo_mlp.py deleted file mode 100644 index d3d7859..0000000 --- a/tutorials/tutorial_10_byod_eval_physicsnemo_mlp.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -Tutorial 10 (MLP): Predict cardiac stage meshes for a subject with a trained -PhysicsNeMo fully connected (MLP) model. - -Final stage of the cardiac 4D deep-learning pipeline (Tutorials 8 -> 9 -> 10). -This tutorial is a thin driver over -:class:`physiotwin4d.WorkflowInferPhysicsNeMoMLP`. It builds a per-subject -manifest from the fitted-meshes directory and calls the workflow to predict -cardiac surfaces for one subject, loading the model trained by Tutorial 9 -(``tutorial_09_byod_train_physicsnemo_mlp.py``). - -This is a bring-your-own-data tutorial: the path constants below point at a local -``D:/PhysioTwin4D/`` layout and the Tutorial 9 run directory, not at the -repository ``data/`` directory. - -Usage (command line) --------------------- - py tutorial_10_byod_eval_physicsnemo_mlp.py pm0028 --out results/pm0028_mlp - py tutorial_10_byod_eval_physicsnemo_mlp.py pm0028 --out results/pm0028_mlp --stages 0.0 0.25 0.5 0.75 - -Arguments - subject Subject ID, e.g. pm0028 - --epoch Optional intermittent-checkpoint epoch; omit to use the final - weights stored in the main checkpoint. - --out Output directory (created if missing) - --stages RR-interval fractions to predict (0-1). If omitted, predicts at - every gated phase in the subject's manifest and computes error - statistics against the ground-truth surfaces. When supplied, only - the requested stages are predicted (no error statistics). -""" - -from __future__ import annotations - -import argparse -import json -import logging -import sys -from pathlib import Path -from typing import Any, Optional - -from physiotwin4d import WorkflowInferPhysicsNeMoMLP - -TUTORIALS_DIR = Path(__file__).resolve().parent -FITTED_MESHES_DIR = Path("D:/PhysioTwin4D/duke_data/fitted_kcl_meshes") -# Tutorial 9 run directory to evaluate (matches that trainer's OUTPUT_DIR). -MODEL_DIR = TUTORIALS_DIR / "output" / "tutorial_09_byod_mlp" - -DEFAULT_SUBJECT = "pm0027" -DEFAULT_OUT_DIR = MODEL_DIR / "tutorial_10_byod_mlp" / DEFAULT_SUBJECT - - -def _gating_stage_from_filename(mesh_file: Path) -> float: - for part in mesh_file.stem.split("_"): - if part.startswith("g") and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse gating percentage from filename: {mesh_file}") - - -def _write_subject_manifest(subject: str, out_dir: Path) -> Path: - """Build a manifest for *subject* from the fitted-meshes directory.""" - subject_dir = FITTED_MESHES_DIR / subject - ref_file = subject_dir / f"{subject}_ssm_surface.vtp" - pca_file = subject_dir / f"{subject}_ssm_pca_coefficients.json" - phase_files = sorted(subject_dir.glob(f"{subject}_g0*_ssm_surface.vtp")) - for required in (ref_file, pca_file): - if not required.exists(): - sys.exit(f"Missing: {required}") - if not phase_files: - sys.exit(f"No gated phase files found in {subject_dir}") - - manifest = { - "subject_id": subject, - "reference_surface": str(ref_file), - "pca_coefficients": str(pca_file), - "phases": [ - {"surface": str(pf), "stage": _gating_stage_from_filename(pf)} - for pf in phase_files - ], - } - out_dir.mkdir(parents=True, exist_ok=True) - manifest_path = out_dir / f"{subject}_manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") - return manifest_path - - -def predict( - subject: str, - out_dir: Path, - epoch: Optional[int] = None, - stages: Optional[list[float]] = None, -) -> dict[str, Any]: - """Predict cardiac surfaces for *subject* using the trained MLP.""" - manifest_path = _write_subject_manifest(subject, out_dir) - infer = WorkflowInferPhysicsNeMoMLP(model_directory=MODEL_DIR, epoch=epoch) - return infer.predict(manifest_path, stages=stages, output_directory=out_dir) - - -def run_tutorial() -> dict[str, Any]: - """Tutorial / test entry point: evaluate DEFAULT_SUBJECT with the final weights.""" - return predict(DEFAULT_SUBJECT, DEFAULT_OUT_DIR) - - -def main() -> None: - ap = argparse.ArgumentParser( - description="Predict cardiac stage meshes for one subject with an MLP.", - ) - ap.add_argument("subject", help="Subject ID, e.g. pm0028") - ap.add_argument( - "--epoch", type=int, default=None, help="Checkpoint epoch (default: final)" - ) - ap.add_argument("--out", type=Path, required=True, help="Output directory") - ap.add_argument( - "--stages", - type=float, - nargs="+", - metavar="FRAC", - help="RR-interval fractions to predict (omit for per-phase error stats).", - ) - args = ap.parse_args() - predict(args.subject, args.out, epoch=args.epoch, stages=args.stages) - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - if len(sys.argv) > 1: - main() - else: - # Tutorial / test entry point (no CLI arguments) - tutorial_results = run_tutorial()