Source code for pyphi.matching.matching

"""Matching: the expected differentiation gap between world and noise."""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

import numpy as np

from pyphi import numerics

from .differentiation import Differentiation

if TYPE_CHECKING:
    from collections.abc import Mapping

    from .perception import Perception


[docs] @dataclass(frozen=True) class MatchingResult: """The result of a matching computation (Eq. 21). Carries everything needed to reproduce the value: the seed, the sampling parameters, and the per-trial perceptual differentiation of the world and noise sequences. Attributes ---------- value : float The matching estimate: the mean of ``world_differentiation`` minus the mean of ``noise_differentiation``. seed : int The seed of the RNG used to draw the sequences. n_trials : int The number of paired world/noise sequences sampled. k : int The length of each sampled stimulus sequence. world_differentiation : tuple of float Per-trial perceptual differentiation of the world sequences. noise_differentiation : tuple of float Per-trial perceptual differentiation of the noise sequences. subsequence : tuple of int or None The winning 1-based inclusive window ``(a, b)`` when the maximum was taken over contiguous subsequences; ``None`` when the full sequence was used. The per-trial values are those of this window. """ value: float seed: int n_trials: int k: int world_differentiation: tuple[float, ...] noise_differentiation: tuple[float, ...] subsequence: tuple[int, int] | None = None
[docs] @dataclass(frozen=True) class MatchingAnalysis: """Matching between a system and a world distribution over stimuli. Matching (Eq. 21) is the expected excess of perceptual differentiation that a complex's environment evokes over what random noise evokes. Attributes ---------- perceptions : Mapping[tuple[int, ...], Perception] Maps each stimulus to the perceptual structure it triggers. world_distribution : Mapping[tuple[int, ...], float] The probability of each stimulus in the world; must sum to 1 and every stimulus with positive probability must appear in ``perceptions``. Notes ----- The noise distribution is uniform over all stimuli that have a perceptual structure (the structureless world). """ perceptions: Mapping[tuple[int, ...], Perception] world_distribution: Mapping[tuple[int, ...], float] def __post_init__(self): if not self.perceptions: raise ValueError("perceptions must contain at least one stimulus") missing = set(self.world_distribution) - set(self.perceptions) if missing: raise ValueError( f"world stimuli without a perceptual structure: {sorted(missing)}" ) probabilities = list(self.world_distribution.values()) if any(p < 0 for p in probabilities): raise ValueError("world probabilities must be nonnegative") total = float(sum(probabilities)) if not numerics.eq(total, 1.0): raise ValueError(f"world probabilities must sum to 1 (got {total})") @property def noise_distribution(self) -> dict[tuple[int, ...], float]: """The structureless world: uniform over stimuli with a structure.""" stimuli = sorted(self.perceptions) return {stimulus: 1.0 / len(stimuli) for stimulus in stimuli}
[docs] def matching( self, *, seed: int, n_trials: int, k: int, subsequence_max: bool = False, ) -> MatchingResult: """Estimate matching M (Eq. 21) by seeded Monte Carlo sampling. Each trial samples a length-``k`` world sequence (i.i.d. from ``world_distribution``) and a length-``k`` noise sequence (i.i.d. from ``noise_distribution``) and compares their perceptual differentiation. The two sequences are drawn from common random numbers (the same uniform deviates mapped through each distribution's inverse CDF), so the comparison is paired: identical distributions yield identical sequences and a gap of exactly zero. Parameters ---------- seed : int Seed for the isolated ``numpy`` RNG; recorded in the result. n_trials : int Number of paired world/noise sequences to sample. Must be >= 1. k : int Length of each stimulus sequence. Must be >= 1. subsequence_max : bool, optional If False (default), the gap is computed over the full sequence (Eq. 21 with ``(a, b) = (1, k)``), a lower bound on the maximized quantity. If True, the trial-mean gap is computed for every contiguous 1-based window ``(a, b)`` with ``a < b`` — Eq. 21's maximization domain, so ``k`` must be at least 2 — and the maximum is returned, recording the winning window. Returns ------- MatchingResult The estimate, its inputs, and the per-trial world and noise differentiation values. Raises ------ ValueError If ``n_trials`` or ``k`` is less than 1, or if ``subsequence_max`` is true and ``k`` is less than 2. """ if n_trials < 1: raise ValueError("n_trials must be at least 1") if k < 1: raise ValueError("k must be at least 1") rng = np.random.default_rng(seed) stimuli = sorted(self.perceptions) world_cdf = np.cumsum([self.world_distribution.get(s, 0.0) for s in stimuli]) noise_cdf = np.cumsum(list(self.noise_distribution.values())) # Guard against round-off in the final bin; the totals were validated. world_cdf[-1] = noise_cdf[-1] = 1.0 deviates = rng.random((n_trials, k)) world_sequences = np.searchsorted(world_cdf, deviates, side="right") noise_sequences = np.searchsorted(noise_cdf, deviates, side="right") # D_p of a sequence depends only on its set of distinct stimuli. cache: dict[frozenset[int], float] = {} def differentiation_of(indices) -> float: key = frozenset(int(i) for i in indices) if key not in cache: cache[key] = Differentiation( tuple(self.perceptions[stimuli[i]] for i in sorted(key)) ).perceptual_differentiation return cache[key] def per_trial(a: int, b: int) -> tuple[tuple[float, ...], tuple[float, ...]]: world = tuple( differentiation_of(world_sequences[t, a - 1 : b]) for t in range(n_trials) ) noise = tuple( differentiation_of(noise_sequences[t, a - 1 : b]) for t in range(n_trials) ) return world, noise def gap(world, noise) -> float: return float(np.mean(np.asarray(world) - np.asarray(noise))) subsequence = None if subsequence_max: if k < 2: raise ValueError( "subsequence_max requires k >= 2: Eq. 21 maximizes over " "windows (a, b) with a < b" ) windows = { (a, b): per_trial(a, b) for a in range(1, k + 1) for b in range(a + 1, k + 1) } subsequence = max(windows, key=lambda window: gap(*windows[window])) world, noise = windows[subsequence] else: world, noise = per_trial(1, k) return MatchingResult( value=gap(world, noise), seed=seed, n_trials=n_trials, k=k, world_differentiation=world, noise_differentiation=noise, subsequence=subsequence, )