# substrate_generator/mechanisms.py
"""Library of unit mechanisms ported from Bjørn Juel's ``substrate_modeler``.
Each mechanism is a unit function with the signature
``f(element, weights, state, **params) -> float`` returning the probability that
``element`` is ON at the next step, given the substrate ``state`` at the current
step. This is the contract consumed by :func:`pyphi.substrate_generator.build_tpm`
(it is called once per from-state), so the assembled substrate TPM equals the
``dynamic_tpm`` of the ``substrate_modeler`` library, in which the present state
equals the past state.
State-dependent mechanisms (the "endorsement" family — :func:`resonator`,
:func:`mismatch_corrector`, :func:`stabilized_sigmoid`, :func:`biased_sigmoid`,
:func:`modulated_sigmoid`) read the unit's own current state, ``state[element]``.
They therefore require a self-loop (a nonzero ``weights[element, element]``) so
that the dependency is represented in the connectivity matrix;
:func:`pyphi.substrate_generator.create_substrate` inserts it automatically.
``resonator`` realizes the state-dependent coupling of the matching paper
(Mayner, Juel & Tononi), whose default ``weight_scale_mapping`` is the coupling
factor ``g(I_j, s_k)`` of that paper's appendix.
"""
import numpy as np
from .utils import map_to_floor_and_ceil
# Default state-dependent coupling for ``resonator``, keyed by
# ``(unit_state, input_state)`` — the matching paper's coupling factor g.
DEFAULT_WEIGHT_SCALE_MAPPING = {
(0, 0): 1.0,
(1, 0): 0.5,
(0, 1): 0.75,
(1, 1): 1.5,
}
def _ordered_inputs(element, weights, inputs):
"""Return the unit's input indices, preserving caller order when given.
With no explicit ``inputs``, the inputs are the nonzero entries of the
element's weight column, in ascending index order.
"""
if inputs is not None:
return tuple(inputs)
return tuple(int(i) for i in np.nonzero(weights[:, element])[0])
def _logistic(total_input, determinism, threshold):
return 1.0 / (1.0 + np.e ** (-determinism * (total_input - threshold)))
[docs]
def sigmoid(
element,
weights,
state,
*,
floor=0.0,
ceiling=1.0,
determinism=5.0,
threshold=0.0,
ising=True,
inputs=None,
**kwargs,
):
"""Logistic activation of the weighted input.
The probability that the unit is ON at the next step is
``σ(determinism · (Σ_i w_i s_i − threshold))``, where the inputs ``s_i``
are the unit's current input states (mapped to ±1 when ``ising`` is True)
and ``w_i`` their weights. ``determinism`` is the slope ``k`` of the
logistic unit in Albantakis et al. (2023, Eq. 60) and Marshall et al.
(2023, Eq. 2): larger values make the unit more deterministic. ``floor``
and ``ceiling`` clip the output.
"""
ins = _ordered_inputs(element, weights, inputs)
s = np.array([state[i] for i in ins], dtype=float)
if ising:
s = s * 2 - 1
w = np.array([weights[i, element] for i in ins], dtype=float)
y = _logistic(float(np.sum(s * w)), determinism, threshold)
return map_to_floor_and_ceil(y, floor, ceiling)
[docs]
def resonator(
element,
weights,
state,
*,
determinism,
threshold,
weight_scale_mapping=None,
floor=0.0,
ceiling=1.0,
inputs=None,
**kwargs,
):
"""State-dependent endorsement: inputs that agree with the unit's own state
are excitatory and amplified; disagreeing inputs are inhibitory.
Requires a self-loop so ``state[element]`` is part of the connectivity.
"""
if weight_scale_mapping is None:
weight_scale_mapping = DEFAULT_WEIGHT_SCALE_MAPPING
ins = _ordered_inputs(element, weights, inputs)
unit_state = state[element]
w = []
for i in ins:
input_state = state[i]
scale = weight_scale_mapping[(unit_state, input_state)]
base = weights[i, element]
w.append(base * scale if input_state == unit_state else -base * scale)
spin = np.array([2 * state[i] - 1 for i in ins], dtype=float)
y = _logistic(float(np.sum(spin * np.array(w))), determinism, threshold)
return map_to_floor_and_ceil(y, floor, ceiling)
[docs]
def sor_gate(
element,
weights,
state,
*,
pattern_selection=(),
ceiling=1.0,
floor=0.0,
selectivity=2.0,
inputs=None,
**kwargs,
):
"""Selective-OR detector: ON with probability ``ceiling`` iff the input
pattern is one of ``pattern_selection``, otherwise OFF.
(``floor`` and ``selectivity`` parameterize the off-diagonal entries of the
original per-unit TPM, which the present=past substrate TPM never reads; they
are accepted for signature parity.)
"""
patterns = [tuple(p) for p in pattern_selection]
ins = _ordered_inputs(element, weights, inputs)
present = tuple(state[i] for i in ins)
return ceiling if present in patterns else 0.0
[docs]
def mismatch_pattern_detector(
element,
weights,
state,
*,
pattern_selection=(),
ceiling=1.0,
floor=0.0,
selectivity=1.0,
inputs=None,
**kwargs,
):
"""Pattern detector that responds strongly to *mismatched* inputs: weakly
coupled to inputs matching its current state, strongly to unexpected ones.
Requires a self-loop (reads ``state[element]``).
"""
if not selectivity > 1:
selectivity = 1 / selectivity
patterns = [tuple(p) for p in pattern_selection]
ins = _ordered_inputs(element, weights, inputs)
present = tuple(state[i] for i in ins)
if state[element] == 1:
p_pattern = 0.5 + (ceiling - 0.5) / selectivity
p_no_pattern = floor
else:
p_pattern = ceiling
p_no_pattern = 0.5 - (0.5 - floor) / selectivity
return p_pattern if present in patterns else p_no_pattern
[docs]
def gabor_gate(
element,
weights,
state,
*,
preferred_states=(),
ceiling=1.0,
floor=0.0,
inputs=None,
**kwargs,
):
"""Gabor-like detector: ``ceiling`` on a preferred input pattern,
``floor`` on its complement (anti-pattern), and ``0.5`` otherwise.
"""
preferred = [tuple(p) for p in preferred_states]
anti = [tuple(int(1 - s) for s in p) for p in preferred]
ins = _ordered_inputs(element, weights, inputs)
present = tuple(state[i] for i in ins)
if present in preferred:
return ceiling
if present in anti:
return floor
return 0.5
[docs]
def copy_gate(element, weights, state, *, floor=0.0, ceiling=1.0, inputs=None, **kwargs):
"""Single-input copy: output follows the input."""
ins = _ordered_inputs(element, weights, inputs)
return ceiling if state[ins[0]] == 1 else floor
[docs]
def and_gate(element, weights, state, *, floor=0.0, ceiling=1.0, inputs=None, **kwargs):
"""Two-input AND truth table (``ceiling`` only when both inputs are ON)."""
ins = _ordered_inputs(element, weights, inputs)
present = (state[ins[0]], state[ins[1]])
return ceiling if present == (1, 1) else floor
[docs]
def or_gate(element, weights, state, *, floor=0.0, ceiling=1.0, inputs=None, **kwargs):
"""Two-input OR truth table (``floor`` only when both inputs are OFF)."""
ins = _ordered_inputs(element, weights, inputs)
present = (state[ins[0]], state[ins[1]])
return floor if present == (0, 0) else ceiling
[docs]
def xor_gate(element, weights, state, *, floor=0.0, ceiling=1.0, inputs=None, **kwargs):
"""Two-input XOR truth table (``ceiling`` iff the inputs differ)."""
ins = _ordered_inputs(element, weights, inputs)
present = (state[ins[0]], state[ins[1]])
return ceiling if present in {(0, 1), (1, 0)} else floor
[docs]
def democracy(element, weights, state, *, floor=0.0, ceiling=1.0, inputs=None, **kwargs):
"""Activation equal to the mean of the (binary) input states."""
ins = _ordered_inputs(element, weights, inputs)
avg_vote = float(np.mean([state[i] for i in ins]))
return avg_vote * (ceiling - floor) + floor
[docs]
def majority(element, weights, state, *, floor=0.0, ceiling=1.0, inputs=None, **kwargs):
"""Activation equal to the rounded mean of the (binary) input states."""
ins = _ordered_inputs(element, weights, inputs)
avg_vote = round(float(np.mean([state[i] for i in ins])))
return avg_vote * (ceiling - floor) + floor
[docs]
def weighted_mean(
element, weights, state, *, floor=0.0, ceiling=1.0, inputs=None, **kwargs
):
"""Spin-weighted mean of the inputs, using the (normalized) input weights."""
ins = _ordered_inputs(element, weights, inputs)
w = np.array([weights[i, element] for i in ins], dtype=float)
total = np.sum(w)
if total == 0:
return floor
w = w / total
n = len(ins)
wm = sum((1 + wi * (state[i] * 2 - 1)) / 2 for wi, i in zip(w, ins, strict=True)) / n
return wm * (ceiling - floor) + floor
[docs]
def mismatch_corrector(
element, weights, state, *, floor=0.0, ceiling=1.0, bias=0.0, inputs=None, **kwargs
):
"""Single-input corrector: when the unit and its input match, it relaxes
toward 0.5 (biased by ``bias``); when they mismatch, it copies the input.
Requires a self-loop (reads ``state[element]``).
"""
ins = _ordered_inputs(element, weights, inputs)
unit_state = state[element]
input_state = state[ins[0]]
if unit_state == input_state:
return 0.5 - (unit_state * 2 - 1) * bias * 0.5
return ceiling if input_state == 1 else floor
[docs]
def modulated_sigmoid(
element,
weights,
state,
*,
input_weights,
modulation,
floor=0.0,
ceiling=1.0,
determinism=2.0,
threshold=0.0,
inputs=None,
**kwargs,
):
"""Sigmoid whose threshold and determinism are shifted by the number of
active modulator inputs, scaled by the unit's own (Ising) state.
``modulation`` is ``{'modulator': tuple(indices), 'threshold': float,
'determinism': float}``. Requires a self-loop (reads ``state[element]``).
"""
modulator = tuple(modulation["modulator"])
ins = _ordered_inputs(element, weights, inputs)
true_inputs = [i for i in ins if i not in modulator]
total_input = sum(
state[i] * w for i, w in zip(true_inputs, input_weights, strict=False)
)
mods_on = sum(state[i] for i in modulator)
unit_state = state[element] * 2 - 1
new_threshold = threshold + unit_state * mods_on * modulation["threshold"]
new_determinism = determinism + unit_state * mods_on * modulation["determinism"]
return ceiling * (
floor + (1 - floor) * _logistic(total_input, new_determinism, new_threshold)
)
[docs]
def stabilized_sigmoid(
element,
weights,
state,
*,
input_weights,
determinism,
threshold,
modulation,
floor=0.0,
ceiling=1.0,
inputs=None,
**kwargs,
):
"""Sigmoid whose modulators stabilize the unit's current state, more
strongly the more modulators are active.
``modulation`` is ``{'modulator': tuple(indices), 'threshold': float,
'determinism': float, 'selectivity': float}``. Requires a self-loop.
The modulator inputs are exactly ``modulation['modulator']``; the remaining
inputs drive the sigmoid. Results differ from the ``substrate_modeler``
library for this mechanism, which treated the input and modulator axes in
swapped order.
"""
modulator = tuple(modulation["modulator"])
ins = _ordered_inputs(element, weights, inputs)
true_inputs = [i for i in ins if i not in modulator]
total_input = sum(
state[i] * w for i, w in zip(true_inputs, input_weights, strict=False)
)
mods_on = sum(state[i] for i in modulator)
if mods_on == 0:
mods_on = 1 / modulation["selectivity"]
ising_state = state[element] * 2 - 1
new_threshold = threshold - ising_state * mods_on * modulation["threshold"]
if mods_on == 0 or modulation["determinism"] == 0:
new_determinism = determinism
else:
new_determinism = determinism * float(mods_on * modulation["determinism"]) ** (
ising_state
)
return ceiling * (
floor + (1 - floor) * _logistic(total_input, new_determinism, new_threshold)
)
[docs]
def biased_sigmoid(
element,
weights,
state,
*,
input_weights,
floor=0.0,
ceiling=1.0,
determinism=2.0,
threshold=0.0,
inputs=None,
**kwargs,
):
"""Sigmoid biased toward the last input unit's state: when the bias unit is
OFF the activation is divided by the bias factor; when ON, the inactivation
is divided by it. The bias unit is the last of ``inputs``; the bias factor
is the last of ``input_weights``.
"""
ins = _ordered_inputs(element, weights, inputs)
bias_index = ins[-1]
true_inputs = ins[:-1]
true_weights = input_weights[:-1]
bias_factor = input_weights[-1]
total_input = sum(
state[i] * w for i, w in zip(true_inputs, true_weights, strict=False)
)
y = ceiling * (floor + (1 - floor) * _logistic(total_input, determinism, threshold))
if state[bias_index] == 0:
return y / bias_factor
return 1 - (1 - y) / bias_factor
# Mechanisms that read the unit's own state and therefore need a self-loop.
STATE_DEPENDENT = frozenset(
{
"resonator",
"mismatch_corrector",
"mismatch_pattern_detector",
"modulated_sigmoid",
"stabilized_sigmoid",
}
)
# Mechanisms that weight their inputs (so ``input_weights`` map to real edge
# weights); the rest are connectivity-only (edges carry a marker weight of 1.0).
WEIGHTED = frozenset(
{
"sigmoid",
"resonator",
"weighted_mean",
"modulated_sigmoid",
"stabilized_sigmoid",
"biased_sigmoid",
}
)
MECHANISMS = {
"sigmoid": sigmoid,
"resonator": resonator,
"sor": sor_gate,
"mismatch_pattern_detector": mismatch_pattern_detector,
"gabor": gabor_gate,
"copy": copy_gate,
"and": and_gate,
"or": or_gate,
"xor": xor_gate,
"democracy": democracy,
"majority": majority,
"weighted_mean": weighted_mean,
"mismatch_corrector": mismatch_corrector,
"modulated_sigmoid": modulated_sigmoid,
"stabilized_sigmoid": stabilized_sigmoid,
"biased_sigmoid": biased_sigmoid,
}