Source code for pyphi.models.mice

# models/mice.py
"""Maximally irreducible cause/effect (MICE) wrapper objects.

A MICE wraps the :class:`RepertoireIrreducibilityAnalysis` of a mechanism
over its maximally-specifying purview in one direction. The two concrete
subclasses (:class:`MaximallyIrreducibleCause` and
:class:`MaximallyIrreducibleEffect`) enforce a direction invariant.
"""

from __future__ import annotations

from itertools import chain
from typing import TYPE_CHECKING

import numpy as np
from more_itertools import flatten

from pyphi import connectivity
from pyphi import numerics
from pyphi.direction import Direction
from pyphi.display import LOW
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.mixin import FULL
from pyphi.display.numbers import format_value
from pyphi.exceptions import WrongDirectionError
from pyphi.models import fmt
from pyphi.models.explanation import Explanation
from pyphi.models.explanation import Finding

from .pandas import ToDictFromExplicitAttrsMixin
from .pandas import ToPandasMixin
from .ria import _ria_dict_attrs

if TYPE_CHECKING:
    from .concept import Concept

from . import cmp


# TODO: implement as a subclass of RIA?
[docs] class MaximallyIrreducibleCauseOrEffect( Displayable, cmp.Orderable, ToDictFromExplicitAttrsMixin, ToPandasMixin ): """A maximally irreducible cause or effect (MICE). These can be compared with the built-in Python comparison operators (``<``, ``>``, etc.). Comparison is by φ value (:meth:`order_by`). """ parent: Concept # Set by Concept.__init__ def __init__(self, ria): self._ria = ria self._state_ties = None self._partition_ties = None self._purview_ties = None self._purview_margin = None @property def phi(self): """float: The difference between the mechanism's unpartitioned and partitioned repertoires. """ return self._ria.phi @property def normalized_phi(self): """float: Normalized φ value.""" return self._ria.normalized_phi @property def direction(self): """Direction: CAUSE or EFFECT.""" return self._ria.direction @property def mechanism(self): """list[int]: The mechanism for which the MICE is evaluated.""" return self._ria.mechanism @property def mechanism_label(self): """list[int]: The mechanism for which the MICE is evaluated.""" return self._ria.mechanism_label @property def mechanism_state(self): """tuple[int]: The current state of the mechanism.""" return self._ria.mechanism_state @property def purview(self): """list[int]: The purview over which this mechanism's φ is maximal.""" return self._ria.purview @property def purview_label(self): return self.ria.purview_label @property def purview_units(self): return self.ria.purview_units # TODO: remove or rename to "purview_current_state" @property def purview_state(self): """tuple[int]: The current state of the purview.""" return self._ria.purview_state @property def mip(self): """JointPartition: The partition that makes the least difference to the mechanism's repertoire. """ return self._ria.partition @property def repertoire(self): """np.ndarray: The unpartitioned repertoire of the mechanism over the purview. """ return self._ria.repertoire @property def partitioned_repertoire(self): """np.ndarray: The partitioned repertoire of the mechanism over the purview. """ return self._ria.partitioned_repertoire @property def selectivity(self): """float: The selectivity factor.""" return self._ria.selectivity @property def specified_state(self): """The state(s) with the maximal absolute intrinsic difference between the unpartitioned and partitioned repertoires.""" return self._ria.specified_state @property def ria(self): """RepertoireIrreducibilityAnalysis: The irreducibility analysis for this mechanism. """ return self._ria @property def node_labels(self): return self.ria.node_labels @property def partition(self): return self.ria.partition @property def reasons(self): return self.ria.reasons @property def purview_margin(self): """The φ gap between this purview and the best competing purview. Purview selection is keyed on the value it reports, so a purview switch changes the cause-effect structure's composition without a discontinuity in φ; this margin measures how decisively the structural choice was made. Zero when another purview ties exactly; ``None`` when there was no competing purview. Excluded from equality and hashing. """ return self._purview_margin @purview_margin.setter def purview_margin(self, value): self._purview_margin = None if value is None else float(value) @property def partition_margin(self): """The winning RIA's mechanism-partition selection margin (:attr:`~pyphi.models.ria.RepertoireIrreducibilityAnalysis.partition_margin`). """ return self.ria.partition_margin @property def state_margin(self): """The winning RIA's specified-state selection margin (:attr:`~pyphi.models.ria.RepertoireIrreducibilityAnalysis.state_margin`). """ return self.ria.state_margin @property def effectively_tied(self): """Whether the purview, partition, or specified-state selection is within ``config.numerics.precision`` of a tie.""" return ( self.purview_margin is not None and numerics.eq(float(self.purview_margin), 0.0) ) or self.ria.effectively_tied
[docs] def explain(self): """A typed account of why this φ value came out as it did: the underlying RIA's findings plus the purview-selection margin.""" explanation = self.ria.explain() if self.purview_margin is None: return explanation return Explanation( subject=explanation.subject, level=explanation.level, findings=( *explanation.findings, Finding( kind="purview_margin", label="Purview selection margin (φ)", value=self.purview_margin, tone=tone_of(self.direction), ), ), )
[docs] def diff(self, other): """Structured delta to ``other`` (``a.diff(b)``), delegated to the underlying RIA.""" other_ria = other.ria if hasattr(other, "ria") else other return self.ria.diff(other_ria)
@property def state_ties(self): if self._state_ties is None: self._state_ties = ( self, *tuple( type(self)(tie) for tie in self.ria.state_ties if tie is not self.ria ), ) return self._state_ties def set_state_ties(self, ties): # Update state ties on other tied objects ties = tuple(ties) self._state_ties = ties # Update state ties on other tied objects for tie in chain.from_iterable( filter(None, [self.partition_ties, self.purview_ties]) ): tie._state_ties = ties @property def num_state_ties(self): return self.ria.num_state_ties @property def partition_ties(self): if self._partition_ties is None: self._partition_ties = ( self, *tuple( type(self)(tie) for tie in self.ria.partition_ties if tie is not self.ria ), ) return self._partition_ties def set_partition_ties(self, ties): ties = tuple(ties) self._partition_ties = ties # Update partition ties on other tied objects for tie in chain.from_iterable( filter(None, [self.state_ties, self.purview_ties]) ): tie._partition_ties = ties @property def num_partition_ties(self): return self.ria.num_partition_ties @property def purview_ties(self): """tuple[MaximallyIrreducibleCauseOrEffect]: The purviews that are tied for maximal φ value. """ return self._purview_ties
[docs] def set_purview_ties(self, ties): """Set the ties.""" self._purview_ties = tuple(ties) # Update purview ties on other tied objects for tie in flatten([self.state_ties, self.partition_ties]): tie._purview_ties = ties
@property def num_purview_ties(self): if self._purview_ties is None: return np.nan return len(self._purview_ties) - 1
[docs] def flip(self): """Return the linked MICE in the other direction.""" return self.parent.mice(self.direction.flip())
[docs] def is_congruent(self, specified_state): """Return whether the state specified by this MICE is congruent.""" return self.ria.is_congruent(specified_state)
def _describe(self, verbosity: int) -> Description: direction_label = ( self.direction.name.title() if self.direction is not None else "" ) title = f"MaximallyIrreducible{direction_label}" compact = f"{title}({fmt.SMALL_PHI}={format_value(self.phi)})" if verbosity == LOW: return Description(title=title, compact=compact) # Inherit the RIA's sections, adding the purview-ties count (and, at # FULL verbosity, the purview-selection margin) to its "Ties" section. extra_rows = [Row("Purview ties", self.num_purview_ties)] if verbosity >= FULL and self.purview_margin is not None: extra_rows.append(Row("Purview margin", self.purview_margin)) sections = [] injected = False for sec in self.ria._describe(verbosity).sections: if sec.label == "Ties": sections.append( Section(label="Ties", rows=(*sec.rows, *extra_rows), body=sec.body) ) injected = True else: sections.append(sec) if not injected: sections.append(Section(label="Ties", rows=tuple(extra_rows))) return Description(title=title, sections=tuple(sections), compact=compact)
[docs] def order_by(self): return self.ria.order_by()
def __eq__(self, other: object) -> bool: if not isinstance(other, MaximallyIrreducibleCauseOrEffect): return NotImplemented return self.ria == other.ria def __hash__(self): return hash(self._ria) _dict_attrs = _ria_dict_attrs
[docs] def to_dict(self): dct = super().to_dict() dct["is_mice"] = True return dct
def _pandas_record(self): record = self.ria._pandas_record() record["purview_margin"] = ( None if self.purview_margin is None else float(self.purview_margin) ) record["effectively_tied"] = self.effectively_tied return record def _relevant_connections(self, system): """Identify the connections that matter to this MICE. For a MIC, the connections that matter are those from the purview to the mechanism; for a MIE they are those from the mechanism to the purview. The result is an N × N matrix, where N is the substrate size: ``direction == Direction.CAUSE``: ``relevant_connections[i, j]`` is ``1`` if node ``i`` is in the cause purview and node ``j`` is in the mechanism (and ``0`` otherwise). ``direction == Direction.EFFECT``: ``relevant_connections[i, j]`` is ``1`` if node ``i`` is in the mechanism and node ``j`` is in the effect purview (and ``0`` otherwise). Parameters ---------- system : System The System of this MICE. Returns ------- np.ndarray An N × N matrix of connections, where N is the substrate size. Raises ------ ValueError If ``direction`` is invalid. """ _from, to = self.direction.order(self.mechanism, self.purview) return connectivity.relevant_connections(system.substrate.size, _from, to) # TODO: pass in `cut` instead? We can infer # system indices from the cut itself, validate, and check.
[docs] def damaged_by_cut(self, system): """Return ``True`` if this MICE is affected by the system's cut. The cut affects the MICE if it either splits the MICE's mechanism or splits the connections between the purview and mechanism. """ return system.partition.splits_mechanism(self.mechanism) or np.any( self._relevant_connections(system) * system.partition.cut_matrix(system.substrate.size) == 1 )
def __getstate__(self): dct = self.__dict__.copy() dct["parent"] = None return dct
[docs] class MaximallyIrreducibleCause(MaximallyIrreducibleCauseOrEffect): """A maximally irreducible cause (MIC). These can be compared with the built-in Python comparison operators (``<``, ``>``, etc.). Comparison is by φ value (:meth:`order_by`). """ def __init__(self, ria): if ria.direction != Direction.CAUSE: raise WrongDirectionError( "A MIC must be initialized with a RIA in the cause direction." ) super().__init__(ria)
[docs] def order_by(self): return self.ria.order_by()
@property def direction(self): """Direction: CAUSE.""" return self._ria.direction
[docs] class MaximallyIrreducibleEffect(MaximallyIrreducibleCauseOrEffect): """A maximally irreducible effect (MIE). These can be compared with the built-in Python comparison operators (``<``, ``>``, etc.). Comparison is by φ value (:meth:`order_by`). """ def __init__(self, ria): if ria.direction != Direction.EFFECT: raise WrongDirectionError( "A MIE must be initialized with a RIA in the effect direction." ) super().__init__(ria) @property def direction(self): """Direction: EFFECT.""" return self._ria.direction