# formalism/iit3/__init__.py
"""IIT 3.0 (Oizumi et al. 2014) algorithms.
Distribution-distance-based phi computation. Partition scheme:
``JOINT_BIPARTITION``. Compatible measures: ``EMD``, ``L1``, ``KLD``,
``ENTROPY_DIFFERENCE``, ``PSQ2``, ``MP2Q``, ``AID`` (absolute intrinsic
difference), ``ID`` (intrinsic difference).
The dispatch class :class:`IIT3Formalism` lives in :mod:`.formalism` and
calls back into this module's ``sia`` / ``ces`` / etc. for the actual
computation.
"""
from __future__ import annotations
import contextlib
import logging
from collections.abc import Iterable
from typing import TYPE_CHECKING
from typing import Any
from more_itertools import collapse
from pyphi import conf
from pyphi import connectivity
from pyphi import utils
from pyphi.conf import config
from pyphi.direction import Direction
from pyphi.measures.ces import ces_distance
from pyphi.models import DirectedBipartition
from pyphi.models import Distinction
from pyphi.models import Distinctions
from pyphi.models import IIT3SystemIrreducibilityAnalysis
from pyphi.models import NullResultReason
from pyphi.models import ResolvedDistinctions
from pyphi.models import UnresolvedDistinctions
from pyphi.models import _null_sia
from pyphi.models.ces import CauseEffectStructure
from pyphi.models.explanation import runner_up_from_candidates
from pyphi.parallel import map_reduce
from pyphi.partition import system_partition_types
from pyphi.relations import NullRelations
from pyphi.types import Mechanism
from pyphi.types import Purview
if TYPE_CHECKING:
from pyphi.labels import NodeLabels
from pyphi.system import System
# Create a logger for this module.
log = logging.getLogger(__name__)
def _check_active_version() -> None:
"""Raise unless the active formalism version is IIT 3.0.
Mechanism-level work dispatches through the *active* formalism, so
calling these functions under another version would silently mix
formalisms.
"""
from pyphi.conf._field_routing import ConfigurationError
version = config.formalism.iit.version
if version != "IIT_3_0":
raise ConfigurationError(
f"pyphi.formalism.iit3 functions dispatch mechanism-level work "
f"through the active formalism, which is {version!r}; pin "
f"IIT 3.0 first, e.g. with "
f"pyphi.config.override(**pyphi.conf.presets.iit3)"
)
[docs]
def concept(
system: System,
mechanism: Mechanism,
purviews: Iterable[Purview] | None = None,
cause_purviews: Iterable[Purview] | None = None,
effect_purviews: Iterable[Purview] | None = None,
**kwargs: Any,
) -> Distinction:
"""Return the IIT 3.0 concept specified by a mechanism.
A concept pairs the mechanism's maximally irreducible cause (its MIC) with
its maximally irreducible effect (its MIE). The empty mechanism specifies
the null concept.
Parameters
----------
system : System
The system the mechanism belongs to.
mechanism : tuple[int]
The mechanism for which to determine the concept.
purviews : tuple[tuple[int]], optional
A list of purviews to consider.
cause_purviews : tuple[tuple[int]], optional
A list of cause purviews to consider, overriding ``purviews``.
effect_purviews : tuple[tuple[int]], optional
A list of effect purviews to consider, overriding ``purviews``.
Returns
-------
Distinction
The concept of the given mechanism.
"""
from pyphi.formalism.queries import distinction
return distinction(
system,
mechanism,
purviews=purviews,
cause_purviews=cause_purviews,
effect_purviews=effect_purviews,
**kwargs,
)
def _compute_distinctions(
system: System,
mechanisms: Iterable[Mechanism] | None = None,
purviews: Iterable[Purview] | None = None,
cause_purviews: Iterable[Purview] | None = None,
effect_purviews: Iterable[Purview] | None = None,
directions: Iterable[Direction] | None = None,
only_positive_phi: bool = True,
**kwargs: Any,
) -> Distinctions:
"""Compute the bag of distinctions for a system, restricted by the
given mechanism / purview / direction filters.
Distinctions are computed over the mechanisms via
:func:`pyphi.parallel.map_reduce`, so the ``parallel`` keyword and the
other keys of ``config.infrastructure.parallel_distinction_evaluation``
govern the work.
Parameters
----------
system : System
The system for which to determine the
:class:`~pyphi.models.distinctions.Distinctions`.
mechanisms : tuple[tuple[int]], optional
Restrict possible mechanisms to those in this list. If ``None``, the
non-empty powerset of system nodes is used.
purviews : tuple[tuple[int]], optional
Same as in :func:`pyphi.formalism.iit3.concept`.
cause_purviews : tuple[tuple[int]], optional
Same as in :func:`pyphi.formalism.iit3.concept`.
effect_purviews : tuple[tuple[int]], optional
Same as in :func:`pyphi.formalism.iit3.concept`.
directions : Iterable[Direction], optional
Restrict possible directions to these.
only_positive_phi : bool
Whether to only return distinctions with positive φ (default
``True``).
parallel : bool, optional
Whether to compute distinctions in parallel. If given, overrides
:data:`config.infrastructure.parallel_distinction_evaluation`.
Returns
-------
Distinctions
An :class:`~pyphi.models.UnresolvedDistinctions` over the surviving
distinctions. It is unresolved because the active formalism's MICE search
may return tied specified states; IIT 3.0 callers route through
``ces_distance``, which accepts the base distinctions unchanged.
"""
total = None
if mechanisms is None:
mechanisms = utils.powerset(system.node_indices, nonempty=True)
total = 2 ** len(system.node_indices) - 1
else:
with contextlib.suppress(TypeError):
total = len(mechanisms) # type: ignore[arg-type] # mechanisms may be generator
def compute_concept(*args, **kwargs):
return concept(system, *args, **kwargs, progress=False)
reduce_func = _only_positive_phi if only_positive_phi else _any_phi
parallel_kwargs = conf.parallel_kwargs(
dict(config.infrastructure.parallel_distinction_evaluation), **kwargs
)
concepts = map_reduce(
compute_concept,
mechanisms,
map_kwargs={ # type: ignore[arg-type] # None values allowed in map_kwargs
"purviews": purviews,
"cause_purviews": cause_purviews,
"effect_purviews": effect_purviews,
"directions": directions,
},
reduce_func=reduce_func,
desc="Computing distinctions",
total=total,
# Larger mechanisms span larger cause/effect repertoires; a cheap
# directional proxy for per-mechanism cost.
size_func=lambda mechanism: 2 ** len(mechanism),
**parallel_kwargs, # type: ignore[arg-type] # parallel_kwargs contains map_reduce params
)
# ``find_mice`` may return tied specified states under the active
# formalism (IIT 4.0 in particular), so the conservative answer is
# ``UnresolvedDistinctions``. Callers in IIT 4.0 phi_structure
# immediately call ``resolve_congruence``; IIT 3.0 callers route
# through ``ces_distance`` which accepts the base ``Distinctions``.
return UnresolvedDistinctions(concepts)
[docs]
def ces(
system: System,
*,
sia: IIT3SystemIrreducibilityAnalysis | None = None,
distinctions: Distinctions | None = None,
sia_kwargs: dict | None = None,
distinctions_kwargs: dict | None = None,
) -> CauseEffectStructure:
"""Compute the cause-effect structure of a system under IIT 3.0.
Returns a :class:`CauseEffectStructure` wrapping the SIA, the resolved
distinctions, and an empty :class:`NullRelations` (IIT 3.0 does not
define relations between distinctions).
Pass ``sia=`` or ``distinctions=`` to reuse pre-computed values.
"""
import time
from pyphi.provenance import stamp_wall_time
_check_active_version()
start = time.perf_counter()
sia_kwargs = sia_kwargs or {}
distinctions_kwargs = distinctions_kwargs or {}
if sia is None:
sia = _sia(system, **sia_kwargs)
if distinctions is None:
cached = sia.distinctions
if cached is not None and not distinctions_kwargs:
distinctions = cached
else:
distinctions = _compute_distinctions(system, **distinctions_kwargs)
if not isinstance(distinctions, ResolvedDistinctions):
distinctions = ResolvedDistinctions(distinctions)
result = CauseEffectStructure(
sia=sia,
distinctions=distinctions,
relations=NullRelations(),
)
return stamp_wall_time(result, time.perf_counter() - start)
def _only_positive_phi(concepts: Iterable[Any]) -> list[Distinction]:
return list(filter(None, collapse(concepts)))
def _any_phi(concepts: Iterable[Any]) -> list[Distinction]:
return list(collapse(concepts))
[docs]
def conceptual_info(system: System, **kwargs: Any) -> float:
"""Return the conceptual information for a :class:`~pyphi.system.System`.
This is the distance from the system's
:class:`~pyphi.models.distinctions.Distinctions` to the null concept.
"""
_check_active_version()
ci = ces_distance(
_compute_distinctions(system, **kwargs), ResolvedDistinctions(()), system=system
)
return round(ci, config.numerics.precision) # type: ignore[arg-type] # config.Option descriptor
[docs]
def evaluate_partition(
partition: DirectedBipartition,
unpartitioned_system: System,
unpartitioned_ces: Distinctions,
**kwargs: Any,
) -> IIT3SystemIrreducibilityAnalysis:
"""Compute the system irreducibility for a given partition.
The partition is applied to the system, the partitioned system's
distinctions are computed, and φ is the CES distance
(:func:`pyphi.measures.ces.ces_distance`) between the unpartitioned and
partitioned distinctions.
Parameters
----------
partition : DirectedBipartition
The partition to evaluate.
unpartitioned_system : System
The system without a partition applied.
unpartitioned_ces : Distinctions
The cause-effect structure of the unpartitioned system.
Returns
-------
IIT3SystemIrreducibilityAnalysis
The Φ analysis for that partition.
"""
log.debug("Evaluating %s...", partition)
partitioned_system = unpartitioned_system.apply_partition(partition)
if config.formalism.iit.assume_partitions_cannot_create_new_concepts:
mechanisms = list(unpartitioned_ces.mechanisms)
else:
# Mechanisms can only produce concepts if they were concepts in the
# original system, or the partition splits the mechanism.
mechanisms = set(
list(unpartitioned_ces.mechanisms)
+ list(partitioned_system.partitioned_mechanisms)
)
kwargs = {"progress": False, **kwargs}
partitioned_ces = _compute_distinctions(partitioned_system, mechanisms, **kwargs)
log.debug("Finished evaluating %s.", partition)
phi_ = ces_distance(unpartitioned_ces, partitioned_ces, system=unpartitioned_system)
return IIT3SystemIrreducibilityAnalysis(
phi=phi_,
partitioned_distinctions=partitioned_ces,
partition=partitioned_system.partition,
node_indices=unpartitioned_system.node_indices,
node_labels=unpartitioned_system.substrate.node_labels,
current_state=unpartitioned_system.proper_state,
)
[docs]
def sia_partitions(
nodes: tuple[int, ...], node_labels: NodeLabels | None = None
) -> list[DirectedBipartition]:
"""Return all Φ cuts for the given nodes.
The scheme is chosen by the
:const:`config.formalism.iit.system_partition_scheme` option, which for IIT
3.0 must be one of the schemes in
:attr:`IIT3Formalism.compatible_system_partition_schemes`
(``DIRECTED_BIPARTITION`` or ``DIRECTED_BIPARTITION_CUT_ONE``); any other
value raises :class:`ValueError`.
Parameters
----------
nodes : tuple[int]
The node indices to partition.
node_labels : NodeLabels, optional
Enables printing the partition with labels.
Returns
-------
list[DirectedBipartition]
All unidirectional partitions.
Raises
------
ValueError
If the configured system partition scheme is not compatible with IIT
3.0.
"""
# TODO(4.0 consolidate 3.0 and 4.0 cuts)
scheme = config.formalism.iit.system_partition_scheme
from pyphi.formalism.iit3.formalism import IIT3Formalism
valid = IIT3Formalism.compatible_system_partition_schemes
if valid is not None and scheme not in valid:
raise ValueError(
"IIT 3.0 calculations must use one of the following system "
f"partition schemes: {sorted(valid)}; got {scheme}"
)
return system_partition_types[config.formalism.iit.system_partition_scheme]( # type: ignore[index] # config.Option descriptor
nodes, node_labels=node_labels
)
def _ces(system: System, **kwargs: Any) -> Distinctions:
"""Compute the unpartitioned :class:`~pyphi.models.distinctions.Distinctions`
using the partition-evaluation parallel settings.
"""
kwargs = {**dict(config.infrastructure.parallel_partition_evaluation), **kwargs}
return _compute_distinctions(system, **kwargs)
def _sia_map_reduce(
cuts: Iterable[DirectedBipartition],
system: System,
unpartitioned_ces: Distinctions,
**kwargs: Any,
) -> IIT3SystemIrreducibilityAnalysis:
"""Evaluate every partition and select the MIP via
``resolve_ties.sias``. The config knob
``config.formalism.iit.sia_tie_resolution`` controls the selection
strategy.
"""
from pyphi import resolve_ties
kwargs = {**dict(config.infrastructure.parallel_partition_evaluation), **kwargs}
null = _null_sia(system, reasons=[NullResultReason.NO_VALID_PARTITIONS])
cuts = list(cuts)
candidates = map_reduce(
evaluate_partition,
cuts,
map_kwargs={
"unpartitioned_system": system,
"unpartitioned_ces": unpartitioned_ces,
},
shortcircuit_func=utils.is_falsy,
desc="Evaluating cuts",
**kwargs,
)
if not candidates:
return null
ties = tuple(resolve_ties.sias(candidates, default=null))
if not ties:
return null
winner = ties[0]
# A short-circuited sweep evaluated only a prefix of the cuts, so its
# next-best candidate is not the runner-up over all of them.
if len(candidates) == len(cuts):
winner.runner_up = runner_up_from_candidates(candidates, winner.phi)
# Capture phi-tied peers (same minimum phi value) before any
# lex-based tiebreaker collapses the set to a single winner. These
# peers are equally valid MIPs for the system; preserving them on
# the winner lets callers (serialization, diagnostic display, and
# fixtures comparing across tied alternatives) see the full tie set.
phi_ties = tuple(resolve_ties.sias(candidates, strategy=["PHI"], default=null))
winner.set_ties(list(phi_ties) if len(phi_ties) > 1 else [winner])
return winner
def _sia(system: System, **kwargs: Any) -> IIT3SystemIrreducibilityAnalysis:
"""Return the minimal information partition of a system.
Φ is necessarily zero, and a null analysis is returned immediately, when the
system is empty, is not strongly connected, or is a single micro element
with no self-loop (or with a self-loop but
``config.formalism.iit.single_micro_nodes_with_selfloops_have_phi`` unset).
Otherwise the unpartitioned distinctions are computed once, every system
partition is evaluated, and the partition of minimum φ is selected.
Parameters
----------
system : System
The candidate set of nodes.
**kwargs
Options of the partition evaluation's
:func:`~pyphi.parallel.map_reduce` (``parallel``, ``progress``, ...).
Any other name raises ``TypeError``.
Returns
-------
IIT3SystemIrreducibilityAnalysis
A nested structure containing all the data from the intermediate
calculations. The top level contains the basic irreducibility
information for the given system.
"""
conf.reject_unknown_kwargs("sia", kwargs)
_check_active_version()
log.info("Calculating big-phi data for %s...", system)
# Check for degenerate cases
# =========================================================================
# Phi is necessarily zero if the system is:
# - not strongly connected;
# - empty;
# - an elementary micro mechanism (i.e. no nontrivial bipartitions).
# So in those cases we immediately return a null SIA.
if not system:
log.info("System %s is empty; returning null SIA immediately.", system)
return _null_sia(system, reasons=[NullResultReason.NO_SYSTEM])
if not connectivity.is_strong(system.cm, system.node_indices):
log.info(
"%s is not strongly connected; returning null SIA immediately.",
system,
)
return _null_sia(system, reasons=[NullResultReason.NO_STRONG_CONNECTIVITY])
# Handle elementary micro mechanism cases.
# Single macro element systems have nontrivial bipartitions because their
# bipartitions are over their micro elements.
if len(system.partition_indices) == 1:
# If the node lacks a self-loop, phi is trivially zero.
if not system.cm[system.node_indices][system.node_indices]:
log.info(
"Single micro nodes %s without selfloops cannot have "
"phi; returning null SIA immediately.",
system,
)
return _null_sia(system, reasons=[NullResultReason.MONAD_WITH_NO_SELFLOOP])
# Even if the node has a self-loop, we may still define phi to be zero.
if not config.formalism.iit.single_micro_nodes_with_selfloops_have_phi:
log.info(
"Single micro nodes %s with selfloops cannot have "
"phi; returning null SIA immediately.",
system,
)
return _null_sia(
system,
reasons=[NullResultReason.MONAD_WITH_SELFLOOP_DEFINED_TO_BE_ZERO_PHI],
)
# =========================================================================
log.debug("Finding unpartitioned Distinctions...")
unpartitioned_ces = _ces(system, progress=kwargs.get("progress"))
if not unpartitioned_ces:
log.info("Empty unpartitioned Distinctions; returning null SIA immediately.")
# Short-circuit if there are no concepts in the unpartitioned CES.
return _null_sia(system, reasons=[NullResultReason.EMPTY_CAUSE_EFFECT_STRUCTURE])
log.debug("Found unpartitioned Distinctions.")
# TODO: move this into sia_bipartitions?
# Only True if SINGLE_MICRO_NODES...=True, no?
if len(system.partition_indices) == 1:
cuts = [
DirectedBipartition(
Direction.EFFECT,
system.partition_indices,
system.partition_indices,
system.partition_node_labels,
)
]
else:
cuts = sia_partitions(system.partition_indices, system.partition_node_labels)
result = _sia_map_reduce(cuts, system, unpartitioned_ces, **kwargs)
# Attach the unpartitioned distinctions already computed above so that
# ``ces`` can reuse them rather than recomputing. They are a property of
# the system and state, not of the partition, so every phi-tied peer
# shares the same CES; attaching it across the tie set keeps the
# alternatives comparable under ``__eq__`` (which compares distinctions).
for peer in result.ties:
peer.distinctions = unpartitioned_ces
if config.infrastructure.clear_system_caches_after_computing_sia:
log.debug("Clearing system caches.")
system.clear_caches()
log.info("Finished calculating big-phi data for %s.", system)
return result
sia = _sia
[docs]
def phi(system: System) -> float:
"""Return the Φ value of a system."""
return sia(system).phi