Advanced Usage¶
This tutorial covers Bayinx's loop utilities — map and fori_loop — which let you process data element-by-element inside a model while still accumulating the log-density correctly and remaining fully compatible with JAX compilation.
In many models you naturally want to iterate over observations — computing per-observation quantities, applying different transformations, or building up contributions to the likelihood one at a time.
Python's built-in for loops work fine in Bayinx in general, but when those loops need to run inside a jax.jit-compiled function (which all fitted models do), the loop must be static, meaning the number of iterations is fixed at compile time and JAX unrolls every iteration into the computation graph.
For small loops this is fine, but for large datasets it produces enormous, slow-to-compile graphs.
JAX offers a variety of functions to keep the computation graph small by compiling the inner work of the loop once and iterating with map, scan, fori_loop, while_loop, etc.
However we cannot use distribution statements natively with these functions, and so Bayinx offers alternatives with the same functionality.
In other words, they are context-aware: when called inside a model's model method, they automatically accumulate each iteration's log-probability contribution into the running target density, so you never have to manage that bookkeeping yourself.
Importing the Utilities¶
map — Mapping Over Data¶
map applies a function over the leading axis of one or more arrays, accumulating log-probability contributions along the way.
fis a callable that receives a single element (or a tuple of elements, one from each array inxs) and optionally returns a value.xsare the arrays to map over. They are sliced along their leading axis and passed positionally tof.- If
freturns values,mapstacks them and returns the result; iffreturns nothing,mapreturnsNone.
fori_loop — Looping Over Indices¶
fori_loop is the index-based counterpart: it iterates an integer index from lower to upper (exclusive), calling f(i) at each step.
freceives the current integer indexi.- Like
map, it accumulates log-probability contributions inside a model context and stacks any returned values.
Example: A GLM With Per-Observation Likelihood¶
To show both utilities in a realistic setting, consider a Poisson GLM where we process each observation individually. The model is:
Setup¶
import jax.numpy as jnp
import jax.random as jr
from jaxtyping import Array
from bayinx import Model, Posterior, stochastic, observed
from bayinx.dists import Normal, Poisson
from bayinx.ops import map, fori_loop
# Simulate some data
key = jr.key(0)
n_obs, n_pred = 200, 3
X = jr.normal(key, (n_obs, n_pred))
true_beta = jnp.array([0.5, -1.0, 0.3])
lam_true = jnp.exp(X @ true_beta)
y = jr.poisson(jr.key(1), lam_true)
Using map¶
map is the most natural fit here: we have a fixed array of observations and want to apply the same likelihood function to each row.
class PoissonGLM_Map(Model):
beta: Array = stochastic(shape='n_pred')
X: Array = observed(shape=('n_obs', 'n_pred'))
y: Array = observed(shape='n_obs')
def model(self, target):
# Prior on coefficients
self.beta << Normal(0, 3)
# Process each observation separately using map
def observation_likelihood(xi, yi):
lam = jnp.exp(xi @ self.beta)
yi << Poisson(lam)
map(observation_likelihood, self.X, self.y)
post = Posterior(
PoissonGLM_Map,
n_obs=n_obs,
n_pred=n_pred,
X=X,
y=y
)
map slices self.X and self.y along their leading axis, passing one row xi and one scalar yi to observation_likelihood per iteration.
Each call to yi << Poisson(lam_i) contributes to the log-density, and map accumulates all those contributions back into the outer model context.
Using fori_loop¶
fori_loop is useful when you need explicit index access — for example, to index into arrays differently for each step, or when the loop bounds come from a shape rather than an array.
class PoissonGLM_Fori(Model):
beta: Array = stochastic(shape='n_pred')
X: Array = observed(shape=('n_obs', 'n_pred'))
y: Array = observed(shape='n_obs')
def model(self, target):
# Prior on coefficients
self.beta << Normal(0, 3)
# Process each observation separately using fori_loop
def observation_likelihood(i):
lam = jnp.exp(self.X[i] @ self.beta)
self.y[i] << Poisson(lam)
fori_loop(0, self.y.shape[0], observation_likelihood)
post = Posterior(
PoissonGLM_Fori,
n_obs=n_obs,
n_pred=n_pred,
X=X,
y=y
)
The two approaches produce identical results — choose whichever reads more clearly for your model.
Fitting and Sampling¶
Both model variants are fitted the same way as any other Bayinx model:
from bayinx.flows import DiagAffine
post.configure([DiagAffine()])
post.fit()
beta_draws = post.sample('beta', 10_000)
print(f"True beta: {true_beta}")
print(f"Posterior mean (beta): {beta_draws.mean(0)}")
Fitting Variational Approximation: 100%|████████████████████████████████████████| 100000/100000 [00:01<00:00, 67082.35it/s]
True beta: [ 0.5 -1. 0.3]
Posterior mean (beta): [ 0.53247786 -0.91966873 0.38757807]
Collecting Return Values¶
Both map and fori_loop can also collect values returned by f, which is useful for computing per-observation quantities you want to inspect after fitting.
def compute_lambda(model, key):
def per_obs(xi):
return jnp.exp(xi @ model.beta)
return map(per_obs, model.X) # returns shape (n_obs,) for each draw
lambda_draws = post.predictive(compute_lambda, 1000)
print(f"True lambda[0]: {lam_true[0]}")
print(f"Posterior mean of lambda[0]: {lambda_draws.mean(0)[0]}")
When f returns a value, the results are stacked along a new leading axis, giving you an (n_draws, ...) array. When f returns nothing (as in the likelihood-only examples above), both utilities return None.