Explore substrate parameter landscapes#
Every quantity PyPhi computes is a function of the substrate’s parameters:
change a connection weight and \(\varphi_s\) changes with it. pyphi.landscape
makes that function inspectable along one parameter axis —
landscape_section evaluates the system irreducibility analysis over a grid
of parameter values, and perturb estimates local derivatives at a single
point. Both track not just \(\varphi_s\) but the identity of every discrete
selection behind it, because the landscape’s most important features are the
places where a selection switches.
import numpy as np
import pyphi
pyphi.config.progress_bars = False
A parameter axis#
A parameter axis is any callable mapping a float to a Substrate. For
substrates built from a weight matrix, weight_axis varies a single
connection. Here we use the three-unit substrate of Figure 1A of the IIT 4.0
paper — the same network as pyphi.examples.iit4_2023_fig1a_substrate()
— taking its A→B coupling (published value 0.7) as the axis:
from pyphi.substrate_generator import ising
weights = np.array(
[
[-0.2, 0.7, 0.2],
[0.7, -0.2, 0.0],
[0.0, -0.8, 0.2],
]
)
axis = pyphi.weight_axis([ising.probability] * 3, weights, (0, 1), temperature=0.25)
A section through the landscape#
landscape_section analyzes the substrate at every grid point and returns a
tidy table:
section = pyphi.landscape_section(axis, (1, 0, 0), np.linspace(0.40, 0.75, 15))
section.df[["phi", "signed_phi", "normalized_phi", "partition_margin", "regime"]].round(4)
| phi | signed_phi | normalized_phi | partition_margin | regime | |
|---|---|---|---|---|---|
| theta | |||||
| 0.400 | 0.6850 | 0.6850 | 0.1370 | 0.0041 | 0 |
| 0.425 | 0.6816 | 0.6816 | 0.1363 | 0.0049 | 0 |
| 0.450 | 0.6781 | 0.6781 | 0.1356 | 0.0006 | 0 |
| 0.475 | 0.2602 | 0.2602 | 0.1301 | 0.0046 | 1 |
| 0.500 | 0.2439 | 0.2439 | 0.1219 | 0.0083 | 1 |
| 0.525 | 0.2280 | 0.2280 | 0.1140 | 0.0111 | 1 |
| 0.550 | 0.2127 | 0.2127 | 0.1063 | 0.0137 | 1 |
| 0.575 | 0.1979 | 0.1979 | 0.0989 | 0.0162 | 1 |
| 0.600 | 0.1837 | 0.1837 | 0.0919 | 0.0186 | 1 |
| 0.625 | 0.1702 | 0.1702 | 0.0851 | 0.0209 | 1 |
| 0.650 | 0.1574 | 0.1574 | 0.0787 | 0.0230 | 1 |
| 0.675 | 0.1453 | 0.1453 | 0.0726 | 0.0251 | 1 |
| 0.700 | 0.1339 | 0.1339 | 0.0669 | 0.0269 | 1 |
| 0.725 | 0.0000 | -0.0255 | 0.0000 | NaN | 2 |
| 0.750 | 0.0000 | -0.1001 | 0.0000 | NaN | 3 |
The regime column groups grid points by their selection regime: the
set of points where the minimum partition and the specified
cause and effect states are all the same. Within a regime, \(\varphi_s\) is a
smooth function of the parameter. At a regime boundary a selection switches,
and the reported \(\varphi_s\) can jump — here, the MIP switches near
\(\theta \approx 0.45\) and \(\varphi_s\) drops discontinuously, even though the
normalized value is continuous across that switch. boundaries brackets
every switch at the grid’s resolution:
section.boundaries
[(0.45, 0.47500000000000003), (0.7, 0.725), (0.725, 0.75)]
The raw analysis objects are kept alongside the table (section.sias), so
any point can be inspected in full detail.
Local derivatives and distance to a switch#
perturb evaluates three analyses (at \(\theta - h\), \(\theta\), \(\theta + h\))
and reports finite-difference derivatives. The default quantity is
signed_phi — the value before the positive-part clamp — because the
clamped phi is exactly flat wherever the raw integration is negative,
while the signed value retains gradient information:
result = pyphi.perturb(axis, (1, 0, 0), 0.7)
{
"value": round(result.value, 6),
"derivative": round(result.derivative, 4),
"same_regime": result.same_regime,
}
{'value': 0.133873, 'derivative': -0.4425, 'same_regime': True}
same_regime confirms all three evaluation points share one selection
regime, so the derivative describes a smooth stretch of the landscape. When
it is False, the estimate straddles a switch: trust only the one-sided
left_derivative and right_derivative, each on its own side.
The margins reported by the analysis (see
Control tie-breaking) are in the selections’ own value
units. perturb converts them into parameter units: switch_distances
divides each margin by the rate at which it is shrinking, giving a
first-order estimate of how far the parameter can move before that selection
switches.
{name: round(d, 5) for name, d in result.switch_distances.items()}
{'partition': 0.3687, 'cause_state': 0.00166, 'effect_state': 0.04585}
This substrate — the published Figure 1A system, at its published weights —
is about 0.0017 away, in the A→B weight, from a switch in the specified
cause state. Crossing it sends \(\varphi_s\) to zero: the published weights lie close to
a boundary where the substrate specifies a different past state. That
distance is invisible in \(\varphi_s\) itself and in its derivative; it is
what the margins and switch_distances are for. The estimate is
first-order, so check a candidate switch by evaluating on both sides.
before = pyphi.analyze(axis(0.700), (1, 0, 0), compute="sia")
after = pyphi.analyze(axis(0.704), (1, 0, 0), compute="sia")
(float(before.phi), tuple(before.system_state.cause.state)), (
float(after.phi),
tuple(after.system_state.cause.state),
)
((0.13387310087987658, (0, 1, 1)), (0.0, (0, 1, 0)))
Optimizing over weights#
landscape_section shows \(\varphi_s\) along an axis; pyphi.optimize
searches over one. Give it a builder mapping a parameter vector to a
substrate — weight_axes builds one, the vector analogue of weight_axis —
a box of bounds, and a seed. The optimizer is population-based and
gradient-free, so it is unaffected by the selection switches that make the
landscape piecewise-smooth; it maximizes signed normalized \(\varphi_s\) by
default, the quantity that stays continuous across those switches.
box_axis = pyphi.weight_axes(
[ising.probability] * 3, weights, [(0, 1), (1, 0)], temperature=0.25
)
result = pyphi.optimize(
box_axis, (1, 0, 0), [(0.1, 1.3), (0.1, 1.3)], seed=20260711, popsize=6, maxiter=8
)
float(result.best_objective), result.best_params
(0.14343706524276256, array([0.11986986, 1.23498072]))
Every evaluation is logged to result.to_pandas(), and
result.save("run_seed20260711.json") persists the complete result — the
winning substrate and its analysis, the trajectory, and the run metadata —
which pyphi.optimize.OptimizationResult.load reads back.
result.to_pandas().head()
| eval | generation | p0 | p1 | objective | reachable | partition | cause_state | effect_state | partition_margin | cause_state_margin | effect_state_margin | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0.757186 | 1.274370 | -0.002211 | True | [[0, 1], [2]]|[(0, 2), (1, 2), (2, 0), (2, 1)] | (0, 1, 0) | (0, 1, 1) | NaN | 0.119912 | 0.025012 |
| 1 | 1 | 0 | 0.385929 | 0.543437 | 0.128859 | True | [[0], [1], [2]]|[(0, 1), (0, 2), (1, 0), (1, 2... | (0, 1, 1) | (0, 1, 1) | 0.005397 | 0.739221 | 0.192418 |
| 2 | 2 | 0 | 0.253328 | 0.321744 | 0.097098 | True | [[0], [1], [2]]|[(0, 1), (0, 2), (1, 0), (1, 2... | (0, 1, 1) | (0, 1, 1) | 0.010318 | 0.805232 | 0.191432 |
| 3 | 3 | 0 | 1.086976 | 0.266514 | -0.034722 | True | [[0, 1], [2]]|[(0, 2), (1, 2), (2, 0), (2, 1)] | (0, 1, 0) | (0, 1, 0) | NaN | 0.374433 | 0.249597 |
| 4 | 4 | 0 | 0.119870 | 0.649331 | 0.133678 | True | [[0], [1], [2]]|[(0, 1), (0, 2), (1, 0), (1, 2... | (0, 1, 1) | (0, 1, 1) | 0.005561 | 1.220647 | 0.321941 |
Notes#
Setting a weight to exactly 0 removes the connection from the derived connectivity matrix — a discrete topology change. A section whose grid crosses 0 will show it as its own (typically reducible) regime.
Each grid point is one full SIA, so section cost scales linearly with the grid and exponentially with the number of units;
perturbcosts exactly three analyses.For sweeping the discrete axes of a fixed substrate — states and candidate subsystems — see Sweep states and subsystems.