Source code for pyphi.models.actual_causation

# models/actual_causation.py
"""Objects that represent structures used in actual causation."""

from __future__ import annotations

from collections import namedtuple
from collections.abc import Sequence

from pyphi import numerics
from pyphi.direction import Direction
from pyphi.display import PROVENANCE
from pyphi.display import Description
from pyphi.display import Displayable
from pyphi.display import Row
from pyphi.display import Section
from pyphi.display import tone_of
from pyphi.display.numbers import format_value
from pyphi.display.tables import capped_table
from pyphi.models.explanation import Explanation
from pyphi.models.explanation import Finding
from pyphi.provenance import HasProvenance
from pyphi.serializable import Serializable

from . import cmp
from . import fmt
from .diff import Change
from .diff import ResultDiff
from .diff import _diff_common
from .pandas import ToPandasMixin
from .pandas import records_to_frame
from .partitions import concise_partition


[docs] class AcRepertoireIrreducibilityAnalysis(Displayable, cmp.Orderable, ToPandasMixin): """The irreducibility analysis of an actual cause or effect. Holds the minimum information partition of a mechanism over a purview and the resulting α, the integrated cause or effect information (Albantakis et al. 2019, Eqs. 15-16): how much more the occurrence specifies about its actual cause or effect than it does under that partition. These can be compared with the built-in Python comparison operators (``<``, ``>``, etc.). The ordering key is ``(α, mechanism size, -purview size)``, so α is compared first, ties are broken toward the larger mechanism, and remaining ties toward the smaller purview. Attributes ---------- alpha : float α, the integrated cause or effect information. Derived from the unpartitioned and partitioned actual probabilities via the configured ``alpha_measure``. state : tuple[int, ...] The state of the mechanism in the specified temporal direction. direction : Direction Whether this analysis is computed with cause or effect repertoires. mechanism : tuple[int, ...] The mechanism analyzed. purview : tuple[int, ...] The purview over which the unpartitioned actual probability differs the least from the actual probability under the partition. partition : JointPartition or None The minimum information partition of the mechanism over the purview (``None`` for a null analysis). probability : float The unpartitioned actual probability of the purview state. partitioned_probability : float The actual probability of the purview state under the partition. """ def __init__( self, alpha, state, direction, mechanism, purview, partition, probability, partitioned_probability, node_labels=None, reasons=None, ): self.alpha = alpha self.state = state self.direction = direction self.mechanism = mechanism self.purview = purview self.partition = partition self.probability = probability self.partitioned_probability = partitioned_probability self.node_labels = node_labels self.reasons = reasons or [] self._partition_ties: tuple[AcRepertoireIrreducibilityAnalysis, ...] | None = ( None ) def _pandas_record(self): labels = self.node_labels def labelled(nodes): if labels is None: return tuple(nodes) return tuple(labels.coerce_to_labels(nodes)) return { "alpha": float(self.alpha), "direction": str(self.direction), "mechanism": labelled(self.mechanism), "purview": labelled(self.purview) if self.purview is not None else None, } @property def partition_ties( self, ) -> tuple[AcRepertoireIrreducibilityAnalysis, ...] | None: """Tuple of AcRIAs tied with this one at the cascade's minimum α level over the MIP search, or ``None`` if no tie.""" return self._partition_ties
[docs] def set_partition_ties( self, ties: Sequence[AcRepertoireIrreducibilityAnalysis] | None ) -> None: """Attach a tied AcRIA set to this analysis. The tied set is shared by reference among peers; each tied member exposes the same tuple via ``.partition_ties``.""" if ties is None or len(tuple(ties)) <= 1: self._partition_ties = None return tied = tuple(ties) for member in tied: member._partition_ties = tied
[docs] def is_orderable_with(self, other: object) -> bool: return isinstance(other, AcRepertoireIrreducibilityAnalysis) and ( self.direction == other.direction )
[docs] def order_by(self): # Here we enforce that ties are broken in favor of smaller purviews; # null analyses (purview None) sort as empty. purview_size = len(self.purview) if self.purview is not None else 0 return [self.alpha, len(self.mechanism), -purview_size]
def __eq__(self, other: object) -> bool: # noqa: PLR0911 # TODO(slipperyhank): include 2nd state here? if not isinstance(other, AcRepertoireIrreducibilityAnalysis): return NotImplemented if self.state != other.state: return False if self.direction != other.direction: return False if self.mechanism != other.mechanism: return False if self.purview != other.purview: return False if not numerics.eq(self.alpha, other.alpha): return False if self.probability is None or other.probability is None: return self.probability is other.probability return numerics.eq(self.probability, other.probability) def __bool__(self): """An :class:`AcRepertoireIrreducibilityAnalysis` is ``True`` if it has α > 0. """ return numerics.is_positive(self.alpha) @property def phi(self): """Alias for α for PyPhi utility functions.""" return self.alpha
[docs] def explain(self) -> Explanation: """A typed account of why this actual cause/effect link's α came out as it did.""" findings = [ Finding(kind="null_result", label="Null result", value=reason) for reason in (self.reasons or []) ] if self.purview: findings.append(Finding(kind="purview", label="Purview", value=self.purview)) if self.partition is not None: findings.append( Finding( kind="winning_partition", label="Partition", value=concise_partition(self.partition), ) ) return Explanation( subject=f"α = {format_value(self.alpha)}", level="mechanism", findings=tuple(findings), )
def __hash__(self) -> int: return hash( ( self.state, self.direction, self.mechanism, self.purview, ) ) def _describe(self, verbosity: int) -> Description: # noqa: ARG002 cls = type(self).__name__ mechanism_str = fmt.fmt_mechanism(self.mechanism, self.node_labels) purview_str = fmt.fmt_mechanism(self.purview, self.node_labels) partition_str = ( concise_partition(self.partition) if self.partition is not None else None ) return Description( title=cls, sections=( Section( rows=( Row("α", self.alpha), Row( "Direction", str(self.direction), tone=tone_of(self.direction), ), Row("Mechanism", mechanism_str), Row("Purview", purview_str), Row("State", str(self.state)), Row("Partition", partition_str), Row("Probability", self.probability), Row("Partitioned probability", self.partitioned_probability), ), ), ), compact=( f"{cls}(α={format_value(self.alpha)}, " f"{self.direction}, {mechanism_str}→{purview_str})" ), )
def _null_ac_ria(state, direction, mechanism, purview, partition=None, reasons=None): """The irreducibility AC analysis for a reducible causal link. ``reasons`` records why (a list of :class:`~pyphi.models.explanation.NullResultReason`). """ return AcRepertoireIrreducibilityAnalysis( state=state, direction=direction, mechanism=mechanism, purview=purview, partition=partition, probability=None, partitioned_probability=None, alpha=0.0, reasons=reasons, )
[docs] class Event(namedtuple("Event", ["actual_cause", "actual_effect"])): """A mechanism which has both an actual cause and an actual effect. Attributes ---------- actual_cause : CausalLink The actual cause of the mechanism. actual_effect : CausalLink The actual effect of the mechanism. """ @property def mechanism(self): """The mechanism of the event.""" assert self.actual_cause.mechanism == self.actual_effect.mechanism return self.actual_cause.mechanism
[docs] class Account(Displayable, Sequence, ToPandasMixin, Serializable): """The set of :class:`CausalLink` instances with α > 0. This includes both actual causes and actual effects. """ def __init__(self, causal_links): self.causal_links = tuple(causal_links) def _to_pandas(self): rows = [link._pandas_record() for link in self] return records_to_frame( rows, columns=["alpha", "direction", "mechanism", "purview"] ) def __len__(self): return len(self.causal_links) def __iter__(self): return iter(self.causal_links) def __getitem__(self, i): return self.causal_links[i] def __eq__(self, other: object) -> bool: if not isinstance(other, Account): return NotImplemented # An account is a set of causal links; the storage order is a # presentation detail and must not affect equality. return frozenset(self.causal_links) == frozenset(other.causal_links) def __hash__(self): return hash(frozenset(self.causal_links)) def __add__(self, other: object) -> Account: if not isinstance(other, Account): return NotImplemented return self.__class__(self.causal_links + other.causal_links) @property def irreducible_causes(self): """The set of irreducible causes in this :class:`Account`.""" return tuple(link for link in self if link.direction is Direction.CAUSE) @property def irreducible_effects(self): """The set of irreducible effects in this :class:`Account`.""" return tuple(link for link in self if link.direction is Direction.EFFECT) @property def _sum_alpha(self): """Total alpha across all causal links.""" return sum(link.alpha for link in self.causal_links) def _describe(self, verbosity: int) -> Description: # noqa: ARG002 cls = type(self).__name__ num_links = len(self.causal_links) headers = ("Direction", "Mechanism", "Purview", "α") table = capped_table( headers, self.causal_links, lambda link: ( str(link.direction), fmt.fmt_mechanism(link.mechanism, link.node_labels), fmt.fmt_mechanism(link.purview, link.node_labels), link.alpha, ), total=num_links, cell_tones=lambda link: (tone_of(link.direction), None, None, None), ) return Description( title=cls, sections=( Section( rows=( Row("Causal links", num_links), Row("Σα", self._sum_alpha), ), ), Section(label="Causal links", body=(table,)), ), compact=(f"{cls}({num_links} links, Σα={format_value(self._sum_alpha)})"), )
[docs] def explain(self) -> Explanation: """A typed account listing each irreducible causal link with its α.""" findings = [ Finding( kind="link", label=f"{link.direction}: {link.mechanism} → {link.purview}", value=link.alpha, tone=tone_of(link.direction), ) for link in self.causal_links ] return Explanation( subject=f"Account ({len(self.causal_links)} links)", level="system", findings=tuple(findings), )
[docs] def diff(self, other) -> ResultDiff: """Structured delta from this account to ``other`` (``a.diff(b)``). Causal links are keyed by direction + mechanism + purview; a link present in both is *changed* when its α differs. An account carries no :class:`ConfigSnapshot`, so ``config_diff`` is empty. """ from pyphi import numerics if not isinstance(other, Account): raise TypeError( f"cannot diff {type(self).__name__} against {type(other).__name__}" ) def key(link): return (str(link.direction), link.mechanism, link.purview) a_by = {key(link): link for link in self.causal_links} b_by = {key(link): link for link in other.causal_links} changes: list[Change] = [] changes.extend( Change("link_lost", k, a_value=a_by[k].alpha) for k in a_by.keys() - b_by.keys() ) changes.extend( Change("link_gained", k, b_value=b_by[k].alpha) for k in b_by.keys() - a_by.keys() ) changes.extend( Change("link_changed", k, a_by[k].alpha, b_by[k].alpha) for k in a_by.keys() & b_by.keys() if not numerics.eq(a_by[k].alpha, b_by[k].alpha) ) return ResultDiff( subject=f"ΔΣα ({len(self)} → {len(other)} links)", level="system", delta_phi=float(other._sum_alpha) - float(self._sum_alpha), mip_changed=False, changes=tuple(changes), config_diff={}, substrate_note=None, )
[docs] class DirectedAccount(Account): """The set of :class:`CausalLink` instances with α > 0 for one direction of a transition. """
# TODO(slipperyhank): Check if we do the same, i.e. take the bigger system, or # take the smaller?
[docs] class AcSystemIrreducibilityAnalysis( HasProvenance, Displayable, cmp.Orderable, ToPandasMixin, Serializable ): """An analysis of transition-level irreducibility (𝒜). Contains the 𝒜 value of the :class:`~pyphi.actual.Transition`, the causal account, and all the intermediate results obtained in the course of computing them. Attributes ---------- alpha : float 𝒜, the transition-level integrated cause-effect information: the distance between the unpartitioned account and this analysis's partitioned account. direction : Direction The causal direction the account is taken over. account : Account The account of the whole transition. partitioned_account : Account The account of the partitioned transition. partition : DirectedJointPartition The minimal partition. before_state : tuple[int, ...] The state of the substrate at time t-1. after_state : tuple[int, ...] The state of the substrate at time t. size : int Number of nodes in the transition. node_indices : tuple[int, ...] Indices of nodes in the transition. node_labels : NodeLabels Labels corresponding to ``node_indices``. """ alpha: float # Override parent to allow None during init def __init__( self, alpha=None, direction=None, account=None, partitioned_account=None, partition=None, before_state=None, after_state=None, size=None, node_indices=None, cause_indices=None, effect_indices=None, node_labels=None, config=None, provenance=None, reasons=None, ): self.alpha = alpha # type: ignore[assignment] self.direction = direction self.account = account self.partitioned_account = partitioned_account self.partition = partition self.before_state = before_state self.after_state = after_state self.size = size self.node_indices = node_indices self.cause_indices = cause_indices self.effect_indices = effect_indices self.node_labels = node_labels self.reasons = reasons or [] # ConfigSnapshot of the layered config at construction time. # Lazy-snapshot if None: callers that don't pass one still get a # recorded config (matching SystemIrreducibilityAnalysis). if config is None: from pyphi.conf import config as _global config = _global.snapshot() self.config = config if provenance is None: from pyphi.provenance import Provenance provenance = Provenance.capture() self.provenance = provenance self._ties: tuple[AcSystemIrreducibilityAnalysis, ...] = (self,) @property def ties(self) -> tuple[AcSystemIrreducibilityAnalysis, ...]: """System analyses tied with this one at the winning 𝒜, including this one. A singleton when the minimum is unique. Records only candidates equal up to ``config.numerics.precision`` — the interchangeable final answers, separated at most by the canonical partition ordering, never by a theoretical quantity.""" return self._ties
[docs] def set_ties(self, ties: Sequence[AcSystemIrreducibilityAnalysis]) -> None: """Attach the tied analysis set, shared by reference among peers.""" tied = tuple(ties) if len(tied) <= 1: self._ties = (self,) return for member in tied: member._ties = tied
def _pandas_record(self): return { "alpha": float(self.alpha), "direction": str(self.direction), "before_state": self.before_state, "after_state": self.after_state, } def _system_label(self) -> str | None: node_indices = self.node_indices node_labels = self.node_labels if node_labels is not None and node_indices is not None: return ",".join( str(label) for label in node_labels.coerce_to_labels(node_indices) ) if node_indices is not None: return ",".join(str(i) for i in node_indices) return None def _describe(self, verbosity: int) -> Description: cls = type(self).__name__ account = self.account num_links = len(account) if account is not None else None sum_alpha = sum(link.alpha for link in account) if account is not None else None partition_str = ( concise_partition(self.partition) if self.partition is not None else None ) before_str = ( fmt.state(self.before_state) if self.before_state is not None else None ) after_str = fmt.state(self.after_state) if self.after_state is not None else None sections = [ Section( rows=( Row("α", self.alpha), Row( "Direction", str(self.direction) if self.direction is not None else None, tone=tone_of(self.direction), ), Row("System", self._system_label()), Row("Before state", before_str), Row("After state", after_str), Row("Partition", partition_str), Row("Causal links", num_links), Row("Σα", sum_alpha), ), ), ] if verbosity >= PROVENANCE and self.provenance is not None: from pyphi.display.provenance import provenance_section sections.append(provenance_section(self.provenance)) return Description( title=cls, sections=tuple(sections), compact=f"{cls}(α={format_value(self.alpha)})", )
[docs] def explain(self) -> Explanation: """A typed account of why this transition's 𝒜 came out as it did. A runner-up / α-gap is not retained for actual causation.""" findings = [ Finding(kind="null_result", label="Null result", value=reason) for reason in (self.reasons or []) ] # A null short-circuit's partition is the trivial default, not a MIP; # only a computed result has a meaningful winning partition. if not self.reasons and self.partition is not None: findings.append( Finding( kind="winning_partition", label="Partition", value=concise_partition(self.partition), ) ) return Explanation( subject=f"α = {format_value(self.alpha)}", level="system", findings=tuple(findings), )
[docs] def diff(self, other) -> ResultDiff: """Structured delta from this analysis to ``other`` (``a.diff(b)``).""" if not isinstance(other, AcSystemIrreducibilityAnalysis): raise TypeError( f"cannot diff {type(self).__name__} against {type(other).__name__}" ) common = _diff_common(self, other) return ResultDiff( subject=f"Δα = {format_value(common['delta_phi'])}", level="system", delta_phi=common["delta_phi"], mip_changed=common["mip_changed"], config_diff=common["config_diff"], substrate_note=common["substrate_note"], )
[docs] def is_orderable_with(self, other: object) -> bool: return isinstance(other, AcSystemIrreducibilityAnalysis) and ( self.direction == other.direction )
# TODO: shouldn't the minimal irreducible account be chosen?
[docs] def order_by(self): return [self.alpha, self.size]
def __eq__(self, other: object) -> bool: # noqa: PLR0911 if not isinstance(other, AcSystemIrreducibilityAnalysis): return NotImplemented if self.direction != other.direction: return False if self.account != other.account: return False if self.partitioned_account != other.partitioned_account: return False if self.partition != other.partition: return False if self.before_state != other.before_state: return False if self.after_state != other.after_state: return False if self.size != other.size: return False if self.node_indices != other.node_indices: return False if self.cause_indices != other.cause_indices: return False if self.effect_indices != other.effect_indices: return False if self.node_labels != other.node_labels: return False return numerics.eq(self.alpha, other.alpha) def __bool__(self): """An :class:`AcSystemIrreducibilityAnalysis` is ``True`` if it has 𝒜 > 0. """ return numerics.is_positive(self.alpha) def __hash__(self) -> int: return hash( ( self.direction, self.account, self.partitioned_account, self.partition, self.before_state, self.after_state, self.size, self.node_indices, self.cause_indices, self.effect_indices, self.node_labels, ) )
def _null_ac_sia(transition, direction, alpha=0.0, reasons=None): """Return an :class:`AcSystemIrreducibilityAnalysis` with zero 𝒜 and empty accounts. ``reasons`` records why (a list of :class:`~pyphi.models.explanation.NullResultReason`). """ return AcSystemIrreducibilityAnalysis( direction=direction, alpha=alpha, account=Account(()), partitioned_account=Account(()), partition=transition.partition, before_state=transition.before_state, after_state=transition.after_state, size=len(transition), node_indices=transition.node_indices, cause_indices=transition.cause_indices, effect_indices=transition.effect_indices, node_labels=transition.substrate.node_labels, reasons=reasons, )