Source code for pyphi.relations

# relations.py
"""Implements the formalism for computing relations."""

from __future__ import annotations

import heapq
import itertools
import math
import random
import statistics
from collections import Counter
from collections import defaultdict
from collections.abc import Iterable
from collections.abc import Iterator
from functools import cached_property
from itertools import product
from typing import TYPE_CHECKING
from typing import Any
from typing import NoReturn

import pandas as pd
from tqdm.auto import tqdm

from . import combinatorics
from . import conf
from . import numerics
from .conf import config
from .conf import fallback
from .direction import Direction
from .display import Description
from .display import Displayable
from .display import Row
from .display import Section
from .display import Table
from .display.numbers import format_value
from .display.tables import capped_table
from .models import cmp
from .models.distinctions import ResolvedDistinctions
from .models.pandas import ToPandasMixin
from .models.pandas import records_to_frame
from .parallel import map_reduce
from .registry import Registry
from .serializable import Serializable

if TYPE_CHECKING:
    from .formalism.iit4 import Distinction  # type: ignore[attr-defined]


def _restore_relation_face(cls, contents, phi):
    """Reconstruct a :class:`RelationFace` from pickled state."""
    return cls(contents, phi=phi)


[docs] class RelationFace(Displayable, ToPandasMixin, cmp.OrderableByPhi, frozenset): """A set of (potentially) related causes/effects. Ordering compares φ (via :class:`~pyphi.models.cmp.OrderableByPhi`); equality and hashing are set semantics inherited from ``frozenset``. """ phi: float # Set in __new__ def __new__(cls, *args, phi=None): self = super().__new__(cls, *args) if phi is None: raise ValueError("phi keyword argument is required") # Preserve DistanceResult type if possible, otherwise convert to float from pyphi.measures.distribution import DistanceResult if isinstance(phi, DistanceResult): self.phi = phi # type: ignore[misc] # frozenset is immutable but we set this in __new__ else: self.phi = float(phi) # type: ignore[misc] # frozenset is immutable but we set this in __new__ return self # Orderable.__eq__ raises NotImplementedError; keep frozenset's set # semantics for equality and hashing. Comparisons (φ order) come from # OrderableByPhi, which precedes frozenset in the MRO. def __eq__(self, other: object) -> bool: return frozenset.__eq__(self, other) def __ne__(self, other: object) -> bool: return frozenset.__ne__(self, other) def __hash__(self) -> int: return frozenset.__hash__(self) # frozenset's default __reduce__ reconstructs via ``cls(list(self))``, # which omits the required ``phi`` keyword; route pickling (and hence # copy/deepcopy) through a reconstructor that passes it. Cached # properties are recomputable and deliberately not carried. def __reduce__(self): return (_restore_relation_face, (type(self), list(self), self.phi))
[docs] @cached_property def overlap(self): """The set of elements that are in the purview of every relatum.""" return set.intersection(*map(set, self.relata_purviews))
[docs] @cached_property def congruent_overlap(self): """Return the congruent overlap(s) among the relata. These are the common purview elements among the relata whose specified states are consistent; that is, the largest subset of the union of the purviews such that each relatum specifies the same state for each element. """ return set.intersection(*self.relata_units)
# Alias @property def purview(self): """The purview of the relation face. Alias for ``congruent_overlap``.""" return self.congruent_overlap @property def relata_units(self): """The Units in the purview of each cause/effect in this face.""" return (set(relatum.purview_units) for relatum in self) @property def relata_purviews(self): """The purview of each cause/effect in this face.""" return (relatum.purview for relatum in self) @property def distinctions(self): """The distinctions whose causes/effects are in this face.""" return (relatum.parent for relatum in self) @property def num_distinctions(self): """The number of distinctions whose causes/effects are in this face.""" return len(set(self.distinctions)) def __bool__(self): return bool(self.congruent_overlap) def _pandas_record(self): return { "purview": tuple(sorted(self.purview)), "phi": float(self.phi), "degree": len(self), } def _describe(self, verbosity: int) -> Description: cls = type(self).__name__ return Description( title=cls, sections=( Section( rows=( Row("Purview", str(sorted(self.purview))), Row("Relata", len(self)), ), ), ), compact=f"{cls}(purview={sorted(self.purview)}, relata={len(self)})", ) # frozenset.__repr__ takes priority in the MRO; delegate to Displayable. def __repr__(self) -> str: return Displayable.__repr__(self) __str__ = __repr__ def _repr_html_(self) -> str: return Displayable._repr_html_(self)
[docs] class Relation(Displayable, ToPandasMixin, cmp.OrderableByPhi, frozenset): """A set of relation faces forming the relation among a set of distinctions. Ordering compares φ (via :class:`~pyphi.models.cmp.OrderableByPhi`); equality and hashing are set semantics inherited from ``frozenset``. """ @property def is_self_relation(self): return len(self) == 1 @property def _ordered_relata(self): """The relatum distinctions, ordered by mechanism index.""" return sorted(self, key=lambda d: tuple(d.mechanism)) @property def labeled_mechanisms(self): """The state-labeled mechanism of each relatum, ordered by mechanism index — for display. Each relatum is rendered as its distinction's ``mechanism_label`` (node labels cased by the mechanism state), falling back to the raw index tuple when a relatum carries no labels. """ return tuple( getattr(distinction, "mechanism_label", None) or str(tuple(distinction.mechanism)) for distinction in self._ordered_relata ) def _relatum_labels(self, distinction): node_labels = getattr(distinction, "node_labels", None) mechanism = distinction.mechanism if node_labels is None: return tuple(mechanism) return tuple(node_labels.coerce_to_labels(mechanism)) def _pandas_record(self): # Structured data for analysis: each relatum is a plain label tuple, # not a display string. Card formatting lives in ``relations_table``. return { "relata": tuple(self._relatum_labels(d) for d in self._ordered_relata), "phi": float(self.phi), "degree": len(self), "purview": tuple(sorted(self.purview)), } def _faces(self): """Yield faces of the relation.""" # Exclude single-relatum faces for self-relations as a special case if self.is_self_relation: direction_set = [Direction.BIDIRECTIONAL] else: direction_set = Direction.all() distinctions = list(self) for directions in product(direction_set, repeat=len(self)): mice = [] for direction, distinction in zip(directions, distinctions, strict=False): if direction is Direction.BIDIRECTIONAL: mice.extend([distinction.cause, distinction.effect]) else: mice.append(distinction.mice(direction)) face = RelationFace(mice, phi=self.phi) if face: yield face @cached_property def faces(self): return frozenset(self._faces()) @property def num_faces(self): return len(self.faces) @cached_property def purview(self): # Special case for self-relations if self.is_self_relation: distinction = next(iter(self)) return distinction.cause.purview_units & distinction.effect.purview_units return set.intersection(*(distinction.purview_union for distinction in self)) @cached_property def phi(self) -> float: # type: ignore[override] # Overrides OrderableByPhi.phi with cached_property return float( len(self.purview) * min(self.distinction_phi_per_unique_purview_unit()) ) def distinction_phi_per_unique_purview_unit(self): return (relatum.phi / len(relatum.purview_union) for relatum in self) def __bool__(self): return numerics.is_positive(self.phi) def __eq__(self, other: object) -> bool: if not isinstance(other, Relation): return NotImplemented return frozenset.__eq__(self, other) def __hash__(self) -> int: return frozenset.__hash__(self) @cached_property def mechanisms(self): return {distinction.mechanism for distinction in self} def _describe(self, verbosity: int) -> Description: cls = type(self).__name__ return Description( title=cls, sections=( Section( rows=( Row("φ_r", self.phi), Row("Purview", str(sorted(self.purview))), Row("Degree", len(self)), Row("Faces", self.num_faces), ), ), ), compact=f"{cls}(φ_r={format_value(self.phi)}, degree={len(self)})", )
def _relation_size_func(purview_unions): """Build a parent-side relation cost estimate over a combination. The cost of a relation rises with its overlap (the relation is computed over the intersection of the relata's purview unions) and its degree. Only the relative ordering matters, so this cheap proxy suffices for chunking. """ def cost(combination): overlap = set.intersection(*(purview_unions[i] for i in combination)) return len(overlap) * len(combination) return cost def _passes(relation, max_degree, min_phi): """Filter predicate shared by ``materialize`` and ``strongest``.""" if max_degree is not None and len(relation) > max_degree: return False if min_phi is not None: phi = float(relation.phi) if not (phi > min_phi or numerics.eq(phi, min_phi)): return False return True
[docs] def all_relations(distinctions, min_degree=2, max_degree=None, **kwargs): """Yield causal relations among a set of distinctions.""" # Self relations yield from _self_relations(distinctions) # Non-self relations combinations = _combinations_with_nonempty_congruent_overlap( distinctions, min_degree=min_degree, max_degree=max_degree ) # ``Relation`` is lazy (phi/faces are cached properties), so each mapped # item is nearly free (~µs) and parallel dispatch cost is dominated by # pickling the relations back — measured never to pay at any size # (benchmarks/b18_dispatch_gate.py), which is what the high # ``parallel_relation_evaluation`` sequential_threshold default encodes. # If relation evaluation gains real per-item cost (e.g. an eager or # expensive phi), force that work inside this worker and remeasure; # note eager phi also caches ~1 kB/relation (phi + purview) on objects # whose count grows combinatorially with the number of distinctions. def worker(combination): return Relation(distinctions[i] for i in combination) pkwargs = conf.parallel_kwargs( config.infrastructure.parallel_relation_evaluation, **kwargs ) result = map_reduce( worker, combinations, desc="Evaluating relations", size_func=_relation_size_func([d.purview_union for d in distinctions]), **pkwargs, # type: ignore[arg-type] # parallel_kwargs contains map_reduce params ) if result is not None: yield from result
def _self_relations(distinctions): return filter(None, (Relation([distinction]) for distinction in distinctions)) def _combinations_with_nonempty_congruent_overlap( components, min_degree=2, max_degree=None ): """Return combinations of distinctions with nonempty congruent overlap. Two distinctions can relate only if their purview-unions share a unit; a combination can relate only if all its members share a common unit, i.e. the intersection of their purview-unions is nonempty. Because the intersection compares :class:`UnitState` values — ``(index, state)`` pairs — congruence of the shared state is enforced here at candidate generation, so the family is exactly the Eq. 49/56 congruent overlaps. Parameters ---------- components : Distinctions The distinctions to find overlaps among. """ purview_unions = [frozenset(component.purview_union) for component in components] return combinatorics.combinations_with_nonempty_intersection( purview_unions, min_size=min_degree, max_size=max_degree ) def _atom_groups(distinctions, atoms=None): """Map each atom (a state-tagged unit) to the distinctions whose purview-union contains it. This is the incidence Z(n) of the S3 Appendix. When ``atoms`` is given, only those atoms are indexed. """ groups = defaultdict(set) for distinction in distinctions: for atom in distinction.purview_union: if atoms is None or atom in atoms: groups[atom].add(distinction) return groups def _maximal_sets(sets): """Return the maximal elements of a family of sets under inclusion.""" distinct = sorted(set(sets), key=len, reverse=True) maximal = [] for candidate in distinct: if not any(candidate < kept for kept in maximal): maximal.append(candidate) return maximal
[docs] def maximal_relations(distinctions, atoms=None): """Return the relations maximal under set inclusion of their relata. These are the facets of the relation complex: the relations (degree ≥ 2) form a downward-closed family, so every relation's relata are a subset of some maximal relation's. A set of distinctions is a relation exactly when it is contained in some Z(n) — the distinctions whose purview-union contains the state-tagged unit n [1]_ — and each Z(n) is itself a relation, so the maximal relations are the inclusion-maximal elements of {Z(n)}. No relations are enumerated; cost is quadratic in the number of atoms. Self-relations are excluded: the family is not downward-closed into degree 1 (a self-relation's overlap is the congruent intersection of one distinction's cause and effect purviews, which can be empty even when the distinction relates strongly to others). For φ_r-ranked relations see :meth:`Relations.strongest`. Parameters ---------- distinctions : Iterable The distinctions generating the relation complex. atoms : collection, optional If given, only relations whose overlap contains one of these atoms are considered. If None, all atoms count. Returns ------- ConcreteRelations The maximal relations, as lazy :class:`Relation` objects. References ---------- .. [1] Albantakis L, Barbosa L, Findlay G, Grasso M, et al. (2023). Integrated information theory (IIT) 4.0. PLoS Computational Biology 19(10): e1011465, S3 Appendix. """ groups = _atom_groups(distinctions, atoms) candidates = [frozenset(group) for group in groups.values() if len(group) >= 2] return ConcreteRelations(Relation(group) for group in _maximal_sets(candidates))
[docs] def maximal_faces(distinctions, atoms=None): """Return the relation faces maximal under set inclusion of their relata (causes/effects), across all relations. The face at atom n is M(n), the causes and effects whose purview contains n; every face of every relation is contained in some M(n), and M(n) is itself a face of the relation Z(n) (the distinctions whose purview-union contains n [1]_), so the maximal faces are the inclusion-maximal elements of {M(n)}. Each face carries the φ_r of the relation it is a face of. A maximal face's parent relation need not be a maximal relation, so the maximal faces are not obtainable from :func:`maximal_relations`. Parameters ---------- distinctions : Iterable The distinctions generating the relation complex. atoms : collection, optional If given, only faces whose overlap contains one of these atoms are considered. If None, all atoms count. Returns ------- frozenset[RelationFace] The maximal faces. References ---------- .. [1] Albantakis L, Barbosa L, Findlay G, Grasso M, et al. (2023). Integrated information theory (IIT) 4.0. PLoS Computational Biology 19(10): e1011465, S3 Appendix. """ groups = _atom_groups(distinctions, atoms) candidates = {} for atom, group in groups.items(): if len(group) < 2: continue sides = frozenset( side for distinction in group for side in (distinction.cause, distinction.effect) if atom in side.purview_units ) candidates.setdefault(sides, frozenset(group)) return frozenset( RelationFace(sides, phi=Relation(candidates[sides]).phi) for sides in _maximal_sets(candidates) )
[docs] def relations_table(relations: Relations) -> Table | None: """Capped display table of relations (relata, ``φ_r``, degree). Returns ``None`` for relation sets that are not row-enumerable (e.g. :class:`AnalyticalRelations`). The cap (``config.infrastructure.repr_max_table_rows``) bounds how many rows are materialized, so a huge relation set is not fully realized to display. """ try: iter(relations) # type: ignore[arg-type] except TypeError: return None return capped_table( ("Relata (mechanisms)", "φ_r", "Degree"), relations, # type: ignore[arg-type] # iterability guarded above lambda r: (", ".join(r.labeled_mechanisms), r.phi, len(r)), total=relations.num_relations(), )
[docs] class Relations(Displayable, ToPandasMixin, Serializable): """A set of relations among distinctions.""" def __init__(self, *args, **kwargs): self._num_relations_cached = None self._sum_phi_cached = None self._apportioned_sum_phi_cached = None def _to_pandas(self): rows = [ r._pandas_record() for r in self # type: ignore[attr-defined] # iterable in subclasses ] return records_to_frame(rows, columns=["relata", "phi", "degree", "purview"]) def sum_phi(self): if self._sum_phi_cached is None: self._sum_phi_cached = self._sum_phi() # type: ignore[attr-defined] # Defined in subclass return self._sum_phi_cached def apportioned_sum_phi(self): if self._apportioned_sum_phi_cached is None: self._apportioned_sum_phi_cached = self._apportioned_sum_phi() # type: ignore[attr-defined] # Defined in subclass return self._apportioned_sum_phi_cached def num_relations(self): if self._num_relations_cached is None: self._num_relations_cached = self._num_relations() # type: ignore[attr-defined] # Defined in subclass return self._num_relations_cached
[docs] def sum_phi_moment(self, k: int = 2) -> float: """Return Σφ_r^k over all relations, including self-relations.""" if k < 1: raise ValueError(f"moment order must be a positive integer: {k}") return math.fsum(float(relation.phi) ** k for relation in self) # type: ignore[attr-defined] # iterable in subclasses
[docs] def phi_mean_std(self) -> tuple[float, float]: """Return the population mean and standard deviation of φ_r. Derived from the count, Σφ_r, and Σφ_r², so it is exact on any backend that answers those queries without enumeration. Raises ------ ValueError If there are no relations. """ n = self.num_relations() if n == 0: raise ValueError("no relations to summarize") mean = self.sum_phi() / n variance = self.sum_phi_moment(2) / n - mean**2 return mean, math.sqrt(max(variance, 0.0))
[docs] def num_relations_of_degree(self, degree: int) -> int: """Return the number of relations with exactly ``degree`` relata. Degree 1 counts the self-relations. """ return sum(1 for relation in self if len(relation) == degree) # type: ignore[attr-defined] # iterable in subclasses
[docs] def sum_phi_of_degree(self, degree: int) -> float: """Return Σφ_r over relations with exactly ``degree`` relata.""" return math.fsum( float(relation.phi) for relation in self # type: ignore[attr-defined] # iterable in subclasses if len(relation) == degree )
[docs] def degree_spectrum(self) -> dict[int, tuple[int, float]]: """Return ``{degree: (count, Σφ_r)}`` over all relations. Degrees with no relations are omitted. The counts sum to ``num_relations()`` and the φ sums to ``sum_phi()``. """ counts: Counter[int] = Counter() sums: defaultdict[int, list[float]] = defaultdict(list) for relation in self: # type: ignore[attr-defined] # iterable in subclasses counts[len(relation)] += 1 sums[len(relation)].append(float(relation.phi)) return { degree: (counts[degree], math.fsum(sums[degree])) for degree in sorted(counts) }
[docs] def sum_phi_by_distinction(self, distinctions) -> tuple[float, ...]: """Return each distinction's incident Σφ_r, aligned to ``distinctions``. A distinction's incident Σφ_r is the sum of φ_r over every relation that contains it, including its self-relation. The result is a tuple parallel to ``distinctions``; a distinction that no relation reaches contributes ``0.0``. """ position = {tuple(d.mechanism): i for i, d in enumerate(distinctions)} sums = [0.0] * len(position) for relation in self: # type: ignore[attr-defined] # iterable in subclasses phi = float(relation.phi) for mechanism in relation.mechanisms: index = position.get(tuple(mechanism)) if index is not None: sums[index] += phi return tuple(sums)
[docs] def max_phi(self) -> float: """Return the maximum φ_r over all relations, or ``0.0`` if empty.""" # numerics: exact — the reported maximum, not a tolerant selection. return max( (float(relation.phi) for relation in self), # type: ignore[attr-defined] # iterable in subclasses default=0.0, )
[docs] def phi_histogram(self) -> dict[float, int]: """Return ``{φ_r: count}`` over all relations. Keys are grouped at the configured precision (:func:`pyphi.numerics.round_to_precision`), so mathematically equal values that differ by float noise share a bucket. Counts sum to ``num_relations()``. """ histogram: Counter[float] = Counter( numerics.round_to_precision(float(relation.phi)) for relation in self # type: ignore[attr-defined] # iterable in subclasses ) return dict(histogram)
[docs] def num_faces(self) -> int: """Return the total number of faces across all relations.""" return sum(relation.num_faces for relation in self) # type: ignore[attr-defined] # iterable in subclasses
[docs] def binding_matrix(self) -> pd.DataFrame: """Return the atom-pair binding matrix of the relational structure. Entry ``(a, b)`` is the total minimum density (``φ_r / |O|``) of the non-self relations whose congruent overlap contains both atoms — the strength with which the two unit-states are jointly bound by relations. The diagonal decomposes the apportioned relation strength per atom. Index and columns are the atoms (state-tagged units) incident to at least one non-self relation, sorted. Self-relations are excluded: the matrix measures binding between distinctions. """ weights: defaultdict[tuple, float] = defaultdict(float) atoms = set() for relation in self: # type: ignore[attr-defined] # iterable in subclasses if relation.is_self_relation: continue purview = sorted(relation.purview) atoms.update(purview) weight = float(relation.phi) / len(purview) for a in purview: for b in purview: weights[a, b] += weight ordered = sorted(atoms) matrix = pd.DataFrame(0.0, index=pd.Index(ordered), columns=pd.Index(ordered)) for (a, b), weight in weights.items(): matrix.loc[a, b] = weight return matrix
[docs] def strongest( self, k: int | None = None, min_phi: float | None = None, max_degree: int | None = None, ) -> Iterator[Relation]: """Yield relations in descending φ_r order. Ties in φ_r yield in an unspecified but deterministic order. Parameters ---------- k : int, optional Yield at most this many relations. If None, yield all. min_phi : float, optional Stop once φ_r falls below this threshold (compared tolerantly at the configured precision). max_degree : int, optional Skip relations with more than this many relata. """ if k is not None and k <= 0: return # Descending sort for a stream; the min_phi threshold below is tolerant. # numerics: exact — total order for streaming, not a tolerant selection. candidates = sorted(self, key=lambda r: float(r.phi), reverse=True) # type: ignore[attr-defined] # iterable in subclasses yielded = 0 for relation in candidates: if min_phi is not None: phi = float(relation.phi) if not (phi > min_phi or numerics.eq(phi, min_phi)): return if max_degree is not None and len(relation) > max_degree: continue yield relation yielded += 1 if k is not None and yielded >= k: return
[docs] def materialize( self, max_degree: int | None = None, min_phi: float | None = None ) -> ConcreteRelations: """Return the relations as an explicit :class:`ConcreteRelations`. Enumerates relation objects from a backend that otherwise answers queries in closed form. ``max_degree`` and ``min_phi`` (tolerant ``≥``) bound what is materialized. """ return ConcreteRelations( relation for relation in self # type: ignore[attr-defined] # iterable in subclasses if _passes(relation, max_degree, min_phi) )
def _facet_context(self) -> tuple[Iterable, frozenset | None]: """Return ``(distinctions, atoms)`` generating this set's relation complex: the participating distinctions and the atom filter (None means all atoms).""" return ({d for relation in self for d in relation}, None) # type: ignore[attr-defined] # iterable in subclasses
[docs] def maximal_relations(self) -> ConcreteRelations: """Return the relations maximal under set inclusion of their relata. The facets of the relation complex: every relation's relata are a subset of some maximal relation's. Degree ≥ 2 only; self-relations are excluded. Computed in closed form from the distinctions — on a filtered set (e.g. from :meth:`materialize` with bounds) the result is the facets of the complex generated by the participating distinctions, not of the filtered subset. For φ_r-ranked relations see :meth:`strongest`. """ distinctions, atoms = self._facet_context() return maximal_relations(distinctions, atoms=atoms)
[docs] def maximal_faces(self) -> frozenset: """Return the relation faces maximal under set inclusion of their relata (causes/effects), across all relations; see :func:`maximal_faces`.""" distinctions, atoms = self._facet_context() return maximal_faces(distinctions, atoms=atoms)
[docs] def maximal_relations_by_distinction(self, distinctions) -> tuple: """Return, for each distinction, the maximal relations containing it. Parallel to ``distinctions``; a distinction contained in no maximal relation (an isolated distinction) gets an empty tuple. Within each tuple, facets are ordered by their sorted mechanism tuples. """ facets = sorted( self.maximal_relations(), key=lambda r: tuple(sorted(tuple(d.mechanism) for d in r)), ) return tuple( tuple(facet for facet in facets if distinction in facet) for distinction in distinctions )
[docs] def sample(self, n: int, *, seed: int) -> RelationSample: """Draw a coverage-weighted sample of relations. Implemented on backends that hold the distinction set; see :meth:`AnalyticalRelations.sample`. """ raise NotImplementedError( f"{type(self).__name__} does not support sampling; use AnalyticalRelations" )
def _describe(self, verbosity: int) -> Description: cls = type(self).__name__ num_r = self.num_relations() sum_phi_r = self.sum_phi() table = relations_table(self) relations_section = ( (Section(label="Relations", body=(table,)),) if table is not None else () ) return Description( title=cls, sections=( Section( rows=( Row("Relations", num_r), Row("Σφ_r", sum_phi_r), ), ), *relations_section, ), compact=f"{cls}({num_r} relations, Σφ_r={format_value(sum_phi_r)})", )
[docs] class NullRelations(Relations): """An empty set of relations, for a cause-effect structure computed under a version of IIT that does not define relations (IIT 3.0). """ def __init__(self, *args, **kwargs): super().__init__() def __iter__(self): return iter(()) def _sum_phi(self): return 0 def _apportioned_sum_phi(self): return 0 def _num_relations(self): return 0 def __len__(self): return 0 # NullRelations carries no state (it is always empty), so all instances # are interchangeable: value equality, constant hash. def __eq__(self, other: object) -> bool: if not isinstance(other, NullRelations): return NotImplemented return True def __hash__(self) -> int: return hash("pyphi.relations.NullRelations")
[docs] class ConcreteRelations(frozenset, Relations): def _sum_phi(self): return sum(relation.phi for relation in self) def _apportioned_sum_phi(self): return sum(relation.phi / len(relation) for relation in self) def _num_relations(self): return len(self) # frozenset.__repr__ and __str__ take priority over Displayable in the MRO; # delegate explicitly so the unified display card is used instead. def __repr__(self) -> str: return Displayable.__repr__(self) __str__ = __repr__ def _repr_html_(self) -> str: return Displayable._repr_html_(self)
[docs] @cached_property def faces_by_degree(self): """Return a dictionary mapping degree to relation faces of that degree.""" faces = defaultdict(list) for relation in tqdm( self, desc="Grouping relation faces by degree", leave=False, ): for face in relation.faces: faces[len(face)].append(face) return dict(faces)
[docs] class RelationSample: """An i.i.d., coverage-weighted sample of non-self relations. Relations are drawn with probability proportional to the size of their congruent overlap ``|O(S)|`` — the number of atoms covering them — which is known exactly per sample, so any sum over non-self relations is estimable without bias by Horvitz-Thompson reweighting (the union-of-sets sampling scheme of Karp & Luby (1983)). Self-relations are never sampled: there are at most ``|D|`` of them, and their exact totals are carried on the sample so the convenience estimators cover all relations. Attributes ---------- relations : tuple[Relation, ...] The sampled relations, drawn with replacement. normalization : int The exact coverage-weighted total ``Σ_S |O(S)|`` over all non-self relations. seed : int The seed of the isolated random generator that produced the sample. num_self_relations : int The exact number of self-relations in the structure. sum_phi_self_relations : float The exact Σφ_r over the self-relations. """ def __init__( self, relations, normalization, seed, num_self_relations, sum_phi_self_relations, ): self.relations = tuple(relations) self.normalization = normalization self.seed = seed self.num_self_relations = num_self_relations self.sum_phi_self_relations = sum_phi_self_relations def __len__(self): return len(self.relations) def __iter__(self): return iter(self.relations) def __repr__(self): return ( f"{type(self).__name__}(n={len(self.relations)}, " f"normalization={self.normalization}, seed={self.seed})" )
[docs] def estimate(self, f) -> tuple[float, float]: """Return an unbiased estimate and standard error of ``Σ f(S)`` over all non-self relations. Parameters ---------- f : Callable[[Relation], float] The per-relation summand. """ if not self.relations: return 0.0, 0.0 values = [ self.normalization * float(f(relation)) / len(relation.purview) for relation in self.relations ] mean = math.fsum(values) / len(values) stderr = ( statistics.stdev(values) / math.sqrt(len(values)) if len(values) > 1 else float("nan") ) return mean, stderr
[docs] def num_relations(self) -> tuple[float, float]: """Return an estimate and standard error of the total relation count, including the exact self-relation count.""" estimate, stderr = self.estimate(lambda _: 1.0) return estimate + self.num_self_relations, stderr
[docs] def sum_phi(self) -> tuple[float, float]: """Return an estimate and standard error of Σφ_r over all relations, including the exact self-relation total.""" estimate, stderr = self.estimate(lambda relation: float(relation.phi)) return estimate + self.sum_phi_self_relations, stderr
[docs] class AnalyticalRelations(Relations): """A closed-form summary of the relations among a set of distinctions. Every query (``sum_phi``, ``num_relations``, degree spectra, ...) is a pure function of ``distinctions``, so two instances are equal exactly when their ``distinctions`` are equal — regardless of whether either was freshly computed, deserialized, or produced by a separate call. A :class:`ConcreteRelations` built from the same distinctions is never equal to one: the two are distinct representations, and comparing them would require materializing one into the other's form. """ def __init__(self, distinctions): self.distinctions = distinctions super().__init__() def __eq__(self, other: object) -> bool: if type(other) is not type(self): return NotImplemented return self.distinctions == other.distinctions def __hash__(self) -> int: return hash((type(self), self.distinctions)) def _not_enumerable(self, verb: str) -> NoReturn: raise TypeError( f"AnalyticalRelations is a closed-form summary and cannot be {verb}: " "it does not enumerate the relation set. Use .strongest(k) for the " "top-k relations by φ_r, .materialize() to enumerate all of them " "(may be expensive on large structures), or set " "pyphi.config.relation_computation = 'CONCRETE' to compute " "the full concrete relation set." ) def __iter__(self) -> NoReturn: self._not_enumerable("iterated") def __getitem__(self, index: object) -> NoReturn: self._not_enumerable("indexed") @cached_property def self_relations(self): return tuple(_self_relations(self.distinctions)) @cached_property def _atom_index(self): """Map each atom (a state-tagged unit) to the distinctions whose purview-union contains it. This incidence, together with each distinction's φ density, generates the entire relational structure (Albantakis et al. 2023, S3 Appendix); every closed-form query below is computed from it. Groups are deterministically ordered by mechanism. """ index = {} for purview, group in self.distinctions.purview_inclusion(max_order=1): (atom,) = purview index[atom] = tuple(sorted(group, key=lambda d: tuple(d.mechanism))) return index @staticmethod def _density(distinction) -> float: """The distinction's φ per unique purview unit.""" return float(distinction.phi) / len(distinction.purview_union) def _facet_context(self) -> tuple[Iterable, frozenset | None]: return (self.distinctions, None)
[docs] def sample(self, n: int, *, seed: int) -> RelationSample: """Draw ``n`` non-self relations, coverage-weighted, i.i.d. Sampling walks the atom incidence: an atom is drawn with probability proportional to the number of relations inside its distinction group (``2**m − m − 1`` for a group of ``m``), then a subset of size ≥ 2 of that group is drawn uniformly. The resulting relation is drawn with probability proportional to its overlap size ``|O(S)|``, which is known per sample, so the returned :class:`RelationSample` yields unbiased estimates with standard errors for any per-relation sum. No burn-in; exact normalization. Parameters ---------- n : int The number of draws (with replacement). seed : int Seed for the isolated random generator. Required. """ rng = random.Random(seed) index = self._atom_index atoms = sorted(index) weights = [2 ** len(index[a]) - len(index[a]) - 1 for a in atoms] normalization = sum(weights) sampled = [] if normalization > 0: for _ in range(n): atom = rng.choices(atoms, weights=weights)[0] group = index[atom] while True: mask = rng.getrandbits(len(group)) if mask.bit_count() >= 2: break sampled.append( Relation( distinction for i, distinction in enumerate(group) if mask >> i & 1 ) ) return RelationSample( relations=sampled, normalization=normalization, seed=seed, num_self_relations=len(self.self_relations), sum_phi_self_relations=math.fsum( float(relation.phi) for relation in self.self_relations ), )
[docs] def sum_phi_moment(self, k: int = 2) -> float: """Return Σφ_r^k over all relations, in closed form. Since ``φ_r = |O(S)| · min q`` and ``|O(S)|^k`` counts the ordered k-tuples of atoms covering ``S``, the k-th moment decomposes over atom k-tuples, each contributing a sum-of-minimum of ``q^k`` over the distinctions shared by the tuple. Cost is ``O(N^k)`` inner sums for ``N`` atoms. """ if k < 1: raise ValueError(f"moment order must be a positive integer: {k}") index = self._atom_index atoms = sorted(index) total = 0.0 for combo in itertools.product(atoms, repeat=k): group = set(index[combo[0]]) for atom in combo[1:]: group &= set(index[atom]) if len(group) >= 2: total += combinatorics.sum_of_minimum_among_subsets( [self._density(d) ** k for d in group] ) total += math.fsum(float(relation.phi) ** k for relation in self.self_relations) return total
[docs] def num_relations_of_degree(self, degree: int) -> int: """Return the number of relations with exactly ``degree`` relata, by inclusion-exclusion over shared purview subsets.""" if degree < 1: return 0 if degree == 1: return len(self.self_relations) count = 0 for purview, group in self.distinctions.purview_inclusion(max_order=None): count += (-1) ** (len(purview) - 1) * math.comb(len(group), degree) return count
[docs] def sum_phi_of_degree(self, degree: int) -> float: """Return Σφ_r over relations with exactly ``degree`` relata, as a per-atom sorted dot product with binomial coefficients.""" if degree == 1: return math.fsum(float(r.phi) for r in self.self_relations) return math.fsum( combinatorics.sum_of_minimum_of_size_among_subsets( [self._density(d) for d in group], degree ) for group in self._atom_index.values() )
[docs] def degree_spectrum(self) -> dict[int, tuple[int, float]]: """Return ``{degree: (count, Σφ_r)}``, in closed form per degree.""" num_distinctions = sum(1 for _ in self.distinctions) spectrum = {} for degree in range(1, num_distinctions + 1): count = self.num_relations_of_degree(degree) if count: spectrum[degree] = (count, self.sum_phi_of_degree(degree)) return spectrum
[docs] def sum_phi_by_distinction(self, distinctions) -> tuple[float, ...]: """Return each distinction's incident Σφ_r in closed form. A relation either contains a given distinction or does not, so its incident Σφ_r is ``total − Σφ_r(relations avoiding it)``: the full total differenced against the total over the remaining distinctions. No relations are enumerated. The result is parallel to ``distinctions``. """ from pyphi.models.distinctions import ResolvedDistinctions total = self.sum_phi() result = [] for distinction in distinctions: mechanism = tuple(distinction.mechanism) others = ResolvedDistinctions( d for d in self.distinctions if tuple(d.mechanism) != mechanism ) result.append(total - AnalyticalRelations(others).sum_phi()) return tuple(result)
[docs] def max_phi(self) -> float: """Return the maximum φ_r, scanning only pairs and self-relations. Notes ----- The maximum over relations of degree ≥ 2 is always attained at degree 2: for any relation ``S`` with minimum-density member ``d*`` and any other member ``d'``, the pair ``{d*, d'}`` has overlap containing ``O(S)`` and the same minimum density, so its φ_r is at least ``φ_r(S)``. The scan is ``O(D^2)``. """ ds = list(self.distinctions) unions = [frozenset(d.purview_union) for d in ds] densities = [self._density(d) for d in ds] # numerics: exact — seeds a running max; callers compare tolerantly. best = max( (float(relation.phi) for relation in self.self_relations), default=0.0, ) for i, j in itertools.combinations(range(len(ds)), 2): overlap = unions[i] & unions[j] if overlap: # numerics: exact — running max; callers compare tolerantly. best = max(best, len(overlap) * min(densities[i], densities[j])) return best
[docs] def strongest( self, k: int | None = None, min_phi: float | None = None, max_degree: int | None = None, ) -> Iterator[Relation]: """Yield relations in descending φ_r order, lazily. Best-first search over the subset lattice: φ_r never increases when a relatum is added (the overlap shrinks and the minimum density can only fall), so seeding a max-heap with all valid pairs and the self-relations, and expanding each popped combination by larger-index distinctions only, yields relations in exact descending order. The first ``K`` yields cost ``O(|D|²)`` seeding plus ``O(K·|D|)`` heap pushes, independent of the total relation count. Ties in φ_r yield in an unspecified but deterministic order. The heap can grow to ``O(yielded · |D|)`` entries when the stream is consumed deeply; full enumeration is better served by :meth:`materialize`. Parameters ---------- k : int, optional Yield at most this many relations. If None, yield all. min_phi : float, optional Stop once φ_r falls below this threshold (compared tolerantly at the configured precision). Sound as an early exit because the stream is globally descending. max_degree : int, optional Do not yield or expand relations with more than this many relata. """ if k is not None and k <= 0: return ds = list(self.distinctions) unions = [frozenset(d.purview_union) for d in ds] densities = [self._density(d) for d in ds] def phi_of(indices): overlap = frozenset.intersection(*(unions[i] for i in indices)) if not overlap: return None return len(overlap) * min(densities[i] for i in indices) heap: list = [] counter = itertools.count() def push(phi, payload): # numerics: exact — heap ordering is a total order over floats; # the min_phi threshold at yield time is tolerant. heapq.heappush(heap, (-phi, next(counter), payload)) if max_degree is None or max_degree >= 1: for relation in self.self_relations: push(float(relation.phi), relation) if max_degree is None or max_degree >= 2: for i, j in itertools.combinations(range(len(ds)), 2): phi = phi_of((i, j)) if phi is not None: push(phi, (i, j)) yielded = 0 while heap: negative_phi, _, payload = heapq.heappop(heap) phi = -negative_phi if min_phi is not None and not (phi > min_phi or numerics.eq(phi, min_phi)): return if isinstance(payload, Relation): relation = payload else: relation = Relation(ds[i] for i in payload) if max_degree is None or len(payload) < max_degree: for nxt in range(payload[-1] + 1, len(ds)): extended = (*payload, nxt) extended_phi = phi_of(extended) if extended_phi is not None: push(extended_phi, extended) yield relation yielded += 1 if k is not None and yielded >= k: return
[docs] def materialize( self, max_degree: int | None = None, min_phi: float | None = None ) -> ConcreteRelations: """Enumerate the relations as an explicit :class:`ConcreteRelations`. The output is exponential in the number of distinctions, so ``max_degree`` and ``min_phi`` (tolerant ``≥``) bound it. Self-relations are always included (they have degree 1 and there are at most ``|D|`` of them). """ return ConcreteRelations( relation for relation in all_relations(self.distinctions, max_degree=max_degree) if _passes(relation, max_degree, min_phi) )
[docs] def binding_matrix(self) -> pd.DataFrame: """Return the atom-pair binding matrix, in closed form. Each entry is one sum-of-minimum over the distinctions shared by the atom pair — ``O(A²)`` sorted dot products for ``A`` atoms, never touching a relation. """ index = self._atom_index atoms = sorted(a for a in index if len(index[a]) >= 2) matrix = pd.DataFrame(0.0, index=pd.Index(atoms), columns=pd.Index(atoms)) for a in atoms: members = set(index[a]) for b in atoms: group = [d for d in index[b] if d in members] if len(group) >= 2: matrix.loc[a, b] = combinatorics.sum_of_minimum_among_subsets( [self._density(d) for d in group] ) return matrix
[docs] def phi_histogram(self) -> dict[float, int]: """Return ``{φ_r: count}`` over all relations, in closed form. φ_r takes at most ``A × D`` distinct values, for ``A`` purview atoms and ``D`` distinctions (overlap size times minimum density). The histogram is computed by sweeping density thresholds from high to low: at each threshold, relations among the distinctions at or above it are counted by exact overlap size via Möbius inversion over the intersection closure of their purview-unions; differencing consecutive sweeps assigns counts to ``overlap × density`` buckets. Distinctions are grouped by density at the configured precision (:func:`pyphi.numerics.round_to_precision`), so mathematically equal densities that differ by float noise share a threshold, and each bucket key is rounded to that same precision. Notes ----- The intersection closure is bounded by ``2 ** A`` for ``A`` purview atoms but is small for structured systems; if it grows pathologically, materialization or sampling are the fallbacks. """ histogram: Counter[float] = Counter() groups: defaultdict[float, list] = defaultdict(list) for distinction in self.distinctions: groups[numerics.round_to_precision(self._density(distinction))].append( distinction ) cumulative: list = [] previous: Counter[int] = Counter() # numerics: exact — iteration over precision-rounded representatives. for threshold in sorted(groups, reverse=True): cumulative.extend(groups[threshold]) density = min(self._density(d) for d in groups[threshold]) counts: Counter[int] = Counter() exact = combinatorics.exact_intersection_counts( [frozenset(d.purview_union) for d in cumulative] ) for overlap, count in exact.items(): counts[len(overlap)] += count for size in counts.keys() | previous.keys(): delta = counts[size] - previous[size] if delta: histogram[numerics.round_to_precision(size * density)] += delta previous = counts for relation in self.self_relations: histogram[numerics.round_to_precision(float(relation.phi))] += 1 return dict(histogram)
def _sum_phi(self): sum_phi = 0 # Sum of phi excluding self-relations for _, overlapping_distinctions in self.distinctions.purview_inclusion( max_order=1 ): sum_phi += combinatorics.sum_of_minimum_among_subsets( [ distinction.phi / len(distinction.purview_union) for distinction in overlapping_distinctions ] ) # Count self-relations sum_phi += sum(relation.phi for relation in self.self_relations) return sum_phi def _apportioned_sum_phi(self): apportioned = 0 # Apportioned sum (Σ φ_r / |r|) excluding self-relations for _, overlapping_distinctions in self.distinctions.purview_inclusion( max_order=1 ): apportioned += combinatorics.sum_of_minimum_over_size_among_subsets( [ distinction.phi / len(distinction.purview_union) for distinction in overlapping_distinctions ] ) # Self-relations have |r| = 1, so they enter at full phi apportioned += sum(relation.phi for relation in self.self_relations) return apportioned def _num_relations(self): count = 0 # Compute number of relations excluding self-relations for purview, overlapping_distinctions in self.distinctions.purview_inclusion( max_order=None ): inclusion_exclusion_term = (-1) ** (len(purview) - 1) overlap_size_term = ( 2 ** len(overlapping_distinctions) - len(overlapping_distinctions) - 1 ) count += inclusion_exclusion_term * overlap_size_term # Count self-relations count += len(self.self_relations) return count
[docs] def num_faces(self) -> int: """Return the total number of faces across all relations, in closed form. A face is a set of two or more causes/effects (one per direction choice per relatum) with nonempty state-tagged overlap, so the total face count is the same subfamily count that :meth:`num_relations` computes over distinctions, run instead over the individual causes and effects — Möbius inversion over the intersection closure of the per-side purviews. Faces of self-relations (a distinction's cause paired with its own effect) are included, matching enumeration. """ mice_purviews = [ frozenset(side.purview_units) for distinction in self.distinctions for side in (distinction.cause, distinction.effect) ] return sum(combinatorics.exact_intersection_counts(mice_purviews).values())
# No __len__: analytical relations hold no relation objects (iteration # raises), and the closed-form count (~2^(2^n)) exceeds sys.maxsize on # large systems, so a length would work at small sizes and raise at # production sizes. The count is num_relations().
[docs] class AnalyticalFoldRelations(AnalyticalRelations): """Closed-form sums over the relations incident to a set of seed distinctions within a parent structure. Every analytical quantity is a sum over relations, and a relation either touches the seed set ``F`` or it does not, so the incident total is ``total(D) - total(D\\F)`` over two plain :class:`AnalyticalRelations`. Self-relations of ``D\\F`` cancel in the difference; self-relations of the seeds survive. Enumeration (iteration, faces) is not supported -- use concrete relations for that. Equality additionally requires matching seed distinctions: a fold summary with the same parent distinctions but a different seed set describes a different (incident-only) relation set, so it is a distinct value, not just a distinct view. """
[docs] def save(self, target, **kwargs): """Fold summaries have no serialized form; save the parent structure.""" raise NotImplementedError( "AnalyticalFoldRelations is an incident-only summary over its " "parent structure and has no serialized form; save the parent " "CauseEffectStructure (or its AnalyticalRelations) instead" )
def __init__(self, parent_distinctions, seeds): super().__init__(parent_distinctions) self._full = AnalyticalRelations(parent_distinctions) from pyphi.models.distinctions import _concept_sort_key self._seeds = tuple(sorted(seeds, key=_concept_sort_key)) self._share_weighted_cached = None seed_mechanisms = {tuple(d.mechanism) for d in self._seeds} from pyphi.models.distinctions import ResolvedDistinctions complement = ResolvedDistinctions( d for d in parent_distinctions if tuple(d.mechanism) not in seed_mechanisms ) self._complement = AnalyticalRelations(complement) def __eq__(self, other: object) -> bool: if type(other) is not type(self): return NotImplemented return self.distinctions == other.distinctions and self._seeds == other._seeds def __hash__(self) -> int: return hash((type(self), self.distinctions, self._seeds)) def _facet_context(self) -> tuple[Iterable, frozenset | None]: """Every incident relation lies inside an incident Z(n) with n in a seed's purview union, so fold facets restrict the atoms to the seeds' purview unions.""" atoms = frozenset().union(*(seed.purview_union for seed in self._seeds)) return (self.distinctions, atoms) def _sum_phi(self): return self._full.sum_phi() - self._complement.sum_phi() def _num_relations(self): return self._full.num_relations() - self._complement.num_relations()
[docs] def sum_phi_by_distinction(self, distinctions) -> tuple[float, ...]: """Each distinction's Σφ_r over the *incident* relations, in closed form. A fold relation either contains a given distinction or does not, so its incident Σφ_r is the fold total differenced against the fold of the remaining distinctions — with the seed set restricted accordingly, since a relation avoiding a seed distinction is incident to the fold exactly when it touches one of the *other* seeds. The result is parallel to ``distinctions``. """ from pyphi.models.distinctions import ResolvedDistinctions total = self.sum_phi() result = [] for distinction in distinctions: mechanism = tuple(distinction.mechanism) others = ResolvedDistinctions( d for d in self.distinctions if tuple(d.mechanism) != mechanism ) remaining_seeds = [ seed for seed in self._seeds if tuple(seed.mechanism) != mechanism ] if remaining_seeds: avoiding = AnalyticalFoldRelations(others, remaining_seeds).sum_phi() else: # Every fold relation contains the sole seed, so every fold # relation is incident to it. avoiding = 0.0 result.append(total - avoiding) return tuple(result)
def _apportioned_sum_phi(self): return self._full.apportioned_sum_phi() - self._complement.apportioned_sum_phi()
[docs] def share_weighted_sum_phi(self): """Σ over incident relations of ``φ_r · |r ∩ F| / |r|``, where ``F`` is the seed set. Computed without enumeration: for a single seed ``d``, the incident apportioned total is ``total(D) - total(D\\{d})`` over two closed-form :class:`AnalyticalRelations` sums, and the share-weighted total over ``F`` is the sum of these single-seed incident totals (a relation of degree ``|r|`` binding ``k`` seeds is counted ``k`` times at ``φ_r/|r|``). """ if self._share_weighted_cached is None: from pyphi.models.distinctions import ResolvedDistinctions total = self._full.apportioned_sum_phi() result = 0 for seed in self._seeds: seed_mechanism = tuple(seed.mechanism) others = ResolvedDistinctions( d for d in self.distinctions if tuple(d.mechanism) != seed_mechanism ) result += total - AnalyticalRelations(others).apportioned_sum_phi() self._share_weighted_cached = result return self._share_weighted_cached
def _difference(self, query, *args, **kwargs): """Evaluate an additive query as full − complement. A relation either touches the seed set or it does not, so any quantity that is a sum over relations restricts to the incident set by differencing the parent total against the seed-free total. Self-relations of non-seed distinctions cancel; the seeds' survive. """ return getattr(self._full, query)(*args, **kwargs) - getattr( self._complement, query )(*args, **kwargs)
[docs] def sum_phi_moment(self, k: int = 2) -> float: """Return Σφ_r^k over the incident relations.""" return self._difference("sum_phi_moment", k)
[docs] def num_relations_of_degree(self, degree: int) -> int: """Return the number of incident relations with exactly ``degree`` relata.""" return self._difference("num_relations_of_degree", degree)
[docs] def sum_phi_of_degree(self, degree: int) -> float: """Return Σφ_r over incident relations with exactly ``degree`` relata.""" return self._difference("sum_phi_of_degree", degree)
[docs] def num_faces(self) -> int: """Return the total face count over the incident relations.""" return self._difference("num_faces")
[docs] def phi_histogram(self) -> dict[float, int]: """Return ``{φ_r: count}`` over the incident relations. Bucket-wise difference of the parent and seed-free histograms. Each bucket key is ``overlap size × density`` rounded to the configured precision, where the density is a raw representative of a precision-rounded group of distinctions. The parent and seed-free histograms share bucket keys when these representatives are seed-independent, so the difference is well defined. A representative that is a seed can shift a key between the two histograms; that misalignment surfaces as a negative count and is raised rather than returned. """ histogram = Counter(self._full.phi_histogram()) histogram.subtract(self._complement.phi_histogram()) if any(count < 0 for count in histogram.values()): raise ValueError( "bucket keys failed to align between the parent and seed-free " "histograms; materialize the fold to enumerate exact values" ) return {phi: count for phi, count in histogram.items() if count}
[docs] def binding_matrix(self) -> pd.DataFrame: """Return the atom-pair binding matrix of the incident relations. Entry-wise difference of the parent and seed-free matrices, on the parent's atom index (rows for atoms bound only by seed-free relations go to zero). """ full = self._full.binding_matrix() complement = self._complement.binding_matrix() aligned = complement.reindex( index=full.index, columns=full.columns, fill_value=0.0 ) return full - aligned
[docs] def max_phi(self) -> float: """Return the maximum φ_r over the incident relations. Notes ----- The incident maximum is attained at an incident pair or a seed's self-relation: for any incident relation ``S``, its minimum-density member ``d*`` paired with any seed in ``S`` is an incident pair with overlap containing ``O(S)`` and the same minimum density. """ seed_set = set(self._seeds) ds = list(self.distinctions) unions = [frozenset(d.purview_union) for d in ds] densities = [self._density(d) for d in ds] # numerics: exact — seeds a running max; callers compare tolerantly. best = max( ( float(relation.phi) for relation in self.self_relations if not seed_set.isdisjoint(relation) ), default=0.0, ) for i, j in itertools.combinations(range(len(ds)), 2): if ds[i] not in seed_set and ds[j] not in seed_set: continue overlap = unions[i] & unions[j] if overlap: # numerics: exact — running max; callers compare tolerantly. best = max(best, len(overlap) * min(densities[i], densities[j])) return best
[docs] def strongest( self, k: int | None = None, min_phi: float | None = None, max_degree: int | None = None, ) -> Iterator[Relation]: """Yield the incident relations in descending φ_r order. Filters the parent's descending stream by seed incidence, so the order is exact; non-incident relations are popped and discarded, so the cost tracks the parent stream's, not the incident count. """ if k is not None and k <= 0: return seed_set = set(self._seeds) yielded = 0 for relation in self._full.strongest( k=None, min_phi=min_phi, max_degree=max_degree ): if seed_set.isdisjoint(relation): continue yield relation yielded += 1 if k is not None and yielded >= k: return
[docs] def materialize( self, max_degree: int | None = None, min_phi: float | None = None ) -> ConcreteRelations: """Return the incident relations as an explicit :class:`ConcreteRelations`.""" seed_set = set(self._seeds) return ConcreteRelations( relation for relation in self._full.materialize(max_degree, min_phi) if not seed_set.isdisjoint(relation) )
[docs] def sample(self, n: int, *, seed: int) -> RelationSample: """Not supported on folds: sample the parent structure and restrict the summand to incident relations instead.""" raise NotImplementedError( "sampling a fold is not supported; sample the parent " "AnalyticalRelations and restrict the estimated summand to " "relations touching the seeds" )
[docs] def relations( distinctions: ResolvedDistinctions, relation_computation: str | None = None, **kwargs: Any, ) -> Relations: """Return causal relations among a set of distinctions. Requires :class:`~pyphi.models.distinctions.ResolvedDistinctions`: relations between distinctions whose tied specified states haven't been disambiguated by a SIA system_state can include phantom faces that wouldn't exist after resolution. Pass the result of :meth:`~pyphi.models.distinctions.Distinctions.resolve_congruence` or use :func:`pyphi.formalism.iit4.ces` to obtain a consistent structure. """ return relation_computations[ fallback(relation_computation, config.formalism.iit.relation_computation) # type: ignore[index] # config.Option descriptor ](distinctions, **kwargs)
[docs] class RelationComputationsRegistry(Registry): """Storage for functions for computing relations. Users can define custom schemes and use them by setting ``pyphi.config.relation_computation = 'NONE'``. Examples -------- >>> @relation_computations.register('NONE') # doctest: +SKIP ... def no_relations(system, ces): ... return Relations([]) """ desc = "methods for computing relations"
relation_computations = RelationComputationsRegistry()
[docs] @relation_computations.register("CONCRETE") def concrete_relations( distinctions: Iterable[Distinction], **kwargs: Any ) -> ConcreteRelations: return ConcreteRelations(all_relations(distinctions, **kwargs))
[docs] @relation_computations.register("ANALYTICAL") def analytical_relations( distinctions: Iterable[Distinction], **kwargs: Any ) -> AnalyticalRelations: if kwargs: raise TypeError( f"analytical relation computation does not support keyword " f"arguments {sorted(kwargs)}: the analytical backend summarizes " f"all degrees in closed form. Use " f"relation_computation='CONCRETE' for degree caps or parallel " f"controls." ) return AnalyticalRelations(distinctions)
# Functional alias
[docs] def relation(distinctions: Iterable[Distinction]) -> Relation: return Relation(distinctions)