"""``StructureOutput`` -- the one uniform return type of every algorithm.
A thin, immutable run envelope over a first-class ``Structure`` (``.structure``), plus the
run-level slots that hang off the run rather than the graph: the discovered ``ordering``, a sparse
``weighted_adjacency`` (its own asymmetric ``float64`` CSR), and JSON-safe ``metadata``. Every dense
view derives lazily and uncached. ``save`` / ``load`` serialize the whole envelope.
"""
from __future__ import annotations
import json
import os
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, Literal
import numpy as np
from .structure import (
SUMMARY_EDGES,
GraphStructure,
Structure,
SummaryGraph,
TemporalStructure,
_frozen,
)
if TYPE_CHECKING:
import scipy.sparse
# A serialized (time, lag) axis requires format version 1; other files retain version 0.
_FORMAT_VERSION = 0
_TIME_FORMAT_VERSION = 1
_SUPPORTED_FORMAT_VERSIONS = frozenset({_FORMAT_VERSION, _TIME_FORMAT_VERSION})
SUMMARY_WIDTH = 100 # a metadata list printing longer than this shows as its length
JSONScalar = str | bool | int | float | None
JSONValue = JSONScalar | list | dict
[docs]
@dataclass(frozen=True, eq=False, slots=True, repr=False)
class StructureOutput:
"""The result of every structure-learning method: the learned graph plus run details.
Each method returns one (``multi_group_direct_lingam`` returns one per group). ``structure``
holds the graph, a ``GraphStructure`` or ``TemporalStructure``. The other fields depend on the
method, and each method's Returns section lists them: ``ordering`` is a causal order,
``weighted_adjacency`` holds edge weights, and ``metadata`` holds values such as the algorithm
name and score. ``print(out)`` shows the graph kind, the edges, the order, and the metadata.
``save`` writes the result to a file and ``load`` reads it back. The result is read-only.
"""
_structure: Structure
_ordering: tuple[int, ...] | None = None
_metadata: Mapping[str, JSONValue] = field(default_factory=dict)
_w_indptr: np.ndarray | None = None # int64
_w_indices: np.ndarray | None = None # int32
_w_data: np.ndarray | None = None # float64
# ---- construction ---------------------------------------------------------------------
[docs]
@classmethod
def new(
cls,
structure: Structure,
*,
ordering: tuple[int, ...] | list[int] | None = None,
weighted_adjacency: np.ndarray | None = None,
metadata: Mapping[str, JSONValue] | None = None,
) -> StructureOutput:
"""Build a result from a structure and optional run details.
Use it to wrap a graph from custom code so that it prints, saves, and loads like a
method's result.
Parameters
----------
structure : Structure
The graph: a ``GraphStructure`` or ``TemporalStructure``.
ordering : tuple[int, ...] or list[int] or None, default=None
Causal order as node indices, causes first; a permutation of
``range(structure.n_nodes)``.
weighted_adjacency : np.ndarray of shape (n_nodes, n_nodes) or None, default=None
Edge weights, ``W[i, j]`` for ``i -> j``, stored sparsely. ``None`` gives no weights.
metadata : Mapping[str, JSONValue] or None, default=None
Run values by name. Keys are strings; values are ``str``, ``bool``, ``int``,
``float``, ``None``, or lists and dicts of them.
Returns
-------
StructureOutput
The result.
Raises
------
TypeError
If ``metadata`` has a non-string key or a value of another type, such as a tuple, a
set, or a NumPy array or integer.
ValueError
If ``weighted_adjacency`` is not ``(n_nodes, n_nodes)``, ``ordering`` is not a
permutation of ``range(n_nodes)``, or a graph in ``structure`` fails ``validate()``.
"""
meta = dict(metadata) if metadata is not None else {}
_check_json_safe(meta, "metadata")
w_indptr = w_indices = w_data = None
if weighted_adjacency is not None:
W = np.asarray(weighted_adjacency, dtype=np.float64)
if W.ndim != 2 or W.shape[0] != W.shape[1] or W.shape[0] != structure.n_nodes:
raise ValueError(
f"weighted_adjacency must be ({structure.n_nodes}, {structure.n_nodes}), "
f"got {W.shape}"
)
import scipy.sparse
sp = scipy.sparse.csr_array(W)
sp.sum_duplicates()
sp.sort_indices()
w_indptr = _frozen(sp.indptr, np.int64)
w_indices = _frozen(sp.indices, np.int32)
w_data = _frozen(sp.data, np.float64)
out = cls(
_structure=structure,
_ordering=tuple(int(x) for x in ordering) if ordering is not None else None,
_metadata=meta,
_w_indptr=w_indptr,
_w_indices=w_indices,
_w_data=w_data,
)
out._validate() # fail fast now, so new() never builds an envelope load() would reject
return out
# ---- surface --------------------------------------------------------------------------
@property
def structure(self) -> Structure:
"""The learned graph: a ``GraphStructure``, or a ``TemporalStructure`` for time series."""
return self._structure
@property
def ordering(self) -> tuple[int, ...] | None:
"""Causal order as node indices, causes first; ``None`` unless the method finds one.
Set by ``direct_lingam``, ``ica_lingam``, ``multi_group_direct_lingam``, and
``varma_lingam`` (the order within one time step).
"""
return self._ordering
@property
def metadata(self) -> Mapping[str, JSONValue]:
"""Method-specific values such as ``algorithm`` and ``score``, as a read-only mapping."""
return MappingProxyType(dict(self._metadata))
@property
def weighted_adjacency(self) -> np.ndarray | None:
"""Edge weights as a new dense ``(n_nodes, n_nodes)`` array, or ``None`` without weights.
``W[i, j]`` is the weight of ``i -> j``, so rows are causes: the transpose of ``B`` in
``x = Bx + e``. Set by ``calm``, ``direct_lingam``, ``ica_lingam``, and
``multi_group_direct_lingam``; a time-series result keeps its weights in
``structure.lag_weights``.
"""
if self._w_indptr is None or self._w_indices is None or self._w_data is None:
return None
n = self._structure.n_nodes
W = np.zeros((n, n), dtype=np.float64)
if self._w_indices.size:
rows = np.repeat(np.arange(n, dtype=np.intp), np.diff(self._w_indptr))
W[rows, self._w_indices] = self._w_data
return W
def __repr__(self) -> str:
"""Return the structure summary with the algorithm, causal order, and metadata."""
return "\n".join(self._summary())
def _summary(
self, *, name: str | None = None, limit: int | None = SUMMARY_EDGES, more: str = ""
) -> list[str]:
"""Build the result summary as lines for ``repr`` and ``andrey run``.
``name`` defaults to ``metadata["algorithm"]``. ``limit`` caps the listed edges, and
``more`` extends the omitted-edge count. Edge weights come from the sparse store.
"""
algorithm = self._metadata.get("algorithm")
if name is None and algorithm is not None:
name = str(algorithm)
weights = None if self._w_indptr is None else self.to_scipy_sparse()
s = self._structure
lines = s._summary(name=name, weights=weights, limit=limit, more=more)
if self._ordering is not None:
labels = s.labels
order = " -> ".join(labels[i] if labels else str(i) for i in self._ordering)
lines += ["", f" order: {order}"]
meta = [f" {k}: {_brief(k, v)}" for k, v in self._metadata.items() if k != "algorithm"]
if meta:
lines += ["", *meta]
return lines
[docs]
def to_scipy_sparse(self, *, weights: bool = True) -> scipy.sparse.csr_array:
"""Return the edge weights as a ``scipy.sparse.csr_array``, without a dense array.
Parameters
----------
weights : bool, default=True
Must be ``True``. For the graph's endpoint marks, call ``structure.to_scipy_sparse()``.
Returns
-------
scipy.sparse.csr_array of shape (n_nodes, n_nodes)
The ``weighted_adjacency`` values: entry ``[i, j]`` is the weight of ``i -> j``.
Raises
------
ValueError
If ``weights`` is ``False``, or the method gives no edge weights.
"""
if not weights:
raise ValueError("StructureOutput.to_scipy_sparse only exposes the weighted adjacency")
if self._w_indptr is None:
raise ValueError("this StructureOutput carries no edge weights")
import scipy.sparse
n = self._structure.n_nodes
return scipy.sparse.csr_array((self._w_data, self._w_indices, self._w_indptr), shape=(n, n))
# ---- serialization --------------------------------------------------------------------
[docs]
def save(self, path: str | os.PathLike, *, fmt: Literal["json", "npz"] = "json") -> None:
"""Write the result to ``path``; ``StructureOutput.load`` reads it back.
Parameters
----------
path : str or os.PathLike
Destination file. With ``fmt="npz"`` the path must end in ``.npz``.
fmt : {"json", "npz"}, default="json"
File format. ``"json"`` holds a graph or time-series result; ``"npz"``, a NumPy
archive, holds a ``GraphStructure`` result only.
Raises
------
ValueError
If ``fmt`` is neither ``"json"`` nor ``"npz"``, or ``fmt="npz"`` and ``path`` does not
end in ``.npz``.
NotImplementedError
If ``fmt="npz"`` and the structure is a ``TemporalStructure``.
TypeError
If the structure is a ``SummaryGraph``; save the ``TemporalStructure`` it came from.
Notes
-----
The JSON file is one object with sorted keys. ``format_version`` is ``0``, or ``1`` for a
panel result that keeps its occasions; ``load`` rejects any other version.
``structure_type`` is ``"graph"`` or ``"temporal"``. A graph is stored as its sparse
arrays ``indptr``, ``indices``, and ``marks``, beside ``kind``, ``n_nodes``, ``labels``,
``node_types``, ``ordering``, ``weighted``, and ``metadata``; a temporal result stores one
such graph per lag under ``lags``. The npz file holds the same arrays, with the other
fields as one JSON string, and ``load`` reads it without pickle, so loading a file never
runs code from it.
"""
doc = self._to_doc()
if fmt == "json":
with open(path, "w", encoding="utf-8") as fh:
json.dump(doc, fh, indent=2, sort_keys=True)
elif fmt == "npz":
# np.savez silently appends '.npz'; load() dispatches on the suffix, so a path without
# it would save to X.npz yet load(X) fails. Require the suffix up front.
if not os.fspath(path).endswith(".npz"):
raise ValueError("fmt='npz' requires a path ending in '.npz'")
if doc["structure_type"] != "graph":
raise NotImplementedError("npz is graph-only; use fmt='json' for temporal outputs")
arrays: dict[str, np.ndarray] = {
"indptr": np.asarray(doc["indptr"], dtype=np.int64),
"indices": np.asarray(doc["indices"], dtype=np.int32),
"marks": np.asarray(doc["marks"], dtype=np.int8),
}
weighted = doc["weighted"]
if weighted is not None:
arrays["w_indptr"] = np.asarray(weighted["indptr"], dtype=np.int64)
arrays["w_indices"] = np.asarray(weighted["indices"], dtype=np.int32)
arrays["w_data"] = np.asarray(weighted["data"], dtype=np.float64)
# Non-numeric fields ride along as one JSON string in a 0-d <U array (pickle-free).
blob = {
k: doc[k]
for k in (
"format_version",
"structure_type",
"kind",
"n_nodes",
"labels",
"node_types",
"ordering",
"metadata",
"has_weighted",
)
}
arrays["_json"] = np.array(json.dumps(blob, sort_keys=True))
np.savez(path, **arrays) # ty: ignore[invalid-argument-type] # numpy stub over-narrows savez kwargs
else:
raise ValueError(f"fmt must be 'json' or 'npz', got {fmt!r}")
[docs]
@classmethod
def load(cls, path: str | os.PathLike) -> StructureOutput:
"""Read a result written by ``save``.
After ``out.save(path)``, ``StructureOutput.load(path) == out``.
Parameters
----------
path : str or os.PathLike
File to read. A ``.npz`` suffix reads the npz format; any other suffix reads JSON.
Returns
-------
StructureOutput
The saved result.
Raises
------
FileNotFoundError
If ``path`` does not exist.
ValueError
If the file has an unsupported format version or an unknown structure type, or holds a
result that ``new`` would reject.
"""
path = os.fspath(path)
if path.endswith(".npz"):
out = cls._from_npz(path)
else:
with open(path, encoding="utf-8") as fh:
out = cls._from_doc(json.load(fh))
out._validate()
return out
def _to_doc(self) -> dict:
s = self._structure
weighted = None
if self._w_indptr is not None and self._w_indices is not None and self._w_data is not None:
weighted = {
"indptr": self._w_indptr.tolist(),
"indices": self._w_indices.tolist(),
"data": self._w_data.tolist(),
}
_check_json_safe(dict(self._metadata), "metadata")
doc: dict = {
"format_version": _FORMAT_VERSION,
"structure_type": s.type,
"n_nodes": int(s.n_nodes),
"labels": list(s.labels) if s.labels is not None else None,
"ordering": list(self._ordering) if self._ordering is not None else None,
"weighted": weighted,
"has_weighted": weighted is not None,
"metadata": dict(self._metadata),
"dtypes": {
"indptr": "int64",
"indices": "int32",
"marks": "int8",
"w_indptr": "int64",
"w_indices": "int32",
"w_data": "float64",
},
}
if s.type == "graph":
doc.update(_graph_to_doc(s)) # ty: ignore[invalid-argument-type] # s narrowed to GraphStructure by s.type
elif s.type == "temporal":
doc["lags"] = [_graph_to_doc(g) for g in s._lags] # ty: ignore[unresolved-attribute] # s is TemporalStructure here
doc["lag_indices"] = list(s._lag_indices) # ty: ignore[unresolved-attribute]
doc["lag_weights"] = (
s._lag_weights.tolist() if s._lag_weights is not None else None # ty: ignore[unresolved-attribute]
)
if s._lag_weights_ma is not None: # ty: ignore[unresolved-attribute]
doc["lag_weights_ma"] = s._lag_weights_ma.tolist() # ty: ignore[unresolved-attribute]
if s._time_indices: # ty: ignore[unresolved-attribute] # (time, lag) axis -> v1
doc["format_version"] = _TIME_FORMAT_VERSION
doc["time_indices"] = list(s._time_indices) # ty: ignore[unresolved-attribute]
doc["time_graphs"] = [
[_graph_to_doc(g) for g in row]
for row in s._time_graphs # ty: ignore[unresolved-attribute]
]
tw = s._time_weights # ty: ignore[unresolved-attribute]
# NaN (uncomputable blocks) encodes as JSON null, not the non-standard bare ``NaN``
# token; ``_structure_from_doc`` reads null back to NaN via ``np.asarray``.
if tw is None:
doc["time_weights"] = None
else:
masked = tw.astype(object)
masked[np.isnan(tw)] = None
doc["time_weights"] = masked.tolist()
else: # pragma: no cover - guarded by the type system
raise TypeError(f"cannot serialize structure type {s.type!r}")
return doc
@classmethod
def _from_doc(cls, doc: dict) -> StructureOutput:
_check_format_version(doc.get("format_version"))
structure = _structure_from_doc(doc)
weighted = doc.get("weighted")
w_indptr = w_indices = w_data = None
if weighted is not None:
w_indptr = _frozen(weighted["indptr"], np.int64)
w_indices = _frozen(weighted["indices"], np.int32)
w_data = _frozen(weighted["data"], np.float64)
ordering = doc.get("ordering")
return cls(
_structure=structure,
_ordering=tuple(int(x) for x in ordering) if ordering is not None else None,
_metadata=dict(doc.get("metadata", {})),
_w_indptr=w_indptr,
_w_indices=w_indices,
_w_data=w_data,
)
@classmethod
def _from_npz(cls, path: str) -> StructureOutput:
with np.load(path, allow_pickle=False) as npz:
blob = json.loads(str(npz["_json"]))
_check_format_version(blob.get("format_version"))
if blob["structure_type"] != "graph": # pragma: no cover - save writes only graph npz
raise NotImplementedError("npz load supports graph structures only")
labels = tuple(blob["labels"]) if blob["labels"] is not None else None
node_types = (
np.asarray(blob["node_types"], dtype=np.int8)
if blob.get("node_types") is not None
else None
)
structure = GraphStructure._from_csr(
blob["n_nodes"],
npz["indptr"],
npz["indices"],
npz["marks"],
kind=blob["kind"],
labels=labels,
node_types=node_types,
)
w_indptr = w_indices = w_data = None
if blob["has_weighted"]:
w_indptr = _frozen(npz["w_indptr"], np.int64)
w_indices = _frozen(npz["w_indices"], np.int32)
w_data = _frozen(npz["w_data"], np.float64)
ordering = blob["ordering"]
return cls(
_structure=structure,
_ordering=tuple(int(x) for x in ordering) if ordering is not None else None,
_metadata=dict(blob["metadata"]),
_w_indptr=w_indptr,
_w_indices=w_indices,
_w_data=w_data,
)
def _validate(self) -> None:
s = self._structure
if isinstance(s, GraphStructure):
s.validate()
elif isinstance(s, TemporalStructure):
for lag_graph in s._lags: # traverse the stack directly; lag(k) by-value is for callers
lag_graph.validate()
for row in s._time_graphs: # and the full (time, lag) grid when present
for g in row:
g.validate()
n = s.n_nodes
if self._ordering is not None and sorted(self._ordering) != list(range(n)):
raise ValueError("ordering must be a permutation of range(n_nodes)")
if self._w_indptr is not None and self._w_indptr.shape[0] != n + 1:
raise ValueError("weighted CSR indptr disagrees with n_nodes")
def __eq__(self, other: object) -> bool:
if not isinstance(other, StructureOutput):
return NotImplemented
if not (
self._structure == other._structure
and self._ordering == other._ordering
and dict(self._metadata) == dict(other._metadata)
):
return False
for a, b, equal_nan in (
(self._w_indptr, other._w_indptr, False),
(self._w_indices, other._w_indices, False),
(self._w_data, other._w_data, True), # float weights: NaN==NaN so a==a stays reflexive
):
if (a is None) != (b is None):
return False
if a is not None and b is not None and not np.array_equal(a, b, equal_nan=equal_nan):
return False
return True
__hash__ = None # type: ignore[assignment]
def _graph_to_doc(g: GraphStructure) -> dict:
if isinstance(g, SummaryGraph):
# The single graph-serialization choke point (top-level structure and every temporal lag
# graph, json and npz). A SummaryGraph is a derived projection: this path writes marks and
# kind but not the lag-sets, so it would degrade to a bare digraph on load. Refuse rather
# than lose data silently -- persist the source TemporalStructure, which re-derives it.
raise TypeError(
"a SummaryGraph is a derived projection and does not serialize (its lag-sets "
"would be silently dropped); save the source TemporalStructure and re-derive via "
"summary_graph(), or save the collapsed digraph explicitly with "
"GraphStructure.from_numpy(g.to_numpy(), kind='digraph', allow_self_loops=True)"
)
# Self-contained: a per-lag graph carries its OWN n_nodes/labels so temporal stacks round-trip
# even when a lag's labels differ from the envelope's. The store serializes symmetrically
# (via _sym_csr) whatever the internal layout, so the on-disk format_version stays stable.
indptr, indices, marks = g._sym_csr()
return {
"kind": g.kind,
"n_nodes": int(g.n_nodes),
"labels": list(g.labels) if g.labels is not None else None,
"indptr": indptr.tolist(),
"indices": indices.tolist(),
"marks": marks.tolist(),
"node_types": g._node_types.tolist() if g._node_types is not None else None,
}
def _graph_from_doc(doc: dict) -> GraphStructure:
labels = tuple(doc["labels"]) if doc.get("labels") is not None else None
node_types = (
np.asarray(doc["node_types"], dtype=np.int8) if doc.get("node_types") is not None else None
)
return GraphStructure._from_csr(
doc["n_nodes"],
doc["indptr"],
doc["indices"],
doc["marks"],
kind=doc["kind"],
labels=labels,
node_types=node_types,
)
def _structure_from_doc(doc: dict) -> Structure:
kind = doc["structure_type"]
if kind == "graph":
return _graph_from_doc(doc)
if kind == "temporal":
labels = tuple(doc["labels"]) if doc.get("labels") is not None else None
lags = tuple(_graph_from_doc(lag) for lag in doc["lags"])
lag_weights = (
_frozen(doc["lag_weights"], np.float64) if doc.get("lag_weights") is not None else None
)
lag_weights_ma = (
_frozen(doc["lag_weights_ma"], np.float64)
if doc.get("lag_weights_ma") is not None
else None
)
lag_indices = tuple(doc["lag_indices"]) if doc.get("lag_indices") is not None else ()
time_indices = tuple(doc["time_indices"]) if doc.get("time_indices") is not None else ()
time_graphs = (
tuple(tuple(_graph_from_doc(g) for g in row) for row in doc["time_graphs"])
if doc.get("time_graphs") is not None
else ()
)
time_weights = (
_frozen(doc["time_weights"], np.float64)
if doc.get("time_weights") is not None
else None
)
return TemporalStructure(
_n_nodes=doc["n_nodes"],
_labels=labels,
_lags=lags,
_lag_indices=lag_indices,
_lag_weights=lag_weights,
_lag_weights_ma=lag_weights_ma,
_time_graphs=time_graphs,
_time_indices=time_indices,
_time_weights=time_weights,
)
raise ValueError(f"unknown structure_type {kind!r}")
def _check_format_version(version: object) -> None:
if version not in _SUPPORTED_FORMAT_VERSIONS:
raise ValueError(
f"unsupported format_version {version!r}; this build reads "
f"{sorted(_SUPPORTED_FORMAT_VERSIONS)}"
)
def _check_json_safe(obj: object, path: str) -> None:
"""Raise unless ``obj`` is JSON-safe (str/bool/int/float/None + list/dict thereof).
Rejects numpy scalars (``np.int64``, ``np.float32``), ``np.ndarray``, ``set``, and ``tuple``
so metadata cannot silently break ``json.dump`` or round-trip lossily.
"""
if obj is None or isinstance(obj, (str, bool, int, float)):
return
if isinstance(obj, list):
for i, item in enumerate(obj):
_check_json_safe(item, f"{path}[{i}]")
return
if isinstance(obj, dict):
for key, value in obj.items():
if not isinstance(key, str):
raise TypeError(f"metadata keys must be str, got {type(key).__name__} at {path}")
_check_json_safe(value, f"{path}.{key}")
return
raise TypeError(f"non-JSON-safe metadata value of type {type(obj).__name__} at {path}")
def _brief(key: str, value: object) -> str:
"""A metadata value as summary text: a float to 4 digits, a list too long for a line by size."""
if isinstance(value, float):
return f"{value:.4g}"
text = str(value)
if isinstance(value, list) and len(text) > SUMMARY_WIDTH:
return f"a list of {len(value)}, too long to print; read metadata[{key!r}]"
return text