Source code for ibl_bwb_eval.scoring.ts1

"""TS1 scoring utility.

Loads prediction .safetensors files, matches them to pre-generated ground truth
files by trial_id, and computes all metrics defined in the TS1ReadoutSpec.

Usage:
    python -m ibl_bwb_eval.scoring.ts1 --pred_dir predictions/ts1 --gt_dir ground_truth/ts1
    python -m ibl_bwb_eval.scoring.ts1 --pred_dir predictions/ts1/mlp-baseline/ts1-licking_rate --gt_dir ground_truth/ts1
"""

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.metrics import aggregate_metrics
from ibl_bwb_eval.scoring._types import MetricSummary
from ibl_bwb_eval.tasks import DataType, TargetResolution, get_ts1_readout_spec, is_task_of

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


[docs] def score_file(task: str, pred_path: str | Path, gt_path: str | Path) -> dict[str, float]: """Score one prediction file against its ground truth file. Args: task: Flattened task id (e.g. ``"ts1-choice"``). pred_path: Path to a ``seed_N.safetensors`` prediction file. gt_path: Path to the matching ``ground_truth.safetensors`` file. Returns: Dict mapping metric name to scalar value. Raises: ValueError: If the task declares a mask the ground truth does not carry, or if the mask leaves no timestep to score. """ pred = load_file(str(pred_path)) gt = load_file(str(gt_path)) spec = get_ts1_readout_spec(task.split("-", 1)[1]) # align predictions and ground truth by trial_id _, pred_idx, gt_idx = np.intersect1d( pred["trial_id"].numpy(), gt["trial_id"].numpy(), return_indices=True ) predictions = pred["predictions"][pred_idx] # (N, 1, D) or (N, T, D) values = gt["values"][gt_idx] # (N, 1) or (N, T, D) if spec.mask_key is not None: if "mask" not in gt: raise ValueError( f"{task} declares mask_key={spec.mask_key!r} but {gt_path} carries no " "'mask'. Scoring it unmasked would silently include the low-confidence " "timesteps the mask exists to drop." ) mask = gt["mask"][gt_idx] else: mask = None metrics = {name: ctor() for name, ctor in spec.metrics.items()} if spec.target_resolution == TargetResolution.TIMESTEP: N, T, D = predictions.shape predictions = predictions.reshape(N * T, D) values = values.reshape(N * T, D) if mask is not None: mask = mask.reshape(-1) if not mask.any(): raise ValueError(f"{task} in {gt_path} has no unmasked timestep to score.") else: predictions = predictions.squeeze(1) # (N, D) logits for classification for metric in metrics.values(): if mask is not None: metric.update(predictions[mask], values[mask]) elif spec.data_type in (DataType.BINARY, DataType.MULTINOMIAL): metric.update(predictions, values.squeeze(-1).long()) else: metric.update(predictions, values) return aggregate_metrics(metrics)
[docs] def score_dir( pred_dir: str | Path, gt_dir: str | Path, ) -> dict[tuple[str, str, str, int], dict[str, float]]: """Score all TS1 prediction files found under ``pred_dir``. Expects prediction files written by :class:`PredictionsWriter` (metadata carries ``label``, ``task``, ``recording_id``, ``seed``). ``pred_dir`` may point at any subtree (e.g. the shared ``predictions/`` root), files whose ``task`` isn't a ``ts1-*`` task are skipped. Ground truth files are looked up as: ``{gt_dir}/{task}/{recording_id}/ground_truth.safetensors`` Returns a dict keyed by ``(label, task, recording_id, seed)``, where each value is a dict mapping metric name to scalar value. """ pred_dir = Path(pred_dir) gt_dir = Path(gt_dir) console = Console() results = {} failed: list[Path] = [] 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() task = meta["task"] if not is_task_of("ts1", task): continue label = meta["label"] recording_id = meta["recording_id"] seed = int(meta["seed"]) gt_path = gt_dir / task / recording_id / "ground_truth.safetensors" if not gt_path.exists(): console.print(f"[yellow]GT not found, skipping:[/yellow] {gt_path}") continue try: results[(label, task, recording_id, seed)] = score_file(task, pred_path, gt_path) except Exception as e: failed.append(pred_path) console.print(f"[red]Error scoring {pred_path}:[/red] {e}") if failed: console.print( f"[red]{len(failed)} of {len(pred_paths)} prediction files failed to score " "and are absent from the results below.[/red]" ) return results
[docs] def summarize( raw: dict[tuple[str, str, str, int], dict[str, float]], ) -> dict[tuple[str, str, str], MetricSummary]: """Aggregate per-seed scores into mean ± SEM per (label, task, recording_id). Returns a dict keyed by ``(label, task, recording_id)`` where each value maps metric name to ``(mean, sem, n)``. ``sem`` is ``None`` when ``n == 1``. """ grouped: dict[tuple[str, str, str], dict[str, list[float]]] = defaultdict( lambda: defaultdict(list) ) for (label, task, recording_id, _seed), metrics in raw.items(): for metric_name, value in metrics.items(): grouped[(label, task, recording_id)][metric_name].append(value) result: dict[tuple[str, str, str], MetricSummary] = {} for (label, task, recording_id), metric_vals in grouped.items(): result[(label, task, recording_id)] = {} 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, task, recording_id)][metric_name] = (mean, sem, n) return result
def print_results( summary: dict[tuple[str, str, str], MetricSummary], console: Console | None = None, ) -> None: """Print a summary table with one row per (label, task, recording_id).""" console = console or Console() # collect all metric names in insertion order (union across rows) all_metrics: list[str] = [] for metric_vals in summary.values(): for m in metric_vals: if m not in all_metrics: all_metrics.append(m) table = Table(title="TS1 Scoring Results", show_header=True, show_lines=False) table.add_column("Label", style="magenta", no_wrap=True) table.add_column("Task", style="cyan", no_wrap=True) table.add_column("Recording", style="yellow", no_wrap=True) for m in all_metrics: table.add_column(m, style="green", justify="right") table.add_column("n", style="dim", justify="right") for (label, task, recording_id), metric_vals in sorted(summary.items()): metric_cells = [] n = 1 for m in all_metrics: if m not in metric_vals: metric_cells.append("[dim]--[/dim]") continue mean, sem, n = metric_vals[m] sem_str = f{sem:.4f}" if sem is not None else "[dim]± --[/dim]" metric_cells.append(f"{mean:.4f} {sem_str}") table.add_row(label, task, recording_id, *metric_cells, str(n)) console.print(table) def main(): parser = argparse.ArgumentParser( description="Score TS1 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()