Files
hypertower/scripts/grid_search_analytics/derived_statistics.py
T
2026-02-24 10:39:48 +01:00

315 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Shared helpers for building grid search analytics.
The class below will gradually accumulate reusable utilities for working with
grid search outputs (summary.json, cli_args.json, etc).
"""
from __future__ import annotations
import json
import csv
import re
from pathlib import Path
from typing import Dict, Iterable, Iterator, List, Optional
import numpy as np
DEFAULT_EXCLUDE_KEYS = {
"run_id",
"fold_metrics",
"best_metric",
"best_metric_mode",
"best_metric_mean",
"best_metric_std",
"eval_mode",
"n_splits",
"num_classes",
}
class GridSearchAnalytics:
"""Utility wrapper for inspecting grid search result directories."""
def __init__(self,
analysis_dir: Path | str,
exclude_keys: Optional[Iterable[str]] = None) -> None:
self.analysis_dir = Path(analysis_dir)
if not self.analysis_dir.exists():
raise FileNotFoundError(f"analysis_dir does not exist: {self.analysis_dir}")
self.exclude_keys = set(exclude_keys or DEFAULT_EXCLUDE_KEYS)
def iter_run_dirs(self, shallow: bool = True) -> Iterator[Path]:
"""
Yield run directories containing grid search artifacts.
Shallow iteration only walks direct children. Deep iteration scans the
entire subtree.
"""
candidates: Iterable[Path]
if shallow:
candidates = (p for p in sorted(self.analysis_dir.iterdir()) if p.is_dir())
else:
candidates = (p for p in self.analysis_dir.rglob("*") if p.is_dir())
for run_dir in candidates:
summary = run_dir / "summary.json"
cli = run_dir / "cli_args.json"
if summary.exists() or cli.exists():
yield run_dir
def read_summary(self, run_dir: Path) -> Optional[Dict[str, object]]:
"""Load summary.json for a run directory."""
return self._read_json(run_dir / "summary.json")
def read_cli_args(self, run_dir: Path) -> Optional[Dict[str, object]]:
"""Load cli_args.json for a run directory."""
return self._read_json(run_dir / "cli_args.json")
def read_run_id(self,
run_dir: Path,
summary: Optional[Dict[str, object]]) -> str:
if summary:
rid = summary.get("run_id")
if isinstance(rid, str) and rid:
return rid
return run_dir.name
def task_from_summary(self, summary: Optional[Dict[str, object]]) -> Optional[str]:
"""Infer task (binary vs multiclass) from a summary payload."""
if not summary:
return None
eval_mode = summary.get("eval_mode")
if isinstance(eval_mode, str):
mode = eval_mode.strip().lower()
if mode == "binary":
return "binary"
if mode in {"multiclass", "multi", "multi-class"}:
return "multiclass"
num_classes = summary.get("num_classes")
if isinstance(num_classes, (int, float)):
return "binary" if int(num_classes) <= 2 else "multiclass"
return None
def flatten_config(self,
data: Dict[str, object],
prefix: str = "",
exclude_keys: Optional[Iterable[str]] = None) -> Dict[str, object]:
"""Flatten nested CLI args or config dictionaries for analysis."""
out: Dict[str, object] = {}
excludes = set(exclude_keys or self.exclude_keys)
for key, value in data.items():
if key in excludes or key.startswith("best_"):
continue
full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
if isinstance(value, dict):
out.update(self.flatten_config(value, full_key, exclude_keys=excludes))
continue
if isinstance(value, list):
continue
out[full_key] = value
return out
def fusion_correction_events(self,
output_csv: Path | str | None = None,
shallow: bool = True) -> Path:
"""
Build a table of cases where the fused head is correct while both towers
are wrong. Rows are written to CSV for downstream analysis.
"""
output_path = Path(output_csv) if output_csv else Path("analysis_data/grid_search_analytics/fusion_corrections.csv")
output_path.parent.mkdir(parents=True, exist_ok=True)
rows: List[Dict[str, object]] = []
for run_dir in self.iter_run_dirs(shallow=shallow):
summary = self.read_summary(run_dir)
run_id = self.read_run_id(run_dir, summary)
folds = self._available_folds(run_dir, summary)
for fold in folds:
y_true = self._load_y_true(run_dir, fold)
if y_true is None:
continue
epoch_prob_paths = self._collect_epoch_prob_paths(run_dir, fold)
if not epoch_prob_paths:
# Per-epoch dumps were not found; fall back to the saved fold-level probabilities.
base_paths = self._collect_base_prob_paths(run_dir, fold)
if base_paths:
epoch_hint = self._fold_epoch_hint(summary, fold)
epoch_prob_paths = {epoch_hint if epoch_hint is not None else 0: base_paths}
for epoch, paths in epoch_prob_paths.items():
arrays = {head: self._load_probs_array(path) for head, path in paths.items()}
if not self._has_all_heads(arrays):
continue
events = self._fusion_corrections_for_probs(y_true, arrays, run_id, fold, epoch)
rows.extend(events)
if rows:
fieldnames = [
"run_id",
"fold",
"epoch",
"index",
"y_true",
"pred_fused",
"pred_img",
"pred_md",
"conf_fused",
"conf_img",
"conf_md",
]
with output_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
else:
output_path.write_text("")
return output_path
@staticmethod
def _read_json(path: Path) -> Optional[Dict[str, object]]:
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except Exception:
return None
if not isinstance(data, dict):
return None
return data
@staticmethod
def _fold_epoch_hint(summary: Optional[Dict[str, object]], fold: int) -> Optional[int]:
if not summary:
return None
fold_metrics = summary.get("fold_metrics") or []
for entry in fold_metrics:
if not isinstance(entry, dict):
continue
if entry.get("fold") == fold:
stats = entry.get("stats") if isinstance(entry.get("stats"), dict) else {}
epoch = stats.get("epoch") or entry.get("best_epoch")
if isinstance(epoch, (int, float)):
return int(epoch)
return None
@staticmethod
def _available_folds(run_dir: Path, summary: Optional[Dict[str, object]]) -> List[int]:
folds: List[int] = []
if summary:
for entry in summary.get("fold_metrics") or []:
if not isinstance(entry, dict):
continue
fold_idx = entry.get("fold")
if isinstance(fold_idx, int):
folds.append(fold_idx)
if not folds:
pattern = re.compile(r"fold(\d+)_y_true\.npy$")
for path in run_dir.glob("fold*_y_true.npy"):
match = pattern.match(path.name)
if match:
folds.append(int(match.group(1)))
return sorted(set(folds))
@staticmethod
def _load_y_true(run_dir: Path, fold: int) -> Optional[np.ndarray]:
path = run_dir / f"fold{fold}_y_true.npy"
if not path.exists():
return None
try:
return np.load(path)
except Exception:
return None
@staticmethod
def _collect_epoch_prob_paths(run_dir: Path, fold: int) -> Dict[int, Dict[str, Path]]:
pattern = re.compile(rf"fold{fold}_epoch(\d+)_probs_(\w+)\.npy$")
epoch_paths: Dict[int, Dict[str, Path]] = {}
for path in run_dir.glob(f"fold{fold}_epoch*_probs_*.npy"):
match = pattern.match(path.name)
if not match:
continue
epoch = int(match.group(1))
head = match.group(2)
epoch_paths.setdefault(epoch, {})[head] = path
return epoch_paths
@staticmethod
def _collect_base_prob_paths(run_dir: Path, fold: int) -> Dict[str, Path]:
paths: Dict[str, Path] = {}
for head in ("fused", "img", "md"):
candidate = run_dir / f"fold{fold}_probs_{head}.npy"
if candidate.exists():
paths[head] = candidate
return paths
@staticmethod
def _load_probs_array(path: Path) -> Optional[np.ndarray]:
try:
return np.load(path)
except Exception:
return None
@staticmethod
def _prepare_probs(arr: np.ndarray) -> Optional[np.ndarray]:
if arr is None:
return None
probs = np.asarray(arr, dtype=float)
if probs.ndim == 1:
probs = np.stack([1.0 - probs, probs], axis=1)
if probs.ndim != 2:
return None
return probs
@staticmethod
def _has_all_heads(arrays: Dict[str, Optional[np.ndarray]]) -> bool:
needed = ("fused", "img", "md")
return all(arrays.get(head) is not None for head in needed)
def _fusion_corrections_for_probs(self,
y_true: np.ndarray,
arrays: Dict[str, np.ndarray],
run_id: str,
fold: int,
epoch: int) -> List[Dict[str, object]]:
fused = self._prepare_probs(arrays.get("fused"))
img = self._prepare_probs(arrays.get("img"))
md = self._prepare_probs(arrays.get("md"))
if fused is None or img is None or md is None:
return []
if not (len(fused) == len(img) == len(md) == len(y_true)):
return []
fused_pred = fused.argmax(axis=1)
img_pred = img.argmax(axis=1)
md_pred = md.argmax(axis=1)
fused_conf = np.take_along_axis(fused, fused_pred[:, None], axis=1).squeeze(1)
img_conf = np.take_along_axis(img, img_pred[:, None], axis=1).squeeze(1)
md_conf = np.take_along_axis(md, md_pred[:, None], axis=1).squeeze(1)
mask = (fused_pred == y_true) & (img_pred != y_true) & (md_pred != y_true)
indices = np.nonzero(mask)[0]
events: List[Dict[str, object]] = []
for idx in indices:
events.append({
"run_id": run_id,
"fold": fold,
"epoch": epoch,
"index": int(idx),
"y_true": int(y_true[idx]),
"pred_fused": int(fused_pred[idx]),
"pred_img": int(img_pred[idx]),
"pred_md": int(md_pred[idx]),
"conf_fused": float(fused_conf[idx]),
"conf_img": float(img_conf[idx]),
"conf_md": float(md_conf[idx]),
})
return events
if __name__ == "__main__":
analytics = GridSearchAnalytics(Path("analysis_data/grid_search"))
output = analytics.fusion_correction_events()
print(f"Fusion correction events written to {output}")