from functools import partial
import jax.numpy as jnp
from jax import Array, grad, jit
from pyhgf.typing import Edges
[docs]
@partial(jit, static_argnames=("node_idx",))
def predict_precision_volatility_level(
attributes: dict,
node_idx: int,
) -> tuple[Array, Array]:
"""Predict the precision of the implicit volatility level.
Parameters
----------
attributes :
The attributes of the probabilistic nodes.
node_idx :
Pointer to the volatile-state node that will be updated.
Returns
-------
expected_precision_vol :
The expected (predicted) precision of the volatility level.
effective_precision_vol :
The effective precision of the volatility-level prediction.
"""
time_step = attributes[-1]["time_step"]
# Get volatility level parameters
precision_vol = attributes[node_idx]["precision_vol"]
tonic_volatility_vol = attributes[node_idx]["tonic_volatility_vol"]
# Compute predicted volatility for the volatility level
predicted_volatility_vol = time_step * jnp.exp(tonic_volatility_vol)
predicted_volatility_vol = jnp.where(
predicted_volatility_vol > 1e-128, predicted_volatility_vol, jnp.nan
)
# Expected precision
expected_precision_vol = 1 / ((1 / precision_vol) + predicted_volatility_vol)
# Effective precision
effective_precision_vol = predicted_volatility_vol * expected_precision_vol
return expected_precision_vol, effective_precision_vol
[docs]
@partial(jit, static_argnames=("edges", "node_idx"))
def predict_mean_value_level(
attributes: dict,
edges: Edges,
node_idx: int,
) -> Array:
"""Predict the mean of the value level (external facing).
This uses value parents if they exist.
Parameters
----------
attributes :
The attributes of the probabilistic nodes.
edges :
The edges of the probabilistic nodes as a tuple of
:py:class:`pyhgf.typing.AdjacencyLists`. For each node, the entry lists its
value/volatility parents and children.
node_idx :
Pointer to the volatile-state node that will be updated.
Returns
-------
expected_mean :
The expected (predicted) mean of the value level.
"""
time_step = attributes[-1]["time_step"]
# List the node's value parents
value_parents_idxs = edges[node_idx].value_parents
# Get the drift rate from the node
driftrate = 0.0
# Look at the (optional) value parents for this node
if value_parents_idxs is not None:
for value_parent_idx, value_coupling_parent in zip(
value_parents_idxs,
attributes[node_idx]["value_coupling_parents"],
):
# Get the coupling function
child_position = edges[value_parent_idx].value_children.index(node_idx)
coupling_fn = edges[value_parent_idx].coupling_fn[child_position]
if coupling_fn is None:
parent_value = attributes[value_parent_idx]["expected_mean"]
else:
parent_value = coupling_fn(
attributes[value_parent_idx]["expected_mean"]
)
driftrate += value_coupling_parent * parent_value
# The new expected mean from the previous value
expected_mean = (
attributes[node_idx]["autoconnection_strength"] * attributes[node_idx]["mean"]
) + (time_step * driftrate)
return expected_mean
[docs]
@partial(jit, static_argnames=("edges", "node_idx"))
def predict_precision_value_level(
attributes: dict,
edges: Edges,
node_idx: int,
) -> tuple[Array, Array, Array]:
r"""Predict the value-level precisions using the implicit volatility level.
The implicit volatility level is treated as a full Gaussian parent: the exact
moment-generating-function correction :math:`\kappa^2 / (2 \, \hat{\pi}_{\mathrm{vol}})`
is added inside the log-volatility exponent. In addition, each value parent
contributes a first-order Laplace term
:math:`(t^{(k)} \, \alpha \, g'(\hat{\mu}_b))^2 / \tilde{\pi}_b` to the marginal
predictive variance (using each parent's marginal predicted precision,
``expected_precision``).
Two predicted precisions are returned (see
:class:`pyhgf.typing.LayerState` for the notation table):
.. math::
\frac{1}{\hat{\pi}_a^{(k)}}
= \frac{1}{\pi_a^{(k-1)}} + \Omega_a^{(k)}, \qquad
\frac{1}{\tilde{\pi}_a^{(k)}}
= \frac{1}{\hat{\pi}_a^{(k)}}
+ \sum_{b} \frac{ (t^{(k)} \, \alpha_b \, g'(\hat{\mu}_b))^2 }
{ \tilde{\pi}_b }.
:math:`\hat{\pi}_a` is the *conditional* predicted precision used by the
parent's posterior-step Schur complement; :math:`\tilde{\pi}_a` is the *marginal*
predicted precision consumed by downstream surprise/likelihood code.
Parameters
----------
attributes :
The attributes of the probabilistic nodes.
edges :
The edges of the probabilistic network as a tuple of
:py:class:`pyhgf.typing.AdjacencyLists`.
node_idx :
Index of the volatile state node.
Returns
-------
expected_precision :
The marginal predicted precision :math:`\tilde{\pi}_a^{(k)}`.
conditional_expected_precision :
The conditional predicted precision :math:`\hat{\pi}_a^{(k)}`.
effective_precision :
The effective precision :math:`\gamma_a^{(k)} = \Omega_a^{(k)} \tilde{\pi}_a^{(k)}`,
consumed by the volatility-coupling posterior update.
"""
time_step = attributes[-1]["time_step"]
# Get value level parameters
precision = attributes[node_idx]["precision"]
tonic_volatility = attributes[node_idx]["tonic_volatility"]
# Get volatility level's expected mean and precision (already computed)
expected_mean_vol = attributes[node_idx]["expected_mean_vol"]
expected_precision_vol = attributes[node_idx]["expected_precision_vol"]
# Get internal coupling strength
volatility_coupling_internal = attributes[node_idx]["volatility_coupling_internal"]
# Total volatility = tonic + linear contribution of the implicit volatility
# parent + closed-form moment-generating-function correction κ²/(2 π̂_vol)
# that arises from marginalising over the volatility parent's Gaussian.
total_volatility = (
tonic_volatility
+ (volatility_coupling_internal * expected_mean_vol)
+ (volatility_coupling_internal**2) / (2.0 * expected_precision_vol)
)
# Compute predicted volatility
predicted_volatility = time_step * jnp.exp(total_volatility)
predicted_volatility = jnp.where(
predicted_volatility > 1e-128, predicted_volatility, jnp.nan
)
# Laplace value-coupling correction. The conditional mean of the value level
# is linearised around μ̂_b via a first-order Taylor expansion of the coupling
# function g; the variance contribution from each value parent is then
# (t · α · g'(μ̂_b))² / π̃_b, using the parent's marginal predicted precision
# π̃_b (= `expected_precision`). The factor t arises because the value-parent
# contribution to the mean is scaled by the time step in
# :func:`predict_mean_value_level`.
value_parents_idxs = edges[node_idx].value_parents
value_coupling_variance = jnp.zeros_like(predicted_volatility)
if value_parents_idxs is not None:
for value_parent_idx, psi in zip(
value_parents_idxs,
attributes[node_idx]["value_coupling_parents"],
):
child_position = edges[value_parent_idx].value_children.index(node_idx)
coupling_fn = edges[value_parent_idx].coupling_fn[child_position]
mu_b = attributes[value_parent_idx]["expected_mean"]
if coupling_fn is None:
g_prime = jnp.ones_like(mu_b)
else:
g_prime = grad(coupling_fn)(mu_b)
value_coupling_variance += (time_step * psi * g_prime) ** 2 / attributes[
value_parent_idx
]["expected_precision"]
# Conditional predicted precision π̂_a — the precision of x_a given its
# value parents (own variance + volatility only), WITHOUT the parent-uncertainty
# value-coupling term. This is the quantity the parent's posterior-step Schur
# complement acts on; substituting the marginal there would double-count parent
# uncertainty.
conditional_expected_precision = 1 / ((1 / precision) + predicted_volatility)
# Expected precision = inverse marginal predictive variance.
expected_precision = 1 / (
(1 / precision) + predicted_volatility + value_coupling_variance
)
# Effective precision (γ): only the volatility-driven part enters γ, since
# γ is consumed by the volatility-coupling posterior update.
effective_precision = predicted_volatility * expected_precision
return expected_precision, conditional_expected_precision, effective_precision
[docs]
@partial(jit, static_argnames=("edges", "node_idx"))
def volatile_node_prediction(
attributes: dict, node_idx: int, edges: Edges, **args
) -> dict:
"""Update the expected mean and expected precision of a value-volatility node.
This node has two internal levels:
1. Volatility level (implicit, internal)
2. Value level (external facing)
The volatility level predicts first, then affects the value level's precision.
Parameters
----------
attributes :
The attributes of the probabilistic nodes.
node_idx :
Pointer to the volatile-state node that will be updated.
edges :
The edges of the probabilistic nodes as a tuple of
:py:class:`pyhgf.typing.AdjacencyLists`. For each node, the entry lists its
value/volatility parents and children.
Returns
-------
attributes :
The updated attributes of the probabilistic nodes.
"""
# Store current variance for potential unbounded updates
attributes[node_idx]["temp"]["current_variance"] = (
1 / attributes[node_idx]["precision"]
)
# 1. PREDICT VOLATILITY LEVEL (implicit internal state)
expected_precision_vol, effective_precision_vol = (
predict_precision_volatility_level(attributes, node_idx)
)
attributes[node_idx]["expected_mean_vol"] = attributes[node_idx]["mean_vol"]
attributes[node_idx]["expected_precision_vol"] = expected_precision_vol
attributes[node_idx]["temp"]["effective_precision_vol"] = effective_precision_vol
# 2. PREDICT VALUE LEVEL (external facing)
# Value level's precision depends on volatility level
expected_precision, conditional_expected_precision, effective_precision = (
predict_precision_value_level(attributes, edges, node_idx)
)
# Value level's mean
expected_mean = predict_mean_value_level(attributes, edges, node_idx)
attributes[node_idx]["expected_mean"] = expected_mean
# Input/leaf override: an observed volatile node has no value children, so it
# does not undergo a Gaussian random walk between observations. Skip the
# tonic-volatility contribution at the value level and use the prior precision
# directly, mirroring the continuous-node treatment in
# :func:`continuous_node_prediction`.
if (
(edges[node_idx].value_children is None)
and (edges[node_idx].volatility_children is None)
and (edges[node_idx].volatility_parents is None)
):
attributes[node_idx]["expected_precision"] = attributes[node_idx]["precision"]
# A leaf has no volatility random walk, so the conditional and marginal
# predicted precisions coincide with the prior precision.
attributes[node_idx]["temp"]["conditional_expected_precision"] = attributes[
node_idx
]["precision"]
attributes[node_idx]["temp"]["effective_precision"] = jnp.zeros_like(
effective_precision
)
else:
attributes[node_idx]["expected_precision"] = expected_precision
attributes[node_idx]["temp"]["conditional_expected_precision"] = (
conditional_expected_precision
)
attributes[node_idx]["temp"]["effective_precision"] = effective_precision
return attributes
[docs]
@partial(jit, static_argnames=("edges", "node_idx"))
def predict_precision_value_level_mean_field(
attributes: dict,
edges: Edges,
node_idx: int,
) -> tuple[Array, Array, Array]:
"""Predict the precision of the value level using the implicit volatility level.
The volatility level's mean modulates the value level's precision.
Parameters
----------
attributes :
The attributes of the probabilistic nodes.
edges :
The edges of the probabilistic nodes as a tuple of
:py:class:`pyhgf.typing.AdjacencyLists`. For each node, the entry lists its
value/volatility parents and children.
node_idx :
Pointer to the volatile-state node that will be updated.
Returns
-------
expected_precision :
The expected (marginal) precision of the value level.
conditional_expected_precision :
The conditional predicted precision of the value level.
effective_precision :
The effective precision of the value-level prediction.
"""
time_step = attributes[-1]["time_step"]
precision = attributes[node_idx]["precision"]
tonic_volatility = attributes[node_idx]["tonic_volatility"]
expected_mean_vol = attributes[node_idx]["expected_mean_vol"]
volatility_coupling_internal = attributes[node_idx]["volatility_coupling_internal"]
total_volatility = tonic_volatility + (
volatility_coupling_internal * expected_mean_vol
)
predicted_volatility = time_step * jnp.exp(total_volatility)
predicted_volatility = jnp.where(
predicted_volatility > 1e-128, predicted_volatility, jnp.nan
)
expected_precision = 1 / ((1 / precision) + predicted_volatility)
effective_precision = predicted_volatility * expected_precision
return expected_precision, expected_precision, effective_precision
[docs]
@partial(jit, static_argnames=("edges", "node_idx"))
def volatile_node_prediction_mean_field(
attributes: dict, node_idx: int, edges: Edges, **args
) -> dict:
"""Update the expected mean and expected precision of a value-volatility node.
This node has two internal levels:
1. Volatility level (implicit, internal)
2. Value level (external facing)
The volatility level predicts first, then affects the value level's precision.
Parameters
----------
attributes :
The attributes of the probabilistic nodes.
node_idx :
Pointer to the volatile-state node that will be updated.
edges :
The edges of the probabilistic nodes as a tuple of
:py:class:`pyhgf.typing.AdjacencyLists`. For each node, the entry lists its
value/volatility parents and children.
Returns
-------
attributes :
The updated attributes of the probabilistic nodes.
"""
attributes[node_idx]["temp"]["current_variance"] = (
1 / attributes[node_idx]["precision"]
)
# Volatility level prediction (unchanged — no relaxed changes here)
expected_precision_vol, effective_precision_vol = (
predict_precision_volatility_level(attributes, node_idx)
)
attributes[node_idx]["expected_mean_vol"] = attributes[node_idx]["mean_vol"]
attributes[node_idx]["expected_precision_vol"] = expected_precision_vol
attributes[node_idx]["temp"]["effective_precision_vol"] = effective_precision_vol
# Value level precision — mean_field: no MGF, no Laplace correction
expected_precision, conditional_expected_precision, effective_precision = (
predict_precision_value_level_mean_field(attributes, edges, node_idx)
)
expected_mean = predict_mean_value_level(attributes, edges, node_idx)
attributes[node_idx]["expected_mean"] = expected_mean
if (
(edges[node_idx].value_children is None)
and (edges[node_idx].volatility_children is None)
and (edges[node_idx].volatility_parents is None)
):
attributes[node_idx]["expected_precision"] = attributes[node_idx]["precision"]
attributes[node_idx]["temp"]["conditional_expected_precision"] = attributes[
node_idx
]["precision"]
attributes[node_idx]["temp"]["effective_precision"] = jnp.zeros_like(
effective_precision
)
else:
attributes[node_idx]["expected_precision"] = expected_precision
attributes[node_idx]["temp"]["conditional_expected_precision"] = (
conditional_expected_precision
)
attributes[node_idx]["temp"]["effective_precision"] = effective_precision
return attributes