Example 02 · Partial structure in a hierarchy
In a hierarchy, the group means are conditionally Gaussian once the population mean is fixed. The plan solves them exactly and leaves only the population mean to NUTS. A second model, with no exact block, runs on NUTS alone.
Model
Six groups share a population mean μ. Each group has a latent mean ug, and each of its five measurements has known noise:
The function hierarchy in the script declares this graph: a vector latent groups whose prior mean is population, an indexing operator that assigns each measurement to its group, and the observed node.
Complete script
"""A hierarchy with exact group means. Run with: JAX_ENABLE_X64=1 python partial_structure.py"""
import jax
import jax.numpy as jnp
import numpy as np
import numpyro.distributions as dist
import bayesmith as bs
GROUPS, PER_GROUP = 6, 5
INDEX = np.repeat(np.arange(GROUPS), PER_GROUP)
PRIOR_SD, GROUP_SD, NOISE_SD = 2.0, 0.6, 0.25
def hierarchy(index, data):
index = bs.const("group_index", index)
population = bs.sample("population", lambda: dist.Normal(0.0, PRIOR_SD))
groups = bs.sample(
"groups",
lambda mu: dist.Normal(mu * jnp.ones(GROUPS), GROUP_SD).to_event(1),
population,
)
location = bs.det("location", lambda u, i: u[i], groups, index)
bs.observe("obs", lambda loc: dist.Normal(loc, NOISE_SD), location, obs=data)
def simulate(seed=0, population=0.8):
"""Set a truth, draw group means from their prior, then draw data."""
rng = np.random.default_rng(seed)
groups = population + GROUP_SD * rng.standard_normal(GROUPS)
data = groups[INDEX] + NOISE_SD * rng.standard_normal(INDEX.size)
return {"population": population, "groups": groups}, data
def exact_posterior(data):
"""Closed-form posterior of (population, groups): the model is jointly Gaussian."""
prior = np.full((GROUPS + 1, GROUPS + 1), PRIOR_SD**2)
prior[1:, 1:] += GROUP_SD**2 * np.eye(GROUPS)
design = np.zeros((INDEX.size, GROUPS + 1))
design[np.arange(INDEX.size), 1 + INDEX] = 1.0
precision = np.linalg.inv(prior) + design.T @ design / NOISE_SD**2
covariance = np.linalg.inv(precision)
mean = covariance @ design.T @ data / NOISE_SD**2
return mean, np.sqrt(np.diag(covariance))
def compare(seed=1, num_warmup=1000, num_samples=4000):
"""Sample through the compiled plan and set it beside the exact posterior."""
truth, data = simulate()
plan = bs.compile(bs.trace(hierarchy, INDEX, jnp.asarray(data)))
draws = plan.sample(
jax.random.key(seed), num_warmup=num_warmup, num_samples=num_samples
).samples
sampled = np.column_stack([draws["population"], draws["groups"]])
exact_mean, exact_sd = exact_posterior(data)
names = ["population"] + [f"groups[{g}]" for g in range(GROUPS)]
truths = [truth["population"], *truth["groups"]]
return [
{
"name": name,
"truth": float(truths[i]),
"exact_mean": float(exact_mean[i]),
"sampled_mean": float(sampled[:, i].mean()),
"exact_sd": float(exact_sd[i]),
"sampled_sd": float(sampled[:, i].std()),
}
for i, name in enumerate(names)
]
if __name__ == "__main__":
_, data = simulate()
print(bs.compile(bs.trace(hierarchy, INDEX, jnp.asarray(data))))
for row in compare():
print(row)
Simulate
simulate() sets μ = 0.8, draws the six group means from their prior and then draws five measurements per group, with a fixed seed. The truth is kept for the check and is not given to inference.
Infer
Compiling the graph gives this plan:
| Block | Latents | Route | Why |
|---|---|---|---|
| 0 | groups | GCR exact | structurally certified; numerical check 3 scales x 3 at-points (max 0.00e+00) |
| 1 | population | NUTS | outside the exact block ['groups']: sampled by NUTS, and the exact block is solved CONDITIONAL on them, once per sweep |
| Execution | HMCGibbs(inner=NUTS, gibbs_sites=['groups']); noise_std rebuilt every sweep | ||
Printed plan
block 0 {groups} GCR exact structurally certified; numerical check 3 scales x 3 at-points (max 0.00e+00)
kappa=29.8 -> tol=3.3557047e-05, guard hoisted out of the sweep
exact block ['groups']: sigma does not move with the block (0.000e+00 relative,
rtol=1.0e-08), so the covariance is fixed and one Wiener solve is exact
block 1 {population} NUTS
outside the exact block ['groups']: sampled by NUTS, and the exact block is
solved CONDITIONAL on them, once per sweep
execution: HMCGibbs(inner=NUTS, gibbs_sites=['groups']); noise_std rebuilt every sweeppartial_structure.py. Condition numbers and tolerances in the printout depend on the BLAS; block membership and routes are re-checked by the test suite. Full record.With μ fixed, the six group means have a Gaussian prior, each prediction is a selection from them and the noise does not depend on them, so groups is certified as an exact block. The prior of groups depends on μ, so μ cannot sit in the same exact block; it goes to NUTS. The execution line states the result: NUTS moves μ, and every sweep draws all six group means exactly given the current μ.
The exact route reads a Normal whether or not it is wrapped by .to_event or .expand. The hierarchical notebook writes its group prior with .expand and gets the same plan.
Check
Every distribution here is Gaussian and every operator is affine, so the joint posterior of all seven latents is Gaussian and has a closed form (exact_posterior in the script). That gives an independent reference for the draws the plan produces.
Scroll the figure horizontally, or open it for a full-size view.
Numerical recovery results and run provenance
| Parameter | Truth | Exact mean | Sampled mean | Exact sd | Sampled sd |
|---|---|---|---|---|---|
| population | 0.800 | 0.8067 | 0.8096 | 0.2473 | 0.2460 |
| groups[0] | 0.875 | 0.8566 | 0.8543 | 0.1102 | 0.1093 |
| groups[1] | 0.721 | 0.5071 | 0.5067 | 0.1102 | 0.1078 |
| groups[2] | 1.184 | 1.1941 | 1.1956 | 0.1102 | 0.1089 |
| groups[3] | 0.863 | 0.9601 | 0.9573 | 0.1102 | 0.1094 |
| groups[4] | 0.479 | 0.3489 | 0.3475 | 0.1102 | 0.1078 |
| groups[5] | 1.017 | 1.0458 | 1.0472 | 0.1102 | 0.1112 |
Agreement on one dataset with one seed checks this route on this model. It does not calibrate the sampler across datasets; diagnostics and model checks covers that.
When nothing is exact
An exponential decay with an unknown rate is nonlinear in its only latent, and the rate has a log-normal prior. The plan has no exact block:
| Block | Latents | Route | Why |
|---|---|---|---|
| 0 | rate | NUTS | 'rate': node 'rate' returns LogNormal; the exact linear-Gaussian path needs a diagonal Normal (a Normal, one wrapped by .to_event(...) or .expand(...), or a ComplexNormal) |
| Execution | NUTS | ||
Printed plan
block 0 {rate} NUTS
'rate': node 'rate' returns LogNormal; the exact linear-Gaussian path needs a
diagonal Normal (a Normal, one wrapped by .to_event(...) or .expand(...), or a
ComplexNormal)
execution: NUTSnonlinear.py. Condition numbers and tolerances in the printout depend on the BLAS; block membership and routes are re-checked by the test suite. Full record.The script below runs the same model through the typed task protocol with a deliberately small budget.
Complete nonlinear script
"""A small NUTS tutorial; short chains demonstrate the API, not convergence."""
import jax
import jax.numpy as jnp
import numpyro.distributions as dist
import bayesmith as bs
from bayesmith.artifacts import (
ComputeBudget,
PosteriorTask,
Refusal,
model_ref_from_callable,
new_task_meta,
)
DATA = jnp.array([1.02, 0.57, 0.30, 0.21, 0.12])
def model(data):
rate = bs.sample("rate", lambda: dist.LogNormal(-0.5, 0.4))
mean = bs.det("mean", lambda r: jnp.exp(-r * jnp.arange(5.0)), rate)
bs.observe("signal", lambda m: dist.Normal(m, 0.1), mean, obs=data)
def main():
graph = bs.trace(model, DATA)
print(bs.compile(graph))
task = PosteriorTask(
meta=new_task_meta(label="decay rate"),
budget=ComputeBudget(draws=100, warmup=100, chains=1),
require_convergence=False, # tutorial budget only; inspect real runs separately
)
planned = bs.compile_task(
graph,
task,
model_ref=model_ref_from_callable(model, identifier="decay"),
key=jax.random.key(1),
)
if isinstance(planned, Refusal):
raise SystemExit(planned.failed_premise)
result = bs.execute_task(planned, key=jax.random.key(2))
if isinstance(result, Refusal):
raise SystemExit(result.failed_premise)
print(result.run.backend)
print(result.run.termination)
return result
if __name__ == "__main__":
main()
Scroll the figure horizontally, or open it for a full-size view.
| Quantity | Recorded output |
|---|---|
| Rate mean | 0.5988 |
| Rate sd | 0.0849 |
| Backend | numpyro 0.21.0 |
| Termination | fixed budget completed; convergence criteria not met |
Sampling budget and diagnostics
The short single-chain example explicitly disables the convergence requirement. For a scientific result, choose an adequate warmup and draw budget, use multiple chains where supported, inspect divergences and chain diagnostics, and retain termination details. Increasing the number of draws does not repair an incorrect likelihood, a poor parameterization or missing identifiability.
result.run.backend identifies the numerical engine. result.run.termination states how the run stopped. Keep both with the result so a fixed-budget stop cannot later be presented as a convergence decision.
Conditional exactness and its limits
An exact block is exact given the other latents. It does not make the chain over the remaining latents independent, and it does not make the whole graph analytically integrable. A declaration cannot create an exact block the checks reject: the Overview shows a false declaration refused with its measurements.
Continue with structure and inference routes and sampling policies.