"""TS2 scoring utility.
Loads prediction .safetensors files, aligns them to pre-generated ground truth files
by ``window_timestamps``, and computes Poisson D² and bits-per-spike.
Usage:
python -m ibl_bwb_eval.scoring.ts2 --pred_dir predictions/<label> --gt_dir ground_truth/ts2
python -m ibl_bwb_eval.scoring.ts2 --pred_dir predictions/<label>/my-model/ts2-co_smoothing --gt_dir ground_truth/ts2
"""
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 BPS, PoissonD2Score
from ibl_bwb_eval.scoring._types import MetricSummary
from ibl_bwb_eval.tasks import is_task_of
__api_ref__ = {
"description": None,
"sections": [{"title": None, "autosummary": ["score_file", "score_dir", "summarize"]}],
}
[docs]
def score_file(pred_path: Path, gt_path: Path) -> dict[str, float]:
"""Score one prediction file against its ground truth file.
Predictions and ground truth are aligned by ``window_timestamps``.
Args:
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.
"""
pred = load_file(str(pred_path))
gt = load_file(str(gt_path))
pred_ts = pred["window_timestamps"].numpy() # (B_total,)
gt_ts = gt["window_timestamps"].numpy() # (B_total,)
_, pred_idx, gt_idx = np.intersect1d(pred_ts, gt_ts, return_indices=True)
if len(pred_idx) == 0:
raise ValueError("No overlapping windows between predictions and ground truth.")
predictions = pred["predictions"][pred_idx] # (n, T, num_units) or (n, num_ts, N)
targets = gt["targets"][gt_idx] # same shape
if predictions.shape != targets.shape:
raise ValueError(
f"Shape mismatch after alignment: predictions {predictions.shape} vs GT {targets.shape}."
)
# flatten batch and window dims -> (samples, units). Predictions may be stored at
# reduced precision (e.g. fp16); upcast before metrics, since exp() summed over many
# samples can overflow fp16's range even when no individual prediction is extreme.
pred_2d = predictions.reshape(-1, predictions.shape[-1]).float()
target_2d = targets.reshape(-1, targets.shape[-1]).float()
# skip expensive per-call NaN/inf scan, assuming predictions are already validated
poisson_d2 = PoissonD2Score(validate_finite=False)
bps = BPS(validate_finite=False)
poisson_d2.update(pred_2d, target_2d)
bps.update(pred_2d, target_2d)
return {
"poisson_d2": poisson_d2.compute().item(),
"bps": bps.compute().item(),
}
[docs]
def score_dir(
pred_dir: str | Path,
gt_dir: str | Path,
) -> dict[tuple[str, str, str, int], dict[str, float]]:
"""Score all TS2 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 ``ts2-*`` 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 = {}
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("ts2", 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(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, 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()
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="TS2 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 TS2 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()