Note
Go to the end to download the full example code.
From a model file and a dataframe to checked posterior draws¶
The complete workflow on a Gaussian regression: load the model from a FlatPPL file, bind a Polars table and the fixed constants once, take the posterior’s log density, check it and its gradient against the closed form, batch it, sample four NUTS chains with BlackJAX, and compare the draws with the exact posterior. The prior and the noise are Gaussian, so the posterior and the evidence are known in closed form.
Runtime budget: 120 s.
Simulate a dataset¶
alpha, beta ~ Normal(0, 2) and y | alpha, beta ~ Normal(alpha + beta x,
0.5). The simulated intercept is 0.6 and the slope 1.4. The data arrive as
a Polars table.
rng = np.random.default_rng(20261008)
n, noise_sd, prior_sd = 48, 0.5, 2.0
x = np.linspace(-2.0, 2.0, n)
y = 0.6 + 1.4 * x + rng.normal(0.0, noise_sd, n)
table = pl.DataFrame({"x": x, "y": y})
Load the model and bind the data once¶
models/regression.flatppl declares the size, both scales and the table as
externals. Module.set binds all of them in one call; the table converts at
this host boundary. The model’s posterior is its prior times the
likelihood of the table.
The gallery runs each page from the examples directory, without __file__.
flatppl_compat = "0.1"
n = external(posintegers)
noise_sd = external(posreals)
prior_sd = external(posreals)
data = external(cartpow(cartprod(x = reals, y = reals), n))
alpha ~ Normal(0.0, prior_sd)
beta ~ Normal(0.0, prior_sd)
means = alpha .+ beta .* data.x
y ~ Normal.(means, noise_sd)
prior = lawof(record(alpha = alpha, beta = beta))
forward = kernelof(record(y = y), alpha = alpha, beta = beta)
likelihood = likelihoodof(forward, record(y = data.y))
posterior = bayesupdate(likelihood, prior)
('alpha', 'beta') {'alpha': (), 'beta': ()}
Check the value and the gradient¶
With w = (alpha, beta) and X = [1, x] the log density is
-log(2 pi s^2) - |w|^2 / (2 s^2) - n/2 log(2 pi sigma^2)
- |y - X w|^2 / (2 sigma^2), with gradient
-w / s^2 + X^T (y - X w) / sigma^2. NumPy computes both without FlatPPL.
X = np.column_stack([np.ones(n), x])
point = {"alpha": jnp.float32(0.3), "beta": jnp.float32(-0.2)}
value, gradient = jax.value_and_grad(density.fn)(point)
w = np.array([0.3, -0.2])
residual = y - X @ w
expected_value = (
-log(2 * pi * prior_sd**2) - w @ w / (2 * prior_sd**2)
- n / 2 * log(2 * pi * noise_sd**2) - residual @ residual / (2 * noise_sd**2)
) # fmt: skip
expected_gradient = -w / prior_sd**2 + X.T @ residual / noise_sd**2
print(f"log density {float(value):.4f}, closed form {expected_value:.4f}")
np.testing.assert_allclose(float(value), expected_value, rtol=2e-6)
np.testing.assert_allclose(
[gradient["alpha"], gradient["beta"]], expected_gradient, rtol=2e-5, atol=2e-4
)
log density -386.4451, closed form -386.4451
Batch candidate points¶
jax.vmap maps the density over a leading axis of every field.
candidates = {
"alpha": jnp.array([-0.5, 0.0, 0.6, 1.0]),
"beta": jnp.array([0.5, 1.0, 1.4, 2.0]),
}
print(np.asarray(jax.jit(jax.vmap(density.fn))(candidates)).round(3))
[-284.713 -106.183 -37.091 -93.669]
The exact posterior and its normalization¶
The posterior precision is Lambda = I/s^2 + X^T X/sigma^2, its mean
Lambda^-1 X^T y / sigma^2. The evidence is the Gaussian density of y
with covariance sigma^2 I + s^2 X X^T. A grid over (alpha, beta)
integrates the density to that evidence.
precision = np.eye(2) / prior_sd**2 + X.T @ X / noise_sd**2
covariance = np.linalg.inv(precision)
exact_mean = covariance @ X.T @ y / noise_sd**2
marginal = noise_sd**2 * np.eye(n) + prior_sd**2 * X @ X.T
_, logdet = np.linalg.slogdet(marginal)
log_evidence = -0.5 * (n * log(2 * pi) + logdet + y @ np.linalg.solve(marginal, y))
sd = np.sqrt(np.diag(covariance))
axes = [
np.linspace(m - 8 * s, m + 8 * s, 401) for m, s in zip(exact_mean, sd, strict=True)
]
grid = jax.vmap(
lambda a: jax.vmap(lambda b: density({"alpha": a, "beta": b}))(jnp.asarray(axes[1]))
)(jnp.asarray(axes[0]))
total = np.trapezoid(
np.trapezoid(np.exp(np.asarray(grid, dtype=np.float64)), axes[1], axis=1), axes[0]
)
print(f"log evidence: grid {np.log(total):.5f}, closed form {log_evidence:.5f}")
assert abs(np.log(total) - log_evidence) < 1e-4
log evidence: grid -38.91706, closed form -38.91707
Four NUTS chains¶
Each chain adapts its step size and mass matrix, then keeps its draws.
jax.vmap runs the chains together on one device; with several devices,
jax.shard_map gives each its own chains.
chains, warmup, draws = 4, 500, 1000
def run_chain(key: jax.Array, start: Any) -> Any:
"""Adapt one chain, then keep its draws and divergence flags."""
warmup_key, sample_key = jax.random.split(key)
adaptation = blackjax.window_adaptation(
blackjax.nuts, density.fn, target_acceptance_rate=0.9
)
(state, parameters), _ = adaptation.run(warmup_key, start, num_steps=warmup)
kernel = blackjax.nuts(density.fn, **parameters)
def step(state: Any, key: jax.Array) -> tuple[Any, Any]:
state, info = kernel.step(key, state)
return state, (state.position, info.is_divergent)
return jax.lax.scan(step, state, jax.random.split(sample_key, draws))[1]
starts = {
"alpha": jnp.array([-1.0, 0.0, 1.0, 2.0]),
"beta": jnp.array([-2.0, -0.5, 0.5, 2.0]),
}
samples, divergent = jax.jit(jax.vmap(run_chain))(
jax.random.split(jax.random.key(827), chains), starts
)
print("divergences:", int(divergent.sum()))
divergences: 0
Compare the draws with the exact posterior¶
ArviZ summarizes the draws. Each mean lies within four Monte Carlo standard errors of the exact mean and each standard deviation within five percent.
summary = az.summary(
az.from_dict({"posterior": {k: np.asarray(v) for k, v in samples.items()}}),
ci_prob=0.9,
round_to="none",
)
print(summary.to_string())
for index, name in enumerate(("alpha", "beta")):
row = {key: float(value) for key, value in summary.loc[name].items()}
assert abs(row["mean"] - exact_mean[index]) < 4 * row["mcse_mean"], name
assert abs(row["sd"] / sd[index] - 1) < 0.05, name
assert row["r_hat"] < 1.01, name
mean sd eti90_lb eti90_ub ess_bulk ess_tail r_hat mcse_mean mcse_sd
alpha 0.733054 0.071311 0.614970 0.849390 2872.757036 2475.105730 1.003159 0.001332 0.001222
beta 1.378538 0.060744 1.278704 1.475739 2734.983188 2380.009418 1.000786 0.001162 0.001040
Total running time of the script: (0 minutes 2.256 seconds)