"""Causal-order metrics: how well a discovered ordering respects the true ancestral relation.
A permutation method (DirectLiNGAM and kin) emits a causal order -- upstream causes first. These
score that order against a true DAG's ancestral pairs, independent of the edge estimates.
"""
from __future__ import annotations
import numpy as np
from andrey.core.output import StructureOutput
from ._common import as_marks, directed_adjacency
def _positions(order: object, n: int) -> np.ndarray:
"""Rank of each node in ``order`` (``pos[node]`` = its slot); accepts a StructureOutput."""
if isinstance(order, StructureOutput):
if order.ordering is None:
raise ValueError("StructureOutput carries no ordering")
order = order.ordering
seq = np.asarray(order, dtype=np.intp)
if seq.shape != (n,) or sorted(seq.tolist()) != list(range(n)):
raise ValueError(f"order must be a permutation of range({n}), got {seq.tolist()}")
pos = np.empty(n, dtype=np.intp)
pos[seq] = np.arange(n)
return pos
def _ancestor_pairs(true_dag: object) -> tuple[np.ndarray, int]:
"""``(pairs, n)`` where ``pairs`` are the ``(a, b)`` with ``a`` a strict ancestor of ``b``."""
marks, _ = as_marks(true_dag)
n = marks.shape[0]
reach = directed_adjacency(marks)
np.fill_diagonal(reach, False)
while True: # transitive closure by boolean reachability (fixed point in <= n steps)
step = reach | ((reach.astype(np.int64) @ reach.astype(np.int64)) > 0)
np.fill_diagonal(step, False)
if np.array_equal(step, reach):
break
reach = step
return np.argwhere(reach), n
[docs]
def causal_order_scores(order: object, true_dag: object) -> dict[str, float]:
"""Accuracy, Kendall tau, and comparable-pair count of an order against a true DAG.
``accuracy`` is the fraction of true ancestral pairs the order sequences correctly;
``kendall_tau`` is ``2 * accuracy - 1`` over those comparable pairs; ``n_comparable_pairs`` is
how many ancestral pairs exist (so tau and accuracy carry distinct information). A valid
topological order scores accuracy 1.0; an order with no comparable pairs scores 1.0 / tau 1.0.
Examples
--------
>>> causal_order_scores([0, 1, 2], [[0, 1, 0], [0, 0, 1], [0, 0, 0]])["accuracy"]
1.0
"""
pairs, n = _ancestor_pairs(true_dag)
count = int(pairs.shape[0])
if count == 0:
return {"accuracy": 1.0, "kendall_tau": 1.0, "n_comparable_pairs": 0}
pos = _positions(order, n)
correct = int((pos[pairs[:, 0]] < pos[pairs[:, 1]]).sum())
accuracy = correct / count
return {
"accuracy": accuracy,
"kendall_tau": 2 * accuracy - 1,
"n_comparable_pairs": count,
}
[docs]
def causal_order_accuracy(order: object, true_dag: object) -> float:
"""Fraction of true ancestral pairs the order places in the right sequence (1.0 is perfect).
Examples
--------
>>> causal_order_accuracy([0, 1, 2], [[0, 1, 0], [0, 0, 1], [0, 0, 0]]) # 0->1->2
1.0
"""
return causal_order_scores(order, true_dag)["accuracy"]
[docs]
def causal_order_kendall_tau(order: object, true_dag: object) -> float:
"""Kendall tau over comparable (ancestral) pairs: ``(concordant - discordant) / comparable``.
Ranges from ``-1`` (every ancestral pair reversed) to ``1``; equals ``2 * accuracy - 1``.
Examples
--------
>>> causal_order_kendall_tau([0, 1, 2], [[0, 1, 0], [0, 0, 1], [0, 0, 0]])
1.0
"""
return causal_order_scores(order, true_dag)["kendall_tau"]