From 29eea6f22c727fdd040ab02def4368f48334e437 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 26 Jun 2026 19:46:51 +0900 Subject: [PATCH 01/44] feat: add --w-topo override to multiseed driver for topology ablation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_backend_multiseed.py に loss.w_topo を上書きする任意 CLI を追加し、 topology ablation(w_topo=0 等)をマルチシード再現と同じ経路で実行可能にする。 Co-authored-by: Cursor --- experiments/run_backend_multiseed.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/experiments/run_backend_multiseed.py b/experiments/run_backend_multiseed.py index 1835699..4d9161a 100644 --- a/experiments/run_backend_multiseed.py +++ b/experiments/run_backend_multiseed.py @@ -267,13 +267,14 @@ def run_one( epochs: int, out_base: str, progress_csv: str, + w_topo: float | None = None, ) -> None: cfg = load_config(base_config_name, project_root=REPO_ROOT) config_id = f"backend_{backend}_seed{seed}" topo_cfg = cfg.get("model", {}).get("topology_loss", {}) ellphi_diff = bool(topo_cfg.get("ellphi_differentiable", True)) - overrides = { + overrides: dict[str, Any] = { "meta": {"config_id": config_id}, "training": {"epochs": epochs}, "data": {"seed": seed}, @@ -285,6 +286,8 @@ def run_one( }, "outputs": {"base_dir": out_base}, } + if w_topo is not None: + overrides["loss"] = {"w_topo": float(w_topo)} cfg = deep_update(cfg, overrides) before = set(glob.glob(os.path.join(out_base, f"{config_id}_*"))) @@ -346,6 +349,12 @@ def parse_args() -> argparse.Namespace: "progress_summary.csv." ), ) + p.add_argument( + "--w-topo", + type=float, + default=None, + help="Override loss.w_topo (e.g. 0 for topology ablation).", + ) return p.parse_args() @@ -387,6 +396,7 @@ def main() -> None: epochs=args.epochs, out_base=args.out_base, progress_csv=progress_csv, + w_topo=args.w_topo, ) write_backend_stats(progress_csv, stats_csv) From 1197c419123e283fb735ee97d2a9f5dd49150539 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Sat, 27 Jun 2026 13:01:41 +0900 Subject: [PATCH 02/44] Add paper experiment scripts for reproduce_1week_tuned protocol - evaluate_paper_protocol.py: ellphi DBSCAN inference (MCC/G-Mean/W-Dist) with val-tuned eps/min_samples - evaluate_paper_baselines.py: Phase 3 baselines (Euclidean DBSCAN, IsolationForest, LOF, ADBSCAN) - aggregate_paper_results.py: build summary_for_paper.csv + MANIFEST.md - ellphi_postfix_autopipeline.sh: pilot -> fixed 12ep x 5seed main run (paper_reproduce_1week_tuned) - reproduce_ellphi_main.yaml: ellphi main config Migration of dxs0 work to dxs1. Co-authored-by: Cursor --- configs/reproduce_ellphi_main.yaml | 48 ++ experiments/aggregate_paper_results.py | 441 ++++++++++++++++++ experiments/ellphi_postfix_autopipeline.sh | 303 ++++++++++++ experiments/evaluate_paper_baselines.py | 514 +++++++++++++++++++++ experiments/evaluate_paper_protocol.py | 407 ++++++++++++++++ 5 files changed, 1713 insertions(+) create mode 100644 configs/reproduce_ellphi_main.yaml create mode 100644 experiments/aggregate_paper_results.py create mode 100755 experiments/ellphi_postfix_autopipeline.sh create mode 100644 experiments/evaluate_paper_baselines.py create mode 100644 experiments/evaluate_paper_protocol.py diff --git a/configs/reproduce_ellphi_main.yaml b/configs/reproduce_ellphi_main.yaml new file mode 100644 index 0000000..b33dbe3 --- /dev/null +++ b/configs/reproduce_ellphi_main.yaml @@ -0,0 +1,48 @@ +# Post-fix ellphi multiseed main run (same hyperparams as reproduce.yaml; lighter viz I/O). +# Pilot calibration uses reproduce.yaml; this config is for the 5-seed main phase. + +meta: + config_id: "reproduce_ellphi_main" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + +loss: + w_class: 1.0 + w_topo: 0.1 + w_aniso: 0.01099204345474479 + w_size: 0.0055785823086202556 + pos_weight: 1.0 + aniso_mode: "linear" + +training: + lr: 0.0004897466143769238 + epochs: 50 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + use_amp: true + +data: + max_points: 200 + num_outliers: 20 + seed: 42 + test_size: 1000 + num_workers: 4 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" diff --git a/experiments/aggregate_paper_results.py b/experiments/aggregate_paper_results.py new file mode 100644 index 0000000..00a2cca --- /dev/null +++ b/experiments/aggregate_paper_results.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +Aggregate paper Table~\\ref{tab:comparison} metrics into ``summary_for_paper.csv``. + +Reads test-split DBSCAN metrics (``logs/paper_metrics_test.json`` from +``evaluate_paper_protocol.py``) for proposed / ablation runs, and +``summary_baselines.csv`` from Phase 3. + +Usage:: + + uv run python experiments/aggregate_paper_results.py \\ + --proposed-dir outputs/paper_reproduce_1week_tuned \\ + --wtopo0-dir outputs/paper_wtopo0 \\ + --baselines-csv outputs/paper_baselines/summary_baselines.csv + +Writes ``summary_for_paper.csv`` and ``MANIFEST.md`` under ``--out-dir`` +(default: ``--proposed-dir``). +""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Sequence + +import numpy as np + +from tda_ml.supervised_diagnostics import git_revision + +REPO_ROOT = Path(__file__).resolve().parents[1] +PAPER_SEEDS = [42, 123, 456, 789, 1024] + +SUMMARY_COLUMNS = [ + "method", + "mcc_mean", + "mcc_std", + "gmean_mean", + "gmean_std", + "wdist_mean", + "wdist_std", + "notes", +] + +METHOD_ORDER = [ + "proposed_w_topo_pos", + "proposed_w_topo_0", + "euclidean_dbscan", + "isolation_forest", + "lof", + "adbscan", +] + +# ``evaluate_paper_protocol`` writes SplitMetrics fields (mcc, gmean, wdist). +# Mac-side stubs may use alternate names — try all aliases. +MCC_KEYS = ("mcc", "test_mcc", "mcc_mean", "mean_mcc") +GMEAN_KEYS = ("gmean", "g_mean", "test_gmean", "gmean_mean", "mean_gmean") +WDIST_KEYS = ("wdist", "w_dist", "test_wdist", "wdist_mean", "mean_wdist", "w_dist_mean") + + +@dataclass +class SeedPaperMetrics: + seed: int | None + run_dir: Path + metrics_path: Path + mcc: float + gmean: float + wdist: float + n_clouds: int | None + dbscan_eps: float | None + dbscan_min_samples: int | None + backend: str | None + source_revision: str | None + + +def sample_std(values: Sequence[float]) -> float: + arr = np.asarray(values, dtype=np.float64) + if arr.size < 2: + return 0.0 + return float(np.std(arr, ddof=1)) + + +def _first_key(payload: dict[str, Any], keys: Sequence[str], *, label: str) -> float: + for key in keys: + if key in payload and payload[key] is not None: + return float(payload[key]) + raise KeyError(f"Missing {label} in JSON (tried {list(keys)}); keys={sorted(payload)}") + + +def load_paper_metrics_json(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("split") not in (None, "test"): + raise ValueError(f"Expected test-split metrics in {path}; got split={payload.get('split')!r}") + return payload + + +def parse_seed_paper_metrics(run_dir: Path, metrics_path: Path) -> SeedPaperMetrics: + payload = load_paper_metrics_json(metrics_path) + manifest_path = run_dir / "logs" / "run_manifest.json" + seed: int | None = None + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("seed") is not None: + seed = int(manifest["seed"]) + + return SeedPaperMetrics( + seed=seed, + run_dir=run_dir.resolve(), + metrics_path=metrics_path.resolve(), + mcc=_first_key(payload, MCC_KEYS, label="mcc"), + gmean=_first_key(payload, GMEAN_KEYS, label="gmean"), + wdist=_first_key(payload, WDIST_KEYS, label="wdist"), + n_clouds=int(payload["n_clouds"]) if payload.get("n_clouds") is not None else None, + dbscan_eps=float(payload["dbscan_eps"]) if payload.get("dbscan_eps") is not None else None, + dbscan_min_samples=( + int(payload["dbscan_min_samples"]) if payload.get("dbscan_min_samples") is not None else None + ), + backend=str(payload["backend"]) if payload.get("backend") is not None else None, + source_revision=str(payload["source_revision"]) if payload.get("source_revision") else None, + ) + + +def discover_run_dirs(out_base: Path) -> list[Path]: + """Return run directories with ``logs/paper_metrics_test.json``.""" + found: list[Path] = [] + progress = out_base / "progress_summary.csv" + if progress.is_file(): + with progress.open(newline="", encoding="utf-8") as f: + for row in csv.DictReader(f): + if row.get("backend") not in (None, "", "ellphi"): + continue + run_dir = Path(row["run_dir"]) + if (run_dir / "logs" / "paper_metrics_test.json").is_file(): + found.append(run_dir.resolve()) + + if not found: + for metrics_path in sorted(out_base.glob("backend_ellphi_seed*/logs/paper_metrics_test.json")): + found.append(metrics_path.parent.parent.resolve()) + for metrics_path in sorted(out_base.glob("reproduce_*/logs/paper_metrics_test.json")): + found.append(metrics_path.parent.parent.resolve()) + + # Deduplicate while preserving order + seen: set[Path] = set() + unique: list[Path] = [] + for rd in found: + if rd not in seen: + seen.add(rd) + unique.append(rd) + return unique + + +def aggregate_seed_metrics( + seeds: list[SeedPaperMetrics], + *, + method: str, + notes: str, +) -> dict[str, Any]: + if not seeds: + raise ValueError(f"No seed metrics for method={method!r}") + mccs = [s.mcc for s in seeds] + gmeans = [s.gmean for s in seeds] + wdist = [s.wdist for s in seeds] + n_clouds = seeds[0].n_clouds + cloud_note = f"test n_clouds={n_clouds} per seed" if n_clouds is not None else "test split" + return { + "method": method, + "mcc_mean": float(np.mean(mccs)), + "mcc_std": sample_std(mccs), + "gmean_mean": float(np.mean(gmeans)), + "gmean_std": sample_std(gmeans), + "wdist_mean": float(np.mean(wdist)), + "wdist_std": sample_std(wdist), + "notes": notes or f"{len(seeds)} seeds; DBSCAN test metrics; {cloud_note}", + } + + +def load_proposed_row( + out_base: Path, + *, + method: str, + default_notes: str, + expected_seeds: Sequence[int], +) -> tuple[dict[str, Any] | None, list[SeedPaperMetrics], list[str]]: + warnings: list[str] = [] + run_dirs = discover_run_dirs(out_base) + if not run_dirs: + warnings.append(f"{method}: no run dirs with paper_metrics_test.json under {out_base}") + return None, [], warnings + + per_seed: list[SeedPaperMetrics] = [] + for run_dir in run_dirs: + metrics_path = run_dir / "logs" / "paper_metrics_test.json" + try: + per_seed.append(parse_seed_paper_metrics(run_dir, metrics_path)) + except (KeyError, ValueError, json.JSONDecodeError) as exc: + warnings.append(f"{method}: skip {run_dir}: {exc}") + + if not per_seed: + warnings.append(f"{method}: no parseable paper_metrics_test.json under {out_base}") + return None, [], warnings + + found_seeds = {s.seed for s in per_seed if s.seed is not None} + missing = [s for s in expected_seeds if s not in found_seeds] + if missing: + warnings.append(f"{method}: missing seeds {missing} (found {sorted(found_seeds)})") + + row = aggregate_seed_metrics(per_seed, method=method, notes=default_notes) + return row, per_seed, warnings + + +def load_baselines_rows(path: Path) -> list[dict[str, Any]]: + if not path.is_file(): + raise FileNotFoundError(f"Baselines CSV not found: {path}") + with path.open(newline="", encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + for row in rows: + for col in SUMMARY_COLUMNS: + if col not in row: + row[col] = "" + return rows + + +def write_summary_csv(path: Path, rows: Sequence[dict[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=SUMMARY_COLUMNS) + writer.writeheader() + for row in rows: + writer.writerow({col: row.get(col, "") for col in SUMMARY_COLUMNS}) + + +def format_pm(mean: float, std: float, digits: int = 3) -> str: + return f"{mean:.{digits}f} ± {std:.{digits}f}" + + +def write_manifest( + path: Path, + *, + summary_csv: Path, + proposed_dir: Path, + wtopo0_dir: Path | None, + baselines_csv: Path, + rows: Sequence[dict[str, Any]], + proposed_seeds: list[SeedPaperMetrics], + wtopo0_seeds: list[SeedPaperMetrics], + warnings: Sequence[str], +) -> None: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + lines = [ + "# Paper experiment manifest", + "", + f"- Generated: {now}", + f"- Git HEAD: `{git_revision(REPO_ROOT)}`", + f"- Summary CSV: `{summary_csv}`", + f"- Proposed runs: `{proposed_dir}`", + f"- w_topo=0 ablation: `{wtopo0_dir}`" if wtopo0_dir else "- w_topo=0 ablation: (not provided)", + f"- Baselines CSV: `{baselines_csv}`", + "", + "## Metrics source", + "", + "- Proposed / ablation: `evaluate_paper_protocol.py` → `logs/paper_metrics_test.json`", + " (keys: `mcc`, `gmean`, `wdist` from DBSCAN test evaluation)", + "- Baselines: `evaluate_paper_baselines.py` → `summary_baselines.csv`", + "", + "## summary_for_paper.csv", + "", + "| method | MCC | G-Mean | W-Dist | notes |", + "|--------|-----|--------|--------|-------|", + ] + + for row in rows: + method = row.get("method", "") + if not row.get("mcc_mean"): + lines.append(f"| {method} | — | — | — | pending |") + continue + lines.append( + f"| {method} | {format_pm(float(row['mcc_mean']), float(row['mcc_std']))} " + f"| {format_pm(float(row['gmean_mean']), float(row['gmean_std']))} " + f"| {format_pm(float(row['wdist_mean']), float(row['wdist_std']))} " + f"| {row.get('notes', '')} |" + ) + + def _seed_section(title: str, seeds: Sequence[SeedPaperMetrics]) -> list[str]: + if not seeds: + return [f"## {title}", "", "(not available)", ""] + out = [f"## {title}", ""] + for s in sorted(seeds, key=lambda x: (x.seed is None, x.seed or 0)): + hparam = "" + if s.dbscan_eps is not None and s.dbscan_min_samples is not None: + hparam = f" eps={s.dbscan_eps}, min_samples={s.dbscan_min_samples}" + seed_label = s.seed if s.seed is not None else "?" + out.append( + f"- seed {seed_label}: `{s.run_dir}` — " + f"MCC={s.mcc:.4f}, G-Mean={s.gmean:.4f}, W-Dist={s.wdist:.4f}{hparam}" + ) + out.append("") + return out + + lines.extend(_seed_section("Proposed per-seed (w_topo > 0)", proposed_seeds)) + lines.extend(_seed_section("Ablation per-seed (w_topo = 0)", wtopo0_seeds)) + + if warnings: + lines.append("## Warnings") + lines.append("") + for w in warnings: + lines.append(f"- {w}") + lines.append("") + + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--proposed-dir", + type=Path, + default=REPO_ROOT / "outputs" / "paper_reproduce_1week_tuned", + help="Main proposed runs (12ep × 5 seed, ellphi).", + ) + p.add_argument( + "--wtopo0-dir", + type=Path, + default=REPO_ROOT / "outputs" / "paper_wtopo0", + help="w_topo=0 ablation output tree (optional until Phase 2 completes).", + ) + p.add_argument( + "--baselines-csv", + type=Path, + default=REPO_ROOT / "outputs" / "paper_baselines" / "summary_baselines.csv", + ) + p.add_argument( + "--out-dir", + type=Path, + default=None, + help="Write summary_for_paper.csv and MANIFEST.md here (default: --proposed-dir).", + ) + p.add_argument("--seeds", type=int, nargs="+", default=PAPER_SEEDS) + p.add_argument( + "--skip-wtopo0", + action="store_true", + help="Do not require w_topo=0 ablation row.", + ) + p.add_argument( + "--require-proposed", + action="store_true", + help="Exit non-zero if proposed runs are missing.", + ) + return p.parse_args() + + +def main() -> int: + args = parse_args() + proposed_dir = args.proposed_dir.resolve() + wtopo0_dir = args.wtopo0_dir.resolve() if args.wtopo0_dir else None + baselines_csv = args.baselines_csv.resolve() + out_dir = (args.out_dir or proposed_dir).resolve() + warnings: list[str] = [] + + proposed_row, proposed_seeds, w1 = load_proposed_row( + proposed_dir, + method="proposed_w_topo_pos", + default_notes="proposed; ellphi DBSCAN test metrics; val-tuned eps/min_samples per seed", + expected_seeds=args.seeds, + ) + warnings.extend(w1) + + wtopo0_row: dict[str, Any] | None = None + wtopo0_seeds: list[SeedPaperMetrics] = [] + if not args.skip_wtopo0 and wtopo0_dir is not None: + wtopo0_row, wtopo0_seeds, w2 = load_proposed_row( + wtopo0_dir, + method="proposed_w_topo_0", + default_notes="ablation w_topo=0; ellphi DBSCAN test metrics; val-tuned eps/min_samples per seed", + expected_seeds=args.seeds, + ) + warnings.extend(w2) + elif not args.skip_wtopo0: + warnings.append("proposed_w_topo_0: --wtopo0-dir not set") + + baseline_rows = load_baselines_rows(baselines_csv) + baseline_by_method = {r["method"]: r for r in baseline_rows} + + rows_by_method: dict[str, dict[str, Any]] = {} + if proposed_row: + rows_by_method["proposed_w_topo_pos"] = proposed_row + if wtopo0_row: + rows_by_method["proposed_w_topo_0"] = wtopo0_row + for method in ("euclidean_dbscan", "isolation_forest", "lof", "adbscan"): + if method in baseline_by_method: + rows_by_method[method] = baseline_by_method[method] + + ordered_rows: list[dict[str, Any]] = [] + for method in METHOD_ORDER: + if method in rows_by_method: + ordered_rows.append(rows_by_method[method]) + else: + warnings.append(f"metrics pending: {method}") + ordered_rows.append({"method": method, "notes": "pending"}) + + summary_path = out_dir / "summary_for_paper.csv" + manifest_path = out_dir / "MANIFEST.md" + write_summary_csv(summary_path, ordered_rows) + write_manifest( + manifest_path, + summary_csv=summary_path, + proposed_dir=proposed_dir, + wtopo0_dir=wtopo0_dir if not args.skip_wtopo0 else None, + baselines_csv=baselines_csv, + rows=ordered_rows, + proposed_seeds=proposed_seeds, + wtopo0_seeds=wtopo0_seeds, + warnings=warnings, + ) + + print(f"Wrote {summary_path}") + print(f"Wrote {manifest_path}") + for row in ordered_rows: + if row.get("mcc_mean"): + print( + f" {row['method']}: MCC={float(row['mcc_mean']):.4f}±{float(row['mcc_std']):.4f} " + f"G-Mean={float(row['gmean_mean']):.4f}±{float(row['gmean_std']):.4f} " + f"W-Dist={float(row['wdist_mean']):.4f}±{float(row['wdist_std']):.4f}" + ) + else: + print(f" {row['method']}: pending") + + if warnings: + print("\nWarnings:") + for w in warnings: + print(f" - {w}") + + if args.require_proposed and proposed_row is None: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/ellphi_postfix_autopipeline.sh b/experiments/ellphi_postfix_autopipeline.sh new file mode 100755 index 0000000..07b9db5 --- /dev/null +++ b/experiments/ellphi_postfix_autopipeline.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# Wait for 12ep ellphi calibration (seed 42), then launch 5-seed paper main run (fixed 12ep). +# +# Usage (background): +# nohup ./experiments/ellphi_postfix_autopipeline.sh >> outputs/ellphi_postfix_calib/autopipeline.log 2>&1 & +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +CALIB_OUT="${CALIB_OUT:-outputs/ellphi_postfix_calib}" +PILOT_LOG="${CALIB_OUT}/driver_12ep_seed42.log" +STATE_JSON="${CALIB_OUT}/autopipeline_state.json" +DECISION_JSON="${CALIB_OUT}/epoch_decision.json" +POLL_SEC="${POLL_SEC:-300}" +SEEDS=(42 123 456 789 1024) +MAIN_EPOCHS="${MAIN_EPOCHS:-12}" +MAIN_OUT="${MAIN_OUT:-outputs/paper_reproduce_1week_tuned}" +MAIN_CONFIG="${MAIN_CONFIG:-reproduce}" + +log() { echo "[$(date -Iseconds)] $*"; } + +write_state() { + local phase="$1" + local detail="${2:-}" + python3 - "$phase" "$detail" "$STATE_JSON" <<'PY' +import json, sys +from datetime import datetime, timezone +phase, detail, path = sys.argv[1:4] +state = {} +try: + with open(path) as f: + state = json.load(f) +except FileNotFoundError: + pass +state.update({ + "updated_utc": datetime.now(timezone.utc).isoformat(), + "phase": phase, + "detail": detail, +}) +with open(path, "w") as f: + json.dump(state, f, indent=2, ensure_ascii=False) + f.write("\n") +PY +} + +pilot_running() { + pgrep -f "run_backend_multiseed.py.*out-base ${CALIB_OUT}" >/dev/null 2>&1 \ + || pgrep -f "run_backend_multiseed.py.*${CALIB_OUT}" >/dev/null 2>&1 +} + +pilot_done() { + [[ -f "$PILOT_LOG" ]] && grep -q '^\[DONE\]' "$PILOT_LOG" +} + +pilot_failed() { + if pilot_running; then + return 1 + fi + if pilot_done; then + return 1 + fi + if [[ -f "$PILOT_LOG" ]] && grep -q 'Traceback' "$PILOT_LOG"; then + return 0 + fi + # No process, no DONE — treat as failure if log exists and has START + if [[ -f "$PILOT_LOG" ]] && grep -q '^\[START\]' "$PILOT_LOG"; then + return 0 + fi + return 1 +} + +wait_for_pilot() { + write_state "waiting_pilot" "polling every ${POLL_SEC}s" + log "Waiting for 12ep calibration in ${CALIB_OUT} ..." + while true; do + if pilot_done; then + log "Pilot completed ([DONE] in log)." + write_state "pilot_completed" + return 0 + fi + if pilot_failed; then + log "ERROR: Pilot exited without [DONE]. See ${PILOT_LOG}" + write_state "pilot_failed" "check driver log" + exit 1 + fi + if pilot_running; then + progress="$(python3 - "$PILOT_LOG" <<'PY' || true +import re, sys +t = open(sys.argv[1]).read() +m = list(re.finditer(r'Epoch (\d+):.*?(\d+)/71', t)) +if m: + print(f"epoch {m[-1].group(1)} batch {m[-1].group(2)}/71") +else: + n = len(re.findall(r'Epoch \d+: Val MCC=', t)) + if n: + print(f"completed_epochs={n}") + else: + print("epoch 1 starting") +PY +)" + log "Pilot still running (${progress})" + evaluate_pilot_if_done + else + log "Pilot process not found yet; waiting ..." + fi + sleep "$POLL_SEC" + done +} + +decide_epochs() { + python3 - "$CALIB_OUT" "$DECISION_JSON" <<'PY' +import csv +import json +import sys +from pathlib import Path + +calib = Path(sys.argv[1]) +out_path = Path(sys.argv[2]) + +# Find metrics from progress_summary or latest run dir +metrics_path = None +progress = calib / "progress_summary.csv" +if progress.exists(): + rows = list(csv.DictReader(progress.open())) + if rows: + run_dir = Path(rows[-1]["run_dir"]) + candidate = run_dir / "logs" / "metrics.csv" + if candidate.exists(): + metrics_path = candidate + +if metrics_path is None: + candidates = sorted(calib.glob("backend_ellphi_seed42_*/logs/metrics.csv")) + if candidates: + metrics_path = candidates[-1] + +if metrics_path is None or not metrics_path.exists(): + raise SystemExit("metrics.csv not found for pilot") + +rows = list(csv.DictReader(metrics_path.open())) +if len(rows) < 3: + raise SystemExit(f"Too few epochs in {metrics_path} ({len(rows)} rows)") + +def mcc_at(epoch: int) -> float | None: + for r in rows: + if int(r["epoch"]) == epoch: + return float(r["val_mcc"]) + return None + +best_row = max(rows, key=lambda r: float(r["val_mcc"])) +best_ep = int(best_row["epoch"]) +best_mcc = float(best_row["val_mcc"]) + +m10 = mcc_at(10) +m12 = mcc_at(12) if mcc_at(12) is not None else float(rows[-1]["val_mcc"]) +last_ep = int(rows[-1]["epoch"]) + +gain_10_12 = None +if m10 is not None and m12 is not None: + gain_10_12 = m12 - m10 + +use_20 = False +reason = [] +if gain_10_12 is not None and gain_10_12 < 0.002 and best_ep <= 11: + if m10 is not None and m10 >= 0.99 * best_mcc: + use_20 = True + reason.append("gain_10_12<0.002, best<=11, ep10>=99%best") +if last_ep < 10: + use_20 = False + reason = [f"only {last_ep} epochs logged; default 30"] + +chosen = 20 if use_20 else 30 +decision = { + "chosen_epochs": chosen, + "criteria": { + "gain_10_12": gain_10_12, + "best_epoch": best_ep, + "best_val_mcc": best_mcc, + "val_mcc_ep10": m10, + "val_mcc_ep12_or_last": m12, + "last_logged_epoch": last_ep, + }, + "reason": reason or ["default 30 (still improving or criteria not met)"], + "metrics_path": str(metrics_path), +} +out_path.write_text(json.dumps(decision, indent=2, ensure_ascii=False) + "\n") +print(chosen) +PY +} + +launch_main() { + local epochs="$1" + local out_base="${MAIN_OUT}" + mkdir -p "$out_base" + + if [[ -f "${out_base}/progress_summary.csv" ]]; then + completed="$(python3 - "$out_base/progress_summary.csv" <<'PY' +import csv, sys +rows = list(csv.DictReader(open(sys.argv[1]))) +print(sum(1 for r in rows if r.get("backend") == "ellphi")) +PY +)" + if [[ "$completed" -ge 5 ]]; then + log "Main run already has ${completed} ellphi rows in ${out_base}; skipping." + write_state "main_already_complete" "$out_base" + evaluate_all_runs "$out_base" + return 0 + fi + fi + + if pgrep -f "run_backend_multiseed.py.*out-base ${out_base}" >/dev/null 2>&1; then + log "Main run already in progress under ${out_base}; skipping duplicate launch." + write_state "main_running" "$out_base" + return 0 + fi + + log "Launching main: ${epochs}ep × 5 seeds → ${out_base}" + write_state "main_starting" "${epochs}ep ${out_base}" + + uv run python experiments/run_backend_multiseed.py \ + --base-config "$MAIN_CONFIG" \ + --epochs "$epochs" \ + --seeds "${SEEDS[@]}" \ + --backends ellphi \ + --out-base "$out_base" \ + 2>&1 | tee -a "${out_base}/driver_main.log" + + write_state "main_completed" "$out_base" + log "Main run finished. See ${out_base}/backend_stats.csv" + evaluate_all_runs "$out_base" +} + +evaluate_run_paper_protocol() { + local run_dir="$1" + local base_cfg="${2:-reproduce}" + log "Paper DBSCAN eval: ${run_dir} (config=${base_cfg})" + uv run python experiments/evaluate_paper_protocol.py \ + --run-dir "$run_dir" \ + --base-config "$base_cfg" \ + --split val + uv run python experiments/evaluate_paper_protocol.py \ + --run-dir "$run_dir" \ + --base-config "$base_cfg" \ + --split test +} + +evaluate_all_runs() { + local out_base="$1" + local base_cfg="${2:-$MAIN_CONFIG}" + local progress="${out_base}/progress_summary.csv" + [[ -f "$progress" ]] || return 0 + python3 - "$progress" <<'PY' | while read -r rd; do +import csv, sys +for row in csv.DictReader(open(sys.argv[1])): + if row.get("backend") == "ellphi": + print(row["run_dir"]) +PY + evaluate_run_paper_protocol "$rd" "$base_cfg" + done +} + +evaluate_pilot_if_done() { + if ! pilot_done; then + return 0 + fi + evaluate_all_runs "$CALIB_OUT" "reproduce" +} + +main() { + log "=== ellphi postfix autopipeline start (repo=${REPO_ROOT}) ===" + if ! pilot_done; then + wait_for_pilot + else + log "Pilot already complete; skipping wait." + write_state "pilot_completed" "pre-existing" + fi + + evaluate_pilot_if_done + + epochs="$MAIN_EPOCHS" + log "Paper main: fixed ${epochs}ep → ${MAIN_OUT} (config=${MAIN_CONFIG})" + python3 - "$epochs" "$DECISION_JSON" <<'PY' +import json, sys +from datetime import datetime, timezone +epochs, path = sys.argv[1:3] +decision = { + "chosen_epochs": int(epochs), + "reason": ["fixed 12ep for paper reproduce_1week_tuned (override MAIN_EPOCHS to change)"], + "decided_utc": datetime.now(timezone.utc).isoformat(), +} +with open(path, "w") as f: + json.dump(decision, f, indent=2, ensure_ascii=False) + f.write("\n") +PY + write_state "epoch_decided" "${epochs}ep ${MAIN_OUT}" + + launch_main "$epochs" + write_state "pipeline_finished" "${epochs}ep ${MAIN_OUT}" + log "=== autopipeline finished ===" +} + +main "$@" diff --git a/experiments/evaluate_paper_baselines.py b/experiments/evaluate_paper_baselines.py new file mode 100644 index 0000000..3deb7aa --- /dev/null +++ b/experiments/evaluate_paper_baselines.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +""" +Paper-aligned baseline evaluation (Phase 3). + +Same data split as ``evaluate_paper_protocol.py`` (``configs/reproduce.yaml``, +seeds 42, 123, 456, 789, 1024): tune hyperparameters on validation clouds, +report MCC / G-Mean / W-Dist on test via ``compute_recall_specificity_gmean_mcc_wdist``. + +Methods: + 1. Euclidean DBSCAN (sklearn on raw coordinates) + 2. Isolation Forest + 3. Local Outlier Factor (LOF) + 4. ADBSCAN (local PCA ellipses + Mahalanobis ``apply_anisotropic_dbscan``) + +Usage:: + + uv run python experiments/evaluate_paper_baselines.py \\ + --out-dir outputs/paper_baselines +""" + +from __future__ import annotations + +import argparse +import csv +import json +from collections.abc import Callable, Sequence +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import torch +from sklearn.cluster import DBSCAN +from sklearn.ensemble import IsolationForest +from sklearn.neighbors import LocalOutlierFactor +from tqdm import tqdm + +from experiments.evaluate_paper_protocol import ( + CloudMetrics, + _aggregate_cloud_metrics, + _valid_clean_inliers, + build_split_loader, + dbscan_labels_to_outlier_pred, + evaluate_cloud_dbscan, +) +from tda_ml.config import deep_update, load_config +from tda_ml.metrics import compute_recall_specificity_gmean_mcc_wdist +from tda_ml.numerical_eps import EIGENVALUE_FLOOR, PCA_RIDGE_EPS +from tda_ml.supervised_diagnostics import git_revision + +REPO_ROOT = Path(__file__).resolve().parents[1] +PAPER_SEEDS = [42, 123, 456, 789, 1024] +LOCAL_PCA_K = 10 + +DEFAULT_EPS_VALUES = list(np.linspace(0.15, 1.5, 15)) +DEFAULT_MIN_SAMPLES_VALUES = [3, 5, 7, 10, 15] +DEFAULT_CONTAMINATION_VALUES = [0.05, 0.07, 0.09, 0.11, 0.13, 0.15, 0.20] +DEFAULT_LOF_N_NEIGHBORS = [5, 10, 15, 20, 30] + + +@dataclass +class CloudSample: + points: np.ndarray + labels_gt: np.ndarray + clean_pc: np.ndarray + adbscan_params: np.ndarray | None = None + + +@dataclass +class SeedResult: + seed: int + method: str + hparams: dict[str, Any] + val_mcc: float + test_recall: float + test_specificity: float + test_gmean: float + test_mcc: float + test_wdist: float + n_test_clouds: int + + +def local_pca_ellipse_params(points: np.ndarray, k: int = LOCAL_PCA_K) -> np.ndarray: + """ + Baseline ellipse parameters from local PCA only (no learned deltas). + + Matches ``DecoupledGeometricEncoder`` + zero MLP corrections: + ``[a, b, theta]`` per point, shape ``(N, 3)``. + """ + if points.ndim != 2 or points.shape[1] != 2: + raise ValueError(f"points must be (N, 2); got {points.shape}") + n = points.shape[0] + if n < k: + raise ValueError(f"Need at least k={k} points; got n={n}") + + x = torch.from_numpy(points.astype(np.float32)).unsqueeze(0) # 1, N, 2 + dist_sq = torch.cdist(x, x, p=2) ** 2 + _, idx = torch.topk(-dist_sq, k=k, dim=-1) + + batch_idx = torch.arange(1).view(1, 1, 1).expand(1, n, k) + flat_x = x.view(n, 2) + flat_neighbors = flat_x[idx.view(1, -1) + (batch_idx.view(1, -1) * n), :] + neighbors = flat_neighbors.view(1, n, k, 2) + + relative_coords = neighbors - x.unsqueeze(2) + mean_neighbor = relative_coords.mean(dim=2, keepdim=True) + centered = relative_coords - mean_neighbor + cov = torch.matmul(centered.transpose(-1, -2), centered) / (k - 1) + + eye2 = torch.eye(2, dtype=torch.float32) + e, v = torch.linalg.eigh(cov.float() + eye2 * PCA_RIDGE_EPS) + v1 = v[:, :, :, 1] + base_angle = torch.atan2(v1[:, :, 1], v1[:, :, 0]) + + base_axes = torch.sqrt(torch.clamp(e, min=EIGENVALUE_FLOOR)) + base_axes = torch.flip(base_axes, dims=[-1]) + base_axes = base_axes / (base_axes.max(dim=-1, keepdim=True)[0] + EIGENVALUE_FLOOR) + + params = torch.cat([base_axes, base_angle.unsqueeze(-1)], dim=-1) + return params.squeeze(0).numpy() + + +def load_clouds(config: dict[str, Any], split: str, device: torch.device) -> list[CloudSample]: + loader = build_split_loader(config, split, device) + clouds: list[CloudSample] = [] + for data, labels, clean_pc in loader: + data_np = data.numpy() + labels_np = labels.numpy() + clean_np = clean_pc.numpy() + for b in range(data_np.shape[0]): + points = data_np[b] + params = local_pca_ellipse_params(points) + clouds.append( + CloudSample( + points=points, + labels_gt=labels_np[b], + clean_pc=clean_np[b], + adbscan_params=params, + ) + ) + return clouds + + +def cloud_metrics_from_pred( + labels_gt: np.ndarray, + pred: np.ndarray, + points: np.ndarray, + clean_pc: np.ndarray, +) -> CloudMetrics: + gt_inliers = _valid_clean_inliers(clean_pc) + recall, specificity, gmean, mcc, wdist = compute_recall_specificity_gmean_mcc_wdist( + labels_gt, + pred, + points=points, + gt_inliers=gt_inliers, + ) + return CloudMetrics(recall, specificity, gmean, mcc, wdist) + + +def evaluate_euclidean_dbscan( + cloud: CloudSample, + *, + eps: float, + min_samples: int, +) -> CloudMetrics: + db_labels = DBSCAN(eps=eps, min_samples=min_samples).fit_predict(cloud.points) + pred = dbscan_labels_to_outlier_pred(db_labels) + return cloud_metrics_from_pred(cloud.labels_gt, pred, cloud.points, cloud.clean_pc) + + +def evaluate_isolation_forest( + cloud: CloudSample, + *, + contamination: float, + random_state: int, +) -> CloudMetrics: + iso = IsolationForest(contamination=contamination, random_state=random_state) + sk_pred = iso.fit_predict(cloud.points) + pred = (sk_pred == -1).astype(np.int64) + return cloud_metrics_from_pred(cloud.labels_gt, pred, cloud.points, cloud.clean_pc) + + +def evaluate_lof( + cloud: CloudSample, + *, + n_neighbors: int, + contamination: float, +) -> CloudMetrics: + lof = LocalOutlierFactor( + n_neighbors=n_neighbors, + contamination=contamination, + novelty=False, + ) + sk_pred = lof.fit_predict(cloud.points) + pred = (sk_pred == -1).astype(np.int64) + return cloud_metrics_from_pred(cloud.labels_gt, pred, cloud.points, cloud.clean_pc) + + +def evaluate_adbscan( + cloud: CloudSample, + *, + eps: float, + min_samples: int, +) -> CloudMetrics: + if cloud.adbscan_params is None: + raise ValueError("adbscan_params missing") + return evaluate_cloud_dbscan( + cloud.points, + cloud.adbscan_params, + cloud.labels_gt, + cloud.clean_pc, + eps=eps, + min_samples=min_samples, + backend="mahalanobis", + ) + + +def grid_search_clouds( + clouds: Sequence[CloudSample], + evaluate_fn: Callable[..., CloudMetrics], + param_combos: Sequence[dict[str, Any]], + *, + desc: str, +) -> tuple[dict[str, Any], float, list[dict[str, Any]]]: + best_mcc = -1.0 + best_params = dict(param_combos[0]) + grid_log: list[dict[str, Any]] = [] + + for params in tqdm(param_combos, desc=desc, leave=False): + per_cloud: list[CloudMetrics] = [] + error: str | None = None + for cloud in clouds: + try: + per_cloud.append(evaluate_fn(cloud, **params)) + except Exception as exc: # noqa: BLE001 — skip invalid hparam combos + error = str(exc) + per_cloud = [] + break + if error is not None: + grid_log.append({**params, "error": error}) + continue + _, _, _, mcc, _ = _aggregate_cloud_metrics(per_cloud) + grid_log.append({**params, "mean_mcc": mcc, "n_clouds": len(per_cloud)}) + if mcc > best_mcc: + best_mcc = mcc + best_params = dict(params) + + if best_mcc < 0: + raise RuntimeError(f"Grid search failed for all hparams ({desc}); log={grid_log[:5]}") + return best_params, best_mcc, grid_log + + +def dbscan_param_combos( + eps_values: Sequence[float], + min_samples_values: Sequence[int], +) -> list[dict[str, Any]]: + return [ + {"eps": float(eps), "min_samples": int(ms)} + for eps in eps_values + for ms in min_samples_values + ] + + +def contamination_param_combos(contamination_values: Sequence[float]) -> list[dict[str, Any]]: + return [{"contamination": float(c)} for c in contamination_values] + + +def lof_param_combos( + n_neighbors_values: Sequence[int], + contamination_values: Sequence[float], +) -> list[dict[str, Any]]: + return [ + {"n_neighbors": int(n), "contamination": float(c)} + for n in n_neighbors_values + for c in contamination_values + ] + + +def evaluate_method_on_seed( + method: str, + config: dict[str, Any], + seed: int, + device: torch.device, + *, + eps_values: Sequence[float], + min_samples_values: Sequence[int], + contamination_values: Sequence[float], + lof_n_neighbors_values: Sequence[int], +) -> tuple[SeedResult, list[dict[str, Any]]]: + val_clouds = load_clouds(config, "val", device) + test_clouds = load_clouds(config, "test", device) + + if method == "euclidean_dbscan": + combos = dbscan_param_combos(eps_values, min_samples_values) + best_params, val_mcc, grid_log = grid_search_clouds( + val_clouds, + evaluate_euclidean_dbscan, + combos, + desc=f"seed{seed} euclidean_dbscan val", + ) + test_fn: Callable[..., CloudMetrics] = evaluate_euclidean_dbscan + elif method == "isolation_forest": + combos = [ + {"contamination": float(c), "random_state": seed} + for c in contamination_values + ] + best_params, val_mcc, grid_log = grid_search_clouds( + val_clouds, + evaluate_isolation_forest, + combos, + desc=f"seed{seed} isolation_forest val", + ) + test_fn = evaluate_isolation_forest + elif method == "lof": + combos = lof_param_combos(lof_n_neighbors_values, contamination_values) + best_params, val_mcc, grid_log = grid_search_clouds( + val_clouds, + evaluate_lof, + combos, + desc=f"seed{seed} lof val", + ) + test_fn = evaluate_lof + elif method == "adbscan": + combos = dbscan_param_combos(eps_values, min_samples_values) + best_params, val_mcc, grid_log = grid_search_clouds( + val_clouds, + evaluate_adbscan, + combos, + desc=f"seed{seed} adbscan val", + ) + test_fn = evaluate_adbscan + else: + raise ValueError(f"Unknown method: {method!r}") + + per_test: list[CloudMetrics] = [] + for cloud in test_clouds: + per_test.append(test_fn(cloud, **best_params)) + recall, specificity, gmean, mcc, wdist = _aggregate_cloud_metrics(per_test) + + return SeedResult( + seed=seed, + method=method, + hparams=best_params, + val_mcc=val_mcc, + test_recall=recall, + test_specificity=specificity, + test_gmean=gmean, + test_mcc=mcc, + test_wdist=wdist, + n_test_clouds=len(per_test), + ), grid_log + + +def config_for_seed(base_config: str, seed: int) -> dict[str, Any]: + cfg = load_config(base_config, project_root=REPO_ROOT) + return deep_update(cfg, {"data": {"seed": int(seed)}}) + + +def sample_std(values: Sequence[float]) -> float: + arr = np.asarray(values, dtype=np.float64) + if arr.size < 2: + return 0.0 + return float(np.std(arr, ddof=1)) + + +def write_summary_csv( + out_path: Path, + rows: list[dict[str, Any]], +) -> None: + fieldnames = [ + "method", + "mcc_mean", + "mcc_std", + "gmean_mean", + "gmean_std", + "wdist_mean", + "wdist_std", + "notes", + ] + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({k: row.get(k, "") for k in fieldnames}) + + +def aggregate_method_results(seed_results: Sequence[SeedResult]) -> dict[str, Any]: + mccs = [r.test_mcc for r in seed_results] + gmeans = [r.test_gmean for r in seed_results] + wdist = [r.test_wdist for r in seed_results] + method = seed_results[0].method + return { + "method": method, + "mcc_mean": float(np.mean(mccs)), + "mcc_std": sample_std(mccs), + "gmean_mean": float(np.mean(gmeans)), + "gmean_std": sample_std(gmeans), + "wdist_mean": float(np.mean(wdist)), + "wdist_std": sample_std(wdist), + "notes": ( + f"5 seeds; val hparam selection by max mean cloud MCC; " + f"test n_clouds={seed_results[0].n_test_clouds} per seed" + ), + } + + +METHOD_ORDER = [ + "euclidean_dbscan", + "isolation_forest", + "lof", + "adbscan", +] + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--base-config", type=str, default="reproduce") + p.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs" / "paper_baselines") + p.add_argument("--seeds", type=int, nargs="+", default=PAPER_SEEDS) + p.add_argument( + "--methods", + nargs="+", + choices=METHOD_ORDER, + default=METHOD_ORDER, + ) + p.add_argument("--eps-values", type=float, nargs="+", default=DEFAULT_EPS_VALUES) + p.add_argument("--min-samples-values", type=int, nargs="+", default=DEFAULT_MIN_SAMPLES_VALUES) + p.add_argument("--contamination-values", type=float, nargs="+", default=DEFAULT_CONTAMINATION_VALUES) + p.add_argument("--lof-n-neighbors", type=int, nargs="+", default=DEFAULT_LOF_N_NEIGHBORS) + return p.parse_args() + + +def main() -> int: + args = parse_args() + out_dir = args.out_dir.resolve() + out_dir.mkdir(parents=True, exist_ok=True) + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + manifest = { + "source_revision": git_revision(REPO_ROOT), + "base_config": args.base_config, + "seeds": list(args.seeds), + "methods": list(args.methods), + "selection": "max mean cloud MCC on validation split", + "metrics": "compute_recall_specificity_gmean_mcc_wdist", + "local_pca_k": LOCAL_PCA_K, + "grids": { + "eps_values": list(args.eps_values), + "min_samples_values": list(args.min_samples_values), + "contamination_values": list(args.contamination_values), + "lof_n_neighbors": list(args.lof_n_neighbors), + }, + } + + all_seed_results: dict[str, list[SeedResult]] = {m: [] for m in args.methods} + + for seed in args.seeds: + config = config_for_seed(args.base_config, seed) + seed_dir = out_dir / f"seed{seed}" + seed_dir.mkdir(parents=True, exist_ok=True) + + for method in args.methods: + print(f"\n=== seed={seed} method={method} ===") + result, grid_log = evaluate_method_on_seed( + method, + config, + seed, + device, + eps_values=args.eps_values, + min_samples_values=args.min_samples_values, + contamination_values=args.contamination_values, + lof_n_neighbors_values=args.lof_n_neighbors, + ) + all_seed_results[method].append(result) + + payload = { + **asdict(result), + "grid_log": grid_log, + } + out_json = seed_dir / f"{method}.json" + out_json.write_text(json.dumps(payload, indent=2) + "\n") + print( + f" val_mcc={result.val_mcc:.4f} test_mcc={result.test_mcc:.4f} " + f"test_gmean={result.test_gmean:.4f} test_wdist={result.test_wdist:.4f} " + f"hparams={result.hparams}" + ) + + summary_rows = [ + aggregate_method_results(all_seed_results[m]) + for m in args.methods + if all_seed_results[m] + ] + summary_path = out_dir / "summary_baselines.csv" + write_summary_csv(summary_path, summary_rows) + + manifest["summary_csv"] = str(summary_path) + manifest["per_seed_json"] = str(out_dir / "seed{seed}/{method}.json") + manifest_path = out_dir / "MANIFEST_baselines.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") + + print(f"\nWrote {summary_path}") + print(f"Wrote {manifest_path}") + for row in summary_rows: + print( + f" {row['method']}: MCC={row['mcc_mean']:.4f}±{row['mcc_std']:.4f} " + f"G-Mean={row['gmean_mean']:.4f}±{row['gmean_std']:.4f} " + f"W-Dist={row['wdist_mean']:.4f}±{row['wdist_std']:.4f}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py new file mode 100644 index 0000000..cbb67e4 --- /dev/null +++ b/experiments/evaluate_paper_protocol.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +Paper-aligned evaluation: ellphi DBSCAN inference + MCC / G-Mean / W-Dist. + +Trainer ``val_mcc`` uses sigmoid(logit) > threshold; the paper reports metrics +after DBSCAN on the learned ellphi precomputed distance matrix. + +Usage:: + + uv run python experiments/evaluate_paper_protocol.py \\ + --run-dir outputs/paper_reproduce_1week_tuned/backend_ellphi_seed42_* \\ + --base-config reproduce \\ + --split val + + uv run python experiments/evaluate_paper_protocol.py \\ + --run-dir ... --split test \\ + --dbscan-hparams outputs/.../logs/dbscan_hparams.json +""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Iterator + +import numpy as np +import torch + +from tda_ml.checkpoint_io import extract_model_state_dict, load_torch_checkpoint +from tda_ml.config import deep_update, load_config, model_kwargs_from_config +from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader +from tda_ml.dbscan import apply_anisotropic_dbscan +from tda_ml.metrics import compute_recall_specificity_gmean_mcc_wdist +from tda_ml.models import AnisotropicOutlierClassifier +from tda_ml.seed_utils import set_global_seed +from tda_ml.supervised_diagnostics import git_revision + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@dataclass +class CloudMetrics: + recall: float + specificity: float + gmean: float + mcc: float + wdist: float + + +@dataclass +class SplitMetrics: + split: str + n_clouds: int + recall: float + specificity: float + gmean: float + mcc: float + wdist: float + dbscan_eps: float | None = None + dbscan_min_samples: int | None = None + backend: str = "ellphi" + + +def _valid_clean_inliers(clean_pc: np.ndarray) -> np.ndarray: + mask = np.abs(clean_pc).sum(axis=1) > 1e-6 + return clean_pc[mask] + + +def dbscan_labels_to_outlier_pred(labels: np.ndarray) -> np.ndarray: + """DBSCAN noise (-1) -> outlier (1); clustered points -> inlier (0).""" + return (labels == -1).astype(np.int64) + + +def evaluate_cloud_dbscan( + points: np.ndarray, + params: np.ndarray, + labels_gt: np.ndarray, + clean_pc: np.ndarray, + *, + eps: float, + min_samples: int, + backend: str = "ellphi", + metric: str = "max", +) -> CloudMetrics: + db_labels = apply_anisotropic_dbscan( + points, + params, + eps=eps, + min_samples=min_samples, + metric=metric, + backend=backend, + ) + pred = dbscan_labels_to_outlier_pred(db_labels) + gt_inliers = _valid_clean_inliers(clean_pc) + recall, specificity, gmean, mcc, wdist = compute_recall_specificity_gmean_mcc_wdist( + labels_gt, + pred, + points=points, + gt_inliers=gt_inliers, + ) + return CloudMetrics(recall, specificity, gmean, mcc, wdist) + + +def _aggregate_cloud_metrics(rows: list[CloudMetrics]) -> tuple[float, float, float, float, float]: + if not rows: + raise ValueError("No clouds to aggregate") + return ( + float(np.mean([r.recall for r in rows])), + float(np.mean([r.specificity for r in rows])), + float(np.mean([r.gmean for r in rows])), + float(np.mean([r.mcc for r in rows])), + float(np.mean([r.wdist for r in rows])), + ) + + +def build_split_loader(config: dict[str, Any], split: str, device: torch.device): + data_cfg = config["data"] + seed = int(data_cfg.get("seed", 42)) + set_global_seed(seed, deterministic_algorithms=False) + + train_size = int(data_cfg.get("train_size", 4500)) + val_size = int(data_cfg.get("val_size", 500)) + test_size = int(data_cfg.get("test_size", 1000)) + generator = torch.Generator().manual_seed(seed) + full_train_indices = torch.randperm(60000, generator=generator)[: train_size + val_size] + train_indices = full_train_indices[:train_size] + val_indices = full_train_indices[train_size:] + test_indices = torch.randperm(10000, generator=generator)[:test_size] + + num_workers = int(data_cfg.get("num_workers", 0)) + pin_memory = bool(data_cfg.get("pin_memory", device.type == "cuda")) + batch_size = int(data_cfg.get("batch_size", 64)) + + if split == "val": + indices = val_indices + train_flag = True + elif split == "test": + indices = test_indices + train_flag = False + else: + raise ValueError(f"split must be 'val' or 'test'; got {split!r}") + + dataset = NoisyMNISTDataset( + root=str(REPO_ROOT / "data"), + train=train_flag, + max_points=data_cfg["max_points"], + num_outliers=data_cfg["num_outliers"], + indices=indices, + deterministic=True, + noise_seed=seed, + preload=True, + ) + loader = create_data_loader( + dataset, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + pin_memory=pin_memory, + persistent_workers=False, + prefetch_factor=2 if num_workers > 0 else None, + ) + return loader + + +def load_model_from_run(run_dir: Path, config: dict[str, Any], device: torch.device) -> AnisotropicOutlierClassifier: + ckpt_path = run_dir / "best_model.pth" + if not ckpt_path.is_file(): + raise FileNotFoundError(f"Missing checkpoint: {ckpt_path}") + model = AnisotropicOutlierClassifier(**model_kwargs_from_config(config)) + ckpt = load_torch_checkpoint(str(ckpt_path), map_location="cpu") + model.load_state_dict(extract_model_state_dict(ckpt), strict=False) + model.to(device) + model.eval() + return model + + +def iter_cloud_predictions( + model: AnisotropicOutlierClassifier, + loader, + device: torch.device, +) -> Iterator[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]: + with torch.no_grad(): + for data, labels, clean_pc in loader: + data = data.to(device, non_blocking=True) + _, params = model(data) + data_np = data.cpu().numpy() + params_np = params.cpu().numpy() + labels_np = labels.cpu().numpy() + clean_np = clean_pc.cpu().numpy() + batch_size = data_np.shape[0] + for b in range(batch_size): + yield ( + data_np[b], + params_np[b], + labels_np[b], + clean_np[b], + ) + + +def grid_search_dbscan( + clouds: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], + *, + eps_values: list[float], + min_samples_values: list[int], + backend: str = "ellphi", +) -> tuple[float, int, float]: + best_mcc = -1.0 + best_eps = eps_values[0] + best_min_samples = min_samples_values[0] + grid_log: list[dict[str, Any]] = [] + + for eps in eps_values: + for min_samples in min_samples_values: + per_cloud: list[CloudMetrics] = [] + for points, params, labels_gt, clean_pc in clouds: + try: + m = evaluate_cloud_dbscan( + points, + params, + labels_gt, + clean_pc, + eps=eps, + min_samples=min_samples, + backend=backend, + ) + per_cloud.append(m) + except Exception as exc: # noqa: BLE001 — log and skip bad hparams + grid_log.append( + { + "eps": eps, + "min_samples": min_samples, + "error": str(exc), + } + ) + per_cloud = [] + break + if not per_cloud: + continue + _, _, _, mcc, _ = _aggregate_cloud_metrics(per_cloud) + grid_log.append( + { + "eps": eps, + "min_samples": min_samples, + "mean_mcc": mcc, + "n_clouds": len(per_cloud), + } + ) + if mcc > best_mcc: + best_mcc = mcc + best_eps = eps + best_min_samples = min_samples + + if best_mcc < 0: + raise RuntimeError(f"DBSCAN grid search failed for all hparams; log={grid_log[:5]}") + return best_eps, best_min_samples, best_mcc + + +def evaluate_split( + run_dir: Path, + config: dict[str, Any], + split: str, + device: torch.device, + *, + eps: float | None = None, + min_samples: int | None = None, + eps_values: list[float] | None = None, + min_samples_values: list[int] | None = None, + backend: str = "ellphi", +) -> SplitMetrics: + loader = build_split_loader(config, split, device) + model = load_model_from_run(run_dir, config, device) + clouds = list(iter_cloud_predictions(model, loader, device)) + + if split == "val": + eps_values = eps_values or list(np.linspace(0.15, 1.5, 15)) + min_samples_values = min_samples_values or [3, 5, 7, 10, 15] + eps, min_samples, _ = grid_search_dbscan( + clouds, + eps_values=eps_values, + min_samples_values=min_samples_values, + backend=backend, + ) + else: + if eps is None or min_samples is None: + raise ValueError("test split requires eps and min_samples (from val tuning)") + + per_cloud: list[CloudMetrics] = [] + for points, params, labels_gt, clean_pc in clouds: + per_cloud.append( + evaluate_cloud_dbscan( + points, + params, + labels_gt, + clean_pc, + eps=float(eps), + min_samples=int(min_samples), + backend=backend, + ) + ) + recall, specificity, gmean, mcc, wdist = _aggregate_cloud_metrics(per_cloud) + return SplitMetrics( + split=split, + n_clouds=len(per_cloud), + recall=recall, + specificity=specificity, + gmean=gmean, + mcc=mcc, + wdist=wdist, + dbscan_eps=float(eps), + dbscan_min_samples=int(min_samples), + backend=backend, + ) + + +def load_run_config(run_dir: Path, base_config: str, seed: int | None) -> dict[str, Any]: + manifest_path = run_dir / "logs" / "run_manifest.json" + cfg = load_config(base_config, project_root=REPO_ROOT) + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text()) + if seed is None: + seed = manifest.get("seed") + if seed is not None: + cfg = deep_update(cfg, {"data": {"seed": int(seed)}}) + return cfg + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--run-dir", type=Path, required=True) + p.add_argument("--base-config", type=str, default="reproduce") + p.add_argument("--split", choices=["val", "test"], required=True) + p.add_argument("--seed", type=int, default=None, help="Override data.seed (else run_manifest)") + p.add_argument("--backend", type=str, default="ellphi", choices=["ellphi", "mahalanobis"]) + p.add_argument( + "--dbscan-hparams", + type=Path, + default=None, + help="JSON with eps/min_samples for test split", + ) + p.add_argument("--out-json", type=Path, default=None, help="Write metrics JSON (default: run_dir/logs/)") + return p.parse_args() + + +def main() -> int: + args = parse_args() + run_dir = args.run_dir.resolve() + if not run_dir.is_dir(): + raise SystemExit(f"run-dir not found: {run_dir}") + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + config = load_run_config(run_dir, args.base_config, args.seed) + + eps = min_samples = None + if args.split == "test": + if args.dbscan_hparams is None: + args.dbscan_hparams = run_dir / "logs" / "dbscan_hparams.json" + hparams = json.loads(args.dbscan_hparams.read_text()) + eps = float(hparams["eps"]) + min_samples = int(hparams["min_samples"]) + + metrics = evaluate_split( + run_dir, + config, + args.split, + device, + eps=eps, + min_samples=min_samples, + backend=args.backend, + ) + + log_dir = run_dir / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + if args.split == "val": + hparams_path = log_dir / "dbscan_hparams.json" + hparams_path.write_text( + json.dumps( + { + "eps": metrics.dbscan_eps, + "min_samples": metrics.dbscan_min_samples, + "backend": metrics.backend, + "mean_val_mcc_dbscan": metrics.mcc, + "selection": "max mean cloud MCC on validation split", + }, + indent=2, + ) + + "\n" + ) + print(f"Saved DBSCAN hparams: {hparams_path}") + + out_path = args.out_json or log_dir / f"paper_metrics_{args.split}.json" + payload = { + "source_revision": git_revision(REPO_ROOT), + "run_dir": str(run_dir), + "split": args.split, + **asdict(metrics), + } + out_path.write_text(json.dumps(payload, indent=2) + "\n") + print(json.dumps(payload, indent=2)) + print(f"Wrote {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a05ffa0c23b1afcd69b6e43f9f8dc5fa72aa48c8 Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:19:26 +0900 Subject: [PATCH 03/44] =?UTF-8?q?wip:=20w-topo=20ablation=20=E5=AE=9F?= =?UTF-8?q?=E9=A8=93=E3=81=AE=E4=BD=9C=E6=A5=AD=E5=86=85=E5=AE=B9=E3=82=92?= =?UTF-8?q?=E4=B8=80=E6=99=82=E4=BF=9D=E5=AD=98=EF=BC=88=E3=83=AA=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=AF=E3=82=BF=E5=89=8D=E3=82=B9=E3=83=8A=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=82=B7=E3=83=A7=E3=83=83=E3=83=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit リファクタリング着手前に実験中の変更(configs 追加、losses/trainer 変更、 topo_wdist/teacher_pd/local_pca 等の新規モジュール、実験スクリプト群)を保全する。 Co-authored-by: Cursor --- configs/_conv_n100.yaml | 42 +++ configs/_mahal_n100_noprob.yaml | 44 +++ configs/elongate_n100.yaml | 61 ++++ configs/elongate_n100_ellphi_tuned.yaml | 57 ++++ configs/elongate_n100_no_cls.yaml | 59 ++++ ...elongate_n100_no_cls_full120_baseline.yaml | 58 ++++ ...gate_n100_no_cls_full120_ellphi_tuned.yaml | 58 ++++ ...n100_no_cls_full120_teacher_local_pca.yaml | 61 ++++ configs/elongate_n100_no_cls_scaleinv.yaml | 74 +++++ ...gate_n100_no_cls_scaleinv_legacy_topo.yaml | 62 ++++ .../elongate_n100_no_cls_scaleinv_minb.yaml | 62 ++++ ...ate_n100_no_cls_scaleinv_minb_full120.yaml | 63 ++++ ...gate_n100_no_cls_scaleinv_minb_topo12.yaml | 62 ++++ ...gate_n100_no_cls_scaleinv_stronganiso.yaml | 61 ++++ configs/elongate_n100_no_cls_topo12_only.yaml | 60 ++++ .../elongate_n100_no_cls_tune_local_pca.yaml | 57 ++++ configs/prod.yaml | 2 +- experiments/_bench_topo_workers.py | 103 +++++++ experiments/aggregate_paper_results.py | 8 +- experiments/apply_tune_best_to_configs.py | 113 +++++++ experiments/checkpoint_selection.py | 88 ++++++ experiments/ellphi_retune_v2_autopipeline.sh | 42 +++ experiments/evaluate_paper_protocol.py | 76 ++++- experiments/evaluate_topo_checkpoint.py | 197 ++++++++++++ experiments/launch_detached_screen.sh | 45 +++ experiments/plot_distance_matrix.py | 282 ++++++++++++++++++ experiments/run_ellphi_tuned_full120_30ep.sh | 48 +++ .../run_ellphi_tuned_v2_full120_30ep.sh | 49 +++ experiments/run_retune_parallel.sh | 96 ++++++ ...run_teacher_local_pca_ellphi_smoke_10ep.py | 166 +++++++++++ experiments/run_tune_local_pca_parallel.sh | 94 ++++++ experiments/run_tune_parallel.sh | 73 +++++ experiments/tune_elongate_wdist.py | 254 ++++++++++++++++ pyproject.toml | 3 + scripts/new_experiment.sh | 72 +++++ tda_ml/dbscan_eval.py | 266 +++++++++++++++++ tda_ml/local_pca.py | 78 +++++ tda_ml/losses.py | 126 ++++++-- tda_ml/main.py | 144 ++++++++- tda_ml/metrics.py | 20 +- tda_ml/teacher_pd.py | 89 ++++++ tda_ml/topo_wdist.py | 151 ++++++++++ tda_ml/trainer.py | 125 ++++++-- tda_ml/visualization.py | 164 +++++----- tests/test_evaluate_topo_checkpoint.py | 60 ++++ tests/test_losses_topo_distance.py | 113 +++++++ tests/test_teacher_pd.py | 92 ++++++ tests/test_topo_wdist.py | 47 +++ tests/test_trainer.py | 27 ++ tests/test_tune_config.py | 36 +++ uv.lock | 62 ++-- 51 files changed, 4174 insertions(+), 178 deletions(-) create mode 100644 configs/_conv_n100.yaml create mode 100644 configs/_mahal_n100_noprob.yaml create mode 100644 configs/elongate_n100.yaml create mode 100644 configs/elongate_n100_ellphi_tuned.yaml create mode 100644 configs/elongate_n100_no_cls.yaml create mode 100644 configs/elongate_n100_no_cls_full120_baseline.yaml create mode 100644 configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml create mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml create mode 100644 configs/elongate_n100_no_cls_scaleinv.yaml create mode 100644 configs/elongate_n100_no_cls_scaleinv_legacy_topo.yaml create mode 100644 configs/elongate_n100_no_cls_scaleinv_minb.yaml create mode 100644 configs/elongate_n100_no_cls_scaleinv_minb_full120.yaml create mode 100644 configs/elongate_n100_no_cls_scaleinv_minb_topo12.yaml create mode 100644 configs/elongate_n100_no_cls_scaleinv_stronganiso.yaml create mode 100644 configs/elongate_n100_no_cls_topo12_only.yaml create mode 100644 configs/elongate_n100_no_cls_tune_local_pca.yaml create mode 100644 experiments/_bench_topo_workers.py create mode 100755 experiments/apply_tune_best_to_configs.py create mode 100644 experiments/checkpoint_selection.py create mode 100755 experiments/ellphi_retune_v2_autopipeline.sh create mode 100644 experiments/evaluate_topo_checkpoint.py create mode 100755 experiments/launch_detached_screen.sh create mode 100644 experiments/plot_distance_matrix.py create mode 100755 experiments/run_ellphi_tuned_full120_30ep.sh create mode 100755 experiments/run_ellphi_tuned_v2_full120_30ep.sh create mode 100755 experiments/run_retune_parallel.sh create mode 100755 experiments/run_teacher_local_pca_ellphi_smoke_10ep.py create mode 100755 experiments/run_tune_local_pca_parallel.sh create mode 100644 experiments/run_tune_parallel.sh create mode 100644 experiments/tune_elongate_wdist.py create mode 100755 scripts/new_experiment.sh create mode 100644 tda_ml/dbscan_eval.py create mode 100644 tda_ml/local_pca.py create mode 100644 tda_ml/teacher_pd.py create mode 100644 tda_ml/topo_wdist.py create mode 100644 tests/test_evaluate_topo_checkpoint.py create mode 100644 tests/test_teacher_pd.py create mode 100644 tests/test_topo_wdist.py create mode 100644 tests/test_tune_config.py diff --git a/configs/_conv_n100.yaml b/configs/_conv_n100.yaml new file mode 100644 index 0000000..244ba33 --- /dev/null +++ b/configs/_conv_n100.yaml @@ -0,0 +1,42 @@ +# EXPERIMENTAL (uncommitted) config for the N=100 convergence check (seed42, 30ep). +# Mirrors reproduce.yaml hyperparameters but max_points=100 (matches base.yaml / +# paper eval protocol). num_outliers kept at 20 (=> 20% ratio, same as eval run_meta). +# Pending spec decision: see proposed issue "max_points 200->100". +meta: + config_id: "conv_n100" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + +loss: + w_class: 1.0 + w_topo: 0.1 + w_aniso: 0.01099204345474479 + w_size: 0.0055785823086202556 + pos_weight: 1.0 + aniso_mode: "linear" + +training: + lr: 0.0004897466143769238 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + +data: + max_points: 100 + num_outliers: 20 + seed: 42 + test_size: 1000 + num_workers: 4 + batch_size: 64 + +outputs: + base_dir: "outputs" diff --git a/configs/_mahal_n100_noprob.yaml b/configs/_mahal_n100_noprob.yaml new file mode 100644 index 0000000..2eab99a --- /dev/null +++ b/configs/_mahal_n100_noprob.yaml @@ -0,0 +1,44 @@ +# EXPERIMENTAL (uncommitted) config: mahalanobis backend convergence check, N=100. +# Probability-based distance weighting DISABLED (prob_weighting: false) so the +# comparison against ellphi is a fair backend ablation (ellphi never uses probs). +# Mirrors _conv_n100.yaml hyperparameters. distance_backend is also set by the +# multiseed driver via --backends mahalanobis. +meta: + config_id: "mahal_n100_noprob" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 1.0 + w_topo: 0.1 + w_aniso: 0.01099204345474479 + w_size: 0.0055785823086202556 + pos_weight: 1.0 + aniso_mode: "linear" + +training: + lr: 0.0004897466143769238 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + +data: + max_points: 100 + num_outliers: 20 + seed: 42 + test_size: 1000 + num_workers: 4 + batch_size: 64 + +outputs: + base_dir: "outputs" diff --git a/configs/elongate_n100.yaml b/configs/elongate_n100.yaml new file mode 100644 index 0000000..5b9daa3 --- /dev/null +++ b/configs/elongate_n100.yaml @@ -0,0 +1,61 @@ +# Elongate main run (1 seed, 30ep) for n100 (100 inliers + 20 outliers, 16.7% contamination). +# Hyperparams from Optuna 4D W-Dist tuning (outputs/tune_elongate/best_elongate_wdist.json, +# 56 trials, mahalanobis backend, 20ep proxy). See docs/anisotropy_investigation.md. + +meta: + config_id: "elongate_n100" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed + ellphi_differentiable: true + +loss: + w_class: 1.0 + w_topo: 0.0883 # tuned (W-Dist objective, val best=0.0390) + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: "elongate" + +training: + lr: 0.000158 # tuned + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + # Checkpoint selection = paper protocol: DBSCAN on predicted ellipse params, + # pick the epoch with the lowest mean val W-Dist (same grid as final eval). + # The chosen (eps, min_samples) is saved to logs/dbscan_hparams_train.json. + selection: + metric: wdist # wdist | dbscan_mcc | threshold_mcc | val_loss + eval_every: 1 # grid-search every epoch (mahalanobis: ~+tens of min) + dbscan: + eps_values: null # null -> linspace(0.15, 1.5, 15) + min_samples_values: null # null -> [3, 5, 7, 10, 15] + +data: + max_points: 100 # inlier (digit stroke) points + num_outliers: 20 # uniform outliers -> 20/120 = 16.7% contamination + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 # conservative for shared 192-core host + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_ellphi_tuned.yaml b/configs/elongate_n100_ellphi_tuned.yaml new file mode 100644 index 0000000..7be69b3 --- /dev/null +++ b/configs/elongate_n100_ellphi_tuned.yaml @@ -0,0 +1,57 @@ +# elongate_n100 + ellphi Optuna tuning v1 (50 trials, 20ep proxy, val W-Dist objective). +# Source: /home/murata/TDA-ML/outputs/tune_elongate_ellphi_local_pca_v3/best_elongate_wdist_ellphi.json (v3 val_topo ckpt, val topo W-Dist=0.03465) + +meta: + config_id: "elongate_n100_ellphi_tuned" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed + ellphi_differentiable: true + +loss: + w_class: 1.0 + w_topo: 0.3961 # tuned ellphi v2 + w_aniso: 0.094 + w_size: 0.005 + pos_weight: 1.0 + aniso_mode: "elongate" + +training: + lr: 0.00093 # tuned ellphi v2 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls.yaml b/configs/elongate_n100_no_cls.yaml new file mode 100644 index 0000000..8c68c5e --- /dev/null +++ b/configs/elongate_n100_no_cls.yaml @@ -0,0 +1,59 @@ +# Elongate backend comparison WITHOUT point-level classification supervision. +# Same hyperparams as elongate_n100.yaml except w_class=0.0 (geometry-only learning +# via topo + aniso + size losses). Fair mahalanobis vs ellphi ablation. + +meta: + config_id: "elongate_n100_no_cls" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed + ellphi_differentiable: true + prob_weighting: false # no outlier-prob distance weighting (ellphi never uses probs) + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: "elongate" + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_baseline.yaml b/configs/elongate_n100_no_cls_full120_baseline.yaml new file mode 100644 index 0000000..0513361 --- /dev/null +++ b/configs/elongate_n100_no_cls_full120_baseline.yaml @@ -0,0 +1,58 @@ +# Pure baseline: no fix1 (median), no fix2 (min_b), no fix3 (topo12). +# Full 120-point cloud for topo loss (default max_points unset). + +meta: + config_id: "elongate_n100_no_cls_full120_baseline" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: "elongate" + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml b/configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml new file mode 100644 index 0000000..f983fb5 --- /dev/null +++ b/configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml @@ -0,0 +1,58 @@ +# Same loss weights as ellphi Optuna tune v1; no_cls + full-cloud topo (compare to full120_baseline). +# Source: /home/murata/TDA-ML/outputs/tune_elongate_ellphi_local_pca_v3/best_elongate_wdist_ellphi.json (v3 val_topo ckpt, val topo W-Dist=0.03465) + +meta: + config_id: "elongate_n100_no_cls_full120_ellphi_tuned" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.3961 # tuned ellphi v2 + w_aniso: 0.094 + w_size: 0.005 + pos_weight: 1.0 + aniso_mode: "elongate" + +training: + lr: 0.00093 # tuned ellphi v2 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml new file mode 100644 index 0000000..2ba018d --- /dev/null +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml @@ -0,0 +1,61 @@ +# Ablation: paper §3.3.2 teacher (local PCA ideal ellipses + same backend as prediction). +# Source: /home/murata/TDA-ML/outputs/tune_elongate_ellphi_local_pca_v3/best_elongate_wdist_ellphi.json (v3 val_topo ckpt, val topo W-Dist=0.03465) +# Switch teacher: loss.teacher_mode = euclidean | local_pca + +meta: + config_id: "elongate_n100_no_cls_full120_teacher_local_pca" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.3961 # tuned ellphi v2 + w_aniso: 0.094 + w_size: 0.005 + pos_weight: 1.0 + aniso_mode: "elongate" + teacher_mode: local_pca + teacher_local_pca_k: 10 + +training: + lr: 0.00093 # tuned ellphi v2 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv.yaml b/configs/elongate_n100_no_cls_scaleinv.yaml new file mode 100644 index 0000000..88ba819 --- /dev/null +++ b/configs/elongate_n100_no_cls_scaleinv.yaml @@ -0,0 +1,74 @@ +# Scale-invariant variant of elongate_n100_no_cls.yaml. +# +# Root-cause fix for "ellphi W-Dist does not improve during training": the predicted +# topology distance matrix (Mahalanobis or ellphi tangency units) lives on a different +# scale than the Euclidean clean (teacher) PD. The legacy pipeline aligned them with a +# scalar `topo_eps_scale` (v73=0.7022). Here we use the backend-agnostic, tuning-free +# `topo_scale_mode: median`: the *prediction* is rescaled onto the teacher's Euclidean +# scale, D <- D * (m_e / median(D)), where m_e is the median pairwise Euclidean distance +# of the clean cloud. The teacher PD is left untouched. This removes the metric/unit +# mismatch that previously dominated the ellphi topology loss. +# +# Everything else matches elongate_n100_no_cls.yaml for a fair before/after comparison. + +meta: + config_id: "elongate_n100_no_cls_scaleinv" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: "elongate" + # Filtration-unit alignment between predicted matrix and Euclidean teacher PD. + # median: rescale prediction onto teacher's Euclidean scale, teacher untouched + # (recommended, backend-agnostic, tuning-free) + # fixed: D <- D * topo_eps_scale (legacy behavior; set topo_scale_mode: fixed) + topo_scale_mode: "median" + topo_eps_scale: 1.0 + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_legacy_topo.yaml b/configs/elongate_n100_no_cls_scaleinv_legacy_topo.yaml new file mode 100644 index 0000000..0bffae8 --- /dev/null +++ b/configs/elongate_n100_no_cls_scaleinv_legacy_topo.yaml @@ -0,0 +1,62 @@ +# scaleinv + min_b + strong aniso + topo_loss_max_points (full legacy stack; stronganiso は単独で有害のため step3 では使わない). + +meta: + config_id: "elongate_n100_no_cls_scaleinv_legacy_topo" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 4.5307 + w_size: 0.488 + w_min_b: 5.6525 + min_b_target: 0.05 + pos_weight: 1.0 + aniso_mode: "elongate" + topo_scale_mode: "median" + topo_eps_scale: 1.0 + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + topo_loss_max_points: 12 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_minb.yaml b/configs/elongate_n100_no_cls_scaleinv_minb.yaml new file mode 100644 index 0000000..0e2e1bc --- /dev/null +++ b/configs/elongate_n100_no_cls_scaleinv_minb.yaml @@ -0,0 +1,62 @@ +# scaleinv + legacy min_b regularization (step 1 ablation). +# Adds lambda_min_b / min_b_target from legacy ellphi config; keeps weak w_aniso. + +meta: + config_id: "elongate_n100_no_cls_scaleinv_minb" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + w_min_b: 5.6525 + min_b_target: 0.05 + pos_weight: 1.0 + aniso_mode: "elongate" + topo_scale_mode: "median" + topo_eps_scale: 1.0 + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_minb_full120.yaml b/configs/elongate_n100_no_cls_scaleinv_minb_full120.yaml new file mode 100644 index 0000000..38d0f41 --- /dev/null +++ b/configs/elongate_n100_no_cls_scaleinv_minb_full120.yaml @@ -0,0 +1,63 @@ +# median + min_b, NO topo_loss_max_points → full cloud (120 pts) for topo loss. +# Ablation: compare against minb_topo12 (12-pt subsampling). + +meta: + config_id: "elongate_n100_no_cls_scaleinv_minb_full120" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + w_min_b: 5.6525 + min_b_target: 0.05 + pos_weight: 1.0 + aniso_mode: "elongate" + topo_scale_mode: "median" + topo_eps_scale: 1.0 + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + # topo_loss_max_points なし → 全120点 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_minb_topo12.yaml b/configs/elongate_n100_no_cls_scaleinv_minb_topo12.yaml new file mode 100644 index 0000000..4567684 --- /dev/null +++ b/configs/elongate_n100_no_cls_scaleinv_minb_topo12.yaml @@ -0,0 +1,62 @@ +# scaleinv + min_b + topo_loss_max_points:12 (step 3 ablation, no strong aniso). + +meta: + config_id: "elongate_n100_no_cls_scaleinv_minb_topo12" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + w_min_b: 5.6525 + min_b_target: 0.05 + pos_weight: 1.0 + aniso_mode: "elongate" + topo_scale_mode: "median" + topo_eps_scale: 1.0 + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + topo_loss_max_points: 12 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_stronganiso.yaml b/configs/elongate_n100_no_cls_scaleinv_stronganiso.yaml new file mode 100644 index 0000000..7449844 --- /dev/null +++ b/configs/elongate_n100_no_cls_scaleinv_stronganiso.yaml @@ -0,0 +1,61 @@ +# scaleinv + min_b + legacy-strong aniso (step 2 ablation). + +meta: + config_id: "elongate_n100_no_cls_scaleinv_stronganiso" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 4.5307 + w_size: 0.488 + w_min_b: 5.6525 + min_b_target: 0.05 + pos_weight: 1.0 + aniso_mode: "elongate" + topo_scale_mode: "median" + topo_eps_scale: 1.0 + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_topo12_only.yaml b/configs/elongate_n100_no_cls_topo12_only.yaml new file mode 100644 index 0000000..2b7f635 --- /dev/null +++ b/configs/elongate_n100_no_cls_topo12_only.yaml @@ -0,0 +1,60 @@ +# topo12 ONLY ablation: no median (fix1), no min_b (fix2). +# Baseline elongate_n100_no_cls + topo_loss_max_points:12. + +meta: + config_id: "elongate_n100_no_cls_topo12_only" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: "elongate" + # no topo_scale_mode / w_min_b + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + topo_loss_max_points: 12 + selection: + metric: wdist + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca.yaml b/configs/elongate_n100_no_cls_tune_local_pca.yaml new file mode 100644 index 0000000..eab017e --- /dev/null +++ b/configs/elongate_n100_no_cls_tune_local_pca.yaml @@ -0,0 +1,57 @@ +# Optuna tune base: no_cls + full-cloud topo + local_pca ellphi teacher + val_topo ckpt. +# 20-epoch proxy; hyperparams (w_*, lr) are searched by tune_elongate_wdist.py. + +meta: + config_id: "elongate_n100_no_cls_tune_local_pca" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden per trial (--backend ellphi) + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 # placeholder; Optuna search replaces + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: "elongate" + teacher_mode: local_pca + teacher_local_pca_k: 10 + +training: + lr: 0.000158 + epochs: 20 + grad_clip_value: 1.0 + visualize_every: 10000 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/prod.yaml b/configs/prod.yaml index 960d271..61f6a2c 100644 --- a/configs/prod.yaml +++ b/configs/prod.yaml @@ -42,4 +42,4 @@ device: "cpu" outputs: base_dir: "outputs/prod" - save_every: 10 + save_every: 1 diff --git a/experiments/_bench_topo_workers.py b/experiments/_bench_topo_workers.py new file mode 100644 index 0000000..7d80ea0 --- /dev/null +++ b/experiments/_bench_topo_workers.py @@ -0,0 +1,103 @@ +"""TEMP benchmark: verify intra-batch topo-loss threading (correctness + speed). + +Delete after use. Compares workers=1 vs workers=8 on identical inputs: +- max abs diff of topo loss value +- max abs diff of gradients w.r.t. model parameters +- per-batch wall time for a few full train steps (fwd + bwd) +""" + +from __future__ import annotations + +import time + +import torch + +from tda_ml.config import load_config +from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader +from tda_ml.models import AnisotropicOutlierClassifier +from tda_ml.config import model_kwargs_from_config +from tda_ml.losses import TopologicalLoss +from tda_ml.trainer import Trainer + + +def get_batches(cfg, n_clouds, batch_size): + data_cfg = cfg["data"] + gen = torch.Generator().manual_seed(0) + idx = torch.randperm(60000, generator=gen)[:n_clouds] + ds = NoisyMNISTDataset( + root="./data", train=True, max_points=data_cfg["max_points"], + num_outliers=data_cfg["num_outliers"], indices=idx, + deterministic=True, noise_seed=42, + ) + return create_data_loader(ds, batch_size=batch_size, shuffle=False, num_workers=0) + + +def grad_vector(model): + return torch.cat([ + p.grad.detach().reshape(-1) if p.grad is not None else torch.zeros_like(p).reshape(-1) + for p in model.parameters() + ]) + + +def run_once(model, trainer, topo_fn, data, clean_pc): + model.zero_grad(set_to_none=True) + logits, params = model(data) + clean_pd_info = trainer._compute_clean_pd_info(clean_pc) + loss = topo_fn(data, params, logits, clean_pd_info) + loss.backward() + return float(loss.detach()), grad_vector(model) + + +def main(): + torch.manual_seed(0) + cfg = load_config("_conv_n100") + device = torch.device("cpu") + batch_size = cfg["data"]["batch_size"] + + loader = get_batches(cfg, n_clouds=batch_size * 3, batch_size=batch_size) + batches = [(d, c) for d, _, c in loader] + + model = AnisotropicOutlierClassifier(**model_kwargs_from_config(cfg)).to(device) + trainer = Trainer(model, cfg, device=device) + + # The multiseed driver overrides the backend to ellphi at runtime; match that + # here so the benchmark exercises the per-sample ellphi path (not vectorized mahalanobis). + backend = "ellphi" + print(f"backend={backend} batch_size={batch_size} max_points={cfg['data']['max_points']}") + + topo1 = TopologicalLoss(weight=cfg["loss"]["w_topo"], distance_backend=backend, + ellphi_differentiable=True, topo_workers=1) + topo8 = TopologicalLoss(weight=cfg["loss"]["w_topo"], distance_backend=backend, + ellphi_differentiable=True, topo_workers=8) + + # --- correctness on one batch --- + d, c = batches[0] + d, c = d.to(device), c.to(device) + l1, g1 = run_once(model, trainer, topo1, d, c) + l8, g8 = run_once(model, trainer, topo8, d, c) + print(f"[correctness] loss w1={l1:.8f} w8={l8:.8f} |dloss|={abs(l1-l8):.3e}") + print(f"[correctness] max|dgrad|={ (g1-g8).abs().max().item():.3e} " + f"max|grad|={g1.abs().max().item():.3e}") + + # --- speed: time a few full steps --- + def timed(topo_fn, label): + # warmup + d0, c0 = batches[0] + run_once(model, trainer, topo_fn, d0.to(device), c0.to(device)) + t0 = time.perf_counter() + n = 0 + for d, c in batches: + run_once(model, trainer, topo_fn, d.to(device), c.to(device)) + n += 1 + dt = (time.perf_counter() - t0) / n + print(f"[speed] {label}: {dt:.2f} s/batch over {n} batches") + return dt + + t1 = timed(topo1, "workers=1") + t8 = timed(topo8, "workers=8") + print(f"[speed] speedup x{t1/t8:.2f} -> 30ep ETA " + f"@w8 = {t8*71*30/3600:.1f} h (val negligible)") + + +if __name__ == "__main__": + main() diff --git a/experiments/aggregate_paper_results.py b/experiments/aggregate_paper_results.py index 00a2cca..e1b234f 100644 --- a/experiments/aggregate_paper_results.py +++ b/experiments/aggregate_paper_results.py @@ -9,9 +9,9 @@ Usage:: uv run python experiments/aggregate_paper_results.py \\ - --proposed-dir outputs/paper_reproduce_1week_tuned \\ + --proposed-dir outputs/supervised/20260627/055936_paper_reproduce_1week_tuned \\ --wtopo0-dir outputs/paper_wtopo0 \\ - --baselines-csv outputs/paper_baselines/summary_baselines.csv + --baselines-csv outputs/supervised/20260623/155756_paper_baselines/summary_baselines.csv Writes ``summary_for_paper.csv`` and ``MANIFEST.md`` under ``--out-dir`` (default: ``--proposed-dir``). @@ -317,7 +317,7 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--proposed-dir", type=Path, - default=REPO_ROOT / "outputs" / "paper_reproduce_1week_tuned", + default=REPO_ROOT / "outputs" / "supervised" / "20260627" / "055936_paper_reproduce_1week_tuned", help="Main proposed runs (12ep × 5 seed, ellphi).", ) p.add_argument( @@ -329,7 +329,7 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--baselines-csv", type=Path, - default=REPO_ROOT / "outputs" / "paper_baselines" / "summary_baselines.csv", + default=REPO_ROOT / "outputs" / "supervised" / "20260623" / "155756_paper_baselines" / "summary_baselines.csv", ) p.add_argument( "--out-dir", diff --git a/experiments/apply_tune_best_to_configs.py b/experiments/apply_tune_best_to_configs.py new file mode 100755 index 0000000..7efc8b4 --- /dev/null +++ b/experiments/apply_tune_best_to_configs.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Apply Optuna best params from JSON into elongate ellphi-tuned YAML configs.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +CONFIGS = ( + "configs/elongate_n100_ellphi_tuned.yaml", + "configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml", + "configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml", +) + + +def _fmt_lr(x: float) -> str: + return f"{x:.6f}".rstrip("0").rstrip(".") + + +def _fmt_weight(x: float) -> str: + return f"{x:.4f}".rstrip("0").rstrip(".") + + +def _set_scalar_line(text: str, key: str, value: str, comment: str | None = None) -> str: + pat = re.compile(rf"^(\s*{re.escape(key)}:\s*)([^\s#]+)(.*)$", re.MULTILINE) + suffix = f" # {comment}" if comment else "" + + def repl(m: re.Match[str]) -> str: + return f"{m.group(1)}{value}{suffix}" + + new, n = pat.subn(repl, text, count=1) + if n != 1: + raise ValueError(f"could not update {key} in config") + return new + + +def _set_source_comment(text: str, source_line: str) -> str: + pat = re.compile(r"^# Source:.*$", re.MULTILINE) + if pat.search(text): + return pat.sub(f"# {source_line}", text, count=1) + # prepend after first comment block line + lines = text.splitlines(keepends=True) + for i, line in enumerate(lines): + if line.startswith("#") and i < 5: + continue + lines.insert(1, f"# {source_line}\n") + return "".join(lines) + return f"# {source_line}\n{text}" + + +def apply_best(json_path: Path, configs: tuple[str, ...] = CONFIGS) -> dict: + payload = json.loads(json_path.read_text()) + params = payload["best_params"] + w_topo = float(params["w_topo"]) + w_aniso = float(params["w_aniso"]) + w_size = float(params["w_size"]) + lr = float(params["lr"]) + best_wd = float(payload["best_value_wdist"]) + source = f"Source: {json_path} (v3 val_topo ckpt, val topo W-Dist={best_wd:.5f})" + + w_topo_s = _fmt_weight(w_topo) + w_aniso_s = _fmt_weight(w_aniso) + w_size_s = _fmt_weight(w_size) + lr_s = _fmt_lr(lr) + + updated = [] + for rel in configs: + path = REPO_ROOT / rel + text = path.read_text() + text = _set_source_comment(text, source) + text = _set_scalar_line(text, "w_topo", w_topo_s, "tuned ellphi v2") + text = _set_scalar_line(text, "w_aniso", w_aniso_s) + text = _set_scalar_line(text, "w_size", w_size_s) + text = _set_scalar_line(text, "lr", lr_s, "tuned ellphi v2") + path.write_text(text) + updated.append(str(path)) + + return { + "json": str(json_path), + "w_topo": w_topo_s, + "w_aniso": w_aniso_s, + "w_size": w_size_s, + "lr": lr_s, + "best_value_wdist": best_wd, + "configs": updated, + } + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--json", + type=Path, + default=REPO_ROOT / "outputs/tune_elongate_ellphi_local_pca_v3/best_elongate_wdist_ellphi.json", + ) + return p.parse_args() + + +def main() -> int: + args = parse_args() + if not args.json.is_file(): + raise SystemExit(f"missing {args.json}") + summary = apply_best(args.json.resolve()) + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/checkpoint_selection.py b/experiments/checkpoint_selection.py new file mode 100644 index 0000000..1cd05c0 --- /dev/null +++ b/experiments/checkpoint_selection.py @@ -0,0 +1,88 @@ +"""Select training checkpoints by topo loss column in metrics.csv (saved epochs only).""" + +from __future__ import annotations + +import csv +from pathlib import Path + +DEFAULT_TOPO_COLUMN = "train_topo_loss" + + +def topo_loss_column(metrics_path: Path) -> str: + """Prefer ``val_topo_loss`` when logged; else ``train_topo_loss``.""" + with metrics_path.open() as f: + reader = csv.DictReader(f) + fields = reader.fieldnames or () + if "val_topo_loss" in fields: + return "val_topo_loss" + return DEFAULT_TOPO_COLUMN + + +def list_checkpoint_epochs(run_dir: Path) -> set[int]: + """Epoch numbers with ``checkpoint_epoch_{k}.pth`` on disk.""" + prefix = "checkpoint_epoch_" + epochs: set[int] = set() + for path in run_dir.glob(f"{prefix}*.pth"): + suffix = path.stem.removeprefix(prefix) + if suffix.isdigit(): + epochs.add(int(suffix)) + return epochs + + +def best_topo_epoch( + metrics_path: Path, + *, + column: str | None = None, + saved_epochs: set[int] | None = None, +) -> tuple[int, float]: + """Return epoch with minimum topo loss, optionally restricted to saved ckpts.""" + col = column or topo_loss_column(metrics_path) + with metrics_path.open() as f: + rows = list(csv.DictReader(f)) + if not rows: + raise RuntimeError(f"empty metrics: {metrics_path}") + if col not in (rows[0].keys() if rows else ()): + raise RuntimeError(f"column {col!r} missing from {metrics_path}") + pairs = [(int(r["epoch"]), float(r[col])) for r in rows if r.get(col, "")] + if saved_epochs is not None: + pairs = [p for p in pairs if p[0] in saved_epochs] + if not pairs: + raise RuntimeError( + f"no metrics rows for saved checkpoints under {metrics_path.parent.parent} " + f"(saved epochs: {sorted(saved_epochs)})" + ) + return min(pairs, key=lambda t: t[1]) + + +def best_topo_checkpoint( + run_dir: Path, + *, + column: str | None = None, +) -> tuple[str, int, float, int | None, float | None]: + """Pick ``checkpoint_epoch_{k}.pth`` with minimum topo loss among saved ckpts. + + Returns: + (checkpoint_name, epoch, topo_loss, global_best_epoch, global_train_topo_loss) + Global fields are set when the unrestricted minimum lies on an unsaved epoch. + """ + run_dir = run_dir.resolve() + metrics_path = run_dir / "logs" / "metrics.csv" + if not metrics_path.is_file(): + raise FileNotFoundError(f"missing {metrics_path}") + + col = column or topo_loss_column(metrics_path) + saved_epochs = list_checkpoint_epochs(run_dir) + if not saved_epochs: + raise FileNotFoundError(f"no checkpoint_epoch_*.pth under {run_dir}") + + epoch, topo_loss = best_topo_epoch(metrics_path, column=col, saved_epochs=saved_epochs) + global_epoch, global_topo = best_topo_epoch(metrics_path, column=col) + global_epoch_out = global_epoch if global_epoch != epoch else None + global_topo_out = global_topo if global_epoch != epoch else None + return ( + f"checkpoint_epoch_{epoch}.pth", + epoch, + topo_loss, + global_epoch_out, + global_topo_out, + ) diff --git a/experiments/ellphi_retune_v2_autopipeline.sh b/experiments/ellphi_retune_v2_autopipeline.sh new file mode 100755 index 0000000..eaf7907 --- /dev/null +++ b/experiments/ellphi_retune_v2_autopipeline.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Autopipeline: (1) ellphi re-tune v2 → (2) update configs → (3) full120 30ep + eval +# +# Detached: +# bash experiments/launch_detached_screen.sh ellphi_v2_pipeline \ +# outputs/tune_elongate_ellphi_v2/autopipeline.log \ +# experiments/ellphi_retune_v2_autopipeline.sh + +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" + +N_WORKERS="${N_WORKERS:-8}" +N_TRIALS="${N_TRIALS:-50}" +TUNE_EPOCHS="${TUNE_EPOCHS:-20}" +BACKEND="${BACKEND:-ellphi}" +OUT_BASE="${OUT_BASE:-outputs/tune_elongate_ellphi_v2}" +BEST_JSON="${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" +POLL_SEC="${POLL_SEC:-120}" +LOG_DIR="${OUT_BASE}" + +log() { echo "[$(date -Iseconds)] $*"; } + +mkdir -p "${LOG_DIR}" + +log "=== STEP 1/3: re-tune v2 (${N_WORKERS} workers, ${N_TRIALS} trials, ${TUNE_EPOCHS}ep) ===" +bash experiments/run_retune_parallel.sh "${N_WORKERS}" "${N_TRIALS}" "${TUNE_EPOCHS}" "${BACKEND}" + +if [[ ! -f "${BEST_JSON}" ]]; then + log "ERROR: missing ${BEST_JSON} after tune" + exit 1 +fi +log "tune complete: ${BEST_JSON}" + +log "=== STEP 2/3: apply best params to configs ===" +uv run python experiments/apply_tune_best_to_configs.py --json "${BEST_JSON}" + +log "=== STEP 3/3: full120 30ep train + evaluate ===" +TUNE_JSON="${BEST_JSON}" bash experiments/run_ellphi_tuned_v2_full120_30ep.sh + +log "=== PIPELINE COMPLETE ===" diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index cbb67e4..da5bdb6 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 """ -Paper-aligned evaluation: ellphi DBSCAN inference + MCC / G-Mean / W-Dist. +Paper-aligned evaluation: ellphi DBSCAN inference + MCC / G-Mean / topo W-Dist. -Trainer ``val_mcc`` uses sigmoid(logit) > threshold; the paper reports metrics -after DBSCAN on the learned ellphi precomputed distance matrix. +W-Dist matches ``TopologicalLoss``: learned ellipses on the full noisy cloud vs +clean teacher PD (local PCA + ellphi by default). DBSCAN affects MCC only. Usage:: uv run python experiments/evaluate_paper_protocol.py \\ - --run-dir outputs/paper_reproduce_1week_tuned/backend_ellphi_seed42_* \\ + --run-dir outputs/supervised/20260627/055936_paper_reproduce_1week_tuned/backend_ellphi_seed42_* \\ --base-config reproduce \\ --split val @@ -36,6 +36,7 @@ from tda_ml.models import AnisotropicOutlierClassifier from tda_ml.seed_utils import set_global_seed from tda_ml.supervised_diagnostics import git_revision +from tda_ml.topo_wdist import TopoWdistOptions, topo_wdist_options_from_config REPO_ROOT = Path(__file__).resolve().parents[1] @@ -83,6 +84,7 @@ def evaluate_cloud_dbscan( min_samples: int, backend: str = "ellphi", metric: str = "max", + topo_options: TopoWdistOptions | None = None, ) -> CloudMetrics: db_labels = apply_anisotropic_dbscan( points, @@ -93,12 +95,13 @@ def evaluate_cloud_dbscan( backend=backend, ) pred = dbscan_labels_to_outlier_pred(db_labels) - gt_inliers = _valid_clean_inliers(clean_pc) recall, specificity, gmean, mcc, wdist = compute_recall_specificity_gmean_mcc_wdist( labels_gt, pred, points=points, - gt_inliers=gt_inliers, + params=params, + clean_pc=clean_pc, + topo_options=topo_options, ) return CloudMetrics(recall, specificity, gmean, mcc, wdist) @@ -125,7 +128,6 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) test_size = int(data_cfg.get("test_size", 1000)) generator = torch.Generator().manual_seed(seed) full_train_indices = torch.randperm(60000, generator=generator)[: train_size + val_size] - train_indices = full_train_indices[:train_size] val_indices = full_train_indices[train_size:] test_indices = torch.randperm(10000, generator=generator)[:test_size] @@ -164,8 +166,13 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) return loader -def load_model_from_run(run_dir: Path, config: dict[str, Any], device: torch.device) -> AnisotropicOutlierClassifier: - ckpt_path = run_dir / "best_model.pth" +def load_model_from_run( + run_dir: Path, + config: dict[str, Any], + device: torch.device, + checkpoint_name: str = "best_model.pth", +) -> AnisotropicOutlierClassifier: + ckpt_path = run_dir / checkpoint_name if not ckpt_path.is_file(): raise FileNotFoundError(f"Missing checkpoint: {ckpt_path}") model = AnisotropicOutlierClassifier(**model_kwargs_from_config(config)) @@ -205,6 +212,7 @@ def grid_search_dbscan( eps_values: list[float], min_samples_values: list[int], backend: str = "ellphi", + topo_options: TopoWdistOptions | None = None, ) -> tuple[float, int, float]: best_mcc = -1.0 best_eps = eps_values[0] @@ -224,6 +232,7 @@ def grid_search_dbscan( eps=eps, min_samples=min_samples, backend=backend, + topo_options=topo_options, ) per_cloud.append(m) except Exception as exc: # noqa: BLE001 — log and skip bad hparams @@ -268,10 +277,12 @@ def evaluate_split( eps_values: list[float] | None = None, min_samples_values: list[int] | None = None, backend: str = "ellphi", + checkpoint_name: str = "best_model.pth", ) -> SplitMetrics: loader = build_split_loader(config, split, device) - model = load_model_from_run(run_dir, config, device) + model = load_model_from_run(run_dir, config, device, checkpoint_name=checkpoint_name) clouds = list(iter_cloud_predictions(model, loader, device)) + topo_options = topo_wdist_options_from_config(config) if split == "val": eps_values = eps_values or list(np.linspace(0.15, 1.5, 15)) @@ -281,6 +292,7 @@ def evaluate_split( eps_values=eps_values, min_samples_values=min_samples_values, backend=backend, + topo_options=topo_options, ) else: if eps is None or min_samples is None: @@ -297,6 +309,7 @@ def evaluate_split( eps=float(eps), min_samples=int(min_samples), backend=backend, + topo_options=topo_options, ) ) recall, specificity, gmean, mcc, wdist = _aggregate_cloud_metrics(per_cloud) @@ -333,6 +346,35 @@ def parse_args() -> argparse.Namespace: p.add_argument("--split", choices=["val", "test"], required=True) p.add_argument("--seed", type=int, default=None, help="Override data.seed (else run_manifest)") p.add_argument("--backend", type=str, default="ellphi", choices=["ellphi", "mahalanobis"]) + p.add_argument( + "--checkpoint-name", + type=str, + default="best_model.pth", + help="Checkpoint filename inside run-dir (e.g. final_model.pth).", + ) + p.add_argument( + "--eps-values", + type=float, + nargs="+", + default=None, + help="Override DBSCAN eps grid for val grid search (default linspace(0.15,1.5,15)).", + ) + p.add_argument( + "--min-samples-values", + type=int, + nargs="+", + default=None, + help="Override DBSCAN min_samples grid for val grid search (default 3 5 7 10 15).", + ) + p.add_argument( + "--tag", + type=str, + default=None, + help=( + "Optional suffix for output files (dbscan_hparams_.json, " + "paper_metrics__.json) to avoid clobbering originals." + ), + ) p.add_argument( "--dbscan-hparams", type=Path, @@ -352,10 +394,13 @@ def main() -> int: device = torch.device("cuda" if torch.cuda.is_available() else "cpu") config = load_run_config(run_dir, args.base_config, args.seed) + tag_suffix = f"_{args.tag}" if args.tag else "" + hparams_name = f"dbscan_hparams{tag_suffix}.json" + eps = min_samples = None if args.split == "test": if args.dbscan_hparams is None: - args.dbscan_hparams = run_dir / "logs" / "dbscan_hparams.json" + args.dbscan_hparams = run_dir / "logs" / hparams_name hparams = json.loads(args.dbscan_hparams.read_text()) eps = float(hparams["eps"]) min_samples = int(hparams["min_samples"]) @@ -367,20 +412,24 @@ def main() -> int: device, eps=eps, min_samples=min_samples, + eps_values=args.eps_values, + min_samples_values=args.min_samples_values, backend=args.backend, + checkpoint_name=args.checkpoint_name, ) log_dir = run_dir / "logs" log_dir.mkdir(parents=True, exist_ok=True) if args.split == "val": - hparams_path = log_dir / "dbscan_hparams.json" + hparams_path = log_dir / hparams_name hparams_path.write_text( json.dumps( { "eps": metrics.dbscan_eps, "min_samples": metrics.dbscan_min_samples, "backend": metrics.backend, + "checkpoint_name": args.checkpoint_name, "mean_val_mcc_dbscan": metrics.mcc, "selection": "max mean cloud MCC on validation split", }, @@ -390,11 +439,12 @@ def main() -> int: ) print(f"Saved DBSCAN hparams: {hparams_path}") - out_path = args.out_json or log_dir / f"paper_metrics_{args.split}.json" + out_path = args.out_json or log_dir / f"paper_metrics_{args.split}{tag_suffix}.json" payload = { "source_revision": git_revision(REPO_ROOT), "run_dir": str(run_dir), "split": args.split, + "checkpoint_name": args.checkpoint_name, **asdict(metrics), } out_path.write_text(json.dumps(payload, indent=2) + "\n") diff --git a/experiments/evaluate_topo_checkpoint.py b/experiments/evaluate_topo_checkpoint.py new file mode 100644 index 0000000..97238ce --- /dev/null +++ b/experiments/evaluate_topo_checkpoint.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Evaluate a run using the epoch with minimum train_topo_loss (saved checkpoints). + +Selects checkpoint_epoch_{k}.pth where k minimizes train_topo_loss in metrics.csv, +runs val DBSCAN grid search, then test evaluation (paper protocol). +Optionally compares against a baseline run JSON output. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from checkpoint_selection import best_topo_checkpoint, best_topo_epoch, list_checkpoint_epochs + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def run_eval( + run_dir: Path, + base_config: str, + split: str, + backend: str, + checkpoint_name: str, + *, + tag: str | None = None, + dbscan_hparams: Path | None = None, +) -> Path: + cmd = [ + sys.executable, + str(REPO_ROOT / "experiments" / "evaluate_paper_protocol.py"), + "--run-dir", + str(run_dir), + "--base-config", + base_config, + "--split", + split, + "--backend", + backend, + "--checkpoint-name", + checkpoint_name, + ] + if tag: + cmd.extend(["--tag", tag]) + if dbscan_hparams is not None: + cmd.extend(["--dbscan-hparams", str(dbscan_hparams)]) + subprocess.run(cmd, cwd=REPO_ROOT, check=True) + suffix = f"_{tag}" if tag else "" + name = f"paper_metrics_{split}{suffix}.json" + return run_dir / "logs" / name + + +def dbscan_hparams_path(run_dir: Path, tag: str | None) -> Path: + suffix = f"_{tag}" if tag else "" + return run_dir / "logs" / f"dbscan_hparams{suffix}.json" + + +def load_metrics_json(path: Path) -> dict: + return json.loads(path.read_text()) + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--run-dir", type=Path, required=True) + p.add_argument("--base-config", type=str, required=True) + p.add_argument("--backend", type=str, default="ellphi", choices=["ellphi", "mahalanobis"]) + p.add_argument("--tag", type=str, default="topo_best") + p.add_argument("--baseline-run-dir", type=Path, default=None) + p.add_argument("--baseline-config", type=str, default=None) + p.add_argument("--baseline-tag", type=str, default="topo_best") + p.add_argument( + "--compare-json", + type=Path, + default=None, + help="Write side-by-side test metrics JSON (default: run-dir/logs/ellphi_tuned_vs_baseline_test.json)", + ) + return p.parse_args() + + +def main() -> int: + args = parse_args() + run_dir = args.run_dir.resolve() + metrics_path = run_dir / "logs" / "metrics.csv" + if not metrics_path.is_file(): + raise SystemExit(f"missing {metrics_path}") + + saved_epochs = list_checkpoint_epochs(run_dir) + if not saved_epochs: + raise SystemExit(f"no checkpoint_epoch_*.pth under {run_dir}") + + ckpt, epoch, topo_loss, global_epoch, global_topo = best_topo_checkpoint(run_dir) + ckpt_path = run_dir / ckpt + if not ckpt_path.is_file(): + raise SystemExit(f"missing checkpoint {ckpt_path} (best saved-topo ep={epoch})") + + print( + f"[topo] best saved epoch={epoch} train_topo_loss={topo_loss:.6f} checkpoint={ckpt}" + ) + if global_epoch is not None and global_topo is not None: + print( + f"[topo] note: global train_topo min is ep={global_epoch} " + f"(loss={global_topo:.6f}) but no checkpoint on disk" + ) + + run_eval( + run_dir, + args.base_config, + "val", + args.backend, + ckpt, + tag=args.tag, + ) + hparams_path = dbscan_hparams_path(run_dir, args.tag) + if not hparams_path.is_file(): + raise SystemExit(f"missing {hparams_path} (expected after val DBSCAN grid search)") + test_json = run_eval( + run_dir, + args.base_config, + "test", + args.backend, + ckpt, + tag=args.tag, + dbscan_hparams=hparams_path, + ) + tuned = load_metrics_json(test_json) + print(f"[test] MCC={tuned['mcc']:.4f} W-Dist={tuned['wdist']:.4f} recall={tuned['recall']:.4f}") + + out: dict = { + "run_dir": str(run_dir), + "base_config": args.base_config, + "backend": args.backend, + "best_topo_epoch": epoch, + "train_topo_loss": topo_loss, + "checkpoint_name": ckpt, + "test_metrics": tuned, + "val_dbscan_hparams": json.loads(hparams_path.read_text()), + } + + if args.baseline_run_dir is not None: + base_dir = args.baseline_run_dir.resolve() + base_config = args.baseline_config or args.base_config + base_metrics = None + for candidate in ( + base_dir / "logs" / "paper_metrics_test_topo_ep30.json", + base_dir / "logs" / f"paper_metrics_test_{args.baseline_tag}.json", + ): + if candidate.is_file(): + base_metrics = load_metrics_json(candidate) + print(f"[baseline] loaded existing {candidate}") + break + if base_metrics is None: + base_saved = list_checkpoint_epochs(base_dir) + if not base_saved: + raise SystemExit(f"no checkpoint_epoch_*.pth under {base_dir}") + base_epoch, base_topo = best_topo_epoch( + base_dir / "logs" / "metrics.csv", + saved_epochs=base_saved, + ) + base_ckpt = f"checkpoint_epoch_{base_epoch}.pth" + print( + f"[baseline] evaluating ep={base_epoch} train_topo={base_topo:.6f} ckpt={base_ckpt}" + ) + run_eval(base_dir, base_config, "val", args.backend, base_ckpt, tag=args.baseline_tag) + hp = dbscan_hparams_path(base_dir, args.baseline_tag) + base_test_path = run_eval( + base_dir, + base_config, + "test", + args.backend, + base_ckpt, + tag=args.baseline_tag, + dbscan_hparams=hp, + ) + base_metrics = load_metrics_json(base_test_path) + baseline = base_metrics + out["baseline"] = { + "run_dir": str(base_dir), + "base_config": base_config, + "test_metrics": baseline, + } + print( + f"[baseline test] MCC={baseline['mcc']:.4f} W-Dist={baseline['wdist']:.4f} " + f"recall={baseline['recall']:.4f}" + ) + + compare_path = args.compare_json or (run_dir / "logs" / "ellphi_tuned_vs_baseline_test.json") + compare_path.parent.mkdir(parents=True, exist_ok=True) + compare_path.write_text(json.dumps(out, indent=2) + "\n") + print(f"wrote {compare_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/launch_detached_screen.sh b/experiments/launch_detached_screen.sh new file mode 100755 index 0000000..9a5bca2 --- /dev/null +++ b/experiments/launch_detached_screen.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Detached screen launcher for long jobs (SSH logout / terminal close safe). +# Note: machine reboot or power-off still stops the job. +# +# Usage: +# bash experiments/launch_detached_screen.sh SESSION_NAME LOG_FILE SCRIPT.sh [args...] +# +# Example: +# bash experiments/launch_detached_screen.sh ellphi_tuned30 \ +# outputs/supervised/20260703_ellphi_tuned_full120_30ep_screen/driver.log \ +# experiments/run_ellphi_tuned_full120_30ep.sh + +set -euo pipefail + +if [[ $# -lt 3 ]]; then + echo "usage: $0 SESSION_NAME LOG_FILE SCRIPT [args...]" >&2 + exit 1 +fi + +SESSION="$1" +LOG="$2" +shift 2 + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +mkdir -p "$(dirname "${ROOT}/${LOG}")" + +if screen -ls | grep -q "[[:space:]]\+[0-9]*\.${SESSION}[[:space:]]"; then + echo "screen session already exists: ${SESSION}" >&2 + screen -ls | grep "${SESSION}" || true + exit 1 +fi + +export PATH="${HOME}/.local/bin:${PATH}" + +# nohup inside screen: survives SIGHUP if the shell layer exits unexpectedly. +screen -dmS "${SESSION}" bash -lc " + cd '${ROOT}' + export PATH='${HOME}/.local/bin:'\${PATH} + exec nohup $(printf '%q ' "$@") >> '${ROOT}/${LOG}' 2>&1 +" + +echo "started screen session: ${SESSION}" +echo "log: ${ROOT}/${LOG}" +echo "reattach: screen -r ${SESSION}" +echo "detach: Ctrl+A then D" diff --git a/experiments/plot_distance_matrix.py b/experiments/plot_distance_matrix.py new file mode 100644 index 0000000..b1e7616 --- /dev/null +++ b/experiments/plot_distance_matrix.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Side-by-side heatmaps of Mahalanobis vs ellphi anisotropic distance matrices.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +import torch + +from tda_ml.config import load_config +from tda_ml.data_loader import NoisyMNISTDataset +from tda_ml.dbscan import calculate_anisotropic_distance_matrix +from tda_ml.model_inference import load_model + +REPO_ROOT = Path(__file__).resolve().parents[1] + +INLIER_COLOR = "#2166ac" +OUTLIER_COLOR = "#b2182b" + + +def _build_val_dataset(config: dict, sample_idx: int) -> tuple[np.ndarray, np.ndarray, int]: + data_cfg = config["data"] + seed = int(data_cfg.get("seed", 42)) + train_size = int(data_cfg.get("train_size", 4500)) + val_size = int(data_cfg.get("val_size", 500)) + generator = torch.Generator().manual_seed(seed) + full_train_indices = torch.randperm(60000, generator=generator)[: train_size + val_size] + val_indices = full_train_indices[train_size:] + + dataset = NoisyMNISTDataset( + root=str(REPO_ROOT / "data"), + train=True, + max_points=data_cfg["max_points"], + num_outliers=data_cfg["num_outliers"], + indices=val_indices, + deterministic=True, + noise_seed=seed, + preload=True, + ) + idx = sample_idx % len(dataset) + data, labels, _clean = dataset[idx] + return data.numpy(), labels.numpy().astype(int).flatten(), idx + + +def _sort_by_label(points: np.ndarray, labels: np.ndarray, params: np.ndarray): + order = np.argsort(labels, kind="stable") + return points[order], labels[order], params[order] + + +def _off_diagonal(dm: np.ndarray) -> np.ndarray: + return dm[np.triu_indices(dm.shape[0], k=1)] + + +def _matrix_stats(dm: np.ndarray) -> dict[str, float]: + off = _off_diagonal(dm) + return { + "min": float(off.min()), + "median": float(np.median(off)), + "mean": float(off.mean()), + "max": float(off.max()), + } + + +def _normalize_matrix(dm: np.ndarray, mode: str) -> tuple[np.ndarray, float]: + """Return (normalized matrix, scale factor applied to off-diagonal pairs).""" + if mode == "none": + return dm, 1.0 + off = _off_diagonal(dm) + if mode == "median": + scale = float(np.median(off)) + elif mode == "max": + scale = float(off.max()) + elif mode == "mean": + scale = float(off.mean()) + else: + raise ValueError(f"Unknown normalize mode: {mode!r}") + scale = max(scale, 1e-12) + out = dm.copy() + n = dm.shape[0] + mask = ~np.eye(n, dtype=bool) + out[mask] = dm[mask] / scale + return out, scale + + +def plot_distance_matrices( + points: np.ndarray, + params: np.ndarray, + labels: np.ndarray, + *, + output_path: Path, + title_suffix: str = "", + normalize: str = "none", +) -> None: + dm_maha = calculate_anisotropic_distance_matrix( + points, params, metric="max", probs=None, backend="mahalanobis" + ) + dm_ell = calculate_anisotropic_distance_matrix( + points, params, metric="max", probs=None, backend="ellphi" + ) + + dm_maha, scale_m = _normalize_matrix(dm_maha, normalize) + dm_ell, scale_e = _normalize_matrix(dm_ell, normalize) + ratio = dm_ell / np.clip(dm_maha, 1e-12, None) + + stats_m = _matrix_stats(dm_maha) + stats_e = _matrix_stats(dm_ell) + stats_r = _matrix_stats(ratio) + + n = points.shape[0] + n_in = int((labels == 0).sum()) + n_out = int((labels == 1).sum()) + boundary = n_in - 0.5 + + norm_note = "" + if normalize != "none": + norm_note = f" (normalized by off-diagonal {normalize}; scales maha={scale_m:.3f}, ellphi={scale_e:.3f})" + + vmax = max(stats_m["max"], stats_e["max"]) + fig, axes = plt.subplots(1, 3, figsize=(16, 5.2), constrained_layout=True) + + maha_title = "Mahalanobis" + (f" / {normalize}" if normalize != "none" else "") + ell_title = "ellphi (tangency)" + (f" / {normalize}" if normalize != "none" else "") + + for ax, mat, title, stats, cmap in ( + (axes[0], dm_maha, maha_title, stats_m, "viridis"), + (axes[1], dm_ell, ell_title, stats_e, "viridis"), + (axes[2], ratio, "ellphi / Mahalanobis", stats_r, "coolwarm"), + ): + if cmap == "viridis": + im = ax.imshow(mat, cmap=cmap, vmin=0, vmax=vmax) + else: + rlim = max(abs(stats_r["min"] - 1.0), abs(stats_r["max"] - 1.0), 0.05) + im = ax.imshow(mat, cmap=cmap, vmin=1.0 - rlim, vmax=1.0 + rlim) + ax.axhline(boundary, color="white", lw=0.8, alpha=0.9) + ax.axvline(boundary, color="white", lw=0.8, alpha=0.9) + ax.set_title( + f"{title}\n" + f"min={stats['min']:.3f} med={stats['median']:.3f} " + f"mean={stats['mean']:.3f} max={stats['max']:.3f}", + fontsize=10, + ) + ax.set_xlabel("point j") + ax.set_ylabel("point i") + fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + + suptitle = ( + f"Anisotropic distance matrices (N={n}, inlier={n_in}, outlier={n_out})" + f"{': ' + title_suffix if title_suffix else ''}{norm_note}\n" + "Rows/cols sorted: inliers (blue region) then outliers (red region); " + "white lines mark the inlier|outlier boundary." + ) + fig.suptitle(suptitle, fontsize=11) + + # Label strip on the left panel + strip = np.where(labels == 0, 0, 1)[None, :] + inset = axes[0].inset_axes([-0.12, 0.0, 0.04, 1.0]) + inset.imshow(strip, aspect="auto", cmap=plt.matplotlib.colors.ListedColormap([INLIER_COLOR, OUTLIER_COLOR])) + inset.set_xticks([]) + inset.set_yticks([]) + inset.set_ylabel("GT", rotation=0, labelpad=12, va="center") + + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output_path, dpi=160, bbox_inches="tight") + plt.close(fig) + print(f"Saved: {output_path}") + print( + f" Mahalanobis median={stats_m['median']:.4f} mean={stats_m['mean']:.4f}\n" + f" ellphi median={stats_e['median']:.4f} mean={stats_e['mean']:.4f}\n" + f" ratio median={stats_r['median']:.4f} mean={stats_r['mean']:.4f}" + ) + + +def plot_point_cloud( + points: np.ndarray, + params: np.ndarray, + labels: np.ndarray, + *, + output_path: Path, + title_suffix: str = "", +) -> None: + """Two panels: GT-labeled scatter, and the same points with learned ellipses.""" + in_m = labels == 0 + out_m = labels == 1 + + fig, axes = plt.subplots(1, 2, figsize=(11, 5.5), constrained_layout=True) + + ax0 = axes[0] + ax0.scatter(points[in_m, 0], points[in_m, 1], c=INLIER_COLOR, s=14, label=f"Inlier ({in_m.sum()})") + ax0.scatter(points[out_m, 0], points[out_m, 1], c=OUTLIER_COLOR, s=14, label=f"Outlier ({out_m.sum()})") + ax0.set_title("GT labels") + ax0.legend(loc="upper right", fontsize=8) + + ax1 = axes[1] + ax1.scatter(points[in_m, 0], points[in_m, 1], c=INLIER_COLOR, s=10) + ax1.scatter(points[out_m, 0], points[out_m, 1], c=OUTLIER_COLOR, s=10) + t = np.linspace(0, 2 * np.pi, 60) + for k in range(len(points)): + a, b, theta = params[k] + x_e = a * np.cos(t) + y_e = b * np.sin(t) + x_r = x_e * np.cos(theta) - y_e * np.sin(theta) + points[k, 0] + y_r = x_e * np.sin(theta) + y_e * np.cos(theta) + points[k, 1] + color = OUTLIER_COLOR if labels[k] == 1 else INLIER_COLOR + ax1.plot(x_r, y_r, color=color, alpha=0.35, linewidth=0.8) + ax1.set_title("Learned ellipses (GT-colored)") + + for ax in axes: + ax.set_aspect("equal") + ax.set_xlim(-1.2, 1.2) + ax.set_ylim(-1.2, 1.2) + + fig.suptitle(f"Point cloud used for the distance matrices{': ' + title_suffix if title_suffix else ''}", fontsize=11) + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output_path, dpi=160, bbox_inches="tight") + plt.close(fig) + print(f"Saved: {output_path}") + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--config", default="elongate_n100_no_cls_full120_ellphi_tuned") + p.add_argument("--weights", type=Path, required=True) + p.add_argument("--sample-idx", type=int, default=0) + p.add_argument( + "--output", + type=Path, + default=REPO_ROOT / "outputs" / "images" / "distance_matrix_maha_vs_ellphi.png", + ) + p.add_argument("--device", default="cpu") + p.add_argument( + "--normalize", + choices=["none", "median", "mean", "max"], + default="none", + help="Scale off-diagonal entries by median/mean/max of upper triangle (diagonal stays 0).", + ) + p.add_argument( + "--cloud-output", + type=Path, + default=None, + help="Also save a scatter plot (GT labels + learned ellipses) of the point cloud.", + ) + return p.parse_args() + + +def main() -> int: + args = parse_args() + config = load_config(args.config) + device = torch.device(args.device) + + points, labels, dataset_idx = _build_val_dataset(config, args.sample_idx) + model = load_model(device, weights_path=str(args.weights)) + with torch.no_grad(): + data_t = torch.as_tensor(points, dtype=torch.float32, device=device).unsqueeze(0) + _logits, params_t = model(data_t) + params = params_t.squeeze(0).cpu().numpy() + + points, labels, params = _sort_by_label(points, labels, params) + title_suffix = f"val idx={dataset_idx}, weights={args.weights.parent.name}" + plot_distance_matrices( + points, + params, + labels, + output_path=args.output, + title_suffix=title_suffix, + normalize=args.normalize, + ) + if args.cloud_output is not None: + plot_point_cloud( + points, + params, + labels, + output_path=args.cloud_output, + title_suffix=title_suffix, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/run_ellphi_tuned_full120_30ep.sh b/experiments/run_ellphi_tuned_full120_30ep.sh new file mode 100755 index 0000000..86ccd4c --- /dev/null +++ b/experiments/run_ellphi_tuned_full120_30ep.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Train ellphi 30ep with tuned hyperparams, then evaluate vs maha-hparam baseline. +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" +export OMP_NUM_THREADS="${OMP_NUM_THREADS:-4}" + +OUT_BASE="outputs/supervised/20260703_ellphi_tuned_full120_30ep_screen" +BASELINE_PARENT="outputs/supervised/20260701/190250_elongate_n100_no_cls_full120_baseline_ellphi_30ep_screen" +CONFIG="elongate_n100_no_cls_full120_ellphi_tuned" +BASELINE_CONFIG="elongate_n100_no_cls_full120_baseline" + +mkdir -p "$OUT_BASE" +cat > "${OUT_BASE}/PURPOSE.md" <<'EOF' +# ellphi full120 30ep with Optuna-tuned hyperparams (ellphi backend) + +Compare against `.../190250_..._baseline_ellphi_30ep_screen` (same config shape, maha-tuned weights). + +Tune source: `outputs/tune_elongate_ellphi/best_elongate_wdist_ellphi.json` +- w_topo=0.2088, w_aniso=0.0843, w_size=0.4446, lr=0.000287 + +Post-train: `experiments/evaluate_topo_checkpoint.py` (train_topo_loss min checkpoint → test). +EOF + +echo "=== train $(date -Iseconds) ===" +uv run python experiments/run_backend_multiseed.py \ + --base-config "$CONFIG" \ + --backends ellphi \ + --seeds 42 \ + --epochs 30 \ + --out-base "$OUT_BASE" + +RUN_DIR=$(ls -d "${OUT_BASE}"/backend_ellphi_seed42_* | tail -1) +BASELINE_DIR=$(ls -d "${BASELINE_PARENT}"/backend_ellphi_seed42_* | tail -1) + +echo "=== evaluate $(date -Iseconds) ===" +uv run python experiments/evaluate_topo_checkpoint.py \ + --run-dir "$RUN_DIR" \ + --base-config "$CONFIG" \ + --backend ellphi \ + --baseline-run-dir "$BASELINE_DIR" \ + --baseline-config "$BASELINE_CONFIG" \ + --compare-json "${RUN_DIR}/logs/ellphi_tuned_vs_maha_hparams_test.json" + +echo "=== done $(date -Iseconds) ===" +echo "run_dir=$RUN_DIR" +echo "compare=${RUN_DIR}/logs/ellphi_tuned_vs_maha_hparams_test.json" diff --git a/experiments/run_ellphi_tuned_v2_full120_30ep.sh b/experiments/run_ellphi_tuned_v2_full120_30ep.sh new file mode 100755 index 0000000..4249f5b --- /dev/null +++ b/experiments/run_ellphi_tuned_v2_full120_30ep.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# 30ep full120 with v2 ellphi-tuned hyperparams + paper eval (train topo ckpt). +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" +export OMP_NUM_THREADS="${OMP_NUM_THREADS:-4}" + +OUT_BASE="${OUT_BASE:-outputs/supervised/20260704_ellphi_tuned_v2_full120_30ep_screen}" +BASELINE_PARENT="${BASELINE_PARENT:-outputs/supervised/20260701/190250_elongate_n100_no_cls_full120_baseline_ellphi_30ep_screen}" +CONFIG="${CONFIG:-elongate_n100_no_cls_full120_ellphi_tuned}" +BASELINE_CONFIG="${BASELINE_CONFIG:-elongate_n100_no_cls_full120_baseline}" +TUNE_JSON="${TUNE_JSON:-outputs/tune_elongate_ellphi_v2/best_elongate_wdist_ellphi.json}" + +mkdir -p "$OUT_BASE" +cat > "${OUT_BASE}/PURPOSE.md" < "${OUT_BASE}/PURPOSE.md" < "${OUT_BASE}/worker_${i}.log" 2>&1 & + pids+=($!) + echo " worker ${i}: PID $!, seed ${SEED}, log ${OUT_BASE}/worker_${i}.log" + sleep 1 +done + +echo "Waiting for ${N_WORKERS} workers..." +for pid in "${pids[@]}"; do + wait "${pid}" || echo " worker PID ${pid} exited non-zero" +done + +echo "Writing best params..." +uv run python -u experiments/tune_elongate_wdist.py \ + --base-config elongate_n100 \ + --n-trials "${N_TRIALS}" \ + --tune-epochs "${TUNE_EPOCHS}" \ + --backend "${BACKEND}" \ + --out-base "${OUT_BASE}" \ + --storage "${STORAGE}" \ + --study-name "${STUDY_NAME}" \ + --write-best + +echo "Done: ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" diff --git a/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py b/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py new file mode 100755 index 0000000..3e96754 --- /dev/null +++ b/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""10-epoch smoke: ellphi + local_pca teacher. Logs init Wasserstein before training.""" + +from __future__ import annotations + +import argparse +import json +import logging +from pathlib import Path + +import torch +from torch_topological.nn import VietorisRipsComplex, WassersteinDistance + +from tda_ml.config import deep_update, load_config, model_kwargs_from_config +from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader +from tda_ml.distance_backend import compute_distance_matrix_batch +from tda_ml.main import _configure_torch_runtime, _resolve_dataloader_settings, main as train_main +from tda_ml.models import AnisotropicOutlierClassifier +from tda_ml.seed_utils import set_global_seed +from tda_ml.trainer import Trainer + +REPO_ROOT = Path(__file__).resolve().parents[1] +logger = logging.getLogger(__name__) + + +def _init_wasserstein( + trainer: Trainer, + loader, + device: torch.device, +) -> dict[str, float]: + """Mean init Wasserstein^2 between pred PD and teacher PD (one train batch).""" + model = trainer.model + model.eval() + vr = VietorisRipsComplex(dim=1) + wdist_fn = WassersteinDistance(q=2) + topo_fn = trainer.topo_loss_fn + + data, _labels, clean_pc = next(iter(loader)) + data = data.to(device) + clean_pc = clean_pc.to(device) + + with torch.no_grad(): + logits, params = model(data) + clean_pd, clean_scales = trainer._compute_clean_pd_info(clean_pc) + + per_sample: list[float] = [] + with torch.no_grad(): + for i in range(data.shape[0]): + pts_i, par_i, logits_i = topo_fn._subsample_points( + data[i], params[i], logits[i] + ) + probs_i = ( + torch.sigmoid(logits_i).squeeze(-1) + if topo_fn.prob_weighting + else None + ) + d_batch = compute_distance_matrix_batch( + pts_i.unsqueeze(0), + par_i.unsqueeze(0), + probs=probs_i.unsqueeze(0) if probs_i is not None else None, + symmetrize="max", + backend=topo_fn.distance_backend, + ellphi_differentiable=topo_fn.ellphi_differentiable, + ) + clean_scale_i = clean_scales[i] if clean_scales is not None else None + d_mat = topo_fn._rescale_distance_matrix(d_batch[0], clean_scale=clean_scale_i) + pd_pred = vr(d_mat, treat_as_distances=True) + w2 = float(wdist_fn(pd_pred, clean_pd[i]) ** 2) + per_sample.append(w2) + + return { + "n_samples": len(per_sample), + "wasserstein2_mean": float(sum(per_sample) / len(per_sample)), + "wasserstein2_min": float(min(per_sample)), + "wasserstein2_max": float(max(per_sample)), + "teacher_mode": trainer.teacher_mode, + "distance_backend": trainer.distance_backend, + } + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--config", + default="configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml", + ) + p.add_argument("--epochs", type=int, default=10) + p.add_argument("--seed", type=int, default=42) + p.add_argument( + "--out-base", + type=Path, + default=REPO_ROOT / "outputs/supervised/20260705_teacher_local_pca_ellphi_10ep", + ) + p.add_argument("--backend", default="ellphi", choices=["ellphi", "mahalanobis"]) + return p.parse_args() + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + args = parse_args() + set_global_seed(args.seed) + + cfg = load_config(args.config, project_root=REPO_ROOT) + cfg = deep_update( + cfg, + { + "training": {"epochs": args.epochs}, + "data": {"seed": args.seed}, + "model": { + "topology_loss": { + "distance_backend": args.backend, + "prob_weighting": False, + } + }, + "loss": { + "teacher_mode": "local_pca", + "w_class": 0.0, + }, + "outputs": {"base_dir": str(args.out_base)}, + "meta": {"config_id": f"teacher_local_pca_{args.backend}_seed{args.seed}"}, + }, + ) + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + _configure_torch_runtime(cfg, device) + num_workers, pin_memory, persistent_workers, prefetch_factor = _resolve_dataloader_settings( + cfg, device + ) + data_cfg = cfg["data"] + train_ds = NoisyMNISTDataset( + root="./data", + train=True, + num_samples=data_cfg["train_size"], + max_points=data_cfg["max_points"], + num_outliers=data_cfg["num_outliers"], + noise_std=data_cfg["noise_std"], + deterministic=True, + noise_seed=data_cfg["seed"], + ) + loader = create_data_loader( + train_ds, + batch_size=min(8, data_cfg["batch_size"]), + shuffle=True, + num_workers=num_workers, + pin_memory=pin_memory, + persistent_workers=persistent_workers, + prefetch_factor=prefetch_factor, + ) + + model = AnisotropicOutlierClassifier(**model_kwargs_from_config(cfg)).to(device) + trainer = Trainer(model, cfg, device=device) + init_stats = _init_wasserstein(trainer, loader, device) + logger.info("Init teacher Wasserstein^2: %s", json.dumps(init_stats, indent=2)) + + out_base = Path(args.out_base) + out_base.mkdir(parents=True, exist_ok=True) + init_path = out_base / f"init_wasserstein_{args.backend}_seed{args.seed}.json" + init_path.write_text(json.dumps(init_stats, indent=2) + "\n", encoding="utf-8") + logger.info("Wrote %s", init_path) + + train_main(config=cfg) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/run_tune_local_pca_parallel.sh b/experiments/run_tune_local_pca_parallel.sh new file mode 100755 index 0000000..ff0fa2f --- /dev/null +++ b/experiments/run_tune_local_pca_parallel.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Optuna tune: local_pca ellphi teacher, no_cls, val_topo checkpoint, topo W-Dist objective. +# +# Usage: +# bash experiments/run_tune_local_pca_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] +# +# Defaults: 8 workers, 50 trials, 20 epochs, ellphi. +# +# Detached (SSH-safe): +# bash experiments/launch_detached_screen.sh tune_local_pca_v3 \ +# outputs/tune_elongate_ellphi_local_pca_v3/launcher.log \ +# experiments/run_tune_local_pca_parallel.sh 8 50 20 ellphi + +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" + +N_WORKERS="${1:-8}" +N_TRIALS="${2:-50}" +TUNE_EPOCHS="${3:-20}" +BACKEND="${4:-ellphi}" +BASE_CONFIG="elongate_n100_no_cls_tune_local_pca" + +if [[ "${BACKEND}" == "mahalanobis" ]]; then + OUT_BASE="outputs/tune_elongate_local_pca_v3" +else + OUT_BASE="outputs/tune_elongate_${BACKEND}_local_pca_v3" +fi + +STUDY_NAME="elongate_local_pca_topo_wdist_${BACKEND}_v3" +STORAGE="sqlite:///${OUT_BASE}/study.db" +THREADS_PER_WORKER=12 + +mkdir -p "${OUT_BASE}" +cat > "${OUT_BASE}/PURPOSE.md" < "${OUT_BASE}/worker_${i}.log" 2>&1 & + pids+=($!) + echo " worker ${i}: PID $!, seed ${SEED}, log ${OUT_BASE}/worker_${i}.log" + sleep 1 +done + +echo "Waiting for ${N_WORKERS} workers..." +for pid in "${pids[@]}"; do + wait "${pid}" || echo " worker PID ${pid} exited non-zero" +done + +echo "Writing best params..." +uv run python -u experiments/tune_elongate_wdist.py \ + --base-config "${BASE_CONFIG}" \ + --n-trials "${N_TRIALS}" \ + --tune-epochs "${TUNE_EPOCHS}" \ + --backend "${BACKEND}" \ + --out-base "${OUT_BASE}" \ + --storage "${STORAGE}" \ + --study-name "${STUDY_NAME}" \ + --write-best + +echo "Done: ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" +echo "Apply to configs: uv run python experiments/apply_tune_best_to_configs.py --json ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" diff --git a/experiments/run_tune_parallel.sh b/experiments/run_tune_parallel.sh new file mode 100644 index 0000000..b7be58e --- /dev/null +++ b/experiments/run_tune_parallel.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Parallel Optuna tuning launcher for elongate (W-Dist objective). +# +# NOTE: For re-tunes aligned with train_topo checkpoint selection + save_every=1, +# use experiments/run_retune_parallel.sh instead (writes to outputs/*_v2). +# +# Spawns N worker processes that share ONE SQLite study, so trials are sampled +# cooperatively (TPE sees all workers' results). The study stops once TOTAL +# completed trials reaches --n-trials (enforced per-worker via MaxTrialsCallback). +# +# Usage: +# bash experiments/run_tune_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] [OUT_BASE] +# Defaults: 8 workers, 50 trials, 20 epochs, mahalanobis, outputs/tune_elongate. +# +# Examples: +# bash experiments/run_tune_parallel.sh 8 50 20 mahalanobis +# bash experiments/run_tune_parallel.sh 8 50 20 ellphi outputs/tune_elongate_ellphi + +set -euo pipefail + +N_WORKERS="${1:-8}" +N_TRIALS="${2:-50}" +TUNE_EPOCHS="${3:-20}" +BACKEND="${4:-mahalanobis}" +OUT_BASE="${5:-outputs/tune_elongate}" +STORAGE="sqlite:///${OUT_BASE}/study.db" +STUDY_NAME="elongate_wdist_4d_${BACKEND}" +THREADS_PER_WORKER=12 + +mkdir -p "${OUT_BASE}" + +echo "Launching ${N_WORKERS} workers, study-wide budget=${N_TRIALS} trials, ${TUNE_EPOCHS}ep, backend=${BACKEND}" +echo "Storage: ${STORAGE}" + +pids=() +for i in $(seq 0 $((N_WORKERS - 1))); do + SEED=$((42 + i)) # different sampler seed per worker to decorrelate startup + OMP_NUM_THREADS=${THREADS_PER_WORKER} \ + MKL_NUM_THREADS=${THREADS_PER_WORKER} \ + OPENBLAS_NUM_THREADS=${THREADS_PER_WORKER} \ + nohup uv run python -u experiments/tune_elongate_wdist.py \ + --base-config elongate_n100 \ + --n-trials "${N_TRIALS}" \ + --n-startup-trials 12 \ + --tune-epochs "${TUNE_EPOCHS}" \ + --backend "${BACKEND}" \ + --out-base "${OUT_BASE}" \ + --storage "${STORAGE}" \ + --study-name "${STUDY_NAME}" \ + --seed "${SEED}" \ + > "${OUT_BASE}/worker_${i}.log" 2>&1 & + pids+=($!) + echo " worker ${i}: PID $!, seed ${SEED}, log ${OUT_BASE}/worker_${i}.log" + sleep 1 +done + +echo "Waiting for ${N_WORKERS} workers to finish..." +for pid in "${pids[@]}"; do + wait "${pid}" || echo " worker PID ${pid} exited non-zero" +done + +echo "All workers done. Writing best params..." +uv run python -u experiments/tune_elongate_wdist.py \ + --base-config elongate_n100 \ + --n-trials "${N_TRIALS}" \ + --tune-epochs "${TUNE_EPOCHS}" \ + --backend "${BACKEND}" \ + --out-base "${OUT_BASE}" \ + --storage "${STORAGE}" \ + --study-name "${STUDY_NAME}" \ + --write-best + +echo "Done. See ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py new file mode 100644 index 0000000..f7a81ca --- /dev/null +++ b/experiments/tune_elongate_wdist.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +Optuna tuning of elongate hyperparameters (val topo W-Dist objective). + +Search space (log-uniform): ``w_aniso``, ``w_size``, ``w_topo``, ``lr``. + +Each trial: +1. Trains ``--tune-epochs`` with ``save_every=1`` (``best_model.pth`` = val_topo min). +2. Loads ``best_model.pth`` (fallback: train_topo best saved checkpoint). +3. Objective = mean val **topo W-Dist** (learned ellipses vs local_pca teacher PD). + +Base config ``elongate_n100_no_cls_tune_local_pca`` sets ``teacher_mode: local_pca``, +``w_class: 0``, ``selection.metric: val_topo``. + +Usage (parallel, recommended):: + + bash experiments/run_tune_local_pca_parallel.sh 8 50 20 ellphi +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import numpy as np +import optuna +import torch +from optuna.study import MaxTrialsCallback +from optuna.trial import TrialState + +from tda_ml.checkpoint_io import load_torch_checkpoint +from tda_ml.config import deep_update, load_config +from tda_ml.main import main as train_main +from tda_ml.topo_wdist import TopoWdistOptions, compute_topo_wdist, topo_wdist_options_from_config + +from checkpoint_selection import best_topo_checkpoint # noqa: E402 +from evaluate_paper_protocol import ( # noqa: E402 + build_split_loader, + iter_cloud_predictions, + load_model_from_run, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHECKPOINT_POLICY = "val_topo_best" +SAVE_EVERY = 1 + + +def mean_val_topo_wdist( + clouds: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], + *, + topo_options: TopoWdistOptions, +) -> float: + """Mean per-cloud topo W-Dist on val (independent of DBSCAN).""" + vals: list[float] = [] + for points, params, _labels_gt, clean_pc in clouds: + vals.append(compute_topo_wdist(points, params, clean_pc, topo_options)) + if not vals: + return float("inf") + return float(np.mean(vals)) + + +def resolve_tune_checkpoint(run_dir: Path) -> tuple[str, int, float]: + """Prefer ``best_model.pth`` (val_topo); else train_topo best saved ckpt.""" + best_path = run_dir / "best_model.pth" + if best_path.is_file(): + ckpt = load_torch_checkpoint(str(best_path), map_location="cpu") + epoch = int(ckpt.get("epoch", -1)) + sel = ckpt.get("selection_value", ckpt.get("val_topo_loss")) + val_topo = float(sel) if sel is not None else float("nan") + return "best_model.pth", epoch, val_topo + ckpt_name, epoch, train_topo, _g_ep, _g_topo = best_topo_checkpoint(run_dir) + return ckpt_name, epoch, train_topo + + +def build_trial_config( + base_config: str, *, w_aniso: float, w_size: float, w_topo: float, lr: float, + backend: str, tune_epochs: int, out_base: str, trial_number: int, +) -> dict[str, Any]: + cfg = load_config(base_config, project_root=REPO_ROOT) + overrides = { + "meta": {"config_id": f"tune_elongate_t{trial_number:03d}"}, + "loss": { + "w_aniso": float(w_aniso), + "w_size": float(w_size), + "w_topo": float(w_topo), + "aniso_mode": "elongate", + }, + "training": { + "epochs": int(tune_epochs), + "lr": float(lr), + "visualize_every": 10_000, + "selection": {"metric": "val_topo", "eval_every": 1}, + }, + "model": { + "topology_loss": { + "distance_backend": backend, + "prob_weighting": False, + } + }, + "outputs": {"base_dir": out_base, "save_every": SAVE_EVERY}, + } + return deep_update(cfg, overrides) + + +def make_objective(args: argparse.Namespace): + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + def objective(trial: optuna.Trial) -> float: + w_aniso = trial.suggest_float("w_aniso", 0.05, 5.0, log=True) + w_size = trial.suggest_float("w_size", 0.005, 0.5, log=True) + w_topo = trial.suggest_float("w_topo", 0.02, 0.5, log=True) + lr = trial.suggest_float("lr", 1e-4, 2e-3, log=True) + + cfg = build_trial_config( + args.base_config, w_aniso=w_aniso, w_size=w_size, w_topo=w_topo, lr=lr, + backend=args.backend, tune_epochs=args.tune_epochs, out_base=args.out_base, + trial_number=trial.number, + ) + result = train_main(config=cfg) + run_dir = Path(result["run_dir"]) + + ckpt_name, ckpt_epoch, val_topo_sel = resolve_tune_checkpoint(run_dir) + topo_options = topo_wdist_options_from_config(cfg) + model = load_model_from_run(run_dir, cfg, device, checkpoint_name=ckpt_name) + loader = build_split_loader(cfg, "val", device) + clouds = list(iter_cloud_predictions(model, loader, device)) + + mwd = mean_val_topo_wdist(clouds, topo_options=topo_options) + trial.set_user_attr("checkpoint_policy", CHECKPOINT_POLICY) + trial.set_user_attr("checkpoint_name", ckpt_name) + trial.set_user_attr("checkpoint_epoch", ckpt_epoch) + trial.set_user_attr("val_topo_loss_at_ckpt", val_topo_sel) + trial.set_user_attr("teacher_mode", topo_options.teacher_mode) + trial.set_user_attr("run_dir", str(run_dir)) + print( + f"[trial {trial.number}] w_aniso={w_aniso:.4f} w_size={w_size:.4f} " + f"w_topo={w_topo:.4f} lr={lr:.2e} " + f"ckpt={ckpt_name} ep={ckpt_epoch} val_topo_sel={val_topo_sel:.5f} " + f"-> val topo W-Dist={mwd:.5f}" + ) + return mwd + + return objective + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--base-config", type=str, default="elongate_n100_no_cls_tune_local_pca") + p.add_argument("--n-trials", type=int, default=50) + p.add_argument( + "--n-startup-trials", + type=int, + default=12, + help="Random startup trials before TPE modeling kicks in.", + ) + p.add_argument("--tune-epochs", type=int, default=20) + p.add_argument("--backend", type=str, default="mahalanobis", choices=["mahalanobis", "ellphi"]) + p.add_argument("--out-base", type=str, default="outputs/tune_elongate") + p.add_argument("--seed", type=int, default=42, help="Optuna sampler seed") + p.add_argument( + "--storage", + type=str, + default=None, + help="Optuna storage URL (required for parallel workers).", + ) + p.add_argument( + "--study-name", + type=str, + default="elongate_wdist_4d", + help="Study name (shared across parallel workers when using --storage).", + ) + p.add_argument( + "--write-best", + action="store_true", + help="Write best_elongate_wdist.json at the end (run once after all workers).", + ) + return p.parse_args() + + +def main() -> int: + args = parse_args() + Path(args.out_base).mkdir(parents=True, exist_ok=True) + + study = optuna.create_study( + study_name=args.study_name, + storage=args.storage, + load_if_exists=bool(args.storage), + direction="minimize", + sampler=optuna.samplers.TPESampler(seed=args.seed, n_startup_trials=args.n_startup_trials), + ) + + if not args.write_best: + callbacks = [] + if args.storage: + callbacks.append( + MaxTrialsCallback(args.n_trials, states=(TrialState.COMPLETE,)) + ) + study.optimize( + make_objective(args), + n_trials=args.n_trials, + callbacks=callbacks, + catch=(Exception,), + ) + n_done = len([t for t in study.trials if t.state == TrialState.COMPLETE]) + print(f"[worker done] completed trials in study so far: {n_done}") + return 0 + + best = study.best_trial + payload = { + "objective": "val_topo_wdist_min_at_val_topo_best_ckpt", + "checkpoint_policy": CHECKPOINT_POLICY, + "save_every": SAVE_EVERY, + "base_config": args.base_config, + "backend": args.backend, + "teacher_mode": "local_pca", + "tune_epochs": args.tune_epochs, + "n_trials": args.n_trials, + "n_startup_trials": args.n_startup_trials, + "search_space": { + "w_aniso": [0.05, 5.0], + "w_size": [0.005, 0.5], + "w_topo": [0.02, 0.5], + "lr": [1e-4, 2e-3], + }, + "best_value_wdist": best.value, + "best_params": best.params, + "best_checkpoint_epoch": best.user_attrs.get("checkpoint_epoch"), + "best_val_topo_loss_at_ckpt": best.user_attrs.get("val_topo_loss_at_ckpt"), + "best_run_dir": best.user_attrs.get("run_dir"), + "all_trials": [ + { + "number": t.number, + "value": t.value, + "params": t.params, + "checkpoint_epoch": t.user_attrs.get("checkpoint_epoch"), + "val_topo_loss_at_ckpt": t.user_attrs.get("val_topo_loss_at_ckpt"), + } + for t in study.trials + ], + } + out_path = Path(args.out_base) / f"best_elongate_wdist_{args.backend}.json" + out_path.write_text(json.dumps(payload, indent=2) + "\n") + print(json.dumps({k: payload[k] for k in ( + "best_value_wdist", "best_params", "best_checkpoint_epoch", + "best_val_topo_loss_at_ckpt", + )}, indent=2)) + print(f"Wrote {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 89e49b0..d26d1c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ packages = ["tda_ml"] [tool.uv.sources] torch_topological = { path = "pytorch-topological", editable = true } +# Local fork of t-uda/ellphi (v0.1.2) adding a C++ pdist_tangency_grad kernel +# (~175x faster differentiable tangency backward). See ellphi_repo/. +ellphi = { path = "ellphi_repo", editable = true } [dependency-groups] dev = [ diff --git a/scripts/new_experiment.sh b/scripts/new_experiment.sh new file mode 100755 index 0000000..e852b4c --- /dev/null +++ b/scripts/new_experiment.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# 新しい数値実験フォルダを規約どおりに作成する。 +# outputs///_/ +# PURPOSE.md の雛形を生成し、run_backend_multiseed.py に渡す out-base のパスを表示する。 +# +# 使い方: +# scripts/new_experiment.sh [--unsupervised] ["1行の目的"] +# 例: +# scripts/new_experiment.sh mahal_n100_noprob_30ep_seed42 "確率重みOFFの収束確認" +# scripts/new_experiment.sh --unsupervised topo_prior_30ep "topo-prior のみ" +set -euo pipefail + +mode_dir="supervised" +if [[ "${1:-}" == "--unsupervised" || "${1:-}" == "-u" ]]; then + mode_dir="unsupervised" + shift +fi + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [--unsupervised] [\"one-line purpose\"]" >&2 + exit 1 +fi + +slug="$1" +purpose="${2:-(目的をここに記述)}" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +date_dir="$(date +%Y%m%d)" +time_id="$(date +%H%M%S)" +exp_dir="${repo_root}/outputs/${mode_dir}/${date_dir}/${time_id}_${slug}" + +mkdir -p "${exp_dir}" + +cat > "${exp_dir}/PURPOSE.md" </dev/null || echo "unknown") +- config: \`config_snapshot.yaml\` + +## 目的・仮説 + +${purpose} + +## 設定(要点) + +- + +## 実行コマンド + +\`\`\`bash +\`\`\` + +## 結果 + +- + +## 解釈 + +- +EOF + +echo "created: ${exp_dir}" +echo "" +echo "次の手順:" +echo " 1) 使う config を snapshot: cp configs/.yaml ${exp_dir}/config_snapshot.yaml" +echo " 2) 実行時に out-base を指定: --out-base ${exp_dir}" +echo " (標準出力は ... | tee ${exp_dir}/run.log で保存)" +echo " 3) 実行後 PURPOSE.md の結果/解釈を埋める" +echo "" +echo "out-base path (copy):" +echo "${exp_dir}" diff --git a/tda_ml/dbscan_eval.py b/tda_ml/dbscan_eval.py new file mode 100644 index 0000000..597be83 --- /dev/null +++ b/tda_ml/dbscan_eval.py @@ -0,0 +1,266 @@ +"""Shared DBSCAN-based cloud evaluation (paper protocol). + +Per cloud: ellphi/mahalanobis DBSCAN for outlier labels (MCC), and topo W-Dist +(learned ellipses vs clean teacher PD — same as ``TopologicalLoss``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterator, Sequence + +import numpy as np +import torch +from sklearn.cluster import DBSCAN + +from tda_ml.dbscan import calculate_anisotropic_distance_matrix +from tda_ml.metrics import compute_recall_specificity_gmean_mcc_wdist +from tda_ml.topo_wdist import TopoWdistOptions + +DEFAULT_EPS_VALUES: list[float] = list(np.linspace(0.15, 1.5, 15)) +DEFAULT_MIN_SAMPLES_VALUES: list[int] = [3, 5, 7, 10, 15] + + +@dataclass +class CloudMetrics: + recall: float + specificity: float + gmean: float + mcc: float + wdist: float + + +@dataclass +class GridResult: + """Aggregated metrics at the best DBSCAN hyperparameters.""" + + recall: float + specificity: float + gmean: float + mcc: float + wdist: float + eps: float + min_samples: int + n_clouds: int + objective: str + + +@dataclass +class PreparedCloud: + """Per-cloud data with a cached distance matrix (grid-reusable).""" + + points: np.ndarray + params: np.ndarray + labels_gt: np.ndarray + clean_pc: np.ndarray + dist: np.ndarray + + +def valid_clean_inliers(clean_pc: np.ndarray) -> np.ndarray: + """Drop zero-padding rows from the clean (ground-truth inlier) point cloud.""" + mask = np.abs(clean_pc).sum(axis=1) > 1e-6 + return clean_pc[mask] + + +def dbscan_labels_to_outlier_pred(labels: np.ndarray) -> np.ndarray: + """DBSCAN noise (-1) -> outlier (1); clustered points -> inlier (0).""" + return (labels == -1).astype(np.int64) + + +def iter_cloud_predictions( + model: torch.nn.Module, + loader, + device: torch.device, +) -> Iterator[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]: + """Yield ``(points, params, labels_gt, clean_pc)`` per cloud in ``loader``.""" + model.eval() + with torch.no_grad(): + for data, labels, clean_pc in loader: + data = data.to(device, non_blocking=True) + _, params = model(data) + data_np = data.cpu().numpy() + params_np = params.cpu().numpy() + labels_np = labels.cpu().numpy() + clean_np = clean_pc.cpu().numpy() + for b in range(data_np.shape[0]): + yield data_np[b], params_np[b], labels_np[b], clean_np[b] + + +def prepare_clouds( + clouds: Sequence[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], + *, + backend: str, + metric: str = "max", +) -> list[PreparedCloud]: + """Precompute distance matrices once per cloud (reused across the grid).""" + prepared: list[PreparedCloud] = [] + for points, params, labels_gt, clean_pc in clouds: + dist = calculate_anisotropic_distance_matrix( + points, params, metric=metric, backend=backend + ) + prepared.append( + PreparedCloud( + points=points, + params=params, + labels_gt=labels_gt, + clean_pc=clean_pc, + dist=dist, + ) + ) + return prepared + + +def _eval_prepared_cloud( + cloud: PreparedCloud, + eps: float, + min_samples: int, + *, + topo_options: TopoWdistOptions | None = None, +) -> CloudMetrics: + db = DBSCAN(eps=eps, min_samples=min_samples, metric="precomputed") + labels = db.fit_predict(cloud.dist) + pred = dbscan_labels_to_outlier_pred(labels) + recall, specificity, gmean, mcc, wdist = compute_recall_specificity_gmean_mcc_wdist( + cloud.labels_gt, + pred, + points=cloud.points, + params=cloud.params, + clean_pc=cloud.clean_pc, + topo_options=topo_options, + ) + return CloudMetrics(recall, specificity, gmean, mcc, wdist) + + +def _aggregate(rows: list[CloudMetrics]) -> tuple[float, float, float, float, float]: + return ( + float(np.mean([r.recall for r in rows])), + float(np.mean([r.specificity for r in rows])), + float(np.mean([r.gmean for r in rows])), + float(np.mean([r.mcc for r in rows])), + float(np.mean([r.wdist for r in rows])), + ) + + +def grid_search_prepared( + prepared: list[PreparedCloud], + *, + eps_values: list[float], + min_samples_values: list[int], + objective: str = "wdist", + topo_options: TopoWdistOptions | None = None, +) -> GridResult: + """Grid-search DBSCAN over cached clouds; pick the best by ``objective``. + + ``objective='wdist'`` minimizes mean topo W-Dist (independent of DBSCAN hparams). + ``objective='mcc'`` maximizes mean MCC. Returns aggregated metrics at the selected + hyperparameters. + """ + if objective not in ("wdist", "mcc"): + raise ValueError(f"objective must be 'wdist' or 'mcc'; got {objective!r}") + if not prepared: + raise ValueError("No clouds to evaluate") + + minimize = objective == "wdist" + best_score = float("inf") if minimize else -1.0 + best: GridResult | None = None + + for eps in eps_values: + for min_samples in min_samples_values: + rows: list[CloudMetrics] = [] + ok = True + for cloud in prepared: + try: + rows.append( + _eval_prepared_cloud( + cloud, + float(eps), + int(min_samples), + topo_options=topo_options, + ) + ) + except Exception: # noqa: BLE001 — skip degenerate hyperparameters + ok = False + break + if not ok or not rows: + continue + recall, specificity, gmean, mcc, wdist = _aggregate(rows) + score = wdist if minimize else mcc + is_better = score < best_score if minimize else score > best_score + if is_better: + best_score = score + best = GridResult( + recall=recall, + specificity=specificity, + gmean=gmean, + mcc=mcc, + wdist=wdist, + eps=float(eps), + min_samples=int(min_samples), + n_clouds=len(rows), + objective=objective, + ) + + if best is None: + raise RuntimeError("DBSCAN grid search failed for all hyperparameters") + return best + + +def evaluate_model_grid( + model: torch.nn.Module, + loader, + device: torch.device, + *, + backend: str, + eps_values: list[float] | None = None, + min_samples_values: list[int] | None = None, + objective: str = "wdist", + metric: str = "max", + topo_options: TopoWdistOptions | None = None, +) -> GridResult: + """Forward ``loader`` through ``model`` and grid-search DBSCAN by ``objective``.""" + clouds = list(iter_cloud_predictions(model, loader, device)) + prepared = prepare_clouds(clouds, backend=backend, metric=metric) + return grid_search_prepared( + prepared, + eps_values=eps_values or DEFAULT_EPS_VALUES, + min_samples_values=min_samples_values or DEFAULT_MIN_SAMPLES_VALUES, + objective=objective, + topo_options=topo_options, + ) + + +def evaluate_model_fixed( + model: torch.nn.Module, + loader, + device: torch.device, + *, + backend: str, + eps: float, + min_samples: int, + metric: str = "max", + topo_options: TopoWdistOptions | None = None, +) -> GridResult: + """Evaluate at a single fixed ``(eps, min_samples)`` (no grid search).""" + clouds = list(iter_cloud_predictions(model, loader, device)) + prepared = prepare_clouds(clouds, backend=backend, metric=metric) + rows = [ + _eval_prepared_cloud( + c, + float(eps), + int(min_samples), + topo_options=topo_options, + ) + for c in prepared + ] + recall, specificity, gmean, mcc, wdist = _aggregate(rows) + return GridResult( + recall=recall, + specificity=specificity, + gmean=gmean, + mcc=mcc, + wdist=wdist, + eps=float(eps), + min_samples=int(min_samples), + n_clouds=len(rows), + objective="fixed", + ) diff --git a/tda_ml/local_pca.py b/tda_ml/local_pca.py new file mode 100644 index 0000000..9dc070a --- /dev/null +++ b/tda_ml/local_pca.py @@ -0,0 +1,78 @@ +"""Local PCA ellipse parameters (paper §3.3.2 ideal ellipses, ADBSCAN baseline).""" + +from __future__ import annotations + +import torch + +from tda_ml.numerical_eps import EIGENVALUE_FLOOR, PCA_RIDGE_EPS + +TEACHER_MODE_EUCLIDEAN = "euclidean" +TEACHER_MODE_LOCAL_PCA = "local_pca" + + +def normalize_teacher_mode(mode: str) -> str: + m = str(mode).strip().lower() + if m in (TEACHER_MODE_EUCLIDEAN, "euclid"): + return TEACHER_MODE_EUCLIDEAN + if m in (TEACHER_MODE_LOCAL_PCA, "local-pca", "ideal", "d_ideal"): + return TEACHER_MODE_LOCAL_PCA + raise ValueError( + f"teacher_mode must be 'euclidean' or 'local_pca', got {mode!r}" + ) + + +def local_pca_ellipse_params( + points: torch.Tensor, + *, + k: int = 10, +) -> torch.Tensor: + """ + Ideal ellipse parameters from local PCA only (no learned corrections). + + Args: + points: ``(B, N, 2)`` or ``(N, 2)`` coordinates. + k: number of Euclidean nearest neighbors (including self in the k-ball). + + Returns: + ``(..., N, 3)`` with ``[a, b, theta]`` per point (major/minor normalized). + """ + if points.ndim == 2: + return local_pca_ellipse_params(points.unsqueeze(0), k=k).squeeze(0) + + if points.ndim != 3 or points.shape[-1] != 2: + raise ValueError(f"points must be (B, N, 2) or (N, 2); got {tuple(points.shape)}") + + b, n, d = points.shape + if n < 2: + raise ValueError(f"Need at least 2 points for local PCA; got n={n}") + k_eff = min(int(k), n) + if k_eff < 2: + raise ValueError(f"Effective k must be >= 2; got k={k}, n={n}") + + dist_sq = torch.cdist(points, points, p=2) ** 2 + _, idx = torch.topk(-dist_sq, k=k_eff, dim=-1) + + batch_idx = torch.arange(b, device=points.device).view(b, 1, 1).expand(b, n, k_eff) + flat_x = points.view(b * n, d) + flat_neighbors = flat_x[idx.reshape(b, -1) + (batch_idx.reshape(b, -1) * n), :] + neighbors = flat_neighbors.view(b, n, k_eff, d) + + relative_coords = neighbors - points.unsqueeze(2) + mean_neighbor = relative_coords.mean(dim=2, keepdim=True) + centered = relative_coords - mean_neighbor + cov = torch.matmul(centered.transpose(-1, -2), centered) / (k_eff - 1) + + cov32 = cov.float() + eye2 = torch.eye(2, device=points.device, dtype=torch.float32) + e, v = torch.linalg.eigh(cov32 + eye2 * PCA_RIDGE_EPS) + + v1 = v[:, :, :, 1] + base_angle = torch.atan2(v1[:, :, 1], v1[:, :, 0]) + + base_axes = torch.sqrt(torch.clamp(e, min=EIGENVALUE_FLOOR)) + base_axes = torch.flip(base_axes, dims=[-1]) + base_axes = base_axes / ( + base_axes.max(dim=-1, keepdim=True)[0] + EIGENVALUE_FLOOR + ) + + return torch.cat([base_axes, base_angle.unsqueeze(-1)], dim=-1).to(dtype=points.dtype) diff --git a/tda_ml/losses.py b/tda_ml/losses.py index 0ae9bfc..aee45e2 100644 --- a/tda_ml/losses.py +++ b/tda_ml/losses.py @@ -90,11 +90,15 @@ def forward(self, params): class AnisotropyPenaltyLoss(nn.Module): """ - Prevents ellipses from becoming too elongated by penalizing high aspect ratios. - + Shapes the ellipse aspect ratio. The direction of the effect depends on ``mode``. + Modes: - - linear: Penalizes aspect ratio (major/minor) directly. - - barrier: Penalizes aspect ratio squared only above a certain threshold. + - linear: Penalizes aspect ratio (major/minor) directly -> drives ellipses toward circles. + - barrier: Penalizes aspect ratio squared only above ``barrier_threshold`` + -> free below the threshold, strong push back above it. + - elongate: Minimizes minor/major (circularity) -> rewards elongation along the + local principal direction; circle (ratio=1) is the worst case. This reproduces the + legacy ``aniso_mode: elongate`` behavior that yielded data-aligned ellipses. """ def __init__(self, weight=0.01, mode='linear', barrier_threshold=6.0): super().__init__() @@ -109,17 +113,34 @@ def forward(self, params): axes = params[..., 0:2] major_axis = axes.max(dim=-1)[0] minor_axis = axes.min(dim=-1)[0] - - aspect_ratios = major_axis / (minor_axis + NUMERICAL_EPS) - + if self.mode == 'barrier': + aspect_ratios = major_axis / (minor_axis + NUMERICAL_EPS) barrier_term = F.relu(aspect_ratios - self.barrier_threshold).pow(2).mean() loss = 10.0 * barrier_term + elif self.mode == 'elongate': + loss = (minor_axis / (major_axis + NUMERICAL_EPS)).mean() else: + aspect_ratios = major_axis / (minor_axis + NUMERICAL_EPS) loss = aspect_ratios.mean() return self.weight * loss +class MinBRegularizationLoss(nn.Module): + """Penalize minor axis falling below ``target`` (legacy ``lambda_min_b`` / ``min_b_target``).""" + + def __init__(self, weight: float = 0.0, target: float = 0.2): + super().__init__() + self.weight = weight + self.target = target + + def forward(self, params: torch.Tensor) -> torch.Tensor: + if self.weight <= 0: + return torch.tensor(0.0, device=params.device) + axes = params[..., 0:2] + minor_axis = axes.min(dim=-1)[0] + return self.weight * F.relu(self.target - minor_axis).mean() + class TopologicalLoss(nn.Module): """ Computes the Topological Loss between the predicted anisotropic filtration @@ -139,36 +160,101 @@ def __init__( weight=0.1, distance_backend: str = "mahalanobis", ellphi_differentiable: bool = True, + prob_weighting: bool = True, + eps_scale: float = 1.0, + scale_mode: str = "fixed", + max_points: int | None = None, ): super().__init__() self.weight = weight self.distance_backend = distance_backend.lower().strip() self.ellphi_differentiable = ellphi_differentiable + # When False, outlier-probability weighting of the distance matrix is + # disabled (probs=None). Only affects the ``mahalanobis`` backend; ``ellphi`` + # never uses probs. Useful for a fair backend ablation against ellphi. + self.prob_weighting = prob_weighting + # Filtration-unit alignment between the predicted distance matrix (Mahalanobis + # or ellphi tangency units) and the Euclidean clean (teacher) PD. Legacy + # ``topo_eps_scale`` (v73 default 0.7022) multiplied D by a scalar; ``ellphi`` + # tangency distances live on a different scale than Euclidean, so without this + # the topology loss is dominated by scale rather than shape. + # - scale_mode="fixed": D <- D * eps_scale (scalar; eps_scale=1.0 == no-op) + # - scale_mode="median": bring the *prediction* onto the teacher's Euclidean + # scale: D <- D * (m_e / median(offdiag D)), where m_e is the median pairwise + # Euclidean distance of the clean cloud (provided by the trainer as + # ``clean_scales``). The teacher PD is left untouched. If m_e is unavailable, + # fall back to D <- D / median(offdiag D) (per-sample unit median). + self.eps_scale = float(eps_scale) + self.scale_mode = str(scale_mode).strip().lower() + if self.scale_mode not in ("fixed", "median"): + raise ValueError( + f"scale_mode must be 'fixed' or 'median', got {scale_mode!r}" + ) + # Legacy ``topo_loss_max_points``: subsample points before VR/Wasserstein. + self.max_points = int(max_points) if max_points is not None else None + if self.max_points is not None and self.max_points < 2: + raise ValueError(f"max_points must be >= 2, got {self.max_points}") self.vr_complex = VietorisRipsComplex(dim=1) self.wasserstein = WassersteinDistance(q=2) - def forward(self, points, params, logits, clean_pd_info): + def _rescale_distance_matrix(self, d_mat: torch.Tensor, clean_scale=None) -> torch.Tensor: + """Align the predicted distance matrix to the teacher PD filtration units. + + ``clean_scale`` (m_e) is the median pairwise Euclidean distance of the teacher + cloud for this sample. In median mode the prediction is scaled so its median + matches m_e, i.e. it is brought onto the (untouched) teacher's Euclidean scale. + """ + if self.scale_mode == "median": + off = d_mat[d_mat > 0] + if off.numel() == 0: + return d_mat + denom = torch.median(off).detach() + NUMERICAL_EPS + if clean_scale is not None: + return d_mat * (float(clean_scale) / denom) + return d_mat / denom + if self.eps_scale != 1.0: + return d_mat * self.eps_scale + return d_mat + + def _subsample_points( + self, + points_i: torch.Tensor, + params_i: torch.Tensor, + logits_i: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + n = points_i.shape[0] + if self.max_points is None or n <= self.max_points: + return points_i, params_i, logits_i + idx = torch.randperm(n, device=points_i.device)[: self.max_points] + return points_i[idx], params_i[idx], logits_i[idx] + + def forward(self, points, params, logits, clean_pd_info, clean_scales=None): if self.weight <= 0: return torch.tensor(0.0, device=points.device) batch_size = points.shape[0] - probs_outlier = torch.sigmoid(logits).squeeze(-1) - - D_prime = compute_distance_matrix_batch( - points, - params, - probs=probs_outlier, - symmetrize="max", - backend=self.distance_backend, - ellphi_differentiable=self.ellphi_differentiable, - ) - + total_loss = 0.0 valid_samples = 0 topo_failures: list[tuple[int, str]] = [] for i in range(batch_size): - d_mat = D_prime[i] + pts_i, par_i, logits_i = self._subsample_points( + points[i], params[i], logits[i] + ) + probs_i = ( + torch.sigmoid(logits_i).squeeze(-1) if self.prob_weighting else None + ) + d_batch = compute_distance_matrix_batch( + pts_i.unsqueeze(0), + par_i.unsqueeze(0), + probs=probs_i.unsqueeze(0) if probs_i is not None else None, + symmetrize="max", + backend=self.distance_backend, + ellphi_differentiable=self.ellphi_differentiable, + ) + clean_scale_i = clean_scales[i] if clean_scales is not None else None + d_mat = self._rescale_distance_matrix(d_batch[0], clean_scale=clean_scale_i) try: pd_pred_info = self.vr_complex(d_mat, treat_as_distances=True) loss_sample = self.wasserstein(pd_pred_info, clean_pd_info[i]) ** 2 diff --git a/tda_ml/main.py b/tda_ml/main.py index e3ce9e9..960f8b4 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -9,6 +9,8 @@ from tda_ml.checkpoint_io import extract_model_state_dict, load_torch_checkpoint from tda_ml.config import deep_update, load_config, model_kwargs_from_config from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader +from tda_ml.dbscan_eval import evaluate_model_grid +from tda_ml.topo_wdist import topo_wdist_options_from_config from tda_ml.models import AnisotropicOutlierClassifier from tda_ml.seed_utils import set_global_seed from tda_ml.runtime_profile import build_runtime_profile @@ -226,26 +228,64 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): with open(metrics_path, 'w', newline='') as f: writer = csv.writer(f) - writer.writerow(['epoch', 'train_loss', 'train_class_loss', 'train_topo_loss', 'train_aniso_loss', 'train_size_loss', 'val_loss', 'val_recall', 'val_mcc', 'val_aniso', 'val_size']) + writer.writerow([ + 'epoch', 'train_loss', 'train_class_loss', 'train_topo_loss', + 'train_aniso_loss', 'train_size_loss', 'val_loss', 'val_recall', + 'val_mcc', 'val_aniso', 'val_size', 'val_topo_loss', 'val_wdist', + 'sel_eps', 'sel_min_samples', + ]) # --- Training Loop --- epochs = config['training']['epochs'] - save_every = config['outputs'].get('save_every', 10) + save_every = config['outputs'].get('save_every', 1) best_val_mcc = -1.0 early_abort_cfg = config.get("training", {}).get("early_abort", {}) metrics_history: list[dict] = [] final_status = "completed" abort_report_path = None + # --- Model-selection (checkpoint) policy --- + # Prefer ``val_topo`` so the saved checkpoint matches the topology loss used + # during training (same ellphi/local_pca teacher PD as train_epoch). + # ``wdist`` / ``dbscan_mcc`` use DBSCAN + Euclidean inlier-point W-Dist + # (paper reporting metric; not the training objective). + # metric: 'val_topo' | 'wdist' | 'dbscan_mcc' | 'threshold_mcc' | 'val_loss' + # 'threshold_mcc' reproduces the legacy behavior. The threshold MCC is always + # tracked separately for early-abort diagnostics regardless of this setting. + sel_cfg = (config.get("training", {}).get("selection") or {}) + sel_metric = sel_cfg.get("metric", "threshold_mcc") + sel_eval_every = max(1, int(sel_cfg.get("eval_every", 1))) + sel_dbscan_cfg = (sel_cfg.get("dbscan") or {}) + sel_eps_values = sel_dbscan_cfg.get("eps_values") + sel_min_samples_values = sel_dbscan_cfg.get("min_samples_values") + sel_backend = ( + config.get("model", {}).get("topology_loss", {}).get("distance_backend", "mahalanobis") + ) + sel_minimize = sel_metric in ("wdist", "val_loss", "val_topo") + best_sel_value = float("inf") if sel_minimize else -1.0 + if sel_metric == "val_topo": + logger.info("Model selection: val_topo (same TopologicalLoss as training)") + elif sel_metric in ("wdist", "dbscan_mcc"): + logger.info( + "Model selection: %s via DBSCAN grid (backend=%s, eval_every=%d)", + sel_metric, sel_backend, sel_eval_every, + ) + else: + logger.info("Model selection: %s", sel_metric) + for epoch in range(1, epochs + 1): # res returns (avg_loss, class_loss, topo_loss, aniso_loss, size_loss, ...) res = trainer.train_epoch(data_loader, epoch) val_res = trainer.validate(val_loader) val_mcc = val_res[4] # MCC is at index 4 + val_topo_loss = val_res[7] train_mcc = res[10] val_recall = val_res[1] - print(f"Epoch {epoch}: Val MCC={val_mcc:.4f}, Aniso={val_res[5]:.4f}") + print( + f"Epoch {epoch}: Val MCC={val_mcc:.4f}, Val topo={val_topo_loss:.4f}, " + f"Aniso={val_res[5]:.4f}" + ) metrics_history.append( { @@ -258,19 +298,90 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): "train_loss": float(res[0]), "val_size": float(val_res[6]), "val_aniso": float(val_res[5]), + "val_topo_loss": float(val_topo_loss), } ) - # Save best model logic + # Track best threshold MCC for early-abort diagnostics (independent of + # the checkpoint-selection metric). if val_mcc > best_val_mcc: best_val_mcc = val_mcc - best_model_path = os.path.join(run_dir, 'best_model.pth') - torch.save({ - 'epoch': epoch, - 'model_state_dict': model.state_dict(), - 'val_mcc': val_mcc, - }, best_model_path) - print(f"Saved best model (MCC: {val_mcc:.4f}) to {best_model_path}") + + # --- Checkpoint selection --- + run_sel_eval = (epoch % sel_eval_every == 0) or (epoch == epochs) + sel_value = None + sel_eps = None + sel_min_samples = None + sel_wdist = None + sel_mcc_dbscan = None + if sel_metric == "threshold_mcc": + sel_value = float(val_mcc) + elif sel_metric == "val_loss": + sel_value = float(val_res[0]) + elif sel_metric == "val_topo": + sel_value = float(val_topo_loss) + print(f"Epoch {epoch}: selection[val_topo] val_topo_loss={sel_value:.5f}") + elif sel_metric in ("wdist", "dbscan_mcc") and run_sel_eval: + objective = "wdist" if sel_metric == "wdist" else "mcc" + grid = evaluate_model_grid( + model, + val_loader, + device, + backend=sel_backend, + eps_values=sel_eps_values, + min_samples_values=sel_min_samples_values, + objective=objective, + topo_options=topo_wdist_options_from_config(config), + ) + sel_eps = grid.eps + sel_min_samples = grid.min_samples + sel_wdist = grid.wdist + sel_mcc_dbscan = grid.mcc + sel_value = grid.wdist if objective == "wdist" else grid.mcc + print( + f"Epoch {epoch}: selection[{sel_metric}] val_wdist={grid.wdist:.5f} " + f"val_mcc_dbscan={grid.mcc:.4f} (eps={grid.eps:.3f}, min_samples={grid.min_samples})" + ) + + if sel_value is not None: + is_better = ( + sel_value < best_sel_value if sel_minimize else sel_value > best_sel_value + ) + if is_better: + best_sel_value = sel_value + best_model_path = os.path.join(run_dir, 'best_model.pth') + ckpt = { + 'epoch': epoch, + 'model_state_dict': model.state_dict(), + 'val_mcc': float(val_mcc), + 'selection_metric': sel_metric, + 'selection_value': float(sel_value), + } + if sel_metric == "val_topo": + ckpt['val_topo_loss'] = float(sel_value) + if sel_eps is not None: + ckpt['dbscan_eps'] = float(sel_eps) + ckpt['dbscan_min_samples'] = int(sel_min_samples) + ckpt['val_wdist'] = float(sel_wdist) + ckpt['val_mcc_dbscan'] = float(sel_mcc_dbscan) + hp_path = os.path.join(log_dir, 'dbscan_hparams_train.json') + with open(hp_path, 'w', encoding='utf-8') as f: + json.dump( + { + 'eps': float(sel_eps), + 'min_samples': int(sel_min_samples), + 'backend': sel_backend, + 'epoch': epoch, + 'val_wdist': float(sel_wdist), + 'val_mcc_dbscan': float(sel_mcc_dbscan), + 'selection_metric': sel_metric, + }, + f, + ensure_ascii=True, + indent=2, + ) + torch.save(ckpt, best_model_path) + print(f"Saved best model ({sel_metric}={sel_value:.5f}) to {best_model_path}") if epoch % save_every == 0: checkpoint_path = os.path.join(run_dir, f'checkpoint_epoch_{epoch}.pth') @@ -283,7 +394,14 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): with open(metrics_path, 'a', newline='') as f: writer = csv.writer(f) - writer.writerow([epoch, res[0], res[1], res[2], res[3], res[4], val_res[0], val_res[1], val_res[4], val_res[5], val_res[6]]) + writer.writerow([ + epoch, res[0], res[1], res[2], res[3], res[4], + val_res[0], val_res[1], val_res[4], val_res[5], val_res[6], + val_res[7], + '' if sel_wdist is None else sel_wdist, + '' if sel_eps is None else sel_eps, + '' if sel_min_samples is None else sel_min_samples, + ]) do_abort, abort_reason = should_early_abort( epoch=epoch, @@ -354,6 +472,7 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): test_mcc, test_aniso, test_size, + test_topo_loss, ) = test_res test_metrics = { "test_loss": test_loss, @@ -363,6 +482,7 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): "test_mcc": test_mcc, "test_aniso": test_aniso, "test_size": test_size, + "test_topo_loss": test_topo_loss, } test_metrics_path = os.path.join(log_dir, "test_metrics.json") with open(test_metrics_path, "w", encoding="utf-8") as f: diff --git a/tda_ml/metrics.py b/tda_ml/metrics.py index e6335f6..c1e0402 100644 --- a/tda_ml/metrics.py +++ b/tda_ml/metrics.py @@ -6,6 +6,7 @@ from sklearn.metrics import confusion_matrix, matthews_corrcoef, recall_score from tda_ml.persistence import compute_w_distance +from tda_ml.topo_wdist import TopoWdistOptions, compute_topo_wdist def compute_recall_specificity_gmean_mcc( @@ -31,23 +32,28 @@ def compute_recall_specificity_gmean_mcc_wdist( labels_pred: Sequence[int] | np.ndarray, *, points: np.ndarray | None = None, + params: np.ndarray | None = None, + clean_pc: np.ndarray | None = None, + topo_options: TopoWdistOptions | None = None, gt_inliers: np.ndarray | None = None, ) -> tuple[float, float, float, float, float]: """ Return the four classification metrics plus W-Dist. - W-Dist is the H1 1-Wasserstein distance between persistence diagrams of - predicted inliers and ``gt_inliers``. It is computed only when ``points`` - and ``gt_inliers`` are provided; otherwise ``w_dist`` is ``0.0``. - An empty predicted-inlier set uses the standard OT distance - ``W(empty_diagram, PD(gt_inliers))`` — see - :func:`tda_ml.persistence.compute_w_distance`. + When ``params`` and ``clean_pc`` are provided, W-Dist is the ellipse-filtration + distance (``compute_topo_wdist``): learned ellipses on the full cloud vs the + clean teacher PD — same definition as ``TopologicalLoss``. + + When only ``gt_inliers`` is provided (legacy baselines), W-Dist uses Euclidean + Alpha-complex PDs on DBSCAN-predicted inlier **point coordinates**. """ recall, specificity, gmean, mcc = compute_recall_specificity_gmean_mcc( labels_gt, labels_pred ) w_dist = 0.0 - if points is not None and gt_inliers is not None: + if points is not None and params is not None and clean_pc is not None: + w_dist = float(compute_topo_wdist(points, params, clean_pc, topo_options)) + elif points is not None and gt_inliers is not None: pred_inliers = points[np.asarray(labels_pred) == 0] w_dist = float(compute_w_distance(pred_inliers, gt_inliers)) return recall, specificity, gmean, mcc, w_dist diff --git a/tda_ml/teacher_pd.py b/tda_ml/teacher_pd.py new file mode 100644 index 0000000..d14e4d4 --- /dev/null +++ b/tda_ml/teacher_pd.py @@ -0,0 +1,89 @@ +"""Clean (teacher) persistence diagrams for the topology loss.""" + +from __future__ import annotations + +import torch +from torch_topological.nn import VietorisRipsComplex + +from tda_ml.distance_backend import compute_distance_matrix_batch +from tda_ml.local_pca import ( + TEACHER_MODE_EUCLIDEAN, + local_pca_ellipse_params, + normalize_teacher_mode, +) + + +def _median_offdiag(d_mat: torch.Tensor) -> float: + off = d_mat[d_mat > 0] + if off.numel() == 0: + return 1.0 + return float(torch.median(off).item()) + + +def compute_clean_teacher_batch( + clean_points: torch.Tensor, + vr_complex: VietorisRipsComplex, + *, + teacher_mode: str = TEACHER_MODE_EUCLIDEAN, + distance_backend: str = "mahalanobis", + ellphi_differentiable: bool = False, + local_pca_k: int = 10, + max_points: int | None = None, + need_clean_scales: bool = False, +) -> tuple[list, list[float] | None]: + """ + Build teacher PD info (and optional per-sample scale targets) for a batch. + + ``teacher_mode``: + - ``euclidean``: VR on raw clean coordinates (legacy default). + - ``local_pca``: ideal local-PCA ellipses + ``distance_backend`` distance matrix → VR + (paper §3.3.2 ``D_ideal``; same filtration units as the prediction side). + + When ``need_clean_scales`` is True (``topo_scale_mode='median'``), returns per-sample + medians of the teacher filtration: Euclidean pairwise median (euclidean mode) or + median off-diagonal entries of ``D_ideal`` (local_pca mode). + """ + mode = normalize_teacher_mode(teacher_mode) + backend = distance_backend.lower().strip() + clean_pd_info: list = [] + clean_scales: list[float] | None = [] if need_clean_scales else None + + with torch.no_grad(): + for j in range(clean_points.shape[0]): + pts = clean_points[j] + valid_mask = torch.abs(pts).sum(dim=1) > 1e-6 + pts = pts[valid_mask] + if pts.shape[0] < 2: + clean_pd_info.append(vr_complex(pts)) + if clean_scales is not None: + clean_scales.append(1.0) + continue + + if max_points is not None and pts.shape[0] > max_points: + idx = torch.randperm(pts.shape[0], device=pts.device)[:max_points] + pts = pts[idx] + + if mode == TEACHER_MODE_EUCLIDEAN: + clean_pd_info.append(vr_complex(pts)) + if clean_scales is not None: + eu = torch.pdist(pts) + clean_scales.append( + float(torch.median(eu).item()) if eu.numel() > 0 else 1.0 + ) + continue + + ideal_params = local_pca_ellipse_params(pts.unsqueeze(0), k=local_pca_k) + d_batch = compute_distance_matrix_batch( + pts.unsqueeze(0), + ideal_params, + probs=None, + symmetrize="max", + backend=backend, + ellphi_differentiable=ellphi_differentiable, + ) + d_mat = d_batch[0] + clean_pd_info.append(vr_complex(d_mat, treat_as_distances=True)) + if clean_scales is not None: + clean_scales.append(_median_offdiag(d_mat)) + + return clean_pd_info, clean_scales diff --git a/tda_ml/topo_wdist.py b/tda_ml/topo_wdist.py new file mode 100644 index 0000000..96b11ad --- /dev/null +++ b/tda_ml/topo_wdist.py @@ -0,0 +1,151 @@ +"""Ellipse-filtration W-Dist (prediction PD vs clean teacher PD). + +Same definition as ``TopologicalLoss``: predicted ellipses on the (noisy) cloud +vs teacher PD from ``compute_clean_teacher_batch`` (e.g. local PCA + ellphi). +DBSCAN is **not** involved in this metric. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import numpy as np +import torch +from torch_topological.nn import VietorisRipsComplex, WassersteinDistance + +from tda_ml.distance_backend import compute_distance_matrix_batch +from tda_ml.numerical_eps import NUMERICAL_EPS +from tda_ml.teacher_pd import compute_clean_teacher_batch + + +@dataclass(frozen=True) +class TopoWdistOptions: + teacher_mode: str = "local_pca" + distance_backend: str = "ellphi" + teacher_local_pca_k: int = 10 + eps_scale: float = 1.0 + scale_mode: str = "fixed" + max_points: int | None = None + prob_weighting: bool = False + + +def topo_wdist_options_from_config(config: dict[str, Any]) -> TopoWdistOptions: + loss_cfg = config.get("loss", {}) + training_cfg = config.get("training", {}) + topo_cfg = config.get("model", {}).get("topology_loss", {}) + max_pts = training_cfg.get("topo_loss_max_points", loss_cfg.get("topo_loss_max_points")) + return TopoWdistOptions( + teacher_mode=str( + loss_cfg.get("teacher_mode", training_cfg.get("teacher_mode", "local_pca")) + ).strip().lower(), + distance_backend=str(topo_cfg.get("distance_backend", "mahalanobis")).lower().strip(), + teacher_local_pca_k=int( + loss_cfg.get( + "teacher_local_pca_k", + training_cfg.get("teacher_local_pca_k", 10), + ) + ), + eps_scale=float( + loss_cfg.get("topo_eps_scale", training_cfg.get("topo_eps_scale", 1.0)) + ), + scale_mode=str( + loss_cfg.get("topo_scale_mode", training_cfg.get("topo_scale_mode", "fixed")) + ).strip().lower(), + max_points=int(max_pts) if max_pts is not None else None, + prob_weighting=bool(topo_cfg.get("prob_weighting", True)), + ) + + +def _rescale_distance_matrix( + d_mat: torch.Tensor, + *, + scale_mode: str, + eps_scale: float, + clean_scale: float | None, +) -> torch.Tensor: + if scale_mode == "median": + off = d_mat[d_mat > 0] + if off.numel() == 0: + return d_mat + denom = torch.median(off).detach() + NUMERICAL_EPS + if clean_scale is not None: + return d_mat * (float(clean_scale) / denom) + return d_mat / denom + if eps_scale != 1.0: + return d_mat * eps_scale + return d_mat + + +def _subsample_cloud( + points: torch.Tensor, + params: torch.Tensor, + max_points: int | None, +) -> tuple[torch.Tensor, torch.Tensor]: + n = points.shape[0] + if max_points is None or n <= max_points: + return points, params + idx = torch.randperm(n, device=points.device)[:max_points] + return points[idx], params[idx] + + +def compute_topo_wdist( + points: np.ndarray, + params: np.ndarray, + clean_pc: np.ndarray, + options: TopoWdistOptions | None = None, +) -> float: + """ + Wasserstein-2 distance between H1 PDs (same units as ``TopologicalLoss`` term). + + ``points`` / ``params``: full noisy cloud and learned ellipses (DBSCAN unused). + ``clean_pc``: padded clean inlier coordinates for the teacher PD. + """ + opts = options or TopoWdistOptions() + pts = torch.as_tensor(points, dtype=torch.float32) + par = torch.as_tensor(params[..., :3], dtype=torch.float32) + clean = torch.as_tensor(clean_pc, dtype=torch.float32) + + if pts.ndim != 2 or pts.shape[1] != 2: + raise ValueError(f"points must be (N, 2); got {pts.shape}") + if par.shape[0] != pts.shape[0]: + raise ValueError(f"params length {par.shape[0]} != points {pts.shape[0]}") + + vr = VietorisRipsComplex(dim=1) + wdist_fn = WassersteinDistance(q=2) + + with torch.no_grad(): + clean_batch = clean.unsqueeze(0) + clean_pd_info, clean_scales = compute_clean_teacher_batch( + clean_batch, + vr, + teacher_mode=opts.teacher_mode, + distance_backend=opts.distance_backend, + ellphi_differentiable=False, + local_pca_k=opts.teacher_local_pca_k, + max_points=opts.max_points, + need_clean_scales=(opts.scale_mode == "median"), + ) + + pts_i, par_i = _subsample_cloud(pts, par, opts.max_points) + d_batch = compute_distance_matrix_batch( + pts_i.unsqueeze(0), + par_i.unsqueeze(0), + probs=None, + symmetrize="max", + backend=opts.distance_backend, + ellphi_differentiable=False, + ) + clean_scale_i = clean_scales[0] if clean_scales is not None else None + d_mat = _rescale_distance_matrix( + d_batch[0], + scale_mode=opts.scale_mode, + eps_scale=opts.eps_scale, + clean_scale=clean_scale_i, + ) + pd_pred = vr(d_mat, treat_as_distances=True) + w2_sq = wdist_fn(pd_pred, clean_pd_info[0]) ** 2 + value = float(w2_sq.item()) + if not np.isfinite(value): + raise RuntimeError("non-finite topo W-Dist") + return value diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 72b5775..114a884 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -4,14 +4,16 @@ import torch from torch.optim import Adam from torch_topological.nn import VietorisRipsComplex -from tda_ml.visualization import visualize +from tda_ml.teacher_pd import compute_clean_teacher_batch from tda_ml.losses import ( ClassificationLoss, TopologicalLoss, SizeRegularizationLoss, - AnisotropyPenaltyLoss + AnisotropyPenaltyLoss, + MinBRegularizationLoss, ) from tda_ml.metrics import compute_recall_specificity_gmean_mcc +from tda_ml.visualization import visualize import tqdm from sklearn.metrics import f1_score, precision_score, recall_score @@ -50,6 +52,17 @@ def __init__(self, model, config, device=None, trial=None): self.lambda_class = loss_cfg.get('w_class', training_cfg.get('lambda_class', 1.0)) self.lambda_topo = loss_cfg.get('w_topo', training_cfg.get('lambda_topo', 0.1)) self.lambda_aniso = loss_cfg.get('w_aniso', training_cfg.get('lambda_aniso', 0.01)) + self.lambda_min_b = loss_cfg.get( + 'w_min_b', training_cfg.get('lambda_min_b', 0.0) + ) + self.min_b_target = float( + loss_cfg.get('min_b_target', training_cfg.get('min_b_target', 0.2)) + ) + self.topo_loss_max_points = training_cfg.get( + 'topo_loss_max_points', loss_cfg.get('topo_loss_max_points') + ) + if self.topo_loss_max_points is not None: + self.topo_loss_max_points = int(self.topo_loss_max_points) size_default = loss_cfg.get( "w_size", training_cfg.get("lambda_size", 0.1) @@ -68,19 +81,45 @@ def __init__(self, model, config, device=None, trial=None): _topo = config.get("model", {}).get("topology_loss", {}) self.distance_backend = _topo.get("distance_backend", "mahalanobis") self.ellphi_differentiable = _topo.get("ellphi_differentiable", True) + self.prob_weighting = bool(_topo.get("prob_weighting", True)) + # Filtration-unit alignment for the topology loss (see TopologicalLoss). + # Legacy knob `training.topo_eps_scale` (v73=0.7022); also accept loss.topo_eps_scale. + self.topo_eps_scale = float( + loss_cfg.get("topo_eps_scale", training_cfg.get("topo_eps_scale", 1.0)) + ) + self.topo_scale_mode = str( + loss_cfg.get("topo_scale_mode", training_cfg.get("topo_scale_mode", "fixed")) + ).strip().lower() + self.teacher_mode = str( + loss_cfg.get("teacher_mode", training_cfg.get("teacher_mode", "euclidean")) + ).strip().lower() + self.teacher_local_pca_k = int( + loss_cfg.get( + "teacher_local_pca_k", + training_cfg.get("teacher_local_pca_k", 10), + ) + ) logger.info( - "Topological distance backend: %s%s", + "Topological distance backend: %s%s (prob_weighting=%s, scale_mode=%s, eps_scale=%s, teacher_mode=%s)", self.distance_backend, ( f" (ellphi_differentiable={self.ellphi_differentiable})" if self.distance_backend == "ellphi" else "" ), + self.prob_weighting, + self.topo_scale_mode, + self.topo_eps_scale, + self.teacher_mode, ) self.topo_loss_fn = TopologicalLoss( weight=self.lambda_topo, distance_backend=self.distance_backend, ellphi_differentiable=self.ellphi_differentiable, + prob_weighting=self.prob_weighting, + eps_scale=self.topo_eps_scale, + scale_mode=self.topo_scale_mode, + max_points=self.topo_loss_max_points, ) self.size_loss_fn = SizeRegularizationLoss(w_major=self.lambda_major, w_minor=self.lambda_minor) self.aniso_loss_fn = AnisotropyPenaltyLoss( @@ -88,6 +127,18 @@ def __init__(self, model, config, device=None, trial=None): mode=self.aniso_mode, barrier_threshold=config['training'].get('barrier_threshold', 6.0) ) + self.min_b_loss_fn = MinBRegularizationLoss( + weight=self.lambda_min_b, + target=self.min_b_target, + ) + if self.lambda_min_b > 0: + logger.info( + "Min-B regularization: lambda=%s target=%s", + self.lambda_min_b, + self.min_b_target, + ) + if self.topo_loss_max_points is not None: + logger.info("Topological loss subsampling: max_points=%s", self.topo_loss_max_points) self.visualize_every = config['training'].get('visualize_every', 5) self.output_dir = config['outputs']['image_dir'] @@ -108,15 +159,18 @@ def __init__(self, model, config, device=None, trial=None): # _compute_regularization_loss is now handled by classes in losses.py - def _compute_clean_pd_info(self, clean_pc: torch.Tensor) -> list: - """Compute clean persistence diagrams without gradient tracking.""" - clean_pd_info = [] - with torch.no_grad(): - for j in range(clean_pc.shape[0]): - c = clean_pc[j] - valid_mask = torch.abs(c).sum(dim=1) > 1e-6 - clean_pd_info.append(self.vr_complex(c[valid_mask])) - return clean_pd_info + def _compute_clean_pd_info(self, clean_pc: torch.Tensor): + """Compute clean (teacher) persistence diagrams without gradient tracking.""" + return compute_clean_teacher_batch( + clean_pc, + self.vr_complex, + teacher_mode=self.teacher_mode, + distance_backend=self.distance_backend, + ellphi_differentiable=self.ellphi_differentiable, + local_pca_k=self.teacher_local_pca_k, + max_points=self.topo_loss_max_points, + need_clean_scales=(self.topo_scale_mode == "median"), + ) def train_epoch(self, data_loader, epoch): self.model.train() @@ -125,6 +179,7 @@ def train_epoch(self, data_loader, epoch): total_topo_loss = 0 total_aniso_loss = 0 total_size_loss = 0 + total_min_b_loss = 0 steps_completed = 0 all_train_preds = [] @@ -155,9 +210,10 @@ def train_epoch(self, data_loader, epoch): self.optimizer.zero_grad(set_to_none=True) clean_pd_info = None + clean_scales = None if self.lambda_topo > 0 and epoch > self.warmup_epochs: # Topological target PD does not require autograd; keep it out of AMP/grad graph. - clean_pd_info = self._compute_clean_pd_info(clean_pc) + clean_pd_info, clean_scales = self._compute_clean_pd_info(clean_pc) with torch.amp.autocast( device_type=self.autocast_device_type, @@ -169,17 +225,23 @@ def train_epoch(self, data_loader, epoch): topo_loss = torch.tensor(0.0, device=self.device) if clean_pd_info is not None: - topo_loss = self.topo_loss_fn(data, params, logits, clean_pd_info) + topo_loss = self.topo_loss_fn( + data, params, logits, clean_pd_info, clean_scales=clean_scales + ) # Regularization Losses (Size and Anisotropy only, as per slides) if epoch > self.warmup_epochs: size_loss = self.size_loss_fn(params) aniso_loss = self.aniso_loss_fn(params) + min_b_loss = self.min_b_loss_fn(params) else: size_loss = torch.tensor(0.0, device=self.device) aniso_loss = torch.tensor(0.0, device=self.device) + min_b_loss = torch.tensor(0.0, device=self.device) - loss = class_loss + topo_loss + aniso_loss + size_loss + loss = topo_loss + aniso_loss + size_loss + min_b_loss + if self.lambda_class > 0: + loss = loss + self.lambda_class * class_loss if torch.isnan(loss): logger.warning("NaN loss at epoch=%s batch_index=%s; skipping step", epoch, i) @@ -202,13 +264,14 @@ def train_epoch(self, data_loader, epoch): total_topo_loss += topo_loss.item() total_aniso_loss += aniso_loss.item() total_size_loss += size_loss.item() + total_min_b_loss += min_b_loss.item() probs = torch.sigmoid(logits).squeeze(-1) preds = (probs > self.threshold).long() all_train_preds.extend(preds.cpu().numpy().flatten()) all_train_labels.extend(labels.cpu().numpy().flatten()) - pbar.set_postfix(loss=f"{loss.item():.4f}", cls=f"{class_loss.item():.4f}", topo=f"{topo_loss.item():.4f}", aniso=f"{aniso_loss.item():.4f}", size=f"{size_loss.item():.4f}") + pbar.set_postfix(loss=f"{loss.item():.4f}", cls=f"{class_loss.item():.4f}", topo=f"{topo_loss.item():.4f}", aniso=f"{aniso_loss.item():.4f}", size=f"{size_loss.item():.4f}", min_b=f"{min_b_loss.item():.4f}") if i % 10 == 0: logger.debug( "Step %s: loss=%.4f class=%.4f topo=%.4f aniso=%.4f size=%.4f", @@ -254,7 +317,7 @@ def train_epoch(self, data_loader, epoch): ) if epoch % self.visualize_every == 0: - visualize(self.model, self.device, data_loader.dataset, epoch, output_dir=self.output_dir, title_prefix=self.config['meta'].get('config_id', 'train'), sample_indices=self.fixed_indices, threshold=self.threshold) + visualize(self.model, self.device, data_loader.dataset, epoch, output_dir=self.output_dir, title_prefix=self.config['meta'].get('config_id', 'train'), sample_indices=self.fixed_indices, threshold=self.threshold, backend=self.distance_backend) return ( avg_loss, @@ -277,11 +340,20 @@ def validate(self, data_loader): all_preds = [] self._val_aniso_accum = 0.0 self._val_size_accum = 0.0 + total_topo_loss = 0.0 + topo_steps = 0 with torch.no_grad(): - for data, labels, _ in data_loader: + for data, labels, clean_pc in data_loader: data = data.to(self.device, non_blocking=True) labels = labels.to(self.device, non_blocking=True) + clean_pc = clean_pc.to(self.device, non_blocking=True) + + clean_pd_info = None + clean_scales = None + if self.lambda_topo > 0: + clean_pd_info, clean_scales = self._compute_clean_pd_info(clean_pc) + with torch.amp.autocast( device_type=self.autocast_device_type, dtype=self.amp_dtype, @@ -289,7 +361,19 @@ def validate(self, data_loader): ): logits, params = self.model(data) class_loss = self.class_loss_fn(logits, labels) - total_loss += class_loss.item() + topo_loss = torch.tensor(0.0, device=self.device) + if clean_pd_info is not None: + topo_loss = self.topo_loss_fn( + data, + params, + logits, + clean_pd_info, + clean_scales=clean_scales, + ) + total_loss += (self.lambda_class * class_loss).item() + if clean_pd_info is not None: + total_topo_loss += topo_loss.item() + topo_steps += 1 aniso_loss = self.aniso_loss_fn(params) size_loss = self.size_loss_fn(params) @@ -308,6 +392,7 @@ def validate(self, data_loader): avg_aniso = self._val_aniso_accum / num_batches avg_size = self._val_size_accum / num_batches + avg_topo_loss = total_topo_loss / topo_steps if topo_steps > 0 else 0.0 recall = recall_score(all_labels, all_preds, zero_division=0) @@ -315,5 +400,5 @@ def validate(self, data_loader): all_labels, all_preds ) - return avg_loss, recall, specificity, gmean, mcc, avg_aniso, avg_size + return avg_loss, recall, specificity, gmean, mcc, avg_aniso, avg_size, avg_topo_loss diff --git a/tda_ml/visualization.py b/tda_ml/visualization.py index a346ac0..4da3e93 100644 --- a/tda_ml/visualization.py +++ b/tda_ml/visualization.py @@ -6,6 +6,40 @@ import numpy as np import torch +from tda_ml.dbscan import apply_anisotropic_dbscan, calculate_anisotropic_distance_matrix + +INLIER_COLOR = "tab:blue" +OUTLIER_COLOR = "tab:red" + + +def _auto_eps(points_np, params_np, backend, quantile=0.3): + """Visualization-time eps heuristic: a quantile of positive anisotropic distances. + + Declared heuristic so the DBSCAN panel stays informative regardless of the + (possibly collapsed) absolute ellipse scale. Not used for any reported metric. + """ + dm = calculate_anisotropic_distance_matrix( + points_np, params_np, metric="max", probs=None, backend=backend + ) + off = dm[dm > 0] + if off.size == 0: + return None + return float(np.quantile(off, quantile)) + + +def _draw_ellipses(ax, points_np, params_np, gt_labels): + """Overlay each point's learned ellipse, colored by its GT label.""" + t = np.linspace(0, 2 * np.pi, 50) + for k in range(len(points_np)): + a, b, theta = params_np[k] + cx, cy = points_np[k, 0], points_np[k, 1] + x_e = a * np.cos(t) + y_e = b * np.sin(t) + x_r = x_e * np.cos(theta) - y_e * np.sin(theta) + cx + y_r = x_e * np.sin(theta) + y_e * np.cos(theta) + cy + color = OUTLIER_COLOR if int(gt_labels[k]) == 1 else INLIER_COLOR + ax.plot(x_r, y_r, color=color, alpha=0.35, linewidth=0.8) + def visualize( model, @@ -16,9 +50,20 @@ def visualize( title_prefix="", sample_indices=None, threshold=0.5, + backend="mahalanobis", + eps=None, + min_samples=5, ): - """ - Visualizes model predictions on 3 random samples from the dataset. + """Per sample cloud, draw a row of 3 panels. + + col 0 GT Labels : points colored by ground-truth (inlier/outlier). + col 1 GT + learned ellipses : same GT points, each point's learned ellipse drawn + at true scale and colored by the point's GT label + (the anisotropy-acquisition check). + col 2 DBSCAN clusters : clustering on the learned anisotropic distance + matrix; points colored by cluster id (-1=noise). + + ``threshold`` is kept for call-site compatibility (unused in this view). """ model.eval() fig, axes = plt.subplots(3, 3, figsize=(15, 15)) @@ -32,86 +77,53 @@ def visualize( idx = sample_indices[i] else: idx = torch.randint(0, len(dataset), (1,)).item() - data, labels, clean_pc = dataset[idx] + data, labels, _clean_pc = dataset[idx] data_np = data.numpy() - labels_np = labels.numpy() + labels_np = labels.numpy().astype(int).flatten() data_batch = data.to(device).unsqueeze(0) - logits, params = model(data_batch) - - probs = torch.sigmoid(logits).squeeze(0).cpu().numpy() - pred_labels = (probs > threshold).astype(int).flatten() - + _logits, params = model(data_batch) params_np = params.squeeze(0).cpu().numpy() - axes[i, 0].scatter( - data_np[labels_np == 1, 0], - data_np[labels_np == 1, 1], - c="red", - s=10, - label="Outlier (GT)", - ) - axes[i, 0].scatter( - data_np[labels_np == 0, 0], - data_np[labels_np == 0, 1], - c="blue", - s=10, - label="Inlier (GT)", - ) - axes[i, 0].set_title(f"Sample {i + 1}: GT Labels") - axes[i, 0].legend() - - axes[i, 1].scatter( - data_np[pred_labels == 1, 0], - data_np[pred_labels == 1, 1], - c="red", - s=10, - marker="x", - label="Pred Outlier", - ) - axes[i, 1].scatter( - data_np[pred_labels == 0, 0], - data_np[pred_labels == 0, 1], - c="blue", - s=10, - label="Pred Inlier", - ) - axes[i, 1].set_title(f"Sample {i + 1}: Prediction") - axes[i, 1].legend() - - axes[i, 2].scatter( - data_np[pred_labels == 1, 0], - data_np[pred_labels == 1, 1], - c="red", - s=10, - marker="x", - label="Pred Outlier", - ) - axes[i, 2].scatter( - data_np[pred_labels == 0, 0], - data_np[pred_labels == 0, 1], - c="blue", - s=10, - label="Pred Inlier", - ) - - t = np.linspace(0, 2 * np.pi, 50) - if len(data_np) > 0: - for k in range(len(data_np)): - a, b, theta = params_np[k] - cx = data_np[k, 0] - cy = data_np[k, 1] - - x_e = a * np.cos(t) - y_e = b * np.sin(t) - x_r = x_e * np.cos(theta) - y_e * np.sin(theta) + cx - y_r = x_e * np.sin(theta) + y_e * np.cos(theta) + cy - - line_color = "blue" if pred_labels[k] == 0 else "red" - axes[i, 2].plot(x_r, y_r, color=line_color, alpha=0.3, linewidth=1) - - axes[i, 2].set_title(f"Sample {i + 1}: Predicted Inliers & Ellipses") + in_m = labels_np == 0 + out_m = labels_np == 1 + + # col 0: GT labels + ax0 = axes[i, 0] + ax0.scatter(data_np[in_m, 0], data_np[in_m, 1], c=INLIER_COLOR, s=10, label="Inlier (GT)") + ax0.scatter(data_np[out_m, 0], data_np[out_m, 1], c=OUTLIER_COLOR, s=10, label="Outlier (GT)") + ax0.set_title(f"Sample {i + 1}: GT Labels") + ax0.legend(loc="upper right", fontsize=8) + + # col 1: GT points (copied) + learned ellipses colored by GT label + ax1 = axes[i, 1] + ax1.scatter(data_np[in_m, 0], data_np[in_m, 1], c=INLIER_COLOR, s=8) + ax1.scatter(data_np[out_m, 0], data_np[out_m, 1], c=OUTLIER_COLOR, s=8) + _draw_ellipses(ax1, data_np, params_np, labels_np) + ax1.set_title(f"Sample {i + 1}: Learned Ellipses (GT-colored)") + + # col 2: DBSCAN on learned anisotropic distance + ax2 = axes[i, 2] + eps_i = eps if eps is not None else _auto_eps(data_np, params_np, backend) + if eps_i is not None and eps_i > 0: + cl = apply_anisotropic_dbscan( + data_np, params_np, eps=eps_i, min_samples=min_samples, backend=backend + ) + uniq = sorted(set(cl.tolist())) + cmap = plt.get_cmap("tab10") + for j, c in enumerate(uniq): + m = cl == c + if c == -1: + ax2.scatter(data_np[m, 0], data_np[m, 1], c="0.4", s=12, marker="x", label="noise") + else: + ax2.scatter(data_np[m, 0], data_np[m, 1], color=cmap(j % 10), s=12, label=f"cl {c}") + n_clusters = len([c for c in uniq if c != -1]) + ax2.set_title(f"Sample {i + 1}: DBSCAN ({n_clusters} cl, eps={eps_i:.3f})") + ax2.legend(loc="upper right", fontsize=7) + else: + ax2.set_title(f"Sample {i + 1}: DBSCAN (degenerate distances)") + for ax in axes[i]: ax.set_aspect("equal") ax.set_xlim(-1.2, 1.2) diff --git a/tests/test_evaluate_topo_checkpoint.py b/tests/test_evaluate_topo_checkpoint.py new file mode 100644 index 0000000..a2c0fcc --- /dev/null +++ b/tests/test_evaluate_topo_checkpoint.py @@ -0,0 +1,60 @@ +"""Tests for train-topo checkpoint selection (saved epochs only).""" + +from __future__ import annotations + +import csv +from pathlib import Path + +import pytest + +from experiments.checkpoint_selection import ( + best_topo_checkpoint, + best_topo_epoch, + list_checkpoint_epochs, +) + + +def test_best_topo_epoch_restricted_to_saved(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + log_dir = run_dir / "logs" + log_dir.mkdir(parents=True) + metrics = log_dir / "metrics.csv" + with metrics.open("w", newline="") as f: + w = csv.writer(f) + w.writerow(["epoch", "train_topo_loss"]) + w.writerow([28, 0.09]) + w.writerow([29, 0.05]) + w.writerow([30, 0.06]) + + (run_dir / "checkpoint_epoch_30.pth").write_bytes(b"x") + + saved = list_checkpoint_epochs(run_dir) + assert saved == {30} + + ep, loss = best_topo_epoch(metrics, saved_epochs=saved) + assert ep == 30 + assert loss == pytest.approx(0.06) + + global_ep, global_loss = best_topo_epoch(metrics) + assert global_ep == 29 + assert global_loss == pytest.approx(0.05) + + ckpt, ep, loss, gep, gt = best_topo_checkpoint(run_dir) + assert ckpt == "checkpoint_epoch_30.pth" + assert ep == 30 + assert loss == pytest.approx(0.06) + assert gep == 29 + assert gt == pytest.approx(0.05) + + +def test_best_topo_epoch_prefers_val_topo_column(tmp_path: Path) -> None: + metrics = tmp_path / "metrics.csv" + with metrics.open("w", newline="") as f: + w = csv.writer(f) + w.writerow(["epoch", "train_topo_loss", "val_topo_loss"]) + w.writerow([1, 0.20, 0.11]) + w.writerow([2, 0.10, 0.12]) + + ep, loss = best_topo_epoch(metrics) + assert ep == 1 + assert loss == pytest.approx(0.11) diff --git a/tests/test_losses_topo_distance.py b/tests/test_losses_topo_distance.py index 82838c8..b2acdd6 100644 --- a/tests/test_losses_topo_distance.py +++ b/tests/test_losses_topo_distance.py @@ -48,6 +48,84 @@ def test_normalize_invalid_mode_raises_value_error(self): with self.assertRaises(ValueError): normalize_topo_distance_mode("unknown-mode") + +class TestTopoEpsScale(unittest.TestCase): + """eps_scale / scale_mode (filtration-unit alignment) behavior.""" + + def _inputs(self): + torch.manual_seed(1) + b, n = 2, 8 + pt = torch.randn(b, n, 2) + par = torch.randn(b, n, 3) + par[:, :, 0:2] = par[:, :, 0:2].abs() + 0.2 + logits = torch.zeros(b, n, 1) + return pt, par, logits + + def _clean_pd(self, pt): + from torch_topological.nn import VietorisRipsComplex + vr = VietorisRipsComplex(dim=1) + return [vr(pt[i]) for i in range(pt.shape[0])] + + def test_invalid_scale_mode_raises(self): + from tda_ml.losses import TopologicalLoss + with self.assertRaises(ValueError): + TopologicalLoss(scale_mode="bogus") + + def test_eps_scale_default_is_noop(self): + """eps_scale=1.0 (fixed) must not change the loss vs. an explicit 1.0.""" + from tda_ml.losses import TopologicalLoss + pt, par, logits = self._inputs() + clean = self._clean_pd(pt) + base = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", prob_weighting=False) + same = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", + prob_weighting=False, eps_scale=1.0) + l0 = base(pt, par, logits, clean).item() + l1 = same(pt, par, logits, clean).item() + self.assertAlmostEqual(l0, l1, places=6) + + def test_eps_scale_changes_loss(self): + """A non-unit eps_scale rescales the predicted filtration and changes the loss.""" + from tda_ml.losses import TopologicalLoss + pt, par, logits = self._inputs() + clean = self._clean_pd(pt) + base = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", prob_weighting=False) + scaled = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", + prob_weighting=False, eps_scale=0.3) + l0 = base(pt, par, logits, clean).item() + ls = scaled(pt, par, logits, clean).item() + self.assertGreater(abs(l0 - ls), 1e-6) + + def test_median_mode_runs_and_grads(self): + from tda_ml.losses import TopologicalLoss + pt, par, logits = self._inputs() + par = par.clone().requires_grad_(True) + clean = self._clean_pd(pt) + loss_fn = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", + prob_weighting=False, scale_mode="median") + # clean_scales (m_e) provided by the trainer; loss brings prediction onto it. + clean_scales = [float(torch.pdist(pt[i]).median()) for i in range(pt.shape[0])] + loss = loss_fn(pt, par, logits, clean, clean_scales=clean_scales) + self.assertTrue(torch.isfinite(loss)) + loss.backward() + self.assertIsNotNone(par.grad) + + def test_median_aligns_prediction_to_teacher_scale(self): + """median mode must rescale the prediction so its median matches m_e, + leaving the teacher untouched.""" + from tda_ml.losses import TopologicalLoss + pt, par, logits = self._inputs() + loss_fn = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", + prob_weighting=False, scale_mode="median") + i = 0 + from tda_ml.losses import compute_distance_matrix_batch + D = compute_distance_matrix_batch(pt, par, probs=None, symmetrize="max", + backend="mahalanobis") + m_e = float(torch.pdist(pt[i]).median()) + rescaled = loss_fn._rescale_distance_matrix(D[i], clean_scale=m_e) + off = rescaled[rescaled > 0] + # After alignment the predicted median equals the teacher Euclidean median. + self.assertAlmostEqual(float(off.median()), m_e, places=4) + def test_mahalanobis_extreme_params_remain_finite(self): """極小/極大軸長でも距離行列が非有限値にならないことを確認。""" b, n = 1, 5 @@ -68,5 +146,40 @@ def test_mahalanobis_extreme_params_remain_finite(self): self.assertTrue(torch.isfinite(d).all()) +class TestMinBAndTopoSubsampling(unittest.TestCase): + def test_min_b_penalizes_small_minor_axis(self): + from tda_ml.losses import MinBRegularizationLoss + loss_fn = MinBRegularizationLoss(weight=1.0, target=0.5) + params = torch.tensor([[[0.8, 0.1, 0.0], [0.6, 0.4, 0.0]]]) + loss = loss_fn(params) + self.assertGreater(loss.item(), 0.0) + + def test_min_b_zero_weight_is_noop(self): + from tda_ml.losses import MinBRegularizationLoss + loss_fn = MinBRegularizationLoss(weight=0.0, target=0.5) + params = torch.tensor([[[0.8, 0.1, 0.0]]]) + self.assertEqual(loss_fn(params).item(), 0.0) + + def test_topo_max_points_subsamples(self): + from tda_ml.losses import TopologicalLoss + torch.manual_seed(0) + b, n = 1, 20 + pt = torch.randn(b, n, 2) + par = torch.randn(b, n, 3) + par[:, :, 0:2] = par[:, :, 0:2].abs() + 0.2 + logits = torch.zeros(b, n, 1) + from torch_topological.nn import VietorisRipsComplex + vr = VietorisRipsComplex(dim=1) + clean = [vr(pt[0, :12])] + loss_fn = TopologicalLoss( + weight=1.0, + distance_backend="mahalanobis", + prob_weighting=False, + max_points=12, + ) + loss = loss_fn(pt, par, logits, clean) + self.assertTrue(torch.isfinite(loss)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_teacher_pd.py b/tests/test_teacher_pd.py new file mode 100644 index 0000000..58a22eb --- /dev/null +++ b/tests/test_teacher_pd.py @@ -0,0 +1,92 @@ +"""Tests for teacher PD modes (Euclidean vs local-PCA D_ideal).""" + +import unittest + +import torch +from torch_topological.nn import VietorisRipsComplex + +from tda_ml.local_pca import ( + TEACHER_MODE_EUCLIDEAN, + TEACHER_MODE_LOCAL_PCA, + local_pca_ellipse_params, + normalize_teacher_mode, +) +from tda_ml.teacher_pd import compute_clean_teacher_batch + + +class TestTeacherMode(unittest.TestCase): + def test_normalize_teacher_mode(self): + self.assertEqual(normalize_teacher_mode("Euclidean"), TEACHER_MODE_EUCLIDEAN) + self.assertEqual(normalize_teacher_mode("local_pca"), TEACHER_MODE_LOCAL_PCA) + with self.assertRaises(ValueError): + normalize_teacher_mode("unknown") + + def test_local_pca_params_shape(self): + torch.manual_seed(0) + pts = torch.randn(12, 2) + params = local_pca_ellipse_params(pts, k=10) + self.assertEqual(params.shape, (12, 3)) + self.assertTrue(torch.isfinite(params).all()) + self.assertTrue((params[:, 0:2] > 0).all()) + + def test_euclidean_teacher_runs(self): + vr = VietorisRipsComplex(dim=1) + clean = torch.randn(2, 15, 2) + pd_info, scales = compute_clean_teacher_batch( + clean, + vr, + teacher_mode="euclidean", + need_clean_scales=True, + ) + self.assertEqual(len(pd_info), 2) + self.assertIsNotNone(scales) + self.assertEqual(len(scales), 2) + + def test_local_pca_teacher_mahalanobis(self): + vr = VietorisRipsComplex(dim=1) + clean = torch.randn(2, 20, 2) + pd_info, scales = compute_clean_teacher_batch( + clean, + vr, + teacher_mode="local_pca", + distance_backend="mahalanobis", + local_pca_k=10, + need_clean_scales=True, + ) + self.assertEqual(len(pd_info), 2) + self.assertIsNotNone(scales) + self.assertEqual(len(scales), 2) + + def test_local_pca_teacher_ellphi_finite(self): + vr = VietorisRipsComplex(dim=1) + clean = torch.randn(1, 12, 2) + pd_info, _ = compute_clean_teacher_batch( + clean, + vr, + teacher_mode="local_pca", + distance_backend="ellphi", + ellphi_differentiable=False, + local_pca_k=8, + ) + self.assertEqual(len(pd_info), 1) + + def test_modes_differ_for_same_points(self): + vr = VietorisRipsComplex(dim=1) + clean = torch.randn(1, 25, 2) + pd_eucl, _ = compute_clean_teacher_batch( + clean, vr, teacher_mode="euclidean", distance_backend="mahalanobis" + ) + pd_pca, _ = compute_clean_teacher_batch( + clean, + vr, + teacher_mode="local_pca", + distance_backend="mahalanobis", + local_pca_k=10, + ) + # PD objects differ when filtration construction differs + self.assertIsNotNone(pd_eucl[0]) + self.assertIsNotNone(pd_pca[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_topo_wdist.py b/tests/test_topo_wdist.py new file mode 100644 index 0000000..272effd --- /dev/null +++ b/tests/test_topo_wdist.py @@ -0,0 +1,47 @@ +"""Tests for ellipse-filtration topo W-Dist (eval metric).""" + +from __future__ import annotations + +import unittest + +import numpy as np +import torch + +from tda_ml.local_pca import local_pca_ellipse_params +from tda_ml.topo_wdist import TopoWdistOptions, compute_topo_wdist + + +class TestTopoWdist(unittest.TestCase): + def _circle(self, n: int = 12) -> np.ndarray: + t = np.linspace(0, 2 * np.pi, n, endpoint=False) + return np.stack([np.cos(t), np.sin(t)], axis=1).astype(np.float32) + + def test_identical_clouds_near_zero_mahalanobis(self): + clean = self._circle(14) + noisy = clean.copy() + params = local_pca_ellipse_params(torch.from_numpy(noisy).unsqueeze(0)).squeeze(0).numpy() + opts = TopoWdistOptions( + teacher_mode="local_pca", + distance_backend="mahalanobis", + ) + w = compute_topo_wdist(noisy, params, clean, opts) + self.assertTrue(np.isfinite(w)) + self.assertLess(w, 0.05) + + def test_different_clouds_positive(self): + clean = self._circle(14) + noisy = clean + 0.3 * np.random.default_rng(0).standard_normal(clean.shape).astype( + np.float32 + ) + params = local_pca_ellipse_params(torch.from_numpy(noisy).unsqueeze(0)).squeeze(0).numpy() + opts = TopoWdistOptions( + teacher_mode="local_pca", + distance_backend="mahalanobis", + ) + w_same = compute_topo_wdist(clean, params[: clean.shape[0]], clean, opts) + w_diff = compute_topo_wdist(noisy, params, clean, opts) + self.assertGreater(w_diff, w_same) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_trainer.py b/tests/test_trainer.py index a8c8cd8..8db5ad8 100644 --- a/tests/test_trainer.py +++ b/tests/test_trainer.py @@ -78,5 +78,32 @@ def test_train_epoch(self): self.assertTrue(params_updated, "Model weights were not updated after a training step.") self.assertGreater(avg_loss, 0.0) + def test_validate_reports_val_topo_loss(self): + val_res = self.trainer.validate(self.train_loader) + self.assertEqual(len(val_res), 8) + self.assertGreater(val_res[7], 0.0) + + def test_w_class_zero_skips_classification_gradient(self): + """w_class=0 and prob_weighting=false must not update the classification head.""" + self.config["loss"] = {"w_class": 0.0} + self.config["model"]["topology_loss"]["prob_weighting"] = False + trainer = Trainer(self.model, self.config, self.device) + self.assertEqual(trainer.lambda_class, 0.0) + + cls_head_params = list(self.model.classification_head.parameters()) + cls_before = [p.clone() for p in cls_head_params] + topo_before = [p.clone() for p in self.model.topology_head.parameters()] + + trainer.train_epoch(self.train_loader, epoch=1) + + cls_changed = any( + not torch.equal(b, p) for b, p in zip(cls_before, cls_head_params) + ) + topo_changed = any( + not torch.equal(b, p) for b, p in zip(topo_before, self.model.topology_head.parameters()) + ) + self.assertFalse(cls_changed, "classification head should be frozen when w_class=0") + self.assertTrue(topo_changed, "topology head should still update when w_class=0") + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py new file mode 100644 index 0000000..c7938a1 --- /dev/null +++ b/tests/test_tune_config.py @@ -0,0 +1,36 @@ +"""Tests for Optuna tune trial config (local_pca teacher).""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "experiments")) + +from tune_elongate_wdist import build_trial_config # noqa: E402 + + +class TestTuneTrialConfig(unittest.TestCase): + def test_local_pca_tune_base(self): + cfg = build_trial_config( + "elongate_n100_no_cls_tune_local_pca", + w_aniso=0.1, + w_size=0.2, + w_topo=0.3, + lr=1e-4, + backend="ellphi", + tune_epochs=20, + out_base="outputs/test_tune", + trial_number=0, + ) + self.assertEqual(cfg["loss"]["w_class"], 0.0) + self.assertEqual(cfg["loss"]["teacher_mode"], "local_pca") + self.assertEqual(cfg["training"]["selection"]["metric"], "val_topo") + self.assertEqual(cfg["model"]["topology_loss"]["distance_backend"], "ellphi") + self.assertFalse(cfg["model"]["topology_loss"]["prob_weighting"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index f560daf..0eb9662 100644 --- a/uv.lock +++ b/uv.lock @@ -354,37 +354,37 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cublas" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -474,18 +474,30 @@ wheels = [ [[package]] name = "ellphi" version = "0.1.2" -source = { registry = "https://pypi.org/simple" } +source = { editable = "ellphi_repo" } dependencies = [ { name = "joblib" }, { name = "matplotlib" }, { name = "numpy" }, { name = "scipy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/b1/588ca20ea40115e486e42c2b222da1a3e2d8a54717c529aa8d3e5f90edeb/ellphi-0.1.2.tar.gz", hash = "sha256:c8bc7e9a85683fc0cec036b680a45407a10cf7c06ec059a6f9a9cd9c4cdba116", size = 41292, upload-time = "2026-03-25T07:44:53.115Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/14/dc242856414c9f9f2c027417230c3973f85951e08c9e822ead2a6ef4e439/ellphi-0.1.2-cp312-cp312-manylinux_2_39_x86_64.whl", hash = "sha256:b063e735532df6092e6cf03a8c774faeface907d0fce67176b2d6e349e2187c9", size = 1111921, upload-time = "2026-03-25T07:44:50.37Z" }, - { url = "https://files.pythonhosted.org/packages/26/23/40ef263eb40dc361b7ec8b88b27c3cea291e005a2310622db2e361292aba/ellphi-0.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:8a1024539b5282fc50339a31a9f08c6e9ae2237ab5c43642eb695c0795925222", size = 52159, upload-time = "2026-03-25T07:44:52.104Z" }, -] + +[package.metadata] +requires-dist = [ + { name = "homcloud", marker = "extra == 'demo'", specifier = ">=4.8.0" }, + { name = "ipykernel", marker = "extra == 'demo'", specifier = ">=6.28" }, + { name = "joblib", specifier = ">=1.3" }, + { name = "jupyterlab", marker = "extra == 'demo'", specifier = ">=4.1" }, + { name = "matplotlib", specifier = ">=3.9" }, + { name = "nbformat", marker = "extra == 'demo'", specifier = ">=5.10" }, + { name = "numpy", specifier = ">=1.26" }, + { name = "pandas", marker = "extra == 'demo'", specifier = ">=2.2.0" }, + { name = "plotly", marker = "extra == 'demo'", specifier = ">=5.22" }, + { name = "scipy", specifier = ">=1.12" }, + { name = "seaborn", marker = "extra == 'demo'", specifier = ">=0.13" }, + { name = "tqdm", marker = "extra == 'demo'", specifier = ">=4.67.1" }, +] +provides-extras = ["demo"] [[package]] name = "filelock" @@ -1410,10 +1422,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.14'" }, - { name = "pytz", marker = "python_full_version >= '3.14'" }, - { name = "tzdata", marker = "python_full_version >= '3.14'" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -1462,9 +1474,9 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.14'" }, - { name = "python-dateutil", marker = "python_full_version < '3.14'" }, - { name = "tzdata", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } wheels = [ @@ -2326,7 +2338,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ellphi", specifier = "==0.1.2" }, + { name = "ellphi", editable = "ellphi_repo" }, { name = "gudhi", specifier = ">=3.4" }, { name = "homcloud", marker = "extra == 'repro-pd-animation'", specifier = ">=4.8" }, { name = "joblib", marker = "extra == 'experiments'", specifier = ">=1.3" }, From d77920d9428202e756320bdf27bf842c7d1d35e4 Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:24:45 +0900 Subject: [PATCH 04/44] =?UTF-8?q?refactor:=20=E8=B7=9D=E9=9B=A2=E8=A1=8C?= =?UTF-8?q?=E5=88=97=E3=81=AE=E3=83=AA=E3=82=B9=E3=82=B1=E3=83=BC=E3=83=AB?= =?UTF-8?q?/=E3=82=B5=E3=83=96=E3=82=B5=E3=83=B3=E3=83=97=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=B0=E3=82=92=20distance=5Fbackend=20=E3=81=AB?= =?UTF-8?q?=E9=9B=86=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _rescale_distance_matrix の重複(losses.TopologicalLoss / topo_wdist)を distance_backend.rescale_distance_matrix に一本化 - サブサンプリングを subsample_indices として共通化 - compute_topo_distance_matrix / mahalanobis_distance_matrix_batched / normalize_topo_distance_mode / DISTANCE_MODE_* を losses から distance_backend へ移動(距離計算の責務を一箇所に) Co-authored-by: Cursor --- tda_ml/distance_backend.py | 86 ++++++++++++++++++++++++++++++ tda_ml/losses.py | 83 +++++----------------------- tda_ml/topo_wdist.py | 34 +++--------- tests/test_losses_topo_distance.py | 4 +- 4 files changed, 110 insertions(+), 97 deletions(-) diff --git a/tda_ml/distance_backend.py b/tda_ml/distance_backend.py index d7d0ffd..8a5bf1c 100644 --- a/tda_ml/distance_backend.py +++ b/tda_ml/distance_backend.py @@ -19,6 +19,7 @@ pdist_tangency_matrix_differentiable, ) from tda_ml.geometry import ellipse_params_to_centers_cov_numpy +from tda_ml.numerical_eps import NUMERICAL_EPS from tda_ml.topology import compute_anisotropic_distance_matrix try: @@ -33,6 +34,57 @@ _ELLPHI_PROB_WARNED = False +DISTANCE_MODE_MAHALANOBIS = "mahalanobis" +DISTANCE_MODE_ELLPHI = "ellphi" + + +def normalize_topo_distance_mode(mode: str) -> str: + m = str(mode).strip().lower() + if m == "mahalanobis": + return DISTANCE_MODE_MAHALANOBIS + if m == "ellphi": + return DISTANCE_MODE_ELLPHI + raise ValueError(f"Unknown topo distance mode: {mode!r}") + + +def rescale_distance_matrix( + d_mat: torch.Tensor, + *, + scale_mode: str, + eps_scale: float, + clean_scale: float | None = None, +) -> torch.Tensor: + """Align a predicted distance matrix to the teacher PD filtration units. + + ``clean_scale`` (m_e) is the median pairwise Euclidean distance of the teacher + cloud for this sample. In ``median`` mode the prediction is scaled so its median + matches m_e, i.e. it is brought onto the (untouched) teacher's Euclidean scale; + without m_e it falls back to a per-sample unit median. Any other ``scale_mode`` + applies the fixed scalar ``eps_scale`` (1.0 == no-op). + """ + if scale_mode == "median": + off = d_mat[d_mat > 0] + if off.numel() == 0: + return d_mat + denom = torch.median(off).detach() + NUMERICAL_EPS + if clean_scale is not None: + return d_mat * (float(clean_scale) / denom) + return d_mat / denom + if eps_scale != 1.0: + return d_mat * eps_scale + return d_mat + + +def subsample_indices( + n: int, + max_points: int | None, + device: torch.device | str | None = None, +) -> torch.Tensor | None: + """Random subsampling indices (legacy ``topo_loss_max_points``); ``None`` if no-op.""" + if max_points is None or n <= max_points: + return None + return torch.randperm(n, device=device)[:max_points] + def compute_ellphi_distance_matrix_np(points_np: np.ndarray, params_np: np.ndarray) -> np.ndarray: """ @@ -124,3 +176,37 @@ def compute_distance_matrix_batch( ) mats.append(torch.from_numpy(dm).to(device=points.device, dtype=points.dtype)) return torch.stack(mats, dim=0) + + +def compute_topo_distance_matrix( + points: torch.Tensor, + params: torch.Tensor, + *, + distance_mode: str = "mahalanobis", + ellphi_backend: str = "auto", +) -> torch.Tensor: + """ + Shared topology-loss entrypoint: map batched points/ellipse params to ``(B,N,N)`` distances. + + ``distance_mode`` is ``mahalanobis`` or ``ellphi``. + When ``ellphi_backend='auto'``, a differentiable ellphi path is preferred and + falls back to NumPy when unavailable. + """ + backend = normalize_topo_distance_mode(distance_mode) + eb = str(ellphi_backend).strip().lower() + ellphi_diff = eb in ("auto", "torch", "grad", "differentiable", "1", "true", "yes") + return compute_distance_matrix_batch( + points, + params, + probs=None, + symmetrize="max", + backend=backend, + ellphi_differentiable=ellphi_diff, + ) + + +def mahalanobis_distance_matrix_batched(points: torch.Tensor, params: torch.Tensor) -> torch.Tensor: + """Mahalanobis-style batched distance matrix without probability weighting.""" + return compute_anisotropic_distance_matrix( + points, params, probs=None, symmetrize="max" + ) diff --git a/tda_ml/losses.py b/tda_ml/losses.py index aee45e2..11f5e7e 100644 --- a/tda_ml/losses.py +++ b/tda_ml/losses.py @@ -5,59 +5,15 @@ import torch.nn.functional as F from torch_topological.nn import VietorisRipsComplex, WassersteinDistance -from tda_ml.distance_backend import compute_distance_matrix_batch +from tda_ml.distance_backend import ( + compute_distance_matrix_batch, + rescale_distance_matrix, + subsample_indices, +) from tda_ml.numerical_eps import NUMERICAL_EPS -from tda_ml.topology import compute_anisotropic_distance_matrix -# Distance-mode aliases for topology loss (kept for config/test compatibility). logger = logging.getLogger(__name__) -DISTANCE_MODE_MAHALANOBIS = "mahalanobis" -DISTANCE_MODE_ELLPHI = "ellphi" - - -def normalize_topo_distance_mode(mode: str) -> str: - m = str(mode).strip().lower() - if m == "mahalanobis": - return DISTANCE_MODE_MAHALANOBIS - if m == "ellphi": - return DISTANCE_MODE_ELLPHI - raise ValueError(f"Unknown topo distance mode: {mode!r}") - - -def compute_topo_distance_matrix( - points: torch.Tensor, - params: torch.Tensor, - *, - distance_mode: str = "mahalanobis", - ellphi_backend: str = "auto", -) -> torch.Tensor: - """ - Shared topology-loss entrypoint: map batched points/ellipse params to ``(B,N,N)`` distances. - - ``distance_mode`` is ``mahalanobis`` or ``ellphi``. - When ``ellphi_backend='auto'``, a differentiable ellphi path is preferred and - falls back to NumPy when unavailable. - """ - backend = normalize_topo_distance_mode(distance_mode) - eb = str(ellphi_backend).strip().lower() - ellphi_diff = eb in ("auto", "torch", "grad", "differentiable", "1", "true", "yes") - return compute_distance_matrix_batch( - points, - params, - probs=None, - symmetrize="max", - backend=backend, - ellphi_differentiable=ellphi_diff, - ) - - -def mahalanobis_distance_matrix_batched(points: torch.Tensor, params: torch.Tensor) -> torch.Tensor: - """Mahalanobis-style batched distance matrix without probability weighting.""" - return compute_anisotropic_distance_matrix( - points, params, probs=None, symmetrize="max" - ) - class ClassificationLoss(nn.Module): """ @@ -198,23 +154,13 @@ def __init__( self.wasserstein = WassersteinDistance(q=2) def _rescale_distance_matrix(self, d_mat: torch.Tensor, clean_scale=None) -> torch.Tensor: - """Align the predicted distance matrix to the teacher PD filtration units. - - ``clean_scale`` (m_e) is the median pairwise Euclidean distance of the teacher - cloud for this sample. In median mode the prediction is scaled so its median - matches m_e, i.e. it is brought onto the (untouched) teacher's Euclidean scale. - """ - if self.scale_mode == "median": - off = d_mat[d_mat > 0] - if off.numel() == 0: - return d_mat - denom = torch.median(off).detach() + NUMERICAL_EPS - if clean_scale is not None: - return d_mat * (float(clean_scale) / denom) - return d_mat / denom - if self.eps_scale != 1.0: - return d_mat * self.eps_scale - return d_mat + """See :func:`tda_ml.distance_backend.rescale_distance_matrix`.""" + return rescale_distance_matrix( + d_mat, + scale_mode=self.scale_mode, + eps_scale=self.eps_scale, + clean_scale=clean_scale, + ) def _subsample_points( self, @@ -222,10 +168,9 @@ def _subsample_points( params_i: torch.Tensor, logits_i: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - n = points_i.shape[0] - if self.max_points is None or n <= self.max_points: + idx = subsample_indices(points_i.shape[0], self.max_points, device=points_i.device) + if idx is None: return points_i, params_i, logits_i - idx = torch.randperm(n, device=points_i.device)[: self.max_points] return points_i[idx], params_i[idx], logits_i[idx] def forward(self, points, params, logits, clean_pd_info, clean_scales=None): diff --git a/tda_ml/topo_wdist.py b/tda_ml/topo_wdist.py index 96b11ad..dad9cb3 100644 --- a/tda_ml/topo_wdist.py +++ b/tda_ml/topo_wdist.py @@ -14,8 +14,11 @@ import torch from torch_topological.nn import VietorisRipsComplex, WassersteinDistance -from tda_ml.distance_backend import compute_distance_matrix_batch -from tda_ml.numerical_eps import NUMERICAL_EPS +from tda_ml.distance_backend import ( + compute_distance_matrix_batch, + rescale_distance_matrix, + subsample_indices, +) from tda_ml.teacher_pd import compute_clean_teacher_batch @@ -57,35 +60,14 @@ def topo_wdist_options_from_config(config: dict[str, Any]) -> TopoWdistOptions: ) -def _rescale_distance_matrix( - d_mat: torch.Tensor, - *, - scale_mode: str, - eps_scale: float, - clean_scale: float | None, -) -> torch.Tensor: - if scale_mode == "median": - off = d_mat[d_mat > 0] - if off.numel() == 0: - return d_mat - denom = torch.median(off).detach() + NUMERICAL_EPS - if clean_scale is not None: - return d_mat * (float(clean_scale) / denom) - return d_mat / denom - if eps_scale != 1.0: - return d_mat * eps_scale - return d_mat - - def _subsample_cloud( points: torch.Tensor, params: torch.Tensor, max_points: int | None, ) -> tuple[torch.Tensor, torch.Tensor]: - n = points.shape[0] - if max_points is None or n <= max_points: + idx = subsample_indices(points.shape[0], max_points, device=points.device) + if idx is None: return points, params - idx = torch.randperm(n, device=points.device)[:max_points] return points[idx], params[idx] @@ -137,7 +119,7 @@ def compute_topo_wdist( ellphi_differentiable=False, ) clean_scale_i = clean_scales[0] if clean_scales is not None else None - d_mat = _rescale_distance_matrix( + d_mat = rescale_distance_matrix( d_batch[0], scale_mode=opts.scale_mode, eps_scale=opts.eps_scale, diff --git a/tests/test_losses_topo_distance.py b/tests/test_losses_topo_distance.py index b2acdd6..2c939dc 100644 --- a/tests/test_losses_topo_distance.py +++ b/tests/test_losses_topo_distance.py @@ -3,7 +3,7 @@ import unittest import torch -from tda_ml.losses import ( +from tda_ml.distance_backend import ( DISTANCE_MODE_ELLPHI, DISTANCE_MODE_MAHALANOBIS, compute_topo_distance_matrix, @@ -117,7 +117,7 @@ def test_median_aligns_prediction_to_teacher_scale(self): loss_fn = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", prob_weighting=False, scale_mode="median") i = 0 - from tda_ml.losses import compute_distance_matrix_batch + from tda_ml.distance_backend import compute_distance_matrix_batch D = compute_distance_matrix_batch(pt, par, probs=None, symmetrize="max", backend="mahalanobis") m_e = float(torch.pdist(pt[i]).median()) From 37ba96fac75206cbd05276be94a63b8efc8e5849 Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:24:45 +0900 Subject: [PATCH 05/44] =?UTF-8?q?refactor:=20calculate=5Fanisotropic=5Fdis?= =?UTF-8?q?tance=5Fmatrix=20=E3=82=92=20compute=5Fanisotropic=5Fdistance?= =?UTF-8?q?=5Fmatrix=5Fnp=20=E3=81=AB=E6=94=B9=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit topology.compute_anisotropic_distance_matrix と動詞を統一し、NumPy を返す ラッパーであることを _np サフィックスで明示する。 Co-authored-by: Cursor --- experiments/plot_distance_matrix.py | 6 +++--- tda_ml/dbscan.py | 8 ++++---- tda_ml/dbscan_eval.py | 4 ++-- tda_ml/visualization.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/experiments/plot_distance_matrix.py b/experiments/plot_distance_matrix.py index b1e7616..c113e87 100644 --- a/experiments/plot_distance_matrix.py +++ b/experiments/plot_distance_matrix.py @@ -12,7 +12,7 @@ from tda_ml.config import load_config from tda_ml.data_loader import NoisyMNISTDataset -from tda_ml.dbscan import calculate_anisotropic_distance_matrix +from tda_ml.dbscan import compute_anisotropic_distance_matrix_np from tda_ml.model_inference import load_model REPO_ROOT = Path(__file__).resolve().parents[1] @@ -94,10 +94,10 @@ def plot_distance_matrices( title_suffix: str = "", normalize: str = "none", ) -> None: - dm_maha = calculate_anisotropic_distance_matrix( + dm_maha = compute_anisotropic_distance_matrix_np( points, params, metric="max", probs=None, backend="mahalanobis" ) - dm_ell = calculate_anisotropic_distance_matrix( + dm_ell = compute_anisotropic_distance_matrix_np( points, params, metric="max", probs=None, backend="ellphi" ) diff --git a/tda_ml/dbscan.py b/tda_ml/dbscan.py index 2f7a84a..f38e3dd 100644 --- a/tda_ml/dbscan.py +++ b/tda_ml/dbscan.py @@ -5,11 +5,11 @@ from tda_ml.topology import compute_anisotropic_distance_matrix -def calculate_anisotropic_distance_matrix( +def compute_anisotropic_distance_matrix_np( points, params, metric="max", probs=None, backend="mahalanobis" ): """ - Calculate the anisotropic distance matrix between points. + Compute the anisotropic distance matrix between points as a NumPy array. Uses the centralized logic from tda_ml.topology for mathematical consistency. Args: @@ -73,13 +73,13 @@ def apply_anisotropic_dbscan( metric: Symmetrization strategy ('max' or 'min') probs: (N,) outlier probabilities; when ``backend='mahalanobis'``, distances use the same inlier-pair weighting as :func:`tda_ml.topology.compute_anisotropic_distance_matrix` - (see ``calculate_anisotropic_distance_matrix``). Ignored for ``ellphi``. + (see ``compute_anisotropic_distance_matrix_np``). Ignored for ``ellphi``. backend: ``mahalanobis`` or ``ellphi`` Returns: labels: Cluster labels for each point (-1 for noise) """ - dist_matrix = calculate_anisotropic_distance_matrix( + dist_matrix = compute_anisotropic_distance_matrix_np( points, params, metric=metric, probs=probs, backend=backend ) diff --git a/tda_ml/dbscan_eval.py b/tda_ml/dbscan_eval.py index 597be83..e0ed974 100644 --- a/tda_ml/dbscan_eval.py +++ b/tda_ml/dbscan_eval.py @@ -13,7 +13,7 @@ import torch from sklearn.cluster import DBSCAN -from tda_ml.dbscan import calculate_anisotropic_distance_matrix +from tda_ml.dbscan import compute_anisotropic_distance_matrix_np from tda_ml.metrics import compute_recall_specificity_gmean_mcc_wdist from tda_ml.topo_wdist import TopoWdistOptions @@ -95,7 +95,7 @@ def prepare_clouds( """Precompute distance matrices once per cloud (reused across the grid).""" prepared: list[PreparedCloud] = [] for points, params, labels_gt, clean_pc in clouds: - dist = calculate_anisotropic_distance_matrix( + dist = compute_anisotropic_distance_matrix_np( points, params, metric=metric, backend=backend ) prepared.append( diff --git a/tda_ml/visualization.py b/tda_ml/visualization.py index 4da3e93..2749fc5 100644 --- a/tda_ml/visualization.py +++ b/tda_ml/visualization.py @@ -6,7 +6,7 @@ import numpy as np import torch -from tda_ml.dbscan import apply_anisotropic_dbscan, calculate_anisotropic_distance_matrix +from tda_ml.dbscan import apply_anisotropic_dbscan, compute_anisotropic_distance_matrix_np INLIER_COLOR = "tab:blue" OUTLIER_COLOR = "tab:red" @@ -18,7 +18,7 @@ def _auto_eps(points_np, params_np, backend, quantile=0.3): Declared heuristic so the DBSCAN panel stays informative regardless of the (possibly collapsed) absolute ellipse scale. Not used for any reported metric. """ - dm = calculate_anisotropic_distance_matrix( + dm = compute_anisotropic_distance_matrix_np( points_np, params_np, metric="max", probs=None, backend=backend ) off = dm[dm > 0] From 5249753451b05062f6abf89a9b278bd80683c99e Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:30:55 +0900 Subject: [PATCH 06/44] =?UTF-8?q?refactor:=20=E5=AD=A6=E7=BF=92=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=83=AA=E3=83=9D=E3=82=A4=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=AE=E5=88=86=E5=89=B2=EF=BC=88run=5Fsetup=20/=20model=5Fs?= =?UTF-8?q?election=20=E6=8A=BD=E5=87=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tda_ml/run_setup.py: デバイス解決・torch ランタイム設定・DataLoader 構築を main.py から移動(RNG 消費順序は不変) - tda_ml/model_selection.py: チェックポイント選択ポリシー(threshold_mcc / val_loss / val_topo / wdist / dbscan_mcc)を main.py のループから抽出 - trainer.py: 肥大化した __init__ を _init_amp / _init_losses に分割 - main.py は約 520 行 → 約 390 行に縮小、挙動は変更なし Co-authored-by: Cursor --- ...run_teacher_local_pca_ellphi_smoke_10ep.py | 7 +- tda_ml/main.py | 223 ++++-------------- tda_ml/model_selection.py | 119 ++++++++++ tda_ml/run_setup.py | 107 +++++++++ tda_ml/trainer.py | 44 ++-- 5 files changed, 302 insertions(+), 198 deletions(-) create mode 100644 tda_ml/model_selection.py create mode 100644 tda_ml/run_setup.py diff --git a/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py b/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py index 3e96754..0767cbb 100755 --- a/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py +++ b/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py @@ -14,7 +14,8 @@ from tda_ml.config import deep_update, load_config, model_kwargs_from_config from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader from tda_ml.distance_backend import compute_distance_matrix_batch -from tda_ml.main import _configure_torch_runtime, _resolve_dataloader_settings, main as train_main +from tda_ml.main import main as train_main +from tda_ml.run_setup import configure_torch_runtime, resolve_dataloader_settings from tda_ml.models import AnisotropicOutlierClassifier from tda_ml.seed_utils import set_global_seed from tda_ml.trainer import Trainer @@ -122,8 +123,8 @@ def main() -> int: ) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - _configure_torch_runtime(cfg, device) - num_workers, pin_memory, persistent_workers, prefetch_factor = _resolve_dataloader_settings( + configure_torch_runtime(cfg, device) + num_workers, pin_memory, persistent_workers, prefetch_factor = resolve_dataloader_settings( cfg, device ) data_cfg = cfg["data"] diff --git a/tda_ml/main.py b/tda_ml/main.py index 960f8b4..423cc9e 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -8,12 +8,19 @@ from tda_ml.checkpoint_io import extract_model_state_dict, load_torch_checkpoint from tda_ml.config import deep_update, load_config, model_kwargs_from_config -from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader -from tda_ml.dbscan_eval import evaluate_model_grid -from tda_ml.topo_wdist import topo_wdist_options_from_config +from tda_ml.model_selection import ( + compute_epoch_selection, + selection_settings_from_config, +) from tda_ml.models import AnisotropicOutlierClassifier -from tda_ml.seed_utils import set_global_seed +from tda_ml.run_setup import ( + build_dataloaders, + configure_torch_runtime, + resolve_dataloader_settings, + resolve_device, +) from tda_ml.runtime_profile import build_runtime_profile +from tda_ml.seed_utils import set_global_seed from tda_ml.supervised_diagnostics import ( git_revision, run_abort_diagnostics, @@ -25,36 +32,6 @@ logger = logging.getLogger(__name__) -def _resolve_dataloader_settings(config, device): - data_cfg = config["data"] - cpu_threads = os.cpu_count() or 8 - default_workers = min(16, max(4, cpu_threads // 2)) - - num_workers = int(data_cfg.get("num_workers", default_workers)) - num_workers = max(0, min(num_workers, cpu_threads)) - - pin_memory = bool(data_cfg.get("pin_memory", device.type == "cuda")) - persistent_workers = bool(data_cfg.get("persistent_workers", num_workers > 0)) - prefetch_factor = data_cfg.get("prefetch_factor", 4 if num_workers > 0 else None) - - if num_workers == 0: - persistent_workers = False - prefetch_factor = None - - return num_workers, pin_memory, persistent_workers, prefetch_factor - - -def _configure_torch_runtime(config, device): - perf_cfg = config.get("performance", {}) - if device.type != "cuda": - return - - if perf_cfg.get("enable_tf32", True): - torch.backends.cuda.matmul.allow_tf32 = True - torch.backends.cudnn.allow_tf32 = True - torch.set_float32_matmul_precision(perf_cfg.get("matmul_precision", "high")) - torch.backends.cudnn.benchmark = bool(perf_cfg.get("cudnn_benchmark", True)) - def main(config_name=None, config=None, trial=None, config_overrides=None): logging.basicConfig( level=logging.INFO, @@ -70,14 +47,7 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): logger.info("Loaded config: %s", config["meta"].get("config_id", "unknown")) - if config.get('device') and config['device'] != 'auto': - device = torch.device(config['device']) - elif torch.cuda.is_available(): - device = torch.device('cuda') - elif torch.backends.mps.is_available(): - device = torch.device('mps') - else: - device = torch.device('cpu') + device = resolve_device(config) import datetime timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') @@ -108,74 +78,17 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): seed, deterministic_algorithms, ) - train_size = data_cfg.get('train_size', 4500) - val_size = data_cfg.get('val_size', 500) - test_size = data_cfg.get('test_size', 1000) - - generator = torch.Generator().manual_seed(seed) - full_train_indices = torch.randperm(60000, generator=generator)[:train_size + val_size] - - train_indices = full_train_indices[:train_size] - val_indices = full_train_indices[train_size:] - - num_workers, use_pin_memory, persistent_workers, prefetch_factor = _resolve_dataloader_settings(config, device) - _configure_torch_runtime(config, device) + loader_settings = resolve_dataloader_settings(config, device) + configure_torch_runtime(config, device) logger.info( "DataLoader settings: workers=%s, pin_memory=%s, persistent_workers=%s, prefetch_factor=%s", - num_workers, - use_pin_memory, - persistent_workers, - prefetch_factor, + loader_settings.num_workers, + loader_settings.pin_memory, + loader_settings.persistent_workers, + loader_settings.prefetch_factor, ) - train_dataset = NoisyMNISTDataset( - root='./data', train=True, max_points=data_cfg['max_points'], - num_outliers=data_cfg['num_outliers'], indices=train_indices, - deterministic=True, noise_seed=seed - ) - - data_loader = create_data_loader( - train_dataset, - batch_size=data_cfg['batch_size'], - shuffle=True, - num_workers=num_workers, - pin_memory=use_pin_memory, - persistent_workers=persistent_workers, - prefetch_factor=prefetch_factor, - ) - - val_dataset = NoisyMNISTDataset( - root='./data', train=True, max_points=data_cfg['max_points'], - num_outliers=data_cfg['num_outliers'], indices=val_indices, - deterministic=True, noise_seed=seed - ) - - val_loader = create_data_loader( - val_dataset, - batch_size=data_cfg['batch_size'], - shuffle=False, - num_workers=num_workers, - pin_memory=use_pin_memory, - persistent_workers=persistent_workers, - prefetch_factor=prefetch_factor, - ) - - test_indices = torch.randperm(10000, generator=generator)[:test_size] - test_dataset = NoisyMNISTDataset( - root='./data', train=False, max_points=data_cfg['max_points'], - num_outliers=data_cfg['num_outliers'], indices=test_indices, - deterministic=True, noise_seed=seed - ) - - test_loader = create_data_loader( - test_dataset, - batch_size=data_cfg["batch_size"], - shuffle=False, - num_workers=num_workers, - pin_memory=use_pin_memory, - persistent_workers=persistent_workers, - prefetch_factor=prefetch_factor, - ) + data_loader, val_loader, test_loader = build_dataloaders(config, seed, loader_settings) model = AnisotropicOutlierClassifier(**model_kwargs_from_config(config)) model.to(device) @@ -195,10 +108,10 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): runtime_profile = build_runtime_profile( config=config, device=device, - num_workers=num_workers, - pin_memory=use_pin_memory, - persistent_workers=persistent_workers, - prefetch_factor=prefetch_factor, + num_workers=loader_settings.num_workers, + pin_memory=loader_settings.pin_memory, + persistent_workers=loader_settings.persistent_workers, + prefetch_factor=loader_settings.prefetch_factor, use_amp_effective=trainer.use_amp, amp_dtype_effective=str(trainer.amp_dtype).replace("torch.", ""), ) @@ -244,34 +157,10 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): final_status = "completed" abort_report_path = None - # --- Model-selection (checkpoint) policy --- - # Prefer ``val_topo`` so the saved checkpoint matches the topology loss used - # during training (same ellphi/local_pca teacher PD as train_epoch). - # ``wdist`` / ``dbscan_mcc`` use DBSCAN + Euclidean inlier-point W-Dist - # (paper reporting metric; not the training objective). - # metric: 'val_topo' | 'wdist' | 'dbscan_mcc' | 'threshold_mcc' | 'val_loss' - # 'threshold_mcc' reproduces the legacy behavior. The threshold MCC is always - # tracked separately for early-abort diagnostics regardless of this setting. - sel_cfg = (config.get("training", {}).get("selection") or {}) - sel_metric = sel_cfg.get("metric", "threshold_mcc") - sel_eval_every = max(1, int(sel_cfg.get("eval_every", 1))) - sel_dbscan_cfg = (sel_cfg.get("dbscan") or {}) - sel_eps_values = sel_dbscan_cfg.get("eps_values") - sel_min_samples_values = sel_dbscan_cfg.get("min_samples_values") - sel_backend = ( - config.get("model", {}).get("topology_loss", {}).get("distance_backend", "mahalanobis") - ) - sel_minimize = sel_metric in ("wdist", "val_loss", "val_topo") - best_sel_value = float("inf") if sel_minimize else -1.0 - if sel_metric == "val_topo": - logger.info("Model selection: val_topo (same TopologicalLoss as training)") - elif sel_metric in ("wdist", "dbscan_mcc"): - logger.info( - "Model selection: %s via DBSCAN grid (backend=%s, eval_every=%d)", - sel_metric, sel_backend, sel_eval_every, - ) - else: - logger.info("Model selection: %s", sel_metric) + # --- Model-selection (checkpoint) policy (see tda_ml.model_selection) --- + sel_settings = selection_settings_from_config(config) + sel_metric = sel_settings.metric + best_sel_value = float("inf") if sel_settings.minimize else -1.0 for epoch in range(1, epochs + 1): # res returns (avg_loss, class_loss, topo_loss, aniso_loss, size_loss, ...) @@ -308,44 +197,26 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): best_val_mcc = val_mcc # --- Checkpoint selection --- - run_sel_eval = (epoch % sel_eval_every == 0) or (epoch == epochs) - sel_value = None - sel_eps = None - sel_min_samples = None - sel_wdist = None - sel_mcc_dbscan = None - if sel_metric == "threshold_mcc": - sel_value = float(val_mcc) - elif sel_metric == "val_loss": - sel_value = float(val_res[0]) - elif sel_metric == "val_topo": - sel_value = float(val_topo_loss) - print(f"Epoch {epoch}: selection[val_topo] val_topo_loss={sel_value:.5f}") - elif sel_metric in ("wdist", "dbscan_mcc") and run_sel_eval: - objective = "wdist" if sel_metric == "wdist" else "mcc" - grid = evaluate_model_grid( - model, - val_loader, - device, - backend=sel_backend, - eps_values=sel_eps_values, - min_samples_values=sel_min_samples_values, - objective=objective, - topo_options=topo_wdist_options_from_config(config), - ) - sel_eps = grid.eps - sel_min_samples = grid.min_samples - sel_wdist = grid.wdist - sel_mcc_dbscan = grid.mcc - sel_value = grid.wdist if objective == "wdist" else grid.mcc - print( - f"Epoch {epoch}: selection[{sel_metric}] val_wdist={grid.wdist:.5f} " - f"val_mcc_dbscan={grid.mcc:.4f} (eps={grid.eps:.3f}, min_samples={grid.min_samples})" - ) + sel = compute_epoch_selection( + sel_settings, + epoch=epoch, + epochs=epochs, + val_mcc=val_mcc, + val_loss=val_res[0], + val_topo_loss=val_topo_loss, + model=model, + val_loader=val_loader, + device=device, + config=config, + ) + sel_value = sel.value + sel_eps = sel.eps + sel_min_samples = sel.min_samples + sel_wdist = sel.wdist if sel_value is not None: is_better = ( - sel_value < best_sel_value if sel_minimize else sel_value > best_sel_value + sel_value < best_sel_value if sel_settings.minimize else sel_value > best_sel_value ) if is_better: best_sel_value = sel_value @@ -363,17 +234,17 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): ckpt['dbscan_eps'] = float(sel_eps) ckpt['dbscan_min_samples'] = int(sel_min_samples) ckpt['val_wdist'] = float(sel_wdist) - ckpt['val_mcc_dbscan'] = float(sel_mcc_dbscan) + ckpt['val_mcc_dbscan'] = float(sel.mcc_dbscan) hp_path = os.path.join(log_dir, 'dbscan_hparams_train.json') with open(hp_path, 'w', encoding='utf-8') as f: json.dump( { 'eps': float(sel_eps), 'min_samples': int(sel_min_samples), - 'backend': sel_backend, + 'backend': sel_settings.backend, 'epoch': epoch, 'val_wdist': float(sel_wdist), - 'val_mcc_dbscan': float(sel_mcc_dbscan), + 'val_mcc_dbscan': float(sel.mcc_dbscan), 'selection_metric': sel_metric, }, f, diff --git a/tda_ml/model_selection.py b/tda_ml/model_selection.py new file mode 100644 index 0000000..eeefbe3 --- /dev/null +++ b/tda_ml/model_selection.py @@ -0,0 +1,119 @@ +"""Checkpoint-selection policy for the training loop. + +Prefer ``val_topo`` so the saved checkpoint matches the topology loss used +during training (same ellphi/local_pca teacher PD as ``train_epoch``). +``wdist`` / ``dbscan_mcc`` use DBSCAN + Euclidean inlier-point W-Dist +(paper reporting metric; not the training objective). + +metric: 'val_topo' | 'wdist' | 'dbscan_mcc' | 'threshold_mcc' | 'val_loss' + +'threshold_mcc' reproduces the legacy behavior. The threshold MCC is always +tracked separately for early-abort diagnostics regardless of this setting. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from tda_ml.dbscan_eval import evaluate_model_grid +from tda_ml.topo_wdist import topo_wdist_options_from_config + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class SelectionSettings: + metric: str + eval_every: int + eps_values: list | None + min_samples_values: list | None + backend: str + minimize: bool + + +def selection_settings_from_config(config) -> SelectionSettings: + sel_cfg = (config.get("training", {}).get("selection") or {}) + metric = sel_cfg.get("metric", "threshold_mcc") + eval_every = max(1, int(sel_cfg.get("eval_every", 1))) + dbscan_cfg = (sel_cfg.get("dbscan") or {}) + backend = ( + config.get("model", {}).get("topology_loss", {}).get("distance_backend", "mahalanobis") + ) + settings = SelectionSettings( + metric=metric, + eval_every=eval_every, + eps_values=dbscan_cfg.get("eps_values"), + min_samples_values=dbscan_cfg.get("min_samples_values"), + backend=backend, + minimize=metric in ("wdist", "val_loss", "val_topo"), + ) + if metric == "val_topo": + logger.info("Model selection: val_topo (same TopologicalLoss as training)") + elif metric in ("wdist", "dbscan_mcc"): + logger.info( + "Model selection: %s via DBSCAN grid (backend=%s, eval_every=%d)", + metric, backend, eval_every, + ) + else: + logger.info("Model selection: %s", metric) + return settings + + +@dataclass(frozen=True) +class EpochSelection: + value: float | None = None + eps: float | None = None + min_samples: int | None = None + wdist: float | None = None + mcc_dbscan: float | None = None + + +def compute_epoch_selection( + settings: SelectionSettings, + *, + epoch: int, + epochs: int, + val_mcc: float, + val_loss: float, + val_topo_loss: float, + model, + val_loader, + device, + config, +) -> EpochSelection: + """Compute the per-epoch selection value; DBSCAN-grid metrics respect ``eval_every``.""" + if settings.metric == "threshold_mcc": + return EpochSelection(value=float(val_mcc)) + if settings.metric == "val_loss": + return EpochSelection(value=float(val_loss)) + if settings.metric == "val_topo": + value = float(val_topo_loss) + print(f"Epoch {epoch}: selection[val_topo] val_topo_loss={value:.5f}") + return EpochSelection(value=value) + + run_sel_eval = (epoch % settings.eval_every == 0) or (epoch == epochs) + if settings.metric in ("wdist", "dbscan_mcc") and run_sel_eval: + objective = "wdist" if settings.metric == "wdist" else "mcc" + grid = evaluate_model_grid( + model, + val_loader, + device, + backend=settings.backend, + eps_values=settings.eps_values, + min_samples_values=settings.min_samples_values, + objective=objective, + topo_options=topo_wdist_options_from_config(config), + ) + print( + f"Epoch {epoch}: selection[{settings.metric}] val_wdist={grid.wdist:.5f} " + f"val_mcc_dbscan={grid.mcc:.4f} (eps={grid.eps:.3f}, min_samples={grid.min_samples})" + ) + return EpochSelection( + value=grid.wdist if objective == "wdist" else grid.mcc, + eps=grid.eps, + min_samples=grid.min_samples, + wdist=grid.wdist, + mcc_dbscan=grid.mcc, + ) + return EpochSelection() diff --git a/tda_ml/run_setup.py b/tda_ml/run_setup.py new file mode 100644 index 0000000..e0dbb56 --- /dev/null +++ b/tda_ml/run_setup.py @@ -0,0 +1,107 @@ +"""Run-time setup for the training entrypoint: device, torch runtime, DataLoaders.""" + +from __future__ import annotations + +import logging +import os +from typing import NamedTuple + +import torch + +from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader + +logger = logging.getLogger(__name__) + + +class DataLoaderSettings(NamedTuple): + num_workers: int + pin_memory: bool + persistent_workers: bool + prefetch_factor: int | None + + +def resolve_device(config) -> torch.device: + if config.get("device") and config["device"] != "auto": + return torch.device(config["device"]) + if torch.cuda.is_available(): + return torch.device("cuda") + if torch.backends.mps.is_available(): + return torch.device("mps") + return torch.device("cpu") + + +def resolve_dataloader_settings(config, device) -> DataLoaderSettings: + data_cfg = config["data"] + cpu_threads = os.cpu_count() or 8 + default_workers = min(16, max(4, cpu_threads // 2)) + + num_workers = int(data_cfg.get("num_workers", default_workers)) + num_workers = max(0, min(num_workers, cpu_threads)) + + pin_memory = bool(data_cfg.get("pin_memory", device.type == "cuda")) + persistent_workers = bool(data_cfg.get("persistent_workers", num_workers > 0)) + prefetch_factor = data_cfg.get("prefetch_factor", 4 if num_workers > 0 else None) + + if num_workers == 0: + persistent_workers = False + prefetch_factor = None + + return DataLoaderSettings(num_workers, pin_memory, persistent_workers, prefetch_factor) + + +def configure_torch_runtime(config, device) -> None: + perf_cfg = config.get("performance", {}) + if device.type != "cuda": + return + + if perf_cfg.get("enable_tf32", True): + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + torch.set_float32_matmul_precision(perf_cfg.get("matmul_precision", "high")) + torch.backends.cudnn.benchmark = bool(perf_cfg.get("cudnn_benchmark", True)) + + +def build_dataloaders(config, seed: int, settings: DataLoaderSettings): + """Build (train, val, test) DataLoaders with the split/RNG scheme of the official runs. + + Train/val indices come from one ``randperm(60000)`` draw and test indices from a + subsequent ``randperm(10000)`` on the same generator, so the RNG consumption order + must not change. + """ + data_cfg = config["data"] + train_size = data_cfg.get("train_size", 4500) + val_size = data_cfg.get("val_size", 500) + test_size = data_cfg.get("test_size", 1000) + + generator = torch.Generator().manual_seed(seed) + full_train_indices = torch.randperm(60000, generator=generator)[: train_size + val_size] + + train_indices = full_train_indices[:train_size] + val_indices = full_train_indices[train_size:] + + loader_kwargs = dict( + batch_size=data_cfg["batch_size"], + num_workers=settings.num_workers, + pin_memory=settings.pin_memory, + persistent_workers=settings.persistent_workers, + prefetch_factor=settings.prefetch_factor, + ) + dataset_kwargs = dict( + root="./data", + max_points=data_cfg["max_points"], + num_outliers=data_cfg["num_outliers"], + deterministic=True, + noise_seed=seed, + ) + + train_dataset = NoisyMNISTDataset(train=True, indices=train_indices, **dataset_kwargs) + train_loader = create_data_loader(train_dataset, shuffle=True, **loader_kwargs) + + val_dataset = NoisyMNISTDataset(train=True, indices=val_indices, **dataset_kwargs) + val_loader = create_data_loader(val_dataset, shuffle=False, **loader_kwargs) + + test_indices = torch.randperm(10000, generator=generator)[:test_size] + test_dataset = NoisyMNISTDataset(train=False, indices=test_indices, **dataset_kwargs) + test_loader = create_data_loader(test_dataset, shuffle=False, **loader_kwargs) + + return train_loader, val_loader, test_loader diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 114a884..2e039fe 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -36,6 +36,28 @@ def __init__(self, model, config, device=None, trial=None): self.model.to(self.device) self.optimizer = Adam(model.parameters(), lr=config['training']['lr']) + self._init_amp(config) + self._init_losses(config) + + self.visualize_every = config['training'].get('visualize_every', 5) + self.output_dir = config['outputs']['image_dir'] + self.log_dir = config['outputs']['log_dir'] + + os.makedirs(self.output_dir, exist_ok=True) + os.makedirs(self.log_dir, exist_ok=True) + + self.fixed_indices = None + self.threshold = config['model'].get('threshold', 0.5) + + self.vr_complex = VietorisRipsComplex(dim=1) + + self.warmup_epochs = config.get('training', {}).get('warmup_epochs', 0) + + self._val_aniso_accum = 0.0 + self._val_size_accum = 0.0 + + def _init_amp(self, config): + """Mixed-precision settings (AMP autocast / GradScaler).""" training_cfg = config.get('training', {}) perf_cfg = config.get('performance', {}) self.use_amp = ( @@ -47,6 +69,9 @@ def __init__(self, model, config, device=None, trial=None): self.amp_dtype = torch.float16 if amp_dtype_name == "float16" else torch.bfloat16 self.scaler = torch.amp.GradScaler("cuda", enabled=self.use_amp) + def _init_losses(self, config): + """Parse loss weights (loss.* with legacy training.* fallbacks) and build loss modules.""" + training_cfg = config.get('training', {}) loss_cfg = config.get('loss', {}) self.lambda_class = loss_cfg.get('w_class', training_cfg.get('lambda_class', 1.0)) @@ -140,25 +165,6 @@ def __init__(self, model, config, device=None, trial=None): if self.topo_loss_max_points is not None: logger.info("Topological loss subsampling: max_points=%s", self.topo_loss_max_points) - self.visualize_every = config['training'].get('visualize_every', 5) - self.output_dir = config['outputs']['image_dir'] - self.log_dir = config['outputs']['log_dir'] - - os.makedirs(self.output_dir, exist_ok=True) - os.makedirs(self.log_dir, exist_ok=True) - - self.fixed_indices = None - self.threshold = config['model'].get('threshold', 0.5) - - self.vr_complex = VietorisRipsComplex(dim=1) - - self.warmup_epochs = training_cfg.get('warmup_epochs', 0) - - self._val_aniso_accum = 0.0 - self._val_size_accum = 0.0 - - # _compute_regularization_loss is now handled by classes in losses.py - def _compute_clean_pd_info(self, clean_pc: torch.Tensor): """Compute clean (teacher) persistence diagrams without gradient tracking.""" return compute_clean_teacher_batch( From c4e725328847969b7f3799758962a054b811eda3 Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:48:41 +0900 Subject: [PATCH 07/44] =?UTF-8?q?feat:=20=E5=86=8D=E7=8F=BE=E6=80=A7?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=95=E3=83=A9=EF=BC=88preflight=20/=20re?= =?UTF-8?q?producibility=20/=20run=5Fpaths=EF=BC=89=E3=81=A8=E3=82=B3?= =?UTF-8?q?=E3=82=A2=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tda_ml/preflight.py: tune/本番/paper eval の preflight ゲート(objective 種別を classify_tune_objective で判定し wdist/mcc 双方を受理) - tda_ml/reproducibility.py: strict 既定と失敗語彙(not-run / skipped / failed など)・fallback の manifest 記録 - tda_ml/run_paths.py: outputs/ 配下のパス規約 - configs/base.yaml: reproducibility.* の strict 既定 - 学習コア: power size 正則化・ellphi 教師 PD・val_topo 選択・manifest 拡充(main / trainer / losses / model_selection ほか) - tests: reproducibility strict / run_paths / dbscan grid wdist cache / barrier losses を追加、既存テスト更新 --- configs/base.yaml | 36 +++- scripts/new_experiment.sh | 28 +-- tda_ml/checkpoint_io.py | 21 ++ tda_ml/data_loader.py | 19 +- tda_ml/dbscan_eval.py | 141 ++++++++++---- tda_ml/distance_backend.py | 9 +- tda_ml/ellphi_torch.py | 25 +-- tda_ml/local_pca.py | 16 +- tda_ml/losses.py | 75 +++++++- tda_ml/main.py | 151 ++++++++++----- tda_ml/model_selection.py | 27 ++- tda_ml/models.py | 6 +- tda_ml/preflight.py | 265 ++++++++++++++++++++++++++ tda_ml/reproducibility.py | 199 +++++++++++++++++++ tda_ml/run_paths.py | 107 +++++++++++ tda_ml/run_setup.py | 8 + tda_ml/teacher_pd.py | 26 ++- tda_ml/topo_wdist.py | 8 + tda_ml/trainer.py | 155 +++++++++++++-- tda_ml/visualization.py | 3 +- tests/test_data_loader.py | 21 +- tests/test_dbscan_grid_wdist_cache.py | 61 ++++++ tests/test_ellipse_barrier_losses.py | 73 +++++++ tests/test_reproducibility_strict.py | 160 ++++++++++++++++ tests/test_run_paths.py | 54 ++++++ tests/test_teacher_pd.py | 10 + tests/test_trainer.py | 13 +- 27 files changed, 1550 insertions(+), 167 deletions(-) create mode 100644 tda_ml/preflight.py create mode 100644 tda_ml/reproducibility.py create mode 100644 tda_ml/run_paths.py create mode 100644 tests/test_dbscan_grid_wdist_cache.py create mode 100644 tests/test_ellipse_barrier_losses.py create mode 100644 tests/test_reproducibility_strict.py create mode 100644 tests/test_run_paths.py diff --git a/configs/base.yaml b/configs/base.yaml index 4cdfa10..6bc846c 100644 --- a/configs/base.yaml +++ b/configs/base.yaml @@ -42,4 +42,38 @@ device: "auto" # "auto", "cpu", "cuda", or "mps" outputs: log_dir: "outputs/logs" - image_dir: "outputs/images" \ No newline at end of file + image_dir: "outputs/images" + +# Paper / tuning DBSCAN grid (explicit; no Python implicit defaults). +evaluation: + dbscan: + backend: mahalanobis + metric: max + eps_values: + - 0.15 + - 0.246429 + - 0.342857 + - 0.439286 + - 0.535714 + - 0.632143 + - 0.728571 + - 0.825 + - 0.921429 + - 1.017857 + - 1.114286 + - 1.210714 + - 1.307143 + - 1.403571 + - 1.5 + min_samples_values: [3, 5, 7, 10, 15] + baselines: + contamination_values: [0.05, 0.07, 0.09, 0.11, 0.13, 0.15, 0.20] + lof_n_neighbors: [5, 10, 15, 20, 30] + +reproducibility: + strict_topo_samples: true + allow_nan_batch_skip: false + allow_empty_cloud_fallback: false + allow_skip_degenerate_grid_cells: false + allow_otsu_threshold_fallback: false + allow_legacy_loss_keys: false \ No newline at end of file diff --git a/scripts/new_experiment.sh b/scripts/new_experiment.sh index e852b4c..026cc69 100755 --- a/scripts/new_experiment.sh +++ b/scripts/new_experiment.sh @@ -1,23 +1,28 @@ #!/usr/bin/env bash # 新しい数値実験フォルダを規約どおりに作成する。 -# outputs///_/ +# outputs//_/ # PURPOSE.md の雛形を生成し、run_backend_multiseed.py に渡す out-base のパスを表示する。 # # 使い方: -# scripts/new_experiment.sh [--unsupervised] ["1行の目的"] +# scripts/new_experiment.sh [--no-cls|--tune] ["1行の目的"] # 例: -# scripts/new_experiment.sh mahal_n100_noprob_30ep_seed42 "確率重みOFFの収束確認" -# scripts/new_experiment.sh --unsupervised topo_prior_30ep "topo-prior のみ" +# scripts/new_experiment.sh pwr30ep_seed42 "power size 30ep" +# scripts/new_experiment.sh --no-cls bar_smoke "barrier loss smoke" +# scripts/new_experiment.sh --tune pwr_mcc "ellphi+power MCC tune" set -euo pipefail mode_dir="supervised" -if [[ "${1:-}" == "--unsupervised" || "${1:-}" == "-u" ]]; then - mode_dir="unsupervised" - shift -fi +while [[ "${1:-}" == --* ]]; do + case "$1" in + --no-cls|-n) mode_dir="supervised_no_cls"; shift ;; + --tune|-t) mode_dir="tune"; shift ;; + --unsupervised|-u) mode_dir="supervised"; shift ;; # legacy alias + *) echo "unknown option: $1" >&2; exit 1 ;; + esac +done if [[ $# -lt 1 ]]; then - echo "usage: $0 [--unsupervised] [\"one-line purpose\"]" >&2 + echo "usage: $0 [--no-cls|--tune] [\"one-line purpose\"]" >&2 exit 1 fi @@ -25,9 +30,8 @@ slug="$1" purpose="${2:-(目的をここに記述)}" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -date_dir="$(date +%Y%m%d)" -time_id="$(date +%H%M%S)" -exp_dir="${repo_root}/outputs/${mode_dir}/${date_dir}/${time_id}_${slug}" +date_dir="$(date +%m%d)" +exp_dir="${repo_root}/outputs/${mode_dir}/${date_dir}_${slug}" mkdir -p "${exp_dir}" diff --git a/tda_ml/checkpoint_io.py b/tda_ml/checkpoint_io.py index 9b9601e..7adf9f1 100644 --- a/tda_ml/checkpoint_io.py +++ b/tda_ml/checkpoint_io.py @@ -75,6 +75,27 @@ def _looks_like_pytorch_state_dict(obj: Any) -> bool: return all(isinstance(v, torch.Tensor) for v in obj.values()) +def resolve_val_topo_checkpoint(run_dir: Path) -> tuple[str, int, float]: + """Return ``(checkpoint_name, epoch, val_topo_loss)`` from ``best_model.pth`` only. + + Hard-fails if the val_topo checkpoint is missing or lacks selection metadata. + No fallback to ``checkpoint_epoch_*.pth`` (skill: no silent checkpoint substitute). + """ + best_path = run_dir / "best_model.pth" + if not best_path.is_file(): + raise FileNotFoundError( + f"Missing best_model.pth (val_topo selection checkpoint): {best_path}" + ) + ckpt = load_torch_checkpoint(best_path, map_location="cpu") + epoch = int(ckpt.get("epoch", -1)) + sel = ckpt.get("selection_value", ckpt.get("val_topo_loss")) + if sel is None: + raise RuntimeError( + f"Checkpoint missing selection_value/val_topo_loss: {best_path}" + ) + return "best_model.pth", epoch, float(sel) + + def extract_model_state_dict(checkpoint: Any) -> dict[str, Any]: """Return ``model_state_dict`` payload if present; else ``checkpoint`` if it is a state dict.""" if isinstance(checkpoint, dict) and "model_state_dict" in checkpoint: diff --git a/tda_ml/data_loader.py b/tda_ml/data_loader.py index 6227469..22ae2c9 100644 --- a/tda_ml/data_loader.py +++ b/tda_ml/data_loader.py @@ -36,13 +36,17 @@ class NoisyMNISTDataset(Dataset): def __init__(self, root='./data', train=True, num_samples=5000, max_points=150, num_outliers=20, noise_std=0.01, - deterministic=False, indices=None, noise_seed=0, preload=True): + deterministic=False, indices=None, noise_seed=0, preload=True, + allow_empty_cloud_fallback=False, + allow_otsu_threshold_fallback=False): self.max_points = max_points self.num_outliers = num_outliers self.noise_std = noise_std self.deterministic = deterministic self.noise_seed = noise_seed self.preload = preload + self.allow_empty_cloud_fallback = bool(allow_empty_cloud_fallback) + self.allow_otsu_threshold_fallback = bool(allow_otsu_threshold_fallback) full_dataset = datasets.MNIST(root, train=train, download=True) @@ -76,7 +80,13 @@ def _image_to_base_points(self, img): try: thresh = threshold_otsu(img) binary_img = img > thresh - except ValueError: + except ValueError as exc: + if not self.allow_otsu_threshold_fallback: + raise RuntimeError( + "Otsu threshold failed for MNIST image; " + "set reproducibility.allow_otsu_threshold_fallback=true to opt in " + "to img>0 binarization." + ) from exc binary_img = img > 0 # np.argwhere returns (row, col) = (y, x) @@ -117,6 +127,11 @@ def __getitem__(self, idx): num_points = points.shape[0] if num_points == 0: + if not self.allow_empty_cloud_fallback: + raise RuntimeError( + f"Empty foreground point cloud at dataset index {idx}; " + "set data.allow_empty_cloud_fallback=true to opt in to random fallback." + ) fallback_n = min(8, self.max_points) if self.deterministic: points = torch.rand(fallback_n, 2, generator=rng) * 2.0 - 1.0 diff --git a/tda_ml/dbscan_eval.py b/tda_ml/dbscan_eval.py index e0ed974..7fedd65 100644 --- a/tda_ml/dbscan_eval.py +++ b/tda_ml/dbscan_eval.py @@ -6,19 +6,18 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Iterator, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterator, Sequence import numpy as np import torch from sklearn.cluster import DBSCAN from tda_ml.dbscan import compute_anisotropic_distance_matrix_np -from tda_ml.metrics import compute_recall_specificity_gmean_mcc_wdist -from tda_ml.topo_wdist import TopoWdistOptions - -DEFAULT_EPS_VALUES: list[float] = list(np.linspace(0.15, 1.5, 15)) -DEFAULT_MIN_SAMPLES_VALUES: list[int] = [3, 5, 7, 10, 15] +from tda_ml.metrics import compute_recall_specificity_gmean_mcc +from tda_ml.reproducibility import record_fallback, resolve_dbscan_grid, write_grid_log +from tda_ml.topo_wdist import TopoWdistOptions, compute_topo_wdist @dataclass @@ -43,6 +42,7 @@ class GridResult: min_samples: int n_clouds: int objective: str + grid_log: list[dict[str, Any]] = field(default_factory=list) @dataclass @@ -110,24 +110,48 @@ def prepare_clouds( return prepared +def _topo_wdist_for_prepared_cloud( + cloud: PreparedCloud, + *, + topo_options: TopoWdistOptions | None, +) -> float: + """Topo W-Dist for one cloud (independent of DBSCAN hyperparameters).""" + if topo_options is None: + raise ValueError("topo_options is required for topo W-Dist evaluation") + return float( + compute_topo_wdist( + cloud.points, + cloud.params, + cloud.clean_pc, + topo_options, + ) + ) + + +def _dbscan_classification_metrics( + cloud: PreparedCloud, + eps: float, + min_samples: int, +) -> tuple[float, float, float, float]: + db = DBSCAN(eps=eps, min_samples=min_samples, metric="precomputed") + labels = db.fit_predict(cloud.dist) + pred = dbscan_labels_to_outlier_pred(labels) + return compute_recall_specificity_gmean_mcc(cloud.labels_gt, pred) + + def _eval_prepared_cloud( cloud: PreparedCloud, eps: float, min_samples: int, *, topo_options: TopoWdistOptions | None = None, + wdist: float | None = None, ) -> CloudMetrics: - db = DBSCAN(eps=eps, min_samples=min_samples, metric="precomputed") - labels = db.fit_predict(cloud.dist) - pred = dbscan_labels_to_outlier_pred(labels) - recall, specificity, gmean, mcc, wdist = compute_recall_specificity_gmean_mcc_wdist( - cloud.labels_gt, - pred, - points=cloud.points, - params=cloud.params, - clean_pc=cloud.clean_pc, - topo_options=topo_options, + recall, specificity, gmean, mcc = _dbscan_classification_metrics( + cloud, eps, min_samples ) + if wdist is None: + wdist = _topo_wdist_for_prepared_cloud(cloud, topo_options=topo_options) return CloudMetrics(recall, specificity, gmean, mcc, wdist) @@ -148,13 +172,10 @@ def grid_search_prepared( min_samples_values: list[int], objective: str = "wdist", topo_options: TopoWdistOptions | None = None, + allow_skip_degenerate_grid_cells: bool = False, + manifest_ref: dict[str, Any] | None = None, ) -> GridResult: - """Grid-search DBSCAN over cached clouds; pick the best by ``objective``. - - ``objective='wdist'`` minimizes mean topo W-Dist (independent of DBSCAN hparams). - ``objective='mcc'`` maximizes mean MCC. Returns aggregated metrics at the selected - hyperparameters. - """ + """Grid-search DBSCAN over cached clouds; pick the best by ``objective``.""" if objective not in ("wdist", "mcc"): raise ValueError(f"objective must be 'wdist' or 'mcc'; got {objective!r}") if not prepared: @@ -163,12 +184,17 @@ def grid_search_prepared( minimize = objective == "wdist" best_score = float("inf") if minimize else -1.0 best: GridResult | None = None + grid_log: list[dict[str, Any]] = [] + cloud_wdists = [ + _topo_wdist_for_prepared_cloud(cloud, topo_options=topo_options) + for cloud in prepared + ] for eps in eps_values: for min_samples in min_samples_values: rows: list[CloudMetrics] = [] - ok = True - for cloud in prepared: + cell_error: str | None = None + for cloud, wdist in zip(prepared, cloud_wdists, strict=True): try: rows.append( _eval_prepared_cloud( @@ -176,15 +202,45 @@ def grid_search_prepared( float(eps), int(min_samples), topo_options=topo_options, + wdist=wdist, ) ) - except Exception: # noqa: BLE001 — skip degenerate hyperparameters - ok = False + except Exception as exc: + cell_error = f"{type(exc).__name__}: {exc}" break - if not ok or not rows: - continue + log_entry: dict[str, Any] = { + "eps": float(eps), + "min_samples": int(min_samples), + } + if cell_error is not None: + log_entry["status"] = "failed" + log_entry["error"] = cell_error + grid_log.append(log_entry) + if allow_skip_degenerate_grid_cells: + if manifest_ref is not None: + record_fallback( + manifest_ref, + "dbscan_grid_cell_skip", + f"eps={eps} min_samples={min_samples}: {cell_error}", + ) + continue + raise RuntimeError( + "DBSCAN grid cell failed " + f"(eps={eps}, min_samples={min_samples}): {cell_error}. " + "Set reproducibility.allow_skip_degenerate_grid_cells=true to opt in " + "to skipping failed cells." + ) recall, specificity, gmean, mcc, wdist = _aggregate(rows) score = wdist if minimize else mcc + log_entry.update( + { + "status": "ok", + "mean_mcc": mcc, + "mean_wdist": wdist, + "n_clouds": len(rows), + } + ) + grid_log.append(log_entry) is_better = score < best_score if minimize else score > best_score if is_better: best_score = score @@ -198,10 +254,15 @@ def grid_search_prepared( min_samples=int(min_samples), n_clouds=len(rows), objective=objective, + grid_log=grid_log.copy(), ) if best is None: - raise RuntimeError("DBSCAN grid search failed for all hyperparameters") + raise RuntimeError( + "DBSCAN grid search failed for all hyperparameters; " + f"see grid_log ({len(grid_log)} cells)" + ) + best.grid_log = grid_log return best @@ -210,23 +271,37 @@ def evaluate_model_grid( loader, device: torch.device, *, + config: dict[str, Any], backend: str, eps_values: list[float] | None = None, min_samples_values: list[int] | None = None, objective: str = "wdist", metric: str = "max", topo_options: TopoWdistOptions | None = None, + allow_skip_degenerate_grid_cells: bool = False, + grid_log_path: Path | str | None = None, + manifest_ref: dict[str, Any] | None = None, ) -> GridResult: """Forward ``loader`` through ``model`` and grid-search DBSCAN by ``objective``.""" + eps_resolved, ms_resolved = resolve_dbscan_grid( + config, + eps_values=eps_values, + min_samples_values=min_samples_values, + ) clouds = list(iter_cloud_predictions(model, loader, device)) prepared = prepare_clouds(clouds, backend=backend, metric=metric) - return grid_search_prepared( + result = grid_search_prepared( prepared, - eps_values=eps_values or DEFAULT_EPS_VALUES, - min_samples_values=min_samples_values or DEFAULT_MIN_SAMPLES_VALUES, + eps_values=eps_resolved, + min_samples_values=ms_resolved, objective=objective, topo_options=topo_options, + allow_skip_degenerate_grid_cells=allow_skip_degenerate_grid_cells, + manifest_ref=manifest_ref, ) + if grid_log_path is not None: + write_grid_log(grid_log_path, result.grid_log) + return result def evaluate_model_fixed( diff --git a/tda_ml/distance_backend.py b/tda_ml/distance_backend.py index 8a5bf1c..d060673 100644 --- a/tda_ml/distance_backend.py +++ b/tda_ml/distance_backend.py @@ -149,11 +149,10 @@ def compute_distance_matrix_batch( use_torch = ellphi_differentiable and _has_ellphi_grad_api() if ellphi_differentiable and not use_torch: - warnings.warn( - "ellphi.grad (coef_from_cov_grad / pdist_tangency_grad) is unavailable; " - "falling back to the NumPy non-differentiable path. Please update ellphi.", - UserWarning, - stacklevel=2, + raise RuntimeError( + "distance_backend='ellphi' with ellphi_differentiable=True requires " + "ellphi.grad (coef_from_cov_grad / pdist_tangency_grad). " + "Install/update ellphi or set ellphi_differentiable=false explicitly." ) batch_size = points.shape[0] diff --git a/tda_ml/ellphi_torch.py b/tda_ml/ellphi_torch.py index ecccabf..351f50e 100644 --- a/tda_ml/ellphi_torch.py +++ b/tda_ml/ellphi_torch.py @@ -53,29 +53,25 @@ def forward(ctx, centers: torch.Tensor, cov: torch.Tensor) -> torch.Tensor: ctx.save_for_backward(centers, cov) device, dtype = centers.device, centers.dtype - n = centers.shape[0] x_np = centers.detach().cpu().numpy().astype(np.float64) c_np = cov.detach().cpu().numpy().astype(np.float64) coefs, vjp_cov = coef_from_cov_grad(x_np, c_np) if np.isnan(coefs).any(): - ctx.vjp_pdist = None - ctx.vjp_cov = None - ctx.failed = True - return torch.full((n, n), float("nan"), device=device, dtype=dtype) + raise RuntimeError( + "ellphi coef_from_cov_grad returned NaN; degenerate ellipse geometry" + ) try: dists, vjp_pdist = pdist_tangency_grad(coefs) - except (ZeroDivisionError, ValueError, RuntimeError): - ctx.vjp_pdist = None - ctx.vjp_cov = None - ctx.failed = True - return torch.full((n, n), float("nan"), device=device, dtype=dtype) + except (ZeroDivisionError, ValueError, RuntimeError) as exc: + raise RuntimeError( + "ellphi pdist_tangency_grad failed; degenerate ellipse geometry" + ) from exc full = squareform(dists) ctx.vjp_pdist = vjp_pdist ctx.vjp_cov = vjp_cov - ctx.failed = False ctx.device = device ctx.dtype = dtype return torch.as_tensor(full, device=device, dtype=dtype) @@ -83,16 +79,13 @@ def forward(ctx, centers: torch.Tensor, cov: torch.Tensor) -> torch.Tensor: @staticmethod def backward(ctx, grad_output: torch.Tensor): centers, cov = ctx.saved_tensors - if getattr(ctx, "failed", True) or ctx.vjp_pdist is None: - return torch.zeros_like(centers), torch.zeros_like(cov) - g = grad_output.detach().cpu().numpy().astype(np.float64) g_cond = _condensed_gradient_from_full(g) try: grad_coefs = ctx.vjp_pdist(g_cond) grad_x, grad_cov_np = ctx.vjp_cov(grad_coefs) - except (ZeroDivisionError, ValueError, RuntimeError): - return torch.zeros_like(centers), torch.zeros_like(cov) + except (ZeroDivisionError, ValueError, RuntimeError) as exc: + raise RuntimeError("ellphi VJP backward failed") from exc dev, dt = ctx.device, ctx.dtype return ( diff --git a/tda_ml/local_pca.py b/tda_ml/local_pca.py index 9dc070a..93c8345 100644 --- a/tda_ml/local_pca.py +++ b/tda_ml/local_pca.py @@ -25,6 +25,7 @@ def local_pca_ellipse_params( points: torch.Tensor, *, k: int = 10, + normalize_axes: bool = True, ) -> torch.Tensor: """ Ideal ellipse parameters from local PCA only (no learned corrections). @@ -32,12 +33,16 @@ def local_pca_ellipse_params( Args: points: ``(B, N, 2)`` or ``(N, 2)`` coordinates. k: number of Euclidean nearest neighbors (including self in the k-ball). + normalize_axes: if True, divide by the major semi-axis so ``a=1`` (aspect ratio + only). If False, use raw ``sqrt(eigenvalue)`` semi-axes ``(sqrt(l1), sqrt(l2))``. Returns: - ``(..., N, 3)`` with ``[a, b, theta]`` per point (major/minor normalized). + ``(..., N, 3)`` with ``[a, b, theta]`` per point. """ if points.ndim == 2: - return local_pca_ellipse_params(points.unsqueeze(0), k=k).squeeze(0) + return local_pca_ellipse_params( + points.unsqueeze(0), k=k, normalize_axes=normalize_axes + ).squeeze(0) if points.ndim != 3 or points.shape[-1] != 2: raise ValueError(f"points must be (B, N, 2) or (N, 2); got {tuple(points.shape)}") @@ -71,8 +76,9 @@ def local_pca_ellipse_params( base_axes = torch.sqrt(torch.clamp(e, min=EIGENVALUE_FLOOR)) base_axes = torch.flip(base_axes, dims=[-1]) - base_axes = base_axes / ( - base_axes.max(dim=-1, keepdim=True)[0] + EIGENVALUE_FLOOR - ) + if normalize_axes: + base_axes = base_axes / ( + base_axes.max(dim=-1, keepdim=True)[0] + EIGENVALUE_FLOOR + ) return torch.cat([base_axes, base_angle.unsqueeze(-1)], dim=-1).to(dtype=points.dtype) diff --git a/tda_ml/losses.py b/tda_ml/losses.py index 11f5e7e..059d68b 100644 --- a/tda_ml/losses.py +++ b/tda_ml/losses.py @@ -28,19 +28,57 @@ def forward(self, logits, labels): class SizeRegularizationLoss(nn.Module): """ - Penalizes the size of estimated ellipses to prevent over-expansion. - - Formula: L = lambda_major * a^2 + lambda_minor * b^2 + Penalizes ellipse scale. + + Modes: + - quadratic (default): ``w_major * a^2 + w_minor * b^2`` on every point (always on). + - barrier: ``w * relu(a^2 + b^2 - radius^2)^2`` — zero gradient below ``barrier_radius``. + - power: ``w * (||axes||^2 / ref)^gamma`` — always on; gradient grows with scale + (small ellipses: weak pull; large ellipses: stronger than quadratic when gamma > 1). + - softplus: ``w * softplus(beta * (||axes||^2 / ref - 1))^2`` — smooth ramp centred + at ``ref``; no hard dead zone, but gentle below reference scale. """ - def __init__(self, w_major=0.1, w_minor=0.1): + + def __init__( + self, + w_major: float = 0.1, + w_minor: float = 0.1, + mode: str = "quadratic", + barrier_radius: float = 1.5, + size_ref: float = 1.34, + size_power: float = 1.5, + size_softplus_beta: float = 8.0, + ): super().__init__() self.w_major = w_major self.w_minor = w_minor + self.mode = mode + self.barrier_radius = float(barrier_radius) + self.size_ref = float(size_ref) + self.size_power = float(size_power) + self.size_softplus_beta = float(size_softplus_beta) + + def _weight(self) -> float: + return 0.5 * (self.w_major + self.w_minor) def forward(self, params): axes = params[..., 0:2] major_axis = axes.max(dim=-1)[0] minor_axis = axes.min(dim=-1)[0] + sq_norm = major_axis**2 + minor_axis**2 + ref = max(self.size_ref, NUMERICAL_EPS) + weight = self._weight() + + if self.mode == "barrier": + excess = F.relu(sq_norm - self.barrier_radius**2) + return weight * (excess**2).mean() + if self.mode == "power": + scaled = sq_norm / ref + return weight * scaled.pow(self.size_power).mean() + if self.mode == "softplus": + # centred at ref: ~0 below ref, ~quadratic in excess above ref + x = self.size_softplus_beta * (sq_norm / ref - 1.0) + return weight * F.softplus(x).pow(2).mean() loss = (self.w_major * (major_axis**2) + self.w_minor * (minor_axis**2)).mean() return loss @@ -55,6 +93,8 @@ class AnisotropyPenaltyLoss(nn.Module): - elongate: Minimizes minor/major (circularity) -> rewards elongation along the local principal direction; circle (ratio=1) is the worst case. This reproduces the legacy ``aniso_mode: elongate`` behavior that yielded data-aligned ellipses. + - elongate_barrier: ``elongate`` below the aspect-ratio ceiling, plus ``barrier`` on + excess above ``barrier_threshold`` (keep tangent elongation, cap needle-like tails). """ def __init__(self, weight=0.01, mode='linear', barrier_threshold=6.0): super().__init__() @@ -74,6 +114,11 @@ def forward(self, params): aspect_ratios = major_axis / (minor_axis + NUMERICAL_EPS) barrier_term = F.relu(aspect_ratios - self.barrier_threshold).pow(2).mean() loss = 10.0 * barrier_term + elif self.mode == 'elongate_barrier': + aspect_ratios = major_axis / (minor_axis + NUMERICAL_EPS) + elongate = (minor_axis / (major_axis + NUMERICAL_EPS)).mean() + barrier_term = F.relu(aspect_ratios - self.barrier_threshold).pow(2).mean() + loss = elongate + 10.0 * barrier_term elif self.mode == 'elongate': loss = (minor_axis / (major_axis + NUMERICAL_EPS)).mean() else: @@ -120,6 +165,7 @@ def __init__( eps_scale: float = 1.0, scale_mode: str = "fixed", max_points: int | None = None, + strict_topo_samples: bool = True, ): super().__init__() self.weight = weight @@ -150,6 +196,7 @@ def __init__( self.max_points = int(max_points) if max_points is not None else None if self.max_points is not None and self.max_points < 2: raise ValueError(f"max_points must be >= 2, got {self.max_points}") + self.strict_topo_samples = bool(strict_topo_samples) self.vr_complex = VietorisRipsComplex(dim=1) self.wasserstein = WassersteinDistance(q=2) @@ -204,13 +251,21 @@ def forward(self, points, params, logits, clean_pd_info, clean_scales=None): pd_pred_info = self.vr_complex(d_mat, treat_as_distances=True) loss_sample = self.wasserstein(pd_pred_info, clean_pd_info[i]) ** 2 - if not torch.isnan(loss_sample): - total_loss += loss_sample - valid_samples += 1 - else: - topo_failures.append((i, "nan or inf Wasserstein loss")) + if torch.isnan(loss_sample): + msg = f"nan or inf Wasserstein loss at batch_index={i}" + if self.strict_topo_samples: + raise RuntimeError(f"TopologicalLoss: {msg}") + topo_failures.append((i, msg)) + continue + total_loss += loss_sample + valid_samples += 1 + except RuntimeError: + raise except Exception as exc: - topo_failures.append((i, f"{type(exc).__name__}: {exc}")) + msg = f"{type(exc).__name__}: {exc} at batch_index={i}" + if self.strict_topo_samples: + raise RuntimeError(f"TopologicalLoss: {msg}") from exc + topo_failures.append((i, msg)) continue if topo_failures: diff --git a/tda_ml/main.py b/tda_ml/main.py index 423cc9e..b24bd78 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -7,7 +7,7 @@ import torch from tda_ml.checkpoint_io import extract_model_state_dict, load_torch_checkpoint -from tda_ml.config import deep_update, load_config, model_kwargs_from_config +from tda_ml.config import deep_update, load_config, model_kwargs_from_config, default_project_root from tda_ml.model_selection import ( compute_epoch_selection, selection_settings_from_config, @@ -19,8 +19,17 @@ resolve_dataloader_settings, resolve_device, ) +from tda_ml.run_paths import build_run_dir from tda_ml.runtime_profile import build_runtime_profile from tda_ml.seed_utils import set_global_seed +from tda_ml.preflight import preflight_training_config +from tda_ml.reproducibility import ( + RUN_STATUS_COMPLETED, + RUN_STATUS_FAILED, + RUN_STATUS_NOT_RUN, + build_dbscan_eval_manifest_fields, + build_reproducibility_manifest_fields, +) from tda_ml.supervised_diagnostics import ( git_revision, run_abort_diagnostics, @@ -47,30 +56,80 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): logger.info("Loaded config: %s", config["meta"].get("config_id", "unknown")) - device = resolve_device(config) - import datetime - timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') - config_id = config['meta'].get('config_id', 'unknown') - - # --- Directory Setup --- - if 'outputs' in config: - base_dir = config['outputs'].get('base_dir', 'outputs') - run_dir_name = f"{config_id}_{timestamp}" - run_dir = os.path.join(base_dir, run_dir_name) - - # Consistent directory names across the project - config['outputs']['log_dir'] = os.path.join(run_dir, 'logs') - config['outputs']['image_dir'] = os.path.join(run_dir, 'images') - - # Note: Trainer also calls makedirs to ensure robustness - os.makedirs(config['outputs']['log_dir'], exist_ok=True) - os.makedirs(config['outputs']['image_dir'], exist_ok=True) - + + device = resolve_device(config) + config_id = config["meta"].get("config_id", "unknown") + run_dir = run_slug = run_stamp = None + log_dir = None + manifest_path = None + + if "outputs" in config: + run_dir, run_slug, run_stamp = build_run_dir(config) + config["outputs"]["log_dir"] = os.path.join(run_dir, "logs") + config["outputs"]["image_dir"] = os.path.join(run_dir, "images") + log_dir = config["outputs"]["log_dir"] + os.makedirs(log_dir, exist_ok=True) + os.makedirs(config["outputs"]["image_dir"], exist_ok=True) logger.info("Project structure created at: %s", run_dir) - data_cfg = config['data'] - seed = data_cfg.get('seed', 42) + data_cfg = config["data"] + seed = data_cfg.get("seed", 42) + manifest = { + "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "source_revision": git_revision(), + "config_id": config_id, + "run_slug": run_slug, + "run_stamp": run_stamp, + "command_entry": "tda_ml.main", + "seed": seed, + "epochs_planned": config["training"]["epochs"], + "distance_backend": config.get("model", {}) + .get("topology_loss", {}) + .get("distance_backend", "mahalanobis"), + "checkpoint_selection": (config.get("training", {}).get("selection") or {}).get( + "metric", "threshold_mcc" + ), + "early_abort": config.get("training", {}).get("early_abort"), + "run_dir": run_dir, + "run_status": RUN_STATUS_NOT_RUN, + "final_status": "pending", + "preflight_status": "pending", + "fallback_status": "none", + "fallbacks": [], + "reproducibility": build_reproducibility_manifest_fields(config), + } + if config.get("evaluation"): + manifest["dbscan_eval"] = build_dbscan_eval_manifest_fields(config) + manifest.update(config.get("_manifest_extras") or {}) + config.setdefault("_manifest", {}) + manifest_path = os.path.join(log_dir, "run_manifest.json") + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=True, indent=2) + logger.info("Run manifest (pending) saved: %s", manifest_path) + + try: + preflight_training_config(config, project_root=default_project_root()) + except Exception as exc: + manifest["run_status"] = RUN_STATUS_NOT_RUN + manifest["final_status"] = RUN_STATUS_NOT_RUN + manifest["preflight_status"] = "failed" + manifest["preflight_error"] = str(exc) + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=True, indent=2) + raise + manifest["preflight_status"] = "passed" + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=True, indent=2) + else: + preflight_training_config(config, project_root=default_project_root()) + data_cfg = config["data"] + seed = data_cfg.get("seed", 42) + manifest = {"config_id": config_id, "seed": seed} + config.setdefault("_manifest", manifest) + + data_cfg = config["data"] + seed = data_cfg.get("seed", 42) deterministic_algorithms = bool(config.get("reproducibility", {}).get("deterministic_algorithms", False)) set_global_seed(seed, deterministic_algorithms=deterministic_algorithms) logger.info( @@ -96,15 +155,15 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): trainer = Trainer(model, config, device=device) init_checkpoint = config.get('init_checkpoint') - if init_checkpoint and os.path.exists(init_checkpoint): + if init_checkpoint: + if not os.path.exists(init_checkpoint): + raise FileNotFoundError(f"Initial checkpoint not found: {init_checkpoint}") logger.info("Loading initial weights from %s", init_checkpoint) checkpoint = load_torch_checkpoint(init_checkpoint, map_location=device) model.load_state_dict(extract_model_state_dict(checkpoint), strict=False) - elif init_checkpoint: - logger.warning("Initial checkpoint not found at %s", init_checkpoint) - log_dir = config['outputs']['log_dir'] - metrics_path = os.path.join(log_dir, 'metrics.csv') + log_dir = config["outputs"]["log_dir"] + metrics_path = os.path.join(log_dir, "metrics.csv") runtime_profile = build_runtime_profile( config=config, device=device, @@ -120,26 +179,21 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): json.dump(runtime_profile, f, ensure_ascii=True, indent=2) logger.info("Runtime profile saved: %s", runtime_profile_path) - manifest = { - "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "source_revision": git_revision(), - "config_id": config_id, - "command_entry": "tda_ml.main", - "seed": seed, - "epochs_planned": config["training"]["epochs"], - "distance_backend": config.get("model", {}) - .get("topology_loss", {}) - .get("distance_backend", "mahalanobis"), - "early_abort": config.get("training", {}).get("early_abort"), - "run_dir": run_dir, - "fallback_status": "not applicable", - } - manifest_path = os.path.join(log_dir, "run_manifest.json") - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f, ensure_ascii=True, indent=2) - logger.info("Run manifest saved: %s", manifest_path) + if manifest_path is not None: + impl = config.get("_manifest", {}).get("distance_backend_impl") + if impl is not None: + manifest["distance_backend_impl"] = impl + manifest["final_status"] = "running" + manifest["run_status"] = RUN_STATUS_NOT_RUN + if config.get("_manifest", {}).get("fallbacks"): + manifest["fallbacks"] = config["_manifest"]["fallbacks"] + manifest["fallback_status"] = config["_manifest"].get( + "fallback_status", "recorded" + ) + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=True, indent=2) - with open(metrics_path, 'w', newline='') as f: + with open(metrics_path, "w", newline="") as f: writer = csv.writer(f) writer.writerow([ 'epoch', 'train_loss', 'train_class_loss', 'train_topo_loss', @@ -307,6 +361,7 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): abort_report_path = write_abort_report(log_dir, report) logger.warning("Abort diagnostics: %s", abort_report_path) manifest["final_status"] = "early-aborted" + manifest["run_status"] = RUN_STATUS_FAILED manifest["abort_epoch"] = epoch manifest["abort_reason"] = abort_reason manifest["abort_report"] = str(abort_report_path) @@ -317,6 +372,10 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): if final_status == "completed": manifest["final_status"] = "completed" + manifest["run_status"] = RUN_STATUS_COMPLETED + if config.get("_manifest", {}).get("fallbacks"): + manifest["fallback_status"] = "recorded" + manifest["fallbacks"] = config["_manifest"]["fallbacks"] with open(manifest_path, "w", encoding="utf-8") as f: json.dump(manifest, f, ensure_ascii=True, indent=2) diff --git a/tda_ml/model_selection.py b/tda_ml/model_selection.py index eeefbe3..58c52b3 100644 --- a/tda_ml/model_selection.py +++ b/tda_ml/model_selection.py @@ -1,9 +1,12 @@ """Checkpoint-selection policy for the training loop. -Prefer ``val_topo`` so the saved checkpoint matches the topology loss used -during training (same ellphi/local_pca teacher PD as ``train_epoch``). -``wdist`` / ``dbscan_mcc`` use DBSCAN + Euclidean inlier-point W-Dist -(paper reporting metric; not the training objective). +Default production protocol (``val_topo``): +- During training: save ``best_model.pth`` at minimum ``val_topo_loss`` (no DBSCAN grid). +- After training: ``evaluate_paper_protocol.py`` grid-searches DBSCAN on val once, then + reports test MCC at the chosen ``(eps, min_samples)``. + +Optional ``wdist`` / ``dbscan_mcc`` run a val DBSCAN grid every ``eval_every`` epochs to +pick checkpoints; use only for tuning or ablations (much slower on CPU). metric: 'val_topo' | 'wdist' | 'dbscan_mcc' | 'threshold_mcc' | 'val_loss' @@ -15,8 +18,10 @@ import logging from dataclasses import dataclass +from pathlib import Path from tda_ml.dbscan_eval import evaluate_model_grid +from tda_ml.reproducibility import reproducibility_settings, resolve_dbscan_grid from tda_ml.topo_wdist import topo_wdist_options_from_config logger = logging.getLogger(__name__) @@ -95,15 +100,25 @@ def compute_epoch_selection( run_sel_eval = (epoch % settings.eval_every == 0) or (epoch == epochs) if settings.metric in ("wdist", "dbscan_mcc") and run_sel_eval: objective = "wdist" if settings.metric == "wdist" else "mcc" + rep = reproducibility_settings(config) + eps_values, min_samples_values = resolve_dbscan_grid( + config, + eps_values=settings.eps_values, + min_samples_values=settings.min_samples_values, + ) grid = evaluate_model_grid( model, val_loader, device, + config=config, backend=settings.backend, - eps_values=settings.eps_values, - min_samples_values=settings.min_samples_values, + eps_values=eps_values, + min_samples_values=min_samples_values, objective=objective, topo_options=topo_wdist_options_from_config(config), + allow_skip_degenerate_grid_cells=rep["allow_skip_degenerate_grid_cells"], + grid_log_path=Path(config["outputs"]["log_dir"]) / f"dbscan_grid_epoch_{epoch}.json", + manifest_ref=config.get("_manifest"), ) print( f"Epoch {epoch}: selection[{settings.metric}] val_wdist={grid.wdist:.5f} " diff --git a/tda_ml/models.py b/tda_ml/models.py index d3c4f14..5f518f1 100644 --- a/tda_ml/models.py +++ b/tda_ml/models.py @@ -145,7 +145,11 @@ def forward(self, x): outlier_logits = self.classification_head(cls_feats) raw = self.topology_head(topo_feats) - axes_scale = torch.exp(raw[:, :, 0:2]) + # Restrict axes scaling factor within a physically sound range to prevent + # both underflow collapse (NaN) and overflow cheat (over-expansion). + min_scale = 0.2 + max_scale = 3.0 + axes_scale = min_scale + (max_scale - min_scale) * torch.sigmoid(raw[:, :, 0:2]) axes = axes_scale * base_axes angle_delta = torch.tanh(raw[:, :, 2:3]) * (torch.pi / 2) angle = base_angle + angle_delta diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py new file mode 100644 index 0000000..1858985 --- /dev/null +++ b/tda_ml/preflight.py @@ -0,0 +1,265 @@ +"""Preflight checks before expensive experiment execution.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Literal + +from tda_ml.config import load_config +from tda_ml.reproducibility import ( + RUN_STATUS_NOT_RUN, + assert_ellphi_differentiable_available, + assert_loss_config_explicit, + baseline_grids_from_config, + build_dbscan_eval_manifest_fields, + dbscan_grid_from_config, + write_json, +) + +TuneObjectiveKind = Literal["mcc", "wdist"] + + +def _require_import(name: str, import_fn) -> None: + try: + import_fn() + except ImportError as exc: + raise RuntimeError(f"Required dependency {name!r} is not importable: {exc}") from exc + + +def classify_tune_objective(objective: str) -> TuneObjectiveKind: + obj = str(objective).lower() + if "mcc" in obj: + return "mcc" + if "wdist" in obj: + return "wdist" + raise ValueError( + f"Unrecognized tune objective {objective!r}; expected 'mcc' or 'wdist' in objective string." + ) + + +def preflight_training_config( + config: dict[str, Any], + *, + project_root: Path | str | None = None, + data_root: Path | str | None = None, +) -> dict[str, Any]: + """Validate training setup; return manifest preview fields.""" + root = Path(project_root) if project_root is not None else Path.cwd() + data_path = Path(data_root) if data_root is not None else root / "data" + + assert_loss_config_explicit(config) + + _require_import("torch_topological", lambda: __import__("torch_topological")) + _require_import("scipy", lambda: __import__("scipy")) + + topo = (config.get("model") or {}).get("topology_loss") or {} + backend = str(topo.get("distance_backend", "mahalanobis")).lower() + ellphi_diff = bool(topo.get("ellphi_differentiable", True)) + if backend == "ellphi": + _require_import("ellphi", lambda: __import__("ellphi")) + impl = assert_ellphi_differentiable_available(ellphi_differentiable=ellphi_diff) + else: + impl = backend + + dtype = str((config.get("data") or {}).get("dataset_type", "mnist")).lower().strip() + if dtype == "mnist": + if not data_path.is_dir(): + raise FileNotFoundError( + f"MNIST data root missing: {data_path}. " + "Download MNIST first (e.g. run a short training job or place cached data under ./data)." + ) + + out_base = (config.get("outputs") or {}).get("base_dir") + if out_base: + out_path = Path(out_base) + if not out_path.is_absolute(): + out_path = root / out_path + out_path.mkdir(parents=True, exist_ok=True) + if not os_access_writable(out_path): + raise PermissionError(f"Output base not writable: {out_path}") + + preview: dict[str, Any] = { + "config_id": config.get("meta", {}).get("config_id"), + "distance_backend": backend, + "distance_backend_impl": impl, + "seed": config.get("data", {}).get("seed"), + } + if config.get("evaluation"): + preview["dbscan_eval"] = build_dbscan_eval_manifest_fields(config) + return preview + + +def preflight_tune_json( + tune_json: Path, + *, + expected: TuneObjectiveKind | None = None, +) -> dict[str, Any]: + if not tune_json.is_file(): + raise FileNotFoundError(f"Tune JSON not found: {tune_json}") + payload = json.loads(tune_json.read_text(encoding="utf-8")) + for key in ("best_params", "objective"): + if key not in payload: + raise ValueError(f"Tune JSON missing required key {key!r}: {tune_json}") + params = payload["best_params"] + for key in ("w_topo", "w_aniso", "w_size", "lr"): + if key not in params: + raise ValueError(f"Tune JSON best_params missing {key!r}: {tune_json}") + kind = classify_tune_objective(str(payload["objective"])) + if expected is not None and kind != expected: + raise ValueError( + f"Tune JSON objective {payload['objective']!r} is {kind!r}, expected {expected!r}: " + f"{tune_json}" + ) + payload["_objective_kind"] = kind + return payload + + +def preflight_tune_study( + *, + base_config: str, + project_root: Path | str, + out_base: Path | str, + study_objective: TuneObjectiveKind, +) -> dict[str, Any]: + root = Path(project_root) + cfg = load_config(base_config, project_root=root) + dbscan_grid_from_config(cfg) + preview = preflight_training_config(cfg, project_root=root) + out = Path(out_base) + if not out.is_absolute(): + out = root / out + out.mkdir(parents=True, exist_ok=True) + preview["out_base"] = str(out) + preview["base_config"] = base_config + preview["study_objective"] = study_objective + write_json(out / "STUDY_PREFLIGHT.json", preview) + return preview + + +def preflight_mcc_tune_study( + *, + base_config: str, + project_root: Path | str, + out_base: Path | str, +) -> dict[str, Any]: + return preflight_tune_study( + base_config=base_config, + project_root=project_root, + out_base=out_base, + study_objective="mcc", + ) + + +def preflight_wdist_tune_study( + *, + base_config: str, + project_root: Path | str, + out_base: Path | str, +) -> dict[str, Any]: + return preflight_tune_study( + base_config=base_config, + project_root=project_root, + out_base=out_base, + study_objective="wdist", + ) + + +def preflight_tune_production_run( + *, + base_config: str, + tune_json: Path, + project_root: Path | str, + out_base: Path | str, +) -> dict[str, Any]: + root = Path(project_root) + tune_payload = preflight_tune_json(tune_json) + cfg = load_config(base_config, project_root=root) + preview = preflight_training_config(cfg, project_root=root) + preview["tune_json"] = str(tune_json.resolve()) + preview["tune_objective"] = tune_payload.get("objective") + preview["tune_objective_kind"] = tune_payload["_objective_kind"] + preview["checkpoint_selection"] = ( + (cfg.get("training") or {}).get("selection") or {} + ).get("metric", "val_topo") + out = Path(out_base) + if not out.is_absolute(): + out = root / out + out.mkdir(parents=True, exist_ok=True) + write_json(out / "RUN_PREFLIGHT.json", preview) + return preview + + +def preflight_mcc_production_run( + *, + base_config: str, + tune_json: Path, + project_root: Path | str, + out_base: Path | str, +) -> dict[str, Any]: + return preflight_tune_production_run( + base_config=base_config, + tune_json=tune_json, + project_root=project_root, + out_base=out_base, + ) + + +def write_not_run_manifest( + log_dir: Path | str, + *, + reason: str, + config: dict[str, Any] | None = None, + entry: str = "tda_ml.main", +) -> Path: + """Record preflight failure with skill-aligned ``not-run`` status.""" + from tda_ml.supervised_diagnostics import git_revision + + p = Path(log_dir) + p.mkdir(parents=True, exist_ok=True) + payload: dict[str, Any] = { + "run_status": RUN_STATUS_NOT_RUN, + "final_status": RUN_STATUS_NOT_RUN, + "preflight_error": reason, + "command_entry": entry, + "source_revision": git_revision(), + } + if config is not None: + payload["config_id"] = config.get("meta", {}).get("config_id") + payload["seed"] = (config.get("data") or {}).get("seed") + out = p / "run_manifest.json" + write_json(out, payload) + return out + + +def preflight_baseline_eval( + config: dict[str, Any], + *, + project_root: Path | str, + out_dir: Path | str, +) -> dict[str, Any]: + root = Path(project_root) + preview = preflight_training_config(config, project_root=root) + preview["baseline_grids"] = baseline_grids_from_config(config) + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + write_json(out / "BASELINE_PREFLIGHT.json", preview) + return preview + + +def preflight_paper_eval_run_dir(run_dir: Path, *, checkpoint_name: str = "best_model.pth") -> None: + if not run_dir.is_dir(): + raise FileNotFoundError(f"run-dir not found: {run_dir}") + ckpt = run_dir / checkpoint_name + if not ckpt.is_file(): + raise FileNotFoundError(f"Checkpoint not found: {ckpt}") + + +def os_access_writable(path: Path) -> bool: + try: + test = path / ".preflight_write_test" + test.write_text("", encoding="utf-8") + test.unlink(missing_ok=True) + return True + except OSError: + return False diff --git a/tda_ml/reproducibility.py b/tda_ml/reproducibility.py new file mode 100644 index 0000000..699dbbf --- /dev/null +++ b/tda_ml/reproducibility.py @@ -0,0 +1,199 @@ +"""Reproducibility helpers: DBSCAN grid resolution, fallback policy, manifest fields.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import numpy as np + +from tda_ml.ellphi_torch import _has_ellphi_grad_api +from tda_ml.numerical_eps import ( + EIGENVALUE_FLOOR, + INLIER_PROB_MIN, + NUMERICAL_EPS, + PCA_RIDGE_EPS, +) + +DEFAULT_DBSCAN_EPS_LINSPACE = (0.15, 1.5, 15) +DEFAULT_DBSCAN_MIN_SAMPLES = [3, 5, 7, 10, 15] + +# Skill-aligned run status (see computational-reproducibility failure semantics). +RUN_STATUS_NOT_RUN = "not-run" +RUN_STATUS_SKIPPED = "skipped" +RUN_STATUS_FAILED = "failed" +RUN_STATUS_COMPLETED = "completed" +RUN_STATUS_EMPTY_RESULT = "empty-result" +RUN_STATUS_ZERO_RESULT = "zero-result" + + +def default_dbscan_eps_values() -> list[float]: + lo, hi, n = DEFAULT_DBSCAN_EPS_LINSPACE + return [float(x) for x in np.linspace(lo, hi, int(n))] + + +def _evaluation_dbscan_cfg(config: dict[str, Any]) -> dict[str, Any]: + return (config.get("evaluation") or {}).get("dbscan") or {} + + +def dbscan_grid_from_config(config: dict[str, Any]) -> tuple[list[float], list[int]]: + """Resolve DBSCAN grid from ``evaluation.dbscan``; hard-fail if unset.""" + ev = _evaluation_dbscan_cfg(config) + eps_raw = ev.get("eps_values") + ms_raw = ev.get("min_samples_values") + if eps_raw is None: + raise ValueError( + "evaluation.dbscan.eps_values must be set in config (no implicit default). " + f"Example: linspace {DEFAULT_DBSCAN_EPS_LINSPACE}" + ) + if ms_raw is None: + raise ValueError( + "evaluation.dbscan.min_samples_values must be set in config (no implicit default)." + ) + return [float(x) for x in eps_raw], [int(x) for x in ms_raw] + + +def resolve_dbscan_grid( + config: dict[str, Any], + *, + eps_values: list[float] | None = None, + min_samples_values: list[int] | None = None, +) -> tuple[list[float], list[int]]: + """Explicit args win; otherwise read ``evaluation.dbscan`` from *config*.""" + if eps_values is not None and min_samples_values is not None: + return [float(x) for x in eps_values], [int(x) for x in min_samples_values] + cfg_eps, cfg_ms = dbscan_grid_from_config(config) + eps_out = [float(x) for x in eps_values] if eps_values is not None else cfg_eps + ms_out = [int(x) for x in min_samples_values] if min_samples_values is not None else cfg_ms + return eps_out, ms_out + + +def dbscan_backend_from_config(config: dict[str, Any]) -> str: + ev = _evaluation_dbscan_cfg(config) + backend = ev.get("backend") + if backend is None: + raise ValueError("evaluation.dbscan.backend must be set in config.") + return str(backend).strip().lower() + + +def dbscan_metric_from_config(config: dict[str, Any]) -> str: + ev = _evaluation_dbscan_cfg(config) + return str(ev.get("metric", "max")).strip().lower() + + +REQUIRED_LOSS_WEIGHT_KEYS = ("w_class", "w_topo", "w_aniso", "w_size") + + +def reproducibility_settings(config: dict[str, Any]) -> dict[str, bool]: + rep = config.get("reproducibility") or {} + return { + "strict_topo_samples": bool(rep.get("strict_topo_samples", True)), + "allow_nan_batch_skip": bool(rep.get("allow_nan_batch_skip", False)), + "allow_empty_cloud_fallback": bool(rep.get("allow_empty_cloud_fallback", False)), + "allow_skip_degenerate_grid_cells": bool( + rep.get("allow_skip_degenerate_grid_cells", False) + ), + "allow_otsu_threshold_fallback": bool( + rep.get("allow_otsu_threshold_fallback", False) + ), + "allow_legacy_loss_keys": bool(rep.get("allow_legacy_loss_keys", False)), + } + + +def assert_loss_config_explicit(config: dict[str, Any]) -> None: + """Hard-fail when primary loss weights would come from legacy ``training.lambda_*``.""" + if reproducibility_settings(config)["allow_legacy_loss_keys"]: + return + loss_cfg = config.get("loss") or {} + missing = [k for k in REQUIRED_LOSS_WEIGHT_KEYS if k not in loss_cfg] + if missing: + raise ValueError( + f"loss section must define {missing}; legacy training.lambda_* fallbacks " + "are disabled. Set reproducibility.allow_legacy_loss_keys=true to opt in." + ) + training_cfg = config.get("training") or {} + if "lr" not in training_cfg: + raise ValueError( + "training.lr must be set explicitly; no implicit default." + ) + + +def record_fallback(manifest: dict[str, Any], name: str, detail: str) -> None: + """Append a fallback event to *manifest* (creates ``fallbacks`` list).""" + manifest.setdefault("fallbacks", []).append({"name": name, "detail": detail}) + manifest["fallback_status"] = "recorded" + + +def assert_ellphi_differentiable_available(*, ellphi_differentiable: bool) -> str: + """Return impl label or raise if differentiable ellphi was requested but unavailable.""" + if not ellphi_differentiable: + return "ellphi_numpy" + if not _has_ellphi_grad_api(): + raise RuntimeError( + "distance_backend='ellphi' with ellphi_differentiable=True requires " + "ellphi.grad (coef_from_cov_grad / pdist_tangency_grad). " + "Install/update ellphi or set ellphi_differentiable=false explicitly." + ) + return "ellphi_torch_grad" + + +def build_dbscan_eval_manifest_fields(config: dict[str, Any]) -> dict[str, Any]: + eps, ms = dbscan_grid_from_config(config) + return { + "backend": dbscan_backend_from_config(config), + "metric": dbscan_metric_from_config(config), + "eps_values": eps, + "min_samples_values": ms, + } + + +def baseline_grids_from_config(config: dict[str, Any]) -> dict[str, Any]: + """Baseline-only hyperparameter grids (IF / LOF / shared DBSCAN).""" + bl = (config.get("evaluation") or {}).get("baselines") or {} + cont = bl.get("contamination_values") + lof_n = bl.get("lof_n_neighbors") + if cont is None: + raise ValueError( + "evaluation.baselines.contamination_values must be set in config." + ) + if lof_n is None: + raise ValueError("evaluation.baselines.lof_n_neighbors must be set in config.") + eps, ms = dbscan_grid_from_config(config) + return { + "eps_values": eps, + "min_samples_values": ms, + "contamination_values": [float(x) for x in cont], + "lof_n_neighbors": [int(x) for x in lof_n], + } + + +def build_reproducibility_manifest_fields(config: dict[str, Any]) -> dict[str, Any]: + return { + "settings": reproducibility_settings(config), + "numerical_eps_module": "tda_ml.numerical_eps", + "numerical_constants": { + "NUMERICAL_EPS": NUMERICAL_EPS, + "PCA_RIDGE_EPS": PCA_RIDGE_EPS, + "EIGENVALUE_FLOOR": EIGENVALUE_FLOOR, + "INLIER_PROB_MIN": INLIER_PROB_MIN, + }, + } + + +def map_final_status_to_run_status(final_status: str) -> str: + if final_status == "completed": + return RUN_STATUS_COMPLETED + if final_status == "early-aborted": + return RUN_STATUS_FAILED + return final_status + + +def write_json(path: Path | str, payload: dict[str, Any]) -> None: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8") + + +def write_grid_log(path: Path | str, grid_log: list[dict[str, Any]]) -> None: + write_json(path, {"grid_log": grid_log}) diff --git a/tda_ml/run_paths.py b/tda_ml/run_paths.py new file mode 100644 index 0000000..2e0b9cb --- /dev/null +++ b/tda_ml/run_paths.py @@ -0,0 +1,107 @@ +"""Short, consistent output directory and visualization file naming.""" + +from __future__ import annotations + +import datetime +import re +from pathlib import Path +from typing import Any + +_TIMESTAMP_FMT = "%m%d_%H%M" +OUTPUT_CATEGORIES = frozenset({"supervised", "supervised_no_cls", "tune"}) + +_BACKEND_SHORT = { + "ellphi": "eph", + "mahalanobis": "mah", +} + + +def short_backend(name: str) -> str: + return _BACKEND_SHORT.get(name, name[:3]) + + +def shorten_config_id(config_id: str, *, max_len: int = 24) -> str: + """Derive a compact slug from a legacy config_id.""" + m = re.fullmatch(r"teacher_local_pca_power_seed(\d+)", config_id) + if m: + return f"pwr_s{m.group(1)}" + + m = re.fullmatch(r"teacher_local_pca_(ellphi|mahalanobis)_seed(\d+)", config_id) + if m: + return f"lpc_{short_backend(m.group(1))}_s{m.group(2)}" + + m = re.fullmatch(r"backend_(ellphi|mahalanobis)_seed(\d+)", config_id) + if m: + return f"{short_backend(m.group(1))}_s{m.group(2)}" + + m = re.fullmatch(r"tune_mcc_t(\d+)", config_id) + if m: + return f"t{m.group(1)}" + + m = re.fullmatch(r"tune_elongate_t(\d+)", config_id) + if m: + return f"t{m.group(1)}" + + m = re.fullmatch(r"barrier_smoke_(.+)", config_id) + if m: + return f"bar_{m.group(1)}" + + m = re.fullmatch(r"barrier_quick_probe_(.+)", config_id) + if m: + return f"barq_{m.group(1)}" + + m = re.fullmatch(r"smoke_(.+)", config_id) + if m: + return f"smk_{m.group(1)}" + + slug = config_id + for prefix in ("elongate_n100_no_cls_", "teacher_local_pca_"): + if slug.startswith(prefix): + slug = slug[len(prefix) :] + if len(slug) > max_len: + slug = slug[:max_len] + return slug + + +def resolve_run_slug(config: dict[str, Any]) -> str: + outputs = config.get("outputs") or {} + meta = config.get("meta") or {} + if outputs.get("run_slug"): + return str(outputs["run_slug"]) + if meta.get("run_slug"): + return str(meta["run_slug"]) + return shorten_config_id(str(meta.get("config_id", "run"))) + + +def make_run_stamp(when: datetime.datetime | None = None) -> str: + return (when or datetime.datetime.now()).strftime(_TIMESTAMP_FMT) + + +def build_run_dir( + config: dict[str, Any], + when: datetime.datetime | None = None, +) -> tuple[str, str, str]: + """Return ``(run_dir, run_slug, run_stamp)``.""" + base_dir = config.get("outputs", {}).get("base_dir", "outputs") + slug = resolve_run_slug(config) + stamp = make_run_stamp(when) + run_dir = str(Path(base_dir) / f"{slug}_{stamp}") + return run_dir, slug, stamp + + +def experiment_base(category: str, slug: str, when: datetime.datetime | None = None) -> str: + """Batch output root: ``outputs//_``.""" + if category not in OUTPUT_CATEGORIES: + allowed = ", ".join(sorted(OUTPUT_CATEGORIES)) + raise ValueError(f"category must be one of {allowed}, got {category!r}") + stamp = (when or datetime.datetime.now()).strftime("%m%d") + return f"outputs/{category}/{stamp}_{slug}" + + +def tune_base(slug: str, when: datetime.datetime | None = None) -> str: + """Shortcut for ``experiment_base('tune', slug)``.""" + return experiment_base("tune", slug, when=when) + + +def visualization_filename(epoch: int) -> str: + return f"e{epoch}.png" diff --git a/tda_ml/run_setup.py b/tda_ml/run_setup.py index e0dbb56..f155ddd 100644 --- a/tda_ml/run_setup.py +++ b/tda_ml/run_setup.py @@ -92,6 +92,14 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): num_outliers=data_cfg["num_outliers"], deterministic=True, noise_seed=seed, + allow_empty_cloud_fallback=bool( + (config.get("reproducibility") or {}).get("allow_empty_cloud_fallback", False) + or data_cfg.get("allow_empty_cloud_fallback", False) + ), + allow_otsu_threshold_fallback=bool( + (config.get("reproducibility") or {}).get("allow_otsu_threshold_fallback", False) + or data_cfg.get("allow_otsu_threshold_fallback", False) + ), ) train_dataset = NoisyMNISTDataset(train=True, indices=train_indices, **dataset_kwargs) diff --git a/tda_ml/teacher_pd.py b/tda_ml/teacher_pd.py index d14e4d4..eb84229 100644 --- a/tda_ml/teacher_pd.py +++ b/tda_ml/teacher_pd.py @@ -16,7 +16,10 @@ def _median_offdiag(d_mat: torch.Tensor) -> float: off = d_mat[d_mat > 0] if off.numel() == 0: - return 1.0 + raise RuntimeError( + "Teacher distance matrix has no positive off-diagonal entries; " + "cannot compute median scale." + ) return float(torch.median(off).item()) @@ -28,6 +31,7 @@ def compute_clean_teacher_batch( distance_backend: str = "mahalanobis", ellphi_differentiable: bool = False, local_pca_k: int = 10, + local_pca_normalize_axes: bool = True, max_points: int | None = None, need_clean_scales: bool = False, ) -> tuple[list, list[float] | None]: @@ -54,10 +58,10 @@ def compute_clean_teacher_batch( valid_mask = torch.abs(pts).sum(dim=1) > 1e-6 pts = pts[valid_mask] if pts.shape[0] < 2: - clean_pd_info.append(vr_complex(pts)) - if clean_scales is not None: - clean_scales.append(1.0) - continue + raise RuntimeError( + f"Clean teacher cloud has {pts.shape[0]} valid inlier points after " + "zero-padding mask; need at least 2 for persistence." + ) if max_points is not None and pts.shape[0] > max_points: idx = torch.randperm(pts.shape[0], device=pts.device)[:max_points] @@ -68,11 +72,19 @@ def compute_clean_teacher_batch( if clean_scales is not None: eu = torch.pdist(pts) clean_scales.append( - float(torch.median(eu).item()) if eu.numel() > 0 else 1.0 + float(torch.median(eu).item()) if eu.numel() > 0 else None ) + if clean_scales[-1] is None: + raise RuntimeError( + "Euclidean teacher cloud has no pairwise distances for scale." + ) continue - ideal_params = local_pca_ellipse_params(pts.unsqueeze(0), k=local_pca_k) + ideal_params = local_pca_ellipse_params( + pts.unsqueeze(0), + k=local_pca_k, + normalize_axes=local_pca_normalize_axes, + ) d_batch = compute_distance_matrix_batch( pts.unsqueeze(0), ideal_params, diff --git a/tda_ml/topo_wdist.py b/tda_ml/topo_wdist.py index dad9cb3..427697e 100644 --- a/tda_ml/topo_wdist.py +++ b/tda_ml/topo_wdist.py @@ -27,6 +27,7 @@ class TopoWdistOptions: teacher_mode: str = "local_pca" distance_backend: str = "ellphi" teacher_local_pca_k: int = 10 + teacher_local_pca_normalize_axes: bool = True eps_scale: float = 1.0 scale_mode: str = "fixed" max_points: int | None = None @@ -49,6 +50,12 @@ def topo_wdist_options_from_config(config: dict[str, Any]) -> TopoWdistOptions: training_cfg.get("teacher_local_pca_k", 10), ) ), + teacher_local_pca_normalize_axes=bool( + loss_cfg.get( + "teacher_local_pca_normalize_axes", + training_cfg.get("teacher_local_pca_normalize_axes", True), + ) + ), eps_scale=float( loss_cfg.get("topo_eps_scale", training_cfg.get("topo_eps_scale", 1.0)) ), @@ -105,6 +112,7 @@ def compute_topo_wdist( distance_backend=opts.distance_backend, ellphi_differentiable=False, local_pca_k=opts.teacher_local_pca_k, + local_pca_normalize_axes=opts.teacher_local_pca_normalize_axes, max_points=opts.max_points, need_clean_scales=(opts.scale_mode == "median"), ) diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 2e039fe..2fe20be 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -5,6 +5,12 @@ from torch.optim import Adam from torch_topological.nn import VietorisRipsComplex from tda_ml.teacher_pd import compute_clean_teacher_batch +from tda_ml.reproducibility import ( + assert_ellphi_differentiable_available, + assert_loss_config_explicit, + record_fallback, + reproducibility_settings, +) from tda_ml.losses import ( ClassificationLoss, TopologicalLoss, @@ -70,16 +76,34 @@ def _init_amp(self, config): self.scaler = torch.amp.GradScaler("cuda", enabled=self.use_amp) def _init_losses(self, config): - """Parse loss weights (loss.* with legacy training.* fallbacks) and build loss modules.""" + """Parse loss weights from ``loss.*``; legacy ``training.lambda_*`` only when opted in.""" + assert_loss_config_explicit(config) training_cfg = config.get('training', {}) loss_cfg = config.get('loss', {}) + allow_legacy = reproducibility_settings(config)["allow_legacy_loss_keys"] + + def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: + if key in loss_cfg: + return loss_cfg[key] + if allow_legacy and legacy_key in training_cfg: + return training_cfg[legacy_key] + raise ValueError( + f"loss.{key} must be set explicitly; legacy training.{legacy_key} fallback " + "is disabled. Set reproducibility.allow_legacy_loss_keys=true to opt in." + ) - self.lambda_class = loss_cfg.get('w_class', training_cfg.get('lambda_class', 1.0)) - self.lambda_topo = loss_cfg.get('w_topo', training_cfg.get('lambda_topo', 0.1)) - self.lambda_aniso = loss_cfg.get('w_aniso', training_cfg.get('lambda_aniso', 0.01)) - self.lambda_min_b = loss_cfg.get( - 'w_min_b', training_cfg.get('lambda_min_b', 0.0) - ) + self._repro = reproducibility_settings(config) + self._manifest_ref = config.setdefault("_manifest", {}) + + self.lambda_class = _loss_or_legacy("w_class", "lambda_class", 1.0) + self.lambda_topo = _loss_or_legacy("w_topo", "lambda_topo", 0.1) + self.lambda_aniso = _loss_or_legacy("w_aniso", "lambda_aniso", 0.01) + if "w_min_b" in loss_cfg: + self.lambda_min_b = float(loss_cfg["w_min_b"]) + elif allow_legacy and "lambda_min_b" in training_cfg: + self.lambda_min_b = float(training_cfg["lambda_min_b"]) + else: + self.lambda_min_b = 0.0 self.min_b_target = float( loss_cfg.get('min_b_target', training_cfg.get('min_b_target', 0.2)) ) @@ -89,14 +113,63 @@ def _init_losses(self, config): if self.topo_loss_max_points is not None: self.topo_loss_max_points = int(self.topo_loss_max_points) - size_default = loss_cfg.get( - "w_size", training_cfg.get("lambda_size", 0.1) - ) + size_default = _loss_or_legacy("w_size", "lambda_size", 0.1) self.lambda_major = training_cfg.get("lambda_major", size_default) self.lambda_minor = training_cfg.get("lambda_minor", size_default) self.aniso_mode = loss_cfg.get("aniso_mode", training_cfg.get("aniso_mode", "linear")) - logger.info("Anisotropy penalty mode: %s", self.aniso_mode) + self.aniso_barrier_threshold = float( + loss_cfg.get( + "aniso_barrier_threshold", + training_cfg.get("barrier_threshold", 6.0), + ) + ) + self.size_mode = str( + loss_cfg.get("size_mode", training_cfg.get("size_mode", "quadratic")) + ).strip().lower() + self.size_barrier_radius = float( + loss_cfg.get( + "size_barrier_radius", + training_cfg.get("size_barrier_radius", 1.5), + ) + ) + self.size_ref = float( + loss_cfg.get("size_ref", training_cfg.get("size_ref", 1.34)) + ) + self.size_power = float( + loss_cfg.get("size_power", training_cfg.get("size_power", 1.5)) + ) + self.size_softplus_beta = float( + loss_cfg.get( + "size_softplus_beta", + training_cfg.get("size_softplus_beta", 8.0), + ) + ) + logger.info( + "Anisotropy penalty mode: %s (barrier_threshold=%s)", + self.aniso_mode, + self.aniso_barrier_threshold, + ) + if self.size_mode == "barrier": + logger.info( + "Size penalty mode: barrier (radius=%s, w_size=%s)", + self.size_barrier_radius, + size_default, + ) + elif self.size_mode == "power": + logger.info( + "Size penalty mode: power (ref=%s, gamma=%s, w_size=%s)", + self.size_ref, + self.size_power, + size_default, + ) + elif self.size_mode == "softplus": + logger.info( + "Size penalty mode: softplus (ref=%s, beta=%s, w_size=%s)", + self.size_ref, + self.size_softplus_beta, + size_default, + ) pos_weight_val = config.get('loss', {}).get('pos_weight', 1.0) pos_weight = torch.tensor([pos_weight_val], device=self.device) if pos_weight_val != 1.0 else None @@ -124,8 +197,14 @@ def _init_losses(self, config): training_cfg.get("teacher_local_pca_k", 10), ) ) + self.teacher_local_pca_normalize_axes = bool( + loss_cfg.get( + "teacher_local_pca_normalize_axes", + training_cfg.get("teacher_local_pca_normalize_axes", True), + ) + ) logger.info( - "Topological distance backend: %s%s (prob_weighting=%s, scale_mode=%s, eps_scale=%s, teacher_mode=%s)", + "Topological distance backend: %s%s (prob_weighting=%s, scale_mode=%s, eps_scale=%s, teacher_mode=%s%s)", self.distance_backend, ( f" (ellphi_differentiable={self.ellphi_differentiable})" @@ -136,7 +215,17 @@ def _init_losses(self, config): self.topo_scale_mode, self.topo_eps_scale, self.teacher_mode, + ( + f", teacher_local_pca_normalize_axes={self.teacher_local_pca_normalize_axes}" + if self.teacher_mode == "local_pca" + else "" + ), ) + if self.distance_backend == "ellphi": + impl = assert_ellphi_differentiable_available( + ellphi_differentiable=self.ellphi_differentiable + ) + self._manifest_ref["distance_backend_impl"] = impl self.topo_loss_fn = TopologicalLoss( weight=self.lambda_topo, distance_backend=self.distance_backend, @@ -145,12 +234,21 @@ def _init_losses(self, config): eps_scale=self.topo_eps_scale, scale_mode=self.topo_scale_mode, max_points=self.topo_loss_max_points, + strict_topo_samples=self._repro["strict_topo_samples"], + ) + self.size_loss_fn = SizeRegularizationLoss( + w_major=self.lambda_major, + w_minor=self.lambda_minor, + mode=self.size_mode, + barrier_radius=self.size_barrier_radius, + size_ref=self.size_ref, + size_power=self.size_power, + size_softplus_beta=self.size_softplus_beta, ) - self.size_loss_fn = SizeRegularizationLoss(w_major=self.lambda_major, w_minor=self.lambda_minor) self.aniso_loss_fn = AnisotropyPenaltyLoss( - weight=self.lambda_aniso, - mode=self.aniso_mode, - barrier_threshold=config['training'].get('barrier_threshold', 6.0) + weight=self.lambda_aniso, + mode=self.aniso_mode, + barrier_threshold=self.aniso_barrier_threshold, ) self.min_b_loss_fn = MinBRegularizationLoss( weight=self.lambda_min_b, @@ -174,6 +272,7 @@ def _compute_clean_pd_info(self, clean_pc: torch.Tensor): distance_backend=self.distance_backend, ellphi_differentiable=self.ellphi_differentiable, local_pca_k=self.teacher_local_pca_k, + local_pca_normalize_axes=self.teacher_local_pca_normalize_axes, max_points=self.topo_loss_max_points, need_clean_scales=(self.topo_scale_mode == "median"), ) @@ -250,8 +349,22 @@ def train_epoch(self, data_loader, epoch): loss = loss + self.lambda_class * class_loss if torch.isnan(loss): - logger.warning("NaN loss at epoch=%s batch_index=%s; skipping step", epoch, i) - continue + if self._repro["allow_nan_batch_skip"]: + record_fallback( + self._manifest_ref, + "nan_batch_skip", + f"epoch={epoch} batch_index={i}", + ) + logger.warning( + "NaN loss at epoch=%s batch_index=%s; skipping step (opt-in fallback)", + epoch, + i, + ) + continue + raise RuntimeError( + f"NaN loss at epoch={epoch} batch_index={i}; " + "set reproducibility.allow_nan_batch_skip=true to opt in to skipping." + ) steps_completed += 1 if self.use_amp: @@ -398,7 +511,11 @@ def validate(self, data_loader): avg_aniso = self._val_aniso_accum / num_batches avg_size = self._val_size_accum / num_batches - avg_topo_loss = total_topo_loss / topo_steps if topo_steps > 0 else 0.0 + if self.lambda_topo > 0 and topo_steps == 0: + raise RuntimeError( + "validate: w_topo>0 but topological loss was not computed for any batch" + ) + avg_topo_loss = total_topo_loss / topo_steps recall = recall_score(all_labels, all_preds, zero_division=0) diff --git a/tda_ml/visualization.py b/tda_ml/visualization.py index 2749fc5..0dbdfb5 100644 --- a/tda_ml/visualization.py +++ b/tda_ml/visualization.py @@ -7,6 +7,7 @@ import torch from tda_ml.dbscan import apply_anisotropic_dbscan, compute_anisotropic_distance_matrix_np +from tda_ml.run_paths import visualization_filename INLIER_COLOR = "tab:blue" OUTLIER_COLOR = "tab:red" @@ -130,6 +131,6 @@ def visualize( ax.set_ylim(-1.2, 1.2) plt.tight_layout(rect=[0, 0, 1, 0.96]) - filename = os.path.join(output_dir, f"{title_prefix}_result_epoch_{epoch}.png") + filename = os.path.join(output_dir, visualization_filename(epoch)) plt.savefig(filename) plt.close() diff --git a/tests/test_data_loader.py b/tests/test_data_loader.py index d13a33d..a2ce2c2 100644 --- a/tests/test_data_loader.py +++ b/tests/test_data_loader.py @@ -67,8 +67,8 @@ def test_mock_outlier_dataset(self): - def test_empty_foreground_does_not_crash(self): - """Padding with zero foreground points must not call randint(0, 0).""" + def test_empty_foreground_strict_raises(self): + """Empty MNIST foreground must hard-fail unless opt-in fallback is enabled.""" dataset = NoisyMNISTDataset( train=False, num_samples=1, @@ -77,6 +77,23 @@ def test_empty_foreground_does_not_crash(self): preload=False, deterministic=True, noise_seed=42, + allow_empty_cloud_fallback=False, + ) + dataset.images = torch.zeros((1, 28, 28), dtype=torch.uint8) + with self.assertRaises(RuntimeError): + dataset[0] + + def test_empty_foreground_opt_in_fallback(self): + """Opt-in fallback reproduces legacy random-point behavior.""" + dataset = NoisyMNISTDataset( + train=False, + num_samples=1, + max_points=20, + num_outliers=5, + preload=False, + deterministic=True, + noise_seed=42, + allow_empty_cloud_fallback=True, ) dataset.images = torch.zeros((1, 28, 28), dtype=torch.uint8) data, labels, clean_pc = dataset[0] diff --git a/tests/test_dbscan_grid_wdist_cache.py b/tests/test_dbscan_grid_wdist_cache.py new file mode 100644 index 0000000..e74dd3c --- /dev/null +++ b/tests/test_dbscan_grid_wdist_cache.py @@ -0,0 +1,61 @@ +"""DBSCAN grid should compute topo W-Dist once per cloud, not per (eps, min_samples).""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +import numpy as np + +from tda_ml.dbscan_eval import PreparedCloud, grid_search_prepared +from tda_ml.topo_wdist import TopoWdistOptions + + +def _toy_cloud() -> PreparedCloud: + n = 12 + rng = np.random.default_rng(0) + points = rng.normal(size=(n, 2)) + params = np.column_stack([np.full(n, 0.3), np.full(n, 0.2), np.zeros(n)]) + labels_gt = np.array([0] * 10 + [1] * 2, dtype=np.int64) + clean_pc = np.vstack([points[:10], np.zeros((90, 2))]) + dist = np.abs(points[:, None, :] - points[None, :, :]).sum(axis=-1) + np.fill_diagonal(dist, 0.0) + return PreparedCloud(points, params, labels_gt, clean_pc, dist) + + +class TestDbscanGridWdistCache(unittest.TestCase): + def test_topo_wdist_computed_once_per_cloud(self) -> None: + clouds = [_toy_cloud(), _toy_cloud()] + opts = TopoWdistOptions(teacher_mode="euclidean", distance_backend="mahalanobis") + with patch( + "tda_ml.dbscan_eval.compute_topo_wdist", + side_effect=[0.5, 0.6], + ) as mock_wdist: + grid_search_prepared( + clouds, + eps_values=[0.2, 0.4], + min_samples_values=[3, 5], + objective="mcc", + topo_options=opts, + ) + self.assertEqual(mock_wdist.call_count, 2) + + def test_wdist_constant_across_grid_for_same_cloud(self) -> None: + cloud = _toy_cloud() + opts = TopoWdistOptions(teacher_mode="euclidean", distance_backend="mahalanobis") + with patch( + "tda_ml.dbscan_eval.compute_topo_wdist", + return_value=0.42, + ): + result = grid_search_prepared( + [cloud], + eps_values=[0.2, 0.4], + min_samples_values=[3], + objective="mcc", + topo_options=opts, + ) + self.assertAlmostEqual(result.wdist, 0.42) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ellipse_barrier_losses.py b/tests/test_ellipse_barrier_losses.py new file mode 100644 index 0000000..12a9e45 --- /dev/null +++ b/tests/test_ellipse_barrier_losses.py @@ -0,0 +1,73 @@ +"""Tests for threshold (barrier) ellipse regularizers.""" + +import unittest + +import torch + +from tda_ml.losses import AnisotropyPenaltyLoss, SizeRegularizationLoss + + +class TestEllipseBarrierLosses(unittest.TestCase): + def test_size_barrier_inactive_below_radius(self): + loss_fn = SizeRegularizationLoss( + w_major=0.5, w_minor=0.5, mode="barrier", barrier_radius=2.0 + ) + # major=minor=1 -> ||(a,b)||^2 = 2 < 4 + params = torch.tensor([[[1.0, 1.0, 0.0]]]) + self.assertAlmostEqual(float(loss_fn(params).item()), 0.0, places=6) + + def test_size_barrier_active_above_radius(self): + loss_fn = SizeRegularizationLoss( + w_major=0.5, w_minor=0.5, mode="barrier", barrier_radius=1.0 + ) + params = torch.tensor([[[2.0, 2.0, 0.0]]]) + val = float(loss_fn(params).item()) + self.assertGreater(val, 0.0) + + def test_elongate_barrier_keeps_elongate_below_threshold(self): + loss_fn = AnisotropyPenaltyLoss( + weight=1.0, mode="elongate_barrier", barrier_threshold=8.0 + ) + thin = torch.tensor([[[2.0, 0.5, 0.0]]]) # ratio=4 + roundish = torch.tensor([[[1.0, 0.8, 0.0]]]) # ratio=1.25 + self.assertLess(float(loss_fn(thin).item()), float(loss_fn(roundish).item())) + + def test_elongate_barrier_penalizes_above_threshold(self): + loss_fn = AnisotropyPenaltyLoss( + weight=1.0, mode="elongate_barrier", barrier_threshold=3.0 + ) + ok = torch.tensor([[[3.0, 1.0, 0.0]]]) # ratio=3 + bad = torch.tensor([[[6.0, 1.0, 0.0]]]) # ratio=6 + self.assertLess(float(loss_fn(ok).item()), float(loss_fn(bad).item())) + + def test_size_power_small_gradient_at_small_scale(self): + ref = 1.34 + loss_fn = SizeRegularizationLoss( + w_major=0.5, w_minor=0.5, mode="power", size_ref=ref, size_power=1.5 + ) + small = torch.tensor([[[0.2, 0.15, 0.0]]], requires_grad=True) + large = torch.tensor([[[1.0, 0.9, 0.0]]], requires_grad=True) + loss_fn(small).backward() + g_small = small.grad[..., 0:2].abs().max().item() + small.grad = None + loss_fn(large).backward() + g_large = large.grad[..., 0:2].abs().max().item() + self.assertLess(g_small, g_large) + + def test_size_softplus_smooth_nonzero_below_ref(self): + ref = 1.34 + loss_fn = SizeRegularizationLoss( + w_major=0.5, + w_minor=0.5, + mode="softplus", + size_ref=ref, + size_softplus_beta=8.0, + ) + below = torch.tensor([[[0.5, 0.4, 0.0]]]) # sq < ref + above = torch.tensor([[[1.0, 1.0, 0.0]]]) # sq > ref + self.assertGreater(float(loss_fn(below).item()), 0.0) + self.assertLess(float(loss_fn(below).item()), float(loss_fn(above).item())) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reproducibility_strict.py b/tests/test_reproducibility_strict.py new file mode 100644 index 0000000..82e4611 --- /dev/null +++ b/tests/test_reproducibility_strict.py @@ -0,0 +1,160 @@ +"""Strict reproducibility defaults (hard-fail on degenerate cases).""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import torch + +from tda_ml.dbscan_eval import PreparedCloud, grid_search_prepared +from tda_ml.reproducibility import ( + assert_loss_config_explicit, + dbscan_grid_from_config, + reproducibility_settings, + resolve_dbscan_grid, +) +from tda_ml.topo_wdist import TopoWdistOptions + + +class TestReproducibilityConfig(unittest.TestCase): + def test_base_config_has_explicit_dbscan_grid(self): + from tda_ml.config import load_config + + cfg = load_config("dev") + eps, ms = dbscan_grid_from_config(cfg) + self.assertEqual(len(eps), 15) + self.assertEqual(ms, [3, 5, 7, 10, 15]) + + def test_base_config_has_baseline_grids(self): + from tda_ml.config import load_config + from tda_ml.reproducibility import baseline_grids_from_config + + cfg = load_config("dev") + grids = baseline_grids_from_config(cfg) + self.assertIn("contamination_values", grids) + self.assertIn("lof_n_neighbors", grids) + + def test_missing_dbscan_grid_raises(self): + with self.assertRaises(ValueError): + dbscan_grid_from_config({}) + + def test_strict_defaults(self): + settings = reproducibility_settings({}) + self.assertTrue(settings["strict_topo_samples"]) + self.assertFalse(settings["allow_nan_batch_skip"]) + self.assertFalse(settings["allow_empty_cloud_fallback"]) + self.assertFalse(settings["allow_legacy_loss_keys"]) + + def test_resolve_dbscan_grid_explicit_override(self): + from tda_ml.config import load_config + + cfg = load_config("dev") + eps, ms = resolve_dbscan_grid(cfg, eps_values=[0.2], min_samples_values=[3]) + self.assertEqual(eps, [0.2]) + self.assertEqual(ms, [3]) + + def test_assert_loss_config_requires_explicit_weights(self): + with self.assertRaises(ValueError): + assert_loss_config_explicit({"loss": {"w_topo": 0.1}, "training": {"lr": 1e-4}}) + + def test_classify_tune_objective(self): + from tda_ml.preflight import classify_tune_objective + + self.assertEqual( + classify_tune_objective("val_topo_wdist_min_at_val_topo_best_ckpt"), + "wdist", + ) + self.assertEqual(classify_tune_objective("val_dbscan_mcc_max"), "mcc") + + +class TestPreflightTuneJson(unittest.TestCase): + def test_wdist_json_passes_without_mcc_expectation(self): + from tda_ml.preflight import preflight_tune_json + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "best.json" + path.write_text( + '{"objective":"val_topo_wdist_min","best_params":{"w_topo":0.1,' + '"w_aniso":0.1,"w_size":0.1,"lr":1e-4}}\n', + encoding="utf-8", + ) + payload = preflight_tune_json(path) + self.assertEqual(payload["_objective_kind"], "wdist") + + +class TestResolveValTopoCheckpoint(unittest.TestCase): + def test_missing_best_model_raises(self): + from tda_ml.checkpoint_io import resolve_val_topo_checkpoint + + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(FileNotFoundError): + resolve_val_topo_checkpoint(Path(tmp)) + + +class TestDbscanGridStrict(unittest.TestCase): + def _toy_cloud(self) -> PreparedCloud: + n = 12 + import numpy as np + + rng = np.random.default_rng(0) + points = rng.normal(size=(n, 2)) + params = np.column_stack([np.full(n, 0.3), np.full(n, 0.2), np.zeros(n)]) + labels_gt = np.array([0] * 10 + [1] * 2, dtype=np.int64) + clean_pc = np.vstack([points[:10], np.zeros((90, 2))]) + dist = np.abs(points[:, None, :] - points[None, :, :]).sum(axis=-1) + np.fill_diagonal(dist, 0.0) + return PreparedCloud(points, params, labels_gt, clean_pc, dist) + + def test_failed_cell_raises_when_skip_not_allowed(self): + cloud = self._toy_cloud() + opts = TopoWdistOptions(teacher_mode="euclidean", distance_backend="mahalanobis") + with patch( + "tda_ml.dbscan_eval._dbscan_classification_metrics", + side_effect=RuntimeError("degenerate"), + ): + with self.assertRaises(RuntimeError): + grid_search_prepared( + [cloud], + eps_values=[0.2], + min_samples_values=[3], + objective="mcc", + topo_options=opts, + allow_skip_degenerate_grid_cells=False, + ) + + +class TestTopologicalLossStrict(unittest.TestCase): + def test_nan_wasserstein_raises_when_strict(self): + from tda_ml.losses import TopologicalLoss + from torch_topological.nn import VietorisRipsComplex + + torch.manual_seed(0) + b, n = 1, 6 + pts = torch.randn(b, n, 2) + par = torch.randn(b, n, 3) + par[:, :, 0:2] = par[:, :, 0:2].abs() + 0.2 + logits = torch.zeros(b, n, 1) + vr = VietorisRipsComplex(dim=1) + clean = [vr(pts[0])] + + loss_fn = TopologicalLoss( + weight=1.0, + distance_backend="mahalanobis", + prob_weighting=False, + strict_topo_samples=True, + ) + + class BrokenWasserstein(torch.nn.Module): + def forward(self, a, b): + return torch.tensor(float("nan"), device=pts.device) + + loss_fn.wasserstein = BrokenWasserstein() + with self.assertRaises(RuntimeError): + loss_fn(pts, par, logits, clean) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_run_paths.py b/tests/test_run_paths.py new file mode 100644 index 0000000..cb91c20 --- /dev/null +++ b/tests/test_run_paths.py @@ -0,0 +1,54 @@ +"""Tests for tda_ml.run_paths.""" + +from __future__ import annotations + +import datetime +import unittest + +from tda_ml.run_paths import ( + OUTPUT_CATEGORIES, + build_run_dir, + experiment_base, + resolve_run_slug, + shorten_config_id, + tune_base, + visualization_filename, +) + + +class TestRunPaths(unittest.TestCase): + def test_shorten_config_id(self) -> None: + self.assertEqual(shorten_config_id("teacher_local_pca_power_seed42"), "pwr_s42") + self.assertEqual(shorten_config_id("backend_ellphi_seed42"), "eph_s42") + self.assertEqual(shorten_config_id("tune_mcc_t010"), "t010") + + def test_resolve_run_slug_prefers_explicit(self) -> None: + cfg = { + "meta": {"config_id": "backend_ellphi_seed42", "run_slug": "custom"}, + "outputs": {}, + } + self.assertEqual(resolve_run_slug(cfg), "custom") + + def test_build_run_dir(self) -> None: + when = datetime.datetime(2026, 7, 9, 13, 5) + cfg = { + "meta": {"config_id": "tune_mcc_t010", "run_slug": "t010"}, + "outputs": {"base_dir": "outputs/tune/0709_pwr_mcc"}, + } + run_dir, slug, stamp = build_run_dir(cfg, when=when) + self.assertEqual(slug, "t010") + self.assertEqual(stamp, "0709_1305") + self.assertEqual(run_dir, "outputs/tune/0709_pwr_mcc/t010_0709_1305") + + def test_experiment_base(self) -> None: + when = datetime.datetime(2026, 7, 9, 13, 5) + self.assertEqual(experiment_base("supervised", "pwr30", when=when), "outputs/supervised/0709_pwr30") + self.assertEqual(tune_base("pwr_mcc", when=when), "outputs/tune/0709_pwr_mcc") + self.assertEqual(OUTPUT_CATEGORIES, {"supervised", "supervised_no_cls", "tune"}) + + def test_visualization_filename(self) -> None: + self.assertEqual(visualization_filename(30), "e30.png") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_teacher_pd.py b/tests/test_teacher_pd.py index 58a22eb..0cda0ca 100644 --- a/tests/test_teacher_pd.py +++ b/tests/test_teacher_pd.py @@ -29,6 +29,16 @@ def test_local_pca_params_shape(self): self.assertTrue(torch.isfinite(params).all()) self.assertTrue((params[:, 0:2] > 0).all()) + def test_local_pca_raw_axes_not_unit_major(self): + torch.manual_seed(1) + pts = torch.randn(20, 2) * 0.1 + pts[0] = torch.tensor([0.0, 0.0]) + pts[1:] += torch.randn(19, 2) * 0.01 + norm = local_pca_ellipse_params(pts, k=10, normalize_axes=True) + raw = local_pca_ellipse_params(pts, k=10, normalize_axes=False) + self.assertTrue(torch.allclose(norm[:, 0], torch.ones_like(norm[:, 0]), atol=1e-5)) + self.assertGreater(float(raw[:, 0].max()), float(raw[:, 0].min())) + def test_euclidean_teacher_runs(self): vr = VietorisRipsComplex(dim=1) clean = torch.randn(2, 15, 2) diff --git a/tests/test_trainer.py b/tests/test_trainer.py index 8db5ad8..3783284 100644 --- a/tests/test_trainer.py +++ b/tests/test_trainer.py @@ -39,6 +39,12 @@ def setUp(self): 'grad_clip_value': 1.0, 'visualize_every': 10 }, + 'loss': { + 'w_class': 1.0, + 'w_topo': 0.1, + 'w_aniso': 0.01, + 'w_size': 0.1, + }, 'model': { 'topology_loss': { 'distance_backend': 'mahalanobis', @@ -85,7 +91,12 @@ def test_validate_reports_val_topo_loss(self): def test_w_class_zero_skips_classification_gradient(self): """w_class=0 and prob_weighting=false must not update the classification head.""" - self.config["loss"] = {"w_class": 0.0} + self.config["loss"] = { + "w_class": 0.0, + "w_topo": 0.1, + "w_aniso": 0.01, + "w_size": 0.1, + } self.config["model"]["topology_loss"]["prob_weighting"] = False trainer = Trainer(self.model, self.config, self.device) self.assertEqual(trainer.lambda_class, 0.0) From 1054ce59457bafcb497fc171797a108b9d32b85a Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:56:12 +0900 Subject: [PATCH 08/44] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C=EF=BC=88run=5Fsta?= =?UTF-8?q?tus=20=E8=AA=9E=E5=BD=99=E3=83=BBval=5Ftopo=20=E6=97=A2?= =?UTF-8?q?=E5=AE=9A=E3=83=BBpreflight=20=E5=A0=85=E7=89=A2=E5=8C=96?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run_status: preflight 失敗のみ not-run、学習中は running - checkpoint selection / manifest 既定を val_topo に統一 - classify_tune_objective: objective_kind 明示・ホワイトリスト・wdist 優先 - TopologicalLoss 非 strict skip を manifest に記録、未使用 dead code 削除 - REPRODUCIBILITY.md に preflight / reproducibility.* / run_status を追記 - base.yaml / dev.yaml に明示 loss 重みと selection 既定を追加 Co-authored-by: Cursor --- REPRODUCIBILITY.md | 53 +++++++++++++++++++++++++ configs/base.yaml | 8 ++++ configs/dev.yaml | 6 +++ tda_ml/losses.py | 10 +++++ tda_ml/main.py | 8 ++-- tda_ml/model_selection.py | 2 +- tda_ml/preflight.py | 59 +++++++++++++++++----------- tda_ml/reproducibility.py | 9 +---- tda_ml/trainer.py | 1 + tests/test_reproducibility_strict.py | 27 +++++++++++++ 10 files changed, 149 insertions(+), 34 deletions(-) diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 37ac3a9..57881ec 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -33,6 +33,59 @@ - `experiments/run_backend_multiseed.py` が内部で読み込む **`tda_ml/main.py`** の学習処理により、実行ごとのディレクトリ以下に成果物が書き出されます(README の「Expected artifacts」参照)。典型例は `logs/metrics.csv`、`logs/runtime_profile.json`、`best_model.pth`、可視化が有効なら `images/` などです。 - **`outputs/`** は git の対象外です。論文用に実行ツリーを保存する場合は、原稿や付録で **コミットハッシュ・シード・使用した設定名** とあわせてパスを示すと追跡しやすいです。`progress_summary.csv` には絶対パスが入るため、共有時のプライバシーに注意してください。 +## 厳格な再現性インフラ(preflight / manifest) + +[Computational Reproducibility](https://github.com/t-uda/skills/blob/main/skills/computational-reproducibility/SKILL.md) に沿い、本リポジトリでは **暗黙 fallback を禁止**し、実行前検証と manifest 記録で監査可能にしています(実装: `tda_ml/preflight.py`, `tda_ml/reproducibility.py`)。 + +### 実行前 preflight + +| 入口 | 出力 | 内容 | +|------|------|------| +| tune study | `STUDY_PREFLIGHT.json` | ベース config・DBSCAN grid・依存関係 | +| tune 本番 run | `RUN_PREFLIGHT.json` | tune JSON の objective 種別(MCC / W-Dist)・checkpoint 選択方針 | +| 学習 (`tda_ml.main`) | `logs/run_manifest.json` | preflight 通過後に学習開始;失敗時は `not-run` | +| paper eval | run-dir 内 checkpoint 存在確認 | `best_model.pth` 必須(サイレント代替なし) | + +preflight 失敗時は **学習を開始せず** `run_status: not-run` を記録します。 + +### `run_status` 語彙 + +| 値 | 意味 | +|----|------| +| `pending` | manifest 作成直後(preflight 未実施) | +| `not-run` | preflight 失敗により学習未開始 | +| `running` | preflight 通過後、学習実行中 | +| `completed` | 正常終了 | +| `failed` | early-abort 等で異常終了 | +| `skipped` / `empty-result` / `zero-result` | 集計・評価スクリプト側の失敗語彙 | + +`not-run` は preflight 専用です。学習中・中断 run を `not-run` と混同しないでください。 + +### `configs/base.yaml` の `reproducibility.*`(strict 既定) + +| フラグ | 既定 | 効果 | +|--------|------|------| +| `strict_topo_samples` | `true` | 位相損失の NaN / 退化サンプルで hard-fail | +| `allow_nan_batch_skip` | `false` | NaN loss バッチの skip は opt-in のみ(manifest に記録) | +| `allow_empty_cloud_fallback` | `false` | 空点群の暗黙代替禁止 | +| `allow_skip_degenerate_grid_cells` | `false` | DBSCAN grid 退化セルの skip は opt-in のみ | +| `allow_otsu_threshold_fallback` | `false` | Otsu 閾値の暗黙 fallback 禁止 | +| `allow_legacy_loss_keys` | `false` | `training.lambda_*` からの重み読み取り禁止;`loss.w_*` を明示 | + +opt-in fallback を有効にした場合、`run_manifest.json` の `fallbacks` 配列にイベントが追記されます。 + +### 明示必須の評価 grid + +`evaluation.dbscan.eps_values` / `min_samples_values` / `backend` は **config に必須**です(Python 側の implicit default は削除済み)。ベースライン評価用の `evaluation.baselines.*` も同様です。 + +### チェックポイント選択(本番プロトコル) + +既定は **`training.selection.metric: val_topo`**(`configs/base.yaml`)。学習中は `val_topo_loss` 最小の `best_model.pth` を保存し、DBSCAN grid は学習後の paper eval で一度だけ実行します。チューニング用に `wdist` / `dbscan_mcc` を選ぶ場合は docstring(`tda_ml/model_selection.py`)を参照してください。 + +### 出力パス規約 + +`tda_ml/run_paths.py` が `outputs/supervised` / `outputs/supervised_no_cls` / `outputs/tune` 配下の slug・タイムスタンプ命名を統一します。新規実験フォルダは `scripts/new_experiment.sh` を使用してください。 + ## 数値再現の公式手順 **公式**のマルチシード・バックエンド比較は次のとおりです。 diff --git a/configs/base.yaml b/configs/base.yaml index 6bc846c..363f498 100644 --- a/configs/base.yaml +++ b/configs/base.yaml @@ -1,10 +1,18 @@ # Base configuration # Shared parameters for all environments +loss: + w_class: 1.0 + w_topo: 0.1 + w_aniso: 0.01 + w_size: 1.0 + training: lr: 0.0003 grad_clip_value: 1.0 visualize_every: 10 # Epoch interval for visualization + selection: + metric: val_topo # Classification loss weight lambda_class: 1.0 # Anisotropic regularization weight diff --git a/configs/dev.yaml b/configs/dev.yaml index 7766796..2227858 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -3,6 +3,12 @@ meta: config_id: "dev" +loss: + w_class: 1.0 + w_topo: 0.1 + w_aniso: 0.01 + w_size: 0.0001 + training: epochs: 3 visualize_every: 1 diff --git a/tda_ml/losses.py b/tda_ml/losses.py index 059d68b..ff19a2e 100644 --- a/tda_ml/losses.py +++ b/tda_ml/losses.py @@ -11,6 +11,7 @@ subsample_indices, ) from tda_ml.numerical_eps import NUMERICAL_EPS +from tda_ml.reproducibility import record_fallback logger = logging.getLogger(__name__) @@ -166,6 +167,7 @@ def __init__( scale_mode: str = "fixed", max_points: int | None = None, strict_topo_samples: bool = True, + manifest_ref: dict | None = None, ): super().__init__() self.weight = weight @@ -197,6 +199,7 @@ def __init__( if self.max_points is not None and self.max_points < 2: raise ValueError(f"max_points must be >= 2, got {self.max_points}") self.strict_topo_samples = bool(strict_topo_samples) + self.manifest_ref = manifest_ref self.vr_complex = VietorisRipsComplex(dim=1) self.wasserstein = WassersteinDistance(q=2) @@ -271,6 +274,13 @@ def forward(self, points, params, logits, clean_pd_info, clean_scales=None): if topo_failures: batch_skipped = batch_size - valid_samples first_i, first_msg = topo_failures[0] + if self.manifest_ref is not None: + record_fallback( + self.manifest_ref, + "topo_sample_skip", + f"skipped {batch_skipped}/{batch_size} items " + f"(first batch_index={first_i}: {first_msg})", + ) logger.warning( "TopologicalLoss: skipped %d/%d batch items (first batch_index=%s: %s)", batch_skipped, diff --git a/tda_ml/main.py b/tda_ml/main.py index b24bd78..f9556b2 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -27,6 +27,7 @@ RUN_STATUS_COMPLETED, RUN_STATUS_FAILED, RUN_STATUS_NOT_RUN, + RUN_STATUS_RUNNING, build_dbscan_eval_manifest_fields, build_reproducibility_manifest_fields, ) @@ -88,11 +89,11 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): .get("topology_loss", {}) .get("distance_backend", "mahalanobis"), "checkpoint_selection": (config.get("training", {}).get("selection") or {}).get( - "metric", "threshold_mcc" + "metric", "val_topo" ), "early_abort": config.get("training", {}).get("early_abort"), "run_dir": run_dir, - "run_status": RUN_STATUS_NOT_RUN, + "run_status": "pending", "final_status": "pending", "preflight_status": "pending", "fallback_status": "none", @@ -119,6 +120,7 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): json.dump(manifest, f, ensure_ascii=True, indent=2) raise manifest["preflight_status"] = "passed" + manifest["run_status"] = RUN_STATUS_RUNNING with open(manifest_path, "w", encoding="utf-8") as f: json.dump(manifest, f, ensure_ascii=True, indent=2) else: @@ -184,7 +186,7 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): if impl is not None: manifest["distance_backend_impl"] = impl manifest["final_status"] = "running" - manifest["run_status"] = RUN_STATUS_NOT_RUN + manifest["run_status"] = RUN_STATUS_RUNNING if config.get("_manifest", {}).get("fallbacks"): manifest["fallbacks"] = config["_manifest"]["fallbacks"] manifest["fallback_status"] = config["_manifest"].get( diff --git a/tda_ml/model_selection.py b/tda_ml/model_selection.py index 58c52b3..54c6639 100644 --- a/tda_ml/model_selection.py +++ b/tda_ml/model_selection.py @@ -39,7 +39,7 @@ class SelectionSettings: def selection_settings_from_config(config) -> SelectionSettings: sel_cfg = (config.get("training", {}).get("selection") or {}) - metric = sel_cfg.get("metric", "threshold_mcc") + metric = sel_cfg.get("metric", "val_topo") eval_every = max(1, int(sel_cfg.get("eval_every", 1))) dbscan_cfg = (sel_cfg.get("dbscan") or {}) backend = ( diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py index 1858985..687b523 100644 --- a/tda_ml/preflight.py +++ b/tda_ml/preflight.py @@ -19,6 +19,13 @@ TuneObjectiveKind = Literal["mcc", "wdist"] +_KNOWN_TUNE_OBJECTIVES: dict[str, TuneObjectiveKind] = { + "val_topo_wdist_min": "wdist", + "val_topo_wdist_min_at_val_topo_best_ckpt": "wdist", + "val_dbscan_mcc_max": "mcc", + "val_dbscan_mcc": "mcc", +} + def _require_import(name: str, import_fn) -> None: try: @@ -27,14 +34,34 @@ def _require_import(name: str, import_fn) -> None: raise RuntimeError(f"Required dependency {name!r} is not importable: {exc}") from exc -def classify_tune_objective(objective: str) -> TuneObjectiveKind: - obj = str(objective).lower() - if "mcc" in obj: - return "mcc" - if "wdist" in obj: +def classify_tune_objective( + objective: str, + *, + objective_kind: str | None = None, +) -> TuneObjectiveKind: + """Resolve tune objective kind from explicit field, whitelist, or substring fallback.""" + if objective_kind is not None: + kind = str(objective_kind).lower().strip() + if kind in ("mcc", "wdist"): + return kind # type: ignore[return-value] + raise ValueError( + f"Unrecognized tune objective_kind {objective_kind!r}; expected 'mcc' or 'wdist'." + ) + + obj = str(objective).strip() + if obj in _KNOWN_TUNE_OBJECTIVES: + return _KNOWN_TUNE_OBJECTIVES[obj] + lower = obj.lower() + if lower in _KNOWN_TUNE_OBJECTIVES: + return _KNOWN_TUNE_OBJECTIVES[lower] + + if "wdist" in lower: return "wdist" + if "mcc" in lower: + return "mcc" raise ValueError( - f"Unrecognized tune objective {objective!r}; expected 'mcc' or 'wdist' in objective string." + f"Unrecognized tune objective {objective!r}; set objective_kind in tune JSON " + "or use a name containing 'mcc' or 'wdist'." ) @@ -105,7 +132,10 @@ def preflight_tune_json( for key in ("w_topo", "w_aniso", "w_size", "lr"): if key not in params: raise ValueError(f"Tune JSON best_params missing {key!r}: {tune_json}") - kind = classify_tune_objective(str(payload["objective"])) + kind = classify_tune_objective( + str(payload["objective"]), + objective_kind=payload.get("objective_kind"), + ) if expected is not None and kind != expected: raise ValueError( f"Tune JSON objective {payload['objective']!r} is {kind!r}, expected {expected!r}: " @@ -190,21 +220,6 @@ def preflight_tune_production_run( return preview -def preflight_mcc_production_run( - *, - base_config: str, - tune_json: Path, - project_root: Path | str, - out_base: Path | str, -) -> dict[str, Any]: - return preflight_tune_production_run( - base_config=base_config, - tune_json=tune_json, - project_root=project_root, - out_base=out_base, - ) - - def write_not_run_manifest( log_dir: Path | str, *, diff --git a/tda_ml/reproducibility.py b/tda_ml/reproducibility.py index 699dbbf..54dfd4b 100644 --- a/tda_ml/reproducibility.py +++ b/tda_ml/reproducibility.py @@ -6,8 +6,6 @@ from pathlib import Path from typing import Any -import numpy as np - from tda_ml.ellphi_torch import _has_ellphi_grad_api from tda_ml.numerical_eps import ( EIGENVALUE_FLOOR, @@ -17,10 +15,10 @@ ) DEFAULT_DBSCAN_EPS_LINSPACE = (0.15, 1.5, 15) -DEFAULT_DBSCAN_MIN_SAMPLES = [3, 5, 7, 10, 15] # Skill-aligned run status (see computational-reproducibility failure semantics). RUN_STATUS_NOT_RUN = "not-run" +RUN_STATUS_RUNNING = "running" RUN_STATUS_SKIPPED = "skipped" RUN_STATUS_FAILED = "failed" RUN_STATUS_COMPLETED = "completed" @@ -28,11 +26,6 @@ RUN_STATUS_ZERO_RESULT = "zero-result" -def default_dbscan_eps_values() -> list[float]: - lo, hi, n = DEFAULT_DBSCAN_EPS_LINSPACE - return [float(x) for x in np.linspace(lo, hi, int(n))] - - def _evaluation_dbscan_cfg(config: dict[str, Any]) -> dict[str, Any]: return (config.get("evaluation") or {}).get("dbscan") or {} diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 2fe20be..35ce2fd 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -235,6 +235,7 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: scale_mode=self.topo_scale_mode, max_points=self.topo_loss_max_points, strict_topo_samples=self._repro["strict_topo_samples"], + manifest_ref=self._manifest_ref, ) self.size_loss_fn = SizeRegularizationLoss( w_major=self.lambda_major, diff --git a/tests/test_reproducibility_strict.py b/tests/test_reproducibility_strict.py index 82e4611..f811396 100644 --- a/tests/test_reproducibility_strict.py +++ b/tests/test_reproducibility_strict.py @@ -68,6 +68,20 @@ def test_classify_tune_objective(self): "wdist", ) self.assertEqual(classify_tune_objective("val_dbscan_mcc_max"), "mcc") + self.assertEqual( + classify_tune_objective("legacy_name", objective_kind="mcc"), + "mcc", + ) + self.assertEqual( + classify_tune_objective("val_topo_wdist_min"), + "wdist", + ) + + def test_selection_default_is_val_topo(self): + from tda_ml.model_selection import selection_settings_from_config + + settings = selection_settings_from_config({}) + self.assertEqual(settings.metric, "val_topo") class TestPreflightTuneJson(unittest.TestCase): @@ -84,6 +98,19 @@ def test_wdist_json_passes_without_mcc_expectation(self): payload = preflight_tune_json(path) self.assertEqual(payload["_objective_kind"], "wdist") + def test_explicit_objective_kind_overrides_name(self): + from tda_ml.preflight import preflight_tune_json + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "best.json" + path.write_text( + '{"objective":"custom_objective","objective_kind":"wdist",' + '"best_params":{"w_topo":0.1,"w_aniso":0.1,"w_size":0.1,"lr":1e-4}}\n', + encoding="utf-8", + ) + payload = preflight_tune_json(path) + self.assertEqual(payload["_objective_kind"], "wdist") + class TestResolveValTopoCheckpoint(unittest.TestCase): def test_missing_best_model_raises(self): From 3b861f9a2c06023ed3b967133784491debb3473e Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:03:17 +0900 Subject: [PATCH 09/44] =?UTF-8?q?ci:=20ellphi=5Frepo=20=E3=82=92=20ensure?= =?UTF-8?q?=20=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=81=A7=20pin?= =?UTF-8?q?=20=E5=8F=96=E5=BE=97=EF=BC=88CI=20uv=20sync=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - third_party/ellphi.ref + scripts/ensure_ellphi_repo.sh を追加 - CI workflow で ensure 実行(pytorch-topological と同パターン) - pytest 用に CI の uv sync に --extra experiments を追加 - README / REPRODUCIBILITY.md / third_party/README.md を更新 Co-authored-by: Cursor --- .github/workflows/ruff.yml | 6 +++++- README.md | 8 ++++++- REPRODUCIBILITY.md | 5 ++++- scripts/ensure_ellphi_repo.sh | 39 +++++++++++++++++++++++++++++++++++ third_party/README.md | 9 +++++++- third_party/ellphi.ref | 5 +++++ 6 files changed, 68 insertions(+), 4 deletions(-) create mode 100755 scripts/ensure_ellphi_repo.sh create mode 100644 third_party/ellphi.ref diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index d4617b7..6f04c85 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -27,6 +27,10 @@ jobs: - name: Ensure pytorch-topological (uv path dependency) run: ./scripts/ensure_pytorch_topological.sh + # pyproject path dependency; pin matches third_party/ellphi.ref. + - name: Ensure ellphi_repo (uv path dependency) + run: ./scripts/ensure_ellphi_repo.sh + - name: Install uv and Python 3.12 uses: astral-sh/setup-uv@v5 with: @@ -34,7 +38,7 @@ jobs: enable-cache: true - name: Install dependencies (all groups, incl. dev / ruff) - run: uv sync --all-groups --frozen + run: uv sync --all-groups --extra experiments --frozen - name: Ruff check (repo root; F-1 canonical command) run: uv run ruff check . diff --git a/README.md b/README.md index 407ce06..a093773 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ Use `uv` as the canonical dependency manager. ./scripts/ensure_pytorch_topological.sh ``` +`ellphi` is also a **local path dependency** (`pyproject.toml` → `ellphi_repo/`). Sync the pinned fork (differentiable tangency grad API) before `uv sync`: + +```bash +./scripts/ensure_ellphi_repo.sh +``` + Optional: if this repository records `pytorch-topological` as a git submodule gitlink, `git submodule update --init --recursive` may populate the tree first; the ensure script still verifies the pinned commit. See `third_party/README.md` when bumping the pin. Then install Python dependencies: @@ -34,7 +40,7 @@ Optional package extras (e.g. `robustness_sweep`, HomCloud animation, explicit P uv sync --extra experiments --extra repro-pd-animation --extra images ``` -If you use `pip` instead of `uv`, run `./scripts/ensure_pytorch_topological.sh` from the repository root, then install from `pyproject.toml` with `pip install .` or `pip install -e .` for an editable install; add optional extras when needed (for example `pip install -e ".[experiments,repro-pd-animation,images]"`). There is no `requirements.txt`; dependency pins live in `uv.lock` for `uv` users. +If you use `pip` instead of `uv`, run `./scripts/ensure_pytorch_topological.sh` and `./scripts/ensure_ellphi_repo.sh` from the repository root, then install from `pyproject.toml` with `pip install .` or `pip install -e .` for an editable install; add optional extras when needed (for example `pip install -e ".[experiments,repro-pd-animation,images]"`). There is no `requirements.txt`; dependency pins live in `uv.lock` for `uv` users. ## Minimal Reproduction diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 57881ec..9874ae3 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -14,10 +14,13 @@ ```bash ./scripts/ensure_pytorch_topological.sh + ./scripts/ensure_ellphi_repo.sh uv sync --all-groups # ローカル検証用に dev(pytest, ruff)を含める ``` - `torch_topological` は **`pytorch-topological/` の path 依存**(`pyproject.toml`)であり、通常の `git clone` だけではディレクトリが揃いません。コミットは **`third_party/pytorch_topological.ref`** に固定し、**`scripts/ensure_pytorch_topological.sh`** がその ref に checkout します(CI と同じ)。サブモジュール gitlink がある場合は `git submodule update --init --recursive` のあとも ensure で pin を検証してください。詳細は `third_party/README.md`。 + `torch_topological` は **`pytorch-topological/` の path 依存**(`pyproject.toml`)であり、通常の `git clone` だけではディレクトリが揃いません。コミットは **`third_party/pytorch_topological.ref`** に固定し、**`scripts/ensure_pytorch_topological.sh`** がその ref に checkout します(CI と同じ)。 + + `ellphi` も **`ellphi_repo/` の path 依存**です。fork の pin は **`third_party/ellphi.ref`**、取得は **`scripts/ensure_ellphi_repo.sh`**(CI と同じ)。`ellphi_repo/` 自体は git に含めません。 - **PyTorch / CUDA**: 数値結果はデバイスや dtype によって変わり得ます。`tda_ml/main.py` の学習ループを使う場合、有効な設定は各実行の `logs/` 配下の `runtime_profile.json` などに記録されます。 diff --git a/scripts/ensure_ellphi_repo.sh b/scripts/ensure_ellphi_repo.sh new file mode 100755 index 0000000..838ae17 --- /dev/null +++ b/scripts/ensure_ellphi_repo.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Ensure ellphi_repo/ matches third_party/ellphi.ref. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REF_FILE="${ROOT}/third_party/ellphi.ref" +REPO_DIR="${ROOT}/ellphi_repo" +URL="https://github.com/koki3070/ellphi.git" + +if [[ ! -f "${REF_FILE}" ]]; then + echo "error: missing ${REF_FILE}" >&2 + exit 1 +fi + +REF="$( + grep -v '^[[:space:]]*#' "${REF_FILE}" | grep -v '^[[:space:]]*$' | head -1 | tr -d '[:space:]' +)" +if [[ -z "${REF}" ]]; then + echo "error: empty ref in ${REF_FILE}" >&2 + exit 1 +fi + +checkout_ref() { + cd "${REPO_DIR}" + git fetch --depth 1 origin "${REF}" + git checkout --detach "${REF}" +} + +if [[ -f "${REPO_DIR}/pyproject.toml" || -f "${REPO_DIR}/setup.py" ]]; then + current="$(cd "${REPO_DIR}" && git rev-parse HEAD)" + if [[ "${current}" == "${REF}" ]]; then + exit 0 + fi + checkout_ref + exit 0 +fi + +git clone "${URL}" "${REPO_DIR}" +checkout_ref diff --git a/third_party/README.md b/third_party/README.md index 531f6c4..b8cd2a6 100644 --- a/third_party/README.md +++ b/third_party/README.md @@ -7,4 +7,11 @@ Pins the commit checked out under `pytorch-topological/` for the `torch_topologi - **Fetch / sync:** `./scripts/ensure_pytorch_topological.sh` (used by CI and `README.md`). - **Bump upstream:** choose a new commit on [aidos-lab/pytorch-topological](https://github.com/aidos-lab/pytorch-topological), update the SHA in `pytorch_topological.ref`, run the ensure script, then `uv sync` and `uv run ruff check .`. -`.gitmodules` may list the same repository; the ref file is the canonical pin when the parent repo does not record a submodule gitlink. +## `ellphi.ref` + +Pins the commit checked out under `ellphi_repo/` for the `ellphi` path dependency in `pyproject.toml`. The pinned fork adds the differentiable `pdist_tangency_grad` API used by `tda_ml/ellphi_torch.py`. + +- **Fetch / sync:** `./scripts/ensure_ellphi_repo.sh` (used by CI and `README.md`). +- **Bump fork:** choose a new commit on [koki3070/ellphi](https://github.com/koki3070/ellphi) (based on [t-uda/ellphi](https://github.com/t-uda/ellphi)), update the SHA in `ellphi.ref`, run the ensure script, then `uv sync` and `uv run pytest`. + +`.gitmodules` may list the same repositories; the ref files are the canonical pins when the parent repo does not record submodule gitlinks. diff --git a/third_party/ellphi.ref b/third_party/ellphi.ref new file mode 100644 index 0000000..8f158eb --- /dev/null +++ b/third_party/ellphi.ref @@ -0,0 +1,5 @@ +# Pinned commit for the ellphi path dependency (ellphi_repo/). +# Fork with C++ pdist_tangency_grad (grad API for differentiable training). +# Upstream base: https://github.com/t-uda/ellphi +# Fork: https://github.com/koki3070/ellphi +9fcad0cea3d83d57d3cdeb1f4cfc3464588b792f From f150407392e23472eb7d82674e736c29dd5c56a0 Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:08:42 +0900 Subject: [PATCH 10/44] =?UTF-8?q?fix:=20run=5Fbackend=5Fmultiseed=20?= =?UTF-8?q?=E3=81=8C=20run=5Fpaths=20slug=20=E5=A4=89=E6=9B=B4=E5=BE=8C?= =?UTF-8?q?=E3=82=82=20run=5Fdir=20=E3=82=92=E5=8F=96=E5=BE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit train_main の戻り値 run_dir を使い、config_id プレフィックス glob を廃止。 CI repro smoke(mah_s42_* ディレクトリ)の検出失敗を修正。 Co-authored-by: Cursor --- experiments/run_backend_multiseed.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/experiments/run_backend_multiseed.py b/experiments/run_backend_multiseed.py index 4d9161a..8c455f6 100644 --- a/experiments/run_backend_multiseed.py +++ b/experiments/run_backend_multiseed.py @@ -29,9 +29,8 @@ Concurrency / filesystem: - A lock file under ``--out-base`` serializes this driver for one output tree. Do not run two instances sharing the same ``--out-base``. -- Run directory detection uses new directories matching ``config_id_*`` before - and after each training call; **parallel** runs with the same ``config_id`` - prefix can collide. Intended use is **one sequential process** per ``out-base``. +- Run directory is taken from ``train_main`` return value (``run_dir``); no + filesystem glob on ``config_id`` prefix. Privacy / version control: - ``progress_summary.csv`` stores absolute ``run_dir`` paths; avoid committing @@ -42,7 +41,6 @@ import argparse import csv -import glob import math import os from contextlib import contextmanager @@ -247,19 +245,6 @@ def write_backend_stats(progress_csv: str, stats_csv: str) -> None: ) -def detect_new_run_dir(base_dir: str, prefix: str, before: set[str]) -> str: - """Infer new run directory after train_main; sequential runs only (see module doc).""" - after = set(glob.glob(os.path.join(base_dir, f"{prefix}_*"))) - created = sorted(after - before) - if created: - return created[-1] - # Fallback if directory existed before timing race. - candidates = sorted(after) - if not candidates: - raise RuntimeError(f"Could not detect run directory for prefix={prefix}") - return candidates[-1] - - def run_one( base_config_name: str, backend: str, @@ -290,10 +275,13 @@ def run_one( overrides["loss"] = {"w_topo": float(w_topo)} cfg = deep_update(cfg, overrides) - before = set(glob.glob(os.path.join(out_base, f"{config_id}_*"))) print(f"[START] backend={backend} seed={seed} epochs={epochs}") - train_main(config=cfg) - run_dir = detect_new_run_dir(out_base, config_id, before) + result = train_main(config=cfg) + run_dir = result.get("run_dir") + if not run_dir: + raise RuntimeError( + f"train_main did not return run_dir for backend={backend} seed={seed}" + ) metrics_path = os.path.join(run_dir, "logs", "metrics.csv") metrics = parse_metrics_csv(metrics_path) From 0411a561199058bc3419006235edd7fcf2fcf68a Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:16:21 +0900 Subject: [PATCH 11/44] =?UTF-8?q?feat:=20ellphi=5Frepo=20=E5=86=8D?= =?UTF-8?q?=E7=8F=BE=E6=80=A7=E5=BC=B7=E5=8C=96=EF=BC=88manifest=20pin?= =?UTF-8?q?=E3=83=BBCI=20smoke=E3=83=BBrun=5Fdir=20=E3=83=98=E3=83=AB?= =?UTF-8?q?=E3=83=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest/preflight に ellphi pin と installed SHA を記録、不一致は hard-fail - CI に ellphi grad API smoke を追加 - tests/test_ellphi_repo_pin.py、scripts/latest_run_dir.sh を追加 - 実験 shell / aggregate の legacy glob を eph_s42 対応 - README / REPRODUCIBILITY / third_party README を fork aware に更新 Co-authored-by: Cursor --- .github/workflows/ruff.yml | 16 ++++ README.md | 7 +- REPRODUCIBILITY.md | 4 +- experiments/aggregate_paper_results.py | 5 +- experiments/ellphi_postfix_autopipeline.sh | 5 +- experiments/evaluate_paper_protocol.py | 2 +- experiments/run_ellphi_tuned_full120_30ep.sh | 4 +- .../run_ellphi_tuned_v2_full120_30ep.sh | 4 +- scripts/latest_run_dir.sh | 33 +++++++ tda_ml/main.py | 4 +- tda_ml/preflight.py | 4 + tda_ml/reproducibility.py | 94 ++++++++++++++++++- tests/test_ellphi_repo_pin.py | 60 ++++++++++++ third_party/README.md | 3 + 14 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 scripts/latest_run_dir.sh create mode 100644 tests/test_ellphi_repo_pin.py diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 6f04c85..441c785 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -40,6 +40,22 @@ jobs: - name: Install dependencies (all groups, incl. dev / ruff) run: uv sync --all-groups --extra experiments --frozen + - name: Ellphi pin and grad API smoke + run: | + uv run python - <<'PY' + from pathlib import Path + from tda_ml.reproducibility import ( + assert_ellphi_differentiable_available, + assert_ellphi_repo_matches_pin, + read_pinned_ellphi_revision, + ) + pin = read_pinned_ellphi_revision(Path.cwd()) + assert pin, "missing third_party/ellphi.ref" + assert_ellphi_repo_matches_pin(project_root=Path.cwd()) + impl = assert_ellphi_differentiable_available(ellphi_differentiable=True) + print(f"ellphi ok pin={pin[:12]} impl={impl}") + PY + - name: Ruff check (repo root; F-1 canonical command) run: uv run ruff check . diff --git a/README.md b/README.md index a093773..ed080bc 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,12 @@ For **topological loss**, the batched distance matrix is built per `model.topolo This project is licensed under the MIT License. See `LICENSE`. -External implementations (for example `ellphi_repo` and `pytorch-topological`) are reference-only and are not redistributed here. +External implementations are reference-only and are not redistributed in this repository. + +- **ellphi (training / differentiable tangency):** requires the pinned fork checked out via `./scripts/ensure_ellphi_repo.sh` (`third_party/ellphi.ref` → `ellphi_repo/`). PyPI `ellphi==0.1.2` alone does **not** include the `ellphi.grad` API used for training. Fork: [koki3070/ellphi](https://github.com/koki3070/ellphi) (based on [t-uda/ellphi](https://github.com/t-uda/ellphi)). +- **pytorch-topological:** [aidos-lab/pytorch-topological](https://github.com/aidos-lab/pytorch-topological) via `./scripts/ensure_pytorch_topological.sh`. + +Upstream references: - `https://github.com/t-uda/ellphi` - `https://github.com/aidos-lab/pytorch-topological` diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 9874ae3..4125fba 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -20,7 +20,9 @@ `torch_topological` は **`pytorch-topological/` の path 依存**(`pyproject.toml`)であり、通常の `git clone` だけではディレクトリが揃いません。コミットは **`third_party/pytorch_topological.ref`** に固定し、**`scripts/ensure_pytorch_topological.sh`** がその ref に checkout します(CI と同じ)。 - `ellphi` も **`ellphi_repo/` の path 依存**です。fork の pin は **`third_party/ellphi.ref`**、取得は **`scripts/ensure_ellphi_repo.sh`**(CI と同じ)。`ellphi_repo/` 自体は git に含めません。 + `ellphi` も **`ellphi_repo/` の path 依存**です。fork の pin は **`third_party/ellphi.ref`**、取得は **`scripts/ensure_ellphi_repo.sh`**(CI と同じ)。`ellphi_repo/` 自体は git に含めません。PyPI の `ellphi==0.1.2` だけでは **`ellphi.grad`(学習用)が不足**するため、clone 後は ensure 必須です。 + + 各 run の `logs/run_manifest.json` には `reproducibility.ellphi_repo` として **pin 済み SHA** と **インストール済み checkout SHA** が記録されます。不一致時は preflight が hard-fail します。 - **PyTorch / CUDA**: 数値結果はデバイスや dtype によって変わり得ます。`tda_ml/main.py` の学習ループを使う場合、有効な設定は各実行の `logs/` 配下の `runtime_profile.json` などに記録されます。 diff --git a/experiments/aggregate_paper_results.py b/experiments/aggregate_paper_results.py index e1b234f..defad9e 100644 --- a/experiments/aggregate_paper_results.py +++ b/experiments/aggregate_paper_results.py @@ -137,8 +137,9 @@ def discover_run_dirs(out_base: Path) -> list[Path]: found.append(run_dir.resolve()) if not found: - for metrics_path in sorted(out_base.glob("backend_ellphi_seed*/logs/paper_metrics_test.json")): - found.append(metrics_path.parent.parent.resolve()) + for pattern in ("eph_s*/logs/paper_metrics_test.json", "backend_ellphi_seed*/logs/paper_metrics_test.json"): + for metrics_path in sorted(out_base.glob(pattern)): + found.append(metrics_path.parent.parent.resolve()) for metrics_path in sorted(out_base.glob("reproduce_*/logs/paper_metrics_test.json")): found.append(metrics_path.parent.parent.resolve()) diff --git a/experiments/ellphi_postfix_autopipeline.sh b/experiments/ellphi_postfix_autopipeline.sh index 07b9db5..449de91 100755 --- a/experiments/ellphi_postfix_autopipeline.sh +++ b/experiments/ellphi_postfix_autopipeline.sh @@ -131,7 +131,10 @@ if progress.exists(): metrics_path = candidate if metrics_path is None: - candidates = sorted(calib.glob("backend_ellphi_seed42_*/logs/metrics.csv")) + candidates = sorted( + list(calib.glob("eph_s42_*/logs/metrics.csv")) + + list(calib.glob("backend_ellphi_seed42_*/logs/metrics.csv")) + ) if candidates: metrics_path = candidates[-1] diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index da5bdb6..da38b9a 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -8,7 +8,7 @@ Usage:: uv run python experiments/evaluate_paper_protocol.py \\ - --run-dir outputs/supervised/20260627/055936_paper_reproduce_1week_tuned/backend_ellphi_seed42_* \\ + --run-dir outputs/supervised/.../eph_s42_ \\ --base-config reproduce \\ --split val diff --git a/experiments/run_ellphi_tuned_full120_30ep.sh b/experiments/run_ellphi_tuned_full120_30ep.sh index 86ccd4c..b14cfc6 100755 --- a/experiments/run_ellphi_tuned_full120_30ep.sh +++ b/experiments/run_ellphi_tuned_full120_30ep.sh @@ -31,8 +31,8 @@ uv run python experiments/run_backend_multiseed.py \ --epochs 30 \ --out-base "$OUT_BASE" -RUN_DIR=$(ls -d "${OUT_BASE}"/backend_ellphi_seed42_* | tail -1) -BASELINE_DIR=$(ls -d "${BASELINE_PARENT}"/backend_ellphi_seed42_* | tail -1) +RUN_DIR=$(scripts/latest_run_dir.sh "$OUT_BASE" eph_s42 backend_ellphi_seed42) +BASELINE_DIR=$(scripts/latest_run_dir.sh "$BASELINE_PARENT" eph_s42 backend_ellphi_seed42) echo "=== evaluate $(date -Iseconds) ===" uv run python experiments/evaluate_topo_checkpoint.py \ diff --git a/experiments/run_ellphi_tuned_v2_full120_30ep.sh b/experiments/run_ellphi_tuned_v2_full120_30ep.sh index 4249f5b..cf97d47 100755 --- a/experiments/run_ellphi_tuned_v2_full120_30ep.sh +++ b/experiments/run_ellphi_tuned_v2_full120_30ep.sh @@ -30,8 +30,8 @@ uv run python experiments/run_backend_multiseed.py \ --epochs 30 \ --out-base "$OUT_BASE" -RUN_DIR=$(ls -d "${OUT_BASE}"/backend_ellphi_seed42_* | tail -1) -BASELINE_DIR=$(ls -d "${BASELINE_PARENT}"/backend_ellphi_seed42_* | tail -1) +RUN_DIR=$(scripts/latest_run_dir.sh "$OUT_BASE" eph_s42 backend_ellphi_seed42) +BASELINE_DIR=$(scripts/latest_run_dir.sh "$BASELINE_PARENT" eph_s42 backend_ellphi_seed42) echo "=== evaluate $(date -Iseconds) ===" uv run python experiments/evaluate_topo_checkpoint.py \ diff --git a/scripts/latest_run_dir.sh b/scripts/latest_run_dir.sh new file mode 100644 index 0000000..09f4760 --- /dev/null +++ b/scripts/latest_run_dir.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Resolve the newest run directory under OUT_BASE for run_paths slug(s). +# +# Usage: +# scripts/latest_run_dir.sh OUT_BASE SLUG [LEGACY_PREFIX...] +# +# Example (backend_ellphi_seed42 → slug eph_s42): +# scripts/latest_run_dir.sh outputs/foo eph_s42 backend_ellphi_seed42 +set -euo pipefail + +OUT_BASE="${1:?OUT_BASE required}" +SLUG="${2:?SLUG required}" +shift 2 +LEGACY=("$@") + +latest="" +for prefix in "$SLUG" "${LEGACY[@]}"; do + shopt -s nullglob + dirs=("${OUT_BASE}/${prefix}_"*) + shopt -u nullglob + if (("${#dirs[@]}")); then + candidate="$(printf '%s\n' "${dirs[@]}" | sort | tail -1)" + if [[ -z "$latest" || "$candidate" > "$latest" ]]; then + latest="$candidate" + fi + fi +done + +if [[ -z "$latest" ]]; then + echo "error: no run directory under ${OUT_BASE} for slug=${SLUG} legacy=${LEGACY[*]:-}" >&2 + exit 1 +fi +echo "$latest" diff --git a/tda_ml/main.py b/tda_ml/main.py index f9556b2..f874ebd 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -98,7 +98,9 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): "preflight_status": "pending", "fallback_status": "none", "fallbacks": [], - "reproducibility": build_reproducibility_manifest_fields(config), + "reproducibility": build_reproducibility_manifest_fields( + config, project_root=default_project_root() + ), } if config.get("evaluation"): manifest["dbscan_eval"] = build_dbscan_eval_manifest_fields(config) diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py index 687b523..3d2c8af 100644 --- a/tda_ml/preflight.py +++ b/tda_ml/preflight.py @@ -11,8 +11,10 @@ RUN_STATUS_NOT_RUN, assert_ellphi_differentiable_available, assert_loss_config_explicit, + assert_ellphi_repo_matches_pin, baseline_grids_from_config, build_dbscan_eval_manifest_fields, + build_ellphi_repo_manifest_fields, dbscan_grid_from_config, write_json, ) @@ -85,6 +87,7 @@ def preflight_training_config( ellphi_diff = bool(topo.get("ellphi_differentiable", True)) if backend == "ellphi": _require_import("ellphi", lambda: __import__("ellphi")) + assert_ellphi_repo_matches_pin(project_root=root) impl = assert_ellphi_differentiable_available(ellphi_differentiable=ellphi_diff) else: impl = backend @@ -111,6 +114,7 @@ def preflight_training_config( "distance_backend": backend, "distance_backend_impl": impl, "seed": config.get("data", {}).get("seed"), + "ellphi_repo": build_ellphi_repo_manifest_fields(root), } if config.get("evaluation"): preview["dbscan_eval"] = build_dbscan_eval_manifest_fields(config) diff --git a/tda_ml/reproducibility.py b/tda_ml/reproducibility.py index 54dfd4b..3b18cba 100644 --- a/tda_ml/reproducibility.py +++ b/tda_ml/reproducibility.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import re +import subprocess from pathlib import Path from typing import Any @@ -16,6 +18,10 @@ DEFAULT_DBSCAN_EPS_LINSPACE = (0.15, 1.5, 15) +ELLPHI_REPO_URL = "https://github.com/koki3070/ellphi.git" +ELLPHI_REF_REL = Path("third_party/ellphi.ref") +_SHA40_RE = re.compile(r"^[0-9a-fA-F]{40}$") + # Skill-aligned run status (see computational-reproducibility failure semantics). RUN_STATUS_NOT_RUN = "not-run" RUN_STATUS_RUNNING = "running" @@ -118,6 +124,87 @@ def record_fallback(manifest: dict[str, Any], name: str, detail: str) -> None: manifest["fallback_status"] = "recorded" +def default_project_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def read_pinned_ellphi_revision(project_root: Path | str | None = None) -> str | None: + """Return pinned SHA from ``third_party/ellphi.ref`` (first non-comment line).""" + root = Path(project_root) if project_root is not None else default_project_root() + ref_file = root / ELLPHI_REF_REL + if not ref_file.is_file(): + return None + for line in ref_file.read_text(encoding="utf-8").splitlines(): + token = line.strip() + if token and not token.startswith("#"): + return token + return None + + +def read_ellphi_repo_head(project_root: Path | str | None = None) -> str | None: + """Return ``ellphi_repo`` HEAD if the directory is a git checkout.""" + root = Path(project_root) if project_root is not None else default_project_root() + repo = root / "ellphi_repo" + git_dir = repo / ".git" + if not git_dir.exists(): + return None + try: + return ( + subprocess.check_output( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + stderr=subprocess.DEVNULL, + text=True, + ) + .strip() + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + + +def assert_ellphi_repo_matches_pin(*, project_root: Path | str | None = None) -> None: + """Hard-fail when ``ellphi_repo`` checkout differs from ``third_party/ellphi.ref``.""" + root = Path(project_root) if project_root is not None else default_project_root() + pinned = read_pinned_ellphi_revision(root) + if pinned is None: + raise FileNotFoundError( + f"Missing ellphi pin file: {root / ELLPHI_REF_REL}. " + "Run ./scripts/ensure_ellphi_repo.sh after clone." + ) + if not _SHA40_RE.fullmatch(pinned): + raise ValueError(f"Invalid ellphi pin (expected 40-char SHA): {pinned!r}") + + installed = read_ellphi_repo_head(root) + if installed is None: + raise FileNotFoundError( + f"ellphi_repo checkout missing under {root / 'ellphi_repo'}. " + "Run ./scripts/ensure_ellphi_repo.sh before training or CI." + ) + if installed != pinned: + raise RuntimeError( + "ellphi_repo HEAD does not match third_party/ellphi.ref: " + f"installed={installed}, pinned={pinned}. " + "Run ./scripts/ensure_ellphi_repo.sh to sync the fork." + ) + + +def build_ellphi_repo_manifest_fields(project_root: Path | str | None = None) -> dict[str, Any]: + """Manifest fields for the pinned ellphi fork (path dependency, not vendored).""" + root = Path(project_root) if project_root is not None else default_project_root() + pinned = read_pinned_ellphi_revision(root) + installed = read_ellphi_repo_head(root) + fields: dict[str, Any] = { + "ellphi_repo_url": ELLPHI_REPO_URL, + "ellphi_repo_pin_file": str(ELLPHI_REF_REL).replace("\\", "/"), + } + if pinned is not None: + fields["ellphi_repo_revision_pinned"] = pinned + if installed is not None: + fields["ellphi_repo_revision_installed"] = installed + if pinned is not None and installed is not None: + fields["ellphi_repo_revision_mismatch"] = installed != pinned + return fields + + def assert_ellphi_differentiable_available(*, ellphi_differentiable: bool) -> str: """Return impl label or raise if differentiable ellphi was requested but unavailable.""" if not ellphi_differentiable: @@ -161,7 +248,11 @@ def baseline_grids_from_config(config: dict[str, Any]) -> dict[str, Any]: } -def build_reproducibility_manifest_fields(config: dict[str, Any]) -> dict[str, Any]: +def build_reproducibility_manifest_fields( + config: dict[str, Any], + *, + project_root: Path | str | None = None, +) -> dict[str, Any]: return { "settings": reproducibility_settings(config), "numerical_eps_module": "tda_ml.numerical_eps", @@ -171,6 +262,7 @@ def build_reproducibility_manifest_fields(config: dict[str, Any]) -> dict[str, A "EIGENVALUE_FLOOR": EIGENVALUE_FLOOR, "INLIER_PROB_MIN": INLIER_PROB_MIN, }, + "ellphi_repo": build_ellphi_repo_manifest_fields(project_root), } diff --git a/tests/test_ellphi_repo_pin.py b/tests/test_ellphi_repo_pin.py new file mode 100644 index 0000000..d211447 --- /dev/null +++ b/tests/test_ellphi_repo_pin.py @@ -0,0 +1,60 @@ +"""Tests for ellphi_repo pin / ensure reproducibility helpers.""" + +from __future__ import annotations + +import subprocess +import unittest +from pathlib import Path + +from tda_ml.reproducibility import ( + ELLPHI_REPO_URL, + assert_ellphi_differentiable_available, + assert_ellphi_repo_matches_pin, + build_ellphi_repo_manifest_fields, + read_ellphi_repo_head, + read_pinned_ellphi_revision, +) + +REPO = Path(__file__).resolve().parents[1] + + +class TestEllphiRepoPin(unittest.TestCase): + def test_ref_file_has_40_char_sha(self) -> None: + pinned = read_pinned_ellphi_revision(REPO) + self.assertIsNotNone(pinned) + self.assertEqual(len(pinned), 40) + self.assertTrue(all(c in "0123456789abcdef" for c in pinned.lower())) + + def test_manifest_fields_include_pin(self) -> None: + fields = build_ellphi_repo_manifest_fields(REPO) + self.assertEqual(fields["ellphi_repo_url"], ELLPHI_REPO_URL) + self.assertIn("ellphi_repo_revision_pinned", fields) + + def test_installed_matches_pin_when_ellphi_repo_present(self) -> None: + if read_ellphi_repo_head(REPO) is None: + self.skipTest("ellphi_repo not checked out") + assert_ellphi_repo_matches_pin(project_root=REPO) + fields = build_ellphi_repo_manifest_fields(REPO) + self.assertFalse(fields.get("ellphi_repo_revision_mismatch", True)) + + def test_grad_api_available_when_ellphi_repo_present(self) -> None: + if read_ellphi_repo_head(REPO) is None: + self.skipTest("ellphi_repo not checked out") + impl = assert_ellphi_differentiable_available(ellphi_differentiable=True) + self.assertEqual(impl, "ellphi_torch_grad") + + def test_ensure_script_is_executable(self) -> None: + script = REPO / "scripts" / "ensure_ellphi_repo.sh" + self.assertTrue(script.is_file()) + mode = script.stat().st_mode + self.assertTrue(mode & 0o111, "ensure_ellphi_repo.sh must be executable") + + def test_ensure_script_idempotent(self) -> None: + script = REPO / "scripts" / "ensure_ellphi_repo.sh" + if read_ellphi_repo_head(REPO) is None: + self.skipTest("ellphi_repo not checked out") + subprocess.run([str(script)], cwd=REPO, check=True, capture_output=True, text=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/third_party/README.md b/third_party/README.md index b8cd2a6..bed301c 100644 --- a/third_party/README.md +++ b/third_party/README.md @@ -13,5 +13,8 @@ Pins the commit checked out under `ellphi_repo/` for the `ellphi` path dependenc - **Fetch / sync:** `./scripts/ensure_ellphi_repo.sh` (used by CI and `README.md`). - **Bump fork:** choose a new commit on [koki3070/ellphi](https://github.com/koki3070/ellphi) (based on [t-uda/ellphi](https://github.com/t-uda/ellphi)), update the SHA in `ellphi.ref`, run the ensure script, then `uv sync` and `uv run pytest`. +- **Long-term:** when differentiable tangency lands upstream, migrate the pin to [t-uda/ellphi](https://github.com/t-uda/ellphi) (or an org mirror) and drop the personal fork URL in `scripts/ensure_ellphi_repo.sh` / `tda_ml/reproducibility.py` together in one commit. + +Run directories from `run_backend_multiseed.py` use slug `eph_s42` (not legacy `backend_ellphi_seed42_*`). Shell helpers: `scripts/latest_run_dir.sh OUT_BASE eph_s42 backend_ellphi_seed42`. `.gitmodules` may list the same repositories; the ref files are the canonical pins when the parent repo does not record submodule gitlinks. From 47c47c1b47781d59a3436288efa825a6cdbdb949 Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:49:00 +0900 Subject: [PATCH 12/44] =?UTF-8?q?feat:=20ellphi=20+=20power=20=E4=BA=8C?= =?UTF-8?q?=E7=9B=AE=E7=9A=84=E3=83=81=E3=83=A5=E3=83=BC=E3=83=8B=E3=83=B3?= =?UTF-8?q?=E3=82=B0=E3=81=A8=2030ep=20=E3=83=9E=E3=83=AB=E3=83=81?= =?UTF-8?q?=E3=82=B7=E3=83=BC=E3=83=89=E6=9C=AC=E7=95=AA=E3=83=91=E3=82=A4?= =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=A4=E3=83=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - チューニング: tune_elongate_wdist.py(val topo W-Dist 最小)/ tune_elongate_mcc.py(val DBSCAN MCC 最大・mahalanobis)と各起動シェル - 本番: run_teacher_local_pca_power_30ep.py(tune JSON の重み固定・30ep・val_topo ckpt・maha paper eval) - マルチシード: run_teacher_local_pca_power_30ep_multiseed.sh(paper seeds 42/123/456/789/1024、完了 seed は skip) - 集計: aggregate_power_30ep_multiseed.py → summary_proposed.csv(mean ± std) - eval: evaluate_paper_protocol.py / evaluate_paper_baselines.py を n100・maha 比較プロトコルに更新 - configs: 本番 base(elongate_n100_no_cls_full120_teacher_local_pca)と tune base(ellphi_power_mcc) - REPRODUCIBILITY.md: 論文比較で使う experiments/ の範囲と source_revision の注意を更新 --- REPRODUCIBILITY.md | 28 +- ...n100_no_cls_full120_teacher_local_pca.yaml | 15 +- ...o_cls_tune_local_pca_ellphi_power_mcc.yaml | 64 ++++ experiments/aggregate_paper_results.py | 8 +- experiments/aggregate_power_30ep_multiseed.py | 194 +++++++++++ experiments/evaluate_paper_baselines.py | 136 ++++++-- experiments/evaluate_paper_protocol.py | 140 +++----- .../run_teacher_local_pca_power_30ep.py | 225 ++++++++++++ ..._teacher_local_pca_power_30ep_multiseed.sh | 115 +++++++ .../run_tune_local_pca_power_mcc_parallel.sh | 89 +++++ .../run_tune_local_pca_power_objectives.sh | 62 ++++ ...run_tune_local_pca_power_wdist_parallel.sh | 83 +++++ experiments/tune_elongate_mcc.py | 322 ++++++++++++++++++ experiments/tune_elongate_wdist.py | 150 +++++--- 14 files changed, 1458 insertions(+), 173 deletions(-) create mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml create mode 100644 experiments/aggregate_power_30ep_multiseed.py create mode 100644 experiments/run_teacher_local_pca_power_30ep.py create mode 100755 experiments/run_teacher_local_pca_power_30ep_multiseed.sh create mode 100755 experiments/run_tune_local_pca_power_mcc_parallel.sh create mode 100755 experiments/run_tune_local_pca_power_objectives.sh create mode 100755 experiments/run_tune_local_pca_power_wdist_parallel.sh create mode 100644 experiments/tune_elongate_mcc.py diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 4125fba..37ca352 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -4,8 +4,24 @@ ## リポジトリに含まれる範囲(目安) -- **含む:** `tda_ml/`、**`configs/` 直下の正本 YAML**(`base.yaml` と `reproduce` / `dev` / `prod` / `test_fast` の各ファイル)、`tests/`、追跡されている `scripts/`、`experiments/run_backend_multiseed.py`、`experiments/issue59_verify_mahalanobis.py`、および `README.md` / `REPRODUCIBILITY.md` / `pyproject.toml` / `uv.lock` / `LICENSE` / `CITATION.cff` などのメタデータ。 -- **含めない:** `docs/` 以下、`configs/archive/`(履歴用 YAML を置く場合は **ローカルのみ**)、`outputs/`、`data/` など。`load_config("archive/...")` は、手元に `configs/archive/*.yaml` を置いた場合にのみ使えます。 +- **含む:** `tda_ml/`、**`configs/` 直下の正本 YAML**(`base.yaml` と `reproduce` / `dev` / `prod` / `test_fast`、および論文比較用の `elongate_n100_no_cls_*`)、`tests/`、追跡されている `scripts/`、論文・再現用 `experiments/`(下記)、および `README.md` / `REPRODUCIBILITY.md` / `pyproject.toml` / `uv.lock` / `LICENSE` / `CITATION.cff` などのメタデータ。 +- **含めない:** `docs/` 以下(**ローカル実験メモ**;公開方針で git に入れる場合は別途決定)、`configs/archive/`(履歴用 YAML を置く場合は **ローカルのみ**)、`outputs/`、`data/`、`.cursor/` など。`load_config("archive/...")` は、手元に `configs/archive/*.yaml` を置いた場合にのみ使えます。 + +### 論文比較(ellphi + power 二目的)で使う `experiments/`(2026-07-12) + +**現在の主 run:** W-Dist tune 重みの 30ep 5-seed(`run_teacher_local_pca_power_30ep_multiseed.sh wdist`)。 + +| 区分 | パス | +|------|------| +| 本番 5-seed(計算中) | `run_teacher_local_pca_power_30ep_multiseed.sh`, `run_teacher_local_pca_power_30ep.py`, `aggregate_power_30ep_multiseed.py` | +| paper eval | `evaluate_paper_protocol.py` | +| ベースライン | `evaluate_paper_baselines.py` | +| チューニング(重みの出所) | `tune_elongate_wdist.py`, `tune_elongate_mcc.py`, `run_tune_local_pca_power_*` | +| 補助 | `launch_detached_screen.sh` | + +プロトコル・ソース一覧の詳細: ローカル `docs/experiments/20260710_power_dual_objective.md`(§使用ソースコード)、公開前添削: `docs/experiments/20260712_publication_scope.md`。 + +**実行記録:** 各 run の `source_revision`(git HEAD)は `logs/run_manifest.json` および `paper_metrics_*.json` に記録。未コミットのまま実行した場合、リモート clone では数値が再現できない。 ## 環境 @@ -116,6 +132,14 @@ uv run python experiments/run_backend_multiseed.py \ したがって、`run_backend_multiseed.py` で同じ YAML を回しても、**位相損失が見ている距離空間はバックエンド間で同一ではありません**。ここでは「同一のデータ・スケジュール・設定表面での再現パイプライン比較」を意図しており、**両バックエンドが数学的に完全に同型の重み付き距離目的関数を共有する**という読み方はしません。`ellphi` 側に Mahalanobis の確率重みに相当する項を無理に足す予定はなく、比較の解釈は本節および `README.md` の英語節(*Backend comparison: outlier-probability weighting*)に従ってください。 +### ellphi + power:二目的チューニング(実験メモ) + +no_cls・local_pca 教師・`size_mode=power` スタックについて、**W-Dist 最小**と **DBSCAN MCC 最大**の 2 本の Optuna study、および各 best 重みでの 30ep 本番結果は、次にまとめています。 + +- [`docs/experiments/20260710_power_dual_objective.md`](docs/experiments/20260710_power_dual_objective.md) — 各段階の**目的**、距離 backend の使い分け、数値表、再現コマンド、成果物パス + +要点: **学習 topo loss と教師 PD は ellphi**;**MCC のチューニング objective と paper eval の DBSCAN は mahalanobis**(filtration 時刻をクラスタリング距離に使わない)。 + ## 教師あり学習の目的関数(論文 Methods 用) 本線 `tda_ml/` の学習は **点ラベル BCE** と **clean 点群の $H_1$ 持久図との Wasserstein 教師** を併用します(`configs/reproduce.yaml` 系)。 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml index 2ba018d..c37f0d7 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml @@ -1,5 +1,5 @@ # Ablation: paper §3.3.2 teacher (local PCA ideal ellipses + same backend as prediction). -# Source: /home/murata/TDA-ML/outputs/tune_elongate_ellphi_local_pca_v3/best_elongate_wdist_ellphi.json (v3 val_topo ckpt, val topo W-Dist=0.03465) +# Loss weights: baseline (maha-tune v1 anchors). v3 topo-only tune (w_size≈0) hurt DBSCAN MCC — reverted 2026-07-06. # Switch teacher: loss.teacher_mode = euclidean | local_pca meta: @@ -18,20 +18,25 @@ model: loss: w_class: 0.0 - w_topo: 0.3961 # tuned ellphi v2 - w_aniso: 0.094 - w_size: 0.005 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 pos_weight: 1.0 aniso_mode: "elongate" + size_mode: power + size_ref: 1.34 + size_power: 1.5 teacher_mode: local_pca teacher_local_pca_k: 10 training: - lr: 0.00093 # tuned ellphi v2 + lr: 0.000158 epochs: 30 grad_clip_value: 1.0 visualize_every: 10 warmup_epochs: 0 + # Train ckpt: val_topo (same TopologicalLoss as training; no per-epoch DBSCAN grid). + # DBSCAN grid + MCC: once after training via evaluate_paper_protocol.py (val → test). selection: metric: val_topo eval_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml new file mode 100644 index 0000000..dd65b47 --- /dev/null +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml @@ -0,0 +1,64 @@ +# Optuna tune: local_pca + ellphi + size_mode=power. +# Train ckpt: val_topo (fast). Trial objective: one val DBSCAN grid → MCC max. + +meta: + config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden per trial (--backend ellphi) + ellphi_differentiable: true + prob_weighting: false + +loss: + w_class: 0.0 + w_topo: 0.0883 # maha anchors as Optuna prior centre; search in tune script + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: "elongate" + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 20 + grad_clip_value: 1.0 + visualize_every: 10000 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/experiments/aggregate_paper_results.py b/experiments/aggregate_paper_results.py index defad9e..a0e6054 100644 --- a/experiments/aggregate_paper_results.py +++ b/experiments/aggregate_paper_results.py @@ -137,11 +137,13 @@ def discover_run_dirs(out_base: Path) -> list[Path]: found.append(run_dir.resolve()) if not found: - for pattern in ("eph_s*/logs/paper_metrics_test.json", "backend_ellphi_seed*/logs/paper_metrics_test.json"): + for pattern in ( + "eph_s*/logs/paper_metrics_test.json", + "backend_ellphi_seed*/logs/paper_metrics_test.json", + "reproduce_*/logs/paper_metrics_test.json", + ): for metrics_path in sorted(out_base.glob(pattern)): found.append(metrics_path.parent.parent.resolve()) - for metrics_path in sorted(out_base.glob("reproduce_*/logs/paper_metrics_test.json")): - found.append(metrics_path.parent.parent.resolve()) # Deduplicate while preserving order seen: set[Path] = set() diff --git a/experiments/aggregate_power_30ep_multiseed.py b/experiments/aggregate_power_30ep_multiseed.py new file mode 100644 index 0000000..b221d8f --- /dev/null +++ b/experiments/aggregate_power_30ep_multiseed.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Aggregate multiseed 30ep proposed runs (W-Dist / MCC tune weights) for paper comparison.""" + +from __future__ import annotations + +import argparse +import csv +import json +from pathlib import Path +from typing import Any, Sequence + +import numpy as np + +from tda_ml.supervised_diagnostics import git_revision + +REPO_ROOT = Path(__file__).resolve().parents[1] +PAPER_SEEDS = [42, 123, 456, 789, 1024] + +MCC_KEYS = ("mcc", "test_mcc") +GMEAN_KEYS = ("gmean", "g_mean", "test_gmean") +WDIST_KEYS = ("wdist", "w_dist", "test_wdist") + + +def sample_std(values: Sequence[float]) -> float: + arr = np.asarray(values, dtype=np.float64) + if arr.size < 2: + return 0.0 + return float(np.std(arr, ddof=1)) + + +def _first_key(payload: dict[str, Any], keys: Sequence[str], *, label: str) -> float: + for key in keys: + if key in payload and payload[key] is not None: + return float(payload[key]) + raise KeyError(f"Missing {label}; keys={sorted(payload)}") + + +def discover_seed_metrics(out_base: Path, test_glob: str) -> dict[int, dict[str, Any]]: + by_seed: dict[int, dict[str, Any]] = {} + for metrics_path in sorted(out_base.glob(test_glob)): + run_dir = metrics_path.parent.parent + manifest_path = run_dir / "logs" / "run_manifest.json" + seed: int | None = None + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("seed") is not None: + seed = int(manifest["seed"]) + if seed is None: + slug = run_dir.name + if slug.startswith("pwr_s"): + seed = int(slug.split("_")[1].replace("s", "")) + if seed is None: + continue + payload = json.loads(metrics_path.read_text(encoding="utf-8")) + by_seed[seed] = { + "seed": seed, + "run_dir": str(run_dir), + "metrics_path": str(metrics_path), + "mcc": _first_key(payload, MCC_KEYS, label="mcc"), + "gmean": _first_key(payload, GMEAN_KEYS, label="gmean"), + "wdist": _first_key(payload, WDIST_KEYS, label="wdist"), + "recall": float(payload["recall"]) if payload.get("recall") is not None else None, + } + return by_seed + + +def aggregate_row( + *, + method: str, + by_seed: dict[int, dict[str, Any]], + expected_seeds: Sequence[int], + tune_json: str, +) -> tuple[dict[str, Any], list[str]]: + warnings: list[str] = [] + missing = [s for s in expected_seeds if s not in by_seed] + if missing: + warnings.append(f"{method}: missing seeds {missing}") + seeds_present = [by_seed[s] for s in expected_seeds if s in by_seed] + if not seeds_present: + raise ValueError(f"No metrics for {method} under expected seeds") + + mccs = [r["mcc"] for r in seeds_present] + gmeans = [r["gmean"] for r in seeds_present] + wdist = [r["wdist"] for r in seeds_present] + n = len(seeds_present) + return { + "method": method, + "mcc_mean": float(np.mean(mccs)), + "mcc_std": sample_std(mccs), + "gmean_mean": float(np.mean(gmeans)), + "gmean_std": sample_std(gmeans), + "wdist_mean": float(np.mean(wdist)), + "wdist_std": sample_std(wdist), + "notes": ( + f"{n}/{len(expected_seeds)} seeds; fixed tune weights from {tune_json}; " + "30ep val_topo ckpt; maha DBSCAN eval" + ), + }, warnings + + +def write_csv(path: Path, rows: list[dict[str, Any]]) -> None: + fields = [ + "method", + "mcc_mean", + "mcc_std", + "gmean_mean", + "gmean_std", + "wdist_mean", + "wdist_std", + "notes", + ] + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=fields) + writer.writeheader() + for row in rows: + writer.writerow({k: row.get(k, "") for k in fields}) + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--wdist-out", type=Path, default=REPO_ROOT / "outputs/supervised/0710_pwr30_wdist") + p.add_argument("--mcc-out", type=Path, default=REPO_ROOT / "outputs/supervised/0710_pwr30_mcc_maha") + p.add_argument( + "--wdist-tune-json", + default="outputs/tune/0709_pwr_wdist/best_elongate_wdist_ellphi.json", + ) + p.add_argument( + "--mcc-tune-json", + default="outputs/tune/0709_pwr_mcc_dbscan_mahalanobis/best_elongate_mcc_ellphi_dbscan_mahalanobis.json", + ) + p.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs/supervised/0710_pwr30_multiseed") + p.add_argument("--seeds", type=int, nargs="+", default=PAPER_SEEDS) + return p.parse_args() + + +def main() -> int: + args = parse_args() + wdist_by_seed = discover_seed_metrics( + args.wdist_out, + "pwr_s*/logs/paper_metrics_test_power_wdist_valtopo_paper_eval.json", + ) + mcc_by_seed = discover_seed_metrics( + args.mcc_out, + "pwr_s*/logs/paper_metrics_test_power_mcc_valtopo_paper_eval.json", + ) + + rows: list[dict[str, Any]] = [] + all_warnings: list[str] = [] + for method, by_seed, tune_json in ( + ("proposed_wdist_tune_30ep", wdist_by_seed, args.wdist_tune_json), + ("proposed_mcc_tune_30ep", mcc_by_seed, args.mcc_tune_json), + ): + row, warnings = aggregate_row( + method=method, + by_seed=by_seed, + expected_seeds=args.seeds, + tune_json=tune_json, + ) + rows.append(row) + all_warnings.extend(warnings) + + out_dir = args.out_dir + out_dir.mkdir(parents=True, exist_ok=True) + summary_path = out_dir / "summary_proposed.csv" + write_csv(summary_path, rows) + + manifest = { + "source_revision": git_revision(REPO_ROOT), + "seeds_expected": list(args.seeds), + "wdist_out": str(args.wdist_out), + "mcc_out": str(args.mcc_out), + "per_seed": { + "wdist": wdist_by_seed, + "mcc": mcc_by_seed, + }, + "warnings": all_warnings, + "summary_csv": str(summary_path), + } + (out_dir / "MANIFEST_proposed.json").write_text(json.dumps(manifest, indent=2) + "\n") + + for w in all_warnings: + print(f"WARNING: {w}") + for row in rows: + print( + f"{row['method']}: MCC={row['mcc_mean']:.4f}±{row['mcc_std']:.4f} " + f"G-Mean={row['gmean_mean']:.4f}±{row['gmean_std']:.4f}" + ) + print(f"Wrote {summary_path}") + return 0 if not all_warnings else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/evaluate_paper_baselines.py b/experiments/evaluate_paper_baselines.py index 3deb7aa..b3c45c8 100644 --- a/experiments/evaluate_paper_baselines.py +++ b/experiments/evaluate_paper_baselines.py @@ -46,17 +46,20 @@ from tda_ml.config import deep_update, load_config from tda_ml.metrics import compute_recall_specificity_gmean_mcc_wdist from tda_ml.numerical_eps import EIGENVALUE_FLOOR, PCA_RIDGE_EPS +from tda_ml.preflight import preflight_baseline_eval +from tda_ml.reproducibility import ( + RUN_STATUS_COMPLETED, + baseline_grids_from_config, + record_fallback, + reproducibility_settings, + write_json, +) from tda_ml.supervised_diagnostics import git_revision REPO_ROOT = Path(__file__).resolve().parents[1] PAPER_SEEDS = [42, 123, 456, 789, 1024] LOCAL_PCA_K = 10 -DEFAULT_EPS_VALUES = list(np.linspace(0.15, 1.5, 15)) -DEFAULT_MIN_SAMPLES_VALUES = [3, 5, 7, 10, 15] -DEFAULT_CONTAMINATION_VALUES = [0.05, 0.07, 0.09, 0.11, 0.13, 0.15, 0.20] -DEFAULT_LOF_N_NEIGHBORS = [5, 10, 15, 20, 30] - @dataclass class CloudSample: @@ -221,6 +224,8 @@ def grid_search_clouds( param_combos: Sequence[dict[str, Any]], *, desc: str, + allow_skip_degenerate_grid_cells: bool = False, + manifest_ref: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], float, list[dict[str, Any]]]: best_mcc = -1.0 best_params = dict(param_combos[0]) @@ -232,15 +237,30 @@ def grid_search_clouds( for cloud in clouds: try: per_cloud.append(evaluate_fn(cloud, **params)) - except Exception as exc: # noqa: BLE001 — skip invalid hparam combos - error = str(exc) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" per_cloud = [] break if error is not None: - grid_log.append({**params, "error": error}) - continue + entry = {**params, "status": "failed", "error": error} + grid_log.append(entry) + if allow_skip_degenerate_grid_cells: + if manifest_ref is not None: + record_fallback( + manifest_ref, + "baseline_grid_cell_skip", + f"{desc} {params}: {error}", + ) + continue + raise RuntimeError( + f"Baseline grid cell failed ({desc} {params}): {error}. " + "Set reproducibility.allow_skip_degenerate_grid_cells=true to opt in " + "to skipping failed cells." + ) _, _, _, mcc, _ = _aggregate_cloud_metrics(per_cloud) - grid_log.append({**params, "mean_mcc": mcc, "n_clouds": len(per_cloud)}) + grid_log.append( + {**params, "status": "ok", "mean_mcc": mcc, "n_clouds": len(per_cloud)} + ) if mcc > best_mcc: best_mcc = mcc best_params = dict(params) @@ -286,6 +306,8 @@ def evaluate_method_on_seed( min_samples_values: Sequence[int], contamination_values: Sequence[float], lof_n_neighbors_values: Sequence[int], + allow_skip_degenerate_grid_cells: bool = False, + manifest_ref: dict[str, Any] | None = None, ) -> tuple[SeedResult, list[dict[str, Any]]]: val_clouds = load_clouds(config, "val", device) test_clouds = load_clouds(config, "test", device) @@ -297,6 +319,8 @@ def evaluate_method_on_seed( evaluate_euclidean_dbscan, combos, desc=f"seed{seed} euclidean_dbscan val", + allow_skip_degenerate_grid_cells=allow_skip_degenerate_grid_cells, + manifest_ref=manifest_ref, ) test_fn: Callable[..., CloudMetrics] = evaluate_euclidean_dbscan elif method == "isolation_forest": @@ -309,6 +333,8 @@ def evaluate_method_on_seed( evaluate_isolation_forest, combos, desc=f"seed{seed} isolation_forest val", + allow_skip_degenerate_grid_cells=allow_skip_degenerate_grid_cells, + manifest_ref=manifest_ref, ) test_fn = evaluate_isolation_forest elif method == "lof": @@ -318,6 +344,8 @@ def evaluate_method_on_seed( evaluate_lof, combos, desc=f"seed{seed} lof val", + allow_skip_degenerate_grid_cells=allow_skip_degenerate_grid_cells, + manifest_ref=manifest_ref, ) test_fn = evaluate_lof elif method == "adbscan": @@ -327,6 +355,8 @@ def evaluate_method_on_seed( evaluate_adbscan, combos, desc=f"seed{seed} adbscan val", + allow_skip_degenerate_grid_cells=allow_skip_degenerate_grid_cells, + manifest_ref=manifest_ref, ) test_fn = evaluate_adbscan else: @@ -424,10 +454,34 @@ def parse_args() -> argparse.Namespace: choices=METHOD_ORDER, default=METHOD_ORDER, ) - p.add_argument("--eps-values", type=float, nargs="+", default=DEFAULT_EPS_VALUES) - p.add_argument("--min-samples-values", type=int, nargs="+", default=DEFAULT_MIN_SAMPLES_VALUES) - p.add_argument("--contamination-values", type=float, nargs="+", default=DEFAULT_CONTAMINATION_VALUES) - p.add_argument("--lof-n-neighbors", type=int, nargs="+", default=DEFAULT_LOF_N_NEIGHBORS) + p.add_argument( + "--eps-values", + type=float, + nargs="+", + default=None, + help="Override DBSCAN eps grid (default: evaluation.dbscan.eps_values from config).", + ) + p.add_argument( + "--min-samples-values", + type=int, + nargs="+", + default=None, + help="Override DBSCAN min_samples grid (default: config).", + ) + p.add_argument( + "--contamination-values", + type=float, + nargs="+", + default=None, + help="Override IF/LOF contamination grid (default: evaluation.baselines).", + ) + p.add_argument( + "--lof-n-neighbors", + type=int, + nargs="+", + default=None, + help="Override LOF n_neighbors grid (default: config).", + ) return p.parse_args() @@ -435,9 +489,35 @@ def main() -> int: args = parse_args() out_dir = args.out_dir.resolve() out_dir.mkdir(parents=True, exist_ok=True) + + cfg = load_config(args.base_config, project_root=REPO_ROOT) + preflight_baseline_eval(cfg, project_root=REPO_ROOT, out_dir=out_dir) + grids = baseline_grids_from_config(cfg) + rep = reproducibility_settings(cfg) + manifest_ref: dict[str, Any] = { + "fallback_status": "none", + "fallbacks": [], + } + + eps_values = args.eps_values if args.eps_values is not None else grids["eps_values"] + min_samples_values = ( + args.min_samples_values + if args.min_samples_values is not None + else grids["min_samples_values"] + ) + contamination_values = ( + args.contamination_values + if args.contamination_values is not None + else grids["contamination_values"] + ) + lof_n_neighbors = ( + args.lof_n_neighbors if args.lof_n_neighbors is not None else grids["lof_n_neighbors"] + ) + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") manifest = { + "run_status": RUN_STATUS_COMPLETED, "source_revision": git_revision(REPO_ROOT), "base_config": args.base_config, "seeds": list(args.seeds), @@ -446,12 +526,15 @@ def main() -> int: "metrics": "compute_recall_specificity_gmean_mcc_wdist", "local_pca_k": LOCAL_PCA_K, "grids": { - "eps_values": list(args.eps_values), - "min_samples_values": list(args.min_samples_values), - "contamination_values": list(args.contamination_values), - "lof_n_neighbors": list(args.lof_n_neighbors), + "eps_values": list(eps_values), + "min_samples_values": list(min_samples_values), + "contamination_values": list(contamination_values), + "lof_n_neighbors": list(lof_n_neighbors), }, + "reproducibility": rep, + "fallback_status": manifest_ref["fallback_status"], } + write_json(out_dir / "MANIFEST_baselines.json", manifest) all_seed_results: dict[str, list[SeedResult]] = {m: [] for m in args.methods} @@ -467,10 +550,12 @@ def main() -> int: config, seed, device, - eps_values=args.eps_values, - min_samples_values=args.min_samples_values, - contamination_values=args.contamination_values, - lof_n_neighbors_values=args.lof_n_neighbors, + eps_values=eps_values, + min_samples_values=min_samples_values, + contamination_values=contamination_values, + lof_n_neighbors_values=lof_n_neighbors, + allow_skip_degenerate_grid_cells=rep["allow_skip_degenerate_grid_cells"], + manifest_ref=manifest_ref, ) all_seed_results[method].append(result) @@ -496,11 +581,12 @@ def main() -> int: manifest["summary_csv"] = str(summary_path) manifest["per_seed_json"] = str(out_dir / "seed{seed}/{method}.json") - manifest_path = out_dir / "MANIFEST_baselines.json" - manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") + manifest["fallback_status"] = manifest_ref.get("fallback_status", "none") + manifest["fallbacks"] = manifest_ref.get("fallbacks", []) + write_json(out_dir / "MANIFEST_baselines.json", manifest) print(f"\nWrote {summary_path}") - print(f"Wrote {manifest_path}") + print(f"Wrote {out_dir / 'MANIFEST_baselines.json'}") for row in summary_rows: print( f" {row['method']}: MCC={row['mcc_mean']:.4f}±{row['mcc_std']:.4f} " diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index da38b9a..87dde4d 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -23,7 +23,7 @@ import json from dataclasses import asdict, dataclass from pathlib import Path -from typing import Any, Iterator +from typing import Any import numpy as np import torch @@ -31,9 +31,11 @@ from tda_ml.checkpoint_io import extract_model_state_dict, load_torch_checkpoint from tda_ml.config import deep_update, load_config, model_kwargs_from_config from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader -from tda_ml.dbscan import apply_anisotropic_dbscan +from tda_ml.dbscan_eval import evaluate_model_grid, iter_cloud_predictions from tda_ml.metrics import compute_recall_specificity_gmean_mcc_wdist from tda_ml.models import AnisotropicOutlierClassifier +from tda_ml.preflight import preflight_paper_eval_run_dir +from tda_ml.reproducibility import reproducibility_settings from tda_ml.seed_utils import set_global_seed from tda_ml.supervised_diagnostics import git_revision from tda_ml.topo_wdist import TopoWdistOptions, topo_wdist_options_from_config @@ -86,6 +88,8 @@ def evaluate_cloud_dbscan( metric: str = "max", topo_options: TopoWdistOptions | None = None, ) -> CloudMetrics: + from tda_ml.dbscan import apply_anisotropic_dbscan + db_labels = apply_anisotropic_dbscan( points, params, @@ -183,88 +187,6 @@ def load_model_from_run( return model -def iter_cloud_predictions( - model: AnisotropicOutlierClassifier, - loader, - device: torch.device, -) -> Iterator[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]: - with torch.no_grad(): - for data, labels, clean_pc in loader: - data = data.to(device, non_blocking=True) - _, params = model(data) - data_np = data.cpu().numpy() - params_np = params.cpu().numpy() - labels_np = labels.cpu().numpy() - clean_np = clean_pc.cpu().numpy() - batch_size = data_np.shape[0] - for b in range(batch_size): - yield ( - data_np[b], - params_np[b], - labels_np[b], - clean_np[b], - ) - - -def grid_search_dbscan( - clouds: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], - *, - eps_values: list[float], - min_samples_values: list[int], - backend: str = "ellphi", - topo_options: TopoWdistOptions | None = None, -) -> tuple[float, int, float]: - best_mcc = -1.0 - best_eps = eps_values[0] - best_min_samples = min_samples_values[0] - grid_log: list[dict[str, Any]] = [] - - for eps in eps_values: - for min_samples in min_samples_values: - per_cloud: list[CloudMetrics] = [] - for points, params, labels_gt, clean_pc in clouds: - try: - m = evaluate_cloud_dbscan( - points, - params, - labels_gt, - clean_pc, - eps=eps, - min_samples=min_samples, - backend=backend, - topo_options=topo_options, - ) - per_cloud.append(m) - except Exception as exc: # noqa: BLE001 — log and skip bad hparams - grid_log.append( - { - "eps": eps, - "min_samples": min_samples, - "error": str(exc), - } - ) - per_cloud = [] - break - if not per_cloud: - continue - _, _, _, mcc, _ = _aggregate_cloud_metrics(per_cloud) - grid_log.append( - { - "eps": eps, - "min_samples": min_samples, - "mean_mcc": mcc, - "n_clouds": len(per_cloud), - } - ) - if mcc > best_mcc: - best_mcc = mcc - best_eps = eps - best_min_samples = min_samples - - if best_mcc < 0: - raise RuntimeError(f"DBSCAN grid search failed for all hparams; log={grid_log[:5]}") - return best_eps, best_min_samples, best_mcc - def evaluate_split( run_dir: Path, @@ -278,26 +200,48 @@ def evaluate_split( min_samples_values: list[int] | None = None, backend: str = "ellphi", checkpoint_name: str = "best_model.pth", + tag: str | None = None, ) -> SplitMetrics: loader = build_split_loader(config, split, device) model = load_model_from_run(run_dir, config, device, checkpoint_name=checkpoint_name) - clouds = list(iter_cloud_predictions(model, loader, device)) topo_options = topo_wdist_options_from_config(config) + rep = reproducibility_settings(config) + log_dir = run_dir / "logs" + tag_suffix = f"_{tag}" if tag else "" if split == "val": - eps_values = eps_values or list(np.linspace(0.15, 1.5, 15)) - min_samples_values = min_samples_values or [3, 5, 7, 10, 15] - eps, min_samples, _ = grid_search_dbscan( - clouds, + grid = evaluate_model_grid( + model, + loader, + device, + config=config, + backend=backend, eps_values=eps_values, min_samples_values=min_samples_values, - backend=backend, + objective="mcc", topo_options=topo_options, + allow_skip_degenerate_grid_cells=rep["allow_skip_degenerate_grid_cells"], + grid_log_path=log_dir / f"dbscan_grid_log_val{tag_suffix}.json", + ) + eps = grid.eps + min_samples = grid.min_samples + return SplitMetrics( + split=split, + n_clouds=grid.n_clouds, + recall=grid.recall, + specificity=grid.specificity, + gmean=grid.gmean, + mcc=grid.mcc, + wdist=grid.wdist, + dbscan_eps=float(eps), + dbscan_min_samples=int(min_samples), + backend=backend, ) - else: - if eps is None or min_samples is None: - raise ValueError("test split requires eps and min_samples (from val tuning)") + if eps is None or min_samples is None: + raise ValueError("test split requires eps and min_samples (from val tuning)") + + clouds = list(iter_cloud_predictions(model, loader, device)) per_cloud: list[CloudMetrics] = [] for points, params, labels_gt, clean_pc in clouds: per_cloud.append( @@ -357,14 +301,14 @@ def parse_args() -> argparse.Namespace: type=float, nargs="+", default=None, - help="Override DBSCAN eps grid for val grid search (default linspace(0.15,1.5,15)).", + help="Override DBSCAN eps grid (default: evaluation.dbscan.eps_values from config).", ) p.add_argument( "--min-samples-values", type=int, nargs="+", default=None, - help="Override DBSCAN min_samples grid for val grid search (default 3 5 7 10 15).", + help="Override DBSCAN min_samples grid (default: evaluation.dbscan from config).", ) p.add_argument( "--tag", @@ -388,8 +332,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() run_dir = args.run_dir.resolve() - if not run_dir.is_dir(): - raise SystemExit(f"run-dir not found: {run_dir}") + preflight_paper_eval_run_dir(run_dir, checkpoint_name=args.checkpoint_name) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") config = load_run_config(run_dir, args.base_config, args.seed) @@ -401,6 +344,8 @@ def main() -> int: if args.split == "test": if args.dbscan_hparams is None: args.dbscan_hparams = run_dir / "logs" / hparams_name + if not args.dbscan_hparams.is_file(): + raise FileNotFoundError(f"DBSCAN hparams JSON not found: {args.dbscan_hparams}") hparams = json.loads(args.dbscan_hparams.read_text()) eps = float(hparams["eps"]) min_samples = int(hparams["min_samples"]) @@ -416,6 +361,7 @@ def main() -> int: min_samples_values=args.min_samples_values, backend=args.backend, checkpoint_name=args.checkpoint_name, + tag=args.tag, ) log_dir = run_dir / "logs" diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_teacher_local_pca_power_30ep.py new file mode 100644 index 0000000..38747dd --- /dev/null +++ b/experiments/run_teacher_local_pca_power_30ep.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""30ep full run: recover loss weights + size_mode=power + val_topo ckpt + paper eval.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from tda_ml.config import deep_update, load_config # noqa: E402 +from tda_ml.main import main as train_main # noqa: E402 +from tda_ml.preflight import preflight_tune_production_run # noqa: E402 + +BASE_CONFIG = "elongate_n100_no_cls_full120_teacher_local_pca" +DEFAULT_OUT = REPO_ROOT / "outputs/supervised/0709_pwr30" +TAG = "power_valtopo_paper_eval" +TAG_MCC = "power_mcc_valtopo_paper_eval" +TAG_WDIST = "power_wdist_valtopo_paper_eval" + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--epochs", type=int, default=30) + p.add_argument("--seed", type=int, default=42) + p.add_argument("--size-ref", type=float, default=1.34) + p.add_argument("--size-power", type=float, default=1.5) + p.add_argument("--out-base", type=Path, default=None) + p.add_argument( + "--tune-json", + type=Path, + default=None, + help="Optuna best JSON (overrides w_topo/w_aniso/w_size/lr).", + ) + p.add_argument( + "--tag", + type=str, + default=None, + help="Paper eval output tag (default: inferred from tune JSON objective).", + ) + p.add_argument( + "--dbscan-backend", + type=str, + default="mahalanobis", + choices=["ellphi", "mahalanobis"], + help="DBSCAN distance for paper eval (default: mahalanobis).", + ) + p.add_argument("--skip-eval", action="store_true") + return p.parse_args() + + +def load_tune_weights(path: Path) -> dict[str, float]: + if not path.is_file(): + raise FileNotFoundError(f"Tune JSON not found: {path}") + payload = json.loads(path.read_text(encoding="utf-8")) + params = payload["best_params"] + return { + "w_topo": float(params["w_topo"]), + "w_aniso": float(params["w_aniso"]), + "w_size": float(params["w_size"]), + "lr": float(params["lr"]), + } + + +def infer_tag(tune_json: Path | None, explicit: str | None) -> str: + if explicit: + return explicit + if tune_json is None: + return TAG + payload = json.loads(tune_json.read_text(encoding="utf-8")) + objective = str(payload.get("objective", "")) + if "wdist" in objective: + return TAG_WDIST + if "mcc" in objective: + return TAG_MCC + return TAG_MCC + + +def main() -> int: + args = parse_args() + tune_json = args.tune_json + tag = infer_tag(tune_json, args.tag) + out_base = args.out_base + if out_base is None: + out_base = ( + REPO_ROOT / "outputs/supervised/0709_pwr30_mcc" + if tune_json is not None + else DEFAULT_OUT + ) + out_base.mkdir(parents=True, exist_ok=True) + + if tune_json is not None: + preflight_tune_production_run( + base_config=BASE_CONFIG, + tune_json=tune_json, + project_root=REPO_ROOT, + out_base=out_base, + ) + + cfg = load_config(BASE_CONFIG, project_root=REPO_ROOT) + loss_overrides: dict = { + "size_mode": "power", + "size_ref": args.size_ref, + "size_power": args.size_power, + } + training_overrides: dict = {"epochs": args.epochs} + tune_source = None + if tune_json is not None: + tune_weights = load_tune_weights(tune_json) + loss_overrides.update( + { + "w_topo": tune_weights["w_topo"], + "w_aniso": tune_weights["w_aniso"], + "w_size": tune_weights["w_size"], + } + ) + training_overrides["lr"] = tune_weights["lr"] + tune_source = str(tune_json) + + cfg = deep_update( + cfg, + { + "meta": { + "config_id": f"teacher_local_pca_power_seed{args.seed}", + "run_slug": f"pwr_s{args.seed}", + }, + "loss": loss_overrides, + "training": training_overrides, + "data": {"seed": args.seed}, + "model": { + "topology_loss": { + "distance_backend": "ellphi", + "prob_weighting": False, + } + }, + "outputs": {"base_dir": str(out_base)}, + }, + ) + + purpose = { + "tag": tag, + "size_mode": cfg["loss"].get("size_mode", "power"), + "size_ref": cfg["loss"].get("size_ref", args.size_ref), + "size_power": cfg["loss"].get("size_power", args.size_power), + "weights": { + "w_topo": cfg["loss"]["w_topo"], + "w_aniso": cfg["loss"]["w_aniso"], + "w_size": cfg["loss"]["w_size"], + "lr": cfg["training"]["lr"], + }, + "selection": cfg["training"]["selection"]["metric"], + "tune_json": tune_source, + "dbscan_backend": args.dbscan_backend, + "reference": tune_source or "recover baseline (~0.76 test MCC, quadratic size)", + } + cfg["_manifest_extras"] = { + "tune_json": tune_source, + "tune_objective": purpose.get("tune_json") + and json.loads(Path(tune_source).read_text(encoding="utf-8")).get("objective"), + "checkpoint_selection": cfg["training"]["selection"]["metric"], + "paper_eval_dbscan_backend": args.dbscan_backend, + "loss_overrides": { + "size_mode": purpose["size_mode"], + "size_ref": purpose["size_ref"], + "size_power": purpose["size_power"], + "w_topo": purpose["weights"]["w_topo"], + "w_aniso": purpose["weights"]["w_aniso"], + "w_size": purpose["weights"]["w_size"], + }, + } + + (out_base / "RUN_PLAN.json").write_text( + json.dumps(purpose, indent=2) + "\n", encoding="utf-8" + ) + # Legacy alias + (out_base / "PURPOSE.json").write_text( + json.dumps(purpose, indent=2) + "\n", encoding="utf-8" + ) + + result = train_main(config=cfg) + run_dir = Path(result["run_dir"]) + print(f"run_dir={run_dir}") + + if args.skip_eval: + return 0 + + eval_script = REPO_ROOT / "experiments" / "evaluate_paper_protocol.py" + for split in ("val", "test"): + cmd = [ + "uv", + "run", + "python", + str(eval_script), + "--run-dir", + str(run_dir), + "--base-config", + BASE_CONFIG, + "--split", + split, + "--backend", + args.dbscan_backend, + "--checkpoint-name", + "best_model.pth", + "--tag", + tag, + ] + if split == "test": + cmd.extend( + [ + "--dbscan-hparams", + str(run_dir / "logs" / f"dbscan_hparams_{tag}.json"), + ] + ) + print(f"[eval {split}] {' '.join(cmd)}") + subprocess.run(cmd, check=True, cwd=REPO_ROOT) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh new file mode 100755 index 0000000..4e768a3 --- /dev/null +++ b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# 30ep proposed runs on paper seeds (fixed tune weights from seed-42 Optuna). +# +# Protocol: tune once on seed 42 → apply same w_*, lr to all 5 data seeds. +# +# Usage: +# bash experiments/run_teacher_local_pca_power_30ep_multiseed.sh [MODE] [SEEDS...] +# +# MODE: wdist | mcc | both (default both) +# SEEDS: default 42 123 456 789 1024 +# +# Detached: +# bash experiments/launch_detached_screen.sh pwr30_ms \ +# outputs/supervised/0710_pwr30_multiseed/driver.log \ +# experiments/run_teacher_local_pca_power_30ep_multiseed.sh both + +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" +export OMP_NUM_THREADS="${OMP_NUM_THREADS:-4}" +export MKL_NUM_THREADS="${MKL_NUM_THREADS:-4}" +export OPENBLAS_NUM_THREADS="${OPENBLAS_NUM_THREADS:-4}" + +MODE="${1:-both}" +shift || true +if [[ $# -gt 0 && "${1:-}" =~ ^[0-9]+$ ]]; then + SEEDS=("$@") +else + SEEDS=(42 123 456 789 1024) +fi + +WDIST_JSON="${WDIST_JSON:-outputs/tune/0709_pwr_wdist/best_elongate_wdist_ellphi.json}" +MCC_JSON="${MCC_JSON:-outputs/tune/0709_pwr_mcc_dbscan_mahalanobis/best_elongate_mcc_ellphi_dbscan_mahalanobis.json}" +WDIST_OUT="${WDIST_OUT:-outputs/supervised/0710_pwr30_wdist}" +MCC_OUT="${MCC_OUT:-outputs/supervised/0710_pwr30_mcc_maha}" +LOG_ROOT="${LOG_ROOT:-outputs/supervised/0710_pwr30_multiseed}" +EPOCHS="${EPOCHS:-30}" +DBSCAN_BACKEND="${DBSCAN_BACKEND:-mahalanobis}" + +mkdir -p "${LOG_ROOT}" + +_metrics_done() { + local out_base="$1" + local seed="$2" + local tag="$3" + local f + for f in "${out_base}"/pwr_s"${seed}"_*/logs/paper_metrics_test_"${tag}".json; do + if [[ -f "${f}" ]]; then + return 0 + fi + done + return 1 +} + +_run_seed() { + local label="$1" + local tune_json="$2" + local out_base="$3" + local tag="$4" + local seed="$5" + local log_file="${LOG_ROOT}/${label}_s${seed}.log" + + if _metrics_done "${out_base}" "${seed}" "${tag}"; then + echo "[skip] ${label} seed=${seed} (paper_metrics_test already exists)" + return 0 + fi + + echo "=== ${label} seed=${seed} ===" + uv run python -u experiments/run_teacher_local_pca_power_30ep.py \ + --epochs "${EPOCHS}" \ + --seed "${seed}" \ + --out-base "${out_base}" \ + --tune-json "${tune_json}" \ + --tag "${tag}" \ + --dbscan-backend "${DBSCAN_BACKEND}" \ + 2>&1 | tee -a "${log_file}" +} + +_run_method() { + local label="$1" + local tune_json="$2" + local out_base="$3" + local tag="$4" + mkdir -p "${out_base}" + for seed in "${SEEDS[@]}"; do + _run_seed "${label}" "${tune_json}" "${out_base}" "${tag}" "${seed}" + done +} + +case "${MODE}" in + wdist) + _run_method "wdist" "${WDIST_JSON}" "${WDIST_OUT}" "power_wdist_valtopo_paper_eval" + ;; + mcc) + _run_method "mcc" "${MCC_JSON}" "${MCC_OUT}" "power_mcc_valtopo_paper_eval" + ;; + both) + _run_method "wdist" "${WDIST_JSON}" "${WDIST_OUT}" "power_wdist_valtopo_paper_eval" + _run_method "mcc" "${MCC_JSON}" "${MCC_OUT}" "power_mcc_valtopo_paper_eval" + ;; + *) + echo "Unknown MODE=${MODE} (use wdist, mcc, or both)" >&2 + exit 1 + ;; +esac + +echo "Aggregating..." +uv run python -u experiments/aggregate_power_30ep_multiseed.py \ + --wdist-out "${WDIST_OUT}" \ + --mcc-out "${MCC_OUT}" \ + --out-dir "${LOG_ROOT}" \ + || true + +echo "Done. Logs: ${LOG_ROOT}/" diff --git a/experiments/run_tune_local_pca_power_mcc_parallel.sh b/experiments/run_tune_local_pca_power_mcc_parallel.sh new file mode 100755 index 0000000..6e77637 --- /dev/null +++ b/experiments/run_tune_local_pca_power_mcc_parallel.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Optuna tune: local_pca + size_mode=power, objective = val DBSCAN MCC. +# +# Distance backends by role: +# BACKEND (arg 4, default ellphi) = training topo-loss (ellipse tangency). +# DBSCAN_BACKEND (arg 5, default mahalanobis) = clustering distance for MCC objective. +# +# Train per trial: val_topo ckpt (fast). Score: one DBSCAN grid on val at trial end. +# +# Usage: +# bash experiments/run_tune_local_pca_power_mcc_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] [DBSCAN_BACKEND] +# +# Detached: +# bash experiments/launch_detached_screen.sh tune_power_mcc_maha \ +# outputs/tune/0709_pwr_mcc_maha/launcher.log \ +# experiments/run_tune_local_pca_power_mcc_parallel.sh 4 24 20 ellphi mahalanobis + +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" + +N_WORKERS="${1:-4}" +N_TRIALS="${2:-24}" +TUNE_EPOCHS="${3:-20}" +BACKEND="${4:-ellphi}" +DBSCAN_BACKEND="${5:-mahalanobis}" +BASE_CONFIG="elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc" +OUT_BASE="${OUT_BASE:-outputs/tune/0709_pwr_mcc_dbscan_${DBSCAN_BACKEND}}" +STUDY_NAME="elongate_local_pca_power_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}" +STORAGE="sqlite:///${OUT_BASE}/study.db" +THREADS_PER_WORKER=12 + +mkdir -p "${OUT_BASE}" +cat > "${OUT_BASE}/PURPOSE.md" < "${OUT_BASE}/worker_${i}.log" 2>&1 & + pids+=($!) + echo " worker ${i}: PID $!, log ${OUT_BASE}/worker_${i}.log" + sleep 1 +done + +for pid in "${pids[@]}"; do wait "${pid}"; done + +uv run python -u experiments/tune_elongate_mcc.py \ + --base-config "${BASE_CONFIG}" \ + --n-trials "${N_TRIALS}" \ + --tune-epochs "${TUNE_EPOCHS}" \ + --backend "${BACKEND}" \ + --dbscan-backend "${DBSCAN_BACKEND}" \ + --size-mode power \ + --out-base "${OUT_BASE}" \ + --storage "${STORAGE}" \ + --study-name "${STUDY_NAME}" \ + --write-best + +echo "Done: ${OUT_BASE}/best_elongate_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}.json" diff --git a/experiments/run_tune_local_pca_power_objectives.sh b/experiments/run_tune_local_pca_power_objectives.sh new file mode 100755 index 0000000..84a9382 --- /dev/null +++ b/experiments/run_tune_local_pca_power_objectives.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Run both tuning objectives for power + local_pca + ellphi stack: +# 1. val topo W-Dist min (hyperparams for geometric PD fit) +# 2. val DBSCAN MCC max (hyperparams for clustering; maha DBSCAN distance) +# +# Usage: +# bash experiments/run_tune_local_pca_power_objectives.sh [MODE] [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] +# +# MODE: wdist | mcc | both (default both). Runs sequentially when both. +# +# Detached (both studies, ~8–10h total with 4 workers each): +# bash experiments/launch_detached_screen.sh tune_pwr_obj \ +# outputs/tune/0709_pwr_objectives/launcher.log \ +# experiments/run_tune_local_pca_power_objectives.sh both 4 24 20 + +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" + +MODE="${1:-both}" +N_WORKERS="${2:-4}" +N_TRIALS="${3:-24}" +TUNE_EPOCHS="${4:-20}" +BACKEND="ellphi" +DBSCAN_BACKEND="mahalanobis" + +LOG_ROOT="${LOG_ROOT:-outputs/tune/0709_pwr_objectives}" +mkdir -p "${LOG_ROOT}" + +run_wdist() { + echo "=== W-Dist objective tune ===" + OUT_BASE="${OUT_BASE:-outputs/tune/0709_pwr_wdist}" \ + bash experiments/run_tune_local_pca_power_wdist_parallel.sh \ + "${N_WORKERS}" "${N_TRIALS}" "${TUNE_EPOCHS}" "${BACKEND}" \ + 2>&1 | tee "${LOG_ROOT}/wdist_launch.log" +} + +run_mcc() { + echo "=== MCC objective tune (DBSCAN=${DBSCAN_BACKEND}) ===" + OUT_BASE="${OUT_BASE:-outputs/tune/0709_pwr_mcc_dbscan_${DBSCAN_BACKEND}}" \ + bash experiments/run_tune_local_pca_power_mcc_parallel.sh \ + "${N_WORKERS}" "${N_TRIALS}" "${TUNE_EPOCHS}" "${BACKEND}" "${DBSCAN_BACKEND}" \ + 2>&1 | tee "${LOG_ROOT}/mcc_launch.log" +} + +case "${MODE}" in + wdist) run_wdist ;; + mcc) run_mcc ;; + both) + run_wdist + run_mcc + ;; + *) + echo "Unknown MODE=${MODE} (use wdist, mcc, or both)" >&2 + exit 1 + ;; +esac + +echo "Objectives complete (mode=${MODE})." +echo " W-Dist best: outputs/tune/0709_pwr_wdist/best_elongate_wdist_${BACKEND}.json" +echo " MCC best: outputs/tune/0709_pwr_mcc_dbscan_${DBSCAN_BACKEND}/best_elongate_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}.json" diff --git a/experiments/run_tune_local_pca_power_wdist_parallel.sh b/experiments/run_tune_local_pca_power_wdist_parallel.sh new file mode 100755 index 0000000..bd7ed9b --- /dev/null +++ b/experiments/run_tune_local_pca_power_wdist_parallel.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Optuna tune: local_pca + size_mode=power, objective = val topo W-Dist. +# +# Train per trial: val_topo ckpt. Score: mean val topo W-Dist (ellphi teacher PD). +# Search bands match tune_elongate_mcc power protocol (--narrow-search). +# +# Usage: +# bash experiments/run_tune_local_pca_power_wdist_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] +# +# Detached: +# bash experiments/launch_detached_screen.sh tune_power_wdist \ +# outputs/tune/0709_pwr_wdist/launcher.log \ +# experiments/run_tune_local_pca_power_wdist_parallel.sh 4 24 20 ellphi + +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" + +N_WORKERS="${1:-4}" +N_TRIALS="${2:-24}" +TUNE_EPOCHS="${3:-20}" +BACKEND="${4:-ellphi}" +BASE_CONFIG="elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc" +OUT_BASE="${OUT_BASE:-outputs/tune/0709_pwr_wdist}" +STUDY_NAME="elongate_local_pca_power_wdist_${BACKEND}" +STORAGE="sqlite:///${OUT_BASE}/study.db" +THREADS_PER_WORKER=12 + +mkdir -p "${OUT_BASE}" +cat > "${OUT_BASE}/PURPOSE.md" < "${OUT_BASE}/worker_${i}.log" 2>&1 & + pids+=($!) + echo " worker ${i}: PID $!, log ${OUT_BASE}/worker_${i}.log" + sleep 1 +done + +for pid in "${pids[@]}"; do wait "${pid}"; done + +uv run python -u experiments/tune_elongate_wdist.py \ + --base-config "${BASE_CONFIG}" \ + --n-trials "${N_TRIALS}" \ + --tune-epochs "${TUNE_EPOCHS}" \ + --backend "${BACKEND}" \ + --size-mode power \ + --narrow-search \ + --out-base "${OUT_BASE}" \ + --storage "${STORAGE}" \ + --study-name "${STUDY_NAME}" \ + --write-best + +echo "Done: ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" diff --git a/experiments/tune_elongate_mcc.py b/experiments/tune_elongate_mcc.py new file mode 100644 index 0000000..4681afe --- /dev/null +++ b/experiments/tune_elongate_mcc.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +""" +Optuna tuning for ellphi + local_pca teacher with **val DBSCAN MCC** objective. + +Protocol (2026-07-09): +- Loss stack matches production: ``size_mode=power`` (or CLI override). +- Train checkpoint: ``val_topo`` min (fast; no per-epoch DBSCAN grid). +- Trial score: one val DBSCAN grid on ``best_model.pth`` → MCC max. + +Distance backends are separated by role: +- ``--backend`` (default ``ellphi``): training topo-loss = ellipse tangency filtration. +- ``--dbscan-backend`` (default ``mahalanobis``): clustering distance for the MCC + objective. Mahalanobis is the geometrically meaningful point-to-point distance; + ellphi tangency time is a filtration parameter, not a clustering distance. + +Search: ``w_topo``, ``w_aniso``, ``w_size``, ``lr`` (narrow bands around recover). +Optional: ``size_ref``, ``size_power`` when ``--tune-size-hyperparams``. + +Usage:: + + bash experiments/run_tune_local_pca_power_mcc_parallel.sh 4 24 20 ellphi +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import optuna +import torch +from optuna.study import MaxTrialsCallback +from optuna.trial import TrialState + +from tda_ml.checkpoint_io import resolve_val_topo_checkpoint +from tda_ml.config import deep_update, load_config +from tda_ml.dbscan_eval import evaluate_model_grid +from tda_ml.main import main as train_main +from tda_ml.preflight import preflight_mcc_tune_study +from tda_ml.reproducibility import reproducibility_settings, write_json +from tda_ml.topo_wdist import topo_wdist_options_from_config + +from evaluate_paper_protocol import ( # noqa: E402 + build_split_loader, + iter_cloud_predictions, + load_model_from_run, +) +from tune_elongate_wdist import mean_val_topo_wdist # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHECKPOINT_POLICY = "val_topo_best" +SAVE_EVERY = 1 + +# Recover-informed narrow search (log-uniform). +W_TOPO_RANGE = (0.05, 0.25) +W_SIZE_RANGE = (0.1, 0.6) +W_ANISO_RANGE = (0.03, 0.15) +LR_RANGE = (1e-4, 5e-4) +SIZE_REF_RANGE = (0.8, 1.6) +SIZE_POWER_RANGE = (1.0, 2.0) + + +def build_trial_config( + base_config: str, + *, + w_aniso: float, + w_size: float, + w_topo: float, + lr: float, + backend: str, + tune_epochs: int, + out_base: str, + trial_number: int, + size_mode: str, + size_ref: float, + size_power: float, +) -> dict[str, Any]: + cfg = load_config(base_config, project_root=REPO_ROOT) + overrides = { + "meta": {"config_id": f"tune_mcc_t{trial_number:03d}", "run_slug": f"t{trial_number:03d}"}, + "loss": { + "w_aniso": float(w_aniso), + "w_size": float(w_size), + "w_topo": float(w_topo), + "aniso_mode": "elongate", + "size_mode": size_mode, + "size_ref": float(size_ref), + "size_power": float(size_power), + }, + "training": { + "epochs": int(tune_epochs), + "lr": float(lr), + "visualize_every": 10_000, + "selection": {"metric": "val_topo", "eval_every": 1}, + }, + "model": { + "topology_loss": { + "distance_backend": backend, + "prob_weighting": False, + } + }, + "outputs": {"base_dir": out_base, "save_every": SAVE_EVERY}, + } + return deep_update(cfg, overrides) + + +def make_objective(args: argparse.Namespace): + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + def objective(trial: optuna.Trial) -> float: + w_aniso = trial.suggest_float("w_aniso", *W_ANISO_RANGE, log=True) + w_size = trial.suggest_float("w_size", *W_SIZE_RANGE, log=True) + w_topo = trial.suggest_float("w_topo", *W_TOPO_RANGE, log=True) + lr = trial.suggest_float("lr", *LR_RANGE, log=True) + size_ref = ( + trial.suggest_float("size_ref", *SIZE_REF_RANGE) + if args.tune_size_hyperparams + else float(args.size_ref) + ) + size_power = ( + trial.suggest_float("size_power", *SIZE_POWER_RANGE) + if args.tune_size_hyperparams + else float(args.size_power) + ) + + cfg = build_trial_config( + args.base_config, + w_aniso=w_aniso, + w_size=w_size, + w_topo=w_topo, + lr=lr, + backend=args.backend, + tune_epochs=args.tune_epochs, + out_base=args.out_base, + trial_number=trial.number, + size_mode=args.size_mode, + size_ref=size_ref, + size_power=size_power, + ) + rep = reproducibility_settings(cfg) + trial_manifest_path = Path(args.out_base) / f"trial_{trial.number:03d}_manifest.json" + write_json( + trial_manifest_path, + { + "trial_number": trial.number, + "params": trial.params, + "checkpoint_policy": CHECKPOINT_POLICY, + "dbscan_backend": args.dbscan_backend, + }, + ) + result = train_main(config=cfg) + run_dir = Path(result["run_dir"]) + + ckpt_name, ckpt_epoch, val_topo = resolve_val_topo_checkpoint(run_dir) + topo_options = topo_wdist_options_from_config(cfg) + model = load_model_from_run(run_dir, cfg, device, checkpoint_name=ckpt_name) + loader = build_split_loader(cfg, "val", device) + + grid = evaluate_model_grid( + model, + loader, + device, + config=cfg, + backend=args.dbscan_backend, + objective="mcc", + topo_options=topo_options, + allow_skip_degenerate_grid_cells=rep["allow_skip_degenerate_grid_cells"], + grid_log_path=run_dir / "logs" / "dbscan_grid_log_tune_trial.json", + manifest_ref=cfg.get("_manifest"), + ) + clouds = list(iter_cloud_predictions(model, loader, device)) + mwd = mean_val_topo_wdist(clouds, topo_options=topo_options) + + trial.set_user_attr("checkpoint_policy", CHECKPOINT_POLICY) + trial.set_user_attr("checkpoint_name", ckpt_name) + trial.set_user_attr("checkpoint_epoch", ckpt_epoch) + trial.set_user_attr("val_topo_loss", val_topo) + trial.set_user_attr("val_topo_wdist", mwd) + trial.set_user_attr("size_mode", args.size_mode) + trial.set_user_attr("dbscan_backend", args.dbscan_backend) + trial.set_user_attr("size_ref", size_ref) + trial.set_user_attr("size_power", size_power) + trial.set_user_attr("dbscan_eps", grid.eps) + trial.set_user_attr("dbscan_min_samples", grid.min_samples) + trial.set_user_attr("run_dir", str(run_dir)) + print( + f"[trial {trial.number}] mcc={grid.mcc:.4f} ({args.dbscan_backend} DBSCAN) " + f"topo_WDist={mwd:.4f} eps={grid.eps:.3f} ms={grid.min_samples} " + f"w_topo={w_topo:.3f} w_size={w_size:.3f} ep={ckpt_epoch}" + ) + return grid.mcc + + return objective + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--base-config", + default="elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", + ) + p.add_argument("--n-trials", type=int, default=24) + p.add_argument("--n-startup-trials", type=int, default=8) + p.add_argument("--tune-epochs", type=int, default=20) + p.add_argument( + "--backend", + default="ellphi", + choices=["ellphi", "mahalanobis"], + help="Training topo-loss distance backend (ellphi = ellipse tangency filtration).", + ) + p.add_argument( + "--dbscan-backend", + default="mahalanobis", + choices=["ellphi", "mahalanobis"], + help=( + "DBSCAN distance backend for the trial objective. Mahalanobis is the " + "geometrically meaningful choice for clustering (ellphi tangency time is " + "not a point-to-point distance)." + ), + ) + p.add_argument("--out-base", default="outputs/tune/0709_pwr_mcc") + p.add_argument("--size-mode", default="power", choices=["quadratic", "power", "softplus", "barrier"]) + p.add_argument("--size-ref", type=float, default=1.34) + p.add_argument("--size-power", type=float, default=1.5) + p.add_argument( + "--tune-size-hyperparams", + action="store_true", + help="Also search size_ref and size_power (6D search).", + ) + p.add_argument("--seed", type=int, default=42) + p.add_argument("--storage", default=None) + p.add_argument("--study-name", default="elongate_local_pca_power_mcc_ellphi") + p.add_argument("--write-best", action="store_true") + return p.parse_args() + + +def main() -> int: + args = parse_args() + Path(args.out_base).mkdir(parents=True, exist_ok=True) + preflight_mcc_tune_study( + base_config=args.base_config, + project_root=REPO_ROOT, + out_base=args.out_base, + ) + + study = optuna.create_study( + study_name=args.study_name, + storage=args.storage, + load_if_exists=bool(args.storage), + direction="maximize", + sampler=optuna.samplers.TPESampler(seed=args.seed, n_startup_trials=args.n_startup_trials), + ) + + if not args.write_best: + callbacks = [] + if args.storage: + callbacks.append(MaxTrialsCallback(args.n_trials, states=(TrialState.COMPLETE,))) + study.optimize( + make_objective(args), + n_trials=args.n_trials, + callbacks=callbacks, + ) + n_done = len([t for t in study.trials if t.state == TrialState.COMPLETE]) + print(f"[worker done] completed trials in study so far: {n_done}") + return 0 + + best = study.best_trial + payload = { + "objective": "val_dbscan_mcc_max_at_val_topo_best_ckpt", + "checkpoint_policy": CHECKPOINT_POLICY, + "checkpoint_selection": "val_topo", + "save_every": SAVE_EVERY, + "base_config": args.base_config, + "backend": args.backend, + "dbscan_backend": args.dbscan_backend, + "teacher_mode": "local_pca", + "size_mode": args.size_mode, + "size_ref_default": args.size_ref, + "size_power_default": args.size_power, + "tune_size_hyperparams": bool(args.tune_size_hyperparams), + "tune_epochs": args.tune_epochs, + "n_trials": args.n_trials, + "search_space": { + "w_aniso": list(W_ANISO_RANGE), + "w_size": list(W_SIZE_RANGE), + "w_topo": list(W_TOPO_RANGE), + "lr": list(LR_RANGE), + **( + {"size_ref": list(SIZE_REF_RANGE), "size_power": list(SIZE_POWER_RANGE)} + if args.tune_size_hyperparams + else {} + ), + }, + "best_value_mcc": best.value, + "best_params": best.params, + "best_val_topo_wdist": best.user_attrs.get("val_topo_wdist"), + "best_checkpoint_epoch": best.user_attrs.get("checkpoint_epoch"), + "best_run_dir": best.user_attrs.get("run_dir"), + "all_trials": [ + { + "number": t.number, + "value": t.value, + "params": t.params, + "val_topo_wdist": t.user_attrs.get("val_topo_wdist"), + "checkpoint_epoch": t.user_attrs.get("checkpoint_epoch"), + } + for t in study.trials + ], + } + out_path = ( + Path(args.out_base) + / f"best_elongate_mcc_{args.backend}_dbscan_{args.dbscan_backend}.json" + ) + out_path.write_text(json.dumps(payload, indent=2) + "\n") + print(json.dumps({k: payload[k] for k in ("best_value_mcc", "best_params", "best_val_topo_wdist")}, indent=2)) + print(f"Wrote {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py index f7a81ca..065c40e 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_elongate_wdist.py @@ -6,7 +6,7 @@ Each trial: 1. Trains ``--tune-epochs`` with ``save_every=1`` (``best_model.pth`` = val_topo min). -2. Loads ``best_model.pth`` (fallback: train_topo best saved checkpoint). +2. Loads ``best_model.pth`` (val_topo selection checkpoint; hard-fail if missing). 3. Objective = mean val **topo W-Dist** (learned ellipses vs local_pca teacher PD). Base config ``elongate_n100_no_cls_tune_local_pca`` sets ``teacher_mode: local_pca``, @@ -30,12 +30,12 @@ from optuna.study import MaxTrialsCallback from optuna.trial import TrialState -from tda_ml.checkpoint_io import load_torch_checkpoint +from tda_ml.checkpoint_io import resolve_val_topo_checkpoint from tda_ml.config import deep_update, load_config from tda_ml.main import main as train_main +from tda_ml.preflight import preflight_wdist_tune_study from tda_ml.topo_wdist import TopoWdistOptions, compute_topo_wdist, topo_wdist_options_from_config -from checkpoint_selection import best_topo_checkpoint # noqa: E402 from evaluate_paper_protocol import ( # noqa: E402 build_split_loader, iter_cloud_predictions, @@ -46,46 +46,58 @@ CHECKPOINT_POLICY = "val_topo_best" SAVE_EVERY = 1 +# Narrow bands (shared with tune_elongate_mcc power protocol). +NARROW_W_TOPO_RANGE = (0.05, 0.25) +NARROW_W_SIZE_RANGE = (0.1, 0.6) +NARROW_W_ANISO_RANGE = (0.03, 0.15) +NARROW_LR_RANGE = (1e-4, 5e-4) + +# Legacy wide search. +WIDE_W_ANISO_RANGE = (0.05, 5.0) +WIDE_W_SIZE_RANGE = (0.005, 0.5) +WIDE_W_TOPO_RANGE = (0.02, 0.5) +WIDE_LR_RANGE = (1e-4, 2e-3) + def mean_val_topo_wdist( clouds: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], *, topo_options: TopoWdistOptions, ) -> float: - """Mean per-cloud topo W-Dist on val (independent of DBSCAN).""" + """Mean per-cloud topo W-Dist on val (independent of DBSCAN) without ad-hoc fallbacks.""" vals: list[float] = [] for points, params, _labels_gt, clean_pc in clouds: - vals.append(compute_topo_wdist(points, params, clean_pc, topo_options)) - if not vals: - return float("inf") + val = compute_topo_wdist(points, params, clean_pc, topo_options) + vals.append(val) return float(np.mean(vals)) -def resolve_tune_checkpoint(run_dir: Path) -> tuple[str, int, float]: - """Prefer ``best_model.pth`` (val_topo); else train_topo best saved ckpt.""" - best_path = run_dir / "best_model.pth" - if best_path.is_file(): - ckpt = load_torch_checkpoint(str(best_path), map_location="cpu") - epoch = int(ckpt.get("epoch", -1)) - sel = ckpt.get("selection_value", ckpt.get("val_topo_loss")) - val_topo = float(sel) if sel is not None else float("nan") - return "best_model.pth", epoch, val_topo - ckpt_name, epoch, train_topo, _g_ep, _g_topo = best_topo_checkpoint(run_dir) - return ckpt_name, epoch, train_topo - - def build_trial_config( - base_config: str, *, w_aniso: float, w_size: float, w_topo: float, lr: float, - backend: str, tune_epochs: int, out_base: str, trial_number: int, + base_config: str, + *, + w_aniso: float, + w_size: float, + w_topo: float, + lr: float, + backend: str, + tune_epochs: int, + out_base: str, + trial_number: int, + size_mode: str = "quadratic", + size_ref: float = 1.34, + size_power: float = 1.5, ) -> dict[str, Any]: cfg = load_config(base_config, project_root=REPO_ROOT) overrides = { - "meta": {"config_id": f"tune_elongate_t{trial_number:03d}"}, + "meta": {"config_id": f"tune_elongate_t{trial_number:03d}", "run_slug": f"t{trial_number:03d}"}, "loss": { "w_aniso": float(w_aniso), "w_size": float(w_size), "w_topo": float(w_topo), "aniso_mode": "elongate", + "size_mode": size_mode, + "size_ref": float(size_ref), + "size_power": float(size_power), }, "training": { "epochs": int(tune_epochs), @@ -104,24 +116,52 @@ def build_trial_config( return deep_update(cfg, overrides) +def search_ranges(*, narrow: bool) -> tuple[tuple[float, float], ...]: + if narrow: + return ( + NARROW_W_ANISO_RANGE, + NARROW_W_SIZE_RANGE, + NARROW_W_TOPO_RANGE, + NARROW_LR_RANGE, + ) + return ( + WIDE_W_ANISO_RANGE, + WIDE_W_SIZE_RANGE, + WIDE_W_TOPO_RANGE, + WIDE_LR_RANGE, + ) + + def make_objective(args: argparse.Namespace): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + w_aniso_range, w_size_range, w_topo_range, lr_range = search_ranges( + narrow=bool(args.narrow_search) + ) def objective(trial: optuna.Trial) -> float: - w_aniso = trial.suggest_float("w_aniso", 0.05, 5.0, log=True) - w_size = trial.suggest_float("w_size", 0.005, 0.5, log=True) - w_topo = trial.suggest_float("w_topo", 0.02, 0.5, log=True) - lr = trial.suggest_float("lr", 1e-4, 2e-3, log=True) + w_aniso = trial.suggest_float("w_aniso", *w_aniso_range, log=True) + w_size = trial.suggest_float("w_size", *w_size_range, log=True) + w_topo = trial.suggest_float("w_topo", *w_topo_range, log=True) + lr = trial.suggest_float("lr", *lr_range, log=True) cfg = build_trial_config( - args.base_config, w_aniso=w_aniso, w_size=w_size, w_topo=w_topo, lr=lr, - backend=args.backend, tune_epochs=args.tune_epochs, out_base=args.out_base, + args.base_config, + w_aniso=w_aniso, + w_size=w_size, + w_topo=w_topo, + lr=lr, + backend=args.backend, + tune_epochs=args.tune_epochs, + out_base=args.out_base, trial_number=trial.number, + size_mode=args.size_mode, + size_ref=args.size_ref, + size_power=args.size_power, ) result = train_main(config=cfg) run_dir = Path(result["run_dir"]) - ckpt_name, ckpt_epoch, val_topo_sel = resolve_tune_checkpoint(run_dir) + ckpt_name, ckpt_epoch, val_topo_sel = resolve_val_topo_checkpoint(run_dir) topo_options = topo_wdist_options_from_config(cfg) model = load_model_from_run(run_dir, cfg, device, checkpoint_name=ckpt_name) loader = build_split_loader(cfg, "val", device) @@ -133,12 +173,11 @@ def objective(trial: optuna.Trial) -> float: trial.set_user_attr("checkpoint_epoch", ckpt_epoch) trial.set_user_attr("val_topo_loss_at_ckpt", val_topo_sel) trial.set_user_attr("teacher_mode", topo_options.teacher_mode) + trial.set_user_attr("size_mode", args.size_mode) trial.set_user_attr("run_dir", str(run_dir)) print( - f"[trial {trial.number}] w_aniso={w_aniso:.4f} w_size={w_size:.4f} " - f"w_topo={w_topo:.4f} lr={lr:.2e} " - f"ckpt={ckpt_name} ep={ckpt_epoch} val_topo_sel={val_topo_sel:.5f} " - f"-> val topo W-Dist={mwd:.5f}" + f"[trial {trial.number}] wdist={mwd:.5f} " + f"w_topo={w_topo:.3f} w_size={w_size:.3f} ep={ckpt_epoch}" ) return mwd @@ -156,8 +195,26 @@ def parse_args() -> argparse.Namespace: help="Random startup trials before TPE modeling kicks in.", ) p.add_argument("--tune-epochs", type=int, default=20) - p.add_argument("--backend", type=str, default="mahalanobis", choices=["mahalanobis", "ellphi"]) - p.add_argument("--out-base", type=str, default="outputs/tune_elongate") + p.add_argument( + "--backend", + type=str, + default="ellphi", + choices=["mahalanobis", "ellphi"], + help="Training topo-loss backend (ellphi = ellipse tangency filtration).", + ) + p.add_argument("--out-base", type=str, default="outputs/tune/0629_elongate") + p.add_argument( + "--size-mode", + default="quadratic", + choices=["quadratic", "power", "softplus", "barrier"], + ) + p.add_argument("--size-ref", type=float, default=1.34) + p.add_argument("--size-power", type=float, default=1.5) + p.add_argument( + "--narrow-search", + action="store_true", + help="Use narrow search bands (same as tune_elongate_mcc power protocol).", + ) p.add_argument("--seed", type=int, default=42, help="Optuna sampler seed") p.add_argument( "--storage", @@ -182,6 +239,11 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() Path(args.out_base).mkdir(parents=True, exist_ok=True) + preflight_wdist_tune_study( + base_config=args.base_config, + project_root=REPO_ROOT, + out_base=args.out_base, + ) study = optuna.create_study( study_name=args.study_name, @@ -201,28 +263,34 @@ def main() -> int: make_objective(args), n_trials=args.n_trials, callbacks=callbacks, - catch=(Exception,), ) n_done = len([t for t in study.trials if t.state == TrialState.COMPLETE]) print(f"[worker done] completed trials in study so far: {n_done}") return 0 best = study.best_trial + w_aniso_range, w_size_range, w_topo_range, lr_range = search_ranges( + narrow=bool(args.narrow_search) + ) payload = { "objective": "val_topo_wdist_min_at_val_topo_best_ckpt", "checkpoint_policy": CHECKPOINT_POLICY, "save_every": SAVE_EVERY, "base_config": args.base_config, "backend": args.backend, + "size_mode": args.size_mode, + "size_ref_default": args.size_ref, + "size_power_default": args.size_power, + "narrow_search": bool(args.narrow_search), "teacher_mode": "local_pca", "tune_epochs": args.tune_epochs, "n_trials": args.n_trials, "n_startup_trials": args.n_startup_trials, "search_space": { - "w_aniso": [0.05, 5.0], - "w_size": [0.005, 0.5], - "w_topo": [0.02, 0.5], - "lr": [1e-4, 2e-3], + "w_aniso": list(w_aniso_range), + "w_size": list(w_size_range), + "w_topo": list(w_topo_range), + "lr": list(lr_range), }, "best_value_wdist": best.value, "best_params": best.params, From edb31b22b671a2f8522eae82d8427f8c63238fe1 Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:31:41 +0900 Subject: [PATCH 13/44] =?UTF-8?q?fix:=20PR#3=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E5=AF=BE=E5=BF=9C=EF=BC=88=E9=9B=86=E8=A8=88=20hard-f?= =?UTF-8?q?ail=E3=83=BBnormalize=5Faxes=20=E6=98=8E=E7=A4=BA=E3=83=BBobjec?= =?UTF-8?q?tive=20=E5=88=86=E9=A1=9E=E5=85=B1=E9=80=9A=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - multiseed 集計の `|| true` を廃止し、MODE に応じた --methods で厳格に失敗させる - 本番 YAML に teacher_local_pca_normalize_axes: true を明示(tune base と一致) - infer_tag を tda_ml.preflight.classify_tune_objective に統一(未知 objective は hard-fail) - tune_elongate_wdist / evaluate_paper_baselines の --base-config を必須化(暗黙既定の排除) - REPRODUCIBILITY.md に重み固定プロトコル(seed 42 tune のみ・per-seed 再チューニングなし)を明記 --- REPRODUCIBILITY.md | 2 ++ ...e_n100_no_cls_full120_teacher_local_pca.yaml | 3 +++ experiments/aggregate_power_30ep_multiseed.py | 17 +++++++++++++---- experiments/evaluate_paper_baselines.py | 6 +++++- experiments/run_teacher_local_pca_power_30ep.py | 17 ++++++++++------- ...un_teacher_local_pca_power_30ep_multiseed.sh | 17 ++++++++++++----- experiments/tune_elongate_wdist.py | 4 +++- 7 files changed, 48 insertions(+), 18 deletions(-) diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 37ca352..b9417fe 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -140,6 +140,8 @@ no_cls・local_pca 教師・`size_mode=power` スタックについて、**W-Dis 要点: **学習 topo loss と教師 PD は ellphi**;**MCC のチューニング objective と paper eval の DBSCAN は mahalanobis**(filtration 時刻をクラスタリング距離に使わない)。 +**重み固定プロトコル(重要):** ハイパーパラメータ探索(Optuna)は **seed 42 の 20ep proxy で 1 回だけ**行い、得られた best 重み(`w_topo` / `w_aniso` / `w_size` / `lr`)を **5 つのデータ seed(42/123/456/789/1024)すべての 30ep 本番に固定**して適用します。**データ seed ごとの再チューニングは行いません。** 論文の mean ± std はこの固定重みの下でのデータ seed 間ばらつきです。 + ## 教師あり学習の目的関数(論文 Methods 用) 本線 `tda_ml/` の学習は **点ラベル BCE** と **clean 点群の $H_1$ 持久図との Wasserstein 教師** を併用します(`configs/reproduce.yaml` 系)。 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml index c37f0d7..caba2c3 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml @@ -28,6 +28,9 @@ loss: size_power: 1.5 teacher_mode: local_pca teacher_local_pca_k: 10 + # Must match the tune base (elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc); + # tuned weights assume this teacher geometry. + teacher_local_pca_normalize_axes: true training: lr: 0.000158 diff --git a/experiments/aggregate_power_30ep_multiseed.py b/experiments/aggregate_power_30ep_multiseed.py index b221d8f..5ee727d 100644 --- a/experiments/aggregate_power_30ep_multiseed.py +++ b/experiments/aggregate_power_30ep_multiseed.py @@ -131,6 +131,13 @@ def parse_args() -> argparse.Namespace: ) p.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs/supervised/0710_pwr30_multiseed") p.add_argument("--seeds", type=int, nargs="+", default=PAPER_SEEDS) + p.add_argument( + "--methods", + nargs="+", + choices=["wdist", "mcc"], + default=["wdist", "mcc"], + help="Which tune objectives to aggregate (single-mode drivers pass one).", + ) return p.parse_args() @@ -145,12 +152,14 @@ def main() -> int: "pwr_s*/logs/paper_metrics_test_power_mcc_valtopo_paper_eval.json", ) + method_specs = { + "wdist": ("proposed_wdist_tune_30ep", wdist_by_seed, args.wdist_tune_json), + "mcc": ("proposed_mcc_tune_30ep", mcc_by_seed, args.mcc_tune_json), + } rows: list[dict[str, Any]] = [] all_warnings: list[str] = [] - for method, by_seed, tune_json in ( - ("proposed_wdist_tune_30ep", wdist_by_seed, args.wdist_tune_json), - ("proposed_mcc_tune_30ep", mcc_by_seed, args.mcc_tune_json), - ): + for key in args.methods: + method, by_seed, tune_json = method_specs[key] row, warnings = aggregate_row( method=method, by_seed=by_seed, diff --git a/experiments/evaluate_paper_baselines.py b/experiments/evaluate_paper_baselines.py index b3c45c8..f87a6b8 100644 --- a/experiments/evaluate_paper_baselines.py +++ b/experiments/evaluate_paper_baselines.py @@ -15,6 +15,7 @@ Usage:: uv run python experiments/evaluate_paper_baselines.py \\ + --base-config elongate_n100_no_cls_full120_teacher_local_pca \\ --out-dir outputs/paper_baselines """ @@ -445,7 +446,10 @@ def aggregate_method_results(seed_results: Sequence[SeedResult]) -> dict[str, An def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--base-config", type=str, default="reproduce") + # No implicit default: the n100 paper comparison must pass the elongate config + # (e.g. elongate_n100_no_cls_full120_teacher_local_pca); baselines share its + # data settings and evaluation.dbscan / evaluation.baselines grids. + p.add_argument("--base-config", type=str, required=True) p.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs" / "paper_baselines") p.add_argument("--seeds", type=int, nargs="+", default=PAPER_SEEDS) p.add_argument( diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_teacher_local_pca_power_30ep.py index 38747dd..5c221ee 100644 --- a/experiments/run_teacher_local_pca_power_30ep.py +++ b/experiments/run_teacher_local_pca_power_30ep.py @@ -14,7 +14,10 @@ from tda_ml.config import deep_update, load_config # noqa: E402 from tda_ml.main import main as train_main # noqa: E402 -from tda_ml.preflight import preflight_tune_production_run # noqa: E402 +from tda_ml.preflight import ( # noqa: E402 + classify_tune_objective, + preflight_tune_production_run, +) BASE_CONFIG = "elongate_n100_no_cls_full120_teacher_local_pca" DEFAULT_OUT = REPO_ROOT / "outputs/supervised/0709_pwr30" @@ -67,17 +70,17 @@ def load_tune_weights(path: Path) -> dict[str, float]: def infer_tag(tune_json: Path | None, explicit: str | None) -> str: + """Map tune objective to paper-eval tag; hard-fail on unknown objective.""" if explicit: return explicit if tune_json is None: return TAG payload = json.loads(tune_json.read_text(encoding="utf-8")) - objective = str(payload.get("objective", "")) - if "wdist" in objective: - return TAG_WDIST - if "mcc" in objective: - return TAG_MCC - return TAG_MCC + kind = classify_tune_objective( + str(payload.get("objective", "")), + objective_kind=payload.get("objective_kind"), + ) + return TAG_WDIST if kind == "wdist" else TAG_MCC def main() -> int: diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh index 4e768a3..98d3cc2 100755 --- a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh +++ b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh @@ -106,10 +106,17 @@ case "${MODE}" in esac echo "Aggregating..." -uv run python -u experiments/aggregate_power_30ep_multiseed.py \ - --wdist-out "${WDIST_OUT}" \ - --mcc-out "${MCC_OUT}" \ - --out-dir "${LOG_ROOT}" \ - || true +AGG_ARGS=( + --wdist-out "${WDIST_OUT}" + --mcc-out "${MCC_OUT}" + --out-dir "${LOG_ROOT}" + --seeds "${SEEDS[@]}" +) +case "${MODE}" in + wdist) AGG_ARGS+=(--methods wdist) ;; + mcc) AGG_ARGS+=(--methods mcc) ;; +esac +# Strict: missing seeds or aggregation failure must fail this driver (no || true). +uv run python -u experiments/aggregate_power_30ep_multiseed.py "${AGG_ARGS[@]}" echo "Done. Logs: ${LOG_ROOT}/" diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py index 065c40e..21c7704 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_elongate_wdist.py @@ -186,7 +186,9 @@ def objective(trial: optuna.Trial) -> float: def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--base-config", type=str, default="elongate_n100_no_cls_tune_local_pca") + # No implicit default: every study must state its config surface explicitly + # (power stack uses elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc). + p.add_argument("--base-config", type=str, required=True) p.add_argument("--n-trials", type=int, default=50) p.add_argument( "--n-startup-trials", From d21a3ad0cfe4729f46803264e11e0531ad85cd5d Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:17:51 +0900 Subject: [PATCH 14/44] =?UTF-8?q?fix:=20exp=20=E8=BB=B8=E5=86=99=E5=83=8F?= =?UTF-8?q?=E3=82=92=E6=AD=A3=E6=9C=AC=E5=8C=96=E3=81=97=20sigmoid=20clip?= =?UTF-8?q?=20=E5=89=8A=E9=99=A4=E3=83=BB=E5=A4=B1=E6=95=97=20manifest=20?= =?UTF-8?q?=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models: a_i = exp(Δa_i)*a_base(暗黙 sigmoid 有界化を revert) - main: train_epoch 例外時に run_status/final_status=failed を manifest へ記録 - REPRODUCIBILITY: axis_param 幽霊フラグ削除、clip なしと failed 語彙を明記 - multiseed: seed 並列 (N_WORKERS) と THREADS_PER_WORKER を doc 化 - test: exp(Δ)×base 写像の回帰テスト追加 --- REPRODUCIBILITY.md | 4 +- ..._teacher_local_pca_power_30ep_multiseed.sh | 80 +++++++++++++++++-- tda_ml/main.py | 16 +++- tda_ml/models.py | 6 +- tests/test_models.py | 12 +++ 5 files changed, 103 insertions(+), 15 deletions(-) diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index b9417fe..1399ca3 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -154,7 +154,7 @@ no_cls・local_pca 教師・`size_mode=power` スタックについて、**W-Dis + w_{\mathrm{aniso}}\mathcal{L}_{\mathrm{aniso}}. \] -**楕円パラメータ(`axis_param=legacy`、主表の既定)** — 局所 PCA の $a_{i,\mathrm{base}}, b_{i,\mathrm{base}}, \theta_{i,\mathrm{base}}$ に対し: +**楕円パラメータ(主表の既定)** — 局所 PCA の $a_{i,\mathrm{base}}, b_{i,\mathrm{base}}, \theta_{i,\mathrm{base}}$ に対し、学習可能な補正 $\Delta a_i,\Delta b_i,\Delta\theta_i$(`topology_head` 出力)で: \[ a_i = a_{i,\mathrm{base}}\, e^{\Delta a_i},\quad @@ -162,6 +162,8 @@ b_i = b_{i,\mathrm{base}}\, e^{\Delta b_i},\quad \theta_i = \theta_{i,\mathrm{base}} + \tanh(\Delta\theta_i)\frac{\pi}{2}. \] +clip や sigmoid による軸倍率の暗黙クリップは行わない。ellphi 等で退化が起きた run は `run_status: failed` として記録する([Computational Reproducibility skill](https://github.com/t-uda/skills/blob/main/skills/computational-reproducibility/SKILL.md))。 + **正則化(`tda_ml/losses.py`)** \[ diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh index 98d3cc2..f8b232f 100755 --- a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh +++ b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh @@ -9,18 +9,27 @@ # MODE: wdist | mcc | both (default both) # SEEDS: default 42 123 456 789 1024 # -# Detached: +# Detached (SSH/logout safe; machine reboot still stops the job): +# N_WORKERS=4 THREADS_PER_WORKER=4 \ # bash experiments/launch_detached_screen.sh pwr30_ms \ # outputs/supervised/0710_pwr30_multiseed/driver.log \ # experiments/run_teacher_local_pca_power_30ep_multiseed.sh both +# +# Parallelism: N_WORKERS seeds per objective wave; THREADS_PER_WORKER per process +# (bench: raising OMP past 4 does not speed ellphi topo; parallel seeds do). set -euo pipefail cd "$(dirname "$0")/.." export PATH="${HOME}/.local/bin:${PATH}" -export OMP_NUM_THREADS="${OMP_NUM_THREADS:-4}" -export MKL_NUM_THREADS="${MKL_NUM_THREADS:-4}" -export OPENBLAS_NUM_THREADS="${OPENBLAS_NUM_THREADS:-4}" +# Per-process threads (bench 2026-07-12: OMP 4/12/16 → identical ~1.5 s/batch; ellphi topo +# is sample-serial Python + C++). Do not raise unless re-benchmarked. +THREADS_PER_WORKER="${THREADS_PER_WORKER:-4}" +export OMP_NUM_THREADS="${OMP_NUM_THREADS:-${THREADS_PER_WORKER}}" +export MKL_NUM_THREADS="${MKL_NUM_THREADS:-${THREADS_PER_WORKER}}" +export OPENBLAS_NUM_THREADS="${OPENBLAS_NUM_THREADS:-${THREADS_PER_WORKER}}" +# Parallel seeds within each objective (wdist / mcc). Each worker uses THREADS_PER_WORKER. +N_WORKERS="${N_WORKERS:-4}" MODE="${1:-both}" shift || true @@ -66,7 +75,10 @@ _run_seed() { return 0 fi - echo "=== ${label} seed=${seed} ===" + echo "=== ${label} seed=${seed} (threads=${THREADS_PER_WORKER}) ===" + OMP_NUM_THREADS="${THREADS_PER_WORKER}" \ + MKL_NUM_THREADS="${THREADS_PER_WORKER}" \ + OPENBLAS_NUM_THREADS="${THREADS_PER_WORKER}" \ uv run python -u experiments/run_teacher_local_pca_power_30ep.py \ --epochs "${EPOCHS}" \ --seed "${seed}" \ @@ -74,7 +86,36 @@ _run_seed() { --tune-json "${tune_json}" \ --tag "${tag}" \ --dbscan-backend "${DBSCAN_BACKEND}" \ - 2>&1 | tee -a "${log_file}" + >> "${log_file}" 2>&1 +} + +_run_seed_batch() { + local label="$1" + local tune_json="$2" + local out_base="$3" + local tag="$4" + shift 4 + local seeds=("$@") + local pids=() + local seed pid + + for seed in "${seeds[@]}"; do + if _metrics_done "${out_base}" "${seed}" "${tag}"; then + echo "[skip] ${label} seed=${seed} (paper_metrics_test already exists)" + continue + fi + _run_seed "${label}" "${tune_json}" "${out_base}" "${tag}" "${seed}" & + pids+=($!) + echo " launched ${label} seed=${seed} PID=$!" + sleep 1 + done + + for pid in "${pids[@]}"; do + if ! wait "${pid}"; then + echo "error: worker PID ${pid} failed" >&2 + exit 1 + fi + done } _run_method() { @@ -83,9 +124,34 @@ _run_method() { local out_base="$3" local tag="$4" mkdir -p "${out_base}" + + local pending=() + local seed for seed in "${SEEDS[@]}"; do - _run_seed "${label}" "${tune_json}" "${out_base}" "${tag}" "${seed}" + if _metrics_done "${out_base}" "${seed}" "${tag}"; then + echo "[skip] ${label} seed=${seed} (paper_metrics_test already exists)" + else + pending+=("${seed}") + fi done + + if ((${#pending[@]} == 0)); then + echo "[done] ${label}: all seeds complete" + return 0 + fi + + echo "${label}: ${#pending[@]} seed(s) pending, N_WORKERS=${N_WORKERS}, threads=${THREADS_PER_WORKER}" + local batch=() + for seed in "${pending[@]}"; do + batch+=("${seed}") + if ((${#batch[@]} >= N_WORKERS)); then + _run_seed_batch "${label}" "${tune_json}" "${out_base}" "${tag}" "${batch[@]}" + batch=() + fi + done + if ((${#batch[@]} > 0)); then + _run_seed_batch "${label}" "${tune_json}" "${out_base}" "${tag}" "${batch[@]}" + fi } case "${MODE}" in diff --git a/tda_ml/main.py b/tda_ml/main.py index f874ebd..da29b86 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -221,8 +221,20 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): best_sel_value = float("inf") if sel_settings.minimize else -1.0 for epoch in range(1, epochs + 1): - # res returns (avg_loss, class_loss, topo_loss, aniso_loss, size_loss, ...) - res = trainer.train_epoch(data_loader, epoch) + try: + # res returns (avg_loss, class_loss, topo_loss, aniso_loss, size_loss, ...) + res = trainer.train_epoch(data_loader, epoch) + except Exception as exc: + if manifest_path is not None: + manifest["final_status"] = RUN_STATUS_FAILED + manifest["run_status"] = RUN_STATUS_FAILED + manifest["failure_type"] = type(exc).__name__ + manifest["failure_error"] = str(exc) + if metrics_history: + manifest["last_completed_epoch"] = metrics_history[-1]["epoch"] + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=True, indent=2) + raise val_res = trainer.validate(val_loader) val_mcc = val_res[4] # MCC is at index 4 diff --git a/tda_ml/models.py b/tda_ml/models.py index 5f518f1..d3c4f14 100644 --- a/tda_ml/models.py +++ b/tda_ml/models.py @@ -145,11 +145,7 @@ def forward(self, x): outlier_logits = self.classification_head(cls_feats) raw = self.topology_head(topo_feats) - # Restrict axes scaling factor within a physically sound range to prevent - # both underflow collapse (NaN) and overflow cheat (over-expansion). - min_scale = 0.2 - max_scale = 3.0 - axes_scale = min_scale + (max_scale - min_scale) * torch.sigmoid(raw[:, :, 0:2]) + axes_scale = torch.exp(raw[:, :, 0:2]) axes = axes_scale * base_axes angle_delta = torch.tanh(raw[:, :, 2:3]) * (torch.pi / 2) angle = base_angle + angle_delta diff --git a/tests/test_models.py b/tests/test_models.py index cda866d..1c324a0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -34,5 +34,17 @@ def test_legacy_axes_positive_without_modeling_floor(self): axes = params[0, :, 0:2] self.assertTrue((axes > 0).all()) + def test_axes_use_exp_delta_times_base(self): + """a_i = exp(Delta a_i) * a_base (no sigmoid clip on axis scale).""" + torch.manual_seed(7) + model = AnisotropicOutlierClassifier() + x = torch.rand(1, 20, 2) + with torch.no_grad(): + _, topo_feats, _, base_axes = model.encoder(x) + raw = model.topology_head(topo_feats) + expected_axes = torch.exp(raw[:, :, 0:2]) * base_axes + _, params = model(x) + torch.testing.assert_close(params[:, :, 0:2], expected_axes) + if __name__ == '__main__': unittest.main() From e2ffba29d64f54a3405f032b0c50aa38e036ea19 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 10:06:00 +0900 Subject: [PATCH 15/44] =?UTF-8?q?fix:=20no=5Fcls=20=E4=BD=8D=E7=9B=B8?= =?UTF-8?q?=E7=9B=AE=E7=9A=84=E3=82=92=20H1-only=20hard-fail=20=E6=AD=A3?= =?UTF-8?q?=E6=9C=AC=E3=81=B8=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 軸投影や暗黙 fallback で退化を隠さず、訓練・評価・manifest・公開文書の W-Dist 契約を一致させる。 Co-authored-by: Cursor --- .gitignore | 2 + REPRODUCIBILITY.md | 31 +++++-- configs/README.md | 5 +- configs/base.yaml | 5 +- ...n100_no_cls_full120_teacher_local_pca.yaml | 14 ++- ...o_cls_tune_local_pca_ellphi_power_mcc.yaml | 1 + experiments/aggregate_power_30ep_multiseed.py | 10 +-- .../run_teacher_local_pca_power_30ep.py | 17 +++- ..._teacher_local_pca_power_30ep_multiseed.sh | 12 +-- ...run_tune_local_pca_power_wdist_parallel.sh | 11 ++- experiments/tune_elongate_wdist.py | 47 +++++++++- tda_ml/distance_backend.py | 31 ++++--- tda_ml/losses.py | 45 ++++++---- tda_ml/metrics.py | 7 +- tda_ml/persistence_dimensions.py | 51 +++++++++++ tda_ml/preflight.py | 17 ++-- tda_ml/reproducibility.py | 3 + tda_ml/topo_wdist.py | 32 +++++-- tda_ml/trainer.py | 14 ++- tests/test_ellipse_barrier_losses.py | 5 +- tests/test_metrics.py | 6 ++ tests/test_persistence_dimensions.py | 87 +++++++++++++++++++ tests/test_reproducibility_strict.py | 3 + tests/test_tune_config.py | 17 ++++ 24 files changed, 392 insertions(+), 81 deletions(-) create mode 100644 tda_ml/persistence_dimensions.py create mode 100644 tests/test_persistence_dimensions.py diff --git a/.gitignore b/.gitignore index 86fcf54..072311d 100644 --- a/.gitignore +++ b/.gitignore @@ -198,6 +198,7 @@ cython_debug/ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # refer to https://docs.cursor.com/context/ignore-files +.cursor/ .cursorignore .cursorindexingignore @@ -216,6 +217,7 @@ docs/experiments/ data/ trash/ ellphi_repo/ +pytorch-topological/ docs/JSAIM2026UT.zip # OS artifacts diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 1399ca3..c479c05 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -7,22 +7,32 @@ - **含む:** `tda_ml/`、**`configs/` 直下の正本 YAML**(`base.yaml` と `reproduce` / `dev` / `prod` / `test_fast`、および論文比較用の `elongate_n100_no_cls_*`)、`tests/`、追跡されている `scripts/`、論文・再現用 `experiments/`(下記)、および `README.md` / `REPRODUCIBILITY.md` / `pyproject.toml` / `uv.lock` / `LICENSE` / `CITATION.cff` などのメタデータ。 - **含めない:** `docs/` 以下(**ローカル実験メモ**;公開方針で git に入れる場合は別途決定)、`configs/archive/`(履歴用 YAML を置く場合は **ローカルのみ**)、`outputs/`、`data/`、`.cursor/` など。`load_config("archive/...")` は、手元に `configs/archive/*.yaml` を置いた場合にのみ使えます。 -### 論文比較(ellphi + power 二目的)で使う `experiments/`(2026-07-12) +### 論文比較(ellphi + power 二目的)で使う `experiments/` -**現在の主 run:** W-Dist tune 重みの 30ep 5-seed(`run_teacher_local_pca_power_30ep_multiseed.sh wdist`)。 +**論文主表の提案:** W-Dist tune 重みの 30ep 5-seed(`run_teacher_local_pca_power_30ep_multiseed.sh wdist`)。 +**主張:** Euclidean DBSCAN / ADBSCAN と **同程度の外れ値除去性能**(MCC / G-Mean)。主表に Topo W. 列は載せない。 +**正本 config:** `elongate_n100_no_cls_full120_teacher_local_pca`(`w_class=0`, `homology_dimensions=[1]`, `aniso_mode=elongate`, 軸投影・`min_b` 床なし)。 +出力先は `WDIST_OUT` / `MCC_OUT` / `LOG_ROOT`(既定: `outputs/supervised/pwr30_*`)で明示する(生成物は git に含めない)。 | 区分 | パス | |------|------| -| 本番 5-seed(計算中) | `run_teacher_local_pca_power_30ep_multiseed.sh`, `run_teacher_local_pca_power_30ep.py`, `aggregate_power_30ep_multiseed.py` | +| 本番 5-seed | `run_teacher_local_pca_power_30ep_multiseed.sh`, `run_teacher_local_pca_power_30ep.py`, `aggregate_power_30ep_multiseed.py` | | paper eval | `evaluate_paper_protocol.py` | | ベースライン | `evaluate_paper_baselines.py` | | チューニング(重みの出所) | `tune_elongate_wdist.py`, `tune_elongate_mcc.py`, `run_tune_local_pca_power_*` | -| 補助 | `launch_detached_screen.sh` | - -プロトコル・ソース一覧の詳細: ローカル `docs/experiments/20260710_power_dual_objective.md`(§使用ソースコード)、公開前添削: `docs/experiments/20260712_publication_scope.md`。 **実行記録:** 各 run の `source_revision`(git HEAD)は `logs/run_manifest.json` および `paper_metrics_*.json` に記録。未コミットのまま実行した場合、リモート clone では数値が再現できない。 +### W-Dist 契約(場所ごとの定義) + +| 経路 | homology | 距離 / filtration | 備考 | +|------|----------|-------------------|------| +| 訓練 `TopologicalLoss` / eval `compute_topo_wdist` | config の `homology_dimensions`(主表は `[1]`) | ellipse filtration + Wasserstein-2²(torch_topological) | 教師は `loss.teacher_mode`(主表 `local_pca`) | +| Gudhi `persistence.compute_w_distance` | H1-only | Euclidean Alpha / 点座標 | legacy baseline 用。主表の ellipse W-Dist とは別物 | +| `metrics` の W-Dist | 上記どちらかを明示引数で選択 | 引数不足は **hard-fail**(黙って 0 にしない) | | + +Trainer と `TopoWdistOptions` の欠落時既定はどちらも `teacher_mode=euclidean` / `prob_weighting=true`。主表 YAML が `local_pca` と `prob_weighting=false` を明示する。 + ## 環境 - **Python**: `.python-version` を参照(現状 3.12)。 @@ -171,7 +181,12 @@ clip や sigmoid による軸倍率の暗黙クリップは行わない。ellphi M_i=\max(a_i,b_i),\; m_i=\min(a_i,b_i). \] -主表 config では `aniso_mode: linear` により +主表 power 30ep config(`elongate_n100_no_cls_full120_teacher_local_pca`)では +`homology_dimensions: [1]`(H1-only Wasserstein)と `aniso_mode: elongate` を用いる。 +ellphi 退化(NaN 共分散・接線距離未定義など)は **軸投影や min_b 床で隠さず** +`run_status: failed` とする([Computational Reproducibility skill](https://github.com/t-uda/skills/blob/main/skills/computational-reproducibility/SKILL.md))。 + +別 ablation では `aniso_mode: linear` により $\mathcal{L}_{\mathrm{aniso}} = \frac{1}{N}\sum_i R_i$($R_i=M_i/m_i$)。 図用 ablation(`ablation_localscale_try2` 等)では `aniso_mode: barrier` として @@ -180,7 +195,7 @@ $\mathcal{L}_{\mathrm{aniso}} = \frac{10}{N}\sum_i \mathrm{ReLU}(R_i-\tau)^2$。 **Mahalanobis 距離**(`tda_ml/topology.py`)は outlier 確率 $p_i$ により二乗距離を $1/\bigl((1-p_i)(1-p_j)\bigr)$ で重み付け(`INLIER_PROB_MIN` で下限クリップ)。 -**数値安定化のみの定数**(モデリング床ではない)は `tda_ml/numerical_eps.py` に集約し、付録で列挙します。encoder の `clamp(0.2)` や legacy の `+10^{-4}` といった**論文に無い床は削除済み**(issue #59)。 +**数値安定化のみの定数**(モデリング床ではない)は `tda_ml/numerical_eps.py` に集約し、付録で列挙します。encoder の `clamp(0.2)` や legacy の `+10^{-4}` といった**論文に無い床は削除済み**(issue #59)。中心一致時の ellphi nudge(`TOPO_CENTER_SEPARATION_MIN`)は **既定オフ**(`reproducibility.allow_topo_center_separation: false`)。opt-in 時のみ topo / ellphi 経路で適用し manifest に記録する。 ### issue #59 検証(早期打ち切り + 診断) diff --git a/configs/README.md b/configs/README.md index 335abf8..393b6cb 100644 --- a/configs/README.md +++ b/configs/README.md @@ -9,8 +9,11 @@ Canonical YAML files live **in this directory** (deep-merged with `base.yaml` by | `dev.yaml` | Small MNIST subset for local wiring checks (non-official). | | `prod.yaml` | Longer CPU profile (non-official). | | `test_fast.yaml` | Small settings for quick checks and CI smoke. | +| `elongate_n100_no_cls_full120_teacher_local_pca.yaml` | Paper no_cls production (H1-only, local_pca teacher). | +| `elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml` | Optuna tune base for power + ellphi (W-Dist / MCC studies). | +| `elongate_n100_no_cls_full120_baseline.yaml` | no_cls baseline contrast (optional table column). | -**Historical / experiment-specific YAML** is **not** tracked in this public repository. If you maintain an `archive/` directory locally under `configs/`, you can still load it with `load_config("archive/")`. +**Probe / ablation / dated experiment YAML** (scaleinv, topo12, raw_axes, H1 launch variants, etc.) belongs under local `configs/archive/` and is **not** part of the publishable surface. Load with `load_config("archive/")` when present. ## Keys read by the training stack diff --git a/configs/base.yaml b/configs/base.yaml index 363f498..59d11d3 100644 --- a/configs/base.yaml +++ b/configs/base.yaml @@ -29,6 +29,8 @@ model: metric_type: "anisotropic" # "anisotropic" or "euclidean" distance_backend: "mahalanobis" # or "ellphi"; multiseed driver overrides per run ellphi_differentiable: true + # Default includes H0+H1; paper no_cls YAML sets [1] (H1-only). + homology_dimensions: [0, 1] threshold: 0.1 ellipse_param_dim: 5 point_dim: 2 @@ -84,4 +86,5 @@ reproducibility: allow_empty_cloud_fallback: false allow_skip_degenerate_grid_cells: false allow_otsu_threshold_fallback: false - allow_legacy_loss_keys: false \ No newline at end of file + allow_legacy_loss_keys: false + allow_topo_center_separation: false \ No newline at end of file diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml index caba2c3..680ee8c 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml @@ -1,6 +1,5 @@ -# Ablation: paper §3.3.2 teacher (local PCA ideal ellipses + same backend as prediction). -# Loss weights: baseline (maha-tune v1 anchors). v3 topo-only tune (w_size≈0) hurt DBSCAN MCC — reverted 2026-07-06. -# Switch teacher: loss.teacher_mode = euclidean | local_pca +# Paper no_cls production: local-PCA teacher + ellphi topo + H1-only Wasserstein. +# Degenerate ellphi geometry hard-fails (no axis projection / min_b floor). meta: config_id: "elongate_n100_no_cls_full120_teacher_local_pca" @@ -12,9 +11,10 @@ model: threshold: 0.5 topology_loss: metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed + distance_backend: "mahalanobis" # overridden per run by production driver ellphi_differentiable: true prob_weighting: false + homology_dimensions: [1] loss: w_class: 0.0 @@ -22,14 +22,12 @@ loss: w_aniso: 0.0541 w_size: 0.488 pos_weight: 1.0 - aniso_mode: "elongate" + aniso_mode: elongate size_mode: power size_ref: 1.34 size_power: 1.5 teacher_mode: local_pca teacher_local_pca_k: 10 - # Must match the tune base (elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc); - # tuned weights assume this teacher geometry. teacher_local_pca_normalize_axes: true training: @@ -38,8 +36,6 @@ training: grad_clip_value: 1.0 visualize_every: 10 warmup_epochs: 0 - # Train ckpt: val_topo (same TopologicalLoss as training; no per-epoch DBSCAN grid). - # DBSCAN grid + MCC: once after training via evaluate_paper_protocol.py (val → test). selection: metric: val_topo eval_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml index dd65b47..020a0ef 100644 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml @@ -14,6 +14,7 @@ model: distance_backend: "mahalanobis" # overridden per trial (--backend ellphi) ellphi_differentiable: true prob_weighting: false + homology_dimensions: [1] loss: w_class: 0.0 diff --git a/experiments/aggregate_power_30ep_multiseed.py b/experiments/aggregate_power_30ep_multiseed.py index 5ee727d..0f64f00 100644 --- a/experiments/aggregate_power_30ep_multiseed.py +++ b/experiments/aggregate_power_30ep_multiseed.py @@ -119,17 +119,17 @@ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None: def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--wdist-out", type=Path, default=REPO_ROOT / "outputs/supervised/0710_pwr30_wdist") - p.add_argument("--mcc-out", type=Path, default=REPO_ROOT / "outputs/supervised/0710_pwr30_mcc_maha") + p.add_argument("--wdist-out", type=Path, default=REPO_ROOT / "outputs/supervised/pwr30_wdist") + p.add_argument("--mcc-out", type=Path, default=REPO_ROOT / "outputs/supervised/pwr30_mcc_maha") p.add_argument( "--wdist-tune-json", - default="outputs/tune/0709_pwr_wdist/best_elongate_wdist_ellphi.json", + default="outputs/tune/pwr_wdist/best_elongate_wdist_ellphi.json", ) p.add_argument( "--mcc-tune-json", - default="outputs/tune/0709_pwr_mcc_dbscan_mahalanobis/best_elongate_mcc_ellphi_dbscan_mahalanobis.json", + default="outputs/tune/pwr_mcc_dbscan_mahalanobis/best_elongate_mcc_ellphi_dbscan_mahalanobis.json", ) - p.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs/supervised/0710_pwr30_multiseed") + p.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs/supervised/pwr30_multiseed") p.add_argument("--seeds", type=int, nargs="+", default=PAPER_SEEDS) p.add_argument( "--methods", diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_teacher_local_pca_power_30ep.py index 5c221ee..b583921 100644 --- a/experiments/run_teacher_local_pca_power_30ep.py +++ b/experiments/run_teacher_local_pca_power_30ep.py @@ -30,6 +30,7 @@ def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--epochs", type=int, default=30) p.add_argument("--seed", type=int, default=42) + p.add_argument("--base-config", type=str, default=BASE_CONFIG) p.add_argument("--size-ref", type=float, default=1.34) p.add_argument("--size-power", type=float, default=1.5) p.add_argument("--out-base", type=Path, default=None) @@ -98,13 +99,13 @@ def main() -> int: if tune_json is not None: preflight_tune_production_run( - base_config=BASE_CONFIG, + base_config=args.base_config, tune_json=tune_json, project_root=REPO_ROOT, out_base=out_base, ) - cfg = load_config(BASE_CONFIG, project_root=REPO_ROOT) + cfg = load_config(args.base_config, project_root=REPO_ROOT) loss_overrides: dict = { "size_mode": "power", "size_ref": args.size_ref, @@ -158,6 +159,14 @@ def main() -> int: "selection": cfg["training"]["selection"]["metric"], "tune_json": tune_source, "dbscan_backend": args.dbscan_backend, + "aniso_mode": cfg["loss"].get("aniso_mode"), + "homology_dimensions": cfg["model"]["topology_loss"].get( + "homology_dimensions", [0, 1] + ), + "protocol_note": ( + "power 30ep H1-only: raw ellipse params to ellphi; degenerate geometry " + "hard-fails; tune weights fixed from seed-42 Optuna" + ), "reference": tune_source or "recover baseline (~0.76 test MCC, quadratic size)", } cfg["_manifest_extras"] = { @@ -173,6 +182,8 @@ def main() -> int: "w_topo": purpose["weights"]["w_topo"], "w_aniso": purpose["weights"]["w_aniso"], "w_size": purpose["weights"]["w_size"], + "aniso_mode": purpose.get("aniso_mode"), + "homology_dimensions": purpose.get("homology_dimensions"), }, } @@ -201,7 +212,7 @@ def main() -> int: "--run-dir", str(run_dir), "--base-config", - BASE_CONFIG, + args.base_config, "--split", split, "--backend", diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh index f8b232f..84d2b3f 100755 --- a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh +++ b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh @@ -39,13 +39,14 @@ else SEEDS=(42 123 456 789 1024) fi -WDIST_JSON="${WDIST_JSON:-outputs/tune/0709_pwr_wdist/best_elongate_wdist_ellphi.json}" -MCC_JSON="${MCC_JSON:-outputs/tune/0709_pwr_mcc_dbscan_mahalanobis/best_elongate_mcc_ellphi_dbscan_mahalanobis.json}" -WDIST_OUT="${WDIST_OUT:-outputs/supervised/0710_pwr30_wdist}" -MCC_OUT="${MCC_OUT:-outputs/supervised/0710_pwr30_mcc_maha}" -LOG_ROOT="${LOG_ROOT:-outputs/supervised/0710_pwr30_multiseed}" +WDIST_JSON="${WDIST_JSON:-outputs/tune/pwr_wdist/best_elongate_wdist_ellphi.json}" +MCC_JSON="${MCC_JSON:-outputs/tune/pwr_mcc_dbscan_mahalanobis/best_elongate_mcc_ellphi_dbscan_mahalanobis.json}" +WDIST_OUT="${WDIST_OUT:-outputs/supervised/pwr30_wdist}" +MCC_OUT="${MCC_OUT:-outputs/supervised/pwr30_mcc_maha}" +LOG_ROOT="${LOG_ROOT:-outputs/supervised/pwr30_multiseed}" EPOCHS="${EPOCHS:-30}" DBSCAN_BACKEND="${DBSCAN_BACKEND:-mahalanobis}" +BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_full120_teacher_local_pca}" mkdir -p "${LOG_ROOT}" @@ -80,6 +81,7 @@ _run_seed() { MKL_NUM_THREADS="${THREADS_PER_WORKER}" \ OPENBLAS_NUM_THREADS="${THREADS_PER_WORKER}" \ uv run python -u experiments/run_teacher_local_pca_power_30ep.py \ + --base-config "${BASE_CONFIG}" \ --epochs "${EPOCHS}" \ --seed "${seed}" \ --out-base "${out_base}" \ diff --git a/experiments/run_tune_local_pca_power_wdist_parallel.sh b/experiments/run_tune_local_pca_power_wdist_parallel.sh index bd7ed9b..9918905 100755 --- a/experiments/run_tune_local_pca_power_wdist_parallel.sh +++ b/experiments/run_tune_local_pca_power_wdist_parallel.sh @@ -21,11 +21,12 @@ N_WORKERS="${1:-4}" N_TRIALS="${2:-24}" TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" -BASE_CONFIG="elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc" +BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc}" OUT_BASE="${OUT_BASE:-outputs/tune/0709_pwr_wdist}" -STUDY_NAME="elongate_local_pca_power_wdist_${BACKEND}" +STUDY_NAME="${STUDY_NAME:-elongate_local_pca_power_wdist_${BACKEND}}" STORAGE="sqlite:///${OUT_BASE}/study.db" -THREADS_PER_WORKER=12 +THREADS_PER_WORKER="${THREADS_PER_WORKER:-12}" +TRIALS_PER_WORKER="${TRIALS_PER_WORKER:-${N_TRIALS}}" mkdir -p "${OUT_BASE}" cat > "${OUT_BASE}/PURPOSE.md" < argparse.Namespace: # (power stack uses elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc). p.add_argument("--base-config", type=str, required=True) p.add_argument("--n-trials", type=int, default=50) + p.add_argument( + "--max-complete-trials", + type=int, + default=None, + help="Shared-study completion cap; defaults to --n-trials.", + ) p.add_argument( "--n-startup-trials", type=int, @@ -241,10 +247,43 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() Path(args.out_base).mkdir(parents=True, exist_ok=True) - preflight_wdist_tune_study( + preflight = preflight_wdist_tune_study( base_config=args.base_config, project_root=REPO_ROOT, out_base=args.out_base, + config_overrides={ + "model": { + "topology_loss": { + "distance_backend": args.backend, + "prob_weighting": False, + } + }, + "loss": { + "size_mode": args.size_mode, + "size_ref": args.size_ref, + "size_power": args.size_power, + }, + "training": {"epochs": args.tune_epochs}, + "outputs": {"base_dir": args.out_base}, + }, + ) + preflight.update( + { + "run_status": "pending", + "command_entry": "experiments/tune_elongate_wdist.py", + "source_revision": git_revision(REPO_ROOT), + "study_name": args.study_name, + "storage": args.storage, + "n_trials": args.n_trials, + "max_complete_trials": args.max_complete_trials or args.n_trials, + "n_startup_trials": args.n_startup_trials, + "sampler_seed": args.seed, + "fallbacks": [], + } + ) + (Path(args.out_base) / f"WORKER_PREFLIGHT_seed{args.seed}.json").write_text( + json.dumps(preflight, indent=2) + "\n", + encoding="utf-8", ) study = optuna.create_study( @@ -258,8 +297,9 @@ def main() -> int: if not args.write_best: callbacks = [] if args.storage: + max_complete = args.max_complete_trials or args.n_trials callbacks.append( - MaxTrialsCallback(args.n_trials, states=(TrialState.COMPLETE,)) + MaxTrialsCallback(max_complete, states=(TrialState.COMPLETE,)) ) study.optimize( make_objective(args), @@ -287,6 +327,7 @@ def main() -> int: "teacher_mode": "local_pca", "tune_epochs": args.tune_epochs, "n_trials": args.n_trials, + "max_complete_trials": args.max_complete_trials or args.n_trials, "n_startup_trials": args.n_startup_trials, "search_space": { "w_aniso": list(w_aniso_range), diff --git a/tda_ml/distance_backend.py b/tda_ml/distance_backend.py index d060673..a4854f6 100644 --- a/tda_ml/distance_backend.py +++ b/tda_ml/distance_backend.py @@ -58,18 +58,22 @@ def rescale_distance_matrix( ``clean_scale`` (m_e) is the median pairwise Euclidean distance of the teacher cloud for this sample. In ``median`` mode the prediction is scaled so its median - matches m_e, i.e. it is brought onto the (untouched) teacher's Euclidean scale; - without m_e it falls back to a per-sample unit median. Any other ``scale_mode`` - applies the fixed scalar ``eps_scale`` (1.0 == no-op). + matches m_e. ``clean_scale`` is required (missing scale hard-fails). Any other + ``scale_mode`` applies the fixed scalar ``eps_scale`` (1.0 == no-op). """ if scale_mode == "median": + if clean_scale is None: + raise RuntimeError( + "scale_mode='median' requires clean_scale (teacher median Euclidean); " + "refusing silent unit-median fallback." + ) off = d_mat[d_mat > 0] if off.numel() == 0: - return d_mat + raise RuntimeError( + "scale_mode='median' found no positive off-diagonal distances." + ) denom = torch.median(off).detach() + NUMERICAL_EPS - if clean_scale is not None: - return d_mat * (float(clean_scale) / denom) - return d_mat / denom + return d_mat * (float(clean_scale) / denom) if eps_scale != 1.0: return d_mat * eps_scale return d_mat @@ -117,6 +121,7 @@ def compute_distance_matrix_batch( symmetrize: str, backend: str, ellphi_differentiable: bool = True, + allow_topo_center_separation: bool = False, ) -> torch.Tensor: """ Compute batched distance matrices with shape ``(B, N, N)``. @@ -124,8 +129,11 @@ def compute_distance_matrix_batch( backend: - ``mahalanobis``: ``compute_anisotropic_distance_matrix`` (differentiable) - ``ellphi``: tangency distance. If ``ellphi_differentiable=True`` and - ``ellphi.grad`` is available, gradients flow to centers/covariances - (therefore to ellipse parameters). + ``ellphi.grad`` is available, gradients flow to centers/covariances. + Missing grad API is a hard-fail (no NumPy fallback). + + ``allow_topo_center_separation``: when True, nudge coincident ellipse centers + by ``TOPO_CENTER_SEPARATION_MIN`` before ellphi (opt-in; default off). For ``ellphi``, ``probs``-based weighting is currently unsupported and ignored. """ @@ -167,6 +175,8 @@ def compute_distance_matrix_batch( for i in range(batch_size): if use_torch: c, cov = ellipse_params_to_centers_cov(points[i], params[i]) + if allow_topo_center_separation: + c = separate_coincident_centers_for_topo(c, TOPO_CENTER_SEPARATION_MIN) mats.append(pdist_tangency_matrix_differentiable(c, cov)) else: dm = compute_ellphi_distance_matrix_np( @@ -188,8 +198,7 @@ def compute_topo_distance_matrix( Shared topology-loss entrypoint: map batched points/ellipse params to ``(B,N,N)`` distances. ``distance_mode`` is ``mahalanobis`` or ``ellphi``. - When ``ellphi_backend='auto'``, a differentiable ellphi path is preferred and - falls back to NumPy when unavailable. + Differentiable ellphi requires ``ellphi.grad``; otherwise hard-fail. """ backend = normalize_topo_distance_mode(distance_mode) eb = str(ellphi_backend).strip().lower() diff --git a/tda_ml/losses.py b/tda_ml/losses.py index ff19a2e..2351738 100644 --- a/tda_ml/losses.py +++ b/tda_ml/losses.py @@ -11,6 +11,10 @@ subsample_indices, ) from tda_ml.numerical_eps import NUMERICAL_EPS +from tda_ml.persistence_dimensions import ( + normalize_homology_dimensions, + select_persistence_dimensions, +) from tda_ml.reproducibility import record_fallback logger = logging.getLogger(__name__) @@ -145,17 +149,16 @@ def forward(self, params: torch.Tensor) -> torch.Tensor: class TopologicalLoss(nn.Module): """ - Computes the Topological Loss between the predicted anisotropic filtration - and the clean ground truth persistence diagram using Wasserstein distance. + Topological loss: Wasserstein-2 squared between predicted and teacher PDs. + + ``homology_dimensions`` selects which VR diagrams enter the Wasserstein sum + (default ``(0, 1)``). Paper no_cls stack uses ``[1]`` (H1-only). - distance_backend: - - ``mahalanobis``: differentiable anisotropic distance - - ``ellphi``: tangency distance. With ``ellphi_differentiable=True``, - gradients can flow to ellipse parameters via ``ellphi.grad`` (numerical - singularities may still produce NaN/zero gradients). + ``distance_backend``: + - ``mahalanobis``: anisotropic distance (optional prob weighting) + - ``ellphi``: tangency distance via ``ellphi.grad`` when differentiable - ellphi_differentiable: - If ``False``, use NumPy ``EllipseCloud.pdist_tangency`` only (no gradients). + Degenerate ellphi geometry raises ``RuntimeError`` (no silent axis projection). """ def __init__( self, @@ -166,7 +169,9 @@ def __init__( eps_scale: float = 1.0, scale_mode: str = "fixed", max_points: int | None = None, + homology_dimensions: tuple[int, ...] | list[int] | None = None, strict_topo_samples: bool = True, + allow_topo_center_separation: bool = False, manifest_ref: dict | None = None, ): super().__init__() @@ -183,11 +188,8 @@ def __init__( # tangency distances live on a different scale than Euclidean, so without this # the topology loss is dominated by scale rather than shape. # - scale_mode="fixed": D <- D * eps_scale (scalar; eps_scale=1.0 == no-op) - # - scale_mode="median": bring the *prediction* onto the teacher's Euclidean - # scale: D <- D * (m_e / median(offdiag D)), where m_e is the median pairwise - # Euclidean distance of the clean cloud (provided by the trainer as - # ``clean_scales``). The teacher PD is left untouched. If m_e is unavailable, - # fall back to D <- D / median(offdiag D) (per-sample unit median). + # - scale_mode="median": D <- D * (m_e / median(offdiag D)); m_e required + # (missing clean_scale hard-fails; no silent unit-median fallback). self.eps_scale = float(eps_scale) self.scale_mode = str(scale_mode).strip().lower() if self.scale_mode not in ("fixed", "median"): @@ -198,7 +200,11 @@ def __init__( self.max_points = int(max_points) if max_points is not None else None if self.max_points is not None and self.max_points < 2: raise ValueError(f"max_points must be >= 2, got {self.max_points}") + self.homology_dimensions = normalize_homology_dimensions( + homology_dimensions + ) self.strict_topo_samples = bool(strict_topo_samples) + self.allow_topo_center_separation = bool(allow_topo_center_separation) self.manifest_ref = manifest_ref self.vr_complex = VietorisRipsComplex(dim=1) self.wasserstein = WassersteinDistance(q=2) @@ -247,12 +253,21 @@ def forward(self, points, params, logits, clean_pd_info, clean_scales=None): symmetrize="max", backend=self.distance_backend, ellphi_differentiable=self.ellphi_differentiable, + allow_topo_center_separation=self.allow_topo_center_separation, ) clean_scale_i = clean_scales[i] if clean_scales is not None else None d_mat = self._rescale_distance_matrix(d_batch[0], clean_scale=clean_scale_i) try: pd_pred_info = self.vr_complex(d_mat, treat_as_distances=True) - loss_sample = self.wasserstein(pd_pred_info, clean_pd_info[i]) ** 2 + pd_pred_selected = select_persistence_dimensions( + pd_pred_info, self.homology_dimensions + ) + clean_pd_selected = select_persistence_dimensions( + clean_pd_info[i], self.homology_dimensions + ) + loss_sample = ( + self.wasserstein(pd_pred_selected, clean_pd_selected) ** 2 + ) if torch.isnan(loss_sample): msg = f"nan or inf Wasserstein loss at batch_index={i}" diff --git a/tda_ml/metrics.py b/tda_ml/metrics.py index c1e0402..1744cac 100644 --- a/tda_ml/metrics.py +++ b/tda_ml/metrics.py @@ -50,10 +50,15 @@ def compute_recall_specificity_gmean_mcc_wdist( recall, specificity, gmean, mcc = compute_recall_specificity_gmean_mcc( labels_gt, labels_pred ) - w_dist = 0.0 if points is not None and params is not None and clean_pc is not None: w_dist = float(compute_topo_wdist(points, params, clean_pc, topo_options)) elif points is not None and gt_inliers is not None: pred_inliers = points[np.asarray(labels_pred) == 0] w_dist = float(compute_w_distance(pred_inliers, gt_inliers)) + else: + raise ValueError( + "W-Dist requires either (points, params, clean_pc) for ellipse-filtration " + "topo W-Dist, or (points, gt_inliers) for legacy Euclidean Alpha W-Dist; " + "refusing silent zero." + ) return recall, specificity, gmean, mcc, w_dist diff --git a/tda_ml/persistence_dimensions.py b/tda_ml/persistence_dimensions.py new file mode 100644 index 0000000..3714244 --- /dev/null +++ b/tda_ml/persistence_dimensions.py @@ -0,0 +1,51 @@ +"""Explicit homology-dimension selection for persistence losses and metrics.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from typing import Any + +SUPPORTED_HOMOLOGY_DIMENSIONS = (0, 1) + + +def normalize_homology_dimensions(value: Iterable[int] | None) -> tuple[int, ...]: + """Validate an explicit subset of the currently supported dimensions.""" + if value is None: + return SUPPORTED_HOMOLOGY_DIMENSIONS + dimensions = tuple(int(dim) for dim in value) + if not dimensions: + raise ValueError("homology_dimensions must not be empty") + if len(dimensions) != len(set(dimensions)): + raise ValueError(f"homology_dimensions contains duplicates: {dimensions}") + unsupported = [ + dim for dim in dimensions if dim not in SUPPORTED_HOMOLOGY_DIMENSIONS + ] + if unsupported: + raise ValueError( + f"Unsupported homology dimensions {unsupported}; " + f"supported={SUPPORTED_HOMOLOGY_DIMENSIONS}" + ) + return tuple(sorted(dimensions)) + + +def select_persistence_dimensions( + persistence_info: Sequence[Any], + dimensions: Iterable[int] | None, +) -> list[Any]: + """Select persistence records by their declared ``dimension`` field.""" + requested = normalize_homology_dimensions(dimensions) + by_dimension: dict[int, Any] = {} + for info in persistence_info: + if not hasattr(info, "dimension"): + raise TypeError("Persistence record is missing a 'dimension' attribute") + dim = int(info.dimension) + if dim in by_dimension: + raise ValueError(f"Duplicate persistence record for H{dim}") + by_dimension[dim] = info + missing = [dim for dim in requested if dim not in by_dimension] + if missing: + raise ValueError( + f"Persistence output missing requested dimensions {missing}; " + f"available={sorted(by_dimension)}" + ) + return [by_dimension[dim] for dim in requested] diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py index 3d2c8af..d88db6e 100644 --- a/tda_ml/preflight.py +++ b/tda_ml/preflight.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any, Literal -from tda_ml.config import load_config +from tda_ml.config import deep_update, load_config from tda_ml.reproducibility import ( RUN_STATUS_NOT_RUN, assert_ellphi_differentiable_available, @@ -41,7 +41,7 @@ def classify_tune_objective( *, objective_kind: str | None = None, ) -> TuneObjectiveKind: - """Resolve tune objective kind from explicit field, whitelist, or substring fallback.""" + """Resolve tune objective kind from explicit field or whitelist (no substring guess).""" if objective_kind is not None: kind = str(objective_kind).lower().strip() if kind in ("mcc", "wdist"): @@ -57,13 +57,9 @@ def classify_tune_objective( if lower in _KNOWN_TUNE_OBJECTIVES: return _KNOWN_TUNE_OBJECTIVES[lower] - if "wdist" in lower: - return "wdist" - if "mcc" in lower: - return "mcc" raise ValueError( f"Unrecognized tune objective {objective!r}; set objective_kind in tune JSON " - "or use a name containing 'mcc' or 'wdist'." + f"or use a whitelist name: {sorted(_KNOWN_TUNE_OBJECTIVES)}." ) @@ -113,6 +109,7 @@ def preflight_training_config( "config_id": config.get("meta", {}).get("config_id"), "distance_backend": backend, "distance_backend_impl": impl, + "homology_dimensions": list(topo.get("homology_dimensions", [0, 1])), "seed": config.get("data", {}).get("seed"), "ellphi_repo": build_ellphi_repo_manifest_fields(root), } @@ -155,9 +152,12 @@ def preflight_tune_study( project_root: Path | str, out_base: Path | str, study_objective: TuneObjectiveKind, + config_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: root = Path(project_root) cfg = load_config(base_config, project_root=root) + if config_overrides: + cfg = deep_update(cfg, config_overrides) dbscan_grid_from_config(cfg) preview = preflight_training_config(cfg, project_root=root) out = Path(out_base) @@ -167,6 +167,7 @@ def preflight_tune_study( preview["out_base"] = str(out) preview["base_config"] = base_config preview["study_objective"] = study_objective + preview["config_overrides"] = config_overrides or {} write_json(out / "STUDY_PREFLIGHT.json", preview) return preview @@ -190,12 +191,14 @@ def preflight_wdist_tune_study( base_config: str, project_root: Path | str, out_base: Path | str, + config_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: return preflight_tune_study( base_config=base_config, project_root=project_root, out_base=out_base, study_objective="wdist", + config_overrides=config_overrides, ) diff --git a/tda_ml/reproducibility.py b/tda_ml/reproducibility.py index 3b18cba..74bbedf 100644 --- a/tda_ml/reproducibility.py +++ b/tda_ml/reproducibility.py @@ -97,6 +97,9 @@ def reproducibility_settings(config: dict[str, Any]) -> dict[str, bool]: rep.get("allow_otsu_threshold_fallback", False) ), "allow_legacy_loss_keys": bool(rep.get("allow_legacy_loss_keys", False)), + "allow_topo_center_separation": bool( + rep.get("allow_topo_center_separation", False) + ), } diff --git a/tda_ml/topo_wdist.py b/tda_ml/topo_wdist.py index 427697e..cf6deb6 100644 --- a/tda_ml/topo_wdist.py +++ b/tda_ml/topo_wdist.py @@ -19,19 +19,32 @@ rescale_distance_matrix, subsample_indices, ) +from tda_ml.persistence_dimensions import ( + normalize_homology_dimensions, + select_persistence_dimensions, +) from tda_ml.teacher_pd import compute_clean_teacher_batch @dataclass(frozen=True) class TopoWdistOptions: - teacher_mode: str = "local_pca" + # Must match Trainer default unless config overrides it. + teacher_mode: str = "euclidean" distance_backend: str = "ellphi" teacher_local_pca_k: int = 10 teacher_local_pca_normalize_axes: bool = True eps_scale: float = 1.0 scale_mode: str = "fixed" max_points: int | None = None - prob_weighting: bool = False + prob_weighting: bool = True + homology_dimensions: tuple[int, ...] = (0, 1) + + def __post_init__(self) -> None: + object.__setattr__( + self, + "homology_dimensions", + normalize_homology_dimensions(self.homology_dimensions), + ) def topo_wdist_options_from_config(config: dict[str, Any]) -> TopoWdistOptions: @@ -41,7 +54,7 @@ def topo_wdist_options_from_config(config: dict[str, Any]) -> TopoWdistOptions: max_pts = training_cfg.get("topo_loss_max_points", loss_cfg.get("topo_loss_max_points")) return TopoWdistOptions( teacher_mode=str( - loss_cfg.get("teacher_mode", training_cfg.get("teacher_mode", "local_pca")) + loss_cfg.get("teacher_mode", training_cfg.get("teacher_mode", "euclidean")) ).strip().lower(), distance_backend=str(topo_cfg.get("distance_backend", "mahalanobis")).lower().strip(), teacher_local_pca_k=int( @@ -64,6 +77,9 @@ def topo_wdist_options_from_config(config: dict[str, Any]) -> TopoWdistOptions: ).strip().lower(), max_points=int(max_pts) if max_pts is not None else None, prob_weighting=bool(topo_cfg.get("prob_weighting", True)), + homology_dimensions=normalize_homology_dimensions( + topo_cfg.get("homology_dimensions") + ), ) @@ -85,7 +101,7 @@ def compute_topo_wdist( options: TopoWdistOptions | None = None, ) -> float: """ - Wasserstein-2 distance between H1 PDs (same units as ``TopologicalLoss`` term). + Wasserstein-2 distance over configured homology dimensions. ``points`` / ``params``: full noisy cloud and learned ellipses (DBSCAN unused). ``clean_pc``: padded clean inlier coordinates for the teacher PD. @@ -134,7 +150,13 @@ def compute_topo_wdist( clean_scale=clean_scale_i, ) pd_pred = vr(d_mat, treat_as_distances=True) - w2_sq = wdist_fn(pd_pred, clean_pd_info[0]) ** 2 + pd_pred_selected = select_persistence_dimensions( + pd_pred, opts.homology_dimensions + ) + clean_pd_selected = select_persistence_dimensions( + clean_pd_info[0], opts.homology_dimensions + ) + w2_sq = wdist_fn(pd_pred_selected, clean_pd_selected) ** 2 value = float(w2_sq.item()) if not np.isfinite(value): raise RuntimeError("non-finite topo W-Dist") diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 35ce2fd..3167daa 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -180,6 +180,7 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: self.distance_backend = _topo.get("distance_backend", "mahalanobis") self.ellphi_differentiable = _topo.get("ellphi_differentiable", True) self.prob_weighting = bool(_topo.get("prob_weighting", True)) + self.homology_dimensions = _topo.get("homology_dimensions") # Filtration-unit alignment for the topology loss (see TopologicalLoss). # Legacy knob `training.topo_eps_scale` (v73=0.7022); also accept loss.topo_eps_scale. self.topo_eps_scale = float( @@ -204,7 +205,7 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: ) ) logger.info( - "Topological distance backend: %s%s (prob_weighting=%s, scale_mode=%s, eps_scale=%s, teacher_mode=%s%s)", + "Topological distance backend: %s%s (prob_weighting=%s, homology_dimensions=%s, scale_mode=%s, eps_scale=%s, teacher_mode=%s%s)", self.distance_backend, ( f" (ellphi_differentiable={self.ellphi_differentiable})" @@ -212,6 +213,7 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: else "" ), self.prob_weighting, + self.homology_dimensions if self.homology_dimensions is not None else [0, 1], self.topo_scale_mode, self.topo_eps_scale, self.teacher_mode, @@ -234,9 +236,13 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: eps_scale=self.topo_eps_scale, scale_mode=self.topo_scale_mode, max_points=self.topo_loss_max_points, + homology_dimensions=self.homology_dimensions, strict_topo_samples=self._repro["strict_topo_samples"], + allow_topo_center_separation=self._repro["allow_topo_center_separation"], manifest_ref=self._manifest_ref, ) + self.homology_dimensions = self.topo_loss_fn.homology_dimensions + self._manifest_ref["homology_dimensions"] = list(self.homology_dimensions) self.size_loss_fn = SizeRegularizationLoss( w_major=self.lambda_major, w_minor=self.lambda_minor, @@ -332,7 +338,11 @@ def train_epoch(self, data_loader, epoch): topo_loss = torch.tensor(0.0, device=self.device) if clean_pd_info is not None: topo_loss = self.topo_loss_fn( - data, params, logits, clean_pd_info, clean_scales=clean_scales + data, + params, + logits, + clean_pd_info, + clean_scales=clean_scales, ) # Regularization Losses (Size and Anisotropy only, as per slides) diff --git a/tests/test_ellipse_barrier_losses.py b/tests/test_ellipse_barrier_losses.py index 12a9e45..731d0cb 100644 --- a/tests/test_ellipse_barrier_losses.py +++ b/tests/test_ellipse_barrier_losses.py @@ -4,7 +4,10 @@ import torch -from tda_ml.losses import AnisotropyPenaltyLoss, SizeRegularizationLoss +from tda_ml.losses import ( + AnisotropyPenaltyLoss, + SizeRegularizationLoss, +) class TestEllipseBarrierLosses(unittest.TestCase): diff --git a/tests/test_metrics.py b/tests/test_metrics.py index dcbb0a3..ad5290d 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -57,6 +57,12 @@ def test_compute_metrics_with_wdist_empty_pred_inliers(self): self.assertAlmostEqual(wdist, expected, places=6) self.assertTrue(np.isfinite(wdist)) + def test_wdist_missing_inputs_hard_fail(self): + y_true = np.array([0, 1], dtype=int) + y_pred = np.array([0, 1], dtype=int) + with self.assertRaises(ValueError): + compute_recall_specificity_gmean_mcc_wdist(y_true, y_pred) + @staticmethod def _circle_gt_inliers(n: int = 12) -> np.ndarray: theta = np.linspace(0, 2 * np.pi, n, endpoint=False) diff --git a/tests/test_persistence_dimensions.py b/tests/test_persistence_dimensions.py new file mode 100644 index 0000000..fcaf97e --- /dev/null +++ b/tests/test_persistence_dimensions.py @@ -0,0 +1,87 @@ +"""Tests for explicit H0/H1 selection in topology losses.""" + +from __future__ import annotations + +import unittest + +import torch +from torch_topological.nn import VietorisRipsComplex, WassersteinDistance + +from tda_ml.persistence_dimensions import ( + normalize_homology_dimensions, + select_persistence_dimensions, +) +from tda_ml.losses import TopologicalLoss +from tda_ml.topo_wdist import topo_wdist_options_from_config + + +class TestPersistenceDimensions(unittest.TestCase): + def setUp(self): + points = torch.tensor( + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] + ) + self.info = VietorisRipsComplex(dim=1)(points) + + def test_default_selects_h0_and_h1(self): + self.assertEqual(normalize_homology_dimensions(None), (0, 1)) + selected = select_persistence_dimensions(self.info, None) + self.assertEqual([item.dimension for item in selected], [0, 1]) + + def test_h1_only_selects_one_diagram(self): + selected = select_persistence_dimensions(self.info, [1]) + self.assertEqual(len(selected), 1) + self.assertEqual(selected[0].dimension, 1) + + def test_invalid_dimensions_hard_fail(self): + with self.assertRaises(ValueError): + normalize_homology_dimensions([]) + with self.assertRaises(ValueError): + normalize_homology_dimensions([1, 1]) + with self.assertRaises(ValueError): + normalize_homology_dimensions([2]) + + def test_wasserstein_h1_only_differs_from_h0_h1(self): + # Use two actual point clouds for a stable comparison. + x = torch.tensor( + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] + ) + y = torch.tensor( + [[0.0, 0.0], [1.2, 0.0], [1.0, 1.0], [0.0, 1.0]] + ) + vr = VietorisRipsComplex(dim=1) + px, py = vr(x), vr(y) + wd = WassersteinDistance(q=2) + both = wd(px, py) + h1 = wd( + select_persistence_dimensions(px, [1]), + select_persistence_dimensions(py, [1]), + ) + self.assertTrue(torch.isfinite(both)) + self.assertTrue(torch.isfinite(h1)) + self.assertNotEqual(float(both), float(h1)) + + def test_topo_wdist_options_read_h1_only(self): + cfg = {"model": {"topology_loss": {"homology_dimensions": [1]}}} + opts = topo_wdist_options_from_config(cfg) + self.assertEqual(opts.homology_dimensions, (1,)) + + def test_topological_loss_runs_with_h1_only(self): + theta = torch.linspace(0.0, 2.0 * torch.pi, 9)[:-1] + points = torch.stack((torch.cos(theta), torch.sin(theta)), dim=1).unsqueeze(0) + params = torch.zeros(1, 8, 3) + params[..., :2] = 1.0 + logits = torch.zeros(1, 8, 1) + clean = [VietorisRipsComplex(dim=1)(points[0])] + loss_fn = TopologicalLoss( + weight=1.0, + distance_backend="mahalanobis", + prob_weighting=False, + homology_dimensions=[1], + ) + loss = loss_fn(points, params, logits, clean) + self.assertTrue(torch.isfinite(loss)) + self.assertEqual(loss_fn.homology_dimensions, (1,)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reproducibility_strict.py b/tests/test_reproducibility_strict.py index f811396..9b638fe 100644 --- a/tests/test_reproducibility_strict.py +++ b/tests/test_reproducibility_strict.py @@ -47,6 +47,7 @@ def test_strict_defaults(self): self.assertFalse(settings["allow_nan_batch_skip"]) self.assertFalse(settings["allow_empty_cloud_fallback"]) self.assertFalse(settings["allow_legacy_loss_keys"]) + self.assertFalse(settings["allow_topo_center_separation"]) def test_resolve_dbscan_grid_explicit_override(self): from tda_ml.config import load_config @@ -76,6 +77,8 @@ def test_classify_tune_objective(self): classify_tune_objective("val_topo_wdist_min"), "wdist", ) + with self.assertRaises(ValueError): + classify_tune_objective("custom_objective_with_wdist_substring") def test_selection_default_is_val_topo(self): from tda_ml.model_selection import selection_settings_from_config diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index c7938a1..e1c2900 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -31,6 +31,23 @@ def test_local_pca_tune_base(self): self.assertEqual(cfg["model"]["topology_loss"]["distance_backend"], "ellphi") self.assertFalse(cfg["model"]["topology_loss"]["prob_weighting"]) + def test_h1_tune_preserves_declared_regularizer_stack(self): + cfg = build_trial_config( + "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1", + w_aniso=0.1, + w_size=0.2, + w_topo=0.3, + lr=1e-4, + backend="ellphi", + tune_epochs=20, + out_base="outputs/test_tune_h1", + trial_number=0, + size_mode="power", + ) + self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) + self.assertEqual(cfg["loss"]["aniso_mode"], "elongate") + self.assertEqual(cfg["loss"]["size_mode"], "power") + if __name__ == "__main__": unittest.main() From 5ce157696687a39e3fb9c4c32cbff9fad7e38fbb Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 10:08:30 +0900 Subject: [PATCH 16/44] =?UTF-8?q?fix:=20ellphi=20=E9=80=80=E5=8C=96?= =?UTF-8?q?=E8=A3=9C=E6=AD=A3=E3=82=92=E5=85=AC=E9=96=8B=E6=AD=A3=E6=9C=AC?= =?UTF-8?q?=E3=81=8B=E3=82=89=E9=99=A4=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接線外れ値実験由来の中心 nudge を持ち込まず、中心一致を含む退化を一貫して hard-fail させる。 Co-authored-by: Cursor --- REPRODUCIBILITY.md | 2 +- configs/base.yaml | 3 +-- tda_ml/distance_backend.py | 6 ------ tda_ml/losses.py | 3 --- tda_ml/reproducibility.py | 3 --- tda_ml/trainer.py | 1 - tests/test_reproducibility_strict.py | 1 - tests/test_tune_config.py | 6 +++--- 8 files changed, 5 insertions(+), 20 deletions(-) diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index c479c05..36e80b6 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -195,7 +195,7 @@ $\mathcal{L}_{\mathrm{aniso}} = \frac{10}{N}\sum_i \mathrm{ReLU}(R_i-\tau)^2$。 **Mahalanobis 距離**(`tda_ml/topology.py`)は outlier 確率 $p_i$ により二乗距離を $1/\bigl((1-p_i)(1-p_j)\bigr)$ で重み付け(`INLIER_PROB_MIN` で下限クリップ)。 -**数値安定化のみの定数**(モデリング床ではない)は `tda_ml/numerical_eps.py` に集約し、付録で列挙します。encoder の `clamp(0.2)` や legacy の `+10^{-4}` といった**論文に無い床は削除済み**(issue #59)。中心一致時の ellphi nudge(`TOPO_CENTER_SEPARATION_MIN`)は **既定オフ**(`reproducibility.allow_topo_center_separation: false`)。opt-in 時のみ topo / ellphi 経路で適用し manifest に記録する。 +**数値安定化のみの定数**(モデリング床ではない)は `tda_ml/numerical_eps.py` に集約し、付録で列挙します。encoder の `clamp(0.2)` や legacy の `+10^{-4}` といった**論文に無い床は削除済み**(issue #59)。中心一致を含む ellphi 退化は補正せず hard-fail します。 ### issue #59 検証(早期打ち切り + 診断) diff --git a/configs/base.yaml b/configs/base.yaml index 59d11d3..1369102 100644 --- a/configs/base.yaml +++ b/configs/base.yaml @@ -86,5 +86,4 @@ reproducibility: allow_empty_cloud_fallback: false allow_skip_degenerate_grid_cells: false allow_otsu_threshold_fallback: false - allow_legacy_loss_keys: false - allow_topo_center_separation: false \ No newline at end of file + allow_legacy_loss_keys: false \ No newline at end of file diff --git a/tda_ml/distance_backend.py b/tda_ml/distance_backend.py index a4854f6..84a8d13 100644 --- a/tda_ml/distance_backend.py +++ b/tda_ml/distance_backend.py @@ -121,7 +121,6 @@ def compute_distance_matrix_batch( symmetrize: str, backend: str, ellphi_differentiable: bool = True, - allow_topo_center_separation: bool = False, ) -> torch.Tensor: """ Compute batched distance matrices with shape ``(B, N, N)``. @@ -132,9 +131,6 @@ def compute_distance_matrix_batch( ``ellphi.grad`` is available, gradients flow to centers/covariances. Missing grad API is a hard-fail (no NumPy fallback). - ``allow_topo_center_separation``: when True, nudge coincident ellipse centers - by ``TOPO_CENTER_SEPARATION_MIN`` before ellphi (opt-in; default off). - For ``ellphi``, ``probs``-based weighting is currently unsupported and ignored. """ b = backend.lower().strip() @@ -175,8 +171,6 @@ def compute_distance_matrix_batch( for i in range(batch_size): if use_torch: c, cov = ellipse_params_to_centers_cov(points[i], params[i]) - if allow_topo_center_separation: - c = separate_coincident_centers_for_topo(c, TOPO_CENTER_SEPARATION_MIN) mats.append(pdist_tangency_matrix_differentiable(c, cov)) else: dm = compute_ellphi_distance_matrix_np( diff --git a/tda_ml/losses.py b/tda_ml/losses.py index 2351738..b40977d 100644 --- a/tda_ml/losses.py +++ b/tda_ml/losses.py @@ -171,7 +171,6 @@ def __init__( max_points: int | None = None, homology_dimensions: tuple[int, ...] | list[int] | None = None, strict_topo_samples: bool = True, - allow_topo_center_separation: bool = False, manifest_ref: dict | None = None, ): super().__init__() @@ -204,7 +203,6 @@ def __init__( homology_dimensions ) self.strict_topo_samples = bool(strict_topo_samples) - self.allow_topo_center_separation = bool(allow_topo_center_separation) self.manifest_ref = manifest_ref self.vr_complex = VietorisRipsComplex(dim=1) self.wasserstein = WassersteinDistance(q=2) @@ -253,7 +251,6 @@ def forward(self, points, params, logits, clean_pd_info, clean_scales=None): symmetrize="max", backend=self.distance_backend, ellphi_differentiable=self.ellphi_differentiable, - allow_topo_center_separation=self.allow_topo_center_separation, ) clean_scale_i = clean_scales[i] if clean_scales is not None else None d_mat = self._rescale_distance_matrix(d_batch[0], clean_scale=clean_scale_i) diff --git a/tda_ml/reproducibility.py b/tda_ml/reproducibility.py index 74bbedf..3b18cba 100644 --- a/tda_ml/reproducibility.py +++ b/tda_ml/reproducibility.py @@ -97,9 +97,6 @@ def reproducibility_settings(config: dict[str, Any]) -> dict[str, bool]: rep.get("allow_otsu_threshold_fallback", False) ), "allow_legacy_loss_keys": bool(rep.get("allow_legacy_loss_keys", False)), - "allow_topo_center_separation": bool( - rep.get("allow_topo_center_separation", False) - ), } diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 3167daa..5aab399 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -238,7 +238,6 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: max_points=self.topo_loss_max_points, homology_dimensions=self.homology_dimensions, strict_topo_samples=self._repro["strict_topo_samples"], - allow_topo_center_separation=self._repro["allow_topo_center_separation"], manifest_ref=self._manifest_ref, ) self.homology_dimensions = self.topo_loss_fn.homology_dimensions diff --git a/tests/test_reproducibility_strict.py b/tests/test_reproducibility_strict.py index 9b638fe..26f2967 100644 --- a/tests/test_reproducibility_strict.py +++ b/tests/test_reproducibility_strict.py @@ -47,7 +47,6 @@ def test_strict_defaults(self): self.assertFalse(settings["allow_nan_batch_skip"]) self.assertFalse(settings["allow_empty_cloud_fallback"]) self.assertFalse(settings["allow_legacy_loss_keys"]) - self.assertFalse(settings["allow_topo_center_separation"]) def test_resolve_dbscan_grid_explicit_override(self): from tda_ml.config import load_config diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index e1c2900..83902c1 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -31,16 +31,16 @@ def test_local_pca_tune_base(self): self.assertEqual(cfg["model"]["topology_loss"]["distance_backend"], "ellphi") self.assertFalse(cfg["model"]["topology_loss"]["prob_weighting"]) - def test_h1_tune_preserves_declared_regularizer_stack(self): + def test_power_tune_preserves_h1_hard_fail_stack(self): cfg = build_trial_config( - "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1", + "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", w_aniso=0.1, w_size=0.2, w_topo=0.3, lr=1e-4, backend="ellphi", tune_epochs=20, - out_base="outputs/test_tune_h1", + out_base="outputs/test_tune_power", trial_number=0, size_mode="power", ) From 142720b0c49905e7295faba3f6268af3c3d5728b Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 10:10:54 +0900 Subject: [PATCH 17/44] =?UTF-8?q?refactor:=20=E5=85=AC=E9=96=8B=E9=9D=A2?= =?UTF-8?q?=E3=82=92=E6=AD=A3=E6=9C=AC=E5=AE=9F=E9=A8=93=E3=81=AB=E9=99=90?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日付依存の旧 probe・ablation・運用パイプラインを除外し、no_cls 二目的チューニングと再現経路だけを公開対象として残す。 Co-authored-by: Cursor --- REPRODUCIBILITY.md | 4 +- configs/_conv_n100.yaml | 42 -- configs/_mahal_n100_noprob.yaml | 44 -- configs/elongate_n100.yaml | 61 --- configs/elongate_n100_ellphi_tuned.yaml | 57 --- configs/elongate_n100_no_cls.yaml | 59 --- ...gate_n100_no_cls_full120_ellphi_tuned.yaml | 58 --- configs/elongate_n100_no_cls_scaleinv.yaml | 74 --- ...gate_n100_no_cls_scaleinv_legacy_topo.yaml | 62 --- .../elongate_n100_no_cls_scaleinv_minb.yaml | 62 --- ...ate_n100_no_cls_scaleinv_minb_full120.yaml | 63 --- ...gate_n100_no_cls_scaleinv_minb_topo12.yaml | 62 --- ...gate_n100_no_cls_scaleinv_stronganiso.yaml | 61 --- configs/elongate_n100_no_cls_topo12_only.yaml | 60 --- .../elongate_n100_no_cls_tune_local_pca.yaml | 57 --- configs/reproduce_ellphi_main.yaml | 48 -- experiments/_bench_topo_workers.py | 103 ---- experiments/aggregate_paper_results.py | 444 ------------------ experiments/apply_tune_best_to_configs.py | 113 ----- experiments/ellphi_postfix_autopipeline.sh | 306 ------------ experiments/ellphi_retune_v2_autopipeline.sh | 42 -- experiments/launch_detached_screen.sh | 6 +- experiments/plot_distance_matrix.py | 282 ----------- experiments/run_ellphi_tuned_full120_30ep.sh | 48 -- .../run_ellphi_tuned_v2_full120_30ep.sh | 49 -- experiments/run_retune_parallel.sh | 96 ---- ...run_teacher_local_pca_ellphi_smoke_10ep.py | 167 ------- .../run_teacher_local_pca_power_30ep.py | 4 +- ..._teacher_local_pca_power_30ep_multiseed.sh | 2 +- experiments/run_tune_local_pca_parallel.sh | 94 ---- .../run_tune_local_pca_power_mcc_parallel.sh | 4 +- .../run_tune_local_pca_power_objectives.sh | 12 +- ...run_tune_local_pca_power_wdist_parallel.sh | 4 +- experiments/run_tune_parallel.sh | 73 --- experiments/tune_elongate_mcc.py | 2 +- experiments/tune_elongate_wdist.py | 5 +- tests/test_tune_config.py | 7 +- 37 files changed, 26 insertions(+), 2711 deletions(-) delete mode 100644 configs/_conv_n100.yaml delete mode 100644 configs/_mahal_n100_noprob.yaml delete mode 100644 configs/elongate_n100.yaml delete mode 100644 configs/elongate_n100_ellphi_tuned.yaml delete mode 100644 configs/elongate_n100_no_cls.yaml delete mode 100644 configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml delete mode 100644 configs/elongate_n100_no_cls_scaleinv.yaml delete mode 100644 configs/elongate_n100_no_cls_scaleinv_legacy_topo.yaml delete mode 100644 configs/elongate_n100_no_cls_scaleinv_minb.yaml delete mode 100644 configs/elongate_n100_no_cls_scaleinv_minb_full120.yaml delete mode 100644 configs/elongate_n100_no_cls_scaleinv_minb_topo12.yaml delete mode 100644 configs/elongate_n100_no_cls_scaleinv_stronganiso.yaml delete mode 100644 configs/elongate_n100_no_cls_topo12_only.yaml delete mode 100644 configs/elongate_n100_no_cls_tune_local_pca.yaml delete mode 100644 configs/reproduce_ellphi_main.yaml delete mode 100644 experiments/_bench_topo_workers.py delete mode 100644 experiments/aggregate_paper_results.py delete mode 100755 experiments/apply_tune_best_to_configs.py delete mode 100755 experiments/ellphi_postfix_autopipeline.sh delete mode 100755 experiments/ellphi_retune_v2_autopipeline.sh delete mode 100644 experiments/plot_distance_matrix.py delete mode 100755 experiments/run_ellphi_tuned_full120_30ep.sh delete mode 100755 experiments/run_ellphi_tuned_v2_full120_30ep.sh delete mode 100755 experiments/run_retune_parallel.sh delete mode 100755 experiments/run_teacher_local_pca_ellphi_smoke_10ep.py delete mode 100755 experiments/run_tune_local_pca_parallel.sh delete mode 100644 experiments/run_tune_parallel.sh diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 36e80b6..6616a32 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -144,9 +144,7 @@ uv run python experiments/run_backend_multiseed.py \ ### ellphi + power:二目的チューニング(実験メモ) -no_cls・local_pca 教師・`size_mode=power` スタックについて、**W-Dist 最小**と **DBSCAN MCC 最大**の 2 本の Optuna study、および各 best 重みでの 30ep 本番結果は、次にまとめています。 - -- [`docs/experiments/20260710_power_dual_objective.md`](docs/experiments/20260710_power_dual_objective.md) — 各段階の**目的**、距離 backend の使い分け、数値表、再現コマンド、成果物パス +no_cls・local_pca 教師・`size_mode=power` スタックでは、`run_tune_local_pca_power_objectives.sh` が **W-Dist 最小**と **DBSCAN MCC 最大**の 2 本の Optuna study を実行し、`run_teacher_local_pca_power_30ep_multiseed.sh` が固定した best 重みで 30ep 本番を実行します。 要点: **学習 topo loss と教師 PD は ellphi**;**MCC のチューニング objective と paper eval の DBSCAN は mahalanobis**(filtration 時刻をクラスタリング距離に使わない)。 diff --git a/configs/_conv_n100.yaml b/configs/_conv_n100.yaml deleted file mode 100644 index 244ba33..0000000 --- a/configs/_conv_n100.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# EXPERIMENTAL (uncommitted) config for the N=100 convergence check (seed42, 30ep). -# Mirrors reproduce.yaml hyperparameters but max_points=100 (matches base.yaml / -# paper eval protocol). num_outliers kept at 20 (=> 20% ratio, same as eval run_meta). -# Pending spec decision: see proposed issue "max_points 200->100". -meta: - config_id: "conv_n100" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - -loss: - w_class: 1.0 - w_topo: 0.1 - w_aniso: 0.01099204345474479 - w_size: 0.0055785823086202556 - pos_weight: 1.0 - aniso_mode: "linear" - -training: - lr: 0.0004897466143769238 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - -data: - max_points: 100 - num_outliers: 20 - seed: 42 - test_size: 1000 - num_workers: 4 - batch_size: 64 - -outputs: - base_dir: "outputs" diff --git a/configs/_mahal_n100_noprob.yaml b/configs/_mahal_n100_noprob.yaml deleted file mode 100644 index 2eab99a..0000000 --- a/configs/_mahal_n100_noprob.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# EXPERIMENTAL (uncommitted) config: mahalanobis backend convergence check, N=100. -# Probability-based distance weighting DISABLED (prob_weighting: false) so the -# comparison against ellphi is a fair backend ablation (ellphi never uses probs). -# Mirrors _conv_n100.yaml hyperparameters. distance_backend is also set by the -# multiseed driver via --backends mahalanobis. -meta: - config_id: "mahal_n100_noprob" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 1.0 - w_topo: 0.1 - w_aniso: 0.01099204345474479 - w_size: 0.0055785823086202556 - pos_weight: 1.0 - aniso_mode: "linear" - -training: - lr: 0.0004897466143769238 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - -data: - max_points: 100 - num_outliers: 20 - seed: 42 - test_size: 1000 - num_workers: 4 - batch_size: 64 - -outputs: - base_dir: "outputs" diff --git a/configs/elongate_n100.yaml b/configs/elongate_n100.yaml deleted file mode 100644 index 5b9daa3..0000000 --- a/configs/elongate_n100.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# Elongate main run (1 seed, 30ep) for n100 (100 inliers + 20 outliers, 16.7% contamination). -# Hyperparams from Optuna 4D W-Dist tuning (outputs/tune_elongate/best_elongate_wdist.json, -# 56 trials, mahalanobis backend, 20ep proxy). See docs/anisotropy_investigation.md. - -meta: - config_id: "elongate_n100" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed - ellphi_differentiable: true - -loss: - w_class: 1.0 - w_topo: 0.0883 # tuned (W-Dist objective, val best=0.0390) - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: "elongate" - -training: - lr: 0.000158 # tuned - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - # Checkpoint selection = paper protocol: DBSCAN on predicted ellipse params, - # pick the epoch with the lowest mean val W-Dist (same grid as final eval). - # The chosen (eps, min_samples) is saved to logs/dbscan_hparams_train.json. - selection: - metric: wdist # wdist | dbscan_mcc | threshold_mcc | val_loss - eval_every: 1 # grid-search every epoch (mahalanobis: ~+tens of min) - dbscan: - eps_values: null # null -> linspace(0.15, 1.5, 15) - min_samples_values: null # null -> [3, 5, 7, 10, 15] - -data: - max_points: 100 # inlier (digit stroke) points - num_outliers: 20 # uniform outliers -> 20/120 = 16.7% contamination - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 # conservative for shared 192-core host - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_ellphi_tuned.yaml b/configs/elongate_n100_ellphi_tuned.yaml deleted file mode 100644 index 7be69b3..0000000 --- a/configs/elongate_n100_ellphi_tuned.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# elongate_n100 + ellphi Optuna tuning v1 (50 trials, 20ep proxy, val W-Dist objective). -# Source: /home/murata/TDA-ML/outputs/tune_elongate_ellphi_local_pca_v3/best_elongate_wdist_ellphi.json (v3 val_topo ckpt, val topo W-Dist=0.03465) - -meta: - config_id: "elongate_n100_ellphi_tuned" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed - ellphi_differentiable: true - -loss: - w_class: 1.0 - w_topo: 0.3961 # tuned ellphi v2 - w_aniso: 0.094 - w_size: 0.005 - pos_weight: 1.0 - aniso_mode: "elongate" - -training: - lr: 0.00093 # tuned ellphi v2 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: wdist - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls.yaml b/configs/elongate_n100_no_cls.yaml deleted file mode 100644 index 8c68c5e..0000000 --- a/configs/elongate_n100_no_cls.yaml +++ /dev/null @@ -1,59 +0,0 @@ -# Elongate backend comparison WITHOUT point-level classification supervision. -# Same hyperparams as elongate_n100.yaml except w_class=0.0 (geometry-only learning -# via topo + aniso + size losses). Fair mahalanobis vs ellphi ablation. - -meta: - config_id: "elongate_n100_no_cls" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed - ellphi_differentiable: true - prob_weighting: false # no outlier-prob distance weighting (ellphi never uses probs) - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: "elongate" - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: wdist - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml b/configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml deleted file mode 100644 index f983fb5..0000000 --- a/configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# Same loss weights as ellphi Optuna tune v1; no_cls + full-cloud topo (compare to full120_baseline). -# Source: /home/murata/TDA-ML/outputs/tune_elongate_ellphi_local_pca_v3/best_elongate_wdist_ellphi.json (v3 val_topo ckpt, val topo W-Dist=0.03465) - -meta: - config_id: "elongate_n100_no_cls_full120_ellphi_tuned" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 0.0 - w_topo: 0.3961 # tuned ellphi v2 - w_aniso: 0.094 - w_size: 0.005 - pos_weight: 1.0 - aniso_mode: "elongate" - -training: - lr: 0.00093 # tuned ellphi v2 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv.yaml b/configs/elongate_n100_no_cls_scaleinv.yaml deleted file mode 100644 index 88ba819..0000000 --- a/configs/elongate_n100_no_cls_scaleinv.yaml +++ /dev/null @@ -1,74 +0,0 @@ -# Scale-invariant variant of elongate_n100_no_cls.yaml. -# -# Root-cause fix for "ellphi W-Dist does not improve during training": the predicted -# topology distance matrix (Mahalanobis or ellphi tangency units) lives on a different -# scale than the Euclidean clean (teacher) PD. The legacy pipeline aligned them with a -# scalar `topo_eps_scale` (v73=0.7022). Here we use the backend-agnostic, tuning-free -# `topo_scale_mode: median`: the *prediction* is rescaled onto the teacher's Euclidean -# scale, D <- D * (m_e / median(D)), where m_e is the median pairwise Euclidean distance -# of the clean cloud. The teacher PD is left untouched. This removes the metric/unit -# mismatch that previously dominated the ellphi topology loss. -# -# Everything else matches elongate_n100_no_cls.yaml for a fair before/after comparison. - -meta: - config_id: "elongate_n100_no_cls_scaleinv" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: "elongate" - # Filtration-unit alignment between predicted matrix and Euclidean teacher PD. - # median: rescale prediction onto teacher's Euclidean scale, teacher untouched - # (recommended, backend-agnostic, tuning-free) - # fixed: D <- D * topo_eps_scale (legacy behavior; set topo_scale_mode: fixed) - topo_scale_mode: "median" - topo_eps_scale: 1.0 - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: wdist - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_legacy_topo.yaml b/configs/elongate_n100_no_cls_scaleinv_legacy_topo.yaml deleted file mode 100644 index 0bffae8..0000000 --- a/configs/elongate_n100_no_cls_scaleinv_legacy_topo.yaml +++ /dev/null @@ -1,62 +0,0 @@ -# scaleinv + min_b + strong aniso + topo_loss_max_points (full legacy stack; stronganiso は単独で有害のため step3 では使わない). - -meta: - config_id: "elongate_n100_no_cls_scaleinv_legacy_topo" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 4.5307 - w_size: 0.488 - w_min_b: 5.6525 - min_b_target: 0.05 - pos_weight: 1.0 - aniso_mode: "elongate" - topo_scale_mode: "median" - topo_eps_scale: 1.0 - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - topo_loss_max_points: 12 - selection: - metric: wdist - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_minb.yaml b/configs/elongate_n100_no_cls_scaleinv_minb.yaml deleted file mode 100644 index 0e2e1bc..0000000 --- a/configs/elongate_n100_no_cls_scaleinv_minb.yaml +++ /dev/null @@ -1,62 +0,0 @@ -# scaleinv + legacy min_b regularization (step 1 ablation). -# Adds lambda_min_b / min_b_target from legacy ellphi config; keeps weak w_aniso. - -meta: - config_id: "elongate_n100_no_cls_scaleinv_minb" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - w_min_b: 5.6525 - min_b_target: 0.05 - pos_weight: 1.0 - aniso_mode: "elongate" - topo_scale_mode: "median" - topo_eps_scale: 1.0 - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: wdist - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_minb_full120.yaml b/configs/elongate_n100_no_cls_scaleinv_minb_full120.yaml deleted file mode 100644 index 38d0f41..0000000 --- a/configs/elongate_n100_no_cls_scaleinv_minb_full120.yaml +++ /dev/null @@ -1,63 +0,0 @@ -# median + min_b, NO topo_loss_max_points → full cloud (120 pts) for topo loss. -# Ablation: compare against minb_topo12 (12-pt subsampling). - -meta: - config_id: "elongate_n100_no_cls_scaleinv_minb_full120" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - w_min_b: 5.6525 - min_b_target: 0.05 - pos_weight: 1.0 - aniso_mode: "elongate" - topo_scale_mode: "median" - topo_eps_scale: 1.0 - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - # topo_loss_max_points なし → 全120点 - selection: - metric: wdist - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_minb_topo12.yaml b/configs/elongate_n100_no_cls_scaleinv_minb_topo12.yaml deleted file mode 100644 index 4567684..0000000 --- a/configs/elongate_n100_no_cls_scaleinv_minb_topo12.yaml +++ /dev/null @@ -1,62 +0,0 @@ -# scaleinv + min_b + topo_loss_max_points:12 (step 3 ablation, no strong aniso). - -meta: - config_id: "elongate_n100_no_cls_scaleinv_minb_topo12" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - w_min_b: 5.6525 - min_b_target: 0.05 - pos_weight: 1.0 - aniso_mode: "elongate" - topo_scale_mode: "median" - topo_eps_scale: 1.0 - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - topo_loss_max_points: 12 - selection: - metric: wdist - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_scaleinv_stronganiso.yaml b/configs/elongate_n100_no_cls_scaleinv_stronganiso.yaml deleted file mode 100644 index 7449844..0000000 --- a/configs/elongate_n100_no_cls_scaleinv_stronganiso.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# scaleinv + min_b + legacy-strong aniso (step 2 ablation). - -meta: - config_id: "elongate_n100_no_cls_scaleinv_stronganiso" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 4.5307 - w_size: 0.488 - w_min_b: 5.6525 - min_b_target: 0.05 - pos_weight: 1.0 - aniso_mode: "elongate" - topo_scale_mode: "median" - topo_eps_scale: 1.0 - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: wdist - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_topo12_only.yaml b/configs/elongate_n100_no_cls_topo12_only.yaml deleted file mode 100644 index 2b7f635..0000000 --- a/configs/elongate_n100_no_cls_topo12_only.yaml +++ /dev/null @@ -1,60 +0,0 @@ -# topo12 ONLY ablation: no median (fix1), no min_b (fix2). -# Baseline elongate_n100_no_cls + topo_loss_max_points:12. - -meta: - config_id: "elongate_n100_no_cls_topo12_only" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: "elongate" - # no topo_scale_mode / w_min_b - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - topo_loss_max_points: 12 - selection: - metric: wdist - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca.yaml b/configs/elongate_n100_no_cls_tune_local_pca.yaml deleted file mode 100644 index eab017e..0000000 --- a/configs/elongate_n100_no_cls_tune_local_pca.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# Optuna tune base: no_cls + full-cloud topo + local_pca ellphi teacher + val_topo ckpt. -# 20-epoch proxy; hyperparams (w_*, lr) are searched by tune_elongate_wdist.py. - -meta: - config_id: "elongate_n100_no_cls_tune_local_pca" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden per trial (--backend ellphi) - ellphi_differentiable: true - prob_weighting: false - -loss: - w_class: 0.0 - w_topo: 0.0883 # placeholder; Optuna search replaces - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: "elongate" - teacher_mode: local_pca - teacher_local_pca_k: 10 - -training: - lr: 0.000158 - epochs: 20 - grad_clip_value: 1.0 - visualize_every: 10000 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/reproduce_ellphi_main.yaml b/configs/reproduce_ellphi_main.yaml deleted file mode 100644 index b33dbe3..0000000 --- a/configs/reproduce_ellphi_main.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# Post-fix ellphi multiseed main run (same hyperparams as reproduce.yaml; lighter viz I/O). -# Pilot calibration uses reproduce.yaml; this config is for the 5-seed main phase. - -meta: - config_id: "reproduce_ellphi_main" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - -loss: - w_class: 1.0 - w_topo: 0.1 - w_aniso: 0.01099204345474479 - w_size: 0.0055785823086202556 - pos_weight: 1.0 - aniso_mode: "linear" - -training: - lr: 0.0004897466143769238 - epochs: 50 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - use_amp: true - -data: - max_points: 200 - num_outliers: 20 - seed: 42 - test_size: 1000 - num_workers: 4 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" diff --git a/experiments/_bench_topo_workers.py b/experiments/_bench_topo_workers.py deleted file mode 100644 index 7d80ea0..0000000 --- a/experiments/_bench_topo_workers.py +++ /dev/null @@ -1,103 +0,0 @@ -"""TEMP benchmark: verify intra-batch topo-loss threading (correctness + speed). - -Delete after use. Compares workers=1 vs workers=8 on identical inputs: -- max abs diff of topo loss value -- max abs diff of gradients w.r.t. model parameters -- per-batch wall time for a few full train steps (fwd + bwd) -""" - -from __future__ import annotations - -import time - -import torch - -from tda_ml.config import load_config -from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader -from tda_ml.models import AnisotropicOutlierClassifier -from tda_ml.config import model_kwargs_from_config -from tda_ml.losses import TopologicalLoss -from tda_ml.trainer import Trainer - - -def get_batches(cfg, n_clouds, batch_size): - data_cfg = cfg["data"] - gen = torch.Generator().manual_seed(0) - idx = torch.randperm(60000, generator=gen)[:n_clouds] - ds = NoisyMNISTDataset( - root="./data", train=True, max_points=data_cfg["max_points"], - num_outliers=data_cfg["num_outliers"], indices=idx, - deterministic=True, noise_seed=42, - ) - return create_data_loader(ds, batch_size=batch_size, shuffle=False, num_workers=0) - - -def grad_vector(model): - return torch.cat([ - p.grad.detach().reshape(-1) if p.grad is not None else torch.zeros_like(p).reshape(-1) - for p in model.parameters() - ]) - - -def run_once(model, trainer, topo_fn, data, clean_pc): - model.zero_grad(set_to_none=True) - logits, params = model(data) - clean_pd_info = trainer._compute_clean_pd_info(clean_pc) - loss = topo_fn(data, params, logits, clean_pd_info) - loss.backward() - return float(loss.detach()), grad_vector(model) - - -def main(): - torch.manual_seed(0) - cfg = load_config("_conv_n100") - device = torch.device("cpu") - batch_size = cfg["data"]["batch_size"] - - loader = get_batches(cfg, n_clouds=batch_size * 3, batch_size=batch_size) - batches = [(d, c) for d, _, c in loader] - - model = AnisotropicOutlierClassifier(**model_kwargs_from_config(cfg)).to(device) - trainer = Trainer(model, cfg, device=device) - - # The multiseed driver overrides the backend to ellphi at runtime; match that - # here so the benchmark exercises the per-sample ellphi path (not vectorized mahalanobis). - backend = "ellphi" - print(f"backend={backend} batch_size={batch_size} max_points={cfg['data']['max_points']}") - - topo1 = TopologicalLoss(weight=cfg["loss"]["w_topo"], distance_backend=backend, - ellphi_differentiable=True, topo_workers=1) - topo8 = TopologicalLoss(weight=cfg["loss"]["w_topo"], distance_backend=backend, - ellphi_differentiable=True, topo_workers=8) - - # --- correctness on one batch --- - d, c = batches[0] - d, c = d.to(device), c.to(device) - l1, g1 = run_once(model, trainer, topo1, d, c) - l8, g8 = run_once(model, trainer, topo8, d, c) - print(f"[correctness] loss w1={l1:.8f} w8={l8:.8f} |dloss|={abs(l1-l8):.3e}") - print(f"[correctness] max|dgrad|={ (g1-g8).abs().max().item():.3e} " - f"max|grad|={g1.abs().max().item():.3e}") - - # --- speed: time a few full steps --- - def timed(topo_fn, label): - # warmup - d0, c0 = batches[0] - run_once(model, trainer, topo_fn, d0.to(device), c0.to(device)) - t0 = time.perf_counter() - n = 0 - for d, c in batches: - run_once(model, trainer, topo_fn, d.to(device), c.to(device)) - n += 1 - dt = (time.perf_counter() - t0) / n - print(f"[speed] {label}: {dt:.2f} s/batch over {n} batches") - return dt - - t1 = timed(topo1, "workers=1") - t8 = timed(topo8, "workers=8") - print(f"[speed] speedup x{t1/t8:.2f} -> 30ep ETA " - f"@w8 = {t8*71*30/3600:.1f} h (val negligible)") - - -if __name__ == "__main__": - main() diff --git a/experiments/aggregate_paper_results.py b/experiments/aggregate_paper_results.py deleted file mode 100644 index a0e6054..0000000 --- a/experiments/aggregate_paper_results.py +++ /dev/null @@ -1,444 +0,0 @@ -#!/usr/bin/env python3 -""" -Aggregate paper Table~\\ref{tab:comparison} metrics into ``summary_for_paper.csv``. - -Reads test-split DBSCAN metrics (``logs/paper_metrics_test.json`` from -``evaluate_paper_protocol.py``) for proposed / ablation runs, and -``summary_baselines.csv`` from Phase 3. - -Usage:: - - uv run python experiments/aggregate_paper_results.py \\ - --proposed-dir outputs/supervised/20260627/055936_paper_reproduce_1week_tuned \\ - --wtopo0-dir outputs/paper_wtopo0 \\ - --baselines-csv outputs/supervised/20260623/155756_paper_baselines/summary_baselines.csv - -Writes ``summary_for_paper.csv`` and ``MANIFEST.md`` under ``--out-dir`` -(default: ``--proposed-dir``). -""" - -from __future__ import annotations - -import argparse -import csv -import json -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Sequence - -import numpy as np - -from tda_ml.supervised_diagnostics import git_revision - -REPO_ROOT = Path(__file__).resolve().parents[1] -PAPER_SEEDS = [42, 123, 456, 789, 1024] - -SUMMARY_COLUMNS = [ - "method", - "mcc_mean", - "mcc_std", - "gmean_mean", - "gmean_std", - "wdist_mean", - "wdist_std", - "notes", -] - -METHOD_ORDER = [ - "proposed_w_topo_pos", - "proposed_w_topo_0", - "euclidean_dbscan", - "isolation_forest", - "lof", - "adbscan", -] - -# ``evaluate_paper_protocol`` writes SplitMetrics fields (mcc, gmean, wdist). -# Mac-side stubs may use alternate names — try all aliases. -MCC_KEYS = ("mcc", "test_mcc", "mcc_mean", "mean_mcc") -GMEAN_KEYS = ("gmean", "g_mean", "test_gmean", "gmean_mean", "mean_gmean") -WDIST_KEYS = ("wdist", "w_dist", "test_wdist", "wdist_mean", "mean_wdist", "w_dist_mean") - - -@dataclass -class SeedPaperMetrics: - seed: int | None - run_dir: Path - metrics_path: Path - mcc: float - gmean: float - wdist: float - n_clouds: int | None - dbscan_eps: float | None - dbscan_min_samples: int | None - backend: str | None - source_revision: str | None - - -def sample_std(values: Sequence[float]) -> float: - arr = np.asarray(values, dtype=np.float64) - if arr.size < 2: - return 0.0 - return float(np.std(arr, ddof=1)) - - -def _first_key(payload: dict[str, Any], keys: Sequence[str], *, label: str) -> float: - for key in keys: - if key in payload and payload[key] is not None: - return float(payload[key]) - raise KeyError(f"Missing {label} in JSON (tried {list(keys)}); keys={sorted(payload)}") - - -def load_paper_metrics_json(path: Path) -> dict[str, Any]: - payload = json.loads(path.read_text(encoding="utf-8")) - if payload.get("split") not in (None, "test"): - raise ValueError(f"Expected test-split metrics in {path}; got split={payload.get('split')!r}") - return payload - - -def parse_seed_paper_metrics(run_dir: Path, metrics_path: Path) -> SeedPaperMetrics: - payload = load_paper_metrics_json(metrics_path) - manifest_path = run_dir / "logs" / "run_manifest.json" - seed: int | None = None - if manifest_path.is_file(): - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - if manifest.get("seed") is not None: - seed = int(manifest["seed"]) - - return SeedPaperMetrics( - seed=seed, - run_dir=run_dir.resolve(), - metrics_path=metrics_path.resolve(), - mcc=_first_key(payload, MCC_KEYS, label="mcc"), - gmean=_first_key(payload, GMEAN_KEYS, label="gmean"), - wdist=_first_key(payload, WDIST_KEYS, label="wdist"), - n_clouds=int(payload["n_clouds"]) if payload.get("n_clouds") is not None else None, - dbscan_eps=float(payload["dbscan_eps"]) if payload.get("dbscan_eps") is not None else None, - dbscan_min_samples=( - int(payload["dbscan_min_samples"]) if payload.get("dbscan_min_samples") is not None else None - ), - backend=str(payload["backend"]) if payload.get("backend") is not None else None, - source_revision=str(payload["source_revision"]) if payload.get("source_revision") else None, - ) - - -def discover_run_dirs(out_base: Path) -> list[Path]: - """Return run directories with ``logs/paper_metrics_test.json``.""" - found: list[Path] = [] - progress = out_base / "progress_summary.csv" - if progress.is_file(): - with progress.open(newline="", encoding="utf-8") as f: - for row in csv.DictReader(f): - if row.get("backend") not in (None, "", "ellphi"): - continue - run_dir = Path(row["run_dir"]) - if (run_dir / "logs" / "paper_metrics_test.json").is_file(): - found.append(run_dir.resolve()) - - if not found: - for pattern in ( - "eph_s*/logs/paper_metrics_test.json", - "backend_ellphi_seed*/logs/paper_metrics_test.json", - "reproduce_*/logs/paper_metrics_test.json", - ): - for metrics_path in sorted(out_base.glob(pattern)): - found.append(metrics_path.parent.parent.resolve()) - - # Deduplicate while preserving order - seen: set[Path] = set() - unique: list[Path] = [] - for rd in found: - if rd not in seen: - seen.add(rd) - unique.append(rd) - return unique - - -def aggregate_seed_metrics( - seeds: list[SeedPaperMetrics], - *, - method: str, - notes: str, -) -> dict[str, Any]: - if not seeds: - raise ValueError(f"No seed metrics for method={method!r}") - mccs = [s.mcc for s in seeds] - gmeans = [s.gmean for s in seeds] - wdist = [s.wdist for s in seeds] - n_clouds = seeds[0].n_clouds - cloud_note = f"test n_clouds={n_clouds} per seed" if n_clouds is not None else "test split" - return { - "method": method, - "mcc_mean": float(np.mean(mccs)), - "mcc_std": sample_std(mccs), - "gmean_mean": float(np.mean(gmeans)), - "gmean_std": sample_std(gmeans), - "wdist_mean": float(np.mean(wdist)), - "wdist_std": sample_std(wdist), - "notes": notes or f"{len(seeds)} seeds; DBSCAN test metrics; {cloud_note}", - } - - -def load_proposed_row( - out_base: Path, - *, - method: str, - default_notes: str, - expected_seeds: Sequence[int], -) -> tuple[dict[str, Any] | None, list[SeedPaperMetrics], list[str]]: - warnings: list[str] = [] - run_dirs = discover_run_dirs(out_base) - if not run_dirs: - warnings.append(f"{method}: no run dirs with paper_metrics_test.json under {out_base}") - return None, [], warnings - - per_seed: list[SeedPaperMetrics] = [] - for run_dir in run_dirs: - metrics_path = run_dir / "logs" / "paper_metrics_test.json" - try: - per_seed.append(parse_seed_paper_metrics(run_dir, metrics_path)) - except (KeyError, ValueError, json.JSONDecodeError) as exc: - warnings.append(f"{method}: skip {run_dir}: {exc}") - - if not per_seed: - warnings.append(f"{method}: no parseable paper_metrics_test.json under {out_base}") - return None, [], warnings - - found_seeds = {s.seed for s in per_seed if s.seed is not None} - missing = [s for s in expected_seeds if s not in found_seeds] - if missing: - warnings.append(f"{method}: missing seeds {missing} (found {sorted(found_seeds)})") - - row = aggregate_seed_metrics(per_seed, method=method, notes=default_notes) - return row, per_seed, warnings - - -def load_baselines_rows(path: Path) -> list[dict[str, Any]]: - if not path.is_file(): - raise FileNotFoundError(f"Baselines CSV not found: {path}") - with path.open(newline="", encoding="utf-8") as f: - rows = list(csv.DictReader(f)) - for row in rows: - for col in SUMMARY_COLUMNS: - if col not in row: - row[col] = "" - return rows - - -def write_summary_csv(path: Path, rows: Sequence[dict[str, Any]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", newline="", encoding="utf-8") as f: - writer = csv.DictWriter(f, fieldnames=SUMMARY_COLUMNS) - writer.writeheader() - for row in rows: - writer.writerow({col: row.get(col, "") for col in SUMMARY_COLUMNS}) - - -def format_pm(mean: float, std: float, digits: int = 3) -> str: - return f"{mean:.{digits}f} ± {std:.{digits}f}" - - -def write_manifest( - path: Path, - *, - summary_csv: Path, - proposed_dir: Path, - wtopo0_dir: Path | None, - baselines_csv: Path, - rows: Sequence[dict[str, Any]], - proposed_seeds: list[SeedPaperMetrics], - wtopo0_seeds: list[SeedPaperMetrics], - warnings: Sequence[str], -) -> None: - now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - lines = [ - "# Paper experiment manifest", - "", - f"- Generated: {now}", - f"- Git HEAD: `{git_revision(REPO_ROOT)}`", - f"- Summary CSV: `{summary_csv}`", - f"- Proposed runs: `{proposed_dir}`", - f"- w_topo=0 ablation: `{wtopo0_dir}`" if wtopo0_dir else "- w_topo=0 ablation: (not provided)", - f"- Baselines CSV: `{baselines_csv}`", - "", - "## Metrics source", - "", - "- Proposed / ablation: `evaluate_paper_protocol.py` → `logs/paper_metrics_test.json`", - " (keys: `mcc`, `gmean`, `wdist` from DBSCAN test evaluation)", - "- Baselines: `evaluate_paper_baselines.py` → `summary_baselines.csv`", - "", - "## summary_for_paper.csv", - "", - "| method | MCC | G-Mean | W-Dist | notes |", - "|--------|-----|--------|--------|-------|", - ] - - for row in rows: - method = row.get("method", "") - if not row.get("mcc_mean"): - lines.append(f"| {method} | — | — | — | pending |") - continue - lines.append( - f"| {method} | {format_pm(float(row['mcc_mean']), float(row['mcc_std']))} " - f"| {format_pm(float(row['gmean_mean']), float(row['gmean_std']))} " - f"| {format_pm(float(row['wdist_mean']), float(row['wdist_std']))} " - f"| {row.get('notes', '')} |" - ) - - def _seed_section(title: str, seeds: Sequence[SeedPaperMetrics]) -> list[str]: - if not seeds: - return [f"## {title}", "", "(not available)", ""] - out = [f"## {title}", ""] - for s in sorted(seeds, key=lambda x: (x.seed is None, x.seed or 0)): - hparam = "" - if s.dbscan_eps is not None and s.dbscan_min_samples is not None: - hparam = f" eps={s.dbscan_eps}, min_samples={s.dbscan_min_samples}" - seed_label = s.seed if s.seed is not None else "?" - out.append( - f"- seed {seed_label}: `{s.run_dir}` — " - f"MCC={s.mcc:.4f}, G-Mean={s.gmean:.4f}, W-Dist={s.wdist:.4f}{hparam}" - ) - out.append("") - return out - - lines.extend(_seed_section("Proposed per-seed (w_topo > 0)", proposed_seeds)) - lines.extend(_seed_section("Ablation per-seed (w_topo = 0)", wtopo0_seeds)) - - if warnings: - lines.append("## Warnings") - lines.append("") - for w in warnings: - lines.append(f"- {w}") - lines.append("") - - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description=__doc__) - p.add_argument( - "--proposed-dir", - type=Path, - default=REPO_ROOT / "outputs" / "supervised" / "20260627" / "055936_paper_reproduce_1week_tuned", - help="Main proposed runs (12ep × 5 seed, ellphi).", - ) - p.add_argument( - "--wtopo0-dir", - type=Path, - default=REPO_ROOT / "outputs" / "paper_wtopo0", - help="w_topo=0 ablation output tree (optional until Phase 2 completes).", - ) - p.add_argument( - "--baselines-csv", - type=Path, - default=REPO_ROOT / "outputs" / "supervised" / "20260623" / "155756_paper_baselines" / "summary_baselines.csv", - ) - p.add_argument( - "--out-dir", - type=Path, - default=None, - help="Write summary_for_paper.csv and MANIFEST.md here (default: --proposed-dir).", - ) - p.add_argument("--seeds", type=int, nargs="+", default=PAPER_SEEDS) - p.add_argument( - "--skip-wtopo0", - action="store_true", - help="Do not require w_topo=0 ablation row.", - ) - p.add_argument( - "--require-proposed", - action="store_true", - help="Exit non-zero if proposed runs are missing.", - ) - return p.parse_args() - - -def main() -> int: - args = parse_args() - proposed_dir = args.proposed_dir.resolve() - wtopo0_dir = args.wtopo0_dir.resolve() if args.wtopo0_dir else None - baselines_csv = args.baselines_csv.resolve() - out_dir = (args.out_dir or proposed_dir).resolve() - warnings: list[str] = [] - - proposed_row, proposed_seeds, w1 = load_proposed_row( - proposed_dir, - method="proposed_w_topo_pos", - default_notes="proposed; ellphi DBSCAN test metrics; val-tuned eps/min_samples per seed", - expected_seeds=args.seeds, - ) - warnings.extend(w1) - - wtopo0_row: dict[str, Any] | None = None - wtopo0_seeds: list[SeedPaperMetrics] = [] - if not args.skip_wtopo0 and wtopo0_dir is not None: - wtopo0_row, wtopo0_seeds, w2 = load_proposed_row( - wtopo0_dir, - method="proposed_w_topo_0", - default_notes="ablation w_topo=0; ellphi DBSCAN test metrics; val-tuned eps/min_samples per seed", - expected_seeds=args.seeds, - ) - warnings.extend(w2) - elif not args.skip_wtopo0: - warnings.append("proposed_w_topo_0: --wtopo0-dir not set") - - baseline_rows = load_baselines_rows(baselines_csv) - baseline_by_method = {r["method"]: r for r in baseline_rows} - - rows_by_method: dict[str, dict[str, Any]] = {} - if proposed_row: - rows_by_method["proposed_w_topo_pos"] = proposed_row - if wtopo0_row: - rows_by_method["proposed_w_topo_0"] = wtopo0_row - for method in ("euclidean_dbscan", "isolation_forest", "lof", "adbscan"): - if method in baseline_by_method: - rows_by_method[method] = baseline_by_method[method] - - ordered_rows: list[dict[str, Any]] = [] - for method in METHOD_ORDER: - if method in rows_by_method: - ordered_rows.append(rows_by_method[method]) - else: - warnings.append(f"metrics pending: {method}") - ordered_rows.append({"method": method, "notes": "pending"}) - - summary_path = out_dir / "summary_for_paper.csv" - manifest_path = out_dir / "MANIFEST.md" - write_summary_csv(summary_path, ordered_rows) - write_manifest( - manifest_path, - summary_csv=summary_path, - proposed_dir=proposed_dir, - wtopo0_dir=wtopo0_dir if not args.skip_wtopo0 else None, - baselines_csv=baselines_csv, - rows=ordered_rows, - proposed_seeds=proposed_seeds, - wtopo0_seeds=wtopo0_seeds, - warnings=warnings, - ) - - print(f"Wrote {summary_path}") - print(f"Wrote {manifest_path}") - for row in ordered_rows: - if row.get("mcc_mean"): - print( - f" {row['method']}: MCC={float(row['mcc_mean']):.4f}±{float(row['mcc_std']):.4f} " - f"G-Mean={float(row['gmean_mean']):.4f}±{float(row['gmean_std']):.4f} " - f"W-Dist={float(row['wdist_mean']):.4f}±{float(row['wdist_std']):.4f}" - ) - else: - print(f" {row['method']}: pending") - - if warnings: - print("\nWarnings:") - for w in warnings: - print(f" - {w}") - - if args.require_proposed and proposed_row is None: - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/experiments/apply_tune_best_to_configs.py b/experiments/apply_tune_best_to_configs.py deleted file mode 100755 index 7efc8b4..0000000 --- a/experiments/apply_tune_best_to_configs.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -"""Apply Optuna best params from JSON into elongate ellphi-tuned YAML configs.""" - -from __future__ import annotations - -import argparse -import json -import re -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] - -CONFIGS = ( - "configs/elongate_n100_ellphi_tuned.yaml", - "configs/elongate_n100_no_cls_full120_ellphi_tuned.yaml", - "configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml", -) - - -def _fmt_lr(x: float) -> str: - return f"{x:.6f}".rstrip("0").rstrip(".") - - -def _fmt_weight(x: float) -> str: - return f"{x:.4f}".rstrip("0").rstrip(".") - - -def _set_scalar_line(text: str, key: str, value: str, comment: str | None = None) -> str: - pat = re.compile(rf"^(\s*{re.escape(key)}:\s*)([^\s#]+)(.*)$", re.MULTILINE) - suffix = f" # {comment}" if comment else "" - - def repl(m: re.Match[str]) -> str: - return f"{m.group(1)}{value}{suffix}" - - new, n = pat.subn(repl, text, count=1) - if n != 1: - raise ValueError(f"could not update {key} in config") - return new - - -def _set_source_comment(text: str, source_line: str) -> str: - pat = re.compile(r"^# Source:.*$", re.MULTILINE) - if pat.search(text): - return pat.sub(f"# {source_line}", text, count=1) - # prepend after first comment block line - lines = text.splitlines(keepends=True) - for i, line in enumerate(lines): - if line.startswith("#") and i < 5: - continue - lines.insert(1, f"# {source_line}\n") - return "".join(lines) - return f"# {source_line}\n{text}" - - -def apply_best(json_path: Path, configs: tuple[str, ...] = CONFIGS) -> dict: - payload = json.loads(json_path.read_text()) - params = payload["best_params"] - w_topo = float(params["w_topo"]) - w_aniso = float(params["w_aniso"]) - w_size = float(params["w_size"]) - lr = float(params["lr"]) - best_wd = float(payload["best_value_wdist"]) - source = f"Source: {json_path} (v3 val_topo ckpt, val topo W-Dist={best_wd:.5f})" - - w_topo_s = _fmt_weight(w_topo) - w_aniso_s = _fmt_weight(w_aniso) - w_size_s = _fmt_weight(w_size) - lr_s = _fmt_lr(lr) - - updated = [] - for rel in configs: - path = REPO_ROOT / rel - text = path.read_text() - text = _set_source_comment(text, source) - text = _set_scalar_line(text, "w_topo", w_topo_s, "tuned ellphi v2") - text = _set_scalar_line(text, "w_aniso", w_aniso_s) - text = _set_scalar_line(text, "w_size", w_size_s) - text = _set_scalar_line(text, "lr", lr_s, "tuned ellphi v2") - path.write_text(text) - updated.append(str(path)) - - return { - "json": str(json_path), - "w_topo": w_topo_s, - "w_aniso": w_aniso_s, - "w_size": w_size_s, - "lr": lr_s, - "best_value_wdist": best_wd, - "configs": updated, - } - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description=__doc__) - p.add_argument( - "--json", - type=Path, - default=REPO_ROOT / "outputs/tune_elongate_ellphi_local_pca_v3/best_elongate_wdist_ellphi.json", - ) - return p.parse_args() - - -def main() -> int: - args = parse_args() - if not args.json.is_file(): - raise SystemExit(f"missing {args.json}") - summary = apply_best(args.json.resolve()) - print(json.dumps(summary, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/experiments/ellphi_postfix_autopipeline.sh b/experiments/ellphi_postfix_autopipeline.sh deleted file mode 100755 index 449de91..0000000 --- a/experiments/ellphi_postfix_autopipeline.sh +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env bash -# Wait for 12ep ellphi calibration (seed 42), then launch 5-seed paper main run (fixed 12ep). -# -# Usage (background): -# nohup ./experiments/ellphi_postfix_autopipeline.sh >> outputs/ellphi_postfix_calib/autopipeline.log 2>&1 & -# -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$REPO_ROOT" - -CALIB_OUT="${CALIB_OUT:-outputs/ellphi_postfix_calib}" -PILOT_LOG="${CALIB_OUT}/driver_12ep_seed42.log" -STATE_JSON="${CALIB_OUT}/autopipeline_state.json" -DECISION_JSON="${CALIB_OUT}/epoch_decision.json" -POLL_SEC="${POLL_SEC:-300}" -SEEDS=(42 123 456 789 1024) -MAIN_EPOCHS="${MAIN_EPOCHS:-12}" -MAIN_OUT="${MAIN_OUT:-outputs/paper_reproduce_1week_tuned}" -MAIN_CONFIG="${MAIN_CONFIG:-reproduce}" - -log() { echo "[$(date -Iseconds)] $*"; } - -write_state() { - local phase="$1" - local detail="${2:-}" - python3 - "$phase" "$detail" "$STATE_JSON" <<'PY' -import json, sys -from datetime import datetime, timezone -phase, detail, path = sys.argv[1:4] -state = {} -try: - with open(path) as f: - state = json.load(f) -except FileNotFoundError: - pass -state.update({ - "updated_utc": datetime.now(timezone.utc).isoformat(), - "phase": phase, - "detail": detail, -}) -with open(path, "w") as f: - json.dump(state, f, indent=2, ensure_ascii=False) - f.write("\n") -PY -} - -pilot_running() { - pgrep -f "run_backend_multiseed.py.*out-base ${CALIB_OUT}" >/dev/null 2>&1 \ - || pgrep -f "run_backend_multiseed.py.*${CALIB_OUT}" >/dev/null 2>&1 -} - -pilot_done() { - [[ -f "$PILOT_LOG" ]] && grep -q '^\[DONE\]' "$PILOT_LOG" -} - -pilot_failed() { - if pilot_running; then - return 1 - fi - if pilot_done; then - return 1 - fi - if [[ -f "$PILOT_LOG" ]] && grep -q 'Traceback' "$PILOT_LOG"; then - return 0 - fi - # No process, no DONE — treat as failure if log exists and has START - if [[ -f "$PILOT_LOG" ]] && grep -q '^\[START\]' "$PILOT_LOG"; then - return 0 - fi - return 1 -} - -wait_for_pilot() { - write_state "waiting_pilot" "polling every ${POLL_SEC}s" - log "Waiting for 12ep calibration in ${CALIB_OUT} ..." - while true; do - if pilot_done; then - log "Pilot completed ([DONE] in log)." - write_state "pilot_completed" - return 0 - fi - if pilot_failed; then - log "ERROR: Pilot exited without [DONE]. See ${PILOT_LOG}" - write_state "pilot_failed" "check driver log" - exit 1 - fi - if pilot_running; then - progress="$(python3 - "$PILOT_LOG" <<'PY' || true -import re, sys -t = open(sys.argv[1]).read() -m = list(re.finditer(r'Epoch (\d+):.*?(\d+)/71', t)) -if m: - print(f"epoch {m[-1].group(1)} batch {m[-1].group(2)}/71") -else: - n = len(re.findall(r'Epoch \d+: Val MCC=', t)) - if n: - print(f"completed_epochs={n}") - else: - print("epoch 1 starting") -PY -)" - log "Pilot still running (${progress})" - evaluate_pilot_if_done - else - log "Pilot process not found yet; waiting ..." - fi - sleep "$POLL_SEC" - done -} - -decide_epochs() { - python3 - "$CALIB_OUT" "$DECISION_JSON" <<'PY' -import csv -import json -import sys -from pathlib import Path - -calib = Path(sys.argv[1]) -out_path = Path(sys.argv[2]) - -# Find metrics from progress_summary or latest run dir -metrics_path = None -progress = calib / "progress_summary.csv" -if progress.exists(): - rows = list(csv.DictReader(progress.open())) - if rows: - run_dir = Path(rows[-1]["run_dir"]) - candidate = run_dir / "logs" / "metrics.csv" - if candidate.exists(): - metrics_path = candidate - -if metrics_path is None: - candidates = sorted( - list(calib.glob("eph_s42_*/logs/metrics.csv")) - + list(calib.glob("backend_ellphi_seed42_*/logs/metrics.csv")) - ) - if candidates: - metrics_path = candidates[-1] - -if metrics_path is None or not metrics_path.exists(): - raise SystemExit("metrics.csv not found for pilot") - -rows = list(csv.DictReader(metrics_path.open())) -if len(rows) < 3: - raise SystemExit(f"Too few epochs in {metrics_path} ({len(rows)} rows)") - -def mcc_at(epoch: int) -> float | None: - for r in rows: - if int(r["epoch"]) == epoch: - return float(r["val_mcc"]) - return None - -best_row = max(rows, key=lambda r: float(r["val_mcc"])) -best_ep = int(best_row["epoch"]) -best_mcc = float(best_row["val_mcc"]) - -m10 = mcc_at(10) -m12 = mcc_at(12) if mcc_at(12) is not None else float(rows[-1]["val_mcc"]) -last_ep = int(rows[-1]["epoch"]) - -gain_10_12 = None -if m10 is not None and m12 is not None: - gain_10_12 = m12 - m10 - -use_20 = False -reason = [] -if gain_10_12 is not None and gain_10_12 < 0.002 and best_ep <= 11: - if m10 is not None and m10 >= 0.99 * best_mcc: - use_20 = True - reason.append("gain_10_12<0.002, best<=11, ep10>=99%best") -if last_ep < 10: - use_20 = False - reason = [f"only {last_ep} epochs logged; default 30"] - -chosen = 20 if use_20 else 30 -decision = { - "chosen_epochs": chosen, - "criteria": { - "gain_10_12": gain_10_12, - "best_epoch": best_ep, - "best_val_mcc": best_mcc, - "val_mcc_ep10": m10, - "val_mcc_ep12_or_last": m12, - "last_logged_epoch": last_ep, - }, - "reason": reason or ["default 30 (still improving or criteria not met)"], - "metrics_path": str(metrics_path), -} -out_path.write_text(json.dumps(decision, indent=2, ensure_ascii=False) + "\n") -print(chosen) -PY -} - -launch_main() { - local epochs="$1" - local out_base="${MAIN_OUT}" - mkdir -p "$out_base" - - if [[ -f "${out_base}/progress_summary.csv" ]]; then - completed="$(python3 - "$out_base/progress_summary.csv" <<'PY' -import csv, sys -rows = list(csv.DictReader(open(sys.argv[1]))) -print(sum(1 for r in rows if r.get("backend") == "ellphi")) -PY -)" - if [[ "$completed" -ge 5 ]]; then - log "Main run already has ${completed} ellphi rows in ${out_base}; skipping." - write_state "main_already_complete" "$out_base" - evaluate_all_runs "$out_base" - return 0 - fi - fi - - if pgrep -f "run_backend_multiseed.py.*out-base ${out_base}" >/dev/null 2>&1; then - log "Main run already in progress under ${out_base}; skipping duplicate launch." - write_state "main_running" "$out_base" - return 0 - fi - - log "Launching main: ${epochs}ep × 5 seeds → ${out_base}" - write_state "main_starting" "${epochs}ep ${out_base}" - - uv run python experiments/run_backend_multiseed.py \ - --base-config "$MAIN_CONFIG" \ - --epochs "$epochs" \ - --seeds "${SEEDS[@]}" \ - --backends ellphi \ - --out-base "$out_base" \ - 2>&1 | tee -a "${out_base}/driver_main.log" - - write_state "main_completed" "$out_base" - log "Main run finished. See ${out_base}/backend_stats.csv" - evaluate_all_runs "$out_base" -} - -evaluate_run_paper_protocol() { - local run_dir="$1" - local base_cfg="${2:-reproduce}" - log "Paper DBSCAN eval: ${run_dir} (config=${base_cfg})" - uv run python experiments/evaluate_paper_protocol.py \ - --run-dir "$run_dir" \ - --base-config "$base_cfg" \ - --split val - uv run python experiments/evaluate_paper_protocol.py \ - --run-dir "$run_dir" \ - --base-config "$base_cfg" \ - --split test -} - -evaluate_all_runs() { - local out_base="$1" - local base_cfg="${2:-$MAIN_CONFIG}" - local progress="${out_base}/progress_summary.csv" - [[ -f "$progress" ]] || return 0 - python3 - "$progress" <<'PY' | while read -r rd; do -import csv, sys -for row in csv.DictReader(open(sys.argv[1])): - if row.get("backend") == "ellphi": - print(row["run_dir"]) -PY - evaluate_run_paper_protocol "$rd" "$base_cfg" - done -} - -evaluate_pilot_if_done() { - if ! pilot_done; then - return 0 - fi - evaluate_all_runs "$CALIB_OUT" "reproduce" -} - -main() { - log "=== ellphi postfix autopipeline start (repo=${REPO_ROOT}) ===" - if ! pilot_done; then - wait_for_pilot - else - log "Pilot already complete; skipping wait." - write_state "pilot_completed" "pre-existing" - fi - - evaluate_pilot_if_done - - epochs="$MAIN_EPOCHS" - log "Paper main: fixed ${epochs}ep → ${MAIN_OUT} (config=${MAIN_CONFIG})" - python3 - "$epochs" "$DECISION_JSON" <<'PY' -import json, sys -from datetime import datetime, timezone -epochs, path = sys.argv[1:3] -decision = { - "chosen_epochs": int(epochs), - "reason": ["fixed 12ep for paper reproduce_1week_tuned (override MAIN_EPOCHS to change)"], - "decided_utc": datetime.now(timezone.utc).isoformat(), -} -with open(path, "w") as f: - json.dump(decision, f, indent=2, ensure_ascii=False) - f.write("\n") -PY - write_state "epoch_decided" "${epochs}ep ${MAIN_OUT}" - - launch_main "$epochs" - write_state "pipeline_finished" "${epochs}ep ${MAIN_OUT}" - log "=== autopipeline finished ===" -} - -main "$@" diff --git a/experiments/ellphi_retune_v2_autopipeline.sh b/experiments/ellphi_retune_v2_autopipeline.sh deleted file mode 100755 index eaf7907..0000000 --- a/experiments/ellphi_retune_v2_autopipeline.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# Autopipeline: (1) ellphi re-tune v2 → (2) update configs → (3) full120 30ep + eval -# -# Detached: -# bash experiments/launch_detached_screen.sh ellphi_v2_pipeline \ -# outputs/tune_elongate_ellphi_v2/autopipeline.log \ -# experiments/ellphi_retune_v2_autopipeline.sh - -set -euo pipefail - -cd "$(dirname "$0")/.." -export PATH="${HOME}/.local/bin:${PATH}" - -N_WORKERS="${N_WORKERS:-8}" -N_TRIALS="${N_TRIALS:-50}" -TUNE_EPOCHS="${TUNE_EPOCHS:-20}" -BACKEND="${BACKEND:-ellphi}" -OUT_BASE="${OUT_BASE:-outputs/tune_elongate_ellphi_v2}" -BEST_JSON="${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" -POLL_SEC="${POLL_SEC:-120}" -LOG_DIR="${OUT_BASE}" - -log() { echo "[$(date -Iseconds)] $*"; } - -mkdir -p "${LOG_DIR}" - -log "=== STEP 1/3: re-tune v2 (${N_WORKERS} workers, ${N_TRIALS} trials, ${TUNE_EPOCHS}ep) ===" -bash experiments/run_retune_parallel.sh "${N_WORKERS}" "${N_TRIALS}" "${TUNE_EPOCHS}" "${BACKEND}" - -if [[ ! -f "${BEST_JSON}" ]]; then - log "ERROR: missing ${BEST_JSON} after tune" - exit 1 -fi -log "tune complete: ${BEST_JSON}" - -log "=== STEP 2/3: apply best params to configs ===" -uv run python experiments/apply_tune_best_to_configs.py --json "${BEST_JSON}" - -log "=== STEP 3/3: full120 30ep train + evaluate ===" -TUNE_JSON="${BEST_JSON}" bash experiments/run_ellphi_tuned_v2_full120_30ep.sh - -log "=== PIPELINE COMPLETE ===" diff --git a/experiments/launch_detached_screen.sh b/experiments/launch_detached_screen.sh index 9a5bca2..51f505c 100755 --- a/experiments/launch_detached_screen.sh +++ b/experiments/launch_detached_screen.sh @@ -6,9 +6,9 @@ # bash experiments/launch_detached_screen.sh SESSION_NAME LOG_FILE SCRIPT.sh [args...] # # Example: -# bash experiments/launch_detached_screen.sh ellphi_tuned30 \ -# outputs/supervised/20260703_ellphi_tuned_full120_30ep_screen/driver.log \ -# experiments/run_ellphi_tuned_full120_30ep.sh +# bash experiments/launch_detached_screen.sh pwr30 \ +# outputs/supervised/pwr30/driver.log \ +# experiments/run_teacher_local_pca_power_30ep_multiseed.sh wdist set -euo pipefail diff --git a/experiments/plot_distance_matrix.py b/experiments/plot_distance_matrix.py deleted file mode 100644 index c113e87..0000000 --- a/experiments/plot_distance_matrix.py +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env python3 -"""Side-by-side heatmaps of Mahalanobis vs ellphi anisotropic distance matrices.""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -import matplotlib.pyplot as plt -import numpy as np -import torch - -from tda_ml.config import load_config -from tda_ml.data_loader import NoisyMNISTDataset -from tda_ml.dbscan import compute_anisotropic_distance_matrix_np -from tda_ml.model_inference import load_model - -REPO_ROOT = Path(__file__).resolve().parents[1] - -INLIER_COLOR = "#2166ac" -OUTLIER_COLOR = "#b2182b" - - -def _build_val_dataset(config: dict, sample_idx: int) -> tuple[np.ndarray, np.ndarray, int]: - data_cfg = config["data"] - seed = int(data_cfg.get("seed", 42)) - train_size = int(data_cfg.get("train_size", 4500)) - val_size = int(data_cfg.get("val_size", 500)) - generator = torch.Generator().manual_seed(seed) - full_train_indices = torch.randperm(60000, generator=generator)[: train_size + val_size] - val_indices = full_train_indices[train_size:] - - dataset = NoisyMNISTDataset( - root=str(REPO_ROOT / "data"), - train=True, - max_points=data_cfg["max_points"], - num_outliers=data_cfg["num_outliers"], - indices=val_indices, - deterministic=True, - noise_seed=seed, - preload=True, - ) - idx = sample_idx % len(dataset) - data, labels, _clean = dataset[idx] - return data.numpy(), labels.numpy().astype(int).flatten(), idx - - -def _sort_by_label(points: np.ndarray, labels: np.ndarray, params: np.ndarray): - order = np.argsort(labels, kind="stable") - return points[order], labels[order], params[order] - - -def _off_diagonal(dm: np.ndarray) -> np.ndarray: - return dm[np.triu_indices(dm.shape[0], k=1)] - - -def _matrix_stats(dm: np.ndarray) -> dict[str, float]: - off = _off_diagonal(dm) - return { - "min": float(off.min()), - "median": float(np.median(off)), - "mean": float(off.mean()), - "max": float(off.max()), - } - - -def _normalize_matrix(dm: np.ndarray, mode: str) -> tuple[np.ndarray, float]: - """Return (normalized matrix, scale factor applied to off-diagonal pairs).""" - if mode == "none": - return dm, 1.0 - off = _off_diagonal(dm) - if mode == "median": - scale = float(np.median(off)) - elif mode == "max": - scale = float(off.max()) - elif mode == "mean": - scale = float(off.mean()) - else: - raise ValueError(f"Unknown normalize mode: {mode!r}") - scale = max(scale, 1e-12) - out = dm.copy() - n = dm.shape[0] - mask = ~np.eye(n, dtype=bool) - out[mask] = dm[mask] / scale - return out, scale - - -def plot_distance_matrices( - points: np.ndarray, - params: np.ndarray, - labels: np.ndarray, - *, - output_path: Path, - title_suffix: str = "", - normalize: str = "none", -) -> None: - dm_maha = compute_anisotropic_distance_matrix_np( - points, params, metric="max", probs=None, backend="mahalanobis" - ) - dm_ell = compute_anisotropic_distance_matrix_np( - points, params, metric="max", probs=None, backend="ellphi" - ) - - dm_maha, scale_m = _normalize_matrix(dm_maha, normalize) - dm_ell, scale_e = _normalize_matrix(dm_ell, normalize) - ratio = dm_ell / np.clip(dm_maha, 1e-12, None) - - stats_m = _matrix_stats(dm_maha) - stats_e = _matrix_stats(dm_ell) - stats_r = _matrix_stats(ratio) - - n = points.shape[0] - n_in = int((labels == 0).sum()) - n_out = int((labels == 1).sum()) - boundary = n_in - 0.5 - - norm_note = "" - if normalize != "none": - norm_note = f" (normalized by off-diagonal {normalize}; scales maha={scale_m:.3f}, ellphi={scale_e:.3f})" - - vmax = max(stats_m["max"], stats_e["max"]) - fig, axes = plt.subplots(1, 3, figsize=(16, 5.2), constrained_layout=True) - - maha_title = "Mahalanobis" + (f" / {normalize}" if normalize != "none" else "") - ell_title = "ellphi (tangency)" + (f" / {normalize}" if normalize != "none" else "") - - for ax, mat, title, stats, cmap in ( - (axes[0], dm_maha, maha_title, stats_m, "viridis"), - (axes[1], dm_ell, ell_title, stats_e, "viridis"), - (axes[2], ratio, "ellphi / Mahalanobis", stats_r, "coolwarm"), - ): - if cmap == "viridis": - im = ax.imshow(mat, cmap=cmap, vmin=0, vmax=vmax) - else: - rlim = max(abs(stats_r["min"] - 1.0), abs(stats_r["max"] - 1.0), 0.05) - im = ax.imshow(mat, cmap=cmap, vmin=1.0 - rlim, vmax=1.0 + rlim) - ax.axhline(boundary, color="white", lw=0.8, alpha=0.9) - ax.axvline(boundary, color="white", lw=0.8, alpha=0.9) - ax.set_title( - f"{title}\n" - f"min={stats['min']:.3f} med={stats['median']:.3f} " - f"mean={stats['mean']:.3f} max={stats['max']:.3f}", - fontsize=10, - ) - ax.set_xlabel("point j") - ax.set_ylabel("point i") - fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) - - suptitle = ( - f"Anisotropic distance matrices (N={n}, inlier={n_in}, outlier={n_out})" - f"{': ' + title_suffix if title_suffix else ''}{norm_note}\n" - "Rows/cols sorted: inliers (blue region) then outliers (red region); " - "white lines mark the inlier|outlier boundary." - ) - fig.suptitle(suptitle, fontsize=11) - - # Label strip on the left panel - strip = np.where(labels == 0, 0, 1)[None, :] - inset = axes[0].inset_axes([-0.12, 0.0, 0.04, 1.0]) - inset.imshow(strip, aspect="auto", cmap=plt.matplotlib.colors.ListedColormap([INLIER_COLOR, OUTLIER_COLOR])) - inset.set_xticks([]) - inset.set_yticks([]) - inset.set_ylabel("GT", rotation=0, labelpad=12, va="center") - - output_path.parent.mkdir(parents=True, exist_ok=True) - fig.savefig(output_path, dpi=160, bbox_inches="tight") - plt.close(fig) - print(f"Saved: {output_path}") - print( - f" Mahalanobis median={stats_m['median']:.4f} mean={stats_m['mean']:.4f}\n" - f" ellphi median={stats_e['median']:.4f} mean={stats_e['mean']:.4f}\n" - f" ratio median={stats_r['median']:.4f} mean={stats_r['mean']:.4f}" - ) - - -def plot_point_cloud( - points: np.ndarray, - params: np.ndarray, - labels: np.ndarray, - *, - output_path: Path, - title_suffix: str = "", -) -> None: - """Two panels: GT-labeled scatter, and the same points with learned ellipses.""" - in_m = labels == 0 - out_m = labels == 1 - - fig, axes = plt.subplots(1, 2, figsize=(11, 5.5), constrained_layout=True) - - ax0 = axes[0] - ax0.scatter(points[in_m, 0], points[in_m, 1], c=INLIER_COLOR, s=14, label=f"Inlier ({in_m.sum()})") - ax0.scatter(points[out_m, 0], points[out_m, 1], c=OUTLIER_COLOR, s=14, label=f"Outlier ({out_m.sum()})") - ax0.set_title("GT labels") - ax0.legend(loc="upper right", fontsize=8) - - ax1 = axes[1] - ax1.scatter(points[in_m, 0], points[in_m, 1], c=INLIER_COLOR, s=10) - ax1.scatter(points[out_m, 0], points[out_m, 1], c=OUTLIER_COLOR, s=10) - t = np.linspace(0, 2 * np.pi, 60) - for k in range(len(points)): - a, b, theta = params[k] - x_e = a * np.cos(t) - y_e = b * np.sin(t) - x_r = x_e * np.cos(theta) - y_e * np.sin(theta) + points[k, 0] - y_r = x_e * np.sin(theta) + y_e * np.cos(theta) + points[k, 1] - color = OUTLIER_COLOR if labels[k] == 1 else INLIER_COLOR - ax1.plot(x_r, y_r, color=color, alpha=0.35, linewidth=0.8) - ax1.set_title("Learned ellipses (GT-colored)") - - for ax in axes: - ax.set_aspect("equal") - ax.set_xlim(-1.2, 1.2) - ax.set_ylim(-1.2, 1.2) - - fig.suptitle(f"Point cloud used for the distance matrices{': ' + title_suffix if title_suffix else ''}", fontsize=11) - output_path.parent.mkdir(parents=True, exist_ok=True) - fig.savefig(output_path, dpi=160, bbox_inches="tight") - plt.close(fig) - print(f"Saved: {output_path}") - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--config", default="elongate_n100_no_cls_full120_ellphi_tuned") - p.add_argument("--weights", type=Path, required=True) - p.add_argument("--sample-idx", type=int, default=0) - p.add_argument( - "--output", - type=Path, - default=REPO_ROOT / "outputs" / "images" / "distance_matrix_maha_vs_ellphi.png", - ) - p.add_argument("--device", default="cpu") - p.add_argument( - "--normalize", - choices=["none", "median", "mean", "max"], - default="none", - help="Scale off-diagonal entries by median/mean/max of upper triangle (diagonal stays 0).", - ) - p.add_argument( - "--cloud-output", - type=Path, - default=None, - help="Also save a scatter plot (GT labels + learned ellipses) of the point cloud.", - ) - return p.parse_args() - - -def main() -> int: - args = parse_args() - config = load_config(args.config) - device = torch.device(args.device) - - points, labels, dataset_idx = _build_val_dataset(config, args.sample_idx) - model = load_model(device, weights_path=str(args.weights)) - with torch.no_grad(): - data_t = torch.as_tensor(points, dtype=torch.float32, device=device).unsqueeze(0) - _logits, params_t = model(data_t) - params = params_t.squeeze(0).cpu().numpy() - - points, labels, params = _sort_by_label(points, labels, params) - title_suffix = f"val idx={dataset_idx}, weights={args.weights.parent.name}" - plot_distance_matrices( - points, - params, - labels, - output_path=args.output, - title_suffix=title_suffix, - normalize=args.normalize, - ) - if args.cloud_output is not None: - plot_point_cloud( - points, - params, - labels, - output_path=args.cloud_output, - title_suffix=title_suffix, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/experiments/run_ellphi_tuned_full120_30ep.sh b/experiments/run_ellphi_tuned_full120_30ep.sh deleted file mode 100755 index b14cfc6..0000000 --- a/experiments/run_ellphi_tuned_full120_30ep.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# Train ellphi 30ep with tuned hyperparams, then evaluate vs maha-hparam baseline. -set -euo pipefail - -cd "$(dirname "$0")/.." -export PATH="${HOME}/.local/bin:${PATH}" -export OMP_NUM_THREADS="${OMP_NUM_THREADS:-4}" - -OUT_BASE="outputs/supervised/20260703_ellphi_tuned_full120_30ep_screen" -BASELINE_PARENT="outputs/supervised/20260701/190250_elongate_n100_no_cls_full120_baseline_ellphi_30ep_screen" -CONFIG="elongate_n100_no_cls_full120_ellphi_tuned" -BASELINE_CONFIG="elongate_n100_no_cls_full120_baseline" - -mkdir -p "$OUT_BASE" -cat > "${OUT_BASE}/PURPOSE.md" <<'EOF' -# ellphi full120 30ep with Optuna-tuned hyperparams (ellphi backend) - -Compare against `.../190250_..._baseline_ellphi_30ep_screen` (same config shape, maha-tuned weights). - -Tune source: `outputs/tune_elongate_ellphi/best_elongate_wdist_ellphi.json` -- w_topo=0.2088, w_aniso=0.0843, w_size=0.4446, lr=0.000287 - -Post-train: `experiments/evaluate_topo_checkpoint.py` (train_topo_loss min checkpoint → test). -EOF - -echo "=== train $(date -Iseconds) ===" -uv run python experiments/run_backend_multiseed.py \ - --base-config "$CONFIG" \ - --backends ellphi \ - --seeds 42 \ - --epochs 30 \ - --out-base "$OUT_BASE" - -RUN_DIR=$(scripts/latest_run_dir.sh "$OUT_BASE" eph_s42 backend_ellphi_seed42) -BASELINE_DIR=$(scripts/latest_run_dir.sh "$BASELINE_PARENT" eph_s42 backend_ellphi_seed42) - -echo "=== evaluate $(date -Iseconds) ===" -uv run python experiments/evaluate_topo_checkpoint.py \ - --run-dir "$RUN_DIR" \ - --base-config "$CONFIG" \ - --backend ellphi \ - --baseline-run-dir "$BASELINE_DIR" \ - --baseline-config "$BASELINE_CONFIG" \ - --compare-json "${RUN_DIR}/logs/ellphi_tuned_vs_maha_hparams_test.json" - -echo "=== done $(date -Iseconds) ===" -echo "run_dir=$RUN_DIR" -echo "compare=${RUN_DIR}/logs/ellphi_tuned_vs_maha_hparams_test.json" diff --git a/experiments/run_ellphi_tuned_v2_full120_30ep.sh b/experiments/run_ellphi_tuned_v2_full120_30ep.sh deleted file mode 100755 index cf97d47..0000000 --- a/experiments/run_ellphi_tuned_v2_full120_30ep.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# 30ep full120 with v2 ellphi-tuned hyperparams + paper eval (train topo ckpt). -set -euo pipefail - -cd "$(dirname "$0")/.." -export PATH="${HOME}/.local/bin:${PATH}" -export OMP_NUM_THREADS="${OMP_NUM_THREADS:-4}" - -OUT_BASE="${OUT_BASE:-outputs/supervised/20260704_ellphi_tuned_v2_full120_30ep_screen}" -BASELINE_PARENT="${BASELINE_PARENT:-outputs/supervised/20260701/190250_elongate_n100_no_cls_full120_baseline_ellphi_30ep_screen}" -CONFIG="${CONFIG:-elongate_n100_no_cls_full120_ellphi_tuned}" -BASELINE_CONFIG="${BASELINE_CONFIG:-elongate_n100_no_cls_full120_baseline}" -TUNE_JSON="${TUNE_JSON:-outputs/tune_elongate_ellphi_v2/best_elongate_wdist_ellphi.json}" - -mkdir -p "$OUT_BASE" -cat > "${OUT_BASE}/PURPOSE.md" < "${OUT_BASE}/PURPOSE.md" < "${OUT_BASE}/worker_${i}.log" 2>&1 & - pids+=($!) - echo " worker ${i}: PID $!, seed ${SEED}, log ${OUT_BASE}/worker_${i}.log" - sleep 1 -done - -echo "Waiting for ${N_WORKERS} workers..." -for pid in "${pids[@]}"; do - wait "${pid}" || echo " worker PID ${pid} exited non-zero" -done - -echo "Writing best params..." -uv run python -u experiments/tune_elongate_wdist.py \ - --base-config elongate_n100 \ - --n-trials "${N_TRIALS}" \ - --tune-epochs "${TUNE_EPOCHS}" \ - --backend "${BACKEND}" \ - --out-base "${OUT_BASE}" \ - --storage "${STORAGE}" \ - --study-name "${STUDY_NAME}" \ - --write-best - -echo "Done: ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" diff --git a/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py b/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py deleted file mode 100755 index 0767cbb..0000000 --- a/experiments/run_teacher_local_pca_ellphi_smoke_10ep.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python3 -"""10-epoch smoke: ellphi + local_pca teacher. Logs init Wasserstein before training.""" - -from __future__ import annotations - -import argparse -import json -import logging -from pathlib import Path - -import torch -from torch_topological.nn import VietorisRipsComplex, WassersteinDistance - -from tda_ml.config import deep_update, load_config, model_kwargs_from_config -from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader -from tda_ml.distance_backend import compute_distance_matrix_batch -from tda_ml.main import main as train_main -from tda_ml.run_setup import configure_torch_runtime, resolve_dataloader_settings -from tda_ml.models import AnisotropicOutlierClassifier -from tda_ml.seed_utils import set_global_seed -from tda_ml.trainer import Trainer - -REPO_ROOT = Path(__file__).resolve().parents[1] -logger = logging.getLogger(__name__) - - -def _init_wasserstein( - trainer: Trainer, - loader, - device: torch.device, -) -> dict[str, float]: - """Mean init Wasserstein^2 between pred PD and teacher PD (one train batch).""" - model = trainer.model - model.eval() - vr = VietorisRipsComplex(dim=1) - wdist_fn = WassersteinDistance(q=2) - topo_fn = trainer.topo_loss_fn - - data, _labels, clean_pc = next(iter(loader)) - data = data.to(device) - clean_pc = clean_pc.to(device) - - with torch.no_grad(): - logits, params = model(data) - clean_pd, clean_scales = trainer._compute_clean_pd_info(clean_pc) - - per_sample: list[float] = [] - with torch.no_grad(): - for i in range(data.shape[0]): - pts_i, par_i, logits_i = topo_fn._subsample_points( - data[i], params[i], logits[i] - ) - probs_i = ( - torch.sigmoid(logits_i).squeeze(-1) - if topo_fn.prob_weighting - else None - ) - d_batch = compute_distance_matrix_batch( - pts_i.unsqueeze(0), - par_i.unsqueeze(0), - probs=probs_i.unsqueeze(0) if probs_i is not None else None, - symmetrize="max", - backend=topo_fn.distance_backend, - ellphi_differentiable=topo_fn.ellphi_differentiable, - ) - clean_scale_i = clean_scales[i] if clean_scales is not None else None - d_mat = topo_fn._rescale_distance_matrix(d_batch[0], clean_scale=clean_scale_i) - pd_pred = vr(d_mat, treat_as_distances=True) - w2 = float(wdist_fn(pd_pred, clean_pd[i]) ** 2) - per_sample.append(w2) - - return { - "n_samples": len(per_sample), - "wasserstein2_mean": float(sum(per_sample) / len(per_sample)), - "wasserstein2_min": float(min(per_sample)), - "wasserstein2_max": float(max(per_sample)), - "teacher_mode": trainer.teacher_mode, - "distance_backend": trainer.distance_backend, - } - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description=__doc__) - p.add_argument( - "--config", - default="configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml", - ) - p.add_argument("--epochs", type=int, default=10) - p.add_argument("--seed", type=int, default=42) - p.add_argument( - "--out-base", - type=Path, - default=REPO_ROOT / "outputs/supervised/20260705_teacher_local_pca_ellphi_10ep", - ) - p.add_argument("--backend", default="ellphi", choices=["ellphi", "mahalanobis"]) - return p.parse_args() - - -def main() -> int: - logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") - args = parse_args() - set_global_seed(args.seed) - - cfg = load_config(args.config, project_root=REPO_ROOT) - cfg = deep_update( - cfg, - { - "training": {"epochs": args.epochs}, - "data": {"seed": args.seed}, - "model": { - "topology_loss": { - "distance_backend": args.backend, - "prob_weighting": False, - } - }, - "loss": { - "teacher_mode": "local_pca", - "w_class": 0.0, - }, - "outputs": {"base_dir": str(args.out_base)}, - "meta": {"config_id": f"teacher_local_pca_{args.backend}_seed{args.seed}"}, - }, - ) - - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - configure_torch_runtime(cfg, device) - num_workers, pin_memory, persistent_workers, prefetch_factor = resolve_dataloader_settings( - cfg, device - ) - data_cfg = cfg["data"] - train_ds = NoisyMNISTDataset( - root="./data", - train=True, - num_samples=data_cfg["train_size"], - max_points=data_cfg["max_points"], - num_outliers=data_cfg["num_outliers"], - noise_std=data_cfg["noise_std"], - deterministic=True, - noise_seed=data_cfg["seed"], - ) - loader = create_data_loader( - train_ds, - batch_size=min(8, data_cfg["batch_size"]), - shuffle=True, - num_workers=num_workers, - pin_memory=pin_memory, - persistent_workers=persistent_workers, - prefetch_factor=prefetch_factor, - ) - - model = AnisotropicOutlierClassifier(**model_kwargs_from_config(cfg)).to(device) - trainer = Trainer(model, cfg, device=device) - init_stats = _init_wasserstein(trainer, loader, device) - logger.info("Init teacher Wasserstein^2: %s", json.dumps(init_stats, indent=2)) - - out_base = Path(args.out_base) - out_base.mkdir(parents=True, exist_ok=True) - init_path = out_base / f"init_wasserstein_{args.backend}_seed{args.seed}.json" - init_path.write_text(json.dumps(init_stats, indent=2) + "\n", encoding="utf-8") - logger.info("Wrote %s", init_path) - - train_main(config=cfg) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_teacher_local_pca_power_30ep.py index b583921..be72348 100644 --- a/experiments/run_teacher_local_pca_power_30ep.py +++ b/experiments/run_teacher_local_pca_power_30ep.py @@ -20,7 +20,7 @@ ) BASE_CONFIG = "elongate_n100_no_cls_full120_teacher_local_pca" -DEFAULT_OUT = REPO_ROOT / "outputs/supervised/0709_pwr30" +DEFAULT_OUT = REPO_ROOT / "outputs/supervised/pwr30" TAG = "power_valtopo_paper_eval" TAG_MCC = "power_mcc_valtopo_paper_eval" TAG_WDIST = "power_wdist_valtopo_paper_eval" @@ -91,7 +91,7 @@ def main() -> int: out_base = args.out_base if out_base is None: out_base = ( - REPO_ROOT / "outputs/supervised/0709_pwr30_mcc" + REPO_ROOT / "outputs/supervised/pwr30_mcc" if tune_json is not None else DEFAULT_OUT ) diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh index 84d2b3f..707a294 100755 --- a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh +++ b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh @@ -12,7 +12,7 @@ # Detached (SSH/logout safe; machine reboot still stops the job): # N_WORKERS=4 THREADS_PER_WORKER=4 \ # bash experiments/launch_detached_screen.sh pwr30_ms \ -# outputs/supervised/0710_pwr30_multiseed/driver.log \ +# outputs/supervised/pwr30_multiseed/driver.log \ # experiments/run_teacher_local_pca_power_30ep_multiseed.sh both # # Parallelism: N_WORKERS seeds per objective wave; THREADS_PER_WORKER per process diff --git a/experiments/run_tune_local_pca_parallel.sh b/experiments/run_tune_local_pca_parallel.sh deleted file mode 100755 index ff0fa2f..0000000 --- a/experiments/run_tune_local_pca_parallel.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -# Optuna tune: local_pca ellphi teacher, no_cls, val_topo checkpoint, topo W-Dist objective. -# -# Usage: -# bash experiments/run_tune_local_pca_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] -# -# Defaults: 8 workers, 50 trials, 20 epochs, ellphi. -# -# Detached (SSH-safe): -# bash experiments/launch_detached_screen.sh tune_local_pca_v3 \ -# outputs/tune_elongate_ellphi_local_pca_v3/launcher.log \ -# experiments/run_tune_local_pca_parallel.sh 8 50 20 ellphi - -set -euo pipefail - -cd "$(dirname "$0")/.." -export PATH="${HOME}/.local/bin:${PATH}" - -N_WORKERS="${1:-8}" -N_TRIALS="${2:-50}" -TUNE_EPOCHS="${3:-20}" -BACKEND="${4:-ellphi}" -BASE_CONFIG="elongate_n100_no_cls_tune_local_pca" - -if [[ "${BACKEND}" == "mahalanobis" ]]; then - OUT_BASE="outputs/tune_elongate_local_pca_v3" -else - OUT_BASE="outputs/tune_elongate_${BACKEND}_local_pca_v3" -fi - -STUDY_NAME="elongate_local_pca_topo_wdist_${BACKEND}_v3" -STORAGE="sqlite:///${OUT_BASE}/study.db" -THREADS_PER_WORKER=12 - -mkdir -p "${OUT_BASE}" -cat > "${OUT_BASE}/PURPOSE.md" < "${OUT_BASE}/worker_${i}.log" 2>&1 & - pids+=($!) - echo " worker ${i}: PID $!, seed ${SEED}, log ${OUT_BASE}/worker_${i}.log" - sleep 1 -done - -echo "Waiting for ${N_WORKERS} workers..." -for pid in "${pids[@]}"; do - wait "${pid}" || echo " worker PID ${pid} exited non-zero" -done - -echo "Writing best params..." -uv run python -u experiments/tune_elongate_wdist.py \ - --base-config "${BASE_CONFIG}" \ - --n-trials "${N_TRIALS}" \ - --tune-epochs "${TUNE_EPOCHS}" \ - --backend "${BACKEND}" \ - --out-base "${OUT_BASE}" \ - --storage "${STORAGE}" \ - --study-name "${STUDY_NAME}" \ - --write-best - -echo "Done: ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" -echo "Apply to configs: uv run python experiments/apply_tune_best_to_configs.py --json ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" diff --git a/experiments/run_tune_local_pca_power_mcc_parallel.sh b/experiments/run_tune_local_pca_power_mcc_parallel.sh index 6e77637..eb0cd5f 100755 --- a/experiments/run_tune_local_pca_power_mcc_parallel.sh +++ b/experiments/run_tune_local_pca_power_mcc_parallel.sh @@ -12,7 +12,7 @@ # # Detached: # bash experiments/launch_detached_screen.sh tune_power_mcc_maha \ -# outputs/tune/0709_pwr_mcc_maha/launcher.log \ +# outputs/tune/pwr_mcc_maha/launcher.log \ # experiments/run_tune_local_pca_power_mcc_parallel.sh 4 24 20 ellphi mahalanobis set -euo pipefail @@ -26,7 +26,7 @@ TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" DBSCAN_BACKEND="${5:-mahalanobis}" BASE_CONFIG="elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc" -OUT_BASE="${OUT_BASE:-outputs/tune/0709_pwr_mcc_dbscan_${DBSCAN_BACKEND}}" +OUT_BASE="${OUT_BASE:-outputs/tune/pwr_mcc_dbscan_${DBSCAN_BACKEND}}" STUDY_NAME="elongate_local_pca_power_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}" STORAGE="sqlite:///${OUT_BASE}/study.db" THREADS_PER_WORKER=12 diff --git a/experiments/run_tune_local_pca_power_objectives.sh b/experiments/run_tune_local_pca_power_objectives.sh index 84a9382..7ccaa3f 100755 --- a/experiments/run_tune_local_pca_power_objectives.sh +++ b/experiments/run_tune_local_pca_power_objectives.sh @@ -10,7 +10,7 @@ # # Detached (both studies, ~8–10h total with 4 workers each): # bash experiments/launch_detached_screen.sh tune_pwr_obj \ -# outputs/tune/0709_pwr_objectives/launcher.log \ +# outputs/tune/pwr_objectives/launcher.log \ # experiments/run_tune_local_pca_power_objectives.sh both 4 24 20 set -euo pipefail @@ -25,12 +25,12 @@ TUNE_EPOCHS="${4:-20}" BACKEND="ellphi" DBSCAN_BACKEND="mahalanobis" -LOG_ROOT="${LOG_ROOT:-outputs/tune/0709_pwr_objectives}" +LOG_ROOT="${LOG_ROOT:-outputs/tune/pwr_objectives}" mkdir -p "${LOG_ROOT}" run_wdist() { echo "=== W-Dist objective tune ===" - OUT_BASE="${OUT_BASE:-outputs/tune/0709_pwr_wdist}" \ + OUT_BASE="${OUT_BASE:-outputs/tune/pwr_wdist}" \ bash experiments/run_tune_local_pca_power_wdist_parallel.sh \ "${N_WORKERS}" "${N_TRIALS}" "${TUNE_EPOCHS}" "${BACKEND}" \ 2>&1 | tee "${LOG_ROOT}/wdist_launch.log" @@ -38,7 +38,7 @@ run_wdist() { run_mcc() { echo "=== MCC objective tune (DBSCAN=${DBSCAN_BACKEND}) ===" - OUT_BASE="${OUT_BASE:-outputs/tune/0709_pwr_mcc_dbscan_${DBSCAN_BACKEND}}" \ + OUT_BASE="${OUT_BASE:-outputs/tune/pwr_mcc_dbscan_${DBSCAN_BACKEND}}" \ bash experiments/run_tune_local_pca_power_mcc_parallel.sh \ "${N_WORKERS}" "${N_TRIALS}" "${TUNE_EPOCHS}" "${BACKEND}" "${DBSCAN_BACKEND}" \ 2>&1 | tee "${LOG_ROOT}/mcc_launch.log" @@ -58,5 +58,5 @@ case "${MODE}" in esac echo "Objectives complete (mode=${MODE})." -echo " W-Dist best: outputs/tune/0709_pwr_wdist/best_elongate_wdist_${BACKEND}.json" -echo " MCC best: outputs/tune/0709_pwr_mcc_dbscan_${DBSCAN_BACKEND}/best_elongate_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}.json" +echo " W-Dist best: outputs/tune/pwr_wdist/best_elongate_wdist_${BACKEND}.json" +echo " MCC best: outputs/tune/pwr_mcc_dbscan_${DBSCAN_BACKEND}/best_elongate_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}.json" diff --git a/experiments/run_tune_local_pca_power_wdist_parallel.sh b/experiments/run_tune_local_pca_power_wdist_parallel.sh index 9918905..201552a 100755 --- a/experiments/run_tune_local_pca_power_wdist_parallel.sh +++ b/experiments/run_tune_local_pca_power_wdist_parallel.sh @@ -9,7 +9,7 @@ # # Detached: # bash experiments/launch_detached_screen.sh tune_power_wdist \ -# outputs/tune/0709_pwr_wdist/launcher.log \ +# outputs/tune/pwr_wdist/launcher.log \ # experiments/run_tune_local_pca_power_wdist_parallel.sh 4 24 20 ellphi set -euo pipefail @@ -22,7 +22,7 @@ N_TRIALS="${2:-24}" TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc}" -OUT_BASE="${OUT_BASE:-outputs/tune/0709_pwr_wdist}" +OUT_BASE="${OUT_BASE:-outputs/tune/pwr_wdist}" STUDY_NAME="${STUDY_NAME:-elongate_local_pca_power_wdist_${BACKEND}}" STORAGE="sqlite:///${OUT_BASE}/study.db" THREADS_PER_WORKER="${THREADS_PER_WORKER:-12}" diff --git a/experiments/run_tune_parallel.sh b/experiments/run_tune_parallel.sh deleted file mode 100644 index b7be58e..0000000 --- a/experiments/run_tune_parallel.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -# Parallel Optuna tuning launcher for elongate (W-Dist objective). -# -# NOTE: For re-tunes aligned with train_topo checkpoint selection + save_every=1, -# use experiments/run_retune_parallel.sh instead (writes to outputs/*_v2). -# -# Spawns N worker processes that share ONE SQLite study, so trials are sampled -# cooperatively (TPE sees all workers' results). The study stops once TOTAL -# completed trials reaches --n-trials (enforced per-worker via MaxTrialsCallback). -# -# Usage: -# bash experiments/run_tune_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] [OUT_BASE] -# Defaults: 8 workers, 50 trials, 20 epochs, mahalanobis, outputs/tune_elongate. -# -# Examples: -# bash experiments/run_tune_parallel.sh 8 50 20 mahalanobis -# bash experiments/run_tune_parallel.sh 8 50 20 ellphi outputs/tune_elongate_ellphi - -set -euo pipefail - -N_WORKERS="${1:-8}" -N_TRIALS="${2:-50}" -TUNE_EPOCHS="${3:-20}" -BACKEND="${4:-mahalanobis}" -OUT_BASE="${5:-outputs/tune_elongate}" -STORAGE="sqlite:///${OUT_BASE}/study.db" -STUDY_NAME="elongate_wdist_4d_${BACKEND}" -THREADS_PER_WORKER=12 - -mkdir -p "${OUT_BASE}" - -echo "Launching ${N_WORKERS} workers, study-wide budget=${N_TRIALS} trials, ${TUNE_EPOCHS}ep, backend=${BACKEND}" -echo "Storage: ${STORAGE}" - -pids=() -for i in $(seq 0 $((N_WORKERS - 1))); do - SEED=$((42 + i)) # different sampler seed per worker to decorrelate startup - OMP_NUM_THREADS=${THREADS_PER_WORKER} \ - MKL_NUM_THREADS=${THREADS_PER_WORKER} \ - OPENBLAS_NUM_THREADS=${THREADS_PER_WORKER} \ - nohup uv run python -u experiments/tune_elongate_wdist.py \ - --base-config elongate_n100 \ - --n-trials "${N_TRIALS}" \ - --n-startup-trials 12 \ - --tune-epochs "${TUNE_EPOCHS}" \ - --backend "${BACKEND}" \ - --out-base "${OUT_BASE}" \ - --storage "${STORAGE}" \ - --study-name "${STUDY_NAME}" \ - --seed "${SEED}" \ - > "${OUT_BASE}/worker_${i}.log" 2>&1 & - pids+=($!) - echo " worker ${i}: PID $!, seed ${SEED}, log ${OUT_BASE}/worker_${i}.log" - sleep 1 -done - -echo "Waiting for ${N_WORKERS} workers to finish..." -for pid in "${pids[@]}"; do - wait "${pid}" || echo " worker PID ${pid} exited non-zero" -done - -echo "All workers done. Writing best params..." -uv run python -u experiments/tune_elongate_wdist.py \ - --base-config elongate_n100 \ - --n-trials "${N_TRIALS}" \ - --tune-epochs "${TUNE_EPOCHS}" \ - --backend "${BACKEND}" \ - --out-base "${OUT_BASE}" \ - --storage "${STORAGE}" \ - --study-name "${STUDY_NAME}" \ - --write-best - -echo "Done. See ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" diff --git a/experiments/tune_elongate_mcc.py b/experiments/tune_elongate_mcc.py index 4681afe..b4a5a51 100644 --- a/experiments/tune_elongate_mcc.py +++ b/experiments/tune_elongate_mcc.py @@ -219,7 +219,7 @@ def parse_args() -> argparse.Namespace: "not a point-to-point distance)." ), ) - p.add_argument("--out-base", default="outputs/tune/0709_pwr_mcc") + p.add_argument("--out-base", default="outputs/tune/pwr_mcc") p.add_argument("--size-mode", default="power", choices=["quadratic", "power", "softplus", "barrier"]) p.add_argument("--size-ref", type=float, default=1.34) p.add_argument("--size-power", type=float, default=1.5) diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py index c7486ee..c1855aa 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_elongate_wdist.py @@ -9,12 +9,13 @@ 2. Loads ``best_model.pth`` (val_topo selection checkpoint; hard-fail if missing). 3. Objective = mean val **topo W-Dist** (learned ellipses vs local_pca teacher PD). -Base config ``elongate_n100_no_cls_tune_local_pca`` sets ``teacher_mode: local_pca``, +Base config ``elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc`` sets +``teacher_mode: local_pca``, H1-only persistence, and ``size_mode: power``. ``w_class: 0``, ``selection.metric: val_topo``. Usage (parallel, recommended):: - bash experiments/run_tune_local_pca_parallel.sh 8 50 20 ellphi + bash experiments/run_tune_local_pca_power_wdist_parallel.sh 8 50 20 ellphi """ from __future__ import annotations diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index 83902c1..7dedb3f 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -13,9 +13,9 @@ class TestTuneTrialConfig(unittest.TestCase): - def test_local_pca_tune_base(self): + def test_canonical_power_tune_base(self): cfg = build_trial_config( - "elongate_n100_no_cls_tune_local_pca", + "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", w_aniso=0.1, w_size=0.2, w_topo=0.3, @@ -24,9 +24,12 @@ def test_local_pca_tune_base(self): tune_epochs=20, out_base="outputs/test_tune", trial_number=0, + size_mode="power", ) self.assertEqual(cfg["loss"]["w_class"], 0.0) self.assertEqual(cfg["loss"]["teacher_mode"], "local_pca") + self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) + self.assertEqual(cfg["loss"]["size_mode"], "power") self.assertEqual(cfg["training"]["selection"]["metric"], "val_topo") self.assertEqual(cfg["model"]["topology_loss"]["distance_backend"], "ellphi") self.assertFalse(cfg["model"]["topology_loss"]["prob_weighting"]) From 96fd4da451915b3f3cfb61f24e81baa4d477fc00 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 10:44:22 +0900 Subject: [PATCH 18/44] =?UTF-8?q?fix:=20=E6=9C=AA=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E3=81=AE=20MinB=20=E6=AD=A3=E5=89=87=E5=8C=96=E3=82=92?= =?UTF-8?q?=E5=85=AC=E9=96=8B=E6=AD=A3=E6=9C=AC=E3=81=8B=E3=82=89=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 読者を混乱させる廃止済み損失項のコード・文書言及を残さず、現行の損失定義だけを公開面に残す。 Co-authored-by: Cursor --- REPRODUCIBILITY.md | 4 +- ...elongate_n100_no_cls_full120_baseline.yaml | 3 +- ...n100_no_cls_full120_teacher_local_pca.yaml | 2 +- tda_ml/losses.py | 15 ------- tda_ml/trainer.py | 42 ++++++------------- tests/test_losses_topo_distance.py | 15 +------ 6 files changed, 17 insertions(+), 64 deletions(-) diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 6616a32..5cff6f8 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -11,7 +11,7 @@ **論文主表の提案:** W-Dist tune 重みの 30ep 5-seed(`run_teacher_local_pca_power_30ep_multiseed.sh wdist`)。 **主張:** Euclidean DBSCAN / ADBSCAN と **同程度の外れ値除去性能**(MCC / G-Mean)。主表に Topo W. 列は載せない。 -**正本 config:** `elongate_n100_no_cls_full120_teacher_local_pca`(`w_class=0`, `homology_dimensions=[1]`, `aniso_mode=elongate`, 軸投影・`min_b` 床なし)。 +**正本 config:** `elongate_n100_no_cls_full120_teacher_local_pca`(`w_class=0`, `homology_dimensions=[1]`, `aniso_mode=elongate`)。 出力先は `WDIST_OUT` / `MCC_OUT` / `LOG_ROOT`(既定: `outputs/supervised/pwr30_*`)で明示する(生成物は git に含めない)。 | 区分 | パス | @@ -181,7 +181,7 @@ M_i=\max(a_i,b_i),\; m_i=\min(a_i,b_i). 主表 power 30ep config(`elongate_n100_no_cls_full120_teacher_local_pca`)では `homology_dimensions: [1]`(H1-only Wasserstein)と `aniso_mode: elongate` を用いる。 -ellphi 退化(NaN 共分散・接線距離未定義など)は **軸投影や min_b 床で隠さず** +ellphi 退化(NaN 共分散・接線距離未定義など)は `run_status: failed` とする([Computational Reproducibility skill](https://github.com/t-uda/skills/blob/main/skills/computational-reproducibility/SKILL.md))。 別 ablation では `aniso_mode: linear` により diff --git a/configs/elongate_n100_no_cls_full120_baseline.yaml b/configs/elongate_n100_no_cls_full120_baseline.yaml index 0513361..f9b9737 100644 --- a/configs/elongate_n100_no_cls_full120_baseline.yaml +++ b/configs/elongate_n100_no_cls_full120_baseline.yaml @@ -1,5 +1,4 @@ -# Pure baseline: no fix1 (median), no fix2 (min_b), no fix3 (topo12). -# Full 120-point cloud for topo loss (default max_points unset). +# no_cls baseline contrast: full 120-point cloud for topo loss (default max_points unset). meta: config_id: "elongate_n100_no_cls_full120_baseline" diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml index 680ee8c..7c67702 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml @@ -1,5 +1,5 @@ # Paper no_cls production: local-PCA teacher + ellphi topo + H1-only Wasserstein. -# Degenerate ellphi geometry hard-fails (no axis projection / min_b floor). +# Degenerate ellphi geometry hard-fails. meta: config_id: "elongate_n100_no_cls_full120_teacher_local_pca" diff --git a/tda_ml/losses.py b/tda_ml/losses.py index b40977d..b523f2b 100644 --- a/tda_ml/losses.py +++ b/tda_ml/losses.py @@ -132,21 +132,6 @@ def forward(self, params): return self.weight * loss -class MinBRegularizationLoss(nn.Module): - """Penalize minor axis falling below ``target`` (legacy ``lambda_min_b`` / ``min_b_target``).""" - - def __init__(self, weight: float = 0.0, target: float = 0.2): - super().__init__() - self.weight = weight - self.target = target - - def forward(self, params: torch.Tensor) -> torch.Tensor: - if self.weight <= 0: - return torch.tensor(0.0, device=params.device) - axes = params[..., 0:2] - minor_axis = axes.min(dim=-1)[0] - return self.weight * F.relu(self.target - minor_axis).mean() - class TopologicalLoss(nn.Module): """ Topological loss: Wasserstein-2 squared between predicted and teacher PDs. diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 5aab399..6cb10d4 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -12,11 +12,10 @@ reproducibility_settings, ) from tda_ml.losses import ( - ClassificationLoss, - TopologicalLoss, - SizeRegularizationLoss, + ClassificationLoss, + TopologicalLoss, + SizeRegularizationLoss, AnisotropyPenaltyLoss, - MinBRegularizationLoss, ) from tda_ml.metrics import compute_recall_specificity_gmean_mcc from tda_ml.visualization import visualize @@ -98,15 +97,6 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: self.lambda_class = _loss_or_legacy("w_class", "lambda_class", 1.0) self.lambda_topo = _loss_or_legacy("w_topo", "lambda_topo", 0.1) self.lambda_aniso = _loss_or_legacy("w_aniso", "lambda_aniso", 0.01) - if "w_min_b" in loss_cfg: - self.lambda_min_b = float(loss_cfg["w_min_b"]) - elif allow_legacy and "lambda_min_b" in training_cfg: - self.lambda_min_b = float(training_cfg["lambda_min_b"]) - else: - self.lambda_min_b = 0.0 - self.min_b_target = float( - loss_cfg.get('min_b_target', training_cfg.get('min_b_target', 0.2)) - ) self.topo_loss_max_points = training_cfg.get( 'topo_loss_max_points', loss_cfg.get('topo_loss_max_points') ) @@ -256,16 +246,6 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: mode=self.aniso_mode, barrier_threshold=self.aniso_barrier_threshold, ) - self.min_b_loss_fn = MinBRegularizationLoss( - weight=self.lambda_min_b, - target=self.min_b_target, - ) - if self.lambda_min_b > 0: - logger.info( - "Min-B regularization: lambda=%s target=%s", - self.lambda_min_b, - self.min_b_target, - ) if self.topo_loss_max_points is not None: logger.info("Topological loss subsampling: max_points=%s", self.topo_loss_max_points) @@ -290,7 +270,6 @@ def train_epoch(self, data_loader, epoch): total_topo_loss = 0 total_aniso_loss = 0 total_size_loss = 0 - total_min_b_loss = 0 steps_completed = 0 all_train_preds = [] @@ -348,13 +327,11 @@ def train_epoch(self, data_loader, epoch): if epoch > self.warmup_epochs: size_loss = self.size_loss_fn(params) aniso_loss = self.aniso_loss_fn(params) - min_b_loss = self.min_b_loss_fn(params) else: size_loss = torch.tensor(0.0, device=self.device) aniso_loss = torch.tensor(0.0, device=self.device) - min_b_loss = torch.tensor(0.0, device=self.device) - loss = topo_loss + aniso_loss + size_loss + min_b_loss + loss = topo_loss + aniso_loss + size_loss if self.lambda_class > 0: loss = loss + self.lambda_class * class_loss @@ -393,14 +370,19 @@ def train_epoch(self, data_loader, epoch): total_topo_loss += topo_loss.item() total_aniso_loss += aniso_loss.item() total_size_loss += size_loss.item() - total_min_b_loss += min_b_loss.item() - + probs = torch.sigmoid(logits).squeeze(-1) preds = (probs > self.threshold).long() all_train_preds.extend(preds.cpu().numpy().flatten()) all_train_labels.extend(labels.cpu().numpy().flatten()) - pbar.set_postfix(loss=f"{loss.item():.4f}", cls=f"{class_loss.item():.4f}", topo=f"{topo_loss.item():.4f}", aniso=f"{aniso_loss.item():.4f}", size=f"{size_loss.item():.4f}", min_b=f"{min_b_loss.item():.4f}") + pbar.set_postfix( + loss=f"{loss.item():.4f}", + cls=f"{class_loss.item():.4f}", + topo=f"{topo_loss.item():.4f}", + aniso=f"{aniso_loss.item():.4f}", + size=f"{size_loss.item():.4f}", + ) if i % 10 == 0: logger.debug( "Step %s: loss=%.4f class=%.4f topo=%.4f aniso=%.4f size=%.4f", diff --git a/tests/test_losses_topo_distance.py b/tests/test_losses_topo_distance.py index 2c939dc..58acb7e 100644 --- a/tests/test_losses_topo_distance.py +++ b/tests/test_losses_topo_distance.py @@ -146,20 +146,7 @@ def test_mahalanobis_extreme_params_remain_finite(self): self.assertTrue(torch.isfinite(d).all()) -class TestMinBAndTopoSubsampling(unittest.TestCase): - def test_min_b_penalizes_small_minor_axis(self): - from tda_ml.losses import MinBRegularizationLoss - loss_fn = MinBRegularizationLoss(weight=1.0, target=0.5) - params = torch.tensor([[[0.8, 0.1, 0.0], [0.6, 0.4, 0.0]]]) - loss = loss_fn(params) - self.assertGreater(loss.item(), 0.0) - - def test_min_b_zero_weight_is_noop(self): - from tda_ml.losses import MinBRegularizationLoss - loss_fn = MinBRegularizationLoss(weight=0.0, target=0.5) - params = torch.tensor([[[0.8, 0.1, 0.0]]]) - self.assertEqual(loss_fn(params).item(), 0.0) - +class TestTopoSubsampling(unittest.TestCase): def test_topo_max_points_subsamples(self): from tda_ml.losses import TopologicalLoss torch.manual_seed(0) From 96a9cdda215386e0b859def97b505ca23ae6e5f7 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 11:17:36 +0900 Subject: [PATCH 19/44] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=E8=AB=96=E6=96=87=E5=A5=91?= =?UTF-8?q?=E7=B4=84=E3=83=BBMCC=20preflight=E3=83=BBellphi=20=E9=BB=99?= =?UTF-8?q?=E6=AE=BA=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本番経路を壊す objective whitelist 欠落と、H1-only 契約の黙認デフォルトを直し、旧 tune JSON を拒否して再チューニング必須を明示する。 Co-authored-by: Cursor --- README.md | 4 +- REPRODUCIBILITY.md | 25 +---- configs/README.md | 2 +- ...elongate_n100_no_cls_full120_baseline.yaml | 2 + configs/issue59_verify_mahalanobis.yaml | 52 --------- experiments/issue59_verify_mahalanobis.py | 101 ------------------ experiments/run_backend_multiseed.py | 3 + .../run_teacher_local_pca_power_30ep.py | 36 +++++-- .../run_tune_local_pca_power_mcc_parallel.sh | 11 +- experiments/tune_elongate_mcc.py | 52 ++++++++- experiments/tune_elongate_wdist.py | 3 +- tda_ml/dbscan.py | 8 +- tda_ml/distance_backend.py | 19 ++-- tda_ml/preflight.py | 90 +++++++++++++++- tests/test_losses_topo_distance.py | 27 +++++ tests/test_reproducibility_strict.py | 97 +++++++++++++++++ tests/test_tune_config.py | 8 ++ 17 files changed, 333 insertions(+), 207 deletions(-) delete mode 100644 configs/issue59_verify_mahalanobis.yaml delete mode 100644 experiments/issue59_verify_mahalanobis.py diff --git a/README.md b/README.md index ed080bc..8c8e1f3 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,12 @@ The full official command (50 epochs × five seeds × two backends) is **not** e - External implementations are reference-only and are not redistributed in this repository. - Scripts under `tda_ml/experiments/` are **non-official** and require your own checkpoints (see `configs/README.md`). - `run_backend_multiseed.py` multiseed backend comparison is **not** a pure distance-backend-only ablation; it compares full training pipelines, not an isolated backend switch. -- For topological loss, **`mahalanobis`** can incorporate predicted **outlier-probability weighting** in the distance matrix; **`ellphi`** does not (geometry-only distances; `probs` ignored). +- For topological loss, **`mahalanobis`** can incorporate predicted **outlier-probability weighting** in the distance matrix; **`ellphi`** is geometry-only and requires `prob_weighting=false`. - Read outcomes as **two full pipelines** under the same schedule and config surface, not as isolating distance-backend effects alone. ### Backend comparison: outlier-probability weighting -For **topological loss**, the batched distance matrix is built per `model.topology_loss.distance_backend`. With **`mahalanobis`**, the implementation can incorporate **predicted outlier probabilities** when forming pairwise distances (see `tda_ml.topology.compute_anisotropic_distance_matrix`). With **`ellphi`**, tangency distances are computed from ellipse geometry only; **probability-based weighting is not implemented** and `probs` are ignored (a warning is emitted once per process; see `tda_ml.distance_backend.compute_distance_matrix_batch`). Hyperparameters in `configs/reproduce.yaml` are shared across backends, but **the induced metric for topological loss is not identical across backends** by design: the intended read is a reproducible pipeline comparison (same data, schedule, and config surface), not a claim that both backends optimize the exact same weighted distance objective. Elliptic contact distances are left as defined by `ellphi`; no synthetic “prob-equivalent” weighting is applied on the ellphi path. +For **topological loss**, the batched distance matrix is built per `model.topology_loss.distance_backend`. With **`mahalanobis`**, the implementation can incorporate **predicted outlier probabilities** when forming pairwise distances (see `tda_ml.topology.compute_anisotropic_distance_matrix`). With **`ellphi`**, tangency distances are computed from ellipse geometry only; **probability-based weighting is not implemented**, so the backend comparison driver explicitly sets `prob_weighting=false`. Requesting it now hard-fails rather than silently ignoring `probs` (see `tda_ml.distance_backend.compute_distance_matrix_batch`). Hyperparameters in `configs/reproduce.yaml` are shared across backends, but **the induced metric for topological loss is not identical across backends** by design: the intended read is a reproducible pipeline comparison (same data, schedule, and config surface), not a claim that both backends optimize the exact same weighted distance objective. Elliptic contact distances are left as defined by `ellphi`; no synthetic “prob-equivalent” weighting is applied on the ellphi path. ## License / Attribution diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 5cff6f8..b7ac0e1 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -31,7 +31,7 @@ | Gudhi `persistence.compute_w_distance` | H1-only | Euclidean Alpha / 点座標 | legacy baseline 用。主表の ellipse W-Dist とは別物 | | `metrics` の W-Dist | 上記どちらかを明示引数で選択 | 引数不足は **hard-fail**(黙って 0 にしない) | | -Trainer と `TopoWdistOptions` の欠落時既定はどちらも `teacher_mode=euclidean` / `prob_weighting=true`。主表 YAML が `local_pca` と `prob_weighting=false` を明示する。 +主表・チューニングの preflight は `homology_dimensions=[1]`、`teacher_mode=local_pca`、`prob_weighting=false`、`aniso_mode=elongate` の明示を要求する。欠落や不一致は実行前に hard-fail する。 ## 環境 @@ -135,10 +135,10 @@ uv run python experiments/run_backend_multiseed.py \ ### バックエンド比較と outlier 確率の重み(非対称) - `run_backend_multiseed.py` のマルチシード・バックエンド比較は、**距離バックエンドだけを切り替えた純粋な ablation ではありません**(学習パイプライン全体の比較です)。 -- 位相損失では **`mahalanobis`** が outlier **確率による重み付け**を距離行列に織り込める一方、**`ellphi`** では **未実装**で `probs` は使われません。 +- 位相損失では **`mahalanobis`** が outlier **確率による重み付け**を距離行列に織り込める一方、**`ellphi`** では未実装のため `prob_weighting=false` を明示する。`true` は黙って無視せず hard-fail する。 - 結果は **同一スケジュール・同一設定表面**(典型: `configs/reproduce.yaml`)上の **2 本のフルパイプライン**として読み、距離実装だけの効果に還元しないでください。 -位相損失用の距離行列は `model.topology_loss.distance_backend` ごとに別定義です。**`mahalanobis`** では、学習で予測した **outlier 確率 `probs`** を距離の重み付けに織り込めます(`tda_ml.topology.compute_anisotropic_distance_matrix`)。**`ellphi`** では楕円の接触距離のみを用い、**確率に基づく重み付けは未実装のため `probs` は使われません**(初回のみ `UserWarning` が出ます。実装は `tda_ml.distance_backend.compute_distance_matrix_batch`)。 +位相損失用の距離行列は `model.topology_loss.distance_backend` ごとに別定義です。**`mahalanobis`** では、学習で予測した **outlier 確率 `probs`** を距離の重み付けに織り込めます(`tda_ml.topology.compute_anisotropic_distance_matrix`)。**`ellphi`** では楕円の接触距離のみを用い、`run_backend_multiseed.py` が `prob_weighting=false` を明示します。未実装の確率重みを要求すると `tda_ml.distance_backend.compute_distance_matrix_batch` が hard-fail します。 したがって、`run_backend_multiseed.py` で同じ YAML を回しても、**位相損失が見ている距離空間はバックエンド間で同一ではありません**。ここでは「同一のデータ・スケジュール・設定表面での再現パイプライン比較」を意図しており、**両バックエンドが数学的に完全に同型の重み付き距離目的関数を共有する**という読み方はしません。`ellphi` 側に Mahalanobis の確率重みに相当する項を無理に足す予定はなく、比較の解釈は本節および `README.md` の英語節(*Backend comparison: outlier-probability weighting*)に従ってください。 @@ -150,6 +150,8 @@ no_cls・local_pca 教師・`size_mode=power` スタックでは、`run_tune_loc **重み固定プロトコル(重要):** ハイパーパラメータ探索(Optuna)は **seed 42 の 20ep proxy で 1 回だけ**行い、得られた best 重み(`w_topo` / `w_aniso` / `w_size` / `lr`)を **5 つのデータ seed(42/123/456/789/1024)すべての 30ep 本番に固定**して適用します。**データ seed ごとの再チューニングは行いません。** 論文の mean ± std はこの固定重みの下でのデータ seed 間ばらつきです。 +**H1-only 移行:** `homology_dimensions=[1]` 導入前に生成した tune JSON(旧 `0709_*` など)は目的関数が異なるため再利用しません。新しい best JSON は H1-only 契約(homology、teacher、probability weighting、anisotropy mode)を記録し、本番 preflight は契約キーの欠落・不一致を hard-fail します。H1-only スタックで二目的を再チューニングした後、その重みで 30ep 本番を再学習してください。 + ## 教師あり学習の目的関数(論文 Methods 用) 本線 `tda_ml/` の学習は **点ラベル BCE** と **clean 点群の $H_1$ 持久図との Wasserstein 教師** を併用します(`configs/reproduce.yaml` 系)。 @@ -195,23 +197,6 @@ $1/\bigl((1-p_i)(1-p_j)\bigr)$ で重み付け(`INLIER_PROB_MIN` で下限ク **数値安定化のみの定数**(モデリング床ではない)は `tda_ml/numerical_eps.py` に集約し、付録で列挙します。encoder の `clamp(0.2)` や legacy の `+10^{-4}` といった**論文に無い床は削除済み**(issue #59)。中心一致を含む ellphi 退化は補正せず hard-fail します。 -### issue #59 検証(早期打ち切り + 診断) - -床削除後の教師あり本線を確認するには: - -```bash -uv run python experiments/issue59_verify_mahalanobis.py -``` - -- 設定: `configs/issue59_verify_mahalanobis.yaml`(`reproduce_1week_tuned` 相当・**mahalanobis**) -- **`probe_epochs` 後**(`configs/issue59_verify_mahalanobis.yaml` では既定 8)も `val_mcc` / `train_mcc` が閾値(0.05 / 0.02)を超えない、または全 outlier 予測(`val_recall≈1`)なら **学習を打ち切り** -- 打ち切り時の成果物(`/logs/`): - - `issue59_abort_report.json` / `.md` — 楕円軸・encoder PCA・距離行列・分類の統計と**原因仮説リスト** - - `abort_checkpoint.pth` — 打ち切り時点の重み - - `run_manifest.json` — コミット SHA・early_abort 設定 - -CLI: `--epochs 12 --seed 42 --out-base outputs/issue59_verify` - ## 図・定性出力 `docs/paper/` 以下の LaTeX の **すべての図を一括で出す単一スクリプトはありません**。原稿専用のアセットもあります。下の表は **コードに近い** 図の流れを示すものです。論文の図を増やしたら表も追記してください。 diff --git a/configs/README.md b/configs/README.md index 393b6cb..a530e78 100644 --- a/configs/README.md +++ b/configs/README.md @@ -11,7 +11,7 @@ Canonical YAML files live **in this directory** (deep-merged with `base.yaml` by | `test_fast.yaml` | Small settings for quick checks and CI smoke. | | `elongate_n100_no_cls_full120_teacher_local_pca.yaml` | Paper no_cls production (H1-only, local_pca teacher). | | `elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml` | Optuna tune base for power + ellphi (W-Dist / MCC studies). | -| `elongate_n100_no_cls_full120_baseline.yaml` | no_cls baseline contrast (optional table column). | +| `elongate_n100_no_cls_full120_baseline.yaml` | H1-only no_cls contrast with Euclidean teacher (optional table column). | **Probe / ablation / dated experiment YAML** (scaleinv, topo12, raw_axes, H1 launch variants, etc.) belongs under local `configs/archive/` and is **not** part of the publishable surface. Load with `load_config("archive/")` when present. diff --git a/configs/elongate_n100_no_cls_full120_baseline.yaml b/configs/elongate_n100_no_cls_full120_baseline.yaml index f9b9737..5750b7d 100644 --- a/configs/elongate_n100_no_cls_full120_baseline.yaml +++ b/configs/elongate_n100_no_cls_full120_baseline.yaml @@ -13,6 +13,7 @@ model: distance_backend: "mahalanobis" ellphi_differentiable: true prob_weighting: false + homology_dimensions: [1] loss: w_class: 0.0 @@ -21,6 +22,7 @@ loss: w_size: 0.488 pos_weight: 1.0 aniso_mode: "elongate" + teacher_mode: euclidean training: lr: 0.000158 diff --git a/configs/issue59_verify_mahalanobis.yaml b/configs/issue59_verify_mahalanobis.yaml deleted file mode 100644 index a430163..0000000 --- a/configs/issue59_verify_mahalanobis.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# Issue #59 verification: reproduce_1week_tuned + mahalanobis + early MCC abort + diagnostics. -# Mahalanobis only; aborts if MCC stays ~0 after probe_epochs (default 8 — size reg needs epochs). - -meta: - config_id: "issue59_verify_mahalanobis" - -model: - point_dim: 2 - feature_dim: 128 - threshold: 0.5 - topology_loss: - distance_backend: "mahalanobis" - ellphi_differentiable: true - -loss: - w_class: 1.0 - w_topo: 0.2 - w_aniso: 0.01099204345474479 - w_size: 0.0005578582308620256 - pos_weight: 1.0 - aniso_mode: "linear" - -training: - lr: 0.0004897466143769238 - epochs: 12 - grad_clip_value: 1.0 - visualize_every: 1 - warmup_epochs: 0 - use_amp: true - early_abort: - enabled: true - probe_epochs: 8 - min_val_mcc: 0.05 - min_train_mcc: 0.02 - max_val_recall_for_abort: 0.99 - -data: - max_points: 100 - num_outliers: 20 - seed: 42 - test_size: 1000 - num_workers: 4 - batch_size: 16 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" diff --git a/experiments/issue59_verify_mahalanobis.py b/experiments/issue59_verify_mahalanobis.py deleted file mode 100644 index 9f98386..0000000 --- a/experiments/issue59_verify_mahalanobis.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -""" -Issue #59 supervised verification (mahalanobis, reproduce_1week_tuned hyperparameters). - -Runs training with early abort when val/train MCC stay near zero after probe_epochs. -On abort, writes ``logs/issue59_abort_report.{json,md}`` with ellipse / encoder / -distance / classification diagnostics and ranked failure hypotheses. - -Usage:: - - uv run python experiments/issue59_verify_mahalanobis.py - uv run python experiments/issue59_verify_mahalanobis.py --epochs 12 --seed 42 -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -from tda_ml.config import deep_update, load_config -from tda_ml.main import main as train_main -from tda_ml.supervised_diagnostics import git_revision - -REPO = Path(__file__).resolve().parents[1] - - -def preflight(config_name: str) -> dict: - cfg = load_config(config_name, project_root=REPO) - data = cfg.get("data", {}) - training = cfg.get("training", {}) - early = training.get("early_abort", {}) - if not early.get("enabled", False): - raise ValueError( - f"{config_name}: training.early_abort.enabled must be true for verification" - ) - backend = ( - cfg.get("model", {}).get("topology_loss", {}).get("distance_backend", "") - ) - if backend != "mahalanobis": - raise ValueError( - f"issue59 verification expects mahalanobis backend; got {backend!r}" - ) - data_root = REPO / "data" - if not data_root.exists(): - raise FileNotFoundError( - f"MNIST data root missing: {data_root}. Run training once to download." - ) - return { - "config_id": cfg["meta"].get("config_id"), - "source_revision": git_revision(REPO), - "epochs": training.get("epochs"), - "seed": data.get("seed"), - "early_abort": early, - "distance_backend": backend, - } - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--config", - default="issue59_verify_mahalanobis", - help="Config name under configs/ (default: issue59_verify_mahalanobis)", - ) - parser.add_argument("--epochs", type=int, default=None) - parser.add_argument("--seed", type=int, default=None) - parser.add_argument( - "--out-base", - type=Path, - default=REPO / "outputs" / "issue59_verify", - ) - args = parser.parse_args() - - manifest_preview = preflight(args.config) - print("=== Preflight OK ===") - print(json.dumps(manifest_preview, indent=2, ensure_ascii=True)) - - cfg = load_config(args.config, project_root=REPO) - overrides: dict = {"outputs": {"base_dir": str(args.out_base)}} - if args.epochs is not None: - overrides.setdefault("training", {})["epochs"] = args.epochs - if args.seed is not None: - overrides.setdefault("data", {})["seed"] = args.seed - cfg = deep_update(cfg, overrides) - - result = train_main(config=cfg) - status = result.get("status", "completed") - print("\n=== Verification result ===") - print(f"status: {status}") - print(f"run_dir: {result.get('run_dir')}") - print(f"best_val_mcc: {result.get('best_val_mcc', result.get('val_mcc')):.4f}") - if result.get("abort_report"): - print(f"abort_report: {result['abort_report']}") - print("\nReview hypotheses in issue59_abort_report.md before long runs.") - return 2 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/experiments/run_backend_multiseed.py b/experiments/run_backend_multiseed.py index 8c455f6..5a89bac 100644 --- a/experiments/run_backend_multiseed.py +++ b/experiments/run_backend_multiseed.py @@ -267,6 +267,9 @@ def run_one( "topology_loss": { "distance_backend": backend, "ellphi_differentiable": ellphi_diff, + # ellphi is geometry-only and rejects unsupported probability + # weighting instead of silently ignoring it. + "prob_weighting": backend != "ellphi", } }, "outputs": {"base_dir": out_base}, diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_teacher_local_pca_power_30ep.py index be72348..61e1156 100644 --- a/experiments/run_teacher_local_pca_power_30ep.py +++ b/experiments/run_teacher_local_pca_power_30ep.py @@ -15,6 +15,7 @@ from tda_ml.config import deep_update, load_config # noqa: E402 from tda_ml.main import main as train_main # noqa: E402 from tda_ml.preflight import ( # noqa: E402 + assert_paper_no_cls_contract, classify_tune_objective, preflight_tune_production_run, ) @@ -86,15 +87,18 @@ def infer_tag(tune_json: Path | None, explicit: str | None) -> str: def main() -> int: args = parse_args() + cfg = load_config(args.base_config, project_root=REPO_ROOT) + paper_contract = assert_paper_no_cls_contract(cfg) tune_json = args.tune_json tag = infer_tag(tune_json, args.tag) out_base = args.out_base if out_base is None: - out_base = ( - REPO_ROOT / "outputs/supervised/pwr30_mcc" - if tune_json is not None - else DEFAULT_OUT - ) + if tune_json is None: + out_base = DEFAULT_OUT + elif tag == TAG_WDIST: + out_base = REPO_ROOT / "outputs/supervised/pwr30_wdist" + else: + out_base = REPO_ROOT / "outputs/supervised/pwr30_mcc" out_base.mkdir(parents=True, exist_ok=True) if tune_json is not None: @@ -103,9 +107,24 @@ def main() -> int: tune_json=tune_json, project_root=REPO_ROOT, out_base=out_base, + config_overrides={ + "loss": { + "size_mode": "power", + "size_ref": args.size_ref, + "size_power": args.size_power, + }, + "training": {"epochs": args.epochs}, + "data": {"seed": args.seed}, + "model": { + "topology_loss": { + "distance_backend": "ellphi", + "prob_weighting": False, + } + }, + "outputs": {"base_dir": str(out_base)}, + }, ) - cfg = load_config(args.base_config, project_root=REPO_ROOT) loss_overrides: dict = { "size_mode": "power", "size_ref": args.size_ref, @@ -160,9 +179,8 @@ def main() -> int: "tune_json": tune_source, "dbscan_backend": args.dbscan_backend, "aniso_mode": cfg["loss"].get("aniso_mode"), - "homology_dimensions": cfg["model"]["topology_loss"].get( - "homology_dimensions", [0, 1] - ), + "homology_dimensions": cfg["model"]["topology_loss"]["homology_dimensions"], + "paper_no_cls_contract": paper_contract, "protocol_note": ( "power 30ep H1-only: raw ellipse params to ellphi; degenerate geometry " "hard-fails; tune weights fixed from seed-42 Optuna" diff --git a/experiments/run_tune_local_pca_power_mcc_parallel.sh b/experiments/run_tune_local_pca_power_mcc_parallel.sh index eb0cd5f..8025e6b 100755 --- a/experiments/run_tune_local_pca_power_mcc_parallel.sh +++ b/experiments/run_tune_local_pca_power_mcc_parallel.sh @@ -25,11 +25,12 @@ N_TRIALS="${2:-24}" TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" DBSCAN_BACKEND="${5:-mahalanobis}" -BASE_CONFIG="elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc" +BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc}" OUT_BASE="${OUT_BASE:-outputs/tune/pwr_mcc_dbscan_${DBSCAN_BACKEND}}" -STUDY_NAME="elongate_local_pca_power_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}" +STUDY_NAME="${STUDY_NAME:-elongate_local_pca_power_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}}" STORAGE="sqlite:///${OUT_BASE}/study.db" -THREADS_PER_WORKER=12 +THREADS_PER_WORKER="${THREADS_PER_WORKER:-12}" +TRIALS_PER_WORKER="${TRIALS_PER_WORKER:-${N_TRIALS}}" mkdir -p "${OUT_BASE}" cat > "${OUT_BASE}/PURPOSE.md" < argparse.Namespace: default="elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", ) p.add_argument("--n-trials", type=int, default=24) + p.add_argument( + "--max-complete-trials", + type=int, + default=None, + help="Shared-study completion cap; defaults to --n-trials.", + ) p.add_argument("--n-startup-trials", type=int, default=8) p.add_argument("--tune-epochs", type=int, default=20) p.add_argument( @@ -238,10 +245,44 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() Path(args.out_base).mkdir(parents=True, exist_ok=True) - preflight_mcc_tune_study( + preflight = preflight_mcc_tune_study( base_config=args.base_config, project_root=REPO_ROOT, out_base=args.out_base, + config_overrides={ + "model": { + "topology_loss": { + "distance_backend": args.backend, + "prob_weighting": False, + } + }, + "loss": { + "aniso_mode": "elongate", + "size_mode": args.size_mode, + "size_ref": args.size_ref, + "size_power": args.size_power, + }, + "training": {"epochs": args.tune_epochs}, + "outputs": {"base_dir": args.out_base}, + }, + ) + preflight.update( + { + "run_status": "pending", + "command_entry": "experiments/tune_elongate_mcc.py", + "source_revision": git_revision(REPO_ROOT), + "study_name": args.study_name, + "storage": args.storage, + "n_trials": args.n_trials, + "max_complete_trials": args.max_complete_trials or args.n_trials, + "n_startup_trials": args.n_startup_trials, + "sampler_seed": args.seed, + "fallbacks": [], + } + ) + write_json( + Path(args.out_base) / f"WORKER_PREFLIGHT_seed{args.seed}.json", + preflight, ) study = optuna.create_study( @@ -255,7 +296,10 @@ def main() -> int: if not args.write_best: callbacks = [] if args.storage: - callbacks.append(MaxTrialsCallback(args.n_trials, states=(TrialState.COMPLETE,))) + max_complete = args.max_complete_trials or args.n_trials + callbacks.append( + MaxTrialsCallback(max_complete, states=(TrialState.COMPLETE,)) + ) study.optimize( make_objective(args), n_trials=args.n_trials, @@ -268,19 +312,21 @@ def main() -> int: best = study.best_trial payload = { "objective": "val_dbscan_mcc_max_at_val_topo_best_ckpt", + "objective_kind": "mcc", "checkpoint_policy": CHECKPOINT_POLICY, "checkpoint_selection": "val_topo", "save_every": SAVE_EVERY, "base_config": args.base_config, "backend": args.backend, "dbscan_backend": args.dbscan_backend, - "teacher_mode": "local_pca", "size_mode": args.size_mode, "size_ref_default": args.size_ref, "size_power_default": args.size_power, "tune_size_hyperparams": bool(args.tune_size_hyperparams), "tune_epochs": args.tune_epochs, "n_trials": args.n_trials, + "max_complete_trials": args.max_complete_trials or args.n_trials, + **preflight["paper_no_cls_contract"], "search_space": { "w_aniso": list(W_ANISO_RANGE), "w_size": list(W_SIZE_RANGE), diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py index c1855aa..289cbba 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_elongate_wdist.py @@ -317,6 +317,7 @@ def main() -> int: ) payload = { "objective": "val_topo_wdist_min_at_val_topo_best_ckpt", + "objective_kind": "wdist", "checkpoint_policy": CHECKPOINT_POLICY, "save_every": SAVE_EVERY, "base_config": args.base_config, @@ -325,11 +326,11 @@ def main() -> int: "size_ref_default": args.size_ref, "size_power_default": args.size_power, "narrow_search": bool(args.narrow_search), - "teacher_mode": "local_pca", "tune_epochs": args.tune_epochs, "n_trials": args.n_trials, "max_complete_trials": args.max_complete_trials or args.n_trials, "n_startup_trials": args.n_startup_trials, + **preflight["paper_no_cls_contract"], "search_space": { "w_aniso": list(w_aniso_range), "w_size": list(w_size_range), diff --git a/tda_ml/dbscan.py b/tda_ml/dbscan.py index f38e3dd..eb707b6 100644 --- a/tda_ml/dbscan.py +++ b/tda_ml/dbscan.py @@ -21,7 +21,8 @@ def compute_anisotropic_distance_matrix_np( With inlier probability ``p_in,i = clamp(1 - prob_i, min=1e-4)``, squared distances are divided by ``p_in,i * p_in,j`` before symmetrization and square root, i.e. distances scale like ``1 / sqrt(p_in,i * p_in,j)``. - backend (str): ``mahalanobis`` or ``ellphi`` (probs are ignored for ellphi) + backend (str): ``mahalanobis`` or ``ellphi``. Supplying ``probs`` with + ``ellphi`` hard-fails because probability weighting is not implemented. Returns: np.ndarray: (N, N) distance matrix. @@ -32,6 +33,11 @@ def compute_anisotropic_distance_matrix_np( if b == "ellphi" and points_t.ndim != 2: raise ValueError("backend='ellphi' requires points with shape (N, 2).") + if b == "ellphi" and probs is not None: + raise RuntimeError( + "backend='ellphi' does not implement probability weighting; " + "pass probs=None or use mahalanobis." + ) if points_t.ndim == 2: pts_np = points_t.numpy() diff --git a/tda_ml/distance_backend.py b/tda_ml/distance_backend.py index 84a8d13..1c5a04a 100644 --- a/tda_ml/distance_backend.py +++ b/tda_ml/distance_backend.py @@ -8,8 +8,6 @@ from __future__ import annotations -import warnings - import numpy as np import torch @@ -32,8 +30,6 @@ except ImportError: squareform = None # type: ignore[misc, assignment] -_ELLPHI_PROB_WARNED = False - DISTANCE_MODE_MAHALANOBIS = "mahalanobis" DISTANCE_MODE_ELLPHI = "ellphi" @@ -131,7 +127,8 @@ def compute_distance_matrix_batch( ``ellphi.grad`` is available, gradients flow to centers/covariances. Missing grad API is a hard-fail (no NumPy fallback). - For ``ellphi``, ``probs``-based weighting is currently unsupported and ignored. + For ``ellphi``, requesting ``probs``-based weighting hard-fails because that + method is not implemented. """ b = backend.lower().strip() if b not in ("mahalanobis", "ellphi"): @@ -142,14 +139,12 @@ def compute_distance_matrix_batch( points, params, probs=probs, symmetrize=symmetrize ) - global _ELLPHI_PROB_WARNED - if probs is not None and not _ELLPHI_PROB_WARNED: - warnings.warn( - "distance_backend='ellphi' ignores outlier-probability weighting because it is not implemented yet.", - UserWarning, - stacklevel=2, + if probs is not None: + raise RuntimeError( + "distance_backend='ellphi' does not implement probability weighting; " + "set model.topology_loss.prob_weighting=false or use mahalanobis. " + "Refusing to ignore the requested method." ) - _ELLPHI_PROB_WARNED = True use_torch = ellphi_differentiable and _has_ellphi_grad_api() if ellphi_differentiable and not use_torch: diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py index d88db6e..57b78d4 100644 --- a/tda_ml/preflight.py +++ b/tda_ml/preflight.py @@ -25,9 +25,57 @@ "val_topo_wdist_min": "wdist", "val_topo_wdist_min_at_val_topo_best_ckpt": "wdist", "val_dbscan_mcc_max": "mcc", + "val_dbscan_mcc_max_at_val_topo_best_ckpt": "mcc", "val_dbscan_mcc": "mcc", } +PAPER_NO_CLS_CONTRACT: dict[str, Any] = { + "homology_dimensions": [1], + "teacher_mode": "local_pca", + "prob_weighting": False, + "aniso_mode": "elongate", +} + + +def assert_paper_no_cls_contract(config: dict[str, Any]) -> dict[str, Any]: + """Require the declared H1-only paper method; never infer missing fields.""" + topo = (config.get("model") or {}).get("topology_loss") or {} + loss = config.get("loss") or {} + actual: dict[str, Any] = {} + + if "homology_dimensions" not in topo: + raise ValueError( + "Paper no_cls config must explicitly define " + "model.topology_loss.homology_dimensions=[1]" + ) + actual["homology_dimensions"] = list(topo["homology_dimensions"]) + + if "teacher_mode" not in loss: + raise ValueError( + "Paper no_cls config must explicitly define loss.teacher_mode='local_pca'" + ) + actual["teacher_mode"] = str(loss["teacher_mode"]).strip().lower() + + if "prob_weighting" not in topo: + raise ValueError( + "Paper no_cls config must explicitly define " + "model.topology_loss.prob_weighting=false" + ) + actual["prob_weighting"] = bool(topo["prob_weighting"]) + + if "aniso_mode" not in loss: + raise ValueError( + "Paper no_cls config must explicitly define loss.aniso_mode='elongate'" + ) + actual["aniso_mode"] = str(loss["aniso_mode"]).strip().lower() + + if actual != PAPER_NO_CLS_CONTRACT: + raise ValueError( + "Paper no_cls contract mismatch: " + f"expected={PAPER_NO_CLS_CONTRACT}, actual={actual}" + ) + return actual + def _require_import(name: str, import_fn) -> None: try: @@ -122,6 +170,7 @@ def preflight_tune_json( tune_json: Path, *, expected: TuneObjectiveKind | None = None, + expected_contract: dict[str, Any] | None = None, ) -> dict[str, Any]: if not tune_json.is_file(): raise FileNotFoundError(f"Tune JSON not found: {tune_json}") @@ -142,6 +191,19 @@ def preflight_tune_json( f"Tune JSON objective {payload['objective']!r} is {kind!r}, expected {expected!r}: " f"{tune_json}" ) + if expected_contract is not None: + missing = [key for key in expected_contract if key not in payload] + if missing: + raise ValueError( + f"Tune JSON predates the declared paper contract; missing {missing}: " + f"{tune_json}. Re-tune with the H1-only stack." + ) + actual_contract = {key: payload[key] for key in expected_contract} + if actual_contract != expected_contract: + raise ValueError( + "Tune JSON paper contract does not match the production config: " + f"expected={expected_contract}, actual={actual_contract}: {tune_json}" + ) payload["_objective_kind"] = kind return payload @@ -158,6 +220,7 @@ def preflight_tune_study( cfg = load_config(base_config, project_root=root) if config_overrides: cfg = deep_update(cfg, config_overrides) + contract = assert_paper_no_cls_contract(cfg) dbscan_grid_from_config(cfg) preview = preflight_training_config(cfg, project_root=root) out = Path(out_base) @@ -167,6 +230,7 @@ def preflight_tune_study( preview["out_base"] = str(out) preview["base_config"] = base_config preview["study_objective"] = study_objective + preview["paper_no_cls_contract"] = contract preview["config_overrides"] = config_overrides or {} write_json(out / "STUDY_PREFLIGHT.json", preview) return preview @@ -177,12 +241,14 @@ def preflight_mcc_tune_study( base_config: str, project_root: Path | str, out_base: Path | str, + config_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: return preflight_tune_study( base_config=base_config, project_root=project_root, out_base=out_base, study_objective="mcc", + config_overrides=config_overrides, ) @@ -208,14 +274,36 @@ def preflight_tune_production_run( tune_json: Path, project_root: Path | str, out_base: Path | str, + config_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: root = Path(project_root) - tune_payload = preflight_tune_json(tune_json) cfg = load_config(base_config, project_root=root) + if config_overrides: + cfg = deep_update(cfg, config_overrides) + contract = assert_paper_no_cls_contract(cfg) + tune_payload = preflight_tune_json( + tune_json, + expected_contract=contract, + ) + tune_params = tune_payload["best_params"] + cfg = deep_update( + cfg, + { + "loss": { + "w_topo": float(tune_params["w_topo"]), + "w_aniso": float(tune_params["w_aniso"]), + "w_size": float(tune_params["w_size"]), + }, + "training": {"lr": float(tune_params["lr"])}, + }, + ) preview = preflight_training_config(cfg, project_root=root) preview["tune_json"] = str(tune_json.resolve()) preview["tune_objective"] = tune_payload.get("objective") preview["tune_objective_kind"] = tune_payload["_objective_kind"] + preview["tune_best_params"] = tune_params + preview["paper_no_cls_contract"] = contract + preview["config_overrides"] = config_overrides or {} preview["checkpoint_selection"] = ( (cfg.get("training") or {}).get("selection") or {} ).get("metric", "val_topo") diff --git a/tests/test_losses_topo_distance.py b/tests/test_losses_topo_distance.py index 58acb7e..5334393 100644 --- a/tests/test_losses_topo_distance.py +++ b/tests/test_losses_topo_distance.py @@ -3,9 +3,11 @@ import unittest import torch +from tda_ml.dbscan import compute_anisotropic_distance_matrix_np from tda_ml.distance_backend import ( DISTANCE_MODE_ELLPHI, DISTANCE_MODE_MAHALANOBIS, + compute_distance_matrix_batch, compute_topo_distance_matrix, mahalanobis_distance_matrix_batched, normalize_topo_distance_mode, @@ -17,6 +19,31 @@ def test_normalize_aliases(self): self.assertEqual(normalize_topo_distance_mode("Mahalanobis"), DISTANCE_MODE_MAHALANOBIS) self.assertEqual(normalize_topo_distance_mode("ellphi"), DISTANCE_MODE_ELLPHI) + def test_ellphi_probability_weighting_hard_fails(self): + points = torch.zeros(1, 3, 2) + params = torch.ones(1, 3, 3) + probs = torch.full((1, 3), 0.5) + with self.assertRaisesRegex(RuntimeError, "does not implement probability weighting"): + compute_distance_matrix_batch( + points, + params, + probs=probs, + symmetrize="max", + backend="ellphi", + ) + + def test_ellphi_dbscan_probability_weighting_hard_fails(self): + points = torch.zeros(3, 2) + params = torch.ones(3, 3) + probs = torch.full((3,), 0.5) + with self.assertRaisesRegex(RuntimeError, "does not implement probability weighting"): + compute_anisotropic_distance_matrix_np( + points, + params, + probs=probs, + backend="ellphi", + ) + def test_mahalanobis_shape(self): torch.manual_seed(0) b, n = 2, 9 diff --git a/tests/test_reproducibility_strict.py b/tests/test_reproducibility_strict.py index 26f2967..a51b891 100644 --- a/tests/test_reproducibility_strict.py +++ b/tests/test_reproducibility_strict.py @@ -68,6 +68,12 @@ def test_classify_tune_objective(self): "wdist", ) self.assertEqual(classify_tune_objective("val_dbscan_mcc_max"), "mcc") + self.assertEqual( + classify_tune_objective( + "val_dbscan_mcc_max_at_val_topo_best_ckpt" + ), + "mcc", + ) self.assertEqual( classify_tune_objective("legacy_name", objective_kind="mcc"), "mcc", @@ -100,6 +106,53 @@ def test_wdist_json_passes_without_mcc_expectation(self): payload = preflight_tune_json(path) self.assertEqual(payload["_objective_kind"], "wdist") + def test_legacy_tune_json_without_paper_contract_raises(self): + from tda_ml.preflight import ( + PAPER_NO_CLS_CONTRACT, + preflight_tune_json, + ) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "best.json" + path.write_text( + '{"objective":"val_topo_wdist_min","objective_kind":"wdist",' + '"best_params":{"w_topo":0.1,"w_aniso":0.1,"w_size":0.1,"lr":1e-4}}\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "Re-tune with the H1-only stack"): + preflight_tune_json( + path, + expected_contract=PAPER_NO_CLS_CONTRACT, + ) + + def test_tune_json_paper_contract_must_match(self): + import json + + from tda_ml.preflight import ( + PAPER_NO_CLS_CONTRACT, + preflight_tune_json, + ) + + payload = { + "objective": "val_dbscan_mcc_max_at_val_topo_best_ckpt", + "objective_kind": "mcc", + "best_params": { + "w_topo": 0.1, + "w_aniso": 0.1, + "w_size": 0.1, + "lr": 1e-4, + }, + **PAPER_NO_CLS_CONTRACT, + } + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "best.json" + path.write_text(json.dumps(payload) + "\n", encoding="utf-8") + loaded = preflight_tune_json( + path, + expected_contract=PAPER_NO_CLS_CONTRACT, + ) + self.assertEqual(loaded["_objective_kind"], "mcc") + def test_explicit_objective_kind_overrides_name(self): from tda_ml.preflight import preflight_tune_json @@ -114,6 +167,50 @@ def test_explicit_objective_kind_overrides_name(self): self.assertEqual(payload["_objective_kind"], "wdist") +class TestPaperNoClsContract(unittest.TestCase): + @staticmethod + def _valid_config() -> dict: + return { + "model": { + "topology_loss": { + "homology_dimensions": [1], + "prob_weighting": False, + } + }, + "loss": { + "teacher_mode": "local_pca", + "aniso_mode": "elongate", + }, + } + + def test_explicit_contract_passes(self): + from tda_ml.preflight import ( + PAPER_NO_CLS_CONTRACT, + assert_paper_no_cls_contract, + ) + + self.assertEqual( + assert_paper_no_cls_contract(self._valid_config()), + PAPER_NO_CLS_CONTRACT, + ) + + def test_missing_homology_dimensions_raises(self): + from tda_ml.preflight import assert_paper_no_cls_contract + + config = self._valid_config() + del config["model"]["topology_loss"]["homology_dimensions"] + with self.assertRaisesRegex(ValueError, "homology_dimensions"): + assert_paper_no_cls_contract(config) + + def test_wrong_teacher_mode_raises(self): + from tda_ml.preflight import assert_paper_no_cls_contract + + config = self._valid_config() + config["loss"]["teacher_mode"] = "euclidean" + with self.assertRaisesRegex(ValueError, "contract mismatch"): + assert_paper_no_cls_contract(config) + + class TestResolveValTopoCheckpoint(unittest.TestCase): def test_missing_best_model_raises(self): from tda_ml.checkpoint_io import resolve_val_topo_checkpoint diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index 7dedb3f..b629c9b 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -13,6 +13,14 @@ class TestTuneTrialConfig(unittest.TestCase): + def test_baseline_declares_topology_contract(self): + from tda_ml.config import load_config + + cfg = load_config("elongate_n100_no_cls_full120_baseline") + self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) + self.assertFalse(cfg["model"]["topology_loss"]["prob_weighting"]) + self.assertEqual(cfg["loss"]["teacher_mode"], "euclidean") + def test_canonical_power_tune_base(self): cfg = build_trial_config( "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", From 44ddd48b4f9fa9a494aa77784384001fa58ed7ef Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 11:27:20 +0900 Subject: [PATCH 20/44] =?UTF-8?q?fix:=20skill=20=E6=BA=96=E6=8B=A0?= =?UTF-8?q?=E3=81=A7=E6=9A=97=E9=BB=99=E3=83=87=E3=83=95=E3=82=A9=E3=83=AB?= =?UTF-8?q?=E3=83=88=E3=81=A8=E8=AB=96=E6=96=87=E5=A5=91=E7=B4=84=E3=82=AE?= =?UTF-8?q?=E3=83=A3=E3=83=83=E3=83=97=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 欠落時の H0+H1 / prob_weighting / teacher / backend 黙認を禁止し、論文契約に ellphi+power を含め、本番は H1 tune JSON 必須・集計は欠落 seed で hard-fail する。 Co-authored-by: Cursor --- REPRODUCIBILITY.md | 2 +- configs/README.md | 10 +- configs/base.yaml | 6 +- configs/dev.yaml | 3 + ...elongate_n100_no_cls_full120_baseline.yaml | 3 + ...n100_no_cls_full120_teacher_local_pca.yaml | 5 +- ...o_cls_tune_local_pca_ellphi_power_mcc.yaml | 2 +- configs/prod.yaml | 3 + configs/reproduce.yaml | 3 + experiments/aggregate_power_30ep_multiseed.py | 44 +++++---- .../run_teacher_local_pca_power_30ep.py | 96 +++++++++---------- experiments/tune_elongate_mcc.py | 1 + experiments/tune_elongate_wdist.py | 4 +- tda_ml/losses.py | 9 +- tda_ml/metrics.py | 5 + tda_ml/persistence_dimensions.py | 14 ++- tda_ml/preflight.py | 89 +++++++++++++---- tda_ml/topo_wdist.py | 77 +++++++++++---- tda_ml/trainer.py | 31 ++++-- tests/test_dbscan_grid_wdist_cache.py | 14 ++- tests/test_losses_topo_distance.py | 15 +-- tests/test_persistence_dimensions.py | 28 +++++- tests/test_reproducibility_strict.py | 28 +++++- tests/test_topo_wdist.py | 4 + tests/test_trainer.py | 4 + tests/test_tune_config.py | 1 + 26 files changed, 351 insertions(+), 150 deletions(-) diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index b7ac0e1..9fe1c19 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -31,7 +31,7 @@ | Gudhi `persistence.compute_w_distance` | H1-only | Euclidean Alpha / 点座標 | legacy baseline 用。主表の ellipse W-Dist とは別物 | | `metrics` の W-Dist | 上記どちらかを明示引数で選択 | 引数不足は **hard-fail**(黙って 0 にしない) | | -主表・チューニングの preflight は `homology_dimensions=[1]`、`teacher_mode=local_pca`、`prob_weighting=false`、`aniso_mode=elongate` の明示を要求する。欠落や不一致は実行前に hard-fail する。 +主表・チューニングの preflight は `homology_dimensions=[1]`、`teacher_mode=local_pca`、`prob_weighting=false`、`aniso_mode=elongate`、`distance_backend=ellphi`、`size_mode=power` の明示を要求する。欠落や不一致は実行前に hard-fail する。本番 30ep は `--tune-json`(H1-only Optuna best)必須で、YAML 埋め込みの旧重みでは起動しない。 ## 環境 diff --git a/configs/README.md b/configs/README.md index a530e78..56d0db2 100644 --- a/configs/README.md +++ b/configs/README.md @@ -24,11 +24,13 @@ Canonical YAML files live **in this directory** (deep-merged with `base.yaml` by | `meta` | `config_id` | `main` | Run directory prefix `_`. | | `model` | `point_dim`, `feature_dim` | `main` | Passed to `AnisotropicOutlierClassifier`. | | `model` | `threshold` | `Trainer` | Classification threshold. | -| `model` | `topology_loss.distance_backend` | `Trainer` | `mahalanobis` or `ellphi` (set by multiseed driver per run). | +| `model` | `topology_loss.distance_backend` | `Trainer` | `mahalanobis` or `ellphi` (explicit; no silent default). | +| `model` | `topology_loss.homology_dimensions` | `Trainer` / topo W-Dist | Explicit list (e.g. `[0,1]` or paper `[1]`). | +| `model` | `topology_loss.prob_weighting` | `Trainer` | Explicit bool; `ellphi` requires `false`. | | `model` | `topology_loss.ellphi_differentiable` | `Trainer` | Default `true`; multiseed driver reads from merged config. | -| `loss` | `w_class`, `w_topo`, `w_aniso` | `Trainer` | Loss weights (fallback: `training.lambda_*`). | -| `loss` | `w_size` | `Trainer` | Size regularization scale (fallback: `training.lambda_size` / `lambda_major` / `lambda_minor`). | -| `loss` | `pos_weight`, `aniso_mode` | `Trainer` | BCE positive weight; anisotropy penalty mode. | +| `loss` | `w_class`, `w_topo`, `w_aniso`, `w_size` | `Trainer` | Required under `loss.*` (`reproducibility.allow_legacy_loss_keys=false`). | +| `loss` | `teacher_mode` | `Trainer` / topo W-Dist | Explicit (`euclidean` or `local_pca`). | +| `loss` | `pos_weight`, `aniso_mode`, `size_mode` | `Trainer` | BCE positive weight; anisotropy / size penalty modes. | | `training` | `lr`, `epochs`, `grad_clip_value`, `visualize_every`, `warmup_epochs` | `main` / `Trainer` | | | `training` | `lambda_major`, `lambda_minor`, `lambda_size` | `Trainer` | Ellipse size loss (overrides `w_size` when set). | | `training` | `barrier_threshold`, `rotation_augmentation` | `Trainer` | Anisotropy barrier / data aug. | diff --git a/configs/base.yaml b/configs/base.yaml index 1369102..8e30c98 100644 --- a/configs/base.yaml +++ b/configs/base.yaml @@ -6,6 +6,8 @@ loss: w_topo: 0.1 w_aniso: 0.01 w_size: 1.0 + # Explicit teacher for shared profiles; paper no_cls YAML overrides to local_pca. + teacher_mode: euclidean training: lr: 0.0003 @@ -29,8 +31,10 @@ model: metric_type: "anisotropic" # "anisotropic" or "euclidean" distance_backend: "mahalanobis" # or "ellphi"; multiseed driver overrides per run ellphi_differentiable: true - # Default includes H0+H1; paper no_cls YAML sets [1] (H1-only). + # Shared profiles use H0+H1; paper no_cls YAML sets [1] (H1-only). homology_dimensions: [0, 1] + # mahalanobis may weight by outlier probs; ellphi requires false. + prob_weighting: true threshold: 0.1 ellipse_param_dim: 5 point_dim: 2 diff --git a/configs/dev.yaml b/configs/dev.yaml index 2227858..548c5a9 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -8,6 +8,7 @@ loss: w_topo: 0.1 w_aniso: 0.01 w_size: 0.0001 + teacher_mode: euclidean training: epochs: 3 @@ -21,6 +22,8 @@ model: topology_loss: metric_type: "anisotropic" distance_backend: "mahalanobis" + homology_dimensions: [0, 1] + prob_weighting: true data: train_size: 80 diff --git a/configs/elongate_n100_no_cls_full120_baseline.yaml b/configs/elongate_n100_no_cls_full120_baseline.yaml index 5750b7d..ff8ec64 100644 --- a/configs/elongate_n100_no_cls_full120_baseline.yaml +++ b/configs/elongate_n100_no_cls_full120_baseline.yaml @@ -22,6 +22,9 @@ loss: w_size: 0.488 pos_weight: 1.0 aniso_mode: "elongate" + size_mode: power + size_ref: 1.34 + size_power: 1.5 teacher_mode: euclidean training: diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml index 7c67702..7730ece 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml @@ -1,5 +1,7 @@ # Paper no_cls production: local-PCA teacher + ellphi topo + H1-only Wasserstein. # Degenerate ellphi geometry hard-fails. +# Production 30ep weights MUST come from an H1-only Optuna best JSON (--tune-json). +# The w_* / lr values below are Optuna prior centres only (not paper results). meta: config_id: "elongate_n100_no_cls_full120_teacher_local_pca" @@ -11,13 +13,14 @@ model: threshold: 0.5 topology_loss: metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden per run by production driver + distance_backend: "ellphi" ellphi_differentiable: true prob_weighting: false homology_dimensions: [1] loss: w_class: 0.0 + # Prior centres for Optuna; paper production requires --tune-json overrides. w_topo: 0.0883 w_aniso: 0.0541 w_size: 0.488 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml index 020a0ef..13dc14f 100644 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml @@ -11,7 +11,7 @@ model: threshold: 0.5 topology_loss: metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden per trial (--backend ellphi) + distance_backend: "ellphi" ellphi_differentiable: true prob_weighting: false homology_dimensions: [1] diff --git a/configs/prod.yaml b/configs/prod.yaml index 61f6a2c..8afe12d 100644 --- a/configs/prod.yaml +++ b/configs/prod.yaml @@ -22,11 +22,14 @@ training: loss: pos_weight: 0.2 aniso_mode: "barrier" + teacher_mode: euclidean model: threshold: 0.5 topology_loss: distance_backend: "mahalanobis" + homology_dimensions: [0, 1] + prob_weighting: true data: batch_size: 4 diff --git a/configs/reproduce.yaml b/configs/reproduce.yaml index b0fa1bd..01c45b7 100644 --- a/configs/reproduce.yaml +++ b/configs/reproduce.yaml @@ -13,6 +13,8 @@ model: metric_type: "anisotropic" distance_backend: "mahalanobis" # overridden per run by run_backend_multiseed ellphi_differentiable: true + homology_dimensions: [0, 1] + prob_weighting: true # ellphi runs set false in the multiseed driver loss: w_class: 1.0 @@ -21,6 +23,7 @@ loss: w_size: 0.0055785823086202556 pos_weight: 1.0 aniso_mode: "linear" + teacher_mode: euclidean training: lr: 0.0004897466143769238 diff --git a/experiments/aggregate_power_30ep_multiseed.py b/experiments/aggregate_power_30ep_multiseed.py index 0f64f00..d543352 100644 --- a/experiments/aggregate_power_30ep_multiseed.py +++ b/experiments/aggregate_power_30ep_multiseed.py @@ -24,7 +24,10 @@ def sample_std(values: Sequence[float]) -> float: arr = np.asarray(values, dtype=np.float64) if arr.size < 2: - return 0.0 + raise ValueError( + f"sample_std requires at least 2 values; got {arr.size} " + "(refusing silent zero std for incomplete seed sets)" + ) return float(np.std(arr, ddof=1)) @@ -50,7 +53,10 @@ def discover_seed_metrics(out_base: Path, test_glob: str) -> dict[int, dict[str, if slug.startswith("pwr_s"): seed = int(slug.split("_")[1].replace("s", "")) if seed is None: - continue + raise ValueError( + f"Cannot resolve seed for metrics path {metrics_path}; " + "refusing silent skip" + ) payload = json.loads(metrics_path.read_text(encoding="utf-8")) by_seed[seed] = { "seed": seed, @@ -70,14 +76,13 @@ def aggregate_row( by_seed: dict[int, dict[str, Any]], expected_seeds: Sequence[int], tune_json: str, -) -> tuple[dict[str, Any], list[str]]: - warnings: list[str] = [] +) -> dict[str, Any]: missing = [s for s in expected_seeds if s not in by_seed] if missing: - warnings.append(f"{method}: missing seeds {missing}") - seeds_present = [by_seed[s] for s in expected_seeds if s in by_seed] - if not seeds_present: - raise ValueError(f"No metrics for {method} under expected seeds") + raise ValueError( + f"{method}: missing seeds {missing}; refusing partial aggregate" + ) + seeds_present = [by_seed[s] for s in expected_seeds] mccs = [r["mcc"] for r in seeds_present] gmeans = [r["gmean"] for r in seeds_present] @@ -95,7 +100,7 @@ def aggregate_row( f"{n}/{len(expected_seeds)} seeds; fixed tune weights from {tune_json}; " "30ep val_topo ckpt; maha DBSCAN eval" ), - }, warnings + } def write_csv(path: Path, rows: list[dict[str, Any]]) -> None: @@ -157,17 +162,16 @@ def main() -> int: "mcc": ("proposed_mcc_tune_30ep", mcc_by_seed, args.mcc_tune_json), } rows: list[dict[str, Any]] = [] - all_warnings: list[str] = [] for key in args.methods: method, by_seed, tune_json = method_specs[key] - row, warnings = aggregate_row( - method=method, - by_seed=by_seed, - expected_seeds=args.seeds, - tune_json=tune_json, + rows.append( + aggregate_row( + method=method, + by_seed=by_seed, + expected_seeds=args.seeds, + tune_json=tune_json, + ) ) - rows.append(row) - all_warnings.extend(warnings) out_dir = args.out_dir out_dir.mkdir(parents=True, exist_ok=True) @@ -183,20 +187,18 @@ def main() -> int: "wdist": wdist_by_seed, "mcc": mcc_by_seed, }, - "warnings": all_warnings, + "warnings": [], "summary_csv": str(summary_path), } (out_dir / "MANIFEST_proposed.json").write_text(json.dumps(manifest, indent=2) + "\n") - for w in all_warnings: - print(f"WARNING: {w}") for row in rows: print( f"{row['method']}: MCC={row['mcc_mean']:.4f}±{row['mcc_std']:.4f} " f"G-Mean={row['gmean_mean']:.4f}±{row['gmean_std']:.4f}" ) print(f"Wrote {summary_path}") - return 0 if not all_warnings else 1 + return 0 if __name__ == "__main__": diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_teacher_local_pca_power_30ep.py index 61e1156..b53670b 100644 --- a/experiments/run_teacher_local_pca_power_30ep.py +++ b/experiments/run_teacher_local_pca_power_30ep.py @@ -21,8 +21,6 @@ ) BASE_CONFIG = "elongate_n100_no_cls_full120_teacher_local_pca" -DEFAULT_OUT = REPO_ROOT / "outputs/supervised/pwr30" -TAG = "power_valtopo_paper_eval" TAG_MCC = "power_mcc_valtopo_paper_eval" TAG_WDIST = "power_wdist_valtopo_paper_eval" @@ -38,8 +36,11 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--tune-json", type=Path, - default=None, - help="Optuna best JSON (overrides w_topo/w_aniso/w_size/lr).", + required=True, + help=( + "H1-only Optuna best JSON (required). YAML-embedded w_*/lr are prior " + "centres only and must not be used as paper production weights." + ), ) p.add_argument( "--tag", @@ -71,12 +72,10 @@ def load_tune_weights(path: Path) -> dict[str, float]: } -def infer_tag(tune_json: Path | None, explicit: str | None) -> str: +def infer_tag(tune_json: Path, explicit: str | None) -> str: """Map tune objective to paper-eval tag; hard-fail on unknown objective.""" if explicit: return explicit - if tune_json is None: - return TAG payload = json.loads(tune_json.read_text(encoding="utf-8")) kind = classify_tune_objective( str(payload.get("objective", "")), @@ -93,56 +92,54 @@ def main() -> int: tag = infer_tag(tune_json, args.tag) out_base = args.out_base if out_base is None: - if tune_json is None: - out_base = DEFAULT_OUT - elif tag == TAG_WDIST: + if tag == TAG_WDIST: out_base = REPO_ROOT / "outputs/supervised/pwr30_wdist" else: out_base = REPO_ROOT / "outputs/supervised/pwr30_mcc" out_base.mkdir(parents=True, exist_ok=True) - if tune_json is not None: - preflight_tune_production_run( - base_config=args.base_config, - tune_json=tune_json, - project_root=REPO_ROOT, - out_base=out_base, - config_overrides={ - "loss": { - "size_mode": "power", - "size_ref": args.size_ref, - "size_power": args.size_power, - }, - "training": {"epochs": args.epochs}, - "data": {"seed": args.seed}, - "model": { - "topology_loss": { - "distance_backend": "ellphi", - "prob_weighting": False, - } - }, - "outputs": {"base_dir": str(out_base)}, + preflight_tune_production_run( + base_config=args.base_config, + tune_json=tune_json, + project_root=REPO_ROOT, + out_base=out_base, + config_overrides={ + "loss": { + "aniso_mode": "elongate", + "size_mode": "power", + "size_ref": args.size_ref, + "size_power": args.size_power, + }, + "training": {"epochs": args.epochs}, + "data": {"seed": args.seed}, + "model": { + "topology_loss": { + "distance_backend": "ellphi", + "prob_weighting": False, + "homology_dimensions": [1], + } }, - ) + "outputs": {"base_dir": str(out_base)}, + }, + ) loss_overrides: dict = { + "aniso_mode": "elongate", "size_mode": "power", "size_ref": args.size_ref, "size_power": args.size_power, } training_overrides: dict = {"epochs": args.epochs} - tune_source = None - if tune_json is not None: - tune_weights = load_tune_weights(tune_json) - loss_overrides.update( - { - "w_topo": tune_weights["w_topo"], - "w_aniso": tune_weights["w_aniso"], - "w_size": tune_weights["w_size"], - } - ) - training_overrides["lr"] = tune_weights["lr"] - tune_source = str(tune_json) + tune_weights = load_tune_weights(tune_json) + loss_overrides.update( + { + "w_topo": tune_weights["w_topo"], + "w_aniso": tune_weights["w_aniso"], + "w_size": tune_weights["w_size"], + } + ) + training_overrides["lr"] = tune_weights["lr"] + tune_source = str(tune_json) cfg = deep_update( cfg, @@ -158,6 +155,7 @@ def main() -> int: "topology_loss": { "distance_backend": "ellphi", "prob_weighting": False, + "homology_dimensions": [1], } }, "outputs": {"base_dir": str(out_base)}, @@ -166,9 +164,9 @@ def main() -> int: purpose = { "tag": tag, - "size_mode": cfg["loss"].get("size_mode", "power"), - "size_ref": cfg["loss"].get("size_ref", args.size_ref), - "size_power": cfg["loss"].get("size_power", args.size_power), + "size_mode": cfg["loss"]["size_mode"], + "size_ref": cfg["loss"]["size_ref"], + "size_power": cfg["loss"]["size_power"], "weights": { "w_topo": cfg["loss"]["w_topo"], "w_aniso": cfg["loss"]["w_aniso"], @@ -178,14 +176,14 @@ def main() -> int: "selection": cfg["training"]["selection"]["metric"], "tune_json": tune_source, "dbscan_backend": args.dbscan_backend, - "aniso_mode": cfg["loss"].get("aniso_mode"), + "aniso_mode": cfg["loss"]["aniso_mode"], "homology_dimensions": cfg["model"]["topology_loss"]["homology_dimensions"], "paper_no_cls_contract": paper_contract, "protocol_note": ( "power 30ep H1-only: raw ellipse params to ellphi; degenerate geometry " "hard-fails; tune weights fixed from seed-42 Optuna" ), - "reference": tune_source or "recover baseline (~0.76 test MCC, quadratic size)", + "reference": tune_source, } cfg["_manifest_extras"] = { "tune_json": tune_source, diff --git a/experiments/tune_elongate_mcc.py b/experiments/tune_elongate_mcc.py index 59a841c..4d5a357 100644 --- a/experiments/tune_elongate_mcc.py +++ b/experiments/tune_elongate_mcc.py @@ -254,6 +254,7 @@ def main() -> int: "topology_loss": { "distance_backend": args.backend, "prob_weighting": False, + "homology_dimensions": [1], } }, "loss": { diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py index 289cbba..99e5fa2 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_elongate_wdist.py @@ -211,7 +211,7 @@ def parse_args() -> argparse.Namespace: choices=["mahalanobis", "ellphi"], help="Training topo-loss backend (ellphi = ellipse tangency filtration).", ) - p.add_argument("--out-base", type=str, default="outputs/tune/0629_elongate") + p.add_argument("--out-base", type=str, default="outputs/tune/pwr_wdist") p.add_argument( "--size-mode", default="quadratic", @@ -257,9 +257,11 @@ def main() -> int: "topology_loss": { "distance_backend": args.backend, "prob_weighting": False, + "homology_dimensions": [1], } }, "loss": { + "aniso_mode": "elongate", "size_mode": args.size_mode, "size_ref": args.size_ref, "size_power": args.size_power, diff --git a/tda_ml/losses.py b/tda_ml/losses.py index b523f2b..37fc4d9 100644 --- a/tda_ml/losses.py +++ b/tda_ml/losses.py @@ -137,7 +137,7 @@ class TopologicalLoss(nn.Module): Topological loss: Wasserstein-2 squared between predicted and teacher PDs. ``homology_dimensions`` selects which VR diagrams enter the Wasserstein sum - (default ``(0, 1)``). Paper no_cls stack uses ``[1]`` (H1-only). + and must be set explicitly (paper no_cls stack uses ``[1]``). ``distance_backend``: - ``mahalanobis``: anisotropic distance (optional prob weighting) @@ -150,11 +150,12 @@ def __init__( weight=0.1, distance_backend: str = "mahalanobis", ellphi_differentiable: bool = True, - prob_weighting: bool = True, + prob_weighting: bool = False, eps_scale: float = 1.0, scale_mode: str = "fixed", max_points: int | None = None, - homology_dimensions: tuple[int, ...] | list[int] | None = None, + *, + homology_dimensions: tuple[int, ...] | list[int], strict_topo_samples: bool = True, manifest_ref: dict | None = None, ): @@ -165,7 +166,7 @@ def __init__( # When False, outlier-probability weighting of the distance matrix is # disabled (probs=None). Only affects the ``mahalanobis`` backend; ``ellphi`` # never uses probs. Useful for a fair backend ablation against ellphi. - self.prob_weighting = prob_weighting + self.prob_weighting = bool(prob_weighting) # Filtration-unit alignment between the predicted distance matrix (Mahalanobis # or ellphi tangency units) and the Euclidean clean (teacher) PD. Legacy # ``topo_eps_scale`` (v73 default 0.7022) multiplied D by a scalar; ``ellphi`` diff --git a/tda_ml/metrics.py b/tda_ml/metrics.py index 1744cac..619c022 100644 --- a/tda_ml/metrics.py +++ b/tda_ml/metrics.py @@ -51,6 +51,11 @@ def compute_recall_specificity_gmean_mcc_wdist( labels_gt, labels_pred ) if points is not None and params is not None and clean_pc is not None: + if topo_options is None: + raise ValueError( + "topo_options is required for ellipse-filtration topo W-Dist; " + "refusing silent TopoWdistOptions defaults" + ) w_dist = float(compute_topo_wdist(points, params, clean_pc, topo_options)) elif points is not None and gt_inliers is not None: pred_inliers = points[np.asarray(labels_pred) == 0] diff --git a/tda_ml/persistence_dimensions.py b/tda_ml/persistence_dimensions.py index 3714244..f1e6a0c 100644 --- a/tda_ml/persistence_dimensions.py +++ b/tda_ml/persistence_dimensions.py @@ -8,10 +8,16 @@ SUPPORTED_HOMOLOGY_DIMENSIONS = (0, 1) -def normalize_homology_dimensions(value: Iterable[int] | None) -> tuple[int, ...]: - """Validate an explicit subset of the currently supported dimensions.""" +def normalize_homology_dimensions(value: Iterable[int]) -> tuple[int, ...]: + """Validate an explicit subset of the currently supported dimensions. + + Missing / ``None`` hard-fails: never infer H0+H1. + """ if value is None: - return SUPPORTED_HOMOLOGY_DIMENSIONS + raise ValueError( + "homology_dimensions must be set explicitly; " + "refusing silent default to H0+H1" + ) dimensions = tuple(int(dim) for dim in value) if not dimensions: raise ValueError("homology_dimensions must not be empty") @@ -30,7 +36,7 @@ def normalize_homology_dimensions(value: Iterable[int] | None) -> tuple[int, ... def select_persistence_dimensions( persistence_info: Sequence[Any], - dimensions: Iterable[int] | None, + dimensions: Iterable[int], ) -> list[Any]: """Select persistence records by their declared ``dimension`` field.""" requested = normalize_homology_dimensions(dimensions) diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py index 57b78d4..23d2802 100644 --- a/tda_ml/preflight.py +++ b/tda_ml/preflight.py @@ -34,6 +34,8 @@ "teacher_mode": "local_pca", "prob_weighting": False, "aniso_mode": "elongate", + "distance_backend": "ellphi", + "size_mode": "power", } @@ -69,6 +71,19 @@ def assert_paper_no_cls_contract(config: dict[str, Any]) -> dict[str, Any]: ) actual["aniso_mode"] = str(loss["aniso_mode"]).strip().lower() + if "distance_backend" not in topo: + raise ValueError( + "Paper no_cls config must explicitly define " + "model.topology_loss.distance_backend='ellphi'" + ) + actual["distance_backend"] = str(topo["distance_backend"]).strip().lower() + + if "size_mode" not in loss: + raise ValueError( + "Paper no_cls config must explicitly define loss.size_mode='power'" + ) + actual["size_mode"] = str(loss["size_mode"]).strip().lower() + if actual != PAPER_NO_CLS_CONTRACT: raise ValueError( "Paper no_cls contract mismatch: " @@ -89,26 +104,30 @@ def classify_tune_objective( *, objective_kind: str | None = None, ) -> TuneObjectiveKind: - """Resolve tune objective kind from explicit field or whitelist (no substring guess).""" - if objective_kind is not None: - kind = str(objective_kind).lower().strip() - if kind in ("mcc", "wdist"): - return kind # type: ignore[return-value] + """Resolve tune objective kind from whitelist; optional kind must agree.""" + obj = str(objective).strip() + from_name = _KNOWN_TUNE_OBJECTIVES.get(obj) + if from_name is None: + from_name = _KNOWN_TUNE_OBJECTIVES.get(obj.lower()) + if from_name is None: raise ValueError( - f"Unrecognized tune objective_kind {objective_kind!r}; expected 'mcc' or 'wdist'." + f"Unrecognized tune objective {objective!r}; use a whitelist name: " + f"{sorted(_KNOWN_TUNE_OBJECTIVES)}." ) - obj = str(objective).strip() - if obj in _KNOWN_TUNE_OBJECTIVES: - return _KNOWN_TUNE_OBJECTIVES[obj] - lower = obj.lower() - if lower in _KNOWN_TUNE_OBJECTIVES: - return _KNOWN_TUNE_OBJECTIVES[lower] - - raise ValueError( - f"Unrecognized tune objective {objective!r}; set objective_kind in tune JSON " - f"or use a whitelist name: {sorted(_KNOWN_TUNE_OBJECTIVES)}." - ) + if objective_kind is not None: + kind = str(objective_kind).lower().strip() + if kind not in ("mcc", "wdist"): + raise ValueError( + f"Unrecognized tune objective_kind {objective_kind!r}; " + "expected 'mcc' or 'wdist'." + ) + if kind != from_name: + raise ValueError( + f"objective_kind {kind!r} conflicts with objective {objective!r} " + f"(whitelist kind={from_name!r})" + ) + return from_name def preflight_training_config( @@ -127,12 +146,42 @@ def preflight_training_config( _require_import("scipy", lambda: __import__("scipy")) topo = (config.get("model") or {}).get("topology_loss") or {} - backend = str(topo.get("distance_backend", "mahalanobis")).lower() + loss = config.get("loss") or {} + training = config.get("training") or {} + missing_topo = [ + key + for key in ("distance_backend", "homology_dimensions", "prob_weighting") + if key not in topo + ] + if missing_topo: + raise ValueError( + "model.topology_loss must explicitly define " + f"{missing_topo}; refusing silent defaults" + ) + if "teacher_mode" not in loss and "teacher_mode" not in training: + raise ValueError( + "loss.teacher_mode must be set explicitly; refusing silent euclidean default" + ) + + backend = str(topo["distance_backend"]).lower().strip() + if backend not in ("mahalanobis", "ellphi"): + raise ValueError(f"Unknown distance_backend {backend!r}") + homology_dimensions = list(topo["homology_dimensions"]) + if not homology_dimensions: + raise ValueError("model.topology_loss.homology_dimensions must not be empty") + prob_weighting = bool(topo["prob_weighting"]) + teacher_mode = str( + loss.get("teacher_mode", training.get("teacher_mode")) + ).strip().lower() ellphi_diff = bool(topo.get("ellphi_differentiable", True)) if backend == "ellphi": _require_import("ellphi", lambda: __import__("ellphi")) assert_ellphi_repo_matches_pin(project_root=root) impl = assert_ellphi_differentiable_available(ellphi_differentiable=ellphi_diff) + if prob_weighting: + raise ValueError( + "distance_backend='ellphi' requires model.topology_loss.prob_weighting=false" + ) else: impl = backend @@ -157,7 +206,9 @@ def preflight_training_config( "config_id": config.get("meta", {}).get("config_id"), "distance_backend": backend, "distance_backend_impl": impl, - "homology_dimensions": list(topo.get("homology_dimensions", [0, 1])), + "homology_dimensions": homology_dimensions, + "prob_weighting": prob_weighting, + "teacher_mode": teacher_mode, "seed": config.get("data", {}).get("seed"), "ellphi_repo": build_ellphi_repo_manifest_fields(root), } diff --git a/tda_ml/topo_wdist.py b/tda_ml/topo_wdist.py index cf6deb6..285508a 100644 --- a/tda_ml/topo_wdist.py +++ b/tda_ml/topo_wdist.py @@ -28,16 +28,17 @@ @dataclass(frozen=True) class TopoWdistOptions: - # Must match Trainer default unless config overrides it. - teacher_mode: str = "euclidean" - distance_backend: str = "ellphi" + """Eval options; method-defining fields have no silent defaults.""" + + teacher_mode: str + distance_backend: str + homology_dimensions: tuple[int, ...] + prob_weighting: bool teacher_local_pca_k: int = 10 teacher_local_pca_normalize_axes: bool = True eps_scale: float = 1.0 scale_mode: str = "fixed" max_points: int | None = None - prob_weighting: bool = True - homology_dimensions: tuple[int, ...] = (0, 1) def __post_init__(self) -> None: object.__setattr__( @@ -45,18 +46,56 @@ def __post_init__(self) -> None: "homology_dimensions", normalize_homology_dimensions(self.homology_dimensions), ) + object.__setattr__( + self, + "teacher_mode", + str(self.teacher_mode).strip().lower(), + ) + object.__setattr__( + self, + "distance_backend", + str(self.distance_backend).strip().lower(), + ) + object.__setattr__(self, "prob_weighting", bool(self.prob_weighting)) + object.__setattr__( + self, + "scale_mode", + str(self.scale_mode).strip().lower(), + ) + if self.scale_mode not in ("fixed", "median"): + raise ValueError( + f"scale_mode must be 'fixed' or 'median', got {self.scale_mode!r}" + ) def topo_wdist_options_from_config(config: dict[str, Any]) -> TopoWdistOptions: - loss_cfg = config.get("loss", {}) - training_cfg = config.get("training", {}) - topo_cfg = config.get("model", {}).get("topology_loss", {}) + """Build options from config; missing method fields hard-fail.""" + loss_cfg = config.get("loss") or {} + training_cfg = config.get("training") or {} + topo_cfg = (config.get("model") or {}).get("topology_loss") or {} + + missing_topo = [ + key + for key in ("distance_backend", "homology_dimensions", "prob_weighting") + if key not in topo_cfg + ] + if missing_topo: + raise ValueError( + "topo W-Dist requires explicit model.topology_loss fields " + f"{missing_topo}; refusing silent defaults" + ) + + teacher_mode = loss_cfg.get("teacher_mode", training_cfg.get("teacher_mode")) + if teacher_mode is None: + raise ValueError( + "topo W-Dist requires explicit loss.teacher_mode " + "(or training.teacher_mode); refusing silent euclidean default" + ) + max_pts = training_cfg.get("topo_loss_max_points", loss_cfg.get("topo_loss_max_points")) return TopoWdistOptions( - teacher_mode=str( - loss_cfg.get("teacher_mode", training_cfg.get("teacher_mode", "euclidean")) - ).strip().lower(), - distance_backend=str(topo_cfg.get("distance_backend", "mahalanobis")).lower().strip(), + teacher_mode=str(teacher_mode).strip().lower(), + distance_backend=str(topo_cfg["distance_backend"]).lower().strip(), teacher_local_pca_k=int( loss_cfg.get( "teacher_local_pca_k", @@ -76,9 +115,9 @@ def topo_wdist_options_from_config(config: dict[str, Any]) -> TopoWdistOptions: loss_cfg.get("topo_scale_mode", training_cfg.get("topo_scale_mode", "fixed")) ).strip().lower(), max_points=int(max_pts) if max_pts is not None else None, - prob_weighting=bool(topo_cfg.get("prob_weighting", True)), + prob_weighting=bool(topo_cfg["prob_weighting"]), homology_dimensions=normalize_homology_dimensions( - topo_cfg.get("homology_dimensions") + topo_cfg["homology_dimensions"] ), ) @@ -98,15 +137,21 @@ def compute_topo_wdist( points: np.ndarray, params: np.ndarray, clean_pc: np.ndarray, - options: TopoWdistOptions | None = None, + options: TopoWdistOptions, ) -> float: """ Wasserstein-2 distance over configured homology dimensions. ``points`` / ``params``: full noisy cloud and learned ellipses (DBSCAN unused). ``clean_pc``: padded clean inlier coordinates for the teacher PD. + ``options`` is required (no silent TopoWdistOptions defaults). """ - opts = options or TopoWdistOptions() + if options is None: + raise ValueError( + "compute_topo_wdist requires explicit TopoWdistOptions; " + "refusing silent defaults" + ) + opts = options pts = torch.as_tensor(points, dtype=torch.float32) par = torch.as_tensor(params[..., :3], dtype=torch.float32) clean = torch.as_tensor(clean_pc, dtype=torch.float32) diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 6cb10d4..43a301e 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -166,11 +166,26 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: # Initialize Losses self.class_loss_fn = ClassificationLoss(pos_weight=pos_weight) - _topo = config.get("model", {}).get("topology_loss", {}) - self.distance_backend = _topo.get("distance_backend", "mahalanobis") - self.ellphi_differentiable = _topo.get("ellphi_differentiable", True) - self.prob_weighting = bool(_topo.get("prob_weighting", True)) - self.homology_dimensions = _topo.get("homology_dimensions") + _topo = config.get("model", {}).get("topology_loss", {}) or {} + missing_topo = [ + key + for key in ("distance_backend", "homology_dimensions", "prob_weighting") + if key not in _topo + ] + if missing_topo: + raise ValueError( + "model.topology_loss must explicitly define " + f"{missing_topo}; refusing silent defaults" + ) + teacher_raw = loss_cfg.get("teacher_mode", training_cfg.get("teacher_mode")) + if teacher_raw is None: + raise ValueError( + "loss.teacher_mode must be set explicitly; refusing silent euclidean default" + ) + self.distance_backend = str(_topo["distance_backend"]).lower().strip() + self.ellphi_differentiable = bool(_topo.get("ellphi_differentiable", True)) + self.prob_weighting = bool(_topo["prob_weighting"]) + self.homology_dimensions = _topo["homology_dimensions"] # Filtration-unit alignment for the topology loss (see TopologicalLoss). # Legacy knob `training.topo_eps_scale` (v73=0.7022); also accept loss.topo_eps_scale. self.topo_eps_scale = float( @@ -179,9 +194,7 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: self.topo_scale_mode = str( loss_cfg.get("topo_scale_mode", training_cfg.get("topo_scale_mode", "fixed")) ).strip().lower() - self.teacher_mode = str( - loss_cfg.get("teacher_mode", training_cfg.get("teacher_mode", "euclidean")) - ).strip().lower() + self.teacher_mode = str(teacher_raw).strip().lower() self.teacher_local_pca_k = int( loss_cfg.get( "teacher_local_pca_k", @@ -203,7 +216,7 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: else "" ), self.prob_weighting, - self.homology_dimensions if self.homology_dimensions is not None else [0, 1], + self.homology_dimensions, self.topo_scale_mode, self.topo_eps_scale, self.teacher_mode, diff --git a/tests/test_dbscan_grid_wdist_cache.py b/tests/test_dbscan_grid_wdist_cache.py index e74dd3c..7fea51c 100644 --- a/tests/test_dbscan_grid_wdist_cache.py +++ b/tests/test_dbscan_grid_wdist_cache.py @@ -26,7 +26,12 @@ def _toy_cloud() -> PreparedCloud: class TestDbscanGridWdistCache(unittest.TestCase): def test_topo_wdist_computed_once_per_cloud(self) -> None: clouds = [_toy_cloud(), _toy_cloud()] - opts = TopoWdistOptions(teacher_mode="euclidean", distance_backend="mahalanobis") + opts = TopoWdistOptions( + teacher_mode="euclidean", + distance_backend="mahalanobis", + homology_dimensions=(0, 1), + prob_weighting=False, + ) with patch( "tda_ml.dbscan_eval.compute_topo_wdist", side_effect=[0.5, 0.6], @@ -42,7 +47,12 @@ def test_topo_wdist_computed_once_per_cloud(self) -> None: def test_wdist_constant_across_grid_for_same_cloud(self) -> None: cloud = _toy_cloud() - opts = TopoWdistOptions(teacher_mode="euclidean", distance_backend="mahalanobis") + opts = TopoWdistOptions( + teacher_mode="euclidean", + distance_backend="mahalanobis", + homology_dimensions=(0, 1), + prob_weighting=False, + ) with patch( "tda_ml.dbscan_eval.compute_topo_wdist", return_value=0.42, diff --git a/tests/test_losses_topo_distance.py b/tests/test_losses_topo_distance.py index 5334393..e636d2a 100644 --- a/tests/test_losses_topo_distance.py +++ b/tests/test_losses_topo_distance.py @@ -96,16 +96,16 @@ def _clean_pd(self, pt): def test_invalid_scale_mode_raises(self): from tda_ml.losses import TopologicalLoss with self.assertRaises(ValueError): - TopologicalLoss(scale_mode="bogus") + TopologicalLoss(scale_mode="bogus", homology_dimensions=[0, 1]) def test_eps_scale_default_is_noop(self): """eps_scale=1.0 (fixed) must not change the loss vs. an explicit 1.0.""" from tda_ml.losses import TopologicalLoss pt, par, logits = self._inputs() clean = self._clean_pd(pt) - base = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", prob_weighting=False) + base = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", prob_weighting=False, homology_dimensions=[0, 1]) same = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", - prob_weighting=False, eps_scale=1.0) + prob_weighting=False, eps_scale=1.0, homology_dimensions=[0, 1]) l0 = base(pt, par, logits, clean).item() l1 = same(pt, par, logits, clean).item() self.assertAlmostEqual(l0, l1, places=6) @@ -115,9 +115,9 @@ def test_eps_scale_changes_loss(self): from tda_ml.losses import TopologicalLoss pt, par, logits = self._inputs() clean = self._clean_pd(pt) - base = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", prob_weighting=False) + base = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", prob_weighting=False, homology_dimensions=[0, 1]) scaled = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", - prob_weighting=False, eps_scale=0.3) + prob_weighting=False, eps_scale=0.3, homology_dimensions=[0, 1]) l0 = base(pt, par, logits, clean).item() ls = scaled(pt, par, logits, clean).item() self.assertGreater(abs(l0 - ls), 1e-6) @@ -128,7 +128,7 @@ def test_median_mode_runs_and_grads(self): par = par.clone().requires_grad_(True) clean = self._clean_pd(pt) loss_fn = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", - prob_weighting=False, scale_mode="median") + prob_weighting=False, scale_mode="median", homology_dimensions=[0, 1]) # clean_scales (m_e) provided by the trainer; loss brings prediction onto it. clean_scales = [float(torch.pdist(pt[i]).median()) for i in range(pt.shape[0])] loss = loss_fn(pt, par, logits, clean, clean_scales=clean_scales) @@ -142,7 +142,7 @@ def test_median_aligns_prediction_to_teacher_scale(self): from tda_ml.losses import TopologicalLoss pt, par, logits = self._inputs() loss_fn = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", - prob_weighting=False, scale_mode="median") + prob_weighting=False, scale_mode="median", homology_dimensions=[0, 1]) i = 0 from tda_ml.distance_backend import compute_distance_matrix_batch D = compute_distance_matrix_batch(pt, par, probs=None, symmetrize="max", @@ -190,6 +190,7 @@ def test_topo_max_points_subsamples(self): distance_backend="mahalanobis", prob_weighting=False, max_points=12, + homology_dimensions=[0, 1], ) loss = loss_fn(pt, par, logits, clean) self.assertTrue(torch.isfinite(loss)) diff --git a/tests/test_persistence_dimensions.py b/tests/test_persistence_dimensions.py index fcaf97e..98ec93c 100644 --- a/tests/test_persistence_dimensions.py +++ b/tests/test_persistence_dimensions.py @@ -22,10 +22,11 @@ def setUp(self): ) self.info = VietorisRipsComplex(dim=1)(points) - def test_default_selects_h0_and_h1(self): - self.assertEqual(normalize_homology_dimensions(None), (0, 1)) - selected = select_persistence_dimensions(self.info, None) - self.assertEqual([item.dimension for item in selected], [0, 1]) + def test_missing_homology_dimensions_hard_fail(self): + with self.assertRaisesRegex(ValueError, "explicit"): + normalize_homology_dimensions(None) + with self.assertRaisesRegex(ValueError, "explicit"): + select_persistence_dimensions(self.info, None) def test_h1_only_selects_one_diagram(self): selected = select_persistence_dimensions(self.info, [1]) @@ -60,10 +61,27 @@ def test_wasserstein_h1_only_differs_from_h0_h1(self): self.assertTrue(torch.isfinite(h1)) self.assertNotEqual(float(both), float(h1)) + def test_topo_wdist_options_require_explicit_method_fields(self): + with self.assertRaisesRegex(ValueError, "explicit"): + topo_wdist_options_from_config( + {"model": {"topology_loss": {"homology_dimensions": [1]}}} + ) + def test_topo_wdist_options_read_h1_only(self): - cfg = {"model": {"topology_loss": {"homology_dimensions": [1]}}} + cfg = { + "model": { + "topology_loss": { + "homology_dimensions": [1], + "distance_backend": "mahalanobis", + "prob_weighting": False, + } + }, + "loss": {"teacher_mode": "local_pca"}, + } opts = topo_wdist_options_from_config(cfg) self.assertEqual(opts.homology_dimensions, (1,)) + self.assertEqual(opts.teacher_mode, "local_pca") + self.assertFalse(opts.prob_weighting) def test_topological_loss_runs_with_h1_only(self): theta = torch.linspace(0.0, 2.0 * torch.pi, 9)[:-1] diff --git a/tests/test_reproducibility_strict.py b/tests/test_reproducibility_strict.py index a51b891..e684935 100644 --- a/tests/test_reproducibility_strict.py +++ b/tests/test_reproducibility_strict.py @@ -75,7 +75,10 @@ def test_classify_tune_objective(self): "mcc", ) self.assertEqual( - classify_tune_objective("legacy_name", objective_kind="mcc"), + classify_tune_objective( + "val_dbscan_mcc_max", + objective_kind="mcc", + ), "mcc", ) self.assertEqual( @@ -84,6 +87,13 @@ def test_classify_tune_objective(self): ) with self.assertRaises(ValueError): classify_tune_objective("custom_objective_with_wdist_substring") + with self.assertRaises(ValueError): + classify_tune_objective("legacy_name", objective_kind="mcc") + with self.assertRaisesRegex(ValueError, "conflicts"): + classify_tune_objective( + "val_topo_wdist_min", + objective_kind="mcc", + ) def test_selection_default_is_val_topo(self): from tda_ml.model_selection import selection_settings_from_config @@ -153,7 +163,7 @@ def test_tune_json_paper_contract_must_match(self): ) self.assertEqual(loaded["_objective_kind"], "mcc") - def test_explicit_objective_kind_overrides_name(self): + def test_objective_kind_must_agree_with_whitelist_name(self): from tda_ml.preflight import preflight_tune_json with tempfile.TemporaryDirectory() as tmp: @@ -163,8 +173,8 @@ def test_explicit_objective_kind_overrides_name(self): '"best_params":{"w_topo":0.1,"w_aniso":0.1,"w_size":0.1,"lr":1e-4}}\n', encoding="utf-8", ) - payload = preflight_tune_json(path) - self.assertEqual(payload["_objective_kind"], "wdist") + with self.assertRaisesRegex(ValueError, "Unrecognized tune objective"): + preflight_tune_json(path) class TestPaperNoClsContract(unittest.TestCase): @@ -175,11 +185,13 @@ def _valid_config() -> dict: "topology_loss": { "homology_dimensions": [1], "prob_weighting": False, + "distance_backend": "ellphi", } }, "loss": { "teacher_mode": "local_pca", "aniso_mode": "elongate", + "size_mode": "power", }, } @@ -236,7 +248,12 @@ def _toy_cloud(self) -> PreparedCloud: def test_failed_cell_raises_when_skip_not_allowed(self): cloud = self._toy_cloud() - opts = TopoWdistOptions(teacher_mode="euclidean", distance_backend="mahalanobis") + opts = TopoWdistOptions( + teacher_mode="euclidean", + distance_backend="mahalanobis", + homology_dimensions=(0, 1), + prob_weighting=False, + ) with patch( "tda_ml.dbscan_eval._dbscan_classification_metrics", side_effect=RuntimeError("degenerate"), @@ -270,6 +287,7 @@ def test_nan_wasserstein_raises_when_strict(self): weight=1.0, distance_backend="mahalanobis", prob_weighting=False, + homology_dimensions=[0, 1], strict_topo_samples=True, ) diff --git a/tests/test_topo_wdist.py b/tests/test_topo_wdist.py index 272effd..8f5ce3c 100644 --- a/tests/test_topo_wdist.py +++ b/tests/test_topo_wdist.py @@ -23,6 +23,8 @@ def test_identical_clouds_near_zero_mahalanobis(self): opts = TopoWdistOptions( teacher_mode="local_pca", distance_backend="mahalanobis", + homology_dimensions=(0, 1), + prob_weighting=False, ) w = compute_topo_wdist(noisy, params, clean, opts) self.assertTrue(np.isfinite(w)) @@ -37,6 +39,8 @@ def test_different_clouds_positive(self): opts = TopoWdistOptions( teacher_mode="local_pca", distance_backend="mahalanobis", + homology_dimensions=(0, 1), + prob_weighting=False, ) w_same = compute_topo_wdist(clean, params[: clean.shape[0]], clean, opts) w_diff = compute_topo_wdist(noisy, params, clean, opts) diff --git a/tests/test_trainer.py b/tests/test_trainer.py index 3783284..daea973 100644 --- a/tests/test_trainer.py +++ b/tests/test_trainer.py @@ -44,10 +44,13 @@ def setUp(self): 'w_topo': 0.1, 'w_aniso': 0.01, 'w_size': 0.1, + 'teacher_mode': 'euclidean', }, 'model': { 'topology_loss': { 'distance_backend': 'mahalanobis', + 'homology_dimensions': [0, 1], + 'prob_weighting': True, } }, 'outputs': { @@ -96,6 +99,7 @@ def test_w_class_zero_skips_classification_gradient(self): "w_topo": 0.1, "w_aniso": 0.01, "w_size": 0.1, + "teacher_mode": "euclidean", } self.config["model"]["topology_loss"]["prob_weighting"] = False trainer = Trainer(self.model, self.config, self.device) diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index b629c9b..10eb6e6 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -58,6 +58,7 @@ def test_power_tune_preserves_h1_hard_fail_stack(self): self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) self.assertEqual(cfg["loss"]["aniso_mode"], "elongate") self.assertEqual(cfg["loss"]["size_mode"], "power") + self.assertEqual(cfg["model"]["topology_loss"]["distance_backend"], "ellphi") if __name__ == "__main__": From 4bb5d1fb47d159f7cd0ffda2fb6b071e588e2f56 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 11:49:26 +0900 Subject: [PATCH 21/44] =?UTF-8?q?fix:=20=E8=AB=96=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E7=95=AA=E7=B5=8C=E8=B7=AF=E3=81=AE=E6=9A=97=E9=BB=99=E9=81=B8?= =?UTF-8?q?=E6=8A=9E=E3=81=A8=E5=A5=91=E7=B4=84=E6=8D=8F=E9=80=A0=E3=82=92?= =?UTF-8?q?=E5=A1=9E=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tune trial に paper contract を明示適用し、stale skip・duplicate seed・ size_* 非伝播・弱い preflight / stuck running を hard-fail で解消する。 Co-authored-by: Cursor --- REPRODUCIBILITY.md | 2 +- configs/base.yaml | 4 + configs/reproduce.yaml | 3 + experiments/aggregate_power_30ep_multiseed.py | 69 +++++-- experiments/evaluate_paper_protocol.py | 73 ++++++- experiments/power_30ep_freshness.py | 120 ++++++++++++ .../run_teacher_local_pca_power_30ep.py | 91 ++++++--- ..._teacher_local_pca_power_30ep_multiseed.sh | 37 ++-- experiments/tune_elongate_mcc.py | 6 + experiments/tune_elongate_wdist.py | 11 +- tda_ml/main.py | 166 +++++++++------- tda_ml/preflight.py | 165 ++++++++++++---- tda_ml/run_paths.py | 16 +- tda_ml/supervised_diagnostics.py | 12 +- tda_ml/topo_wdist.py | 6 + tda_ml/trainer.py | 28 ++- tests/test_paper_production_gates.py | 178 ++++++++++++++++++ tests/test_reproducibility_strict.py | 3 + tests/test_run_paths.py | 22 ++- tests/test_trainer.py | 8 + tests/test_tune_config.py | 57 +++++- 21 files changed, 890 insertions(+), 187 deletions(-) create mode 100644 experiments/power_30ep_freshness.py create mode 100644 tests/test_paper_production_gates.py diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 9fe1c19..cece9e1 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -31,7 +31,7 @@ | Gudhi `persistence.compute_w_distance` | H1-only | Euclidean Alpha / 点座標 | legacy baseline 用。主表の ellipse W-Dist とは別物 | | `metrics` の W-Dist | 上記どちらかを明示引数で選択 | 引数不足は **hard-fail**(黙って 0 にしない) | | -主表・チューニングの preflight は `homology_dimensions=[1]`、`teacher_mode=local_pca`、`prob_weighting=false`、`aniso_mode=elongate`、`distance_backend=ellphi`、`size_mode=power` の明示を要求する。欠落や不一致は実行前に hard-fail する。本番 30ep は `--tune-json`(H1-only Optuna best)必須で、YAML 埋め込みの旧重みでは起動しない。 +主表・チューニングの preflight は `homology_dimensions=[1]`、`teacher_mode=local_pca`、`prob_weighting=false`、`aniso_mode=elongate`、`distance_backend=ellphi`、`size_mode=power`、`w_class=0.0`、`teacher_local_pca_k=10`、`teacher_local_pca_normalize_axes=true` の明示を要求する。欠落や不一致は実行前に hard-fail する。本番 30ep は `--tune-json`(H1-only Optuna best)必須で、YAML 埋め込みの旧重みでは起動しない。 ## 環境 diff --git a/configs/base.yaml b/configs/base.yaml index 8e30c98..28fb4c3 100644 --- a/configs/base.yaml +++ b/configs/base.yaml @@ -8,6 +8,10 @@ loss: w_size: 1.0 # Explicit teacher for shared profiles; paper no_cls YAML overrides to local_pca. teacher_mode: euclidean + aniso_mode: linear + size_mode: quadratic + size_ref: 1.34 + size_power: 1.5 training: lr: 0.0003 diff --git a/configs/reproduce.yaml b/configs/reproduce.yaml index 01c45b7..ecfc4d1 100644 --- a/configs/reproduce.yaml +++ b/configs/reproduce.yaml @@ -23,6 +23,9 @@ loss: w_size: 0.0055785823086202556 pos_weight: 1.0 aniso_mode: "linear" + size_mode: quadratic + size_ref: 1.34 + size_power: 1.5 teacher_mode: euclidean training: diff --git a/experiments/aggregate_power_30ep_multiseed.py b/experiments/aggregate_power_30ep_multiseed.py index d543352..9cec444 100644 --- a/experiments/aggregate_power_30ep_multiseed.py +++ b/experiments/aggregate_power_30ep_multiseed.py @@ -11,6 +11,7 @@ import numpy as np +from tda_ml.preflight import PAPER_NO_CLS_CONTRACT, preflight_tune_json from tda_ml.supervised_diagnostics import git_revision REPO_ROOT = Path(__file__).resolve().parents[1] @@ -43,30 +44,37 @@ def discover_seed_metrics(out_base: Path, test_glob: str) -> dict[int, dict[str, for metrics_path in sorted(out_base.glob(test_glob)): run_dir = metrics_path.parent.parent manifest_path = run_dir / "logs" / "run_manifest.json" - seed: int | None = None - if manifest_path.is_file(): - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - if manifest.get("seed") is not None: - seed = int(manifest["seed"]) - if seed is None: - slug = run_dir.name - if slug.startswith("pwr_s"): - seed = int(slug.split("_")[1].replace("s", "")) - if seed is None: + if not manifest_path.is_file(): raise ValueError( - f"Cannot resolve seed for metrics path {metrics_path}; " - "refusing silent skip" + f"Missing run_manifest.json for {metrics_path}; " + "refusing directory-name seed inference" ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("seed") is None: + raise ValueError( + f"run_manifest.json missing seed for {metrics_path}; " + "refusing directory-name seed inference" + ) + seed = int(manifest["seed"]) payload = json.loads(metrics_path.read_text(encoding="utf-8")) - by_seed[seed] = { + row = { "seed": seed, "run_dir": str(run_dir), "metrics_path": str(metrics_path), + "tune_json": manifest.get("tune_json"), + "source_revision": manifest.get("source_revision"), "mcc": _first_key(payload, MCC_KEYS, label="mcc"), "gmean": _first_key(payload, GMEAN_KEYS, label="gmean"), "wdist": _first_key(payload, WDIST_KEYS, label="wdist"), "recall": float(payload["recall"]) if payload.get("recall") is not None else None, } + if seed in by_seed: + raise ValueError( + f"Duplicate metrics for seed={seed}: " + f"{by_seed[seed]['metrics_path']} and {metrics_path}; " + "refusing silent last-wins selection" + ) + by_seed[seed] = row return by_seed @@ -82,6 +90,21 @@ def aggregate_row( raise ValueError( f"{method}: missing seeds {missing}; refusing partial aggregate" ) + expected_tune = str(Path(tune_json).resolve()) + mismatched = [] + for seed in expected_seeds: + actual = by_seed[seed].get("tune_json") + if actual is None: + mismatched.append((seed, None)) + continue + actual_resolved = str(Path(actual).resolve()) + if actual_resolved != expected_tune: + mismatched.append((seed, actual_resolved)) + if mismatched: + raise ValueError( + f"{method}: per-seed tune_json mismatch vs aggregate {expected_tune}: " + f"{mismatched}" + ) seeds_present = [by_seed[s] for s in expected_seeds] mccs = [r["mcc"] for r in seeds_present] @@ -97,7 +120,7 @@ def aggregate_row( "wdist_mean": float(np.mean(wdist)), "wdist_std": sample_std(wdist), "notes": ( - f"{n}/{len(expected_seeds)} seeds; fixed tune weights from {tune_json}; " + f"{n}/{len(expected_seeds)} seeds; fixed tune weights from {expected_tune}; " "30ep val_topo ckpt; maha DBSCAN eval" ), } @@ -148,6 +171,17 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() + tune_paths = { + "wdist": Path(args.wdist_tune_json), + "mcc": Path(args.mcc_tune_json), + } + for key in args.methods: + path = tune_paths[key] + if not path.is_absolute(): + path = REPO_ROOT / path + preflight_tune_json(path, expected_contract=PAPER_NO_CLS_CONTRACT) + tune_paths[key] = path.resolve() + wdist_by_seed = discover_seed_metrics( args.wdist_out, "pwr_s*/logs/paper_metrics_test_power_wdist_valtopo_paper_eval.json", @@ -158,8 +192,8 @@ def main() -> int: ) method_specs = { - "wdist": ("proposed_wdist_tune_30ep", wdist_by_seed, args.wdist_tune_json), - "mcc": ("proposed_mcc_tune_30ep", mcc_by_seed, args.mcc_tune_json), + "wdist": ("proposed_wdist_tune_30ep", wdist_by_seed, str(tune_paths["wdist"])), + "mcc": ("proposed_mcc_tune_30ep", mcc_by_seed, str(tune_paths["mcc"])), } rows: list[dict[str, Any]] = [] for key in args.methods: @@ -183,6 +217,9 @@ def main() -> int: "seeds_expected": list(args.seeds), "wdist_out": str(args.wdist_out), "mcc_out": str(args.mcc_out), + "wdist_tune_json": str(tune_paths["wdist"]), + "mcc_tune_json": str(tune_paths["mcc"]), + "paper_no_cls_contract": PAPER_NO_CLS_CONTRACT, "per_seed": { "wdist": wdist_by_seed, "mcc": mcc_by_seed, diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index 87dde4d..0ee273f 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Paper-aligned evaluation: ellphi DBSCAN inference + MCC / G-Mean / topo W-Dist. +Paper-aligned evaluation: mahalanobis DBSCAN inference + MCC / G-Mean / topo W-Dist. W-Dist matches ``TopologicalLoss``: learned ellipses on the full noisy cloud vs clean teacher PD (local PCA + ellphi by default). DBSCAN affects MCC only. @@ -8,8 +8,8 @@ Usage:: uv run python experiments/evaluate_paper_protocol.py \\ - --run-dir outputs/supervised/.../eph_s42_ \\ - --base-config reproduce \\ + --run-dir outputs/supervised/.../pwr_s42_ \\ + --base-config elongate_n100_no_cls_full120_teacher_local_pca \\ --split val uv run python experiments/evaluate_paper_protocol.py \\ @@ -278,6 +278,58 @@ def load_run_config(run_dir: Path, base_config: str, seed: int | None) -> dict[s manifest = json.loads(manifest_path.read_text()) if seed is None: seed = manifest.get("seed") + loss_overrides = manifest.get("loss_overrides") or {} + if loss_overrides: + topo_patch: dict[str, Any] = {} + if "homology_dimensions" in loss_overrides: + topo_patch["homology_dimensions"] = loss_overrides["homology_dimensions"] + loss_patch = { + key: loss_overrides[key] + for key in ( + "aniso_mode", + "size_mode", + "size_ref", + "size_power", + "w_topo", + "w_aniso", + "w_size", + ) + if key in loss_overrides + } + patch: dict[str, Any] = {} + if loss_patch: + patch["loss"] = loss_patch + if topo_patch: + patch["model"] = {"topology_loss": topo_patch} + if patch: + cfg = deep_update(cfg, patch) + contract = manifest.get("paper_no_cls_contract") + if isinstance(contract, dict): + # Prefer method fields recorded at train time when present. + loss_c = { + key: contract[key] + for key in ( + "teacher_mode", + "aniso_mode", + "size_mode", + "w_class", + "teacher_local_pca_k", + "teacher_local_pca_normalize_axes", + ) + if key in contract + } + topo_c = { + key: contract[key] + for key in ("homology_dimensions", "prob_weighting", "distance_backend") + if key in contract + } + patch = {} + if loss_c: + patch["loss"] = loss_c + if topo_c: + patch["model"] = {"topology_loss": topo_c} + if patch: + cfg = deep_update(cfg, patch) if seed is not None: cfg = deep_update(cfg, {"data": {"seed": int(seed)}}) return cfg @@ -286,10 +338,21 @@ def load_run_config(run_dir: Path, base_config: str, seed: int | None) -> dict[s def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--run-dir", type=Path, required=True) - p.add_argument("--base-config", type=str, default="reproduce") + p.add_argument( + "--base-config", + type=str, + default="elongate_n100_no_cls_full120_teacher_local_pca", + help="YAML used when run_manifest lacks method overrides (paper no_cls default).", + ) p.add_argument("--split", choices=["val", "test"], required=True) p.add_argument("--seed", type=int, default=None, help="Override data.seed (else run_manifest)") - p.add_argument("--backend", type=str, default="ellphi", choices=["ellphi", "mahalanobis"]) + p.add_argument( + "--backend", + type=str, + default="mahalanobis", + choices=["ellphi", "mahalanobis"], + help="DBSCAN backend for MCC (paper protocol default: mahalanobis).", + ) p.add_argument( "--checkpoint-name", type=str, diff --git a/experiments/power_30ep_freshness.py b/experiments/power_30ep_freshness.py new file mode 100644 index 0000000..ad51535 --- /dev/null +++ b/experiments/power_30ep_freshness.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Freshness gate for multiseed 30ep skip decisions. + +Exit codes: + 0 — matching metrics exist (safe to skip) + 1 — no metrics (must run) + 2 — stale / ambiguous / corrupt (hard-fail; do not skip and do not silently reuse) +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from tda_ml.supervised_diagnostics import git_revision + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _resolve_tune_json(value: str | None) -> str | None: + if value is None: + return None + path = Path(value) + if not path.is_absolute(): + path = (REPO_ROOT / path).resolve() + else: + path = path.resolve() + return str(path) + + +def inspect_seed_metrics( + *, + out_base: Path, + seed: int, + tag: str, + tune_json: Path, + expected_revision: str | None = None, +) -> tuple[str, list[Path]]: + """Return ``(status, paths)`` where status is fresh|missing|stale|ambiguous.""" + pattern = f"pwr_s{seed}_*/logs/paper_metrics_test_{tag}.json" + matches = sorted(out_base.glob(pattern)) + if not matches: + return "missing", [] + + expected_tune = _resolve_tune_json(str(tune_json)) + expected_rev = expected_revision or git_revision(REPO_ROOT) + fresh: list[Path] = [] + stale_reasons: list[str] = [] + + for metrics_path in matches: + run_dir = metrics_path.parent.parent + manifest_path = run_dir / "logs" / "run_manifest.json" + if not manifest_path.is_file(): + stale_reasons.append(f"{metrics_path}: missing run_manifest.json") + continue + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if int(manifest.get("seed", -1)) != int(seed): + stale_reasons.append( + f"{metrics_path}: manifest seed {manifest.get('seed')!r} != {seed}" + ) + continue + manifest_tune = _resolve_tune_json(manifest.get("tune_json")) + if manifest_tune != expected_tune: + stale_reasons.append( + f"{metrics_path}: tune_json {manifest_tune!r} != {expected_tune!r}" + ) + continue + rev = manifest.get("source_revision") + if rev != expected_rev: + stale_reasons.append( + f"{metrics_path}: source_revision {rev!r} != {expected_rev!r}" + ) + continue + fresh.append(metrics_path) + + if len(fresh) == 1 and not stale_reasons: + return "fresh", fresh + if len(fresh) > 1: + return "ambiguous", fresh + if fresh and stale_reasons: + return "ambiguous", fresh + return "stale", matches + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--out-base", type=Path, required=True) + p.add_argument("--seed", type=int, required=True) + p.add_argument("--tag", type=str, required=True) + p.add_argument("--tune-json", type=Path, required=True) + return p.parse_args() + + +def main() -> int: + args = parse_args() + status, paths = inspect_seed_metrics( + out_base=args.out_base, + seed=args.seed, + tag=args.tag, + tune_json=args.tune_json, + ) + if status == "fresh": + print(f"[fresh] seed={args.seed} metrics={paths[0]}") + return 0 + if status == "missing": + print(f"[missing] seed={args.seed} (will run)") + return 1 + detail = ", ".join(str(p) for p in paths) + print( + f"error: seed={args.seed} metrics are {status}: {detail}. " + "Delete stale runs or align tune-json/revision before re-running.", + file=sys.stderr, + ) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_teacher_local_pca_power_30ep.py index b53670b..53438aa 100644 --- a/experiments/run_teacher_local_pca_power_30ep.py +++ b/experiments/run_teacher_local_pca_power_30ep.py @@ -59,56 +59,91 @@ def parse_args() -> argparse.Namespace: return p.parse_args() -def load_tune_weights(path: Path) -> dict[str, float]: +def load_tune_weights( + path: Path, + *, + size_ref_default: float, + size_power_default: float, +) -> dict[str, float]: if not path.is_file(): raise FileNotFoundError(f"Tune JSON not found: {path}") payload = json.loads(path.read_text(encoding="utf-8")) params = payload["best_params"] - return { + weights = { "w_topo": float(params["w_topo"]), "w_aniso": float(params["w_aniso"]), "w_size": float(params["w_size"]), "lr": float(params["lr"]), } + has_ref = "size_ref" in params + has_power = "size_power" in params + if has_ref != has_power: + raise ValueError( + "Tune JSON best_params must define both size_ref and size_power " + f"together (or neither): {path}" + ) + if has_ref: + weights["size_ref"] = float(params["size_ref"]) + weights["size_power"] = float(params["size_power"]) + weights["size_from_tune"] = 1.0 + else: + weights["size_ref"] = float(size_ref_default) + weights["size_power"] = float(size_power_default) + weights["size_from_tune"] = 0.0 + return weights def infer_tag(tune_json: Path, explicit: str | None) -> str: - """Map tune objective to paper-eval tag; hard-fail on unknown objective.""" - if explicit: - return explicit + """Map tune objective to paper-eval tag; hard-fail on unknown / mismatched tag.""" payload = json.loads(tune_json.read_text(encoding="utf-8")) kind = classify_tune_objective( str(payload.get("objective", "")), objective_kind=payload.get("objective_kind"), ) - return TAG_WDIST if kind == "wdist" else TAG_MCC + expected = TAG_WDIST if kind == "wdist" else TAG_MCC + if explicit is not None and explicit != expected: + raise ValueError( + f"--tag {explicit!r} does not match tune objective kind {kind!r} " + f"(expected tag {expected!r})" + ) + return expected def main() -> int: args = parse_args() cfg = load_config(args.base_config, project_root=REPO_ROOT) paper_contract = assert_paper_no_cls_contract(cfg) - tune_json = args.tune_json + tune_json = args.tune_json.resolve() tag = infer_tag(tune_json, args.tag) out_base = args.out_base if out_base is None: if tag == TAG_WDIST: out_base = REPO_ROOT / "outputs/supervised/pwr30_wdist" else: - out_base = REPO_ROOT / "outputs/supervised/pwr30_mcc" + out_base = REPO_ROOT / "outputs/supervised/pwr30_mcc_maha" + out_base = out_base.resolve() out_base.mkdir(parents=True, exist_ok=True) + tune_weights = load_tune_weights( + tune_json, + size_ref_default=args.size_ref, + size_power_default=args.size_power, + ) + size_ref = tune_weights["size_ref"] + size_power = tune_weights["size_power"] + preflight_tune_production_run( base_config=args.base_config, tune_json=tune_json, project_root=REPO_ROOT, out_base=out_base, + preflight_filename=f"RUN_PREFLIGHT_s{args.seed}.json", config_overrides={ "loss": { "aniso_mode": "elongate", "size_mode": "power", - "size_ref": args.size_ref, - "size_power": args.size_power, + "size_ref": size_ref, + "size_power": size_power, }, "training": {"epochs": args.epochs}, "data": {"seed": args.seed}, @@ -126,19 +161,13 @@ def main() -> int: loss_overrides: dict = { "aniso_mode": "elongate", "size_mode": "power", - "size_ref": args.size_ref, - "size_power": args.size_power, + "size_ref": size_ref, + "size_power": size_power, + "w_topo": tune_weights["w_topo"], + "w_aniso": tune_weights["w_aniso"], + "w_size": tune_weights["w_size"], } - training_overrides: dict = {"epochs": args.epochs} - tune_weights = load_tune_weights(tune_json) - loss_overrides.update( - { - "w_topo": tune_weights["w_topo"], - "w_aniso": tune_weights["w_aniso"], - "w_size": tune_weights["w_size"], - } - ) - training_overrides["lr"] = tune_weights["lr"] + training_overrides: dict = {"epochs": args.epochs, "lr": tune_weights["lr"]} tune_source = str(tune_json) cfg = deep_update( @@ -164,9 +193,11 @@ def main() -> int: purpose = { "tag": tag, + "seed": args.seed, "size_mode": cfg["loss"]["size_mode"], "size_ref": cfg["loss"]["size_ref"], "size_power": cfg["loss"]["size_power"], + "size_from_tune": bool(tune_weights["size_from_tune"]), "weights": { "w_topo": cfg["loss"]["w_topo"], "w_aniso": cfg["loss"]["w_aniso"], @@ -187,10 +218,12 @@ def main() -> int: } cfg["_manifest_extras"] = { "tune_json": tune_source, - "tune_objective": purpose.get("tune_json") - and json.loads(Path(tune_source).read_text(encoding="utf-8")).get("objective"), + "tune_objective": json.loads(tune_json.read_text(encoding="utf-8")).get( + "objective" + ), "checkpoint_selection": cfg["training"]["selection"]["metric"], "paper_eval_dbscan_backend": args.dbscan_backend, + "paper_no_cls_contract": paper_contract, "loss_overrides": { "size_mode": purpose["size_mode"], "size_ref": purpose["size_ref"], @@ -203,17 +236,21 @@ def main() -> int: }, } - (out_base / "RUN_PLAN.json").write_text( + # Per-seed artifacts avoid parallel workers clobbering a shared RUN_PLAN.json. + plan_name = f"RUN_PLAN_s{args.seed}.json" + (out_base / plan_name).write_text( json.dumps(purpose, indent=2) + "\n", encoding="utf-8" ) - # Legacy alias - (out_base / "PURPOSE.json").write_text( + (out_base / f"PURPOSE_s{args.seed}.json").write_text( json.dumps(purpose, indent=2) + "\n", encoding="utf-8" ) result = train_main(config=cfg) run_dir = Path(result["run_dir"]) print(f"run_dir={run_dir}") + (run_dir / "RUN_PLAN.json").write_text( + json.dumps(purpose, indent=2) + "\n", encoding="utf-8" + ) if args.skip_eval: return 0 diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh index 707a294..617f036 100755 --- a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh +++ b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh @@ -51,16 +51,25 @@ BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_full120_teacher_local_pca}" mkdir -p "${LOG_ROOT}" _metrics_done() { + # Exit 0 = fresh skip; 1 = missing (run); other = stale/ambiguous hard-fail. local out_base="$1" local seed="$2" local tag="$3" - local f - for f in "${out_base}"/pwr_s"${seed}"_*/logs/paper_metrics_test_"${tag}".json; do - if [[ -f "${f}" ]]; then - return 0 - fi - done - return 1 + local tune_json="$4" + local rc=0 + uv run python experiments/power_30ep_freshness.py \ + --out-base "${out_base}" \ + --seed "${seed}" \ + --tag "${tag}" \ + --tune-json "${tune_json}" || rc=$? + if [[ "${rc}" -eq 0 ]]; then + return 0 + fi + if [[ "${rc}" -eq 1 ]]; then + return 1 + fi + echo "error: freshness check failed for seed=${seed} (rc=${rc})" >&2 + exit "${rc}" } _run_seed() { @@ -71,8 +80,8 @@ _run_seed() { local seed="$5" local log_file="${LOG_ROOT}/${label}_s${seed}.log" - if _metrics_done "${out_base}" "${seed}" "${tag}"; then - echo "[skip] ${label} seed=${seed} (paper_metrics_test already exists)" + if _metrics_done "${out_base}" "${seed}" "${tag}" "${tune_json}"; then + echo "[skip] ${label} seed=${seed} (fresh paper_metrics_test matches tune/revision)" return 0 fi @@ -102,8 +111,8 @@ _run_seed_batch() { local seed pid for seed in "${seeds[@]}"; do - if _metrics_done "${out_base}" "${seed}" "${tag}"; then - echo "[skip] ${label} seed=${seed} (paper_metrics_test already exists)" + if _metrics_done "${out_base}" "${seed}" "${tag}" "${tune_json}"; then + echo "[skip] ${label} seed=${seed} (fresh paper_metrics_test matches tune/revision)" continue fi _run_seed "${label}" "${tune_json}" "${out_base}" "${tag}" "${seed}" & @@ -130,8 +139,8 @@ _run_method() { local pending=() local seed for seed in "${SEEDS[@]}"; do - if _metrics_done "${out_base}" "${seed}" "${tag}"; then - echo "[skip] ${label} seed=${seed} (paper_metrics_test already exists)" + if _metrics_done "${out_base}" "${seed}" "${tag}" "${tune_json}"; then + echo "[skip] ${label} seed=${seed} (fresh paper_metrics_test matches tune/revision)" else pending+=("${seed}") fi @@ -177,6 +186,8 @@ echo "Aggregating..." AGG_ARGS=( --wdist-out "${WDIST_OUT}" --mcc-out "${MCC_OUT}" + --wdist-tune-json "${WDIST_JSON}" + --mcc-tune-json "${MCC_JSON}" --out-dir "${LOG_ROOT}" --seeds "${SEEDS[@]}" ) diff --git a/experiments/tune_elongate_mcc.py b/experiments/tune_elongate_mcc.py index 4d5a357..ba40afc 100644 --- a/experiments/tune_elongate_mcc.py +++ b/experiments/tune_elongate_mcc.py @@ -99,6 +99,8 @@ def build_trial_config( "topology_loss": { "distance_backend": backend, "prob_weighting": False, + # Mirror paper contract / STUDY_PREFLIGHT overrides onto every trial. + "homology_dimensions": [1], } }, "outputs": {"base_dir": out_base, "save_every": SAVE_EVERY}, @@ -327,6 +329,10 @@ def main() -> int: "tune_epochs": args.tune_epochs, "n_trials": args.n_trials, "max_complete_trials": args.max_complete_trials or args.n_trials, + "source_revision": git_revision(REPO_ROOT), + "study_name": args.study_name, + "storage": args.storage, + "sampler_seed": args.seed, **preflight["paper_no_cls_contract"], "search_space": { "w_aniso": list(W_ANISO_RANGE), diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py index 99e5fa2..f0abaa8 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_elongate_wdist.py @@ -85,7 +85,7 @@ def build_trial_config( tune_epochs: int, out_base: str, trial_number: int, - size_mode: str = "quadratic", + size_mode: str = "power", size_ref: float = 1.34, size_power: float = 1.5, ) -> dict[str, Any]: @@ -96,6 +96,8 @@ def build_trial_config( "w_aniso": float(w_aniso), "w_size": float(w_size), "w_topo": float(w_topo), + # Mirror paper contract / STUDY_PREFLIGHT overrides onto every trial. + "aniso_mode": "elongate", "size_mode": size_mode, "size_ref": float(size_ref), "size_power": float(size_power), @@ -110,6 +112,7 @@ def build_trial_config( "topology_loss": { "distance_backend": backend, "prob_weighting": False, + "homology_dimensions": [1], } }, "outputs": {"base_dir": out_base, "save_every": SAVE_EVERY}, @@ -214,7 +217,7 @@ def parse_args() -> argparse.Namespace: p.add_argument("--out-base", type=str, default="outputs/tune/pwr_wdist") p.add_argument( "--size-mode", - default="quadratic", + default="power", choices=["quadratic", "power", "softplus", "barrier"], ) p.add_argument("--size-ref", type=float, default=1.34) @@ -332,6 +335,10 @@ def main() -> int: "n_trials": args.n_trials, "max_complete_trials": args.max_complete_trials or args.n_trials, "n_startup_trials": args.n_startup_trials, + "source_revision": git_revision(REPO_ROOT), + "study_name": args.study_name, + "storage": args.storage, + "sampler_seed": args.seed, **preflight["paper_no_cls_contract"], "search_space": { "w_aniso": list(w_aniso_range), diff --git a/tda_ml/main.py b/tda_ml/main.py index da29b86..84b927d 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -75,7 +75,24 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): logger.info("Project structure created at: %s", run_dir) data_cfg = config["data"] - seed = data_cfg.get("seed", 42) + if "seed" not in data_cfg: + raise ValueError("data.seed must be set explicitly; refusing silent seed=42") + seed = int(data_cfg["seed"]) + topo_cfg = (config.get("model") or {}).get("topology_loss") or {} + if "distance_backend" not in topo_cfg: + raise ValueError( + "model.topology_loss.distance_backend must be set before manifest write; " + "refusing silent mahalanobis default" + ) + selection = (config.get("training", {}).get("selection") or {}) + if "metric" not in selection: + raise ValueError( + "training.selection.metric must be set explicitly; " + "refusing silent val_topo default in run manifest" + ) + init_checkpoint = config.get("init_checkpoint") + if init_checkpoint and not os.path.exists(init_checkpoint): + raise FileNotFoundError(f"Initial checkpoint not found: {init_checkpoint}") manifest = { "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), "source_revision": git_revision(), @@ -85,12 +102,8 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): "command_entry": "tda_ml.main", "seed": seed, "epochs_planned": config["training"]["epochs"], - "distance_backend": config.get("model", {}) - .get("topology_loss", {}) - .get("distance_backend", "mahalanobis"), - "checkpoint_selection": (config.get("training", {}).get("selection") or {}).get( - "metric", "val_topo" - ), + "distance_backend": str(topo_cfg["distance_backend"]).lower().strip(), + "checkpoint_selection": selection["metric"], "early_abort": config.get("training", {}).get("early_abort"), "run_dir": run_dir, "run_status": "pending", @@ -128,74 +141,91 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): else: preflight_training_config(config, project_root=default_project_root()) data_cfg = config["data"] - seed = data_cfg.get("seed", 42) + if "seed" not in data_cfg: + raise ValueError("data.seed must be set explicitly; refusing silent seed=42") + seed = int(data_cfg["seed"]) manifest = {"config_id": config_id, "seed": seed} config.setdefault("_manifest", manifest) - data_cfg = config["data"] - seed = data_cfg.get("seed", 42) - deterministic_algorithms = bool(config.get("reproducibility", {}).get("deterministic_algorithms", False)) - set_global_seed(seed, deterministic_algorithms=deterministic_algorithms) - logger.info( - "Global seed initialized: seed=%s, deterministic_algorithms=%s", - seed, - deterministic_algorithms, - ) - loader_settings = resolve_dataloader_settings(config, device) - configure_torch_runtime(config, device) - logger.info( - "DataLoader settings: workers=%s, pin_memory=%s, persistent_workers=%s, prefetch_factor=%s", - loader_settings.num_workers, - loader_settings.pin_memory, - loader_settings.persistent_workers, - loader_settings.prefetch_factor, - ) + def _mark_failed(exc: BaseException) -> None: + if manifest_path is None: + return + manifest["final_status"] = RUN_STATUS_FAILED + manifest["run_status"] = RUN_STATUS_FAILED + manifest["error"] = str(exc) + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=True, indent=2) - data_loader, val_loader, test_loader = build_dataloaders(config, seed, loader_settings) + try: + data_cfg = config["data"] + seed = int(data_cfg["seed"]) + deterministic_algorithms = bool( + config.get("reproducibility", {}).get("deterministic_algorithms", False) + ) + set_global_seed(seed, deterministic_algorithms=deterministic_algorithms) + logger.info( + "Global seed initialized: seed=%s, deterministic_algorithms=%s", + seed, + deterministic_algorithms, + ) + loader_settings = resolve_dataloader_settings(config, device) + configure_torch_runtime(config, device) + logger.info( + "DataLoader settings: workers=%s, pin_memory=%s, persistent_workers=%s, prefetch_factor=%s", + loader_settings.num_workers, + loader_settings.pin_memory, + loader_settings.persistent_workers, + loader_settings.prefetch_factor, + ) - model = AnisotropicOutlierClassifier(**model_kwargs_from_config(config)) - model.to(device) + data_loader, val_loader, test_loader = build_dataloaders( + config, seed, loader_settings + ) - trainer = Trainer(model, config, device=device) + model = AnisotropicOutlierClassifier(**model_kwargs_from_config(config)) + model.to(device) - init_checkpoint = config.get('init_checkpoint') - if init_checkpoint: - if not os.path.exists(init_checkpoint): - raise FileNotFoundError(f"Initial checkpoint not found: {init_checkpoint}") - logger.info("Loading initial weights from %s", init_checkpoint) - checkpoint = load_torch_checkpoint(init_checkpoint, map_location=device) - model.load_state_dict(extract_model_state_dict(checkpoint), strict=False) - - log_dir = config["outputs"]["log_dir"] - metrics_path = os.path.join(log_dir, "metrics.csv") - runtime_profile = build_runtime_profile( - config=config, - device=device, - num_workers=loader_settings.num_workers, - pin_memory=loader_settings.pin_memory, - persistent_workers=loader_settings.persistent_workers, - prefetch_factor=loader_settings.prefetch_factor, - use_amp_effective=trainer.use_amp, - amp_dtype_effective=str(trainer.amp_dtype).replace("torch.", ""), - ) - runtime_profile_path = os.path.join(log_dir, "runtime_profile.json") - with open(runtime_profile_path, "w", encoding="utf-8") as f: - json.dump(runtime_profile, f, ensure_ascii=True, indent=2) - logger.info("Runtime profile saved: %s", runtime_profile_path) - - if manifest_path is not None: - impl = config.get("_manifest", {}).get("distance_backend_impl") - if impl is not None: - manifest["distance_backend_impl"] = impl - manifest["final_status"] = "running" - manifest["run_status"] = RUN_STATUS_RUNNING - if config.get("_manifest", {}).get("fallbacks"): - manifest["fallbacks"] = config["_manifest"]["fallbacks"] - manifest["fallback_status"] = config["_manifest"].get( - "fallback_status", "recorded" - ) - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f, ensure_ascii=True, indent=2) + trainer = Trainer(model, config, device=device) + + init_checkpoint = config.get("init_checkpoint") + if init_checkpoint: + logger.info("Loading initial weights from %s", init_checkpoint) + checkpoint = load_torch_checkpoint(init_checkpoint, map_location=device) + model.load_state_dict(extract_model_state_dict(checkpoint), strict=False) + + log_dir = config["outputs"]["log_dir"] + metrics_path = os.path.join(log_dir, "metrics.csv") + runtime_profile = build_runtime_profile( + config=config, + device=device, + num_workers=loader_settings.num_workers, + pin_memory=loader_settings.pin_memory, + persistent_workers=loader_settings.persistent_workers, + prefetch_factor=loader_settings.prefetch_factor, + use_amp_effective=trainer.use_amp, + amp_dtype_effective=str(trainer.amp_dtype).replace("torch.", ""), + ) + runtime_profile_path = os.path.join(log_dir, "runtime_profile.json") + with open(runtime_profile_path, "w", encoding="utf-8") as f: + json.dump(runtime_profile, f, ensure_ascii=True, indent=2) + logger.info("Runtime profile saved: %s", runtime_profile_path) + + if manifest_path is not None: + impl = config.get("_manifest", {}).get("distance_backend_impl") + if impl is not None: + manifest["distance_backend_impl"] = impl + manifest["final_status"] = "running" + manifest["run_status"] = RUN_STATUS_RUNNING + if config.get("_manifest", {}).get("fallbacks"): + manifest["fallbacks"] = config["_manifest"]["fallbacks"] + manifest["fallback_status"] = config["_manifest"].get( + "fallback_status", "recorded" + ) + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=True, indent=2) + except Exception as exc: + _mark_failed(exc) + raise with open(metrics_path, "w", newline="") as f: writer = csv.writer(f) diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py index 23d2802..2b72103 100644 --- a/tda_ml/preflight.py +++ b/tda_ml/preflight.py @@ -7,6 +7,7 @@ from typing import Any, Literal from tda_ml.config import deep_update, load_config +from tda_ml.persistence_dimensions import normalize_homology_dimensions from tda_ml.reproducibility import ( RUN_STATUS_NOT_RUN, assert_ellphi_differentiable_available, @@ -36,8 +37,13 @@ "aniso_mode": "elongate", "distance_backend": "ellphi", "size_mode": "power", + "w_class": 0.0, + "teacher_local_pca_k": 10, + "teacher_local_pca_normalize_axes": True, } +_KNOWN_TEACHER_MODES = frozenset({"euclidean", "local_pca"}) + def assert_paper_no_cls_contract(config: dict[str, Any]) -> dict[str, Any]: """Require the declared H1-only paper method; never infer missing fields.""" @@ -84,6 +90,27 @@ def assert_paper_no_cls_contract(config: dict[str, Any]) -> dict[str, Any]: ) actual["size_mode"] = str(loss["size_mode"]).strip().lower() + if "w_class" not in loss: + raise ValueError( + "Paper no_cls config must explicitly define loss.w_class=0.0" + ) + actual["w_class"] = float(loss["w_class"]) + + if "teacher_local_pca_k" not in loss: + raise ValueError( + "Paper no_cls config must explicitly define loss.teacher_local_pca_k=10" + ) + actual["teacher_local_pca_k"] = int(loss["teacher_local_pca_k"]) + + if "teacher_local_pca_normalize_axes" not in loss: + raise ValueError( + "Paper no_cls config must explicitly define " + "loss.teacher_local_pca_normalize_axes=true" + ) + actual["teacher_local_pca_normalize_axes"] = bool( + loss["teacher_local_pca_normalize_axes"] + ) + if actual != PAPER_NO_CLS_CONTRACT: raise ValueError( "Paper no_cls contract mismatch: " @@ -166,13 +193,21 @@ def preflight_training_config( backend = str(topo["distance_backend"]).lower().strip() if backend not in ("mahalanobis", "ellphi"): raise ValueError(f"Unknown distance_backend {backend!r}") - homology_dimensions = list(topo["homology_dimensions"]) - if not homology_dimensions: - raise ValueError("model.topology_loss.homology_dimensions must not be empty") + homology_dimensions = list(normalize_homology_dimensions(topo["homology_dimensions"])) prob_weighting = bool(topo["prob_weighting"]) teacher_mode = str( loss.get("teacher_mode", training.get("teacher_mode")) ).strip().lower() + if teacher_mode not in _KNOWN_TEACHER_MODES: + raise ValueError( + f"Unknown teacher_mode {teacher_mode!r}; " + f"supported={sorted(_KNOWN_TEACHER_MODES)}" + ) + for key in ("aniso_mode", "size_mode"): + if key not in loss and key not in training: + raise ValueError( + f"loss.{key} must be set explicitly; refusing silent Trainer defaults" + ) ellphi_diff = bool(topo.get("ellphi_differentiable", True)) if backend == "ellphi": _require_import("ellphi", lambda: __import__("ellphi")) @@ -233,6 +268,12 @@ def preflight_tune_json( for key in ("w_topo", "w_aniso", "w_size", "lr"): if key not in params: raise ValueError(f"Tune JSON best_params missing {key!r}: {tune_json}") + size_keys = ("size_ref" in params, "size_power" in params) + if any(size_keys) and not all(size_keys): + raise ValueError( + "Tune JSON best_params must define both size_ref and size_power " + f"together (or neither): {tune_json}" + ) kind = classify_tune_objective( str(payload["objective"]), objective_kind=payload.get("objective_kind"), @@ -268,21 +309,41 @@ def preflight_tune_study( config_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: root = Path(project_root) - cfg = load_config(base_config, project_root=root) - if config_overrides: - cfg = deep_update(cfg, config_overrides) - contract = assert_paper_no_cls_contract(cfg) - dbscan_grid_from_config(cfg) - preview = preflight_training_config(cfg, project_root=root) out = Path(out_base) if not out.is_absolute(): out = root / out out.mkdir(parents=True, exist_ok=True) + try: + cfg = load_config(base_config, project_root=root) + if config_overrides: + cfg = deep_update(cfg, config_overrides) + contract = assert_paper_no_cls_contract(cfg) + dbscan_grid_from_config(cfg) + preview = preflight_training_config(cfg, project_root=root) + except Exception as exc: + write_not_run_manifest( + out / "logs_not_run", + reason=str(exc), + entry=f"preflight_tune_study:{study_objective}", + config={"meta": {"config_id": base_config}, "data": {}}, + ) + write_json( + out / "STUDY_PREFLIGHT.json", + { + "run_status": RUN_STATUS_NOT_RUN, + "preflight_status": "failed", + "preflight_error": str(exc), + "base_config": base_config, + "study_objective": study_objective, + }, + ) + raise preview["out_base"] = str(out) preview["base_config"] = base_config preview["study_objective"] = study_objective preview["paper_no_cls_contract"] = contract preview["config_overrides"] = config_overrides or {} + preview["run_status"] = "pending" write_json(out / "STUDY_PREFLIGHT.json", preview) return preview @@ -326,43 +387,71 @@ def preflight_tune_production_run( project_root: Path | str, out_base: Path | str, config_overrides: dict[str, Any] | None = None, + preflight_filename: str | None = None, ) -> dict[str, Any]: root = Path(project_root) - cfg = load_config(base_config, project_root=root) - if config_overrides: - cfg = deep_update(cfg, config_overrides) - contract = assert_paper_no_cls_contract(cfg) - tune_payload = preflight_tune_json( - tune_json, - expected_contract=contract, - ) - tune_params = tune_payload["best_params"] - cfg = deep_update( - cfg, - { - "loss": { - "w_topo": float(tune_params["w_topo"]), - "w_aniso": float(tune_params["w_aniso"]), - "w_size": float(tune_params["w_size"]), + out = Path(out_base) + if not out.is_absolute(): + out = root / out + out.mkdir(parents=True, exist_ok=True) + artifact_name = preflight_filename or "RUN_PREFLIGHT.json" + try: + cfg = load_config(base_config, project_root=root) + if config_overrides: + cfg = deep_update(cfg, config_overrides) + contract = assert_paper_no_cls_contract(cfg) + tune_payload = preflight_tune_json( + tune_json, + expected_contract=contract, + ) + tune_params = tune_payload["best_params"] + loss_apply: dict[str, Any] = { + "w_topo": float(tune_params["w_topo"]), + "w_aniso": float(tune_params["w_aniso"]), + "w_size": float(tune_params["w_size"]), + } + if "size_ref" in tune_params: + loss_apply["size_ref"] = float(tune_params["size_ref"]) + loss_apply["size_power"] = float(tune_params["size_power"]) + cfg = deep_update( + cfg, + { + "loss": loss_apply, + "training": {"lr": float(tune_params["lr"])}, }, - "training": {"lr": float(tune_params["lr"])}, - }, - ) - preview = preflight_training_config(cfg, project_root=root) - preview["tune_json"] = str(tune_json.resolve()) + ) + preview = preflight_training_config(cfg, project_root=root) + except Exception as exc: + write_json( + out / artifact_name, + { + "run_status": RUN_STATUS_NOT_RUN, + "preflight_status": "failed", + "preflight_error": str(exc), + "tune_json": str(Path(tune_json).resolve()) + if Path(tune_json).is_file() + else str(tune_json), + "base_config": base_config, + }, + ) + raise + preview["tune_json"] = str(Path(tune_json).resolve()) preview["tune_objective"] = tune_payload.get("objective") preview["tune_objective_kind"] = tune_payload["_objective_kind"] preview["tune_best_params"] = tune_params + preview["tune_source_revision"] = tune_payload.get("source_revision") + preview["tune_study_name"] = tune_payload.get("study_name") preview["paper_no_cls_contract"] = contract preview["config_overrides"] = config_overrides or {} - preview["checkpoint_selection"] = ( - (cfg.get("training") or {}).get("selection") or {} - ).get("metric", "val_topo") - out = Path(out_base) - if not out.is_absolute(): - out = root / out - out.mkdir(parents=True, exist_ok=True) - write_json(out / "RUN_PREFLIGHT.json", preview) + selection = (cfg.get("training") or {}).get("selection") or {} + if "metric" not in selection: + raise ValueError( + "training.selection.metric must be set explicitly; " + "refusing silent val_topo default in production preflight" + ) + preview["checkpoint_selection"] = selection["metric"] + preview["applied_size_from_tune"] = "size_ref" in tune_params + write_json(out / artifact_name, preview) return preview diff --git a/tda_ml/run_paths.py b/tda_ml/run_paths.py index 2e0b9cb..283403a 100644 --- a/tda_ml/run_paths.py +++ b/tda_ml/run_paths.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -_TIMESTAMP_FMT = "%m%d_%H%M" +_TIMESTAMP_FMT = "%m%d_%H%M%S" OUTPUT_CATEGORIES = frozenset({"supervised", "supervised_no_cls", "tune"}) _BACKEND_SHORT = { @@ -81,12 +81,20 @@ def build_run_dir( config: dict[str, Any], when: datetime.datetime | None = None, ) -> tuple[str, str, str]: - """Return ``(run_dir, run_slug, run_stamp)``.""" + """Return ``(run_dir, run_slug, run_stamp)``. + + Hard-fails if the resolved path already exists so two runs never merge + checkpoints/metrics into one directory. + """ base_dir = config.get("outputs", {}).get("base_dir", "outputs") slug = resolve_run_slug(config) stamp = make_run_stamp(when) - run_dir = str(Path(base_dir) / f"{slug}_{stamp}") - return run_dir, slug, stamp + run_path = Path(base_dir) / f"{slug}_{stamp}" + if run_path.exists(): + raise FileExistsError( + f"run_dir already exists: {run_path}; refusing to merge separate runs" + ) + return str(run_path), slug, stamp def experiment_base(category: str, slug: str, when: datetime.datetime | None = None) -> str: diff --git a/tda_ml/supervised_diagnostics.py b/tda_ml/supervised_diagnostics.py index 42dc6d7..d37053b 100644 --- a/tda_ml/supervised_diagnostics.py +++ b/tda_ml/supervised_diagnostics.py @@ -17,9 +17,10 @@ def git_revision(repo_root: Path | None = None) -> str: + """Return HEAD SHA, with ``-dirty`` when the worktree has local changes.""" root = repo_root or Path(__file__).resolve().parents[1] try: - return ( + head = ( subprocess.check_output( ["git", "rev-parse", "HEAD"], cwd=root, @@ -28,6 +29,15 @@ def git_revision(repo_root: Path | None = None) -> str: ) .strip() ) + porcelain = subprocess.check_output( + ["git", "status", "--porcelain"], + cwd=root, + stderr=subprocess.DEVNULL, + text=True, + ) + if porcelain.strip(): + return f"{head}-dirty" + return head except (subprocess.CalledProcessError, FileNotFoundError): return "unknown" diff --git a/tda_ml/topo_wdist.py b/tda_ml/topo_wdist.py index 285508a..87ceb83 100644 --- a/tda_ml/topo_wdist.py +++ b/tda_ml/topo_wdist.py @@ -178,6 +178,12 @@ def compute_topo_wdist( need_clean_scales=(opts.scale_mode == "median"), ) + if opts.prob_weighting: + raise ValueError( + "compute_topo_wdist does not apply outlier-probability weighting; " + "set model.topology_loss.prob_weighting=false (refusing silent probs=None)" + ) + pts_i, par_i = _subsample_cloud(pts, par, opts.max_points) d_batch = compute_distance_matrix_batch( pts_i.unsqueeze(0), diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 43a301e..23fba91 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -107,27 +107,43 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: self.lambda_major = training_cfg.get("lambda_major", size_default) self.lambda_minor = training_cfg.get("lambda_minor", size_default) - self.aniso_mode = loss_cfg.get("aniso_mode", training_cfg.get("aniso_mode", "linear")) + aniso_raw = loss_cfg.get("aniso_mode", training_cfg.get("aniso_mode")) + if aniso_raw is None: + raise ValueError( + "loss.aniso_mode must be set explicitly; refusing silent linear default" + ) + self.aniso_mode = str(aniso_raw).strip().lower() self.aniso_barrier_threshold = float( loss_cfg.get( "aniso_barrier_threshold", training_cfg.get("barrier_threshold", 6.0), ) ) - self.size_mode = str( - loss_cfg.get("size_mode", training_cfg.get("size_mode", "quadratic")) - ).strip().lower() + size_mode_raw = loss_cfg.get("size_mode", training_cfg.get("size_mode")) + if size_mode_raw is None: + raise ValueError( + "loss.size_mode must be set explicitly; refusing silent quadratic default" + ) + self.size_mode = str(size_mode_raw).strip().lower() self.size_barrier_radius = float( loss_cfg.get( "size_barrier_radius", training_cfg.get("size_barrier_radius", 1.5), ) ) + if "size_ref" not in loss_cfg and "size_ref" not in training_cfg: + raise ValueError( + "loss.size_ref must be set explicitly; refusing silent 1.34 default" + ) + if "size_power" not in loss_cfg and "size_power" not in training_cfg: + raise ValueError( + "loss.size_power must be set explicitly; refusing silent 1.5 default" + ) self.size_ref = float( - loss_cfg.get("size_ref", training_cfg.get("size_ref", 1.34)) + loss_cfg.get("size_ref", training_cfg.get("size_ref")) ) self.size_power = float( - loss_cfg.get("size_power", training_cfg.get("size_power", 1.5)) + loss_cfg.get("size_power", training_cfg.get("size_power")) ) self.size_softplus_beta = float( loss_cfg.get( diff --git a/tests/test_paper_production_gates.py b/tests/test_paper_production_gates.py new file mode 100644 index 0000000..2771d73 --- /dev/null +++ b/tests/test_paper_production_gates.py @@ -0,0 +1,178 @@ +"""Regression tests for paper multiseed / aggregate / production helpers.""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "experiments")) + +from aggregate_power_30ep_multiseed import discover_seed_metrics # noqa: E402 +from power_30ep_freshness import inspect_seed_metrics # noqa: E402 +from run_teacher_local_pca_power_30ep import ( # noqa: E402 + TAG_MCC, + TAG_WDIST, + infer_tag, + load_tune_weights, +) +from tda_ml.preflight import PAPER_NO_CLS_CONTRACT, preflight_training_config # noqa: E402 + + +class TestAggregateDiscover(unittest.TestCase): + def test_duplicate_seed_hard_fails(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for i, mcc in enumerate([0.1, 0.9]): + logs = root / f"pwr_s42_run{i}" / "logs" + logs.mkdir(parents=True) + (logs / "run_manifest.json").write_text( + json.dumps({"seed": 42, "tune_json": "/x.json"}), + encoding="utf-8", + ) + (logs / "paper_metrics_test_foo.json").write_text( + json.dumps( + {"mcc": mcc, "gmean": 0.5, "wdist": 1.0, "recall": 0.5} + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "Duplicate metrics"): + discover_seed_metrics(root, "**/paper_metrics_test_*.json") + + def test_missing_manifest_seed_hard_fails(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + logs = root / "pwr_s42_x" / "logs" + logs.mkdir(parents=True) + (logs / "paper_metrics_test_foo.json").write_text( + json.dumps({"mcc": 0.1, "gmean": 0.5, "wdist": 1.0}), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "Missing run_manifest"): + discover_seed_metrics(root, "**/paper_metrics_test_*.json") + + +class TestLoadTuneWeights(unittest.TestCase): + def test_size_params_propagated_when_present(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "best.json" + path.write_text( + json.dumps( + { + "best_params": { + "w_topo": 1.0, + "w_aniso": 2.0, + "w_size": 3.0, + "lr": 1e-3, + "size_ref": 9.9, + "size_power": 0.25, + } + } + ), + encoding="utf-8", + ) + weights = load_tune_weights( + path, size_ref_default=1.34, size_power_default=1.5 + ) + self.assertEqual(weights["size_ref"], 9.9) + self.assertEqual(weights["size_power"], 0.25) + self.assertEqual(weights["size_from_tune"], 1.0) + + +class TestInferTag(unittest.TestCase): + def test_explicit_tag_must_match_objective(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "w.json" + path.write_text( + json.dumps( + { + "objective": "val_topo_wdist_min_at_val_topo_best_ckpt", + "best_params": { + "w_topo": 1, + "w_aniso": 1, + "w_size": 1, + "lr": 1e-3, + }, + } + ), + encoding="utf-8", + ) + self.assertEqual(infer_tag(path, None), TAG_WDIST) + with self.assertRaisesRegex(ValueError, "does not match"): + infer_tag(path, TAG_MCC) + + +class TestPreflightHomology(unittest.TestCase): + def test_invalid_homology_rejected(self): + from tda_ml.config import deep_update, load_config + + cfg = load_config( + "elongate_n100_no_cls_full120_teacher_local_pca", + project_root=REPO, + ) + bad = deep_update( + cfg, + { + "model": {"topology_loss": {"homology_dimensions": [2, 2]}}, + "loss": {"teacher_mode": "nonsense"}, + }, + ) + with self.assertRaises(ValueError): + preflight_training_config(bad, project_root=REPO) + + +class TestFreshness(unittest.TestCase): + def test_missing_is_missing(self): + with tempfile.TemporaryDirectory() as tmp: + status, paths = inspect_seed_metrics( + out_base=Path(tmp), + seed=42, + tag="power_wdist_valtopo_paper_eval", + tune_json=Path(tmp) / "missing.json", + expected_revision="deadbeef", + ) + self.assertEqual(status, "missing") + self.assertEqual(paths, []) + + def test_stale_revision_detected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + tune = root / "best.json" + tune.write_text("{}", encoding="utf-8") + logs = root / "pwr_s42_x" / "logs" + logs.mkdir(parents=True) + (logs / "run_manifest.json").write_text( + json.dumps( + { + "seed": 42, + "tune_json": str(tune.resolve()), + "source_revision": "oldrev", + } + ), + encoding="utf-8", + ) + ( + logs / "paper_metrics_test_power_wdist_valtopo_paper_eval.json" + ).write_text("{}", encoding="utf-8") + status, _ = inspect_seed_metrics( + out_base=root, + seed=42, + tag="power_wdist_valtopo_paper_eval", + tune_json=tune, + expected_revision="newrev", + ) + self.assertEqual(status, "stale") + + +class TestPaperContractExpanded(unittest.TestCase): + def test_contract_includes_w_class_and_pca(self): + self.assertEqual(PAPER_NO_CLS_CONTRACT["w_class"], 0.0) + self.assertEqual(PAPER_NO_CLS_CONTRACT["teacher_local_pca_k"], 10) + self.assertTrue(PAPER_NO_CLS_CONTRACT["teacher_local_pca_normalize_axes"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reproducibility_strict.py b/tests/test_reproducibility_strict.py index e684935..509268e 100644 --- a/tests/test_reproducibility_strict.py +++ b/tests/test_reproducibility_strict.py @@ -192,6 +192,9 @@ def _valid_config() -> dict: "teacher_mode": "local_pca", "aniso_mode": "elongate", "size_mode": "power", + "w_class": 0.0, + "teacher_local_pca_k": 10, + "teacher_local_pca_normalize_axes": True, }, } diff --git a/tests/test_run_paths.py b/tests/test_run_paths.py index cb91c20..4181430 100644 --- a/tests/test_run_paths.py +++ b/tests/test_run_paths.py @@ -30,15 +30,31 @@ def test_resolve_run_slug_prefers_explicit(self) -> None: self.assertEqual(resolve_run_slug(cfg), "custom") def test_build_run_dir(self) -> None: - when = datetime.datetime(2026, 7, 9, 13, 5) + when = datetime.datetime(2026, 7, 9, 13, 5, 0) cfg = { "meta": {"config_id": "tune_mcc_t010", "run_slug": "t010"}, "outputs": {"base_dir": "outputs/tune/0709_pwr_mcc"}, } run_dir, slug, stamp = build_run_dir(cfg, when=when) self.assertEqual(slug, "t010") - self.assertEqual(stamp, "0709_1305") - self.assertEqual(run_dir, "outputs/tune/0709_pwr_mcc/t010_0709_1305") + self.assertEqual(stamp, "0709_130500") + self.assertEqual(run_dir, "outputs/tune/0709_pwr_mcc/t010_0709_130500") + + def test_build_run_dir_refuses_existing(self) -> None: + import tempfile + from pathlib import Path + + when = datetime.datetime(2026, 7, 9, 13, 5, 1) + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) / "out" + existing = base / "t010_0709_130501" + existing.mkdir(parents=True) + cfg = { + "meta": {"run_slug": "t010"}, + "outputs": {"base_dir": str(base)}, + } + with self.assertRaises(FileExistsError): + build_run_dir(cfg, when=when) def test_experiment_base(self) -> None: when = datetime.datetime(2026, 7, 9, 13, 5) diff --git a/tests/test_trainer.py b/tests/test_trainer.py index daea973..a65fd58 100644 --- a/tests/test_trainer.py +++ b/tests/test_trainer.py @@ -45,6 +45,10 @@ def setUp(self): 'w_aniso': 0.01, 'w_size': 0.1, 'teacher_mode': 'euclidean', + 'aniso_mode': 'linear', + 'size_mode': 'quadratic', + 'size_ref': 1.34, + 'size_power': 1.5, }, 'model': { 'topology_loss': { @@ -100,6 +104,10 @@ def test_w_class_zero_skips_classification_gradient(self): "w_aniso": 0.01, "w_size": 0.1, "teacher_mode": "euclidean", + "aniso_mode": "linear", + "size_mode": "quadratic", + "size_ref": 1.34, + "size_power": 1.5, } self.config["model"]["topology_loss"]["prob_weighting"] = False trainer = Trainer(self.model, self.config, self.device) diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index 10eb6e6..e13b9f9 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -5,11 +5,13 @@ import sys import unittest from pathlib import Path +from unittest import mock REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO / "experiments")) -from tune_elongate_wdist import build_trial_config # noqa: E402 +from tune_elongate_mcc import build_trial_config as build_mcc_trial # noqa: E402 +from tune_elongate_wdist import build_trial_config as build_wdist_trial # noqa: E402 class TestTuneTrialConfig(unittest.TestCase): @@ -22,7 +24,7 @@ def test_baseline_declares_topology_contract(self): self.assertEqual(cfg["loss"]["teacher_mode"], "euclidean") def test_canonical_power_tune_base(self): - cfg = build_trial_config( + cfg = build_wdist_trial( "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", w_aniso=0.1, w_size=0.2, @@ -43,7 +45,7 @@ def test_canonical_power_tune_base(self): self.assertFalse(cfg["model"]["topology_loss"]["prob_weighting"]) def test_power_tune_preserves_h1_hard_fail_stack(self): - cfg = build_trial_config( + cfg = build_wdist_trial( "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", w_aniso=0.1, w_size=0.2, @@ -60,6 +62,55 @@ def test_power_tune_preserves_h1_hard_fail_stack(self): self.assertEqual(cfg["loss"]["size_mode"], "power") self.assertEqual(cfg["model"]["topology_loss"]["distance_backend"], "ellphi") + def test_wdist_builder_overrides_divergent_yaml_homology_and_aniso(self): + from copy import deepcopy + + from tda_ml.config import load_config + + divergent = load_config( + "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", + project_root=REPO, + ) + divergent["model"]["topology_loss"]["homology_dimensions"] = [0, 1] + divergent["loss"]["aniso_mode"] = "linear" + + with mock.patch( + "tune_elongate_wdist.load_config", + side_effect=lambda *a, **k: deepcopy(divergent), + ): + cfg = build_wdist_trial( + "ignored", + w_aniso=0.1, + w_size=0.2, + w_topo=0.3, + lr=1e-4, + backend="ellphi", + tune_epochs=5, + out_base="outputs/x", + trial_number=1, + size_mode="power", + ) + self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) + self.assertEqual(cfg["loss"]["aniso_mode"], "elongate") + + def test_mcc_builder_forces_homology_h1(self): + cfg = build_mcc_trial( + "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", + w_aniso=0.1, + w_size=0.2, + w_topo=0.3, + lr=1e-4, + backend="ellphi", + tune_epochs=5, + out_base="outputs/x", + trial_number=2, + size_mode="power", + size_ref=1.34, + size_power=1.5, + ) + self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) + self.assertEqual(cfg["loss"]["aniso_mode"], "elongate") + if __name__ == "__main__": unittest.main() From 8c129503cd53b551bbfa5baed515cae0b084a53e Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 12:44:34 +0900 Subject: [PATCH 22/44] =?UTF-8?q?feat:=20=E6=8E=A5=E7=B7=9A=E6=96=B9?= =?UTF-8?q?=E5=90=91=E5=A4=96=E3=82=8C=E5=80=A4=E3=81=A7=E3=81=AE=E3=83=81?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=83=8B=E3=83=B3=E3=82=B0=E7=B5=8C=E8=B7=AF?= =?UTF-8?q?=E3=82=92=E9=85=8D=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MNIST の局所PCA接線方向に外れ値を置く異方性ノイズで再チューニング できるよう、学習・評価・manifest を貫通する配線を追加。 - run_setup / evaluate_paper_protocol: data.outlier_mode と tangent_* を NoisyMNISTDataset へ伝播。欠落は hard-fail(暗黙 uniform 禁止)。 - main: run_manifest に data_outliers(外れ値ジオメトリ)を記録。 - base.yaml: outlier_mode / noise_std を明示(既定 uniform / 0.01)。 - tangent_outliers: 宣言定数 MIN_TANGENT_OUTLIER_SEPARATION による 最小分離ガードを追加。同一元点+同方向から生成される外れ値が ほぼ完全一致すると ellphi の中心微分が未定義になるため、近接候補 は棄却・再試行し、達成不能なら hard-fail(退化を黙って潰さない)。 - 接線 tune config は noise_std=0.01(一様ベースラインと同一の インライアjitter=外れ値分布のみが差の公正比較、かつ量子化由来の 重複点を回避)。 - multi-fidelity ドライバ/ランチャ(接線版)を追加。 --- configs/base.yaml | 3 + ...no_cls_tune_local_pca_ellphi_power_h1.yaml | 65 ++++++++++ ...une_local_pca_ellphi_power_h1_tangent.yaml | 76 ++++++++++++ experiments/enqueue_top_optuna_trials.py | 109 +++++++++++++++++ experiments/evaluate_paper_protocol.py | 29 ++++- experiments/launch_h1_wdist_multifidelity.sh | 20 ++++ .../launch_h1_wdist_tangent_multifidelity.sh | 24 ++++ experiments/run_h1_wdist_multifidelity.sh | 111 ++++++++++++++++++ tda_ml/data_loader.py | 48 ++++++-- tda_ml/main.py | 27 +++++ tda_ml/numerical_eps.py | 6 + tda_ml/run_setup.py | 31 ++++- tda_ml/tangent_outliers.py | 89 ++++++++++++++ tests/test_tangent_outliers.py | 72 ++++++++++++ 14 files changed, 701 insertions(+), 9 deletions(-) create mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1.yaml create mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml create mode 100755 experiments/enqueue_top_optuna_trials.py create mode 100755 experiments/launch_h1_wdist_multifidelity.sh create mode 100755 experiments/launch_h1_wdist_tangent_multifidelity.sh create mode 100755 experiments/run_h1_wdist_multifidelity.sh create mode 100644 tda_ml/tangent_outliers.py create mode 100644 tests/test_tangent_outliers.py diff --git a/configs/base.yaml b/configs/base.yaml index 28fb4c3..0ba0d68 100644 --- a/configs/base.yaml +++ b/configs/base.yaml @@ -48,6 +48,9 @@ data: max_points: 100 batch_size: 32 num_outliers: 20 + # Explicit outlier/noise geometry; run_setup hard-fails if these are absent. + outlier_mode: uniform # uniform | local_pca_tangent + noise_std: 0.01 num_workers: 2 pin_memory: true seed: 42 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1.yaml new file mode 100644 index 0000000..e666437 --- /dev/null +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1.yaml @@ -0,0 +1,65 @@ +# H1-only tune: local-PCA teacher + ellphi + power size. +# Objective: val topology W-Dist over H1 only. No min_b / axis projection. + +meta: + config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden to ellphi by tune script + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 20 + grad_clip_value: 1.0 + visualize_every: 10000 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + noise_std: 0.01 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml new file mode 100644 index 0000000..58d91e1 --- /dev/null +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml @@ -0,0 +1,76 @@ +# H1-only tune on tangent-direction outliers. +# Same stack as elongate_n100_no_cls_tune_local_pca_ellphi_power_h1 (local-PCA +# teacher + ellphi + power size, W-Dist over H1 only), but outliers are placed +# along local-PCA first principal axes (anisotropic noise) instead of uniform. + +meta: + config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden to ellphi by tune script + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 20 + grad_clip_value: 1.0 + visualize_every: 10000 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + # Anisotropic outliers: displace inliers along local-PCA PC1 (tangent). + # noise_std matches the uniform baseline: inliers get the same isotropic jitter + # so only the OUTLIER distribution differs (tangent = anisotropic). Jitter also + # breaks exact MNIST-quantized coincidences (dmin=0) that make the ellphi + # tangency derivative w.r.t. mu undefined. + outlier_mode: local_pca_tangent + noise_std: 0.01 + tangent_pca_k: 10 + tangent_offset_min: 0.15 + tangent_offset_max: 0.40 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/experiments/enqueue_top_optuna_trials.py b/experiments/enqueue_top_optuna_trials.py new file mode 100755 index 0000000..1a7b665 --- /dev/null +++ b/experiments/enqueue_top_optuna_trials.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Create a refinement study by enqueueing top trials from a proxy study.""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path + +import optuna +from optuna.trial import TrialState + +from tda_ml.supervised_diagnostics import git_revision + +REPO_ROOT = Path(__file__).resolve().parents[1] +REQUIRED_PARAMS = ("w_aniso", "w_size", "w_topo", "lr") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-storage", required=True) + parser.add_argument("--source-study", required=True) + parser.add_argument("--target-storage", required=True) + parser.add_argument("--target-study", required=True) + parser.add_argument("--top-k", type=int, required=True) + parser.add_argument("--out-dir", type=Path, required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.top_k < 1: + raise ValueError(f"top-k must be >= 1, got {args.top_k}") + if args.out_dir.exists(): + raise FileExistsError(f"Refinement output already exists: {args.out_dir}") + + source = optuna.load_study( + study_name=args.source_study, + storage=args.source_storage, + ) + complete = sorted( + (trial for trial in source.trials if trial.state == TrialState.COMPLETE), + key=lambda trial: float(trial.value), + ) + if len(complete) < args.top_k: + raise RuntimeError( + f"Source study has {len(complete)} completed trials; " + f"need top_k={args.top_k}" + ) + + selected = complete[: args.top_k] + for trial in selected: + missing = [name for name in REQUIRED_PARAMS if name not in trial.params] + if missing: + raise ValueError( + f"Source trial {trial.number} is missing parameters {missing}" + ) + + args.out_dir.mkdir(parents=True) + target = optuna.create_study( + study_name=args.target_study, + storage=args.target_storage, + direction="minimize", + load_if_exists=False, + ) + for trial in selected: + target.enqueue_trial( + {name: float(trial.params[name]) for name in REQUIRED_PARAMS}, + user_attrs={ + "source_study": args.source_study, + "source_trial_number": trial.number, + "source_proxy_value": float(trial.value), + }, + ) + + manifest = { + "run_status": "pending", + "created_at_utc": datetime.now(timezone.utc).isoformat(), + "command_entry": "experiments/enqueue_top_optuna_trials.py", + "source_revision": git_revision(REPO_ROOT), + "source_storage": args.source_storage, + "source_study": args.source_study, + "target_storage": args.target_storage, + "target_study": args.target_study, + "selection": "lowest proxy W-Dist", + "top_k": args.top_k, + "selected_trials": [ + { + "source_trial_number": trial.number, + "source_proxy_value": float(trial.value), + "params": { + name: float(trial.params[name]) for name in REQUIRED_PARAMS + }, + } + for trial in selected + ], + "fallbacks": [], + } + (args.out_dir / "REFINEMENT_PREFLIGHT.json").write_text( + json.dumps(manifest, indent=2) + "\n", + encoding="utf-8", + ) + print(json.dumps(manifest["selected_trials"], indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index 0ee273f..e7e56ea 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -148,16 +148,43 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) else: raise ValueError(f"split must be 'val' or 'test'; got {split!r}") - dataset = NoisyMNISTDataset( + if "outlier_mode" not in data_cfg: + raise ValueError( + "data.outlier_mode must be set explicitly (uniform|local_pca_tangent); " + "refusing silent uniform default" + ) + outlier_mode = str(data_cfg["outlier_mode"]).strip().lower() + if outlier_mode not in ("uniform", "local_pca_tangent"): + raise ValueError( + f"data.outlier_mode must be 'uniform' or 'local_pca_tangent', got {outlier_mode!r}" + ) + if "noise_std" not in data_cfg: + raise ValueError("data.noise_std must be set explicitly; refusing silent default") + + dataset_kwargs: dict[str, Any] = dict( root=str(REPO_ROOT / "data"), train=train_flag, max_points=data_cfg["max_points"], num_outliers=data_cfg["num_outliers"], + noise_std=float(data_cfg["noise_std"]), indices=indices, deterministic=True, noise_seed=seed, preload=True, + outlier_mode=outlier_mode, ) + if outlier_mode == "local_pca_tangent": + for key in ("tangent_pca_k", "tangent_offset_min", "tangent_offset_max"): + if key not in data_cfg: + raise ValueError( + f"data.{key} must be set explicitly for outlier_mode=local_pca_tangent" + ) + dataset_kwargs.update( + tangent_pca_k=int(data_cfg["tangent_pca_k"]), + tangent_offset_min=float(data_cfg["tangent_offset_min"]), + tangent_offset_max=float(data_cfg["tangent_offset_max"]), + ) + dataset = NoisyMNISTDataset(**dataset_kwargs) loader = create_data_loader( dataset, batch_size=batch_size, diff --git a/experiments/launch_h1_wdist_multifidelity.sh b/experiments/launch_h1_wdist_multifidelity.sh new file mode 100755 index 0000000..2660e73 --- /dev/null +++ b/experiments/launch_h1_wdist_multifidelity.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Launch the prepared H1 multifidelity tune as a detached process. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +ROOT_OUT="${ROOT_OUT:-outputs/tune/0716_pwr_wdist_h1_multifidelity}" +SESSION="${SESSION:-h1_wdist_multifidelity_0716}" +DRIVER_LOG="${ROOT_OUT}_driver.log" + +if [[ -e "${ROOT_OUT}" || -e "${DRIVER_LOG}" ]]; then + echo "output or driver log already exists: ${ROOT_OUT}" >&2 + exit 1 +fi + +exec bash experiments/launch_detached_screen.sh \ + "${SESSION}" \ + "${DRIVER_LOG}" \ + env "ROOT_OUT=${ROOT_OUT}" bash experiments/run_h1_wdist_multifidelity.sh diff --git a/experiments/launch_h1_wdist_tangent_multifidelity.sh b/experiments/launch_h1_wdist_tangent_multifidelity.sh new file mode 100755 index 0000000..01aa4fa --- /dev/null +++ b/experiments/launch_h1_wdist_tangent_multifidelity.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Launch the H1 W-Dist multifidelity tune on tangent-direction outliers. +# Same 2-stage schedule (stage1 5ep proxy -> stage2 top-4 20ep) as the uniform +# H1 tune, but with outlier_mode=local_pca_tangent (anisotropic noise). + +set -euo pipefail + +cd "$(dirname "$0")/.." + +BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent}" +ROOT_OUT="${ROOT_OUT:-outputs/tune/0717_pwr_wdist_h1_tangent_multifidelity}" +SESSION="${SESSION:-h1_wdist_tangent_multifidelity_0717}" +DRIVER_LOG="${ROOT_OUT}_driver.log" + +if [[ -e "${ROOT_OUT}" || -e "${DRIVER_LOG}" ]]; then + echo "output or driver log already exists: ${ROOT_OUT}" >&2 + exit 1 +fi + +exec bash experiments/launch_detached_screen.sh \ + "${SESSION}" \ + "${DRIVER_LOG}" \ + env "ROOT_OUT=${ROOT_OUT}" "BASE_CONFIG=${BASE_CONFIG}" \ + bash experiments/run_h1_wdist_multifidelity.sh diff --git a/experiments/run_h1_wdist_multifidelity.sh b/experiments/run_h1_wdist_multifidelity.sh new file mode 100755 index 0000000..7c89e01 --- /dev/null +++ b/experiments/run_h1_wdist_multifidelity.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# H1-only W-Dist tune: +# stage 1: 16 proxy trials x 5 epochs +# stage 2: top 4 proxy conditions x 20 epochs + +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" + +BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power_h1}" +ROOT_OUT="${ROOT_OUT:-outputs/tune/0716_pwr_wdist_h1_multifidelity}" +STAGE1_OUT="${ROOT_OUT}/stage1_proxy5" +STAGE2_OUT="${ROOT_OUT}/stage2_top4_full20" +STAGE1_STUDY="h1_wdist_proxy5_16" +STAGE2_STUDY="h1_wdist_top4_full20" +STAGE1_STORAGE="sqlite:///${STAGE1_OUT}/study.db" +STAGE2_STORAGE="sqlite:///${STAGE2_OUT}/study.db" + +record_plan_status() { + local status="$1" + local reason="$2" + local plan="${ROOT_OUT}/MULTIFIDELITY_PLAN.json" + [[ -f "${plan}" ]] || return 0 + uv run python - "${plan}" "${status}" "${reason}" <<'PY' +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +path = Path(sys.argv[1]) +payload = json.loads(path.read_text(encoding="utf-8")) +payload["run_status"] = sys.argv[2] +payload["status_reason"] = sys.argv[3] +payload["status_recorded_at_utc"] = datetime.now(timezone.utc).isoformat() +path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") +PY +} + +on_error() { + local code=$? + trap - ERR + record_plan_status "failed" "pipeline command failed with exit code ${code}" + exit "${code}" +} + +on_interrupt() { + trap - INT TERM + record_plan_status "skipped" "pipeline interrupted intentionally" + exit 130 +} + +trap on_error ERR +trap on_interrupt INT TERM + +if [[ -e "${ROOT_OUT}" ]]; then + echo "output already exists: ${ROOT_OUT}" >&2 + exit 1 +fi + +mkdir -p "${ROOT_OUT}" +cat > "${ROOT_OUT}/MULTIFIDELITY_PLAN.json" < 0: - if self.deterministic: - outliers = torch.rand(self.num_outliers, 2, generator=rng) * 2.0 - 1.0 + if self.outlier_mode == "uniform": + if self.deterministic: + outliers = torch.rand(self.num_outliers, 2, generator=rng) * 2.0 - 1.0 + else: + outliers = torch.rand(self.num_outliers, 2) * 2.0 - 1.0 else: - outliers = torch.rand(self.num_outliers, 2) * 2.0 - 1.0 + from tda_ml.tangent_outliers import sample_local_pca_tangent_outliers + + # Local PCA on clean digit geometry (pre-jitter) so tangent is defined + # by the stroke, not by isotropic noise. + pca_src = clean_pc_points if clean_pc_points.shape[0] >= 2 else inliers + outliers = sample_local_pca_tangent_outliers( + pca_src, + self.num_outliers, + k=self.tangent_pca_k, + offset_min=self.tangent_offset_min, + offset_max=self.tangent_offset_max, + generator=rng, + ) else: outliers = torch.empty(0, 2) diff --git a/tda_ml/main.py b/tda_ml/main.py index 84b927d..c3011d9 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -93,6 +93,32 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): init_checkpoint = config.get("init_checkpoint") if init_checkpoint and not os.path.exists(init_checkpoint): raise FileNotFoundError(f"Initial checkpoint not found: {init_checkpoint}") + if "outlier_mode" not in data_cfg: + raise ValueError( + "data.outlier_mode must be set explicitly (uniform|local_pca_tangent); " + "refusing silent uniform default in run manifest" + ) + outlier_mode = str(data_cfg["outlier_mode"]).strip().lower() + if "noise_std" not in data_cfg: + raise ValueError( + "data.noise_std must be set explicitly; refusing silent default in run manifest" + ) + data_outliers = { + "outlier_mode": outlier_mode, + "noise_std": float(data_cfg["noise_std"]), + "num_outliers": int(data_cfg["num_outliers"]), + } + if outlier_mode == "local_pca_tangent": + for key in ("tangent_pca_k", "tangent_offset_min", "tangent_offset_max"): + if key not in data_cfg: + raise ValueError( + f"data.{key} must be set explicitly for outlier_mode=local_pca_tangent" + ) + data_outliers.update( + tangent_pca_k=int(data_cfg["tangent_pca_k"]), + tangent_offset_min=float(data_cfg["tangent_offset_min"]), + tangent_offset_max=float(data_cfg["tangent_offset_max"]), + ) manifest = { "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), "source_revision": git_revision(), @@ -103,6 +129,7 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): "seed": seed, "epochs_planned": config["training"]["epochs"], "distance_backend": str(topo_cfg["distance_backend"]).lower().strip(), + "data_outliers": data_outliers, "checkpoint_selection": selection["metric"], "early_abort": config.get("training", {}).get("early_abort"), "run_dir": run_dir, diff --git a/tda_ml/numerical_eps.py b/tda_ml/numerical_eps.py index fc6f49b..b841ded 100644 --- a/tda_ml/numerical_eps.py +++ b/tda_ml/numerical_eps.py @@ -11,3 +11,9 @@ # Lower clamp on inlier probability in Mahalanobis distance weighting. INLIER_PROB_MIN = 1e-4 + +# Minimum center separation enforced when sampling tangent-direction outliers. +# Two nearly coincident cloud points make the ellphi tangency derivative w.r.t. +# the ellipse center (mu) numerically undefined; the sampler rejects candidates +# closer than this to any existing point rather than emitting degenerate clouds. +MIN_TANGENT_OUTLIER_SEPARATION = 1e-2 diff --git a/tda_ml/run_setup.py b/tda_ml/run_setup.py index f155ddd..f977050 100644 --- a/tda_ml/run_setup.py +++ b/tda_ml/run_setup.py @@ -86,13 +86,30 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): persistent_workers=settings.persistent_workers, prefetch_factor=settings.prefetch_factor, ) + if "outlier_mode" not in data_cfg: + raise ValueError( + "data.outlier_mode must be set explicitly (uniform|local_pca_tangent); " + "refusing silent uniform default" + ) + outlier_mode = str(data_cfg["outlier_mode"]).strip().lower() + if outlier_mode not in ("uniform", "local_pca_tangent"): + raise ValueError( + f"data.outlier_mode must be 'uniform' or 'local_pca_tangent', got {outlier_mode!r}" + ) + if "noise_std" not in data_cfg: + raise ValueError( + "data.noise_std must be set explicitly; refusing silent default" + ) + dataset_kwargs = dict( root="./data", max_points=data_cfg["max_points"], num_outliers=data_cfg["num_outliers"], + noise_std=float(data_cfg["noise_std"]), deterministic=True, noise_seed=seed, - allow_empty_cloud_fallback=bool( + outlier_mode=outlier_mode, + allow_empty_cloud_fallback=bool( (config.get("reproducibility") or {}).get("allow_empty_cloud_fallback", False) or data_cfg.get("allow_empty_cloud_fallback", False) ), @@ -102,6 +119,18 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): ), ) + if outlier_mode == "local_pca_tangent": + for key in ("tangent_pca_k", "tangent_offset_min", "tangent_offset_max"): + if key not in data_cfg: + raise ValueError( + f"data.{key} must be set explicitly for outlier_mode=local_pca_tangent" + ) + dataset_kwargs.update( + tangent_pca_k=int(data_cfg["tangent_pca_k"]), + tangent_offset_min=float(data_cfg["tangent_offset_min"]), + tangent_offset_max=float(data_cfg["tangent_offset_max"]), + ) + train_dataset = NoisyMNISTDataset(train=True, indices=train_indices, **dataset_kwargs) train_loader = create_data_loader(train_dataset, shuffle=True, **loader_kwargs) diff --git a/tda_ml/tangent_outliers.py b/tda_ml/tangent_outliers.py new file mode 100644 index 0000000..f9fd137 --- /dev/null +++ b/tda_ml/tangent_outliers.py @@ -0,0 +1,89 @@ +"""Place outliers along local-PCA first principal axes (tangent directions).""" + +from __future__ import annotations + +import torch + +from tda_ml.local_pca import local_pca_ellipse_params +from tda_ml.numerical_eps import MIN_TANGENT_OUTLIER_SEPARATION + + +def sample_local_pca_tangent_outliers( + inliers: torch.Tensor, + num_outliers: int, + *, + k: int = 10, + offset_min: float = 0.15, + offset_max: float = 0.40, + generator: torch.Generator | None = None, + max_attempts: int = 200, + box_min: float = -1.0, + box_max: float = 1.0, + min_separation: float = MIN_TANGENT_OUTLIER_SEPARATION, +) -> torch.Tensor: + """ + Sample outliers by displacing random inliers along local PCA PC1. + + Local PCA is computed on ``inliers`` only (no isotropic jitter). Each outlier + is ``p + s * u1`` where ``u1 = (cos θ, sin θ)`` is the major-axis direction + at a randomly chosen inlier ``p``, and ``s`` has random sign with + ``|s| ~ Uniform(offset_min, offset_max)``. + + Candidates that leave ``[box_min, box_max]^2`` or land within + ``min_separation`` of any inlier or already-accepted outlier are retried: + two nearly coincident centers make the ellphi tangency derivative w.r.t. the + center undefined. Exhausting ``max_attempts`` hard-fails; candidates are + never clipped into the box nor merged onto an existing point. + """ + if num_outliers <= 0: + return torch.empty(0, 2, dtype=inliers.dtype, device=inliers.device) + if inliers.ndim != 2 or inliers.shape[-1] != 2: + raise ValueError(f"inliers must be (N, 2); got {tuple(inliers.shape)}") + n = int(inliers.shape[0]) + if n < 2: + raise ValueError(f"Need at least 2 inliers for local PCA; got n={n}") + if offset_min <= 0 or offset_max < offset_min: + raise ValueError( + f"Require 0 < offset_min <= offset_max; got {offset_min}, {offset_max}" + ) + if min_separation < 0: + raise ValueError(f"min_separation must be >= 0; got {min_separation}") + + params = local_pca_ellipse_params(inliers, k=k, normalize_axes=True) # (N, 3) + theta = params[:, 2] + direction = torch.stack([torch.cos(theta), torch.sin(theta)], dim=-1) + + outliers = torch.empty(num_outliers, 2, dtype=inliers.dtype, device=inliers.device) + for j in range(num_outliers): + for _ in range(max_attempts): + if generator is not None: + idx = int(torch.randint(0, n, (1,), generator=generator).item()) + u = float(torch.rand(1, generator=generator).item()) + sign = 1.0 if float(torch.rand(1, generator=generator).item()) < 0.5 else -1.0 + else: + idx = int(torch.randint(0, n, (1,)).item()) + u = float(torch.rand(1).item()) + sign = 1.0 if float(torch.rand(1).item()) < 0.5 else -1.0 + mag = offset_min + (offset_max - offset_min) * u + cand = inliers[idx] + (sign * mag) * direction[idx] + if not bool(torch.all((cand >= box_min) & (cand <= box_max)).item()): + continue + if min_separation > 0: + d_inl = torch.linalg.norm(inliers - cand, dim=-1).min() + if bool((d_inl < min_separation).item()): + continue + if j > 0: + d_out = torch.linalg.norm(outliers[:j] - cand, dim=-1).min() + if bool((d_out < min_separation).item()): + continue + outliers[j] = cand + break + else: + raise RuntimeError( + "Failed to sample an in-bounds, well-separated tangent outlier after " + f"{max_attempts} attempts (outlier_index={j}, " + f"box=[{box_min}, {box_max}], " + f"offset=[{offset_min}, {offset_max}], " + f"min_separation={min_separation})." + ) + return outliers diff --git a/tests/test_tangent_outliers.py b/tests/test_tangent_outliers.py new file mode 100644 index 0000000..a007b11 --- /dev/null +++ b/tests/test_tangent_outliers.py @@ -0,0 +1,72 @@ +"""Unit tests for local-PCA tangent outlier sampling.""" + +from __future__ import annotations + +import unittest + +import torch + +from tda_ml.tangent_outliers import sample_local_pca_tangent_outliers + + +class TestTangentOutliers(unittest.TestCase): + def test_count_and_box(self): + g = torch.Generator().manual_seed(0) + # Horizontal line → PC1 roughly along x. + inliers = torch.stack( + [torch.linspace(-0.5, 0.5, 40), torch.zeros(40)], dim=1 + ) + outliers = sample_local_pca_tangent_outliers( + inliers, + 8, + k=8, + offset_min=0.2, + offset_max=0.35, + generator=g, + ) + self.assertEqual(tuple(outliers.shape), (8, 2)) + self.assertTrue(torch.all(outliers >= -1.0).item()) + self.assertTrue(torch.all(outliers <= 1.0).item()) + + def test_deterministic(self): + inliers = torch.randn(30, 2) + g1 = torch.Generator().manual_seed(7) + g2 = torch.Generator().manual_seed(7) + a = sample_local_pca_tangent_outliers(inliers, 5, generator=g1) + b = sample_local_pca_tangent_outliers(inliers, 5, generator=g2) + self.assertTrue(torch.allclose(a, b)) + + def test_min_separation_enforced(self): + # Outliers must be at least ``min_separation`` from every inlier and from + # each other so the ellphi tangency derivative w.r.t. mu stays defined. + g = torch.Generator().manual_seed(3) + inliers = torch.stack( + [torch.linspace(-0.5, 0.5, 40), torch.zeros(40)], dim=1 + ) + min_sep = 0.05 + outliers = sample_local_pca_tangent_outliers( + inliers, 12, k=8, offset_min=0.2, offset_max=0.5, + generator=g, min_separation=min_sep, + ) + d_inl = torch.cdist(outliers, inliers).min() + self.assertGreaterEqual(float(d_inl), min_sep) + d_out = torch.cdist(outliers, outliers) + d_out.fill_diagonal_(float("inf")) + self.assertGreaterEqual(float(d_out.min()), min_sep) + + def test_min_separation_unsatisfiable_hard_fails(self): + # A tiny box with a large min_separation cannot be satisfied -> hard-fail + # rather than emitting near-coincident (degenerate) outliers. + g = torch.Generator().manual_seed(0) + inliers = torch.stack( + [torch.linspace(-0.02, 0.02, 20), torch.zeros(20)], dim=1 + ) + with self.assertRaises(RuntimeError): + sample_local_pca_tangent_outliers( + inliers, 20, k=5, offset_min=0.01, offset_max=0.02, + generator=g, min_separation=0.5, box_min=-0.05, box_max=0.05, + ) + + +if __name__ == "__main__": + unittest.main() From fb4d182cd9768d67c74145e10124f8116f9884cb Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 12:45:17 +0900 Subject: [PATCH 23/44] =?UTF-8?q?chore:=20multifidelity=20=E3=81=AE?= =?UTF-8?q?=E3=83=AF=E3=83=BC=E3=82=AB=E3=83=BC=E3=82=B9=E3=83=AC=E3=83=83?= =?UTF-8?q?=E3=83=89=E6=95=B0=E3=82=92=20env=20=E5=8C=96(STAGE1/2=5FTHREAD?= =?UTF-8?q?S)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- experiments/run_h1_wdist_multifidelity.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/experiments/run_h1_wdist_multifidelity.sh b/experiments/run_h1_wdist_multifidelity.sh index 7c89e01..76017b1 100755 --- a/experiments/run_h1_wdist_multifidelity.sh +++ b/experiments/run_h1_wdist_multifidelity.sh @@ -75,7 +75,7 @@ EOF BASE_CONFIG="${BASE_CONFIG}" \ OUT_BASE="${STAGE1_OUT}" \ STUDY_NAME="${STAGE1_STUDY}" \ -THREADS_PER_WORKER=4 \ +THREADS_PER_WORKER="${STAGE1_THREADS:-12}" \ TRIALS_PER_WORKER=2 \ bash experiments/run_tune_local_pca_power_wdist_parallel.sh 8 16 5 ellphi @@ -90,7 +90,7 @@ uv run python -u experiments/enqueue_top_optuna_trials.py \ BASE_CONFIG="${BASE_CONFIG}" \ OUT_BASE="${STAGE2_OUT}" \ STUDY_NAME="${STAGE2_STUDY}" \ -THREADS_PER_WORKER=4 \ +THREADS_PER_WORKER="${STAGE2_THREADS:-24}" \ TRIALS_PER_WORKER=1 \ bash experiments/run_tune_local_pca_power_wdist_parallel.sh 4 4 20 ellphi From 8991b94441e4bd1ce8c9fadc1f8f800ebec9df43 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 15:42:55 +0900 Subject: [PATCH 24/44] =?UTF-8?q?feat:=20multifidelity=E5=A4=B1=E6=95=97?= =?UTF-8?q?=E5=80=99=E8=A3=9C=E3=81=AE=E9=A0=86=E4=BD=8D=E4=BB=98=E3=81=8D?= =?UTF-8?q?=E5=9B=9E=E5=BE=A9=E3=82=92=E8=A8=98=E9=8C=B2=E5=8F=AF=E8=83=BD?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数値的に失敗したrefinement候補を黙って再利用せず、次順位のproxy候補を既存studyへ明示的に追加できるようにする。選択順位とappend操作をpreflight manifestへ記録し、回復経路を監査可能にする。 --- experiments/enqueue_top_optuna_trials.py | 25 ++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/experiments/enqueue_top_optuna_trials.py b/experiments/enqueue_top_optuna_trials.py index 1a7b665..d90f3d5 100755 --- a/experiments/enqueue_top_optuna_trials.py +++ b/experiments/enqueue_top_optuna_trials.py @@ -24,6 +24,17 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--target-storage", required=True) parser.add_argument("--target-study", required=True) parser.add_argument("--top-k", type=int, required=True) + parser.add_argument( + "--rank-start", + type=int, + default=1, + help="1-based first proxy rank to enqueue (default: 1).", + ) + parser.add_argument( + "--append-existing", + action="store_true", + help="Append selected trials to an existing target study (recovery only).", + ) parser.add_argument("--out-dir", type=Path, required=True) return parser.parse_args() @@ -32,6 +43,8 @@ def main() -> int: args = parse_args() if args.top_k < 1: raise ValueError(f"top-k must be >= 1, got {args.top_k}") + if args.rank_start < 1: + raise ValueError(f"rank-start must be >= 1, got {args.rank_start}") if args.out_dir.exists(): raise FileExistsError(f"Refinement output already exists: {args.out_dir}") @@ -43,13 +56,14 @@ def main() -> int: (trial for trial in source.trials if trial.state == TrialState.COMPLETE), key=lambda trial: float(trial.value), ) - if len(complete) < args.top_k: + rank_stop = args.rank_start - 1 + args.top_k + if len(complete) < rank_stop: raise RuntimeError( f"Source study has {len(complete)} completed trials; " - f"need top_k={args.top_k}" + f"need ranks {args.rank_start}..{rank_stop}" ) - selected = complete[: args.top_k] + selected = complete[args.rank_start - 1 : rank_stop] for trial in selected: missing = [name for name in REQUIRED_PARAMS if name not in trial.params] if missing: @@ -62,7 +76,7 @@ def main() -> int: study_name=args.target_study, storage=args.target_storage, direction="minimize", - load_if_exists=False, + load_if_exists=args.append_existing, ) for trial in selected: target.enqueue_trial( @@ -85,6 +99,9 @@ def main() -> int: "target_study": args.target_study, "selection": "lowest proxy W-Dist", "top_k": args.top_k, + "rank_start": args.rank_start, + "rank_stop": rank_stop, + "append_existing": args.append_existing, "selected_trials": [ { "source_trial_number": trial.number, From 32d0db36369c62f6327f6c5966ae7348f11becbe Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 16:30:45 +0900 Subject: [PATCH 25/44] =?UTF-8?q?feat:=20=E6=8E=A5=E7=B7=9A=E5=A4=96?= =?UTF-8?q?=E3=82=8C=E5=80=A4=E3=81=AE30ep=E5=86=8D=E5=AD=A6=E7=BF=92?= =?UTF-8?q?=E7=94=A8=20full120=20config=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接線方向外れ値(local_pca_tangent)で本番30ep再学習するためのH1-only full120 config。uniform版と同一スタック(local_pca teacher + ellphi + power size, W-Dist)で、data.outlier_modeのみ接線に切替。production重みは 接線tune best JSON(--tune-json)から適用する前提。 --- ..._full120_teacher_local_pca_h1_tangent.yaml | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml new file mode 100644 index 0000000..918758e --- /dev/null +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml @@ -0,0 +1,78 @@ +# Paper no_cls production on tangent-direction outliers. +# Same stack as elongate_n100_no_cls_full120_teacher_local_pca (local-PCA teacher +# + ellphi topo + H1-only Wasserstein), but outliers are placed along local-PCA +# first principal axes (anisotropic noise) instead of uniform. +# Production 30ep weights MUST come from an H1-only tangent Optuna best JSON +# (--tune-json). The w_* / lr values below are Optuna prior centres only. + +meta: + config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "ellphi" + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + # Prior centres for Optuna; paper production requires --tune-json overrides. + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + # Anisotropic outliers: displace inliers along local-PCA PC1 (tangent). noise_std + # matches the uniform baseline so only the OUTLIER distribution differs, and the + # jitter breaks exact MNIST-quantized coincidences that make the ellphi tangency + # derivative w.r.t. mu undefined. + outlier_mode: local_pca_tangent + noise_std: 0.01 + tangent_pca_k: 10 + tangent_offset_min: 0.15 + tangent_offset_max: 0.40 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 From cea4271cec9796abcaad9c742af8fb947d35a4bd Mon Sep 17 00:00:00 2001 From: koki3070 Date: Fri, 17 Jul 2026 20:10:10 +0900 Subject: [PATCH 26/44] =?UTF-8?q?fix:=20ellphi=E7=94=A8=E3=81=AB=E9=9B=B2?= =?UTF-8?q?=E5=85=A8=E4=BD=93=E3=81=AE=E7=82=B9=E9=96=93=E6=9C=80=E5=B0=8F?= =?UTF-8?q?=E8=B7=9D=E9=9B=A2=E3=82=92=E4=BF=9D=E8=A8=BC=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MNISTの前景不足時に重複パディング+独立ノイズで中心が潰れると、 ellphiの mu 微分が数値的にゼロになり seed 789 などで学習が落ちる。 inlierノイズ適用とuniform外れ値にも最小分離を強制し、定数をmanifestへ記録する。 --- tda_ml/cloud_separation.py | 103 +++++++++++++++++++++++++++++++++ tda_ml/data_loader.py | 28 +++++---- tda_ml/numerical_eps.py | 10 ++-- tda_ml/reproducibility.py | 2 + tda_ml/tangent_outliers.py | 21 +++++-- tests/test_cloud_separation.py | 67 +++++++++++++++++++++ 6 files changed, 210 insertions(+), 21 deletions(-) create mode 100644 tda_ml/cloud_separation.py create mode 100644 tests/test_cloud_separation.py diff --git a/tda_ml/cloud_separation.py b/tda_ml/cloud_separation.py new file mode 100644 index 0000000..eb66a9b --- /dev/null +++ b/tda_ml/cloud_separation.py @@ -0,0 +1,103 @@ +"""Enforce pairwise center separation so ellphi tangency derivatives stay defined.""" + +from __future__ import annotations + +import torch + +from tda_ml.numerical_eps import MIN_ELLPHI_CENTER_SEPARATION + + +def apply_noise_with_min_separation( + base: torch.Tensor, + noise_std: float, + *, + min_separation: float = MIN_ELLPHI_CENTER_SEPARATION, + generator: torch.Generator | None = None, + max_attempts: int = 200, +) -> torch.Tensor: + """ + Apply isotropic Gaussian noise while keeping pairwise distances >= ``min_separation``. + + Points are accepted left-to-right. A candidate that lands within + ``min_separation`` of any already-accepted point is rejected and re-noised. + Exhausting ``max_attempts`` hard-fails (no silent merge / clip). + + This prevents near-coincident ellipse centers (common when MNIST clouds are + padded by duplicating foreground pixels) from making the ellphi tangency + derivative w.r.t. mu numerically undefined. + """ + if base.ndim != 2 or base.shape[-1] != 2: + raise ValueError(f"base must be (N, 2); got {tuple(base.shape)}") + if noise_std < 0: + raise ValueError(f"noise_std must be >= 0; got {noise_std}") + if min_separation < 0: + raise ValueError(f"min_separation must be >= 0; got {min_separation}") + + n = int(base.shape[0]) + out = torch.empty_like(base) + for i in range(n): + for _ in range(max_attempts): + if noise_std > 0: + if generator is not None: + noise = torch.randn(2, generator=generator, dtype=base.dtype) * noise_std + else: + noise = torch.randn(2, dtype=base.dtype, device=base.device) * noise_std + else: + noise = torch.zeros(2, dtype=base.dtype, device=base.device) + cand = base[i] + noise + if min_separation > 0 and i > 0: + dmin = torch.linalg.norm(out[:i] - cand, dim=-1).min() + if bool((dmin < min_separation).item()): + continue + out[i] = cand + break + else: + raise RuntimeError( + "Failed to place a well-separated inlier after " + f"{max_attempts} noise draws (point_index={i}, " + f"noise_std={noise_std}, min_separation={min_separation})." + ) + return out + + +def sample_uniform_outliers_with_min_separation( + num_outliers: int, + existing: torch.Tensor, + *, + min_separation: float = MIN_ELLPHI_CENTER_SEPARATION, + generator: torch.Generator | None = None, + max_attempts: int = 200, + box_min: float = -1.0, + box_max: float = 1.0, +) -> torch.Tensor: + """Sample uniform ``[-1,1]^2`` outliers that stay ``min_separation`` from ``existing``.""" + if num_outliers <= 0: + return torch.empty(0, 2, dtype=existing.dtype, device=existing.device) + if min_separation < 0: + raise ValueError(f"min_separation must be >= 0; got {min_separation}") + + span = box_max - box_min + outliers = torch.empty(num_outliers, 2, dtype=existing.dtype, device=existing.device) + for j in range(num_outliers): + for _ in range(max_attempts): + if generator is not None: + cand = torch.rand(2, generator=generator, dtype=existing.dtype) * span + box_min + else: + cand = torch.rand(2, dtype=existing.dtype, device=existing.device) * span + box_min + if min_separation > 0: + d_ex = torch.linalg.norm(existing - cand, dim=-1).min() + if bool((d_ex < min_separation).item()): + continue + if j > 0: + d_out = torch.linalg.norm(outliers[:j] - cand, dim=-1).min() + if bool((d_out < min_separation).item()): + continue + outliers[j] = cand + break + else: + raise RuntimeError( + "Failed to sample a well-separated uniform outlier after " + f"{max_attempts} attempts (outlier_index={j}, " + f"min_separation={min_separation})." + ) + return outliers diff --git a/tda_ml/data_loader.py b/tda_ml/data_loader.py index a3bc465..213c19f 100644 --- a/tda_ml/data_loader.py +++ b/tda_ml/data_loader.py @@ -174,24 +174,29 @@ def __getitem__(self, idx): inliers = torch.cat([points, points[pad_indices]], dim=0) clean_pc_points = points - if self.noise_std > 0: - if self.deterministic: - noise = torch.randn(inliers.size(), generator=rng) * self.noise_std - else: - noise = torch.randn_like(inliers) * self.noise_std - inliers = inliers + noise + # Isotropic jitter with pairwise min-separation. Padding by duplicated + # MNIST pixels otherwise yields near-coincident centers that make the + # ellphi tangency derivative w.r.t. mu undefined. + from tda_ml.cloud_separation import ( + apply_noise_with_min_separation, + sample_uniform_outliers_with_min_separation, + ) + + inliers = apply_noise_with_min_separation( + inliers, self.noise_std, generator=rng + ) if self.num_outliers > 0: if self.outlier_mode == "uniform": - if self.deterministic: - outliers = torch.rand(self.num_outliers, 2, generator=rng) * 2.0 - 1.0 - else: - outliers = torch.rand(self.num_outliers, 2) * 2.0 - 1.0 + outliers = sample_uniform_outliers_with_min_separation( + self.num_outliers, inliers, generator=rng + ) else: from tda_ml.tangent_outliers import sample_local_pca_tangent_outliers # Local PCA on clean digit geometry (pre-jitter) so tangent is defined - # by the stroke, not by isotropic noise. + # by the stroke, not by isotropic noise. Separation is checked + # against the *noisy* inliers that actually enter the cloud. pca_src = clean_pc_points if clean_pc_points.shape[0] >= 2 else inliers outliers = sample_local_pca_tangent_outliers( pca_src, @@ -200,6 +205,7 @@ def __getitem__(self, idx): offset_min=self.tangent_offset_min, offset_max=self.tangent_offset_max, generator=rng, + existing_points=inliers, ) else: outliers = torch.empty(0, 2) diff --git a/tda_ml/numerical_eps.py b/tda_ml/numerical_eps.py index b841ded..258e143 100644 --- a/tda_ml/numerical_eps.py +++ b/tda_ml/numerical_eps.py @@ -12,8 +12,10 @@ # Lower clamp on inlier probability in Mahalanobis distance weighting. INLIER_PROB_MIN = 1e-4 -# Minimum center separation enforced when sampling tangent-direction outliers. +# Minimum pairwise center separation for ellphi-compatible clouds. # Two nearly coincident cloud points make the ellphi tangency derivative w.r.t. -# the ellipse center (mu) numerically undefined; the sampler rejects candidates -# closer than this to any existing point rather than emitting degenerate clouds. -MIN_TANGENT_OUTLIER_SEPARATION = 1e-2 +# the ellipse center (mu) numerically undefined; samplers reject candidates +# closer than this rather than emitting degenerate clouds. +MIN_ELLPHI_CENTER_SEPARATION = 1e-2 +# Backward-compatible alias (tangent outlier sampler historically used this name). +MIN_TANGENT_OUTLIER_SEPARATION = MIN_ELLPHI_CENTER_SEPARATION diff --git a/tda_ml/reproducibility.py b/tda_ml/reproducibility.py index 3b18cba..8c638d9 100644 --- a/tda_ml/reproducibility.py +++ b/tda_ml/reproducibility.py @@ -12,6 +12,7 @@ from tda_ml.numerical_eps import ( EIGENVALUE_FLOOR, INLIER_PROB_MIN, + MIN_ELLPHI_CENTER_SEPARATION, NUMERICAL_EPS, PCA_RIDGE_EPS, ) @@ -258,6 +259,7 @@ def build_reproducibility_manifest_fields( "numerical_eps_module": "tda_ml.numerical_eps", "numerical_constants": { "NUMERICAL_EPS": NUMERICAL_EPS, + "MIN_ELLPHI_CENTER_SEPARATION": MIN_ELLPHI_CENTER_SEPARATION, "PCA_RIDGE_EPS": PCA_RIDGE_EPS, "EIGENVALUE_FLOOR": EIGENVALUE_FLOOR, "INLIER_PROB_MIN": INLIER_PROB_MIN, diff --git a/tda_ml/tangent_outliers.py b/tda_ml/tangent_outliers.py index f9fd137..01608c2 100644 --- a/tda_ml/tangent_outliers.py +++ b/tda_ml/tangent_outliers.py @@ -20,6 +20,7 @@ def sample_local_pca_tangent_outliers( box_min: float = -1.0, box_max: float = 1.0, min_separation: float = MIN_TANGENT_OUTLIER_SEPARATION, + existing_points: torch.Tensor | None = None, ) -> torch.Tensor: """ Sample outliers by displacing random inliers along local PCA PC1. @@ -30,10 +31,13 @@ def sample_local_pca_tangent_outliers( ``|s| ~ Uniform(offset_min, offset_max)``. Candidates that leave ``[box_min, box_max]^2`` or land within - ``min_separation`` of any inlier or already-accepted outlier are retried: - two nearly coincident centers make the ellphi tangency derivative w.r.t. the - center undefined. Exhausting ``max_attempts`` hard-fails; candidates are - never clipped into the box nor merged onto an existing point. + ``min_separation`` of the separation reference set or already-accepted + outliers are retried: two nearly coincident centers make the ellphi + tangency derivative w.r.t. the center undefined. The separation reference + defaults to ``inliers``; pass ``existing_points`` (e.g. noisy inliers that + enter the cloud) when PCA geometry and cloud geometry differ. Exhausting + ``max_attempts`` hard-fails; candidates are never clipped into the box nor + merged onto an existing point. """ if num_outliers <= 0: return torch.empty(0, 2, dtype=inliers.dtype, device=inliers.device) @@ -48,6 +52,11 @@ def sample_local_pca_tangent_outliers( ) if min_separation < 0: raise ValueError(f"min_separation must be >= 0; got {min_separation}") + sep_ref = inliers if existing_points is None else existing_points + if sep_ref.ndim != 2 or sep_ref.shape[-1] != 2: + raise ValueError( + f"existing_points must be (N, 2); got {tuple(sep_ref.shape)}" + ) params = local_pca_ellipse_params(inliers, k=k, normalize_axes=True) # (N, 3) theta = params[:, 2] @@ -69,8 +78,8 @@ def sample_local_pca_tangent_outliers( if not bool(torch.all((cand >= box_min) & (cand <= box_max)).item()): continue if min_separation > 0: - d_inl = torch.linalg.norm(inliers - cand, dim=-1).min() - if bool((d_inl < min_separation).item()): + d_ref = torch.linalg.norm(sep_ref - cand, dim=-1).min() + if bool((d_ref < min_separation).item()): continue if j > 0: d_out = torch.linalg.norm(outliers[:j] - cand, dim=-1).min() diff --git a/tests/test_cloud_separation.py b/tests/test_cloud_separation.py new file mode 100644 index 0000000..ab0d153 --- /dev/null +++ b/tests/test_cloud_separation.py @@ -0,0 +1,67 @@ +"""Unit tests for ellphi-safe pairwise cloud separation.""" + +from __future__ import annotations + +import unittest + +import torch + +from tda_ml.cloud_separation import ( + apply_noise_with_min_separation, + sample_uniform_outliers_with_min_separation, +) +from tda_ml.numerical_eps import MIN_ELLPHI_CENTER_SEPARATION + + +class TestCloudSeparation(unittest.TestCase): + def test_noise_enforces_min_separation_on_duplicates(self): + # Padding-style duplicates: identical base points must still separate. + g = torch.Generator().manual_seed(0) + base = torch.zeros(30, 2) + base[:, 0] = torch.linspace(-0.5, 0.5, 15).repeat_interleave(2) + min_sep = 0.05 + out = apply_noise_with_min_separation( + base, noise_std=0.08, min_separation=min_sep, generator=g + ) + d = torch.cdist(out, out) + d.fill_diagonal_(float("inf")) + self.assertGreaterEqual(float(d.min()), min_sep) + + def test_noise_unsatisfiable_hard_fails(self): + g = torch.Generator().manual_seed(0) + base = torch.zeros(10, 2) + with self.assertRaises(RuntimeError): + apply_noise_with_min_separation( + base, + noise_std=0.0, + min_separation=0.1, + generator=g, + max_attempts=5, + ) + + def test_uniform_outliers_separated(self): + g = torch.Generator().manual_seed(1) + existing = torch.stack( + [torch.linspace(-0.8, 0.8, 40), torch.zeros(40)], dim=1 + ) + min_sep = MIN_ELLPHI_CENTER_SEPARATION + outliers = sample_uniform_outliers_with_min_separation( + 15, existing, min_separation=min_sep, generator=g + ) + d_ex = torch.cdist(outliers, existing).min() + self.assertGreaterEqual(float(d_ex), min_sep) + d_out = torch.cdist(outliers, outliers) + d_out.fill_diagonal_(float("inf")) + self.assertGreaterEqual(float(d_out.min()), min_sep) + + def test_deterministic(self): + base = torch.randn(20, 2) + g1 = torch.Generator().manual_seed(9) + g2 = torch.Generator().manual_seed(9) + a = apply_noise_with_min_separation(base, 0.01, generator=g1) + b = apply_noise_with_min_separation(base, 0.01, generator=g2) + self.assertTrue(torch.allclose(a, b)) + + +if __name__ == "__main__": + unittest.main() From 7378f7feee0844617055fe032b600ec733cc40fa Mon Sep 17 00:00:00 2001 From: koki3070 Date: Sat, 18 Jul 2026 12:35:42 +0900 Subject: [PATCH 27/44] =?UTF-8?q?fix:=20baseline=20ADBSCAN=E8=A9=95?= =?UTF-8?q?=E4=BE=A1=E3=81=AB=E6=A5=95=E5=86=86=E3=83=95=E3=82=A3=E3=83=AB?= =?UTF-8?q?=E3=83=88=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3W-Dist?= =?UTF-8?q?=E3=81=AEtopo=5Foptions=E3=82=92=E9=85=8D=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit metrics側の明示topo_options必須化(hard-fail)にbaselineスクリプトが未対応で 接線データのADBSCANグリッド探索が落ちていた。val選択はMCCのみ(W-Distは eps/min_samplesに非依存)なのでNaNプレースホルダとし、test集計のみ実W-Distを config由来のTopoWdistOptionsで計算する。gitignoreはsymlink形態の pytorch-topologicalも無視するよう修正(worktreeのdirty誤判定防止)。 --- .gitignore | 2 +- experiments/evaluate_paper_baselines.py | 47 +++++++++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 072311d..fe8b36b 100644 --- a/.gitignore +++ b/.gitignore @@ -217,7 +217,7 @@ docs/experiments/ data/ trash/ ellphi_repo/ -pytorch-topological/ +pytorch-topological docs/JSAIM2026UT.zip # OS artifacts diff --git a/experiments/evaluate_paper_baselines.py b/experiments/evaluate_paper_baselines.py index f87a6b8..a2aa132 100644 --- a/experiments/evaluate_paper_baselines.py +++ b/experiments/evaluate_paper_baselines.py @@ -42,10 +42,12 @@ _valid_clean_inliers, build_split_loader, dbscan_labels_to_outlier_pred, - evaluate_cloud_dbscan, ) from tda_ml.config import deep_update, load_config -from tda_ml.metrics import compute_recall_specificity_gmean_mcc_wdist +from tda_ml.metrics import ( + compute_recall_specificity_gmean_mcc, + compute_recall_specificity_gmean_mcc_wdist, +) from tda_ml.numerical_eps import EIGENVALUE_FLOOR, PCA_RIDGE_EPS from tda_ml.preflight import preflight_baseline_eval from tda_ml.reproducibility import ( @@ -56,6 +58,11 @@ write_json, ) from tda_ml.supervised_diagnostics import git_revision +from tda_ml.topo_wdist import ( + TopoWdistOptions, + compute_topo_wdist, + topo_wdist_options_from_config, +) REPO_ROOT = Path(__file__).resolve().parents[1] PAPER_SEEDS = [42, 123, 456, 789, 1024] @@ -205,18 +212,41 @@ def evaluate_adbscan( *, eps: float, min_samples: int, + topo_options: TopoWdistOptions | None = None, ) -> CloudMetrics: + """ + ADBSCAN baseline metrics for one cloud. + + ``topo_options=None`` is the val grid-search phase: selection uses MCC only, + and the ellipse-filtration topo W-Dist does not depend on (eps, min_samples), + so it is deliberately NOT computed there (wdist=NaN placeholder, never + aggregated into results). The test phase passes explicit ``topo_options`` + and reports the real W-Dist. + """ + from tda_ml.dbscan import apply_anisotropic_dbscan + if cloud.adbscan_params is None: raise ValueError("adbscan_params missing") - return evaluate_cloud_dbscan( + db_labels = apply_anisotropic_dbscan( cloud.points, cloud.adbscan_params, - cloud.labels_gt, - cloud.clean_pc, eps=eps, min_samples=min_samples, + metric="max", backend="mahalanobis", ) + pred = dbscan_labels_to_outlier_pred(db_labels) + recall, specificity, gmean, mcc = compute_recall_specificity_gmean_mcc( + cloud.labels_gt, pred + ) + if topo_options is None: + return CloudMetrics(recall, specificity, gmean, mcc, float("nan")) + wdist = float( + compute_topo_wdist( + cloud.points, cloud.adbscan_params, cloud.clean_pc, topo_options + ) + ) + return CloudMetrics(recall, specificity, gmean, mcc, wdist) def grid_search_clouds( @@ -360,12 +390,17 @@ def evaluate_method_on_seed( manifest_ref=manifest_ref, ) test_fn = evaluate_adbscan + # Real ellipse-filtration W-Dist only on test (grid phase selects by MCC). + test_extra_kwargs = {"topo_options": topo_wdist_options_from_config(config)} else: raise ValueError(f"Unknown method: {method!r}") + if method != "adbscan": + test_extra_kwargs = {} + per_test: list[CloudMetrics] = [] for cloud in test_clouds: - per_test.append(test_fn(cloud, **best_params)) + per_test.append(test_fn(cloud, **best_params, **test_extra_kwargs)) recall, specificity, gmean, mcc, wdist = _aggregate_cloud_metrics(per_test) return SeedResult( From 21881bcbd612c9d59aabcf93c3b3c71a8cb5978b Mon Sep 17 00:00:00 2001 From: koki3070 Date: Sat, 18 Jul 2026 13:36:48 +0900 Subject: [PATCH 28/44] =?UTF-8?q?feat:=20GT=E8=BF=91=E5=82=8D=E3=81=AEnear?= =?UTF-8?q?-tangent=E5=A4=96=E3=82=8C=E5=80=A4=E3=83=A2=E3=83=BC=E3=83=89(?= =?UTF-8?q?=C2=B130=C2=B0=E3=82=B8=E3=83=83=E3=82=BF+=E7=AD=86=E7=94=BB?= =?UTF-8?q?=E3=82=AF=E3=83=AA=E3=82=A2=E3=83=A9=E3=83=B3=E3=82=B90.08)?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 純接線変位は筆画に沿って移動するため外れ値の過半が筆画上に着地し、 ラベルと幾何が矛盾していた(外れ値→最寄りinlier中央値0.048 < inlier間隔0.064)。 接線±30°のコーンで方向を散らし、全clean inlierから0.08以上のsemantic クリアランスを棄却再抽選で保証する。jitter=0はRNG消費なしで旧接線モードと バイト一致。既存tangent configには旧挙動を明示する0.0を追記し、 tune/full120のneartangent configを新設。データ・manifest配線と検証つき。 --- ...l120_teacher_local_pca_h1_neartangent.yaml | 80 +++++++++++++++++++ ..._full120_teacher_local_pca_h1_tangent.yaml | 3 + ...local_pca_ellphi_power_h1_neartangent.yaml | 80 +++++++++++++++++++ ...une_local_pca_ellphi_power_h1_tangent.yaml | 3 + experiments/evaluate_paper_protocol.py | 10 ++- tda_ml/data_loader.py | 19 ++++- tda_ml/main.py | 10 ++- tda_ml/run_setup.py | 10 ++- tda_ml/tangent_outliers.py | 66 +++++++++++---- tests/test_tangent_outliers.py | 37 +++++++++ 10 files changed, 299 insertions(+), 19 deletions(-) create mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml create mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml new file mode 100644 index 0000000..af2b7f3 --- /dev/null +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml @@ -0,0 +1,80 @@ +# Paper no_cls production on NEAR-tangent outliers (GT-proximal anisotropic noise). +# Same stack as elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent, but +# the displacement direction gets a ±30° jitter cone around local-PCA PC1 and a +# semantic stroke clearance of 0.08 rejects candidates landing back on the digit. +# Production 30ep weights MUST come from an H1-only near-tangent Optuna best JSON +# (--tune-json). The w_* / lr values below are Optuna prior centres only. + +meta: + config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "ellphi" + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + # Prior centres for Optuna; paper production requires --tune-json overrides. + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + # GT-proximal anisotropic outliers: displace inliers within a ±30° cone + # around local-PCA PC1, rejected until >=0.08 from every clean inlier. + # noise_std matches the uniform baseline: inliers get the same isotropic + # jitter so only the OUTLIER distribution differs. + outlier_mode: local_pca_tangent + noise_std: 0.01 + tangent_pca_k: 10 + tangent_offset_min: 0.15 + tangent_offset_max: 0.40 + tangent_angle_jitter_deg: 30.0 + tangent_stroke_clearance: 0.08 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml index 918758e..86179fc 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml @@ -60,6 +60,9 @@ data: tangent_pca_k: 10 tangent_offset_min: 0.15 tangent_offset_max: 0.40 + # Pure tangent (no direction jitter, no semantic stroke clearance). + tangent_angle_jitter_deg: 0.0 + tangent_stroke_clearance: 0.0 seed: 42 train_size: 4500 val_size: 500 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml new file mode 100644 index 0000000..ff121cf --- /dev/null +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml @@ -0,0 +1,80 @@ +# H1-only tune on NEAR-tangent outliers (GT-proximal anisotropic noise). +# Same stack as elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent, but +# the displacement direction gets a ±30° jitter cone around local-PCA PC1 and a +# semantic stroke clearance of 0.08 (> median inlier NN spacing 0.064) rejects +# candidates landing back on the digit. Pure-tangent displacement follows the +# stroke, so >50% of those outliers were geometrically indistinguishable from +# inliers; near-tangent keeps the anisotropy while making the labels sound. + +meta: + config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden to ellphi by tune script + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 20 + grad_clip_value: 1.0 + visualize_every: 10000 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + # GT-proximal anisotropic outliers: displace inliers within a ±30° cone + # around local-PCA PC1, rejected until >=0.08 from every clean inlier. + # noise_std matches the uniform baseline: inliers get the same isotropic + # jitter so only the OUTLIER distribution differs. + outlier_mode: local_pca_tangent + noise_std: 0.01 + tangent_pca_k: 10 + tangent_offset_min: 0.15 + tangent_offset_max: 0.40 + tangent_angle_jitter_deg: 30.0 + tangent_stroke_clearance: 0.08 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml index 58d91e1..6088ed2 100644 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml @@ -58,6 +58,9 @@ data: tangent_pca_k: 10 tangent_offset_min: 0.15 tangent_offset_max: 0.40 + # Pure tangent (no direction jitter, no semantic stroke clearance). + tangent_angle_jitter_deg: 0.0 + tangent_stroke_clearance: 0.0 seed: 42 train_size: 4500 val_size: 500 diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index e7e56ea..1d59e66 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -174,7 +174,13 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) outlier_mode=outlier_mode, ) if outlier_mode == "local_pca_tangent": - for key in ("tangent_pca_k", "tangent_offset_min", "tangent_offset_max"): + for key in ( + "tangent_pca_k", + "tangent_offset_min", + "tangent_offset_max", + "tangent_angle_jitter_deg", + "tangent_stroke_clearance", + ): if key not in data_cfg: raise ValueError( f"data.{key} must be set explicitly for outlier_mode=local_pca_tangent" @@ -183,6 +189,8 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) tangent_pca_k=int(data_cfg["tangent_pca_k"]), tangent_offset_min=float(data_cfg["tangent_offset_min"]), tangent_offset_max=float(data_cfg["tangent_offset_max"]), + tangent_angle_jitter_deg=float(data_cfg["tangent_angle_jitter_deg"]), + tangent_stroke_clearance=float(data_cfg["tangent_stroke_clearance"]), ) dataset = NoisyMNISTDataset(**dataset_kwargs) loader = create_data_loader( diff --git a/tda_ml/data_loader.py b/tda_ml/data_loader.py index 213c19f..672f350 100644 --- a/tda_ml/data_loader.py +++ b/tda_ml/data_loader.py @@ -28,11 +28,16 @@ class NoisyMNISTDataset(Dataset): indices (torch.Tensor, optional): Explicit index subset to use. noise_seed (int): Base seed for deterministic noise generation. outlier_mode (str): ``uniform`` (``[-1,1]^2``) or ``local_pca_tangent`` - (displace along local PCA PC1 of the clean inliers). + (displace along/near local PCA PC1 of the clean inliers). tangent_pca_k (int): Neighbor count for local PCA when ``outlier_mode=local_pca_tangent``. tangent_offset_min / tangent_offset_max (float): Displacement magnitude range along PC1 (normalized coordinates). + tangent_angle_jitter_deg (float): Half-width of the uniform angular cone + around PC1 for the displacement direction (0 = exact tangent). + tangent_stroke_clearance (float): Semantic floor on the distance from an + outlier to every clean inlier; candidates landing back on the stroke + are rejected and retried (0 = disabled). Returns (per item): data (Tensor): Shape (max_points + num_outliers, 2). Shuffled point cloud. @@ -48,7 +53,9 @@ def __init__(self, root='./data', train=True, num_samples=5000, outlier_mode="uniform", tangent_pca_k=10, tangent_offset_min=0.15, - tangent_offset_max=0.40): + tangent_offset_max=0.40, + tangent_angle_jitter_deg=0.0, + tangent_stroke_clearance=0.0): self.max_points = max_points self.num_outliers = num_outliers self.noise_std = noise_std @@ -66,6 +73,8 @@ def __init__(self, root='./data', train=True, num_samples=5000, self.tangent_pca_k = int(tangent_pca_k) self.tangent_offset_min = float(tangent_offset_min) self.tangent_offset_max = float(tangent_offset_max) + self.tangent_angle_jitter_deg = float(tangent_angle_jitter_deg) + self.tangent_stroke_clearance = float(tangent_stroke_clearance) full_dataset = datasets.MNIST(root, train=train, download=True) @@ -206,6 +215,12 @@ def __getitem__(self, idx): offset_max=self.tangent_offset_max, generator=rng, existing_points=inliers, + angle_jitter_deg=self.tangent_angle_jitter_deg, + stroke_clearance=self.tangent_stroke_clearance, + # Stroke-clearance rejection lowers per-attempt acceptance on + # straight strokes; give the sampler more retries before the + # hard-fail. + max_attempts=400, ) else: outliers = torch.empty(0, 2) diff --git a/tda_ml/main.py b/tda_ml/main.py index c3011d9..aa0266d 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -109,7 +109,13 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): "num_outliers": int(data_cfg["num_outliers"]), } if outlier_mode == "local_pca_tangent": - for key in ("tangent_pca_k", "tangent_offset_min", "tangent_offset_max"): + for key in ( + "tangent_pca_k", + "tangent_offset_min", + "tangent_offset_max", + "tangent_angle_jitter_deg", + "tangent_stroke_clearance", + ): if key not in data_cfg: raise ValueError( f"data.{key} must be set explicitly for outlier_mode=local_pca_tangent" @@ -118,6 +124,8 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): tangent_pca_k=int(data_cfg["tangent_pca_k"]), tangent_offset_min=float(data_cfg["tangent_offset_min"]), tangent_offset_max=float(data_cfg["tangent_offset_max"]), + tangent_angle_jitter_deg=float(data_cfg["tangent_angle_jitter_deg"]), + tangent_stroke_clearance=float(data_cfg["tangent_stroke_clearance"]), ) manifest = { "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), diff --git a/tda_ml/run_setup.py b/tda_ml/run_setup.py index f977050..0089559 100644 --- a/tda_ml/run_setup.py +++ b/tda_ml/run_setup.py @@ -120,7 +120,13 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): ) if outlier_mode == "local_pca_tangent": - for key in ("tangent_pca_k", "tangent_offset_min", "tangent_offset_max"): + for key in ( + "tangent_pca_k", + "tangent_offset_min", + "tangent_offset_max", + "tangent_angle_jitter_deg", + "tangent_stroke_clearance", + ): if key not in data_cfg: raise ValueError( f"data.{key} must be set explicitly for outlier_mode=local_pca_tangent" @@ -129,6 +135,8 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): tangent_pca_k=int(data_cfg["tangent_pca_k"]), tangent_offset_min=float(data_cfg["tangent_offset_min"]), tangent_offset_max=float(data_cfg["tangent_offset_max"]), + tangent_angle_jitter_deg=float(data_cfg["tangent_angle_jitter_deg"]), + tangent_stroke_clearance=float(data_cfg["tangent_stroke_clearance"]), ) train_dataset = NoisyMNISTDataset(train=True, indices=train_indices, **dataset_kwargs) diff --git a/tda_ml/tangent_outliers.py b/tda_ml/tangent_outliers.py index 01608c2..3a35155 100644 --- a/tda_ml/tangent_outliers.py +++ b/tda_ml/tangent_outliers.py @@ -1,7 +1,9 @@ -"""Place outliers along local-PCA first principal axes (tangent directions).""" +"""Place outliers along (or near) local-PCA first principal axes (tangent directions).""" from __future__ import annotations +import math + import torch from tda_ml.local_pca import local_pca_ellipse_params @@ -21,23 +23,31 @@ def sample_local_pca_tangent_outliers( box_max: float = 1.0, min_separation: float = MIN_TANGENT_OUTLIER_SEPARATION, existing_points: torch.Tensor | None = None, + angle_jitter_deg: float = 0.0, + stroke_clearance: float = 0.0, ) -> torch.Tensor: """ - Sample outliers by displacing random inliers along local PCA PC1. + Sample outliers by displacing random inliers along (or near) local PCA PC1. Local PCA is computed on ``inliers`` only (no isotropic jitter). Each outlier - is ``p + s * u1`` where ``u1 = (cos θ, sin θ)`` is the major-axis direction - at a randomly chosen inlier ``p``, and ``s`` has random sign with + is ``p + s * u`` where ``u = (cos φ, sin φ)`` with ``φ = θ + Uniform(-j, j)``, + ``θ`` the major-axis direction at a randomly chosen inlier ``p``, ``j`` the + ``angle_jitter_deg`` cone (0 → exact tangent), and ``s`` has random sign with ``|s| ~ Uniform(offset_min, offset_max)``. - Candidates that leave ``[box_min, box_max]^2`` or land within - ``min_separation`` of the separation reference set or already-accepted - outliers are retried: two nearly coincident centers make the ellphi - tangency derivative w.r.t. the center undefined. The separation reference - defaults to ``inliers``; pass ``existing_points`` (e.g. noisy inliers that - enter the cloud) when PCA geometry and cloud geometry differ. Exhausting - ``max_attempts`` hard-fails; candidates are never clipped into the box nor - merged onto an existing point. + Rejection rules (candidates are retried, never clipped or merged): + + - leaves ``[box_min, box_max]^2``; + - within ``min_separation`` of the separation reference set or an accepted + outlier (numerical: near-coincident centers make the ellphi tangency + derivative w.r.t. the center undefined); + - within ``stroke_clearance`` of ANY inlier (semantic: pure-tangent + displacement follows the stroke, so without this floor most candidates + land back on the digit and the outlier label contradicts the geometry). + + The separation reference defaults to ``inliers``; pass ``existing_points`` + (e.g. noisy inliers that enter the cloud) when PCA geometry and cloud + geometry differ. Exhausting ``max_attempts`` hard-fails. """ if num_outliers <= 0: return torch.empty(0, 2, dtype=inliers.dtype, device=inliers.device) @@ -52,6 +62,12 @@ def sample_local_pca_tangent_outliers( ) if min_separation < 0: raise ValueError(f"min_separation must be >= 0; got {min_separation}") + if angle_jitter_deg < 0 or angle_jitter_deg > 90: + raise ValueError( + f"angle_jitter_deg must be in [0, 90]; got {angle_jitter_deg}" + ) + if stroke_clearance < 0: + raise ValueError(f"stroke_clearance must be >= 0; got {stroke_clearance}") sep_ref = inliers if existing_points is None else existing_points if sep_ref.ndim != 2 or sep_ref.shape[-1] != 2: raise ValueError( @@ -62,6 +78,7 @@ def sample_local_pca_tangent_outliers( theta = params[:, 2] direction = torch.stack([torch.cos(theta), torch.sin(theta)], dim=-1) + jitter_rad = math.radians(angle_jitter_deg) outliers = torch.empty(num_outliers, 2, dtype=inliers.dtype, device=inliers.device) for j in range(num_outliers): for _ in range(max_attempts): @@ -73,10 +90,29 @@ def sample_local_pca_tangent_outliers( idx = int(torch.randint(0, n, (1,)).item()) u = float(torch.rand(1).item()) sign = 1.0 if float(torch.rand(1).item()) < 0.5 else -1.0 + if jitter_rad > 0: + # Draw only when jitter is enabled so jitter=0 preserves the + # exact RNG stream (and thus the clouds) of the pure-tangent mode. + if generator is not None: + dth = (float(torch.rand(1, generator=generator).item()) * 2 - 1) * jitter_rad + else: + dth = (float(torch.rand(1).item()) * 2 - 1) * jitter_rad + ang = float(theta[idx]) + dth + direction_j = torch.tensor( + [math.cos(ang), math.sin(ang)], + dtype=inliers.dtype, + device=inliers.device, + ) + else: + direction_j = direction[idx] mag = offset_min + (offset_max - offset_min) * u - cand = inliers[idx] + (sign * mag) * direction[idx] + cand = inliers[idx] + (sign * mag) * direction_j if not bool(torch.all((cand >= box_min) & (cand <= box_max)).item()): continue + if stroke_clearance > 0: + d_stroke = torch.linalg.norm(inliers - cand, dim=-1).min() + if bool((d_stroke < stroke_clearance).item()): + continue if min_separation > 0: d_ref = torch.linalg.norm(sep_ref - cand, dim=-1).min() if bool((d_ref < min_separation).item()): @@ -93,6 +129,8 @@ def sample_local_pca_tangent_outliers( f"{max_attempts} attempts (outlier_index={j}, " f"box=[{box_min}, {box_max}], " f"offset=[{offset_min}, {offset_max}], " - f"min_separation={min_separation})." + f"min_separation={min_separation}, " + f"angle_jitter_deg={angle_jitter_deg}, " + f"stroke_clearance={stroke_clearance})." ) return outliers diff --git a/tests/test_tangent_outliers.py b/tests/test_tangent_outliers.py index a007b11..25588cf 100644 --- a/tests/test_tangent_outliers.py +++ b/tests/test_tangent_outliers.py @@ -54,6 +54,43 @@ def test_min_separation_enforced(self): d_out.fill_diagonal_(float("inf")) self.assertGreaterEqual(float(d_out.min()), min_sep) + def test_stroke_clearance_enforced(self): + # Near-tangent mode: outliers must stay >= stroke_clearance from every + # inlier so the outlier label never contradicts the geometry. + g = torch.Generator().manual_seed(5) + inliers = torch.stack( + [torch.linspace(-0.5, 0.5, 40), torch.zeros(40)], dim=1 + ) + clearance = 0.08 + outliers = sample_local_pca_tangent_outliers( + inliers, 12, k=8, offset_min=0.15, offset_max=0.40, + generator=g, angle_jitter_deg=30.0, stroke_clearance=clearance, + max_attempts=400, + ) + d_inl = torch.cdist(outliers, inliers).min() + self.assertGreaterEqual(float(d_inl), clearance) + + def test_zero_jitter_matches_pure_tangent_stream(self): + # angle_jitter_deg=0 must not consume extra RNG draws, so the sampled + # outliers are byte-identical to the historical pure-tangent mode. + inliers = torch.randn(30, 2) + g1 = torch.Generator().manual_seed(11) + g2 = torch.Generator().manual_seed(11) + a = sample_local_pca_tangent_outliers(inliers, 5, generator=g1) + b = sample_local_pca_tangent_outliers( + inliers, 5, generator=g2, angle_jitter_deg=0.0, stroke_clearance=0.0 + ) + self.assertTrue(torch.equal(a, b)) + + def test_invalid_jitter_and_clearance_rejected(self): + inliers = torch.randn(30, 2) + with self.assertRaises(ValueError): + sample_local_pca_tangent_outliers(inliers, 5, angle_jitter_deg=-1.0) + with self.assertRaises(ValueError): + sample_local_pca_tangent_outliers(inliers, 5, angle_jitter_deg=91.0) + with self.assertRaises(ValueError): + sample_local_pca_tangent_outliers(inliers, 5, stroke_clearance=-0.1) + def test_min_separation_unsatisfiable_hard_fails(self): # A tiny box with a large min_separation cannot be satisfied -> hard-fail # rather than emitting near-coincident (degenerate) outliers. From b145c3c94806edc804bf6c201614a676fd6596c5 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Sat, 18 Jul 2026 14:04:32 +0900 Subject: [PATCH 29/44] =?UTF-8?q?fix:=20Optuna=20study=E3=82=92=E3=83=AF?= =?UTF-8?q?=E3=83=BC=E3=82=AB=E3=83=BC=E8=B5=B7=E5=8B=95=E5=89=8D=E3=81=AB?= =?UTF-8?q?=E4=B8=80=E5=BA=A6=E3=81=A0=E3=81=91=E4=BD=9C=E6=88=90=E3=81=97?= =?UTF-8?q?sqlite=E3=82=B9=E3=82=AD=E3=83=BC=E3=83=9E=E7=AB=B6=E5=90=88?= =?UTF-8?q?=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新規study.dbへの並列create_study(load_if_exists=True)がalembicスキーマ移行で 競合し("table alembic_version already exists")、負けたワーカーが起動直後に 死んで総trial数が黙って不足する。ワーカー起動前に単独プロセスでstudyを 初期化する。 --- .../run_tune_local_pca_power_wdist_parallel.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/experiments/run_tune_local_pca_power_wdist_parallel.sh b/experiments/run_tune_local_pca_power_wdist_parallel.sh index 201552a..cc2f22e 100755 --- a/experiments/run_tune_local_pca_power_wdist_parallel.sh +++ b/experiments/run_tune_local_pca_power_wdist_parallel.sh @@ -43,6 +43,24 @@ EOF echo "Power W-Dist tune: ${N_WORKERS} workers, ${N_TRIALS} trials, ${TUNE_EPOCHS}ep, topo=${BACKEND}" echo "OUT_BASE=${OUT_BASE}" +# Create the Optuna study once before spawning workers: concurrent +# create_study(load_if_exists=True) on a fresh sqlite file races inside the +# alembic schema migration ("table alembic_version already exists") and kills +# the losing worker at startup. +STUDY_NAME="${STUDY_NAME}" STORAGE="${STORAGE}" uv run python - <<'PY' +import os + +import optuna + +optuna.create_study( + study_name=os.environ["STUDY_NAME"], + storage=os.environ["STORAGE"], + direction="minimize", + load_if_exists=True, +) +print(f"study initialized: {os.environ['STUDY_NAME']}") +PY + pids=() for i in $(seq 0 $((N_WORKERS - 1))); do SEED=$((42 + i)) From 5d25ad135601f87dc1375f1267300d8bd2c2ea5b Mon Sep 17 00:00:00 2001 From: koki3070 Date: Sat, 18 Jul 2026 15:53:44 +0900 Subject: [PATCH 30/44] =?UTF-8?q?fix:=20enqueue=5Ftop=5Foptuna=5Ftrials?= =?UTF-8?q?=E3=81=AE--append-existing=E5=BE=A9=E6=97=A7=E6=99=82=E3=81=AB?= =?UTF-8?q?=E6=97=A2=E5=AD=98out-dir=E3=82=92=E8=A8=B1=E5=8F=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 復旧用フラグなのに既存ディレクトリでFileExistsErrorになり追加投入できなかった。 append時はrank範囲付きの別名REFINEMENT_PREFLIGHTを書き、初回分を上書きしない。 --- experiments/enqueue_top_optuna_trials.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/experiments/enqueue_top_optuna_trials.py b/experiments/enqueue_top_optuna_trials.py index d90f3d5..ed6e93c 100755 --- a/experiments/enqueue_top_optuna_trials.py +++ b/experiments/enqueue_top_optuna_trials.py @@ -45,7 +45,7 @@ def main() -> int: raise ValueError(f"top-k must be >= 1, got {args.top_k}") if args.rank_start < 1: raise ValueError(f"rank-start must be >= 1, got {args.rank_start}") - if args.out_dir.exists(): + if args.out_dir.exists() and not args.append_existing: raise FileExistsError(f"Refinement output already exists: {args.out_dir}") source = optuna.load_study( @@ -71,7 +71,7 @@ def main() -> int: f"Source trial {trial.number} is missing parameters {missing}" ) - args.out_dir.mkdir(parents=True) + args.out_dir.mkdir(parents=True, exist_ok=args.append_existing) target = optuna.create_study( study_name=args.target_study, storage=args.target_storage, @@ -114,7 +114,15 @@ def main() -> int: ], "fallbacks": [], } - (args.out_dir / "REFINEMENT_PREFLIGHT.json").write_text( + if args.append_existing: + # Recovery append: keep the original preflight intact for provenance. + preflight_name = f"REFINEMENT_PREFLIGHT_rank{args.rank_start}-{rank_stop}.json" + else: + preflight_name = "REFINEMENT_PREFLIGHT.json" + preflight_path = args.out_dir / preflight_name + if preflight_path.exists(): + raise FileExistsError(f"Refinement preflight already exists: {preflight_path}") + preflight_path.write_text( json.dumps(manifest, indent=2) + "\n", encoding="utf-8", ) From 64bd34f8430106b0752bb27c6852ab845bcde785 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Sun, 19 Jul 2026 10:08:57 +0900 Subject: [PATCH 31/44] =?UTF-8?q?feat:=20=E9=80=80=E5=8C=96=E3=82=AC?= =?UTF-8?q?=E3=83=BC=E3=83=89elongate=5Fbarrier=E3=82=92opt-in=E3=81=AE?= =?UTF-8?q?=E7=AC=AC2=E5=A5=91=E7=B4=84variant=E3=81=A8=E3=81=97=E3=81=A6?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit near-tangentデータで素のelongateが短軸を~1e-5(アスペクト比~350)まで潰し、 ellphi tangencyがhard-failしてstage-2 tune 16条件中15条件が死んだ。対策として elongate報酬+閾値超過アスペクト比への二次バリアのvariantを明示宣言式で導入: - PAPER_NO_CLS_BARRIER_CONTRACT(aniso_barrier_threshold=6.0必須)を宣言し、 assert_paper_no_cls_contractはloss.aniso_mode宣言でvariantを選択 - paper_aniso_fields()でbase configの宣言をtune/本番overridesへ伝播 (従来の'elongate'ハードコードを廃止、非契約modeはhard-fail) - trainer/preflightはbarrier系modeでaniso_barrier_threshold未指定をhard-fail (暗黙の6.0デフォルト禁止) - 30ep本番はaniso_barrier_thresholdをRUN_PLAN/manifestのloss_overridesに記録、 paper評価はloss_overridesからbarrier閾値を復元 - aggregate_power_30ep_multiseedに--aniso-variantを追加し契約一致を検証 - near-tangent barrier用のtune/full120 configを追加 - barrier勾配の退化回復テスト、契約variantテスト、tune配線テストを追加 --- REPRODUCIBILITY.md | 2 + ...cher_local_pca_h1_neartangent_barrier.yaml | 82 ++++++++++++++++++ ...a_ellphi_power_h1_neartangent_barrier.yaml | 84 +++++++++++++++++++ experiments/aggregate_power_30ep_multiseed.py | 24 +++++- experiments/evaluate_paper_protocol.py | 3 +- .../run_teacher_local_pca_power_30ep.py | 8 +- experiments/tune_elongate_mcc.py | 8 +- experiments/tune_elongate_wdist.py | 9 +- tda_ml/preflight.py | 78 ++++++++++++++++- tda_ml/trainer.py | 9 ++ tests/test_ellipse_barrier_losses.py | 21 +++++ tests/test_reproducibility_strict.py | 46 ++++++++++ tests/test_tune_config.py | 38 +++++++++ 13 files changed, 396 insertions(+), 16 deletions(-) create mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml create mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index cece9e1..4d4836e 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -33,6 +33,8 @@ 主表・チューニングの preflight は `homology_dimensions=[1]`、`teacher_mode=local_pca`、`prob_weighting=false`、`aniso_mode=elongate`、`distance_backend=ellphi`、`size_mode=power`、`w_class=0.0`、`teacher_local_pca_k=10`、`teacher_local_pca_normalize_axes=true` の明示を要求する。欠落や不一致は実行前に hard-fail する。本番 30ep は `--tune-json`(H1-only Optuna best)必須で、YAML 埋め込みの旧重みでは起動しない。 +**退化ガード variant(opt-in):** near-tangent データでは素の `elongate` が短軸→0(epoch 15 で短半径 ~1e-5、アスペクト比 ~350)まで潰し、ellphi の tangency 計算が hard-fail する(stage-2 tune 16 条件中 15 失敗)。この対策として `aniso_mode: elongate_barrier`(elongate 報酬 + 閾値超過アスペクト比への二次バリア)を **config で明示宣言する第 2 の契約 variant**(`PAPER_NO_CLS_BARRIER_CONTRACT`、`aniso_barrier_threshold=6.0` 必須)として定義する。暗黙の切替・clamp は行わず、variant は base config の `loss.aniso_mode` 宣言から `paper_aniso_fields()` 経由で tune/本番へ伝播し、STUDY_PREFLIGHT・tune JSON・run manifest(`paper_no_cls_contract`/`loss_overrides`)に記録される。集計は `aggregate_power_30ep_multiseed.py --aniso-variant elongate_barrier` で契約一致を検証する。 + ## 環境 - **Python**: `.python-version` を参照(現状 3.12)。 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml new file mode 100644 index 0000000..5bfb6bd --- /dev/null +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml @@ -0,0 +1,82 @@ +# Paper no_cls production on NEAR-tangent outliers with the elongate_barrier +# degeneracy guard. Identical to elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent +# except loss.aniso_mode: elongate_barrier (+ explicit aniso_barrier_threshold). +# See the tune config of the same suffix for the motivation (minor-axis collapse +# under plain elongate killed 15/16 stage-2 tune conditions via ellphi hard-fail). +# Production 30ep weights MUST come from an H1-only near-tangent barrier Optuna +# best JSON (--tune-json). The w_* / lr values below are Optuna prior centres only. + +meta: + config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "ellphi" + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + # Prior centres for Optuna; paper production requires --tune-json overrides. + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate_barrier + aniso_barrier_threshold: 6.0 + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + # GT-proximal anisotropic outliers: displace inliers within a ±30° cone + # around local-PCA PC1, rejected until >=0.08 from every clean inlier. + # noise_std matches the uniform baseline: inliers get the same isotropic + # jitter so only the OUTLIER distribution differs. + outlier_mode: local_pca_tangent + noise_std: 0.01 + tangent_pca_k: 10 + tangent_offset_min: 0.15 + tangent_offset_max: 0.40 + tangent_angle_jitter_deg: 30.0 + tangent_stroke_clearance: 0.08 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml new file mode 100644 index 0000000..ea9a3ca --- /dev/null +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml @@ -0,0 +1,84 @@ +# H1-only tune on NEAR-tangent outliers with the elongate_barrier degeneracy guard. +# Identical to elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent +# except loss.aniso_mode: elongate_barrier (+ explicit aniso_barrier_threshold). +# +# Why: on near-tangent data, plain `elongate` rewards minor/major -> 0 with no +# counterweight; by epoch 10-20 minor semi-axes collapsed to ~1e-5 (aspect +# ~350) and ellphi tangency hard-failed on the needle geometry (15/16 stage-2 +# conditions died). The barrier keeps the elongate reward below the aspect +# ceiling and pushes back quadratically above it, bounding minor >= major/T. +# T=6.0 matches the declared PAPER_NO_CLS_BARRIER_CONTRACT constant. + +meta: + config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden to ellphi by tune script + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate_barrier + aniso_barrier_threshold: 6.0 + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 20 + grad_clip_value: 1.0 + visualize_every: 10000 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + # GT-proximal anisotropic outliers: displace inliers within a ±30° cone + # around local-PCA PC1, rejected until >=0.08 from every clean inlier. + # noise_std matches the uniform baseline: inliers get the same isotropic + # jitter so only the OUTLIER distribution differs. + outlier_mode: local_pca_tangent + noise_std: 0.01 + tangent_pca_k: 10 + tangent_offset_min: 0.15 + tangent_offset_max: 0.40 + tangent_angle_jitter_deg: 30.0 + tangent_stroke_clearance: 0.08 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/experiments/aggregate_power_30ep_multiseed.py b/experiments/aggregate_power_30ep_multiseed.py index 9cec444..9ebd415 100644 --- a/experiments/aggregate_power_30ep_multiseed.py +++ b/experiments/aggregate_power_30ep_multiseed.py @@ -11,7 +11,11 @@ import numpy as np -from tda_ml.preflight import PAPER_NO_CLS_CONTRACT, preflight_tune_json +from tda_ml.preflight import ( + PAPER_NO_CLS_BARRIER_CONTRACT, + PAPER_NO_CLS_CONTRACT, + preflight_tune_json, +) from tda_ml.supervised_diagnostics import git_revision REPO_ROOT = Path(__file__).resolve().parents[1] @@ -166,11 +170,25 @@ def parse_args() -> argparse.Namespace: default=["wdist", "mcc"], help="Which tune objectives to aggregate (single-mode drivers pass one).", ) + p.add_argument( + "--aniso-variant", + choices=["elongate", "elongate_barrier"], + default="elongate", + help=( + "Declared paper contract variant the tune JSONs must match " + "(elongate_barrier = degeneracy-guard stack)." + ), + ) return p.parse_args() def main() -> int: args = parse_args() + expected_contract = ( + PAPER_NO_CLS_BARRIER_CONTRACT + if args.aniso_variant == "elongate_barrier" + else PAPER_NO_CLS_CONTRACT + ) tune_paths = { "wdist": Path(args.wdist_tune_json), "mcc": Path(args.mcc_tune_json), @@ -179,7 +197,7 @@ def main() -> int: path = tune_paths[key] if not path.is_absolute(): path = REPO_ROOT / path - preflight_tune_json(path, expected_contract=PAPER_NO_CLS_CONTRACT) + preflight_tune_json(path, expected_contract=expected_contract) tune_paths[key] = path.resolve() wdist_by_seed = discover_seed_metrics( @@ -219,7 +237,7 @@ def main() -> int: "mcc_out": str(args.mcc_out), "wdist_tune_json": str(tune_paths["wdist"]), "mcc_tune_json": str(tune_paths["mcc"]), - "paper_no_cls_contract": PAPER_NO_CLS_CONTRACT, + "paper_no_cls_contract": expected_contract, "per_seed": { "wdist": wdist_by_seed, "mcc": mcc_by_seed, diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index 1d59e66..069604c 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -322,6 +322,7 @@ def load_run_config(run_dir: Path, base_config: str, seed: int | None) -> dict[s key: loss_overrides[key] for key in ( "aniso_mode", + "aniso_barrier_threshold", "size_mode", "size_ref", "size_power", @@ -329,7 +330,7 @@ def load_run_config(run_dir: Path, base_config: str, seed: int | None) -> dict[s "w_aniso", "w_size", ) - if key in loss_overrides + if key in loss_overrides and loss_overrides[key] is not None } patch: dict[str, Any] = {} if loss_patch: diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_teacher_local_pca_power_30ep.py index 53438aa..101b8e7 100644 --- a/experiments/run_teacher_local_pca_power_30ep.py +++ b/experiments/run_teacher_local_pca_power_30ep.py @@ -17,6 +17,7 @@ from tda_ml.preflight import ( # noqa: E402 assert_paper_no_cls_contract, classify_tune_objective, + paper_aniso_fields, preflight_tune_production_run, ) @@ -132,6 +133,7 @@ def main() -> int: size_ref = tune_weights["size_ref"] size_power = tune_weights["size_power"] + aniso_fields = paper_aniso_fields(cfg) preflight_tune_production_run( base_config=args.base_config, tune_json=tune_json, @@ -140,7 +142,7 @@ def main() -> int: preflight_filename=f"RUN_PREFLIGHT_s{args.seed}.json", config_overrides={ "loss": { - "aniso_mode": "elongate", + **aniso_fields, "size_mode": "power", "size_ref": size_ref, "size_power": size_power, @@ -159,7 +161,7 @@ def main() -> int: ) loss_overrides: dict = { - "aniso_mode": "elongate", + **aniso_fields, "size_mode": "power", "size_ref": size_ref, "size_power": size_power, @@ -208,6 +210,7 @@ def main() -> int: "tune_json": tune_source, "dbscan_backend": args.dbscan_backend, "aniso_mode": cfg["loss"]["aniso_mode"], + "aniso_barrier_threshold": aniso_fields.get("aniso_barrier_threshold"), "homology_dimensions": cfg["model"]["topology_loss"]["homology_dimensions"], "paper_no_cls_contract": paper_contract, "protocol_note": ( @@ -232,6 +235,7 @@ def main() -> int: "w_aniso": purpose["weights"]["w_aniso"], "w_size": purpose["weights"]["w_size"], "aniso_mode": purpose.get("aniso_mode"), + "aniso_barrier_threshold": purpose.get("aniso_barrier_threshold"), "homology_dimensions": purpose.get("homology_dimensions"), }, } diff --git a/experiments/tune_elongate_mcc.py b/experiments/tune_elongate_mcc.py index ba40afc..6fef4f0 100644 --- a/experiments/tune_elongate_mcc.py +++ b/experiments/tune_elongate_mcc.py @@ -37,7 +37,7 @@ from tda_ml.config import deep_update, load_config from tda_ml.dbscan_eval import evaluate_model_grid from tda_ml.main import main as train_main -from tda_ml.preflight import preflight_mcc_tune_study +from tda_ml.preflight import paper_aniso_fields, preflight_mcc_tune_study from tda_ml.reproducibility import reproducibility_settings, write_json from tda_ml.supervised_diagnostics import git_revision from tda_ml.topo_wdist import topo_wdist_options_from_config @@ -84,7 +84,8 @@ def build_trial_config( "w_aniso": float(w_aniso), "w_size": float(w_size), "w_topo": float(w_topo), - "aniso_mode": "elongate", + # aniso variant mirrors the base config declaration. + **paper_aniso_fields(cfg), "size_mode": size_mode, "size_ref": float(size_ref), "size_power": float(size_power), @@ -247,6 +248,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() Path(args.out_base).mkdir(parents=True, exist_ok=True) + base_cfg_for_aniso = load_config(args.base_config, project_root=REPO_ROOT) preflight = preflight_mcc_tune_study( base_config=args.base_config, project_root=REPO_ROOT, @@ -260,7 +262,7 @@ def main() -> int: } }, "loss": { - "aniso_mode": "elongate", + **paper_aniso_fields(base_cfg_for_aniso), "size_mode": args.size_mode, "size_ref": args.size_ref, "size_power": args.size_power, diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py index f0abaa8..4e2f409 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_elongate_wdist.py @@ -34,7 +34,7 @@ from tda_ml.checkpoint_io import resolve_val_topo_checkpoint from tda_ml.config import deep_update, load_config from tda_ml.main import main as train_main -from tda_ml.preflight import preflight_wdist_tune_study +from tda_ml.preflight import paper_aniso_fields, preflight_wdist_tune_study from tda_ml.supervised_diagnostics import git_revision from tda_ml.topo_wdist import TopoWdistOptions, compute_topo_wdist, topo_wdist_options_from_config @@ -97,7 +97,9 @@ def build_trial_config( "w_size": float(w_size), "w_topo": float(w_topo), # Mirror paper contract / STUDY_PREFLIGHT overrides onto every trial. - "aniso_mode": "elongate", + # aniso variant (elongate vs elongate_barrier) comes from the base + # config declaration; hard-fails if the base config omits it. + **paper_aniso_fields(cfg), "size_mode": size_mode, "size_ref": float(size_ref), "size_power": float(size_power), @@ -251,6 +253,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() Path(args.out_base).mkdir(parents=True, exist_ok=True) + base_cfg_for_aniso = load_config(args.base_config, project_root=REPO_ROOT) preflight = preflight_wdist_tune_study( base_config=args.base_config, project_root=REPO_ROOT, @@ -264,7 +267,7 @@ def main() -> int: } }, "loss": { - "aniso_mode": "elongate", + **paper_aniso_fields(base_cfg_for_aniso), "size_mode": args.size_mode, "size_ref": args.size_ref, "size_power": args.size_power, diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py index 2b72103..1e5df4d 100644 --- a/tda_ml/preflight.py +++ b/tda_ml/preflight.py @@ -42,11 +42,28 @@ "teacher_local_pca_normalize_axes": True, } +# Opt-in degeneracy-guard variant: identical stack, but the anisotropy loss is +# ``elongate_barrier`` (elongate reward + quadratic barrier on aspect ratios +# above the declared threshold). Motivated by near-tangent tuning where plain +# ``elongate`` drove minor axes to ~1e-5 / aspect ~350 by epoch 15 and ellphi +# tangency hard-failed on the needle geometry. +PAPER_NO_CLS_BARRIER_ANISO_THRESHOLD = 6.0 +PAPER_NO_CLS_BARRIER_CONTRACT: dict[str, Any] = { + **PAPER_NO_CLS_CONTRACT, + "aniso_mode": "elongate_barrier", + "aniso_barrier_threshold": PAPER_NO_CLS_BARRIER_ANISO_THRESHOLD, +} + _KNOWN_TEACHER_MODES = frozenset({"euclidean", "local_pca"}) def assert_paper_no_cls_contract(config: dict[str, Any]) -> dict[str, Any]: - """Require the declared H1-only paper method; never infer missing fields.""" + """Require a declared H1-only paper method variant; never infer missing fields. + + Two declared variants exist, selected explicitly by ``loss.aniso_mode``: + ``elongate`` (original) and ``elongate_barrier`` (degeneracy guard, which + additionally requires ``loss.aniso_barrier_threshold``). + """ topo = (config.get("model") or {}).get("topology_loss") or {} loss = config.get("loss") or {} actual: dict[str, Any] = {} @@ -73,9 +90,18 @@ def assert_paper_no_cls_contract(config: dict[str, Any]) -> dict[str, Any]: if "aniso_mode" not in loss: raise ValueError( - "Paper no_cls config must explicitly define loss.aniso_mode='elongate'" + "Paper no_cls config must explicitly define loss.aniso_mode " + "('elongate' or 'elongate_barrier')" ) actual["aniso_mode"] = str(loss["aniso_mode"]).strip().lower() + if actual["aniso_mode"] == "elongate_barrier": + if "aniso_barrier_threshold" not in loss: + raise ValueError( + "Paper no_cls barrier variant must explicitly define " + "loss.aniso_barrier_threshold " + f"(declared value: {PAPER_NO_CLS_BARRIER_ANISO_THRESHOLD})" + ) + actual["aniso_barrier_threshold"] = float(loss["aniso_barrier_threshold"]) if "distance_backend" not in topo: raise ValueError( @@ -111,14 +137,49 @@ def assert_paper_no_cls_contract(config: dict[str, Any]) -> dict[str, Any]: loss["teacher_local_pca_normalize_axes"] ) - if actual != PAPER_NO_CLS_CONTRACT: + expected = ( + PAPER_NO_CLS_BARRIER_CONTRACT + if actual.get("aniso_mode") == "elongate_barrier" + else PAPER_NO_CLS_CONTRACT + ) + if actual != expected: raise ValueError( "Paper no_cls contract mismatch: " - f"expected={PAPER_NO_CLS_CONTRACT}, actual={actual}" + f"expected={expected}, actual={actual}" ) return actual +def paper_aniso_fields(config: dict[str, Any]) -> dict[str, Any]: + """Extract the declared anisotropy variant fields from a config. + + Returns ``{"aniso_mode": ...}`` plus ``aniso_barrier_threshold`` for the + barrier variant. Hard-fails on missing declarations so scripts mirror the + base config instead of hardcoding a variant. + """ + loss = config.get("loss") or {} + if "aniso_mode" not in loss: + raise ValueError( + "loss.aniso_mode must be set explicitly " + "('elongate' or 'elongate_barrier')" + ) + mode = str(loss["aniso_mode"]).strip().lower() + if mode not in ("elongate", "elongate_barrier"): + raise ValueError( + f"loss.aniso_mode={mode!r} is not a declared paper variant " + "('elongate' or 'elongate_barrier')" + ) + fields: dict[str, Any] = {"aniso_mode": mode} + if mode == "elongate_barrier": + if "aniso_barrier_threshold" not in loss: + raise ValueError( + "loss.aniso_barrier_threshold must be set explicitly for " + "aniso_mode='elongate_barrier'" + ) + fields["aniso_barrier_threshold"] = float(loss["aniso_barrier_threshold"]) + return fields + + def _require_import(name: str, import_fn) -> None: try: import_fn() @@ -208,6 +269,15 @@ def preflight_training_config( raise ValueError( f"loss.{key} must be set explicitly; refusing silent Trainer defaults" ) + aniso_mode = str( + loss.get("aniso_mode", training.get("aniso_mode")) + ).strip().lower() + if aniso_mode in ("barrier", "elongate_barrier"): + if "aniso_barrier_threshold" not in loss and "barrier_threshold" not in training: + raise ValueError( + "loss.aniso_barrier_threshold must be set explicitly for " + f"aniso_mode={aniso_mode!r}; refusing silent 6.0 default" + ) ellphi_diff = bool(topo.get("ellphi_differentiable", True)) if backend == "ellphi": _require_import("ellphi", lambda: __import__("ellphi")) diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index 23fba91..b8e423f 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -113,6 +113,15 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: "loss.aniso_mode must be set explicitly; refusing silent linear default" ) self.aniso_mode = str(aniso_raw).strip().lower() + if self.aniso_mode in ("barrier", "elongate_barrier"): + if ( + "aniso_barrier_threshold" not in loss_cfg + and "barrier_threshold" not in training_cfg + ): + raise ValueError( + "loss.aniso_barrier_threshold must be set explicitly for " + f"aniso_mode={self.aniso_mode!r}; refusing silent 6.0 default" + ) self.aniso_barrier_threshold = float( loss_cfg.get( "aniso_barrier_threshold", diff --git a/tests/test_ellipse_barrier_losses.py b/tests/test_ellipse_barrier_losses.py index 731d0cb..0775e25 100644 --- a/tests/test_ellipse_barrier_losses.py +++ b/tests/test_ellipse_barrier_losses.py @@ -43,6 +43,27 @@ def test_elongate_barrier_penalizes_above_threshold(self): bad = torch.tensor([[[6.0, 1.0, 0.0]]]) # ratio=6 self.assertLess(float(loss_fn(ok).item()), float(loss_fn(bad).item())) + def test_elongate_barrier_gradient_recovers_needle(self): + # Degeneracy guard: for needle geometry (aspect >> T) the barrier must + # dominate the elongate reward and push the minor axis back up. + loss_fn = AnisotropyPenaltyLoss( + weight=1.0, mode="elongate_barrier", barrier_threshold=6.0 + ) + needle = torch.tensor([[[0.35, 1e-4, 0.0]]], requires_grad=True) # ratio 3500 + loss_fn(needle).backward() + grad_minor = needle.grad[0, 0, 1].item() + # descending the loss must increase the minor axis + self.assertLess(grad_minor, 0.0) + + def test_plain_elongate_gradient_shrinks_minor_axis(self): + # Documents the failure mode motivating the barrier: plain elongate always + # rewards minor/major -> 0. + loss_fn = AnisotropyPenaltyLoss(weight=1.0, mode="elongate") + needle = torch.tensor([[[0.35, 1e-4, 0.0]]], requires_grad=True) + loss_fn(needle).backward() + grad_minor = needle.grad[0, 0, 1].item() + self.assertGreater(grad_minor, 0.0) + def test_size_power_small_gradient_at_small_scale(self): ref = 1.34 loss_fn = SizeRegularizationLoss( diff --git a/tests/test_reproducibility_strict.py b/tests/test_reproducibility_strict.py index 509268e..d5d55b8 100644 --- a/tests/test_reproducibility_strict.py +++ b/tests/test_reproducibility_strict.py @@ -225,6 +225,52 @@ def test_wrong_teacher_mode_raises(self): with self.assertRaisesRegex(ValueError, "contract mismatch"): assert_paper_no_cls_contract(config) + def test_barrier_variant_passes_with_explicit_threshold(self): + from tda_ml.preflight import ( + PAPER_NO_CLS_BARRIER_CONTRACT, + assert_paper_no_cls_contract, + ) + + config = self._valid_config() + config["loss"]["aniso_mode"] = "elongate_barrier" + config["loss"]["aniso_barrier_threshold"] = 6.0 + self.assertEqual( + assert_paper_no_cls_contract(config), + PAPER_NO_CLS_BARRIER_CONTRACT, + ) + + def test_barrier_variant_without_threshold_raises(self): + from tda_ml.preflight import assert_paper_no_cls_contract + + config = self._valid_config() + config["loss"]["aniso_mode"] = "elongate_barrier" + with self.assertRaisesRegex(ValueError, "aniso_barrier_threshold"): + assert_paper_no_cls_contract(config) + + def test_barrier_variant_wrong_threshold_raises(self): + from tda_ml.preflight import assert_paper_no_cls_contract + + config = self._valid_config() + config["loss"]["aniso_mode"] = "elongate_barrier" + config["loss"]["aniso_barrier_threshold"] = 3.0 + with self.assertRaisesRegex(ValueError, "contract mismatch"): + assert_paper_no_cls_contract(config) + + def test_paper_aniso_fields_mirrors_declaration(self): + from tda_ml.preflight import paper_aniso_fields + + config = self._valid_config() + self.assertEqual(paper_aniso_fields(config), {"aniso_mode": "elongate"}) + config["loss"]["aniso_mode"] = "elongate_barrier" + config["loss"]["aniso_barrier_threshold"] = 6.0 + self.assertEqual( + paper_aniso_fields(config), + {"aniso_mode": "elongate_barrier", "aniso_barrier_threshold": 6.0}, + ) + del config["loss"]["aniso_barrier_threshold"] + with self.assertRaisesRegex(ValueError, "aniso_barrier_threshold"): + paper_aniso_fields(config) + class TestResolveValTopoCheckpoint(unittest.TestCase): def test_missing_best_model_raises(self): diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index e13b9f9..58c96fb 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -74,6 +74,27 @@ def test_wdist_builder_overrides_divergent_yaml_homology_and_aniso(self): divergent["model"]["topology_loss"]["homology_dimensions"] = [0, 1] divergent["loss"]["aniso_mode"] = "linear" + with mock.patch( + "tune_elongate_wdist.load_config", + side_effect=lambda *a, **k: deepcopy(divergent), + ): + # aniso_mode is now mirrored from the base config declaration, so a + # non-paper variant must hard-fail instead of being silently forced. + with self.assertRaisesRegex(ValueError, "not a declared paper variant"): + build_wdist_trial( + "ignored", + w_aniso=0.1, + w_size=0.2, + w_topo=0.3, + lr=1e-4, + backend="ellphi", + tune_epochs=5, + out_base="outputs/x", + trial_number=1, + size_mode="power", + ) + + divergent["loss"]["aniso_mode"] = "elongate" with mock.patch( "tune_elongate_wdist.load_config", side_effect=lambda *a, **k: deepcopy(divergent), @@ -93,6 +114,23 @@ def test_wdist_builder_overrides_divergent_yaml_homology_and_aniso(self): self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) self.assertEqual(cfg["loss"]["aniso_mode"], "elongate") + def test_wdist_builder_mirrors_barrier_variant_from_base_config(self): + cfg = build_wdist_trial( + "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier", + w_aniso=0.1, + w_size=0.2, + w_topo=0.3, + lr=1e-4, + backend="ellphi", + tune_epochs=5, + out_base="outputs/x", + trial_number=3, + size_mode="power", + ) + self.assertEqual(cfg["loss"]["aniso_mode"], "elongate_barrier") + self.assertEqual(cfg["loss"]["aniso_barrier_threshold"], 6.0) + self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) + def test_mcc_builder_forces_homology_h1(self): cfg = build_mcc_trial( "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", From 8c43ea52829088490cd75992ceda141b86379c39 Mon Sep 17 00:00:00 2001 From: koki3070 Date: Sun, 19 Jul 2026 10:10:37 +0900 Subject: [PATCH 32/44] =?UTF-8?q?test:=20elongate=E9=80=80=E5=8C=96?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E3=82=92barrier=E3=81=A720ep=E5=86=8D?= =?UTF-8?q?=E8=B5=B0=E3=81=99=E3=82=8B=E3=82=B9=E3=83=A2=E3=83=BC=E3=82=AF?= =?UTF-8?q?=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- experiments/barrier_neartangent_smoke.py | 138 +++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 experiments/barrier_neartangent_smoke.py diff --git a/experiments/barrier_neartangent_smoke.py b/experiments/barrier_neartangent_smoke.py new file mode 100644 index 0000000..15f1344 --- /dev/null +++ b/experiments/barrier_neartangent_smoke.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Degeneracy-guard smoke: rerun a failed near-tangent tune condition with elongate_barrier. + +Motivation: on near-tangent data, 15/16 stage-2 (20ep) conditions of the plain +``elongate`` stack hard-failed in ellphi (minor axes collapsed to ~1e-5, aspect +~350 by epoch 15). This smoke reruns one of those exact failed weight sets on +the ``elongate_barrier`` base config for the full 20 epochs and reports the +final ellipse geometry, so the guard is validated on the worst case before +spending hours on a fresh Optuna study. + +Usage:: + + uv run python experiments/barrier_neartangent_smoke.py \ + --w-topo 0.201 --w-aniso 0.107 --w-size 0.179 --lr 0.000205 \ + --out-base outputs/supervised_no_cls/0719_barrier_smoke +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +import numpy as np +import torch + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) +sys.path.insert(0, str(REPO_ROOT / "experiments")) + +from tda_ml.checkpoint_io import resolve_val_topo_checkpoint # noqa: E402 +from tda_ml.main import main as train_main # noqa: E402 +from tda_ml.supervised_diagnostics import git_revision # noqa: E402 +from tda_ml.topo_wdist import topo_wdist_options_from_config # noqa: E402 + +from evaluate_paper_protocol import ( # noqa: E402 + build_split_loader, + iter_cloud_predictions, + load_model_from_run, +) +from tune_elongate_wdist import build_trial_config, mean_val_topo_wdist # noqa: E402 + +BASE_CONFIG = "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier" + + +def geometry_stats(par: np.ndarray) -> dict[str, float]: + axes = par[:, 0:2] + major = axes.max(axis=1) + minor = axes.min(axis=1) + aspect = major / np.maximum(minor, 1e-12) + return { + "minor_min": float(minor.min()), + "aspect_max": float(aspect.max()), + "aspect_p99": float(np.percentile(aspect, 99)), + } + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--w-topo", type=float, required=True) + p.add_argument("--w-aniso", type=float, required=True) + p.add_argument("--w-size", type=float, required=True) + p.add_argument("--lr", type=float, required=True) + p.add_argument("--epochs", type=int, default=20) + p.add_argument("--base-config", type=str, default=BASE_CONFIG) + p.add_argument("--trial-number", type=int, default=900) + p.add_argument("--out-base", type=Path, required=True) + return p.parse_args() + + +def main() -> int: + args = parse_args() + args.out_base.mkdir(parents=True, exist_ok=True) + + cfg = build_trial_config( + args.base_config, + w_aniso=args.w_aniso, + w_size=args.w_size, + w_topo=args.w_topo, + lr=args.lr, + backend="ellphi", + tune_epochs=args.epochs, + out_base=str(args.out_base), + trial_number=args.trial_number, + size_mode="power", + ) + + t0 = time.perf_counter() + result = train_main(config=cfg) + train_s = time.perf_counter() - t0 + run_dir = Path(result["run_dir"]) + + device = torch.device("cpu") + ckpt_name, ckpt_epoch, val_topo_sel = resolve_val_topo_checkpoint(run_dir) + topo_options = topo_wdist_options_from_config(cfg) + model = load_model_from_run(run_dir, cfg, device, checkpoint_name=ckpt_name) + loader = build_split_loader(cfg, "val", device) + clouds = list(iter_cloud_predictions(model, loader, device)) + wdist = mean_val_topo_wdist(clouds, topo_options=topo_options) + + geom = [geometry_stats(par) for _, par, _, _ in clouds] + summary = { + "purpose": ( + "elongate_barrier 20ep smoke on a weight set that hard-failed under " + "plain elongate (near-tangent stage-2 tune)" + ), + "base_config": args.base_config, + "source_revision": git_revision(REPO_ROOT), + "weights": { + "w_topo": args.w_topo, + "w_aniso": args.w_aniso, + "w_size": args.w_size, + "lr": args.lr, + }, + "epochs": args.epochs, + "train_elapsed_s": round(train_s, 1), + "run_dir": str(run_dir), + "checkpoint_name": ckpt_name, + "checkpoint_epoch": ckpt_epoch, + "val_topo_loss_at_ckpt": val_topo_sel, + "val_topo_wdist_mean": wdist, + "geometry_val": { + "minor_min": min(g["minor_min"] for g in geom), + "aspect_max": max(g["aspect_max"] for g in geom), + "aspect_p99_max": max(g["aspect_p99"] for g in geom), + }, + } + out_path = args.out_base / f"barrier_smoke_t{args.trial_number:03d}.json" + out_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, indent=2)) + print(f"Wrote {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 79c7df9ca622baf61b45c32d3dd8f62d46d863bb Mon Sep 17 00:00:00 2001 From: koki3070 Date: Sun, 19 Jul 2026 11:38:15 +0900 Subject: [PATCH 33/44] =?UTF-8?q?feat:=20multiseed=2030ep=E3=83=89?= =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=83=90=E3=81=ABANISO=5FVARIANT=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=97barrier=E5=A5=91=E7=B4=84=E3=81=AE?= =?UTF-8?q?=E9=9B=86=E8=A8=88=E6=A4=9C=E8=A8=BC=E3=82=92=E9=85=8D=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- experiments/run_teacher_local_pca_power_30ep_multiseed.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh index 617f036..ef6ccc4 100755 --- a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh +++ b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh @@ -47,6 +47,8 @@ LOG_ROOT="${LOG_ROOT:-outputs/supervised/pwr30_multiseed}" EPOCHS="${EPOCHS:-30}" DBSCAN_BACKEND="${DBSCAN_BACKEND:-mahalanobis}" BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_full120_teacher_local_pca}" +# Declared paper contract variant the tune JSONs must match (elongate | elongate_barrier). +ANISO_VARIANT="${ANISO_VARIANT:-elongate}" mkdir -p "${LOG_ROOT}" @@ -190,6 +192,7 @@ AGG_ARGS=( --mcc-tune-json "${MCC_JSON}" --out-dir "${LOG_ROOT}" --seeds "${SEEDS[@]}" + --aniso-variant "${ANISO_VARIANT}" ) case "${MODE}" in wdist) AGG_ARGS+=(--methods wdist) ;; From 48b1bb1b9c24aae3f0281277806010333e84cbf8 Mon Sep 17 00:00:00 2001 From: murata Date: Sun, 19 Jul 2026 16:22:45 +0900 Subject: [PATCH 34/44] =?UTF-8?q?feat:=20=E6=B3=95=E7=B7=9A=E6=96=B9?= =?UTF-8?q?=E5=90=91=EF=BC=88normal=EF=BC=89=E5=A4=96=E3=82=8C=E5=80=A4?= =?UTF-8?q?=E3=83=A2=E3=83=BC=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97?= =?UTF-8?q?normal=5Fbarrier=E5=AE=9F=E9=A8=93config=E3=82=92=E9=85=8D?= =?UTF-8?q?=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit near-tangentでは外れ値が楕円長軸上に乗り異方性距離に吸収される (提案・ADBSCANともrecall~0.48)ため、異方性仮説を正しく検証する 短軸方向(ストローク横断)配置をtangent_directionとして明示配線。 既存configにはtangent_direction: tangentを明示追加(暗黙デフォルト排除)。 --- ...l120_teacher_local_pca_h1_neartangent.yaml | 1 + ...cher_local_pca_h1_neartangent_barrier.yaml | 1 + ...0_teacher_local_pca_h1_normal_barrier.yaml | 84 ++++++++++++++++++ ..._full120_teacher_local_pca_h1_tangent.yaml | 1 + ...local_pca_ellphi_power_h1_neartangent.yaml | 1 + ...a_ellphi_power_h1_neartangent_barrier.yaml | 1 + ...al_pca_ellphi_power_h1_normal_barrier.yaml | 88 +++++++++++++++++++ ...une_local_pca_ellphi_power_h1_tangent.yaml | 1 + experiments/evaluate_paper_protocol.py | 2 + tda_ml/data_loader.py | 13 ++- tda_ml/main.py | 2 + tda_ml/run_setup.py | 2 + tda_ml/tangent_outliers.py | 28 ++++-- tests/test_tangent_outliers.py | 49 +++++++++++ 14 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier.yaml create mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier.yaml diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml index af2b7f3..55e5009 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml @@ -62,6 +62,7 @@ data: tangent_offset_max: 0.40 tangent_angle_jitter_deg: 30.0 tangent_stroke_clearance: 0.08 + tangent_direction: tangent seed: 42 train_size: 4500 val_size: 500 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml index 5bfb6bd..8f8f5fe 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml @@ -64,6 +64,7 @@ data: tangent_offset_max: 0.40 tangent_angle_jitter_deg: 30.0 tangent_stroke_clearance: 0.08 + tangent_direction: tangent seed: 42 train_size: 4500 val_size: 500 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier.yaml new file mode 100644 index 0000000..30b36b6 --- /dev/null +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier.yaml @@ -0,0 +1,84 @@ +# Paper no_cls production on NORMAL-direction outliers with the +# elongate_barrier guard. Identical to +# elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier except +# data.tangent_direction: normal and a tighter offset range (0.10-0.25). +# See the tune config of the same suffix for the motivation (near-tangent +# outliers are absorbed along the ellipse major axis; normal-direction is the +# setting the anisotropy hypothesis predicts an advantage for). +# Production 30ep weights MUST come from an H1-only normal-barrier Optuna +# best JSON (--tune-json). The w_* / lr values below are Optuna prior centres only. + +meta: + config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "ellphi" + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + # Prior centres for Optuna; paper production requires --tune-json overrides. + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate_barrier + aniso_barrier_threshold: 6.0 + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + # Off-manifold anisotropic outliers: displace inliers within a ±30° cone + # around the local-PCA MINOR axis (across the stroke), rejected until + # >=0.08 from every clean inlier. noise_std matches the uniform baseline. + outlier_mode: local_pca_tangent + noise_std: 0.01 + tangent_pca_k: 10 + tangent_offset_min: 0.10 + tangent_offset_max: 0.25 + tangent_angle_jitter_deg: 30.0 + tangent_stroke_clearance: 0.08 + tangent_direction: normal + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml index 86179fc..ff9099a 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml @@ -63,6 +63,7 @@ data: # Pure tangent (no direction jitter, no semantic stroke clearance). tangent_angle_jitter_deg: 0.0 tangent_stroke_clearance: 0.0 + tangent_direction: tangent seed: 42 train_size: 4500 val_size: 500 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml index ff121cf..7b686c1 100644 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml @@ -62,6 +62,7 @@ data: tangent_offset_max: 0.40 tangent_angle_jitter_deg: 30.0 tangent_stroke_clearance: 0.08 + tangent_direction: tangent seed: 42 train_size: 4500 val_size: 500 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml index ea9a3ca..6732795 100644 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml @@ -66,6 +66,7 @@ data: tangent_offset_max: 0.40 tangent_angle_jitter_deg: 30.0 tangent_stroke_clearance: 0.08 + tangent_direction: tangent seed: 42 train_size: 4500 val_size: 500 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier.yaml new file mode 100644 index 0000000..1e884f4 --- /dev/null +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier.yaml @@ -0,0 +1,88 @@ +# H1-only tune on NORMAL-direction outliers with the elongate_barrier guard. +# Identical to elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier +# except data.tangent_direction: normal and a tighter offset range. +# +# Why normal: near-tangent outliers sit along the local-PCA major axis, so +# anisotropic (ellipse/Mahalanobis) methods absorb them as stroke continuation +# (proposed AND ADBSCAN both dropped to recall ~0.48 on near-tangent data). +# Displacing across the stroke (minor axis) is the setting the anisotropy +# hypothesis actually predicts an advantage for: the outlier stays Euclidean- +# close to the stroke (adversarial for isotropic DBSCAN) but crosses the thin +# ellipse (large Mahalanobis distance). +# +# Offsets 0.10-0.25 keep outliers inside the DBSCAN eps grid (0.15-0.825) so +# Euclidean clustering tends to connect them to the stroke. + +meta: + config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden to ellphi by tune script + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate_barrier + aniso_barrier_threshold: 6.0 + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 20 + grad_clip_value: 1.0 + visualize_every: 10000 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + max_points: 100 + num_outliers: 20 + # Off-manifold anisotropic outliers: displace inliers within a ±30° cone + # around the local-PCA MINOR axis (across the stroke), rejected until + # >=0.08 from every clean inlier. noise_std matches the uniform baseline. + outlier_mode: local_pca_tangent + noise_std: 0.01 + tangent_pca_k: 10 + tangent_offset_min: 0.10 + tangent_offset_max: 0.25 + tangent_angle_jitter_deg: 30.0 + tangent_stroke_clearance: 0.08 + tangent_direction: normal + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml index 6088ed2..4b8a40c 100644 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml @@ -61,6 +61,7 @@ data: # Pure tangent (no direction jitter, no semantic stroke clearance). tangent_angle_jitter_deg: 0.0 tangent_stroke_clearance: 0.0 + tangent_direction: tangent seed: 42 train_size: 4500 val_size: 500 diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index 069604c..4fb8c63 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -180,6 +180,7 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) "tangent_offset_max", "tangent_angle_jitter_deg", "tangent_stroke_clearance", + "tangent_direction", ): if key not in data_cfg: raise ValueError( @@ -191,6 +192,7 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) tangent_offset_max=float(data_cfg["tangent_offset_max"]), tangent_angle_jitter_deg=float(data_cfg["tangent_angle_jitter_deg"]), tangent_stroke_clearance=float(data_cfg["tangent_stroke_clearance"]), + tangent_direction=str(data_cfg["tangent_direction"]), ) dataset = NoisyMNISTDataset(**dataset_kwargs) loader = create_data_loader( diff --git a/tda_ml/data_loader.py b/tda_ml/data_loader.py index 672f350..a3d535e 100644 --- a/tda_ml/data_loader.py +++ b/tda_ml/data_loader.py @@ -38,6 +38,9 @@ class NoisyMNISTDataset(Dataset): tangent_stroke_clearance (float): Semantic floor on the distance from an outlier to every clean inlier; candidates landing back on the stroke are rejected and retried (0 = disabled). + tangent_direction (str): Base axis for the displacement: ``tangent`` + (local PCA major axis, along the stroke) or ``normal`` (minor axis, + across the stroke). Returns (per item): data (Tensor): Shape (max_points + num_outliers, 2). Shuffled point cloud. @@ -55,7 +58,8 @@ def __init__(self, root='./data', train=True, num_samples=5000, tangent_offset_min=0.15, tangent_offset_max=0.40, tangent_angle_jitter_deg=0.0, - tangent_stroke_clearance=0.0): + tangent_stroke_clearance=0.0, + tangent_direction="tangent"): self.max_points = max_points self.num_outliers = num_outliers self.noise_std = noise_std @@ -75,6 +79,12 @@ def __init__(self, root='./data', train=True, num_samples=5000, self.tangent_offset_max = float(tangent_offset_max) self.tangent_angle_jitter_deg = float(tangent_angle_jitter_deg) self.tangent_stroke_clearance = float(tangent_stroke_clearance) + tangent_direction = str(tangent_direction).strip().lower() + if tangent_direction not in ("tangent", "normal"): + raise ValueError( + f"tangent_direction must be 'tangent' or 'normal', got {tangent_direction!r}" + ) + self.tangent_direction = tangent_direction full_dataset = datasets.MNIST(root, train=train, download=True) @@ -217,6 +227,7 @@ def __getitem__(self, idx): existing_points=inliers, angle_jitter_deg=self.tangent_angle_jitter_deg, stroke_clearance=self.tangent_stroke_clearance, + direction=self.tangent_direction, # Stroke-clearance rejection lowers per-attempt acceptance on # straight strokes; give the sampler more retries before the # hard-fail. diff --git a/tda_ml/main.py b/tda_ml/main.py index aa0266d..984b1a1 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -115,6 +115,7 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): "tangent_offset_max", "tangent_angle_jitter_deg", "tangent_stroke_clearance", + "tangent_direction", ): if key not in data_cfg: raise ValueError( @@ -126,6 +127,7 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): tangent_offset_max=float(data_cfg["tangent_offset_max"]), tangent_angle_jitter_deg=float(data_cfg["tangent_angle_jitter_deg"]), tangent_stroke_clearance=float(data_cfg["tangent_stroke_clearance"]), + tangent_direction=str(data_cfg["tangent_direction"]), ) manifest = { "timestamp_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(), diff --git a/tda_ml/run_setup.py b/tda_ml/run_setup.py index 0089559..93b020c 100644 --- a/tda_ml/run_setup.py +++ b/tda_ml/run_setup.py @@ -126,6 +126,7 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): "tangent_offset_max", "tangent_angle_jitter_deg", "tangent_stroke_clearance", + "tangent_direction", ): if key not in data_cfg: raise ValueError( @@ -137,6 +138,7 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): tangent_offset_max=float(data_cfg["tangent_offset_max"]), tangent_angle_jitter_deg=float(data_cfg["tangent_angle_jitter_deg"]), tangent_stroke_clearance=float(data_cfg["tangent_stroke_clearance"]), + tangent_direction=str(data_cfg["tangent_direction"]), ) train_dataset = NoisyMNISTDataset(train=True, indices=train_indices, **dataset_kwargs) diff --git a/tda_ml/tangent_outliers.py b/tda_ml/tangent_outliers.py index 3a35155..422ae94 100644 --- a/tda_ml/tangent_outliers.py +++ b/tda_ml/tangent_outliers.py @@ -1,4 +1,4 @@ -"""Place outliers along (or near) local-PCA first principal axes (tangent directions).""" +"""Place outliers along (or near) local-PCA principal axes (tangent or normal directions).""" from __future__ import annotations @@ -25,16 +25,23 @@ def sample_local_pca_tangent_outliers( existing_points: torch.Tensor | None = None, angle_jitter_deg: float = 0.0, stroke_clearance: float = 0.0, + direction: str = "tangent", ) -> torch.Tensor: """ - Sample outliers by displacing random inliers along (or near) local PCA PC1. + Sample outliers by displacing random inliers along (or near) a local PCA axis. Local PCA is computed on ``inliers`` only (no isotropic jitter). Each outlier is ``p + s * u`` where ``u = (cos φ, sin φ)`` with ``φ = θ + Uniform(-j, j)``, - ``θ`` the major-axis direction at a randomly chosen inlier ``p``, ``j`` the - ``angle_jitter_deg`` cone (0 → exact tangent), and ``s`` has random sign with + ``θ`` the base-axis direction at a randomly chosen inlier ``p``, ``j`` the + ``angle_jitter_deg`` cone (0 → exact axis), and ``s`` has random sign with ``|s| ~ Uniform(offset_min, offset_max)``. + ``direction`` selects the base axis: ``"tangent"`` uses the local PCA major + axis (PC1, along the stroke); ``"normal"`` uses the minor axis (PC1 + 90°, + across the stroke). Normal-direction outliers sit close to the stroke in + Euclidean distance but off the tangent line — the adversarial case for + isotropic clustering and the favourable case for anisotropic ellipses. + Rejection rules (candidates are retried, never clipped or merged): - leaves ``[box_min, box_max]^2``; @@ -68,6 +75,10 @@ def sample_local_pca_tangent_outliers( ) if stroke_clearance < 0: raise ValueError(f"stroke_clearance must be >= 0; got {stroke_clearance}") + if direction not in ("tangent", "normal"): + raise ValueError( + f"direction must be 'tangent' or 'normal'; got {direction!r}" + ) sep_ref = inliers if existing_points is None else existing_points if sep_ref.ndim != 2 or sep_ref.shape[-1] != 2: raise ValueError( @@ -76,7 +87,9 @@ def sample_local_pca_tangent_outliers( params = local_pca_ellipse_params(inliers, k=k, normalize_axes=True) # (N, 3) theta = params[:, 2] - direction = torch.stack([torch.cos(theta), torch.sin(theta)], dim=-1) + if direction == "normal": + theta = theta + math.pi / 2 + axis_dirs = torch.stack([torch.cos(theta), torch.sin(theta)], dim=-1) jitter_rad = math.radians(angle_jitter_deg) outliers = torch.empty(num_outliers, 2, dtype=inliers.dtype, device=inliers.device) @@ -104,7 +117,7 @@ def sample_local_pca_tangent_outliers( device=inliers.device, ) else: - direction_j = direction[idx] + direction_j = axis_dirs[idx] mag = offset_min + (offset_max - offset_min) * u cand = inliers[idx] + (sign * mag) * direction_j if not bool(torch.all((cand >= box_min) & (cand <= box_max)).item()): @@ -131,6 +144,7 @@ def sample_local_pca_tangent_outliers( f"offset=[{offset_min}, {offset_max}], " f"min_separation={min_separation}, " f"angle_jitter_deg={angle_jitter_deg}, " - f"stroke_clearance={stroke_clearance})." + f"stroke_clearance={stroke_clearance}, " + f"direction={direction})." ) return outliers diff --git a/tests/test_tangent_outliers.py b/tests/test_tangent_outliers.py index 25588cf..6ce2309 100644 --- a/tests/test_tangent_outliers.py +++ b/tests/test_tangent_outliers.py @@ -91,6 +91,55 @@ def test_invalid_jitter_and_clearance_rejected(self): with self.assertRaises(ValueError): sample_local_pca_tangent_outliers(inliers, 5, stroke_clearance=-0.1) + def test_normal_direction_is_perpendicular(self): + # Horizontal line -> PC1 along x; direction="normal" must displace + # along y (across the stroke), i.e. |dy| >> |dx| for every outlier. + g = torch.Generator().manual_seed(9) + inliers = torch.stack( + [torch.linspace(-0.5, 0.5, 40), torch.zeros(40)], dim=1 + ) + outliers = sample_local_pca_tangent_outliers( + inliers, 10, k=8, offset_min=0.10, offset_max=0.25, + generator=g, direction="normal", + ) + # Displacement from the nearest inlier is essentially the offset vector. + d = torch.cdist(outliers, inliers) + nearest = inliers[d.argmin(dim=1)] + disp = outliers - nearest + self.assertTrue(torch.all(disp[:, 1].abs() > disp[:, 0].abs()).item()) + self.assertTrue(torch.all(disp[:, 1].abs() >= 0.05).item()) + + def test_normal_direction_with_jitter_and_clearance(self): + g = torch.Generator().manual_seed(13) + inliers = torch.stack( + [torch.linspace(-0.5, 0.5, 40), torch.zeros(40)], dim=1 + ) + clearance = 0.08 + outliers = sample_local_pca_tangent_outliers( + inliers, 12, k=8, offset_min=0.10, offset_max=0.25, + generator=g, angle_jitter_deg=30.0, stroke_clearance=clearance, + direction="normal", max_attempts=400, + ) + self.assertEqual(tuple(outliers.shape), (12, 2)) + d_inl = torch.cdist(outliers, inliers).min() + self.assertGreaterEqual(float(d_inl), clearance) + + def test_tangent_default_stream_unchanged_by_direction_arg(self): + # direction="tangent" (explicit) must match the historical default. + inliers = torch.randn(30, 2) + g1 = torch.Generator().manual_seed(17) + g2 = torch.Generator().manual_seed(17) + a = sample_local_pca_tangent_outliers(inliers, 5, generator=g1) + b = sample_local_pca_tangent_outliers( + inliers, 5, generator=g2, direction="tangent" + ) + self.assertTrue(torch.equal(a, b)) + + def test_invalid_direction_rejected(self): + inliers = torch.randn(30, 2) + with self.assertRaises(ValueError): + sample_local_pca_tangent_outliers(inliers, 5, direction="diagonal") + def test_min_separation_unsatisfiable_hard_fails(self): # A tiny box with a large min_separation cannot be satisfied -> hard-fail # rather than emitting near-coincident (degenerate) outliers. From d1c73f8f23fb3679c74a7272cd0838a0d5f6bdef Mon Sep 17 00:00:00 2001 From: murata Date: Sun, 19 Jul 2026 17:26:24 +0900 Subject: [PATCH 35/44] =?UTF-8?q?feat:=20=E7=B4=B0=E3=81=84=E5=90=88?= =?UTF-8?q?=E6=88=90=E3=83=AA=E3=83=B3=E3=82=B0=E3=83=87=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E3=82=BB=E3=83=83=E3=83=88=EF=BC=88=E5=8D=8A=E5=BE=84=E6=96=B9?= =?UTF-8?q?=E5=90=91=E5=A4=96=E3=82=8C=E5=80=A4=EF=BC=89=E3=82=92dataset?= =?UTF-8?q?=5Ftype=E3=81=A8=E3=81=97=E3=81=A6=E9=85=8D=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MNIST雲では教師楕円のアスペクト比が~1.8しかなくノイズ方向の設計が 効かないため、異方性仮説が構造的に検証可能な最小構成として ThinRingsDataset(教師アスペクト~8、半径方向外れ値はユークリッド 曖昧・マハラノビス明瞭、H1ループ)を追加。data.dataset_typeを run_setup/main(manifest)/evaluate_paper_protocolに明示必須で配線。 --- ...20_teacher_local_pca_h1_rings_barrier.yaml | 85 +++++ ...cal_pca_ellphi_power_h1_rings_barrier.yaml | 91 ++++++ experiments/evaluate_paper_protocol.py | 40 +++ tda_ml/main.py | 37 ++- tda_ml/ring_dataset.py | 299 ++++++++++++++++++ tda_ml/run_setup.py | 36 +++ tests/test_ring_dataset.py | 101 ++++++ 7 files changed, 680 insertions(+), 9 deletions(-) create mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier.yaml create mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier.yaml create mode 100644 tda_ml/ring_dataset.py create mode 100644 tests/test_ring_dataset.py diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier.yaml new file mode 100644 index 0000000..fe3bd0f --- /dev/null +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier.yaml @@ -0,0 +1,85 @@ +# Paper no_cls production on THIN SYNTHETIC RINGS with radial outliers and the +# elongate_barrier guard. Identical to +# elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier except +# epochs=30, distance_backend=ellphi, visualize_every=10. +# See the tune config of the same suffix for the motivation. +# Production 30ep weights MUST come from an H1-only rings-barrier Optuna +# best JSON (--tune-json). The w_* / lr values below are Optuna prior centres only. + +meta: + config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "ellphi" + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + # Prior centres for Optuna; paper production requires --tune-json overrides. + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate_barrier + aniso_barrier_threshold: 6.0 + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + dataset_type: thin_rings + max_points: 100 + num_outliers: 20 + # Thin rings: transverse sigma (noise_std) << along-ring NN spacing (~0.04), + # so local-PCA ellipses are strongly anisotropic. Outliers displace clean + # ring points radially by 0.03-0.06 (within ~1 NN spacing: Euclidean-hard) + # and must stay >= 0.03 off every ring band (semantic guard). + noise_std: 0.005 + ring_count_min: 1 + ring_count_max: 2 + ring_radius_min: 0.25 + ring_radius_max: 0.60 + ring_center_box: 0.30 + ring_outlier_offset_min: 0.03 + ring_outlier_offset_max: 0.06 + ring_outlier_clearance: 0.03 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier.yaml new file mode 100644 index 0000000..7843976 --- /dev/null +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier.yaml @@ -0,0 +1,91 @@ +# H1-only tune on THIN SYNTHETIC RINGS with radial (normal-direction) outliers +# and the elongate_barrier degeneracy guard. +# +# Why rings (docs/experiments/20260719_neartangent_barrier.md, 追記): +# on MNIST 100-point clouds the local-PCA teacher ellipses are only mildly +# anisotropic (aspect median ~1.8; strokes have width), so no outlier direction +# can favour anisotropic methods. Thin rings reach teacher aspect ~8-9 and the +# radial outliers sit within ~1 NN spacing of the ring: Euclidean-ambiguous +# (NN AUC ~0.82) yet Mahalanobis-clear (~0.94). Rings are H1 loops, matching +# the H1-only pipeline. +# +# Known tension: the barrier caps predicted aspect at 6.0 (declared contract +# constant) while ring teacher aspect is ~8-9; accepted for stability, revisit +# if topo match suffers. + +meta: + config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden to ellphi by tune script + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate_barrier + aniso_barrier_threshold: 6.0 + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 20 + grad_clip_value: 1.0 + visualize_every: 10000 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + dataset_type: thin_rings + max_points: 100 + num_outliers: 20 + # Thin rings: transverse sigma (noise_std) << along-ring NN spacing (~0.04), + # so local-PCA ellipses are strongly anisotropic. Outliers displace clean + # ring points radially by 0.03-0.06 (within ~1 NN spacing: Euclidean-hard) + # and must stay >= 0.03 off every ring band (semantic guard). + noise_std: 0.005 + ring_count_min: 1 + ring_count_max: 2 + ring_radius_min: 0.25 + ring_radius_max: 0.60 + ring_center_box: 0.30 + ring_outlier_offset_min: 0.03 + ring_outlier_offset_max: 0.06 + ring_outlier_clearance: 0.03 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index 4fb8c63..569d896 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -148,6 +148,46 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) else: raise ValueError(f"split must be 'val' or 'test'; got {split!r}") + dataset_type = str(data_cfg.get("dataset_type", "")).strip().lower() + if not dataset_type: + raise ValueError( + "data.dataset_type must be set explicitly (mnist|thin_rings); " + "refusing silent MNIST default" + ) + if dataset_type == "thin_rings": + from tda_ml.ring_dataset import ( + TEST_INDEX_OFFSET, + ThinRingsDataset, + ring_kwargs_from_config, + ) + + if "noise_std" not in data_cfg: + raise ValueError("data.noise_std must be set explicitly; refusing silent default") + common = dict( + max_points=int(data_cfg["max_points"]), + num_outliers=int(data_cfg["num_outliers"]), + noise_std=float(data_cfg["noise_std"]), + noise_seed=seed, + **ring_kwargs_from_config(data_cfg), + ) + if split == "val": + dataset = ThinRingsDataset(val_size, index_offset=train_size, **common) + else: + dataset = ThinRingsDataset(test_size, index_offset=TEST_INDEX_OFFSET, **common) + return create_data_loader( + dataset, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + pin_memory=pin_memory, + persistent_workers=False, + prefetch_factor=2 if num_workers > 0 else None, + ) + if dataset_type != "mnist": + raise ValueError( + f"data.dataset_type must be 'mnist' or 'thin_rings', got {dataset_type!r}" + ) + if "outlier_mode" not in data_cfg: raise ValueError( "data.outlier_mode must be set explicitly (uniform|local_pca_tangent); " diff --git a/tda_ml/main.py b/tda_ml/main.py index 984b1a1..e8e4617 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -93,21 +93,40 @@ def main(config_name=None, config=None, trial=None, config_overrides=None): init_checkpoint = config.get("init_checkpoint") if init_checkpoint and not os.path.exists(init_checkpoint): raise FileNotFoundError(f"Initial checkpoint not found: {init_checkpoint}") - if "outlier_mode" not in data_cfg: + dataset_type = str(data_cfg.get("dataset_type", "")).strip().lower() + if not dataset_type: raise ValueError( - "data.outlier_mode must be set explicitly (uniform|local_pca_tangent); " - "refusing silent uniform default in run manifest" + "data.dataset_type must be set explicitly (mnist|thin_rings); " + "refusing silent MNIST default in run manifest" ) - outlier_mode = str(data_cfg["outlier_mode"]).strip().lower() if "noise_std" not in data_cfg: raise ValueError( "data.noise_std must be set explicitly; refusing silent default in run manifest" ) - data_outliers = { - "outlier_mode": outlier_mode, - "noise_std": float(data_cfg["noise_std"]), - "num_outliers": int(data_cfg["num_outliers"]), - } + if dataset_type == "thin_rings": + from tda_ml.ring_dataset import ring_kwargs_from_config + + outlier_mode = "ring_radial" + data_outliers = { + "dataset_type": dataset_type, + "outlier_mode": outlier_mode, + "noise_std": float(data_cfg["noise_std"]), + "num_outliers": int(data_cfg["num_outliers"]), + **ring_kwargs_from_config(data_cfg), + } + else: + if "outlier_mode" not in data_cfg: + raise ValueError( + "data.outlier_mode must be set explicitly (uniform|local_pca_tangent); " + "refusing silent uniform default in run manifest" + ) + outlier_mode = str(data_cfg["outlier_mode"]).strip().lower() + data_outliers = { + "dataset_type": dataset_type, + "outlier_mode": outlier_mode, + "noise_std": float(data_cfg["noise_std"]), + "num_outliers": int(data_cfg["num_outliers"]), + } if outlier_mode == "local_pca_tangent": for key in ( "tangent_pca_k", diff --git a/tda_ml/ring_dataset.py b/tda_ml/ring_dataset.py new file mode 100644 index 0000000..53e7d7e --- /dev/null +++ b/tda_ml/ring_dataset.py @@ -0,0 +1,299 @@ +"""Thin synthetic rings with normal-direction (radial) outliers. + +Motivation (2026-07-19, docs/experiments/20260719_neartangent_barrier.md): +on MNIST 100-point clouds the local-PCA teacher ellipses are only mildly +anisotropic (aspect median ~1.8) because strokes have width, so no outlier +*direction* can create an advantage for anisotropic distances. Thin rings are +the minimal geometry where the anisotropy hypothesis is structurally testable: + +- the curve is thin (transverse sigma << along-curve spacing), so local-PCA + ellipses reach aspect ~8-9; +- radial outliers sit within ~1 nearest-neighbour spacing of the ring + (Euclidean-ambiguous) but cross the ellipse minor axis (Mahalanobis-clear); +- rings are H1 loops, matching the H1-only paper pipeline. + +Interface mirrors :class:`tda_ml.data_loader.NoisyMNISTDataset`: items are +``(data, labels, clean_pc)`` with ``data`` of shape +``(max_points + num_outliers, 2)`` shuffled, ``labels`` 0=inlier / 1=outlier, +and ``clean_pc`` the noise-free inlier points. + +Split scheme (no images to index): each cloud is generated from +``noise_seed + index_offset + idx``. Train/val use offsets ``0`` / +``train_size``; test uses ``TEST_INDEX_OFFSET`` so its stream never overlaps +train/val for the configured sizes. +""" + +from __future__ import annotations + +import math + +import torch +from torch.utils.data import Dataset + +from tda_ml.cloud_separation import apply_noise_with_min_separation +from tda_ml.numerical_eps import MIN_ELLPHI_CENTER_SEPARATION + +TEST_INDEX_OFFSET = 1_000_000 + + +class ThinRingsDataset(Dataset): + """1-2 thin rings per cloud + radial (normal-direction) outliers. + + All geometry parameters are required explicitly (no silent defaults) in + line with the repo reproducibility policy; callers wire them from + ``config['data']``. + """ + + def __init__( + self, + num_samples: int, + *, + max_points: int, + num_outliers: int, + noise_std: float, + ring_count_min: int, + ring_count_max: int, + ring_radius_min: float, + ring_radius_max: float, + ring_center_box: float, + ring_outlier_offset_min: float, + ring_outlier_offset_max: float, + ring_outlier_clearance: float, + noise_seed: int = 0, + index_offset: int = 0, + min_separation: float = MIN_ELLPHI_CENTER_SEPARATION, + max_attempts: int = 400, + ) -> None: + if num_samples <= 0: + raise ValueError(f"num_samples must be > 0; got {num_samples}") + if max_points < 8: + raise ValueError(f"max_points must be >= 8; got {max_points}") + if num_outliers < 0: + raise ValueError(f"num_outliers must be >= 0; got {num_outliers}") + if noise_std < 0: + raise ValueError(f"noise_std must be >= 0; got {noise_std}") + if not (1 <= ring_count_min <= ring_count_max): + raise ValueError( + f"Require 1 <= ring_count_min <= ring_count_max; " + f"got {ring_count_min}, {ring_count_max}" + ) + if not (0 < ring_radius_min <= ring_radius_max): + raise ValueError( + f"Require 0 < ring_radius_min <= ring_radius_max; " + f"got {ring_radius_min}, {ring_radius_max}" + ) + if ring_center_box < 0: + raise ValueError(f"ring_center_box must be >= 0; got {ring_center_box}") + if not (0 < ring_outlier_offset_min <= ring_outlier_offset_max): + raise ValueError( + f"Require 0 < ring_outlier_offset_min <= ring_outlier_offset_max; " + f"got {ring_outlier_offset_min}, {ring_outlier_offset_max}" + ) + if ring_outlier_clearance < 0: + raise ValueError( + f"ring_outlier_clearance must be >= 0; got {ring_outlier_clearance}" + ) + if ring_outlier_clearance > ring_outlier_offset_min: + raise ValueError( + "ring_outlier_clearance must be <= ring_outlier_offset_min, otherwise " + "every candidate is rejected against its own source ring; got " + f"{ring_outlier_clearance} > {ring_outlier_offset_min}" + ) + extent = ring_radius_max + ring_center_box + ring_outlier_offset_max + if extent > 1.0: + raise ValueError( + "ring geometry can leave [-1, 1]^2: " + f"radius_max + center_box + outlier_offset_max = {extent:.3f} > 1.0" + ) + if min_separation < 0: + raise ValueError(f"min_separation must be >= 0; got {min_separation}") + + self.num_samples = int(num_samples) + self.max_points = int(max_points) + self.num_outliers = int(num_outliers) + self.noise_std = float(noise_std) + self.ring_count_min = int(ring_count_min) + self.ring_count_max = int(ring_count_max) + self.ring_radius_min = float(ring_radius_min) + self.ring_radius_max = float(ring_radius_max) + self.ring_center_box = float(ring_center_box) + self.ring_outlier_offset_min = float(ring_outlier_offset_min) + self.ring_outlier_offset_max = float(ring_outlier_offset_max) + self.ring_outlier_clearance = float(ring_outlier_clearance) + self.noise_seed = int(noise_seed) + self.index_offset = int(index_offset) + self.min_separation = float(min_separation) + self.max_attempts = int(max_attempts) + + def __len__(self) -> int: + return self.num_samples + + def _sample_clean_rings(self, rng: torch.Generator): + """Sample ring parameters and min-separated clean points on the circles.""" + n_rings = int( + torch.randint(self.ring_count_min, self.ring_count_max + 1, (1,), generator=rng) + ) + counts = [] + remaining = self.max_points + for r in range(n_rings): + if r == n_rings - 1: + counts.append(remaining) + else: + # 35-65% share keeps every ring dense enough for local PCA (k=10). + share = 0.35 + 0.3 * float(torch.rand(1, generator=rng)) + cnt = max(20, int(round(remaining * share))) + cnt = min(cnt, remaining - 20) + counts.append(cnt) + remaining -= cnt + centers, radii = [], [] + for _ in range(n_rings): + centers.append((torch.rand(2, generator=rng) * 2.0 - 1.0) * self.ring_center_box) + radii.append( + self.ring_radius_min + + (self.ring_radius_max - self.ring_radius_min) + * float(torch.rand(1, generator=rng)) + ) + + clean = torch.empty(self.max_points, 2) + ring_of = torch.empty(self.max_points, dtype=torch.long) + pos = 0 + for r, (c, radius, cnt) in enumerate(zip(centers, radii, counts)): + for j in range(cnt): + for _ in range(self.max_attempts): + ang = float(torch.rand(1, generator=rng)) * 2.0 * math.pi + cand = c + radius * torch.tensor([math.cos(ang), math.sin(ang)]) + if pos > 0 and self.min_separation > 0: + d = torch.linalg.norm(clean[:pos] - cand, dim=-1).min() + if bool((d < self.min_separation).item()): + continue + clean[pos] = cand + ring_of[pos] = r + pos += 1 + break + else: + raise RuntimeError( + "Failed to place a min-separated clean ring point after " + f"{self.max_attempts} attempts (ring={r}, point={j}, " + f"radius={radius:.3f}, min_separation={self.min_separation})." + ) + return clean, ring_of, centers, radii + + def _sample_radial_outliers( + self, + rng: torch.Generator, + clean: torch.Tensor, + ring_of: torch.Tensor, + centers: list[torch.Tensor], + radii: list[float], + inliers: torch.Tensor, + ) -> torch.Tensor: + outliers = torch.empty(self.num_outliers, 2) + n = clean.shape[0] + for j in range(self.num_outliers): + for _ in range(self.max_attempts): + i = int(torch.randint(0, n, (1,), generator=rng)) + c = centers[int(ring_of[i])] + v = clean[i] - c + nv = float(torch.linalg.norm(v)) + if nv < 1e-9: + continue + u = v / nv + sign = 1.0 if float(torch.rand(1, generator=rng)) < 0.5 else -1.0 + mag = self.ring_outlier_offset_min + ( + self.ring_outlier_offset_max - self.ring_outlier_offset_min + ) * float(torch.rand(1, generator=rng)) + cand = clean[i] + (sign * mag) * u + if not bool(torch.all((cand >= -1.0) & (cand <= 1.0)).item()): + continue + # Semantic guard: candidate must stay off EVERY ring band, not + # just its source ring (a second ring may pass nearby). + ok = True + for c_r, r_r in zip(centers, radii): + band = abs(float(torch.linalg.norm(cand - c_r)) - r_r) + if band < self.ring_outlier_clearance: + ok = False + break + if not ok: + continue + if self.min_separation > 0: + d = torch.linalg.norm(inliers - cand, dim=-1).min() + if bool((d < self.min_separation).item()): + continue + if j > 0: + d_out = torch.linalg.norm(outliers[:j] - cand, dim=-1).min() + if bool((d_out < self.min_separation).item()): + continue + outliers[j] = cand + break + else: + raise RuntimeError( + "Failed to sample an in-bounds, ring-cleared radial outlier after " + f"{self.max_attempts} attempts (outlier_index={j}, " + f"offset=[{self.ring_outlier_offset_min}, {self.ring_outlier_offset_max}], " + f"clearance={self.ring_outlier_clearance}, " + f"min_separation={self.min_separation})." + ) + return outliers + + def __getitem__(self, idx: int): + if not (0 <= idx < self.num_samples): + raise IndexError(idx) + rng = torch.Generator() + rng.manual_seed(self.noise_seed + self.index_offset + idx) + + clean, ring_of, centers, radii = self._sample_clean_rings(rng) + inliers = apply_noise_with_min_separation(clean, self.noise_std, generator=rng) + + if self.num_outliers > 0: + outliers = self._sample_radial_outliers( + rng, clean, ring_of, centers, radii, inliers + ) + else: + outliers = torch.empty(0, 2) + + total = self.max_points + self.num_outliers + data = torch.zeros(total, 2) + labels = torch.ones(total, dtype=torch.long) + data[: self.max_points] = inliers + labels[: self.max_points] = 0 + if self.num_outliers > 0: + data[self.max_points:] = outliers + + perm = torch.randperm(total, generator=rng) + data = data[perm] + labels = labels[perm] + + clean_pc = torch.zeros(self.max_points, 2) + clean_pc[: clean.shape[0]] = clean + return data, labels, clean_pc + + +REQUIRED_RING_KEYS = ( + "ring_count_min", + "ring_count_max", + "ring_radius_min", + "ring_radius_max", + "ring_center_box", + "ring_outlier_offset_min", + "ring_outlier_offset_max", + "ring_outlier_clearance", +) + + +def ring_kwargs_from_config(data_cfg: dict) -> dict: + """Extract required ring geometry keys, hard-failing on any omission.""" + for key in REQUIRED_RING_KEYS: + if key not in data_cfg: + raise ValueError( + f"data.{key} must be set explicitly for dataset_type=thin_rings" + ) + return dict( + ring_count_min=int(data_cfg["ring_count_min"]), + ring_count_max=int(data_cfg["ring_count_max"]), + ring_radius_min=float(data_cfg["ring_radius_min"]), + ring_radius_max=float(data_cfg["ring_radius_max"]), + ring_center_box=float(data_cfg["ring_center_box"]), + ring_outlier_offset_min=float(data_cfg["ring_outlier_offset_min"]), + ring_outlier_offset_max=float(data_cfg["ring_outlier_offset_max"]), + ring_outlier_clearance=float(data_cfg["ring_outlier_clearance"]), + ) diff --git a/tda_ml/run_setup.py b/tda_ml/run_setup.py index 93b020c..572f027 100644 --- a/tda_ml/run_setup.py +++ b/tda_ml/run_setup.py @@ -86,6 +86,42 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): persistent_workers=settings.persistent_workers, prefetch_factor=settings.prefetch_factor, ) + + dataset_type = str(data_cfg.get("dataset_type", "")).strip().lower() + if not dataset_type: + raise ValueError( + "data.dataset_type must be set explicitly (mnist|thin_rings); " + "refusing silent MNIST default" + ) + if dataset_type == "thin_rings": + from tda_ml.ring_dataset import ( + TEST_INDEX_OFFSET, + ThinRingsDataset, + ring_kwargs_from_config, + ) + + if "noise_std" not in data_cfg: + raise ValueError("data.noise_std must be set explicitly; refusing silent default") + common = dict( + max_points=int(data_cfg["max_points"]), + num_outliers=int(data_cfg["num_outliers"]), + noise_std=float(data_cfg["noise_std"]), + noise_seed=seed, + **ring_kwargs_from_config(data_cfg), + ) + train_dataset = ThinRingsDataset(train_size, index_offset=0, **common) + val_dataset = ThinRingsDataset(val_size, index_offset=train_size, **common) + test_dataset = ThinRingsDataset(test_size, index_offset=TEST_INDEX_OFFSET, **common) + return ( + create_data_loader(train_dataset, shuffle=True, **loader_kwargs), + create_data_loader(val_dataset, shuffle=False, **loader_kwargs), + create_data_loader(test_dataset, shuffle=False, **loader_kwargs), + ) + if dataset_type != "mnist": + raise ValueError( + f"data.dataset_type must be 'mnist' or 'thin_rings', got {dataset_type!r}" + ) + if "outlier_mode" not in data_cfg: raise ValueError( "data.outlier_mode must be set explicitly (uniform|local_pca_tangent); " diff --git a/tests/test_ring_dataset.py b/tests/test_ring_dataset.py new file mode 100644 index 0000000..c138330 --- /dev/null +++ b/tests/test_ring_dataset.py @@ -0,0 +1,101 @@ +"""Unit tests for the thin synthetic rings dataset.""" + +from __future__ import annotations + +import unittest + +import torch + +from tda_ml.local_pca import local_pca_ellipse_params +from tda_ml.numerical_eps import MIN_ELLPHI_CENTER_SEPARATION +from tda_ml.ring_dataset import ThinRingsDataset, ring_kwargs_from_config + +RING_KW = dict( + max_points=100, + num_outliers=20, + noise_std=0.005, + ring_count_min=1, + ring_count_max=2, + ring_radius_min=0.25, + ring_radius_max=0.60, + ring_center_box=0.30, + ring_outlier_offset_min=0.03, + ring_outlier_offset_max=0.06, + ring_outlier_clearance=0.03, +) + + +class TestThinRingsDataset(unittest.TestCase): + def test_shapes_and_labels(self): + ds = ThinRingsDataset(5, noise_seed=42, **RING_KW) + data, labels, clean = ds[0] + self.assertEqual(tuple(data.shape), (120, 2)) + self.assertEqual(tuple(labels.shape), (120,)) + self.assertEqual(tuple(clean.shape), (100, 2)) + self.assertEqual(int((labels == 0).sum()), 100) + self.assertEqual(int((labels == 1).sum()), 20) + self.assertTrue(torch.all(data >= -1.0).item()) + self.assertTrue(torch.all(data <= 1.0).item()) + + def test_deterministic_and_split_disjoint(self): + a = ThinRingsDataset(3, noise_seed=42, **RING_KW)[1] + b = ThinRingsDataset(3, noise_seed=42, **RING_KW)[1] + for x, y in zip(a, b): + self.assertTrue(torch.equal(x, y)) + # A different index_offset must generate a different cloud stream. + c = ThinRingsDataset(3, noise_seed=42, index_offset=4500, **RING_KW)[1] + self.assertFalse(torch.equal(a[0], c[0])) + + def test_min_separation_all_points(self): + ds = ThinRingsDataset(4, noise_seed=7, **RING_KW) + for i in range(4): + data, labels, clean = ds[i] + d = torch.cdist(data, data) + d.fill_diagonal_(float("inf")) + self.assertGreaterEqual(float(d.min()), MIN_ELLPHI_CENTER_SEPARATION) + + def test_teacher_ellipses_strongly_anisotropic(self): + # The design goal: local-PCA teacher aspect must be far above MNIST's ~1.8. + ds = ThinRingsDataset(6, noise_seed=42, **RING_KW) + aspects = [] + for i in range(6): + _, _, clean = ds[i] + p = local_pca_ellipse_params(clean, k=10, normalize_axes=True) + aspects.extend((p[:, 0] / p[:, 1]).tolist()) + med = torch.tensor(aspects).median() + self.assertGreater(float(med), 4.0) + + def test_outliers_off_ring_band(self): + # Outliers must be at least ~clearance away from every CLEAN ring point + # (band clearance implies point clearance up to sampling density). + ds = ThinRingsDataset(4, noise_seed=3, **RING_KW) + for i in range(4): + data, labels, clean = ds[i] + out = data[labels == 1] + d = torch.cdist(out, clean).min(dim=1).values + self.assertGreaterEqual(float(d.min()), RING_KW["ring_outlier_offset_min"] * 0.9) + + def test_invalid_geometry_rejected(self): + bad = dict(RING_KW) + bad.update(ring_radius_max=0.70, ring_center_box=0.30) # extent 1.06 > 1 + with self.assertRaises(ValueError): + ThinRingsDataset(2, **bad) + bad = dict(RING_KW) + bad.update(ring_outlier_clearance=0.05) # > offset_min + with self.assertRaises(ValueError): + ThinRingsDataset(2, **bad) + bad = dict(RING_KW) + bad.update(ring_count_min=0) + with self.assertRaises(ValueError): + ThinRingsDataset(2, **bad) + + def test_ring_kwargs_from_config_requires_all_keys(self): + cfg = {k: v for k, v in RING_KW.items() if k.startswith("ring_")} + self.assertEqual(ring_kwargs_from_config(cfg)["ring_count_max"], 2) + del cfg["ring_outlier_clearance"] + with self.assertRaises(ValueError): + ring_kwargs_from_config(cfg) + + +if __name__ == "__main__": + unittest.main() From da2a1c24917fb5407fcb6d528cb83ecb6662eb38 Mon Sep 17 00:00:00 2001 From: murata Date: Sun, 19 Jul 2026 17:39:40 +0900 Subject: [PATCH 36/44] =?UTF-8?q?perf:=20stage1=E3=83=AF=E3=83=BC=E3=82=AB?= =?UTF-8?q?=E3=83=BC=E6=95=B0=E3=82=92STAGE1=5FWORKERS=E3=81=A7=E5=8F=AF?= =?UTF-8?q?=E5=A4=89=E5=8C=96=EF=BC=881=E3=83=AF=E3=83=BC=E3=82=AB?= =?UTF-8?q?=E3=83=BC=E5=AE=9F=E5=8A=B9~4=E3=82=B3=E3=82=A2=E3=81=AE?= =?UTF-8?q?=E3=81=9F=E3=82=81=E5=A4=9A=E3=83=AF=E3=83=BC=E3=82=AB=E3=83=BC?= =?UTF-8?q?=E5=84=AA=E5=85=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- experiments/run_h1_wdist_multifidelity.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/experiments/run_h1_wdist_multifidelity.sh b/experiments/run_h1_wdist_multifidelity.sh index 76017b1..2276611 100755 --- a/experiments/run_h1_wdist_multifidelity.sh +++ b/experiments/run_h1_wdist_multifidelity.sh @@ -72,12 +72,16 @@ cat > "${ROOT_OUT}/MULTIFIDELITY_PLAN.json" < Date: Mon, 20 Jul 2026 07:35:11 +0900 Subject: [PATCH 37/44] =?UTF-8?q?feat:=20=E8=BF=91=E5=82=8D=E6=B1=9A?= =?UTF-8?q?=E6=9F=93=E3=82=92=E5=BC=B7=E3=82=81=E3=81=9F=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=B0=E5=AE=9F=E9=A8=93config=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=EF=BC=88=E5=A4=96=E3=82=8C=E5=80=A440=E3=83=BB=E3=82=AA?= =?UTF-8?q?=E3=83=95=E3=82=BB=E3=83=83=E3=83=880.015-0.035=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADBSCANは観測雲のlocal PCAを使うため近傍汚染に弱い一方、提案はclean 教師で学ぶ。汚染率~98%まで上げて帰納バイアスの勝ち筋を検証する。 --- ...her_local_pca_h1_rings_contam_barrier.yaml | 79 +++++++++++++++++ ..._ellphi_power_h1_rings_contam_barrier.yaml | 87 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier.yaml create mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier.yaml diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier.yaml new file mode 100644 index 0000000..5c3ff5d --- /dev/null +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier.yaml @@ -0,0 +1,79 @@ +# Paper production on CONTAMINATED thin rings (see matching tune config). +# Production 30ep weights MUST come from rings_contam_barrier Optuna best JSON. + +meta: + config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "ellphi" + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + # Prior centres for Optuna; paper production requires --tune-json overrides. + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate_barrier + aniso_barrier_threshold: 6.0 + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 30 + grad_clip_value: 1.0 + visualize_every: 10 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + dataset_type: thin_rings + max_points: 100 + num_outliers: 40 + # Thin rings: transverse sigma (noise_std) << along-ring NN spacing (~0.04), + # so local-PCA ellipses are strongly anisotropic. Outliers: 40 points, radial offset 0.015-0.035 (inside typical kNN radius) + # so ADBSCAN local-PCA neighborhoods are heavily contaminated. Clearance 0.015. + noise_std: 0.005 + ring_count_min: 1 + ring_count_max: 2 + ring_radius_min: 0.25 + ring_radius_max: 0.60 + ring_center_box: 0.30 + ring_outlier_offset_min: 0.015 + ring_outlier_offset_max: 0.035 + ring_outlier_clearance: 0.015 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier.yaml new file mode 100644 index 0000000..d2827ba --- /dev/null +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier.yaml @@ -0,0 +1,87 @@ +# H1-only tune on CONTAMINATED thin rings: more/closer radial outliers so that +# ADBSCAN local-PCA (computed on the observed cloud) is systematically poisoned, +# while the proposed method is trained against a CLEAN local-PCA teacher. +# +# Probe (40 clouds): current rings already contaminate 83% of inlier kNN balls +# (obs aspect 8.9→3.1). This variant amplifies that: 40 outliers, offset 0.015-0.035, +# ~98% inlier neighborhoods contaminated, outlier Mahalanobis under obs ellipses +# halves again vs clean teacher. Hypothesis: proposed inductive bias beats +# contaminated ADBSCAN; Euclidean should still fail. +# +# Known tension: barrier aspect cap 6.0 vs clean teacher aspect ~9. + +meta: + config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier" + +model: + point_dim: 2 + feature_dim: 128 + ellipse_param_dim: 5 + threshold: 0.5 + topology_loss: + metric_type: "anisotropic" + distance_backend: "mahalanobis" # overridden to ellphi by tune script + ellphi_differentiable: true + prob_weighting: false + homology_dimensions: [1] + +loss: + w_class: 0.0 + w_topo: 0.0883 + w_aniso: 0.0541 + w_size: 0.488 + pos_weight: 1.0 + aniso_mode: elongate_barrier + aniso_barrier_threshold: 6.0 + size_mode: power + size_ref: 1.34 + size_power: 1.5 + teacher_mode: local_pca + teacher_local_pca_k: 10 + teacher_local_pca_normalize_axes: true + +training: + lr: 0.000158 + epochs: 20 + grad_clip_value: 1.0 + visualize_every: 10000 + warmup_epochs: 0 + selection: + metric: val_topo + eval_every: 1 + dbscan: + eps_values: null + min_samples_values: null + +data: + dataset_type: thin_rings + max_points: 100 + num_outliers: 40 + # Thin rings: transverse sigma (noise_std) << along-ring NN spacing (~0.04), + # so local-PCA ellipses are strongly anisotropic. Outliers: 40 points, radial offset 0.015-0.035 (inside typical kNN radius) + # so ADBSCAN local-PCA neighborhoods are heavily contaminated. Clearance 0.015. + noise_std: 0.005 + ring_count_min: 1 + ring_count_max: 2 + ring_radius_min: 0.25 + ring_radius_max: 0.60 + ring_center_box: 0.30 + ring_outlier_offset_min: 0.015 + ring_outlier_offset_max: 0.035 + ring_outlier_clearance: 0.015 + seed: 42 + train_size: 4500 + val_size: 500 + test_size: 1000 + num_workers: 2 + batch_size: 64 + pin_memory: true + +performance: + cudnn_benchmark: true + enable_tf32: true + matmul_precision: high + +outputs: + base_dir: "outputs" + save_every: 1 From 7522f5928962f45e6608a1d607e3fe57ef9ef61e Mon Sep 17 00:00:00 2001 From: murata Date: Mon, 20 Jul 2026 12:27:52 +0900 Subject: [PATCH 38/44] fix: baseline grid search accepts negative MCC as valid selection --- experiments/evaluate_paper_baselines.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/experiments/evaluate_paper_baselines.py b/experiments/evaluate_paper_baselines.py index a2aa132..4b54e40 100644 --- a/experiments/evaluate_paper_baselines.py +++ b/experiments/evaluate_paper_baselines.py @@ -258,9 +258,13 @@ def grid_search_clouds( allow_skip_degenerate_grid_cells: bool = False, manifest_ref: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], float, list[dict[str, Any]]]: - best_mcc = -1.0 - best_params = dict(param_combos[0]) + # Select by max mean MCC among successful cells. Negative MCC is a valid + # outcome (method worse than chance on this data); only hard-fail when every + # cell errored or the combo list is empty. + best_mcc: float | None = None + best_params: dict[str, Any] | None = None grid_log: list[dict[str, Any]] = [] + n_ok = 0 for params in tqdm(param_combos, desc=desc, leave=False): per_cloud: list[CloudMetrics] = [] @@ -289,15 +293,18 @@ def grid_search_clouds( "to skipping failed cells." ) _, _, _, mcc, _ = _aggregate_cloud_metrics(per_cloud) + n_ok += 1 grid_log.append( {**params, "status": "ok", "mean_mcc": mcc, "n_clouds": len(per_cloud)} ) - if mcc > best_mcc: + if best_mcc is None or mcc > best_mcc: best_mcc = mcc best_params = dict(params) - if best_mcc < 0: - raise RuntimeError(f"Grid search failed for all hparams ({desc}); log={grid_log[:5]}") + if n_ok == 0 or best_params is None or best_mcc is None: + raise RuntimeError( + f"Grid search failed for all hparams ({desc}); log={grid_log[:5]}" + ) return best_params, best_mcc, grid_log From 40f1cda5d948f67493a741c18fd44118f1e35031 Mon Sep 17 00:00:00 2001 From: murata Date: Sun, 26 Jul 2026 14:36:21 +0900 Subject: [PATCH 39/44] =?UTF-8?q?refactor:=20=E5=85=AC=E9=96=8B=E9=9D=A2?= =?UTF-8?q?=E3=82=92=E8=AB=96=E6=96=87=E6=AD=A3=E6=9C=AC=EF=BC=88ADBSCAN?= =?UTF-8?q?=E5=90=8C=E7=AD=89=E4=B8=BB=E5=BC=B5=E3=83=BBMCC/G-Mean?= =?UTF-8?q?=EF=BC=89=E3=81=AB=E6=95=B4=E7=90=86=E3=81=97=E5=86=8D=E7=8F=BE?= =?UTF-8?q?=E6=80=A7=E3=82=92=E5=8E=B3=E6=A0=BC=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 探索系config/実験スクリプト(tangent/rings/multifidelity等)を公開面から削除 - paper eval を best_model.pth(val_topo)専用化し strict=True で部分読込を禁止 - tuneベースYAMLを objective 中立名(..._ellphi_power)へ改名 - topo_wdist/preflight/run_setup の暗黙デフォルトを hard-fail 化 - fallback opt-in を reproducibility.* 単一経路に統一、ZERO_PAD_ABS_SUM をmanifest記録 - ADBSCAN局所PCAを tda_ml.local_pca に共通化、dead code(model_inference等)削除 - README/REPRODUCIBILITY を主表主張(記述的比較・学習有無の非対称明記)に更新 --- README.md | 160 +++---- REPRODUCIBILITY.md | 100 ++-- configs/README.md | 72 +-- configs/base.yaml | 3 + ...elongate_n100_no_cls_full120_baseline.yaml | 7 +- ...n100_no_cls_full120_teacher_local_pca.yaml | 2 + ...l120_teacher_local_pca_h1_neartangent.yaml | 81 ---- ...cher_local_pca_h1_neartangent_barrier.yaml | 83 ---- ...0_teacher_local_pca_h1_normal_barrier.yaml | 84 ---- ...20_teacher_local_pca_h1_rings_barrier.yaml | 85 ---- ...her_local_pca_h1_rings_contam_barrier.yaml | 79 --- ..._full120_teacher_local_pca_h1_tangent.yaml | 82 ---- ...0_no_cls_tune_local_pca_ellphi_power.yaml} | 9 +- ...no_cls_tune_local_pca_ellphi_power_h1.yaml | 65 --- ...local_pca_ellphi_power_h1_neartangent.yaml | 81 ---- ...a_ellphi_power_h1_neartangent_barrier.yaml | 9 +- ...al_pca_ellphi_power_h1_normal_barrier.yaml | 88 ---- ...cal_pca_ellphi_power_h1_rings_barrier.yaml | 91 ---- ..._ellphi_power_h1_rings_contam_barrier.yaml | 87 ---- ...une_local_pca_ellphi_power_h1_tangent.yaml | 80 ---- experiments/aggregate_power_30ep_multiseed.py | 3 +- experiments/barrier_neartangent_smoke.py | 138 ------ experiments/checkpoint_selection.py | 88 ---- experiments/enqueue_top_optuna_trials.py | 134 ------ experiments/evaluate_paper_baselines.py | 63 +-- experiments/evaluate_paper_protocol.py | 59 ++- experiments/evaluate_topo_checkpoint.py | 197 -------- experiments/launch_detached_screen.sh | 2 +- experiments/launch_h1_wdist_multifidelity.sh | 20 - .../launch_h1_wdist_tangent_multifidelity.sh | 24 - experiments/run_backend_multiseed.py | 7 +- experiments/run_h1_wdist_multifidelity.sh | 115 ----- .../run_teacher_local_pca_power_30ep.py | 2 - ..._teacher_local_pca_power_30ep_multiseed.sh | 4 +- .../run_tune_local_pca_power_mcc_parallel.sh | 2 +- ...run_tune_local_pca_power_wdist_parallel.sh | 2 +- experiments/tune_elongate_mcc.py | 4 +- experiments/tune_elongate_wdist.py | 6 +- main.py | 6 - pyproject.toml | 6 - tda_ml/data_loader.py | 3 +- tda_ml/dbscan_eval.py | 3 +- tda_ml/gudhi_utils.py | 13 - tda_ml/main.py | 2 +- tda_ml/model_inference.py | 43 -- tda_ml/model_selection.py | 30 +- tda_ml/numerical_eps.py | 15 +- tda_ml/persistence.py | 167 +------ tda_ml/preflight.py | 16 +- tda_ml/reproducibility.py | 2 + tda_ml/run_setup.py | 13 +- tda_ml/teacher_pd.py | 3 +- tda_ml/topo_wdist.py | 53 +- tda_ml/topology.py | 3 + tda_ml/trainer.py | 115 ++++- tests/test_evaluate_topo_checkpoint.py | 60 --- tests/test_paper_eval_imports.py | 57 +++ tests/test_persistence.py | 8 +- tests/test_persistence_dimensions.py | 38 +- tests/test_reproducibility_strict.py | 18 +- tests/test_trainer.py | 8 +- tests/test_tune_config.py | 8 +- uv.lock | 451 +----------------- 63 files changed, 598 insertions(+), 2761 deletions(-) delete mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml delete mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml delete mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier.yaml delete mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier.yaml delete mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier.yaml delete mode 100644 configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml rename configs/{elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml => elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml} (76%) delete mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1.yaml delete mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml delete mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier.yaml delete mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier.yaml delete mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier.yaml delete mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml delete mode 100644 experiments/barrier_neartangent_smoke.py delete mode 100644 experiments/checkpoint_selection.py delete mode 100755 experiments/enqueue_top_optuna_trials.py delete mode 100644 experiments/evaluate_topo_checkpoint.py delete mode 100755 experiments/launch_h1_wdist_multifidelity.sh delete mode 100755 experiments/launch_h1_wdist_tangent_multifidelity.sh delete mode 100755 experiments/run_h1_wdist_multifidelity.sh delete mode 100644 main.py delete mode 100644 tda_ml/gudhi_utils.py delete mode 100644 tda_ml/model_inference.py delete mode 100644 tests/test_evaluate_topo_checkpoint.py create mode 100644 tests/test_paper_eval_imports.py diff --git a/README.md b/README.md index 8c8e1f3..4e1fb47 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,112 @@ -# TDA-ML Reproducibility Repository +# TDA-ML -This repository provides a reproducibility-focused implementation for anisotropic topological denoising experiments. +Clean-room, reproducibility-focused implementation for anisotropic topological +denoising (Letters / applied-math reference code). -For **data layout, checkpoints, figure-related scripts, and CI tests**, see [`REPRODUCIBILITY.md`](REPRODUCIBILITY.md) (reader-facing). +Reader-facing protocol, manifests, and failure semantics: +[`REPRODUCIBILITY.md`](REPRODUCIBILITY.md). -## Setup +Computational discipline follows +[computational-reproducibility](https://github.com/t-uda/skills/blob/main/skills/computational-reproducibility/SKILL.md) +(no silent fallback; declared numerical constants in `tda_ml/numerical_eps.py`). -Use `uv` as the canonical dependency manager. +## Claim (main table) -`torch_topological` is a **local path dependency** (`pyproject.toml` → `pytorch-topological/`). That directory is not always present after a plain `git clone`; sync it to the pinned commit in `third_party/pytorch_topological.ref` (same as CI): +Proposed method (W-Dist-tuned weights, 30 epochs × 5 data seeds) shows +**comparable** outlier-removal performance to Euclidean DBSCAN and ADBSCAN on +**MCC / G-Mean** (descriptive mean ± sample std over seeds; no equivalence test). +Main table does **not** include a Topo W. column. -```bash -./scripts/ensure_pytorch_topological.sh -``` +ADBSCAN uses fixed local-PCA ellipses (no training). The proposed method trains +for 30 epochs on the same data; the comparison is not compute-matched. -`ellphi` is also a **local path dependency** (`pyproject.toml` → `ellphi_repo/`). Sync the pinned fork (differentiable tangency grad API) before `uv sync`: +## Setup ```bash +./scripts/ensure_pytorch_topological.sh ./scripts/ensure_ellphi_repo.sh +uv sync +# development (tests, ruff): +uv sync --all-groups +# Optuna tune drivers: +uv sync --extra experiments ``` -Optional: if this repository records `pytorch-topological` as a git submodule gitlink, `git submodule update --init --recursive` may populate the tree first; the ensure script still verifies the pinned commit. See `third_party/README.md` when bumping the pin. +`torch_topological` and `ellphi` are **local path dependencies** (pinned under +`third_party/*.ref`). A plain `git clone` is not enough; run the ensure scripts +before `uv sync`. See `third_party/README.md` when bumping pins. -Then install Python dependencies: +## Paper production path (primary) -```bash -uv sync -``` +Main table: **W-Dist-tuned weights**, 30 epochs × 5 data seeds, `w_class=0`, +H1-only, local-PCA teacher, ellphi distance, checkpoint `best_model.pth` +(`selection=val_topo`), then val DBSCAN grid → test MCC / G-Mean. -For development dependencies: +Config: `elongate_n100_no_cls_full120_teacher_local_pca`. ```bash -uv sync --all-groups -``` +# 1) Tune once (Optuna sampler seeds differ per worker; data seed in YAML is 42). +# Default MODE=both also runs the secondary MCC study; main table needs wdist. +MODE=wdist bash experiments/run_tune_local_pca_power_objectives.sh -Optional package extras (e.g. `robustness_sweep`, HomCloud animation, explicit Pillow): +# 2) Fixed W-Dist weights → 30ep × 5 seeds → paper eval (+ baselines separately) +bash experiments/run_teacher_local_pca_power_30ep_multiseed.sh wdist -```bash -uv sync --extra experiments --extra repro-pd-animation --extra images +uv run python experiments/evaluate_paper_baselines.py \ + --base-config elongate_n100_no_cls_full120_teacher_local_pca \ + --out-dir outputs/paper_baselines ``` -If you use `pip` instead of `uv`, run `./scripts/ensure_pytorch_topological.sh` and `./scripts/ensure_ellphi_repo.sh` from the repository root, then install from `pyproject.toml` with `pip install .` or `pip install -e .` for an editable install; add optional extras when needed (for example `pip install -e ".[experiments,repro-pd-animation,images]"`). There is no `requirements.txt`; dependency pins live in `uv.lock` for `uv` users. +Details and contract keys: `REPRODUCIBILITY.md` / `configs/README.md`. +Generated artifacts stay under `outputs/` (not committed). -## Minimal Reproduction +## Backend pipeline comparison (secondary / CI) -Use the following command as the official reproduction entrypoint: +`experiments/run_backend_multiseed.py` compares **full training pipelines** under +`configs/reproduce.yaml`. It is **not** the paper main-table entrypoint and +**not** a pure distance-backend ablation. ```bash +# CI-style smoke uv run python experiments/run_backend_multiseed.py \ --base-config reproduce \ - --epochs 50 \ - --seeds 42 123 456 789 1024 \ - --backends mahalanobis ellphi \ - --out-base outputs/backend_compare -``` - -Run this command from the repository root. - -Expected artifacts: - -- `outputs/backend_compare/progress_summary.csv` -- `outputs/backend_compare/backend_stats.csv` -- `outputs/backend_compare/*/logs/metrics.csv` - -Direct execution of `tda_ml/main.py` is non-official and not part of the canonical reproduction path. - -### Resume Behavior - -- `run_backend_multiseed.py` skips completed runs using the key `(backend, seed, epochs)`. -- Changing `--epochs` creates a different run key and will not be skipped. -- Use `--rerun-completed` to force rerun even when the same key already exists. -- `progress_summary.csv` header is validated on startup. If header mismatch occurs, use a fresh `--out-base` or fix the CSV manually. - -### Operational Notes - -- `run_backend_multiseed.py` creates a lock file under `--out-base` and aborts if another process is active there. -- `progress_summary.csv` stores `run_dir` as provided by `--out-base`; avoid sharing logs with personal absolute paths if privacy is a concern. -- Keep `metrics.csv` schema compatible with the current trainer output (`val_mcc`, `val_recall`, `val_loss` are required). - -## Quick Smoke Run - -For a fast wiring check before the full run: + --epochs 1 --seeds 42 --backends mahalanobis \ + --out-base outputs/smoke -```bash +# Full secondary comparison (local / own runner; not CI) uv run python experiments/run_backend_multiseed.py \ --base-config reproduce \ - --epochs 1 \ - --seeds 42 \ - --backends mahalanobis \ - --out-base outputs/smoke + --epochs 50 --seeds 42 123 456 789 1024 \ + --backends mahalanobis ellphi \ + --out-base outputs/backend_compare ``` -## Continuous integration - -On pull requests and pushes to `main` / `feature/**`, CI runs: - -- `ruff check` -- `pytest` -- A **repro smoke** only: `run_backend_multiseed.py` with `--epochs 1`, one seed, and `mahalanobis` (see `.github/workflows/ruff.yml`). +Expected under `--out-base`: `progress_summary.csv`, `backend_stats.csv`, and +per-run `*/logs/metrics.csv`. Resume skips completed `(backend, seed, epochs)` +keys; use `--rerun-completed` to force. A lock file under `--out-base` aborts if +another process is active. -The full official command (50 epochs × five seeds × two backends) is **not** executed in CI. Run that locally or in your own runner when validating paper numbers. +`tda_ml.main` is an internal trainer entry used by the drivers above; do not +treat direct invocation as the public protocol. -## Known Constraints +## Continuous integration -- The official comparison uses a fixed five-seed set: `42 123 456 789 1024`. -- Runtime and numeric behavior can vary by `device`, `thread`, and `dtype`; effective values should be logged per run. -- External implementations are reference-only and are not redistributed in this repository. -- Scripts under `tda_ml/experiments/` are **non-official** and require your own checkpoints (see `configs/README.md`). -- `run_backend_multiseed.py` multiseed backend comparison is **not** a pure distance-backend-only ablation; it compares full training pipelines, not an isolated backend switch. -- For topological loss, **`mahalanobis`** can incorporate predicted **outlier-probability weighting** in the distance matrix; **`ellphi`** is geometry-only and requires `prob_weighting=false`. -- Read outcomes as **two full pipelines** under the same schedule and config surface, not as isolating distance-backend effects alone. +PRs and pushes to `main` / `feature/**` run `ruff`, tests, and the **1-epoch +mahalanobis smoke** above. Paper 30ep × 5-seed production is not run in CI. -### Backend comparison: outlier-probability weighting +## Known constraints -For **topological loss**, the batched distance matrix is built per `model.topology_loss.distance_backend`. With **`mahalanobis`**, the implementation can incorporate **predicted outlier probabilities** when forming pairwise distances (see `tda_ml.topology.compute_anisotropic_distance_matrix`). With **`ellphi`**, tangency distances are computed from ellipse geometry only; **probability-based weighting is not implemented**, so the backend comparison driver explicitly sets `prob_weighting=false`. Requesting it now hard-fails rather than silently ignoring `probs` (see `tda_ml.distance_backend.compute_distance_matrix_batch`). Hyperparameters in `configs/reproduce.yaml` are shared across backends, but **the induced metric for topological loss is not identical across backends** by design: the intended read is a reproducible pipeline comparison (same data, schedule, and config surface), not a claim that both backends optimize the exact same weighted distance objective. Elliptic contact distances are left as defined by `ellphi`; no synthetic “prob-equivalent” weighting is applied on the ellphi path. +- Fixed paper seed set: `42 123 456 789 1024`. +- Runtime depends on device / threads / dtype; each run records a manifest. +- For topological loss, **mahalanobis** may use outlier-probability weighting; + **ellphi** is geometry-only and requires `prob_weighting=false` (hard-fail + otherwise). Do not read backend comparison as an isolated metric swap. ## License / Attribution -This project is licensed under the MIT License. See `LICENSE`. - -External implementations are reference-only and are not redistributed in this repository. - -- **ellphi (training / differentiable tangency):** requires the pinned fork checked out via `./scripts/ensure_ellphi_repo.sh` (`third_party/ellphi.ref` → `ellphi_repo/`). PyPI `ellphi==0.1.2` alone does **not** include the `ellphi.grad` API used for training. Fork: [koki3070/ellphi](https://github.com/koki3070/ellphi) (based on [t-uda/ellphi](https://github.com/t-uda/ellphi)). -- **pytorch-topological:** [aidos-lab/pytorch-topological](https://github.com/aidos-lab/pytorch-topological) via `./scripts/ensure_pytorch_topological.sh`. - -Upstream references: +MIT — see `LICENSE`. -- `https://github.com/t-uda/ellphi` -- `https://github.com/aidos-lab/pytorch-topological` +- **ellphi (differentiable tangency):** pinned fork via + `./scripts/ensure_ellphi_repo.sh` (`third_party/ellphi.ref`). PyPI + `ellphi==0.1.2` alone lacks the training `ellphi.grad` API. +- **pytorch-topological:** via `./scripts/ensure_pytorch_topological.sh`. diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 4d4836e..db7e75f 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -10,19 +10,59 @@ ### 論文比較(ellphi + power 二目的)で使う `experiments/` **論文主表の提案:** W-Dist tune 重みの 30ep 5-seed(`run_teacher_local_pca_power_30ep_multiseed.sh wdist`)。 -**主張:** Euclidean DBSCAN / ADBSCAN と **同程度の外れ値除去性能**(MCC / G-Mean)。主表に Topo W. 列は載せない。 +**主張:** Euclidean DBSCAN / ADBSCAN と **同程度の外れ値除去性能**(MCC / G-Mean;5 seed の mean ± sample std による**記述的**比較。同等性検定は行わない)。主表に Topo W. 列は載せない。 +**比較の非対称:** ADBSCAN は学習なしの局所 PCA 楕円ベースライン。提案法は同一データで 30ep 学習する(計算資源・パラメータ更新は対等ではない)。 **正本 config:** `elongate_n100_no_cls_full120_teacher_local_pca`(`w_class=0`, `homology_dimensions=[1]`, `aniso_mode=elongate`)。 出力先は `WDIST_OUT` / `MCC_OUT` / `LOG_ROOT`(既定: `outputs/supervised/pwr30_*`)で明示する(生成物は git に含めない)。 | 区分 | パス | |------|------| | 本番 5-seed | `run_teacher_local_pca_power_30ep_multiseed.sh`, `run_teacher_local_pca_power_30ep.py`, `aggregate_power_30ep_multiseed.py` | -| paper eval | `evaluate_paper_protocol.py` | +| paper eval | `evaluate_paper_protocol.py`(`best_model.pth` のみ) | | ベースライン | `evaluate_paper_baselines.py` | | チューニング(重みの出所) | `tune_elongate_wdist.py`, `tune_elongate_mcc.py`, `run_tune_local_pca_power_*` | **実行記録:** 各 run の `source_revision`(git HEAD)は `logs/run_manifest.json` および `paper_metrics_*.json` に記録。未コミットのまま実行した場合、リモート clone では数値が再現できない。 +### 正本の起動(学習 → paper eval) + +checkpoint は **`best_model.pth`(`selection=val_topo`)のみ**。欠落は hard-fail(サイレント代替なし)。YAML 役割は `configs/README.md`。 + +**1. Tune(主表は W-Dist;YAML の data.seed=42。Optuna sampler seed は worker ごとに異なる)** + +```bash +MODE=wdist bash experiments/run_tune_local_pca_power_objectives.sh +``` + +**2. 本番 30ep × 5-seed(tune JSON 必須)→ 内部で paper eval** + +```bash +bash experiments/run_teacher_local_pca_power_30ep_multiseed.sh wdist +``` + +**3. ベースライン(ADBSCAN 等)** + +```bash +uv run python experiments/evaluate_paper_baselines.py \ + --base-config elongate_n100_no_cls_full120_teacher_local_pca \ + --out-dir outputs/paper_baselines +``` + +単一 seed・手動 eval: + +```bash +uv run python experiments/run_teacher_local_pca_power_30ep.py \ + --tune-json outputs/tune/pwr_wdist/best_elongate_wdist_ellphi.json \ + --seed 42 + +uv run python experiments/evaluate_paper_protocol.py \ + --run-dir outputs/supervised/.../pwr_s42_ \ + --base-config elongate_n100_no_cls_full120_teacher_local_pca \ + --split val +``` + +運用補助: `experiments/launch_detached_screen.sh`(screen 経由の長時間ジョブ)。論文主表の入口ではない。 + ### W-Dist 契約(場所ごとの定義) | 経路 | homology | 距離 / filtration | 備考 | @@ -33,7 +73,7 @@ 主表・チューニングの preflight は `homology_dimensions=[1]`、`teacher_mode=local_pca`、`prob_weighting=false`、`aniso_mode=elongate`、`distance_backend=ellphi`、`size_mode=power`、`w_class=0.0`、`teacher_local_pca_k=10`、`teacher_local_pca_normalize_axes=true` の明示を要求する。欠落や不一致は実行前に hard-fail する。本番 30ep は `--tune-json`(H1-only Optuna best)必須で、YAML 埋め込みの旧重みでは起動しない。 -**退化ガード variant(opt-in):** near-tangent データでは素の `elongate` が短軸→0(epoch 15 で短半径 ~1e-5、アスペクト比 ~350)まで潰し、ellphi の tangency 計算が hard-fail する(stage-2 tune 16 条件中 15 失敗)。この対策として `aniso_mode: elongate_barrier`(elongate 報酬 + 閾値超過アスペクト比への二次バリア)を **config で明示宣言する第 2 の契約 variant**(`PAPER_NO_CLS_BARRIER_CONTRACT`、`aniso_barrier_threshold=6.0` 必須)として定義する。暗黙の切替・clamp は行わず、variant は base config の `loss.aniso_mode` 宣言から `paper_aniso_fields()` 経由で tune/本番へ伝播し、STUDY_PREFLIGHT・tune JSON・run manifest(`paper_no_cls_contract`/`loss_overrides`)に記録される。集計は `aggregate_power_30ep_multiseed.py --aniso-variant elongate_barrier` で契約一致を検証する。 +**退化ガード variant(主表外・Methods opt-in):** near-tangent データでは素の `elongate` が短軸→0 まで潰し、ellphi tangency が hard-fail し得る。対策として `aniso_mode: elongate_barrier`(`PAPER_NO_CLS_BARRIER_CONTRACT`、`aniso_barrier_threshold=6.0`、`distance_backend=ellphi`)を **YAML で明示したときだけ**使う。公開の tune 入口は `elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier`(`BASE_CONFIG=...`)。暗黙の切替はしない。主表の ADBSCAN 比較には使わない。 ## 環境 @@ -59,11 +99,12 @@ - MNIST は git にコミットしません(`data/` は無視対象)。 - 初回の学習またはデータセットアクセス時に、`torchvision` 経由で **`./data`** 以下にダウンロードされます(`configs/reproduce.yaml` を前提とした設定が典型です)。 - 初回はインターネットに到達できるようにするか、キャッシュ済みの MNIST を自分で `./data` に置いてください。 -- 設定 YAML の役割分担は **`configs/README.md`**(正本 5 本)を参照。旧設定はローカルで `configs/archive/` に置けるが、公開クローンには同梱されない。 +- 設定 YAML の役割分担は **`configs/README.md`** を参照(共有プロファイル + 論文用 `elongate_n100_no_cls_*`)。旧設定はローカルで `configs/archive/` に置けるが、公開クローンには同梱されない。 ## チェックポイントと実行出力 -- `experiments/run_backend_multiseed.py` が内部で読み込む **`tda_ml/main.py`** の学習処理により、実行ごとのディレクトリ以下に成果物が書き出されます(README の「Expected artifacts」参照)。典型例は `logs/metrics.csv`、`logs/runtime_profile.json`、`best_model.pth`、可視化が有効なら `images/` などです。 +- 論文・backend 比較とも、学習成果物は実行ごとのディレクトリ以下に書き出されます。典型例は `logs/metrics.csv`、`logs/runtime_profile.json`、`logs/run_manifest.json`、`best_model.pth`、可視化が有効なら `images/` などです。 +- paper eval / tune の評価 checkpoint は **`best_model.pth` のみ**(`resolve_val_topo_checkpoint`)。別名への切替は不可。 - **`outputs/`** は git の対象外です。論文用に実行ツリーを保存する場合は、原稿や付録で **コミットハッシュ・シード・使用した設定名** とあわせてパスを示すと追跡しやすいです。`progress_summary.csv` には絶対パスが入るため、共有時のプライバシーに注意してください。 ## 厳格な再現性インフラ(preflight / manifest) @@ -119,9 +160,9 @@ opt-in fallback を有効にした場合、`run_manifest.json` の `fallbacks` `tda_ml/run_paths.py` が `outputs/supervised` / `outputs/supervised_no_cls` / `outputs/tune` 配下の slug・タイムスタンプ命名を統一します。新規実験フォルダは `scripts/new_experiment.sh` を使用してください。 -## 数値再現の公式手順 +## 副次: バックエンドパイプライン比較(論文主表ではない) -**公式**のマルチシード・バックエンド比較は次のとおりです。 +`run_backend_multiseed.py` は **二次比較 / CI smoke** 用です。論文主表の入口ではありません。 ```bash uv run python experiments/run_backend_multiseed.py \ @@ -132,7 +173,7 @@ uv run python experiments/run_backend_multiseed.py \ --out-base outputs/backend_compare ``` -再開の挙動、ロックファイル、CSV の意味は `README.md` に書いてあります。 +再開・ロック・期待成果物は `README.md` の Backend pipeline comparison 節を参照。 ### バックエンド比較と outlier 確率の重み(非対称) @@ -142,7 +183,7 @@ uv run python experiments/run_backend_multiseed.py \ 位相損失用の距離行列は `model.topology_loss.distance_backend` ごとに別定義です。**`mahalanobis`** では、学習で予測した **outlier 確率 `probs`** を距離の重み付けに織り込めます(`tda_ml.topology.compute_anisotropic_distance_matrix`)。**`ellphi`** では楕円の接触距離のみを用い、`run_backend_multiseed.py` が `prob_weighting=false` を明示します。未実装の確率重みを要求すると `tda_ml.distance_backend.compute_distance_matrix_batch` が hard-fail します。 -したがって、`run_backend_multiseed.py` で同じ YAML を回しても、**位相損失が見ている距離空間はバックエンド間で同一ではありません**。ここでは「同一のデータ・スケジュール・設定表面での再現パイプライン比較」を意図しており、**両バックエンドが数学的に完全に同型の重み付き距離目的関数を共有する**という読み方はしません。`ellphi` 側に Mahalanobis の確率重みに相当する項を無理に足す予定はなく、比較の解釈は本節および `README.md` の英語節(*Backend comparison: outlier-probability weighting*)に従ってください。 +したがって、`run_backend_multiseed.py` で同じ YAML を回しても、**位相損失が見ている距離空間はバックエンド間で同一ではありません**。ここでは「同一のデータ・スケジュール・設定表面での再現パイプライン比較」を意図しており、**両バックエンドが数学的に完全に同型の重み付き距離目的関数を共有する**という読み方はしません。`ellphi` 側に Mahalanobis の確率重みに相当する項を無理に足す予定はなく、比較の解釈は本節および `README.md` の Known constraints に従ってください。 ### ellphi + power:二目的チューニング(実験メモ) @@ -176,23 +217,27 @@ b_i = b_{i,\mathrm{base}}\, e^{\Delta b_i},\quad clip や sigmoid による軸倍率の暗黙クリップは行わない。ellphi 等で退化が起きた run は `run_status: failed` として記録する([Computational Reproducibility skill](https://github.com/t-uda/skills/blob/main/skills/computational-reproducibility/SKILL.md))。 -**正則化(`tda_ml/losses.py`)** +**正則化(`tda_ml/losses.py`)** — 主表は `size_mode: power`: \[ -\mathcal{L}_{\mathrm{size}} = \frac{1}{N}\sum_i (M_i^2 + m_i^2),\quad -M_i=\max(a_i,b_i),\; m_i=\min(a_i,b_i). +\mathcal{L}_{\mathrm{size}} += \frac{1}{N}\sum_i \left(\frac{M_i^2 + m_i^2}{\mathrm{ref}}\right)^{\gamma},\quad +M_i=\max(a_i,b_i),\; m_i=\min(a_i,b_i), \] +(`size_ref` \(=\mathrm{ref}\)、`size_power` \(=\gamma\);主表は ref=1.34, γ=1.5)。 +`size_mode: quadratic`(\(\frac{1}{N}\sum_i (M_i^2+m_i^2)\))は非主表の共有プロファイル用。 + 主表 power 30ep config(`elongate_n100_no_cls_full120_teacher_local_pca`)では `homology_dimensions: [1]`(H1-only Wasserstein)と `aniso_mode: elongate` を用いる。 ellphi 退化(NaN 共分散・接線距離未定義など)は `run_status: failed` とする([Computational Reproducibility skill](https://github.com/t-uda/skills/blob/main/skills/computational-reproducibility/SKILL.md))。 -別 ablation では `aniso_mode: linear` により -$\mathcal{L}_{\mathrm{aniso}} = \frac{1}{N}\sum_i R_i$($R_i=M_i/m_i$)。 -図用 ablation(`ablation_localscale_try2` 等)では -`aniso_mode: barrier` として -$\mathcal{L}_{\mathrm{aniso}} = \frac{10}{N}\sum_i \mathrm{ReLU}(R_i-\tau)^2$。 +非主表 ablation では `aniso_mode: linear` +($\mathcal{L}_{\mathrm{aniso}} = \frac{1}{N}\sum_i R_i$、$R_i=M_i/m_i$)や +`aniso_mode: barrier` +($\mathcal{L}_{\mathrm{aniso}} = \frac{10}{N}\sum_i \mathrm{ReLU}(R_i-\tau)^2$)を使う。 +これらの ablation config は公開ツリーに含めず、ローカル `configs/archive/` のみ。 **Mahalanobis 距離**(`tda_ml/topology.py`)は outlier 確率 $p_i$ により二乗距離を $1/\bigl((1-p_i)(1-p_j)\bigr)$ で重み付け(`INLIER_PROB_MIN` で下限クリップ)。 @@ -201,29 +246,24 @@ $1/\bigl((1-p_i)(1-p_j)\bigr)$ で重み付け(`INLIER_PROB_MIN` で下限ク ## 図・定性出力 -`docs/paper/` 以下の LaTeX の **すべての図を一括で出す単一スクリプトはありません**。原稿専用のアセットもあります。下の表は **コードに近い** 図の流れを示すものです。論文の図を増やしたら表も追記してください。 - -| 種類 | スクリプト / 場所 | 典型出力 | -|------|-------------------|----------| -| 学習中のスナップショット(楕円・点) | `tda_ml.trainer` → `tda_ml.visualization.visualize` | `/images/`、`result_epoch_*.png` を含むファイル名 | -| PD アニメ(任意 extra) | `tda_ml/reproduce_pd_animation.py`(`pyproject` の optional `repro-pd-animation` 参照) | `experiments/repro_pd_animation_final/frames/` 付近(スクリプトが `image_dir` を設定) | -| ノイズ感度プロット | `tda_ml/experiments/run_noise_sensitivity.py` | `outputs/metrics_vs_noise_level.png` | -| クラスタリングベンチマーク図 | `tda_ml/experiments/clustering_benchmark.py`(`--checkpoint` 必須) | `--output` で指定(既定 `outputs/clustering_benchmark.png`) | -| ロバストネススイープの図 | `tda_ml/robustness_sweep.py` | `important_results/robustness_*.png` | -| 学習 PNG の分割・後処理 | `tda_ml/split_results_images.py` | ユーザー指定の `--image_dir` / `--output_dir` | -| 静的な論文用アセット | `docs/paper/` 以下の LaTeX(`\includegraphics` のパスは各 `.tex` を参照) | 論文ビルドが参照する PNG/PDF(ローカル生成のこともあり、常に git に無いとは限らない) | +学習中の楕円・点スナップショットは `tda_ml.trainer` → `tda_ml.visualization.visualize` が +`/images/` に書き出します。論文用の静的図アセットはローカルの `docs/`(git 外)で管理します。 -楕円描画は `tda_ml/visualization.py` と `tda_ml/geometry.py`(パラメータ → 共分散 → 描画)を参照してください。内部用の長い数式メモは公開 git には含めません。 +楕円パラメータ → 共分散 → 描画は `tda_ml/visualization.py` と `tda_ml/geometry.py` を参照してください。 ## 自動テスト ローカルでは: ```bash +uv run python -m unittest discover -s tests -v +# または uv run pytest ``` -PR および **`main` と `feature/**` への push** のたびに、CI では **ruff**、**pytest**、軽量な **再現スモーク**(`experiments/run_backend_multiseed.py` を `--epochs 1`・1 seed・`mahalanobis` で実行)が走ります。**50 epoch × 5 seed × 2 backend の本番コマンドは CI では実行しません。** +PR および **`main` と `feature/**` への push** のたびに、CI では **ruff**、テスト、軽量な +**backend smoke**(`run_backend_multiseed.py` を `--epochs 1`・1 seed・`mahalanobis`)が走ります。 +**論文本番(30ep × 5 seed)は CI では実行しません。** ## 論文提出時のスナップショット diff --git a/configs/README.md b/configs/README.md index 56d0db2..d597f55 100644 --- a/configs/README.md +++ b/configs/README.md @@ -1,49 +1,63 @@ # Configuration layout -Canonical YAML files live **in this directory** (deep-merged with `base.yaml` by `tda_ml.config.load_config`): +Canonical YAML files live **in this directory** (deep-merged with `base.yaml` by +`tda_ml.config.load_config`). + +## 正本(公開・論文) | File | Role | |------|------| -| `base.yaml` | Shared defaults; always merged first. | -| `reproduce.yaml` | Paper / official multi-seed reproduction (`run_backend_multiseed.py`). | -| `dev.yaml` | Small MNIST subset for local wiring checks (non-official). | -| `prod.yaml` | Longer CPU profile (non-official). | -| `test_fast.yaml` | Small settings for quick checks and CI smoke. | -| `elongate_n100_no_cls_full120_teacher_local_pca.yaml` | Paper no_cls production (H1-only, local_pca teacher). | -| `elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml` | Optuna tune base for power + ellphi (W-Dist / MCC studies). | -| `elongate_n100_no_cls_full120_baseline.yaml` | H1-only no_cls contrast with Euclidean teacher (optional table column). | +| `base.yaml` | Shared defaults; always merged first. Declared keys only (no silent trainer defaults). | +| `reproduce.yaml` | Secondary backend pipeline comparison (`run_backend_multiseed.py` / CI smoke). | +| `dev.yaml` | Small MNIST subset for local wiring (non-paper). | +| `prod.yaml` | Longer CPU profile (non-paper). | +| `test_fast.yaml` | Quick checks / CI. | +| `elongate_n100_no_cls_full120_teacher_local_pca.yaml` | **Paper production** (`w_class=0`, H1-only, local_pca, `aniso_mode=elongate`). | +| `elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml` | Shared Optuna tune base for **both** W-Dist and MCC studies (`config_id` is objective-neutral). | +| `elongate_n100_no_cls_full120_baseline.yaml` | Optional Euclidean-teacher contrast column. | + +## 宣言済み契約 variant(主表外・Methods で主張する場合のみ) + +| File | Role | +|------|------| +| `elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml` | Degeneracy guard: `aniso_mode=elongate_barrier`, `aniso_barrier_threshold=6.0`, `distance_backend=ellphi`. Opt-in via `BASE_CONFIG=...`; never an implicit swap. | + +## 置かないもの + +Probe / ablation / dated experiment YAML → local `configs/archive/` only +(gitignored). Do not reintroduce rings / contam / raw_axes configs here. -**Probe / ablation / dated experiment YAML** (scaleinv, topo12, raw_axes, H1 launch variants, etc.) belongs under local `configs/archive/` and is **not** part of the publishable surface. Load with `load_config("archive/")` when present. +Library support without public paper YAML: `tda_ml/ring_dataset.py` (`dataset_type=thin_rings`) +and `tda_ml/tangent_outliers.py` remain importable for opt-in Methods experiments; +they are **not** the main-table path unless a public YAML above selects them. ## Keys read by the training stack `tda_ml.main` and `Trainer` use the following (other YAML keys are ignored). +Missing required keys **hard-fail** (no silent method defaults). | Section | Key | Used by | Notes | |---------|-----|---------|--------| | `meta` | `config_id` | `main` | Run directory prefix `_`. | | `model` | `point_dim`, `feature_dim` | `main` | Passed to `AnisotropicOutlierClassifier`. | | `model` | `threshold` | `Trainer` | Classification threshold. | -| `model` | `topology_loss.distance_backend` | `Trainer` | `mahalanobis` or `ellphi` (explicit; no silent default). | -| `model` | `topology_loss.homology_dimensions` | `Trainer` / topo W-Dist | Explicit list (e.g. `[0,1]` or paper `[1]`). | -| `model` | `topology_loss.prob_weighting` | `Trainer` | Explicit bool; `ellphi` requires `false`. | -| `model` | `topology_loss.ellphi_differentiable` | `Trainer` | Default `true`; multiseed driver reads from merged config. | -| `loss` | `w_class`, `w_topo`, `w_aniso`, `w_size` | `Trainer` | Required under `loss.*` (`reproducibility.allow_legacy_loss_keys=false`). | -| `loss` | `teacher_mode` | `Trainer` / topo W-Dist | Explicit (`euclidean` or `local_pca`). | -| `loss` | `pos_weight`, `aniso_mode`, `size_mode` | `Trainer` | BCE positive weight; anisotropy / size penalty modes. | +| `model` | `topology_loss.distance_backend` | `Trainer` | `mahalanobis` or `ellphi` (required). | +| `model` | `topology_loss.homology_dimensions` | `Trainer` / topo W-Dist | Required list. | +| `model` | `topology_loss.prob_weighting` | `Trainer` | Required bool; `ellphi` requires `false`. | +| `model` | `topology_loss.ellphi_differentiable` | `Trainer` | Required bool. | +| `loss` | `w_class`, `w_topo`, `w_aniso`, `w_size` | `Trainer` | Required under `loss.*`. | +| `loss` | `teacher_mode` | `Trainer` / topo W-Dist | Required (`euclidean` or `local_pca`). | +| `loss` | `topo_eps_scale`, `topo_scale_mode` | `Trainer` | Required filtration alignment. | +| `loss` | `teacher_local_pca_*` | `Trainer` | Required when `teacher_mode=local_pca`. | +| `loss` | `pos_weight`, `aniso_mode`, `size_mode` | `Trainer` | Mode-specific extras required when that mode is selected. | | `training` | `lr`, `epochs`, `grad_clip_value`, `visualize_every`, `warmup_epochs` | `main` / `Trainer` | | -| `training` | `lambda_major`, `lambda_minor`, `lambda_size` | `Trainer` | Ellipse size loss (overrides `w_size` when set). | -| `training` | `barrier_threshold`, `rotation_augmentation` | `Trainer` | Anisotropy barrier / data aug. | -| `training` | `use_amp`, `amp_dtype` | `Trainer` | CUDA AMP (optional). | -| `data` | `seed`, `train_size`, `val_size`, `test_size` | `main` | MNIST index splits (not `num_samples`). | -| `data` | `max_points`, `num_outliers`, `batch_size`, `num_workers`, … | `main` | DataLoader / dataset. | +| `training` | `selection.metric` | `Trainer` | Required (paper: `val_topo`). | +| `data` | `seed`, sizes, `outlier_mode`, … | `main` | Explicit geometry; hard-fail if absent. | | `outputs` | `base_dir` | `main` | Parent of per-run trees. | -| `outputs` | `log_dir`, `image_dir` | — | **Overwritten** by `main` to `/logs` and `/images`. | -| `outputs` | `save_every` | `main` | Periodic checkpoint interval. | -| `device` | | `main` | `auto`, `cpu`, `cuda`, or `mps`. | -| `reproducibility` | `deterministic_algorithms` | `main` | Passed to `set_global_seed`. | -| `init_checkpoint` | | `main` | Optional warm-start (top-level or via CLI). | +| `reproducibility` | `*` | preflight / trainer | Strict defaults in `base.yaml`. | +| `init_checkpoint` | | `main` | Optional warm-start. | -## Non-official scripts +## Non-paper scripts -Modules under `tda_ml/experiments/`, `reproduce_pd_animation.py`, and `robustness_sweep.py` may require **your own checkpoints** and optional extras. They are not part of the canonical `run_backend_multiseed.py` path. +Optional extras under `pyproject.toml` (`experiments`, `images`) support tune / +plotting. They are not the paper production path. diff --git a/configs/base.yaml b/configs/base.yaml index 0ba0d68..5f78013 100644 --- a/configs/base.yaml +++ b/configs/base.yaml @@ -12,6 +12,9 @@ loss: size_mode: quadratic size_ref: 1.34 size_power: 1.5 + # Filtration-unit alignment (declared; paper configs may override). + topo_eps_scale: 1.0 + topo_scale_mode: fixed training: lr: 0.0003 diff --git a/configs/elongate_n100_no_cls_full120_baseline.yaml b/configs/elongate_n100_no_cls_full120_baseline.yaml index ff8ec64..cbb53a4 100644 --- a/configs/elongate_n100_no_cls_full120_baseline.yaml +++ b/configs/elongate_n100_no_cls_full120_baseline.yaml @@ -1,4 +1,5 @@ -# no_cls baseline contrast: full 120-point cloud for topo loss (default max_points unset). +# no_cls baseline contrast: Euclidean-teacher column (not the paper main table). +# selection.metric=val_topo matches the paper checkpoint protocol (best_model.pth). meta: config_id: "elongate_n100_no_cls_full120_baseline" @@ -25,6 +26,8 @@ loss: size_mode: power size_ref: 1.34 size_power: 1.5 + topo_eps_scale: 1.0 + topo_scale_mode: fixed teacher_mode: euclidean training: @@ -34,7 +37,7 @@ training: visualize_every: 10 warmup_epochs: 0 selection: - metric: wdist + metric: val_topo eval_every: 1 dbscan: eps_values: null diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml index 7730ece..b72155d 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml +++ b/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml @@ -32,6 +32,8 @@ loss: teacher_mode: local_pca teacher_local_pca_k: 10 teacher_local_pca_normalize_axes: true + topo_eps_scale: 1.0 + topo_scale_mode: fixed training: lr: 0.000158 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml deleted file mode 100644 index 55e5009..0000000 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent.yaml +++ /dev/null @@ -1,81 +0,0 @@ -# Paper no_cls production on NEAR-tangent outliers (GT-proximal anisotropic noise). -# Same stack as elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent, but -# the displacement direction gets a ±30° jitter cone around local-PCA PC1 and a -# semantic stroke clearance of 0.08 rejects candidates landing back on the digit. -# Production 30ep weights MUST come from an H1-only near-tangent Optuna best JSON -# (--tune-json). The w_* / lr values below are Optuna prior centres only. - -meta: - config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "ellphi" - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - # Prior centres for Optuna; paper production requires --tune-json overrides. - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - # GT-proximal anisotropic outliers: displace inliers within a ±30° cone - # around local-PCA PC1, rejected until >=0.08 from every clean inlier. - # noise_std matches the uniform baseline: inliers get the same isotropic - # jitter so only the OUTLIER distribution differs. - outlier_mode: local_pca_tangent - noise_std: 0.01 - tangent_pca_k: 10 - tangent_offset_min: 0.15 - tangent_offset_max: 0.40 - tangent_angle_jitter_deg: 30.0 - tangent_stroke_clearance: 0.08 - tangent_direction: tangent - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml deleted file mode 100644 index 8f8f5fe..0000000 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier.yaml +++ /dev/null @@ -1,83 +0,0 @@ -# Paper no_cls production on NEAR-tangent outliers with the elongate_barrier -# degeneracy guard. Identical to elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent -# except loss.aniso_mode: elongate_barrier (+ explicit aniso_barrier_threshold). -# See the tune config of the same suffix for the motivation (minor-axis collapse -# under plain elongate killed 15/16 stage-2 tune conditions via ellphi hard-fail). -# Production 30ep weights MUST come from an H1-only near-tangent barrier Optuna -# best JSON (--tune-json). The w_* / lr values below are Optuna prior centres only. - -meta: - config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "ellphi" - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - # Prior centres for Optuna; paper production requires --tune-json overrides. - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate_barrier - aniso_barrier_threshold: 6.0 - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - # GT-proximal anisotropic outliers: displace inliers within a ±30° cone - # around local-PCA PC1, rejected until >=0.08 from every clean inlier. - # noise_std matches the uniform baseline: inliers get the same isotropic - # jitter so only the OUTLIER distribution differs. - outlier_mode: local_pca_tangent - noise_std: 0.01 - tangent_pca_k: 10 - tangent_offset_min: 0.15 - tangent_offset_max: 0.40 - tangent_angle_jitter_deg: 30.0 - tangent_stroke_clearance: 0.08 - tangent_direction: tangent - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier.yaml deleted file mode 100644 index 30b36b6..0000000 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier.yaml +++ /dev/null @@ -1,84 +0,0 @@ -# Paper no_cls production on NORMAL-direction outliers with the -# elongate_barrier guard. Identical to -# elongate_n100_no_cls_full120_teacher_local_pca_h1_neartangent_barrier except -# data.tangent_direction: normal and a tighter offset range (0.10-0.25). -# See the tune config of the same suffix for the motivation (near-tangent -# outliers are absorbed along the ellipse major axis; normal-direction is the -# setting the anisotropy hypothesis predicts an advantage for). -# Production 30ep weights MUST come from an H1-only normal-barrier Optuna -# best JSON (--tune-json). The w_* / lr values below are Optuna prior centres only. - -meta: - config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_normal_barrier" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "ellphi" - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - # Prior centres for Optuna; paper production requires --tune-json overrides. - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate_barrier - aniso_barrier_threshold: 6.0 - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - # Off-manifold anisotropic outliers: displace inliers within a ±30° cone - # around the local-PCA MINOR axis (across the stroke), rejected until - # >=0.08 from every clean inlier. noise_std matches the uniform baseline. - outlier_mode: local_pca_tangent - noise_std: 0.01 - tangent_pca_k: 10 - tangent_offset_min: 0.10 - tangent_offset_max: 0.25 - tangent_angle_jitter_deg: 30.0 - tangent_stroke_clearance: 0.08 - tangent_direction: normal - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier.yaml deleted file mode 100644 index fe3bd0f..0000000 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier.yaml +++ /dev/null @@ -1,85 +0,0 @@ -# Paper no_cls production on THIN SYNTHETIC RINGS with radial outliers and the -# elongate_barrier guard. Identical to -# elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier except -# epochs=30, distance_backend=ellphi, visualize_every=10. -# See the tune config of the same suffix for the motivation. -# Production 30ep weights MUST come from an H1-only rings-barrier Optuna -# best JSON (--tune-json). The w_* / lr values below are Optuna prior centres only. - -meta: - config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_barrier" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "ellphi" - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - # Prior centres for Optuna; paper production requires --tune-json overrides. - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate_barrier - aniso_barrier_threshold: 6.0 - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - dataset_type: thin_rings - max_points: 100 - num_outliers: 20 - # Thin rings: transverse sigma (noise_std) << along-ring NN spacing (~0.04), - # so local-PCA ellipses are strongly anisotropic. Outliers displace clean - # ring points radially by 0.03-0.06 (within ~1 NN spacing: Euclidean-hard) - # and must stay >= 0.03 off every ring band (semantic guard). - noise_std: 0.005 - ring_count_min: 1 - ring_count_max: 2 - ring_radius_min: 0.25 - ring_radius_max: 0.60 - ring_center_box: 0.30 - ring_outlier_offset_min: 0.03 - ring_outlier_offset_max: 0.06 - ring_outlier_clearance: 0.03 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier.yaml deleted file mode 100644 index 5c3ff5d..0000000 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier.yaml +++ /dev/null @@ -1,79 +0,0 @@ -# Paper production on CONTAMINATED thin rings (see matching tune config). -# Production 30ep weights MUST come from rings_contam_barrier Optuna best JSON. - -meta: - config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_rings_contam_barrier" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "ellphi" - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - # Prior centres for Optuna; paper production requires --tune-json overrides. - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate_barrier - aniso_barrier_threshold: 6.0 - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - dataset_type: thin_rings - max_points: 100 - num_outliers: 40 - # Thin rings: transverse sigma (noise_std) << along-ring NN spacing (~0.04), - # so local-PCA ellipses are strongly anisotropic. Outliers: 40 points, radial offset 0.015-0.035 (inside typical kNN radius) - # so ADBSCAN local-PCA neighborhoods are heavily contaminated. Clearance 0.015. - noise_std: 0.005 - ring_count_min: 1 - ring_count_max: 2 - ring_radius_min: 0.25 - ring_radius_max: 0.60 - ring_center_box: 0.30 - ring_outlier_offset_min: 0.015 - ring_outlier_offset_max: 0.035 - ring_outlier_clearance: 0.015 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml b/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml deleted file mode 100644 index ff9099a..0000000 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent.yaml +++ /dev/null @@ -1,82 +0,0 @@ -# Paper no_cls production on tangent-direction outliers. -# Same stack as elongate_n100_no_cls_full120_teacher_local_pca (local-PCA teacher -# + ellphi topo + H1-only Wasserstein), but outliers are placed along local-PCA -# first principal axes (anisotropic noise) instead of uniform. -# Production 30ep weights MUST come from an H1-only tangent Optuna best JSON -# (--tune-json). The w_* / lr values below are Optuna prior centres only. - -meta: - config_id: "elongate_n100_no_cls_full120_teacher_local_pca_h1_tangent" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "ellphi" - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - # Prior centres for Optuna; paper production requires --tune-json overrides. - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - # Anisotropic outliers: displace inliers along local-PCA PC1 (tangent). noise_std - # matches the uniform baseline so only the OUTLIER distribution differs, and the - # jitter breaks exact MNIST-quantized coincidences that make the ellphi tangency - # derivative w.r.t. mu undefined. - outlier_mode: local_pca_tangent - noise_std: 0.01 - tangent_pca_k: 10 - tangent_offset_min: 0.15 - tangent_offset_max: 0.40 - # Pure tangent (no direction jitter, no semantic stroke clearance). - tangent_angle_jitter_deg: 0.0 - tangent_stroke_clearance: 0.0 - tangent_direction: tangent - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml similarity index 76% rename from configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml rename to configs/elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml index 13dc14f..6960bde 100644 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc.yaml +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml @@ -1,8 +1,9 @@ -# Optuna tune: local_pca + ellphi + size_mode=power. -# Train ckpt: val_topo (fast). Trial objective: one val DBSCAN grid → MCC max. +# Shared Optuna tune base for W-Dist and MCC studies (local_pca + ellphi + power). +# Train ckpt: val_topo (fast). Trial objective is chosen by the tune driver +# (tune_elongate_wdist.py vs tune_elongate_mcc.py), not by this config_id. meta: - config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc" + config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power" model: point_dim: 2 @@ -29,6 +30,8 @@ loss: teacher_mode: local_pca teacher_local_pca_k: 10 teacher_local_pca_normalize_axes: true + topo_eps_scale: 1.0 + topo_scale_mode: fixed training: lr: 0.000158 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1.yaml deleted file mode 100644 index e666437..0000000 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# H1-only tune: local-PCA teacher + ellphi + power size. -# Objective: val topology W-Dist over H1 only. No min_b / axis projection. - -meta: - config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden to ellphi by tune script - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 20 - grad_clip_value: 1.0 - visualize_every: 10000 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml deleted file mode 100644 index 7b686c1..0000000 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent.yaml +++ /dev/null @@ -1,81 +0,0 @@ -# H1-only tune on NEAR-tangent outliers (GT-proximal anisotropic noise). -# Same stack as elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent, but -# the displacement direction gets a ±30° jitter cone around local-PCA PC1 and a -# semantic stroke clearance of 0.08 (> median inlier NN spacing 0.064) rejects -# candidates landing back on the digit. Pure-tangent displacement follows the -# stroke, so >50% of those outliers were geometrically indistinguishable from -# inliers; near-tangent keeps the anisotropy while making the labels sound. - -meta: - config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden to ellphi by tune script - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 20 - grad_clip_value: 1.0 - visualize_every: 10000 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - # GT-proximal anisotropic outliers: displace inliers within a ±30° cone - # around local-PCA PC1, rejected until >=0.08 from every clean inlier. - # noise_std matches the uniform baseline: inliers get the same isotropic - # jitter so only the OUTLIER distribution differs. - outlier_mode: local_pca_tangent - noise_std: 0.01 - tangent_pca_k: 10 - tangent_offset_min: 0.15 - tangent_offset_max: 0.40 - tangent_angle_jitter_deg: 30.0 - tangent_stroke_clearance: 0.08 - tangent_direction: tangent - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml index 6732795..60a4b81 100644 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml +++ b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml @@ -1,6 +1,7 @@ # H1-only tune on NEAR-tangent outliers with the elongate_barrier degeneracy guard. -# Identical to elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent -# except loss.aniso_mode: elongate_barrier (+ explicit aniso_barrier_threshold). +# Same stack as elongate_n100_no_cls_tune_local_pca_ellphi_power, plus +# near-tangent outlier data and loss.aniso_mode: elongate_barrier +# (+ explicit aniso_barrier_threshold). # # Why: on near-tangent data, plain `elongate` rewards minor/major -> 0 with no # counterweight; by epoch 10-20 minor semi-axes collapsed to ~1e-5 (aspect @@ -19,7 +20,7 @@ model: threshold: 0.5 topology_loss: metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden to ellphi by tune script + distance_backend: "ellphi" ellphi_differentiable: true prob_weighting: false homology_dimensions: [1] @@ -38,6 +39,8 @@ loss: teacher_mode: local_pca teacher_local_pca_k: 10 teacher_local_pca_normalize_axes: true + topo_eps_scale: 1.0 + topo_scale_mode: fixed training: lr: 0.000158 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier.yaml deleted file mode 100644 index 1e884f4..0000000 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier.yaml +++ /dev/null @@ -1,88 +0,0 @@ -# H1-only tune on NORMAL-direction outliers with the elongate_barrier guard. -# Identical to elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier -# except data.tangent_direction: normal and a tighter offset range. -# -# Why normal: near-tangent outliers sit along the local-PCA major axis, so -# anisotropic (ellipse/Mahalanobis) methods absorb them as stroke continuation -# (proposed AND ADBSCAN both dropped to recall ~0.48 on near-tangent data). -# Displacing across the stroke (minor axis) is the setting the anisotropy -# hypothesis actually predicts an advantage for: the outlier stays Euclidean- -# close to the stroke (adversarial for isotropic DBSCAN) but crosses the thin -# ellipse (large Mahalanobis distance). -# -# Offsets 0.10-0.25 keep outliers inside the DBSCAN eps grid (0.15-0.825) so -# Euclidean clustering tends to connect them to the stroke. - -meta: - config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_normal_barrier" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden to ellphi by tune script - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate_barrier - aniso_barrier_threshold: 6.0 - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 20 - grad_clip_value: 1.0 - visualize_every: 10000 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - # Off-manifold anisotropic outliers: displace inliers within a ±30° cone - # around the local-PCA MINOR axis (across the stroke), rejected until - # >=0.08 from every clean inlier. noise_std matches the uniform baseline. - outlier_mode: local_pca_tangent - noise_std: 0.01 - tangent_pca_k: 10 - tangent_offset_min: 0.10 - tangent_offset_max: 0.25 - tangent_angle_jitter_deg: 30.0 - tangent_stroke_clearance: 0.08 - tangent_direction: normal - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier.yaml deleted file mode 100644 index 7843976..0000000 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier.yaml +++ /dev/null @@ -1,91 +0,0 @@ -# H1-only tune on THIN SYNTHETIC RINGS with radial (normal-direction) outliers -# and the elongate_barrier degeneracy guard. -# -# Why rings (docs/experiments/20260719_neartangent_barrier.md, 追記): -# on MNIST 100-point clouds the local-PCA teacher ellipses are only mildly -# anisotropic (aspect median ~1.8; strokes have width), so no outlier direction -# can favour anisotropic methods. Thin rings reach teacher aspect ~8-9 and the -# radial outliers sit within ~1 NN spacing of the ring: Euclidean-ambiguous -# (NN AUC ~0.82) yet Mahalanobis-clear (~0.94). Rings are H1 loops, matching -# the H1-only pipeline. -# -# Known tension: the barrier caps predicted aspect at 6.0 (declared contract -# constant) while ring teacher aspect is ~8-9; accepted for stability, revisit -# if topo match suffers. - -meta: - config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_barrier" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden to ellphi by tune script - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate_barrier - aniso_barrier_threshold: 6.0 - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 20 - grad_clip_value: 1.0 - visualize_every: 10000 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - dataset_type: thin_rings - max_points: 100 - num_outliers: 20 - # Thin rings: transverse sigma (noise_std) << along-ring NN spacing (~0.04), - # so local-PCA ellipses are strongly anisotropic. Outliers displace clean - # ring points radially by 0.03-0.06 (within ~1 NN spacing: Euclidean-hard) - # and must stay >= 0.03 off every ring band (semantic guard). - noise_std: 0.005 - ring_count_min: 1 - ring_count_max: 2 - ring_radius_min: 0.25 - ring_radius_max: 0.60 - ring_center_box: 0.30 - ring_outlier_offset_min: 0.03 - ring_outlier_offset_max: 0.06 - ring_outlier_clearance: 0.03 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier.yaml deleted file mode 100644 index d2827ba..0000000 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier.yaml +++ /dev/null @@ -1,87 +0,0 @@ -# H1-only tune on CONTAMINATED thin rings: more/closer radial outliers so that -# ADBSCAN local-PCA (computed on the observed cloud) is systematically poisoned, -# while the proposed method is trained against a CLEAN local-PCA teacher. -# -# Probe (40 clouds): current rings already contaminate 83% of inlier kNN balls -# (obs aspect 8.9→3.1). This variant amplifies that: 40 outliers, offset 0.015-0.035, -# ~98% inlier neighborhoods contaminated, outlier Mahalanobis under obs ellipses -# halves again vs clean teacher. Hypothesis: proposed inductive bias beats -# contaminated ADBSCAN; Euclidean should still fail. -# -# Known tension: barrier aspect cap 6.0 vs clean teacher aspect ~9. - -meta: - config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_rings_contam_barrier" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden to ellphi by tune script - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate_barrier - aniso_barrier_threshold: 6.0 - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 20 - grad_clip_value: 1.0 - visualize_every: 10000 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - dataset_type: thin_rings - max_points: 100 - num_outliers: 40 - # Thin rings: transverse sigma (noise_std) << along-ring NN spacing (~0.04), - # so local-PCA ellipses are strongly anisotropic. Outliers: 40 points, radial offset 0.015-0.035 (inside typical kNN radius) - # so ADBSCAN local-PCA neighborhoods are heavily contaminated. Clearance 0.015. - noise_std: 0.005 - ring_count_min: 1 - ring_count_max: 2 - ring_radius_min: 0.25 - ring_radius_max: 0.60 - ring_center_box: 0.30 - ring_outlier_offset_min: 0.015 - ring_outlier_offset_max: 0.035 - ring_outlier_clearance: 0.015 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml deleted file mode 100644 index 4b8a40c..0000000 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent.yaml +++ /dev/null @@ -1,80 +0,0 @@ -# H1-only tune on tangent-direction outliers. -# Same stack as elongate_n100_no_cls_tune_local_pca_ellphi_power_h1 (local-PCA -# teacher + ellphi + power size, W-Dist over H1 only), but outliers are placed -# along local-PCA first principal axes (anisotropic noise) instead of uniform. - -meta: - config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" # overridden to ellphi by tune script - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - -training: - lr: 0.000158 - epochs: 20 - grad_clip_value: 1.0 - visualize_every: 10000 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - # Anisotropic outliers: displace inliers along local-PCA PC1 (tangent). - # noise_std matches the uniform baseline: inliers get the same isotropic jitter - # so only the OUTLIER distribution differs (tangent = anisotropic). Jitter also - # breaks exact MNIST-quantized coincidences (dmin=0) that make the ellphi - # tangency derivative w.r.t. mu undefined. - outlier_mode: local_pca_tangent - noise_std: 0.01 - tangent_pca_k: 10 - tangent_offset_min: 0.15 - tangent_offset_max: 0.40 - # Pure tangent (no direction jitter, no semantic stroke clearance). - tangent_angle_jitter_deg: 0.0 - tangent_stroke_clearance: 0.0 - tangent_direction: tangent - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/experiments/aggregate_power_30ep_multiseed.py b/experiments/aggregate_power_30ep_multiseed.py index 9ebd415..abfbac4 100644 --- a/experiments/aggregate_power_30ep_multiseed.py +++ b/experiments/aggregate_power_30ep_multiseed.py @@ -125,7 +125,8 @@ def aggregate_row( "wdist_std": sample_std(wdist), "notes": ( f"{n}/{len(expected_seeds)} seeds; fixed tune weights from {expected_tune}; " - "30ep val_topo ckpt; maha DBSCAN eval" + "30ep val_topo ckpt; maha DBSCAN eval; " + "wdist_* are diagnostics only (not main-table columns)" ), } diff --git a/experiments/barrier_neartangent_smoke.py b/experiments/barrier_neartangent_smoke.py deleted file mode 100644 index 15f1344..0000000 --- a/experiments/barrier_neartangent_smoke.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python3 -"""Degeneracy-guard smoke: rerun a failed near-tangent tune condition with elongate_barrier. - -Motivation: on near-tangent data, 15/16 stage-2 (20ep) conditions of the plain -``elongate`` stack hard-failed in ellphi (minor axes collapsed to ~1e-5, aspect -~350 by epoch 15). This smoke reruns one of those exact failed weight sets on -the ``elongate_barrier`` base config for the full 20 epochs and reports the -final ellipse geometry, so the guard is validated on the worst case before -spending hours on a fresh Optuna study. - -Usage:: - - uv run python experiments/barrier_neartangent_smoke.py \ - --w-topo 0.201 --w-aniso 0.107 --w-size 0.179 --lr 0.000205 \ - --out-base outputs/supervised_no_cls/0719_barrier_smoke -""" - -from __future__ import annotations - -import argparse -import json -import sys -import time -from pathlib import Path - -import numpy as np -import torch - -REPO_ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(REPO_ROOT)) -sys.path.insert(0, str(REPO_ROOT / "experiments")) - -from tda_ml.checkpoint_io import resolve_val_topo_checkpoint # noqa: E402 -from tda_ml.main import main as train_main # noqa: E402 -from tda_ml.supervised_diagnostics import git_revision # noqa: E402 -from tda_ml.topo_wdist import topo_wdist_options_from_config # noqa: E402 - -from evaluate_paper_protocol import ( # noqa: E402 - build_split_loader, - iter_cloud_predictions, - load_model_from_run, -) -from tune_elongate_wdist import build_trial_config, mean_val_topo_wdist # noqa: E402 - -BASE_CONFIG = "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier" - - -def geometry_stats(par: np.ndarray) -> dict[str, float]: - axes = par[:, 0:2] - major = axes.max(axis=1) - minor = axes.min(axis=1) - aspect = major / np.maximum(minor, 1e-12) - return { - "minor_min": float(minor.min()), - "aspect_max": float(aspect.max()), - "aspect_p99": float(np.percentile(aspect, 99)), - } - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--w-topo", type=float, required=True) - p.add_argument("--w-aniso", type=float, required=True) - p.add_argument("--w-size", type=float, required=True) - p.add_argument("--lr", type=float, required=True) - p.add_argument("--epochs", type=int, default=20) - p.add_argument("--base-config", type=str, default=BASE_CONFIG) - p.add_argument("--trial-number", type=int, default=900) - p.add_argument("--out-base", type=Path, required=True) - return p.parse_args() - - -def main() -> int: - args = parse_args() - args.out_base.mkdir(parents=True, exist_ok=True) - - cfg = build_trial_config( - args.base_config, - w_aniso=args.w_aniso, - w_size=args.w_size, - w_topo=args.w_topo, - lr=args.lr, - backend="ellphi", - tune_epochs=args.epochs, - out_base=str(args.out_base), - trial_number=args.trial_number, - size_mode="power", - ) - - t0 = time.perf_counter() - result = train_main(config=cfg) - train_s = time.perf_counter() - t0 - run_dir = Path(result["run_dir"]) - - device = torch.device("cpu") - ckpt_name, ckpt_epoch, val_topo_sel = resolve_val_topo_checkpoint(run_dir) - topo_options = topo_wdist_options_from_config(cfg) - model = load_model_from_run(run_dir, cfg, device, checkpoint_name=ckpt_name) - loader = build_split_loader(cfg, "val", device) - clouds = list(iter_cloud_predictions(model, loader, device)) - wdist = mean_val_topo_wdist(clouds, topo_options=topo_options) - - geom = [geometry_stats(par) for _, par, _, _ in clouds] - summary = { - "purpose": ( - "elongate_barrier 20ep smoke on a weight set that hard-failed under " - "plain elongate (near-tangent stage-2 tune)" - ), - "base_config": args.base_config, - "source_revision": git_revision(REPO_ROOT), - "weights": { - "w_topo": args.w_topo, - "w_aniso": args.w_aniso, - "w_size": args.w_size, - "lr": args.lr, - }, - "epochs": args.epochs, - "train_elapsed_s": round(train_s, 1), - "run_dir": str(run_dir), - "checkpoint_name": ckpt_name, - "checkpoint_epoch": ckpt_epoch, - "val_topo_loss_at_ckpt": val_topo_sel, - "val_topo_wdist_mean": wdist, - "geometry_val": { - "minor_min": min(g["minor_min"] for g in geom), - "aspect_max": max(g["aspect_max"] for g in geom), - "aspect_p99_max": max(g["aspect_p99"] for g in geom), - }, - } - out_path = args.out_base / f"barrier_smoke_t{args.trial_number:03d}.json" - out_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") - print(json.dumps(summary, indent=2)) - print(f"Wrote {out_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/experiments/checkpoint_selection.py b/experiments/checkpoint_selection.py deleted file mode 100644 index 1cd05c0..0000000 --- a/experiments/checkpoint_selection.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Select training checkpoints by topo loss column in metrics.csv (saved epochs only).""" - -from __future__ import annotations - -import csv -from pathlib import Path - -DEFAULT_TOPO_COLUMN = "train_topo_loss" - - -def topo_loss_column(metrics_path: Path) -> str: - """Prefer ``val_topo_loss`` when logged; else ``train_topo_loss``.""" - with metrics_path.open() as f: - reader = csv.DictReader(f) - fields = reader.fieldnames or () - if "val_topo_loss" in fields: - return "val_topo_loss" - return DEFAULT_TOPO_COLUMN - - -def list_checkpoint_epochs(run_dir: Path) -> set[int]: - """Epoch numbers with ``checkpoint_epoch_{k}.pth`` on disk.""" - prefix = "checkpoint_epoch_" - epochs: set[int] = set() - for path in run_dir.glob(f"{prefix}*.pth"): - suffix = path.stem.removeprefix(prefix) - if suffix.isdigit(): - epochs.add(int(suffix)) - return epochs - - -def best_topo_epoch( - metrics_path: Path, - *, - column: str | None = None, - saved_epochs: set[int] | None = None, -) -> tuple[int, float]: - """Return epoch with minimum topo loss, optionally restricted to saved ckpts.""" - col = column or topo_loss_column(metrics_path) - with metrics_path.open() as f: - rows = list(csv.DictReader(f)) - if not rows: - raise RuntimeError(f"empty metrics: {metrics_path}") - if col not in (rows[0].keys() if rows else ()): - raise RuntimeError(f"column {col!r} missing from {metrics_path}") - pairs = [(int(r["epoch"]), float(r[col])) for r in rows if r.get(col, "")] - if saved_epochs is not None: - pairs = [p for p in pairs if p[0] in saved_epochs] - if not pairs: - raise RuntimeError( - f"no metrics rows for saved checkpoints under {metrics_path.parent.parent} " - f"(saved epochs: {sorted(saved_epochs)})" - ) - return min(pairs, key=lambda t: t[1]) - - -def best_topo_checkpoint( - run_dir: Path, - *, - column: str | None = None, -) -> tuple[str, int, float, int | None, float | None]: - """Pick ``checkpoint_epoch_{k}.pth`` with minimum topo loss among saved ckpts. - - Returns: - (checkpoint_name, epoch, topo_loss, global_best_epoch, global_train_topo_loss) - Global fields are set when the unrestricted minimum lies on an unsaved epoch. - """ - run_dir = run_dir.resolve() - metrics_path = run_dir / "logs" / "metrics.csv" - if not metrics_path.is_file(): - raise FileNotFoundError(f"missing {metrics_path}") - - col = column or topo_loss_column(metrics_path) - saved_epochs = list_checkpoint_epochs(run_dir) - if not saved_epochs: - raise FileNotFoundError(f"no checkpoint_epoch_*.pth under {run_dir}") - - epoch, topo_loss = best_topo_epoch(metrics_path, column=col, saved_epochs=saved_epochs) - global_epoch, global_topo = best_topo_epoch(metrics_path, column=col) - global_epoch_out = global_epoch if global_epoch != epoch else None - global_topo_out = global_topo if global_epoch != epoch else None - return ( - f"checkpoint_epoch_{epoch}.pth", - epoch, - topo_loss, - global_epoch_out, - global_topo_out, - ) diff --git a/experiments/enqueue_top_optuna_trials.py b/experiments/enqueue_top_optuna_trials.py deleted file mode 100755 index ed6e93c..0000000 --- a/experiments/enqueue_top_optuna_trials.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python3 -"""Create a refinement study by enqueueing top trials from a proxy study.""" - -from __future__ import annotations - -import argparse -import json -from datetime import datetime, timezone -from pathlib import Path - -import optuna -from optuna.trial import TrialState - -from tda_ml.supervised_diagnostics import git_revision - -REPO_ROOT = Path(__file__).resolve().parents[1] -REQUIRED_PARAMS = ("w_aniso", "w_size", "w_topo", "lr") - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--source-storage", required=True) - parser.add_argument("--source-study", required=True) - parser.add_argument("--target-storage", required=True) - parser.add_argument("--target-study", required=True) - parser.add_argument("--top-k", type=int, required=True) - parser.add_argument( - "--rank-start", - type=int, - default=1, - help="1-based first proxy rank to enqueue (default: 1).", - ) - parser.add_argument( - "--append-existing", - action="store_true", - help="Append selected trials to an existing target study (recovery only).", - ) - parser.add_argument("--out-dir", type=Path, required=True) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - if args.top_k < 1: - raise ValueError(f"top-k must be >= 1, got {args.top_k}") - if args.rank_start < 1: - raise ValueError(f"rank-start must be >= 1, got {args.rank_start}") - if args.out_dir.exists() and not args.append_existing: - raise FileExistsError(f"Refinement output already exists: {args.out_dir}") - - source = optuna.load_study( - study_name=args.source_study, - storage=args.source_storage, - ) - complete = sorted( - (trial for trial in source.trials if trial.state == TrialState.COMPLETE), - key=lambda trial: float(trial.value), - ) - rank_stop = args.rank_start - 1 + args.top_k - if len(complete) < rank_stop: - raise RuntimeError( - f"Source study has {len(complete)} completed trials; " - f"need ranks {args.rank_start}..{rank_stop}" - ) - - selected = complete[args.rank_start - 1 : rank_stop] - for trial in selected: - missing = [name for name in REQUIRED_PARAMS if name not in trial.params] - if missing: - raise ValueError( - f"Source trial {trial.number} is missing parameters {missing}" - ) - - args.out_dir.mkdir(parents=True, exist_ok=args.append_existing) - target = optuna.create_study( - study_name=args.target_study, - storage=args.target_storage, - direction="minimize", - load_if_exists=args.append_existing, - ) - for trial in selected: - target.enqueue_trial( - {name: float(trial.params[name]) for name in REQUIRED_PARAMS}, - user_attrs={ - "source_study": args.source_study, - "source_trial_number": trial.number, - "source_proxy_value": float(trial.value), - }, - ) - - manifest = { - "run_status": "pending", - "created_at_utc": datetime.now(timezone.utc).isoformat(), - "command_entry": "experiments/enqueue_top_optuna_trials.py", - "source_revision": git_revision(REPO_ROOT), - "source_storage": args.source_storage, - "source_study": args.source_study, - "target_storage": args.target_storage, - "target_study": args.target_study, - "selection": "lowest proxy W-Dist", - "top_k": args.top_k, - "rank_start": args.rank_start, - "rank_stop": rank_stop, - "append_existing": args.append_existing, - "selected_trials": [ - { - "source_trial_number": trial.number, - "source_proxy_value": float(trial.value), - "params": { - name: float(trial.params[name]) for name in REQUIRED_PARAMS - }, - } - for trial in selected - ], - "fallbacks": [], - } - if args.append_existing: - # Recovery append: keep the original preflight intact for provenance. - preflight_name = f"REFINEMENT_PREFLIGHT_rank{args.rank_start}-{rank_stop}.json" - else: - preflight_name = "REFINEMENT_PREFLIGHT.json" - preflight_path = args.out_dir / preflight_name - if preflight_path.exists(): - raise FileExistsError(f"Refinement preflight already exists: {preflight_path}") - preflight_path.write_text( - json.dumps(manifest, indent=2) + "\n", - encoding="utf-8", - ) - print(json.dumps(manifest["selected_trials"], indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/experiments/evaluate_paper_baselines.py b/experiments/evaluate_paper_baselines.py index 4b54e40..e8181fd 100644 --- a/experiments/evaluate_paper_baselines.py +++ b/experiments/evaluate_paper_baselines.py @@ -1,16 +1,19 @@ #!/usr/bin/env python3 """ -Paper-aligned baseline evaluation (Phase 3). +Paper-aligned baseline evaluation. -Same data split as ``evaluate_paper_protocol.py`` (``configs/reproduce.yaml``, -seeds 42, 123, 456, 789, 1024): tune hyperparameters on validation clouds, -report MCC / G-Mean / W-Dist on test via ``compute_recall_specificity_gmean_mcc_wdist``. +Same data split as ``evaluate_paper_protocol.py`` under the paper elongate +config (typically ``elongate_n100_no_cls_full120_teacher_local_pca``), seeds +42 / 123 / 456 / 789 / 1024: tune hyperparameters on validation clouds, report +MCC / G-Mean on test. Topo W-Dist is computed for diagnostics only and is +**not** a main-table column. Methods: 1. Euclidean DBSCAN (sklearn on raw coordinates) 2. Isolation Forest 3. Local Outlier Factor (LOF) - 4. ADBSCAN (local PCA ellipses + Mahalanobis ``apply_anisotropic_dbscan``) + 4. ADBSCAN (local PCA ellipses + Mahalanobis ``apply_anisotropic_dbscan``; + no learned corrections — not compute-matched to the proposed 30ep model) Usage:: @@ -39,16 +42,16 @@ from experiments.evaluate_paper_protocol import ( CloudMetrics, _aggregate_cloud_metrics, - _valid_clean_inliers, build_split_loader, dbscan_labels_to_outlier_pred, ) from tda_ml.config import deep_update, load_config +from tda_ml.dbscan_eval import valid_clean_inliers +from tda_ml.local_pca import local_pca_ellipse_params as _local_pca_ellipse_params_torch from tda_ml.metrics import ( compute_recall_specificity_gmean_mcc, compute_recall_specificity_gmean_mcc_wdist, ) -from tda_ml.numerical_eps import EIGENVALUE_FLOOR, PCA_RIDGE_EPS from tda_ml.preflight import preflight_baseline_eval from tda_ml.reproducibility import ( RUN_STATUS_COMPLETED, @@ -92,43 +95,13 @@ class SeedResult: def local_pca_ellipse_params(points: np.ndarray, k: int = LOCAL_PCA_K) -> np.ndarray: - """ - Baseline ellipse parameters from local PCA only (no learned deltas). - - Matches ``DecoupledGeometricEncoder`` + zero MLP corrections: - ``[a, b, theta]`` per point, shape ``(N, 3)``. - """ + """ADBSCAN ellipses via shared ``tda_ml.local_pca`` (normalize_axes=True).""" if points.ndim != 2 or points.shape[1] != 2: raise ValueError(f"points must be (N, 2); got {points.shape}") - n = points.shape[0] - if n < k: - raise ValueError(f"Need at least k={k} points; got n={n}") - - x = torch.from_numpy(points.astype(np.float32)).unsqueeze(0) # 1, N, 2 - dist_sq = torch.cdist(x, x, p=2) ** 2 - _, idx = torch.topk(-dist_sq, k=k, dim=-1) - - batch_idx = torch.arange(1).view(1, 1, 1).expand(1, n, k) - flat_x = x.view(n, 2) - flat_neighbors = flat_x[idx.view(1, -1) + (batch_idx.view(1, -1) * n), :] - neighbors = flat_neighbors.view(1, n, k, 2) - - relative_coords = neighbors - x.unsqueeze(2) - mean_neighbor = relative_coords.mean(dim=2, keepdim=True) - centered = relative_coords - mean_neighbor - cov = torch.matmul(centered.transpose(-1, -2), centered) / (k - 1) - - eye2 = torch.eye(2, dtype=torch.float32) - e, v = torch.linalg.eigh(cov.float() + eye2 * PCA_RIDGE_EPS) - v1 = v[:, :, :, 1] - base_angle = torch.atan2(v1[:, :, 1], v1[:, :, 0]) - - base_axes = torch.sqrt(torch.clamp(e, min=EIGENVALUE_FLOOR)) - base_axes = torch.flip(base_axes, dims=[-1]) - base_axes = base_axes / (base_axes.max(dim=-1, keepdim=True)[0] + EIGENVALUE_FLOOR) - - params = torch.cat([base_axes, base_angle.unsqueeze(-1)], dim=-1) - return params.squeeze(0).numpy() + if points.shape[0] < k: + raise ValueError(f"Need at least k={k} points; got n={points.shape[0]}") + x = torch.from_numpy(points.astype(np.float32)) + return _local_pca_ellipse_params_torch(x, k=k, normalize_axes=True).numpy() def load_clouds(config: dict[str, Any], split: str, device: torch.device) -> list[CloudSample]: @@ -158,7 +131,7 @@ def cloud_metrics_from_pred( points: np.ndarray, clean_pc: np.ndarray, ) -> CloudMetrics: - gt_inliers = _valid_clean_inliers(clean_pc) + gt_inliers = valid_clean_inliers(clean_pc) recall, specificity, gmean, mcc, wdist = compute_recall_specificity_gmean_mcc_wdist( labels_gt, pred, @@ -473,7 +446,9 @@ def aggregate_method_results(seed_results: Sequence[SeedResult]) -> dict[str, An "wdist_std": sample_std(wdist), "notes": ( f"5 seeds; val hparam selection by max mean cloud MCC; " - f"test n_clouds={seed_results[0].n_test_clouds} per seed" + f"test n_clouds={seed_results[0].n_test_clouds} per seed; " + "wdist_* columns are diagnostics only (not main-table); " + "ADBSCAN uses fixed local-PCA ellipses (no 30ep training)" ), } diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index 569d896..c91ed67 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -28,7 +28,11 @@ import numpy as np import torch -from tda_ml.checkpoint_io import extract_model_state_dict, load_torch_checkpoint +from tda_ml.checkpoint_io import ( + extract_model_state_dict, + load_torch_checkpoint, + resolve_val_topo_checkpoint, +) from tda_ml.config import deep_update, load_config, model_kwargs_from_config from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader from tda_ml.dbscan_eval import evaluate_model_grid, iter_cloud_predictions @@ -43,6 +47,14 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +def _require_data_key(data_cfg: dict[str, Any], key: str) -> Any: + if key not in data_cfg: + raise ValueError( + f"data.{key} must be set explicitly; refusing silent default" + ) + return data_cfg[key] + + @dataclass class CloudMetrics: recall: float @@ -66,11 +78,6 @@ class SplitMetrics: backend: str = "ellphi" -def _valid_clean_inliers(clean_pc: np.ndarray) -> np.ndarray: - mask = np.abs(clean_pc).sum(axis=1) > 1e-6 - return clean_pc[mask] - - def dbscan_labels_to_outlier_pred(labels: np.ndarray) -> np.ndarray: """DBSCAN noise (-1) -> outlier (1); clustered points -> inlier (0).""" return (labels == -1).astype(np.int64) @@ -124,20 +131,20 @@ def _aggregate_cloud_metrics(rows: list[CloudMetrics]) -> tuple[float, float, fl def build_split_loader(config: dict[str, Any], split: str, device: torch.device): data_cfg = config["data"] - seed = int(data_cfg.get("seed", 42)) + seed = int(_require_data_key(data_cfg, "seed")) set_global_seed(seed, deterministic_algorithms=False) - train_size = int(data_cfg.get("train_size", 4500)) - val_size = int(data_cfg.get("val_size", 500)) - test_size = int(data_cfg.get("test_size", 1000)) + train_size = int(_require_data_key(data_cfg, "train_size")) + val_size = int(_require_data_key(data_cfg, "val_size")) + test_size = int(_require_data_key(data_cfg, "test_size")) generator = torch.Generator().manual_seed(seed) full_train_indices = torch.randperm(60000, generator=generator)[: train_size + val_size] val_indices = full_train_indices[train_size:] test_indices = torch.randperm(10000, generator=generator)[:test_size] - num_workers = int(data_cfg.get("num_workers", 0)) - pin_memory = bool(data_cfg.get("pin_memory", device.type == "cuda")) - batch_size = int(data_cfg.get("batch_size", 64)) + num_workers = int(_require_data_key(data_cfg, "num_workers")) + pin_memory = bool(_require_data_key(data_cfg, "pin_memory")) + batch_size = int(_require_data_key(data_cfg, "batch_size")) if split == "val": indices = val_indices @@ -251,20 +258,18 @@ def load_model_from_run( run_dir: Path, config: dict[str, Any], device: torch.device, - checkpoint_name: str = "best_model.pth", ) -> AnisotropicOutlierClassifier: - ckpt_path = run_dir / checkpoint_name - if not ckpt_path.is_file(): - raise FileNotFoundError(f"Missing checkpoint: {ckpt_path}") + """Load ``best_model.pth`` (val_topo) only; hard-fail on missing or partial weights.""" + ckpt_name, _, _ = resolve_val_topo_checkpoint(run_dir) + ckpt_path = run_dir / ckpt_name model = AnisotropicOutlierClassifier(**model_kwargs_from_config(config)) ckpt = load_torch_checkpoint(str(ckpt_path), map_location="cpu") - model.load_state_dict(extract_model_state_dict(ckpt), strict=False) + model.load_state_dict(extract_model_state_dict(ckpt), strict=True) model.to(device) model.eval() return model - def evaluate_split( run_dir: Path, config: dict[str, Any], @@ -276,11 +281,10 @@ def evaluate_split( eps_values: list[float] | None = None, min_samples_values: list[int] | None = None, backend: str = "ellphi", - checkpoint_name: str = "best_model.pth", tag: str | None = None, ) -> SplitMetrics: loader = build_split_loader(config, split, device) - model = load_model_from_run(run_dir, config, device, checkpoint_name=checkpoint_name) + model = load_model_from_run(run_dir, config, device) topo_options = topo_wdist_options_from_config(config) rep = reproducibility_settings(config) log_dir = run_dir / "logs" @@ -431,12 +435,6 @@ def parse_args() -> argparse.Namespace: choices=["ellphi", "mahalanobis"], help="DBSCAN backend for MCC (paper protocol default: mahalanobis).", ) - p.add_argument( - "--checkpoint-name", - type=str, - default="best_model.pth", - help="Checkpoint filename inside run-dir (e.g. final_model.pth).", - ) p.add_argument( "--eps-values", type=float, @@ -473,7 +471,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() run_dir = args.run_dir.resolve() - preflight_paper_eval_run_dir(run_dir, checkpoint_name=args.checkpoint_name) + preflight_paper_eval_run_dir(run_dir) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") config = load_run_config(run_dir, args.base_config, args.seed) @@ -501,7 +499,6 @@ def main() -> int: eps_values=args.eps_values, min_samples_values=args.min_samples_values, backend=args.backend, - checkpoint_name=args.checkpoint_name, tag=args.tag, ) @@ -516,7 +513,7 @@ def main() -> int: "eps": metrics.dbscan_eps, "min_samples": metrics.dbscan_min_samples, "backend": metrics.backend, - "checkpoint_name": args.checkpoint_name, + "checkpoint_name": "best_model.pth", "mean_val_mcc_dbscan": metrics.mcc, "selection": "max mean cloud MCC on validation split", }, @@ -531,7 +528,7 @@ def main() -> int: "source_revision": git_revision(REPO_ROOT), "run_dir": str(run_dir), "split": args.split, - "checkpoint_name": args.checkpoint_name, + "checkpoint_name": "best_model.pth", **asdict(metrics), } out_path.write_text(json.dumps(payload, indent=2) + "\n") diff --git a/experiments/evaluate_topo_checkpoint.py b/experiments/evaluate_topo_checkpoint.py deleted file mode 100644 index 97238ce..0000000 --- a/experiments/evaluate_topo_checkpoint.py +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env python3 -"""Evaluate a run using the epoch with minimum train_topo_loss (saved checkpoints). - -Selects checkpoint_epoch_{k}.pth where k minimizes train_topo_loss in metrics.csv, -runs val DBSCAN grid search, then test evaluation (paper protocol). -Optionally compares against a baseline run JSON output. -""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path - -from checkpoint_selection import best_topo_checkpoint, best_topo_epoch, list_checkpoint_epochs - -REPO_ROOT = Path(__file__).resolve().parents[1] - - -def run_eval( - run_dir: Path, - base_config: str, - split: str, - backend: str, - checkpoint_name: str, - *, - tag: str | None = None, - dbscan_hparams: Path | None = None, -) -> Path: - cmd = [ - sys.executable, - str(REPO_ROOT / "experiments" / "evaluate_paper_protocol.py"), - "--run-dir", - str(run_dir), - "--base-config", - base_config, - "--split", - split, - "--backend", - backend, - "--checkpoint-name", - checkpoint_name, - ] - if tag: - cmd.extend(["--tag", tag]) - if dbscan_hparams is not None: - cmd.extend(["--dbscan-hparams", str(dbscan_hparams)]) - subprocess.run(cmd, cwd=REPO_ROOT, check=True) - suffix = f"_{tag}" if tag else "" - name = f"paper_metrics_{split}{suffix}.json" - return run_dir / "logs" / name - - -def dbscan_hparams_path(run_dir: Path, tag: str | None) -> Path: - suffix = f"_{tag}" if tag else "" - return run_dir / "logs" / f"dbscan_hparams{suffix}.json" - - -def load_metrics_json(path: Path) -> dict: - return json.loads(path.read_text()) - - -def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--run-dir", type=Path, required=True) - p.add_argument("--base-config", type=str, required=True) - p.add_argument("--backend", type=str, default="ellphi", choices=["ellphi", "mahalanobis"]) - p.add_argument("--tag", type=str, default="topo_best") - p.add_argument("--baseline-run-dir", type=Path, default=None) - p.add_argument("--baseline-config", type=str, default=None) - p.add_argument("--baseline-tag", type=str, default="topo_best") - p.add_argument( - "--compare-json", - type=Path, - default=None, - help="Write side-by-side test metrics JSON (default: run-dir/logs/ellphi_tuned_vs_baseline_test.json)", - ) - return p.parse_args() - - -def main() -> int: - args = parse_args() - run_dir = args.run_dir.resolve() - metrics_path = run_dir / "logs" / "metrics.csv" - if not metrics_path.is_file(): - raise SystemExit(f"missing {metrics_path}") - - saved_epochs = list_checkpoint_epochs(run_dir) - if not saved_epochs: - raise SystemExit(f"no checkpoint_epoch_*.pth under {run_dir}") - - ckpt, epoch, topo_loss, global_epoch, global_topo = best_topo_checkpoint(run_dir) - ckpt_path = run_dir / ckpt - if not ckpt_path.is_file(): - raise SystemExit(f"missing checkpoint {ckpt_path} (best saved-topo ep={epoch})") - - print( - f"[topo] best saved epoch={epoch} train_topo_loss={topo_loss:.6f} checkpoint={ckpt}" - ) - if global_epoch is not None and global_topo is not None: - print( - f"[topo] note: global train_topo min is ep={global_epoch} " - f"(loss={global_topo:.6f}) but no checkpoint on disk" - ) - - run_eval( - run_dir, - args.base_config, - "val", - args.backend, - ckpt, - tag=args.tag, - ) - hparams_path = dbscan_hparams_path(run_dir, args.tag) - if not hparams_path.is_file(): - raise SystemExit(f"missing {hparams_path} (expected after val DBSCAN grid search)") - test_json = run_eval( - run_dir, - args.base_config, - "test", - args.backend, - ckpt, - tag=args.tag, - dbscan_hparams=hparams_path, - ) - tuned = load_metrics_json(test_json) - print(f"[test] MCC={tuned['mcc']:.4f} W-Dist={tuned['wdist']:.4f} recall={tuned['recall']:.4f}") - - out: dict = { - "run_dir": str(run_dir), - "base_config": args.base_config, - "backend": args.backend, - "best_topo_epoch": epoch, - "train_topo_loss": topo_loss, - "checkpoint_name": ckpt, - "test_metrics": tuned, - "val_dbscan_hparams": json.loads(hparams_path.read_text()), - } - - if args.baseline_run_dir is not None: - base_dir = args.baseline_run_dir.resolve() - base_config = args.baseline_config or args.base_config - base_metrics = None - for candidate in ( - base_dir / "logs" / "paper_metrics_test_topo_ep30.json", - base_dir / "logs" / f"paper_metrics_test_{args.baseline_tag}.json", - ): - if candidate.is_file(): - base_metrics = load_metrics_json(candidate) - print(f"[baseline] loaded existing {candidate}") - break - if base_metrics is None: - base_saved = list_checkpoint_epochs(base_dir) - if not base_saved: - raise SystemExit(f"no checkpoint_epoch_*.pth under {base_dir}") - base_epoch, base_topo = best_topo_epoch( - base_dir / "logs" / "metrics.csv", - saved_epochs=base_saved, - ) - base_ckpt = f"checkpoint_epoch_{base_epoch}.pth" - print( - f"[baseline] evaluating ep={base_epoch} train_topo={base_topo:.6f} ckpt={base_ckpt}" - ) - run_eval(base_dir, base_config, "val", args.backend, base_ckpt, tag=args.baseline_tag) - hp = dbscan_hparams_path(base_dir, args.baseline_tag) - base_test_path = run_eval( - base_dir, - base_config, - "test", - args.backend, - base_ckpt, - tag=args.baseline_tag, - dbscan_hparams=hp, - ) - base_metrics = load_metrics_json(base_test_path) - baseline = base_metrics - out["baseline"] = { - "run_dir": str(base_dir), - "base_config": base_config, - "test_metrics": baseline, - } - print( - f"[baseline test] MCC={baseline['mcc']:.4f} W-Dist={baseline['wdist']:.4f} " - f"recall={baseline['recall']:.4f}" - ) - - compare_path = args.compare_json or (run_dir / "logs" / "ellphi_tuned_vs_baseline_test.json") - compare_path.parent.mkdir(parents=True, exist_ok=True) - compare_path.write_text(json.dumps(out, indent=2) + "\n") - print(f"wrote {compare_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/experiments/launch_detached_screen.sh b/experiments/launch_detached_screen.sh index 51f505c..c4f3ffb 100755 --- a/experiments/launch_detached_screen.sh +++ b/experiments/launch_detached_screen.sh @@ -26,7 +26,7 @@ mkdir -p "$(dirname "${ROOT}/${LOG}")" if screen -ls | grep -q "[[:space:]]\+[0-9]*\.${SESSION}[[:space:]]"; then echo "screen session already exists: ${SESSION}" >&2 - screen -ls | grep "${SESSION}" || true + screen -ls | grep "${SESSION}" >&2 || echo "(session listed but grep detail failed)" >&2 exit 1 fi diff --git a/experiments/launch_h1_wdist_multifidelity.sh b/experiments/launch_h1_wdist_multifidelity.sh deleted file mode 100755 index 2660e73..0000000 --- a/experiments/launch_h1_wdist_multifidelity.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Launch the prepared H1 multifidelity tune as a detached process. - -set -euo pipefail - -cd "$(dirname "$0")/.." - -ROOT_OUT="${ROOT_OUT:-outputs/tune/0716_pwr_wdist_h1_multifidelity}" -SESSION="${SESSION:-h1_wdist_multifidelity_0716}" -DRIVER_LOG="${ROOT_OUT}_driver.log" - -if [[ -e "${ROOT_OUT}" || -e "${DRIVER_LOG}" ]]; then - echo "output or driver log already exists: ${ROOT_OUT}" >&2 - exit 1 -fi - -exec bash experiments/launch_detached_screen.sh \ - "${SESSION}" \ - "${DRIVER_LOG}" \ - env "ROOT_OUT=${ROOT_OUT}" bash experiments/run_h1_wdist_multifidelity.sh diff --git a/experiments/launch_h1_wdist_tangent_multifidelity.sh b/experiments/launch_h1_wdist_tangent_multifidelity.sh deleted file mode 100755 index 01aa4fa..0000000 --- a/experiments/launch_h1_wdist_tangent_multifidelity.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Launch the H1 W-Dist multifidelity tune on tangent-direction outliers. -# Same 2-stage schedule (stage1 5ep proxy -> stage2 top-4 20ep) as the uniform -# H1 tune, but with outlier_mode=local_pca_tangent (anisotropic noise). - -set -euo pipefail - -cd "$(dirname "$0")/.." - -BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_tangent}" -ROOT_OUT="${ROOT_OUT:-outputs/tune/0717_pwr_wdist_h1_tangent_multifidelity}" -SESSION="${SESSION:-h1_wdist_tangent_multifidelity_0717}" -DRIVER_LOG="${ROOT_OUT}_driver.log" - -if [[ -e "${ROOT_OUT}" || -e "${DRIVER_LOG}" ]]; then - echo "output or driver log already exists: ${ROOT_OUT}" >&2 - exit 1 -fi - -exec bash experiments/launch_detached_screen.sh \ - "${SESSION}" \ - "${DRIVER_LOG}" \ - env "ROOT_OUT=${ROOT_OUT}" "BASE_CONFIG=${BASE_CONFIG}" \ - bash experiments/run_h1_wdist_multifidelity.sh diff --git a/experiments/run_backend_multiseed.py b/experiments/run_backend_multiseed.py index 5a89bac..e722bca 100644 --- a/experiments/run_backend_multiseed.py +++ b/experiments/run_backend_multiseed.py @@ -257,7 +257,12 @@ def run_one( cfg = load_config(base_config_name, project_root=REPO_ROOT) config_id = f"backend_{backend}_seed{seed}" topo_cfg = cfg.get("model", {}).get("topology_loss", {}) - ellphi_diff = bool(topo_cfg.get("ellphi_differentiable", True)) + if "ellphi_differentiable" not in topo_cfg: + raise ValueError( + "model.topology_loss.ellphi_differentiable must be set explicitly " + "in the base config; refusing silent true default" + ) + ellphi_diff = bool(topo_cfg["ellphi_differentiable"]) overrides: dict[str, Any] = { "meta": {"config_id": config_id}, diff --git a/experiments/run_h1_wdist_multifidelity.sh b/experiments/run_h1_wdist_multifidelity.sh deleted file mode 100755 index 2276611..0000000 --- a/experiments/run_h1_wdist_multifidelity.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# H1-only W-Dist tune: -# stage 1: 16 proxy trials x 5 epochs -# stage 2: top 4 proxy conditions x 20 epochs - -set -euo pipefail - -cd "$(dirname "$0")/.." -export PATH="${HOME}/.local/bin:${PATH}" - -BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power_h1}" -ROOT_OUT="${ROOT_OUT:-outputs/tune/0716_pwr_wdist_h1_multifidelity}" -STAGE1_OUT="${ROOT_OUT}/stage1_proxy5" -STAGE2_OUT="${ROOT_OUT}/stage2_top4_full20" -STAGE1_STUDY="h1_wdist_proxy5_16" -STAGE2_STUDY="h1_wdist_top4_full20" -STAGE1_STORAGE="sqlite:///${STAGE1_OUT}/study.db" -STAGE2_STORAGE="sqlite:///${STAGE2_OUT}/study.db" - -record_plan_status() { - local status="$1" - local reason="$2" - local plan="${ROOT_OUT}/MULTIFIDELITY_PLAN.json" - [[ -f "${plan}" ]] || return 0 - uv run python - "${plan}" "${status}" "${reason}" <<'PY' -import json -import sys -from datetime import datetime, timezone -from pathlib import Path - -path = Path(sys.argv[1]) -payload = json.loads(path.read_text(encoding="utf-8")) -payload["run_status"] = sys.argv[2] -payload["status_reason"] = sys.argv[3] -payload["status_recorded_at_utc"] = datetime.now(timezone.utc).isoformat() -path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") -PY -} - -on_error() { - local code=$? - trap - ERR - record_plan_status "failed" "pipeline command failed with exit code ${code}" - exit "${code}" -} - -on_interrupt() { - trap - INT TERM - record_plan_status "skipped" "pipeline interrupted intentionally" - exit 130 -} - -trap on_error ERR -trap on_interrupt INT TERM - -if [[ -e "${ROOT_OUT}" ]]; then - echo "output already exists: ${ROOT_OUT}" >&2 - exit 1 -fi - -mkdir -p "${ROOT_OUT}" -cat > "${ROOT_OUT}/MULTIFIDELITY_PLAN.json" < int: split, "--backend", args.dbscan_backend, - "--checkpoint-name", - "best_model.pth", "--tag", tag, ] diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh index ef6ccc4..e442654 100755 --- a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh +++ b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh @@ -32,7 +32,9 @@ export OPENBLAS_NUM_THREADS="${OPENBLAS_NUM_THREADS:-${THREADS_PER_WORKER}}" N_WORKERS="${N_WORKERS:-4}" MODE="${1:-both}" -shift || true +if [[ $# -gt 0 ]]; then + shift +fi if [[ $# -gt 0 && "${1:-}" =~ ^[0-9]+$ ]]; then SEEDS=("$@") else diff --git a/experiments/run_tune_local_pca_power_mcc_parallel.sh b/experiments/run_tune_local_pca_power_mcc_parallel.sh index 8025e6b..f7adbae 100755 --- a/experiments/run_tune_local_pca_power_mcc_parallel.sh +++ b/experiments/run_tune_local_pca_power_mcc_parallel.sh @@ -25,7 +25,7 @@ N_TRIALS="${2:-24}" TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" DBSCAN_BACKEND="${5:-mahalanobis}" -BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc}" +BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power}" OUT_BASE="${OUT_BASE:-outputs/tune/pwr_mcc_dbscan_${DBSCAN_BACKEND}}" STUDY_NAME="${STUDY_NAME:-elongate_local_pca_power_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}}" STORAGE="sqlite:///${OUT_BASE}/study.db" diff --git a/experiments/run_tune_local_pca_power_wdist_parallel.sh b/experiments/run_tune_local_pca_power_wdist_parallel.sh index cc2f22e..e39368f 100755 --- a/experiments/run_tune_local_pca_power_wdist_parallel.sh +++ b/experiments/run_tune_local_pca_power_wdist_parallel.sh @@ -21,7 +21,7 @@ N_WORKERS="${1:-4}" N_TRIALS="${2:-24}" TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" -BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc}" +BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power}" OUT_BASE="${OUT_BASE:-outputs/tune/pwr_wdist}" STUDY_NAME="${STUDY_NAME:-elongate_local_pca_power_wdist_${BACKEND}}" STORAGE="sqlite:///${OUT_BASE}/study.db" diff --git a/experiments/tune_elongate_mcc.py b/experiments/tune_elongate_mcc.py index 6fef4f0..161721a 100644 --- a/experiments/tune_elongate_mcc.py +++ b/experiments/tune_elongate_mcc.py @@ -158,7 +158,7 @@ def objective(trial: optuna.Trial) -> float: ckpt_name, ckpt_epoch, val_topo = resolve_val_topo_checkpoint(run_dir) topo_options = topo_wdist_options_from_config(cfg) - model = load_model_from_run(run_dir, cfg, device, checkpoint_name=ckpt_name) + model = load_model_from_run(run_dir, cfg, device) loader = build_split_loader(cfg, "val", device) grid = evaluate_model_grid( @@ -202,7 +202,7 @@ def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) p.add_argument( "--base-config", - default="elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", + default="elongate_n100_no_cls_tune_local_pca_ellphi_power", ) p.add_argument("--n-trials", type=int, default=24) p.add_argument( diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py index 4e2f409..2625f37 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_elongate_wdist.py @@ -9,7 +9,7 @@ 2. Loads ``best_model.pth`` (val_topo selection checkpoint; hard-fail if missing). 3. Objective = mean val **topo W-Dist** (learned ellipses vs local_pca teacher PD). -Base config ``elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc`` sets +Base config ``elongate_n100_no_cls_tune_local_pca_ellphi_power`` sets ``teacher_mode: local_pca``, H1-only persistence, and ``size_mode: power``. ``w_class: 0``, ``selection.metric: val_topo``. @@ -169,7 +169,7 @@ def objective(trial: optuna.Trial) -> float: ckpt_name, ckpt_epoch, val_topo_sel = resolve_val_topo_checkpoint(run_dir) topo_options = topo_wdist_options_from_config(cfg) - model = load_model_from_run(run_dir, cfg, device, checkpoint_name=ckpt_name) + model = load_model_from_run(run_dir, cfg, device) loader = build_split_loader(cfg, "val", device) clouds = list(iter_cloud_predictions(model, loader, device)) @@ -193,7 +193,7 @@ def objective(trial: optuna.Trial) -> float: def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) # No implicit default: every study must state its config surface explicitly - # (power stack uses elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc). + # (power stack uses elongate_n100_no_cls_tune_local_pca_ellphi_power). p.add_argument("--base-config", type=str, required=True) p.add_argument("--n-trials", type=int, default=50) p.add_argument( diff --git a/main.py b/main.py deleted file mode 100644 index 3b9fea0..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from tda-ml!") - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index d26d1c8..988d307 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,9 +30,6 @@ experiments = [ "joblib>=1.3", "pandas>=2.0", ] -repro-pd-animation = [ - "homcloud>=4.8", -] images = [ "pillow>=10.0", ] @@ -59,11 +56,9 @@ dev = [ exclude = [ ".git", ".venv", - ".venv_linux", "__pycache__", "ellphi_repo", "pytorch-topological", - "trash", "outputs", "data", ] @@ -72,7 +67,6 @@ exclude = [ testpaths = ["tests"] addopts = "-q" norecursedirs = [ - "trash", "ellphi_repo", "pytorch-topological", "outputs", diff --git a/tda_ml/data_loader.py b/tda_ml/data_loader.py index a3d535e..50850e8 100644 --- a/tda_ml/data_loader.py +++ b/tda_ml/data_loader.py @@ -168,7 +168,8 @@ def __getitem__(self, idx): if not self.allow_empty_cloud_fallback: raise RuntimeError( f"Empty foreground point cloud at dataset index {idx}; " - "set data.allow_empty_cloud_fallback=true to opt in to random fallback." + "set reproducibility.allow_empty_cloud_fallback=true to opt in " + "to random fallback." ) fallback_n = min(8, self.max_points) if self.deterministic: diff --git a/tda_ml/dbscan_eval.py b/tda_ml/dbscan_eval.py index 7fedd65..831972c 100644 --- a/tda_ml/dbscan_eval.py +++ b/tda_ml/dbscan_eval.py @@ -16,6 +16,7 @@ from tda_ml.dbscan import compute_anisotropic_distance_matrix_np from tda_ml.metrics import compute_recall_specificity_gmean_mcc +from tda_ml.numerical_eps import ZERO_PAD_ABS_SUM from tda_ml.reproducibility import record_fallback, resolve_dbscan_grid, write_grid_log from tda_ml.topo_wdist import TopoWdistOptions, compute_topo_wdist @@ -58,7 +59,7 @@ class PreparedCloud: def valid_clean_inliers(clean_pc: np.ndarray) -> np.ndarray: """Drop zero-padding rows from the clean (ground-truth inlier) point cloud.""" - mask = np.abs(clean_pc).sum(axis=1) > 1e-6 + mask = np.abs(clean_pc).sum(axis=1) > ZERO_PAD_ABS_SUM return clean_pc[mask] diff --git a/tda_ml/gudhi_utils.py b/tda_ml/gudhi_utils.py deleted file mode 100644 index 5507809..0000000 --- a/tda_ml/gudhi_utils.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Helpers for GUDHI 3.12+ Python bindings (nanobind).""" - -import numpy as np -from scipy.spatial.distance import squareform - - -def distance_matrix_for_gudhi(dm: np.ndarray) -> np.ndarray: - """Return float64, C-contiguous, read-only square distance matrix for RipsComplex.""" - if dm.ndim == 1: - dm = squareform(dm) - out = np.array(np.asarray(dm, dtype=np.float64), order="C", copy=True) - out.setflags(write=False) - return out diff --git a/tda_ml/main.py b/tda_ml/main.py index e8e4617..2811af7 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -247,7 +247,7 @@ def _mark_failed(exc: BaseException) -> None: if init_checkpoint: logger.info("Loading initial weights from %s", init_checkpoint) checkpoint = load_torch_checkpoint(init_checkpoint, map_location=device) - model.load_state_dict(extract_model_state_dict(checkpoint), strict=False) + model.load_state_dict(extract_model_state_dict(checkpoint), strict=True) log_dir = config["outputs"]["log_dir"] metrics_path = os.path.join(log_dir, "metrics.csv") diff --git a/tda_ml/model_inference.py b/tda_ml/model_inference.py deleted file mode 100644 index 7787dbf..0000000 --- a/tda_ml/model_inference.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Load a checkpoint and run :class:`~tda_ml.models.AnisotropicOutlierClassifier` forward.""" - -from __future__ import annotations - -import logging -import os - -import torch - -from tda_ml.checkpoint_io import extract_model_state_dict, load_torch_checkpoint -from tda_ml.models import AnisotropicOutlierClassifier - -logger = logging.getLogger(__name__) -def default_best_model_path() -> str: - root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - return os.path.join(root, "experiments", "best_model.pth") - - -def load_model( - device: torch.device, - weights_path: str | None = None, -) -> AnisotropicOutlierClassifier: - """Load a trained ``AnisotropicOutlierClassifier`` (topology head outputs ``[a, b, theta]``).""" - path = weights_path or default_best_model_path() - if not os.path.isfile(path): - raise FileNotFoundError( - f"Pretrained weights not found: {path}\n" - "Train and save a checkpoint, or pass an explicit weights_path." - ) - - model = AnisotropicOutlierClassifier() - ckpt = load_torch_checkpoint(path, map_location="cpu") - sd = extract_model_state_dict(ckpt) - model.load_state_dict(sd, strict=False) - model.to(device) - model.eval() - logger.info("Loaded model from %s", path) - return model - - -def model_forward(model: AnisotropicOutlierClassifier, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: - """Same as ``model(x)``; kept for scripts that import this helper name.""" - return model(x) diff --git a/tda_ml/model_selection.py b/tda_ml/model_selection.py index 54c6639..b118ab5 100644 --- a/tda_ml/model_selection.py +++ b/tda_ml/model_selection.py @@ -38,13 +38,31 @@ class SelectionSettings: def selection_settings_from_config(config) -> SelectionSettings: - sel_cfg = (config.get("training", {}).get("selection") or {}) - metric = sel_cfg.get("metric", "val_topo") + training = config.get("training") + if not isinstance(training, dict): + raise ValueError( + "training must be an explicit mapping; refusing silent selection defaults" + ) + sel_cfg = training.get("selection") + if not isinstance(sel_cfg, dict): + raise ValueError( + "training.selection must be set explicitly; refusing silent val_topo default" + ) + if "metric" not in sel_cfg: + raise ValueError( + "training.selection.metric must be set explicitly; " + "refusing silent val_topo default" + ) + metric = str(sel_cfg["metric"]).strip() eval_every = max(1, int(sel_cfg.get("eval_every", 1))) - dbscan_cfg = (sel_cfg.get("dbscan") or {}) - backend = ( - config.get("model", {}).get("topology_loss", {}).get("distance_backend", "mahalanobis") - ) + dbscan_cfg = sel_cfg.get("dbscan") or {} + topo = (config.get("model") or {}).get("topology_loss") or {} + if "distance_backend" not in topo: + raise ValueError( + "model.topology_loss.distance_backend must be set explicitly; " + "refusing silent mahalanobis default in selection" + ) + backend = str(topo["distance_backend"]).lower().strip() settings = SelectionSettings( metric=metric, eval_every=eval_every, diff --git a/tda_ml/numerical_eps.py b/tda_ml/numerical_eps.py index 258e143..496338e 100644 --- a/tda_ml/numerical_eps.py +++ b/tda_ml/numerical_eps.py @@ -1,4 +1,12 @@ -"""Named numerical floors (document in paper Methods / supplement).""" +"""Named numerical floors (document in paper Methods / supplement). + +These are part of the declared numerical method, not silent patches for +degenerate intermediates. Prefer hard-fail over inventing new undeclared floors. + +Declared FP guard without a constant: squared distances are clamped at 0 before +masked ``sqrt`` (``tda_ml.topology._sqrt_off_diagonal_only``) to strip negative +floating-point rounding noise from a mathematically non-negative quantity. +""" # Denominators in metric tensor, probability weighting, aspect ratio. NUMERICAL_EPS = 1e-8 @@ -12,6 +20,11 @@ # Lower clamp on inlier probability in Mahalanobis distance weighting. INLIER_PROB_MIN = 1e-4 +# Absolute-sum threshold to drop zero-padded rows in fixed-size point tensors. +# Dataset loaders pad short clouds with zeros; this is the declared mask, not a +# modeling floor on physical coordinates. +ZERO_PAD_ABS_SUM = 1e-6 + # Minimum pairwise center separation for ellphi-compatible clouds. # Two nearly coincident cloud points make the ellphi tangency derivative w.r.t. # the ellipse center (mu) numerically undefined; samplers reject candidates diff --git a/tda_ml/persistence.py b/tda_ml/persistence.py index cd75be0..018e0fe 100644 --- a/tda_ml/persistence.py +++ b/tda_ml/persistence.py @@ -1,4 +1,9 @@ -"""Persistence diagrams, Wasserstein distances, and related topology helpers.""" +"""Persistence diagrams and Wasserstein distances (Euclidean Alpha / Gudhi). + +Used for the optional Euclidean baseline W-Dist path in ``metrics``. +The paper main-table topo W-Dist uses ellipse filtration via +``tda_ml.topo_wdist`` / ``TopologicalLoss``, not this module. +""" import numpy as np import torch @@ -57,163 +62,3 @@ def get_pd(pts): pd_gt = get_pd(points_gt) return wasserstein_h1(pd_pred, pd_gt, order=1, internal_p=2) - - -def compute_bottleneck_distance(points_pred, points_gt): - """ - Bottleneck distance between H1 persistence diagrams of two point clouds. - - Hold note (B-3 / GitHub #24): currently unused in active tda_ml/scripts/tests, - but referenced in legacy code under `trash/`; keep until removal is fully validated. - - Empty point clouds yield empty H1 diagrams; distance is computed via Gudhi - without sentinel penalties (same policy as :func:`compute_w_distance`). - """ - if isinstance(points_pred, torch.Tensor): - points_pred = points_pred.detach().cpu().numpy() - if isinstance(points_gt, torch.Tensor): - points_gt = points_gt.detach().cpu().numpy() - - def get_pd(pts): - if len(pts) < 3: - return [] - alpha_complex = gudhi.AlphaComplex(points=pts) - simplex_tree = alpha_complex.create_simplex_tree() - simplex_tree.compute_persistence() - pd = simplex_tree.persistence_intervals_in_dimension(1) - return pd - - pd_pred = get_pd(points_pred) - pd_gt = get_pd(points_gt) - - if len(pd_pred) == 0: - pd_pred = np.empty((0, 2)) - if len(pd_gt) == 0: - pd_gt = np.empty((0, 2)) - - return float(gudhi.bottleneck_distance(pd_pred, pd_gt)) - - -def compute_betti_error(points_pred, points_gt): - """ - Computes absolute error in Betti numbers (dim 0 and 1). - Returns: (betti0_err, betti1_err) - """ - if isinstance(points_pred, torch.Tensor): - points_pred = points_pred.detach().cpu().numpy() - if isinstance(points_gt, torch.Tensor): - points_gt = points_gt.detach().cpu().numpy() - - def get_betti(pts): - if len(pts) == 0: - return 0, 0 - if len(pts) < 3: - return 1, 0 - alpha_complex = gudhi.AlphaComplex(points=pts) - simplex_tree = alpha_complex.create_simplex_tree() - simplex_tree.compute_persistence() - betti = simplex_tree.betti_numbers() - b0 = betti[0] if len(betti) > 0 else 0 - b1 = betti[1] if len(betti) > 1 else 0 - return b0, b1 - - b0_pred, b1_pred = get_betti(points_pred) - b0_gt, b1_gt = get_betti(points_gt) - - return abs(b0_pred - b0_gt), abs(b1_pred - b1_gt) - - -def sample_from_ellipses(points, ellipse_params, num_samples_per_ellipse=50): - """ - Samples points from the predicted ellipses. - - Hold note (B-3 / GitHub #24): currently unused in active tda_ml/scripts/tests, - but referenced in legacy code under `trash/`; remove only after reproducibility sign-off. - - Args: - points: (B, N, 2) - ellipse_params: (B, N, 3) [a, b, theta] at each point - Returns: - sampled_points: (B, N * num_samples, 2) - """ - batch_size, N, _ = ellipse_params.shape - device = ellipse_params.device - - cx = points[..., 0:1] - cy = points[..., 1:2] - - a = ellipse_params[..., 0:1] - b = ellipse_params[..., 1:2] - theta = ellipse_params[..., 2:3] - - t = torch.linspace(0, 2 * torch.pi, num_samples_per_ellipse, device=device).view( - 1, 1, num_samples_per_ellipse - ) - - a = a.unsqueeze(-1) - b = b.unsqueeze(-1) - theta = theta.unsqueeze(-1) - cx = cx.unsqueeze(-1) - cy = cy.unsqueeze(-1) - - x_e = a * torch.cos(t) - y_e = b * torch.sin(t) - - cos_theta = torch.cos(theta) - sin_theta = torch.sin(theta) - - x_r = x_e * cos_theta - y_e * sin_theta + cx - y_r = x_e * sin_theta + y_e * cos_theta + cy - - sampled_points = torch.stack([x_r, y_r], dim=-1) - return sampled_points.view(batch_size, N * num_samples_per_ellipse, 2) - - -def calculate_persistent_entropy(pd_info): - """ - Calculates persistent entropy from persistence information. - - Hold note (B-3 / GitHub #24): currently unused in active tda_ml/scripts/tests, - but referenced in legacy code under `trash/`; remove only after reproducibility sign-off. - - Args: - pd_info: List of persistence diagrams [dim0, dim1, ...] - Returns: - entropy: float - """ - if len(pd_info) < 2: - return 0.0 - - pd_h1 = pd_info[1] - - if not isinstance(pd_h1, torch.Tensor): - if hasattr(pd_h1, "diagram"): - pd_h1 = pd_h1.diagram - - if pd_h1.size(0) == 0: - return 0.0 - - births = pd_h1[:, 0] - deaths = pd_h1[:, 1] - - finite_mask = torch.isfinite(deaths) - births = births[finite_mask] - deaths = deaths[finite_mask] - - if len(births) == 0: - return 0.0 - - lifetimes = deaths - births - - valid_mask = lifetimes > 1e-6 - lifetimes = lifetimes[valid_mask] - - if len(lifetimes) == 0: - return 0.0 - - total_lifetime = lifetimes.sum() - probabilities = lifetimes / total_lifetime - - entropy = -torch.sum(probabilities * torch.log(probabilities)) - - return entropy.item() diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py index 1e5df4d..0e7b4e8 100644 --- a/tda_ml/preflight.py +++ b/tda_ml/preflight.py @@ -278,7 +278,12 @@ def preflight_training_config( "loss.aniso_barrier_threshold must be set explicitly for " f"aniso_mode={aniso_mode!r}; refusing silent 6.0 default" ) - ellphi_diff = bool(topo.get("ellphi_differentiable", True)) + if "ellphi_differentiable" not in topo: + raise ValueError( + "model.topology_loss.ellphi_differentiable must be set explicitly; " + "refusing silent true default" + ) + ellphi_diff = bool(topo["ellphi_differentiable"]) if backend == "ellphi": _require_import("ellphi", lambda: __import__("ellphi")) assert_ellphi_repo_matches_pin(project_root=root) @@ -567,12 +572,13 @@ def preflight_baseline_eval( return preview -def preflight_paper_eval_run_dir(run_dir: Path, *, checkpoint_name: str = "best_model.pth") -> None: +def preflight_paper_eval_run_dir(run_dir: Path) -> None: + """Require ``best_model.pth`` (val_topo); no alternate checkpoint names.""" + from tda_ml.checkpoint_io import resolve_val_topo_checkpoint + if not run_dir.is_dir(): raise FileNotFoundError(f"run-dir not found: {run_dir}") - ckpt = run_dir / checkpoint_name - if not ckpt.is_file(): - raise FileNotFoundError(f"Checkpoint not found: {ckpt}") + resolve_val_topo_checkpoint(run_dir) def os_access_writable(path: Path) -> bool: diff --git a/tda_ml/reproducibility.py b/tda_ml/reproducibility.py index 8c638d9..fbcccc9 100644 --- a/tda_ml/reproducibility.py +++ b/tda_ml/reproducibility.py @@ -15,6 +15,7 @@ MIN_ELLPHI_CENTER_SEPARATION, NUMERICAL_EPS, PCA_RIDGE_EPS, + ZERO_PAD_ABS_SUM, ) DEFAULT_DBSCAN_EPS_LINSPACE = (0.15, 1.5, 15) @@ -263,6 +264,7 @@ def build_reproducibility_manifest_fields( "PCA_RIDGE_EPS": PCA_RIDGE_EPS, "EIGENVALUE_FLOOR": EIGENVALUE_FLOOR, "INLIER_PROB_MIN": INLIER_PROB_MIN, + "ZERO_PAD_ABS_SUM": ZERO_PAD_ABS_SUM, }, "ellphi_repo": build_ellphi_repo_manifest_fields(project_root), } diff --git a/tda_ml/run_setup.py b/tda_ml/run_setup.py index 572f027..6d55c02 100644 --- a/tda_ml/run_setup.py +++ b/tda_ml/run_setup.py @@ -69,9 +69,14 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): must not change. """ data_cfg = config["data"] - train_size = data_cfg.get("train_size", 4500) - val_size = data_cfg.get("val_size", 500) - test_size = data_cfg.get("test_size", 1000) + for key in ("train_size", "val_size", "test_size", "batch_size"): + if key not in data_cfg: + raise ValueError( + f"data.{key} must be set explicitly; refusing silent default" + ) + train_size = int(data_cfg["train_size"]) + val_size = int(data_cfg["val_size"]) + test_size = int(data_cfg["test_size"]) generator = torch.Generator().manual_seed(seed) full_train_indices = torch.randperm(60000, generator=generator)[: train_size + val_size] @@ -147,11 +152,9 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): outlier_mode=outlier_mode, allow_empty_cloud_fallback=bool( (config.get("reproducibility") or {}).get("allow_empty_cloud_fallback", False) - or data_cfg.get("allow_empty_cloud_fallback", False) ), allow_otsu_threshold_fallback=bool( (config.get("reproducibility") or {}).get("allow_otsu_threshold_fallback", False) - or data_cfg.get("allow_otsu_threshold_fallback", False) ), ) diff --git a/tda_ml/teacher_pd.py b/tda_ml/teacher_pd.py index eb84229..4cb4e47 100644 --- a/tda_ml/teacher_pd.py +++ b/tda_ml/teacher_pd.py @@ -11,6 +11,7 @@ local_pca_ellipse_params, normalize_teacher_mode, ) +from tda_ml.numerical_eps import ZERO_PAD_ABS_SUM def _median_offdiag(d_mat: torch.Tensor) -> float: @@ -55,7 +56,7 @@ def compute_clean_teacher_batch( with torch.no_grad(): for j in range(clean_points.shape[0]): pts = clean_points[j] - valid_mask = torch.abs(pts).sum(dim=1) > 1e-6 + valid_mask = torch.abs(pts).sum(dim=1) > ZERO_PAD_ABS_SUM pts = pts[valid_mask] if pts.shape[0] < 2: raise RuntimeError( diff --git a/tda_ml/topo_wdist.py b/tda_ml/topo_wdist.py index 87ceb83..2f1cb3b 100644 --- a/tda_ml/topo_wdist.py +++ b/tda_ml/topo_wdist.py @@ -92,28 +92,49 @@ def topo_wdist_options_from_config(config: dict[str, Any]) -> TopoWdistOptions: "(or training.teacher_mode); refusing silent euclidean default" ) - max_pts = training_cfg.get("topo_loss_max_points", loss_cfg.get("topo_loss_max_points")) - return TopoWdistOptions( - teacher_mode=str(teacher_mode).strip().lower(), - distance_backend=str(topo_cfg["distance_backend"]).lower().strip(), - teacher_local_pca_k=int( + teacher_mode_norm = str(teacher_mode).strip().lower() + + def _require_loss_key(key: str) -> Any: + if key in loss_cfg: + return loss_cfg[key] + if key in training_cfg: + return training_cfg[key] + raise ValueError( + f"topo W-Dist requires explicit loss.{key} " + "(or training fallback key); refusing silent default" + ) + + eps_scale = float(_require_loss_key("topo_eps_scale")) + scale_mode = str(_require_loss_key("topo_scale_mode")).strip().lower() + + if teacher_mode_norm == "local_pca": + teacher_local_pca_k = int(_require_loss_key("teacher_local_pca_k")) + teacher_local_pca_normalize_axes = bool( + _require_loss_key("teacher_local_pca_normalize_axes") + ) + else: + # Euclidean teacher never reads the PCA fields; keep declared defaults + # for the dataclass without allowing them to leak into local_pca runs. + teacher_local_pca_k = int( loss_cfg.get( - "teacher_local_pca_k", - training_cfg.get("teacher_local_pca_k", 10), + "teacher_local_pca_k", training_cfg.get("teacher_local_pca_k", 10) ) - ), - teacher_local_pca_normalize_axes=bool( + ) + teacher_local_pca_normalize_axes = bool( loss_cfg.get( "teacher_local_pca_normalize_axes", training_cfg.get("teacher_local_pca_normalize_axes", True), ) - ), - eps_scale=float( - loss_cfg.get("topo_eps_scale", training_cfg.get("topo_eps_scale", 1.0)) - ), - scale_mode=str( - loss_cfg.get("topo_scale_mode", training_cfg.get("topo_scale_mode", "fixed")) - ).strip().lower(), + ) + + max_pts = training_cfg.get("topo_loss_max_points", loss_cfg.get("topo_loss_max_points")) + return TopoWdistOptions( + teacher_mode=teacher_mode_norm, + distance_backend=str(topo_cfg["distance_backend"]).lower().strip(), + teacher_local_pca_k=teacher_local_pca_k, + teacher_local_pca_normalize_axes=teacher_local_pca_normalize_axes, + eps_scale=eps_scale, + scale_mode=scale_mode, max_points=int(max_pts) if max_pts is not None else None, prob_weighting=bool(topo_cfg["prob_weighting"]), homology_dimensions=normalize_homology_dimensions( diff --git a/tda_ml/topology.py b/tda_ml/topology.py index 9389a4d..d83c25a 100644 --- a/tda_ml/topology.py +++ b/tda_ml/topology.py @@ -137,6 +137,9 @@ def _sqrt_off_diagonal_only(dist_sq: torch.Tensor) -> torch.Tensor: ``dist_sq > 0`` (never to zero squared distances, including self-distance and coincident off-diagonal pairs). """ + # Declared FP guard, not a modeling floor: squared Mahalanobis distances are + # mathematically >= 0; this removes negative floating-point rounding noise + # before the masked sqrt (see tda_ml/numerical_eps.py module docstring). dist_sq = torch.clamp(dist_sq, min=0.0) dist = torch.zeros_like(dist_sq) positive = dist_sq > 0 diff --git a/tda_ml/trainer.py b/tda_ml/trainer.py index b8e423f..6c01a8d 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -134,12 +134,25 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: "loss.size_mode must be set explicitly; refusing silent quadratic default" ) self.size_mode = str(size_mode_raw).strip().lower() - self.size_barrier_radius = float( - loss_cfg.get( - "size_barrier_radius", - training_cfg.get("size_barrier_radius", 1.5), + if self.size_mode == "barrier": + if "size_barrier_radius" not in loss_cfg and "size_barrier_radius" not in training_cfg: + raise ValueError( + "loss.size_barrier_radius must be set explicitly for " + "size_mode='barrier'; refusing silent 1.5 default" + ) + self.size_barrier_radius = float( + loss_cfg.get( + "size_barrier_radius", + training_cfg.get("size_barrier_radius"), + ) + ) + else: + self.size_barrier_radius = float( + loss_cfg.get( + "size_barrier_radius", + training_cfg.get("size_barrier_radius", 1.5), + ) ) - ) if "size_ref" not in loss_cfg and "size_ref" not in training_cfg: raise ValueError( "loss.size_ref must be set explicitly; refusing silent 1.34 default" @@ -154,12 +167,25 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: self.size_power = float( loss_cfg.get("size_power", training_cfg.get("size_power")) ) - self.size_softplus_beta = float( - loss_cfg.get( - "size_softplus_beta", - training_cfg.get("size_softplus_beta", 8.0), + if self.size_mode == "softplus": + if "size_softplus_beta" not in loss_cfg and "size_softplus_beta" not in training_cfg: + raise ValueError( + "loss.size_softplus_beta must be set explicitly for " + "size_mode='softplus'; refusing silent 8.0 default" + ) + self.size_softplus_beta = float( + loss_cfg.get( + "size_softplus_beta", + training_cfg.get("size_softplus_beta"), + ) + ) + else: + self.size_softplus_beta = float( + loss_cfg.get( + "size_softplus_beta", + training_cfg.get("size_softplus_beta", 8.0), + ) ) - ) logger.info( "Anisotropy penalty mode: %s (barrier_threshold=%s)", self.aniso_mode, @@ -208,30 +234,71 @@ def _loss_or_legacy(key: str, legacy_key: str, default: float) -> float: "loss.teacher_mode must be set explicitly; refusing silent euclidean default" ) self.distance_backend = str(_topo["distance_backend"]).lower().strip() - self.ellphi_differentiable = bool(_topo.get("ellphi_differentiable", True)) + if "ellphi_differentiable" not in _topo: + raise ValueError( + "model.topology_loss.ellphi_differentiable must be set explicitly; " + "refusing silent true default" + ) + self.ellphi_differentiable = bool(_topo["ellphi_differentiable"]) self.prob_weighting = bool(_topo["prob_weighting"]) self.homology_dimensions = _topo["homology_dimensions"] # Filtration-unit alignment for the topology loss (see TopologicalLoss). - # Legacy knob `training.topo_eps_scale` (v73=0.7022); also accept loss.topo_eps_scale. + if "topo_eps_scale" not in loss_cfg and "topo_eps_scale" not in training_cfg: + raise ValueError( + "loss.topo_eps_scale (or training.topo_eps_scale) must be set " + "explicitly; refusing silent 1.0 default" + ) + if "topo_scale_mode" not in loss_cfg and "topo_scale_mode" not in training_cfg: + raise ValueError( + "loss.topo_scale_mode (or training.topo_scale_mode) must be set " + "explicitly; refusing silent 'fixed' default" + ) self.topo_eps_scale = float( - loss_cfg.get("topo_eps_scale", training_cfg.get("topo_eps_scale", 1.0)) + loss_cfg.get("topo_eps_scale", training_cfg.get("topo_eps_scale")) ) self.topo_scale_mode = str( - loss_cfg.get("topo_scale_mode", training_cfg.get("topo_scale_mode", "fixed")) + loss_cfg.get("topo_scale_mode", training_cfg.get("topo_scale_mode")) ).strip().lower() self.teacher_mode = str(teacher_raw).strip().lower() - self.teacher_local_pca_k = int( - loss_cfg.get( - "teacher_local_pca_k", - training_cfg.get("teacher_local_pca_k", 10), + if self.teacher_mode == "local_pca": + if "teacher_local_pca_k" not in loss_cfg and "teacher_local_pca_k" not in training_cfg: + raise ValueError( + "loss.teacher_local_pca_k must be set explicitly when " + "teacher_mode='local_pca'; refusing silent 10 default" + ) + if ( + "teacher_local_pca_normalize_axes" not in loss_cfg + and "teacher_local_pca_normalize_axes" not in training_cfg + ): + raise ValueError( + "loss.teacher_local_pca_normalize_axes must be set explicitly " + "when teacher_mode='local_pca'; refusing silent true default" + ) + self.teacher_local_pca_k = int( + loss_cfg.get( + "teacher_local_pca_k", + training_cfg.get("teacher_local_pca_k"), + ) ) - ) - self.teacher_local_pca_normalize_axes = bool( - loss_cfg.get( - "teacher_local_pca_normalize_axes", - training_cfg.get("teacher_local_pca_normalize_axes", True), + self.teacher_local_pca_normalize_axes = bool( + loss_cfg.get( + "teacher_local_pca_normalize_axes", + training_cfg.get("teacher_local_pca_normalize_axes"), + ) + ) + else: + self.teacher_local_pca_k = int( + loss_cfg.get( + "teacher_local_pca_k", + training_cfg.get("teacher_local_pca_k", 10), + ) + ) + self.teacher_local_pca_normalize_axes = bool( + loss_cfg.get( + "teacher_local_pca_normalize_axes", + training_cfg.get("teacher_local_pca_normalize_axes", True), + ) ) - ) logger.info( "Topological distance backend: %s%s (prob_weighting=%s, homology_dimensions=%s, scale_mode=%s, eps_scale=%s, teacher_mode=%s%s)", self.distance_backend, diff --git a/tests/test_evaluate_topo_checkpoint.py b/tests/test_evaluate_topo_checkpoint.py deleted file mode 100644 index a2c0fcc..0000000 --- a/tests/test_evaluate_topo_checkpoint.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for train-topo checkpoint selection (saved epochs only).""" - -from __future__ import annotations - -import csv -from pathlib import Path - -import pytest - -from experiments.checkpoint_selection import ( - best_topo_checkpoint, - best_topo_epoch, - list_checkpoint_epochs, -) - - -def test_best_topo_epoch_restricted_to_saved(tmp_path: Path) -> None: - run_dir = tmp_path / "run" - log_dir = run_dir / "logs" - log_dir.mkdir(parents=True) - metrics = log_dir / "metrics.csv" - with metrics.open("w", newline="") as f: - w = csv.writer(f) - w.writerow(["epoch", "train_topo_loss"]) - w.writerow([28, 0.09]) - w.writerow([29, 0.05]) - w.writerow([30, 0.06]) - - (run_dir / "checkpoint_epoch_30.pth").write_bytes(b"x") - - saved = list_checkpoint_epochs(run_dir) - assert saved == {30} - - ep, loss = best_topo_epoch(metrics, saved_epochs=saved) - assert ep == 30 - assert loss == pytest.approx(0.06) - - global_ep, global_loss = best_topo_epoch(metrics) - assert global_ep == 29 - assert global_loss == pytest.approx(0.05) - - ckpt, ep, loss, gep, gt = best_topo_checkpoint(run_dir) - assert ckpt == "checkpoint_epoch_30.pth" - assert ep == 30 - assert loss == pytest.approx(0.06) - assert gep == 29 - assert gt == pytest.approx(0.05) - - -def test_best_topo_epoch_prefers_val_topo_column(tmp_path: Path) -> None: - metrics = tmp_path / "metrics.csv" - with metrics.open("w", newline="") as f: - w = csv.writer(f) - w.writerow(["epoch", "train_topo_loss", "val_topo_loss"]) - w.writerow([1, 0.20, 0.11]) - w.writerow([2, 0.10, 0.12]) - - ep, loss = best_topo_epoch(metrics) - assert ep == 1 - assert loss == pytest.approx(0.11) diff --git a/tests/test_paper_eval_imports.py b/tests/test_paper_eval_imports.py new file mode 100644 index 0000000..f59aeb2 --- /dev/null +++ b/tests/test_paper_eval_imports.py @@ -0,0 +1,57 @@ +"""Smoke tests for paper evaluation entrypoints (imports + contracts).""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +import torch + +from tda_ml.checkpoint_io import resolve_val_topo_checkpoint +from tda_ml.config import load_config +from tda_ml.preflight import assert_paper_no_cls_contract, preflight_paper_eval_run_dir +from tda_ml.reproducibility import build_reproducibility_manifest_fields + + +class TestPaperEvalImports(unittest.TestCase): + def test_baselines_and_protocol_import(self): + import experiments.evaluate_paper_baselines as baselines + import experiments.evaluate_paper_protocol as protocol + + self.assertTrue(callable(baselines.evaluate_adbscan)) + self.assertTrue(callable(protocol.load_model_from_run)) + self.assertTrue(callable(protocol.build_split_loader)) + + def test_public_paper_configs_pass_contract(self): + assert_paper_no_cls_contract( + load_config("elongate_n100_no_cls_full120_teacher_local_pca") + ) + assert_paper_no_cls_contract( + load_config("elongate_n100_no_cls_tune_local_pca_ellphi_power") + ) + assert_paper_no_cls_contract( + load_config( + "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier" + ) + ) + + def test_paper_eval_requires_best_model_only(self): + with tempfile.TemporaryDirectory() as tmp: + run_dir = Path(tmp) + (run_dir / "logs").mkdir() + with self.assertRaises(FileNotFoundError): + preflight_paper_eval_run_dir(run_dir) + # Wrong name must not satisfy paper eval. + torch.save({"model_state_dict": {}, "selection_metric_value": 1.0}, run_dir / "final_model.pth") + with self.assertRaises(FileNotFoundError): + resolve_val_topo_checkpoint(run_dir) + + def test_manifest_records_zero_pad_constant(self): + cfg = load_config("elongate_n100_no_cls_full120_teacher_local_pca") + fields = build_reproducibility_manifest_fields(cfg) + self.assertIn("ZERO_PAD_ABS_SUM", fields["numerical_constants"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 4238ebd..48ee044 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -2,7 +2,7 @@ import numpy as np -from tda_ml.persistence import compute_bottleneck_distance, compute_w_distance +from tda_ml.persistence import compute_w_distance class TestPersistenceWasserstein(unittest.TestCase): @@ -25,12 +25,6 @@ def test_empty_gt_symmetric_with_empty_pred(self): def test_both_empty_point_clouds(self): self.assertEqual(compute_w_distance([], []), 0.0) - def test_bottleneck_empty_pred_not_sentinel(self): - circle = self._circle_points() - b = compute_bottleneck_distance([], circle) - self.assertTrue(np.isfinite(b)) - self.assertLess(b, 10.0) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_persistence_dimensions.py b/tests/test_persistence_dimensions.py index 98ec93c..317421e 100644 --- a/tests/test_persistence_dimensions.py +++ b/tests/test_persistence_dimensions.py @@ -76,13 +76,49 @@ def test_topo_wdist_options_read_h1_only(self): "prob_weighting": False, } }, - "loss": {"teacher_mode": "local_pca"}, + "loss": { + "teacher_mode": "local_pca", + "teacher_local_pca_k": 10, + "teacher_local_pca_normalize_axes": True, + "topo_eps_scale": 1.0, + "topo_scale_mode": "fixed", + }, } opts = topo_wdist_options_from_config(cfg) self.assertEqual(opts.homology_dimensions, (1,)) self.assertEqual(opts.teacher_mode, "local_pca") self.assertFalse(opts.prob_weighting) + def test_topo_wdist_options_hard_fail_on_missing_method_fields(self): + base = { + "model": { + "topology_loss": { + "homology_dimensions": [1], + "distance_backend": "mahalanobis", + "prob_weighting": False, + } + }, + "loss": { + "teacher_mode": "local_pca", + "teacher_local_pca_k": 10, + "teacher_local_pca_normalize_axes": True, + "topo_eps_scale": 1.0, + "topo_scale_mode": "fixed", + }, + } + for key in ( + "topo_eps_scale", + "topo_scale_mode", + "teacher_local_pca_k", + "teacher_local_pca_normalize_axes", + ): + cfg = { + "model": {"topology_loss": dict(base["model"]["topology_loss"])}, + "loss": {k: v for k, v in base["loss"].items() if k != key}, + } + with self.assertRaisesRegex(ValueError, key): + topo_wdist_options_from_config(cfg) + def test_topological_loss_runs_with_h1_only(self): theta = torch.linspace(0.0, 2.0 * torch.pi, 9)[:-1] points = torch.stack((torch.cos(theta), torch.sin(theta)), dim=1).unsqueeze(0) diff --git a/tests/test_reproducibility_strict.py b/tests/test_reproducibility_strict.py index d5d55b8..c9d6bd3 100644 --- a/tests/test_reproducibility_strict.py +++ b/tests/test_reproducibility_strict.py @@ -95,11 +95,25 @@ def test_classify_tune_objective(self): objective_kind="mcc", ) - def test_selection_default_is_val_topo(self): + def test_selection_requires_explicit_metric_and_backend(self): from tda_ml.model_selection import selection_settings_from_config - settings = selection_settings_from_config({}) + with self.assertRaisesRegex(ValueError, "training must be an explicit mapping"): + selection_settings_from_config({}) + with self.assertRaisesRegex(ValueError, "training.selection"): + selection_settings_from_config({"training": {}}) + with self.assertRaisesRegex(ValueError, "distance_backend"): + selection_settings_from_config( + {"training": {"selection": {"metric": "val_topo"}}} + ) + settings = selection_settings_from_config( + { + "training": {"selection": {"metric": "val_topo"}}, + "model": {"topology_loss": {"distance_backend": "ellphi"}}, + } + ) self.assertEqual(settings.metric, "val_topo") + self.assertEqual(settings.backend, "ellphi") class TestPreflightTuneJson(unittest.TestCase): diff --git a/tests/test_trainer.py b/tests/test_trainer.py index a65fd58..1f4a077 100644 --- a/tests/test_trainer.py +++ b/tests/test_trainer.py @@ -37,7 +37,8 @@ def setUp(self): 'lambda_topo': 0.1, 'lambda_aniso': 0.01, 'grad_clip_value': 1.0, - 'visualize_every': 10 + 'visualize_every': 10, + 'selection': {'metric': 'val_topo'}, }, 'loss': { 'w_class': 1.0, @@ -49,12 +50,15 @@ def setUp(self): 'size_mode': 'quadratic', 'size_ref': 1.34, 'size_power': 1.5, + 'topo_eps_scale': 1.0, + 'topo_scale_mode': 'fixed', }, 'model': { 'topology_loss': { 'distance_backend': 'mahalanobis', 'homology_dimensions': [0, 1], 'prob_weighting': True, + 'ellphi_differentiable': True, } }, 'outputs': { @@ -108,6 +112,8 @@ def test_w_class_zero_skips_classification_gradient(self): "size_mode": "quadratic", "size_ref": 1.34, "size_power": 1.5, + "topo_eps_scale": 1.0, + "topo_scale_mode": "fixed", } self.config["model"]["topology_loss"]["prob_weighting"] = False trainer = Trainer(self.model, self.config, self.device) diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index 58c96fb..993b592 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -25,7 +25,7 @@ def test_baseline_declares_topology_contract(self): def test_canonical_power_tune_base(self): cfg = build_wdist_trial( - "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", + "elongate_n100_no_cls_tune_local_pca_ellphi_power", w_aniso=0.1, w_size=0.2, w_topo=0.3, @@ -46,7 +46,7 @@ def test_canonical_power_tune_base(self): def test_power_tune_preserves_h1_hard_fail_stack(self): cfg = build_wdist_trial( - "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", + "elongate_n100_no_cls_tune_local_pca_ellphi_power", w_aniso=0.1, w_size=0.2, w_topo=0.3, @@ -68,7 +68,7 @@ def test_wdist_builder_overrides_divergent_yaml_homology_and_aniso(self): from tda_ml.config import load_config divergent = load_config( - "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", + "elongate_n100_no_cls_tune_local_pca_ellphi_power", project_root=REPO, ) divergent["model"]["topology_loss"]["homology_dimensions"] = [0, 1] @@ -133,7 +133,7 @@ def test_wdist_builder_mirrors_barrier_variant_from_base_config(self): def test_mcc_builder_forces_homology_h1(self): cfg = build_mcc_trial( - "elongate_n100_no_cls_tune_local_pca_ellphi_power_mcc", + "elongate_n100_no_cls_tune_local_pca_ellphi_power", w_aniso=0.1, w_size=0.2, w_topo=0.3, diff --git a/uv.lock b/uv.lock index 0eb9662..a6ddf5a 100644 --- a/uv.lock +++ b/uv.lock @@ -140,15 +140,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "cached-property" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/4b/3d870836119dbe9a5e3c9a61af8cc1a8b69d75aea564572e385882d5aefb/cached_property-2.0.1.tar.gz", hash = "sha256:484d617105e3ee0e4f1f58725e72a8ef9e93deee462222dbd51cd91230897641", size = 10574, upload-time = "2024-10-25T15:43:55.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/0e/7d8225aab3bc1a0f5811f8e1b557aa034ac04bdf641925b30d3caf586b28/cached_property-2.0.1-py3-none-any.whl", hash = "sha256:f617d70ab1100b7bcf6e42228f9ddcb78c676ffa167278d9f730d1c2fba69ccb", size = 7428, upload-time = "2024-10-25T15:43:54.711Z" }, -] - [[package]] name = "certifi" version = "2026.4.22" @@ -396,81 +387,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] -[[package]] -name = "cyclopts" -version = "4.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/f7/3ee212c1bc314551094fc8fda7b4b63c647ac5c32d06daa285d04d33edfc/cyclopts-4.11.2.tar.gz", hash = "sha256:8c9b77921660fa1ee52c150e2217ced672323efb3434e9b338077de1bc551ff4", size = 175935, upload-time = "2026-05-04T00:11:57.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/4cedda786e7da429e7489549a9e5461530d4133130e541f25fb94f015776/cyclopts-4.11.2-py3-none-any.whl", hash = "sha256:838020120b939549ff7c8423aca29c86764b5dd1d8a5d7f3753a6327861f537b", size = 213537, upload-time = "2026-05-04T00:11:56.103Z" }, -] - -[[package]] -name = "cython" -version = "3.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/85/7574c9cd44b69a27210444b6650f6477f56c75fee1b70d7672d3e4166167/cython-3.2.4.tar.gz", hash = "sha256:84226ecd313b233da27dc2eb3601b4f222b8209c3a7216d8733b031da1dc64e6", size = 3280291, upload-time = "2026-01-04T14:14:14.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/4d/1eb0c7c196a136b1926f4d7f0492a96c6fabd604d77e6cd43b56a3a16d83/cython-3.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64d7f71be3dd6d6d4a4c575bb3a4674ea06d1e1e5e4cd1b9882a2bc40ed3c4c9", size = 2970064, upload-time = "2026-01-04T14:15:08.567Z" }, - { url = "https://files.pythonhosted.org/packages/03/1c/46e34b08bea19a1cdd1e938a4c123e6299241074642db9d81983cef95e9f/cython-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:869487ea41d004f8b92171f42271fbfadb1ec03bede3158705d16cd570d6b891", size = 3226757, upload-time = "2026-01-04T14:15:10.812Z" }, - { url = "https://files.pythonhosted.org/packages/12/33/3298a44d201c45bcf0d769659725ae70e9c6c42adf8032f6d89c8241098d/cython-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55b6c44cd30821f0b25220ceba6fe636ede48981d2a41b9bbfe3c7902ce44ea7", size = 3388969, upload-time = "2026-01-04T14:15:12.45Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f3/4275cd3ea0a4cf4606f9b92e7f8766478192010b95a7f516d1b7cf22cb10/cython-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:767b143704bdd08a563153448955935844e53b852e54afdc552b43902ed1e235", size = 2756457, upload-time = "2026-01-04T14:15:14.67Z" }, - { url = "https://files.pythonhosted.org/packages/18/b5/1cfca43b7d20a0fdb1eac67313d6bb6b18d18897f82dd0f17436bdd2ba7f/cython-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28e8075087a59756f2d059273184b8b639fe0f16cf17470bd91c39921bc154e0", size = 2960506, upload-time = "2026-01-04T14:15:16.733Z" }, - { url = "https://files.pythonhosted.org/packages/71/bb/8f28c39c342621047fea349a82fac712a5e2b37546d2f737bbde48d5143d/cython-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03893c88299a2c868bb741ba6513357acd104e7c42265809fd58dce1456a36fc", size = 3213148, upload-time = "2026-01-04T14:15:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d2/16fa02f129ed2b627e88d9d9ebd5ade3eeb66392ae5ba85b259d2d52b047/cython-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f81eda419b5ada7b197bbc3c5f4494090e3884521ffd75a3876c93fbf66c9ca8", size = 3375764, upload-time = "2026-01-04T14:15:20.817Z" }, - { url = "https://files.pythonhosted.org/packages/91/3f/deb8f023a5c10c0649eb81332a58c180fad27c7533bb4aae138b5bc34d92/cython-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:83266c356c13c68ffe658b4905279c993d8a5337bb0160fa90c8a3e297ea9a2e", size = 2754238, upload-time = "2026-01-04T14:15:23.001Z" }, - { url = "https://files.pythonhosted.org/packages/ee/d7/3bda3efce0c5c6ce79cc21285dbe6f60369c20364e112f5a506ee8a1b067/cython-3.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4b4fd5332ab093131fa6172e8362f16adef3eac3179fd24bbdc392531cb82fa", size = 2971496, upload-time = "2026-01-04T14:15:25.038Z" }, - { url = "https://files.pythonhosted.org/packages/89/ed/1021ffc80b9c4720b7ba869aea8422c82c84245ef117ebe47a556bdc00c3/cython-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3b5ac54e95f034bc7fb07313996d27cbf71abc17b229b186c1540942d2dc28e", size = 3256146, upload-time = "2026-01-04T14:15:26.741Z" }, - { url = "https://files.pythonhosted.org/packages/0c/51/ca221ec7e94b3c5dc4138dcdcbd41178df1729c1e88c5dfb25f9d30ba3da/cython-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f43be4eaa6afd58ce20d970bb1657a3627c44e1760630b82aa256ba74b4acb", size = 3383458, upload-time = "2026-01-04T14:15:28.425Z" }, - { url = "https://files.pythonhosted.org/packages/79/2e/1388fc0243240cd54994bb74f26aaaf3b2e22f89d3a2cf8da06d75d46ca2/cython-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:983f9d2bb8a896e16fa68f2b37866ded35fa980195eefe62f764ddc5f9f5ef8e", size = 2791241, upload-time = "2026-01-04T14:15:30.448Z" }, - { url = "https://files.pythonhosted.org/packages/0a/8b/fd393f0923c82be4ec0db712fffb2ff0a7a131707b842c99bf24b549274d/cython-3.2.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36bf3f5eb56d5281aafabecbaa6ed288bc11db87547bba4e1e52943ae6961ccf", size = 2875622, upload-time = "2026-01-04T14:15:39.749Z" }, - { url = "https://files.pythonhosted.org/packages/73/48/48530d9b9d64ec11dbe0dd3178a5fe1e0b27977c1054ecffb82be81e9b6a/cython-3.2.4-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6d5267f22b6451eb1e2e1b88f6f78a2c9c8733a6ddefd4520d3968d26b824581", size = 3210669, upload-time = "2026-01-04T14:15:41.911Z" }, - { url = "https://files.pythonhosted.org/packages/5e/91/4865fbfef1f6bb4f21d79c46104a53d1a3fa4348286237e15eafb26e0828/cython-3.2.4-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b6e58f73a69230218d5381817850ce6d0da5bb7e87eb7d528c7027cbba40b06", size = 2856835, upload-time = "2026-01-04T14:15:43.815Z" }, - { url = "https://files.pythonhosted.org/packages/fa/39/60317957dbef179572398253f29d28f75f94ab82d6d39ea3237fb6c89268/cython-3.2.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e71efb20048358a6b8ec604a0532961c50c067b5e63e345e2e359fff72feaee8", size = 2994408, upload-time = "2026-01-04T14:15:45.422Z" }, - { url = "https://files.pythonhosted.org/packages/8d/30/7c24d9292650db4abebce98abc9b49c820d40fa7c87921c0a84c32f4efe7/cython-3.2.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:28b1e363b024c4b8dcf52ff68125e635cb9cb4b0ba997d628f25e32543a71103", size = 2891478, upload-time = "2026-01-04T14:15:47.394Z" }, - { url = "https://files.pythonhosted.org/packages/86/70/03dc3c962cde9da37a93cca8360e576f904d5f9beecfc9d70b1f820d2e5f/cython-3.2.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:31a90b4a2c47bb6d56baeb926948348ec968e932c1ae2c53239164e3e8880ccf", size = 3225663, upload-time = "2026-01-04T14:15:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/b1/97/10b50c38313c37b1300325e2e53f48ea9a2c078a85c0c9572057135e31d5/cython-3.2.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e65e4773021f8dc8532010b4fbebe782c77f9a0817e93886e518c93bd6a44e9d", size = 3115628, upload-time = "2026-01-04T14:15:51.323Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b1/d6a353c9b147848122a0db370863601fdf56de2d983b5c4a6a11e6ee3cd7/cython-3.2.4-cp39-abi3-win32.whl", hash = "sha256:2b1f12c0e4798293d2754e73cd6f35fa5bbdf072bdc14bc6fc442c059ef2d290", size = 2437463, upload-time = "2026-01-04T14:15:53.787Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d8/319a1263b9c33b71343adfd407e5daffd453daef47ebc7b642820a8b68ed/cython-3.2.4-cp39-abi3-win_arm64.whl", hash = "sha256:3b8e62049afef9da931d55de82d8f46c9a147313b69d5ff6af6e9121d545ce7a", size = 2442754, upload-time = "2026-01-04T14:15:55.382Z" }, - { url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" }, -] - -[[package]] -name = "deprecated" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, -] - -[[package]] -name = "docstring-parser" -version = "0.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, -] - -[[package]] -name = "docutils" -version = "0.22.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, -] - [[package]] name = "ellphi" version = "0.1.2" @@ -717,39 +633,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/b7/848d79fc52fccbb68f5f0fc7b94e3df43e5ca4f0a7c20d9d03ca48e362c5/gudhi-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e44394a355a3cd159a28720be0e96ced69e1b48c74ba3e78cf35a293422dfc5e", size = 3889224, upload-time = "2026-03-30T18:12:34.051Z" }, ] -[[package]] -name = "homcloud" -version = "5.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cached-property" }, - { name = "cython" }, - { name = "imageio" }, - { name = "matplotlib" }, - { name = "msgpack" }, - { name = "numpy" }, - { name = "pillow" }, - { name = "plotly" }, - { name = "pulp" }, - { name = "pyvista" }, - { name = "ripser" }, - { name = "scikit-learn" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/83/8ba69017c76a652442d63b4b265f43af6327c6a6be9cd27b21b59c6401d7/homcloud-5.3.0.tar.gz", hash = "sha256:2ecf9a2b5e0ad9866793c7223dee613ea813cc213f01c3f75de7f1ac7e62e239", size = 46389373, upload-time = "2026-03-25T09:02:43.539Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/29/0310020afdaac14e4135e23d964cf68a37514e45fe284a5dfd65a56771ef/homcloud-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1db8f69fb532ce636472d019b549b90e96911bdaf747d0b5c0c78a99af224d23", size = 27631033, upload-time = "2026-03-25T09:03:49.182Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/1e2392073b9cb2c067bd929807d2a7d9964624036418934ec57112f2dd35/homcloud-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f7c204f388f99ccbf320dd36826f8326c500df0fd8086b73a71a1c41bd550c7", size = 1303723, upload-time = "2026-03-25T09:03:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/53/34/cf7bed0b313cff02ef5e092abddd820511b13bec1edc203fa418651721ab/homcloud-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:22ebe2928f3343f2bf19eaf2003f52b7266babbb4f778a83189ad23250b7800d", size = 27669452, upload-time = "2026-03-25T09:04:06.089Z" }, - { url = "https://files.pythonhosted.org/packages/11/41/3f102873dc4f471256c81a7ae2a738ec08959636a0a618360e1167d84f8d/homcloud-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:417d0655b4f8bd431f44ce72a2d83558e885a38b68ccfbef655e65dfd17564bf", size = 1303656, upload-time = "2026-03-25T09:03:05.71Z" }, -] - -[[package]] -name = "hopcroftkarp" -version = "1.2.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/56/7b03eba3c43008c490c9d52e69ea5334b65955f66836eb4f1962f3b0d421/hopcroftkarp-1.2.5.tar.gz", hash = "sha256:28a7887db81ad995ccd36a1b5164a4c542b16d2781e8c49334dc9d141968c0e7", size = 16856, upload-time = "2019-10-11T13:36:15.736Z" } - [[package]] name = "idna" version = "3.13" @@ -912,18 +795,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -1041,15 +912,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, ] -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - [[package]] name = "mpmath" version = "1.3.0" @@ -1059,50 +921,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] -[[package]] -name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, -] - [[package]] name = "multidict" version = "6.7.1" @@ -1202,15 +1020,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] -[[package]] -name = "narwhals" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/f3/257adc69a71011b4c8cda321b00f02c5bf1980ae38ffd05a58d9632d4de8/narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e", size = 627848, upload-time = "2026-04-20T12:11:45.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" }, -] - [[package]] name = "networkx" version = "3.6.1" @@ -1521,23 +1330,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, ] -[[package]] -name = "persim" -version = "0.3.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "hopcroftkarp" }, - { name = "joblib" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "scikit-learn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/6d/fd280ba8d48cc636406b3342f6d91ad65f1c6cb5f5c07d203d764eccc41a/persim-0.3.8.tar.gz", hash = "sha256:e13d18584176b8a764d16d7e56df08dcc8ba66c6b3257a072345be3bd59ba40d", size = 51514, upload-time = "2025-03-12T17:07:15.797Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/41/9bd99ddfb4741d6dd2857fda7f6d71f560731f4057e69b98840778d10da1/persim-0.3.8-py3-none-any.whl", hash = "sha256:be022cef7f91d03b1ee81deee4d5c863ca1ba0d60cae804b5043aa55855bcc80", size = 48614, upload-time = "2025-03-12T17:07:14.043Z" }, -] - [[package]] name = "pillow" version = "12.2.0" @@ -1607,28 +1399,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] -[[package]] -name = "platformdirs" -version = "4.9.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, -] - -[[package]] -name = "plotly" -version = "6.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "narwhals" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -1638,20 +1408,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "pooch" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "platformdirs" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, -] - [[package]] name = "pot" version = "0.9.6.post1" @@ -1790,15 +1546,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] -[[package]] -name = "pulp" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/1c/d880b739b841a8aa81143091c9bdda5e72e226a660aa13178cb312d4b27f/pulp-3.3.0.tar.gz", hash = "sha256:7eb99b9ce7beeb8bbb7ea9d1c919f02f003ab7867e0d1e322f2f2c26dd31c8ba", size = 16301847, upload-time = "2025-09-18T08:14:57.552Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/6c/64cafaceea3f99927e84b38a362ec6a8f24f33061c90bda77dfe1cd4c3c6/pulp-3.3.0-py3-none-any.whl", hash = "sha256:dd6ad2d63f196d1254eddf9dcff5cd224912c1f046120cb7c143c5b0eda63fae", size = 16387700, upload-time = "2025-09-18T08:14:53.368Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1854,25 +1601,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] -[[package]] -name = "pyvista" -version = "0.48.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cyclopts" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "pillow" }, - { name = "pooch" }, - { name = "scooby" }, - { name = "typing-extensions" }, - { name = "vtk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/24/a8bf4deb6521d4a26bdfe1262e2e942aaf49b866f8dc8395702d789e5089/pyvista-0.48.0.tar.gz", hash = "sha256:317c1590fce0d3777c34d7c2b4686dc52701a2ba03ab0a312bbd0bdae03f0d42", size = 2580723, upload-time = "2026-05-02T22:49:01.23Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/e8/d057b011aee8df7fdb00e5e8f777cea160f2e97f65796bff7c81b85e398d/pyvista-0.48.0-py3-none-any.whl", hash = "sha256:cb6fe80d4a1a7e9ef512b9b555d8b16355ea97b07799fc4f73f2897ca0109e09", size = 2628237, upload-time = "2026-05-02T22:48:58.867Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -1934,79 +1662,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] -[[package]] -name = "rich" -version = "15.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, -] - -[[package]] -name = "rich-rst" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, -] - -[[package]] -name = "ripser" -version = "0.6.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cython" }, - { name = "numpy" }, - { name = "persim" }, - { name = "scikit-learn" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/b7/20bc656af04f289a24873367571d8524b2bd8bb366894ab60041dfd52273/ripser-0.6.14.tar.gz", hash = "sha256:95dcc16e4577090f0ae8abd6b2665892aaed0fdfaacbdcfcc277e20e30860863", size = 112565, upload-time = "2025-12-08T03:42:19.374Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/47/5a48d4ea36ec9c4f70d0df59f27b64da005d3f8a9f35d4ed526e019561a5/ripser-0.6.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:62abfbf3a03dac0e9eda9015ed018eec48722d694c42af4d8e3b253dee7662b7", size = 175467, upload-time = "2025-12-08T03:41:15.045Z" }, - { url = "https://files.pythonhosted.org/packages/99/89/3a65dd5fbe41b93a2285aed3147f2c90b7d5ccb2c1b1673e61be97e4ca0c/ripser-0.6.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2740d387207b97d5662a10c826983437c1c8e5660e06df3a49821be3933cb50f", size = 170041, upload-time = "2025-12-08T03:41:16.19Z" }, - { url = "https://files.pythonhosted.org/packages/46/35/b3843e835408e7f98dbe220c2c3691f820a91c2f056e8e2af55d1b095c52/ripser-0.6.14-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38f96c5aa0e71ac02d6060c9bd86ed0b553f319a9f50d43cae4d268005a8564c", size = 832760, upload-time = "2025-12-08T03:41:17.717Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3e/47bb59869ef942f8abaa0ef29820f30ec3b8690e120590ee1dac67b76493/ripser-0.6.14-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c757a2c130f686ebd8ac5f02fc18326234a96ea26f3115ee231d77de631785c", size = 842087, upload-time = "2025-12-08T03:41:19.031Z" }, - { url = "https://files.pythonhosted.org/packages/79/6b/ba80ef2ff6ee14681bad90db2a439bfcab5a14dba2c6ec3681eda6ed6038/ripser-0.6.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6215698fb29bbde4393565cc75f60dad97fd1506f13b7620d2c6662c46fc85a", size = 1790706, upload-time = "2025-12-08T03:41:20.305Z" }, - { url = "https://files.pythonhosted.org/packages/c0/92/7a371410711fa3e72c6bdddf378ba40f13da5122f9a7686385d7aa56503e/ripser-0.6.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5dff01737061f4318ad4f7e8d2f4bdf17c6711f3ef75c3bc1c83546edcc913ad", size = 1859722, upload-time = "2025-12-08T03:41:21.68Z" }, - { url = "https://files.pythonhosted.org/packages/c9/73/1434be0f98436df2ebe73ed24cfadc84b50a40af7919145e3207bae4ed1f/ripser-0.6.14-cp312-cp312-win32.whl", hash = "sha256:fd132d12e92b63a614fa20cad32d59b6576748f3384fa021b18f733d3c192a93", size = 157694, upload-time = "2025-12-08T03:41:23.248Z" }, - { url = "https://files.pythonhosted.org/packages/dd/09/5ffadf3b2355e21a5aa02c1e7aa4f291d084db5c6d8112ac3b3c877a3fa3/ripser-0.6.14-cp312-cp312-win_amd64.whl", hash = "sha256:c5e02617921a5503420d23648134a9e4baa70636259748dd9779168804b4110e", size = 165385, upload-time = "2025-12-08T03:41:24.379Z" }, - { url = "https://files.pythonhosted.org/packages/62/d3/79cdd1190543c2b3a6f6ec6d0e0d5f2a2fb8988cce037e65d117f96933cd/ripser-0.6.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56c6dea6ca370991c14dfa4b3889ed12c8f921c74a9e220aa4fe57c2dc681f9e", size = 175394, upload-time = "2025-12-08T03:41:25.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f7/977e790a79b57b71b779e9d56fe37e2e7a72c2ec1b308e70255fbdbe8a9d/ripser-0.6.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c1fdc8a20bb99ebb76923907ab0397c7050241a51febc96b4cb8be58ca59a2db", size = 169998, upload-time = "2025-12-08T03:41:27.079Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f1/f2984f15c4b375e971af409bb3573943557e8af45b41db3f3aac075f7953/ripser-0.6.14-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8fd474f789bc1db04753c40efc385229f204363f30b2adad69f8c782276b3e9", size = 832422, upload-time = "2025-12-08T03:41:28.534Z" }, - { url = "https://files.pythonhosted.org/packages/69/f7/2463200eeb7a7f08c0208174a848c22bb70c5afa1e687fd01ebba35f7ee6/ripser-0.6.14-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1f9392fa2481b74a99d422c363f68c598a30e41b24968a4f0a3f3f576ac4001", size = 839669, upload-time = "2025-12-08T03:41:29.961Z" }, - { url = "https://files.pythonhosted.org/packages/9c/00/8eefd2de767fd5699c0407e6d7605e3c6e1c27505c06c4df43435201efcd/ripser-0.6.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5512844d5b3fd1b1c2a255b148224d8741cc65d0586f374c22d26ccb4e3069e1", size = 1790515, upload-time = "2025-12-08T03:41:31.851Z" }, - { url = "https://files.pythonhosted.org/packages/55/33/56d930dc55fd316096530135698ae1a4685f248b153c596dfee8056feb80/ripser-0.6.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c6ab18241c0ec4080f9330494abf8a4493a8f3edd88de6e05067e92e7e5c9793", size = 1856691, upload-time = "2025-12-08T03:41:33.187Z" }, - { url = "https://files.pythonhosted.org/packages/96/28/0075e23e9bb8feca9fdbceeb722059a50184d8afa4211f7c43cd5600a6f9/ripser-0.6.14-cp313-cp313-win32.whl", hash = "sha256:be27ff2ee5378ca1cc25d8d73c7c97341acd179dadf6a71da84401689455a723", size = 157657, upload-time = "2025-12-08T03:41:34.82Z" }, - { url = "https://files.pythonhosted.org/packages/bb/fc/4be597c49e65e0dc866ec2272ba5d3257400ccdaaa9f1044e8fe68c8b18a/ripser-0.6.14-cp313-cp313-win_amd64.whl", hash = "sha256:d829c377fa0e7195a03cdc811b0f3e77591a70efd4820359070fe4a5153caadb", size = 165383, upload-time = "2025-12-08T03:41:35.972Z" }, - { url = "https://files.pythonhosted.org/packages/89/0d/d4467a968f49936f341292c245b925e62c7a175bf1dce0764e198e898995/ripser-0.6.14-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9cfb8c50bd6aaea8d0baf3a612d5cd3d68660c091f7e533cb1f012241b0c43ce", size = 176331, upload-time = "2025-12-08T03:41:37.16Z" }, - { url = "https://files.pythonhosted.org/packages/7f/aa/27238dbf60c6fab0b883ae37239464794b0dfc1e07a08f456e0fb11a3ca2/ripser-0.6.14-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6a757674fb8a10221e4728efade521a9e07f3b8dbca3b00ef7e1b2a9e4144334", size = 171019, upload-time = "2025-12-08T03:41:38.257Z" }, - { url = "https://files.pythonhosted.org/packages/a8/be/ebb02751b441ec8ee2e06e362c004026e9e76502a6b57f672adf8e5195b1/ripser-0.6.14-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eeb947c72d2ce530de60a8ac2f91cc77fdf47d97efafa0a2b2e5844963b14138", size = 831275, upload-time = "2025-12-08T03:41:39.416Z" }, - { url = "https://files.pythonhosted.org/packages/6a/51/c1f1f07aad40670deaa1c314fe991273e1412cc1d2757d556816639ebb55/ripser-0.6.14-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18dce133791a3967f2ca044142432b6698d05c0daf724c09bb7db42f111eef89", size = 839039, upload-time = "2025-12-08T03:41:40.7Z" }, - { url = "https://files.pythonhosted.org/packages/ad/69/9d8cd38f8da5f62aeea41d10a66aaa8f22bf5ee19b1247a26b782458664a/ripser-0.6.14-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d784449191636115d442e56192dd0112e607d1b41cf49089d9a1771bbfc574ae", size = 1790475, upload-time = "2025-12-08T03:41:42.348Z" }, - { url = "https://files.pythonhosted.org/packages/15/b4/e187b0c0cd6a22fde45e3b86480ecb16efe2c643a82f1f9b95d6a41434e7/ripser-0.6.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a37e0b18f775c8873d67ed5dc3482c772b9cea111c0a65a9bdac4d17f87a08", size = 1856549, upload-time = "2025-12-08T03:41:43.701Z" }, - { url = "https://files.pythonhosted.org/packages/b7/df/806d58f379126146385fd1c1e765efbeb4f549494eb915f97924cf6717c0/ripser-0.6.14-cp314-cp314-win32.whl", hash = "sha256:bccdc7b0115b4fcee9cd403795609d3860219994b41ec0b767cba8a13e85ea06", size = 160390, upload-time = "2025-12-08T03:41:45.117Z" }, - { url = "https://files.pythonhosted.org/packages/4d/ef/8c4423d53bcd3af41527f191e16b78e1188d9764615dcbf48ffe8974c15d/ripser-0.6.14-cp314-cp314-win_amd64.whl", hash = "sha256:4c813e07c63436829c32a6fa80e3809676a27e36b4b94d1ebe1ee32e9af71f9e", size = 167872, upload-time = "2025-12-08T03:41:46.451Z" }, - { url = "https://files.pythonhosted.org/packages/67/81/3e4e2793100f5b526c44c68ea80f90461f7b4ead63b6337cb75733f73349/ripser-0.6.14-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4b08896dcbdf75d028842585e69d2b8721490e99fe9b27c79e776532b69677d5", size = 178603, upload-time = "2025-12-08T03:41:47.522Z" }, - { url = "https://files.pythonhosted.org/packages/c3/68/fccf2bd0f8771ffd9f5948788e9abae4c2a32705d21967c316c300f30312/ripser-0.6.14-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efd92bc50d32c8a65b8eeb6e826637eca82b87dd26b27bd8c0f4bed5d84352d8", size = 174066, upload-time = "2025-12-08T03:41:48.659Z" }, - { url = "https://files.pythonhosted.org/packages/f7/88/f409ae0758f2b1ffd0036b1081e6285be115d9a658f0de7a6b0e17aabbe9/ripser-0.6.14-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdeee3f93adfc14ed3b7e9104d6084f83db725dca574aa98deea41f5b5aff0d2", size = 843110, upload-time = "2025-12-08T03:41:49.871Z" }, - { url = "https://files.pythonhosted.org/packages/95/c6/1d11c3111ee6e9c092d69d3cee163314e8c82f535ffffd2dcc14f91378b0/ripser-0.6.14-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f12913d4a415e7e5e28ada39868d125c39fd432ab3f610fd9e8c426a4e2a0eba", size = 846794, upload-time = "2025-12-08T03:41:51.195Z" }, - { url = "https://files.pythonhosted.org/packages/93/b0/499b26d9bc2779dd4ab842cb4c16d6ca205a8615d78227a6108fdfbb7a52/ripser-0.6.14-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df20462b3192c3844a3ce5fffdc9fc732eb4c8651d820b6f376195c2a5bfc220", size = 1804230, upload-time = "2025-12-08T03:41:52.836Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a8/7e0eab946e0f9f055df552a73c61df083cc94f7a4412635eba86af5acc42/ripser-0.6.14-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d51622f6961c93a0028f220cdb44517783186cfc55f63765291aa4959082f731", size = 1864726, upload-time = "2025-12-08T03:41:54.201Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/20dbf9253a2936677378ec20d85ed456bfadaafd72c3718fd4062509e126/ripser-0.6.14-cp314-cp314t-win32.whl", hash = "sha256:5fb9a2977408399fca916b1f18d2c882fd1b6b9b508db4d96be61deac280d259", size = 163291, upload-time = "2025-12-08T03:41:55.629Z" }, - { url = "https://files.pythonhosted.org/packages/d3/7a/89aca4402b44dc32ebadb3a38c217654113b906059778acd1b82405be2ee/ripser-0.6.14-cp314-cp314t-win_amd64.whl", hash = "sha256:d5168a48f727bdcb99d92fb834321cc2957e8e74c448e7fe47064a625c2e703b", size = 171864, upload-time = "2025-12-08T03:41:57.097Z" }, -] - [[package]] name = "ruff" version = "0.15.12" @@ -2195,15 +1850,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] -[[package]] -name = "scooby" -version = "0.11.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/06/9a8600207fd72a29ee965e9a4c61b750cc3fa106768f14a7b3ee3e36cb61/scooby-0.11.2.tar.gz", hash = "sha256:0575c73636ec4c2587bea1f8a038798ddcb249e02067fae897dac3bf4f4e444d", size = 242928, upload-time = "2026-04-22T23:13:12.307Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/bc/1173f502f1870e3bae81c148326c5cbcc19ec77df79a9aaf17a59911355c/scooby-0.11.2-py3-none-any.whl", hash = "sha256:f34c36bbee749b2c55816a080521f216d88304e635017e911c12249607d38c49", size = 20142, upload-time = "2026-04-22T23:13:10.705Z" }, -] - [[package]] name = "seaborn" version = "0.13.2" @@ -2325,9 +1971,6 @@ experiments = [ images = [ { name = "pillow" }, ] -repro-pd-animation = [ - { name = "homcloud" }, -] [package.dev-dependencies] dev = [ @@ -2340,7 +1983,6 @@ dev = [ requires-dist = [ { name = "ellphi", editable = "ellphi_repo" }, { name = "gudhi", specifier = ">=3.4" }, - { name = "homcloud", marker = "extra == 'repro-pd-animation'", specifier = ">=4.8" }, { name = "joblib", marker = "extra == 'experiments'", specifier = ">=1.3" }, { name = "matplotlib", specifier = ">=3.8" }, { name = "numpy", specifier = "<2" }, @@ -2357,7 +1999,7 @@ requires-dist = [ { name = "torchvision", specifier = ">=0.15" }, { name = "tqdm", specifier = ">=4.65" }, ] -provides-extras = ["experiments", "repro-pd-animation", "images"] +provides-extras = ["experiments", "images"] [package.metadata.requires-dev] dev = [ @@ -2569,97 +2211,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] -[[package]] -name = "vtk" -version = "9.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "matplotlib" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/89/c274101ec7b9bf7356333fdacf5e634803fe6b40f776e82c6ce9d941e0ad/vtk-9.6.1-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:b8125e3e3bc3160e18853a15be98101d0efe662c16036179ab15ddf1669b32af", size = 114729308, upload-time = "2026-03-27T13:50:37.547Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1a/ecbebaf31724a00f85fc4dbf95992b507328f615362ee8fa5ea1a38cf9d6/vtk-9.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:956d05b8c53c6a9eba569de244e9c8229815bbb3e024bb9954fafe163407e66d", size = 106814956, upload-time = "2026-03-27T13:51:24.324Z" }, - { url = "https://files.pythonhosted.org/packages/46/66/ba3c8b277cfa8058e982bfbd47875d9c6b4c06e65f98d577c69a2628f8d4/vtk-9.6.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9728e8d41889a0f105b5d20a73a4da80f398b2cfe6057fa7a94cd61128c3ceb4", size = 145920093, upload-time = "2026-03-27T13:53:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/f5/cb/0bbf91cd45a8d8f5453fe01cddf44c913db6316b3a2b15f41893ae0ca9ad/vtk-9.6.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3b5ec2e56bd6165189aa2e6e896edda29460e63040f897e1a123a1592810266d", size = 135683842, upload-time = "2026-03-27T13:52:15.218Z" }, - { url = "https://files.pythonhosted.org/packages/08/c0/653c94939498a3976157f054b830ade5c1da48ae288a23547f55fc25a262/vtk-9.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:4022fda8af46636f74c3c1932c2365da13a1dc8779a6b1ea4b13dc5bbcdb729f", size = 81262921, upload-time = "2026-03-27T13:53:50.192Z" }, - { url = "https://files.pythonhosted.org/packages/a8/8d/16e597f86241772fe188bbdd86a74ce48eadd2dd9513e2410b4ea07f78aa/vtk-9.6.1-cp313-cp313-macosx_10_10_x86_64.whl", hash = "sha256:88983bce26f7665ac6e4fb7de16cf53b896140a1a6cadd942d3c13e7c74a8530", size = 114747320, upload-time = "2026-03-27T13:54:33.138Z" }, - { url = "https://files.pythonhosted.org/packages/63/ca/8f0c19bded437423479d0d3ff0b7457cf6ef68def322666df867e6dacc0f/vtk-9.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:94ed369a54c6cfacea0b34f42d7d3ef41fa06c1aabfc75d93cabdc9047454293", size = 106817051, upload-time = "2026-03-27T13:55:21.903Z" }, - { url = "https://files.pythonhosted.org/packages/82/22/c1d98e6e191481af1e5c82ae3fa750798d868aa442a76db027f6a7901b95/vtk-9.6.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:deeb86794cd42f922ea75711b9717e45841777624203727eb84595b709af1382", size = 145920554, upload-time = "2026-03-27T13:57:14.258Z" }, - { url = "https://files.pythonhosted.org/packages/16/5d/658f60209de7b41b634178aee1f458bcad149aa2654d16bd023c09afd29c/vtk-9.6.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fef8abc33168ad38b2622cf29048b7d5fe48a45789bf0a0421781f5cafa1e554", size = 135686060, upload-time = "2026-03-27T13:56:23.89Z" }, - { url = "https://files.pythonhosted.org/packages/f0/31/e4eb318901a8e736c936491e759ce03a1656792f728ae912db0e20997e9a/vtk-9.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:a5db7b2ff8fc3f56b547c8b9b7bc117a869c902683c86ef5cd6197c087f66183", size = 81264861, upload-time = "2026-03-27T13:57:47.164Z" }, - { url = "https://files.pythonhosted.org/packages/43/de/ad1ccb188681d51b5e5621f383afa0a1dd8711ff8f3dcba4ff950f758cbb/vtk-9.6.1-cp314-cp314-macosx_10_10_x86_64.whl", hash = "sha256:91257894723dfced8be264915d81ba418d08e5bbdb4873da0f12b02c1e21f244", size = 114382745, upload-time = "2026-03-27T13:58:30.592Z" }, - { url = "https://files.pythonhosted.org/packages/59/21/7bb2bd61b4ae05db79e2f40df452a6dbcd3bb66c259fd2043aaf60bbe5b7/vtk-9.6.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e119721774418fba34e95852efaefe5ac4156a4a270362fef06894cdc1377b6e", size = 106826433, upload-time = "2026-03-27T13:59:22.109Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e4/5317afdeaa4a66fe037e1fedcb6bde86b888b8227c89aba6c8ad2946e380/vtk-9.6.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98df48bcd630a4ffa71ac09d6aebb69c628925902920419e3db838dc7f7ce0ed", size = 145936133, upload-time = "2026-03-27T14:01:19.29Z" }, - { url = "https://files.pythonhosted.org/packages/67/7f/f1375aa3ef1835e39b4bea978da06d4985bc1407e3e91384dfaabf5e09d0/vtk-9.6.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3ea3c3e466a4a9cd8fa7e5b64595215467a7936c9f0fd61ec3275954c122a79c", size = 135710278, upload-time = "2026-03-27T14:00:19.358Z" }, - { url = "https://files.pythonhosted.org/packages/91/25/7ff877a0d4f3e848d994d3a774d5a8e4495681ed26c32eb9dbb2a86b50e5/vtk-9.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:a05e12ab8c82e81b225feee5ca08fda5fff814520a11c2941cc866335d990e03", size = 83197720, upload-time = "2026-03-27T14:01:53.313Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ae/f621aaed0a36c99ba3c1b2f2593386094222132a8396f21c616adffacaaf/vtk-9.6.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:043fb013a2669180180bd0ab667f318f2e1f14da69ae943192a7443f5ce3721a", size = 145557875, upload-time = "2026-03-27T14:03:45.531Z" }, - { url = "https://files.pythonhosted.org/packages/e1/d5/41edd3f8b38ad45b8fb30d12ef35be3d62e1221e455722a5d7d103ca2f0d/vtk-9.6.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d08b3760cbd8bffdbb4551033c4c9d87afe30e9e9d4c0e5cad29cbe7107c9af7", size = 135524312, upload-time = "2026-03-27T14:02:46.035Z" }, -] - -[[package]] -name = "wrapt" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, - { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, - { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, - { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, - { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, - { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, - { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, - { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, - { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, - { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, - { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, - { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, - { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, - { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, - { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, - { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, - { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, - { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, - { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, - { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, - { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, - { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, - { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, - { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, - { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, - { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, - { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, - { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, - { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, - { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, - { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, - { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, - { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, - { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, -] - [[package]] name = "xxhash" version = "3.7.0" From c6a5e2dcdd006e3f96228376d28d6a7445939ab4 Mon Sep 17 00:00:00 2001 From: murata Date: Sun, 26 Jul 2026 14:41:38 +0900 Subject: [PATCH 40/44] =?UTF-8?q?fix:=20skill=E6=BA=96=E6=8B=A0=E3=81=A7Bu?= =?UTF-8?q?gbot=E6=8C=87=E6=91=98=E3=81=A8=E6=9A=97=E9=BB=99=E7=B5=8C?= =?UTF-8?q?=E8=B7=AF=E3=82=92=E5=A1=9E=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCC並列tuneのOptunaスキーマ競合、学習/evalのdata root不一致、 paper evalのreproducibilityフラグ未伝播、NaNプレースホルダ集計を解消し、 公開文書のMethods variant位置づけを明確化する。 Co-authored-by: Cursor --- REPRODUCIBILITY.md | 6 +-- configs/README.md | 2 +- experiments/evaluate_paper_baselines.py | 12 +++--- experiments/evaluate_paper_protocol.py | 43 ++++++++++++++++--- .../run_tune_local_pca_power_mcc_parallel.sh | 20 +++++++++ tda_ml/config.py | 5 +++ tda_ml/data_loader.py | 13 ++++-- tda_ml/dbscan_eval.py | 9 +++- tda_ml/preflight.py | 3 +- tda_ml/run_setup.py | 13 +++--- tests/test_config_project_root.py | 11 ++++- tests/test_paper_eval_imports.py | 11 +++++ 12 files changed, 120 insertions(+), 28 deletions(-) diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index db7e75f..87a866f 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -73,7 +73,7 @@ uv run python experiments/evaluate_paper_protocol.py \ 主表・チューニングの preflight は `homology_dimensions=[1]`、`teacher_mode=local_pca`、`prob_weighting=false`、`aniso_mode=elongate`、`distance_backend=ellphi`、`size_mode=power`、`w_class=0.0`、`teacher_local_pca_k=10`、`teacher_local_pca_normalize_axes=true` の明示を要求する。欠落や不一致は実行前に hard-fail する。本番 30ep は `--tune-json`(H1-only Optuna best)必須で、YAML 埋め込みの旧重みでは起動しない。 -**退化ガード variant(主表外・Methods opt-in):** near-tangent データでは素の `elongate` が短軸→0 まで潰し、ellphi tangency が hard-fail し得る。対策として `aniso_mode: elongate_barrier`(`PAPER_NO_CLS_BARRIER_CONTRACT`、`aniso_barrier_threshold=6.0`、`distance_backend=ellphi`)を **YAML で明示したときだけ**使う。公開の tune 入口は `elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier`(`BASE_CONFIG=...`)。暗黙の切替はしない。主表の ADBSCAN 比較には使わない。 +**退化ガード variant(主表外・Methods opt-in):** near-tangent データでは素の `elongate` が短軸→0 まで潰し、ellphi tangency が hard-fail し得る。対策として `aniso_mode: elongate_barrier`(`PAPER_NO_CLS_BARRIER_CONTRACT`、`aniso_barrier_threshold=6.0`、`distance_backend=ellphi`)を **YAML で明示したときだけ**使う。公開参照 YAML は `elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier`(`BASE_CONFIG=...`)。暗黙の切替はしない。主表の ADBSCAN 比較・本番 30ep 経路には使わない(対応する full120 本番 YAML は公開面に置かない)。 ## 環境 @@ -97,8 +97,8 @@ uv run python experiments/evaluate_paper_protocol.py \ ## データ(MNIST) - MNIST は git にコミットしません(`data/` は無視対象)。 -- 初回の学習またはデータセットアクセス時に、`torchvision` 経由で **`./data`** 以下にダウンロードされます(`configs/reproduce.yaml` を前提とした設定が典型です)。 -- 初回はインターネットに到達できるようにするか、キャッシュ済みの MNIST を自分で `./data` に置いてください。 +- 学習・paper eval ともデータ根は **リポジトリ根の `data/`**(`tda_ml.config.default_data_root()`)であり、プロセスの cwd には依存しません。初回アクセス時に `torchvision` 経由でそこにダウンロードされます。 +- 初回はインターネットに到達できるようにするか、キャッシュ済みの MNIST を自分でリポジトリ根の `data/` に置いてください。 - 設定 YAML の役割分担は **`configs/README.md`** を参照(共有プロファイル + 論文用 `elongate_n100_no_cls_*`)。旧設定はローカルで `configs/archive/` に置けるが、公開クローンには同梱されない。 ## チェックポイントと実行出力 diff --git a/configs/README.md b/configs/README.md index d597f55..c72ee52 100644 --- a/configs/README.md +++ b/configs/README.md @@ -20,7 +20,7 @@ Canonical YAML files live **in this directory** (deep-merged with `base.yaml` by | File | Role | |------|------| -| `elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml` | Degeneracy guard: `aniso_mode=elongate_barrier`, `aniso_barrier_threshold=6.0`, `distance_backend=ellphi`. Opt-in via `BASE_CONFIG=...`; never an implicit swap. | +| `elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml` | Methods reference only: `aniso_mode=elongate_barrier`, `aniso_barrier_threshold=6.0`. Opt-in via `BASE_CONFIG=...`; never an implicit swap. No matching public full120 production YAML. | ## 置かないもの diff --git a/experiments/evaluate_paper_baselines.py b/experiments/evaluate_paper_baselines.py index e8181fd..6de3bc0 100644 --- a/experiments/evaluate_paper_baselines.py +++ b/experiments/evaluate_paper_baselines.py @@ -41,6 +41,7 @@ from experiments.evaluate_paper_protocol import ( CloudMetrics, + _aggregate_classification_metrics, _aggregate_cloud_metrics, build_split_loader, dbscan_labels_to_outlier_pred, @@ -192,9 +193,8 @@ def evaluate_adbscan( ``topo_options=None`` is the val grid-search phase: selection uses MCC only, and the ellipse-filtration topo W-Dist does not depend on (eps, min_samples), - so it is deliberately NOT computed there (wdist=NaN placeholder, never - aggregated into results). The test phase passes explicit ``topo_options`` - and reports the real W-Dist. + so it is deliberately NOT computed there (``wdist=None``, never aggregated). + The test phase passes explicit ``topo_options`` and reports the real W-Dist. """ from tda_ml.dbscan import apply_anisotropic_dbscan @@ -213,7 +213,9 @@ def evaluate_adbscan( cloud.labels_gt, pred ) if topo_options is None: - return CloudMetrics(recall, specificity, gmean, mcc, float("nan")) + # Val MCC grid: ellipse W-Dist is independent of (eps, min_samples) and + # is not a selection objective — leave unset rather than NaN-placeholder. + return CloudMetrics(recall, specificity, gmean, mcc, wdist=None) wdist = float( compute_topo_wdist( cloud.points, cloud.adbscan_params, cloud.clean_pc, topo_options @@ -265,7 +267,7 @@ def grid_search_clouds( "Set reproducibility.allow_skip_degenerate_grid_cells=true to opt in " "to skipping failed cells." ) - _, _, _, mcc, _ = _aggregate_cloud_metrics(per_cloud) + _, _, _, mcc = _aggregate_classification_metrics(per_cloud) n_ok += 1 grid_log.append( {**params, "status": "ok", "mean_mcc": mcc, "n_clouds": len(per_cloud)} diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index c91ed67..80cd13a 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -33,7 +33,12 @@ load_torch_checkpoint, resolve_val_topo_checkpoint, ) -from tda_ml.config import deep_update, load_config, model_kwargs_from_config +from tda_ml.config import ( + default_data_root, + deep_update, + load_config, + model_kwargs_from_config, +) from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader from tda_ml.dbscan_eval import evaluate_model_grid, iter_cloud_predictions from tda_ml.metrics import compute_recall_specificity_gmean_mcc_wdist @@ -61,7 +66,9 @@ class CloudMetrics: specificity: float gmean: float mcc: float - wdist: float + # None = intentionally not computed (e.g. MCC-only val grid). Never use NaN + # as a stand-in: aggregation must hard-fail rather than nanmean. + wdist: float | None = None @dataclass @@ -117,7 +124,10 @@ def evaluate_cloud_dbscan( return CloudMetrics(recall, specificity, gmean, mcc, wdist) -def _aggregate_cloud_metrics(rows: list[CloudMetrics]) -> tuple[float, float, float, float, float]: +def _aggregate_classification_metrics( + rows: list[CloudMetrics], +) -> tuple[float, float, float, float]: + """Mean recall / specificity / G-Mean / MCC (no W-Dist).""" if not rows: raise ValueError("No clouds to aggregate") return ( @@ -125,10 +135,30 @@ def _aggregate_cloud_metrics(rows: list[CloudMetrics]) -> tuple[float, float, fl float(np.mean([r.specificity for r in rows])), float(np.mean([r.gmean for r in rows])), float(np.mean([r.mcc for r in rows])), - float(np.mean([r.wdist for r in rows])), ) +def _aggregate_cloud_metrics( + rows: list[CloudMetrics], +) -> tuple[float, float, float, float, float]: + """Mean classification metrics plus W-Dist; hard-fail if any W-Dist missing/non-finite.""" + recall, specificity, gmean, mcc = _aggregate_classification_metrics(rows) + wdists: list[float] = [] + for r in rows: + if r.wdist is None: + raise ValueError( + "cannot aggregate W-Dist: at least one cloud has wdist=None " + "(not computed). Use _aggregate_classification_metrics for MCC-only phases." + ) + if not np.isfinite(r.wdist): + raise ValueError( + f"cannot aggregate non-finite W-Dist ({r.wdist!r}); " + "refusing silent nanmean" + ) + wdists.append(float(r.wdist)) + return recall, specificity, gmean, mcc, float(np.mean(wdists)) + + def build_split_loader(config: dict[str, Any], split: str, device: torch.device): data_cfg = config["data"] seed = int(_require_data_key(data_cfg, "seed")) @@ -208,8 +238,9 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) if "noise_std" not in data_cfg: raise ValueError("data.noise_std must be set explicitly; refusing silent default") + repro = reproducibility_settings(config) dataset_kwargs: dict[str, Any] = dict( - root=str(REPO_ROOT / "data"), + root=str(default_data_root()), train=train_flag, max_points=data_cfg["max_points"], num_outliers=data_cfg["num_outliers"], @@ -219,6 +250,8 @@ def build_split_loader(config: dict[str, Any], split: str, device: torch.device) noise_seed=seed, preload=True, outlier_mode=outlier_mode, + allow_empty_cloud_fallback=repro["allow_empty_cloud_fallback"], + allow_otsu_threshold_fallback=repro["allow_otsu_threshold_fallback"], ) if outlier_mode == "local_pca_tangent": for key in ( diff --git a/experiments/run_tune_local_pca_power_mcc_parallel.sh b/experiments/run_tune_local_pca_power_mcc_parallel.sh index f7adbae..de8bc8e 100755 --- a/experiments/run_tune_local_pca_power_mcc_parallel.sh +++ b/experiments/run_tune_local_pca_power_mcc_parallel.sh @@ -49,6 +49,26 @@ Launch: \`bash experiments/run_tune_local_pca_power_mcc_parallel.sh ${N_WORKERS} EOF echo "Power MCC tune: ${N_WORKERS} workers, ${N_TRIALS} trials, ${TUNE_EPOCHS}ep, topo=${BACKEND}, DBSCAN=${DBSCAN_BACKEND}" +echo "OUT_BASE=${OUT_BASE}" + +# Create the Optuna study once before spawning workers: concurrent +# create_study(load_if_exists=True) on a fresh sqlite file races inside the +# alembic schema migration ("table alembic_version already exists") and kills +# the losing worker at startup. Same guard as the W-Dist launcher. +STUDY_NAME="${STUDY_NAME}" STORAGE="${STORAGE}" uv run python - <<'PY' +import os + +import optuna + +optuna.create_study( + study_name=os.environ["STUDY_NAME"], + storage=os.environ["STORAGE"], + direction="maximize", + load_if_exists=True, +) +print(f"study initialized: {os.environ['STUDY_NAME']}") +PY + pids=() for i in $(seq 0 $((N_WORKERS - 1))); do SEED=$((42 + i)) diff --git a/tda_ml/config.py b/tda_ml/config.py index dbbe797..c473fa9 100644 --- a/tda_ml/config.py +++ b/tda_ml/config.py @@ -21,6 +21,11 @@ def default_project_root() -> Path: return Path(__file__).resolve().parent.parent +def default_data_root() -> Path: + """MNIST / dataset cache under the package-inferred repository root (cwd-independent).""" + return default_project_root() / "data" + + def load_config(config_name: str, *, project_root: Path | str | None = None) -> dict[str, Any]: """ Merge ``configs/base.yaml`` with an environment-specific YAML. diff --git a/tda_ml/data_loader.py b/tda_ml/data_loader.py index 50850e8..8df96b4 100644 --- a/tda_ml/data_loader.py +++ b/tda_ml/data_loader.py @@ -6,6 +6,8 @@ from torch.utils.data import DataLoader, Dataset from torchvision import datasets +from tda_ml.config import default_data_root + logger = logging.getLogger(__name__) @@ -18,7 +20,8 @@ class NoisyMNISTDataset(Dataset): noise, and augmented with outlier points. Args: - root (str): Path to store/load MNIST data. + root (str | None): Path to store/load MNIST data. ``None`` resolves to + ``tda_ml.config.default_data_root()`` (repo-root ``data/``, cwd-independent). train (bool): Use training split if True, else test split. num_samples (int): Number of samples to use (randomly subsampled). max_points (int): Fixed number of inlier points per sample. @@ -48,7 +51,7 @@ class NoisyMNISTDataset(Dataset): clean_pc (Tensor): Shape (max_points, 2). Noise-free inlier points (zero-padded). """ - def __init__(self, root='./data', train=True, num_samples=5000, + def __init__(self, root=None, train=True, num_samples=5000, max_points=150, num_outliers=20, noise_std=0.01, deterministic=False, indices=None, noise_seed=0, preload=True, allow_empty_cloud_fallback=False, @@ -60,6 +63,8 @@ def __init__(self, root='./data', train=True, num_samples=5000, tangent_angle_jitter_deg=0.0, tangent_stroke_clearance=0.0, tangent_direction="tangent"): + if root is None: + root = str(default_data_root()) self.max_points = max_points self.num_outliers = num_outliers self.noise_std = noise_std @@ -309,7 +314,7 @@ class PreloadedOutlierMNIST(NoisyMNISTDataset): def __init__( self, - root="./data", + root=None, train=True, num_samples=5000, max_points=150, @@ -355,7 +360,7 @@ def get_dataset(config): ) if dtype == "mnist": return PreloadedOutlierMNIST( - root=str(data_cfg.get("root", "./data")), + root=str(data_cfg["root"]) if "root" in data_cfg else str(default_data_root()), train=bool(data_cfg.get("train", False)), num_samples=int(data_cfg["num_samples"]), max_points=int(data_cfg["max_points"]), diff --git a/tda_ml/dbscan_eval.py b/tda_ml/dbscan_eval.py index 831972c..9eb6c94 100644 --- a/tda_ml/dbscan_eval.py +++ b/tda_ml/dbscan_eval.py @@ -157,12 +157,19 @@ def _eval_prepared_cloud( def _aggregate(rows: list[CloudMetrics]) -> tuple[float, float, float, float, float]: + if not rows: + raise ValueError("No clouds to aggregate") + wdists = [float(r.wdist) for r in rows] + if any(not np.isfinite(w) for w in wdists): + raise ValueError( + f"cannot aggregate non-finite W-Dist ({wdists!r}); refusing silent nanmean" + ) return ( float(np.mean([r.recall for r in rows])), float(np.mean([r.specificity for r in rows])), float(np.mean([r.gmean for r in rows])), float(np.mean([r.mcc for r in rows])), - float(np.mean([r.wdist for r in rows])), + float(np.mean(wdists)), ) diff --git a/tda_ml/preflight.py b/tda_ml/preflight.py index 0e7b4e8..547ba00 100644 --- a/tda_ml/preflight.py +++ b/tda_ml/preflight.py @@ -300,7 +300,8 @@ def preflight_training_config( if not data_path.is_dir(): raise FileNotFoundError( f"MNIST data root missing: {data_path}. " - "Download MNIST first (e.g. run a short training job or place cached data under ./data)." + "Download MNIST first (e.g. run a short training job or place cached data under " + f"{root / 'data'})." ) out_base = (config.get("outputs") or {}).get("base_dir") diff --git a/tda_ml/run_setup.py b/tda_ml/run_setup.py index 6d55c02..abde18c 100644 --- a/tda_ml/run_setup.py +++ b/tda_ml/run_setup.py @@ -8,7 +8,9 @@ import torch +from tda_ml.config import default_data_root from tda_ml.data_loader import NoisyMNISTDataset, create_data_loader +from tda_ml.reproducibility import reproducibility_settings logger = logging.getLogger(__name__) @@ -142,20 +144,17 @@ def build_dataloaders(config, seed: int, settings: DataLoaderSettings): "data.noise_std must be set explicitly; refusing silent default" ) + repro = reproducibility_settings(config) dataset_kwargs = dict( - root="./data", + root=str(default_data_root()), max_points=data_cfg["max_points"], num_outliers=data_cfg["num_outliers"], noise_std=float(data_cfg["noise_std"]), deterministic=True, noise_seed=seed, outlier_mode=outlier_mode, - allow_empty_cloud_fallback=bool( - (config.get("reproducibility") or {}).get("allow_empty_cloud_fallback", False) - ), - allow_otsu_threshold_fallback=bool( - (config.get("reproducibility") or {}).get("allow_otsu_threshold_fallback", False) - ), + allow_empty_cloud_fallback=repro["allow_empty_cloud_fallback"], + allow_otsu_threshold_fallback=repro["allow_otsu_threshold_fallback"], ) if outlier_mode == "local_pca_tangent": diff --git a/tests/test_config_project_root.py b/tests/test_config_project_root.py index 274b444..a85e5e0 100644 --- a/tests/test_config_project_root.py +++ b/tests/test_config_project_root.py @@ -4,7 +4,13 @@ import unittest -from tda_ml.config import deep_update, default_project_root, load_config, model_kwargs_from_config +from tda_ml.config import ( + deep_update, + default_data_root, + default_project_root, + load_config, + model_kwargs_from_config, +) class TestConfigProjectRoot(unittest.TestCase): @@ -12,6 +18,9 @@ def test_default_root_contains_configs_base(self) -> None: root = default_project_root() self.assertTrue((root / "configs" / "base.yaml").is_file()) + def test_default_data_root_is_under_project_root(self) -> None: + self.assertEqual(default_data_root(), default_project_root() / "data") + def test_load_dev_from_default_root(self) -> None: cfg = load_config("dev") self.assertIn("data", cfg) diff --git a/tests/test_paper_eval_imports.py b/tests/test_paper_eval_imports.py index f59aeb2..5d246d5 100644 --- a/tests/test_paper_eval_imports.py +++ b/tests/test_paper_eval_imports.py @@ -23,6 +23,17 @@ def test_baselines_and_protocol_import(self): self.assertTrue(callable(protocol.load_model_from_run)) self.assertTrue(callable(protocol.build_split_loader)) + def test_train_and_eval_share_data_root(self): + """Training and paper eval must not disagree on MNIST cache path (cwd-independent).""" + from tda_ml.config import default_data_root + from tda_ml.run_setup import default_data_root as train_data_root + + import experiments.evaluate_paper_protocol as protocol + + self.assertIs(protocol.default_data_root, default_data_root) + self.assertIs(train_data_root, default_data_root) + self.assertEqual(default_data_root().name, "data") + def test_public_paper_configs_pass_contract(self): assert_paper_no_cls_contract( load_config("elongate_n100_no_cls_full120_teacher_local_pca") From e07abbd15b7ef6d957c433e79f8160cfce004e50 Mon Sep 17 00:00:00 2001 From: murata Date: Sun, 26 Jul 2026 15:22:10 +0900 Subject: [PATCH 41/44] =?UTF-8?q?refactor:=20=E8=AB=96=E6=96=87=20config?= =?UTF-8?q?=20=E3=82=92=E5=AE=9F=E9=A8=93=E8=A8=AD=E5=AE=9A=E3=81=8C?= =?UTF-8?q?=E8=AA=AD=E3=82=81=E3=82=8B=E5=90=8D=E5=89=8D=E3=81=AB=E6=95=B4?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit elongate/full120 など誤解を招く接頭辞をやめ、 paper_/tune_ + n100_o20_nocls_h1_ellphi_lpca_power に統一。 未使用の baseline と Methods-only barrier YAML は公開面から削除。 Co-authored-by: Cursor --- README.md | 4 +- REPRODUCIBILITY.md | 14 +-- configs/README.md | 42 ++++++--- ...elongate_n100_no_cls_full120_baseline.yaml | 65 -------------- ...a_ellphi_power_h1_neartangent_barrier.yaml | 88 ------------------- ..._n100_o20_nocls_h1_ellphi_lpca_power.yaml} | 9 +- ..._n100_o20_nocls_h1_ellphi_lpca_power.yaml} | 9 +- experiments/evaluate_paper_baselines.py | 6 +- experiments/evaluate_paper_protocol.py | 4 +- .../run_teacher_local_pca_power_30ep.py | 2 +- ..._teacher_local_pca_power_30ep_multiseed.sh | 2 +- .../run_tune_local_pca_power_mcc_parallel.sh | 2 +- ...run_tune_local_pca_power_wdist_parallel.sh | 2 +- experiments/tune_elongate_mcc.py | 2 +- experiments/tune_elongate_wdist.py | 4 +- tda_ml/run_paths.py | 8 +- tests/test_paper_eval_imports.py | 11 +-- tests/test_paper_production_gates.py | 2 +- tests/test_tune_config.py | 55 ++++++++---- 19 files changed, 109 insertions(+), 222 deletions(-) delete mode 100644 configs/elongate_n100_no_cls_full120_baseline.yaml delete mode 100644 configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml rename configs/{elongate_n100_no_cls_full120_teacher_local_pca.yaml => paper_n100_o20_nocls_h1_ellphi_lpca_power.yaml} (77%) rename configs/{elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml => tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml} (75%) diff --git a/README.md b/README.md index 4e1fb47..916ceef 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Main table: **W-Dist-tuned weights**, 30 epochs × 5 data seeds, `w_class=0`, H1-only, local-PCA teacher, ellphi distance, checkpoint `best_model.pth` (`selection=val_topo`), then val DBSCAN grid → test MCC / G-Mean. -Config: `elongate_n100_no_cls_full120_teacher_local_pca`. +Config: `paper_n100_o20_nocls_h1_ellphi_lpca_power`. ```bash # 1) Tune once (Optuna sampler seeds differ per worker; data seed in YAML is 42). @@ -53,7 +53,7 @@ MODE=wdist bash experiments/run_tune_local_pca_power_objectives.sh bash experiments/run_teacher_local_pca_power_30ep_multiseed.sh wdist uv run python experiments/evaluate_paper_baselines.py \ - --base-config elongate_n100_no_cls_full120_teacher_local_pca \ + --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \ --out-dir outputs/paper_baselines ``` diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index 87a866f..f38bff7 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -4,7 +4,7 @@ ## リポジトリに含まれる範囲(目安) -- **含む:** `tda_ml/`、**`configs/` 直下の正本 YAML**(`base.yaml` と `reproduce` / `dev` / `prod` / `test_fast`、および論文比較用の `elongate_n100_no_cls_*`)、`tests/`、追跡されている `scripts/`、論文・再現用 `experiments/`(下記)、および `README.md` / `REPRODUCIBILITY.md` / `pyproject.toml` / `uv.lock` / `LICENSE` / `CITATION.cff` などのメタデータ。 +- **含む:** `tda_ml/`、**`configs/` 直下の正本 YAML**(`base.yaml` と `reproduce` / `dev` / `prod` / `test_fast`、および論文比較用の `paper_n100_o20_nocls_h1_ellphi_lpca_power` / `tune_n100_o20_nocls_h1_ellphi_lpca_power`)、`tests/`、追跡されている `scripts/`、論文・再現用 `experiments/`(下記)、および `README.md` / `REPRODUCIBILITY.md` / `pyproject.toml` / `uv.lock` / `LICENSE` / `CITATION.cff` などのメタデータ。 - **含めない:** `docs/` 以下(**ローカル実験メモ**;公開方針で git に入れる場合は別途決定)、`configs/archive/`(履歴用 YAML を置く場合は **ローカルのみ**)、`outputs/`、`data/`、`.cursor/` など。`load_config("archive/...")` は、手元に `configs/archive/*.yaml` を置いた場合にのみ使えます。 ### 論文比較(ellphi + power 二目的)で使う `experiments/` @@ -12,7 +12,7 @@ **論文主表の提案:** W-Dist tune 重みの 30ep 5-seed(`run_teacher_local_pca_power_30ep_multiseed.sh wdist`)。 **主張:** Euclidean DBSCAN / ADBSCAN と **同程度の外れ値除去性能**(MCC / G-Mean;5 seed の mean ± sample std による**記述的**比較。同等性検定は行わない)。主表に Topo W. 列は載せない。 **比較の非対称:** ADBSCAN は学習なしの局所 PCA 楕円ベースライン。提案法は同一データで 30ep 学習する(計算資源・パラメータ更新は対等ではない)。 -**正本 config:** `elongate_n100_no_cls_full120_teacher_local_pca`(`w_class=0`, `homology_dimensions=[1]`, `aniso_mode=elongate`)。 +**正本 config:** `paper_n100_o20_nocls_h1_ellphi_lpca_power`(`w_class=0`, `homology_dimensions=[1]`, `aniso_mode=elongate`)。 出力先は `WDIST_OUT` / `MCC_OUT` / `LOG_ROOT`(既定: `outputs/supervised/pwr30_*`)で明示する(生成物は git に含めない)。 | 区分 | パス | @@ -44,7 +44,7 @@ bash experiments/run_teacher_local_pca_power_30ep_multiseed.sh wdist ```bash uv run python experiments/evaluate_paper_baselines.py \ - --base-config elongate_n100_no_cls_full120_teacher_local_pca \ + --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \ --out-dir outputs/paper_baselines ``` @@ -57,7 +57,7 @@ uv run python experiments/run_teacher_local_pca_power_30ep.py \ uv run python experiments/evaluate_paper_protocol.py \ --run-dir outputs/supervised/.../pwr_s42_ \ - --base-config elongate_n100_no_cls_full120_teacher_local_pca \ + --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \ --split val ``` @@ -73,7 +73,7 @@ uv run python experiments/evaluate_paper_protocol.py \ 主表・チューニングの preflight は `homology_dimensions=[1]`、`teacher_mode=local_pca`、`prob_weighting=false`、`aniso_mode=elongate`、`distance_backend=ellphi`、`size_mode=power`、`w_class=0.0`、`teacher_local_pca_k=10`、`teacher_local_pca_normalize_axes=true` の明示を要求する。欠落や不一致は実行前に hard-fail する。本番 30ep は `--tune-json`(H1-only Optuna best)必須で、YAML 埋め込みの旧重みでは起動しない。 -**退化ガード variant(主表外・Methods opt-in):** near-tangent データでは素の `elongate` が短軸→0 まで潰し、ellphi tangency が hard-fail し得る。対策として `aniso_mode: elongate_barrier`(`PAPER_NO_CLS_BARRIER_CONTRACT`、`aniso_barrier_threshold=6.0`、`distance_backend=ellphi`)を **YAML で明示したときだけ**使う。公開参照 YAML は `elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier`(`BASE_CONFIG=...`)。暗黙の切替はしない。主表の ADBSCAN 比較・本番 30ep 経路には使わない(対応する full120 本番 YAML は公開面に置かない)。 +**退化ガード variant(主表外・Methods opt-in):** near-tangent データでは素の `elongate` が短軸→0 まで潰し、ellphi tangency が hard-fail し得る。対策として `aniso_mode: elongate_barrier`(`PAPER_NO_CLS_BARRIER_CONTRACT`、`aniso_barrier_threshold=6.0`、`distance_backend=ellphi`)を **YAML で明示したときだけ**使う。公開ツリーにはこの variant 用 YAML を置かない(コード契約とテストで担保;ローカル `configs/archive/` に置く場合のみ)。暗黙の切替はしない。主表の ADBSCAN 比較・本番 30ep 経路には使わない。 ## 環境 @@ -99,7 +99,7 @@ uv run python experiments/evaluate_paper_protocol.py \ - MNIST は git にコミットしません(`data/` は無視対象)。 - 学習・paper eval ともデータ根は **リポジトリ根の `data/`**(`tda_ml.config.default_data_root()`)であり、プロセスの cwd には依存しません。初回アクセス時に `torchvision` 経由でそこにダウンロードされます。 - 初回はインターネットに到達できるようにするか、キャッシュ済みの MNIST を自分でリポジトリ根の `data/` に置いてください。 -- 設定 YAML の役割分担は **`configs/README.md`** を参照(共有プロファイル + 論文用 `elongate_n100_no_cls_*`)。旧設定はローカルで `configs/archive/` に置けるが、公開クローンには同梱されない。 +- 設定 YAML の役割分担は **`configs/README.md`** を参照(共有プロファイル + 論文用 `paper_n100_o20_nocls_h1_ellphi_lpca_power` / `tune_n100_o20_nocls_h1_ellphi_lpca_power`)。旧設定はローカルで `configs/archive/` に置けるが、公開クローンには同梱されない。 ## チェックポイントと実行出力 @@ -228,7 +228,7 @@ M_i=\max(a_i,b_i),\; m_i=\min(a_i,b_i), (`size_ref` \(=\mathrm{ref}\)、`size_power` \(=\gamma\);主表は ref=1.34, γ=1.5)。 `size_mode: quadratic`(\(\frac{1}{N}\sum_i (M_i^2+m_i^2)\))は非主表の共有プロファイル用。 -主表 power 30ep config(`elongate_n100_no_cls_full120_teacher_local_pca`)では +主表 power 30ep config(`paper_n100_o20_nocls_h1_ellphi_lpca_power`)では `homology_dimensions: [1]`(H1-only Wasserstein)と `aniso_mode: elongate` を用いる。 ellphi 退化(NaN 共分散・接線距離未定義など)は `run_status: failed` とする([Computational Reproducibility skill](https://github.com/t-uda/skills/blob/main/skills/computational-reproducibility/SKILL.md))。 diff --git a/configs/README.md b/configs/README.md index c72ee52..17126d7 100644 --- a/configs/README.md +++ b/configs/README.md @@ -3,6 +3,24 @@ Canonical YAML files live **in this directory** (deep-merged with `base.yaml` by `tda_ml.config.load_config`). +## Naming (paper / tune) + +`{role}_n{N}_o{O}_nocls_h1_{backend}_{teacher}_{size}` + +| Token | Meaning | +|-------|---------| +| `paper` / `tune` | Production 30ep vs Optuna tune base | +| `n100` | `data.max_points=100` | +| `o20` | `data.num_outliers=20` | +| `nocls` | `loss.w_class=0` | +| `h1` | `homology_dimensions=[1]` | +| `ellphi` / `maha` | `distance_backend` | +| `lpca` / `euclid` | `teacher_mode` (`local_pca` / `euclidean`) | +| `power` | `size_mode=power` | + +`aniso_mode=elongate` is the paper contract default and is **not** put in the +filename (it is a loss mode, not the experiment identity). + ## 正本(公開・論文) | File | Role | @@ -12,24 +30,22 @@ Canonical YAML files live **in this directory** (deep-merged with `base.yaml` by | `dev.yaml` | Small MNIST subset for local wiring (non-paper). | | `prod.yaml` | Longer CPU profile (non-paper). | | `test_fast.yaml` | Quick checks / CI. | -| `elongate_n100_no_cls_full120_teacher_local_pca.yaml` | **Paper production** (`w_class=0`, H1-only, local_pca, `aniso_mode=elongate`). | -| `elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml` | Shared Optuna tune base for **both** W-Dist and MCC studies (`config_id` is objective-neutral). | -| `elongate_n100_no_cls_full120_baseline.yaml` | Optional Euclidean-teacher contrast column. | - -## 宣言済み契約 variant(主表外・Methods で主張する場合のみ) - -| File | Role | -|------|------| -| `elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml` | Methods reference only: `aniso_mode=elongate_barrier`, `aniso_barrier_threshold=6.0`. Opt-in via `BASE_CONFIG=...`; never an implicit swap. No matching public full120 production YAML. | +| `paper_n100_o20_nocls_h1_ellphi_lpca_power.yaml` | **Paper production** (30ep, `w_class=0`, H1-only, local_pca, ellphi, power). | +| `tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml` | Shared Optuna tune base for **both** W-Dist and MCC studies. | ## 置かないもの Probe / ablation / dated experiment YAML → local `configs/archive/` only -(gitignored). Do not reintroduce rings / contam / raw_axes configs here. +(gitignored). Do not reintroduce rings / contam / raw_axes / near-tangent +barrier / Euclidean-teacher baseline configs here unless they become a claimed +Methods path with a matching production YAML. -Library support without public paper YAML: `tda_ml/ring_dataset.py` (`dataset_type=thin_rings`) -and `tda_ml/tangent_outliers.py` remain importable for opt-in Methods experiments; -they are **not** the main-table path unless a public YAML above selects them. +Library support without public paper YAML: `tda_ml/ring_dataset.py` +(`dataset_type=thin_rings`) and `tda_ml/tangent_outliers.py` remain importable +for opt-in Methods experiments; they are **not** the main-table path unless a +public YAML above selects them. Barrier contract +(`aniso_mode=elongate_barrier`) is code-supported via explicit YAML declaration; +no public Methods YAML ships for it. ## Keys read by the training stack diff --git a/configs/elongate_n100_no_cls_full120_baseline.yaml b/configs/elongate_n100_no_cls_full120_baseline.yaml deleted file mode 100644 index cbb53a4..0000000 --- a/configs/elongate_n100_no_cls_full120_baseline.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# no_cls baseline contrast: Euclidean-teacher column (not the paper main table). -# selection.metric=val_topo matches the paper checkpoint protocol (best_model.pth). - -meta: - config_id: "elongate_n100_no_cls_full120_baseline" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "mahalanobis" - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: "elongate" - size_mode: power - size_ref: 1.34 - size_power: 1.5 - topo_eps_scale: 1.0 - topo_scale_mode: fixed - teacher_mode: euclidean - -training: - lr: 0.000158 - epochs: 30 - grad_clip_value: 1.0 - visualize_every: 10 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - noise_std: 0.01 - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml b/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml deleted file mode 100644 index 60a4b81..0000000 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier.yaml +++ /dev/null @@ -1,88 +0,0 @@ -# H1-only tune on NEAR-tangent outliers with the elongate_barrier degeneracy guard. -# Same stack as elongate_n100_no_cls_tune_local_pca_ellphi_power, plus -# near-tangent outlier data and loss.aniso_mode: elongate_barrier -# (+ explicit aniso_barrier_threshold). -# -# Why: on near-tangent data, plain `elongate` rewards minor/major -> 0 with no -# counterweight; by epoch 10-20 minor semi-axes collapsed to ~1e-5 (aspect -# ~350) and ellphi tangency hard-failed on the needle geometry (15/16 stage-2 -# conditions died). The barrier keeps the elongate reward below the aspect -# ceiling and pushes back quadratically above it, bounding minor >= major/T. -# T=6.0 matches the declared PAPER_NO_CLS_BARRIER_CONTRACT constant. - -meta: - config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier" - -model: - point_dim: 2 - feature_dim: 128 - ellipse_param_dim: 5 - threshold: 0.5 - topology_loss: - metric_type: "anisotropic" - distance_backend: "ellphi" - ellphi_differentiable: true - prob_weighting: false - homology_dimensions: [1] - -loss: - w_class: 0.0 - w_topo: 0.0883 - w_aniso: 0.0541 - w_size: 0.488 - pos_weight: 1.0 - aniso_mode: elongate_barrier - aniso_barrier_threshold: 6.0 - size_mode: power - size_ref: 1.34 - size_power: 1.5 - teacher_mode: local_pca - teacher_local_pca_k: 10 - teacher_local_pca_normalize_axes: true - topo_eps_scale: 1.0 - topo_scale_mode: fixed - -training: - lr: 0.000158 - epochs: 20 - grad_clip_value: 1.0 - visualize_every: 10000 - warmup_epochs: 0 - selection: - metric: val_topo - eval_every: 1 - dbscan: - eps_values: null - min_samples_values: null - -data: - max_points: 100 - num_outliers: 20 - # GT-proximal anisotropic outliers: displace inliers within a ±30° cone - # around local-PCA PC1, rejected until >=0.08 from every clean inlier. - # noise_std matches the uniform baseline: inliers get the same isotropic - # jitter so only the OUTLIER distribution differs. - outlier_mode: local_pca_tangent - noise_std: 0.01 - tangent_pca_k: 10 - tangent_offset_min: 0.15 - tangent_offset_max: 0.40 - tangent_angle_jitter_deg: 30.0 - tangent_stroke_clearance: 0.08 - tangent_direction: tangent - seed: 42 - train_size: 4500 - val_size: 500 - test_size: 1000 - num_workers: 2 - batch_size: 64 - pin_memory: true - -performance: - cudnn_benchmark: true - enable_tf32: true - matmul_precision: high - -outputs: - base_dir: "outputs" - save_every: 1 diff --git a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml b/configs/paper_n100_o20_nocls_h1_ellphi_lpca_power.yaml similarity index 77% rename from configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml rename to configs/paper_n100_o20_nocls_h1_ellphi_lpca_power.yaml index b72155d..8744443 100644 --- a/configs/elongate_n100_no_cls_full120_teacher_local_pca.yaml +++ b/configs/paper_n100_o20_nocls_h1_ellphi_lpca_power.yaml @@ -1,10 +1,13 @@ -# Paper no_cls production: local-PCA teacher + ellphi topo + H1-only Wasserstein. +# Paper main-table production (30 epochs). +# Naming: n100=max_points, o20=num_outliers, nocls=w_class=0, h1, ellphi, +# lpca=teacher local_pca, power=size_mode. aniso_mode=elongate is the contract +# default (not encoded in the filename). # Degenerate ellphi geometry hard-fails. -# Production 30ep weights MUST come from an H1-only Optuna best JSON (--tune-json). +# Production weights MUST come from an H1-only Optuna best JSON (--tune-json). # The w_* / lr values below are Optuna prior centres only (not paper results). meta: - config_id: "elongate_n100_no_cls_full120_teacher_local_pca" + config_id: "paper_n100_o20_nocls_h1_ellphi_lpca_power" model: point_dim: 2 diff --git a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml b/configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml similarity index 75% rename from configs/elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml rename to configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml index 6960bde..9b9b065 100644 --- a/configs/elongate_n100_no_cls_tune_local_pca_ellphi_power.yaml +++ b/configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml @@ -1,9 +1,10 @@ -# Shared Optuna tune base for W-Dist and MCC studies (local_pca + ellphi + power). -# Train ckpt: val_topo (fast). Trial objective is chosen by the tune driver -# (tune_elongate_wdist.py vs tune_elongate_mcc.py), not by this config_id. +# Shared Optuna tune base for W-Dist and MCC studies (same stack as paper YAML). +# Naming matches paper_n100_o20_nocls_h1_ellphi_lpca_power except role=tune +# (default epochs=20; drivers may override). Trial objective is chosen by the +# tune driver (tune_elongate_wdist.py vs tune_elongate_mcc.py), not by config_id. meta: - config_id: "elongate_n100_no_cls_tune_local_pca_ellphi_power" + config_id: "tune_n100_o20_nocls_h1_ellphi_lpca_power" model: point_dim: 2 diff --git a/experiments/evaluate_paper_baselines.py b/experiments/evaluate_paper_baselines.py index 6de3bc0..ca96465 100644 --- a/experiments/evaluate_paper_baselines.py +++ b/experiments/evaluate_paper_baselines.py @@ -3,7 +3,7 @@ Paper-aligned baseline evaluation. Same data split as ``evaluate_paper_protocol.py`` under the paper elongate -config (typically ``elongate_n100_no_cls_full120_teacher_local_pca``), seeds +config (typically ``paper_n100_o20_nocls_h1_ellphi_lpca_power``), seeds 42 / 123 / 456 / 789 / 1024: tune hyperparameters on validation clouds, report MCC / G-Mean on test. Topo W-Dist is computed for diagnostics only and is **not** a main-table column. @@ -18,7 +18,7 @@ Usage:: uv run python experiments/evaluate_paper_baselines.py \\ - --base-config elongate_n100_no_cls_full120_teacher_local_pca \\ + --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \\ --out-dir outputs/paper_baselines """ @@ -466,7 +466,7 @@ def aggregate_method_results(seed_results: Sequence[SeedResult]) -> dict[str, An def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) # No implicit default: the n100 paper comparison must pass the elongate config - # (e.g. elongate_n100_no_cls_full120_teacher_local_pca); baselines share its + # (e.g. paper_n100_o20_nocls_h1_ellphi_lpca_power); baselines share its # data settings and evaluation.dbscan / evaluation.baselines grids. p.add_argument("--base-config", type=str, required=True) p.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs" / "paper_baselines") diff --git a/experiments/evaluate_paper_protocol.py b/experiments/evaluate_paper_protocol.py index 80cd13a..c94e24e 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/evaluate_paper_protocol.py @@ -9,7 +9,7 @@ uv run python experiments/evaluate_paper_protocol.py \\ --run-dir outputs/supervised/.../pwr_s42_ \\ - --base-config elongate_n100_no_cls_full120_teacher_local_pca \\ + --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \\ --split val uv run python experiments/evaluate_paper_protocol.py \\ @@ -456,7 +456,7 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--base-config", type=str, - default="elongate_n100_no_cls_full120_teacher_local_pca", + default="paper_n100_o20_nocls_h1_ellphi_lpca_power", help="YAML used when run_manifest lacks method overrides (paper no_cls default).", ) p.add_argument("--split", choices=["val", "test"], required=True) diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_teacher_local_pca_power_30ep.py index e9f6ef9..feda950 100644 --- a/experiments/run_teacher_local_pca_power_30ep.py +++ b/experiments/run_teacher_local_pca_power_30ep.py @@ -21,7 +21,7 @@ preflight_tune_production_run, ) -BASE_CONFIG = "elongate_n100_no_cls_full120_teacher_local_pca" +BASE_CONFIG = "paper_n100_o20_nocls_h1_ellphi_lpca_power" TAG_MCC = "power_mcc_valtopo_paper_eval" TAG_WDIST = "power_wdist_valtopo_paper_eval" diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh index e442654..a493a07 100755 --- a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh +++ b/experiments/run_teacher_local_pca_power_30ep_multiseed.sh @@ -48,7 +48,7 @@ MCC_OUT="${MCC_OUT:-outputs/supervised/pwr30_mcc_maha}" LOG_ROOT="${LOG_ROOT:-outputs/supervised/pwr30_multiseed}" EPOCHS="${EPOCHS:-30}" DBSCAN_BACKEND="${DBSCAN_BACKEND:-mahalanobis}" -BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_full120_teacher_local_pca}" +BASE_CONFIG="${BASE_CONFIG:-paper_n100_o20_nocls_h1_ellphi_lpca_power}" # Declared paper contract variant the tune JSONs must match (elongate | elongate_barrier). ANISO_VARIANT="${ANISO_VARIANT:-elongate}" diff --git a/experiments/run_tune_local_pca_power_mcc_parallel.sh b/experiments/run_tune_local_pca_power_mcc_parallel.sh index de8bc8e..232c495 100755 --- a/experiments/run_tune_local_pca_power_mcc_parallel.sh +++ b/experiments/run_tune_local_pca_power_mcc_parallel.sh @@ -25,7 +25,7 @@ N_TRIALS="${2:-24}" TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" DBSCAN_BACKEND="${5:-mahalanobis}" -BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power}" +BASE_CONFIG="${BASE_CONFIG:-tune_n100_o20_nocls_h1_ellphi_lpca_power}" OUT_BASE="${OUT_BASE:-outputs/tune/pwr_mcc_dbscan_${DBSCAN_BACKEND}}" STUDY_NAME="${STUDY_NAME:-elongate_local_pca_power_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}}" STORAGE="sqlite:///${OUT_BASE}/study.db" diff --git a/experiments/run_tune_local_pca_power_wdist_parallel.sh b/experiments/run_tune_local_pca_power_wdist_parallel.sh index e39368f..c70f8f4 100755 --- a/experiments/run_tune_local_pca_power_wdist_parallel.sh +++ b/experiments/run_tune_local_pca_power_wdist_parallel.sh @@ -21,7 +21,7 @@ N_WORKERS="${1:-4}" N_TRIALS="${2:-24}" TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" -BASE_CONFIG="${BASE_CONFIG:-elongate_n100_no_cls_tune_local_pca_ellphi_power}" +BASE_CONFIG="${BASE_CONFIG:-tune_n100_o20_nocls_h1_ellphi_lpca_power}" OUT_BASE="${OUT_BASE:-outputs/tune/pwr_wdist}" STUDY_NAME="${STUDY_NAME:-elongate_local_pca_power_wdist_${BACKEND}}" STORAGE="sqlite:///${OUT_BASE}/study.db" diff --git a/experiments/tune_elongate_mcc.py b/experiments/tune_elongate_mcc.py index 161721a..4370bc6 100644 --- a/experiments/tune_elongate_mcc.py +++ b/experiments/tune_elongate_mcc.py @@ -202,7 +202,7 @@ def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) p.add_argument( "--base-config", - default="elongate_n100_no_cls_tune_local_pca_ellphi_power", + default="tune_n100_o20_nocls_h1_ellphi_lpca_power", ) p.add_argument("--n-trials", type=int, default=24) p.add_argument( diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_elongate_wdist.py index 2625f37..cb906f7 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_elongate_wdist.py @@ -9,7 +9,7 @@ 2. Loads ``best_model.pth`` (val_topo selection checkpoint; hard-fail if missing). 3. Objective = mean val **topo W-Dist** (learned ellipses vs local_pca teacher PD). -Base config ``elongate_n100_no_cls_tune_local_pca_ellphi_power`` sets +Base config ``tune_n100_o20_nocls_h1_ellphi_lpca_power`` sets ``teacher_mode: local_pca``, H1-only persistence, and ``size_mode: power``. ``w_class: 0``, ``selection.metric: val_topo``. @@ -193,7 +193,7 @@ def objective(trial: optuna.Trial) -> float: def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) # No implicit default: every study must state its config surface explicitly - # (power stack uses elongate_n100_no_cls_tune_local_pca_ellphi_power). + # (power stack uses tune_n100_o20_nocls_h1_ellphi_lpca_power). p.add_argument("--base-config", type=str, required=True) p.add_argument("--n-trials", type=int, default=50) p.add_argument( diff --git a/tda_ml/run_paths.py b/tda_ml/run_paths.py index 283403a..05635b3 100644 --- a/tda_ml/run_paths.py +++ b/tda_ml/run_paths.py @@ -55,9 +55,15 @@ def shorten_config_id(config_id: str, *, max_len: int = 24) -> str: return f"smk_{m.group(1)}" slug = config_id - for prefix in ("elongate_n100_no_cls_", "teacher_local_pca_"): + for prefix in ( + "paper_n100_o20_nocls_", + "tune_n100_o20_nocls_", + "teacher_local_pca_", + "elongate_n100_no_cls_", # legacy slug prefix (old run dirs) + ): if slug.startswith(prefix): slug = slug[len(prefix) :] + break if len(slug) > max_len: slug = slug[:max_len] return slug diff --git a/tests/test_paper_eval_imports.py b/tests/test_paper_eval_imports.py index 5d246d5..5b585d6 100644 --- a/tests/test_paper_eval_imports.py +++ b/tests/test_paper_eval_imports.py @@ -36,15 +36,10 @@ def test_train_and_eval_share_data_root(self): def test_public_paper_configs_pass_contract(self): assert_paper_no_cls_contract( - load_config("elongate_n100_no_cls_full120_teacher_local_pca") + load_config("paper_n100_o20_nocls_h1_ellphi_lpca_power") ) assert_paper_no_cls_contract( - load_config("elongate_n100_no_cls_tune_local_pca_ellphi_power") - ) - assert_paper_no_cls_contract( - load_config( - "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier" - ) + load_config("tune_n100_o20_nocls_h1_ellphi_lpca_power") ) def test_paper_eval_requires_best_model_only(self): @@ -59,7 +54,7 @@ def test_paper_eval_requires_best_model_only(self): resolve_val_topo_checkpoint(run_dir) def test_manifest_records_zero_pad_constant(self): - cfg = load_config("elongate_n100_no_cls_full120_teacher_local_pca") + cfg = load_config("paper_n100_o20_nocls_h1_ellphi_lpca_power") fields = build_reproducibility_manifest_fields(cfg) self.assertIn("ZERO_PAD_ABS_SUM", fields["numerical_constants"]) diff --git a/tests/test_paper_production_gates.py b/tests/test_paper_production_gates.py index 2771d73..6e37eca 100644 --- a/tests/test_paper_production_gates.py +++ b/tests/test_paper_production_gates.py @@ -110,7 +110,7 @@ def test_invalid_homology_rejected(self): from tda_ml.config import deep_update, load_config cfg = load_config( - "elongate_n100_no_cls_full120_teacher_local_pca", + "paper_n100_o20_nocls_h1_ellphi_lpca_power", project_root=REPO, ) bad = deep_update( diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index 993b592..9784318 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -15,17 +15,20 @@ class TestTuneTrialConfig(unittest.TestCase): - def test_baseline_declares_topology_contract(self): + def test_paper_declares_topology_contract(self): from tda_ml.config import load_config - cfg = load_config("elongate_n100_no_cls_full120_baseline") + cfg = load_config("paper_n100_o20_nocls_h1_ellphi_lpca_power") self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) self.assertFalse(cfg["model"]["topology_loss"]["prob_weighting"]) - self.assertEqual(cfg["loss"]["teacher_mode"], "euclidean") + self.assertEqual(cfg["loss"]["teacher_mode"], "local_pca") + self.assertEqual(cfg["model"]["topology_loss"]["distance_backend"], "ellphi") + self.assertEqual(cfg["data"]["max_points"], 100) + self.assertEqual(cfg["data"]["num_outliers"], 20) def test_canonical_power_tune_base(self): cfg = build_wdist_trial( - "elongate_n100_no_cls_tune_local_pca_ellphi_power", + "tune_n100_o20_nocls_h1_ellphi_lpca_power", w_aniso=0.1, w_size=0.2, w_topo=0.3, @@ -46,7 +49,7 @@ def test_canonical_power_tune_base(self): def test_power_tune_preserves_h1_hard_fail_stack(self): cfg = build_wdist_trial( - "elongate_n100_no_cls_tune_local_pca_ellphi_power", + "tune_n100_o20_nocls_h1_ellphi_lpca_power", w_aniso=0.1, w_size=0.2, w_topo=0.3, @@ -68,7 +71,7 @@ def test_wdist_builder_overrides_divergent_yaml_homology_and_aniso(self): from tda_ml.config import load_config divergent = load_config( - "elongate_n100_no_cls_tune_local_pca_ellphi_power", + "tune_n100_o20_nocls_h1_ellphi_lpca_power", project_root=REPO, ) divergent["model"]["topology_loss"]["homology_dimensions"] = [0, 1] @@ -115,25 +118,41 @@ def test_wdist_builder_overrides_divergent_yaml_homology_and_aniso(self): self.assertEqual(cfg["loss"]["aniso_mode"], "elongate") def test_wdist_builder_mirrors_barrier_variant_from_base_config(self): - cfg = build_wdist_trial( - "elongate_n100_no_cls_tune_local_pca_ellphi_power_h1_neartangent_barrier", - w_aniso=0.1, - w_size=0.2, - w_topo=0.3, - lr=1e-4, - backend="ellphi", - tune_epochs=5, - out_base="outputs/x", - trial_number=3, - size_mode="power", + from copy import deepcopy + + from tda_ml.config import load_config + + # No public Methods YAML for barrier; declare the variant inline. + barrier = load_config( + "tune_n100_o20_nocls_h1_ellphi_lpca_power", + project_root=REPO, ) + barrier["loss"]["aniso_mode"] = "elongate_barrier" + barrier["loss"]["aniso_barrier_threshold"] = 6.0 + + with mock.patch( + "tune_elongate_wdist.load_config", + side_effect=lambda *a, **k: deepcopy(barrier), + ): + cfg = build_wdist_trial( + "ignored", + w_aniso=0.1, + w_size=0.2, + w_topo=0.3, + lr=1e-4, + backend="ellphi", + tune_epochs=5, + out_base="outputs/x", + trial_number=3, + size_mode="power", + ) self.assertEqual(cfg["loss"]["aniso_mode"], "elongate_barrier") self.assertEqual(cfg["loss"]["aniso_barrier_threshold"], 6.0) self.assertEqual(cfg["model"]["topology_loss"]["homology_dimensions"], [1]) def test_mcc_builder_forces_homology_h1(self): cfg = build_mcc_trial( - "elongate_n100_no_cls_tune_local_pca_ellphi_power", + "tune_n100_o20_nocls_h1_ellphi_lpca_power", w_aniso=0.1, w_size=0.2, w_topo=0.3, From 47f77868b304372d94928504479a75523f946462 Mon Sep 17 00:00:00 2001 From: murata Date: Sun, 26 Jul 2026 15:27:49 +0900 Subject: [PATCH 42/44] =?UTF-8?q?refactor:=20=E5=AE=9F=E9=A8=93=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E5=90=8D=E3=82=92=E8=A8=88?= =?UTF-8?q?=E7=AE=97=E5=86=85=E5=AE=B9=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B?= =?UTF-8?q?=E3=81=A6=E5=8D=98=E7=B4=94=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tune_elongate_* / run_teacher_local_pca_power_* 等を tune_wdist / run_paper_30ep / eval_paper 等へ改名。 出力パス・eval タグ・run_slug も paper/tune 語彙に揃える。 Co-authored-by: Cursor --- README.md | 6 ++-- REPRODUCIBILITY.md | 28 ++++++++-------- ...e_n100_o20_nocls_h1_ellphi_lpca_power.yaml | 2 +- ...tiseed.py => aggregate_paper_multiseed.py} | 18 +++++------ ...e_paper_baselines.py => eval_baselines.py} | 8 ++--- ...aluate_paper_protocol.py => eval_paper.py} | 6 ++-- experiments/launch_detached_screen.sh | 6 ++-- ...ep_freshness.py => paper_run_freshness.py} | 2 +- ...al_pca_power_30ep.py => run_paper_30ep.py} | 16 +++++----- ...ltiseed.sh => run_paper_30ep_multiseed.sh} | 32 +++++++++---------- .../{tune_elongate_mcc.py => tune_mcc.py} | 14 ++++---- ...r_mcc_parallel.sh => tune_mcc_parallel.sh} | 20 ++++++------ ...power_objectives.sh => tune_objectives.sh} | 20 ++++++------ .../{tune_elongate_wdist.py => tune_wdist.py} | 22 ++++++------- ...ist_parallel.sh => tune_wdist_parallel.sh} | 22 ++++++------- scripts/new_experiment.sh | 4 +-- tda_ml/model_selection.py | 2 +- tda_ml/run_paths.py | 10 ++++-- tests/test_paper_eval_imports.py | 6 ++-- tests/test_paper_production_gates.py | 18 +++++------ tests/test_run_paths.py | 10 +++--- tests/test_tune_config.py | 10 +++--- 22 files changed, 143 insertions(+), 139 deletions(-) rename experiments/{aggregate_power_30ep_multiseed.py => aggregate_paper_multiseed.py} (93%) rename experiments/{evaluate_paper_baselines.py => eval_baselines.py} (99%) rename experiments/{evaluate_paper_protocol.py => eval_paper.py} (99%) rename experiments/{power_30ep_freshness.py => paper_run_freshness.py} (98%) rename experiments/{run_teacher_local_pca_power_30ep.py => run_paper_30ep.py} (95%) rename experiments/{run_teacher_local_pca_power_30ep_multiseed.sh => run_paper_30ep_multiseed.sh} (81%) rename experiments/{tune_elongate_mcc.py => tune_mcc.py} (96%) rename experiments/{run_tune_local_pca_power_mcc_parallel.sh => tune_mcc_parallel.sh} (80%) rename experiments/{run_tune_local_pca_power_objectives.sh => tune_objectives.sh} (63%) rename experiments/{tune_elongate_wdist.py => tune_wdist.py} (94%) rename experiments/{run_tune_local_pca_power_wdist_parallel.sh => tune_wdist_parallel.sh} (77%) diff --git a/README.md b/README.md index 916ceef..f39e397 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,12 @@ Config: `paper_n100_o20_nocls_h1_ellphi_lpca_power`. ```bash # 1) Tune once (Optuna sampler seeds differ per worker; data seed in YAML is 42). # Default MODE=both also runs the secondary MCC study; main table needs wdist. -MODE=wdist bash experiments/run_tune_local_pca_power_objectives.sh +MODE=wdist bash experiments/tune_objectives.sh # 2) Fixed W-Dist weights → 30ep × 5 seeds → paper eval (+ baselines separately) -bash experiments/run_teacher_local_pca_power_30ep_multiseed.sh wdist +bash experiments/run_paper_30ep_multiseed.sh wdist -uv run python experiments/evaluate_paper_baselines.py \ +uv run python experiments/eval_baselines.py \ --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \ --out-dir outputs/paper_baselines ``` diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index f38bff7..cf5893a 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -7,20 +7,20 @@ - **含む:** `tda_ml/`、**`configs/` 直下の正本 YAML**(`base.yaml` と `reproduce` / `dev` / `prod` / `test_fast`、および論文比較用の `paper_n100_o20_nocls_h1_ellphi_lpca_power` / `tune_n100_o20_nocls_h1_ellphi_lpca_power`)、`tests/`、追跡されている `scripts/`、論文・再現用 `experiments/`(下記)、および `README.md` / `REPRODUCIBILITY.md` / `pyproject.toml` / `uv.lock` / `LICENSE` / `CITATION.cff` などのメタデータ。 - **含めない:** `docs/` 以下(**ローカル実験メモ**;公開方針で git に入れる場合は別途決定)、`configs/archive/`(履歴用 YAML を置く場合は **ローカルのみ**)、`outputs/`、`data/`、`.cursor/` など。`load_config("archive/...")` は、手元に `configs/archive/*.yaml` を置いた場合にのみ使えます。 -### 論文比較(ellphi + power 二目的)で使う `experiments/` +### 論文比較(W-Dist / MCC 二目的)で使う `experiments/` -**論文主表の提案:** W-Dist tune 重みの 30ep 5-seed(`run_teacher_local_pca_power_30ep_multiseed.sh wdist`)。 +**論文主表の提案:** W-Dist tune 重みの 30ep 5-seed(`run_paper_30ep_multiseed.sh wdist`)。 **主張:** Euclidean DBSCAN / ADBSCAN と **同程度の外れ値除去性能**(MCC / G-Mean;5 seed の mean ± sample std による**記述的**比較。同等性検定は行わない)。主表に Topo W. 列は載せない。 **比較の非対称:** ADBSCAN は学習なしの局所 PCA 楕円ベースライン。提案法は同一データで 30ep 学習する(計算資源・パラメータ更新は対等ではない)。 **正本 config:** `paper_n100_o20_nocls_h1_ellphi_lpca_power`(`w_class=0`, `homology_dimensions=[1]`, `aniso_mode=elongate`)。 -出力先は `WDIST_OUT` / `MCC_OUT` / `LOG_ROOT`(既定: `outputs/supervised/pwr30_*`)で明示する(生成物は git に含めない)。 +出力先は `WDIST_OUT` / `MCC_OUT` / `LOG_ROOT`(既定: `outputs/supervised/paper30_*`)で明示する(生成物は git に含めない)。 | 区分 | パス | |------|------| -| 本番 5-seed | `run_teacher_local_pca_power_30ep_multiseed.sh`, `run_teacher_local_pca_power_30ep.py`, `aggregate_power_30ep_multiseed.py` | -| paper eval | `evaluate_paper_protocol.py`(`best_model.pth` のみ) | -| ベースライン | `evaluate_paper_baselines.py` | -| チューニング(重みの出所) | `tune_elongate_wdist.py`, `tune_elongate_mcc.py`, `run_tune_local_pca_power_*` | +| 本番 5-seed | `run_paper_30ep_multiseed.sh`, `run_paper_30ep.py`, `aggregate_paper_multiseed.py` | +| paper eval | `eval_paper.py`(`best_model.pth` のみ) | +| ベースライン | `eval_baselines.py` | +| チューニング(重みの出所) | `tune_wdist.py`, `tune_mcc.py`, `tune_wdist_parallel.sh`, `tune_mcc_parallel.sh`, `tune_objectives.sh` | **実行記録:** 各 run の `source_revision`(git HEAD)は `logs/run_manifest.json` および `paper_metrics_*.json` に記録。未コミットのまま実行した場合、リモート clone では数値が再現できない。 @@ -31,19 +31,19 @@ checkpoint は **`best_model.pth`(`selection=val_topo`)のみ**。欠落は **1. Tune(主表は W-Dist;YAML の data.seed=42。Optuna sampler seed は worker ごとに異なる)** ```bash -MODE=wdist bash experiments/run_tune_local_pca_power_objectives.sh +MODE=wdist bash experiments/tune_objectives.sh ``` **2. 本番 30ep × 5-seed(tune JSON 必須)→ 内部で paper eval** ```bash -bash experiments/run_teacher_local_pca_power_30ep_multiseed.sh wdist +bash experiments/run_paper_30ep_multiseed.sh wdist ``` **3. ベースライン(ADBSCAN 等)** ```bash -uv run python experiments/evaluate_paper_baselines.py \ +uv run python experiments/eval_baselines.py \ --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \ --out-dir outputs/paper_baselines ``` @@ -51,11 +51,11 @@ uv run python experiments/evaluate_paper_baselines.py \ 単一 seed・手動 eval: ```bash -uv run python experiments/run_teacher_local_pca_power_30ep.py \ - --tune-json outputs/tune/pwr_wdist/best_elongate_wdist_ellphi.json \ +uv run python experiments/run_paper_30ep.py \ + --tune-json outputs/tune/wdist/best_wdist_ellphi.json \ --seed 42 -uv run python experiments/evaluate_paper_protocol.py \ +uv run python experiments/eval_paper.py \ --run-dir outputs/supervised/.../pwr_s42_ \ --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \ --split val @@ -187,7 +187,7 @@ uv run python experiments/run_backend_multiseed.py \ ### ellphi + power:二目的チューニング(実験メモ) -no_cls・local_pca 教師・`size_mode=power` スタックでは、`run_tune_local_pca_power_objectives.sh` が **W-Dist 最小**と **DBSCAN MCC 最大**の 2 本の Optuna study を実行し、`run_teacher_local_pca_power_30ep_multiseed.sh` が固定した best 重みで 30ep 本番を実行します。 +no_cls・local_pca 教師・`size_mode=power` スタックでは、`tune_objectives.sh` が **W-Dist 最小**と **DBSCAN MCC 最大**の 2 本の Optuna study を実行し、`run_paper_30ep_multiseed.sh` が固定した best 重みで 30ep 本番を実行します。 要点: **学習 topo loss と教師 PD は ellphi**;**MCC のチューニング objective と paper eval の DBSCAN は mahalanobis**(filtration 時刻をクラスタリング距離に使わない)。 diff --git a/configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml b/configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml index 9b9b065..9d4f275 100644 --- a/configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml +++ b/configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml @@ -1,7 +1,7 @@ # Shared Optuna tune base for W-Dist and MCC studies (same stack as paper YAML). # Naming matches paper_n100_o20_nocls_h1_ellphi_lpca_power except role=tune # (default epochs=20; drivers may override). Trial objective is chosen by the -# tune driver (tune_elongate_wdist.py vs tune_elongate_mcc.py), not by config_id. +# tune driver (tune_wdist.py vs tune_mcc.py), not by config_id. meta: config_id: "tune_n100_o20_nocls_h1_ellphi_lpca_power" diff --git a/experiments/aggregate_power_30ep_multiseed.py b/experiments/aggregate_paper_multiseed.py similarity index 93% rename from experiments/aggregate_power_30ep_multiseed.py rename to experiments/aggregate_paper_multiseed.py index abfbac4..68e6349 100644 --- a/experiments/aggregate_power_30ep_multiseed.py +++ b/experiments/aggregate_paper_multiseed.py @@ -152,17 +152,17 @@ def write_csv(path: Path, rows: list[dict[str, Any]]) -> None: def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--wdist-out", type=Path, default=REPO_ROOT / "outputs/supervised/pwr30_wdist") - p.add_argument("--mcc-out", type=Path, default=REPO_ROOT / "outputs/supervised/pwr30_mcc_maha") + p.add_argument("--wdist-out", type=Path, default=REPO_ROOT / "outputs/supervised/paper30_wdist") + p.add_argument("--mcc-out", type=Path, default=REPO_ROOT / "outputs/supervised/paper30_mcc") p.add_argument( "--wdist-tune-json", - default="outputs/tune/pwr_wdist/best_elongate_wdist_ellphi.json", + default="outputs/tune/wdist/best_wdist_ellphi.json", ) p.add_argument( "--mcc-tune-json", - default="outputs/tune/pwr_mcc_dbscan_mahalanobis/best_elongate_mcc_ellphi_dbscan_mahalanobis.json", + default="outputs/tune/mcc_dbscan_mahalanobis/best_mcc_ellphi_dbscan_mahalanobis.json", ) - p.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs/supervised/pwr30_multiseed") + p.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs/supervised/paper30_multiseed") p.add_argument("--seeds", type=int, nargs="+", default=PAPER_SEEDS) p.add_argument( "--methods", @@ -203,16 +203,16 @@ def main() -> int: wdist_by_seed = discover_seed_metrics( args.wdist_out, - "pwr_s*/logs/paper_metrics_test_power_wdist_valtopo_paper_eval.json", + "paper_s*/logs/paper_metrics_test_wdist.json", ) mcc_by_seed = discover_seed_metrics( args.mcc_out, - "pwr_s*/logs/paper_metrics_test_power_mcc_valtopo_paper_eval.json", + "paper_s*/logs/paper_metrics_test_mcc.json", ) method_specs = { - "wdist": ("proposed_wdist_tune_30ep", wdist_by_seed, str(tune_paths["wdist"])), - "mcc": ("proposed_mcc_tune_30ep", mcc_by_seed, str(tune_paths["mcc"])), + "wdist": ("proposed_wdist_30ep", wdist_by_seed, str(tune_paths["wdist"])), + "mcc": ("proposed_mcc_30ep", mcc_by_seed, str(tune_paths["mcc"])), } rows: list[dict[str, Any]] = [] for key in args.methods: diff --git a/experiments/evaluate_paper_baselines.py b/experiments/eval_baselines.py similarity index 99% rename from experiments/evaluate_paper_baselines.py rename to experiments/eval_baselines.py index ca96465..41e3707 100644 --- a/experiments/evaluate_paper_baselines.py +++ b/experiments/eval_baselines.py @@ -2,7 +2,7 @@ """ Paper-aligned baseline evaluation. -Same data split as ``evaluate_paper_protocol.py`` under the paper elongate +Same data split as ``eval_paper.py`` under the paper config (typically ``paper_n100_o20_nocls_h1_ellphi_lpca_power``), seeds 42 / 123 / 456 / 789 / 1024: tune hyperparameters on validation clouds, report MCC / G-Mean on test. Topo W-Dist is computed for diagnostics only and is @@ -17,7 +17,7 @@ Usage:: - uv run python experiments/evaluate_paper_baselines.py \\ + uv run python experiments/eval_baselines.py \\ --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \\ --out-dir outputs/paper_baselines """ @@ -39,7 +39,7 @@ from sklearn.neighbors import LocalOutlierFactor from tqdm import tqdm -from experiments.evaluate_paper_protocol import ( +from experiments.eval_paper import ( CloudMetrics, _aggregate_classification_metrics, _aggregate_cloud_metrics, @@ -465,7 +465,7 @@ def aggregate_method_results(seed_results: Sequence[SeedResult]) -> dict[str, An def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description=__doc__) - # No implicit default: the n100 paper comparison must pass the elongate config + # No implicit default: the n100 paper comparison must pass the paper config # (e.g. paper_n100_o20_nocls_h1_ellphi_lpca_power); baselines share its # data settings and evaluation.dbscan / evaluation.baselines grids. p.add_argument("--base-config", type=str, required=True) diff --git a/experiments/evaluate_paper_protocol.py b/experiments/eval_paper.py similarity index 99% rename from experiments/evaluate_paper_protocol.py rename to experiments/eval_paper.py index c94e24e..9afaa3a 100644 --- a/experiments/evaluate_paper_protocol.py +++ b/experiments/eval_paper.py @@ -7,12 +7,12 @@ Usage:: - uv run python experiments/evaluate_paper_protocol.py \\ - --run-dir outputs/supervised/.../pwr_s42_ \\ + uv run python experiments/eval_paper.py \\ + --run-dir outputs/supervised/.../paper_s42_ \\ --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \\ --split val - uv run python experiments/evaluate_paper_protocol.py \\ + uv run python experiments/eval_paper.py \\ --run-dir ... --split test \\ --dbscan-hparams outputs/.../logs/dbscan_hparams.json """ diff --git a/experiments/launch_detached_screen.sh b/experiments/launch_detached_screen.sh index c4f3ffb..97359d2 100755 --- a/experiments/launch_detached_screen.sh +++ b/experiments/launch_detached_screen.sh @@ -6,9 +6,9 @@ # bash experiments/launch_detached_screen.sh SESSION_NAME LOG_FILE SCRIPT.sh [args...] # # Example: -# bash experiments/launch_detached_screen.sh pwr30 \ -# outputs/supervised/pwr30/driver.log \ -# experiments/run_teacher_local_pca_power_30ep_multiseed.sh wdist +# bash experiments/launch_detached_screen.sh paper30 \ +# outputs/supervised/paper30/driver.log \ +# experiments/run_paper_30ep_multiseed.sh wdist set -euo pipefail diff --git a/experiments/power_30ep_freshness.py b/experiments/paper_run_freshness.py similarity index 98% rename from experiments/power_30ep_freshness.py rename to experiments/paper_run_freshness.py index ad51535..d83978b 100644 --- a/experiments/power_30ep_freshness.py +++ b/experiments/paper_run_freshness.py @@ -39,7 +39,7 @@ def inspect_seed_metrics( expected_revision: str | None = None, ) -> tuple[str, list[Path]]: """Return ``(status, paths)`` where status is fresh|missing|stale|ambiguous.""" - pattern = f"pwr_s{seed}_*/logs/paper_metrics_test_{tag}.json" + pattern = f"paper_s{seed}_*/logs/paper_metrics_test_{tag}.json" matches = sorted(out_base.glob(pattern)) if not matches: return "missing", [] diff --git a/experiments/run_teacher_local_pca_power_30ep.py b/experiments/run_paper_30ep.py similarity index 95% rename from experiments/run_teacher_local_pca_power_30ep.py rename to experiments/run_paper_30ep.py index feda950..ca72abc 100644 --- a/experiments/run_teacher_local_pca_power_30ep.py +++ b/experiments/run_paper_30ep.py @@ -22,8 +22,8 @@ ) BASE_CONFIG = "paper_n100_o20_nocls_h1_ellphi_lpca_power" -TAG_MCC = "power_mcc_valtopo_paper_eval" -TAG_WDIST = "power_wdist_valtopo_paper_eval" +TAG_MCC = "mcc" +TAG_WDIST = "wdist" def parse_args() -> argparse.Namespace: @@ -119,9 +119,9 @@ def main() -> int: out_base = args.out_base if out_base is None: if tag == TAG_WDIST: - out_base = REPO_ROOT / "outputs/supervised/pwr30_wdist" + out_base = REPO_ROOT / "outputs/supervised/paper30_wdist" else: - out_base = REPO_ROOT / "outputs/supervised/pwr30_mcc_maha" + out_base = REPO_ROOT / "outputs/supervised/paper30_mcc" out_base = out_base.resolve() out_base.mkdir(parents=True, exist_ok=True) @@ -176,8 +176,8 @@ def main() -> int: cfg, { "meta": { - "config_id": f"teacher_local_pca_power_seed{args.seed}", - "run_slug": f"pwr_s{args.seed}", + "config_id": f"paper_seed{args.seed}", + "run_slug": f"paper_s{args.seed}", }, "loss": loss_overrides, "training": training_overrides, @@ -214,7 +214,7 @@ def main() -> int: "homology_dimensions": cfg["model"]["topology_loss"]["homology_dimensions"], "paper_no_cls_contract": paper_contract, "protocol_note": ( - "power 30ep H1-only: raw ellipse params to ellphi; degenerate geometry " + "paper 30ep H1-only: raw ellipse params to ellphi; degenerate geometry " "hard-fails; tune weights fixed from seed-42 Optuna" ), "reference": tune_source, @@ -259,7 +259,7 @@ def main() -> int: if args.skip_eval: return 0 - eval_script = REPO_ROOT / "experiments" / "evaluate_paper_protocol.py" + eval_script = REPO_ROOT / "experiments" / "eval_paper.py" for split in ("val", "test"): cmd = [ "uv", diff --git a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh b/experiments/run_paper_30ep_multiseed.sh similarity index 81% rename from experiments/run_teacher_local_pca_power_30ep_multiseed.sh rename to experiments/run_paper_30ep_multiseed.sh index a493a07..9c77cf4 100755 --- a/experiments/run_teacher_local_pca_power_30ep_multiseed.sh +++ b/experiments/run_paper_30ep_multiseed.sh @@ -4,16 +4,16 @@ # Protocol: tune once on seed 42 → apply same w_*, lr to all 5 data seeds. # # Usage: -# bash experiments/run_teacher_local_pca_power_30ep_multiseed.sh [MODE] [SEEDS...] +# bash experiments/run_paper_30ep_multiseed.sh [MODE] [SEEDS...] # # MODE: wdist | mcc | both (default both) # SEEDS: default 42 123 456 789 1024 # # Detached (SSH/logout safe; machine reboot still stops the job): # N_WORKERS=4 THREADS_PER_WORKER=4 \ -# bash experiments/launch_detached_screen.sh pwr30_ms \ -# outputs/supervised/pwr30_multiseed/driver.log \ -# experiments/run_teacher_local_pca_power_30ep_multiseed.sh both +# bash experiments/launch_detached_screen.sh paper30_ms \ +# outputs/supervised/paper30_multiseed/driver.log \ +# experiments/run_paper_30ep_multiseed.sh both # # Parallelism: N_WORKERS seeds per objective wave; THREADS_PER_WORKER per process # (bench: raising OMP past 4 does not speed ellphi topo; parallel seeds do). @@ -41,11 +41,11 @@ else SEEDS=(42 123 456 789 1024) fi -WDIST_JSON="${WDIST_JSON:-outputs/tune/pwr_wdist/best_elongate_wdist_ellphi.json}" -MCC_JSON="${MCC_JSON:-outputs/tune/pwr_mcc_dbscan_mahalanobis/best_elongate_mcc_ellphi_dbscan_mahalanobis.json}" -WDIST_OUT="${WDIST_OUT:-outputs/supervised/pwr30_wdist}" -MCC_OUT="${MCC_OUT:-outputs/supervised/pwr30_mcc_maha}" -LOG_ROOT="${LOG_ROOT:-outputs/supervised/pwr30_multiseed}" +WDIST_JSON="${WDIST_JSON:-outputs/tune/wdist/best_wdist_ellphi.json}" +MCC_JSON="${MCC_JSON:-outputs/tune/mcc_dbscan_mahalanobis/best_mcc_ellphi_dbscan_mahalanobis.json}" +WDIST_OUT="${WDIST_OUT:-outputs/supervised/paper30_wdist}" +MCC_OUT="${MCC_OUT:-outputs/supervised/paper30_mcc}" +LOG_ROOT="${LOG_ROOT:-outputs/supervised/paper30_multiseed}" EPOCHS="${EPOCHS:-30}" DBSCAN_BACKEND="${DBSCAN_BACKEND:-mahalanobis}" BASE_CONFIG="${BASE_CONFIG:-paper_n100_o20_nocls_h1_ellphi_lpca_power}" @@ -61,7 +61,7 @@ _metrics_done() { local tag="$3" local tune_json="$4" local rc=0 - uv run python experiments/power_30ep_freshness.py \ + uv run python experiments/paper_run_freshness.py \ --out-base "${out_base}" \ --seed "${seed}" \ --tag "${tag}" \ @@ -93,7 +93,7 @@ _run_seed() { OMP_NUM_THREADS="${THREADS_PER_WORKER}" \ MKL_NUM_THREADS="${THREADS_PER_WORKER}" \ OPENBLAS_NUM_THREADS="${THREADS_PER_WORKER}" \ - uv run python -u experiments/run_teacher_local_pca_power_30ep.py \ + uv run python -u experiments/run_paper_30ep.py \ --base-config "${BASE_CONFIG}" \ --epochs "${EPOCHS}" \ --seed "${seed}" \ @@ -171,14 +171,14 @@ _run_method() { case "${MODE}" in wdist) - _run_method "wdist" "${WDIST_JSON}" "${WDIST_OUT}" "power_wdist_valtopo_paper_eval" + _run_method "wdist" "${WDIST_JSON}" "${WDIST_OUT}" "wdist" ;; mcc) - _run_method "mcc" "${MCC_JSON}" "${MCC_OUT}" "power_mcc_valtopo_paper_eval" + _run_method "mcc" "${MCC_JSON}" "${MCC_OUT}" "mcc" ;; both) - _run_method "wdist" "${WDIST_JSON}" "${WDIST_OUT}" "power_wdist_valtopo_paper_eval" - _run_method "mcc" "${MCC_JSON}" "${MCC_OUT}" "power_mcc_valtopo_paper_eval" + _run_method "wdist" "${WDIST_JSON}" "${WDIST_OUT}" "wdist" + _run_method "mcc" "${MCC_JSON}" "${MCC_OUT}" "mcc" ;; *) echo "Unknown MODE=${MODE} (use wdist, mcc, or both)" >&2 @@ -201,6 +201,6 @@ case "${MODE}" in mcc) AGG_ARGS+=(--methods mcc) ;; esac # Strict: missing seeds or aggregation failure must fail this driver (no || true). -uv run python -u experiments/aggregate_power_30ep_multiseed.py "${AGG_ARGS[@]}" +uv run python -u experiments/aggregate_paper_multiseed.py "${AGG_ARGS[@]}" echo "Done. Logs: ${LOG_ROOT}/" diff --git a/experiments/tune_elongate_mcc.py b/experiments/tune_mcc.py similarity index 96% rename from experiments/tune_elongate_mcc.py rename to experiments/tune_mcc.py index 4370bc6..99f21a0 100644 --- a/experiments/tune_elongate_mcc.py +++ b/experiments/tune_mcc.py @@ -18,7 +18,7 @@ Usage:: - bash experiments/run_tune_local_pca_power_mcc_parallel.sh 4 24 20 ellphi + bash experiments/tune_mcc_parallel.sh 4 24 20 ellphi """ from __future__ import annotations @@ -42,12 +42,12 @@ from tda_ml.supervised_diagnostics import git_revision from tda_ml.topo_wdist import topo_wdist_options_from_config -from evaluate_paper_protocol import ( # noqa: E402 +from eval_paper import ( # noqa: E402 build_split_loader, iter_cloud_predictions, load_model_from_run, ) -from tune_elongate_wdist import mean_val_topo_wdist # noqa: E402 +from tune_wdist import mean_val_topo_wdist # noqa: E402 REPO_ROOT = Path(__file__).resolve().parents[1] CHECKPOINT_POLICY = "val_topo_best" @@ -229,7 +229,7 @@ def parse_args() -> argparse.Namespace: "not a point-to-point distance)." ), ) - p.add_argument("--out-base", default="outputs/tune/pwr_mcc") + p.add_argument("--out-base", default="outputs/tune/mcc") p.add_argument("--size-mode", default="power", choices=["quadratic", "power", "softplus", "barrier"]) p.add_argument("--size-ref", type=float, default=1.34) p.add_argument("--size-power", type=float, default=1.5) @@ -240,7 +240,7 @@ def parse_args() -> argparse.Namespace: ) p.add_argument("--seed", type=int, default=42) p.add_argument("--storage", default=None) - p.add_argument("--study-name", default="elongate_local_pca_power_mcc_ellphi") + p.add_argument("--study-name", default="tune_mcc_ellphi") p.add_argument("--write-best", action="store_true") return p.parse_args() @@ -274,7 +274,7 @@ def main() -> int: preflight.update( { "run_status": "pending", - "command_entry": "experiments/tune_elongate_mcc.py", + "command_entry": "experiments/tune_mcc.py", "source_revision": git_revision(REPO_ROOT), "study_name": args.study_name, "storage": args.storage, @@ -365,7 +365,7 @@ def main() -> int: } out_path = ( Path(args.out_base) - / f"best_elongate_mcc_{args.backend}_dbscan_{args.dbscan_backend}.json" + / f"best_mcc_{args.backend}_dbscan_{args.dbscan_backend}.json" ) out_path.write_text(json.dumps(payload, indent=2) + "\n") print(json.dumps({k: payload[k] for k in ("best_value_mcc", "best_params", "best_val_topo_wdist")}, indent=2)) diff --git a/experiments/run_tune_local_pca_power_mcc_parallel.sh b/experiments/tune_mcc_parallel.sh similarity index 80% rename from experiments/run_tune_local_pca_power_mcc_parallel.sh rename to experiments/tune_mcc_parallel.sh index 232c495..cc4403c 100755 --- a/experiments/run_tune_local_pca_power_mcc_parallel.sh +++ b/experiments/tune_mcc_parallel.sh @@ -8,12 +8,12 @@ # Train per trial: val_topo ckpt (fast). Score: one DBSCAN grid on val at trial end. # # Usage: -# bash experiments/run_tune_local_pca_power_mcc_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] [DBSCAN_BACKEND] +# bash experiments/tune_mcc_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] [DBSCAN_BACKEND] # # Detached: -# bash experiments/launch_detached_screen.sh tune_power_mcc_maha \ -# outputs/tune/pwr_mcc_maha/launcher.log \ -# experiments/run_tune_local_pca_power_mcc_parallel.sh 4 24 20 ellphi mahalanobis +# bash experiments/launch_detached_screen.sh tune_mcc_maha \ +# outputs/tune/mcc_maha/launcher.log \ +# experiments/tune_mcc_parallel.sh 4 24 20 ellphi mahalanobis set -euo pipefail @@ -26,8 +26,8 @@ TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" DBSCAN_BACKEND="${5:-mahalanobis}" BASE_CONFIG="${BASE_CONFIG:-tune_n100_o20_nocls_h1_ellphi_lpca_power}" -OUT_BASE="${OUT_BASE:-outputs/tune/pwr_mcc_dbscan_${DBSCAN_BACKEND}}" -STUDY_NAME="${STUDY_NAME:-elongate_local_pca_power_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}}" +OUT_BASE="${OUT_BASE:-outputs/tune/mcc_dbscan_${DBSCAN_BACKEND}}" +STUDY_NAME="${STUDY_NAME:-tune_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}}" STORAGE="sqlite:///${OUT_BASE}/study.db" THREADS_PER_WORKER="${THREADS_PER_WORKER:-12}" TRIALS_PER_WORKER="${TRIALS_PER_WORKER:-${N_TRIALS}}" @@ -45,7 +45,7 @@ Train ckpt: val_topo. Trial objective: val DBSCAN MCC (grid once per trial). Rationale: ellphi tangency time is a filtration parameter, not a clustering distance; Mahalanobis is the geometrically meaningful DBSCAN metric. -Launch: \`bash experiments/run_tune_local_pca_power_mcc_parallel.sh ${N_WORKERS} ${N_TRIALS} ${TUNE_EPOCHS} ${BACKEND} ${DBSCAN_BACKEND}\` +Launch: \`bash experiments/tune_mcc_parallel.sh ${N_WORKERS} ${N_TRIALS} ${TUNE_EPOCHS} ${BACKEND} ${DBSCAN_BACKEND}\` EOF echo "Power MCC tune: ${N_WORKERS} workers, ${N_TRIALS} trials, ${TUNE_EPOCHS}ep, topo=${BACKEND}, DBSCAN=${DBSCAN_BACKEND}" @@ -75,7 +75,7 @@ for i in $(seq 0 $((N_WORKERS - 1))); do OMP_NUM_THREADS=${THREADS_PER_WORKER} \ MKL_NUM_THREADS=${THREADS_PER_WORKER} \ OPENBLAS_NUM_THREADS=${THREADS_PER_WORKER} \ - nohup uv run python -u experiments/tune_elongate_mcc.py \ + nohup uv run python -u experiments/tune_mcc.py \ --base-config "${BASE_CONFIG}" \ --n-trials "${TRIALS_PER_WORKER}" \ --max-complete-trials "${N_TRIALS}" \ @@ -96,7 +96,7 @@ done for pid in "${pids[@]}"; do wait "${pid}"; done -uv run python -u experiments/tune_elongate_mcc.py \ +uv run python -u experiments/tune_mcc.py \ --base-config "${BASE_CONFIG}" \ --n-trials "${N_TRIALS}" \ --max-complete-trials "${N_TRIALS}" \ @@ -109,4 +109,4 @@ uv run python -u experiments/tune_elongate_mcc.py \ --study-name "${STUDY_NAME}" \ --write-best -echo "Done: ${OUT_BASE}/best_elongate_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}.json" +echo "Done: ${OUT_BASE}/best_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}.json" diff --git a/experiments/run_tune_local_pca_power_objectives.sh b/experiments/tune_objectives.sh similarity index 63% rename from experiments/run_tune_local_pca_power_objectives.sh rename to experiments/tune_objectives.sh index 7ccaa3f..517d1e9 100755 --- a/experiments/run_tune_local_pca_power_objectives.sh +++ b/experiments/tune_objectives.sh @@ -4,14 +4,14 @@ # 2. val DBSCAN MCC max (hyperparams for clustering; maha DBSCAN distance) # # Usage: -# bash experiments/run_tune_local_pca_power_objectives.sh [MODE] [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] +# bash experiments/tune_objectives.sh [MODE] [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] # # MODE: wdist | mcc | both (default both). Runs sequentially when both. # # Detached (both studies, ~8–10h total with 4 workers each): # bash experiments/launch_detached_screen.sh tune_pwr_obj \ -# outputs/tune/pwr_objectives/launcher.log \ -# experiments/run_tune_local_pca_power_objectives.sh both 4 24 20 +# outputs/tune/objectives/launcher.log \ +# experiments/tune_objectives.sh both 4 24 20 set -euo pipefail @@ -25,21 +25,21 @@ TUNE_EPOCHS="${4:-20}" BACKEND="ellphi" DBSCAN_BACKEND="mahalanobis" -LOG_ROOT="${LOG_ROOT:-outputs/tune/pwr_objectives}" +LOG_ROOT="${LOG_ROOT:-outputs/tune/objectives}" mkdir -p "${LOG_ROOT}" run_wdist() { echo "=== W-Dist objective tune ===" - OUT_BASE="${OUT_BASE:-outputs/tune/pwr_wdist}" \ - bash experiments/run_tune_local_pca_power_wdist_parallel.sh \ + OUT_BASE="${OUT_BASE:-outputs/tune/wdist}" \ + bash experiments/tune_wdist_parallel.sh \ "${N_WORKERS}" "${N_TRIALS}" "${TUNE_EPOCHS}" "${BACKEND}" \ 2>&1 | tee "${LOG_ROOT}/wdist_launch.log" } run_mcc() { echo "=== MCC objective tune (DBSCAN=${DBSCAN_BACKEND}) ===" - OUT_BASE="${OUT_BASE:-outputs/tune/pwr_mcc_dbscan_${DBSCAN_BACKEND}}" \ - bash experiments/run_tune_local_pca_power_mcc_parallel.sh \ + OUT_BASE="${OUT_BASE:-outputs/tune/mcc_dbscan_${DBSCAN_BACKEND}}" \ + bash experiments/tune_mcc_parallel.sh \ "${N_WORKERS}" "${N_TRIALS}" "${TUNE_EPOCHS}" "${BACKEND}" "${DBSCAN_BACKEND}" \ 2>&1 | tee "${LOG_ROOT}/mcc_launch.log" } @@ -58,5 +58,5 @@ case "${MODE}" in esac echo "Objectives complete (mode=${MODE})." -echo " W-Dist best: outputs/tune/pwr_wdist/best_elongate_wdist_${BACKEND}.json" -echo " MCC best: outputs/tune/pwr_mcc_dbscan_${DBSCAN_BACKEND}/best_elongate_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}.json" +echo " W-Dist best: outputs/tune/wdist/best_wdist_${BACKEND}.json" +echo " MCC best: outputs/tune/mcc_dbscan_${DBSCAN_BACKEND}/best_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}.json" diff --git a/experiments/tune_elongate_wdist.py b/experiments/tune_wdist.py similarity index 94% rename from experiments/tune_elongate_wdist.py rename to experiments/tune_wdist.py index cb906f7..bd79d4b 100644 --- a/experiments/tune_elongate_wdist.py +++ b/experiments/tune_wdist.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Optuna tuning of elongate hyperparameters (val topo W-Dist objective). +Optuna tuning of paper loss weights (val topo W-Dist objective). Search space (log-uniform): ``w_aniso``, ``w_size``, ``w_topo``, ``lr``. @@ -15,7 +15,7 @@ Usage (parallel, recommended):: - bash experiments/run_tune_local_pca_power_wdist_parallel.sh 8 50 20 ellphi + bash experiments/tune_wdist_parallel.sh 8 50 20 ellphi """ from __future__ import annotations @@ -38,7 +38,7 @@ from tda_ml.supervised_diagnostics import git_revision from tda_ml.topo_wdist import TopoWdistOptions, compute_topo_wdist, topo_wdist_options_from_config -from evaluate_paper_protocol import ( # noqa: E402 +from eval_paper import ( # noqa: E402 build_split_loader, iter_cloud_predictions, load_model_from_run, @@ -48,7 +48,7 @@ CHECKPOINT_POLICY = "val_topo_best" SAVE_EVERY = 1 -# Narrow bands (shared with tune_elongate_mcc power protocol). +# Narrow bands (shared with tune_mcc power protocol). NARROW_W_TOPO_RANGE = (0.05, 0.25) NARROW_W_SIZE_RANGE = (0.1, 0.6) NARROW_W_ANISO_RANGE = (0.03, 0.15) @@ -91,7 +91,7 @@ def build_trial_config( ) -> dict[str, Any]: cfg = load_config(base_config, project_root=REPO_ROOT) overrides = { - "meta": {"config_id": f"tune_elongate_t{trial_number:03d}", "run_slug": f"t{trial_number:03d}"}, + "meta": {"config_id": f"tune_t{trial_number:03d}", "run_slug": f"t{trial_number:03d}"}, "loss": { "w_aniso": float(w_aniso), "w_size": float(w_size), @@ -216,7 +216,7 @@ def parse_args() -> argparse.Namespace: choices=["mahalanobis", "ellphi"], help="Training topo-loss backend (ellphi = ellipse tangency filtration).", ) - p.add_argument("--out-base", type=str, default="outputs/tune/pwr_wdist") + p.add_argument("--out-base", type=str, default="outputs/tune/wdist") p.add_argument( "--size-mode", default="power", @@ -227,7 +227,7 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--narrow-search", action="store_true", - help="Use narrow search bands (same as tune_elongate_mcc power protocol).", + help="Use narrow search bands (same as tune_mcc power protocol).", ) p.add_argument("--seed", type=int, default=42, help="Optuna sampler seed") p.add_argument( @@ -239,13 +239,13 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--study-name", type=str, - default="elongate_wdist_4d", + default="tune_wdist", help="Study name (shared across parallel workers when using --storage).", ) p.add_argument( "--write-best", action="store_true", - help="Write best_elongate_wdist.json at the end (run once after all workers).", + help="Write best_wdist.json at the end (run once after all workers).", ) return p.parse_args() @@ -279,7 +279,7 @@ def main() -> int: preflight.update( { "run_status": "pending", - "command_entry": "experiments/tune_elongate_wdist.py", + "command_entry": "experiments/tune_wdist.py", "source_revision": git_revision(REPO_ROOT), "study_name": args.study_name, "storage": args.storage, @@ -365,7 +365,7 @@ def main() -> int: for t in study.trials ], } - out_path = Path(args.out_base) / f"best_elongate_wdist_{args.backend}.json" + out_path = Path(args.out_base) / f"best_wdist_{args.backend}.json" out_path.write_text(json.dumps(payload, indent=2) + "\n") print(json.dumps({k: payload[k] for k in ( "best_value_wdist", "best_params", "best_checkpoint_epoch", diff --git a/experiments/run_tune_local_pca_power_wdist_parallel.sh b/experiments/tune_wdist_parallel.sh similarity index 77% rename from experiments/run_tune_local_pca_power_wdist_parallel.sh rename to experiments/tune_wdist_parallel.sh index c70f8f4..172d876 100755 --- a/experiments/run_tune_local_pca_power_wdist_parallel.sh +++ b/experiments/tune_wdist_parallel.sh @@ -2,15 +2,15 @@ # Optuna tune: local_pca + size_mode=power, objective = val topo W-Dist. # # Train per trial: val_topo ckpt. Score: mean val topo W-Dist (ellphi teacher PD). -# Search bands match tune_elongate_mcc power protocol (--narrow-search). +# Search bands match tune_mcc power protocol (--narrow-search). # # Usage: -# bash experiments/run_tune_local_pca_power_wdist_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] +# bash experiments/tune_wdist_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] # # Detached: -# bash experiments/launch_detached_screen.sh tune_power_wdist \ -# outputs/tune/pwr_wdist/launcher.log \ -# experiments/run_tune_local_pca_power_wdist_parallel.sh 4 24 20 ellphi +# bash experiments/launch_detached_screen.sh tune_wdist \ +# outputs/tune/wdist/launcher.log \ +# experiments/tune_wdist_parallel.sh 4 24 20 ellphi set -euo pipefail @@ -22,8 +22,8 @@ N_TRIALS="${2:-24}" TUNE_EPOCHS="${3:-20}" BACKEND="${4:-ellphi}" BASE_CONFIG="${BASE_CONFIG:-tune_n100_o20_nocls_h1_ellphi_lpca_power}" -OUT_BASE="${OUT_BASE:-outputs/tune/pwr_wdist}" -STUDY_NAME="${STUDY_NAME:-elongate_local_pca_power_wdist_${BACKEND}}" +OUT_BASE="${OUT_BASE:-outputs/tune/wdist}" +STUDY_NAME="${STUDY_NAME:-tune_wdist_${BACKEND}}" STORAGE="sqlite:///${OUT_BASE}/study.db" THREADS_PER_WORKER="${THREADS_PER_WORKER:-12}" TRIALS_PER_WORKER="${TRIALS_PER_WORKER:-${N_TRIALS}}" @@ -37,7 +37,7 @@ Train topo-loss backend: ${BACKEND} (ellipse tangency filtration). Train ckpt: val_topo. Trial objective: mean val **topo W-Dist** (learned ellipses vs teacher PD). Search: w_topo, w_aniso, w_size, lr (narrow; same bands as MCC power tune). -Launch: \`bash experiments/run_tune_local_pca_power_wdist_parallel.sh ${N_WORKERS} ${N_TRIALS} ${TUNE_EPOCHS} ${BACKEND}\` +Launch: \`bash experiments/tune_wdist_parallel.sh ${N_WORKERS} ${N_TRIALS} ${TUNE_EPOCHS} ${BACKEND}\` EOF echo "Power W-Dist tune: ${N_WORKERS} workers, ${N_TRIALS} trials, ${TUNE_EPOCHS}ep, topo=${BACKEND}" @@ -67,7 +67,7 @@ for i in $(seq 0 $((N_WORKERS - 1))); do OMP_NUM_THREADS=${THREADS_PER_WORKER} \ MKL_NUM_THREADS=${THREADS_PER_WORKER} \ OPENBLAS_NUM_THREADS=${THREADS_PER_WORKER} \ - nohup uv run python -u experiments/tune_elongate_wdist.py \ + nohup uv run python -u experiments/tune_wdist.py \ --base-config "${BASE_CONFIG}" \ --n-trials "${TRIALS_PER_WORKER}" \ --max-complete-trials "${N_TRIALS}" \ @@ -88,7 +88,7 @@ done for pid in "${pids[@]}"; do wait "${pid}"; done -uv run python -u experiments/tune_elongate_wdist.py \ +uv run python -u experiments/tune_wdist.py \ --base-config "${BASE_CONFIG}" \ --n-trials "${N_TRIALS}" \ --max-complete-trials "${N_TRIALS}" \ @@ -101,4 +101,4 @@ uv run python -u experiments/tune_elongate_wdist.py \ --study-name "${STUDY_NAME}" \ --write-best -echo "Done: ${OUT_BASE}/best_elongate_wdist_${BACKEND}.json" +echo "Done: ${OUT_BASE}/best_wdist_${BACKEND}.json" diff --git a/scripts/new_experiment.sh b/scripts/new_experiment.sh index 026cc69..a888c7f 100755 --- a/scripts/new_experiment.sh +++ b/scripts/new_experiment.sh @@ -6,9 +6,9 @@ # 使い方: # scripts/new_experiment.sh [--no-cls|--tune] ["1行の目的"] # 例: -# scripts/new_experiment.sh pwr30ep_seed42 "power size 30ep" +# scripts/new_experiment.sh paper30_s42 "paper 30ep" # scripts/new_experiment.sh --no-cls bar_smoke "barrier loss smoke" -# scripts/new_experiment.sh --tune pwr_mcc "ellphi+power MCC tune" +# scripts/new_experiment.sh --tune mcc "paper MCC tune" set -euo pipefail mode_dir="supervised" diff --git a/tda_ml/model_selection.py b/tda_ml/model_selection.py index b118ab5..a2fa514 100644 --- a/tda_ml/model_selection.py +++ b/tda_ml/model_selection.py @@ -2,7 +2,7 @@ Default production protocol (``val_topo``): - During training: save ``best_model.pth`` at minimum ``val_topo_loss`` (no DBSCAN grid). -- After training: ``evaluate_paper_protocol.py`` grid-searches DBSCAN on val once, then +- After training: ``eval_paper.py`` grid-searches DBSCAN on val once, then reports test MCC at the chosen ``(eps, min_samples)``. Optional ``wdist`` / ``dbscan_mcc`` run a val DBSCAN grid every ``eval_every`` epochs to diff --git a/tda_ml/run_paths.py b/tda_ml/run_paths.py index 05635b3..bd7c21f 100644 --- a/tda_ml/run_paths.py +++ b/tda_ml/run_paths.py @@ -21,10 +21,14 @@ def short_backend(name: str) -> str: def shorten_config_id(config_id: str, *, max_len: int = 24) -> str: - """Derive a compact slug from a legacy config_id.""" + """Derive a compact slug from a config_id (including legacy aliases).""" + m = re.fullmatch(r"paper_seed(\d+)", config_id) + if m: + return f"paper_s{m.group(1)}" + m = re.fullmatch(r"teacher_local_pca_power_seed(\d+)", config_id) if m: - return f"pwr_s{m.group(1)}" + return f"paper_s{m.group(1)}" # legacy alias m = re.fullmatch(r"teacher_local_pca_(ellphi|mahalanobis)_seed(\d+)", config_id) if m: @@ -38,7 +42,7 @@ def shorten_config_id(config_id: str, *, max_len: int = 24) -> str: if m: return f"t{m.group(1)}" - m = re.fullmatch(r"tune_elongate_t(\d+)", config_id) + m = re.fullmatch(r"tune_t(\d+)", config_id) if m: return f"t{m.group(1)}" diff --git a/tests/test_paper_eval_imports.py b/tests/test_paper_eval_imports.py index 5b585d6..86c9f79 100644 --- a/tests/test_paper_eval_imports.py +++ b/tests/test_paper_eval_imports.py @@ -16,8 +16,8 @@ class TestPaperEvalImports(unittest.TestCase): def test_baselines_and_protocol_import(self): - import experiments.evaluate_paper_baselines as baselines - import experiments.evaluate_paper_protocol as protocol + import experiments.eval_baselines as baselines + import experiments.eval_paper as protocol self.assertTrue(callable(baselines.evaluate_adbscan)) self.assertTrue(callable(protocol.load_model_from_run)) @@ -28,7 +28,7 @@ def test_train_and_eval_share_data_root(self): from tda_ml.config import default_data_root from tda_ml.run_setup import default_data_root as train_data_root - import experiments.evaluate_paper_protocol as protocol + import experiments.eval_paper as protocol self.assertIs(protocol.default_data_root, default_data_root) self.assertIs(train_data_root, default_data_root) diff --git a/tests/test_paper_production_gates.py b/tests/test_paper_production_gates.py index 6e37eca..08e55ec 100644 --- a/tests/test_paper_production_gates.py +++ b/tests/test_paper_production_gates.py @@ -11,9 +11,9 @@ REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO / "experiments")) -from aggregate_power_30ep_multiseed import discover_seed_metrics # noqa: E402 -from power_30ep_freshness import inspect_seed_metrics # noqa: E402 -from run_teacher_local_pca_power_30ep import ( # noqa: E402 +from aggregate_paper_multiseed import discover_seed_metrics # noqa: E402 +from paper_run_freshness import inspect_seed_metrics # noqa: E402 +from run_paper_30ep import ( # noqa: E402 TAG_MCC, TAG_WDIST, infer_tag, @@ -27,7 +27,7 @@ def test_duplicate_seed_hard_fails(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) for i, mcc in enumerate([0.1, 0.9]): - logs = root / f"pwr_s42_run{i}" / "logs" + logs = root / f"paper_s42_run{i}" / "logs" logs.mkdir(parents=True) (logs / "run_manifest.json").write_text( json.dumps({"seed": 42, "tune_json": "/x.json"}), @@ -45,7 +45,7 @@ def test_duplicate_seed_hard_fails(self): def test_missing_manifest_seed_hard_fails(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - logs = root / "pwr_s42_x" / "logs" + logs = root / "paper_s42_x" / "logs" logs.mkdir(parents=True) (logs / "paper_metrics_test_foo.json").write_text( json.dumps({"mcc": 0.1, "gmean": 0.5, "wdist": 1.0}), @@ -130,7 +130,7 @@ def test_missing_is_missing(self): status, paths = inspect_seed_metrics( out_base=Path(tmp), seed=42, - tag="power_wdist_valtopo_paper_eval", + tag="wdist", tune_json=Path(tmp) / "missing.json", expected_revision="deadbeef", ) @@ -142,7 +142,7 @@ def test_stale_revision_detected(self): root = Path(tmp) tune = root / "best.json" tune.write_text("{}", encoding="utf-8") - logs = root / "pwr_s42_x" / "logs" + logs = root / "paper_s42_x" / "logs" logs.mkdir(parents=True) (logs / "run_manifest.json").write_text( json.dumps( @@ -155,12 +155,12 @@ def test_stale_revision_detected(self): encoding="utf-8", ) ( - logs / "paper_metrics_test_power_wdist_valtopo_paper_eval.json" + logs / "paper_metrics_test_wdist.json" ).write_text("{}", encoding="utf-8") status, _ = inspect_seed_metrics( out_base=root, seed=42, - tag="power_wdist_valtopo_paper_eval", + tag="wdist", tune_json=tune, expected_revision="newrev", ) diff --git a/tests/test_run_paths.py b/tests/test_run_paths.py index 4181430..dabb547 100644 --- a/tests/test_run_paths.py +++ b/tests/test_run_paths.py @@ -18,7 +18,7 @@ class TestRunPaths(unittest.TestCase): def test_shorten_config_id(self) -> None: - self.assertEqual(shorten_config_id("teacher_local_pca_power_seed42"), "pwr_s42") + self.assertEqual(shorten_config_id("paper_seed42"), "paper_s42") self.assertEqual(shorten_config_id("backend_ellphi_seed42"), "eph_s42") self.assertEqual(shorten_config_id("tune_mcc_t010"), "t010") @@ -33,12 +33,12 @@ def test_build_run_dir(self) -> None: when = datetime.datetime(2026, 7, 9, 13, 5, 0) cfg = { "meta": {"config_id": "tune_mcc_t010", "run_slug": "t010"}, - "outputs": {"base_dir": "outputs/tune/0709_pwr_mcc"}, + "outputs": {"base_dir": "outputs/tune/0709_mcc"}, } run_dir, slug, stamp = build_run_dir(cfg, when=when) self.assertEqual(slug, "t010") self.assertEqual(stamp, "0709_130500") - self.assertEqual(run_dir, "outputs/tune/0709_pwr_mcc/t010_0709_130500") + self.assertEqual(run_dir, "outputs/tune/0709_mcc/t010_0709_130500") def test_build_run_dir_refuses_existing(self) -> None: import tempfile @@ -58,8 +58,8 @@ def test_build_run_dir_refuses_existing(self) -> None: def test_experiment_base(self) -> None: when = datetime.datetime(2026, 7, 9, 13, 5) - self.assertEqual(experiment_base("supervised", "pwr30", when=when), "outputs/supervised/0709_pwr30") - self.assertEqual(tune_base("pwr_mcc", when=when), "outputs/tune/0709_pwr_mcc") + self.assertEqual(experiment_base("supervised", "paper30", when=when), "outputs/supervised/0709_paper30") + self.assertEqual(tune_base("mcc", when=when), "outputs/tune/0709_mcc") self.assertEqual(OUTPUT_CATEGORIES, {"supervised", "supervised_no_cls", "tune"}) def test_visualization_filename(self) -> None: diff --git a/tests/test_tune_config.py b/tests/test_tune_config.py index 9784318..5e76849 100644 --- a/tests/test_tune_config.py +++ b/tests/test_tune_config.py @@ -10,8 +10,8 @@ REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO / "experiments")) -from tune_elongate_mcc import build_trial_config as build_mcc_trial # noqa: E402 -from tune_elongate_wdist import build_trial_config as build_wdist_trial # noqa: E402 +from tune_mcc import build_trial_config as build_mcc_trial # noqa: E402 +from tune_wdist import build_trial_config as build_wdist_trial # noqa: E402 class TestTuneTrialConfig(unittest.TestCase): @@ -78,7 +78,7 @@ def test_wdist_builder_overrides_divergent_yaml_homology_and_aniso(self): divergent["loss"]["aniso_mode"] = "linear" with mock.patch( - "tune_elongate_wdist.load_config", + "tune_wdist.load_config", side_effect=lambda *a, **k: deepcopy(divergent), ): # aniso_mode is now mirrored from the base config declaration, so a @@ -99,7 +99,7 @@ def test_wdist_builder_overrides_divergent_yaml_homology_and_aniso(self): divergent["loss"]["aniso_mode"] = "elongate" with mock.patch( - "tune_elongate_wdist.load_config", + "tune_wdist.load_config", side_effect=lambda *a, **k: deepcopy(divergent), ): cfg = build_wdist_trial( @@ -131,7 +131,7 @@ def test_wdist_builder_mirrors_barrier_variant_from_base_config(self): barrier["loss"]["aniso_barrier_threshold"] = 6.0 with mock.patch( - "tune_elongate_wdist.load_config", + "tune_wdist.load_config", side_effect=lambda *a, **k: deepcopy(barrier), ): cfg = build_wdist_trial( From b92f5eed2a507353949c3a1525ccaca0c50e2034 Mon Sep 17 00:00:00 2001 From: murata Date: Sun, 26 Jul 2026 15:32:01 +0900 Subject: [PATCH 43/44] =?UTF-8?q?docs:=20REPRODUCIBILITY=20=E3=81=AE=20run?= =?UTF-8?q?-dir=20=E4=BE=8B=E3=82=92=20paper=5Fs42=20=E3=81=AB=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- REPRODUCIBILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/REPRODUCIBILITY.md b/REPRODUCIBILITY.md index cf5893a..7771cc6 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -56,7 +56,7 @@ uv run python experiments/run_paper_30ep.py \ --seed 42 uv run python experiments/eval_paper.py \ - --run-dir outputs/supervised/.../pwr_s42_ \ + --run-dir outputs/supervised/.../paper_s42_ \ --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \ --split val ``` From 1c5f59b04998b2366f9fe3b844786caab4e09d70 Mon Sep 17 00:00:00 2001 From: Kouki MURATA <154290149+koki3070@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:52:03 +0900 Subject: [PATCH 44/44] =?UTF-8?q?fix:=20run-dir=20slug=20=E3=81=8C=20paper?= =?UTF-8?q?/tune=20=E5=BD=B9=E5=89=B2=E3=82=92=E8=90=BD=E3=81=A8=E3=81=99?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E3=81=A8=E6=97=A7=20id=20=E3=81=AE=E5=88=A5?= =?UTF-8?q?=E5=90=8D=E8=A1=9D=E7=AA=81=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - paper_n100_o20_nocls_* と tune_n100_o20_nocls_* が同一 slug (h1_ellphi_lpca_power) に潰れ、run ディレクトリ名から config を 特定できなくなっていた。role トークンを残すよう変更。 - 旧 config_id teacher_local_pca_power_seed{N} を paper_s{N} に 再割当てしていたため、正本 paper_seed{N} と run 名前空間が衝突。 ディスク上の実ディレクトリ名 pwr_s{N} に戻して分離を回復。 - prefix 除去も origin/main と同一挙動に戻し、旧 slug を維持。 Co-authored-by: Cursor --- tda_ml/run_paths.py | 19 +++++++++++-------- tests/test_run_paths.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/tda_ml/run_paths.py b/tda_ml/run_paths.py index bd7c21f..ddf5f04 100644 --- a/tda_ml/run_paths.py +++ b/tda_ml/run_paths.py @@ -26,9 +26,11 @@ def shorten_config_id(config_id: str, *, max_len: int = 24) -> str: if m: return f"paper_s{m.group(1)}" + # Pre-rename production id. Kept distinct from paper_s* so re-running an old + # config_id cannot land in the current paper run namespace. m = re.fullmatch(r"teacher_local_pca_power_seed(\d+)", config_id) if m: - return f"paper_s{m.group(1)}" # legacy alias + return f"pwr_s{m.group(1)}" m = re.fullmatch(r"teacher_local_pca_(ellphi|mahalanobis)_seed(\d+)", config_id) if m: @@ -58,16 +60,17 @@ def shorten_config_id(config_id: str, *, max_len: int = 24) -> str: if m: return f"smk_{m.group(1)}" + # Named paper/tune YAMLs: drop the dataset/contract tokens but keep the role, + # otherwise paper_* and tune_* collapse to the same run-dir slug. + m = re.fullmatch(r"(paper|tune)_n\d+_o\d+_nocls_(.+)", config_id) + if m: + slug = f"{m.group(1)}_{m.group(2)}" + return slug[:max_len] if len(slug) > max_len else slug + slug = config_id - for prefix in ( - "paper_n100_o20_nocls_", - "tune_n100_o20_nocls_", - "teacher_local_pca_", - "elongate_n100_no_cls_", # legacy slug prefix (old run dirs) - ): + for prefix in ("elongate_n100_no_cls_", "teacher_local_pca_"): if slug.startswith(prefix): slug = slug[len(prefix) :] - break if len(slug) > max_len: slug = slug[:max_len] return slug diff --git a/tests/test_run_paths.py b/tests/test_run_paths.py index dabb547..39eac9e 100644 --- a/tests/test_run_paths.py +++ b/tests/test_run_paths.py @@ -22,6 +22,21 @@ def test_shorten_config_id(self) -> None: self.assertEqual(shorten_config_id("backend_ellphi_seed42"), "eph_s42") self.assertEqual(shorten_config_id("tune_mcc_t010"), "t010") + def test_shorten_config_id_keeps_paper_tune_role_distinct(self) -> None: + paper = shorten_config_id("paper_n100_o20_nocls_h1_ellphi_lpca_power") + tune = shorten_config_id("tune_n100_o20_nocls_h1_ellphi_lpca_power") + self.assertTrue(paper.startswith("paper_")) + self.assertTrue(tune.startswith("tune_")) + self.assertNotEqual(paper, tune) + + def test_shorten_config_id_legacy_ids_keep_old_slugs(self) -> None: + """Pre-rename ids must not alias into the current paper_s* namespace.""" + self.assertEqual(shorten_config_id("teacher_local_pca_power_seed42"), "pwr_s42") + self.assertNotEqual( + shorten_config_id("teacher_local_pca_power_seed42"), + shorten_config_id("paper_seed42"), + ) + def test_resolve_run_slug_prefers_explicit(self) -> None: cfg = { "meta": {"config_id": "backend_ellphi_seed42", "run_slug": "custom"},