| """Out-of-distribution (covariate-shift) split from void-geometry statistics (Gate D). |
| |
| No new FEM is needed: we re-use the existing 2000 samples but split them by a per-sample |
| geometric statistic of the void boundary radii ``rr`` (shape ``(n, 42)``). The model trains on |
| the lower percentile (typical voids) and is tested on the upper percentile (large/extreme voids |
| it never saw) — a genuine covariate shift in geometry. |
| |
| Split rule (documented exactly, master plan §2.6): |
| stat_i = STAT(rr_i) # default STAT = max radius ("void size") |
| threshold = quantile(stat, train_frac) # default train_frac = 0.80 |
| in-distribution (train+id-test): stat_i <= threshold (lower ~80%) |
| out-of-distribution (ood-test) : stat_i > threshold (upper ~20%) |
| The in-distribution pool is further split into train / id-test by ``id_test_frac`` (held-out, |
| seeded) so in-dist and OOD errors are both measured on data unseen during training. |
| """ |
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Dict |
|
|
| import numpy as np |
| import torch |
|
|
| STATS = { |
| "max": lambda rr: rr.max(axis=1), |
| "mean": lambda rr: rr.mean(axis=1), |
| "std": lambda rr: rr.std(axis=1), |
| "range": lambda rr: rr.max(axis=1) - rr.min(axis=1), |
| } |
|
|
|
|
| def geometry_stat(rr: torch.Tensor, kind: str = "max") -> np.ndarray: |
| if kind not in STATS: |
| raise ValueError(f"unknown stat {kind!r}; choose from {list(STATS)}") |
| return STATS[kind](rr.detach().cpu().numpy()) |
|
|
|
|
| @dataclass |
| class OODSplit: |
| train_idx: np.ndarray |
| id_test_idx: np.ndarray |
| ood_test_idx: np.ndarray |
| threshold: float |
| stat_kind: str |
| info: Dict |
|
|
|
|
| def make_ood_split( |
| rr: torch.Tensor, |
| stat_kind: str = "max", |
| train_frac: float = 0.80, |
| id_test_frac: float = 0.20, |
| seed: int = 0, |
| ) -> OODSplit: |
| """Build a covariate-shift split from ``rr`` statistics. Deterministic given ``seed``.""" |
| stat = geometry_stat(rr, stat_kind) |
| n = stat.shape[0] |
| threshold = float(np.quantile(stat, train_frac)) |
|
|
| in_dist = np.where(stat <= threshold)[0] |
| ood = np.where(stat > threshold)[0] |
| |
| |
| |
| if ood.size == 0: |
| raise ValueError( |
| f"OOD split is empty: stat ({stat_kind}) has no values above the " |
| f"{train_frac:.0%} quantile (likely tied at the maximum). Lower train_frac " |
| f"or choose a different stat." |
| ) |
|
|
| rng = np.random.default_rng(seed) |
| perm = rng.permutation(in_dist) |
| n_id_test = int(round(len(in_dist) * id_test_frac)) |
| id_test_idx = np.sort(perm[:n_id_test]) |
| train_idx = np.sort(perm[n_id_test:]) |
|
|
| info = { |
| "n_total": int(n), |
| "n_train": int(train_idx.size), |
| "n_id_test": int(id_test_idx.size), |
| "n_ood_test": int(ood.size), |
| "threshold": threshold, |
| "stat_kind": stat_kind, |
| "stat_train_max": float(stat[train_idx].max()) if train_idx.size else float("nan"), |
| "stat_ood_min": float(stat[ood].min()), |
| "stat_ood_max": float(stat[ood].max()), |
| } |
| return OODSplit( |
| train_idx=train_idx, |
| id_test_idx=id_test_idx, |
| ood_test_idx=np.sort(ood), |
| threshold=threshold, |
| stat_kind=stat_kind, |
| info=info, |
| ) |
|
|