# 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