"""First-class graph structures: the canonical CSR store and its derived views.
``Structure`` is the abstract base every algorithm's ``.structure`` is an instance of; the
concrete subclasses are ``GraphStructure`` (a single kind-tagged graph backed by one symmetric
endpoint-mark CSR) and ``TemporalStructure`` (a per-lag stack of graphs). One compact store per
graph; every dense / SciPy / NetworkX / edge-list view derives from it lazily and
uncached, so nothing is held twice.
Endpoint marks are unsigned: ``NULL`` (0, never stored) / ``TAIL`` (1) / ``ARROW`` (2) /
``CIRCLE`` (3). ``M[i][j]`` is the mark at node ``i`` on edge ``i-j``, so every edge contributes
two entries (``M[i][j]`` and ``M[j][i]``). A double arrowhead ``ARROW``/``ARROW`` on an
off-diagonal pair is kind-relative: a bidirected (latent-confounded) edge under ``pag`` but a
2-cycle (``i -> j`` and ``j -> i``, two directed edges) under ``digraph``, the directed graph kind
that also carries autoregressive self-loops.
"""
from __future__ import annotations
import dataclasses
import itertools
from collections.abc import Iterable, Iterator, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, Literal
import numpy as np
if TYPE_CHECKING: # optional / derived-view deps, imported lazily in the methods
import networkx
import numpy.typing as npt
import scipy.sparse
# Endpoint mark codes (unsigned; ``NULL`` is never stored in the CSR).
NULL = 0
TAIL = 1
ARROW = 2
CIRCLE = 3
# Node-type codes for ``GraphStructure._node_types``; ``None`` means every node is observed.
OBSERVED = 0
LATENT = 1
Kind = Literal["dag", "cpdag", "pag", "digraph"]
KINDS: tuple[Kind, ...] = ("dag", "cpdag", "pag", "digraph")
# pcalg's ``amat.pag`` codes (0 none, 1 circle, 2 arrowhead, 3 tail) by Andrey mark, and back.
_PCALG_PAG_CODE = np.array([0, 3, 2, 1], dtype=np.int8) # indexed by NULL, TAIL, ARROW, CIRCLE
_PCALG_PAG_MARK = np.array([NULL, CIRCLE, ARROW, TAIL], dtype=np.int8) # indexed by pcalg code
# Structured dtype for the derived edge-list view (one row per edge, ``i <= j``).
EDGE_DTYPE = np.dtype([("i", "i4"), ("j", "i4"), ("mark_i", "i1"), ("mark_j", "i1")])
# ``oriented_edges`` type by mark pair on edge ``i-j``, and whether ``(i, j)`` is already
# ``(source, target)``. The source is the tail or circle end; for ``-o`` it is the tail end.
_EDGE_TYPES: dict[tuple[int, int], tuple[str, bool]] = {
(TAIL, ARROW): ("directed", True),
(ARROW, TAIL): ("directed", False),
(CIRCLE, ARROW): ("partially_directed", True),
(ARROW, CIRCLE): ("partially_directed", False),
(TAIL, CIRCLE): ("partially_undirected", True),
(CIRCLE, TAIL): ("partially_undirected", False),
(TAIL, TAIL): ("undirected", True),
(ARROW, ARROW): ("bidirected", True),
(CIRCLE, CIRCLE): ("circle", True),
}
# Printed symbol per edge type, read source end, line, target end.
EDGE_GLYPHS = {
"directed": "->",
"partially_directed": "o->",
"partially_undirected": "-o",
"undirected": "--",
"bidirected": "<->",
"circle": "o-o",
}
# Index into ``_TYPE_NAMES`` by mark-pair code ``mark_i * 4 + mark_j``; -1 for no type.
_TYPE_NAMES = tuple(EDGE_GLYPHS)
_TYPE_CODE = np.full(16, -1, dtype=np.int64)
_TYPE_CODE[[a * 4 + b for a, b in _EDGE_TYPES]] = [
_TYPE_NAMES.index(t) for t, _ in _EDGE_TYPES.values()
]
SUMMARY_EDGES = 30 # edges a printed summary lists before "... and N more"
[docs]
@dataclass(frozen=True, eq=False, slots=True, kw_only=True, repr=False)
class Structure:
"""Base class of the learned graphs, ``GraphStructure`` and ``TemporalStructure``.
A method's result holds one in ``StructureOutput.structure``; ``Structure`` itself is
abstract. The class attribute ``type`` is ``"graph"`` for a ``GraphStructure`` (including a
``SummaryGraph``) and ``"temporal"`` for a ``TemporalStructure``. Both have ``n_nodes`` and
``labels``.
"""
type: ClassVar[Literal["graph", "temporal"]] # set by each concrete subclass
_n_nodes: int
_labels: tuple[str, ...] | None = None
def __post_init__(self) -> None:
if type(self) is Structure:
raise TypeError("Structure is abstract; use GraphStructure or TemporalStructure")
if self._n_nodes < 0:
raise ValueError(f"n_nodes must be non-negative, got {self._n_nodes}")
if self._labels is None:
return
object.__setattr__(self, "_labels", node_labels(self._labels, "node"))
if len(self._labels) != self._n_nodes:
raise ValueError(f"labels length {len(self._labels)} != n_nodes {self._n_nodes}")
@property
def n_nodes(self) -> int:
"""Number of nodes, including nodes without edges and any latent nodes the method adds."""
return self._n_nodes
@property
def labels(self) -> tuple[str, ...] | None:
"""Node names (a DataFrame's column names), or ``None`` when nodes are numbered from 0.
Each name is a ``str``, unique among the nodes: a structure built with other names, such
as the integers ``(1, 0, 2)``, holds them as text, ``("1", "0", "2")``.
"""
return self._labels
def __eq__(self, other: object) -> bool:
"""Shared value-equality; subclasses extend it with their own store fields.
Returns ``NotImplemented`` for a different concrete type (so ``==`` falls back to
identity), ``True`` when the shared fields match, else ``False``.
"""
if type(self) is not type(other):
return NotImplemented
return self._n_nodes == other._n_nodes and self._labels == other._labels # type: ignore[attr-defined]
__hash__ = None # type: ignore[assignment] # frozen + custom __eq__ + unhashable ndarray fields
def __repr__(self) -> str:
"""Return the structure's kind, size, and edges as a readable summary."""
return "\n".join(self._summary())
def _summary(
self,
*,
name: str | None = None,
weights: np.ndarray | scipy.sparse.csr_array | None = None,
limit: int | None = SUMMARY_EDGES,
more: str = "",
) -> list[str]:
"""Require concrete structures to provide summary lines.
``name`` leads the header. ``weights[source, target]`` follows a directed edge. ``limit``
caps the listed edges (``None`` lists all), and ``more`` extends the omitted-edge count.
"""
raise NotImplementedError(f"{type(self).__name__} does not define a summary")
[docs]
@dataclass(frozen=True, eq=False, slots=True, kw_only=True, repr=False)
class GraphStructure(Structure):
"""A learned graph: its nodes, and a mark at each end of every edge.
A method returns one as ``StructureOutput.structure``; ``from_numpy``, ``from_edges``,
``from_networkx``, and ``from_pcalg`` build one from an existing graph. ``kind`` says how to
read the edges:
- ``"dag"``: every edge is directed, with no directed cycles.
- ``"cpdag"``: an equivalence class of DAGs; an undirected edge ``--`` can point either way.
- ``"pag"``: an equivalence class that allows hidden variables; a circle ``o`` is an
unresolved endpoint, and ``<->`` is a relation through hidden causes.
- ``"digraph"``: directed edges that may form cycles and self-loops, such as the graph for a
positive lag of a ``TemporalStructure``. Here the pair ``(2, 2)`` is two directed edges,
``i -> j`` and ``j -> i``.
A cell ``M[i, j]`` of ``to_numpy()`` is the mark at node ``i`` on its edge with ``j``: ``0``
absent, ``1`` tail, ``2`` arrowhead, ``3`` circle (``andrey.core.NULL``, ``TAIL``, ``ARROW``,
``CIRCLE``). ``endpoints(i, j)`` reads one edge, and ``oriented_edges()`` lists every edge as
``(source, target, type)``. ``oriented_edges()``, ``to_edges()``, and ``to_scipy_sparse()``
scale to large graphs; ``to_numpy()`` and ``adjacency`` build a dense ``(n, n)`` array on each
call. The graph is read-only.
"""
type: ClassVar[Literal["graph"]] = "graph"
_indptr: np.ndarray # int64, (n_nodes + 1,)
_indices: np.ndarray # int32, (nnz,)
_marks: np.ndarray # int8, (nnz,)
_kind: Kind = "dag"
# Internal store layout: ``sym`` = the canonical symmetric endpoint-mark CSR; ``compact`` = a
# halved dag/cpdag store (one entry per edge), reconstructed to the symmetric view on read.
# A pure function of (kind, marks) set by ``_from_csr``; the public views are layout-agnostic.
_layout: Literal["sym", "compact"] = "sym"
_node_types: np.ndarray | None = None # int8 (n_nodes,); OBSERVED/LATENT, None = all-observed
# ---- construction ---------------------------------------------------------------------
@classmethod
def _from_csr(
cls,
n_nodes: int,
indptr: np.ndarray,
indices: np.ndarray,
marks: np.ndarray,
*,
kind: Kind,
labels: tuple[str, ...] | None = None,
node_types: np.ndarray | None = None,
) -> GraphStructure:
"""Build directly from CSR arrays, casting to the canonical dtypes. Internal entry point."""
n = int(n_nodes)
types = None
if node_types is not None:
types = _frozen(node_types, np.int8)
if types.shape != (n,):
raise ValueError(f"node_types must have shape ({n},), got {types.shape}")
# Opportunistically halve the store for a conforming dag/cpdag; a nonconforming graph stays
# symmetric so ``validate()`` still reports it.
layout: Literal["sym", "compact"] = "sym"
compact = _try_compact(n, indptr, indices, marks, kind)
if compact is not None:
indptr, indices, marks = compact
layout = "compact"
return cls(
_n_nodes=n,
_labels=labels,
_indptr=_frozen(indptr, np.int64),
_indices=_frozen(indices, np.int32),
_marks=_frozen(marks, np.int8),
_kind=kind,
_layout=layout,
_node_types=types,
)
[docs]
@classmethod
def from_numpy(
cls,
M: np.ndarray,
*,
kind: Kind | None = None,
labels: tuple[str, ...] | None = None,
allow_self_loops: bool = False,
node_types: np.ndarray | None = None,
) -> GraphStructure:
"""Build a graph from an ``(n, n)`` endpoint-mark matrix, the inverse of ``to_numpy``.
Parameters
----------
M : np.ndarray of shape (n, n)
Endpoint marks: ``M[i, j]`` is the mark at node ``i`` on its edge with ``j``, ``0``
absent, ``1`` tail, ``2`` arrowhead, ``3`` circle. ``i -> j`` is ``M[i, j] = 1`` and
``M[j, i] = 2``. Every edge needs a mark in both cells.
kind : {"dag", "cpdag", "pag", "digraph"} or None, default=None
Graph type. ``None`` infers ``"pag"`` from any circle or ``(2, 2)`` pair, else
``"cpdag"`` from any ``(1, 1)`` pair, else ``"dag"``; it never infers ``"digraph"``,
so pass it for a graph with 2-cycles. The marks are not checked against ``kind``;
call ``validate()``.
labels : tuple[str, ...] or None, default=None
Node names, one per node, each taken as text, ``str(label)``; ``None`` numbers the
nodes from 0.
allow_self_loops : bool, default=False
Accept self-loops on the diagonal. A self-loop ``M[i, i] = 2`` is a variable's effect
on its own future, in the graph for a positive lag of a ``TemporalStructure``; ``2`` is
the only self-loop mark.
node_types : np.ndarray of shape (n,) or None, default=None
``0`` for an observed node and ``1`` for a latent one (``andrey.core.OBSERVED``,
``LATENT``); ``None`` when every node is observed.
Returns
-------
GraphStructure
The graph.
Raises
------
ValueError
If ``kind`` is not one of the four kinds, ``M`` is not square, has a nonzero diagonal
without ``allow_self_loops`` or a diagonal value other than ``0`` or ``2`` with it,
holds a value outside ``0..3``, has a mark in ``M[i, j]`` but not in ``M[j, i]``,
``labels`` does not have ``n`` entries or has two that are equal as text, such as ``1``
and ``"1"``, or ``node_types`` is not shape ``(n,)``.
"""
if kind is not None and kind not in KINDS:
raise ValueError(f"kind must be one of {KINDS} or None, got {kind!r}")
M = np.asarray(M)
if M.ndim != 2 or M.shape[0] != M.shape[1]:
raise ValueError(f"expected a square (n, n) matrix, got shape {M.shape}")
n = M.shape[0]
if not allow_self_loops and M.size and np.diagonal(M).any():
raise ValueError("endpoint matrix must have a zero diagonal (no self-loops)")
loops = np.diagonal(M) if M.size else np.zeros(0)
bad = np.flatnonzero((loops != NULL) & (loops != ARROW))
if bad.size:
i = int(bad[0])
raise ValueError(
f"a self-loop is marked {ARROW} (arrowhead), got M[{i}, {i}]={M[i, i]}"
)
if (M < 0).any() or (M > CIRCLE).any():
raise ValueError(f"endpoint marks must be in 0..{CIRCLE}, got other values")
if not np.array_equal(M != 0, M.T != 0):
raise ValueError("endpoint matrix support must be symmetric (every i-j has a j-i mark)")
marks_i8 = M.astype(np.int8)
resolved = kind if kind is not None else _infer_kind(marks_i8)
indptr, indices, marks = _dense_to_csr(marks_i8)
return cls._from_csr(
n, indptr, indices, marks, kind=resolved, labels=labels, node_types=node_types
)
[docs]
@classmethod
def from_edges(
cls,
edges: np.ndarray,
*,
n_nodes: int,
kind: Kind | None = None,
labels: tuple[str, ...] | None = None,
) -> GraphStructure:
"""Build a graph from an edge list, the inverse of ``to_edges``.
Parameters
----------
edges : np.ndarray
Structured array with fields ``i``, ``j``, ``mark_i``, ``mark_j`` (dtype
``andrey.core.EDGE_DTYPE``), one row per edge; ``mark_i`` is the mark at ``i`` and
``mark_j`` the mark at ``j``. A row with ``i == j`` is a self-loop.
n_nodes : int
Number of nodes, including nodes without edges.
kind : {"dag", "cpdag", "pag", "digraph"} or None, default=None
Graph type; ``None`` infers it as ``from_numpy`` does.
labels : tuple[str, ...] or None, default=None
Node names, one per node, each taken as text, ``str(label)``; ``None`` numbers the
nodes from 0.
Returns
-------
GraphStructure
The graph.
Raises
------
ValueError
If a mark is outside ``0..3``, a row has a mark at one end only, or ``labels`` does not
have ``n_nodes`` entries or has two that are equal as text.
IndexError
If ``edges`` is not a structured array with those fields, or names a node outside
``range(n_nodes)``.
"""
edges = np.asarray(edges)
M = np.zeros((n_nodes, n_nodes), dtype=np.int8)
for row in edges:
i, j, mi, mj = int(row["i"]), int(row["j"]), int(row["mark_i"]), int(row["mark_j"])
if not (0 <= i < n_nodes and 0 <= j < n_nodes):
raise IndexError(f"edge ({i}, {j}) names a node outside range({n_nodes})")
M[i, j] = mi
M[j, i] = mj
# keep self-loops if the input carries any -- so a self-loop graph (a lag>=1 view) is a
# faithful round-trip through these documented inverses of to_edges / to_networkx.
self_loops = bool(M.size and np.diagonal(M).any())
return cls.from_numpy(M, kind=kind, labels=labels, allow_self_loops=self_loops)
[docs]
@classmethod
def from_networkx(cls, g: networkx.DiGraph, *, kind: Kind | None = None) -> GraphStructure:
"""Build a graph from a NetworkX graph, the inverse of ``to_networkx``.
Each edge ``(u, v)`` is read from its ``endpoints=(mark at u, mark at v)`` attribute. An
edge without the attribute is ``u -> v``. Two such edges ``u -> v`` and ``v -> u`` give the
mark pair ``(2, 2)``, so pass ``kind="digraph"`` to read them as a 2-cycle rather than a
``pag`` bidirected edge. Self-loops are kept. Nodes are indexed in insertion order; unless
they are exactly ``0, 1, ..., n - 1`` in that order, each node becomes a label, converted
to ``str``.
Parameters
----------
g : networkx.DiGraph or networkx.MultiDiGraph
The graph to read.
kind : {"dag", "cpdag", "pag", "digraph"} or None, default=None
Graph type; ``None`` infers it as ``from_numpy`` does.
Returns
-------
GraphStructure
The graph.
Raises
------
ValueError
If an ``endpoints`` mark is outside ``0..3``, an edge has a mark at one end only, or two
nodes are equal as text, such as ``1`` and ``"1"``.
"""
nodes = list(g.nodes())
n = len(nodes)
index = {node: k for k, node in enumerate(nodes)}
integer_indexed = all(isinstance(node, (int, np.integer)) for node in nodes) and [
int(node) for node in nodes
] == list(range(n))
labels = None if integer_indexed else tuple(str(node) for node in nodes)
M = np.zeros((n, n), dtype=np.int8)
for u, v, data in g.edges(data=True):
ep = data.get("endpoints")
if ep is None:
# A bare edge u -> v is an arrowhead at v; when the reciprocal bare edge (v, u) is
# also present the pair is a 2-cycle (ARROW/ARROW), not the second write clobbering
# the first into a lone directed edge.
M[index[v], index[u]] = ARROW
M[index[u], index[v]] = ARROW if g.has_edge(v, u) else TAIL
else:
M[index[u], index[v]] = int(ep[0])
M[index[v], index[u]] = int(ep[1])
# keep self-loops if the input carries any -- so a self-loop graph (a lag>=1 view) is a
# faithful round-trip through these documented inverses of to_edges / to_networkx.
self_loops = bool(M.size and np.diagonal(M).any())
return cls.from_numpy(M, kind=kind, labels=labels, allow_self_loops=self_loops)
[docs]
@classmethod
def from_pcalg(
cls,
amat: npt.ArrayLike,
kind: Literal["dag", "cpdag", "pag"],
*,
labels: tuple[str, ...] | None = None,
) -> GraphStructure:
"""Build a graph from the adjacency matrix ``amat`` that R's pcalg package uses.
pcalg records a PAG, and a MAG, as ``amat.pag``: ``amat[a, b]`` is the mark at the column
node ``b`` on the edge between ``a`` and ``b``, ``0`` no edge, ``1`` circle, ``2``
arrowhead, ``3`` tail. It records a DAG or CPDAG as ``amat.cpdag``: ``a -> b`` is
``amat[b, a] = 1`` with ``amat[a, b] = 0``, at the child's row, and ``a -- b`` is ``1`` in
both cells. This reads either, the inverse of ``to_pcalg``.
Parameters
----------
amat : array-like of shape (n, n)
The pcalg matrix, such as ``fit@amat`` from ``fci`` or ``as(fit, "amat")`` from ``pc``.
kind : {"dag", "cpdag", "pag"}
How pcalg coded the graph: ``"pag"`` reads ``amat.pag`` (a MAG too), and ``"cpdag"``
and ``"dag"`` read ``amat.cpdag``.
labels : tuple[str, ...] or None, default=None
Node names, one per node, each taken as text, ``str(label)``; ``None`` numbers the
nodes from 0.
Returns
-------
GraphStructure
The graph, of ``kind``.
Raises
------
ValueError
If ``kind`` is not ``"dag"``, ``"cpdag"``, or ``"pag"``, ``amat`` is not square, has a
nonzero diagonal, holds a value other than ``0``, ``1``, ``2``, ``3`` for a ``"pag"``
or ``0`` and ``1`` otherwise, or, for a ``"pag"``, has a mark in ``amat[a, b]`` but
not in ``amat[b, a]``. Also if the edges are not allowed for ``kind``, such as an
undirected edge in a ``"dag"``, or ``labels`` does not have ``n`` entries or has two
that are equal as text.
Examples
--------
>>> import numpy as np
>>> from andrey import GraphStructure
>>> amat = np.array([[0, 0, 0], [0, 0, 0], [1, 1, 0]]) # 0 -> 2 <- 1, child's row 2
>>> GraphStructure.from_pcalg(amat, "cpdag").oriented_edges()
[(0, 2, 'directed'), (1, 2, 'directed')]
"""
if kind not in ("dag", "cpdag", "pag"):
raise ValueError(f"kind must be 'dag', 'cpdag', or 'pag', got {kind!r}")
A = np.asarray(amat)
if A.ndim != 2 or A.shape[0] != A.shape[1]:
raise ValueError(f"expected a square (n, n) matrix, got shape {A.shape}")
codes = (0, 1, 2, 3) if kind == "pag" else (0, 1)
if not np.isin(A, codes).all():
raise ValueError(f"amat for kind={kind!r} holds only the codes {codes}")
if kind == "pag":
M = _PCALG_PAG_MARK[A.astype(np.intp)].T # amat[a, b] marks b; M[i, j] marks i
else:
child_row = A != 0
M = np.zeros(A.shape, dtype=np.int8)
M[child_row.T] = TAIL # the source's end, or both ends of an undirected edge
M[child_row & ~child_row.T] = ARROW # the child's end of a directed edge
graph = cls.from_numpy(M, kind=kind, labels=labels)
graph.validate()
return graph
# ---- store surface --------------------------------------------------------------------
@property
def kind(self) -> Kind:
"""Graph type: ``"dag"``, ``"cpdag"``, ``"pag"``, or ``"digraph"`` (cycles allowed)."""
return self._kind
@property
def node_types(self) -> np.ndarray | None:
"""Per-node codes, ``0`` observed and ``1`` latent, or ``None`` when every node is observed.
``gin`` sets it for the latent nodes it adds (``andrey.core.OBSERVED``, ``LATENT``).
"""
return self._node_types
@property
def adjacency(self) -> np.ndarray:
"""The endpoint-mark matrix, the same as ``to_numpy()``; see ``to_numpy`` for the codes.
Each access builds a new dense ``(n, n)`` array; for a large graph use ``oriented_edges``,
``to_edges``, or ``to_scipy_sparse``.
"""
return self.to_numpy()
[docs]
def to_numpy(self, *, dtype: npt.DTypeLike = np.int8) -> np.ndarray:
"""Return the endpoint marks as a dense ``(n, n)`` array.
A cell ``M[i, j]`` is the mark at node ``i`` on its edge with ``j``: ``0`` absent, ``1``
tail, ``2`` arrowhead, ``3`` circle. ``i -> j`` has ``M[i, j] = 1`` and ``M[j, i] = 2``.
Each call builds a new array.
Parameters
----------
dtype : npt.DTypeLike, default=np.int8
Data type of the returned array.
Returns
-------
np.ndarray of shape (n, n)
The endpoint-mark matrix.
"""
n = self._n_nodes
M = np.zeros((n, n), dtype=dtype)
indptr, indices, marks = self._sym_csr()
if indices.size:
rows = np.repeat(np.arange(n, dtype=np.intp), np.diff(indptr))
M[rows, indices] = marks
return M
[docs]
def to_scipy_sparse(self) -> scipy.sparse.csr_array:
"""Return the endpoint marks as a ``scipy.sparse.csr_array``, without a dense array.
Returns
-------
scipy.sparse.csr_array of shape (n, n)
The ``to_numpy`` matrix in sparse form; memory grows with the number of edges.
"""
import scipy.sparse
indptr, indices, marks = self._sym_csr()
return scipy.sparse.csr_array(
(marks, indices, indptr), shape=(self._n_nodes, self._n_nodes)
)
[docs]
def to_networkx(self, *, multigraph: bool = False) -> networkx.DiGraph | networkx.MultiDiGraph:
"""Return the graph as a NetworkX graph whose edges carry their marks.
Each arc ``(u, v)`` has the attribute ``endpoints=(mark at u, mark at v)``. For a ``dag``,
``cpdag``, or ``pag``, each edge is one arc from the lower to the higher node index
whatever its direction, so read ``endpoints`` or use ``oriented_edges()``. A ``digraph``
runs each arc from tail to arrowhead and gives a 2-cycle both arcs, so NetworkX cycle
functions see its cycles. A ``MultiDiGraph`` also adds the reverse arc for ``<->`` and
``o-o``. Nodes are named by ``labels`` when set, else numbered from 0. ``from_networkx``
reads the result back.
Parameters
----------
multigraph : bool, default=False
Return a ``MultiDiGraph`` for any ``kind``; a ``pag`` always gets one.
Returns
-------
networkx.DiGraph or networkx.MultiDiGraph
A ``MultiDiGraph`` for a ``pag`` or with ``multigraph=True``, else a ``DiGraph``.
"""
import networkx as nx
use_multi = multigraph or self._kind == "pag"
# A multigraph adds the reciprocal for symmetric PAG marks so the pair does not collapse; a
# digraph likewise emits both directed edges of a 2-cycle into a plain DiGraph.
emit_reciprocal = use_multi or self._kind == "digraph"
g: networkx.DiGraph | networkx.MultiDiGraph = (
nx.MultiDiGraph() if use_multi else nx.DiGraph()
)
node_id = self._labels if self._labels is not None else range(self._n_nodes)
g.add_nodes_from(node_id)
ids = list(node_id)
for i, j, mi, mj in self._iter_edges():
# A digraph is topology-faithful: orient a plain directed edge tail -> arrowhead so a
# cycle of any length is visible to NetworkX, not only a direction-symmetric 2-cycle.
if self._kind == "digraph" and i != j and {int(mi), int(mj)} == {TAIL, ARROW}:
src, dst, ep = (i, j, (mi, mj)) if mj == ARROW else (j, i, (mj, mi))
g.add_edge(ids[src], ids[dst], endpoints=(int(ep[0]), int(ep[1])))
continue
g.add_edge(ids[i], ids[j], endpoints=(int(mi), int(mj)))
# A 2-cycle (digraph) or a symmetric non-tail pair (i<->j, i o-o j) contributes the
# reciprocal edge; a plain undirected i--j stays a single edge.
if emit_reciprocal and i != j and mi == mj and mi in (ARROW, CIRCLE):
g.add_edge(ids[j], ids[i], endpoints=(int(mj), int(mi)))
return g
[docs]
def to_pcalg(self) -> np.ndarray:
"""Return the adjacency matrix ``amat`` that R's pcalg package uses for this kind of graph.
A ``pag`` is written as pcalg's ``amat.pag``: ``amat[a, b]`` is the mark at the column node
``b`` on the edge between ``a`` and ``b``, ``0`` no edge, ``1`` circle, ``2`` arrowhead,
``3`` tail. A ``dag`` or ``cpdag`` is written as ``amat.cpdag``: ``a -> b`` is
``amat[b, a] = 1`` with ``amat[a, b] = 0``, at the child's row, and ``a -- b`` is ``1`` in
both cells. ``from_pcalg`` reads the result back. Each call builds a new array.
Returns
-------
np.ndarray of shape (n, n)
The pcalg matrix, ``int8``.
Raises
------
ValueError
If the graph is a ``digraph``, which pcalg has no matrix for, has a self-loop, or has
an edge its ``kind`` does not allow (``validate()`` lists them).
Examples
--------
>>> from andrey import GraphStructure
>>> import numpy as np
>>> M = np.array([[0, 1], [2, 0]]) # 0 -> 1
>>> GraphStructure.from_numpy(M, kind="dag").to_pcalg().tolist()
[[0, 0], [1, 0]]
"""
if self._kind == "digraph":
raise ValueError(
"pcalg has no adjacency matrix for a digraph; use a dag, cpdag, or pag"
)
self.validate()
M = self.to_numpy()
if M.size and np.diagonal(M).any():
raise ValueError("pcalg's adjacency matrix has no self-loops")
if self._kind == "pag":
# M[i, j] marks i, and amat[a, b] marks b.
return np.ascontiguousarray(_PCALG_PAG_CODE[M].T)
# amat[a, b] = 1 where b is the source of a -> b or an end of a -- b.
return np.ascontiguousarray((M == TAIL).T, dtype=np.int8)
[docs]
def to_edges(self) -> np.ndarray:
"""Return the derived typed edge list, one row per edge ``i <= j``.
Returns
-------
np.ndarray
Structured array (dtype ``andrey.core.EDGE_DTYPE``) with fields ``i``, ``j``,
``mark_i``, ``mark_j``, one row per edge with ``i <= j``; ``mark_i`` is the mark at
``i``. A row with ``i == j`` is a self-loop.
"""
rows = [(i, j, mi, mj) for i, j, mi, mj in self._iter_edges()]
return np.array(rows, dtype=EDGE_DTYPE)
[docs]
def oriented_edges(self, *, index: bool = False) -> list[tuple[str | int, str | int, str]]:
"""Return one ``(source, target, type)`` row per edge, oriented by its marks.
A directed edge runs from its tail to its arrowhead whatever the node order, and nodes are
named by ``labels`` when the graph has them. ``andrey run`` prints the same rows.
Parameters
----------
index : bool, default=False
Return node indices even when the graph has labels.
Returns
-------
list[tuple[str | int, str | int, str]]
Rows follow ``to_edges`` order. A ``digraph`` 2-cycle yields two ``directed`` rows,
lower-to-higher node index first, then the reverse. ``type`` is one of:
- ``directed``: ``source -> target``; a self-loop has ``source == target``.
- ``partially_directed``: ``source o-> target``.
- ``partially_undirected``: ``source -o target``, a tail at ``source`` and a circle at
``target``.
- ``undirected``: ``source -- target``.
- ``bidirected``: ``source <-> target`` in a ``pag``.
- ``circle``: ``source o-o target``.
The three symmetric types list the lower node index first.
Examples
--------
>>> import numpy as np
>>> from andrey import GraphStructure
>>> M = np.array([[0, 2], [1, 0]]) # the arrowhead is at node 0
>>> GraphStructure.from_numpy(M, labels=("wet", "rain")).oriented_edges()
[('rain', 'wet', 'directed')]
"""
names = range(self._n_nodes) if index or self._labels is None else self._labels
return [(names[s], names[t], edge_type) for s, t, edge_type in self._oriented()]
[docs]
def neighbors(self, i: int) -> np.ndarray:
"""Return the nodes that share an edge with node ``i``, whatever the marks.
For a ``dag`` or ``cpdag`` each call scans every edge; to visit every node, use
``to_scipy_sparse()``.
Parameters
----------
i : int
Node index.
Returns
-------
np.ndarray of int32
The adjacent node indices, ascending and read-only; includes ``i`` if it has a
self-loop.
"""
if self._layout == "sym":
return self._indices[self._indptr[i] : self._indptr[i + 1]]
# compact: out-edges (row i) plus in-edges (i appearing as a column in another row).
out_cols = self._indices[self._indptr[i] : self._indptr[i + 1]]
in_pos = np.nonzero(self._indices == i)[0]
in_rows = (np.searchsorted(self._indptr, in_pos, side="right") - 1).astype(np.int32)
result = np.union1d(out_cols, in_rows).astype(np.int32)
result.setflags(write=False)
return result
[docs]
def endpoints(self, i: int, j: int) -> tuple[int, int]:
"""Return the endpoint marks on edge ``i-j``. ``O(log deg)``.
Parameters
----------
i : int
First node index.
j : int
Second node index.
Returns
-------
tuple[int, int]
``(mark at i, mark at j)``: ``1`` tail, ``2`` arrowhead, ``3`` circle; ``(0, 0)`` when
there is no edge.
"""
return int(self._mark_at(i, j)), int(self._mark_at(j, i))
# ---- validation -----------------------------------------------------------------------
[docs]
def validate(self) -> None:
"""Check that every edge's marks are allowed for ``kind``.
It does not check a ``dag`` for directed cycles, or that a ``cpdag`` or ``pag`` is a
completed equivalence class.
Raises
------
ValueError
If a circle appears outside a ``pag``, a ``dag`` or ``cpdag`` has an edge with two
arrowheads, a ``dag`` or ``digraph`` has an undirected edge, or an edge has a mark at
one end only.
"""
indptr, indices, marks = self._sym_csr()
if self._kind != "pag" and (marks == CIRCLE).any():
raise ValueError(f"CIRCLE marks require kind='pag', not '{self._kind}'")
for i, j, mi, mj in self._iter_edges():
if i == j:
continue # a self-loop (autoregressive lag edge) is single-endpoint, not bidirected
if self._kind in ("dag", "cpdag") and mi == ARROW and mj == ARROW:
raise ValueError(
f"double arrowhead {i}<->{j} requires kind='pag' (bidirected) or "
f"kind='digraph' (2-cycle)"
)
if self._kind in ("dag", "digraph") and mi == TAIL and mj == TAIL:
raise ValueError(f"undirected edge {i}--{j} requires kind='cpdag' or 'pag'")
# Support symmetry: every stored (i, j) has a matching (j, i).
n = self._n_nodes
rows = np.repeat(np.arange(n, dtype=np.intp), np.diff(indptr))
for i, j in zip(rows.tolist(), indices.tolist()):
if _lookup(indptr, indices, marks, j, i) == NULL:
raise ValueError(f"asymmetric half-edge: ({i}, {j}) stored but ({j}, {i}) missing")
# ---- internals ------------------------------------------------------------------------
def _sym_csr(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""The symmetric endpoint-mark CSR (``indptr``, ``indices``, ``marks``) every view reads.
Identity when the store is already symmetric; reconstructs the full pairs for a ``compact``
dag/cpdag store, so no accessor observes the layout.
"""
if self._layout == "sym":
return self._indptr, self._indices, self._marks
return _expand_compact(self._n_nodes, self._indptr, self._indices, self._marks)
def _mark_at(self, i: int, j: int) -> int:
if self._layout == "sym":
return _lookup(self._indptr, self._indices, self._marks, i, j)
# compact: the edge is stored once. An entry at (i, j) means i is the source (or an
# undirected end) -- the mark AT i is TAIL, the diagonal is a self-loop; an entry at (j, i)
# carries the far mark (ARROW if directed into i, TAIL if undirected).
m = _lookup(self._indptr, self._indices, self._marks, i, j)
if m != NULL:
return m if i == j else TAIL
return _lookup(self._indptr, self._indices, self._marks, j, i)
def _mark_pairs(self) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Return mark arrays for edges with ``i <= j``, in ``to_edges`` order.
The arrays hold ``(i, j, mark_i, mark_j)``. This vectorized form of ``_iter_edges`` avoids
a search for each edge.
"""
import scipy.sparse
indptr, indices, marks = self._sym_csr()
rows = np.repeat(np.arange(self._n_nodes, dtype=np.intp), np.diff(indptr))
cols = indices.astype(np.intp)
keep = rows <= cols
rows, cols, near = rows[keep], cols[keep], marks[keep].astype(np.int64)
if not rows.size:
return rows, cols, near, near.copy()
shape = (self._n_nodes, self._n_nodes)
sym = scipy.sparse.csr_array((marks, indices, indptr), shape=shape)
far = np.asarray(sym[cols, rows], dtype=np.int64).ravel() # the mark at j on edge i-j
return rows, cols, near, far
def _oriented(
self, pairs: tuple[np.ndarray, ...] | None = None
) -> Iterator[tuple[int, int, str]]:
"""Yield ``(source, target, type)`` by node index, in ``to_edges`` order.
A ``digraph`` 2-cycle yields both directions. ``pairs`` accepts arrays already returned
by ``_mark_pairs()``.
"""
for i, j, mi, mj in zip(*(a.tolist() for a in pairs or self._mark_pairs())):
if self._kind == "digraph" and i != j and mi == mj == ARROW:
yield i, j, "directed"
yield j, i, "directed"
continue
edge_type, forward = ("directed", True) if i == j else _EDGE_TYPES[(mi, mj)]
yield (i, j, edge_type) if forward else (j, i, edge_type)
def _type_counts(self) -> dict[str, int]:
"""Count edges by ``oriented_edges`` type, in order of first appearance."""
i, j, near, far = self._mark_pairs()
codes = np.where(i == j, 0, _TYPE_CODE[near * 4 + far]) # a self-loop is directed
reciprocal_count = 0
if self._kind == "digraph":
reciprocal = (i != j) & (near == ARROW) & (far == ARROW)
codes[reciprocal] = 0
reciprocal_count = np.count_nonzero(reciprocal)
if (codes < 0).any():
raise ValueError("an edge carries a mark pair with no type (validate() says which)")
_, first = np.unique(codes, return_index=True)
counts = np.bincount(codes, minlength=len(_TYPE_NAMES))
counts[0] += reciprocal_count
return {_TYPE_NAMES[codes[k]]: int(counts[codes[k]]) for k in np.sort(first)}
def _edge_count_text(self) -> str:
"""Format the edge count and type breakdown for a summary header."""
counts = self._type_counts()
types = ", ".join(f"{n} {t}" for t, n in counts.items()) or "no edges"
return f"{sum(counts.values())} edges ({types})"
def _edge_lines(
self,
*,
weights: np.ndarray | scipy.sparse.csr_array | None = None,
limit: int | None = SUMMARY_EDGES,
more: str = "",
) -> list[str]:
"""Format up to ``limit`` edges and append the omitted-edge count, if any."""
names = self._labels
pairs = self._mark_pairs()
lines = []
for s, t, edge_type in itertools.islice(self._oriented(pairs), limit):
directed = edge_type in ("directed", "partially_directed")
weight = f" ({float(weights[s, t]):+.3g})" if weights is not None and directed else ""
source, target = (names[s], names[t]) if names else (str(s), str(t))
lines.append(f"{source} {EDGE_GLYPHS[edge_type]} {target}{weight}")
left = len(pairs[0]) - len(lines)
if self._kind == "digraph":
i, j, near, far = pairs
left += np.count_nonzero((i != j) & (near == ARROW) & (far == ARROW))
if left > 0:
lines.append(f"... and {left} more{more}")
return lines
def _summary(
self,
*,
name: str | None = None,
weights: np.ndarray | scipy.sparse.csr_array | None = None,
limit: int | None = SUMMARY_EDGES,
more: str = "",
) -> list[str]:
"""Build the graph header, edge lines, and optional latent-node line."""
head = f"{self._kind} | {self._n_nodes} nodes | {self._edge_count_text()}"
lines = [head if name is None else f"{name} {head}"]
edges = self._edge_lines(weights=weights, limit=limit, more=more)
if edges:
lines += ["", *(" " + line for line in edges)]
if self._node_types is not None and (self._node_types == LATENT).any():
latent = np.nonzero(self._node_types == LATENT)[0].tolist()
names = self._labels
lines.append(" latent: " + ", ".join(names[i] if names else str(i) for i in latent))
return lines
def _iter_edges(self):
"""Yield ``(i, j, mark_i, mark_j)`` per edge, ``i <= j`` (self-loop when ``i == j``)."""
indptr, indices, marks = self._sym_csr()
n = self._n_nodes
for i in range(n):
lo, hi = int(indptr[i]), int(indptr[i + 1])
for k in range(lo, hi):
j = int(indices[k])
if j > i:
yield i, j, int(marks[k]), _lookup(indptr, indices, marks, j, i)
elif j == i: # self-loop: single endpoint, both marks are the diagonal cell
yield i, i, int(marks[k]), int(marks[k])
def __eq__(self, other: object) -> bool:
base = Structure.__eq__(self, other)
if base is not True: # NotImplemented (other type) or False (shared fields differ)
return base
assert isinstance(other, GraphStructure) # narrowed by the type check in the base
if self._kind != other._kind or not _opt_array_equal(self._node_types, other._node_types):
return False
# Compare the symmetric view so equal graphs match regardless of store layout. Equal
# constructor-built graphs always share a layout (a pure function of kind + marks), so the
# same-layout raw compare is the fast path; differing layouts normalize through _sym_csr.
if self._layout == other._layout:
a = (self._indptr, self._indices, self._marks)
b = (other._indptr, other._indices, other._marks)
else:
a, b = self._sym_csr(), other._sym_csr()
return all(np.array_equal(x, y) for x, y in zip(a, b))
__hash__ = None # type: ignore[assignment]
[docs]
@dataclass(frozen=True, eq=False, slots=True, kw_only=True, repr=False)
class TemporalStructure(Structure):
"""A time-series result: one ``GraphStructure`` per lag.
``varma_lingam``, ``longitudinal_lingam``, ``pcmci``, ``pcmciplus``, and ``lpcmci`` return one
as ``StructureOutput.structure``; ``from_lag_graphs`` and ``from_time_lag_graphs`` build one
from existing graphs. An edge ``i -> j`` in ``lag(k)`` means ``X_i(t-k) -> X_j(t)``. Lag 0 holds
the relations within one time step, and a self-loop at a positive lag is a variable's effect on
its own future.
``lags`` lists the lag values, ``lag_weights`` holds the coefficients, and ``summary_graph()``
merges the lags into one graph over the variables.
A ``longitudinal_lingam`` result also has a time axis, one lag stack per occasion: ``times``
lists the occasions, ``at(time, lag)`` selects a graph, and ``time_weights`` holds every
occasion's coefficients. On such a result, ``lag(k)``, ``lag_weights``, and
``summary_graph()`` describe the final occasion only.
"""
type: ClassVar[Literal["temporal"]] = "temporal"
_lags: tuple[GraphStructure, ...] = ()
_lag_indices: tuple[int, ...] = () # explicit lag value of each graph; () -> 0..L-1
_lag_weights: np.ndarray | None = None # AR weight stack, (n_lags, n, n) or None
_lag_weights_ma: np.ndarray | None = None # MA weight stack (VARMA), (q, n, n) or None
# Optional (time, lag) axis (Longitudinal). Empty -> a lag-only stack. Each row is one
# occasion's lag stack aligned 1:1 with ``_lag_indices``; ``_lag*`` project the final occasion.
_time_graphs: tuple[tuple[GraphStructure, ...], ...] = ()
_time_indices: tuple[int, ...] = () # explicit time value per row, strictly increasing
_time_weights: np.ndarray | None = None # (n_times, n_lags, n, n) float64; NaN = uncomputable
def __post_init__(self) -> None:
# Explicit parent call: zero-arg super() breaks under @dataclass(slots=True), whose
# class rebuild leaves the method's __class__ cell pointing at the pre-slots class.
Structure.__post_init__(self)
for k, lag in enumerate(self._lags):
if lag.n_nodes != self._n_nodes:
raise ValueError(f"lag {k} has {lag.n_nodes} nodes, expected {self._n_nodes}")
# Default the lag axis to a contiguous 0..L-1 stack when no indices are given;
# ``from_lag_graphs`` and documents that carry lags pass explicit, possibly uneven, indices.
if not self._lag_indices and self._lags:
object.__setattr__(self, "_lag_indices", tuple(range(len(self._lags))))
elif len(self._lag_indices) != len(self._lags):
raise ValueError(
f"lags length {len(self._lag_indices)} != number of graphs {len(self._lags)}"
)
self._validate_time_axis()
def _validate_time_axis(self) -> None:
"""Validate the optional ``(time, lag)`` grid; a no-op for a lag-only stack."""
if not self._time_graphs:
if self._time_indices or self._time_weights is not None:
raise ValueError("_time_indices / _time_weights require _time_graphs")
return
if not self._time_indices:
object.__setattr__(self, "_time_indices", tuple(range(len(self._time_graphs))))
elif len(self._time_indices) != len(self._time_graphs):
raise ValueError(
f"times length {len(self._time_indices)} != occasions {len(self._time_graphs)}"
)
if list(self._time_indices) != sorted(set(self._time_indices)):
raise ValueError(f"times must be strictly increasing, got {self._time_indices}")
n_lags = len(self._lag_indices)
for t, row in enumerate(self._time_graphs):
if len(row) != n_lags:
raise ValueError(f"occasion {t} has {len(row)} lag graphs, expected {n_lags}")
for g in row:
if g.n_nodes != self._n_nodes:
raise ValueError(
f"occasion {t} graph has {g.n_nodes} nodes, expected {self._n_nodes}"
)
if self._time_weights is not None:
expect = (len(self._time_graphs), n_lags, self._n_nodes, self._n_nodes)
if self._time_weights.shape != expect:
raise ValueError(
f"time_weights must have shape {expect}, got {self._time_weights.shape}"
)
# The lag-only surface is documented to be a view of the final occasion; enforce it so a
# corrupted / hand-edited doc cannot load with lag(k) != at(times[-1], k) (fail-fast).
if self._lags != self._time_graphs[-1]:
raise ValueError("the lag-only view must equal the final occasion (_lags != final row)")
if (
self._lag_weights is not None
and self._time_weights is not None
and not np.array_equal(self._lag_weights, np.nan_to_num(self._time_weights[-1]))
):
raise ValueError("lag_weights must be the final occasion's time_weights, zero-filled")
# ---- construction ---------------------------------------------------------------------
[docs]
@classmethod
def from_lag_graphs(
cls,
graphs: Sequence[GraphStructure],
*,
lags: Sequence[int] | None = None,
lag_weights: np.ndarray | None = None,
lag_weights_ma: np.ndarray | None = None,
labels: tuple[str, ...] | None = None,
) -> TemporalStructure:
"""Build a time-series result from one graph per lag.
Parameters
----------
graphs : Sequence[GraphStructure]
One graph per lag, all over the same nodes. An edge ``i -> j`` in the graph for lag
``k`` means ``X_i(t-k) -> X_j(t)``; a variable's effect on its own future is a
self-loop, ``M[i, i] = 2``, built with ``allow_self_loops=True``.
lags : Sequence[int] or None, default=None
The lag value of each graph, without repeats; gaps are allowed, for example,
``[0, 1, 12]``. ``None`` numbers the graphs ``0, 1, ..., len(graphs) - 1``. ``lag(k)``
finds a graph by this value, not by position.
lag_weights : np.ndarray of shape (n_lags, n, n) or None, default=None
Coefficients, one block per graph: ``lag_weights[k, i, j]`` is the weight of
``i -> j`` in ``graphs[k]``. Stored as a read-only ``float64`` copy.
lag_weights_ma : np.ndarray of shape (q, n, n) or None, default=None
Moving-average coefficients of a VARMA model: ``lag_weights_ma[m, i, j]`` is the
weight of ``i -> j`` at moving-average lag ``m + 1``. Stored as a read-only
``float64`` copy.
labels : tuple[str, ...] or None, default=None
Node names, one per node, each taken as text, ``str(label)``; ``None`` numbers the
nodes from 0.
Returns
-------
TemporalStructure
The result, without a time axis.
Raises
------
ValueError
If ``graphs`` is empty, ``lags`` does not have one value per graph or repeats a value,
the graphs differ in node count, ``labels`` does not have one entry per node or has two
that are equal as text, or a weight array has the wrong shape.
"""
graphs = tuple(graphs)
if not graphs:
raise ValueError("from_lag_graphs requires at least one lag GraphStructure")
n = int(graphs[0].n_nodes)
# per-graph node counts are re-checked by __post_init__ (which also guards direct
# construction); the lags/graphs length must be checked here, since __post_init__ reads an
# empty _lag_indices as "default to 0..L-1" and so cannot reject an explicit lags=[].
if lags is None:
indices = tuple(range(len(graphs)))
else:
indices = tuple(int(x) for x in lags)
if len(indices) != len(graphs):
raise ValueError(f"lags length {len(indices)} != number of graphs {len(graphs)}")
if len(set(indices)) != len(indices):
raise ValueError(f"lags must be unique, got {indices}")
ar = _as_lag_stack(lag_weights, "lag_weights", n=n, n_lags=len(graphs))
ma = _as_lag_stack(lag_weights_ma, "lag_weights_ma", n=n, n_lags=None)
return cls(
_n_nodes=n,
_labels=labels,
_lags=graphs,
_lag_indices=indices,
_lag_weights=ar,
_lag_weights_ma=ma,
)
[docs]
@classmethod
def from_time_lag_graphs(
cls,
time_graphs: Sequence[Sequence[GraphStructure]],
*,
lags: Sequence[int] | None = None,
times: Sequence[int] | None = None,
time_weights: np.ndarray | None = None,
labels: tuple[str, ...] | None = None,
) -> TemporalStructure:
"""Build a panel result: one lag stack per occasion.
``lag``, ``n_lags``, ``lag_weights``, and ``summary_graph()`` then describe the final
occasion; ``at`` and ``time_weights`` reach every occasion.
Parameters
----------
time_graphs : Sequence[Sequence[GraphStructure]] of shape (n_times, n_lags)
One row per occasion, each row a graph per lag in the order of ``lags``; every row has
the same length. Use an empty graph for a block that cannot be computed.
lags : Sequence[int] or None, default=None
The lag value of each column, without repeats. ``None`` numbers the columns
``0, 1, ..., n_lags - 1``.
times : Sequence[int] or None, default=None
The occasion value of each row, strictly increasing; gaps are allowed. For example, a
panel whose first occasion has no estimate starts at ``1``. ``None`` numbers the rows
``0, 1, ..., n_times - 1``.
time_weights : np.ndarray of shape (n_times, n_lags, n, n) or None, default=None
Coefficients: ``time_weights[t, k, i, j]`` is the weight of ``i -> j`` in
``time_graphs[t][k]``, with ``NaN`` for a block that cannot be computed.
``lag_weights`` is the last row with ``NaN`` replaced by ``0``.
labels : tuple[str, ...] or None, default=None
Node names, one per node, each taken as text, ``str(label)``; ``None`` numbers the
nodes from 0.
Returns
-------
TemporalStructure
The result, with a time axis.
Raises
------
ValueError
If the grid is empty or its rows differ in length, ``lags`` or ``times`` does not
match the grid, ``lags`` repeats a value, ``times`` is not strictly increasing, the
graphs differ in node count, ``labels`` does not have one entry per node or has two
that are equal as text, or ``time_weights`` has the wrong shape.
"""
rows = tuple(tuple(row) for row in time_graphs)
if not rows or not rows[0]:
raise ValueError("from_time_lag_graphs requires a non-empty (time, lag) grid")
n = int(rows[0][0].n_nodes)
n_lags = len(rows[0])
for t, row in enumerate(rows):
if len(row) != n_lags:
raise ValueError(
f"time_graphs row {t} has {len(row)} graphs, but row 0 has {n_lags}; every "
"row needs one graph per lag"
)
if lags is None:
lag_indices = tuple(range(n_lags))
else:
lag_indices = tuple(int(x) for x in lags)
if len(lag_indices) != n_lags:
raise ValueError(f"lags length {len(lag_indices)} != row width {n_lags}")
if len(set(lag_indices)) != len(lag_indices):
raise ValueError(f"lags must be unique, got {lag_indices}")
if times is None:
time_indices = tuple(range(len(rows)))
else:
time_indices = tuple(int(t) for t in times)
# __post_init__ reads an empty _time_indices as "default to 0..T-1", so an explicit
# times=[] must be rejected here (mirrors the lags=[] guard above).
if len(time_indices) != len(rows):
raise ValueError(f"times length {len(time_indices)} != occasions {len(rows)}")
tw = None
if time_weights is not None:
tw = np.asarray(time_weights, dtype=np.float64)
expect = (len(rows), n_lags, n, n)
if tw.shape != expect:
raise ValueError(f"time_weights must have shape {expect}, got {tw.shape}")
tw = _frozen(tw, np.float64)
# The lag-only projection is the final occasion (fully-observed window, NaN zero-filled).
proj_weights = None if tw is None else _frozen(np.nan_to_num(tw[-1]), np.float64)
return cls(
_n_nodes=n,
_labels=labels,
_lags=rows[-1],
_lag_indices=lag_indices,
_lag_weights=proj_weights,
_lag_weights_ma=None,
_time_graphs=rows,
_time_indices=time_indices,
_time_weights=tw,
)
# ---- surface --------------------------------------------------------------------------
@property
def n_lags(self) -> int:
"""Number of lags in the stack."""
return len(self._lags)
@property
def lags(self) -> tuple[int, ...]:
"""The lag value of each graph, in stack order; ``lag(k)`` looks up by this value."""
return self._lag_indices
[docs]
def lag(self, k: int) -> GraphStructure:
"""Select the graph at lag *value* ``k`` (not position).
For a time-carrying structure this is the final occasion's lag graph (``at(times[-1], k)``);
the full grid is reached via ``at`` / ``times``.
Parameters
----------
k : int
Lag value to look up, matched against ``lags`` (not a positional index).
Returns
-------
GraphStructure
The graph stored at lag ``k``.
Raises
------
KeyError
If no graph is stored at lag ``k``.
"""
try:
pos = self._lag_indices.index(k)
except ValueError:
raise KeyError(f"no lag {k}; present lags are {self._lag_indices}") from None
return self._lags[pos]
@property
def lag_weights(self) -> np.ndarray | None:
"""Coefficients ``(n_lags, n, n)``, or ``None`` when the result has none.
``lag_weights[k, i, j]`` is the weight of ``i -> j`` at lag ``lags[k]``. On a panel result
it is the final occasion with ``NaN`` replaced by ``0``; ``time_weights`` keeps every
occasion.
"""
return self._lag_weights
@property
def lag_weights_ma(self) -> np.ndarray | None:
"""Moving-average coefficients ``(q, n, n)`` from ``varma_lingam``, or ``None``.
``lag_weights_ma[m, i, j]`` is the weight of ``i -> j`` at moving-average lag ``m + 1``.
"""
return self._lag_weights_ma
@property
def n_times(self) -> int:
"""Number of absolute occasions in the ``(time, lag)`` axis; ``0`` for a lag-only stack."""
return len(self._time_indices)
@property
def times(self) -> tuple[int, ...]:
"""The explicit occasion index of each ``(time, lag)`` row; ``()`` when lag-only."""
return self._time_indices
[docs]
def at(self, time: int, lag: int) -> GraphStructure:
"""Select the graph at occasion *value* ``time`` and lag *value* ``lag``.
A lag-only stack has no time axis, so every ``at`` call raises there -- use ``lag`` instead.
Parameters
----------
time : int
Occasion value to look up, matched against ``times`` (not a positional index).
lag : int
Lag value to look up, matched against ``lags`` (not a positional index).
Returns
-------
GraphStructure
The graph stored at occasion ``time`` and lag ``lag``.
Raises
------
KeyError
If either ``time`` or ``lag`` is absent (including any ``at`` call on a lag-only stack).
"""
try:
ti = self._time_indices.index(time)
except ValueError:
raise KeyError(f"no time {time}; present times are {self._time_indices}") from None
try:
li = self._lag_indices.index(lag)
except ValueError:
raise KeyError(f"no lag {lag}; present lags are {self._lag_indices}") from None
return self._time_graphs[ti][li]
@property
def time_weights(self) -> np.ndarray | None:
"""Coefficients ``(n_times, n_lags, n, n)``, or ``None`` without a time axis or weights.
``time_weights[t, k, i, j]`` is the weight of ``i -> j`` in ``at(times[t], lags[k])``;
``NaN`` marks a block that could not be computed.
"""
return self._time_weights
def _summary(
self,
*,
name: str | None = None,
weights: np.ndarray | scipy.sparse.csr_array | None = None,
limit: int | None = SUMMARY_EDGES,
more: str = "",
) -> list[str]:
"""Build the temporal header and one edge block per lag.
``weights`` is unused; each lag graph supplies its own edge lines.
"""
head = f"temporal | {self._n_nodes} nodes | {self.n_lags} lags"
lines = [head if name is None else f"{name} {head}"]
for lag, graph in zip(self._lag_indices, self._lags):
lines += ["", f" lag {lag} | {graph._edge_count_text()}"]
lines += [" " + line for line in graph._edge_lines(limit=limit, more=more)]
return lines
[docs]
def summary_graph(self, *, include_lag0: bool = True) -> SummaryGraph:
"""Merge the lags into one directed graph over the variables.
``i -> j`` in the summary means ``i -> j`` in at least one lag graph; ``lags_of(i, j)``
lists those lags. ``i -> j`` at one lag and ``j -> i`` at another form a 2-cycle, and a
self-loop at a positive lag stays a self-loop. Only directed edges are kept; an undirected
edge or one with a circle is left out. On a panel result it merges the final occasion's
lags. Each call builds a new graph.
Parameters
----------
include_lag0 : bool, default=True
Include lag 0, the relations within one time step. ``False`` summarizes the effects
across time steps only.
Returns
-------
SummaryGraph
A ``kind="digraph"`` graph; ``lags_of(i, j)`` gives the lags of each edge.
"""
n = self._n_nodes
total = np.zeros((n, n), dtype=bool)
lag_sets: dict[tuple[int, int], list[int]] = {}
for lag, graph in zip(self._lag_indices, self._lags):
if lag == 0 and not include_lag0:
continue
support = _directed_support(graph.to_numpy())
total |= support
for i, j in zip(*np.nonzero(support)):
lag_sets.setdefault((int(i), int(j)), []).append(int(lag))
# Rebuild endpoint marks from the directed union: a mutual pair (or the diagonal) is a
# double arrowhead (2-cycle / self-loop), a one-way pair is tail -> arrow.
marks = np.zeros((n, n), dtype=np.int8)
one_way = total & ~total.T
both = total & total.T
marks[one_way] = TAIL
marks[one_way.T] = ARROW
marks[both] = ARROW
indptr, indices, mark_data = _dense_to_csr(marks)
edge_lags = tuple((i, j, tuple(sorted(lag_sets[(i, j)]))) for i, j in sorted(lag_sets))
return SummaryGraph(
_n_nodes=n,
_labels=self._labels,
_indptr=_frozen(indptr, np.int64),
_indices=_frozen(indices, np.int32),
_marks=_frozen(mark_data, np.int8),
_kind="digraph",
_edge_lags=edge_lags,
)
def __eq__(self, other: object) -> bool:
base = Structure.__eq__(self, other)
if base is not True:
return base
assert isinstance(other, TemporalStructure) # narrowed by the base type check
if self._lag_indices != other._lag_indices or self._time_indices != other._time_indices:
return False
if len(self._lags) != len(other._lags) or not all(
a == b for a, b in zip(self._lags, other._lags)
):
return False
if len(self._time_graphs) != len(other._time_graphs) or not all(
len(ra) == len(rb) and all(a == b for a, b in zip(ra, rb))
for ra, rb in zip(self._time_graphs, other._time_graphs)
):
return False
return (
_opt_array_equal(self._lag_weights, other._lag_weights, equal_nan=True)
and _opt_array_equal(self._lag_weights_ma, other._lag_weights_ma, equal_nan=True)
and _opt_array_equal(self._time_weights, other._time_weights, equal_nan=True)
)
__hash__ = None # type: ignore[assignment]
[docs]
@dataclass(frozen=True, eq=False, slots=True, kw_only=True, repr=False)
class SummaryGraph(GraphStructure):
"""The lags of a ``TemporalStructure`` merged into one directed graph over the variables.
Get one from ``TemporalStructure.summary_graph()``. It is a ``GraphStructure`` with
``kind="digraph"``: ``i -> j`` means ``X_i`` affects ``X_j`` at one or more lags, so ``i -> j``
at one lag and ``j -> i`` at another form a 2-cycle, and a variable's effect on its own future
is a self-loop. ``lags_of(i, j)`` and ``edge_lags`` give the lags behind each edge. Every
``GraphStructure`` view applies. ``from_numpy``, ``from_edges``, ``from_networkx``, and
``from_pcalg`` raise ``TypeError``, because a plain graph carries no lags; build a plain graph
with ``GraphStructure``'s. ``StructureOutput.save`` raises ``TypeError`` for a ``SummaryGraph``;
save the ``TemporalStructure`` and call ``summary_graph()`` again after loading.
"""
# Per directed edge ``(i, j, lags)``, sorted by ``(i, j)``; ``lags`` is the sorted, unique tuple
# of lag values at which ``i -> j`` appears (a self-loop when ``i == j``).
_edge_lags: tuple[tuple[int, int, tuple[int, ...]], ...] = ()
@classmethod
def _from_csr(
cls,
n_nodes: int,
indptr: np.ndarray,
indices: np.ndarray,
marks: np.ndarray,
*,
kind: Kind,
labels: tuple[str, ...] | None = None,
node_types: np.ndarray | None = None,
) -> GraphStructure:
"""Refuse the ``from_*`` constructors, which all build through this one."""
raise TypeError(
"a SummaryGraph comes from TemporalStructure.summary_graph(); for a plain graph, use "
"GraphStructure.from_numpy, from_edges, or from_networkx"
)
def __post_init__(self) -> None:
# Explicit parent call: zero-arg super() breaks under @dataclass(slots=True). GraphStructure
# has no own __post_init__, so Structure's runs the shared node/label checks; the kind is
# fixed for this derived type.
Structure.__post_init__(self)
if self._kind != "digraph":
raise ValueError(f"SummaryGraph is always kind='digraph', got '{self._kind}'")
@property
def edge_lags(self) -> tuple[tuple[int, int, tuple[int, ...]], ...]:
"""Every edge with its lags, as ``(i, j, lags)`` triples sorted by ``(i, j)``."""
return self._edge_lags
[docs]
def lags_of(self, i: int, j: int) -> tuple[int, ...]:
"""Return the sorted lag values at which the directed edge ``i -> j`` appears.
Parameters
----------
i : int
Source node index of the directed edge.
j : int
Target node index of the directed edge.
Returns
-------
tuple[int, ...]
The sorted lag values at which ``i -> j`` appears; ``()`` if the edge is absent.
"""
for a, b, lags in self._edge_lags:
if a == i and b == j:
return lags
return ()
def __eq__(self, other: object) -> bool:
base = GraphStructure.__eq__(self, other)
if base is not True: # NotImplemented (other type) or False (marks / kind differ)
return base
assert isinstance(other, SummaryGraph) # narrowed: same concrete type as self
return self._edge_lags == other._edge_lags
__hash__ = None # type: ignore[assignment]
def _frozen(arr: np.ndarray, dtype: npt.DTypeLike) -> np.ndarray:
"""An owned, read-only copy in ``dtype`` -- keeps the frozen container's store immutable."""
out = np.array(arr, dtype=dtype)
out.setflags(write=False)
return out
def node_labels(names: Iterable[object], what: str = "column") -> tuple[str, ...]:
"""Return ``names`` as node labels, each name as text: ``str(name)``.
Raises ``ValueError`` naming each label that two names share, such as ``1`` and ``"1"``, which
are both ``"1"`` as text, and the positions of the names; ``what``, such as ``"column"`` or
``"node"``, says what the names belong to.
"""
labels = tuple(str(name) for name in names)
if len(set(labels)) == len(labels):
return labels
positions: dict[str, list[int]] = {}
for j, label in enumerate(labels):
positions.setdefault(label, []).append(j)
clashes = "; ".join(
f"{label!r} names {what}s {js}" for label, js in positions.items() if len(js) > 1
)
raise ValueError(
f"{what} names must be unique as text, but {clashes}; give each {what} its own name"
)
def relabel(structure: Structure, labels: tuple[str, ...]) -> Structure:
"""The structure with its nodes named ``labels``; a temporal stack renames every lag graph."""
if not isinstance(structure, TemporalStructure):
return dataclasses.replace(structure, _labels=labels)
def named(graph: GraphStructure) -> GraphStructure:
return dataclasses.replace(graph, _labels=labels)
rows = tuple(tuple(named(g) for g in row) for row in structure._time_graphs)
lags = rows[-1] if rows else tuple(named(g) for g in structure._lags)
return dataclasses.replace(structure, _labels=labels, _lags=lags, _time_graphs=rows)
def _opt_array_equal(
a: np.ndarray | None, b: np.ndarray | None, *, equal_nan: bool = False
) -> bool:
"""``np.array_equal`` for optionals: both ``None`` are equal; exactly one ``None`` differs."""
if a is None or b is None:
return a is None and b is None
return bool(np.array_equal(a, b, equal_nan=equal_nan))
def _as_lag_stack(
weights: np.ndarray | None, name: str, *, n: int, n_lags: int | None
) -> np.ndarray | None:
"""Validate a ``(*, n, n)`` weight stack and return an owned read-only ``float64`` copy.
``n_lags`` pins the leading axis (the AR stack is 1:1 with the lag graphs); pass ``None`` to
leave it free (the MA stack carries its own order ``q``).
"""
if weights is None:
return None
arr = np.asarray(weights, dtype=np.float64)
if arr.ndim != 3 or arr.shape[1] != n or arr.shape[2] != n:
expect = f"({'n_lags' if n_lags is None else n_lags}, {n}, {n})"
raise ValueError(f"{name} must have shape {expect}, got {arr.shape}")
if n_lags is not None and arr.shape[0] != n_lags:
raise ValueError(f"{name} has {arr.shape[0]} lags, expected {n_lags} to match lags")
return _frozen(arr, np.float64)
def _dense_to_csr(M: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Canonical CSR (sorted columns, no stored zeros) of the dense endpoint matrix."""
import scipy.sparse
sp = scipy.sparse.csr_array(M)
sp.sum_duplicates()
sp.sort_indices()
return (
sp.indptr.astype(np.int64),
sp.indices.astype(np.int32),
sp.data.astype(np.int8),
)
def _lookup(indptr: np.ndarray, indices: np.ndarray, marks: np.ndarray, a: int, b: int) -> int:
"""Mark at cell ``(a, b)`` of an endpoint-mark CSR (``NULL`` if absent); ``O(log deg)``."""
lo, hi = int(indptr[a]), int(indptr[a + 1])
row = indices[lo:hi]
pos = int(np.searchsorted(row, b))
if pos < row.size and row[pos] == b:
return int(marks[lo + pos])
return NULL
def _try_compact(
n: int, indptr: np.ndarray, indices: np.ndarray, marks: np.ndarray, kind: Kind
) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None:
"""Halved dag/cpdag store (one entry per edge) if the marks conform, else ``None`` (stay full).
A directed edge ``i -> j`` keeps a single entry ``(i, j)`` carrying the far mark ``ARROW``; an
undirected ``i -- j`` a single ``(min, max)`` entry with ``TAIL``; a self-loop the diagonal.
Returns ``None`` for ``pag`` / ``digraph`` or any nonconforming / degenerate store (a bidirected
pair, a circle, an undirected edge under ``dag``, an asymmetric half-edge, a non-canonical or
``NULL``-bearing CSR) so ``validate()`` still flags it. The public views reconstruct the
symmetric matrix via ``_expand_compact``.
"""
if kind not in ("dag", "cpdag"):
return None
import scipy.sparse
n_int = int(n)
indptr = np.asarray(indptr, dtype=np.int64)
indices = np.asarray(indices, dtype=np.int32)
marks = np.asarray(marks, dtype=np.int8)
# Only compact a canonical, non-degenerate store; anything else stays symmetric so
# ``validate()`` reports it exactly as before (no silent canonicalization, no dropped NULL
# half-edge). Stays ``O(nnz)`` -- never materializes a dense ``(n, n)`` matrix, so a large
# sparse graph loads cheaply.
if (marks == NULL).any() or (marks == CIRCLE).any():
return None
rows = np.repeat(np.arange(n_int, dtype=np.int64), np.diff(indptr))
cols = indices.astype(np.int64)
same_row = rows[1:] == rows[:-1]
if bool(np.any(same_row & (np.diff(cols) <= 0))):
return None # unsorted or duplicate columns within a row (non-canonical CSR)
sp = scipy.sparse.csr_array((marks, indices, indptr), shape=(n_int, n_int))
spT = sp.T.tocsr()
spT.sort_indices()
if not (np.array_equal(sp.indptr, spT.indptr) and np.array_equal(sp.indices, spT.indices)):
return None # asymmetric support -> a half-edge; leave it for validate()
partner = spT.data.astype(np.int8) # partner[k] = mark at (cols[k], rows[k])
off = rows != cols
upper = off & (rows < cols)
mi, mj = marks[upper], partner[upper]
fwd = (mi == TAIL) & (mj == ARROW) # r -> c
bwd = (mi == ARROW) & (mj == TAIL) # c -> r
und = (mi == TAIL) & (mj == TAIL) # r -- c (cpdag only)
ok = fwd | bwd | (und if kind == "cpdag" else np.zeros_like(und))
if not bool(ok.all()):
return None # a bidirected pair, or an undirected edge under dag
r_up, c_up = rows[upper], cols[upper]
er = np.concatenate([np.where(bwd, c_up, r_up), rows[~off]]) # + self-loops verbatim
ec = np.concatenate([np.where(bwd, r_up, c_up), cols[~off]])
em = np.concatenate([np.where(und, TAIL, ARROW).astype(np.int8), marks[~off]])
order = np.lexsort((ec, er))
er, ec, em = er[order], ec[order], em[order]
new_indptr = np.zeros(n_int + 1, dtype=np.int64)
np.add.at(new_indptr, er + 1, 1)
np.cumsum(new_indptr, out=new_indptr)
return new_indptr, ec.astype(np.int32), em.astype(np.int8)
def _expand_compact(
n: int, indptr: np.ndarray, indices: np.ndarray, marks: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Symmetric endpoint-mark CSR from the halved dag/cpdag store (inverse of ``_try_compact``)."""
if indices.size == 0:
return indptr, indices, marks
n_int = int(n)
rows = np.repeat(np.arange(n_int, dtype=np.int64), np.diff(indptr))
cols = indices.astype(np.int64)
diag = rows == cols
off = ~diag
n_off = int(off.sum())
r = np.concatenate([rows[diag], rows[off], cols[off]])
c = np.concatenate([cols[diag], cols[off], rows[off]])
m = np.concatenate(
[
marks[diag], # self-loops verbatim
np.full(n_off, TAIL, dtype=np.int8), # (r, c): source / undirected end -> TAIL
np.where(marks[off] == ARROW, ARROW, TAIL).astype(
np.int8
), # (c, r): the stored far mark
]
)
order = np.lexsort((c, r))
r, c, m = r[order], c[order], m[order]
new_indptr = np.zeros(n_int + 1, dtype=np.int64)
np.add.at(new_indptr, r + 1, 1)
np.cumsum(new_indptr, out=new_indptr)
# Freeze so a compact-layout ``to_scipy_sparse`` hands scipy read-only buffers, identical to the
# symmetric layout (which aliases the frozen store).
return _frozen(new_indptr, np.int64), _frozen(c, np.int32), _frozen(m, np.int8)
def _directed_support(m: np.ndarray) -> np.ndarray:
"""Boolean ``(n, n)`` directed support of a mark matrix: ``D[i, j]`` is the edge ``i -> j``.
A plain directed edge (arrowhead at ``j``) sets ``D[i, j]``; a 2-cycle / bidirected pair sets
both directions; an autoregressive self-loop (``m[i, i] == ARROW``) sets ``D[i, i]``. Undirected
and circle marks carry no direction and are ignored. Shared with ``metrics`` so the topological
collapse cannot drift between the summary-graph projection and temporal scoring.
"""
directed = (m == TAIL) & (m.T == ARROW)
both_arrow = (m == ARROW) & (m.T == ARROW)
return directed | both_arrow
def _infer_kind(M: np.ndarray) -> Kind:
"""Infer the mark domain from a dense endpoint matrix; used when ``kind`` is not given."""
if (M == CIRCLE).any():
return "pag"
off_diag = np.arange(M.shape[0])[:, None] != np.arange(M.shape[0])
both_arrow = (M == ARROW) & (M.T == ARROW) & off_diag # off-diagonal: a self-loop is not pag
if both_arrow.any():
return "pag"
both_tail = (M == TAIL) & (M.T == TAIL) & off_diag
if both_tail.any():
return "cpdag"
return "dag"