"""Estimate substrates from observed transitions, with epistemic uncertainty.
The TPM that IIT requires is interventional — ``p(next | do(current))``
under a uniform perturbation of all current states — so what data can
legitimately provide depends on how the data was produced. The caller must
assert the ``regime``:
- ``"perturbational"``: each transition is an independent trial in which the
current state was set by intervention. When the trials cover the state
space, the estimand is identified and estimation is a counting problem.
- ``"observational"``: transitions come from a passively recorded
trajectory. Treating them as interventional assumes the recorded dynamics
are the causal dynamics (no unobserved driver, correct units and grain,
stationarity) — assumptions about the world that the data cannot verify.
States the trajectory never visits are *unidentified*, not merely
unsampled: substrates that differ only on unvisited rows produce
identical data and can have materially different Φ. The
:class:`CoverageReport` records exactly which rows the data constrained.
Estimation is per-unit counting with a conjugate symmetric Beta prior on
every cell of the state-by-node TPM (default Jeffreys, ``a = 1/2``). The
result is a :class:`SubstratePosterior` — a distribution over substrates,
never a single point estimate: Φ of a posterior-mean TPM conflates
epistemic uncertainty with genuine indeterminism and suppresses Φ where the
data is merely uninformative.
"""
from __future__ import annotations
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
import numpy as np
import pandas as pd
from numpy.typing import NDArray
from tqdm.auto import tqdm
from . import convert
from . import numerics
from .analyze import analyze
from .conf import config
from .provenance import Provenance
from .serializable import Serializable
from .substrate import Substrate
from .substrate import irreducible_sias
from .substrate import maximal_complex
REGIMES = ("perturbational", "observational")
def _index_to_state(index: int, n_units: int) -> tuple[int, ...]:
"""Decode a little-endian row index into a binary state tuple."""
return tuple((index >> i) & 1 for i in range(n_units))
[docs]
@dataclass(frozen=True, eq=False)
class CoverageReport(Serializable):
"""Which current states the data constrained, and how often.
In the perturbational regime, uncovered states mark where more trials
are needed. In the observational regime they are stronger: the dynamics
at an unvisited state are unidentified by the data, so any downstream
quantity that depends on those TPM rows rests on the prior alone.
Attributes
----------
counts
Number of observed transitions out of each current state,
indexed by little-endian state index; shape ``(2**n_units,)``.
n_units
Number of units in the substrate.
"""
counts: NDArray[np.int64]
n_units: int
@property
def n_states(self) -> int:
"""Total number of current states."""
return 2**self.n_units
@property
def uncovered_states(self) -> tuple[tuple[int, ...], ...]:
"""States (as little-endian tuples) with zero observed transitions."""
return tuple(
_index_to_state(int(i), self.n_units)
for i in np.flatnonzero(self.counts == 0)
)
@property
def fraction_covered(self) -> float:
"""Fraction of current states with at least one observation."""
return float(np.count_nonzero(self.counts)) / self.n_states
@property
def is_complete(self) -> bool:
"""Whether every current state was observed at least once."""
return bool(np.all(self.counts > 0))
[docs]
def to_pandas(self) -> pd.DataFrame:
"""Long-format DataFrame with one row per state: ``state``, ``count``."""
return pd.DataFrame(
{
"state": [
_index_to_state(i, self.n_units) for i in range(self.n_states)
],
"count": self.counts.astype(int),
}
)
[docs]
@dataclass(frozen=True, eq=False)
class SubstratePosterior(Serializable):
"""Posterior distribution over substrates given observed transitions.
Each cell of the state-by-node TPM carries an independent Beta
posterior: cell ``(s, i)`` is ``Beta(alpha_on[s, i], alpha_off[s, i])``
over ``p(unit i is ON at t+1 | current state s)``. Rows follow the
little-endian state order.
Draw concrete substrates with :meth:`sample`; every existing PyPhi
computation applies to the samples unchanged.
Attributes
----------
alpha_on
Beta ``a`` parameters, shape ``(2**n, n)``.
alpha_off
Beta ``b`` parameters, shape ``(2**n, n)``.
regime
The caller's assertion about how the data was produced
(``"perturbational"`` or ``"observational"``).
prior
The symmetric Beta prior pseudocount added to every cell.
coverage
Per-state observation counts (see :class:`CoverageReport`).
node_labels
Optional labels passed through to sampled substrates.
provenance
Environment record captured at estimation time.
"""
alpha_on: NDArray[np.float64]
alpha_off: NDArray[np.float64]
regime: str
prior: float
coverage: CoverageReport
node_labels: Sequence[str] | None
provenance: Provenance
@property
def n_units(self) -> int:
"""Number of units in the substrate."""
return self.alpha_on.shape[1]
@property
def n_states(self) -> int:
"""Total number of current states."""
return self.alpha_on.shape[0]
[docs]
def sample(
self, *, seed: int | None = None, rng: np.random.Generator | None = None
) -> Substrate:
"""Draw one substrate from the posterior.
Every TPM cell is drawn independently from its Beta posterior;
exactly one of ``seed`` or ``rng`` must be given.
Parameters
----------
seed
Seed for a fresh, isolated ``np.random.default_rng``.
rng
An existing generator to draw from (advances its state).
Returns
-------
An ordinary :class:`~pyphi.substrate.Substrate`.
"""
if (seed is None) == (rng is None):
raise ValueError("Provide exactly one of seed= or rng=.")
if rng is None:
rng = np.random.default_rng(seed)
p_on = rng.beta(self.alpha_on, self.alpha_off)
return Substrate(
tpm=convert.to_multidimensional(p_on), node_labels=self.node_labels
)
[docs]
def mean_substrate(self) -> Substrate:
"""The posterior-mean substrate: every TPM cell at its Beta mean.
A reference point, not an estimator: Φ of the mean substrate
conflates epistemic uncertainty with the substrate's own
indeterminism (see the module docstring), so it must not be
reported as an estimate of Φ. Its role is to anchor reference
computations such as the selection-margin screen in
:func:`phi_posterior`.
"""
p_on = self.alpha_on / (self.alpha_on + self.alpha_off)
return Substrate(
tpm=convert.to_multidimensional(p_on), node_labels=self.node_labels
)
[docs]
def edge_probability(
self, *, n_samples: int, seed: int, threshold: float
) -> NDArray[np.float64]:
r"""Graded posterior probability of a causal edge between each unit pair.
Entry ``(a, b)`` is the fraction of posterior samples in which unit
``b``'s estimated conditional varies by more than ``threshold`` along
input axis ``a`` — that is, the maximum absolute difference in
``p(b = ON | current)`` between any two current states differing only
in unit ``a`` exceeds ``threshold``. This is the graded analogue of a
connectivity matrix: an edge ``a -> b`` is present to the extent the
data makes ``b`` depend on ``a``.
The exact-equality oracle :meth:`FactoredTPM.infer_cm` cannot serve
here: on any continuously-estimated TPM every factor varies along
every axis by sampling noise, so it saturates to fully connected.
This method replaces that oracle for estimated substrates by asking
how *much* a factor varies, at a caller-chosen scale.
``threshold`` has no default: the scale below which a dependence is
treated as absent is a modeling choice the caller must make.
Parameters
----------
n_samples
Number of posterior draws to average over.
seed
Seed for a fresh, isolated generator driving all draws
(required; the statistic is otherwise irreproducible).
threshold
Variation above which an input axis counts as an edge,
on the ``p(ON)`` scale (in ``[0, 1]``).
Returns
-------
An ``(n, n)`` array of edge-firing fractions in ``[0, 1]``.
"""
n = self.n_units
# All draws at once: shape (n_samples, 2**n, n).
p_on = np.random.default_rng(seed).beta(
self.alpha_on, self.alpha_off, size=(n_samples, self.n_states, n)
)
# Expose each input bit as its own axis. Under little-endian row order
# and C-order reshape, reshaped state axis ``1 + (n - 1 - a)`` = ``n - a``
# carries input bit ``a``.
grid = p_on.reshape((n_samples, *([2] * n), n))
prob = np.zeros((n, n))
for a in range(n):
axis = n - a
diff = np.abs(grid.take(0, axis=axis) - grid.take(1, axis=axis))
# Max over the remaining n-1 state axes -> (n_samples, n_target).
max_diff = diff.max(axis=tuple(range(1, n)))
prob[a] = (max_diff > threshold).mean(axis=0)
return prob
[docs]
@dataclass(frozen=True, eq=False)
class PhiPosterior(Serializable):
"""Posterior distribution over Φ, propagated from a substrate posterior.
The distribution is generically a mixture: a point mass at zero from
samples whose candidate system is reducible (the system does not exist
as one entity), and a continuous density over the Φ values of the
irreducible samples. Because the mixture's mean describes no possible
system, ``float()`` refuses; summarize with :attr:`p_positive`,
:meth:`quantiles`, :meth:`conditional_quantiles`, or the raw
:attr:`samples`.
Attributes
----------
samples
Per-draw Φ values, shape ``(n_samples,)``.
complex_samples
Per-draw unit indices of the maximal complex;
``()`` when no irreducible candidate exists in that draw.
state
The analyzed substrate state.
subset
Unit indices of the candidate system, or ``None`` for the
whole substrate.
seed
Seed that drove all draws.
regime
The causal assertion carried from the substrate posterior
(``"perturbational"`` or ``"observational"``).
coverage
The substrate posterior's :class:`CoverageReport`.
provenance
Environment record captured at computation time; its
``estimator`` field carries the substrate posterior's record.
screen_margin
The margin threshold passed to :func:`phi_posterior`;
``None`` when screening was off.
screened
Whether the margin screen actually engaged (the reference
margins all cleared the threshold), fixing the per-draw complex
identity at the posterior mean's answer.
reference_margins
The selection margins of the reference maximal complex at the
posterior mean, recorded whenever a threshold was given — the
audit trail for why the screen engaged or refused. Keys:
``"partition"``, ``"cause_state"``, ``"effect_state"``, and
``"complex"`` (the φ_s gap between the top two irreducible
candidate systems — the margin of the complex identity itself).
"""
samples: NDArray[np.float64]
complex_samples: tuple[tuple[int, ...], ...]
state: tuple[int, ...]
subset: tuple[int, ...] | None
seed: int
regime: str
coverage: CoverageReport
provenance: Provenance
screen_margin: float | None = None
screened: bool = False
reference_margins: dict[str, float | None] | None = None
@property
def p_positive(self) -> float:
"""Posterior probability that Φ is positive (the system is integrated)."""
return sum(numerics.is_positive(phi) for phi in self.samples) / len(self.samples)
[docs]
def quantiles(self, qs: Sequence[float]) -> NDArray[np.float64]:
"""Quantiles of the full mixture (zeros included)."""
return np.quantile(self.samples, qs)
[docs]
def conditional_quantiles(self, qs: Sequence[float]) -> NDArray[np.float64] | None:
"""Quantiles of Φ conditional on the system being integrated.
Returns ``None`` when no sample is positive.
"""
positive = self.samples[[numerics.is_positive(phi) for phi in self.samples]]
if positive.size == 0:
return None
return np.quantile(positive, qs)
@property
def complex_identity(self) -> dict[tuple[int, ...], float]:
"""Categorical distribution over the maximal complex's unit set."""
total = len(self.complex_samples)
return {
units: count / total
for units, count in Counter(self.complex_samples).items()
}
def __float__(self) -> float:
detail = ""
if not self.coverage.is_complete:
n = len(self.coverage.uncovered_states)
detail = (
f" {n} of {self.coverage.n_states} states were never"
" observed, so the underlying TPM is unconstrained there"
" (uncovered states are unidentified, not merely unsampled)."
)
raise TypeError(
"A PhiPosterior is a distribution over Φ (generically a mixture"
" of a point mass at zero and a continuous density) and cannot"
" be summarized by one float. Use .p_positive, .quantiles(qs),"
" .conditional_quantiles(qs), or .samples." + detail
)
def _reference_margins(sia: Any) -> dict[str, float | None]:
"""The selection margins of a reference SIA, as plain floats."""
from .direction import Direction
def _opt(value: Any) -> float | None:
return None if value is None else float(value)
state_margins = sia.state_margins
return {
"partition": _opt(sia.partition_margin),
"cause_state": _opt(state_margins[Direction.CAUSE]),
"effect_state": _opt(state_margins[Direction.EFFECT]),
}
[docs]
def phi_posterior(
posterior: SubstratePosterior,
state: tuple[int, ...],
*,
n_samples: int,
seed: int,
subset: Sequence[int] | None = None,
screen_margin: float | None = None,
) -> PhiPosterior:
"""Propagate a substrate posterior through the SIA by Monte Carlo.
Each draw samples one substrate from ``posterior``, computes the SIA of
the candidate system, and finds the maximal complex. A single generator
seeded with ``seed`` drives every draw.
Parameters
----------
posterior
The substrate posterior to propagate.
state
The substrate state to analyze.
Other Parameters
----------------
n_samples
Number of Monte Carlo draws.
seed
Seed for the draws (required; saved on the result).
subset
Unit indices of the candidate system; ``None`` uses the
whole substrate.
screen_margin
Margin threshold for the complex-identity screen; ``None``
(default) disables it. When given, the maximal complex of the
posterior-mean substrate is computed once, and if that reference
complex exists and the complex-identity margin — the φ_s gap
between the top two irreducible candidate systems at the mean —
is either undefined (no competitor) or strictly greater than the
threshold, the per-draw maximal-complex search is skipped and
the reference identity is reused for every draw. The reference
SIA's internal margins are recorded on the result for audit but
do not gate the screen: a tied partition or specified state
within the winner does not contest which units win. The per-draw
SIA (and hence every Φ sample) is always computed in full, and
the draw stream is identical to the unscreened run.
Returns
-------
A :class:`PhiPosterior` over the per-draw Φ values and complex
identities.
Notes
-----
The screen is a compute heuristic, not a bound: margins at the
posterior mean say nothing about a draw far from the mean, so under a
diffuse posterior a screened ``complex_identity`` degenerates to a
point mass that the unscreened run would not show. The threshold is a
modeling choice about the scale at which a selection counts as safely
untied, which the caller must make. Use
screening after the posterior is tight, or after an unscreened pilot.
"""
if n_samples < 1:
raise ValueError(f"n_samples must be at least 1, got {n_samples}")
screened = False
reference_margins = None
reference_units: tuple[int, ...] | None = None
if screen_margin is not None:
mean = posterior.mean_substrate()
reference = maximal_complex(mean, state)
reference_margins = _reference_margins(reference.sia)
# The identity the screen fixes is *which candidate system* is the
# maximal complex, so the gate must also cover that selection: the
# φ_s gap between the top two irreducible candidates (overlapping
# candidates included — any of them coming within the threshold of
# the winner contests the identity).
# numerics: exact — reported margin (caller-owned raw threshold).
candidate_phis = sorted(
(float(sia.phi) for sia in irreducible_sias(mean, state)), reverse=True
)
reference_margins["complex"] = (
max(0.0, candidate_phis[0] - candidate_phis[1])
if len(candidate_phis) > 1
else None
)
# Only the complex-identity margin gates the screen: the reused
# object is the winning unit set, and internal ties within the
# winner (a tied MIP or specified state) do not contest which
# units win. The winner's own margins are recorded for audit.
complex_margin = reference_margins["complex"]
if bool(reference) and (
complex_margin is None or complex_margin > screen_margin
):
screened = True
reference_units = tuple(reference.node_indices)
rng = np.random.default_rng(seed)
draws = range(n_samples)
if config.infrastructure.progress_bars:
draws = tqdm(draws, desc="Sampling Φ posterior")
phis = []
complexes = []
for _ in draws:
sample = posterior.sample(rng=rng)
sia = analyze(sample, state, subset=subset, compute="sia")
phis.append(float(sia.phi))
if screened:
complexes.append(reference_units)
else:
complex_ = maximal_complex(sample, state)
complexes.append(tuple(complex_.node_indices))
return PhiPosterior(
samples=np.array(phis, dtype=np.float64),
complex_samples=tuple(complexes),
state=tuple(state),
subset=None if subset is None else tuple(subset),
seed=seed,
regime=posterior.regime,
coverage=posterior.coverage,
provenance=Provenance.capture(
seed=seed, estimator=posterior.provenance.estimator
),
screen_margin=screen_margin,
screened=screened,
reference_margins=reference_margins,
)
[docs]
def estimate_substrate(
data: NDArray[np.integer] | tuple[NDArray[np.integer], NDArray[np.integer]],
*,
regime: str,
prior: float = 0.5,
node_labels: Sequence[str] | None = None,
model: str = "counts",
) -> SubstratePosterior:
"""Estimate a posterior over substrates from binary transition data.
Parameters
----------
data
Either a pair ``(current, next)`` of integer arrays, each of
shape ``(T, n)`` with one transition per row (the natural form
for perturbational trials), or a single integer array of shape
``(T, n)`` — a trajectory whose transitions are the consecutive
row pairs (the natural form for observational recordings). Both
forms are accepted under both regimes; values must be 0/1.
Other Parameters
----------------
regime
Required assertion about how the data was produced:
``"perturbational"`` (current states set by intervention) or
``"observational"`` (passively recorded trajectory; see the
module docstring for the identifiability caveat).
prior
Symmetric Beta prior pseudocount added to every TPM cell
(default Jeffreys, ``1/2``). Must be positive.
node_labels
Optional labels for the substrate units.
model
Estimation model; only ``"counts"`` is implemented.
Returns
-------
A :class:`SubstratePosterior` with independent Beta posteriors over
every TPM cell and a :class:`CoverageReport`.
"""
if model != "counts":
raise NotImplementedError(f"model={model!r}; only 'counts' is implemented")
if regime not in REGIMES:
raise ValueError(f"regime must be one of {REGIMES}, got {regime!r}")
if not prior > 0:
raise ValueError(f"prior must be positive, got {prior}")
if isinstance(data, np.ndarray):
trajectory = np.asarray(data)
current, next_ = trajectory[:-1], trajectory[1:]
else:
current, next_ = (np.asarray(a) for a in data)
if current.ndim != 2 or current.shape != next_.shape:
raise ValueError(
"data must be a (T, n) trajectory or a pair of (T, n) arrays "
f"of equal shape; got shapes {current.shape} and {next_.shape}"
)
if not (np.isin(current, (0, 1)).all() and np.isin(next_, (0, 1)).all()):
raise ValueError("estimation is binary-only; data values must be 0 or 1")
n = current.shape[1]
# Little-endian row index of each current state.
rows = current.astype(np.int64) @ (1 << np.arange(n))
counts_on = np.zeros((2**n, n))
counts_off = np.zeros((2**n, n))
np.add.at(counts_on, rows, next_)
np.add.at(counts_off, rows, 1 - next_)
row_counts = np.bincount(rows, minlength=2**n)
coverage = CoverageReport(counts=row_counts, n_units=n)
n_states_observed = int(np.count_nonzero(coverage.counts))
estimator = {
"regime": regime,
"model": model,
"prior": float(prior),
"n_transitions": int(coverage.counts.sum()),
"n_states_observed": n_states_observed,
"n_states_total": coverage.n_states,
"uncovered_state_count": coverage.n_states - n_states_observed,
}
return SubstratePosterior(
alpha_on=counts_on + prior,
alpha_off=counts_off + prior,
regime=regime,
prior=prior,
coverage=coverage,
node_labels=node_labels,
provenance=Provenance.capture(estimator=estimator),
)