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; perturb costs exactly three analyses.

  • For sweeping the discrete axes of a fixed substrate — states and candidate subsystems — see Sweep states and subsystems.