Source code for andrey.metrics.resolve

"""The scoring entry point, :func:`score`.

``score`` runs every metric whose inputs the two structures carry and skips the rest. It reads the
structures alone, never which method produced them, so a graph scores the same way whatever
emitted it. The keys it returns are listed on :func:`score`.
"""

from __future__ import annotations

from andrey.core.output import StructureOutput
from andrey.core.structure import LATENT, GraphStructure, Kind, Structure, TemporalStructure

from ._common import two_marks
from .graph import (
    _confounder,
    _orientation,
    _shd_endpoint,
    _shd_structural,
    _skeleton,
    canonicalize_marks,
    reject_digraph_pag,
)
from .latent import latent_cluster_ari, number_of_latents
from .order import causal_order_scores
from .temporal import temporal_scores
from .weights import coefficient_mae


[docs] def score(output: object, truth: object) -> dict[str, object]: """Score an estimated result against ground truth with the family-appropriate metrics. ``output`` and ``truth`` may each be a StructureOutput, a Structure, or a raw adjacency, and must index the same variables in the same order (a label or node-count mismatch raises). A latent structure is scored on its clustering alone, since latent node indices are not aligned across the two graphs; CDNOD's appended domain node and similar extra variables must be dropped by the caller first. Returns ------- dict A flat, JSON-safe dict whose ``family`` names the structures' kind. Every metric whose inputs both structures carry is present; the rest are absent. - Every graph: ``skeleton_precision``, ``skeleton_recall``, ``skeleton_f1``, ``arrowhead_precision``, ``arrowhead_recall``, ``arrowhead_f1``, and ``shd``. - ``dag`` adds ``mec_shd`` and ``mec_arrowhead_f1``, scored through the CPDAGs for methods identified only up to their equivalence class, and ``order_*`` and ``coefficient_mae`` when an ordering or weights are present. - ``pag`` adds ``shd_endpoint`` and ``confounder_precision``, ``confounder_recall``, and ``confounder_f1``. - ``digraph`` keeps the base metrics only: it has no CPDAG, and a 2-cycle claims no latent confounder. Comparing a ``digraph`` with a ``pag`` raises. - ``latent``: ``latent_cluster_ari``, ``n_latents_estimated``, and ``n_latents_true``. - ``temporal``: ``per_lag``, ``contemporaneous``, ``lagged``, and ``summary``, each nested, plus ``coefficient_mae_per_lag``. Examples -------- >>> import numpy as np >>> score(np.array([[0, 1], [0, 0]]), np.array([[0, 1], [0, 0]]))["family"] 'dag' """ est_temporal = isinstance(_structure(output), TemporalStructure) true_temporal = isinstance(_structure(truth), TemporalStructure) if est_temporal or true_temporal: if est_temporal != true_temporal: raise ValueError("cannot compare a temporal structure to a graph structure") result = dict(temporal_scores(output, truth)) result["family"] = "temporal" return result if _has_latent(output) or _has_latent(truth): return { "family": "latent", "latent_cluster_ari": latent_cluster_ari(output, truth), "n_latents_estimated": number_of_latents(output), "n_latents_true": number_of_latents(truth), } m_est, k_est, m_true, k_true = two_marks(output, truth) # validates shape + labels family = _graph_family(k_est, k_true) result: dict[str, object] = {"family": family} _fill_graph(result, m_est, k_est, m_true, k_true, family) if family == "dag": _fill_directed_extras(result, output, truth) return result
def _structure(x: object) -> Structure | None: """The underlying Structure of an input, or ``None`` for a raw adjacency.""" if isinstance(x, StructureOutput): return x.structure return x if isinstance(x, Structure) else None def _has_latent(x: object) -> bool: s = _structure(x) return ( isinstance(s, GraphStructure) and s.node_types is not None and bool((s.node_types == LATENT).any()) ) def _graph_family(k_est: Kind, k_true: Kind) -> str: """Family by kind precedence: any digraph -> digraph, any PAG -> pag, any CPDAG -> cpdag, else dag. A ``digraph`` (directed, cyclic) has no essential graph and makes no confounder claim, so it scores on the base skeleton / arrowhead / SHD trio alone. A digraph paired with a PAG is rejected -- their shared ARROW/ARROW mark means a 2-cycle versus a latent confounder.""" reject_digraph_pag(k_est, k_true) if k_est == "digraph" or k_true == "digraph": return "digraph" if k_est == "pag" or k_true == "pag": return "pag" if k_est == "cpdag" or k_true == "cpdag": return "cpdag" return "dag" def _flat(prefix: str, scores: dict[str, float]) -> dict[str, float]: return {f"{prefix}_{key}": value for key, value in scores.items()} def _fill_graph(result: dict, m_est, k_est: Kind, m_true, k_true: Kind, family: str) -> None: """Skeleton / SHD / orientation; canonicalizes CPDAGs and dual-reports MEC scores for DAGs.""" est, true_ = m_est, m_true if family == "cpdag": # a DAG and any MEC-equivalent member score through their CPDAG est, true_ = canonicalize_marks(m_est, k_est), canonicalize_marks(m_true, k_true) diag = family == "digraph" # a digraph's autoregressive self-loops are real structure result.update(_flat("skeleton", _skeleton(est, true_, include_diagonal=diag))) result.update(_flat("arrowhead", _orientation(est, true_, include_diagonal=diag))) result["shd"] = _shd_structural(est, true_, include_diagonal=diag) if family == "pag": result["shd_endpoint"] = _shd_endpoint(est, true_) result.update(_flat("confounder", _confounder(est, true_))) if family == "dag": # ExactSearch / CALM emit kind="dag" yet identify only up to the MEC; report both the # directed scores above and the canonicalized ones, so the consumer picks by method. A # non-acyclic input has no CPDAG, so skip the MEC pair and keep the directed scores valid. try: canon_est = canonicalize_marks(m_est, k_est) canon_true = canonicalize_marks(m_true, k_true) except ValueError: return result["mec_shd"] = _shd_structural(canon_est, canon_true) result["mec_arrowhead_f1"] = _orientation(canon_est, canon_true)["f1"] def _fill_directed_extras(result: dict, output: object, truth: object) -> None: """Order accuracy and coefficient MAE when the run carries an ordering / weights.""" if isinstance(output, StructureOutput) and output.ordering is not None: order = causal_order_scores(output.ordering, truth) result["order_accuracy"] = order["accuracy"] result["order_kendall_tau"] = order["kendall_tau"] result["n_comparable_pairs"] = order["n_comparable_pairs"] est_w = output.weighted_adjacency if isinstance(output, StructureOutput) else None true_w = truth.weighted_adjacency if isinstance(truth, StructureOutput) else None if est_w is not None and true_w is not None: result["coefficient_mae"] = coefficient_mae(output, truth)