Source code for pyphi.dynamics

# dynamics.py
"""Functions for simulating system state trajectories."""

from collections.abc import Iterable
from collections.abc import Mapping

import numpy as np
import pandas as pd
from numpy.typing import ArrayLike

from . import utils
from .exceptions import NonConvergenceError


[docs] def mean_dynamics( tpm: ArrayLike, repetitions: int = 100, seed: int | None = None, **kwargs, ): """Return a sample of the dynamics averaged over all initial states. A single generator seeded with ``seed`` is shared across all repetitions, so a given seed reproduces the whole sample. An explicit ``rng`` keyword argument takes precedence over ``seed``. """ tpm = np.asarray(tpm, dtype=float) kwargs.setdefault("rng", np.random.default_rng(seed)) clamp = kwargs.get("clamp", {}) alphabets = _alphabet_sizes(tpm) free = tuple(a for i, a in enumerate(alphabets) if i not in clamp) initial_states = [insert_clamp(clamp, state) for state in utils.all_states(free)] data = np.array( [ [ simulate(tpm, initial_state=initial_state, **kwargs) # pyright: ignore[reportArgumentType] for initial_state in initial_states ] for _ in range(repetitions) ] ) return data.mean(axis=(0, 1))
[docs] def simulate( tpm: ArrayLike, initial_state: tuple[int, ...] | None = None, timesteps: int | None = 100, clamp: Iterable[Mapping] | Mapping | None = None, rng: np.random.Generator | None = None, seed: int | None = None, ): """Return a simulated timeseries of system states. Parameters ---------- tpm : pandas.DataFrame or numpy.ndarray A state-by-state DataFrame (rows indexed by state tuples), a state-by-node multidimensional binary TPM, or an explicit-alphabet TPM of shape ``(*alphabet_sizes, n_units, max_alphabet)`` (as produced by :meth:`~pyphi.substrate.Substrate.joint_tpm`). initial_state : tuple[int, ...], optional The starting state. If None, each unit is drawn uniformly from its own alphabet. timesteps : int, optional Number of steps to simulate. clamp : Mapping or Iterable[Mapping], optional Units held fixed to a given value, either one mapping applied every step or one mapping per step. rng : numpy.random.Generator, optional Generator to draw from. Takes precedence over ``seed``. seed : int, optional Seed for the generator created when ``rng`` is None. If both are None, the trajectory is not reproducible. """ if rng is None: rng = np.random.default_rng(seed) if isinstance(tpm, pd.DataFrame): N = len(tpm.index[0]) # pyright: ignore[reportIndexIssue, reportAttributeAccessIssue] step = _state_by_state_stepper(tpm, rng) if initial_state is None: # Sample from the TPM's own state labels so every unit is drawn # over its full alphabet, not just {0, 1}. labels = tpm.index # pyright: ignore[reportAttributeAccessIssue] initial_state = tuple(labels[int(rng.integers(len(labels)))]) else: arr = np.asarray(tpm, dtype=float) layout = _explicit_alphabet_layout(arr) if layout is None: # Binary state-by-node multidimensional TPM. step = _state_by_node_stepper(arr, rng) N = number_of_units(arr) if initial_state is None: initial_state = tuple(rng.integers(low=0, high=2, size=N)) else: N, alphabets = layout step = _explicit_alphabet_stepper(arr, alphabets, rng) if initial_state is None: # Draw each unit from its own alphabet. initial_state = tuple(int(rng.integers(a)) for a in alphabets) if clamp is None: clamp = {} if len(initial_state) != N: raise ValueError("initial_state must have length equal to the number of units") if isinstance(clamp, Mapping): if timesteps is None: raise ValueError( "timesteps=None requires an explicit iterable of per-step " "clamps; with a single clamp mapping, pass an integer " "number of timesteps" ) clamps = [clamp] * timesteps else: clamps = clamp states = [apply_clamp(clamps[0], initial_state)] # pyright: ignore[reportIndexIssue] for current_clamp in clamps[1:]: # pyright: ignore[reportIndexIssue] states.append(apply_clamp(current_clamp, step(states[-1]))) return states
def _state_by_state_stepper(tpm, rng): """Build a one-step sampler for a state-by-state DataFrame. Precomputes the per-row cumulative distribution once, so each step samples the next state in ``O(log K)`` via inverse-CDF (``numpy.searchsorted``) rather than re-normalizing and drawing per call. Samples the full joint next state, so correlations between units at ``t+1`` are preserved. """ cumulative = tpm.to_numpy().cumsum(axis=1) labels = list(tpm.columns) row_of = {label: i for i, label in enumerate(tpm.index)} last = len(labels) - 1 def step(state): row = cumulative[row_of[state]] j = int(np.searchsorted(row, rng.random(), side="right")) return labels[min(j, last)] return step def _explicit_alphabet_layout(tpm): """Detect the explicit-alphabet TPM layout. Returns ``(n_units, alphabet_sizes)`` if ``tpm`` has the explicit-alphabet shape ``(*alphabet_sizes, n_units, max_alphabet)`` (as produced by :meth:`~pyphi.substrate.Substrate.joint_tpm`), and ``None`` otherwise. """ shape = tpm.shape if tpm.ndim >= 3: n = int(shape[-2]) if ( tpm.ndim == n + 2 and all(s >= 2 for s in shape[:n]) and shape[-1] == max(shape[:n]) ): return n, tuple(int(s) for s in shape[:n]) return None def _alphabet_sizes(tpm): """Per-unit alphabet sizes for either accepted ndarray TPM form.""" layout = _explicit_alphabet_layout(tpm) if layout is not None: return layout[1] return (2,) * number_of_units(tpm) def _explicit_alphabet_stepper(tpm, alphabet_sizes, rng): """Build a one-step sampler for an explicit-alphabet TPM. Samples each unit independently from its own next-state distribution (slots ``[:alphabet_sizes[i]]`` of the trailing axis, ignoring padding). """ def step(state): dists = tpm[tuple(state)] # shape (n_units, max_alphabet) nxt = [] for i, a in enumerate(alphabet_sizes): cumulative = np.cumsum(dists[i, :a]) j = int(np.searchsorted(cumulative, rng.random() * cumulative[-1], "right")) nxt.append(min(j, a - 1)) return tuple(nxt) return step def _state_by_node_stepper(tpm, rng): """Build a one-step sampler for a multidimensional state-by-node TPM. Samples each unit independently from its own ``P(on | state)`` — exact for the conditionally-independent TPMs IIT uses, and ``O(N)`` vectorized numpy per step. """ def step(state): probabilities = tpm[state] return tuple((probabilities > rng.random(len(probabilities))).astype(int)) return step
[docs] def most_probable_next_state(tpm, state): """Return the deterministic most-probable next state. Deterministic counterpart of the sampled step: each unit takes its most-probable next value (for binary state-by-node TPMs, ON iff P(ON) > 0.5; for explicit-alphabet TPMs, the argmax over the unit's own alphabet). """ tpm = np.asarray(tpm, dtype=float) layout = _explicit_alphabet_layout(tpm) if layout is not None: _, alphabets = layout dists = tpm[tuple(state)] # shape (n_units, max_alphabet) return tuple(int(np.argmax(dists[i, :a])) for i, a in enumerate(alphabets)) elementwise_probabilities = tpm[state] return tuple((elementwise_probabilities > 0.5).astype(int))
[docs] def settle(tpm, initial_state, *, clamp=None, max_steps=None): """Iterate the most-probable-transition map to a fixed point. Deterministic complement to :func:`simulate`: each step takes the most-probable next state (each unit ON iff its ON-probability exceeds 0.5) instead of sampling. Parameters ---------- tpm : np.ndarray A state-by-node multidimensional TPM (binary), or an explicit-alphabet TPM of shape ``(*alphabet_sizes, n_units, max_alphabet)``. initial_state : tuple[int, ...] The starting state. clamp : Mapping[int, int] or None, optional Units held fixed to a given value every step. max_steps : int or None, optional Optional cap on the number of steps; raises if exceeded. Returns ------- list[tuple[int, ...]] The trajectory of states ending at the fixed point. The fixed point is the last element and the settling time is ``len(result) - 1``. Raises ------ ~pyphi.exceptions.NonConvergenceError If the map enters a limit cycle, or does not settle within ``max_steps``. """ if clamp is None: clamp = {} if len(initial_state) != number_of_units(tpm): raise ValueError("initial_state must have length equal to the number of units") state = apply_clamp(clamp, tuple(initial_state)) trajectory = [state] seen = {state} while True: nxt = apply_clamp(clamp, most_probable_next_state(tpm, state)) if nxt == state: return trajectory if nxt in seen: raise NonConvergenceError( f"no fixed point; entered a limit cycle at {nxt} " f"(trajectory: {[*trajectory, nxt]})" ) trajectory.append(nxt) seen.add(nxt) state = nxt # The just-appended state may itself be the fixed point (confirmed on # the next iteration), so the best-case settling time here is # len(trajectory) - 1; raise only when even that exceeds the cap. if max_steps is not None and len(trajectory) - 1 > max_steps: raise NonConvergenceError(f"did not settle within max_steps={max_steps}")
# TODO: move to tpm module
[docs] def number_of_units(tpm: ArrayLike): """Number of units of a state-by-node or explicit-alphabet ndarray TPM.""" tpm = np.asarray(tpm) layout = _explicit_alphabet_layout(tpm) if layout is not None: return layout[0] return tpm.shape[-1]
[docs] def apply_clamp(clamp, state): if not clamp: return state state = list(state) for index, unit_state in clamp.items(): state[index] = unit_state return tuple(state)
[docs] def insert_clamp(clamp, state): if not clamp: return state state = list(state) for index, unit_state in sorted(clamp.items()): state.insert(index, unit_state) return tuple(state)