Source code for ibl_bwb_eval.tasks.ts3

"""TS3: the task vocabulary, and what each task predicts.

The multi-unit readout that pools these probabilities across neighbouring units lives in
``ibl_bwb_eval.multi_unit``, kept out of here so the task vocabulary stays numpy-free.
"""

from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Literal, TypeAlias, get_args

from ibl_bwb_eval.tasks.types import TargetResolution

# Other entities (channel) and targets (ccf) are reserved in prds/ts3-extension-axes.md.
TS3Task: TypeAlias = Literal["unit_cosmos"]
"""The scored TS3 tasks, named ``<entity>_<target>``: what is classified, at which atlas level."""

# The Allen CCF Cosmos-level regions TS3 classifies, alphabetical: the order both
# sklearn's `classes_` and a plain `sorted()` produce, so submissions written before this
# was declared stay bit-compatible. `void` and `root` are dropped upstream and unscored.
COSMOS_LABELS: tuple[str, ...] = (
    "CB",
    "CNU",
    "CTXsp",
    "HB",
    "HPF",
    "HY",
    "Isocortex",
    "MB",
    "OLF",
    "TH",
)


# How a classified prediction is judged, per class and macro-averaged over classes. The
# names only: this module is stdlib-only, so the callables that compute them live in
# ``ibl_bwb_eval.metrics.classification`` and a test binds the two together. A task that is
# not classification (regressing a CCF coordinate, say) declares its own set instead.
CLASSIFICATION_METRICS: tuple[str, ...] = ("precision", "recall", "f1-score")


[docs] @dataclass class TS3ReadoutSpec: """What one TS3 task predicts. Attributes: id: the task name. label_names: the class order every ``pred_proba`` column is indexed by. The scorer rejects a submission whose ``label_names`` disagree with the ground truth's, so this is part of the on-disk format. region_key: the ``units`` field the targets are read from. metrics: mapping from metric name to the callable that computes it. Per task, not per suite: region classification reports P/R/F1, and a task regressing a CCF coordinate would declare error metrics instead. Each callable takes ``(y_true, y_pred, label_names)`` and returns the scored names it owns. """ id: TS3Task label_names: tuple[str, ...] region_key: str metrics: dict[str, Callable] target_resolution: TargetResolution = TargetResolution.SEQUENCE @property def dim(self) -> int: """Number of classes. Derived, so it cannot drift from ``label_names``.""" return len(self.label_names) @property def num_timesteps(self) -> int: """Always one: a TS3 target is a unit's label, not a time course.""" return 1
[docs] def score(self, y_true, y_pred) -> dict[str, float]: """Every metric this task declares, flattened. The scorer and the probes both call this, so a run's live scores and its offline scores cannot diverge. """ out: dict[str, float] = {} for fn in self.metrics.values(): out.update(fn(y_true, y_pred, self.label_names)) return out
[docs] def get_ts3_readout_spec(task: TS3Task) -> TS3ReadoutSpec: """What ``task`` classifies, its label vocabulary, and the metrics it is scored on.""" from ibl_bwb_eval.metrics import CLASSIFICATION_SCORERS match task: case "unit_cosmos": return TS3ReadoutSpec( id="unit_cosmos", label_names=COSMOS_LABELS, region_key="region_cosmos", metrics=CLASSIFICATION_SCORERS, ) case _: raise ValueError( f"{task!r} is not a scored TS3 task, expected one of {get_args(TS3Task)}" )
[docs] def check_ts3_label_order(labels: Sequence[str], task: TS3Task) -> None: """Raise unless ``labels`` is the task's vocabulary, in order. Call this wherever a class order is derived from data rather than taken from the spec: a silent disagreement misindexes every probability column. """ expected = get_ts3_readout_spec(task).label_names got = tuple(str(x) for x in labels) if got != expected: raise ValueError( f"Region order does not match the {task!r} vocabulary.\n" f" expected: {expected}\n" f" got: {got}" )