Source code for pyphi.substrate

# substrate.py
"""Represents the substrate of interest.

This is the primary object of PyPhi and the context of all φ and Φ
computation.
"""

from __future__ import annotations

import hashlib
import math
from collections.abc import Iterable
from collections.abc import Mapping
from collections.abc import Sequence
from functools import cached_property
from typing import Any

import numpy as np
from numpy.typing import ArrayLike
from numpy.typing import NDArray

from . import connectivity
from . import convert
from . import utils
from . import validate
from .cache.content import ContentCache
from .condensation import Candidate
from .condensation import _config_iit_version
from .condensation import _sia_node_indices
from .condensation import exclusion_cascade
from .condensation import exclusion_records
from .condensation import iit3_exclusion_cascade
from .core.tpm import _display
from .core.tpm.factored import FactoredTPM
from .core.tpm.factored import StateSpace
from .core.tpm.joint import JointTPM
from .direction import Direction
from .display import HIGH
from .display import LOW
from .display import Description
from .display import Displayable
from .display import Row
from .display import Section
from .labels import NodeLabels
from .models.pandas import ToPandasMixin
from .serializable import Serializable
from .types import ConnectivityMatrix
from .types import Mechanism
from .types import NodeIndices
from .types import Purview

# Module-level cache for potential purviews, keyed on the substrate connectivity
# fingerprint. Shared across every substrate with the same ``cm`` (a single
# registered instance, not per-Substrate, so no registry leak).
_PURVIEW_CACHE = ContentCache("substrate.potential_purviews")


def _coerce_state_to_indices(
    state: tuple[Any, ...],
    state_space: tuple[tuple[Any, ...], ...],
) -> tuple[int, ...]:
    """Convert a state tuple to integer indices via ``state_space`` lookup.

    Each element of ``state`` is either an integer index into the
    corresponding alphabet or a label present in ``state_space[i]``.
    Returns a tuple of integer indices in the same order.

    Parameters
    ----------
    state : tuple
        The state to coerce — may mix integer indices and labels.
    state_space : tuple[tuple, ...]
        Per-node label tuples from :attr:`Substrate.state_space`.

    Returns
    -------
    tuple[int, ...]
        Integer-indexed state.

    Raises
    ------
    ValueError
        If ``len(state) != len(state_space)`` or any element is neither a
        valid label nor a valid index.
    """
    if len(state) != len(state_space):
        raise ValueError(
            f"state length {len(state)} != state_space length {len(state_space)}"
        )
    indices: list[int] = []
    for i, (s, labels) in enumerate(zip(state, state_space, strict=True)):
        if s in labels:
            indices.append(labels.index(s))
        elif isinstance(s, (int, np.integer)) and 0 <= s < len(labels):
            indices.append(int(s))
        else:
            raise ValueError(
                f"state[{i}] = {s!r} is not in state_space[{i}] = {labels!r} "
                f"and is not a valid index for alphabet size {len(labels)}"
            )
    return tuple(indices)


def _has_multivalued_unit(state_space: StateSpace) -> bool:
    """Whether ``state_space`` (any accepted spelling) gives a unit more than
    two states. ``None`` means binary."""
    if state_space is None:
        return False
    if isinstance(state_space, int):
        return state_space != 2
    if state_space and all(not isinstance(labels, tuple) for labels in state_space):
        return len(state_space) != 2  # one flat alphabet applied to every unit
    return any(len(labels) != 2 for labels in state_space)


[docs] class Substrate(Displayable, ToPandasMixin, Serializable): """A substrate of nodes. Represents the substrate under analysis and holds auxiliary data about it. The TPM is stored canonically as a :class:`~pyphi.core.tpm.factored.FactoredTPM` (per-node-factored conditional). ``substrate.tpm`` returns this ``FactoredTPM`` directly. The joint conditional ndarray is available on demand via :meth:`joint_tpm`. Two mutually exclusive forms of TPM input are accepted — exactly one must be supplied: * **Joint form** (``tpm=``): a standard joint conditional array. Accepted shapes are 2-D state-by-node ``(s, n)``, 2-D state-by-state ``(s, s)``, or multidimensional state-by-node ``[2]*n + [n]``. Row indices follow the little-endian convention (see :ref:`little-endian-convention`). Passing a :class:`~pyphi.core.tpm.factored.FactoredTPM` via ``tpm=`` raises ``ValueError``; use ``marginals=`` or :meth:`from_factored` instead. * **Factored form** (``marginals=``): a sequence of per-node conditional arrays, one per node. Each factor has shape ``(*alphabet_sizes, alphabet_size_i)``. Parameters ---------- tpm : numpy.ndarray The joint transition probability matrix of the substrate (joint form only — see above). cm : numpy.ndarray, optional A square binary adjacency matrix indicating the connections between nodes in the substrate. ``cm[i][j] == 1`` means that node ``i`` is connected to node ``j`` (see :ref:`cm-conventions`). If no connectivity matrix is given, PyPhi assumes that every node is connected to every node (including itself). node_labels : tuple[str] or :class:`~pyphi.labels.NodeLabels`, optional Human-readable labels for each node in the substrate. marginals : sequence of numpy.ndarray, optional Per-node conditional arrays (factored form). Mutually exclusive with ``tpm``. state_space : optional The state space for the substrate nodes. Accepts a uniform-flat integer alphabet size, a tuple of per-node label tuples ``((labels_0, ...), (labels_1, ...), ...)``, or a single flat tuple of labels applied uniformly to every node. When ``None``, defaults to binary ``(0, 1)`` per node. alphabet : int, optional Shortcut for a uniform integer alphabet of the given size — equivalent to ``state_space=tuple(range(alphabet))``. Mutually exclusive with ``state_space``. See Also -------- from_factored : Build a ``Substrate`` directly from an existing :class:`~pyphi.core.tpm.factored.FactoredTPM`. Examples -------- In a 3-node binary substrate, ``the_substrate.joint_tpm()[(0, 0, 1)]`` gives, for each node at *t*, the per-alphabet-value distribution given that the state at *t* − 1 was N₀ = 0, N₁ = 0, N₂ = 1; e.g. ``the_substrate.joint_tpm()[(0, 0, 1)][i, 1]`` is the probability that node ``i`` at *t* takes value 1. """ def __init__( self, tpm: NDArray[np.float64] | dict[str, Any] | None = None, cm: ArrayLike | None = None, node_labels: Sequence[str] | NodeLabels | None = None, *, marginals: Sequence[ArrayLike] | None = None, state_space: StateSpace = None, alphabet: int | None = None, ) -> None: if tpm is not None and marginals is not None: raise ValueError("Pass tpm= or marginals=, not both") if tpm is None and marginals is None: raise ValueError("Must pass tpm= (joint) or marginals= (factored)") if alphabet is not None and state_space is not None: raise ValueError("Pass alphabet= or state_space=, not both") if tpm is not None and isinstance(tpm, FactoredTPM): raise ValueError( "pass FactoredTPM instances via marginals=... or use " "Substrate.from_factored(...), not tpm=" ) # Translate alphabet= shortcut to a uniform integer state_space. if alphabet is not None: if alphabet < 2: raise ValueError(f"alphabet must be >= 2; got {alphabet}") state_space = tuple(range(alphabet)) # type: ignore[assignment] if marginals is not None: self._factored_tpm = FactoredTPM(factors=marginals, state_space=state_space) else: arr = self._coerce_joint_array(tpm, state_space=state_space) self._factored_tpm = FactoredTPM.from_joint(arr, state_space=state_space) self._cm, self._cm_hash = self._build_cm(cm) self._node_indices = tuple(range(self.size)) self._node_labels = NodeLabels(node_labels, self._node_indices) # Attach display labels to the canonical TPM so its repr shows node names # (the bare TPM otherwise has no knowledge of substrate node names). self._factored_tpm._node_labels = tuple(self._node_labels) validate.substrate(self) @staticmethod def _coerce_joint_array( tpm: NDArray[np.float64] | dict[str, Any] | Any, state_space: StateSpace = None, ) -> NDArray[np.float64]: """Coerce supported ``tpm=`` argument forms to a joint ndarray. Accepts binary 2-D state-by-node, 2-D state-by-state, and multidimensional state-by-node arrays, normalizing them to multidimensional state-by-node form so callers don't have to pre-reshape. Also accepts the explicit-alphabet shape ``(*alphabet_sizes, n_nodes, max_alphabet)`` produced by :meth:`joint_tpm`, which is forwarded as-is to ``FactoredTPM.from_joint``. Probability validation happens downstream in ``FactoredTPM``. """ if isinstance(tpm, dict): key = "_tpm" if "_tpm" in tpm else "tpm" data: Any = tpm[key] elif hasattr(tpm, "to_array"): data = tpm.to_array() # type: ignore[attr-defined] else: data = tpm arr = np.asarray(data, dtype=np.float64) # The 2-D forms describe binary units only; multi-valued units need # the factored form or the explicit-alphabet joint array. if arr.ndim == 2 and _has_multivalued_unit(state_space): raise ValueError( "a 2-D TPM (state-by-node or state-by-state) describes binary " "units, but state_space/alphabet gives a unit more than two " "states. For multi-valued units pass the factored form, " "marginals=[...] with one array per unit of shape " "(*alphabet_sizes, k_i), or the explicit-alphabet joint array " "of shape (*alphabet_sizes, n_units, max_alphabet)." ) # Explicit-alphabet shape: (*alphabet_sizes, n_nodes, max_alphabet). # Detected when ndim == n + 2 with a leading per-axis alphabet block. # For binary substrates that's (2, ..., 2, n, 2) where ndim == n + 2 # and the second-to-last axis equals n. if arr.ndim >= 3: n_candidate = int(arr.shape[-2]) if ( arr.ndim == n_candidate + 2 and all(s >= 2 for s in arr.shape[:n_candidate]) and arr.shape[-1] == max(arr.shape[:n_candidate]) ): return arr # Otherwise normalize 2-D state-by-node / state-by-state / binary # multidimensional forms to multidimensional state-by-node. A square # 2-D array is state-by-state; everything else reshapes directly (the # reshape is idempotent for already-multidimensional input). # # A non-square 2-D array can only be a binary state-by-node TPM, which # needs one row per input state: 2**n rows for n columns. Anything # else (e.g. 8 rows for 2 columns) would be silently reshaped into a # TPM over the wrong number of nodes. if arr.ndim == 2 and arr.shape[0] != arr.shape[1]: n = int(arr.shape[1]) if arr.shape[0] != 2**n: raise ValueError( f"cannot interpret a TPM with shape {arr.shape} as a 2-D " f"state-by-node TPM: expected 2**{n} = {2**n} rows for " f"{n} (binary) nodes" ) if arr.ndim == 2 and arr.shape[0] == arr.shape[1]: # State-by-state → state-by-node silently drops any conditional # dependence between nodes; reject dependent TPMs unless opted out. from pyphi import exceptions from pyphi.conf import config as _config n_states = int(arr.shape[0]) if n_states < 2 or n_states & (n_states - 1): raise ValueError( f"a state-by-state TPM with {n_states} states cannot describe " "binary units, which need 2**n states for n units. For " "multi-valued units pass the factored form, marginals=[...] " "with one array per unit of shape (*alphabet_sizes, k_i), " "together with state_space= or alphabet=." ) sbn = convert.state_by_state2state_by_node(arr) check_independence = _config.infrastructure.validate_conditional_independence tol = max(10 ** (-_config.numerics.precision), 1e-15) if check_independence and not np.allclose( arr - convert.state_by_node2state_by_state(sbn), 0.0, atol=tol, rtol=0 ): raise exceptions.ConditionallyDependentError( "TPM is not conditionally independent.\n" "See the conditional independence example in the " "documentation for more info." ) return sbn.astype(np.float64) try: return convert.to_multidimensional(arr) except ValueError as error: raise ValueError( f"cannot interpret a TPM with shape {arr.shape}: accepted " "forms are multidimensional state-by-node (2, ..., 2, n), " "2-D state-by-node (2**n, n), square state-by-state " "(S, S), or per-node marginals with an explicit state space" ) from error @property def tpm(self) -> FactoredTPM: """The per-node-factored conditional TPM of the substrate.""" return self._factored_tpm @property def factored_tpm(self) -> FactoredTPM: """Alias for :attr:`tpm` — explicit per-node-factored access.""" return self._factored_tpm @property def state_space(self) -> tuple[tuple[Any, ...], ...]: """Per-node label tuples, delegated from the underlying FactoredTPM.""" return self._factored_tpm.state_space
[docs] def joint_tpm(self) -> JointTPM: """The joint conditional TPM as a read-only :class:`JointTPM` view. The joint peer of :attr:`tpm` (the factored form). Materializes ``P(sₜ₊₁ | sₜ)`` from the factored storage in the explicit-alphabet layout ``[a_1, ..., a_N, N, max_alphabet]`` for both binary and k-ary substrates: per row, axis ``-1`` holds factor ``i``'s distribution in slots ``[:alphabet_sizes[i]]``, with trailing slots zero when alphabets are heterogeneous. The returned view is array-convertible (``numpy.asarray``) and indexable. Recomputes on every call (no cache); callers needing it repeatedly should cache locally. """ return JointTPM(self._factored_tpm.to_joint(), node_labels=self._node_labels)
[docs] @classmethod def from_factored( cls, factored: FactoredTPM, cm: ArrayLike | None = None, node_labels: Sequence[str] | NodeLabels | None = None, ) -> Substrate: """Construct a Substrate from an existing FactoredTPM.""" s = cls.__new__(cls) s._factored_tpm = factored s._cm, s._cm_hash = s._build_cm(cm) s._node_indices = tuple(range(s.size)) s._node_labels = NodeLabels(node_labels, s._node_indices) validate.substrate(s) return s
@property def cm(self) -> ConnectivityMatrix: """np.ndarray: The substrate's connectivity matrix. A square binary adjacency matrix indicating the connections between nodes in the substrate. """ return self._cm @cached_property def _cm_fingerprint(self) -> bytes: """blake2b-256 digest of the connectivity matrix (label-free). The exact dependency of :meth:`potential_purviews`, which reads only ``cm``; substrates that share a topology but differ in TPM weights share this fingerprint and so share their potential-purview results. """ cm = np.ascontiguousarray(self._cm).astype(np.int8, copy=False) h = hashlib.blake2b(digest_size=32) h.update(repr(cm.shape).encode()) h.update(cm.tobytes()) return h.digest() @cached_property def _fingerprint(self) -> bytes: """blake2b-256 digest of the full label-free substrate math identity. Covers exactly what :meth:`__eq__` compares: alphabet sizes, the factor array bytes (``+ 0.0`` folds ``-0.0`` like ``FactoredTPM.__hash__``), and the connectivity. Excludes ``node_labels`` / ``state_space`` labels. """ ftpm = self._factored_tpm h = hashlib.blake2b(digest_size=32) h.update(repr(ftpm.alphabet_sizes).encode()) for i in range(ftpm.n_nodes): factor = ftpm.factor(i) # A factor's size-1 axes encode which units the node depends on; # tobytes() alone is shape-free and would collide substrates with # identical flat values but different dependence structure. h.update(repr(factor.shape).encode()) h.update((factor + 0.0).tobytes()) h.update(self._cm_fingerprint) return h.digest() def _build_cm(self, cm: ArrayLike | None) -> tuple[ConnectivityMatrix, int]: """Convert the passed CM to the proper format, or construct the unitary CM if none was provided. """ cm_array: ConnectivityMatrix if cm is None: # Assume all are connected. cm_array = np.ones((self.size, self.size), dtype=int) else: cm_array = np.array(cm, dtype=int) utils.np_immutable(cm_array) return (cm_array, utils.np_hash(cm_array)) @property def connectivity_matrix(self) -> ConnectivityMatrix: """np.ndarray: Alias for ``cm``.""" return self._cm @property def causally_significant_nodes(self) -> NodeIndices: """See :func:`pyphi.connectivity.causally_significant_nodes`.""" return connectivity.causally_significant_nodes(self.cm) @property def size(self) -> int: """int: The number of nodes in the substrate.""" return len(self) @property def num_states(self) -> int: """int: The number of possible states of the substrate.""" return math.prod(self._factored_tpm.alphabet_sizes) @property def node_indices(self) -> NodeIndices: """tuple[int]: The indices of nodes in the substrate. This is equivalent to ``tuple(range(substrate.size))``. """ return self._node_indices @property def node_labels(self) -> NodeLabels: """tuple[str]: The labels of nodes in the substrate.""" return self._node_labels
[docs] def potential_purviews( self, direction: Direction, mechanism: Mechanism, max_order: int | None = None, ) -> list[Purview]: """All purviews which are not clearly reducible for a mechanism. Depends only on connectivity, so the result is cached on ``_cm_fingerprint`` and shared across every substrate with the same ``cm`` (a parameter sweep over a fixed topology reuses it). The cache key includes ``max_order``, so bounded and unbounded results never alias. Parameters ---------- direction : Direction ``CAUSE`` or ``EFFECT``. mechanism : tuple[int, ...] The mechanism which all purviews are checked for reducibility over. max_order : int, optional Enumerate only purviews of at most this many units. Since reducibility is checked per purview, the result equals the unbounded result filtered to the cap — but the enumeration never constructs the larger candidates, which matters on large substrates. If None, all orders are enumerated. Returns ------- list[tuple[int, ...]] All purviews which are irreducible over ``mechanism``. """ from pyphi.conf import config as _config def compute() -> list[Purview]: all_purviews = utils.powerset(self._node_indices, max_size=max_order) return irreducible_purviews(self.cm, direction, mechanism, all_purviews) fp = self._cm_fingerprint _PURVIEW_CACHE.observe(self, fp) return _PURVIEW_CACHE.get_or_compute( fp, (direction, mechanism, max_order), compute, store=_config.infrastructure.cache_potential_purviews, )
# ---- substrate-level analysis ---- # # Thin convenience methods that delegate to the formalism-agnostic # module-level functions defined below. ``sia`` and ``ces`` construct # a :class:`pyphi.system.System` over the requested node subset; the # remaining methods (``all_sias``, ``irreducible_sias``, ``complexes``, # ``maximal_complex``) walk the candidate space.
[docs] def sia( self, state: tuple[int, ...], indices: NodeIndices | None = None, **kwargs: Any, ) -> Any: """Return the SIA of a single candidate system over this substrate.""" from pyphi.system import System return System.from_substrate( self, state, indices if indices is not None else self.node_indices ).sia(**kwargs)
[docs] def ces( self, state: tuple[int, ...], indices: NodeIndices | None = None, **kwargs: Any, ) -> Any: """Return the cause-effect structure of a single candidate system.""" from pyphi.system import System return System.from_substrate( self, state, indices if indices is not None else self.node_indices ).ces(**kwargs)
[docs] def all_sias( self, state: tuple[int, ...], candidates: Iterable[Any] | None = None, **kwargs: Any, ) -> list[Any]: """Return SIAs for every candidate system; see :func:`all_sias`.""" return all_sias(self, state, candidates=candidates, **kwargs)
[docs] def irreducible_sias( self, state: tuple[int, ...], candidates: Iterable[Any] | None = None, **kwargs: Any, ) -> list[Any]: """Return SIAs with φₛ > 0; see :func:`irreducible_sias`.""" return irreducible_sias(self, state, candidates=candidates, **kwargs)
[docs] def complexes( self, state: tuple[int, ...], candidates: Iterable[Any] | None = None, **kwargs: Any, ) -> tuple[Any, ...]: """Return the substrate's complexes as :class:`~pyphi.models.complex.Complex` objects; see :func:`complexes`. """ return complexes(self, state, candidates=candidates, **kwargs)
[docs] def maximal_complex( self, state: tuple[int, ...], candidates: Iterable[Any] | None = None, **kwargs: Any, ) -> Any: """Return the maximal :class:`~pyphi.models.complex.Complex`; see :func:`maximal_complex`.""" return maximal_complex(self, state, candidates=candidates, **kwargs)
def __len__(self) -> int: """int: The number of nodes in the substrate.""" return self.tpm.shape[-1] def _to_pandas(self): return self.factored_tpm.to_pandas() def _describe(self, verbosity: int) -> Description: compact = f"Substrate({self.tpm._compact_repr()}, cm={self.cm})" if verbosity == LOW: return Description(title="Substrate", compact=compact) labels = [str(label) for label in self.node_labels] alphabet = self.tpm.alphabet_sizes state_space = ( "binary" if all(a == 2 for a in alphabet) else f"alphabets {tuple(alphabet)}" ) sections = [ Section( rows=( Row("Units", ", ".join(labels)), Row("State space", state_space), ) ), Section( label="Connectivity", body=(_display.connectivity_grid(labels, self.cm),), ), ] if verbosity >= HIGH: # the embedded TPM grid is the heavy part sections.append(Section(label="TPM", body=self.tpm.grid_section().body)) return Description( title="Substrate", subtitle=f"{self.size} units · {', '.join(labels)}", sections=tuple(sections), compact=compact, ) def __eq__(self, other: object) -> bool: """Return whether this substrate equals the other object. Substrates are equal if they have the same TPM and CM. """ return ( isinstance(other, Substrate) and self._factored_tpm == other._factored_tpm and np.array_equal(self.cm, other.cm) ) def __ne__(self, other: object) -> bool: return not self.__eq__(other) def __hash__(self) -> int: return hash((hash(self._factored_tpm), self._cm_hash))
[docs] def to_networkx(self, connectivity: str = "inferred") -> Any: """Return a node-labeled :class:`networkx.DiGraph` of the substrate. By default edges are the TPM-inferred causal connectivity; pass ``connectivity="declared"`` to use the declared ``cm`` verbatim. Requires the ``visualize`` extra (networkx). """ from pyphi import graph return graph.substrate_to_networkx(self, connectivity)
[docs] @classmethod def from_networkx( cls, graph: Any, tpm: Any, *, node_labels: Any = None ) -> Substrate: """Build a :class:`Substrate` from a networkx DiGraph topology and a TPM. The graph supplies connectivity and node order; ``tpm`` supplies the dynamics (required). A graph that omits an edge the TPM implies is rejected. """ from pyphi import graph as graph_module return graph_module.substrate_from_networkx(graph, tpm, node_labels=node_labels)
[docs] def to_graphml(self, path: str, connectivity: str = "inferred") -> None: """Write the substrate graph to a GraphML file (see :meth:`to_networkx`).""" from pyphi import graph graph.to_graphml(self, path, connectivity)
[docs] def to_adjacency(self, connectivity: str = "inferred") -> Any: """Return the connectivity matrix as a node-labeled ``pandas.DataFrame``.""" from pyphi import graph return graph.to_adjacency(self, connectivity)
[docs] def to_dbn(self) -> Any: """Return the substrate's 2-timeslice DBN as a ``networkx.DiGraph``. Each node ``X`` becomes ``(X, 0)`` and ``(X, 1)``; inter-slice edges run ``(parent, 0) -> (child, 1)`` over the node's inferred parents, so the graph is acyclic. ``(X, 1)`` nodes carry ``cpd`` and ``parents`` attributes. Requires the ``visualize`` extra (networkx). """ from pyphi import graph return graph.substrate_to_dbn(self)
[docs] def to_dbn_dict(self) -> dict: """Return the substrate's 2-timeslice DBN as a plain dict. Keys ``"variables"``, ``"edges"`` (inter-slice ``(parent, child)``), and ``"cpds"`` (label -> ``{"parents", "table"}``). Pure numpy; no networkx import. """ from pyphi import graph return graph.substrate_to_dbn_dict(self)
[docs] def inactivate(self, fixed: Mapping[int | str, int]) -> Substrate: """Return a copy with the given units frozen in a state. Each unit in ``fixed`` (by index or label) is conditioned into every other unit's transition factor at the given state, so it has no counterfactual states and cannot be intervened upon. Node labels are preserved. Inputs from a frozen unit become fixed biases, and its row of the connectivity matrix is cleared. Albantakis et al. (2023, Fig 7) distinguish an *inactive* unit, in its OFF state and still contributing distinctions and relations, from an *inactivated* one, whose cause-effect power is abolished (Fig 7C): the complex that contained it shrinks. Inactivation is also distinct from holding a unit as a background condition of a candidate system: a background unit is causally marginalized, held at its current state for effects and with its past states weighted by their probability given the current state for causes (2023, Eqs. 3-4); an inactivated unit has no alternative states. Parameters ---------- fixed : Mapping[int or str, int] Units (indices or labels) mapped to the state index each is frozen in. Returns ------- Substrate Raises ------ ValueError If a unit index is out of range or a state is outside the unit's alphabet. KeyError If a unit label is unknown. Examples -------- >>> from pyphi import examples >>> lesioned = examples.iit4_2023_fig7_substrate().inactivate({"E": 0}) >>> lesioned.size 5 """ frozen: dict[int, int] = {} for unit, state in fixed.items(): (index,) = self.node_labels.coerce_to_indices([unit]) alphabet = len(self.state_space[index]) if not isinstance(state, (int, np.integer)) or not 0 <= state < alphabet: raise ValueError( f"state {state!r} for unit {unit!r} is not a valid index for " f"alphabet size {alphabet}" ) frozen[index] = int(state) cm = np.array(self.cm, dtype=int) cm[list(frozen), :] = 0 return type(self).from_factored( self.factored_tpm.condition(frozen), cm=cm, node_labels=self.node_labels )
[docs] def irreducible_purviews( cm: ConnectivityMatrix, direction: Direction, mechanism: Mechanism, purviews: Iterable[Purview], ) -> list[Purview]: """Return all purviews which are irreducible for the mechanism. Parameters ---------- cm : numpy.ndarray An N × N connectivity matrix. direction : Direction ``CAUSE`` or ``EFFECT``. mechanism : tuple[int, ...] The mechanism in question. purviews : Iterable[tuple[int, ...]] The purviews to check. Returns ------- list[tuple[int, ...]] All purviews in ``purviews`` which are not reducible over ``mechanism``. Raises ------ ValueError If ``direction`` is invalid. """ def reducible(purview: Purview) -> bool: """Return ``True`` if purview is trivially reducible.""" _from, to = direction.order(mechanism, purview) return connectivity.block_reducible(cm, _from, to) # TODO: use generator? return [purview for purview in purviews if not reducible(purview)]
# ============================================================================ # Substrate-level system iteration (formalism-agnostic) # ============================================================================ # # These helpers walk the powerset of node subsets and yield System (alias # for System) instances. They don't depend on a specific formalism; # IIT 3.0's ``all_complexes`` and IIT 4.0's ``all_complexes`` both consume # them.
[docs] def reachable_systems( substrate: Substrate, indices: tuple[int, ...], state: tuple[int, ...], **kwargs: Any, ) -> Any: """A generator over all systems in a valid state.""" import contextlib from pyphi import exceptions from pyphi.system import System validate.is_substrate(substrate) # Return systems largest to smallest to optimize parallel # resource usage. for subset in utils.powerset(indices, nonempty=True, reverse=True): with contextlib.suppress(exceptions.StateUnreachableError): yield System.from_substrate(substrate, state, subset, **kwargs)
[docs] def systems(substrate: Substrate, state: tuple[int, ...], **kwargs: Any) -> Any: """Return a generator of all **possible** systems of a substrate. .. note:: Does not return systems that are in an impossible state (after conditioning the system TPM on the state of the other nodes). """ return reachable_systems(substrate, substrate.node_indices, state, **kwargs)
[docs] def possible_complexes( substrate: Substrate, state: tuple[int, ...], **kwargs: Any ) -> Any: """Return a generator of systems of a substrate that could be a complex. The powerset of nodes that have at least one input and one output. Nodes with no inputs or no outputs cannot be part of a main complex because they have no causal link with the rest of the system. """ return reachable_systems( substrate, substrate.causally_significant_nodes, state, **kwargs )
# ============================================================================ # Substrate-level analysis (formalism-agnostic) # ============================================================================ # # The per-candidate SIA computation is the only formalism-specific step; # iteration, filtering, and condensation are identical across IIT 3.0 and # IIT 4.0. These functions resolve the active formalism once at the call # site (avoiding per-subprocess config-mismatch hazards under parallel # map_reduce) and then map it over the candidate iterator. def _resolved_sia(**sia_kwargs: Any) -> tuple[Any, dict[str, Any]]: """Resolve the formalism's per-system ``sia`` callable and its kwargs. Reads the active formalism from ``config.formalism.iit.version`` and, under IIT 4.0, fills in ``system_measure`` and ``specification_measure`` from config when not supplied. Returns a ``(callable, kwargs)`` pair safe to hand to :func:`pyphi.parallel.map_reduce`. """ from pyphi.conf import config as _config kwargs = dict(sia_kwargs) if _config.formalism.iit.version == "IIT_3_0": from pyphi.formalism.iit3 import sia as _sia else: from pyphi.formalism.iit4 import sia as _sia from pyphi.measures.distribution import resolve_mechanism_measure from pyphi.measures.distribution import resolve_system_measure kwargs.setdefault( "system_measure", resolve_system_measure(_config.formalism.iit.system_phi_measure), ) kwargs.setdefault( "specification_measure", resolve_mechanism_measure(_config.formalism.iit.specification_measure), ) return _sia, kwargs
[docs] def all_sias( substrate: Substrate, state: tuple[int, ...], candidates: Iterable[Any] | None = None, parallel_kwargs: dict[str, Any] | None = None, **sia_kwargs: Any, ) -> list[Any]: """Return SIAs for every candidate system of the substrate. Includes reducible (φₛ = 0) candidates. The default candidate iterator is :func:`possible_complexes`, which skips subsets containing nodes that lack either inputs or outputs in the substrate — a mathematically safe optimization, since such candidates are not strongly connected and have φₛ = 0. """ from pyphi import conf as _conf from pyphi.conf import config as _config from pyphi.parallel import map_reduce iterable = possible_complexes(substrate, state) if candidates is None else candidates sia_fn, map_kwargs = _resolved_sia(**sia_kwargs) map_kwargs.setdefault("progress", False) pkwargs = _conf.parallel_kwargs( _config.infrastructure.parallel_complex_evaluation, **(parallel_kwargs or {}), ) # Results must follow candidate enumeration order: downstream tie # resolution (condensation tiers, is_maximal stamping) is # input-order-dependent, so worker-completion order would make the # reported major complex nondeterministic under ties. pkwargs["ordered"] = True result = map_reduce( sia_fn, iterable, total=2 ** len(substrate) - 1, map_kwargs=map_kwargs, desc="Evaluating complexes", **pkwargs, ) assert result is not None return result
[docs] def irreducible_sias( substrate: Substrate, state: tuple[int, ...], candidates: Iterable[Any] | None = None, **kwargs: Any, ) -> list[Any]: """Return candidate SIAs with φₛ > 0. These are *not* complexes — overlapping candidates may both appear in the returned list. The complexes (a subset satisfying exclusion) are obtained from :func:`complexes`. """ return list(filter(None, all_sias(substrate, state, candidates, **kwargs)))
[docs] def complexes( substrate: Substrate, state: tuple[int, ...], candidates: Iterable[Any] | None = None, **kwargs: Any, ) -> tuple[Any, ...]: """Return the complexes of the substrate in its current state. A complex is a set of units that is a local maximum of φₛ: no overlapping candidate has higher φₛ. The returned tuple is non-overlapping (exclusion), ordered by φₛ descending. The search walks SIAs in descending φₛ tiers and groups survivors into overlap cliques per tier. Each multi-candidate clique escalates to the Composition cascade (maximum Φ), and ties at Composition fail the exclusion postulate. Notes ----- For results computed under an earlier version of IIT (see :doc:`/howto/earlier-versions`): IIT 3.0 provides no paper-canonical system-level tie-break, so under it multi-candidate cliques are skipped as indeterminate and the tier walk continues to the next group. """ from pyphi import validate from pyphi.models.complex import Complex from pyphi.system import System candidate_sias = list(irreducible_sias(substrate, state, candidates, **kwargs)) if not candidate_sias: return () def _as_candidate(sia: Any) -> Candidate: indices = tuple(_sia_node_indices(sia) or ()) return Candidate( footprint=frozenset(indices), phi=float(sia.phi), sia_provider=lambda sia=sia: sia, system_provider=lambda indices=indices: System.from_substrate( substrate, state, indices ), ) cascade_candidates = [_as_candidate(sia) for sia in candidate_sias] cascade = ( iit3_exclusion_cascade if _config_iit_version() == "IIT_3_0" else exclusion_cascade ) outcome = cascade(cascade_candidates) if not outcome.accepted: return () records = exclusion_records(outcome.accepted, cascade_candidates) result = tuple( Complex( sia=cand.sia_provider(), substrate=substrate, is_maximal=(i == 0), excluded=records[tuple(sorted(cand.footprint))], ) for i, cand in enumerate(outcome.accepted) ) validate.non_overlapping(result) return result
[docs] def maximal_complex( substrate: Substrate, state: tuple[int, ...], candidates: Iterable[Any] | None = None, **kwargs: Any, ) -> Any: """Return the complex with maximum φₛ over the substrate. Equivalent to the first element of :func:`complexes`. Returns a null-object :class:`~pyphi.models.complex.Complex` (falsy, with empty units) when no irreducible candidate exists. """ from pyphi.models.complex import Complex from pyphi.system import System found = complexes(substrate, state, candidates, **kwargs) if found: return found[0] # No irreducible candidate; return a null-object Complex over the empty # system (falsy, with empty units). from pyphi.conf import config as _config empty = System.from_substrate(substrate, state, ()) if _config.formalism.iit.version == "IIT_3_0": from pyphi.formalism.iit3 import _null_sia null_sia = _null_sia(empty) else: from pyphi.formalism.iit4 import NullSystemIrreducibilityAnalysis null_sia = NullSystemIrreducibilityAnalysis() return Complex(sia=null_sia, substrate=substrate, is_maximal=True, excluded=())