Source code for pyphi.measures.ces

# metrics/ces.py
"""Functions for computing distances between cause-effect structures."""

from __future__ import annotations

from collections.abc import Callable
from collections.abc import Sequence
from typing import TYPE_CHECKING

import numpy as np
from numpy.typing import NDArray

from pyphi import numerics
from pyphi.conf import config
from pyphi.registry import Registry
from pyphi.types import Repertoire

from . import distribution

if TYPE_CHECKING:
    from pyphi.models.distinction import Distinction as Concept
    from pyphi.models.distinctions import Distinctions
    from pyphi.system import System


[docs] class CESMeasureRegistry(Registry): """Storage for distance functions between cause-effect structures. Users can define custom measures. The third positional or keyword argument is the :class:`~pyphi.system.System` context; measures that do not need it can accept it as ``system=None`` and ignore it. Register a measure, then select it by setting ``config.ces_distance = 'ALWAYS_ZERO'``. Examples -------- >>> @measures.register('ALWAYS_ZERO') # doctest: +SKIP ... def always_zero(a, b, system=None): ... return 0 """ # pylint: disable=arguments-differ desc = "distance functions between cause-effect structures" def __init__(self) -> None: super().__init__() self._asymmetric: list[str] = []
[docs] def register( # type: ignore[override] # Intentionally extends base signature with asymmetric parameter self, name: str, asymmetric: bool = False ) -> Callable[[Callable[..., float]], Callable[..., float]]: """Decorator for registering a CES measure with PyPhi. Parameters ---------- name : str The name of the measure. asymmetric : bool ``True`` if the measure is asymmetric. """ def register_func(func: Callable[..., float]) -> Callable[..., float]: if asymmetric: self._asymmetric.append(name) self.store[name] = func # type: ignore[assignment] # Registry[T] specialized to float return func return register_func
[docs] def asymmetric(self) -> list[str]: """Return a list of asymmetric measures.""" return self._asymmetric
measures = CESMeasureRegistry()
[docs] def emd_ground_distance(r1: Repertoire, r2: Repertoire) -> float: """Compute the distance between two repertoires of a system. The measure named by ``config.formalism.iit.mechanism_phi_measure`` is used as the ground distance. An asymmetric measure cannot serve this role, because the system-level EMD requires a symmetric ground metric. Parameters ---------- r1 : np.ndarray The first repertoire. r2 : np.ndarray The second repertoire. Returns ------- float The distance between ``r1`` and ``r2``. Raises ------ ValueError If the configured mechanism measure is asymmetric. """ measure_name = config.formalism.iit.mechanism_phi_measure asymmetric_metrics = ( { name for name, metric in distribution.distribution_measures.items() if getattr(metric, "asymmetric", False) } | { name for name, metric in distribution.stateful_distribution_measures.items() if getattr(metric, "asymmetric", False) } | { name for name, metric in distribution.composite_measures.items() if getattr(metric, "asymmetric", False) } ) if measure_name in asymmetric_metrics: raise ValueError( f"The repertoire measure {measure_name} is " "asymmetric and cannot be used as the ground distance for " "the system-level EMD" ) measure = distribution.resolve_distribution_measure(measure_name) return distribution.repertoire_distance( r1, r2, direction=None, repertoire_distance=measure )
[docs] def emd_concept_distance(c1: Concept, c2: Concept, system: System) -> float: """Return the EMD distance between two concepts in concept space. The distance is the sum of the cause-side and effect-side ground distances. Each concept's cause and effect repertoires are first expanded to the combined purview of the two concepts, so that the two EMD signatures have the same size. Parameters ---------- c1 : Concept The first concept. c2 : Concept The second concept. system : System The system whose repertoire algebra expands each concept's cause and effect repertoires to the combined purview before the ground distance is taken. Returns ------- float The distance between the two concepts in concept space. """ # Calculate the sum of the cause and effect EMDs, expanding the repertoires # to the combined purview of the two concepts, so that the EMD signatures # are the same size. assert c1.cause is not None assert c1.effect is not None assert c2.cause is not None assert c2.effect is not None cause_purview = tuple(set(c1.cause.purview + c2.cause.purview)) effect_purview = tuple(set(c1.effect.purview + c2.effect.purview)) return emd_ground_distance( system.expand_cause_repertoire(c1.cause.repertoire, new_purview=cause_purview), system.expand_cause_repertoire(c2.cause.repertoire, new_purview=cause_purview), ) + emd_ground_distance( system.expand_effect_repertoire( c1.effect.repertoire, new_purview=effect_purview ), system.expand_effect_repertoire( c2.effect.repertoire, new_purview=effect_purview ), )
def _emd_simple(C1: Sequence[Concept], C2: Sequence[Concept], system: System) -> float: """Return the distance between two cause-effect structures. Assumes the only difference between them is that some concepts have disappeared. """ # Make C1 refer to the bigger CES. if len(C2) > len(C1): C1, C2 = C2, C1 destroyed = [c1 for c1 in C1 if not any(c1.emd_eq(c2) for c2 in C2)] null_concept = system.null_concept return sum(c.phi * emd_concept_distance(c, null_concept, system) for c in destroyed) def _emd( unique_C1: Sequence[Concept], unique_C2: Sequence[Concept], system: System ) -> float: """Return the distance between two cause-effect structures. Uses the generalized EMD. """ # Get the pairwise distances between the concepts in the unpartitioned and # partitioned CESs. distances = np.array( [[emd_concept_distance(i, j, system) for j in unique_C2] for i in unique_C1] ) # We need distances from all concepts---in both the unpartitioned and # partitioned CESs---to the null concept, because: # - often a concept in the unpartitioned CES is destroyed by a # cut (and needs to be moved to the null concept); and # - in certain cases, the partitioned system will have *greater* sum of # small-phi, even though it has less big-phi, which means that some # partitioned-CES concepts will be moved to the null concept. null_concept = system.null_concept distances_to_null = np.array( [ emd_concept_distance(c, null_concept, system) for ces in (unique_C1, unique_C2) for c in ces ] ) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Now we make the distance matrix, which will look like this: # # C1 C2 0 # +~~~~~~~~+~~~~~~~~+~~~+ # | | | | # C1 | X | D | | # | | | | # +~~~~~~~~+~~~~~~~~+ D | # | | | n | # C2 | D' | X | | # | | | | # +~~~~~~~~+~~~~~~~~+~~~| # 0 | Dn' | X | # +~~~~~~~~~~~~~~~~~~~~~+ # # The diagonal blocks marked with an X are set to a value larger than any # pairwise distance between concepts. The transport marginals already # forbid within-CES moves (each CES's mass sits on its own rows/columns), # so the large value is a numerical safeguard rather than the mechanism. # The D block is filled with the pairwise distances between the two CESs, # and Dn is filled with the distances from each concept to the null # concept. N, M = len(unique_C1), len(unique_C2) # Add one to the side length for the null concept distances. distance_matrix: NDArray[np.float64] = np.empty([N + M + 1] * 2) # Ensure that concepts are never moved within their own CES. distance_matrix[:] = np.max(distances) + 1 # Set the top-right block to the pairwise CES distances. distance_matrix[:N, N:-1] = distances # Set the bottom-left block to the same, but transposed. distance_matrix[N:-1, :N] = distances.T # Do the same for the distances to the null concept. distance_matrix[-1, :-1] = distances_to_null distance_matrix[:-1, -1] = distances_to_null.T distance_matrix[-1, -1] = 0 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Construct the two phi distributions, with an entry at the end for the # null concept. d1 = [c.phi for c in unique_C1] + [0] * M + [0] d2 = [0] * N + [c.phi for c in unique_C2] + [0] # Balance the two phi signatures onto a common total mass by assigning each # side's deficit to its own null concept. Both signatures stay non-negative # (a valid optimal-transport problem) even when the partitioned constellation # carries more phi than the unpartitioned one, and the construction is # symmetric in the two constellations. sum1, sum2 = sum(d1), sum(d2) total = max(sum1, sum2) d1[-1] = total - sum1 d2[-1] = total - sum2 # The sum of the two signatures should be the same. assert numerics.eq(sum(d1), sum(d2)) # Calculate! return distribution.EMD.compute(np.array(d1), np.array(d2), distance_matrix)
[docs] @measures.register("EMD") def emd(C1: Distinctions, C2: Distinctions, system: System | None = None) -> float: """Return the generalized EMD between two cause-effect structures. When the two structures differ only in that some concepts have disappeared, the simpler :func:`_emd_simple` is used; otherwise the full generalized EMD in :func:`_emd` is taken. The result is rounded to ``config.numerics.precision``. Parameters ---------- C1 : Distinctions The first :class:`~pyphi.models.distinctions.Distinctions`. C2 : Distinctions The second :class:`~pyphi.models.distinctions.Distinctions`. system : System The system the CESs were computed over. Required for the EMD measure: repertoire expansion and the null concept both come from the system. Returns ------- float The generalized EMD between the two structures. Raises ------ ValueError If ``system`` is ``None``. """ if system is None: raise ValueError( "The EMD CES measure requires a ``system`` argument; " "pass ``system=`` through ``ces_distance``." ) concepts_only_in_C1 = [c1 for c1 in C1 if not any(c1.emd_eq(c2) for c2 in C2)] concepts_only_in_C2 = [c2 for c2 in C2 if not any(c2.emd_eq(c1) for c1 in C1)] # If the only difference in the CESs is that some concepts # disappeared, then we don't need to use the EMD. if not concepts_only_in_C1 or not concepts_only_in_C2: dist = _emd_simple(C1, C2, system) else: dist = _emd(concepts_only_in_C1, concepts_only_in_C2, system) return round(dist, config.numerics.precision) # type: ignore[arg-type]
[docs] @measures.register("SUM_SMALL_PHI") def sum_small_phi( C1: Distinctions, C2: Distinctions, system: System | None = None, # noqa: ARG001 ) -> float: """Return the difference in summed φ between two structures. The value is ``sum(C1.phis) - sum(C2.phis)``. This is a signed difference of the total small-φ of each :class:`~pyphi.models.distinctions.Distinctions`, not a symmetric distance, and may be negative. The ``system`` argument is accepted for a uniform measure signature and is ignored. """ return sum(C1.phis) - sum(C2.phis)
[docs] def ces_distance( C1: Distinctions, C2: Distinctions, measure: str | None = None, *, system: System | None = None, ) -> float: """Return the distance between two cause-effect structures. Dispatches to the registered CES measure named by ``measure`` and rounds the result to ``config.numerics.precision``. Parameters ---------- C1 : Distinctions The first :class:`~pyphi.models.distinctions.Distinctions`. C2 : Distinctions The second :class:`~pyphi.models.distinctions.Distinctions`. measure : str, optional Which registered CES measure to use. If ``None``, defaults to ``config.formalism.iit.ces_measure``. system : System, optional The system the CESs were computed over. Required by measures that operate on full repertoires (e.g. ``EMD``); ignored by purely phi-summing measures (e.g. ``SUM_SMALL_PHI``). Returns ------- float The distance between the two cause-effect structures. """ measure_name: str = config.formalism.iit.ces_measure if measure is None else measure # type: ignore[assignment] dist: float = measures[measure_name](C1, C2, system=system) return round(dist, config.numerics.precision) # type: ignore[arg-type]