Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Pattern 1 — `eqx.tree_at`

Regression Masterclass — Pattern 1: eqx.tree_at + raw NumPyro

Open In Colab

A pedagogical reimplementation of the Aboleth Regression Master Class using Equinox for deterministic architecture, NumPyro for probabilistic semantics, and einops for every tensor contraction. Seven Bayesian regression models, all bridged through the same eqx.tree_at pattern. No pyrox abstractions — this notebook is the canonical raw recipe that the sibling notebooks (pyrox_sample, Parameterized) progressively collapse.

What you’ll learn:

  1. The three-library architecture: Equinox owns the architecture, NumPyro owns the probability, einops owns the linear algebra.
  2. The eqx.tree_at bridge — how to inject numpyro.sample outputs into a frozen Equinox PyTree.
  3. Seven model patterns (linear, MLP, MC-dropout, Bayesian MLP, RFF SVR, hierarchical RFF GP, deep RFF GP), each as a tiny Equinox module + tiny NumPyro model function.
  4. Three inference paths from one substrate: NUTS for full Bayesian, SVI+AutoDelta for MAP, Predictive for posterior-predictive sampling.

Background — from Bayesian foundations to running code

Bayesian regression in one diagram

Every model in this notebook is an instance of the same probabilistic structure. We fix:

  • a likelihood p(y∣x,θ)p(y \mid x, \theta) — how observations are generated given parameters,
  • a prior p(θ)p(\theta) — what we believed about parameters before seeing data,

and then condition on the observed dataset D={(xi,yi)}i=1N\mathcal{D} = \{(x_i, y_i)\}_{i=1}^{N} to get the posterior via Bayes’ rule:

p(θ∣D)  =  p(D∣θ) p(θ)p(D)  =  p(θ)∏i=1Np(yi∣xi,θ)∫p(θ′) p(D∣θ′) dθ′.p(\theta \mid \mathcal{D}) \;=\; \frac{p(\mathcal{D} \mid \theta)\, p(\theta)}{p(\mathcal{D})} \;=\; \frac{p(\theta) \prod_{i=1}^N p(y_i \mid x_i, \theta)}{\int p(\theta')\, p(\mathcal{D} \mid \theta')\, d\theta'}.

The denominator p(D)=∫p(θ)p(D∣θ) dθp(\mathcal{D}) = \int p(\theta) p(\mathcal{D} \mid \theta)\,d\theta is the marginal likelihood (a.k.a. the evidence). It is almost never available in closed form for the models we care about — that intractability is the problem that probabilistic programming languages (PPLs) and approximate-inference algorithms exist to solve.

Once we have (an approximation to) the posterior, the quantity we actually want is the posterior predictive at a fresh test point x∗x^*:

p(y∗∣x∗,D)  =  ∫p(y∗∣x∗,θ) p(θ∣D) dθ.p(y^* \mid x^*, \mathcal{D}) \;=\; \int p(y^* \mid x^*, \theta)\, p(\theta \mid \mathcal{D})\, d\theta.

This integral is also intractable, but if we have SS samples θ(s)∼p(θ∣D)\theta^{(s)} \sim p(\theta \mid \mathcal{D}) we can approximate it by Monte Carlo:

p(y∗∣x∗,D)  ≈  1S∑s=1Sp(y∗∣x∗,θ(s)).p(y^* \mid x^*, \mathcal{D}) \;\approx\; \frac{1}{S} \sum_{s=1}^{S} p(y^* \mid x^*, \theta^{(s)}).

That last line is every Predictive(model, posterior_samples=...) call below in one equation.

From continuous math to a probabilistic program

A PPL is a domain-specific language whose primitives correspond directly to the lines of math above. Translation table:

Mathematical statementNumPyro primitive
θ∼p(θ)\theta \sim p(\theta) (prior over a latent)theta = numpyro.sample("theta", prior_dist)
yi∼p(y∣xi,θ)y_i \sim p(y \mid x_i, \theta) given observed yiy_i (likelihood)numpyro.sample("obs", likelihood_dist(x, theta), obs=y)
f(θ)=...f(\theta) = \texttt{...} deterministic function recorded for posteritynumpyro.deterministic("f", f_of_theta)
A scalar log-prob factor +log⁡ϕ(θ)+\log\phi(\theta) that isn’t naturally a sample sitenumpyro.factor("phi", log_phi_of_theta)

A model is then just a Python function that calls these primitives in some order. Running the function under the right handler (NumPyro’s word for “interpreter”) gives different things:

  • Forward sampling. numpyro.handlers.seed(model, rng_seed=0)(...) runs the function with obs=None everywhere and returns one prior-predictive sample.
  • Trace. numpyro.handlers.trace(model).get_trace(...) returns a dict of all sample/deterministic sites with their distributions and values — this is how MCMC and SVI introspect the model.
  • Conditioning. Passing observed obs=y to the likelihood site fixes that draw; the trace’s joint log-prob becomes log⁡p(θ)+log⁡p(y∣θ)\log p(\theta) + \log p(y \mid \theta) — exactly the un-normalized posterior we want to sample.

What about the Equinox bit? numpyro.sample returns a JAX array of the right shape. We need that array to flow into the deterministic forward pass f_\theta(x). Equinox modules are immutable PyTrees, so we cannot do net.weight = w_sampled. The solution is eqx.tree_at:

netposterior  =  eqx.tree_at(λm ⁣: ⁣m.weight, netinit, wsampled)\text{net}_{\text{posterior}} \;=\; \texttt{eqx.tree\_at}\bigl(\lambda m\!:\!m.\text{weight},\, \text{net}_{\text{init}},\, w_{\text{sampled}}\bigr)

returns a new module identical to the old except at the selected leaf. For multi-leaf injection (an MLP with W1, b1, W2, b2), use a tuple selector + tuple of replacements. This is the only glue between the deterministic Equinox world and the probabilistic NumPyro world — every pattern below is just a different choice of which leaves to replace.

From a probabilistic program to a posterior — three solvers

A model function is the input. The output is samples (or a point estimate, or an approximate distribution) of θ. NumPyro ships three solvers, all of which operate on the same model-function interface:

(1) Markov Chain Monte Carlo via NUTS. The No-U-Turn Sampler (Hoffman & Gelman, 2014) is an adaptive variant of Hamiltonian Monte Carlo. It builds a Markov chain whose stationary distribution is the exact posterior. Concretely, it computes ∇θlog⁡p(θ,D)\nabla_\theta \log p(\theta, \mathcal{D}) via reverse-mode autodiff through the model function, simulates Hamiltonian dynamics in (θ,p)(\theta, p)-space (where pp is an auxiliary momentum), and accepts/rejects via the Metropolis criterion. After a warmup phase that adapts step size and mass matrix, the post-warmup draws are (approximately) i.i.d. samples from p(θ∣D)p(\theta \mid \mathcal{D}).

Code: mcmc = MCMC(NUTS(model_fn), num_warmup=W, num_samples=S); mcmc.run(rng, *args, **kwargs). The returned samples are a dict keyed by sample-site name, each value of shape (S,) + site_shape.

(2) Stochastic Variational Inference. Pick a parametric family qϕ(θ)q_\phi(\theta) — the guide — and solve

ϕ∗  =  arg⁡min⁡ϕKL ⁣(qϕ(θ) ∥ p(θ∣D)).\phi^* \;=\; \arg\min_\phi \mathrm{KL}\!\bigl(q_\phi(\theta) \,\|\, p(\theta \mid \mathcal{D})\bigr).

This minimization is equivalent to maximizing the Evidence Lower Bound:

L(ϕ)  =  Eθ∼qϕ[log⁡p(D,θ)−log⁡qϕ(θ)]  =  log⁡p(D)−KL ⁣(qϕ ∥ p(⋅∣D))  ≤  log⁡p(D).\mathcal{L}(\phi) \;=\; \mathbb{E}_{\theta \sim q_\phi}\bigl[\log p(\mathcal{D}, \theta) - \log q_\phi(\theta)\bigr] \;=\; \log p(\mathcal{D}) - \mathrm{KL}\!\bigl(q_\phi \,\|\, p(\cdot \mid \mathcal{D})\bigr) \;\le\; \log p(\mathcal{D}).

The ELBO is a tractable Monte Carlo objective: draw θ~∼qϕ\tilde\theta \sim q_\phi, compute log⁡p(D,θ~)−log⁡qϕ(θ~)\log p(\mathcal{D}, \tilde\theta) - \log q_\phi(\tilde\theta), take a stochastic gradient. NumPyro’s SVI(model, guide, optim, Trace_ELBO()) does exactly this. The choice of qϕq_\phi controls expressiveness:

  • qϕ(θ)=δ(θ−μϕ)q_\phi(\theta) = \delta(\theta - \mu_\phi) — a delta guide, equivalent to MAP since arg⁡max⁡ϕL(ϕ)=arg⁡max⁡θlog⁡p(θ,D)\arg\max_\phi \mathcal{L}(\phi) = \arg\max_\theta \log p(\theta, \mathcal{D}).
  • qϕ(θ)=∏iN(θi;μi,σi2)q_\phi(\theta) = \prod_i \mathcal{N}(\theta_i; \mu_i, \sigma_i^2) — a mean-field normal, gives an approximate posterior with diagonal covariance.
  • Full-rank, normalizing flows, etc.

numpyro.infer.autoguide.AutoDelta(model_fn) constructs the delta guide automatically; AutoNormal(model_fn) does mean-field normal.

(3) Posterior predictive sampling. Once we have either MCMC samples or a fitted guide, getting predictive draws is mechanical:

y∗(s)∼p(y∣x∗,θ(s)),θ(s)∼p(θ∣D) (or qϕ∗).y^{*(s)} \sim p(y \mid x^*, \theta^{(s)}), \qquad \theta^{(s)} \sim p(\theta \mid \mathcal{D}) \text{ (or } q_{\phi^*}\text{)}.

Predictive(model_fn, posterior_samples=samples)(rng, x_test, **kwargs)["obs"] returns shape (S, N_test). The mean across the SS axis is the posterior-predictive mean; the empirical 2.5/97.5 percentiles give a 95% credible interval.

Helpers? None.

Every helper this notebook would normally need (make_dataset, run_mcmc, run_svi, predict, summarise_predictions, compute_r2) is inlined at the call site. The whole notebook is the codebase.

The three-library architecture

To keep the model code crisp, three libraries split the work:

  • Equinox — deterministic architecture. eqx.Module subclasses with __call__ methods, immutable PyTrees, the placeholder weights and forward pass.
  • NumPyro — probabilistic semantics + inference. Sample sites, deterministic sites, observed sites, MCMC, SVI, Predictive.
  • einops — named-axis tensor algebra. Every @ is replaced by einsum(A, B, "n feat, feat -> n") so the contracted axis (feat) and surviving axis (n) are explicit at the call site.

Setup

Detect Colab and install pyrox[colab] (which transitively pulls in numpyro, equinox, einops, matplotlib, and watermark) only when running there. Local / CI users with the environment already set up skip the install and go straight to imports.

Print a reproducibility readout so the exact library versions used to generate the committed outputs are unambiguous.

Python implementation: CPython
Python version       : 3.12.13
IPython version      : 9.12.0

jax       : 0.8.3
equinox   : 0.13.7
numpyro   : 0.20.1
einops    : 0.8.2
matplotlib: 3.10.8

Compiler    : GCC 14.3.0
OS          : Linux
Release     : 6.8.0-1044-azure
Machine     : x86_64
Processor   : x86_64
CPU cores   : 16
Architecture: 64bit

Toy dataset

A noisy sinc curve, f(x)=sin⁡(x)/xf(x) = \sin(x) / x, with 100 training points uniformly drawn in [−10,10][-10, 10] and a dense test grid in the same range for in-distribution evaluation. The function is nonlinear, oscillatory, and decays toward the boundaries — properties that defeat a straight line and challenge any model with too few hidden units.

Training points: 100
Test points:     400

r2 and plot_fit are utility functions to avoid copy-paste across seven model sections — they are scoring and plotting only, not part of any modeling helper layer the notebook would normally pull in. Every MCMC / SVI / Predictive call below is inlined at its call site.

Model 1 — Bayesian linear regression

A polynomial-feature linear model with a Gaussian prior on the weights. Inference: full posterior via NUTS.

Generative model.

w∼N(0,Id+1),σ∼HalfNormal(1)w \sim \mathcal{N}(0, I_{d+1}), \qquad \sigma \sim \text{HalfNormal}(1)
Φ(x)i=[1,xi,xi2,…,xid],f(x)=Φ(x) w,yi∼N(f(xi),σ2)\Phi(x)_i = [1, x_i, x_i^2, \dots, x_i^d], \qquad f(x) = \Phi(x)\, w, \qquad y_i \sim \mathcal{N}(f(x_i), \sigma^2)

The bridge. weight: jax.Array = jnp.zeros(d+1) is a placeholder; numpyro.sample("w", ...) draws the real weights and eqx.tree_at(lambda m: m.weight, net, w) returns a new module with w in place of the placeholder.

Inference. A degree-1 polynomial cannot fit the oscillations of sinc — we expect the line to look terrible and R2≈0R^2 \approx 0. This is the negative control that proves the harness works before we move to expressive models.

Bayesian linear (degree=1) R² = -0.0151
<Figure size 1200x500 with 1 Axes>

Model 2 — MLP via MAP

A single-hidden-layer MLP, fitted with MAP via SVI + AutoDelta. Maximizing the ELBO with a delta guide is exactly MAP estimation: arg⁡max⁡θlog⁡p(D,θ)\arg\max_\theta \log p(\mathcal{D}, \theta). The result is a single point estimate — no uncertainty band.

Generative model.

W1∈R1×H, b1∈RH, W2∈RH×1, b2∈R  ∼  N(0,1)W_1 \in \mathbb{R}^{1 \times H},\ b_1 \in \mathbb{R}^{H},\ W_2 \in \mathbb{R}^{H \times 1},\ b_2 \in \mathbb{R} \;\sim\; \mathcal{N}(0, 1)
h=tanh⁡(xW1+b1),f(x)=hW2+b2,yi∼N(f(xi),σ2)h = \tanh(x W_1 + b_1), \qquad f(x) = h W_2 + b_2, \qquad y_i \sim \mathcal{N}(f(x_i), \sigma^2)

The bridge. Tuple selector lambda m: (m.W1, m.b1, m.W2, m.b2) replaces all four leaves at once. The two einsum patterns spell out the MLP’s two contractions: "n, one h -> n h" broadcasts each scalar input across the hidden dimension; "n h, h one -> n" contracts the hidden dimension and collapses the singleton output dim.

MAP via SVI. AutoDelta places a learnable point mass on every latent variable. The SVI loop optimizes those point locations to maximize the joint log⁡p(y,θ)\log p(y, \theta).

MAP MLP (H=30) R² = 0.9917
<Figure size 1200x500 with 1 Axes>

The “uncertainty band” here is just the observation noise propagated through Predictive — MAP returns a point estimate of the parameters, so all of the predictive uncertainty comes from σ, not from any spread over θ.

Model 3 — MC-Dropout MLP

Gal & Ghahramani (2016) reinterpret dropout as approximate Bayesian inference. The trick is to keep the random binary mask active at both train and predict time, then average predictions across mask draws to get a Monte-Carlo posterior-predictive distribution.

Generative model.

zij∼Bernoulli(1−pdrop),hi=tanh⁡(xiW1+b1)z_{ij} \sim \text{Bernoulli}(1 - p_{\text{drop}}), \qquad h_i = \tanh(x_i W_1 + b_1)
h~i=hi⊙zi/(1−pdrop),f(xi)=h~iW2+b2,yi∼N(f(xi),σ2)\tilde h_i = h_i \odot z_i / (1 - p_{\text{drop}}), \qquad f(x_i) = \tilde h_i W_2 + b_2, \qquad y_i \sim \mathcal{N}(f(x_i), \sigma^2)

The inverted-dropout rescaling by 1/(1−pdrop)1/(1 - p_{\text{drop}}) keeps E[h~]=h\mathbb{E}[\tilde h] = h, so no train-test rescaling is needed.

PPL note — why the mask is not a numpyro.sample site. Discrete latent variables in a model force the ELBO to enumerate them (handled by the optional funsor backend). For a binary mask of shape (N,H)(N, H) that’s 2NH2^{NH} configurations — totally unworkable. The clean alternative: treat the mask as a deterministic JAX op that consumes a per-call RNG via numpyro.prng_key(). Inside any seed / SVI / Predictive handler, numpyro.prng_key() returns a fresh key each call — so a new mask is drawn at every gradient step (the dropout regularization story) and at every predictive draw (the MC posterior-predictive story). Same Bayesian semantics, no enumeration, no extra dependency.

Train via MAP (AutoDelta + Trace_ELBO); predict via Predictive. Each predictive draw triggers a fresh model trace, which calls numpyro.prng_key(), which yields a fresh mask — so the 100 predictive samples below see 100 different masks against the same MAP-estimated weights. The spread across them is the MC-Dropout posterior-predictive uncertainty.

MC-Dropout MLP (H=30, p=0.1) R² = 0.9061
<Figure size 1200x500 with 1 Axes>

Model 4 — Bayesian MLP via NUTS

Same MLP module as Model 2 — only the inference method changes. NUTS gives the full posterior over (W1,b1,W2,b2,σ)(W_1, b_1, W_2, b_2, \sigma), not just a MAP point. Predictive draws now propagate parameter uncertainty in addition to observation noise.

NUTS over neural network weights is notoriously hard for large models, but with a small hidden dim (H=15) and 100 training points the geometry is mild enough to mix in a few seconds. The MLP module from Model 2 is reused as-is.

Bayesian MLP via NUTS (H=15) R² = 0.9945
<Figure size 1200x500 with 1 Axes>

Model 5 — SVR via Random Fourier Features

Rahimi & Recht (2007) approximate any shift-invariant kernel by a finite-dimensional inner product. For the RBF kernel with lengthscale ℓ\ell:

ωj∼N(0,ℓ−2),bj∼Uniform(0,2π),ϕ(x)j=2/D cos⁡(ωjx+bj)\omega_j \sim \mathcal{N}(0, \ell^{-2}), \qquad b_j \sim \text{Uniform}(0, 2\pi), \qquad \phi(x)_j = \sqrt{2/D}\,\cos(\omega_j x + b_j)

gives ϕ(x)⊤ϕ(x′)≈kRBF(x,x′)\phi(x)^\top \phi(x') \approx k_{\text{RBF}}(x, x'). Bayesian inference over the linear weights w∈RDw \in \mathbb{R}^D is equivalent to GP regression with the approximate kernel — at O(D3)O(D^3) cost instead of O(N3)O(N^3).

The bridge. No eqx.tree_at here — the RFF module’s frequencies are fixed, and only the linear weights ww are sampled. The forward pass is einsum(rff(x), w, "n d, d -> n").

SVR via RFF (D=40) R² = 0.9957
<Figure size 1200x500 with 1 Axes>

Model 6 — Approximate GP via hierarchical RFF

Same RFFFeatureMap as Model 5, but with a hierarchical prior on the weight amplitude α. Learning α approximates learning the GP signal-variance hyperparameter:

α∼HalfNormal(1),w∼N(0,α2ID),σ∼HalfNormal(0.1)\alpha \sim \text{HalfNormal}(1), \qquad w \sim \mathcal{N}(0, \alpha^2 I_D), \qquad \sigma \sim \text{HalfNormal}(0.1)

The tighter σ prior pushes more variance into the function (the signal) and less into noise — appropriate when we believe the data is mostly explained by the latent function.

Approx GP via hierarchical RFF (D=40) R² = 0.9962
<Figure size 1200x500 with 1 Axes>

Model 7 — Deep GP via two-layer RFF

Cutajar et al. (2017) compose two RFF layers with a learned linear map between them, mimicking a two-layer Deep GP.

Layer 1. Φ1(x)=rff1(x)∈RN×D1\Phi_1(x) = \text{rff}_1(x) \in \mathbb{R}^{N \times D_1}, h=Φ1W1∈RN×innerh = \Phi_1 W_1 \in \mathbb{R}^{N \times \text{inner}}.

Layer 2. Apply rff2\text{rff}_2 independently to each hidden dimension, average across hidden dims, then linearly project: Φ2=1inner∑jrff2(h:,j)\Phi_2 = \tfrac{1}{\text{inner}}\sum_j \text{rff}_2(h_{:,j}), f=Φ2w2f = \Phi_2 w_2.

The bridge. Tuple selector lambda m: (m.W1, m.w2) — only the learnable weights are replaced; both rff1 and rff2 modules pass through untouched, preserving their fixed random frequencies.

Deep GP via two-layer RFF (D1=15, D2=10, inner=4) R² = 0.9947
<Figure size 1200x500 with 1 Axes>

Side-by-side summary

All seven models, scored on the same held-out grid. The 1×3 panel below stacks three representative posteriors so you can compare the qualitative shape of the predictive bands.

Model                                    Inference         R²
------------------------------------------------------------
1. Bayesian linear (degree=1)            NUTS          -0.015
2. MLP via MAP                           SVI+Delta      0.992
3. MC-Dropout MLP                        SVI+Delta      0.906
4. Bayesian MLP                          NUTS           0.994
5. SVR via RFF (D=40)                    NUTS           0.996
6. Approx GP via hierarchical RFF        NUTS           0.996
7. Deep GP via two-layer RFF             NUTS           0.995
<Figure size 1800x500 with 3 Axes>

What’s next

This notebook is the raw recipe — every prior is a numpyro.sample call in the model function and every weight injection is an explicit eqx.tree_at. The two sibling notebooks show what the same seven models look like when you let pyrox carry more of the boilerplate:

  • Pattern 2 — PyroxModule + pyrox_sample. Layers self-register their priors during __call__. The model function shrinks to f = net(x); numpyro.sample("obs", ..., obs=y). Every layer in pyrox.nn._layers follows this pattern; this notebook will let you read those layers fluently.
  • Pattern 3 — Parameterized + PyroxParam. Constraints, priors, and autoguide selection become declarative class fields registered in setup(). pyrox.gp kernels (RBF, Matern, …) all use this pattern in production, and Models 6–7 swap their RFF approximations for actual pyrox.gp.GPPrior + ConditionedGP end-to-end.

The same seven models, three abstraction levels — pick the one that fits your problem.