Source code for andrey.core.knowledge

"""Background knowledge: edges a graph must have or must not have, the pairs it may connect, and
tiers that order the variables.

:class:`Knowledge` is the public value a search takes as ``knowledge=``. :func:`resolve` turns it
into :class:`Constraints`, two positional ``(d, d)`` boolean matrices the engines read, against the
data's column labels. :func:`orient` and :func:`admits` apply the constraints to an equivalence
class.
"""

from __future__ import annotations

from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal

import numpy as np

from .structure import ARROW, NULL, TAIL

if TYPE_CHECKING:
    import numpy.typing as npt

    from .output import StructureOutput
    from .structure import GraphStructure

__all__ = [
    "Knowledge",
    "KnowledgeError",
    "Constraints",
    "KnowledgePart",
    "KNOWLEDGE_PARTS",
    "resolve",
]

#: A node, by column label or 0-based position.
Node = str | int
#: The parts of a :class:`Knowledge`, as a method's spec lists the ones it honors.
KnowledgePart = Literal["required", "forbidden", "allowed", "tiers"]
KNOWLEDGE_PARTS: tuple[KnowledgePart, ...] = ("required", "forbidden", "allowed", "tiers")


def _node(value: object, what: str) -> Node:
    if isinstance(value, (bool, np.bool_)) or not isinstance(value, (str, int, np.integer)):
        raise TypeError(
            f"{what} names a node by column label or 0-based position, got {value!r} "
            f"({type(value).__name__})"
        )
    return value if isinstance(value, str) else int(value)


def _edges(values: Iterable | None, what: str) -> tuple[tuple[Node, Node], ...]:
    if values is None:
        return ()
    if isinstance(values, (str, bytes)):
        raise TypeError(f"{what} must be an iterable of (a, b) pairs, got {values!r}")
    edges = []
    for pair in values:
        if isinstance(pair, (str, bytes)) or len(pair) != 2:
            raise TypeError(f"{what} must be an iterable of (a, b) pairs, got {pair!r}")
        a, b = _node(pair[0], what), _node(pair[1], what)
        if a == b:
            raise ValueError(f"{what} lists the self-loop ({a!r}, {b!r})")
        edges.append((a, b))
    return tuple(edges)


def column_position(
    node: Node,
    labels: Sequence[str] | None,
    d: int,
    what: str,
    error: type[ValueError] = ValueError,
) -> int:
    """Return the 0-based position of the column ``node`` names among ``d`` columns.

    A string is a column label, looked up in ``labels``; an integer is always a position, so a
    column named ``1`` is named by the label ``"1"``. ``what`` opens each message; ``error`` is the
    ``ValueError`` class raised when the column is missing.
    """
    if isinstance(node, str):
        if labels is None:
            raise error(
                f"{what} names column {node!r}, but data has no column names; give a DataFrame or "
                "the column's 0-based position"
            )
        if node not in labels:
            raise error(
                f"{what} names column {node!r}, which data does not have; its columns are "
                f"{list(labels)}"
            )
        return list(labels).index(node)
    if not 0 <= node < d:
        named = labels is not None and str(node) in labels
        hint = f"; to name the column {str(node)!r}, pass its label as a string" if named else ""
        raise error(f"{what} lists column {node}, but data has {d} columns{hint}")
    return node


class KnowledgeError(ValueError):
    """Background knowledge that names a column the data lacks or that no graph can satisfy.

    A ``ValueError``; the command line reports it as a usage error, since the fix is to the
    knowledge, not the data.
    """


[docs] @dataclass(frozen=True, init=False) class Knowledge: """Background knowledge a search honors: required and forbidden edges, the pairs that may be adjacent, and tiers that order the variables. A node is named by a column label, a string (a DataFrame's column name as text, or the data file's header on the command line), or by its 0-based position, an integer. Labels stay attached to their columns, so the same knowledge fits a reordered table; positions follow the column order. An integer is always a position: a column named ``1`` is named by the label ``"1"``. Each method's ``knowledge`` parameter states the parts it honors and raises ``NotImplementedError`` for another. Parameters ---------- required : iterable of (cause, effect) pairs, optional Directed edges ``cause -> effect`` the result must have. forbidden : iterable of (cause, effect) pairs, optional Directed edges ``cause -> effect`` the result must not have. Forbidding ``a -> b`` forbids that edge only, not a directed path from ``a`` to ``b`` through other variables. Forbid both directions to make two variables non-adjacent. allowed : iterable of (a, b) pairs or None, optional The only pairs that may be adjacent, in either direction. Listing a pair permits an edge but does not require one, and does not override ``forbidden`` or ``tiers``. ``None`` allows every pair, and an empty iterable allows none. Listing few pairs narrows the search, which can make it faster. tiers : iterable of iterables of nodes, optional Temporal tiers, variables grouped in time order: no edge points from a variable of a later tier into one of an earlier tier. A variable in no tier is unconstrained; a variable may be in one tier only. Raises ------ TypeError If ``required``, ``forbidden``, or ``allowed`` is not an iterable of (a, b) pairs, ``tiers`` is not an iterable of node iterables, or a node is neither a column label nor a 0-based position. ValueError If an edge is a self-loop or a node is in two tiers. A method raises ``ValueError`` when the knowledge names a column the data lacks, or contradicts itself: a required edge that is forbidden, directly, by the tiers, or by ``allowed``, or required edges forming a cycle. ``boss``, ``grasp``, and ``direct_lingam`` also raise it when the required edges and the tiers admit no causal order, through a column in no tier. ``pc`` and ``cdnod`` raise it when the edges the knowledge orients form a directed cycle over the adjacencies the search kept; ``fci`` when a directed path of required edges leads from ``a`` to ``b`` while ``forbidden`` or ``tiers`` put an arrowhead at ``a`` on the edge between them. Notes ----- PC, FCI, and CD-NOD start their adjacency search from the allowed pairs in place of the complete graph and never test a required pair, so no required adjacency is removed; the tests can remove any other allowed pair. GFCI's GES phase searches only the allowed pairs, and its test phase starts from the adjacencies GES found. A pair the knowledge makes non-adjacent is still tested for a separating set, so colliders over it are oriented as usual; when the search finds none within ``max_depth``, no rule reads its unshielded triples as colliders or non-colliders. A method whose result is a CPDAG directs every edge the knowledge decides and then applies Meek's rules R1-R4 [1]_, R4 being needed only with background knowledge. For a CPDAG and knowledge consistent with it, the result is the maximally oriented PDAG (MPDAG) [2]_, which stands for the DAGs of the equivalence class that satisfy the knowledge; it keeps the ``cpdag`` tag. On sample data the tests can disagree with the knowledge; the rules then skip the orientations that conflict, so the result can leave undirected an edge the MPDAG would direct. In a PAG, the edges are those of an ancestral graph over the measured variables, where ``a -> b`` says that ``a`` is an ancestor of ``b``, not necessarily its direct cause. A forbidden ``a -> b`` on an adjacent pair puts an arrowhead at ``a`` and can leave a circle at ``b``, and a required ``a -> b`` is ``a -> b``. The methods that search over causal orders (``boss``, ``grasp``, ``direct_lingam``) also keep each variable of a tier after those of earlier tiers; with every variable in a tier, this is the same as forbidding the edges. References ---------- .. [1] Meek, C. (1995). Causal inference and causal explanation with background knowledge. UAI, 403-410. .. [2] Perkovic, E., Kalisch, M., and Maathuis, M. H. (2017). Interpreting and using CPDAGs with background knowledge. UAI. Examples -------- >>> import numpy as np >>> import pandas as pd >>> from andrey import Knowledge, pc >>> rng = np.random.default_rng(0) >>> x = rng.standard_normal(500) >>> y = x + 0.5 * rng.standard_normal(500) >>> data = pd.DataFrame({"x": x, "y": y}) >>> pc(data, knowledge=Knowledge(tiers=[["x"], ["y"]])).structure.oriented_edges() [('x', 'y', 'directed')] """ required: tuple[tuple[Node, Node], ...] = field(default=()) forbidden: tuple[tuple[Node, Node], ...] = field(default=()) allowed: tuple[tuple[Node, Node], ...] | None = field(default=None) tiers: tuple[tuple[Node, ...], ...] = field(default=()) def __init__( self, *, required: Iterable[Sequence[Node]] | None = None, forbidden: Iterable[Sequence[Node]] | None = None, allowed: Iterable[Sequence[Node]] | None = None, tiers: Iterable[Iterable[Node]] | None = None, ) -> None: object.__setattr__(self, "required", _edges(required, "required")) object.__setattr__(self, "forbidden", _edges(forbidden, "forbidden")) object.__setattr__(self, "allowed", None if allowed is None else _edges(allowed, "allowed")) if tiers is None: tiers = () if isinstance(tiers, (str, bytes)): raise TypeError(f"tiers must be an iterable of node iterables, got {tiers!r}") grouped: list[tuple[Node, ...]] = [] seen: set[Node] = set() for tier in tiers: if isinstance(tier, (str, bytes, int, np.integer)): raise TypeError( f"tiers must be an iterable of node iterables, got the tier {tier!r}" ) nodes = tuple(_node(v, "tiers") for v in tier) for v in nodes: if v in seen: raise ValueError(f"tiers list {v!r} more than once") seen.add(v) grouped.append(nodes) object.__setattr__(self, "tiers", tuple(grouped))
[docs] @classmethod def from_skeleton( cls, skeleton: StructureOutput | GraphStructure | npt.ArrayLike, *, required: Iterable[Sequence[Node]] | None = None, forbidden: Iterable[Sequence[Node]] | None = None, tiers: Iterable[Iterable[Node]] | None = None, ) -> Knowledge: """Knowledge whose allowed pairs are the adjacent pairs of ``skeleton``. A later search then looks for edges only among those pairs. Edge directions, endpoint marks, and weights are ignored, and an allowed pair is not required to stay adjacent. Parameters ---------- skeleton : StructureOutput, GraphStructure, or array-like of shape (d, d) A result, a graph, or a square matrix in which a pair is adjacent when either of its two entries is nonzero; the diagonal is ignored. A graph with labels names its pairs by label, otherwise by position. required, forbidden, tiers The other parts, as for :class:`Knowledge`. Returns ------- Knowledge The knowledge, with ``allowed`` set to every adjacent pair of ``skeleton``. Raises ------ ValueError If ``skeleton`` is an array that is not a square matrix. """ graph = getattr(skeleton, "structure", skeleton) labels = getattr(graph, "labels", None) if hasattr(graph, "adjacency"): matrix = np.asarray(graph.adjacency) != 0 else: matrix = np.asarray(skeleton) != 0 if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]: raise ValueError(f"skeleton must be a square matrix, got shape {matrix.shape}") xs, ys = np.nonzero(np.triu(matrix | matrix.T, 1)) name = (lambda i: labels[i]) if labels is not None else int allowed = [(name(x), name(y)) for x, y in zip(xs.tolist(), ys.tolist())] return cls(required=required, forbidden=forbidden, allowed=allowed, tiers=tiers)
[docs] def parts(self) -> tuple[KnowledgePart, ...]: """The parts this knowledge sets, in :data:`KNOWLEDGE_PARTS` order.""" given = { "required": bool(self.required), "forbidden": bool(self.forbidden), "allowed": self.allowed is not None, "tiers": bool(self.tiers), } return tuple(part for part in KNOWLEDGE_PARTS if given[part])
@dataclass(frozen=True) class Constraints: """Knowledge resolved against ``d`` columns: what the search engines read. ``forbidden[i, j]`` forbids the edge ``i -> j``: it holds the forbidden edges, the edges from a later tier into an earlier one, both directions of every pair outside the allowed set, and the reverse of every required edge. ``required[i, j]`` requires ``i -> j``. ``tier[i]`` is column ``i``'s tier, ``-1`` for none. """ forbidden: np.ndarray required: np.ndarray tier: np.ndarray @property def d(self) -> int: """The number of columns.""" return self.forbidden.shape[0] def support(self) -> np.ndarray: """The pairs that may be adjacent: not forbidden in both directions (diagonal ``False``).""" support = ~(self.forbidden & self.forbidden.T) np.fill_diagonal(support, False) return support def decided(self) -> np.ndarray: """``[i, j]``: the knowledge directs an adjacent ``i - j`` as ``i -> j``.""" return self.forbidden.T & ~self.forbidden def precedence(self) -> np.ndarray: """``[i, j]``: ``i`` comes before ``j`` in every causal order an order search visits. Holds the required edges and every pair of tiered columns in tier order. Raises :class:`KnowledgeError` when they form a cycle, so that no order satisfies them. """ tiered = self.tier >= 0 before = self.required | ( tiered[:, None] & tiered[None, :] & (self.tier[:, None] < self.tier[None, :]) ) if _has_cycle(before): raise KnowledgeError( "the required edges and the tiers admit no causal order: following them leads " "from a column back to itself through an untiered column" ) return before def padded(self, d: int) -> Constraints: """These constraints over ``d >= self.d`` columns, the added ones unconstrained.""" forbidden = np.zeros((d, d), dtype=bool) required = np.zeros((d, d), dtype=bool) forbidden[: self.d, : self.d] = self.forbidden required[: self.d, : self.d] = self.required tier = np.full(d, -1, dtype=np.intp) tier[: self.d] = self.tier return Constraints(forbidden, required, tier) def permuted(self, order: npt.ArrayLike) -> Constraints: """These constraints with column ``order[k]`` as column ``k``.""" idx = np.asarray(order, dtype=np.intp) return Constraints( self.forbidden[np.ix_(idx, idx)], self.required[np.ix_(idx, idx)], self.tier[idx] ) def _has_cycle(edges: np.ndarray) -> bool: """Whether the directed graph ``edges[i, j]`` (``i -> j``) has a cycle (Kahn's algorithm).""" indegree = edges.sum(axis=0) ready = list(np.flatnonzero(indegree == 0)) seen = 0 while ready: i = ready.pop() seen += 1 for j in np.flatnonzero(edges[i]).tolist(): indegree[j] -= 1 if indegree[j] == 0: ready.append(j) return seen < edges.shape[0] def resolve( knowledge: Knowledge | None, d: int, labels: Sequence[str] | None, *, method: str, honors: Sequence[KnowledgePart] = KNOWLEDGE_PARTS, ) -> Constraints | None: """Resolve ``knowledge`` against ``d`` columns named ``labels``; ``None`` when it sets nothing. ``method`` names the caller in messages; ``honors`` lists the parts it supports, and a part it does not list raises ``NotImplementedError``. Raises ``TypeError`` when ``knowledge`` is not a :class:`Knowledge`, and :class:`KnowledgeError` when it names a missing column or contradicts itself. """ if knowledge is None: return None if not isinstance(knowledge, Knowledge): raise TypeError(f"knowledge must be an andrey.Knowledge, got {type(knowledge).__name__}") parts = knowledge.parts() if not parts: return None unsupported = [part for part in parts if part not in honors] if unsupported: raise NotImplementedError( f"{method} does not honor the knowledge part(s) {unsupported}; it honors {list(honors)}" ) names = list(labels) if labels is not None else None def position(node: Node, what: str) -> int: return column_position(node, names, d, f"knowledge {what}", KnowledgeError) def label(i: int) -> str: return repr(names[i]) if names is not None else str(i) forbidden = np.zeros((d, d), dtype=bool) required = np.zeros((d, d), dtype=bool) tier = np.full(d, -1, dtype=np.intp) for a, b in knowledge.forbidden: forbidden[position(a, "forbidden"), position(b, "forbidden")] = True for k, nodes in enumerate(knowledge.tiers): for v in nodes: i = position(v, "tiers") if tier[i] >= 0: raise KnowledgeError(f"knowledge tiers list column {label(i)} more than once") tier[i] = k tiered = tier >= 0 forbidden |= tiered[:, None] & tiered[None, :] & (tier[:, None] > tier[None, :]) if knowledge.allowed is not None: allowed = np.zeros((d, d), dtype=bool) for a, b in knowledge.allowed: i, j = position(a, "allowed"), position(b, "allowed") allowed[i, j] = allowed[j, i] = True forbidden |= ~allowed np.fill_diagonal(forbidden, False) for a, b in knowledge.required: i, j = position(a, "required"), position(b, "required") if forbidden[i, j]: raise KnowledgeError( f"knowledge requires the edge {label(i)} -> {label(j)}, which it also forbids " "(directly, by the tiers, or by leaving the pair out of allowed)" ) required[i, j] = True if (required & required.T).any() or _has_cycle(required): raise KnowledgeError("knowledge requires edges that form a directed cycle") forbidden |= required.T return Constraints(forbidden, required, tier) # ---- applying constraints to an equivalence class ----------------------------------------------- def orient(pdag: np.ndarray, constraints: Constraints) -> np.ndarray: """Direct every undirected edge of ``pdag`` the constraints decide; returns a new matrix. ``pdag`` is a mark matrix in the convention of :mod:`andrey.core.orient` (``i -> j`` is ``pdag[i, j] == TAIL`` with ``pdag[j, i] == ARROW``). Directed edges are kept as they are. """ out = pdag.copy() lines = (out == TAIL) & (out.T == TAIL) direct = lines & constraints.decided() out[direct.T] = ARROW # i -> j: the mark at j on edge j-i is an arrowhead return out def violates(pdag: np.ndarray, constraints: Constraints) -> bool: """Whether ``pdag`` directs an edge the constraints forbid, joins a pair they make non-adjacent, or lacks a required adjacency.""" directed = (pdag == TAIL) & (pdag.T == ARROW) adjacent = pdag != NULL return bool( (directed & constraints.forbidden).any() or (adjacent & ~constraints.support()).any() or (constraints.required & ~adjacent).any() ) def mpdag(cpdag: np.ndarray, constraints: Constraints) -> np.ndarray: """The maximally oriented PDAG (MPDAG) of ``cpdag`` under the constraints: knowledge orientations, then Meek's rules with R4. ``cpdag`` is a class :func:`admits` accepts: it has every required adjacency and some DAG in it satisfies the constraints. """ from .orient import meek return meek(orient(cpdag, constraints), rule4=True) def admits(cpdag: np.ndarray, constraints: Constraints) -> bool: """Whether some DAG in the class ``cpdag`` satisfies the constraints. The class must have every required adjacency, direct no edge the constraints forbid, and join no pair they make non-adjacent. Every remaining undirected edge with a forbidden direction is one the constraints decide, so after directing those, a consistent extension (Dor and Tarsi) that keeps the class's skeleton and colliders satisfies them, and one must exist. """ from .orient import _cpdag_of_pdag if violates(cpdag, constraints): return False oriented = orient(cpdag, constraints) if np.array_equal(oriented, cpdag): return True try: return bool(np.array_equal(_cpdag_of_pdag(oriented), cpdag)) except ValueError: return False