Source code for andrey.metrics.temporal

"""Temporal metrics: score a per-lag structure against the truth, lag by lag.

Splits the contemporaneous (lag-0) block from the lagged ones, scores each lag matrix as a directed
graph (precision/recall/f1 + Hamming SHD, where a reversal costs 2 -- time has no equivalence
class), collapses all lags into a summary graph, and reports the per-lag coefficient error where lag
weights are present. Lags align by value, so a gappy or Granger-style (no lag-0) stack scores
correctly.

Lag-0 is scored only when the estimate carries it (``0 in est.lags``), a clean output-class signal:
Granger emits no contemporaneous block, so its missing lag-0 is a class limit, not an error, and is
left out of both the per-lag scores and the summary graph. A truth-only lag >= 1 stays in the union
as false negatives, since a too-small maximum lag is a real miss.
"""

from __future__ import annotations

import numpy as np

from andrey.core.output import StructureOutput
from andrey.core.structure import TemporalStructure

from ._common import _offdiag, directed_adjacency, prf


def _temporal(x: object) -> TemporalStructure:
    """Unwrap a StructureOutput and require a TemporalStructure."""
    if isinstance(x, StructureOutput):
        x = x.structure
    if not isinstance(x, TemporalStructure):
        raise TypeError("temporal_scores needs a TemporalStructure")
    return x


def _lag_directed(t: TemporalStructure, lag: int, n: int) -> np.ndarray:
    """Directed support of lag ``lag`` (all-``False`` when that lag is absent)."""
    if lag in t.lags:
        return directed_adjacency(t.lag(lag).to_numpy())
    return np.zeros((n, n), dtype=bool)


def _masks_scores(est: np.ndarray, true_: np.ndarray) -> dict[str, float]:
    """Precision/recall/f1 between two boolean supports."""
    tp = int((est & true_).sum())
    return prf(tp, int((est & ~true_).sum()), int((~est & true_).sum()))


def _edge_scores(est: np.ndarray, true_: np.ndarray) -> dict[str, float]:
    """Directed-edge precision/recall/f1 and Hamming SHD (reversal costs 2) over two supports."""
    scores = _masks_scores(est, true_)
    scores["shd"] = int((est != true_).sum())
    return scores


[docs] def temporal_scores(estimated: object, true: object) -> dict[str, object]: """Per-lag and summary scores for a temporal structure. Returns ``per_lag`` (each lag's precision/recall/f1/shd), a ``contemporaneous`` block when the estimate carries lag-0, an aggregate ``lagged`` block over lags >= 1, a ``summary`` graph collapsing every scored lag (self-loops excluded so diagonals do not inflate it), and ``coefficient_mae_per_lag`` when both structures carry lag weights. Raises ``NotImplementedError`` for a time-carrying structure (``n_times > 0``): the ``(time, lag)`` axis has no metric yet, and scoring only the final-occasion projection would silently miss per-occasion differences. Examples -------- >>> import numpy as np >>> from andrey.core import GraphStructure, TemporalStructure >>> g = GraphStructure.from_numpy(np.array([[0, 1], [2, 0]], dtype=np.int8), kind="dag") >>> t = TemporalStructure.from_lag_graphs([g]) >>> temporal_scores(t, t)["summary"]["f1"] 1.0 """ est = _temporal(estimated) true_ = _temporal(true) if est.n_nodes != true_.n_nodes: raise ValueError(f"node-count mismatch: estimated {est.n_nodes}, true {true_.n_nodes}") if est.labels is not None and true_.labels is not None and est.labels != true_.labels: raise ValueError("label mismatch: estimated and true index different variables") if est.n_times > 0 or true_.n_times > 0: raise NotImplementedError( "temporal scoring does not yet cover the (time, lag) axis: it would compare only the " "final occasion and silently miss per-occasion differences. Score a specific " "occasion's lag stack via at(time, lag), or await a time-aware metric schema." ) n = est.n_nodes lags_ge1 = sorted({lag for lag in set(est.lags) | set(true_.lags) if lag >= 1}) scored_lags = ([0] if 0 in est.lags else []) + lags_ge1 per_lag: dict[int, dict[str, float]] = {} summary_est = np.zeros((n, n), dtype=bool) summary_true = np.zeros((n, n), dtype=bool) lagged_est = np.zeros((n, n), dtype=bool) lagged_true = np.zeros((n, n), dtype=bool) result: dict[str, object] = {"per_lag": per_lag} for lag in scored_lags: d_est = _lag_directed(est, lag, n) d_true = _lag_directed(true_, lag, n) per_lag[lag] = _edge_scores(d_est, d_true) summary_est |= d_est summary_true |= d_true if lag == 0: result["contemporaneous"] = per_lag[lag] else: lagged_est |= d_est lagged_true |= d_true if lags_ge1: result["lagged"] = _edge_scores(lagged_est, lagged_true) off = _offdiag(n) result["summary"] = _masks_scores(summary_est & off, summary_true & off) est_w, true_w = est.lag_weights, true_.lag_weights if est_w is not None and true_w is not None: result["coefficient_mae_per_lag"] = _coefficient_mae_per_lag( est_w, est.lags, true_w, true_.lags, scored_lags ) return result
def _coefficient_mae_per_lag( est_w: np.ndarray, est_lags: tuple[int, ...], true_w: np.ndarray, true_lags: tuple[int, ...], lags: list[int], ) -> dict[int, float]: """Mean absolute lag-weight error over the true edges, per lag present in both stacks.""" out: dict[int, float] = {} for lag in lags: if lag not in est_lags or lag not in true_lags: continue w_est = est_w[est_lags.index(lag)] w_true = true_w[true_lags.index(lag)] mask = w_true != 0 out[lag] = float(np.abs(w_est[mask] - w_true[mask]).mean()) if mask.any() else 0.0 return out