# substrate_generator/__init__.py
"""High-level interface for creating systems by specifying architecture."""
import difflib
import inspect
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",
]
def _check_unit_function_kwargs(
unit_functions: str | Callable | Iterable[str | Callable],
kwargs: Mapping[str, Any],
) -> None:
"""Raise if a keyword names no parameter of any of the unit functions.
Keywords given to :func:`build_tpm` are passed to every unit function, and
the unit functions accept extra keywords so that one set of keywords can
serve a mix of functions. A misspelt keyword would therefore change the
substrate silently; this check rejects it instead. A callable outside
:data:`UNIT_FUNCTIONS` that accepts ``**kwargs`` may read any keyword, so
when one is present the check is skipped.
Parameters
----------
unit_functions : str or Callable or Iterable[str or Callable]
The unit functions, as accepted by :func:`build_tpm`.
kwargs : Mapping[str, Any]
The keywords that will be passed to every unit function.
Raises
------
TypeError
If a keyword is not a named parameter of at least one unit function.
"""
if not kwargs:
return
if isinstance(unit_functions, str) or callable(unit_functions):
unit_functions = [unit_functions]
builtin = list(UNIT_FUNCTIONS.values())
names: set[str] = set()
for unit_function in unit_functions:
func = (
UNIT_FUNCTIONS.get(unit_function, unit_function)
if isinstance(unit_function, str)
else unit_function
)
try:
params = list(inspect.signature(func).parameters.values())
except (TypeError, ValueError): # not introspectable: accept anything
return
if func not in builtin and any(p.kind is p.VAR_KEYWORD for p in params):
return
names.update(
p.name for p in params[3:] if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD)
)
for key in kwargs:
if key not in names:
close = difflib.get_close_matches(key, names, n=1)
hint = f" Did you mean {close[0]!r}?" if close else ""
raise TypeError(
f"unexpected keyword argument {key!r}: no unit function takes "
f"it (they take {sorted(names)}).{hint}"
)
[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. Each keyword must be a named
parameter of at least one of the unit functions.
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.
TypeError
If a keyword argument is not a parameter of any unit function.
"""
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"
)
unknown = sorted(
{f for f in unit_functions_list if isinstance(f, str)} - UNIT_FUNCTIONS.keys()
)
if unknown:
raise ValueError(
f"unknown unit function(s) {unknown}; the names are "
f"{sorted(UNIT_FUNCTIONS)}, or pass a callable"
)
_check_unit_function_kwargs(unit_functions_list, kwargs)
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. Each keyword must be a named
parameter of at least one of the unit functions.
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']``, 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")
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
_SIMPLE_SPEC_KEYS = ("mechanism", "inputs", "params")
_COMPOSITE_SPEC_KEYS = ("composite", "mechanism_combination", "combination_params")
def _reject_unknown(names: Iterable[str], allowed: Iterable[str], what: str) -> None:
"""Raise ``TypeError`` for a name outside ``allowed``, with a close match."""
allowed = sorted(allowed)
for name in names:
if name not in allowed:
close = difflib.get_close_matches(name, allowed, n=1)
hint = f" Did you mean {close[0]!r}?" if close else ""
raise TypeError(
f"{what} has an unknown key {name!r}; the keys are {allowed}.{hint}"
)
def _named_options(func: Any, skip: int) -> set[str] | None:
"""The keyword options ``func`` names after its first ``skip`` parameters,
or ``None`` when a callable outside PyPhi accepts any keyword."""
params = list(inspect.signature(func).parameters.values())
builtin = func in MECHANISMS.values() or func in MECHANISM_COMBINATIONS.values()
if not builtin and any(p.kind is p.VAR_KEYWORD for p in params):
return None
return {
p.name
for p in params[skip:]
if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD) and p.name != "inputs"
}
def _check_spec(spec: Mapping[str, Any], where: str) -> None:
"""Raise ``TypeError`` for a key or parameter that a node spec does not use.
A misspelt key or parameter would otherwise be dropped and the parameter
left at its default, changing the substrate without warning.
"""
if "composite" in spec:
_reject_unknown(spec, _COMPOSITE_SPEC_KEYS, where)
combination = spec.get("mechanism_combination", "selective")
combine = (
MECHANISM_COMBINATIONS[combination]
if isinstance(combination, str)
else combination
)
options = _named_options(combine, 1)
if options is not None:
_reject_unknown(
spec.get("combination_params", {}),
options,
f"{where}'s combination_params",
)
for k, sub in enumerate(spec["composite"]):
_check_spec(sub, f"{where}'s composite[{k}]")
return
_reject_unknown(spec, _SIMPLE_SPEC_KEYS, where)
mechanism = spec["mechanism"]
func = MECHANISMS[mechanism] if isinstance(mechanism, str) else mechanism
options = _named_options(func, 3)
if options is None:
return
if mechanism in WEIGHTED:
options.add("input_weights")
_reject_unknown(spec.get("params", {}), options, f"{where}'s params")
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, a spec
has a key not listed above, or ``params`` or ``combination_params``
names an option the mechanism or combination does not take.
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)
for j, spec in enumerate(specs):
_check_spec(spec, f"the spec of node {j}")
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)