Query relational structure#

In IIT 4.0, a cause-effect structure is a set of distinctions together with the relations among them. A relation is any set of two or more distinctions whose purviews overlap in a congruent way, and its integrated information \(\varphi_r\) contributes to the system’s Φ. Relations are the combinatorial bottleneck: a structure with |D| distinctions can have up to 2^{|D|} − 1 relations, and |D| itself can reach 2^n − 1 for n units. The IIT 4.0 paper’s Figure 6D — 27 distinctions — has 1,537,080 relations, and its Φ is dominated by the relational part.

But the relation set is fully determined by the distinctions: each distinction contributes a set of state-tagged purview units (its “atoms”) and a φ density, and every question about the relations is a function of those. PyPhi 2.0 exposes that fact as a query interface. A relation set answers structural questions — sums, counts, spectra, histograms, the strongest few, unbiased samples — without ever enumerating the relations, in closed form and in milliseconds, at any scale.

This guide walks through the interface on a small network where we can also enumerate the relations directly and check that the two agree. For the theory of distinctions and relations, see Distinctions and relations.

import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

import pyphi
from pyphi import examples
from pyphi.conf import config
from pyphi.formalism import iit4
from pyphi.measures.distribution import (
    resolve_mechanism_measure,
    resolve_system_measure,
)
from pyphi.relations import AnalyticalRelations, ConcreteRelations

sns.set_theme(style="whitegrid", context="notebook")
config.progress_bars = False  # keep the output readable


# Compute a cause-effect structure (distinctions and relations) for a system,
# using the measures selected by the active configuration.
def compute_ces(system):
    return iit4.ces(
        system,
        system_measure=resolve_system_measure(config.formalism.iit.system_phi_measure),
        specification_measure=resolve_mechanism_measure(
            config.formalism.iit.specification_measure
        ),
    )

1. A cause-effect structure and its relations#

We use grid3, a three-unit network small enough to enumerate every relation explicitly, yet rich enough to have relations of several degrees.

ces = compute_ces(examples.grid3_system())
ces
CauseEffectStructure
Φ6.49857
φs0.0246659
Distinctions7
Σφd2.72171
Relations39
Σφr3.77686
Distinctions
MechanismφdCause purviewEffect purview
a0.801641 aa
b0.784193 bb
c0.801641 cc
ab0.0987274aab
ac0.0571425bb
bc0.0987274cbc
abc0.0796379babc

2. Two backends, one set of answers#

A relation set is computed by one of two backends, selected with config.formalism.iit.relation_computation:

  • CONCRETE builds every relation as an explicit object. Exact and fully general, but the output grows exponentially with the number of distinctions.

  • ANALYTICAL (the default) stores only the distinction set and answers queries in closed form. It never materializes a relation.

Both answer the same questions. Here we build each backend from the same distinctions and confirm the three summary quantities agree.

concrete = ces.relations.materialize()               # the enumerated relations
analytical = AnalyticalRelations(ces.distinctions)   # the closed-form view

pd.DataFrame(
    {
        "concrete (enumerated)": [
            concrete.num_relations(),
            concrete.sum_phi(),
            concrete.apportioned_sum_phi(),
        ],
        "analytical (closed form)": [
            analytical.num_relations(),
            analytical.sum_phi(),
            analytical.apportioned_sum_phi(),
        ],
    },
    index=["num_relations", "Σφ_r", "Σφ_r / |r|"],
)
concrete (enumerated) analytical (closed form)
num_relations 39.000000 39.000000
Σφ_r 3.776862 3.776862
Σφ_r / |r| 3.058222 3.058222

Every query below works on both backends. On ConcreteRelations it iterates the enumerated relations; on AnalyticalRelations it is a closed-form expression over the distinctions. We run the analytical backend from here on and check it against the enumeration where it is cheap to do so.

3. Closed-form structural queries#

Degree spectrum#

The degree of a relation is its number of relata. degree_spectrum() returns, for each degree, how many relations have it and how much \(\varphi_r\) they contribute — exactly, without listing a single relation. (Degree 1 is the self-relations: a distinction’s cause overlapping its own effect.)

spectrum = analytical.degree_spectrum()

spectrum_df = pd.DataFrame(
    [
        {"degree": degree, "count": count, "sum_phi_r": sum_phi}
        for degree, (count, sum_phi) in spectrum.items()
    ]
)
spectrum_df
degree count sum_phi_r
0 1 7 2.569890
1 2 14 0.615056
2 3 12 0.409823
3 4 5 0.155548
4 5 1 0.026546
long = spectrum_df.melt(
    id_vars="degree", value_vars=["count", "sum_phi_r"], var_name="quantity"
)
grid = sns.catplot(
    data=long,
    x="degree",
    y="value",
    col="quantity",
    kind="bar",
    height=3.5,
    aspect=1.1,
    sharey=False,
    color="#0072B2",
)
grid.set_titles("{col_name}")
grid.figure.suptitle("grid3 relation degree spectrum (exact)", y=1.05)
plt.show()
../_images/46b8e55b17138260a63745b5c1e9ce02a747604537faa80d40523f67a266790d.png

Moments, extremes, and faces#

The \(\varphi_r\) distribution’s mean and standard deviation, the single strongest relation’s \(\varphi_r\), and the total number of relation faces are all closed-form. The basic statistics come from the count and the first two moments; max_phi scans only pairs and self-relations (the maximum is provably attained at degree ≤ 2); num_faces counts faces by Möbius inversion over the individual causes and effects.

mean, std = analytical.phi_mean_std()
print(f"φ_r mean ± std:  {mean:.4f} ± {std:.4f}")
print(f"max φ_r:         {analytical.max_phi():.4f}")
print(f"Σφ_r² (2nd mom): {analytical.sum_phi_moment(2):.4f}")
print(f"total faces:     {analytical.num_faces()}")

# these match the enumeration exactly
assert analytical.max_phi() == max(float(r.phi) for r in concrete)
assert analytical.num_faces() == concrete.num_faces()
print("\n(analytical == concrete enumeration)")
φ_r mean ± std:  0.0968 ± 0.2021
max φ_r:         0.8016
Σφ_r² (2nd mom): 1.9592
total faces:     297

(analytical == concrete enumeration)

The exact \(\varphi_r\) histogram#

phi_histogram() returns the exact distribution of \(\varphi_r\) values over all relations, grouped at the configured numerical precision. On a million-relation structure this is the same closed-form computation and just as fast.

hist = analytical.phi_histogram()
hist_df = (
    pd.DataFrame({"phi_r": list(hist.keys()), "count": list(hist.values())})
    .sort_values("phi_r")
    .reset_index(drop=True)
)
hist_df["φ_r"] = hist_df["phi_r"].map(lambda v: f"{v:.3f}")

ax = sns.barplot(data=hist_df, x="φ_r", y="count", color="#0072B2")
ax.set_title("grid3 exact φ_r histogram")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.show()

print(f"distinct φ_r values: {len(hist)}")
print(f"counts sum to num_relations: {sum(hist.values())} == {analytical.num_relations()}")
../_images/d255d6bf2e66f541f41b8234620d9b058fb4537aa1c2827fce59c4aa92d277c1.png
distinct φ_r values: 6
counts sum to num_relations: 39 == 39

The binding matrix#

binding_matrix() is the relational skeleton of the structure: entry (a, b) is the total strength of the relations that jointly bind the state-tagged units a and b. It is one closed-form expression per pair of atoms — the natural input for asking which units are bound to which, and how strongly.

bm = analytical.binding_matrix()
bm.index = [str(a) for a in bm.index]
bm.columns = [str(a) for a in bm.columns]

ax = sns.heatmap(bm, annot=True, fmt=".3f", cmap="rocket_r", square=True, cbar_kws={"label": "binding strength"})
ax.set_title("grid3 atom-pair binding matrix")
plt.tight_layout()
plt.show()
../_images/93cc27faedf5cecf5abed17f47f7aa3aa807923d79de77160b9b8a4f618df71b.png

Maximal relations: the facets of the complex#

The relations of degree ≥ 2 are downward-closed: every subset (with at least two members) of a relation’s relata is itself a relation, with equal or larger overlap and equal or larger \(\varphi_r\). The whole family is therefore determined by its inclusion-maximal members — the facets of the relation complex — and these are closed-form: a set of distinctions relates exactly when some atom is shared by all its members, so the facets are the maximal atom groups. There are at most as many facets as atoms.

maximal_relations() returns them as an ordinary ConcreteRelations, so the whole query surface applies to the facet set. Note that facets are a topological skeleton, not a φ summary: \(\varphi_r\) never increases along a chain of nested relations, so the facets are the \(\varphi_r\)-weakest members of their chains, and \(\Sigma\varphi_r\) lives mostly in their sub-relations.

facets = analytical.maximal_relations()
facets.to_pandas()
relata phi degree purview
0 ((A,), (A, B), (A, B, C)) 0.026546 3 (a,)
1 ((A, B, C), (B, C), (C,)) 0.026546 3 (c,)
2 ((A, B), (A, B, C), (A, C), (B,), (B, C)) 0.026546 5 (b,)
# Every non-self relation is contained in some facet.
non_self = [r for r in concrete if not r.is_self_relation]
assert all(
    any(frozenset(r) <= frozenset(f) for f in facets) for r in non_self
)
print(f"{len(non_self)} relations covered by {facets.num_relations()} facets")
32 relations covered by 3 facets

maximal_relations_by_distinction() maps each distinction to the facets containing it (an isolated distinction gets an empty tuple), and maximal_faces() gives the analogous maximal elements one level down, over individual causes and effects.

4. The strongest relations, lazily#

strongest(k) yields relations one at a time in exact descending \(\varphi_r\) order, and stops after k. Because \(\varphi_r\) never increases when a relatum is added, a best-first search produces the global order without materializing the rest — so the top few of a million-relation structure cost a handful of operations, not a million.

top = list(analytical.strongest(k=5))

pd.DataFrame(
    [
        {
            "relata": ", ".join(r.labeled_mechanisms),
            "degree": len(r),
            "phi_r": float(r.phi),
        }
        for r in top
    ]
)
relata degree phi_r
0 a 1 0.801641
1 c 1 0.801641
2 b 1 0.784193
3 ac 1 0.057143
4 ac, b 2 0.057143

5. Sampling for questions without a closed form#

For arbitrary per-relation quantities — a predicate on degree, on which units a relation touches, on any property you can write as a function — sample(n, seed=...) draws relations with probability proportional to their overlap and returns an unbiased estimate with a standard error. The draw is reproducible from the seed.

Here we estimate the number of degree-2 (non-self) relations and watch the estimate converge to the exact value as the sample grows.

exact_degree2 = sum(1 for r in concrete if not r.is_self_relation and len(r) == 2)

rows = []
for n in [100, 300, 1000, 3000, 10000, 30000]:
    sample = analytical.sample(n, seed=0)
    estimate, stderr = sample.estimate(lambda r: 1.0 if len(r) == 2 else 0.0)
    rows.append({"n": n, "estimate": estimate, "stderr": stderr})
conv = pd.DataFrame(rows)

fig, ax = plt.subplots(figsize=(6, 4))
ax.errorbar(conv["n"], conv["estimate"], yerr=conv["stderr"], marker="o", capsize=4, label="estimate ± SE")
ax.axhline(exact_degree2, color="#C44E52", ls="--", label=f"exact = {exact_degree2}")
ax.set_xscale("log")
ax.set_xlabel("sample size n")
ax.set_ylabel("estimated # degree-2 relations")
ax.set_title("Horvitz–Thompson estimate converges to the exact count")
ax.legend()
plt.tight_layout()
plt.show()

conv
../_images/39d005e6c18d6ba139c80da0c78f7cd6d280c0154e47d6a71738ffa658259b06.png
n estimate stderr
0 100 13.090000 1.563220
1 300 14.733333 0.916422
2 1000 14.501000 0.500348
3 3000 14.444333 0.288614
4 10000 14.159300 0.157676
5 30000 14.022733 0.090594

6. Distinction importance and folds#

distinction_importance() ranks each distinction by its additive contribution to Φ — its own φ plus its share of every relation it participates in. These contributions tile Φ exactly: they sum to big_phi.

importance = ces.distinction_importance()

imp_df = pd.DataFrame(
    [
        {"mechanism": str(tuple(d.mechanism)), "contribution": contribution}
        for d, contribution in importance
    ]
)

ax = sns.barplot(data=imp_df, x="mechanism", y="contribution", color="#0072B2")
ax.set_title("grid3 distinction importance (contributions tile Φ)")
plt.tight_layout()
plt.show()

print(f"Σ contributions = {imp_df['contribution'].sum():.4f}  (Φ = {ces.big_phi:.4f})")
../_images/47f1a9a52de2c0f1bd632353fc4bda4d4fa8a796eff3d59762f1abc0a6e027ee.png
Σ contributions = 6.4986  (Φ = 6.4986)

A fold is the slice of the structure seeded by a set of distinctions: those distinctions plus every relation incident to them. Every query above works on a fold, restricted to the incident relations. Here we fold on the single most important distinction and confirm the fold’s relation count and \(\Sigma\varphi_r\) match the relations that actually touch it.

top_distinction, _ = importance[0]
fold = ces.fold([top_distinction])

seed = {top_distinction}
incident = ConcreteRelations(r for r in concrete if not seed.isdisjoint(r))

pd.DataFrame(
    {
        "fold (closed form)": [fold.relations.num_relations(), fold.relations.sum_phi()],
        "incident (enumerated)": [incident.num_relations(), incident.sum_phi()],
    },
    index=["num_relations", "Σφ_r"],
)
fold (closed form) incident (enumerated)
num_relations 16.000000 16.000000
Σφ_r 1.349885 1.349885

7. Why this scales#

Everything above ran on a 3-unit network so we could enumerate the relations and check the closed forms against them. The point of the analytical backend is what happens when you cannot enumerate.

The cost of every closed-form query scales with the number of distinctions and the number of atoms (at most 2n for n binary units) — not with the number of relations. So the same calls that took microseconds here take milliseconds on the IIT 4.0 paper’s Figure 6D structure, where the relations number in the millions:

grid3 (this guide)

Figure 6D

distinctions

7

27

relations

39

1,537,080

enumerate every relation

instant

≈ 50 s, ≈ 1.4 GiB

num_relations(), sum_phi(), degree_spectrum(), …

instant

≈ milliseconds

Past roughly 35 distinctions the enumeration does not fit in memory at all, while the analytical queries are unaffected. num_relations() already reflects this: it returns the exact count — 1,537,080 for Figure 6D — as a closed-form expression, having built no relations. When you do need explicit relation objects, materialize(max_degree=..., min_phi=...) builds a bounded subset of them, and strongest(k) streams the important ones in order.

The analytical backend is the default, so every relation set a computation returns answers these queries in closed form. To work with enumerated relation sets throughout, set

pyphi.config.relation_computation = "CONCRETE"