diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index d4617b7..441c785 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,23 @@ 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: 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/.gitignore b/.gitignore index 86fcf54..fe8b36b 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/README.md b/README.md index 407ce06..f39e397 100644 --- a/README.md +++ b/README.md @@ -1,119 +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 - -Use `uv` as the canonical dependency manager. +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`). -`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): +## Claim (main table) -```bash -./scripts/ensure_pytorch_topological.sh -``` +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. -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. +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. -Then install Python dependencies: +## Setup ```bash +./scripts/ensure_pytorch_topological.sh +./scripts/ensure_ellphi_repo.sh uv sync -``` - -For development dependencies: - -```bash +# development (tests, ruff): uv sync --all-groups +# Optuna tune drivers: +uv sync --extra experiments ``` -Optional package extras (e.g. `robustness_sweep`, HomCloud animation, explicit Pillow): - -```bash -uv sync --extra experiments --extra repro-pd-animation --extra images -``` +`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. -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. +## Paper production path (primary) -## Minimal Reproduction +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. -Use the following command as the official reproduction entrypoint: +Config: `paper_n100_o20_nocls_h1_ellphi_lpca_power`. ```bash -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. +# 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/tune_objectives.sh -### Resume Behavior +# 2) Fixed W-Dist weights → 30ep × 5 seeds → paper eval (+ baselines separately) +bash experiments/run_paper_30ep_multiseed.sh wdist -- `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 +uv run python experiments/eval_baselines.py \ + --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \ + --out-dir outputs/paper_baselines +``` -- `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). +Details and contract keys: `REPRODUCIBILITY.md` / `configs/README.md`. +Generated artifacts stay under `outputs/` (not committed). -## Quick Smoke Run +## Backend pipeline comparison (secondary / CI) -For a fast wiring check before the full run: +`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 1 \ - --seeds 42 \ - --backends mahalanobis \ + --epochs 1 --seeds 42 --backends mahalanobis \ --out-base outputs/smoke -``` -## Continuous integration - -On pull requests and pushes to `main` / `feature/**`, CI runs: +# Full secondary comparison (local / own runner; not CI) +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 +``` -- `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`** does not (geometry-only distances; `probs` ignored). -- 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** 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. +- 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 (for example `ellphi_repo` and `pytorch-topological`) are reference-only and are not redistributed here. +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 37ac3a9..7771cc6 100644 --- a/REPRODUCIBILITY.md +++ b/REPRODUCIBILITY.md @@ -4,8 +4,76 @@ ## リポジトリに含まれる範囲(目安) -- **含む:** `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`、および論文比較用の `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` を置いた場合にのみ使えます。 + +### 論文比較(W-Dist / MCC 二目的)で使う `experiments/` + +**論文主表の提案:** 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/paper30_*`)で明示する(生成物は git に含めない)。 + +| 区分 | パス | +|------|------| +| 本番 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 では数値が再現できない。 + +### 正本の起動(学習 → 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/tune_objectives.sh +``` + +**2. 本番 30ep × 5-seed(tune JSON 必須)→ 内部で paper eval** + +```bash +bash experiments/run_paper_30ep_multiseed.sh wdist +``` + +**3. ベースライン(ADBSCAN 等)** + +```bash +uv run python experiments/eval_baselines.py \ + --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \ + --out-dir outputs/paper_baselines +``` + +単一 seed・手動 eval: + +```bash +uv run python experiments/run_paper_30ep.py \ + --tune-json outputs/tune/wdist/best_wdist_ellphi.json \ + --seed 42 + +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 +``` + +運用補助: `experiments/launch_detached_screen.sh`(screen 経由の長時間ジョブ)。論文主表の入口ではない。 + +### 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 にしない) | | + +主表・チューニングの 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 で明示したときだけ**使う。公開ツリーにはこの variant 用 YAML を置かない(コード契約とテストで担保;ローカル `configs/archive/` に置く場合のみ)。暗黙の切替はしない。主表の ADBSCAN 比較・本番 30ep 経路には使わない。 ## 環境 @@ -14,28 +82,87 @@ ```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 に含めません。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` などに記録されます。 ## データ(MNIST) - MNIST は git にコミットしません(`data/` は無視対象)。 -- 初回の学習またはデータセットアクセス時に、`torchvision` 経由で **`./data`** 以下にダウンロードされます(`configs/reproduce.yaml` を前提とした設定が典型です)。 -- 初回はインターネットに到達できるようにするか、キャッシュ済みの MNIST を自分で `./data` に置いてください。 -- 設定 YAML の役割分担は **`configs/README.md`**(正本 5 本)を参照。旧設定はローカルで `configs/archive/` に置けるが、公開クローンには同梱されない。 +- 学習・paper eval ともデータ根は **リポジトリ根の `data/`**(`tda_ml.config.default_data_root()`)であり、プロセスの cwd には依存しません。初回アクセス時に `torchvision` 経由でそこにダウンロードされます。 +- 初回はインターネットに到達できるようにするか、キャッシュ済みの MNIST を自分でリポジトリ根の `data/` に置いてください。 +- 設定 YAML の役割分担は **`configs/README.md`** を参照(共有プロファイル + 論文用 `paper_n100_o20_nocls_h1_ellphi_lpca_power` / `tune_n100_o20_nocls_h1_ellphi_lpca_power`)。旧設定はローカルで `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) + +[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` を使用してください。 + +## 副次: バックエンドパイプライン比較(論文主表ではない) + +`run_backend_multiseed.py` は **二次比較 / CI smoke** 用です。論文主表の入口ではありません。 ```bash uv run python experiments/run_backend_multiseed.py \ @@ -46,17 +173,27 @@ uv run python experiments/run_backend_multiseed.py \ --out-base outputs/backend_compare ``` -再開の挙動、ロックファイル、CSV の意味は `README.md` に書いてあります。 +再開・ロック・期待成果物は `README.md` の Backend pipeline comparison 節を参照。 ### バックエンド比較と 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` の Known constraints に従ってください。 -したがって、`run_backend_multiseed.py` で同じ YAML を回しても、**位相損失が見ている距離空間はバックエンド間で同一ではありません**。ここでは「同一のデータ・スケジュール・設定表面での再現パイプライン比較」を意図しており、**両バックエンドが数学的に完全に同型の重み付き距離目的関数を共有する**という読み方はしません。`ellphi` 側に Mahalanobis の確率重みに相当する項を無理に足す予定はなく、比較の解釈は本節および `README.md` の英語節(*Backend comparison: outlier-probability weighting*)に従ってください。 +### ellphi + power:二目的チューニング(実験メモ) + +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 時刻をクラスタリング距離に使わない)。 + +**重み固定プロトコル(重要):** ハイパーパラメータ探索(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 用) @@ -70,7 +207,7 @@ uv run python experiments/run_backend_multiseed.py \ + 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 @@ -78,66 +215,55 @@ b_i = b_{i,\mathrm{base}}\, e^{\Delta b_i},\quad \theta_i = \theta_{i,\mathrm{base}} + \tanh(\Delta\theta_i)\frac{\pi}{2}. \] -**正則化(`tda_ml/losses.py`)** +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`)** — 主表は `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), \] -主表 config では `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$。 - -**Mahalanobis 距離**(`tda_ml/topology.py`)は outlier 確率 $p_i$ により二乗距離を -$1/\bigl((1-p_i)(1-p_j)\bigr)$ で重み付け(`INLIER_PROB_MIN` で下限クリップ)。 +(`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)\))は非主表の共有プロファイル用。 -**数値安定化のみの定数**(モデリング床ではない)は `tda_ml/numerical_eps.py` に集約し、付録で列挙します。encoder の `clamp(0.2)` や legacy の `+10^{-4}` といった**論文に無い床は削除済み**(issue #59)。 +主表 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))。 -### issue #59 検証(早期打ち切り + 診断) +非主表 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/` のみ。 -床削除後の教師あり本線を確認するには: - -```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 設定 +**Mahalanobis 距離**(`tda_ml/topology.py`)は outlier 確率 $p_i$ により二乗距離を +$1/\bigl((1-p_i)(1-p_j)\bigr)$ で重み付け(`INLIER_PROB_MIN` で下限クリップ)。 -CLI: `--epochs 12 --seed 42 --out-base outputs/issue59_verify` +**数値安定化のみの定数**(モデリング床ではない)は `tda_ml/numerical_eps.py` に集約し、付録で列挙します。encoder の `clamp(0.2)` や legacy の `+10^{-4}` といった**論文に無い床は削除済み**(issue #59)。中心一致を含む ellphi 退化は補正せず hard-fail します。 ## 図・定性出力 -`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 335abf8..17126d7 100644 --- a/configs/README.md +++ b/configs/README.md @@ -1,44 +1,79 @@ # 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`). + +## 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 | |------|------| -| `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. | +| `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. | +| `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 / near-tangent +barrier / Euclidean-teacher baseline configs here unless they become a claimed +Methods path with a matching production YAML. -**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/")`. +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 `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` (set by multiseed driver per run). | -| `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. | +| `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 4cdfa10..5f78013 100644 --- a/configs/base.yaml +++ b/configs/base.yaml @@ -1,10 +1,27 @@ # Base configuration # Shared parameters for all environments +loss: + w_class: 1.0 + 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 + aniso_mode: linear + 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 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 @@ -21,6 +38,10 @@ model: metric_type: "anisotropic" # "anisotropic" or "euclidean" distance_backend: "mahalanobis" # or "ellphi"; multiseed driver overrides per run ellphi_differentiable: true + # 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 @@ -30,6 +51,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 @@ -42,4 +66,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/configs/dev.yaml b/configs/dev.yaml index 7766796..548c5a9 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -3,6 +3,13 @@ meta: config_id: "dev" +loss: + w_class: 1.0 + w_topo: 0.1 + w_aniso: 0.01 + w_size: 0.0001 + teacher_mode: euclidean + training: epochs: 3 visualize_every: 1 @@ -15,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/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/configs/paper_n100_o20_nocls_h1_ellphi_lpca_power.yaml b/configs/paper_n100_o20_nocls_h1_ellphi_lpca_power.yaml new file mode 100644 index 0000000..8744443 --- /dev/null +++ b/configs/paper_n100_o20_nocls_h1_ellphi_lpca_power.yaml @@ -0,0 +1,73 @@ +# 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 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: "paper_n100_o20_nocls_h1_ellphi_lpca_power" + +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 + topo_eps_scale: 1.0 + topo_scale_mode: fixed + +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/prod.yaml b/configs/prod.yaml index 960d271..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 @@ -42,4 +45,4 @@ device: "cpu" outputs: base_dir: "outputs/prod" - save_every: 10 + save_every: 1 diff --git a/configs/reproduce.yaml b/configs/reproduce.yaml index b0fa1bd..ecfc4d1 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,10 @@ 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: lr: 0.0004897466143769238 diff --git a/configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml b/configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml new file mode 100644 index 0000000..9d4f275 --- /dev/null +++ b/configs/tune_n100_o20_nocls_h1_ellphi_lpca_power.yaml @@ -0,0 +1,69 @@ +# 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_wdist.py vs tune_mcc.py), not by config_id. + +meta: + config_id: "tune_n100_o20_nocls_h1_ellphi_lpca_power" + +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 # 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 + 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 + 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_multiseed.py b/experiments/aggregate_paper_multiseed.py new file mode 100644 index 0000000..68e6349 --- /dev/null +++ b/experiments/aggregate_paper_multiseed.py @@ -0,0 +1,261 @@ +#!/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.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] +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: + 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)) + + +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" + if not manifest_path.is_file(): + raise ValueError( + 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")) + 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 + + +def aggregate_row( + *, + method: str, + by_seed: dict[int, dict[str, Any]], + expected_seeds: Sequence[int], + tune_json: str, +) -> dict[str, Any]: + missing = [s for s in expected_seeds if s not in by_seed] + if missing: + 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] + 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 {expected_tune}; " + "30ep val_topo ckpt; maha DBSCAN eval; " + "wdist_* are diagnostics only (not main-table columns)" + ), + } + + +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/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/wdist/best_wdist_ellphi.json", + ) + p.add_argument( + "--mcc-tune-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/paper30_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).", + ) + 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), + } + for key in args.methods: + path = tune_paths[key] + if not path.is_absolute(): + path = REPO_ROOT / path + preflight_tune_json(path, expected_contract=expected_contract) + tune_paths[key] = path.resolve() + + wdist_by_seed = discover_seed_metrics( + args.wdist_out, + "paper_s*/logs/paper_metrics_test_wdist.json", + ) + mcc_by_seed = discover_seed_metrics( + args.mcc_out, + "paper_s*/logs/paper_metrics_test_mcc.json", + ) + + method_specs = { + "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: + method, by_seed, tune_json = method_specs[key] + rows.append( + aggregate_row( + method=method, + by_seed=by_seed, + expected_seeds=args.seeds, + tune_json=tune_json, + ) + ) + + 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), + "wdist_tune_json": str(tune_paths["wdist"]), + "mcc_tune_json": str(tune_paths["mcc"]), + "paper_no_cls_contract": expected_contract, + "per_seed": { + "wdist": wdist_by_seed, + "mcc": mcc_by_seed, + }, + "warnings": [], + "summary_csv": str(summary_path), + } + (out_dir / "MANIFEST_proposed.json").write_text(json.dumps(manifest, indent=2) + "\n") + + 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 __name__ == "__main__": + raise SystemExit(main()) diff --git a/experiments/eval_baselines.py b/experiments/eval_baselines.py new file mode 100644 index 0000000..41e3707 --- /dev/null +++ b/experiments/eval_baselines.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python3 +""" +Paper-aligned baseline evaluation. + +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 +**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``; + no learned corrections — not compute-matched to the proposed 30ep model) + +Usage:: + + uv run python experiments/eval_baselines.py \\ + --base-config paper_n100_o20_nocls_h1_ellphi_lpca_power \\ + --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.eval_paper import ( + CloudMetrics, + _aggregate_classification_metrics, + _aggregate_cloud_metrics, + 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.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 +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] +LOCAL_PCA_K = 10 + + +@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: + """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}") + 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]: + 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, + 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=None``, never aggregated). + 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") + db_labels = apply_anisotropic_dbscan( + cloud.points, + cloud.adbscan_params, + 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: + # 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 + ) + ) + return CloudMetrics(recall, specificity, gmean, mcc, wdist) + + +def grid_search_clouds( + clouds: Sequence[CloudSample], + evaluate_fn: Callable[..., CloudMetrics], + 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]]]: + # 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] = [] + error: str | None = None + for cloud in clouds: + try: + per_cloud.append(evaluate_fn(cloud, **params)) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + per_cloud = [] + break + if error is not None: + 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_classification_metrics(per_cloud) + n_ok += 1 + grid_log.append( + {**params, "status": "ok", "mean_mcc": mcc, "n_clouds": len(per_cloud)} + ) + if best_mcc is None or mcc > best_mcc: + best_mcc = mcc + best_params = dict(params) + + 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 + + +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], + 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) + + 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", + 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": + 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", + allow_skip_degenerate_grid_cells=allow_skip_degenerate_grid_cells, + manifest_ref=manifest_ref, + ) + 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", + allow_skip_degenerate_grid_cells=allow_skip_degenerate_grid_cells, + manifest_ref=manifest_ref, + ) + 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", + allow_skip_degenerate_grid_cells=allow_skip_degenerate_grid_cells, + 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, **test_extra_kwargs)) + 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; " + "wdist_* columns are diagnostics only (not main-table); " + "ADBSCAN uses fixed local-PCA ellipses (no 30ep training)" + ), + } + + +METHOD_ORDER = [ + "euclidean_dbscan", + "isolation_forest", + "lof", + "adbscan", +] + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + # 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) + 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=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() + + +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), + "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(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} + + 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=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) + + 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["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 {out_dir / 'MANIFEST_baselines.json'}") + 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/eval_paper.py b/experiments/eval_paper.py new file mode 100644 index 0000000..9afaa3a --- /dev/null +++ b/experiments/eval_paper.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 +""" +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. + +Usage:: + + 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/eval_paper.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 + +import numpy as np +import torch + +from tda_ml.checkpoint_io import ( + extract_model_state_dict, + load_torch_checkpoint, + resolve_val_topo_checkpoint, +) +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 +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 + +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 + specificity: float + gmean: float + mcc: 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 +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 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", + topo_options: TopoWdistOptions | None = None, +) -> CloudMetrics: + from tda_ml.dbscan import apply_anisotropic_dbscan + + 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) + recall, specificity, gmean, mcc, wdist = compute_recall_specificity_gmean_mcc_wdist( + labels_gt, + pred, + points=points, + params=params, + clean_pc=clean_pc, + topo_options=topo_options, + ) + return CloudMetrics(recall, specificity, gmean, mcc, wdist) + + +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 ( + 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])), + ) + + +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")) + set_global_seed(seed, deterministic_algorithms=False) + + 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(_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 + 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_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); " + "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") + + repro = reproducibility_settings(config) + dataset_kwargs: dict[str, Any] = dict( + root=str(default_data_root()), + 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, + 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 ( + "tangent_pca_k", + "tangent_offset_min", + "tangent_offset_max", + "tangent_angle_jitter_deg", + "tangent_stroke_clearance", + "tangent_direction", + ): + 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"]), + 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( + 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: + """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=True) + model.to(device) + model.eval() + return model + + +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", + tag: str | None = None, +) -> SplitMetrics: + loader = build_split_loader(config, split, device) + 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" + tag_suffix = f"_{tag}" if tag else "" + + if split == "val": + grid = evaluate_model_grid( + model, + loader, + device, + config=config, + backend=backend, + eps_values=eps_values, + min_samples_values=min_samples_values, + 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, + ) + + 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( + evaluate_cloud_dbscan( + points, + params, + labels_gt, + clean_pc, + eps=float(eps), + min_samples=int(min_samples), + backend=backend, + topo_options=topo_options, + ) + ) + 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") + 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", + "aniso_barrier_threshold", + "size_mode", + "size_ref", + "size_power", + "w_topo", + "w_aniso", + "w_size", + ) + if key in loss_overrides and loss_overrides[key] is not None + } + 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 + + +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="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) + p.add_argument("--seed", type=int, default=None, help="Override data.seed (else run_manifest)") + p.add_argument( + "--backend", + type=str, + default="mahalanobis", + choices=["ellphi", "mahalanobis"], + help="DBSCAN backend for MCC (paper protocol default: mahalanobis).", + ) + 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: evaluation.dbscan from config).", + ) + 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, + 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() + 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) + + 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" / 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"]) + + metrics = evaluate_split( + run_dir, + config, + args.split, + device, + eps=eps, + min_samples=min_samples, + eps_values=args.eps_values, + min_samples_values=args.min_samples_values, + backend=args.backend, + tag=args.tag, + ) + + log_dir = run_dir / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + if args.split == "val": + 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": "best_model.pth", + "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}{tag_suffix}.json" + payload = { + "source_revision": git_revision(REPO_ROOT), + "run_dir": str(run_dir), + "split": args.split, + "checkpoint_name": "best_model.pth", + **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()) 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/launch_detached_screen.sh b/experiments/launch_detached_screen.sh new file mode 100755 index 0000000..97359d2 --- /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 paper30 \ +# outputs/supervised/paper30/driver.log \ +# experiments/run_paper_30ep_multiseed.sh wdist + +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}" >&2 || echo "(session listed but grep detail failed)" >&2 + 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/paper_run_freshness.py b/experiments/paper_run_freshness.py new file mode 100644 index 0000000..d83978b --- /dev/null +++ b/experiments/paper_run_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"paper_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_backend_multiseed.py b/experiments/run_backend_multiseed.py index 1835699..e722bca 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, @@ -267,13 +252,19 @@ 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)) + 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 = { + overrides: dict[str, Any] = { "meta": {"config_id": config_id}, "training": {"epochs": epochs}, "data": {"seed": seed}, @@ -281,16 +272,24 @@ 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}, } + 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}_*"))) 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) @@ -346,6 +345,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 +392,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) diff --git a/experiments/run_paper_30ep.py b/experiments/run_paper_30ep.py new file mode 100644 index 0000000..ca72abc --- /dev/null +++ b/experiments/run_paper_30ep.py @@ -0,0 +1,294 @@ +#!/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 ( # noqa: E402 + assert_paper_no_cls_contract, + classify_tune_objective, + paper_aniso_fields, + preflight_tune_production_run, +) + +BASE_CONFIG = "paper_n100_o20_nocls_h1_ellphi_lpca_power" +TAG_MCC = "mcc" +TAG_WDIST = "wdist" + + +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) + p.add_argument( + "--tune-json", + type=Path, + 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", + 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, + *, + 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"] + 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 / 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"), + ) + 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.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/paper30_wdist" + else: + out_base = REPO_ROOT / "outputs/supervised/paper30_mcc" + 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"] + + aniso_fields = paper_aniso_fields(cfg) + 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_fields, + "size_mode": "power", + "size_ref": size_ref, + "size_power": 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_fields, + "size_mode": "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, "lr": tune_weights["lr"]} + tune_source = str(tune_json) + + cfg = deep_update( + cfg, + { + "meta": { + "config_id": f"paper_seed{args.seed}", + "run_slug": f"paper_s{args.seed}", + }, + "loss": loss_overrides, + "training": training_overrides, + "data": {"seed": args.seed}, + "model": { + "topology_loss": { + "distance_backend": "ellphi", + "prob_weighting": False, + "homology_dimensions": [1], + } + }, + "outputs": {"base_dir": str(out_base)}, + }, + ) + + 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"], + "w_size": cfg["loss"]["w_size"], + "lr": cfg["training"]["lr"], + }, + "selection": cfg["training"]["selection"]["metric"], + "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": ( + "paper 30ep H1-only: raw ellipse params to ellphi; degenerate geometry " + "hard-fails; tune weights fixed from seed-42 Optuna" + ), + "reference": tune_source, + } + cfg["_manifest_extras"] = { + "tune_json": tune_source, + "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"], + "size_power": purpose["size_power"], + "w_topo": purpose["weights"]["w_topo"], + "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"), + }, + } + + # 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" + ) + (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 + + eval_script = REPO_ROOT / "experiments" / "eval_paper.py" + for split in ("val", "test"): + cmd = [ + "uv", + "run", + "python", + str(eval_script), + "--run-dir", + str(run_dir), + "--base-config", + args.base_config, + "--split", + split, + "--backend", + args.dbscan_backend, + "--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_paper_30ep_multiseed.sh b/experiments/run_paper_30ep_multiseed.sh new file mode 100755 index 0000000..9c77cf4 --- /dev/null +++ b/experiments/run_paper_30ep_multiseed.sh @@ -0,0 +1,206 @@ +#!/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_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 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). + +set -euo pipefail + +cd "$(dirname "$0")/.." +export PATH="${HOME}/.local/bin:${PATH}" +# 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}" +if [[ $# -gt 0 ]]; then + shift +fi +if [[ $# -gt 0 && "${1:-}" =~ ^[0-9]+$ ]]; then + SEEDS=("$@") +else + SEEDS=(42 123 456 789 1024) +fi + +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}" +# Declared paper contract variant the tune JSONs must match (elongate | elongate_barrier). +ANISO_VARIANT="${ANISO_VARIANT:-elongate}" + +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 tune_json="$4" + local rc=0 + uv run python experiments/paper_run_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() { + 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}" "${tune_json}"; then + echo "[skip] ${label} seed=${seed} (fresh paper_metrics_test matches tune/revision)" + return 0 + fi + + 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_paper_30ep.py \ + --base-config "${BASE_CONFIG}" \ + --epochs "${EPOCHS}" \ + --seed "${seed}" \ + --out-base "${out_base}" \ + --tune-json "${tune_json}" \ + --tag "${tag}" \ + --dbscan-backend "${DBSCAN_BACKEND}" \ + >> "${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}" "${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}" & + 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() { + local label="$1" + local tune_json="$2" + local out_base="$3" + local tag="$4" + mkdir -p "${out_base}" + + local pending=() + local seed + for seed in "${SEEDS[@]}"; do + 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 + 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 + wdist) + _run_method "wdist" "${WDIST_JSON}" "${WDIST_OUT}" "wdist" + ;; + mcc) + _run_method "mcc" "${MCC_JSON}" "${MCC_OUT}" "mcc" + ;; + both) + _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 + exit 1 + ;; +esac + +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[@]}" + --aniso-variant "${ANISO_VARIANT}" +) +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_paper_multiseed.py "${AGG_ARGS[@]}" + +echo "Done. Logs: ${LOG_ROOT}/" diff --git a/experiments/tune_mcc.py b/experiments/tune_mcc.py new file mode 100644 index 0000000..99f21a0 --- /dev/null +++ b/experiments/tune_mcc.py @@ -0,0 +1,377 @@ +#!/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/tune_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 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 + +from eval_paper import ( # noqa: E402 + build_split_loader, + iter_cloud_predictions, + load_model_from_run, +) +from tune_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 variant mirrors the base config declaration. + **paper_aniso_fields(cfg), + "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, + # Mirror paper contract / STUDY_PREFLIGHT overrides onto every trial. + "homology_dimensions": [1], + } + }, + "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) + 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="tune_n100_o20_nocls_h1_ellphi_lpca_power", + ) + 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( + "--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/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="tune_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) + 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, + out_base=args.out_base, + config_overrides={ + "model": { + "topology_loss": { + "distance_backend": args.backend, + "prob_weighting": False, + "homology_dimensions": [1], + } + }, + "loss": { + **paper_aniso_fields(base_cfg_for_aniso), + "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_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( + 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: + 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, + 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", + "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, + "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, + "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), + "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_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_mcc_parallel.sh b/experiments/tune_mcc_parallel.sh new file mode 100755 index 0000000..cc4403c --- /dev/null +++ b/experiments/tune_mcc_parallel.sh @@ -0,0 +1,112 @@ +#!/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/tune_mcc_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] [DBSCAN_BACKEND] +# +# Detached: +# 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 + +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="${BASE_CONFIG:-tune_n100_o20_nocls_h1_ellphi_lpca_power}" +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}}" + +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_mcc.py \ + --base-config "${BASE_CONFIG}" \ + --n-trials "${N_TRIALS}" \ + --max-complete-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_mcc_${BACKEND}_dbscan_${DBSCAN_BACKEND}.json" diff --git a/experiments/tune_objectives.sh b/experiments/tune_objectives.sh new file mode 100755 index 0000000..517d1e9 --- /dev/null +++ b/experiments/tune_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/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/objectives/launcher.log \ +# experiments/tune_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/objectives}" +mkdir -p "${LOG_ROOT}" + +run_wdist() { + echo "=== W-Dist objective tune ===" + 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/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" +} + +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/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_wdist.py b/experiments/tune_wdist.py new file mode 100644 index 0000000..bd79d4b --- /dev/null +++ b/experiments/tune_wdist.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +""" +Optuna tuning of paper loss weights (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`` (val_topo selection checkpoint; hard-fail if missing). +3. Objective = mean val **topo W-Dist** (learned ellipses vs local_pca teacher PD). + +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``. + +Usage (parallel, recommended):: + + bash experiments/tune_wdist_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 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 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 + +from eval_paper 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 + +# 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) +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) without ad-hoc fallbacks.""" + vals: list[float] = [] + for points, params, _labels_gt, clean_pc in clouds: + val = compute_topo_wdist(points, params, clean_pc, topo_options) + vals.append(val) + return float(np.mean(vals)) + + +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 = "power", + 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_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), + # Mirror paper contract / STUDY_PREFLIGHT overrides onto every trial. + # 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), + }, + "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, + "homology_dimensions": [1], + } + }, + "outputs": {"base_dir": out_base, "save_every": SAVE_EVERY}, + } + 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", *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, + 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_val_topo_checkpoint(run_dir) + topo_options = topo_wdist_options_from_config(cfg) + model = load_model_from_run(run_dir, cfg, device) + 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("size_mode", args.size_mode) + trial.set_user_attr("run_dir", str(run_dir)) + print( + f"[trial {trial.number}] wdist={mwd:.5f} " + f"w_topo={w_topo:.3f} w_size={w_size:.3f} ep={ckpt_epoch}" + ) + return mwd + + return objective + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + # No implicit default: every study must state its config surface explicitly + # (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( + "--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=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="ellphi", + choices=["mahalanobis", "ellphi"], + help="Training topo-loss backend (ellphi = ellipse tangency filtration).", + ) + p.add_argument("--out-base", type=str, default="outputs/tune/wdist") + 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( + "--narrow-search", + action="store_true", + 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( + "--storage", + type=str, + default=None, + help="Optuna storage URL (required for parallel workers).", + ) + p.add_argument( + "--study-name", + type=str, + default="tune_wdist", + help="Study name (shared across parallel workers when using --storage).", + ) + p.add_argument( + "--write-best", + action="store_true", + help="Write best_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) + 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, + out_base=args.out_base, + config_overrides={ + "model": { + "topology_loss": { + "distance_backend": args.backend, + "prob_weighting": False, + "homology_dimensions": [1], + } + }, + "loss": { + **paper_aniso_fields(base_cfg_for_aniso), + "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_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( + 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: + 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, + 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 + 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", + "objective_kind": "wdist", + "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), + "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, + "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), + "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, + "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_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/experiments/tune_wdist_parallel.sh b/experiments/tune_wdist_parallel.sh new file mode 100755 index 0000000..172d876 --- /dev/null +++ b/experiments/tune_wdist_parallel.sh @@ -0,0 +1,104 @@ +#!/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_mcc power protocol (--narrow-search). +# +# Usage: +# bash experiments/tune_wdist_parallel.sh [N_WORKERS] [N_TRIALS] [TUNE_EPOCHS] [BACKEND] +# +# Detached: +# 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 + +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="${BASE_CONFIG:-tune_n100_o20_nocls_h1_ellphi_lpca_power}" +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}}" + +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_wdist.py \ + --base-config "${BASE_CONFIG}" \ + --n-trials "${N_TRIALS}" \ + --max-complete-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_wdist_${BACKEND}.json" 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 89e49b0..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", ] @@ -42,6 +39,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 = [ @@ -56,11 +56,9 @@ dev = [ exclude = [ ".git", ".venv", - ".venv_linux", "__pycache__", "ellphi_repo", "pytorch-topological", - "trash", "outputs", "data", ] @@ -69,7 +67,6 @@ exclude = [ testpaths = ["tests"] addopts = "-q" norecursedirs = [ - "trash", "ellphi_repo", "pytorch-topological", "outputs", 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/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/scripts/new_experiment.sh b/scripts/new_experiment.sh new file mode 100755 index 0000000..a888c7f --- /dev/null +++ b/scripts/new_experiment.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# 新しい数値実験フォルダを規約どおりに作成する。 +# outputs//_/ +# PURPOSE.md の雛形を生成し、run_backend_multiseed.py に渡す out-base のパスを表示する。 +# +# 使い方: +# scripts/new_experiment.sh [--no-cls|--tune] ["1行の目的"] +# 例: +# scripts/new_experiment.sh paper30_s42 "paper 30ep" +# scripts/new_experiment.sh --no-cls bar_smoke "barrier loss smoke" +# scripts/new_experiment.sh --tune mcc "paper MCC tune" +set -euo pipefail + +mode_dir="supervised" +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 [--no-cls|--tune] [\"one-line purpose\"]" >&2 + exit 1 +fi + +slug="$1" +purpose="${2:-(目的をここに記述)}" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +date_dir="$(date +%m%d)" +exp_dir="${repo_root}/outputs/${mode_dir}/${date_dir}_${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/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/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/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 6227469..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__) @@ -14,19 +16,34 @@ class NoisyMNISTDataset(Dataset): MNIST dataset converted to noisy 2D point clouds. Each image is binarized, converted to a set of (x, y) coordinates, - subsampled or padded to a fixed size, perturbed with Gaussian noise, - and augmented with uniformly random outlier points. + subsampled or padded to a fixed size, optionally perturbed with Gaussian + 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. - num_outliers (int): Number of random outlier points to add. + num_outliers (int): Number of outlier points to add. noise_std (float): Std of Gaussian jitter applied to inlier points. deterministic (bool): If True, fixes RNG per sample for reproducibility. 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/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). + 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. @@ -34,15 +51,45 @@ 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): + deterministic=False, indices=None, noise_seed=0, preload=True, + allow_empty_cloud_fallback=False, + allow_otsu_threshold_fallback=False, + outlier_mode="uniform", + tangent_pca_k=10, + tangent_offset_min=0.15, + tangent_offset_max=0.40, + 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 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) + mode = str(outlier_mode).strip().lower() + if mode not in ("uniform", "local_pca_tangent"): + raise ValueError( + f"outlier_mode must be 'uniform' or 'local_pca_tangent', got {outlier_mode!r}" + ) + self.outlier_mode = mode + 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) + 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) @@ -76,7 +123,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 +170,12 @@ 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 reproducibility.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 @@ -140,18 +199,46 @@ 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.deterministic: - outliers = torch.rand(self.num_outliers, 2, generator=rng) * 2.0 - 1.0 + if self.outlier_mode == "uniform": + outliers = sample_uniform_outliers_with_min_separation( + self.num_outliers, inliers, generator=rng + ) 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. 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, + self.num_outliers, + k=self.tangent_pca_k, + offset_min=self.tangent_offset_min, + 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, + direction=self.tangent_direction, + # 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) @@ -227,7 +314,7 @@ class PreloadedOutlierMNIST(NoisyMNISTDataset): def __init__( self, - root="./data", + root=None, train=True, num_samples=5000, max_points=150, @@ -273,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.py b/tda_ml/dbscan.py index 2f7a84a..eb707b6 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: @@ -21,7 +21,8 @@ def calculate_anisotropic_distance_matrix( 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 calculate_anisotropic_distance_matrix( 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() @@ -73,13 +79,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 new file mode 100644 index 0000000..9eb6c94 --- /dev/null +++ b/tda_ml/dbscan_eval.py @@ -0,0 +1,349 @@ +"""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, 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 +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 + + +@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 + grid_log: list[dict[str, Any]] = field(default_factory=list) + + +@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) > ZERO_PAD_ABS_SUM + 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 = compute_anisotropic_distance_matrix_np( + 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 _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: + 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) + + +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(wdists)), + ) + + +def grid_search_prepared( + prepared: list[PreparedCloud], + *, + eps_values: list[float], + 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``.""" + 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 + 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] = [] + cell_error: str | None = None + for cloud, wdist in zip(prepared, cloud_wdists, strict=True): + try: + rows.append( + _eval_prepared_cloud( + cloud, + float(eps), + int(min_samples), + topo_options=topo_options, + wdist=wdist, + ) + ) + except Exception as exc: + cell_error = f"{type(exc).__name__}: {exc}" + break + 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 + 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, + grid_log=grid_log.copy(), + ) + + if best is None: + raise RuntimeError( + "DBSCAN grid search failed for all hyperparameters; " + f"see grid_log ({len(grid_log)} cells)" + ) + best.grid_log = grid_log + return best + + +def evaluate_model_grid( + model: torch.nn.Module, + 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) + result = grid_search_prepared( + prepared, + 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( + 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/distance_backend.py b/tda_ml/distance_backend.py index d7d0ffd..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 @@ -19,6 +17,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: @@ -31,7 +30,60 @@ except ImportError: squareform = None # type: ignore[misc, assignment] -_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. ``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: + raise RuntimeError( + "scale_mode='median' found no positive off-diagonal distances." + ) + denom = torch.median(off).detach() + NUMERICAL_EPS + return d_mat * (float(clean_scale) / 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: @@ -72,10 +124,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). - 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"): @@ -86,22 +139,19 @@ 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: - 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] @@ -124,3 +174,36 @@ 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``. + Differentiable ellphi requires ``ellphi.grad``; otherwise hard-fail. + """ + 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/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/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/local_pca.py b/tda_ml/local_pca.py new file mode 100644 index 0000000..93c8345 --- /dev/null +++ b/tda_ml/local_pca.py @@ -0,0 +1,84 @@ +"""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, + normalize_axes: bool = True, +) -> 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). + 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. + """ + if points.ndim == 2: + 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)}") + + 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]) + 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 0ae9bfc..37fc4d9 100644 --- a/tda_ml/losses.py +++ b/tda_ml/losses.py @@ -5,59 +5,20 @@ 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 +from tda_ml.persistence_dimensions import ( + normalize_homology_dimensions, + select_persistence_dimensions, +) +from tda_ml.reproducibility import record_fallback -# 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): """ @@ -72,29 +33,73 @@ 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 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. + - 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__() @@ -109,82 +114,171 @@ 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_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: + aspect_ratios = major_axis / (minor_axis + NUMERICAL_EPS) loss = aspect_ratios.mean() return self.weight * loss 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. - 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). + ``homology_dimensions`` selects which VR diagrams enter the Wasserstein sum + and must be set explicitly (paper no_cls stack uses ``[1]``). - ellphi_differentiable: - If ``False``, use NumPy ``EllipseCloud.pdist_tangency`` only (no gradients). + ``distance_backend``: + - ``mahalanobis``: anisotropic distance (optional prob weighting) + - ``ellphi``: tangency distance via ``ellphi.grad`` when differentiable + + Degenerate ellphi geometry raises ``RuntimeError`` (no silent axis projection). """ def __init__( self, weight=0.1, distance_backend: str = "mahalanobis", ellphi_differentiable: 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], + strict_topo_samples: bool = True, + manifest_ref: dict | 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 = 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`` + # 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": 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"): + 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.homology_dimensions = normalize_homology_dimensions( + homology_dimensions + ) + self.strict_topo_samples = bool(strict_topo_samples) + self.manifest_ref = manifest_ref 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: + """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, + points_i: torch.Tensor, + params_i: torch.Tensor, + logits_i: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + 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 + 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 + 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 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: 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 e3ce9e9..2811af7 100644 --- a/tda_ml/main.py +++ b/tda_ml/main.py @@ -7,11 +7,30 @@ 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.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, +) 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.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, + RUN_STATUS_RUNNING, + build_dbscan_eval_manifest_fields, + build_reproducibility_manifest_fields, +) from tda_ml.supervised_diagnostics import ( git_revision, run_abort_diagnostics, @@ -23,36 +42,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, @@ -68,184 +57,280 @@ 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') - 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) - 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, - ) - 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) - logger.info( - "DataLoader settings: workers=%s, pin_memory=%s, persistent_workers=%s, prefetch_factor=%s", - num_workers, - use_pin_memory, - persistent_workers, - prefetch_factor, - ) + data_cfg = config["data"] + 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}") + 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 in run manifest" + ) + if "noise_std" not in data_cfg: + raise ValueError( + "data.noise_std must be set explicitly; refusing silent default in run manifest" + ) + 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", + "tangent_offset_min", + "tangent_offset_max", + "tangent_angle_jitter_deg", + "tangent_stroke_clearance", + "tangent_direction", + ): + 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"]), + 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(), + "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": 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, + "run_status": "pending", + "final_status": "pending", + "preflight_status": "pending", + "fallback_status": "none", + "fallbacks": [], + "reproducibility": build_reproducibility_manifest_fields( + config, project_root=default_project_root() + ), + } + 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" + 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: + preflight_training_config(config, project_root=default_project_root()) + data_cfg = config["data"] + 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) + + 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) - 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, - ) + 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, + ) - 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) - - trainer = Trainer(model, config, device=device) - - init_checkpoint = config.get('init_checkpoint') - if init_checkpoint and os.path.exists(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') - 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, - 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) - - 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) + model = AnisotropicOutlierClassifier(**model_kwargs_from_config(config)) + model.to(device) + + 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=True) + + 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: + 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 (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, ...) - 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 + 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 +343,72 @@ 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 --- + 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_settings.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_settings.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 +421,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, @@ -318,6 +463,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) @@ -328,6 +474,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) @@ -354,6 +504,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 +514,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..619c022 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,38 @@ 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: + 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] 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/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 new file mode 100644 index 0000000..a2fa514 --- /dev/null +++ b/tda_ml/model_selection.py @@ -0,0 +1,152 @@ +"""Checkpoint-selection policy for the training loop. + +Default production protocol (``val_topo``): +- During training: save ``best_model.pth`` at minimum ``val_topo_loss`` (no DBSCAN grid). +- 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 +pick checkpoints; use only for tuning or ablations (much slower on CPU). + +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 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__) + + +@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: + 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 {} + 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, + 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" + 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=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} " + 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/numerical_eps.py b/tda_ml/numerical_eps.py index fc6f49b..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 @@ -11,3 +19,16 @@ # 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 +# 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/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/persistence_dimensions.py b/tda_ml/persistence_dimensions.py new file mode 100644 index 0000000..f1e6a0c --- /dev/null +++ b/tda_ml/persistence_dimensions.py @@ -0,0 +1,57 @@ +"""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]) -> tuple[int, ...]: + """Validate an explicit subset of the currently supported dimensions. + + Missing / ``None`` hard-fails: never infer H0+H1. + """ + if value is None: + 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") + 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], +) -> 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 new file mode 100644 index 0000000..547ba00 --- /dev/null +++ b/tda_ml/preflight.py @@ -0,0 +1,592 @@ +"""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 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, + 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, +) + +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_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", + "distance_backend": "ellphi", + "size_mode": "power", + "w_class": 0.0, + "teacher_local_pca_k": 10, + "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 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] = {} + + 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' 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( + "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 "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"] + ) + + 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={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() + except ImportError as exc: + raise RuntimeError(f"Required dependency {name!r} is not importable: {exc}") from exc + + +def classify_tune_objective( + objective: str, + *, + objective_kind: str | None = None, +) -> TuneObjectiveKind: + """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 {objective!r}; use a whitelist name: " + f"{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( + 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 {} + 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(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" + ) + 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" + ) + 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) + 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 + + 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 " + f"{root / '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, + "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), + } + 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, + 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}") + 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}") + 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"), + ) + 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}" + ) + 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 + + +def preflight_tune_study( + *, + base_config: str, + 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) + 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 + + +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, + ) + + +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, + ) + + +def preflight_tune_production_run( + *, + base_config: str, + tune_json: Path, + 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) + 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"])}, + }, + ) + 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 {} + 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 + + +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) -> 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}") + resolve_val_topo_checkpoint(run_dir) + + +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..fbcccc9 --- /dev/null +++ b/tda_ml/reproducibility.py @@ -0,0 +1,288 @@ +"""Reproducibility helpers: DBSCAN grid resolution, fallback policy, manifest fields.""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path +from typing import Any + +from tda_ml.ellphi_torch import _has_ellphi_grad_api +from tda_ml.numerical_eps import ( + EIGENVALUE_FLOOR, + INLIER_PROB_MIN, + MIN_ELLPHI_CENTER_SEPARATION, + NUMERICAL_EPS, + PCA_RIDGE_EPS, + ZERO_PAD_ABS_SUM, +) + +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" +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 _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 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: + 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], + *, + project_root: Path | str | None = None, +) -> dict[str, Any]: + return { + "settings": reproducibility_settings(config), + "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, + "ZERO_PAD_ABS_SUM": ZERO_PAD_ABS_SUM, + }, + "ellphi_repo": build_ellphi_repo_manifest_fields(project_root), + } + + +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/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_paths.py b/tda_ml/run_paths.py new file mode 100644 index 0000000..ddf5f04 --- /dev/null +++ b/tda_ml/run_paths.py @@ -0,0 +1,128 @@ +"""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%S" +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 config_id (including legacy aliases).""" + m = re.fullmatch(r"paper_seed(\d+)", config_id) + 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"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_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)}" + + # 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 ("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)``. + + 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_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: + """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 new file mode 100644 index 0000000..abde18c --- /dev/null +++ b/tda_ml/run_setup.py @@ -0,0 +1,192 @@ +"""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.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__) + + +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"] + 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] + + 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_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); " + "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" + ) + + repro = reproducibility_settings(config) + dataset_kwargs = dict( + 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=repro["allow_empty_cloud_fallback"], + allow_otsu_threshold_fallback=repro["allow_otsu_threshold_fallback"], + ) + + if outlier_mode == "local_pca_tangent": + for key in ( + "tangent_pca_k", + "tangent_offset_min", + "tangent_offset_max", + "tangent_angle_jitter_deg", + "tangent_stroke_clearance", + "tangent_direction", + ): + 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"]), + 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) + 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/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/tangent_outliers.py b/tda_ml/tangent_outliers.py new file mode 100644 index 0000000..422ae94 --- /dev/null +++ b/tda_ml/tangent_outliers.py @@ -0,0 +1,150 @@ +"""Place outliers along (or near) local-PCA principal axes (tangent or normal directions).""" + +from __future__ import annotations + +import math + +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, + 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) 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 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``; + - 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) + 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}") + 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}") + 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( + 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] + 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) + 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 + 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 = 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()): + 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()): + 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}, " + f"angle_jitter_deg={angle_jitter_deg}, " + f"stroke_clearance={stroke_clearance}, " + f"direction={direction})." + ) + return outliers diff --git a/tda_ml/teacher_pd.py b/tda_ml/teacher_pd.py new file mode 100644 index 0000000..4cb4e47 --- /dev/null +++ b/tda_ml/teacher_pd.py @@ -0,0 +1,102 @@ +"""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, +) +from tda_ml.numerical_eps import ZERO_PAD_ABS_SUM + + +def _median_offdiag(d_mat: torch.Tensor) -> float: + off = d_mat[d_mat > 0] + if off.numel() == 0: + raise RuntimeError( + "Teacher distance matrix has no positive off-diagonal entries; " + "cannot compute median scale." + ) + 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, + local_pca_normalize_axes: bool = True, + 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) > ZERO_PAD_ABS_SUM + pts = pts[valid_mask] + if pts.shape[0] < 2: + 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] + 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 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, + normalize_axes=local_pca_normalize_axes, + ) + 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..2f1cb3b --- /dev/null +++ b/tda_ml/topo_wdist.py @@ -0,0 +1,235 @@ +"""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, + 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: + """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 + + def __post_init__(self) -> None: + object.__setattr__( + self, + "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: + """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" + ) + + 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_normalize_axes = bool( + loss_cfg.get( + "teacher_local_pca_normalize_axes", + training_cfg.get("teacher_local_pca_normalize_axes", True), + ) + ) + + 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( + topo_cfg["homology_dimensions"] + ), + ) + + +def _subsample_cloud( + points: torch.Tensor, + params: torch.Tensor, + max_points: int | None, +) -> tuple[torch.Tensor, torch.Tensor]: + idx = subsample_indices(points.shape[0], max_points, device=points.device) + if idx is None: + return points, params + return points[idx], params[idx] + + +def compute_topo_wdist( + points: np.ndarray, + params: np.ndarray, + clean_pc: np.ndarray, + 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). + """ + 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) + + 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, + local_pca_normalize_axes=opts.teacher_local_pca_normalize_axes, + max_points=opts.max_points, + 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), + 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) + 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") + return value 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 72b5775..6c01a8d 100644 --- a/tda_ml/trainer.py +++ b/tda_ml/trainer.py @@ -4,14 +4,21 @@ 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.reproducibility import ( + assert_ellphi_differentiable_available, + assert_loss_config_explicit, + record_fallback, + reproducibility_settings, +) from tda_ml.losses import ( - ClassificationLoss, - TopologicalLoss, - SizeRegularizationLoss, - AnisotropyPenaltyLoss + ClassificationLoss, + TopologicalLoss, + SizeRegularizationLoss, + AnisotropyPenaltyLoss, ) 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 @@ -34,6 +41,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 = ( @@ -45,78 +74,299 @@ 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 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)) - - size_default = loss_cfg.get( - "w_size", training_cfg.get("lambda_size", 0.1) + 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) + 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_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) + 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() + 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", + training_cfg.get("barrier_threshold", 6.0), + ) + ) + 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() + 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" + ) + 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")) + ) + self.size_power = float( + loss_cfg.get("size_power", training_cfg.get("size_power")) + ) + 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, + 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 # 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) + _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() + 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). + 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")) + ) + self.topo_scale_mode = str( + loss_cfg.get("topo_scale_mode", training_cfg.get("topo_scale_mode")) + ).strip().lower() + self.teacher_mode = str(teacher_raw).strip().lower() + 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"), + ) + ) + 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", + "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})" if self.distance_backend == "ellphi" else "" ), + self.prob_weighting, + self.homology_dimensions, + 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, 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, + homology_dimensions=self.homology_dimensions, + strict_topo_samples=self._repro["strict_topo_samples"], + 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, + 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, + ) + if self.topo_loss_max_points is not None: + logger.info("Topological loss subsampling: max_points=%s", self.topo_loss_max_points) + + 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, + 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"), ) - - 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) -> 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 train_epoch(self, data_loader, epoch): self.model.train() @@ -155,9 +405,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,7 +420,13 @@ 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: @@ -179,11 +436,27 @@ def train_epoch(self, data_loader, epoch): size_loss = torch.tensor(0.0, device=self.device) aniso_loss = torch.tensor(0.0, device=self.device) - loss = class_loss + topo_loss + aniso_loss + size_loss + loss = topo_loss + aniso_loss + size_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) - 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: @@ -202,13 +475,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() - + 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}", + ) if i % 10 == 0: logger.debug( "Step %s: loss=%.4f class=%.4f topo=%.4f aniso=%.4f size=%.4f", @@ -254,7 +533,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 +556,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 +577,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 +608,11 @@ def validate(self, data_loader): avg_aniso = self._val_aniso_accum / num_batches avg_size = self._val_size_accum / num_batches + 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) @@ -315,5 +620,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..0dbdfb5 100644 --- a/tda_ml/visualization.py +++ b/tda_ml/visualization.py @@ -6,6 +6,41 @@ import numpy as np 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" + + +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 = compute_anisotropic_distance_matrix_np( + 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 +51,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,92 +78,59 @@ 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) 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_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() 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_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..7fea51c --- /dev/null +++ b/tests/test_dbscan_grid_wdist_cache.py @@ -0,0 +1,71 @@ +"""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", + homology_dimensions=(0, 1), + prob_weighting=False, + ) + 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", + homology_dimensions=(0, 1), + prob_weighting=False, + ) + 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..0775e25 --- /dev/null +++ b/tests/test_ellipse_barrier_losses.py @@ -0,0 +1,97 @@ +"""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_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( + 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_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/tests/test_losses_topo_distance.py b/tests/test_losses_topo_distance.py index 82838c8..e636d2a 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.losses import ( +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 @@ -48,6 +75,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", 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, homology_dimensions=[0, 1]) + same = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", + 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) + + 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, homology_dimensions=[0, 1]) + scaled = TopologicalLoss(weight=1.0, distance_backend="mahalanobis", + 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) + + 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", 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) + 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", 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", + 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 +173,28 @@ def test_mahalanobis_extreme_params_remain_finite(self): self.assertTrue(torch.isfinite(d).all()) +class TestTopoSubsampling(unittest.TestCase): + 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, + homology_dimensions=[0, 1], + ) + loss = loss_fn(pt, par, logits, clean) + self.assertTrue(torch.isfinite(loss)) + + if __name__ == "__main__": unittest.main() 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_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() diff --git a/tests/test_paper_eval_imports.py b/tests/test_paper_eval_imports.py new file mode 100644 index 0000000..86c9f79 --- /dev/null +++ b/tests/test_paper_eval_imports.py @@ -0,0 +1,63 @@ +"""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.eval_baselines as baselines + import experiments.eval_paper 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_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.eval_paper 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("paper_n100_o20_nocls_h1_ellphi_lpca_power") + ) + assert_paper_no_cls_contract( + load_config("tune_n100_o20_nocls_h1_ellphi_lpca_power") + ) + + 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("paper_n100_o20_nocls_h1_ellphi_lpca_power") + 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_paper_production_gates.py b/tests/test_paper_production_gates.py new file mode 100644 index 0000000..08e55ec --- /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_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, + 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"paper_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 / "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}), + 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( + "paper_n100_o20_nocls_h1_ellphi_lpca_power", + 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="wdist", + 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 / "paper_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_wdist.json" + ).write_text("{}", encoding="utf-8") + status, _ = inspect_seed_metrics( + out_base=root, + seed=42, + tag="wdist", + 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_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 new file mode 100644 index 0000000..317421e --- /dev/null +++ b/tests/test_persistence_dimensions.py @@ -0,0 +1,141 @@ +"""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_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]) + 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_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], + "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", + }, + } + 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) + 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 new file mode 100644 index 0000000..c9d6bd3 --- /dev/null +++ b/tests/test_reproducibility_strict.py @@ -0,0 +1,367 @@ +"""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") + self.assertEqual( + classify_tune_objective( + "val_dbscan_mcc_max_at_val_topo_best_ckpt" + ), + "mcc", + ) + self.assertEqual( + classify_tune_objective( + "val_dbscan_mcc_max", + objective_kind="mcc", + ), + "mcc", + ) + self.assertEqual( + classify_tune_objective("val_topo_wdist_min"), + "wdist", + ) + 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_requires_explicit_metric_and_backend(self): + from tda_ml.model_selection import 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): + 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") + + 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_objective_kind_must_agree_with_whitelist_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", + ) + with self.assertRaisesRegex(ValueError, "Unrecognized tune objective"): + preflight_tune_json(path) + + +class TestPaperNoClsContract(unittest.TestCase): + @staticmethod + def _valid_config() -> dict: + return { + "model": { + "topology_loss": { + "homology_dimensions": [1], + "prob_weighting": False, + "distance_backend": "ellphi", + } + }, + "loss": { + "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, + }, + } + + 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) + + 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): + 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", + homology_dimensions=(0, 1), + prob_weighting=False, + ) + 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, + homology_dimensions=[0, 1], + 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_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() diff --git a/tests/test_run_paths.py b/tests/test_run_paths.py new file mode 100644 index 0000000..39eac9e --- /dev/null +++ b/tests/test_run_paths.py @@ -0,0 +1,85 @@ +"""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("paper_seed42"), "paper_s42") + 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"}, + "outputs": {}, + } + self.assertEqual(resolve_run_slug(cfg), "custom") + + 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_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_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) + 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: + self.assertEqual(visualization_filename(30), "e30.png") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tangent_outliers.py b/tests/test_tangent_outliers.py new file mode 100644 index 0000000..6ce2309 --- /dev/null +++ b/tests/test_tangent_outliers.py @@ -0,0 +1,158 @@ +"""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_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_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. + 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() diff --git a/tests/test_teacher_pd.py b/tests/test_teacher_pd.py new file mode 100644 index 0000000..0cda0ca --- /dev/null +++ b/tests/test_teacher_pd.py @@ -0,0 +1,102 @@ +"""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_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) + 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..8f5ce3c --- /dev/null +++ b/tests/test_topo_wdist.py @@ -0,0 +1,51 @@ +"""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", + homology_dimensions=(0, 1), + prob_weighting=False, + ) + 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", + 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) + 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..1f4a077 100644 --- a/tests/test_trainer.py +++ b/tests/test_trainer.py @@ -37,11 +37,28 @@ 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, + 'w_topo': 0.1, + '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, + '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': { @@ -78,5 +95,44 @@ 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, + "w_topo": 0.1, + "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, + "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) + 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..5e76849 --- /dev/null +++ b/tests/test_tune_config.py @@ -0,0 +1,173 @@ +"""Tests for Optuna tune trial config (local_pca teacher).""" + +from __future__ import annotations + +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_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): + def test_paper_declares_topology_contract(self): + from tda_ml.config import load_config + + 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"], "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( + "tune_n100_o20_nocls_h1_ellphi_lpca_power", + 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, + 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"]) + + def test_power_tune_preserves_h1_hard_fail_stack(self): + cfg = build_wdist_trial( + "tune_n100_o20_nocls_h1_ellphi_lpca_power", + 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_power", + 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") + 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( + "tune_n100_o20_nocls_h1_ellphi_lpca_power", + project_root=REPO, + ) + divergent["model"]["topology_loss"]["homology_dimensions"] = [0, 1] + divergent["loss"]["aniso_mode"] = "linear" + + with mock.patch( + "tune_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_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_wdist_builder_mirrors_barrier_variant_from_base_config(self): + 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_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( + "tune_n100_o20_nocls_h1_ellphi_lpca_power", + 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() diff --git a/third_party/README.md b/third_party/README.md index 531f6c4..bed301c 100644 --- a/third_party/README.md +++ b/third_party/README.md @@ -7,4 +7,14 @@ 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`. +- **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. 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 diff --git a/uv.lock b/uv.lock index f560daf..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" @@ -354,37 +345,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]] @@ -396,96 +387,33 @@ 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" -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" @@ -705,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" @@ -900,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" @@ -1029,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" @@ -1047,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" @@ -1190,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" @@ -1410,10 +1231,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 +1283,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 = [ @@ -1509,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" @@ -1595,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" @@ -1626,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" @@ -1778,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" @@ -1842,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" @@ -1922,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" @@ -2183,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" @@ -2313,9 +1971,6 @@ experiments = [ images = [ { name = "pillow" }, ] -repro-pd-animation = [ - { name = "homcloud" }, -] [package.dev-dependencies] dev = [ @@ -2326,9 +1981,8 @@ 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" }, { name = "matplotlib", specifier = ">=3.8" }, { name = "numpy", specifier = "<2" }, @@ -2345,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 = [ @@ -2557,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"