Add new regression and ensemble experiment configurations for V2-M and OrthoBridge

- Introduced multiple regression experiment configurations targeting vf_md, including:
  - cd_solo_reg_set.json: CD tower only regression setup.
  - img_solo_reg_set.json: Image tower only regression setup.
  - reg_head_epoch_sweep.json: Baseline regression sweeps at different epochs (50, 75, 100).
  - reg_head_set.json: Various regression setups including baseline and OrthoBridge configurations.
  - single_eye_reg.json: Single-eye regression setup for worst-eye aggregation analysis.

- Added ensemble configurations for OrthoBridge with different inner bridges:
  - ortho_alts_ensemble.json: Ensemble tests with ConcatBridge, PairwiseAdditiveBridge, and GatedAdditiveBridge.
  - ortho_alts_tritower.json: Tritower tests with the same inner bridges.

- Created V2-M specific configurations:
  - baseline_reg_nt50.json: Regression baseline with V2-M backbone.
  - geom_vec_gt.json and geom_vec_unet.json: Geometry vector injection experiments with V2-M.
  - single_l1_bridges.json: Single-eye ensemble experiments with various bridge types.
  - tritower_geom_gt.json: Tritower setup with GT contour-rasterized masks.

- Promoted existing experiments to higher repetitions for robustness.
This commit is contained in:
rpotter6298
2026-06-11 15:08:20 +02:00
parent 32a801a572
commit 280060db82
343 changed files with 8558 additions and 57747 deletions
+61 -18
View File
@@ -21,6 +21,7 @@ from pathlib import Path
from typing import Any
import numpy as np
import torch
from torch.utils.data import DataLoader
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -148,7 +149,13 @@ def _balanced_sampler(shell: LoaderShell):
# Fold runner
# ---------------------------------------------------------------------------
def run_fold(fold: int, splits, cfg: dict, data, num_classes: int, device) -> dict:
def run_fold(fold: int, splits, cfg: dict, data, num_classes: int, device):
"""Train + evaluate one fold.
Returns (fold_result, fold_preds, towers, stage_models). The latter two are
handy for opt-in artefact saving (e.g. checkpoint dumps for explainability
runs) without forcing the orchestrator to know about every saved tensor.
"""
seed_everything(cfg["seed"] + fold * 100)
split = splits[fold]
label_filter = cfg.get("label_filter", None)
@@ -210,7 +217,7 @@ def run_fold(fold: int, splits, cfg: dict, data, num_classes: int, device) -> di
# head stages are handled inside fusion.run
return fold_result, fold_preds
return fold_result, fold_preds, towers, stage_models
# ---------------------------------------------------------------------------
@@ -264,6 +271,7 @@ def main():
eval_stage = cfg.get("eval_stage", "hb")
save_predictions = cfg.get("save_predictions", False)
save_features = cfg.get("save_features", False)
save_checkpoints = cfg.get("save_checkpoints", False)
fold_results = []
eval_stage_preds = [] # list[dict] — one per fold, only for eval_stage
all_phase_preds: dict[str, list[dict]] = {} # phase → list[dict] across folds
@@ -274,8 +282,21 @@ def main():
n_train = split.train[group_col].nunique() if group_col else len(split.train)
print(f"\n── fold {fold+1}/{cfg.get('folds', 5)} train_groups={n_train} ──",
flush=True)
result, fold_preds = run_fold(fold, splits, cfg, data, num_classes, device)
result, fold_preds, towers, stage_models = run_fold(
fold, splits, cfg, data, num_classes, device,
)
fold_results.append(result)
if save_checkpoints:
ckpt_dir = out_dir / "checkpoints" / f"fold{fold}"
ckpt_dir.mkdir(parents=True, exist_ok=True)
for name, mod in towers.items():
torch.save(mod.state_dict(), ckpt_dir / f"tower_{name}.pt")
for name, mod in stage_models.items():
# Skip non-Module entries (defensive); only nn.Modules have state_dict
if hasattr(mod, "state_dict"):
torch.save(mod.state_dict(), ckpt_dir / f"stage_{name}.pt")
print(f" Checkpoints saved: {ckpt_dir}", flush=True)
if save_predictions and eval_stage in fold_preds:
eval_stage_preds.append(fold_preds[eval_stage])
if save_features:
@@ -283,26 +304,46 @@ def main():
if pdata.get("val_z") is None:
continue
all_phase_preds.setdefault(ph, []).append(pdata)
# Look up the eval stage's primary metric name (set by the stage runner).
primary_name = result.get(f"{eval_stage}_val_primary_name", "auc")
val_primary = result.get(f"{eval_stage}_val_{primary_name}", float("nan"))
test_primary = result.get(f"{eval_stage}_test_{primary_name}", float("nan"))
print(
f" fold{fold+1} DONE"
f" val_auc={result.get(f'{eval_stage}_val_auc', float('nan')):.4f}"
f" test_auc={result.get(f'{eval_stage}_test_auc', float('nan')):.4f}",
f" val_{primary_name}={val_primary:.4f}"
f" test_{primary_name}={test_primary:.4f}",
flush=True,
)
if fold_results:
val_aucs = [r.get(f"{eval_stage}_val_auc", float("nan")) for r in fold_results]
test_aucs = [r.get(f"{eval_stage}_test_auc", float("nan")) for r in fold_results]
val_aucs = [v for v in val_aucs if not np.isnan(v)]
test_aucs = [v for v in test_aucs if not np.isnan(v)]
# Resolve primary metric name from first valid fold result.
primary_name = next(
(r.get(f"{eval_stage}_val_primary_name", "auc") for r in fold_results
if r.get(f"{eval_stage}_val_primary_name") is not None),
"auc",
)
val_primaries = [r.get(f"{eval_stage}_val_{primary_name}", float("nan"))
for r in fold_results]
test_primaries = [r.get(f"{eval_stage}_test_{primary_name}", float("nan"))
for r in fold_results]
val_primaries = [v for v in val_primaries if not np.isnan(v)]
test_primaries = [v for v in test_primaries if not np.isnan(v)]
summary = {
"run_name": cfg["run_name"],
"eval_stage": eval_stage,
"config": cfg,
"mean_val_auc": float(np.mean(val_aucs)) if val_aucs else float("nan"),
"std_val_auc": float(np.std(val_aucs)) if val_aucs else float("nan"),
"mean_test_auc": float(np.mean(test_aucs)) if test_aucs else float("nan"),
"std_test_auc": float(np.std(test_aucs)) if test_aucs else float("nan"),
"run_name": cfg["run_name"],
"eval_stage": eval_stage,
"primary_metric": primary_name,
"config": cfg,
# Canonical primary-metric stats
f"mean_val_{primary_name}": float(np.mean(val_primaries)) if val_primaries else float("nan"),
f"std_val_{primary_name}": float(np.std(val_primaries)) if val_primaries else float("nan"),
f"mean_test_{primary_name}": float(np.mean(test_primaries)) if test_primaries else float("nan"),
f"std_test_{primary_name}": float(np.std(test_primaries)) if test_primaries else float("nan"),
# Backward-compat aliases so existing analysis tooling (summarize_run.py,
# compare_grid.py) still reads correctly for classification runs.
"mean_val_auc": float(np.mean(val_primaries)) if val_primaries else float("nan"),
"std_val_auc": float(np.std(val_primaries)) if val_primaries else float("nan"),
"mean_test_auc": float(np.mean(test_primaries)) if test_primaries else float("nan"),
"std_test_auc": float(np.std(test_primaries)) if test_primaries else float("nan"),
"elapsed_s": round(time.time() - t0, 1),
"fold_results": fold_results,
}
@@ -310,8 +351,10 @@ def main():
summary_path = out_dir / "summary.json"
summary_path.write_text(json.dumps(summary, indent=2))
print(f"\n{'='*60}", flush=True)
print(f"Val AUC: {summary['mean_val_auc']:.4f} ± {summary['std_val_auc']:.4f}", flush=True)
print(f"Test AUC: {summary['mean_test_auc']:.4f} ± {summary['std_test_auc']:.4f}", flush=True)
print(f"Val {primary_name}: {summary[f'mean_val_{primary_name}']:.4f} ± "
f"{summary[f'std_val_{primary_name}']:.4f}", flush=True)
print(f"Test {primary_name}: {summary[f'mean_test_{primary_name}']:.4f} ± "
f"{summary[f'std_test_{primary_name}']:.4f}", flush=True)
print(f"Saved: {summary_path}", flush=True)
if save_predictions and eval_stage_preds: