bayesmithDocumentation0.10.0 source

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:

\mu\sim\mathcal N(0,2^2),\qquad u_g\mid\mu\sim\mathcal N(\mu,0.6^2),\qquad y_{gj}\mid u_g\sim\mathcal N(u_g,0.25^2)

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
partial_structure.py
"""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)

Download this example

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:

BlockLatentsRouteWhy
0groupsGCR exactstructurally certified; numerical check 3 scales x 3 at-points (max 0.00e+00)
1populationNUTSoutside the exact block ['groups']: sampled by NUTS, and the exact block is solved CONDITIONAL on them, once per sweep
ExecutionHMCGibbs(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 sweep
Recorded 2026-09-19 with bayesmith 0.9.0 on arm64 (accelerate BLAS, x64 on) from partial_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.

Seven parameters: sampled and analytic posterior means and one standard deviation, with generating values

Scroll the figure horizontally, or open it for a full-size view.

Blue and orange show posterior mean ± one standard deviation, not Monte Carlo error bars. Crosses mark the generating values. This figure visualizes the same recorded run as the table below; it is not a new sampling run.
Numerical recovery results and run provenance
ParameterTruthExact meanSampled meanExact sdSampled sd
population0.8000.80670.80960.24730.2460
groups[0]0.8750.85660.85430.11020.1093
groups[1]0.7210.50710.50670.11020.1078
groups[2]1.1841.19411.19560.11020.1089
groups[3]0.8630.96010.95730.11020.1094
groups[4]0.4790.34890.34750.11020.1078
groups[5]1.0171.04581.04720.11020.1112
Recorded 2026-09-19 with bayesmith 0.9.0 on arm64 (accelerate BLAS, x64 on); seed 1, num_warmup 1000, num_samples 4000. Sampled columns are one run on that platform; closed-form columns are recomputed by the test suite. Full record.

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:

BlockLatentsRouteWhy
0rateNUTS'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)
ExecutionNUTS
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: NUTS
Recorded 2026-09-19 with bayesmith 0.9.0 on arm64 (accelerate BLAS, x64 on) from nonlinear.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
nonlinear.py
"""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()

Download this example

Observed exponential decay with posterior median and a 90 percent interval for the mean signal

Scroll the figure horizontally, or open it for a full-size view.

Actual output from the short, single-chain script below the hierarchy example. The shaded band describes the noiseless mean signal; it does not include future observation noise. This run demonstrates the API and does not establish convergence.
QuantityRecorded output
Rate mean0.5988
Rate sd0.0849
Backendnumpyro 0.21.0
Terminationfixed budget completed; convergence criteria not met
Recorded 2026-09-19 with bayesmith 0.9.0 on arm64 (accelerate BLAS, x64 on); compile_key 1, sample_key 2, warmup 100, draws 100, chains 1. Sampled columns are one run on that platform; closed-form columns are recomputed by the test suite. Full record.

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.

Type a concept or API name.