Source code for pyphi.visualize.projection

"""Pure projection of IIT result objects into plot-ready data.

This package is the only part of :mod:`pyphi.visualize` that touches
result-object internals (:class:`Distinction`, :class:`Relation`). It imports
no plotting libraries; renderers consume the dataclasses defined here.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from dataclasses import field

from pyphi.labels import NodeLabels

DEFAULT_MAX_ANALYTICAL_RELATIONS = 1000
"""Relations rendered when ``max_relations`` is None and the relation set is
not enumerable (the analytical backend): the strongest this-many by φ_r."""

__all__ = [
    "DEFAULT_MAX_ANALYTICAL_RELATIONS",
    "CESProjection",
    "DistinctionNode",
    "EndpointNode",
    "InclusionOrder",
    "RelationEdge",
    "RelationFaceEdge",
    "project_ces",
]


[docs] @dataclass(frozen=True) class DistinctionNode: """Plot-ready data for one distinction.""" id: int mechanism: tuple[int, ...] label: str cause_purview: tuple[int, ...] effect_purview: tuple[int, ...] mechanism_state: tuple[int, ...] phi: float sum_phi_relations: float includes: bool included: bool
[docs] @dataclass(frozen=True) class RelationEdge: """Plot-ready data for one relation.""" relata: tuple[int, ...] degree: int phi: float overlap: tuple[int, ...]
[docs] @dataclass(frozen=True) class EndpointNode: """Plot-ready data for one side (cause or effect) of a distinction.""" id: int distinction_id: int direction: str purview: tuple[int, ...] purview_state: tuple[int, ...] phi: float label: str
[docs] @dataclass(frozen=True) class RelationFaceEdge: """Plot-ready data for one relation face (any degree).""" endpoints: tuple[int, ...] degree: int phi: float overlap: tuple[int, ...]
[docs] @dataclass(frozen=True) class InclusionOrder: """An inclusion partial order over distinctions. ``covers[i]`` lists the node ids that node ``i`` directly down-includes (the transitive reduction); ``rank[i]`` is the length of the longest down-chain below ``i`` (minimal elements have rank 0, the "whole" distinction the maximum), so it is monotonic in the partial order and suitable as a vertical layout coordinate. ``size[i]`` is the cardinality of the underlying unit set, an alternative vertical coordinate that leaves gaps at sizes with no distinctions. """ covers: tuple[tuple[int, ...], ...] rank: tuple[int, ...] size: tuple[int, ...]
[docs] @dataclass(frozen=True) class CESProjection: """Everything a renderer needs to draw a cause-effect structure. Two inclusion orders are carried: ``mechanism_inclusion`` orders distinctions by strict subset relation on their mechanisms (the region/location order of Haun & Tononi 2019, Fig 9), and ``purview_union_inclusion`` by strict subset relation on the unions of their cause and effect purviews. ``endpoints`` carries one node per distinction side, interleaved so that ``endpoints[2 * d + 0]`` is distinction ``d``'s cause and ``endpoints[2 * d + 1]`` its effect. ``faces`` carries the relation faces at every degree present, referencing endpoints by id; renderers choose how to draw each degree. """ nodes: tuple[DistinctionNode, ...] edges: tuple[RelationEdge, ...] mechanism_inclusion: InclusionOrder purview_union_inclusion: InclusionOrder node_labels: NodeLabels endpoints: tuple[EndpointNode, ...] = () faces: tuple[RelationFaceEdge, ...] = () degree_spectrum: dict[int, tuple[int, float]] = field(default_factory=dict)
[docs] def inclusion(self, order: str) -> InclusionOrder: """The inclusion order named by ``order``. Parameters ---------- order : str ``"mechanism"`` or ``"purview_union"``. Returns ------- InclusionOrder Raises ------ ValueError If ``order`` is neither ``"mechanism"`` nor ``"purview_union"``. """ if order == "mechanism": return self.mechanism_inclusion if order == "purview_union": return self.purview_union_inclusion raise ValueError(f"unknown order {order!r}")
def _inclusion_order(unit_sets: Sequence[frozenset]) -> InclusionOrder: """Partial order by strict subset relation on unit sets.""" n = len(unit_sets) below: list[set[int]] = [set() for _ in range(n)] for a in range(n): for b in range(n): if a != b and unit_sets[b] < unit_sets[a]: below[a].add(b) covers = tuple( tuple( sorted( b for b in below[a] if not any(b in below[c] for c in below[a] if c != b) ) ) for a in range(n) ) memo: dict[int, int] = {} def longest_chain(a: int) -> int: if a not in memo: memo[a] = 1 + max(longest_chain(b) for b in below[a]) if below[a] else 0 return memo[a] rank = tuple(longest_chain(a) for a in range(n)) size = tuple(len(s) for s in unit_sets) return InclusionOrder(covers=covers, rank=rank, size=size) def _unit_indices(units) -> tuple[int, ...]: """Sorted integer indices from an iterable of units (or bare ints).""" return tuple(sorted(getattr(u, "index", u) for u in units)) def _state_cased_label(purview, purview_state, node_labels) -> str: """Purview label with case set by state (upper = ON, lower = OFF).""" return "".join( node_labels.set_case_by_state(node_labels.indices2labels(purview), purview_state) ) def _endpoints(distinctions, node_labels) -> tuple[EndpointNode, ...]: endpoints = [] for i, d in enumerate(distinctions): for j, (direction, mice) in enumerate( (("cause", d.cause), ("effect", d.effect)) ): purview = tuple(mice.purview) state = tuple(mice.purview_state) endpoints.append( EndpointNode( id=2 * i + j, distinction_id=i, direction=direction, purview=purview, purview_state=state, phi=float(mice.phi), label=_state_cased_label(purview, state, node_labels), ) ) return tuple(endpoints) def _faces(relations, mechanism_to_id) -> tuple[RelationFaceEdge, ...]: faces = [] for relation in relations: for face in relation.faces: endpoint_ids = tuple( sorted( 2 * mechanism_to_id[tuple(relatum.mechanism)] + (0 if relatum.direction.name == "CAUSE" else 1) for relatum in face ) ) faces.append( RelationFaceEdge( endpoints=endpoint_ids, degree=len(face), phi=float(face.phi), overlap=_unit_indices(face.overlap), ) ) faces.sort(key=lambda f: (f.degree, f.endpoints, f.phi)) return tuple(faces)
[docs] def project_ces(ces, node_labels=None, max_relations=None) -> CESProjection: """Project a :class:`~pyphi.models.ces.CauseEffectStructure` into plot-ready data. Parameters ---------- ces : CauseEffectStructure The cause-effect structure to project. Must be relation-closed. node_labels : NodeLabels, optional Labels for substrate units. Defaults to the labels carried by the first distinction. max_relations : int, optional Render only the ``max_relations`` strongest relations (and their faces), in descending φ_r order. If None, render every relation when the set is enumerable; when it is not (the analytical backend), render the strongest ``DEFAULT_MAX_ANALYTICAL_RELATIONS``. Node marker sizes and the degree spectrum are always computed over the full structure, independent of this cap. Returns ------- CESProjection Raises ------ TypeError If ``ces`` is not relation-closed (e.g. a :class:`PhiFold`, whose relations may reference distinctions outside it). ValueError If the structure has no distinctions (an empty, reducible-system structure). """ if not getattr(ces, "relation_closed", True): raise TypeError( "cannot project a view that is not relation-closed (e.g. a PhiFold, " "whose relations may reference distinctions outside it); project " "the parent structure or an induced substructure, or use " "highlight_phi_fold to visualize a fold" ) distinctions = list(ces.distinctions) if not distinctions: raise ValueError( "cannot project an empty cause-effect structure " "(no distinctions: the system is reducible)" ) if node_labels is None: node_labels = distinctions[0].node_labels mechanism_to_id = {tuple(d.mechanism): i for i, d in enumerate(distinctions)} if max_relations is None: try: iter(ces.relations) except TypeError: max_relations = DEFAULT_MAX_ANALYTICAL_RELATIONS top = list(ces.relations.strongest(k=max_relations)) edges = tuple( RelationEdge( relata=tuple(sorted(mechanism_to_id[tuple(m)] for m in relation.mechanisms)), degree=len(relation), phi=float(relation.phi), overlap=_unit_indices(relation.purview), ) for relation in top ) mechanism_inclusion = _inclusion_order( tuple(frozenset(d.mechanism) for d in distinctions) ) # Purview unions as index sets, from the purviews directly: the # projection needs only unit indices, and IIT 3.0 distinctions carry no # specified states for their purview units. unions = tuple( frozenset(d.cause_purview) | frozenset(d.effect_purview) for d in distinctions ) purview_union_inclusion = _inclusion_order(unions) sums = ces.relations.sum_phi_by_distinction(distinctions) nodes = tuple( DistinctionNode( id=i, mechanism=tuple(d.mechanism), label=node_labels.label_string(d.mechanism, d.mechanism_state), cause_purview=tuple(d.cause_purview), effect_purview=tuple(d.effect_purview), mechanism_state=tuple(d.mechanism_state), phi=float(d.phi), sum_phi_relations=sums[i], includes=bool(purview_union_inclusion.covers[i]), included=any(i in c for c in purview_union_inclusion.covers), ) for i, d in enumerate(distinctions) ) return CESProjection( nodes=nodes, edges=edges, mechanism_inclusion=mechanism_inclusion, purview_union_inclusion=purview_union_inclusion, node_labels=node_labels, endpoints=_endpoints(distinctions, node_labels), faces=_faces(top, mechanism_to_id), degree_spectrum=ces.relations.degree_spectrum(), )