Intrinsic units: analyzing systems at a macro grain#

Download this page as a Jupyter notebook Open in Colab

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.

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.