Source code for ibl_bwb_eval.scoring.ts3

"""TS3 scoring utility.

Loads prediction .safetensors files, aligns by unit_id to the ground truth,
and computes brain-region classification metrics.

Usage:
    python -m ibl_bwb_eval.scoring.ts3 --pred_dir predictions/<label> --gt_dir ground_truth/
"""

import argparse
from collections import defaultdict
from pathlib import Path

import numpy as np
from rich.console import Console
from rich.table import Table
from safetensors import safe_open
from safetensors.torch import load_file

from ibl_bwb_eval.entity_ids import decode_entity_ids
from ibl_bwb_eval.scoring._types import MetricSummary
from ibl_bwb_eval.tasks import CLASSIFICATION_METRICS, get_ts3_readout_spec, is_task_of, task_of

__api_ref__ = {
    "description": None,
    "sections": [{"title": None, "autosummary": ["score_file", "score_dir", "summarize"]}],
}


# The report tables below are classification-shaped, so they read the classification names
# directly. Scoring itself is not: it calls whatever metrics the task's spec declares.
MACRO_METRICS: tuple[str, ...] = tuple(f"macro/{m}" for m in CLASSIFICATION_METRICS)


[docs] def score_file(pred_path: Path, gt_path: Path) -> dict[str, float]: """Score one prediction file against its ground truth file. Args: pred_path: path to a predictions .safetensors file. gt_path: path to the ground_truth.safetensors file. Returns: Dict mapping metric name to scalar value. """ with safe_open(str(pred_path), framework="pt") as f: pred_meta = f.metadata() with safe_open(str(gt_path), framework="pt") as f: gt_meta = f.metadata() if pred_meta["label_names"] != gt_meta["label_names"]: raise ValueError( "label_names mismatch between prediction and ground truth: " "ensure both were produced with the same canonical label ordering.\n" f" pred: {pred_meta['label_names']}\n" f" gt: {gt_meta['label_names']}" ) pred = load_file(str(pred_path)) gt = load_file(str(gt_path)) pred_uids = decode_entity_ids(pred["entity_ids"]) gt_uids = decode_entity_ids(gt["entity_ids"]) uid_to_pred_idx = {uid: i for i, uid in enumerate(pred_uids)} try: pred_order = np.array([uid_to_pred_idx[uid] for uid in gt_uids]) except KeyError as e: raise ValueError(f"GT unit {e} not found in predictions") from e label_names = gt_meta["label_names"].split(",") pred_proba = pred["pred_proba"][pred_order].numpy() targets = gt["targets"].numpy() target_labels = [label_names[i] for i in targets] pred_labels = [label_names[i] for i in np.argmax(pred_proba, axis=1)] spec = get_ts3_readout_spec(task_of("ts3", pred_meta["task"])) return spec.score(target_labels, pred_labels)
[docs] def score_dir( pred_dir: str | Path, gt_dir: str | Path, ) -> dict[tuple[str, int], dict[str, float]]: """Score all prediction files found under ``pred_dir``. Expects prediction files written by :class:`PredictionsWriter` (metadata carries ``label``, ``task``, ``seed``). ``pred_dir`` may point at any subtree. Ground truth files are looked up as: ``{gt_dir}/{task}/ground_truth.safetensors``. Prediction files whose ``task`` doesn't start with ``ts3-`` (e.g. other task suites picked up under a shared ``pred_dir``) are skipped silently. Among ``ts3-`` tasks, one that doesn't resolve to an existing ground truth file is skipped with a warning. Returns a dict keyed by ``(label, seed)`` mapping metric name to scalar value. """ pred_dir = Path(pred_dir) gt_dir = Path(gt_dir) console = Console() results = {} pred_paths = sorted(pred_dir.rglob("seed_*.safetensors")) if not pred_paths: console.print(f"[yellow]No prediction files found under {pred_dir}[/yellow]") return results for pred_path in pred_paths: with safe_open(str(pred_path), framework="pt") as f: meta = f.metadata() label = meta["label"] task = meta["task"] seed = int(meta["seed"]) if not is_task_of("ts3", task): continue gt_path = gt_dir / task / "ground_truth.safetensors" if not gt_path.exists(): console.print(f"[yellow]GT not found, skipping:[/yellow] {gt_path}") continue try: results[(label, seed)] = score_file(pred_path, gt_path) except Exception as e: console.print(f"[red]Error scoring {pred_path}:[/red] {e}") return results
[docs] def summarize( raw: dict[tuple[str, int], dict[str, float]], ) -> dict[str, MetricSummary]: """Aggregate per-seed scores into mean ± SEM per label. Returns a dict keyed by ``label`` where each value maps metric name to ``(mean, sem, n)``. ``sem`` is ``None`` when ``n == 1``. """ grouped: dict[str, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list)) for (label, _seed), metrics in raw.items(): for metric_name, value in metrics.items(): grouped[label][metric_name].append(value) result: dict[str, MetricSummary] = {} for label, metric_vals in grouped.items(): result[label] = {} for metric_name, values in metric_vals.items(): n = len(values) mean = float(np.mean(values)) sem = float(np.std(values, ddof=1) / np.sqrt(n)) if n > 1 else None result[label][metric_name] = (mean, sem, n) return result
def _fmt(mean: float, sem: float | None) -> str: sem_str = f{sem:.4f}" if sem is not None else "[dim]± --[/dim]" return f"{mean:.4f} {sem_str}" def print_results( summary: dict[str, MetricSummary], console: Console | None = None, ) -> None: """Print results: one macro summary table, one per-class table (regions as rows).""" console = console or Console() # Collect region names from per-class keys (e.g. "VISp/precision" -> "VISp") all_regions: list[str] = [] for metric_vals in summary.values(): for m in metric_vals: if "/" in m and not m.startswith("macro/"): region = m.split("/")[0] if region not in all_regions: all_regions.append(region) # --- Macro summary table (one row per label) --- macro_table = Table(title="TS3 Macro Summary", show_header=True, show_lines=False) macro_table.add_column("Label", style="magenta", no_wrap=True) for m in MACRO_METRICS: macro_table.add_column(m, style="green", justify="right") macro_table.add_column("n", style="dim", justify="right") for label, metric_vals in sorted(summary.items()): cells = [] n = 1 for m in MACRO_METRICS: if m not in metric_vals: cells.append("[dim]--[/dim]") continue mean, sem, n = metric_vals[m] cells.append(_fmt(mean, sem)) macro_table.add_row(label, *cells, str(n)) console.print(macro_table) # --- Per-class table (one row per region, columns per label) --- for label, metric_vals in sorted(summary.items()): n = next(v[2] for v in metric_vals.values()) table = Table(title=f"TS3 Per-Class: {label} (n={n})", show_header=True, show_lines=False) table.add_column("Region", style="cyan", no_wrap=True) table.add_column("precision", style="green", justify="right") table.add_column("recall", style="green", justify="right") table.add_column("f1-score", style="green", justify="right") for region in sorted(all_regions): cells = [] for metric in CLASSIFICATION_METRICS: key = f"{region}/{metric}" if key not in metric_vals: cells.append("[dim]--[/dim]") continue mean, sem, _ = metric_vals[key] cells.append(_fmt(mean, sem)) table.add_row(region, *cells) console.print(table) def main(): parser = argparse.ArgumentParser( description="Score TS3 predictions against ground truth.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument("--pred_dir", type=str, required=True, help="Root predictions directory.") parser.add_argument("--gt_dir", type=str, required=True, help="Root ground truth directory.") args = parser.parse_args() raw = score_dir(args.pred_dir, args.gt_dir) if raw: print_results(summarize(raw)) if __name__ == "__main__": main()