"""Black-box optimization of IIT quantities over substrate parameters.
Where :mod:`pyphi.landscape` analyzes the φ landscape along one axis, this
module searches it: :func:`optimize` runs a seeded population method over a
bounded box of connection weights, looking for a substrate that maximizes an
IIT quantity — by default the signed normalized system irreducibility φₛ,
which stays continuous across minimum-information-partition switches and so
gives a gradient-free search no discontinuities to trip on.
:func:`weight_axes` builds the search space for the common case: a map from a
parameter vector to a :func:`~pyphi.substrate_generator.build_substrate`
substrate, varying a chosen set of weight-matrix entries.
"""
from __future__ import annotations
import contextlib
import math
from collections.abc import Callable
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 pyphi.conf import config
from pyphi.conf import fallback
from pyphi.conf._helpers import check_parallel_flag
from pyphi.conf._helpers import check_progress_flag
from pyphi.display import Description
from pyphi.display import Displayable
from pyphi.display import Row
from pyphi.display import Section
from pyphi.display.numbers import format_value
from pyphi.landscape import _QUANTITIES
from pyphi.landscape import _UNREACHABLE
from pyphi.landscape import _optional_float
from pyphi.landscape import _part_id
from pyphi.serializable import Serializable
# Rank-based DE selection only needs unreachable to compare worse than any real
# candidate; a fixed large sentinel (internal minimization convention) suffices.
_UNREACHABLE_PENALTY = 1e18
[docs]
def weight_axes(
unit_functions: Any,
weights: NDArray[Any],
indices: Sequence[tuple[int, int]],
**kwargs: Any,
) -> Callable[[NDArray[Any]], Any]:
"""Return a parameter axis varying several weights of a generated substrate.
The vector analogue of :func:`pyphi.landscape.weight_axis`.
Parameters
----------
unit_functions : str or Callable or Iterable
Unit function(s), as accepted by
:func:`pyphi.substrate_generator.build_substrate`.
weights : ArrayLike
The base weight matrix; ``weights[i, j]`` is the connection from unit
``i`` to unit ``j``. Copied on every call — never mutated.
indices : Sequence[tuple[int, int]]
The ``(i, j)`` entries to vary, in the order the parameter vector
indexes them.
Returns
-------
_WeightAxis
A picklable callable mapping a length-``len(indices)`` vector θ to the
substrate built from the weight matrix with ``weights[indices[k]] =
θ[k]``. Additional keyword arguments forward to ``build_substrate`` on
every call.
Notes
-----
The returned axis is a picklable object, not a closure, so a population can
be evaluated across worker processes (``optimize(..., parallel=True)``).
Setting a weight to exactly 0 removes the connection from the derived
connectivity matrix (``cm = weights != 0``), a discrete topology change,
exactly as for :func:`pyphi.landscape.weight_axis`.
"""
from pyphi.substrate_generator import _check_unit_function_kwargs
_check_unit_function_kwargs(unit_functions, kwargs)
return _WeightAxis(
unit_functions=unit_functions,
base=np.array(weights, dtype=float),
entries=[(int(i), int(j)) for i, j in indices],
kwargs=dict(kwargs),
)
@dataclass(frozen=True)
class _WeightAxis:
"""A picklable vector → substrate map varying a fixed set of weight entries.
A module-level callable rather than a closure so the process backend can
pickle it when a population is evaluated in parallel.
"""
unit_functions: Any
base: NDArray[Any]
entries: list[tuple[int, int]]
kwargs: dict[str, Any]
def __call__(self, theta: NDArray[Any]) -> Any:
from pyphi.substrate_generator import build_substrate
varied = self.base.copy()
for (i, j), value in zip(self.entries, np.asarray(theta), strict=True):
varied[i, j] = value
return build_substrate(self.unit_functions, varied, **self.kwargs)
def _objective_value(sia: Any, objective: Any) -> float:
"""The natural (maximization-convention) objective scalar for one SIA."""
if callable(objective):
value: Any = objective(sia)
return float(value)
try:
value = getattr(sia, objective)
except AttributeError:
raise ValueError(
f"quantity {objective!r} is not available on "
f"{type(sia).__name__}; choose a quantity the requested "
f"formalism provides (e.g. quantity='phi')"
) from None
return _optional_float(value)
def _eval_one(
theta: NDArray[Any],
*,
builder: Callable[[NDArray[Any]], Any],
state: tuple[int, ...],
subset: Sequence[int] | None,
formalism: str | None,
quantity: Any,
) -> dict[str, Any]:
"""Evaluate one candidate; unreachable states are penalized, not raised.
``theta`` is first-positional and the rest keyword-only so a
``functools.partial`` over the fixed arguments is a picklable one-argument
worker for ``map_reduce``.
"""
from pyphi.analysis import analyze
from pyphi.direction import Direction
try:
sia: Any = analyze(
builder(np.asarray(theta)),
state,
subset=subset,
formalism=formalism,
compute="sia",
)
except _UNREACHABLE:
return {
"objective": math.nan,
"reachable": False,
"partition": None,
"cause_state": None,
"effect_state": None,
"partition_margin": math.nan,
"cause_state_margin": math.nan,
"effect_state_margin": math.nan,
"_sia": None,
}
# Selection margins and state specifications exist only on IIT 4.0
# SIAs; other formalisms' rows carry None/NaN in those columns.
system_state = getattr(sia, "system_state", None)
cause = system_state.cause if system_state is not None else None
effect = system_state.effect if system_state is not None else None
margins = getattr(sia, "state_margins", None)
return {
"objective": _objective_value(sia, quantity),
"reachable": True,
"partition": _part_id(sia.partition),
"cause_state": None if cause is None else tuple(int(x) for x in cause.state),
"effect_state": None if effect is None else tuple(int(x) for x in effect.state),
"partition_margin": _optional_float(getattr(sia, "partition_margin", None)),
"cause_state_margin": _optional_float(
margins[Direction.CAUSE] if margins is not None else None
),
"effect_state_margin": _optional_float(
margins[Direction.EFFECT] if margins is not None else None
),
"_sia": sia,
}
[docs]
@dataclass(frozen=True, eq=False, repr=False)
class OptimizationResult(Displayable, Serializable):
"""The outcome of an :func:`optimize` run.
Attributes
----------
best_params : NDArray
The winning parameter vector.
best_objective : float
Its objective value.
best_substrate : Substrate
``builder(best_params)``.
best_sia : SystemIrreducibilityAnalysis
The analysis at the best point — its selection identity and margins say
how robust the winning substrate is.
trajectory : pd.DataFrame
One row per evaluation: ``eval``, ``generation``, one column per
parameter dimension (``p0``, ``p1``, …), ``objective``,
``reachable``, the selection identity (``partition``,
``cause_state``, ``effect_state``) and the selection margins
(``partition_margin``, ``cause_state_margin``, ``effect_state_margin``).
bounds : list[tuple[float, float]]
The search box.
seed : int
The seed driving the run.
goal : str
``"maximize"`` or ``"minimize"``.
quantity_name : str
The optimized quantity's name, or ``"<callable>"``.
settings : dict
Backend and budget: ``backend``, ``popsize``, ``maxiter``, ``tol``.
config_snapshot : dict
PyPhi configuration at run time (``precision``, ``formalism``).
n_evaluations : int
Total candidate evaluations.
n_unreachable : int
How many candidates had a dynamically unreachable state.
"""
best_params: NDArray[Any]
best_objective: float
best_substrate: Any
best_sia: Any
trajectory: pd.DataFrame
bounds: list[tuple[float, float]]
seed: int
goal: str
quantity_name: str
settings: dict[str, Any]
config_snapshot: dict[str, Any]
n_evaluations: int
n_unreachable: int
def __eq__(self, other: object) -> bool:
# The generated dataclass __eq__ would compare ``best_params`` and
# ``trajectory`` with ``==``, which yields elementwise results whose
# truth values raise.
if not isinstance(other, OptimizationResult):
return NotImplemented
return (
np.array_equal(self.best_params, other.best_params)
and self.trajectory.equals(other.trajectory)
and self.best_objective == other.best_objective
and self.best_substrate == other.best_substrate
and self.best_sia == other.best_sia
and self.bounds == other.bounds
and self.seed == other.seed
and self.goal == other.goal
and self.quantity_name == other.quantity_name
and self.settings == other.settings
and self.config_snapshot == other.config_snapshot
and self.n_evaluations == other.n_evaluations
and self.n_unreachable == other.n_unreachable
)
def to_pandas(self) -> pd.DataFrame:
return self.trajectory
def _describe(self, verbosity: int) -> Description: # noqa: ARG002
params = ", ".join(format_value(float(p)) for p in self.best_params)
rows = (
Row("Goal", f"{self.goal} {self.quantity_name}"),
Row("Best objective", self.best_objective),
Row("Best parameters", f"({params})"),
Row("Evaluations", self.n_evaluations),
Row("Unreachable", self.n_unreachable),
Row("Seed", self.seed),
)
return Description(
title="OptimizationResult",
sections=(Section(rows=rows),),
compact=(
f"OptimizationResult({self.goal} {self.quantity_name}: "
f"{format_value(float(self.best_objective))})"
),
)
def _eval_batch(
builder: Callable[[NDArray[Any]], Any],
thetas: list[NDArray[Any]],
state: tuple[int, ...],
subset: Sequence[int] | None,
formalism: str | None,
quantity: Any,
parallel: bool,
progress: bool,
) -> list[dict[str, Any]]:
"""Evaluate a whole population, sequentially or through ``map_reduce``.
The parallel branch mirrors ``pyphi.sweeps._run_cells_parallel``: one level
of parallelism (``parallel=True`` on the outer ``map_reduce``, inner
``parallel=False`` in the worker config snapshot), one candidate per cell.
"""
if not parallel or len(thetas) <= 1:
return [
_eval_one(
theta,
builder=builder,
state=state,
subset=subset,
formalism=formalism,
quantity=quantity,
)
for theta in thetas
]
from functools import partial
from pyphi.conf import presets
from pyphi.parallel import map_reduce
preset = presets.by_name[formalism] if formalism is not None else {}
# partial binds the fixed args as keywords; theta stays the one positional,
# so cell_fn is a picklable one-argument worker.
cell_fn = partial(
_eval_one,
builder=builder,
state=state,
subset=subset,
formalism=formalism,
quantity=quantity,
)
results: list[Any] = []
with config.override(parallel=False, **preset):
results = map_reduce(
cell_fn,
thetas,
parallel=True,
ordered=True,
reduce_func=list,
progress=progress,
desc="optimize population",
chunksize=1,
)
if len(results) != len(thetas):
raise AssertionError("map_reduce flattened population results")
return results
def _check_inputs(
builder: Callable[[NDArray[Any]], Any],
box: list[tuple[float, float]],
state: tuple[int, ...],
subset: Sequence[int] | None,
formalism: str | None,
) -> None:
"""Build the substrate at the midpoint of ``box`` and the candidate system
on it, so a wrong bounds length, state or subset raises its own error
before the search starts rather than inside the optimizer.
An unreachable state at the midpoint is not an input error: the search
penalizes unreachable candidates.
"""
from pyphi.conf import presets
from pyphi.system import System
midpoint = np.array([(lo + hi) / 2 for lo, hi in box])
try:
substrate = builder(midpoint)
except (ValueError, TypeError, IndexError) as error:
raise ValueError(
f"builder could not build a substrate from the midpoint of bounds "
f"({len(box)} parameters): {error}"
) from error
with presets.use(formalism), contextlib.suppress(*_UNREACHABLE):
System(substrate, state, subset=None if subset is None else tuple(subset))
[docs]
def optimize(
builder: Callable[[NDArray[Any]], Any],
state: tuple[int, ...],
bounds: Sequence[tuple[float, float]],
*,
seed: int,
quantity: Any = "signed_normalized_phi",
goal: str = "maximize",
x0: NDArray[Any] | None = None,
popsize: int = 15,
maxiter: int = 100,
tol: float = 0.01,
subset: Sequence[int] | None = None,
formalism: str | None = None,
parallel: bool | None = None,
progress: bool | None = None,
) -> OptimizationResult:
"""Search a substrate's weights for a maximizer of an IIT quantity.
Runs a seeded, population-based black-box optimizer
(:func:`scipy.optimize.differential_evolution`) over a bounded box of
parameters, evaluating each candidate as one system irreducibility
analysis. The default quantity, signed normalized φₛ, is continuous across
minimum-information-partition switches, so the gradient-free search sees no
discontinuities.
Parameters
----------
builder : Callable[[NDArray], Substrate]
The parameter axis: a vector → substrate map. :func:`weight_axes` builds
one for the single-substrate weight case.
state : tuple[int, ...]
The substrate state analyzed at every candidate.
bounds : Sequence[tuple[float, float]]
``(low, high)`` per parameter dimension. ``len(bounds)`` fixes the
search dimension.
seed : int
Seeds an isolated ``np.random.default_rng`` driving the optimizer, and
is recorded on the result. Required.
quantity : str or Callable
The quantity to optimize: ``"signed_normalized_phi"`` (default),
``"phi"``, ``"signed_phi"``, or ``"normalized_phi"`` by name, as in
:func:`~pyphi.landscape.perturb`, or a callable ``SIA → float`` for
any other quantity.
goal : str
``"maximize"`` (default) or ``"minimize"``.
x0 : NDArray, optional
Seeds one member of the initial population, e.g. a known-good
substrate's weights.
popsize : int
Differential-evolution population multiplier.
maxiter : int
Maximum number of generations.
tol : float
Relative convergence tolerance.
subset : Sequence[int], optional
Candidate system node indices; ``None`` uses the whole substrate.
formalism : str, optional
Leave unset to compute IIT under the active configuration. Pass an
earlier version's name only to reproduce published results; it
applies to these evaluations only, as in :func:`pyphi.analyze`.
parallel : bool, optional
Evaluate each generation's population through ``map_reduce``; defaults to
``config.infrastructure.parallel``. Requires ``builder`` and a callable
``quantity`` to be picklable (:func:`weight_axes` and the named
quantities are).
progress : bool, optional
Show a progress bar; defaults to ``config.infrastructure.progress_bars``.
Returns
-------
OptimizationResult
Raises
------
ValueError
If ``quantity`` is a name that is not one of the four φₛ variants;
if ``goal`` is neither ``"maximize"`` nor ``"minimize"``; or if
``builder`` cannot build a substrate from ``len(bounds)`` parameters,
or ``state`` or ``subset`` does not fit that substrate. These are
checked once, at the midpoint of ``bounds``, before the search.
Notes
-----
The driver reads only the scalar objective to choose a move. It never
branches on a selection identity (the chosen partition or state), because
that identity flips between near-tied candidates from one grid point to the
next even where the objective is smooth. Selection identities and margins
are logged for inspection only.
With ``parallel=True`` the ``builder`` and ``quantity`` are sent to worker
processes, so both must be picklable. A closure builder or a lambda
quantity must be run with ``parallel=False``.
"""
from scipy.optimize import differential_evolution
if isinstance(quantity, str) and quantity not in _QUANTITIES:
raise ValueError(
f"unknown quantity {quantity!r}; expected one of "
f"{', '.join(sorted(_QUANTITIES))}, or a callable"
)
from pyphi.conf import presets
formalism = presets.canonical(formalism)
if goal not in ("maximize", "minimize"):
raise ValueError(f"goal must be 'maximize' or 'minimize', got {goal!r}")
box = [(float(lo), float(hi)) for lo, hi in bounds]
_check_inputs(builder, box, state, subset, formalism)
sign = -1.0 if goal == "maximize" else 1.0 # DE minimizes sign*objective
show = fallback(check_progress_flag(progress), config.infrastructure.progress_bars)
use_parallel = fallback(
check_parallel_flag(parallel), config.infrastructure.parallel
)
quantity_name = quantity if isinstance(quantity, str) else "<callable>"
rows: list[dict[str, Any]] = []
# Only the best candidate's SIA is retained; keeping every candidate's
# full SIA would grow memory linearly with the number of evaluations.
best_index: int | None = None
best_value = 0.0
best_sia_seen: Any = None
generation = 0
def batch_objective(population: NDArray[Any]) -> NDArray[Any]:
nonlocal generation, best_index, best_value, best_sia_seen
# scipy hands a (D, S) matrix under vectorized=True.
thetas = [population[:, i] for i in range(population.shape[1])]
evaluated = _eval_batch(
builder, thetas, state, subset, formalism, quantity, use_parallel, show
)
scores = np.empty(len(evaluated))
for member, (theta, row) in enumerate(zip(thetas, evaluated, strict=True)):
record = {
"eval": len(rows),
"generation": generation,
**{f"p{d}": float(theta[d]) for d in range(len(theta))},
"objective": row["objective"],
"reachable": row["reachable"],
"partition": row["partition"],
"cause_state": row["cause_state"],
"effect_state": row["effect_state"],
"partition_margin": row["partition_margin"],
"cause_state_margin": row["cause_state_margin"],
"effect_state_margin": row["effect_state_margin"],
}
rows.append(record)
value = record["objective"]
if value is not None and not math.isnan(float(value)):
improved = (
float(value) > best_value
if goal == "maximize"
else float(value) < best_value
)
if best_index is None or improved:
best_index = len(rows) - 1
best_value = float(value)
best_sia_seen = row["_sia"]
if row["reachable"]:
scores[member] = sign * row["objective"]
else:
scores[member] = _UNREACHABLE_PENALTY
generation += 1
return scores
# scipy replaces an exception raised by the objective with its own
# RuntimeError about the map-like callable; keep the original to re-raise.
failures: list[Exception] = []
def checked_objective(population: NDArray[Any]) -> NDArray[Any]:
try:
return batch_objective(population)
except Exception as error:
failures.append(error)
raise
try:
outcome = differential_evolution(
checked_objective,
box,
rng=np.random.default_rng(seed),
maxiter=maxiter,
popsize=popsize,
tol=tol,
x0=x0,
vectorized=True,
updating="deferred",
polish=False,
workers=1,
)
except Exception:
if failures:
raise failures[0] from None
raise
trajectory = pd.DataFrame(rows)
# Derive the best from the logged evaluations (not `outcome`) so
# best_params / best_sia / best_objective stay mutually consistent and
# match the table. The running best skips NaN (unreachable) rows and
# keeps the first occurrence on exact ties; the default RangeIndex makes
# `best_index` line up with the trajectory rows.
param_cols = [f"p{d}" for d in range(len(box))]
if best_index is not None:
best_row = best_index
best_objective = best_value
best_params = trajectory.iloc[best_row][param_cols].to_numpy(dtype=float)
best_sia = best_sia_seen
else:
# Every candidate was dynamically unreachable — no meaningful optimum.
best_objective = math.nan
best_params = np.asarray(outcome.x, dtype=float)
best_sia = None
best_substrate = builder(best_params)
return OptimizationResult(
best_params=best_params,
best_objective=float(best_objective),
best_substrate=best_substrate,
best_sia=best_sia,
trajectory=trajectory,
bounds=box,
seed=int(seed),
goal=goal,
quantity_name=quantity_name,
settings={
"backend": "scipy.differential_evolution",
"popsize": popsize,
"maxiter": maxiter,
"tol": tol,
},
config_snapshot={
"precision": config.numerics.precision,
"formalism": formalism,
},
n_evaluations=len(trajectory),
n_unreachable=int(np.count_nonzero(~trajectory["reachable"].to_numpy())),
)