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 2 — `PyroxModule`

Regression Masterclass — Pattern 2: PyroxModule + pyrox_sample

Open In Colab

Same seven Bayesian regression models as Pattern 1 (eqx.tree_at), rewritten on top of pyrox._core.PyroxModule. Every weight self-registers its prior inside __call__ via self.pyrox_sample(...). The model function shrinks to two lines of likelihood plumbing — and the layer reuse story shines: pyrox.nn.MCDropout and pyrox.nn.RBFFourierFeatures slot in for Models 3, 6, and 7 with no glue code.

What you’ll learn:

  1. The PyroxModule self-registration pattern: self.pyrox_sample(name, prior) inside a @pyrox_method-decorated __call__.
  2. How site scoping works (pyrox_name controls the prefix; sites become f"{pyrox_name}.{name}" inside the trace).
  3. How the Pattern 1 model functions collapse from ~10 lines to ~3 once layers self-register.
  4. How pyrox.nn.MCDropout and pyrox.nn.RBFFourierFeatures plug into a PyroxModule composite — one bonus: RBFFourierFeatures puts a prior on the spectral frequencies, so we get a richer model than Pattern 1’s fixed-frequency RFF for free.

Background — what PyroxModule adds on top of eqx.Module

Pattern 1 had a clean separation: every numpyro.sample("w", ...) call lived in the model function, and eqx.tree_at injected the result into a frozen Equinox module. This works, but the model function ends up doing a lot of bookkeeping: declare each prior, sample it, splice it into the right leaf via a (sometimes-tuple) selector, then call the module.

pyrox._core.PyroxModule collapses that bookkeeping by moving the numpyro.sample calls into the layer itself. Concretely:

class MyLayer(PyroxModule):
    pyrox_name: str = "my_layer"          # scope for site names

    @pyrox_method                          # activates the per-call cache
    def __call__(self, x):
        w = self.pyrox_sample("w", dist.Normal(0.0, 1.0).expand([D]).to_event(1))
        b = self.pyrox_sample("b", dist.Normal(0.0, 1.0))
        return x @ w + b

A few things are happening:

  • PyroxModule is an eqx.Module, so it stays a frozen JAX PyTree — no behaviour changes for vmap/jit/grad. The “weights” simply do not need to be PyTree leaves; they’re random variables drawn fresh on each call.
  • self.pyrox_sample(name, prior) is sugar for numpyro.sample(f"{scope}.{name}", prior), where scope = self.pyrox_name. The @pyrox_method decorator activates a per-call cache so two reads of self.pyrox_sample("w", ...) inside one __call__ collapse to one numpyro site.
  • pyrox_name controls the trace key. With pyrox_name="mlp", the weight site appears in the numpyro trace as "mlp.w". Two instances of the same class need distinct names, otherwise their sites collide.
  • Composition is straightforward. A composite PyroxModule whose fields are other PyroxModules simply calls them; each child registers its own sites under its own pyrox_name.

Pattern 1 → Pattern 2 in one diff

Pattern 1’s Bayesian linear model:

class LinearRegressor(eqx.Module):
    weight: jax.Array
    degree: int = eqx.field(static=True)
    def __init__(self, degree):
        self.degree = degree
        self.weight = jnp.zeros(degree + 1)
    def __call__(self, x):
        return einsum(self.features(x), self.weight, "n feat, feat -> n")

def model_linear(x, y=None, *, degree):
    net = LinearRegressor(degree=degree)
    w = numpyro.sample("w", dist.Normal(jnp.zeros(degree + 1), 1.0))
    net = eqx.tree_at(lambda m: m.weight, net, w)
    f = numpyro.deterministic("f", net(x))
    sigma = numpyro.sample("sigma", dist.HalfNormal(1.0))
    numpyro.sample("obs", dist.Normal(f, sigma), obs=y)

Pattern 2’s version — the prior moves into __call__, the placeholder leaf disappears, and the model function shrinks:

class LinearRegressor(PyroxModule):
    degree: int = eqx.field(static=True)
    pyrox_name: str = "lin"

    @pyrox_method
    def __call__(self, x):
        w = self.pyrox_sample(
            "w", dist.Normal(jnp.zeros(self.degree + 1), 1.0).to_event(1)
        )
        return einsum(self.features(x), w, "n feat, feat -> n")

def model_linear(x, y=None, *, net):
    f = numpyro.deterministic("f", net(x))
    sigma = numpyro.sample("sigma", dist.HalfNormal(1.0))
    numpyro.sample("obs", dist.Normal(f, sigma), obs=y)

The “Equinox owns architecture / NumPyro owns probability” boundary still holds — but the layer carries its own probabilistic semantics, and the model function degenerates to just the likelihood. That’s the win.

Inference is unchanged

MCMC(NUTS(model_fn)), SVI(model_fn, AutoDelta(model_fn), ...), and Predictive(model_fn, ...) all work exactly as in Pattern 1. The auto-guides automatically discover the pyrox_sample sites (they’re plain numpyro.sample sites under the hood, just with auto-scoped names). Every Bayesian story from the Pattern 1 Background section — Bayes’ rule, marginal likelihood, posterior predictive integral, NUTS, ELBO — applies verbatim.

Setup

Detect Colab and install pyrox[colab] (which transitively pulls in numpyro, equinox, einops, matplotlib, and watermark) only when running there.

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
pyrox     : 0.0.6
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

Identical to Pattern 1: noisy sinc, 100 training points in [−10,10][-10, 10], dense test grid in the same range. Same RNG seed, so the Pattern 2 fits are directly comparable to Pattern 1’s.

Training points: 100
Test points:     400

Model 1 — Bayesian linear regression

Polynomial features, Gaussian prior on the weights. The LinearRegressor is now a PyroxModule whose __call__ declares the weight prior in line. The model function loses its numpyro.sample("w", ...) and eqx.tree_at lines — what’s left is just the likelihood.

Sample sites in trace: ['f', 'lin.w', 'sigma']
Bayesian linear (degree=1) R² = -0.0171
<Figure size 1200x500 with 1 Axes>

Note the trace key: lin.w. The pyrox_name="lin" field set the scope; the name="w" argument to pyrox_sample set the leaf. Multiple instances of LinearRegressor would need distinct pyrox_names to avoid collisions.

Models 2 & 4 — MLP, MAP and full Bayesian

A single hidden-layer MLP that self-registers four weight priors plus a sample-noise prior. One class — BayesianMLP(PyroxModule) — drives both Model 2 (MAP via SVI + AutoDelta) and Model 4 (full posterior via NUTS). The only thing that changes between the two is the inference call.

This is the cleanest demonstration of the “module owns probability, not the inference loop” principle: the same Bayesian object yields a delta posterior when paired with AutoDelta and a full posterior when paired with NUTS. The prior_scale field controls weight-prior tightness — small values regularize, large values free up the network.

Model 2 — MAP. AutoDelta(model_nnet) constructs a delta guide over every sample site, including the mlp.W1 / mlp.b1 / etc. ones registered by BayesianMLP. SVI then maximizes the joint log-density.

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

Model 4 — full Bayesian via NUTS. Same module, fresh instance with a smaller hidden dim (NUTS over a hundred-parameter MLP is the limit of what mixes well in a few seconds). The inference call is the only difference from Model 2.

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

Two BayesianMLP instances with distinct pyrox_names coexist in the trace because their site keys are namespaced (mlp_map.W1 vs mlp_nuts.W1). Reuse without surgery.

Model 3 — MC-Dropout MLP via pyrox.nn.MCDropout

Pattern 1 had to write its own dropout layer that called numpyro.prng_key() inside the model function. Pattern 2 reuses pyrox.nn.MCDropout directly — it is a plain eqx.Module that takes an explicit key argument, and our BayesianMLPDropout pulls a fresh key per trace via numpyro.prng_key(). Composition is the only new code.

Generative model (same as Pattern 1):

zij∼Bernoulli(1−pdrop),h~i=hi⊙zi/(1−pdrop),yi∼N(h~iW2+b2, σ2).z_{ij} \sim \text{Bernoulli}(1 - p_{\text{drop}}), \qquad \tilde h_i = h_i \odot z_i / (1 - p_{\text{drop}}), \qquad y_i \sim \mathcal{N}(\tilde h_i W_2 + b_2,\, \sigma^2).
MC-Dropout MLP (H=30, p=0.1) R² = 0.9081
<Figure size 1200x500 with 1 Axes>

pyrox.nn.MCDropout is not a PyroxModule — it’s a plain eqx.Module with a key kwarg, because dropout’s randomness has nothing to do with priors. Drawing the key from numpyro.prng_key() plugs it into the trace machinery without making the mask itself a sample site.

Model 5 — SVR via Random Fourier Features (fresh PyroxModule)

A from-scratch RFF feature map with fixed spectral frequencies (matching Pattern 1), wrapped as a PyroxModule. Only the linear weights are sampled; the frequencies ω and phases bb are deterministic constants stored as jax.Array fields.

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

Notice the model function is the same model_linear we wrote for Model 1: any PyroxModule whose __call__ returns a (N,) mean fits this generic likelihood-only template. That is the boilerplate-collapse story made concrete.

Model 6 — Approximate GP via pyrox.nn.RBFFourierFeatures

Time to lean on the package. pyrox.nn.RBFFourierFeatures is a PyroxModule whose __call__ self-registers two priors:

  • W∼N(0,I)W \sim \mathcal{N}(0, I) for the spectral frequencies (this is the RBF spectral density),
  • ℓ∼LogNormal(log⁡ℓ0,1)\ell \sim \text{LogNormal}(\log \ell_0, 1) for the lengthscale.

So our Pattern 2 Model 6 is richer than Pattern 1’s: where Pattern 1 fixed both ω and ℓ\ell, this version learns posteriors over both, alongside the amplitude and linear weights. We add the amplitude prior + linear weights in our composite, and the package layer handles everything underneath.

Output shape note. RBFFourierFeatures returns the concatenated [cos⁡(xW/ℓ),sin⁡(xW/ℓ)][\cos(xW/\ell), \sin(xW/\ell)] basis with 2×n_features2 \times \texttt{n\_features} columns, so the linear weight vector has length 2×n_features2 \times \texttt{n\_features}.

Approx GP via packaged RFF R² = 0.9978
Sample sites in trace: ['f', 'gp_rff.amplitude', 'gp_rff.rff.W', 'gp_rff.rff.lengthscale', 'gp_rff.w', 'sigma']
<Figure size 1200x500 with 1 Axes>

Inspect the trace keys printed above: gp_rff.rff.W, gp_rff.rff.lengthscale, gp_rff.amplitude, gp_rff.w, sigma. The dotted scoping comes from the nested pyrox_names — the inner layer’s pyrox_name="gp_rff.rff" includes the outer scope as a prefix because we set it that way at construction. (Nothing magical: it’s just a string.)

Model 7 — Deep GP via two stacked pyrox.nn.RBFFourierFeatures

Same Cutajar et al. (2017) deep-RFF architecture as Pattern 1, but now both RFF layers are package classes that learn their own WW and ℓ\ell. The inter-layer linear map W1W_1 and the output linear weights w2w_2 are this composite’s own pyrox_sample sites.

Implementation note. Pattern 1’s deep model called rff2 inside a Python for j in range(inner_dim): loop — fine when rff2 had no priors. With the packaged RBFFourierFeatures that does sample sites, calling it inner_dim\text{inner\_dim} times in a loop would register the same site name inner_dim\text{inner\_dim} times and crash. The fix is to call rff2 once with a batched input of shape (N, inner_dim, 1), then mean-reduce over the inner_dim axis:

Deep GP via stacked packaged RFF R² = 0.9977
<Figure size 1200x500 with 1 Axes>

Side-by-side summary

All seven models. The 1×3 panel below stacks three representative posteriors so the qualitative shape of the predictive bands is visible.

Model                                      Inference        R²
--------------------------------------------------------------
1. Bayesian linear (degree=1)              NUTS         -0.017
2. MAP MLP                                 SVI+Delta     0.992
3. MC-Dropout MLP (MCDropout)              SVI+Delta     0.908
4. Bayesian MLP                            NUTS          0.994
5. SVR via fixed RFF (D=40)                NUTS          0.996
6. Approx GP (RBFFourierFeatures)          NUTS          0.998
7. Deep GP (stacked RBFFourierFeatures)    NUTS          0.998
<Figure size 1800x500 with 3 Axes>

Pattern 1 vs Pattern 2 — what changed

AspectPattern 1 (tree_at)Pattern 2 (PyroxModule)
Where priors liveIn the model function as numpyro.sample callsInside __call__ as self.pyrox_sample calls
Bridge into the moduleeqx.tree_at(selector, net, w)None — the layer is the trace site
Model function size~10 lines per model~3 lines (just the likelihood)
Layer reusabilityDefine a fresh eqx.Module each timeSame BayesianMLP instance (with a unique pyrox_name) drives MAP + NUTS
Reuse of pyrox.nnNoneMCDropout, RBFFourierFeatures slot in via composition
Site namingFlat ("W1", "w", ...)Scoped ("mlp.W1", "gp_rff.rff.W", ...)

Inference machinery is unchanged: MCMC(NUTS(model_fn)), SVI(...), Predictive(...) work identically against both patterns.

What’s next

  • Pattern 3 — Parameterized + PyroxParam. Constraints, priors, and autoguide selection become declarative class fields registered in setup(). Models 6–7 swap their RFF approximations for the actual exact-GP primitives (pyrox.gp.GPPrior + ConditionedGP) — that’s where the abstraction replaces the workaround. See regression_masterclass_parameterized.ipynb once it lands.