Intrinsic units: analyzing systems at a macro grain#
Download this page as a Jupyter notebook
A system’s cause-effect power need not be maximal at the grain of its
smallest parts. The intrinsic-units framework of Marshall, Findlay,
Albantakis, and Tononi (2026) extends the IIT analysis to macro units:
groups of micro units, possibly evaluated over several micro updates, whose
joint state is read out by an explicit state mapping. PyPhi implements the
full framework in pyphi.macro: the macro TPM construction, the
intrinsic-unit criteria, and a bounded search that answers “which units, and
which grain, are intrinsic for this substrate in this state?”
This tutorial walks through the authors’ minimal example (Example 1 of the paper), then lets the search rediscover the coarse-graining example from the paper.
import pyphi
pyphi.config.progress_bars = False
The paper’s values were computed before the system’s intrinsic information became part of the definition of \(\varphi_s\), so this page pins the version of IIT the paper used (see Reproduce results from earlier versions of IIT).
import numpy as np
from pyphi import config
from pyphi.conf import presets
# Reproduce the paper's values under the version of IIT it used.
config.iit = presets.iit4_2023["iit"]
A minimal example, at the micro grain#
The substrate has two units, A and B. Each is nearly silent on its own,
weakly noisy, and strongly ON only when both are already ON (the rows are the
little-endian states 00, 10, 01, 11; the columns give each unit’s
probability of being ON at the next update):
from pyphi.substrate import Substrate
from pyphi.system import System
tpm = np.array(
[
[0.05, 0.05],
[0.05, 0.06],
[0.06, 0.05],
[0.95, 0.95],
]
)
substrate = Substrate(tpm, node_labels=("A", "B"))
state = (0, 0)
At the micro grain the system is barely integrated:
micro_phi = System(substrate, state).sia().phi
round(micro_phi, 6)
0.005107
Defining a macro unit#
A macro unit is specified by its direct constituents, an update grain, and a
mapping: a truth table over the constituents’ joint sequence-states that
says when the macro unit counts as ON. The helper
pyphi.macro.coarse_grain() builds mappings from ON-counts; here, “the
group is ON exactly when both constituents are ON”:
from pyphi.macro import MacroUnit, coarse_grain
coarse_grain(2, on_counts={2})
(0, 0, 0, 1)
alpha = MacroUnit(
constituents=(0, 1),
update_grain=1,
mapping=coarse_grain(2, on_counts={2}),
)
pyphi.macro.blackbox() builds the other common family, where the macro
state reads out designated output constituents at the final update of a
window — for example blackbox(2, update_grain=1, output_constituents=(0,))
is the table (0, 1, 0, 1). Update grains above 1 evaluate the unit over a
sliding window of several micro updates; see pyphi.macro.units.
from pyphi.macro import blackbox
blackbox(2, update_grain=1, output_constituents=(0,))
(0, 1, 0, 1)
Analyzing the macro system#
pyphi.macro.MacroSystem.from_micro() builds the macro cause and effect
TPMs by the paper’s four-step construction and yields an object the IIT
pipeline consumes exactly like a System:
from pyphi.macro import MacroSystem
macro = MacroSystem.from_micro(substrate, (alpha,), state)
macro.state
(0,)
macro_phi = macro.sia().phi
round(macro_phi, 6)
0.788334
Macroing raised the system’s integrated information by two orders of magnitude — the framework’s central phenomenon.
Is the macro unit intrinsic?#
A grouping does not automatically count as one unit. A candidate is an
intrinsic unit only if its constituent system is integrated (Eq. 16) and
strictly more irreducible than every competing system that could be built
within its footprint (Eq. 17). pyphi.macro.is_intrinsic_unit() returns
a verdict with the evidence:
from pyphi.macro import is_intrinsic_unit
verdict = is_intrinsic_unit(substrate, alpha, state)
verdict.valid, round(verdict.phi, 6), verdict.num_competitors
(True, 0.005107, 2)
The candidate’s two competitors (the single-unit systems over A and over
B) both have \(\varphi_s = 0\), so the pair wins. The verdict depends only on
the candidate’s constituents and background apportionment — not on its
mapping or grain — so every variant of the same grouping, whatever its
mapping, shares it. Micro units themselves are exempt (they are the base
case of the recursion), even when their own \(\varphi_s\) is zero:
from pyphi.macro import micro_unit
verdict = is_intrinsic_unit(substrate, micro_unit(0), state)
verdict.valid, verdict.phi
(True, 0.0)
Searching across grains#
pyphi.macro.complexes() is the one-call driver: it derives every
intrinsic unit within the search bounds, assembles every admissible system of
them (Eq. 19), evaluates each over the full universe, and condenses the
candidates into the complexes by the recursive exclusion cascade — accept
the φₛ-maximal candidate, exclude everything overlapping it, and continue on
the remainder (Eq. 20 applied tier by tier; a candidate excluded by an
accepted complex has no standing to exclude others). The same search is
also available as pyphi.analyze(substrate, state, grains=True). The winners are
Complex objects, returned together with the
full evaluation record:
from pyphi.macro import SearchBounds, complexes
result = complexes(substrate, state, SearchBounds(mappings="EXHAUSTIVE"))
len(result.complexes)
1
winner = result.complexes[0]
winner.units
(MacroUnit(constituents=(0, 1), update_grain=1, mapping=(0, 0, 0, 1), background_apportionment=()),)
The search, given every possible 2-constituent mapping, finds exactly the both-ON coarse-graining we built by hand. The winner reports its own φₛ and the record holds every evaluated system:
round(float(winner.phi), 6), len(result.records)
(0.788334, 10)
Candidate mappings are enumerated up to state-label complementation (a
mapping and its complement describe the same physical unit, with the two
macro state labels swapped), and ties at the configured precision are
respected: overlapping candidates that tie on φₛ escalate to Φ
(Composition), and a clique that still ties fails exclusion — none of its
members is a complex, the clique is reported in result.ties, and the
cascade continues past it.
Rediscovering the paper’s coarse-graining example#
Example 2 of the paper is a four-unit substrate built from two interacting pairs. Its micro system has \(\varphi_s\) of about \(0.02\), but the authors show that coarse-graining each pair into a both-ON macro unit yields a two-unit macro system with \(\varphi_s\) of about \(1.004\). The default search bounds (one macroing level, update grain 1, the coarse-graining and black-boxing mapping families) recover that analysis from scratch:
tpm4 = np.array(
[
[0.05, 0.05, 0.05, 0.05],
[0.06, 0.15, 0.05, 0.05],
[0.15, 0.06, 0.05, 0.05],
[0.16, 0.16, 0.85, 0.85],
[0.05, 0.05, 0.06, 0.15],
[0.06, 0.15, 0.06, 0.15],
[0.15, 0.06, 0.06, 0.15],
[0.16, 0.16, 0.86, 0.95],
[0.05, 0.05, 0.15, 0.06],
[0.06, 0.15, 0.15, 0.06],
[0.15, 0.06, 0.15, 0.06],
[0.16, 0.16, 0.95, 0.86],
[0.85, 0.85, 0.16, 0.16],
[0.86, 0.95, 0.16, 0.16],
[0.95, 0.86, 0.16, 0.16],
[0.96, 0.96, 0.96, 0.96],
]
)
substrate4 = Substrate(tpm4, node_labels=("A", "B", "C", "D"))
result = complexes(substrate4, (0, 0, 0, 0)) # a few seconds
len(result.complexes)
1
for unit in result.complexes[0].units:
print(unit.constituents, unit.mapping)
(0, 1) (0, 0, 0, 1)
(2, 3) (0, 0, 0, 1)
The unique complex is exactly the paper’s macro system: both-ON
coarse-grainings over (A, B) and (C, D).
Blackboxing#
Coarse-graining and blackboxing are the two mapping families the default search enumerates, and they read a group’s joint state in opposite ways. Coarse-graining pools the constituents by how many are ON, so every joint state with the same ON-count collapses to the same macro state. Blackboxing instead reads out a chosen subset of output constituents and discards the rest. Only blackboxing extends to update grains above one: reading a designated output at the final micro update of a window is exactly what a unit spanning several updates does, so the temporal search in the next section is built on this family.
The two helpers show the difference. Each returns a mapping — a truth table over the group’s joint states — for a two-constituent group:
print("coarse_grain(2, (0, 2)):", coarse_grain(2, (0, 2)))
print("blackbox(2, 1, (0,)): ", blackbox(2, 1, (0,)))
coarse_grain(2, (0, 2)): (1, 0, 0, 1)
blackbox(2, 1, (0,)): (0, 1, 0, 1)
The coarse-graining (1, 0, 0, 1) is ON for the joint states 00 and 11
(ON-counts 0 and 2) and OFF for 10 and 01; it reports whether the two
constituents agree. The blackboxing (0, 1, 0, 1) copies constituent 0 and
ignores constituent 1: its macro state is ON exactly when A is ON,
whatever B does.
A blackboxed unit is analyzed by the same pipeline as a coarse-grained one.
Here is the blackboxing of the tutorial’s two-unit substrate that reads out
A, built into a MacroUnit and analyzed exactly as alpha was:
boxed = MacroUnit(
constituents=(0, 1),
update_grain=1,
mapping=blackbox(2, 1, (0,)),
)
boxed_macro = MacroSystem.from_micro(substrate, (boxed,), state)
boxed_phi = boxed_macro.sia().phi
boxed_macro.state, round(float(boxed_phi), 6)
((0,), 0.253297)
Temporal grains#
A macro unit may exist over several micro updates as well as over several micro units. A unit with update grain τ = 2 has a state defined by a mapping over two-step sequences of its constituents, so searching a substrate for such units requires a micro history of two states — the two most recent universe states, oldest first — rather than a single current state.
The following substrate is one where a temporal unit wins. It is a three-unit
deterministic system given as a function table: entry i is the index of the
state the system moves to from state i, with states written in little-endian
order (A is bit 0). A short loop turns the table into an 8 × 3
state-by-node TPM:
# state index -> next-state index; little-endian, A = bit 0
fn_table = [2, 3, 4, 3, 0, 0, 5, 0]
tpm = np.zeros((8, 3))
for state_index, next_index in enumerate(fn_table):
for bit in range(3):
tpm[state_index, bit] = (next_index >> bit) & 1
substrate = Substrate(tpm, node_labels=("A", "B", "C"))
history = [(0, 0, 1), (0, 0, 0)]
Searching with max_update_grain=2 lets the driver build units that span two
micro updates. Each complex reports its footprint, its φₛ, and the update
grain of each of its units:
result = pyphi.analyze(
substrate, history, grains=SearchBounds(max_update_grain=2)
)
for complex_ in result.complexes:
grains = [unit.micro_grain for unit in complex_.units]
print(complex_.node_indices, f"{float(complex_.phi):.4f}", grains)
(0,) 0.5083 [2]
(1, 2) 0.4630 [2]
Both complexes are temporal: each is a single update-grain-2 unit — {A} with
φₛ ≈ 0.5083 and {B, C} with φₛ ≈ 0.4630. The winner is the temporal unit
over A:
winner = result.maximal_complex
winner.units[0], round(winner.exclusion_margin, 6)
(MacroUnit(constituents=(0,), update_grain=2, mapping=(0, 0, 1, 1), background_apportionment=()),
0.300787)
Its exclusion_margin is how far it beats the strongest overlapping
alternative. The whole substrate {A, B, C} evaluated at micro time (every unit at
update grain 1) is itself integrated, and appears in the winner’s excluded
record:
micro_universe = max(
(
candidate
for candidate in winner.excluded
if candidate.node_indices == (0, 1, 2)
and all(unit.update_grain == 1 for unit in candidate.units)
),
key=lambda candidate: candidate.phi,
)
print(micro_universe.node_indices, round(micro_universe.phi, 4))
(0, 1, 2) 0.2075
The full micro universe reaches φₛ ≈ 0.2075, yet the temporal unit over A
alone reaches 0.5083. That unit reads A every second step, and that view of
the substrate has more than twice the integrated information of the whole
system read update by update.
Temporal wins are not automatic. On a symmetric substrate — a deterministic rotation, say — the integration criterion rejects a pair decomposition before any temporal variant of it is ever built, so no unit longer than one update survives. Asymmetric substrates are what make temporal units win outright. The mixed-grain case occurs too, where a single complex holds units of the same system at different grains.
Bounding the search#
The space of groupings, mappings, and grains grows combinatorially, so the
search is explicitly bounded by pyphi.macro.SearchBounds:
max_constituents— cap on a unit’s micro footprint (default 4);max_update_grain— largest update grain per level (default 1; set 2+ to search macroing over updates);max_depth— macroing levels above micro (default 1; higher levels build units out of already-validated meso units);mappings—"FAMILIES"(coarse-grainings and black-boxings, the default) or"EXHAUSTIVE"(every surjective table, capped byexhaustive_capsequence-states);apportionment/max_background— opt-in enumeration of background apportionments (Eq. 13/29).
Every \(\varphi_s\) evaluation in a driver run is memoized, and
result.records exposes all of them, so the derivation is fully inspectable:
pyphi.macro.intrinsic_units() returns the unit pool with one verdict
per judged decomposition, and pyphi.macro.valid_systems() the
admissible-system set.
Because a system’s \(\varphi_s\) is at most its intrinsic information, the
drivers skip partition sweeps whose outcome is already certified: a candidate
whose intrinsic information is strictly below an overlapping accepted
complex’s \(\varphi_s\) cannot change the outcome. Skipped candidates appear in
result.records with gated=True, carrying their ceiling in place of an
exact \(\varphi_s\). Results are identical either way; pass prune="off" to
evaluate every candidate exactly. (The version of IIT pinned on this page
does not bound \(\varphi_s\) this way, so here every candidate is evaluated.)
Parallelism#
A search reduces to many independent MacroSystem.sia() evaluations, which
can run across processes. Enable it by turning on the global switch and the
search’s own per-site option, or pass parallel_kwargs to a driver for a
one-off:
with config.override(
parallel=True,
parallel_macro_system_evaluation={"parallel": True},
):
result = complexes(substrate4, (0, 0, 0, 0))
Results are identical to a sequential run — same complexes, ties, and records
in the same order. Within a worker the per-evaluation sia runs sequentially
(search-level parallelism is one process-pool deep); for a single very large
evaluation, leave the search-level option off and let the partition-level
parallelism inside sia do the work instead. The benefit grows with the
number of comparably sized evaluations — large substrates and EXHAUSTIVE
sweeps — and is slight for small searches dominated by one expensive system.