Source code for pyhgf.utils.vectorised_belief_propagation

# Author: Nicolas Legrand <nicolas.legrand@cas.au.dk>
# Author: Aleksandrs Baskakovs <aleks@cas.au.dk>

"""Vectorised belief propagation step for deep predictive coding networks."""

from __future__ import annotations

import dataclasses
from typing import Optional

import equinox as eqx
import jax
import jax.numpy as jnp
import optax

from pyhgf.typing.vectorised import (
    VOLATILITY_STATE_FIELDS as _VOL_STATE_FIELDS,
)
from pyhgf.typing.vectorised import (
    Layer,
    LayerStack,
    VectorisedNetwork,
)
from pyhgf.updates.vectorised.binary import (
    vectorised_binary_prediction,
    vectorised_binary_prediction_error,
)
from pyhgf.updates.vectorised.categorical import (
    vectorised_categorical_prediction,
    vectorised_categorical_prediction_error,
)
from pyhgf.updates.vectorised.continuous import (
    ValueChild,
    VolatilityChild,
    vectorised_continuous_posterior_update,
    vectorised_continuous_prediction,
    vectorised_continuous_prediction_error,
)
from pyhgf.updates.vectorised.learning import (
    SynapticUncertaintySettings,
    clamped_layer_evidence,
    evidence_pullback,
    learning_weights_vectorised,
    vectorised_synaptic_uncertainty_update,
)
from pyhgf.updates.vectorised.volatile import (
    vectorised_layer_posterior_update,
    vectorised_layer_prediction,
    vectorised_layer_prediction_error,
    vectorised_posterior_update_precision_value_level,
    vectorised_root_prediction,
)

# ---------------------------------------------------------------------------
# Element-shape helpers
# ---------------------------------------------------------------------------


def _stack_slice(stack: LayerStack, index: int):
    """Return ``(state, params, weights_mean)`` of the stack slice at ``index``.

    Index ``0`` is the bottommost slice, ``-1`` the topmost.
    """
    state = jax.tree_util.tree_map(lambda x: x[index], stack.state)
    params = jax.tree_util.tree_map(lambda x: x[index], stack.params)
    return state, params, stack.weights_mean[index]


def _parent_view(elem):
    """Treat a ``Layer`` or ``LayerStack`` uniformly when acting as a parent.

    Returns ``(state, weights_mean, coupling_fn, add_constant_input)``. The four pieces
    ``propagation_step`` needs to predict a child below.

    For a ``LayerStack``, the parent is the *bottommost* slice (the slice closest to the
    child below the stack).
    """
    if isinstance(elem, LayerStack):
        state, _, weights = _stack_slice(elem, 0)
        return state, weights, elem.coupling_fn, elem.add_constant_input
    return elem.state, elem.weights_mean, elem.coupling_fn, elem.add_constant_input


def _child_view(elem):
    """Treat a ``Layer`` or ``LayerStack`` uniformly when acting as a child.

    Returns ``(state, kind, is_input_layer)``. What's needed when something above is
    doing a posterior update or computing prediction-error-driven weight gradients using
    this element's state as the child.

    For a ``LayerStack``, the child role is filled by the *topmost* slice (the slice
    closest to the parent above the stack).
    """
    if isinstance(elem, LayerStack):
        state, _, _ = _stack_slice(elem, -1)
        return state, elem.kind, False  # interior; never the clamped leaf
    return elem.state, elem.kind, elem.is_input_layer


# ---------------------------------------------------------------------------
# Top-down prediction
# ---------------------------------------------------------------------------


def _predict_layer_from_parent(
    child: Layer,
    parent_state,
    parent_weights,
    parent_coupling_fn,
    parent_has_constant: bool,
    *,
    time_step: float,
    precision_clipping_value: float,
    predict_precision: bool = True,
    feedforward_uncertainty: bool = False,
    mean_field_updates: bool = False,
):
    """Predict a single ``Layer`` child from a parent view."""
    if child.kind == "binary":
        new_state = vectorised_binary_prediction(
            child_state=child.state,
            parent_state=parent_state,
            weights=parent_weights,
            coupling_fn=parent_coupling_fn,
            parent_has_constant=parent_has_constant,
            precision_clipping_value=precision_clipping_value,
        )
    elif child.kind == "categorical":
        new_state = vectorised_categorical_prediction(
            child_state=child.state,
            parent_state=parent_state,
            weights=parent_weights,
            coupling_fn=parent_coupling_fn,
            parent_has_constant=parent_has_constant,
        )
    else:
        new_state = vectorised_layer_prediction(
            child_state=child.state,
            parent_state=parent_state,
            weights=parent_weights,
            params=child.params,
            time_step=time_step,
            coupling_fn=parent_coupling_fn,
            parent_has_constant=parent_has_constant,
            has_volatility_parent=child.has_volatility_parent,
            is_input_layer=child.is_input_layer,
            predict_precision=predict_precision,
            feedforward_uncertainty=feedforward_uncertainty,
            mean_field_updates=mean_field_updates,
        )
    return dataclasses.replace(child, state=new_state)


def _predict_stack_from_parent(
    stack: LayerStack,
    parent_state,
    parent_weights,
    parent_coupling_fn,
    parent_has_constant: bool,
    *,
    time_step: float,
    predict_precision: bool = True,
    feedforward_uncertainty: bool = False,
    mean_field_updates: bool = False,
):
    """Top-down sweep over a ``LayerStack``.

    Boundary step: predict the topmost slice from the external parent, using the
    parent's coupling function, weights, and bias — a ``Layer`` parent's own, or,
    for a ``LayerStack`` parent, those of its bottommost slice.

    Scan step: predict slice ``k`` from slice ``k+1`` for ``k = N-2 ... 0`` using
    ``stack.weights_mean[k+1]`` and the stack's own coupling function and bias.
    The scan runs in reverse so the carry threads top-to-bottom through the stack.
    """
    top_slice_state, top_slice_params, _ = _stack_slice(stack, -1)
    new_top_state = vectorised_layer_prediction(
        child_state=top_slice_state,
        parent_state=parent_state,
        weights=parent_weights,
        params=top_slice_params,
        time_step=time_step,
        coupling_fn=parent_coupling_fn,
        parent_has_constant=parent_has_constant,
        has_volatility_parent=stack.has_volatility_parent,
        is_input_layer=False,
        predict_precision=predict_precision,
        feedforward_uncertainty=feedforward_uncertainty,
        mean_field_updates=mean_field_updates,
    )

    # xs: per-iteration data for predicting slices N-2 ... 0 from the slice above.
    # At step k, body(parent_state, xs[k]) → predict slice k. The "parent's
    # weights" used to predict slice k come from slice k+1 — i.e. stack.weights_mean[k+1].
    # A single-slice stack yields zero-length xs: the scan runs no steps and the
    # concatenation below just wraps the top state.
    n = stack.n_layers
    xs_child_state = jax.tree_util.tree_map(lambda x: x[: n - 1], stack.state)
    xs_child_params = jax.tree_util.tree_map(lambda x: x[: n - 1], stack.params)
    xs_parent_weights = stack.weights_mean[1:]  # shape (n-1, ...)

    def body(parent_state_carry, k_data):
        child_state, child_params, parent_weights_k = k_data
        new_child_state = vectorised_layer_prediction(
            child_state=child_state,
            parent_state=parent_state_carry,
            weights=parent_weights_k,
            params=child_params,
            time_step=time_step,
            coupling_fn=stack.coupling_fn,
            parent_has_constant=stack.add_constant_input,
            has_volatility_parent=stack.has_volatility_parent,
            is_input_layer=False,
            predict_precision=predict_precision,
            feedforward_uncertainty=feedforward_uncertainty,
            mean_field_updates=mean_field_updates,
        )
        return new_child_state, new_child_state

    _, new_states_below = jax.lax.scan(
        body,
        init=new_top_state,
        xs=(xs_child_state, xs_child_params, xs_parent_weights),
        reverse=True,
    )

    # new_states_below has shape (n-1, ...) for slices 0..n-2;
    # new_top_state is for slice n-1. Concatenate along axis 0.
    new_full_state = jax.tree_util.tree_map(
        lambda below, top: jnp.concatenate([below, top[None, ...]], axis=0),
        new_states_below,
        new_top_state,
    )
    return dataclasses.replace(stack, state=new_full_state)


def _topdown_predict(
    parent_elem,
    child_elem,
    *,
    time_step: float,
    precision_clipping_value: float,
    predict_precision: bool = True,
    feedforward_uncertainty: bool = False,
    mean_field_updates: bool = False,
):
    """Predict ``child_elem`` from ``parent_elem``.

    Either element can be a ``Layer`` or a ``LayerStack``.
    """
    parent_state, parent_weights, parent_coupling_fn, parent_has_const = _parent_view(
        parent_elem
    )
    if isinstance(child_elem, LayerStack):
        # LayerStacks are continuous/volatile only — the binary clip never applies.
        return _predict_stack_from_parent(
            child_elem,
            parent_state,
            parent_weights,
            parent_coupling_fn,
            parent_has_const,
            time_step=time_step,
            predict_precision=predict_precision,
            feedforward_uncertainty=feedforward_uncertainty,
            mean_field_updates=mean_field_updates,
        )
    return _predict_layer_from_parent(
        child_elem,
        parent_state,
        parent_weights,
        parent_coupling_fn,
        parent_has_const,
        time_step=time_step,
        precision_clipping_value=precision_clipping_value,
        predict_precision=predict_precision,
        feedforward_uncertainty=feedforward_uncertainty,
        mean_field_updates=mean_field_updates,
    )


# ---------------------------------------------------------------------------
# Leaf prediction error (bottom element of the network)
# ---------------------------------------------------------------------------


def _leaf_pe(
    layer: Layer,
    *,
    volatility_updates: str,
    max_posterior_precision: float,
    time_step: float = 1.0,
    mean_field_updates: bool = False,
):
    """Compute the prediction error of the bottom layer (never a stack)."""
    if layer.kind == "binary":
        new_state = vectorised_binary_prediction_error(layer=layer.state)
    elif layer.kind == "categorical":
        new_state = vectorised_categorical_prediction_error(layer=layer.state)
    else:
        new_state = vectorised_layer_prediction_error(
            layer=layer.state,
            params=layer.params,
            volatility_updates=volatility_updates,
            time_step=time_step,
            has_volatility_parent=layer.has_volatility_parent,
            max_posterior_precision=max_posterior_precision,
            mean_field_updates=mean_field_updates,
        )
    return dataclasses.replace(layer, state=new_state)


# ---------------------------------------------------------------------------
# Bottom-up posterior update + prediction error
# ---------------------------------------------------------------------------


def _posterior_pe_layer(
    parent: Layer,
    child_state,
    child_is_input_layer: bool,
    *,
    volatility_updates: str,
    max_posterior_precision: float,
    time_step: float = 1.0,
    mean_field_updates: bool = False,
):
    """Single-layer posterior update + prediction error."""
    new_state = vectorised_layer_posterior_update(
        layer=parent.state,
        child=child_state,
        weights=parent.weights_mean,
        coupling_fn=parent.coupling_fn,
        parent_has_constant=parent.add_constant_input,
        max_posterior_precision=max_posterior_precision,
        child_is_input_layer=child_is_input_layer,
        mean_field_updates=mean_field_updates,
    )
    if parent.kind == "binary":
        new_state = vectorised_binary_prediction_error(layer=new_state)
    else:
        new_state = vectorised_layer_prediction_error(
            layer=new_state,
            params=parent.params,
            volatility_updates=volatility_updates,
            time_step=time_step,
            has_volatility_parent=parent.has_volatility_parent,
            max_posterior_precision=max_posterior_precision,
            mean_field_updates=mean_field_updates,
        )
    return dataclasses.replace(parent, state=new_state)


def _top_precision_only(
    parent: Layer,
    child_state,
    child_is_input_layer: bool,
    *,
    max_posterior_precision: float,
    mean_field_updates: bool = False,
):
    r"""Update the top layer's precision from the layer below, leaving its mean clamped.

    The top layer holds the predictors, and its mean is read back by the weight update
    (:func:`~pyhgf.updates.vectorised.learning.learning_weights_vectorised`
    forms the parent-side factor from ``coupling_fn(parent.mean)``). Those weights must
    be learned against the predictors that were actually supplied, so the mean stays
    pinned to ``x`` and only its precision moves.

    Two things follow from the clamp, and both are deliberate:

    * The value prediction error is identically zero. A layer whose mean never leaves
      its prediction has no residual, so the field is written as zero rather than left
      holding a stale value.
    * The volatility level is *not* updated. Its prediction error would reduce to
      :math:`\hat{\pi} / \pi - 1`, which the clamp makes non-positive at every step,
      so the layer would conclude "no volatility" and keep concluding it. That drives
      :math:`\Omega \to 0`, and without diffusion :math:`\hat{\pi} \to \pi`, so
      each step's evidence would add to the last and the precision would grow without
      bound. Leaving the volatility level at its tonic value instead keeps
      :math:`\Omega` constant, and the precision settles at
      :math:`1/\Omega + \text{evidence}` — still tracking how well the layer below
      accounts for the predictors, but bounded.
    """
    # Only called for a top element that has a layer below it, so it carries an
    # incoming matrix.
    assert parent.weights_mean is not None
    weights = parent.weights_mean
    if parent.add_constant_input:
        weights = weights[:, :-1]

    precision = jnp.clip(
        vectorised_posterior_update_precision_value_level(
            layer=parent.state,
            child=child_state,
            weights=weights,
            coupling_fn=parent.coupling_fn,
            child_is_input_layer=child_is_input_layer,
            mean_field_updates=mean_field_updates,
        ),
        parent.state.expected_precision,
        max_posterior_precision,
    )
    new_state = dataclasses.replace(
        parent.state,
        precision=precision,
        value_prediction_error=jnp.zeros_like(precision),
    )
    return dataclasses.replace(parent, state=new_state)


def _match_child_vol_structure(child_state, has_volatility_parent):
    """Align a child state's volatility fields to a consumer's volatility structure.

    A layer without a volatility parent stores its six volatility fields as
    ``None`` rather than arrays. Where such a child meets a ``LayerStack`` with a
    different volatility structure — a ``scan`` carry seeded by the child, or a
    concatenation of the child onto the stack — the two pytrees must match.

    Reconciling them here is value-neutral: cross-layer coupling is value-only,
    so a parent update never reads its child's volatility level (that level is
    internal to each layer). Materialising zero volatility fields when the
    consumer has them, or dropping to ``None`` when it does not, only fixes the
    structure; no volatility quantity of the child is ever consumed.
    """
    if has_volatility_parent:
        n = child_state.mean.shape[-1]
        repl = {
            f: (
                jnp.zeros(n)
                if getattr(child_state, f) is None
                else getattr(child_state, f)
            )
            for f in _VOL_STATE_FIELDS
        }
    else:
        repl = {f: None for f in _VOL_STATE_FIELDS}
    return dataclasses.replace(child_state, **repl)


def _posterior_pe_stack(
    stack: LayerStack,
    child_state_init,
    child_is_input_layer: bool,
    *,
    volatility_updates: str,
    max_posterior_precision: float,
    time_step: float = 1.0,
    mean_field_updates: bool = False,
):
    r"""Bottom-up sweep over a ``LayerStack``.

    Posterior update and prediction error for every slice, from slice 0 (bottommost)
    to slice N-1 (topmost). The carry is the child state below the current slice,
    already carrying its prediction error.

    Slice 0 is the boundary and runs outside the scan: its child is the external
    element below the stack, which may be the clamped observation leaf (volatile,
    binary, or categorical), so it receives the real *child_is_input_layer* flag.
    A leaf never moves its posterior precision, so its evidence
    :math:`\pi_y = \pi_a - \tilde{\pi}_a` is identically zero and the interior
    (harmonic) form of the smoothing correction would silently zero out the whole
    message; the flag switches to the canonical factor instead. This mirrors
    :func:`_predict_stack_from_parent`, which peels the *topmost* slice to meet
    the external parent. Every scanned slice has a stack slice as its child,
    interior by construction, so the scan runs with ``child_is_input_layer=False``.
    """
    # The scan carry becomes a stack slice each step, so seed it with the
    # child's state coerced to the stack's volatility structure.
    child_state_init = _match_child_vol_structure(
        child_state_init, stack.has_volatility_parent
    )

    def slice_posterior_pe(
        slice_state, slice_params, slice_weights, child_state, is_leaf_child
    ):
        new_state = vectorised_layer_posterior_update(
            layer=slice_state,
            child=child_state,
            weights=slice_weights,
            coupling_fn=stack.coupling_fn,
            parent_has_constant=stack.add_constant_input,
            max_posterior_precision=max_posterior_precision,
            child_is_input_layer=is_leaf_child,
            mean_field_updates=mean_field_updates,
        )
        return vectorised_layer_prediction_error(
            layer=new_state,
            params=slice_params,
            volatility_updates=volatility_updates,
            time_step=time_step,
            has_volatility_parent=stack.has_volatility_parent,
            max_posterior_precision=max_posterior_precision,
            mean_field_updates=mean_field_updates,
        )

    # Boundary: slice 0 from the external child.
    slice0_state, slice0_params, slice0_weights = _stack_slice(stack, 0)
    new_slice0 = slice_posterior_pe(
        slice0_state,
        slice0_params,
        slice0_weights,
        child_state_init,
        child_is_input_layer,
    )

    def body(child_carry_state, slice_data):
        slice_state, slice_params, slice_weights = slice_data
        new_state = slice_posterior_pe(
            slice_state, slice_params, slice_weights, child_carry_state, False
        )
        return new_state, new_state

    # Slices 1 .. N-1, each from the freshly updated slice below. A single-slice
    # stack yields zero-length xs: the scan runs no steps and the concatenation
    # below just wraps slice 0.
    _, new_states_above = jax.lax.scan(
        body,
        init=new_slice0,
        xs=(
            jax.tree_util.tree_map(lambda x: x[1:], stack.state),
            jax.tree_util.tree_map(lambda x: x[1:], stack.params),
            stack.weights_mean[1:],
        ),
    )
    new_full_state = jax.tree_util.tree_map(
        lambda first, above: jnp.concatenate([first[None, ...], above], axis=0),
        new_slice0,
        new_states_above,
    )
    return dataclasses.replace(stack, state=new_full_state)


def _bottomup_posterior_pe(
    parent_elem,
    child_elem,
    *,
    volatility_updates: str,
    max_posterior_precision: float,
    time_step: float = 1.0,
    mean_field_updates: bool = False,
):
    """Posterior update + prediction error for ``parent_elem`` from ``child_elem``."""
    child_state, _, child_is_input_layer = _child_view(child_elem)
    if isinstance(parent_elem, LayerStack):
        return _posterior_pe_stack(
            parent_elem,
            child_state,
            child_is_input_layer,
            volatility_updates=volatility_updates,
            max_posterior_precision=max_posterior_precision,
            time_step=time_step,
            mean_field_updates=mean_field_updates,
        )
    return _posterior_pe_layer(
        parent_elem,
        child_state,
        child_is_input_layer,
        volatility_updates=volatility_updates,
        max_posterior_precision=max_posterior_precision,
        time_step=time_step,
        mean_field_updates=mean_field_updates,
    )


# ---------------------------------------------------------------------------
# Weight gradients
# ---------------------------------------------------------------------------


def _layer_weight_op(
    parent: Layer, child_elem, learning_kind: str, child_evidence=None
):
    """Learning factors for a ``Layer`` parent and its child.

    *child_evidence* is passed only by the evidence walk; ``None`` leaves
    ``learning_weights_vectorised`` to recover the evidence from the cache.
    """
    child_state, child_kind, _ = _child_view(child_elem)
    return learning_weights_vectorised(
        parent_state=parent.state,
        child_state=child_state,
        coupling_fn=parent.coupling_fn,
        kind=learning_kind,
        parent_has_constant=parent.add_constant_input,
        child_kind=child_kind,
        child_evidence=child_evidence,
    )


def _stack_weight_op(stack: LayerStack, child_elem, learning_kind: str, evidence=None):
    """Learning factors for every slice of a ``LayerStack``, and the evidence above it.

    The child of slice 0 is the layer below the stack (``child_elem``); the child of
    slice k>0 is slice k-1 within the stack. Slice 0 is the boundary and is computed
    on its own: its child keeps its actual kind, since it may be the clamped binary
    or categorical observation layer, whose gradient and importance factors differ
    from a continuous child's. The interior slices ``1 .. N-1`` all have a stack
    slice as their child and are ``vmap``-ed together.

    The evidence walk cannot be vectorised the same way, because slice k's evidence is
    a function of slice k-1's: it is a recursion, not a map. It is carried by a
    ``scan`` that emits, per slice, the evidence *arriving* at that slice from below,
    which is exactly what the slice's own increment needs; the scan's final carry is
    the evidence leaving the top of the stack, for the element above it.

    *evidence* is ``None`` for the learning kinds that never read the importance
    factor; the walk is then skipped and the evidence above the stack is ``None`` too.

    Returns
    -------
    tuple
        The stacked factor triple, and the evidence at the top of the stack.
    """
    child_state, child_kind, _ = _child_view(child_elem)

    def carry(evidence_below, slice_data):
        slice_state, slice_weights = slice_data
        above = evidence_pullback(
            parent_state=slice_state,
            child_evidence=evidence_below,
            weights=slice_weights,
            coupling_fn=stack.coupling_fn,
            parent_has_constant=stack.add_constant_input,
        )
        return above, evidence_below

    if evidence is None:
        evidence_out = None
        per_slice_evidence = jnp.zeros(stack.state.mean.shape)
    else:
        evidence_out, per_slice_evidence = jax.lax.scan(
            carry, evidence, (stack.state, stack.weights_mean)
        )

    walking = evidence is not None

    def slice_factors(parent_state, child_state_for_slice, child_evidence, kind):
        return learning_weights_vectorised(
            parent_state=parent_state,
            child_state=child_state_for_slice,
            coupling_fn=stack.coupling_fn,
            kind=learning_kind,
            parent_has_constant=stack.add_constant_input,
            child_kind=kind,
            child_evidence=child_evidence if walking else None,
        )

    # Boundary: slice 0 from the external child, with the child's own kind.
    first = slice_factors(
        jax.tree_util.tree_map(lambda x: x[0], stack.state),
        child_state,
        per_slice_evidence[0],
        child_kind,
    )
    # Interior slices 1 .. N-1, whose children are the stack's own slices. A
    # single-slice stack maps over a zero-length axis and the concatenation
    # below just wraps the boundary factors.
    rest = jax.vmap(lambda p, c, e: slice_factors(p, c, e, "continuous"))(
        jax.tree_util.tree_map(lambda x: x[1:], stack.state),
        jax.tree_util.tree_map(lambda x: x[:-1], stack.state),
        per_slice_evidence[1:],
    )
    factors = jax.tree_util.tree_map(
        lambda f, r: jnp.concatenate([f[None, ...], r], axis=0), first, rest
    )
    return (factors, evidence_out) if walking else factors


def _weight_op(parent_elem, child_elem, learning_kind: str):
    """Dispatch the learning factors on ``Layer`` vs ``LayerStack``."""
    if isinstance(parent_elem, LayerStack):
        return _stack_weight_op(parent_elem, child_elem, learning_kind)
    return _layer_weight_op(parent_elem, child_elem, learning_kind)


# ---------------------------------------------------------------------------
# Element-level state writeback (for clamping x/y at the boundaries)
# ---------------------------------------------------------------------------


def _set_top_predictors(elem, x):
    """Clamp ``expected_mean`` and ``mean`` of the top element to the predictors ``x``.

    The top element must be a ``Layer``.
    """
    if isinstance(elem, LayerStack):
        raise NotImplementedError("Top of network must be a Layer, not a LayerStack.")
    new_state = dataclasses.replace(elem.state, expected_mean=x, mean=x)
    return dataclasses.replace(elem, state=new_state)


def _sweeps_reach_top(network: VectorisedNetwork) -> bool:
    """Whether both sweeps should treat the top element as a member of the hierarchy.

    Gating the two sweeps on one predicate keeps them in step: a top element that
    receives a posterior update must also receive the precision prediction that
    carries it into the next step, or its precision would accumulate with nothing
    advancing it.

    Requires ``update_input_layer``, something below the top to send it a message,
    and a volatile top element — a binary layer's predicted precision is a function
    of its predicted mean, which is clamped to the predictors here, so there is no
    precision of its own for the sweeps to move.
    """
    return (
        network.update_input_layer
        and len(network.layers) > 1
        and network.layers[-1].kind == "volatile"
    )


def _predict_top_precisions(
    elem,
    *,
    time_step: float,
    predict_precision: bool = True,
    mean_field_updates: bool = False,
):
    """Predict the top element's precisions, which no parent above it can supply.

    The element's ``expected_mean`` stays clamped to the predictors; what this adds is
    the predicted precision of both levels, so the precision the bottom-up sweep wrote
    into ``precision`` on the previous step is carried forward (damped by the volatility
    level) instead of being ignored.

    ``predict_precision`` is threaded through: the network-level switch has to reach the
    top element as well, or it would keep diffusing while every layer below it froze.
    """
    new_state = vectorised_root_prediction(
        layer_state=elem.state,
        params=elem.params,
        time_step=time_step,
        has_volatility_parent=elem.has_volatility_parent,
        predict_precision=predict_precision,
        mean_field_updates=mean_field_updates,
    )
    return dataclasses.replace(elem, state=new_state)


def _set_bottom_observations(elem, y):
    """Clamp ``mean`` of the bottom element to the observations ``y``.

    The bottom element must be a ``Layer``.
    """
    if isinstance(elem, LayerStack):
        raise NotImplementedError(
            "Bottom of network must be a Layer, not a LayerStack."
        )
    new_state = dataclasses.replace(elem.state, mean=y)
    return dataclasses.replace(elem, state=new_state)


# ---------------------------------------------------------------------------
# Top-level propagation step
# ---------------------------------------------------------------------------


[docs] def propagation_step( network: VectorisedNetwork, opt_state: optax.OptState, inputs: tuple, *, optimiser: Optional[optax.GradientTransformation], time_step: float = 1.0, learning_kind: str = "precision_weighted", weight_update: bool = True, synaptic_uncertainty_settings: Optional[SynapticUncertaintySettings] = None, ) -> tuple[tuple[VectorisedNetwork, optax.OptState], jnp.ndarray]: """Single propagation step through the network. Belief-propagation sweep — top-down prediction, leaf prediction error, then the interleaved posterior update + prediction error bottom-up — followed by an optional weight-learning phase. Each step dispatches per element: * ``Layer``: standard per-layer kernel call (unrolled). * ``LayerStack``: ``jax.lax.scan`` over the stack's slices. Top and bottom elements must be ``Layer``s. A ``LayerStack``'s child below (and parent above) can themselves be ``Layer`` or ``LayerStack``; the stack-stack case requires the boundary widths to match. Parameters ---------- network : The current vectorised network state. opt_state : The current optax optimiser state. inputs : A tuple ``(x, y)`` with the predictors set on the top element and the observations clamped on the bottom element. optimiser : The optax optimiser used for the weight-learning phase. time_step : The time elapsed since the previous step. learning_kind : The weight-gradient mode passed to :py:func:`pyhgf.updates.vectorised.learning.learning_weights_vectorised`. weight_update : Whether to apply the weight-learning phase after belief propagation. Returns ------- carry : A tuple ``((network, opt_state), output_pred)`` where ``network`` and ``opt_state`` are updated and ``output_pred`` is the bottom element's ``expected_mean`` — the prediction of the observations for this step. """ x, y = inputs # Belief propagation: top-down prediction (clamping x on top) then the # bottom-up prediction-error + posterior sweep (clamping y at the bottom). swept = _update_sweep( _prediction_sweep(network, x, time_step=time_step), y, time_step=time_step ) # Optional weight-learning phase. if weight_update: new_network, new_opt_state = _learn_sweep( swept, opt_state, optimiser, learning_kind, synaptic_uncertainty_settings ) else: new_network, new_opt_state = swept, opt_state output_pred = new_network.layers[0].state.expected_mean return (new_network, new_opt_state), output_pred
# --------------------------------------------------------------------------- # Scan driver + prediction-only sweep # ---------------------------------------------------------------------------
[docs] @eqx.filter_jit def run_scan( init_carry: tuple, inputs: tuple, optimiser: Optional[optax.GradientTransformation], learning_kind: str, weight_update: bool, record: tuple, time_step: float = 1.0, update_precisions: bool = True, synaptic_uncertainty_settings: Optional[SynapticUncertaintySettings] = None, ) -> tuple: r"""Run ``jax.lax.scan`` over the belief-propagation step. Decorated with ``eqx.filter_jit``: arrays in ``init_carry`` / ``inputs`` are dynamic; ``optimiser`` / ``learning_kind`` / ``weight_update`` / ``record`` / ``time_step`` are static and form the JIT cache key. Parameters ---------- init_carry : The initial scan carry, a tuple ``(network, opt_state)``. inputs : The per-step inputs scanned over, a tuple of predictor/observation arrays with a leading time axis. optimiser : The optax optimiser used for the weight-learning phase. learning_kind : The weight-gradient mode passed to :py:func:`pyhgf.updates.vectorised.learning.learning_weights_vectorised`. weight_update : Whether to apply the weight-learning phase at every step. record : Tuple of ``LayerState`` field names to record at every time step (e.g. ``("expected_mean", "precision")``). An empty tuple disables recording and the scan output is the per-step ``output_pred`` alone. With a non-empty tuple, the per-step output is ``(traj_step, output_pred)`` where ``traj_step`` is ``dict[field_name, tuple[Array, ...]]`` (one per-element array per field, with ``LayerStack`` elements contributing arrays of shape ``(N, n_nodes)``). After ``scan`` stacks across time, each leaf carries a leading ``(T,)`` axis. time_step : Uniform inference time step :math:`\\Delta t` passed to every ``propagation_step`` call. Defaults to ``1.0``. Returns ------- ``((final_network, final_opt_state), step_output)`` where ``step_output`` is either the stacked predictions alone (``record == ()``) or a ``(stacked_traj, stacked_predictions)`` tuple. """ template = init_carry[0] def _scan_body(carry, xs): network, opt_state = carry (new_network, new_opt_state), pred = propagation_step( network, opt_state, xs, optimiser=optimiser, time_step=time_step, learning_kind=learning_kind, weight_update=weight_update, synaptic_uncertainty_settings=synaptic_uncertainty_settings, ) if not update_precisions: # Static-cascade mode: precisions are parameters, not filter state. Note # that recorded carried fields then show the template values, since the # restore runs before recording. new_network = _restore_precisions(new_network, template) if record: traj_step = { field: tuple(getattr(elem.state, field) for elem in new_network.layers) for field in record } return (new_network, new_opt_state), (traj_step, pred) return (new_network, new_opt_state), pred return jax.lax.scan(_scan_body, init_carry, inputs)
def _prediction_sweep( network: VectorisedNetwork, x: jnp.ndarray, *, time_step: float = 1.0 ) -> VectorisedNetwork: """Top-down prediction sweep, returning the updated network. Clamps the predictors on the top element and predicts every element from the one above. No prediction errors, posterior updates, or weight learning are performed. With ``network.update_input_layer``, the top element also gets its own precision prediction (:func:`_predict_top_precisions`) — the only part of it a parent could have supplied, had there been one. """ elements = list(network.layers) n_elements = len(elements) elements[-1] = _set_top_predictors(elements[-1], x) if _sweeps_reach_top(network): elements[-1] = _predict_top_precisions( elements[-1], time_step=time_step, predict_precision=network.predict_precision, mean_field_updates=network.mean_field_updates, ) for i in range(n_elements - 1, 0, -1): elements[i - 1] = _topdown_predict( elements[i], elements[i - 1], time_step=time_step, precision_clipping_value=network.precision_clipping_value, predict_precision=network.predict_precision, feedforward_uncertainty=network.feedforward_uncertainty, mean_field_updates=network.mean_field_updates, ) return dataclasses.replace(network, layers=tuple(elements)) def _update_sweep( network: VectorisedNetwork, y: jnp.ndarray, *, time_step: float = 1.0 ) -> VectorisedNetwork: """Bottom-up prediction-error + posterior-update sweep, returning the network. Clamps the observations on the bottom element, computes the leaf prediction error, then performs the interleaved posterior update + prediction error for every interior element, in bottom-up order. Belief states are updated; inter-layer weights are not. The inference time step scales the volatility-level posterior updates with the same time step the prediction sweep uses. With ``network.update_input_layer``, the sweep also reaches the top element, but on different terms from the interior: its mean stays clamped to the predictors and only its precision moves (see :func:`_top_precision_only`). The top element holds observed inputs that the weight update reads back, so its value is not the network's to revise, only its precision is. """ elements = list(network.layers) n_elements = len(elements) # Clamp observations and compute the leaf prediction error. elements[0] = _set_bottom_observations(elements[0], y) elements[0] = _leaf_pe( elements[0], volatility_updates=network.volatility_updates, max_posterior_precision=network.max_posterior_precision, time_step=time_step, mean_field_updates=network.mean_field_updates, ) # Interleaved bottom-up posterior update + prediction error on every # interior element. for i in range(1, n_elements - 1): elements[i] = _bottomup_posterior_pe( elements[i], elements[i - 1], volatility_updates=network.volatility_updates, max_posterior_precision=network.max_posterior_precision, time_step=time_step, mean_field_updates=network.mean_field_updates, ) # The top element, when asked for: precision only, mean left on the predictors. if _sweeps_reach_top(network): child_state, _, child_is_input_layer = _child_view(elements[-2]) elements[-1] = _top_precision_only( elements[-1], child_state, child_is_input_layer, max_posterior_precision=network.max_posterior_precision, mean_field_updates=network.mean_field_updates, ) return dataclasses.replace(network, layers=tuple(elements))
[docs] @eqx.filter_jit def prediction_sweep(network: VectorisedNetwork, x: jnp.ndarray) -> VectorisedNetwork: """JIT-compiled top-down prediction sweep. See :func:`_prediction_sweep`. """ return _prediction_sweep(network, x)
[docs] @eqx.filter_jit def update_sweep( network: VectorisedNetwork, y: jnp.ndarray, time_step: float = 1.0 ) -> VectorisedNetwork: """JIT-compiled bottom-up prediction-error + posterior sweep. See :func:`_update_sweep`. """ return _update_sweep(network, y, time_step=time_step)
def _input_prediction_error(network: VectorisedNetwork) -> jnp.ndarray: r"""Prediction error routed to the network's input (top) layer. This is the error message the top layer receives from the layer below it — the same gain-weighted prediction error that drives the posterior mean shift of every interior layer: .. math:: \varepsilon_x = g'(\hat{\mu}_x) \odot W^\top (g_a \, \delta_a), where :math:`\delta_a` is the child layer's value prediction error, :math:`g_a` its smoothing gain (the same gain used by :func:`pyhgf.updates.vectorised.volatile.posterior.vectorised_posterior_update_mean_value_level`), :math:`W` the weight matrix connecting the child into the top layer (bias column excluded), and :math:`g'` the derivative of the top layer's coupling function at the clamped predictors. With unit precisions and an identity coupling this reduces to :math:`W^\top \delta_a` — the error multiplied back through the weights. Because prediction errors follow the ``observed - predicted`` convention, the result is the *negative* of the gradient of a squared-error loss with respect to the predictors. The quantity is the same whether or not the network updates its top layer: it is read off the child, not the top. With ``update_input_layer=True`` the top layer's own ``value_prediction_error`` is this message divided by the top layer's posterior precision — the shift the belief actually made, rather than the raw message that drove it. Must be called after the update sweep, so the child layer carries its posterior prediction error. Parameters ---------- network : The network state, after :func:`_update_sweep`. Returns ------- jnp.ndarray The prediction error at the top layer, shape ``(n_input_features,)``. """ top = network.layers[-1] if isinstance(top, LayerStack): raise NotImplementedError("Top of network must be a Layer, not a LayerStack.") if top.weights_mean is None: raise ValueError( "The network has a single layer: there is no layer below the " "input layer to route an error from." ) child_state, _, _ = _child_view(network.layers[-2]) weights = top.weights_mean if top.add_constant_input: # The bias column connects the constant node, not a real input. weights = weights[:, :-1] # Smoothing gain of the child layer — identical to the gain used by the # interior posterior mean update, so the top layer sees exactly the # message any interior layer would see. Under mean-field the interior mean # update weights by the canonical predicted precision, so the same factor # is used here. if network.mean_field_updates: gain = child_state.expected_precision else: pi_y = child_state.precision - child_state.expected_precision gain = ( child_state.conditional_expected_precision * child_state.precision / (child_state.conditional_expected_precision + pi_y) ) coupling_prime = jax.vmap(jax.grad(top.coupling_fn))(top.state.expected_mean) return ( jnp.matmul(weights.T, gain * child_state.value_prediction_error) * coupling_prime )
[docs] @eqx.filter_jit def input_prediction_error(network: VectorisedNetwork) -> jnp.ndarray: """JIT-compiled prediction error at the input (top) layer. See :func:`_input_prediction_error`. """ return _input_prediction_error(network)
def _weight_quantities(network: VectorisedNetwork, learning_kind: str) -> tuple: r"""Per-element weight-learning factors, without applying them. Must run *after* :func:`_update_sweep`, so the per-layer states already carry their prediction errors / posteriors. Returns one entry per element, matched 1:1 to ``network.layers`` (``None`` for the bottom element, which has no incoming weights); each entry is the factor tuple of :func:`pyhgf.updates.vectorised.learning.learning_weights_vectorised`. Assemble them with :func:`_gradient_matrix` and :func:`_importance_pair`. Under ``learning_kind="synaptic_uncertainty"`` the importance increment's child-side factor is the evidence precision, and this walks it up the stack in its own quantity: seeded at the clamped layer by :func:`~pyhgf.updates.vectorised.learning.clamped_layer_evidence` and raised one element at a time by :func:`~pyhgf.updates.vectorised.learning.evidence_pullback`. This loop runs bottom to top already, which is the order the recursion needs, so the walk costs one matrix product per element and no extra sweep. The evidence is *carried* rather than recovered from the filter's cache as :math:`\pi_a - \tilde\pi_a`. The two agree in exact arithmetic only where the filter's own chain is seeded with the clamped layer's likelihood curvature, which it is not: a clamped categorical layer enters that chain at unit precision, because that convention is what makes the message it routes exact cross-entropy backpropagation. One variable cannot serve both roles, so the curvature gets its own. Carrying it also removes a subtraction of two large nearly-equal precisions, which loses every significant digit once a layer's precision has accumulated. The other learning kinds never read the importance factor, so they skip the walk. """ elements = network.layers if learning_kind != "synaptic_uncertainty": return (None,) + tuple( _weight_op(elements[i], elements[i - 1], learning_kind) for i in range(1, len(elements)) ) child_state, child_kind, _ = _child_view(elements[0]) evidence = clamped_layer_evidence(child_state, child_kind) factors: list = [None] for i in range(1, len(elements)): parent = elements[i] if isinstance(parent, LayerStack): stack_factors, evidence = _stack_weight_op( parent, elements[i - 1], learning_kind, evidence ) factors.append(stack_factors) continue factors.append( _layer_weight_op(parent, elements[i - 1], learning_kind, evidence) ) if i + 1 < len(elements): evidence = evidence_pullback( parent_state=parent.state, child_evidence=evidence, weights=parent.weights_mean, coupling_fn=parent.coupling_fn, parent_has_constant=parent.add_constant_input, ) return tuple(factors) def _gradient_matrix(factors) -> Optional[jnp.ndarray]: """Assemble the descent gradient from one element's factors.""" if factors is None: return None u, h = factors[0], factors[1] return u[..., :, None] * h[..., None, :] def _importance_pair(factors) -> Optional[tuple]: """Build the importance factor pair ``(p, h**2)`` from one element's factors. The parent side is squared here rather than at the source, since both quantities read the same activation. Squaring can overflow a finite activation, so the non- finite guard is applied after it, matching the child side. """ if factors is None: return None squared = factors[1] ** 2 return factors[2], jnp.where(jnp.isfinite(squared), squared, 0.0) def _apply_weight_updates( network: VectorisedNetwork, grads: tuple, opt_state: optax.OptState, optimiser: optax.GradientTransformation, ) -> tuple[VectorisedNetwork, optax.OptState]: """One optimiser step on every ``weights_mean``, from precomputed gradients.""" elements = list(network.layers) weights = tuple(elem.weights_mean for elem in elements) updates, new_opt_state = optimiser.update(grads, opt_state, weights) new_weights = optax.apply_updates(weights, updates) for i, new_w in enumerate(new_weights): if new_w is not None: elements[i] = dataclasses.replace(elements[i], weights_mean=new_w) return dataclasses.replace(network, layers=tuple(elements)), new_opt_state def _apply_synaptic_uncertainty_updates( network: VectorisedNetwork, grads: tuple, importance: tuple, settings: SynapticUncertaintySettings, ) -> VectorisedNetwork: """Advance every weight belief one step, mean and precision together. The rule needs no optimiser: the step size is the belief's own variance (see :func:`pyhgf.updates.vectorised.learning.resolve_synaptic_uncertainty_settings`), so the update is applied here and both the mean (``weights_mean``) and the accumulated precision (``weights_precision_delta``) are written back to the element that carries them. Parameters ---------- network : The network whose beliefs are advanced. grads : One descent gradient per element, ``None`` for the bottom element. importance : One importance entry per element, aligned with ``grads``. settings : The resolved settings of the rule. Returns ------- VectorisedNetwork The network with updated weights and precisions. Raises ------ ValueError If an element holding weights carries no belief, which means the install step was skipped. """ elements = list(network.layers) for i, elem in enumerate(elements): if elem.weights_mean is None or grads[i] is None: continue if elem.weights_precision_delta is None: raise ValueError( f"layers[{i}] holds weights but no weight belief. Install one " "with add_layer(weight_belief=True), or let " "learning_kind='synaptic_uncertainty' install it." ) new_weights, new_delta = vectorised_synaptic_uncertainty_update( elem.weights_mean, elem.weights_precision_delta, grads[i], importance[i], settings, ) elements[i] = dataclasses.replace( elem, weights_mean=new_weights, weights_precision_delta=new_delta ) return dataclasses.replace(network, layers=tuple(elements)) def _learn_sweep( network: VectorisedNetwork, opt_state: optax.OptState, optimiser: Optional[optax.GradientTransformation], learning_kind: str = "precision_weighted", synaptic_uncertainty_settings: Optional[SynapticUncertaintySettings] = None, ) -> tuple[VectorisedNetwork, optax.OptState]: """Weight-learning phase: prediction-error-driven gradients, then one step. Mirrors the weight-update block of :func:`propagation_step`. Must run *after* :func:`_update_sweep`, so the per-layer states already carry their prediction errors / posteriors. Updates ``weights_mean`` on every element that has them. With ``synaptic_uncertainty_settings`` the weight-belief rule runs instead of the optimiser: the gradients are formed from ``learning_kind`` as usual, the importance increments alongside them, and both the means and the precisions advance. The optimiser state is returned unchanged, since the rule carries none. """ factors = _weight_quantities(network, learning_kind) grads = tuple(_gradient_matrix(f) for f in factors) if synaptic_uncertainty_settings is not None: importance = tuple(_importance_pair(f) for f in factors) return _apply_synaptic_uncertainty_updates( network, grads, importance, synaptic_uncertainty_settings ), opt_state return _apply_weight_updates(network, grads, opt_state, optimiser)
[docs] @eqx.filter_jit def learn_sweep( network: VectorisedNetwork, opt_state: optax.OptState, optimiser: Optional[optax.GradientTransformation], learning_kind: str, synaptic_uncertainty_settings: Optional[SynapticUncertaintySettings] = None, ) -> tuple[VectorisedNetwork, optax.OptState]: """JIT-compiled weight-learning phase. See :func:`_learn_sweep`. """ return _learn_sweep( network, opt_state, optimiser, learning_kind, synaptic_uncertainty_settings )
# --------------------------------------------------------------------------- # Pure per-sample step + batch-synchronous learning # --------------------------------------------------------------------------- # The state fields that carry information from one sample to the next. Every # other field is rewritten by the sweeps: expected means and precisions come # from the prediction sweep, posterior means are rebuilt as expected mean + # correction. What persists is the value-level posterior precision (each # prediction reads the previous one) and the volatility level's belief. _CARRIED_FIELDS: tuple = ("precision", "mean_vol", "precision_vol") def _precision_increments(before: VectorisedNetwork, after: VectorisedNetwork) -> tuple: """Per-element change of the carried precision fields, ``after - before``. Returns one ``dict`` per element, keyed by field name. For a ``Layer`` each entry has shape ``(n_nodes,)``; for a ``LayerStack``, ``(n_slices, n_nodes)``. """ # ``mean_vol``/``precision_vol`` are ``None`` on layers without a volatility # parent — there is no volatility-level belief to carry, so skip them. return tuple( { field: getattr(elem_after.state, field) - getattr(elem_before.state, field) for field in _CARRIED_FIELDS if getattr(elem_before.state, field) is not None } for elem_before, elem_after in zip(before.layers, after.layers) )
[docs] def apply_precision_increments( network: VectorisedNetwork, increments: tuple ) -> VectorisedNetwork: """Add precision increments (see :func:`_precision_increments`) to a network. Used by :func:`batch_step` to carry the batch-averaged precision change into the state used by the next batch. """ new_elements = [] for elem, inc in zip(network.layers, increments): new_state = dataclasses.replace( elem.state, **{ field: getattr(elem.state, field) + inc[field] for field in _CARRIED_FIELDS if field in inc }, ) new_elements.append(dataclasses.replace(elem, state=new_state)) return dataclasses.replace(network, layers=tuple(new_elements))
def _restore_precisions( network: VectorisedNetwork, template: VectorisedNetwork ) -> VectorisedNetwork: """Reset the carried precision fields to a template's values. The inverse of carrying: with this applied after every propagation step, each sample's sweeps still compute full per-sample posteriors but the filtered precisions never become the next sample's starting point. """ new_elements = [] for elem, ref in zip(network.layers, template.layers): repl = { field: getattr(ref.state, field) for field in _CARRIED_FIELDS if getattr(ref.state, field) is not None } new_elements.append( dataclasses.replace(elem, state=dataclasses.replace(elem.state, **repl)) ) return dataclasses.replace(network, layers=tuple(new_elements))
[docs] def sample_step( network: VectorisedNetwork, x: jnp.ndarray, y: jnp.ndarray, learning_kind: str = "precision_weighted", time_step: float = 1.0, ) -> tuple[jnp.ndarray, tuple, tuple]: """One full local learning step for one sample, as a pure function. Runs the prediction sweep (clamp ``x`` on top, predict downward) and the update sweep (clamp ``y`` at the bottom, compute errors and correct beliefs upward), then reads out everything a caller needs without mutating anything. Parameters ---------- network : The state template. Not modified; every call starting from the same template sees the same weights and the same precisions, which is what makes this function safe to ``jax.vmap`` over a batch of samples. x : Predictors clamped on the top layer, shape ``(n_input_features,)``. y : Observations clamped on the bottom layer, shape ``(n_output_features,)``. learning_kind : Weight-gradient mode, as in :func:`pyhgf.updates.vectorised.learning.learning_weights_vectorised`. time_step : Inference time step for the prediction sweep. Returns ------- input_error : The prediction error at the input (top) layer — see :func:`input_prediction_error`. grads : Per-element weight gradients (descent form, ``None`` for the bottom element). Average these across a batch and apply once. increments : Per-element change of the carried precision fields (value-level posterior precision and the volatility level), relative to the template. Average these across a batch and apply once with :func:`apply_precision_increments`. """ updated = _update_sweep( _prediction_sweep(network, x, time_step=time_step), y, time_step=time_step ) return ( _input_prediction_error(updated), tuple(_gradient_matrix(f) for f in _weight_quantities(updated, learning_kind)), _precision_increments(network, updated), )
def _batch_step( network: VectorisedNetwork, opt_state: Optional[optax.OptState], x: jnp.ndarray, y: jnp.ndarray, optimiser: Optional[optax.GradientTransformation] = None, learning_kind: str = "precision_weighted", update_precisions: bool = True, time_step: float = 1.0, predicted: Optional[tuple] = None, sample_weight: Optional[jnp.ndarray] = None, synaptic_uncertainty_settings: Optional[SynapticUncertaintySettings] = None, weight_reuse: float = 1.0, ) -> tuple[VectorisedNetwork, Optional[optax.OptState], jnp.ndarray]: """One batch-synchronous learning step over many samples at once. Every sample in the batch is processed from the *same* state template — same weights, same precisions — through the same sweeps as :func:`sample_step`, under ``jax.vmap``, so samples are exchangeable and nothing depends on their order. The per-sample results are then averaged and applied once, so the batch counts as a single observation: * the mean weight gradient drives one optimiser step (skipped when ``optimiser`` is ``None``); * the mean precision increments are added to the carried fields (skipped when ``update_precisions`` is ``False``, e.g. to keep the carried precisions pinned when comparing against backpropagation). Averaging (rather than summing) makes the result invariant to repeating the batch: the same samples twice produce the same step. Parameters ---------- network : The state template shared by every sample in the batch. opt_state : The optimiser state, or ``None`` when ``optimiser`` is ``None``. x : Predictors, shape ``(batch, n_input_features)``. y : Observations, shape ``(batch, n_output_features)``. optimiser : Optax optimiser for the weight step. ``None`` freezes the weights. learning_kind : Weight-gradient mode. update_precisions : Whether to carry the batch-averaged precision increments into the returned network. time_step : Inference time step, applied once per batch. sample_weight : Optional per-sample weights, shape ``(batch,)``. The batch mean becomes a weighted mean whose denominator is ``sample_weight.sum()`` rather than the row count, so rows that carry no information do not dilute the update. This exists because "average over the batch" is ambiguous once a caller pads. A padded row contributes a zero gradient either way, but with a plain mean it still counts in the denominator, so the effective step shrinks by the padding fraction. Anything that hands this function a variable-length batch — a token sequence, a masked objective, a ragged observation — is affected, and the symptom is a *systematic* gradient scale error rather than noise. Pass the mask as weights to make the reduction mean-over-contributing-rows instead. ``None`` (default) keeps the plain mean, so existing behaviour is unchanged. predicted : Optional per-sample predicted states from :func:`batched_prediction_states` (one batched ``LayerState`` per element). When given, the internal prediction sweep is skipped and the update starts from these states — the forward pass a caller has already run is not repeated. ``x`` is ignored in that case. synaptic_uncertainty_settings : When given, the weight-belief rule runs in place of ``optimiser``: each element's mean and accumulated precision advance together and the optimiser state is left untouched (see :func:`pyhgf.updates.vectorised.learning.resolve_synaptic_uncertainty_settings` ). ``learning_kind`` still selects the gradient the rule descends. weight_reuse : How many times each weight matrix is applied per sample, default ``1.0`` (once, the ordinary case). This exists because "average over the batch" is also ambiguous when one weight matrix is *reused* several times per sample. A weight shared across ``k`` positions of a sample sees ``k`` rows per sample, so the plain mean divides by ``k`` more than that weight's true per-sample quantities, which sum over its ``k`` uses and average only over samples. Pass ``k`` to recover those sums. The caller owns the count, since only it knows how the rows were built (see :func:`pyhgf.model.conv.conv_block`, which passes the patch count). Both halves of the step are rescaled, so the weight-belief rule of ``synaptic_uncertainty`` stays internally consistent: the gradient because the chain rule sums a shared weight's uses, and the importance because the curvature those uses impose accumulates the same way. Rescaling only the gradient would move the mean ``k`` times faster while the belief tightened at the one-use rate, leaving a step ``k`` times too large once accumulated curvature dominates the prior. The importance half carries a modelling assumption the gradient half does not. Summing the gradient over uses is the chain rule; summing curvature over them treats the ``k`` uses as independent observations, which overlapping convolution patches are not. Where that matters, the same correction can be had with a smaller ``k``. Returns ------- network : The template advanced by one batch: new weights and, if requested, new precisions. Everything else is untouched (it is rewritten by the sweeps on the next call anyway). opt_state : The advanced optimiser state (``None`` if no optimiser was given). input_errors : Per-sample prediction errors at the input layer, shape ``(batch, n_input_features)`` — the messages a caller passes to whatever sits behind this network. """ # Each sample contributes only its two gradient factors (small vectors); # the batch-mean gradient is then one contraction per weight matrix. This # avoids materialising one weight-matrix-sized gradient per sample under # vmap — the same arithmetic, a batch factor less memory traffic. Every # gradient kind is separable, so this is the only path. def finish_sample(swept: VectorisedNetwork, yi): updated = _update_sweep(swept, yi, time_step=time_step) factors = None if optimiser is not None or synaptic_uncertainty_settings is not None: factors = _weight_quantities(updated, learning_kind) return ( _input_prediction_error(updated), _precision_increments(network, updated), factors, ) if predicted is None: def per_sample(xi, yi): return finish_sample( _prediction_sweep(network, xi, time_step=time_step), yi ) input_errors, increments, factors = jax.vmap(per_sample)(x, y) else: # Rebuild each sample's network around the shared (unbatched) weights # and static fields; only the layer states carry a batch axis. def per_sample_predicted(states_i, yi): swept = dataclasses.replace( network, layers=tuple( dataclasses.replace(elem, state=state_i) for elem, state_i in zip(network.layers, states_i) ), ) return finish_sample(swept, yi) input_errors, increments, factors = jax.vmap(per_sample_predicted)(predicted, y) new_network = network if optimiser is not None or synaptic_uncertainty_settings is not None: mean_grads = tuple( None if f is None else _contract_factors((f[0], f[1]), sample_weight) for f in factors ) if weight_reuse != 1.0: mean_grads = tuple( None if g is None else g * weight_reuse for g in mean_grads ) if synaptic_uncertainty_settings is not None: importance = tuple( _reduce_importance(_importance_pair(f), sample_weight) for f in factors ) if weight_reuse != 1.0: # The increment is the outer product H[a, i] = p[a] * q[i], so # scaling one factor scales it. importance = tuple( None if imp is None else (imp[0] * weight_reuse, imp[1]) for imp in importance ) new_network = _apply_synaptic_uncertainty_updates( new_network, mean_grads, importance, synaptic_uncertainty_settings ) else: new_network, opt_state = _apply_weight_updates( new_network, mean_grads, opt_state, optimiser ) if update_precisions: if sample_weight is None: reduce = lambda i: i.mean(axis=0) # noqa: E731 else: denominator = jnp.maximum(sample_weight.sum(), 1.0) def reduce(i): return jnp.tensordot(sample_weight, i, axes=(0, 0)) / denominator mean_increments = jax.tree_util.tree_map(reduce, increments) new_network = apply_precision_increments(new_network, mean_increments) return new_network, opt_state, input_errors # Compiled entry point. The unjitted ``_batch_step`` is importable so a larger # compiled program (e.g. a fused pipeline step) can inline it. batch_step = eqx.filter_jit(_batch_step) def _reduce_importance(imp_factors, sample_weight=None) -> Optional[tuple]: """Batch-mean importance factors from stacked per-sample factors. ``imp_factors`` is ``None`` for the bottom element, or a ``(p, q)`` pair with a leading batch axis (see :func:`_importance_pair`). Each side is averaged over the batch separately. For a continuous child the child-side factor is identical across the batch (every sample sweeps from the same state template, and the conditional predicted precision is built from carried precisions and volatility states, not from the sample's values), and a binary child contributes ones, so the outer product of the two means equals the mean of the per-sample outer products. ``sample_weight`` follows the semantics of :func:`_contract_factors`: a weighted mean whose denominator is the weight the batch actually carries, so padded rows do not dilute the increment. """ if imp_factors is None: return None p, q = imp_factors if sample_weight is None: return p.mean(axis=0), q.mean(axis=0) denominator = jnp.maximum(sample_weight.sum(), 1.0) return ( jnp.tensordot(sample_weight, p, axes=(0, 0)) / denominator, jnp.tensordot(sample_weight, q, axes=(0, 0)) / denominator, ) def _contract_factors(factors, sample_weight=None) -> Optional[jnp.ndarray]: """Batch-mean gradient from stacked per-sample factors. ``factors`` is ``None`` for the bottom element, or a ``(u, v)`` pair with a leading batch axis: ``(batch, n_children)`` and ``(batch, n_parents)`` for a ``Layer``; ``(batch, n_slices, ...)`` for a ``LayerStack``. The mean over samples of ``u ⊗ v`` is computed as a single contraction. """ if factors is None: return None u, v = factors if sample_weight is None: if u.ndim == 2: return jnp.einsum("bi,bj->ij", u, v) / u.shape[0] return jnp.einsum("bni,bnj->nij", u, v) / u.shape[0] # Weighted mean: the denominator is the weight the batch actually carries, # not the number of rows it happens to be padded to. denominator = jnp.maximum(sample_weight.sum(), 1.0) if u.ndim == 2: return jnp.einsum("b,bi,bj->ij", sample_weight, u, v) / denominator return jnp.einsum("b,bni,bnj->nij", sample_weight, u, v) / denominator
[docs] @eqx.filter_jit def prediction_pass(network: VectorisedNetwork, x: jnp.ndarray) -> jnp.ndarray: """Forward-only sweep through the network. Sets the predictors on the top element and runs the top-down prediction sweep — no prediction errors, posterior updates, or weight learning — returning the bottom element's ``expected_mean``. Used by :meth:`pyhgf.model.DeepNetwork.predict`. Parameters ---------- network : The current vectorised network state. x : The predictors set on the top element. Returns ------- expected_mean : The bottom element's ``expected_mean`` after the forward sweep. """ return _prediction_sweep(network, x).layers[0].state.expected_mean
[docs] @eqx.filter_jit def batched_prediction_pass(network: VectorisedNetwork, x: jnp.ndarray) -> jnp.ndarray: """Forward-only sweep for a batch of samples, compiled once and reused. The batched equivalent of :func:`prediction_pass`: every row of ``x`` is an independent sample swept from the same network state. Used by :meth:`pyhgf.model.DeepNetwork.predict` so repeated batched calls hit the compilation cache instead of rebuilding the batching wrapper. Parameters ---------- network : The current vectorised network state. x : Predictors, shape ``(batch, n_input_features)``. Returns ------- expected_mean : The bottom element's ``expected_mean`` per sample, shape ``(batch, n_output_features)``. """ return jax.vmap( lambda xi: _prediction_sweep(network, xi).layers[0].state.expected_mean )(x)
[docs] @eqx.filter_jit def batched_prediction_states(network: VectorisedNetwork, x: jnp.ndarray) -> tuple: """Batched forward sweep returning the per-sample swept states. Like :func:`batched_prediction_pass`, but keeps what the sweep computed: one batched ``LayerState`` per element (each field with a leading batch axis). Passing these to :func:`batch_step` as ``predicted`` lets the learning step start directly from them instead of repeating the forward sweep — the weights and static fields are not duplicated per sample, only the layer states are. The states are the *only* output: the per-sample predictions are read from the bottom element's ``expected_mean`` after the call. Returning that array alongside the states from the same compiled function produces incorrect values under the vmap-of-jit composition on CPU, so callers must read it from the returned states. Parameters ---------- network : The current vectorised network state. x : Predictors, shape ``(batch, n_input_features)``. Returns ------- states : One batched ``LayerState`` per element, ordered as ``network.layers``. """ def one(xi): return tuple(elem.state for elem in _prediction_sweep(network, xi).layers) return jax.vmap(one)(x)
# --------------------------------------------------------------------------- # DAG networks # --------------------------------------------------------------------------- # # Continuous networks are DAGs rather than chains: each layer can have one # value-parent layer and one volatility-parent layer, recorded on the *parent* # as ``value_child_idx`` / ``volatility_child_idx``. The builder guarantees # that every parent has a higher index than its children, so the prediction # sweep runs top-down in descending index order and the update sweep runs # bottom-up in ascending order. def _assert_continuous_network(network: VectorisedNetwork) -> None: """Check that every element is a continuous ``Layer`` (no stacks, no mixing).""" for i, elem in enumerate(network.layers): if isinstance(elem, LayerStack): raise NotImplementedError( "Continuous networks do not support LayerStack elements yet." ) if elem.kind != "continuous": raise ValueError( f"Layer {i} has kind {elem.kind!r}: continuous sweeps require " "an all-continuous network." ) def _continuous_parent_maps( network: VectorisedNetwork, ) -> tuple[list, list]: """Invert the child indices into per-layer parent indices. Returns ``(value_parent_of, volatility_parent_of)``, each one entry per layer holding the parent's index or ``None``. """ n = len(network.layers) value_parent_of: list = [None] * n volatility_parent_of: list = [None] * n for j, elem in enumerate(network.layers): if elem.value_child_idx is not None: value_parent_of[elem.value_child_idx] = j if elem.volatility_child_idx is not None: volatility_parent_of[elem.volatility_child_idx] = j return value_parent_of, volatility_parent_of def _continuous_prediction_sweep( network: VectorisedNetwork, value_parent_of: list, volatility_parent_of: list, *, time_step: float = 1.0, ) -> VectorisedNetwork: """Top-down prediction sweep over a continuous network. Predicts every layer from its value and volatility parents, in descending index order so parents are always predicted before their children. Nothing is clamped: observations enter in :func:`_continuous_update_sweep`. The parent maps come from :func:`_continuous_parent_maps`. """ elements = list(network.layers) for i in range(len(elements) - 1, -1, -1): elem = elements[i] vp = value_parent_of[i] vlp = volatility_parent_of[i] new_state = vectorised_continuous_prediction( child_state=elem.state, params=elem.params, time_step=time_step, value_parent_state=None if vp is None else elements[vp].state, weights=None if vp is None else elements[vp].weights_mean, coupling_fn=None if vp is None else elements[vp].coupling_fn, volatility_parent_state=(None if vlp is None else elements[vlp].state), volatility_weights=( None if vlp is None else elements[vlp].volatility_weights ), is_static_leaf=elem.is_input_layer and vlp is None, mean_field_updates=network.mean_field_updates, ) elements[i] = dataclasses.replace(elem, state=new_state) return dataclasses.replace(network, layers=tuple(elements)) def _continuous_update_sweep( network: VectorisedNetwork, y: jnp.ndarray, value_parent_of: list, *, time_step: float = 1.0, ) -> VectorisedNetwork: """Bottom-up prediction-error and posterior-update sweep. Clamps the observations on layer 0, computes its prediction errors, then walks the layers in ascending order: each layer's posterior integrates the prediction errors of its value and volatility children (already computed, since children carry lower indices), and its own prediction errors are then written for the parents above. ``value_parent_of`` comes from :func:`_continuous_parent_maps`; the volatility side is read off each layer's ``has_volatility_parent``. """ elements = list(network.layers) # Clamp the observations and compute the leaf prediction errors. leaf_state = dataclasses.replace(elements[0].state, mean=y) leaf_state = vectorised_continuous_prediction_error( leaf_state, has_volatility_parent=elements[0].has_volatility_parent ) elements[0] = dataclasses.replace(elements[0], state=leaf_state) for i in range(1, len(elements)): elem = elements[i] value_child = None if elem.value_child_idx is not None: child_elem = elements[elem.value_child_idx] value_child = ValueChild( state=child_elem.state, weights=elem.weights_mean, coupling_fn=elem.coupling_fn, # Only layer 0 is clamped, and the update loop below skips it, # so its posterior precision is the one that never moves. precision_is_clamped=child_elem.is_input_layer, ) volatility_child = None if elem.volatility_child_idx is not None: child_elem = elements[elem.volatility_child_idx] volatility_child = VolatilityChild( state=child_elem.state, kappa=elem.volatility_weights, params=child_elem.params, ) if value_child is None and volatility_child is None: # A layer nobody names as parent receives no message; nothing to do. continue new_state = vectorised_continuous_posterior_update( elem.state, value_child=value_child, volatility_child=volatility_child, volatility_updates=network.volatility_updates, time_step=time_step, max_posterior_precision=network.max_posterior_precision, mean_field_updates=network.mean_field_updates, ) # Prediction errors are only needed when a parent above will read them. if value_parent_of[i] is not None or elem.has_volatility_parent: new_state = vectorised_continuous_prediction_error( new_state, has_volatility_parent=elem.has_volatility_parent ) elements[i] = dataclasses.replace(elem, state=new_state) return dataclasses.replace(network, layers=tuple(elements))
[docs] @eqx.filter_jit def run_continuous_scan( network: VectorisedNetwork, ys: jnp.ndarray, time_steps: jnp.ndarray, record: tuple = (), ) -> tuple: """Filter a sequence of observations through a continuous network. Runs ``jax.lax.scan`` over (prediction sweep, update sweep) pairs. There is no weight learning: the coupling matrices are parameters of the filter. Parameters ---------- network : The initial continuous network state. ys : Observations clamped on layer 0 at each step, shape ``(T, n_obs)``. time_steps : Per-step time steps, shape ``(T,)``. record : Tuple of ``LayerState`` field names to record at every step. With an empty tuple (default) the per-step output is layer 0's ``expected_mean`` alone; otherwise it is ``(traj_step, prediction)``. Returns ------- ``(final_network, step_output)`` with per-step outputs stacked along a leading ``(T,)`` axis. """ # The topology is static, so validate and invert it once rather than once # per sweep inside the scan body. _assert_continuous_network(network) value_parent_of, volatility_parent_of = _continuous_parent_maps(network) def body(net, xs): y, dt = xs predicted = _continuous_prediction_sweep( net, value_parent_of, volatility_parent_of, time_step=dt ) updated = _continuous_update_sweep(predicted, y, value_parent_of, time_step=dt) prediction = updated.layers[0].state.expected_mean if record: traj_step = { field: tuple(getattr(elem.state, field) for elem in updated.layers) for field in record } return updated, (traj_step, prediction) return updated, prediction return jax.lax.scan(body, network, (ys, time_steps))