# substrate_generator/__init__.py
"""High-level interface for creating systems by specifying architecture."""
import string
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Mapping
from collections.abc import Sequence
from functools import partial
from typing import Any
import numpy as np
from numpy.typing import NDArray
from pyphi.labels import NodeLabels
from pyphi.substrate import Substrate
from pyphi.utils import all_states
from . import ising
from . import unit_functions
from .mechanism_combinations import MECHANISM_COMBINATIONS
from .mechanism_combinations import composite
from .mechanisms import MECHANISMS
from .mechanisms import STATE_DEPENDENT
from .mechanisms import WEIGHTED
UNIT_FUNCTIONS = {
"ising": ising.probability,
"boolean": unit_functions.boolean_function,
"gaussian": unit_functions.gaussian,
"naka_rushton": unit_functions.naka_rushton,
"or": unit_functions.logical_or_function,
"and": unit_functions.logical_and_function,
"parity": unit_functions.logical_parity_function,
"nor": unit_functions.logical_nor_function,
"nand": unit_functions.logical_nand_function,
"nparity": unit_functions.logical_nparity_function,
}
# Register the ported substrate_modeler mechanisms under any name not already
# bound by the weighted-threshold logical gates above (so ``"and"``/``"or"``
# keep their existing meaning in :func:`build_substrate`).
UNIT_FUNCTIONS.update(
{name: func for name, func in MECHANISMS.items() if name not in UNIT_FUNCTIONS}
)
__all__ = [
"MECHANISMS",
"MECHANISM_COMBINATIONS",
"UNIT_FUNCTIONS",
"build_substrate",
"build_tpm",
"composite",
"create_substrate",
]
[docs]
def build_tpm(
unit_functions: str | Callable | Iterable[str | Callable],
weights: NDArray[Any],
**kwargs,
):
"""Build a binary state-by-node TPM from unit functions and a weight matrix.
Each unit function is evaluated at every one of the ``2**N`` substrate
states, returning the probability that its node is ON at the next step.
Parameters
----------
unit_functions : str or Callable or Iterable[str or Callable]
A single unit function (a name in :data:`UNIT_FUNCTIONS` or a callable
with signature ``f(element, weights, state, **kwargs) -> float``)
applied to every node, or an iterable of ``N`` such functions, one per
node.
weights : numpy.ndarray
Square ``N × N`` connection weight matrix; ``weights[i, j]`` couples
input ``i`` to node ``j``.
Other Parameters
----------------
**kwargs
Passed through to every unit function.
Returns
-------
numpy.ndarray
A state-by-node TPM of shape ``(2,) * N + (N,)``; entry
``tpm[state + (j,)]`` is the probability that node ``j`` is ON given
``state``.
Raises
------
ValueError
If ``weights`` is not square, or if an iterable of unit functions does
not have one entry per node.
"""
if weights.ndim != 2 or weights.shape[0] != weights.shape[1]:
raise ValueError("weights must be a square matrix")
N = weights.shape[0]
# Normalize unit_functions to a list
if isinstance(unit_functions, str):
# Single function name string - use for all nodes
unit_functions_list: list[str | Callable] = [unit_functions] * N
elif callable(unit_functions):
# Single function - use for all nodes
unit_functions_list = [unit_functions] * N
else:
# Iterable of functions
unit_functions_list = list(unit_functions)
if len(unit_functions_list) != weights.shape[0]:
raise ValueError(
"Number of unit functions must match number of nodes in weight matrix"
)
tpm = np.zeros([2] * N + [N])
for state in all_states(N):
for element, func in enumerate(unit_functions_list):
if isinstance(func, str):
unit_func = UNIT_FUNCTIONS[func]
else:
unit_func = func
tpm[(*state, element)] = unit_func(element, weights, state, **kwargs)
return tpm
[docs]
def build_substrate(
unit_functions: str | Callable | Iterable[str | Callable],
weights: NDArray[Any],
node_labels: NodeLabels | None = None,
**kwargs,
):
"""Build a PyPhi substrate from a weight matrix and unit functions.
The connectivity matrix is derived from the nonzero entries of ``weights``.
Parameters
----------
unit_functions : str or Callable or Iterable[str or Callable]
A single unit function applied to every node, or an iterable of one
function per node. Each function has signature
``f(element, weights, state, **kwargs) -> float`` returning a
probability; a string names an entry in :data:`UNIT_FUNCTIONS`.
weights : numpy.ndarray
Square weight matrix describing the substrate's connectivity.
node_labels : NodeLabels, optional
Labels for the nodes. Defaults to ``A, B, C, ...``.
Other Parameters
----------------
**kwargs
Passed through to every unit function.
Returns
-------
Substrate
A PyPhi substrate whose TPM is built by :func:`build_tpm` and whose
connectivity matrix marks the nonzero entries of ``weights``.
"""
if node_labels is None:
# Create default labels from uppercase letters
N = weights.shape[0]
node_labels = NodeLabels(string.ascii_uppercase[:N], range(N))
tpm = build_tpm(unit_functions, weights, **kwargs)
cm = (weights != 0).astype(int)
return Substrate(tpm, cm=cm, node_labels=node_labels)
def _sub_specs(spec: Mapping[str, Any]):
"""Yield the simple sub-specs of a node spec (the spec itself if not composite)."""
if "composite" in spec:
yield from spec["composite"]
else:
yield spec
def _connectivity_indices(spec: Mapping[str, Any]):
"""Yield every input index of a node spec (for connectivity markers)."""
for sub in _sub_specs(spec):
for i in sub["inputs"]:
yield int(i)
def _real_weight_pairs(spec: Mapping[str, Any]):
"""Yield ``(input_index, weight)`` for the *weighted* sub-mechanisms only.
Input weights come from ``params['input_weights']`` (or ``params['weights']``),
aligned to ``inputs`` in order; further inputs (e.g. modulators) are left as
connectivity markers.
"""
for sub in _sub_specs(spec):
if sub["mechanism"] not in WEIGHTED:
continue
params = sub.get("params", {})
input_weights = params.get("input_weights", params.get("weights"))
if input_weights is None:
continue
for i, w in zip(sub["inputs"], input_weights, strict=False):
yield int(i), float(w)
def _is_state_dependent(spec: Mapping[str, Any]) -> bool:
if "composite" in spec:
return any(sub["mechanism"] in STATE_DEPENDENT for sub in spec["composite"])
return spec.get("mechanism") in STATE_DEPENDENT
def _node_function(spec: Mapping[str, Any]) -> Callable:
"""Resolve a node spec to a bound unit function ``f(element, weights, state)``."""
if "composite" in spec:
return composite(
spec["composite"],
spec.get("mechanism_combination", "selective"),
**spec.get("combination_params", {}),
)
base = MECHANISMS[spec["mechanism"]]
return partial(base, inputs=tuple(spec["inputs"]), **spec.get("params", {}))
[docs]
def create_substrate(
node_params: Mapping[int, Mapping[str, Any]] | Sequence[Mapping[str, Any]],
labels: Sequence[str] | NodeLabels | None = None,
) -> Substrate:
"""Build a :class:`~pyphi.substrate.Substrate` from per-node specifications.
Each node names a ``mechanism`` (a key in :data:`MECHANISMS`), its
``inputs``, and a ``params`` dict. The resulting substrate TPM matches the
``dynamic_tpm`` of the ``substrate_modeler`` library, in which the present
state equals the past state.
Parameters
----------
node_params : Mapping[int, Mapping] or Sequence[Mapping]
Either an integer-keyed mapping (nodes taken in sorted key order) or an
ordered iterable of node specs. Each spec is a dict with keys:
- ``"mechanism"`` (str): mechanism name, **or**
- ``"composite"`` (list of sub-specs) plus optional
``"mechanism_combination"`` (str, default ``"selective"``) and
``"combination_params"`` (dict);
- ``"inputs"`` (tuple[int]): the unit's input indices;
- ``"params"`` (dict): mechanism parameters (e.g. ``input_weights``,
``determinism``, ``threshold``, ``weight_scale_mapping``).
labels : Sequence[str] or NodeLabels, optional
Node labels. Defaults to ``A, B, C, ...``.
Returns
-------
Substrate
A PyPhi substrate.
Raises
------
TypeError
If ``node_params`` is a mapping whose keys are not all integers.
Notes
-----
The connectivity matrix is assembled in two passes: every input edge is
first marked with weight 1, then the real edge weights of weighted
mechanisms (:data:`~pyphi.substrate_generator.mechanisms.WEIGHTED`) overwrite
those markers. State-dependent mechanisms
(:data:`~pyphi.substrate_generator.mechanisms.STATE_DEPENDENT`) read the
unit's own state, so a self-loop is inserted into the connectivity matrix
when the spec does not already include one.
"""
if isinstance(node_params, Mapping):
if not all(isinstance(k, int) for k in node_params):
raise TypeError(
"node_params mapping must be keyed by integer node index; "
"pass an ordered list otherwise"
)
specs = [node_params[k] for k in sorted(node_params)]
else:
specs = list(node_params)
n = len(specs)
weights = np.zeros((n, n))
functions: list[Callable] = []
for j, spec in enumerate(specs):
# Pass 1: connectivity markers for every input edge.
for i in _connectivity_indices(spec):
weights[i, j] = 1.0
# Pass 2: real edge weights override markers for weighted mechanisms.
for i, w in _real_weight_pairs(spec):
weights[i, j] = w
# Self-loop so a state-dependent unit's own-state dependence is in the CM.
if _is_state_dependent(spec) and weights[j, j] == 0:
weights[j, j] = 1.0
functions.append(_node_function(spec))
if labels is None:
labels = NodeLabels(string.ascii_uppercase[:n], range(n))
elif not isinstance(labels, NodeLabels):
labels = NodeLabels(tuple(labels), range(n))
return build_substrate(functions, weights, node_labels=labels)
[docs]
def random_substrate(
n: int,
*,
seed: int,
epsilon: float = 0.0,
cm: NDArray[np.int_] | None = None,
) -> Substrate:
"""A seeded random binary substrate of ``n`` units.
Each unit's ON probability, for every previous state, is drawn
independently and uniformly from ``(epsilon, 1 − epsilon)`` using an
isolated generator seeded with ``seed``, so the substrate is exactly
reproducible from ``(n, seed, epsilon)``.
Parameters
----------
n : int
Number of binary units.
seed : int
Seed for the isolated random generator.
epsilon : float, optional
Margin keeping probabilities away from 0 and 1 (deterministic
entries); ``0.0`` (the default) permits near-deterministic rows.
cm : numpy.ndarray, optional
Connectivity matrix. If ``None``, connectivity is inferred from
the sampled TPM (generically fully connected).
Returns
-------
Substrate
The sampled substrate.
"""
rng = np.random.default_rng(seed)
tpm = rng.uniform(epsilon, 1.0 - epsilon, size=(2**n, n))
return Substrate(tpm, cm=cm)