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 3 — `Parameterized`

Regression Masterclass — Pattern 3: Parameterized + PyroxParam + native pyrox.gp

Open In Colab

Third and highest-abstraction sibling to Pattern 1 (eqx.tree_at) and Pattern 2 (PyroxModule + pyrox_sample). Now every parameter is a declarative class field — registered with self.register_param(name, init, constraint=...), attached to a prior with self.set_prior(name, dist), optionally wrapped in an autoguide via self.autoguide(name, "delta" | "normal"). The forward pass reads self.get_param(name) and Parameterized resolves it correctly based on the current set_mode("model" | "guide").

More importantly, Model 6 drops the RFF approximation entirely and uses the actual exact GP from pyrox.gp — RBF(_ParameterizedKernel) + GPPrior + ConditionedGP + gp_factor. Once you accept the higher-level abstraction, the workaround is unnecessary.

What you’ll learn:

  1. The Parameterized lifecycle: register_param → set_prior → get_param. Constraints and priors travel together.
  2. How pyrox.gp kernels (RBF, Matern, …) use this exact pattern in production.
  3. How to fit GP hyperparameters with SVI + AutoNormal, then condition on observations and predict — the canonical pyrox.gp end-to-end recipe.
  4. Where the abstraction reaches its current limit: deep / hierarchical GPs aren’t yet shipped natively, so Model 7 stays a Parameterized RFF stack and we file the gap.

Background — Parameterized is “PyroxModule + a parameter registry”

Pattern 2 collapsed Pattern 1’s model-function plumbing by moving numpyro.sample calls inside the layer’s __call__. Pattern 3 collapses the prior wiring by moving constraint + prior declarations to a separate setup() method, called automatically after __init__.

Concretely, Parameterized is a PyroxModule subclass that maintains a per-instance registry of:

  • the parameter’s initial value (used as the unconstrained-space init for guides, or as the constant value if no prior is set),
  • the parameter’s constraint (a numpyro.distributions.constraints object — positive, unit_interval, etc. — used to build the bijector that maps unconstrained guide samples back to the prior’s support),
  • the parameter’s prior (a numpyro.distributions.Distribution — set via set_prior),
  • the parameter’s autoguide type ("delta" for MAP, "normal" for mean-field; set via autoguide).

At call time, self.get_param(name) dispatches:

  • mode = "model" (default) + prior is set ⇒ numpyro.sample(name, prior) — same as Pattern 2.
  • mode = "model" + no prior ⇒ numpyro.param(name, init, constraint=...) — a learnable constant.
  • mode = "guide" + autoguide="delta" ⇒ numpyro.param(name, init, constraint=...) — point estimate.
  • mode = "guide" + autoguide="normal" ⇒ mean-field guide q(θ)=T(N(μθ,σθ2))q(\theta) = T(\mathcal{N}(\mu_\theta, \sigma_\theta^2)) where TT is the constraint’s bijector — guide draws always land in the prior’s support.

This is the pattern every shipped pyrox.gp kernel uses. Reading RBF is a five-line affair:

class RBF(_ParameterizedKernel):
    pyrox_name: str = "RBF"
    init_variance: float = 1.0
    init_lengthscale: float = 1.0

    def setup(self) -> None:
        self.register_param("variance", jnp.asarray(self.init_variance),
                            constraint=dist.constraints.positive)
        self.register_param("lengthscale", jnp.asarray(self.init_lengthscale),
                            constraint=dist.constraints.positive)

    @pyrox_method
    def __call__(self, X1, X2):
        return rbf_kernel(X1, X2, self.get_param("variance"),
                          self.get_param("lengthscale"))

The user attaches priors and chooses guide types from the outside:

k = RBF()
k.set_prior("variance", dist.LogNormal(0.0, 1.0))
k.set_prior("lengthscale", dist.LogNormal(0.0, 1.0))
# default autoguide is delta (i.e. MAP); switch with k.autoguide("variance", "normal")

This is the level of abstraction the rest of the notebook is written at. Models 1-5 mirror their pattern 1/2 counterparts — same architectures, same data, same priors — but rewritten so every parameter is registered up front. Model 6 throws out the approximation and just calls pyrox.gp.GPPrior + ConditionedGP.

Setup

Detect Colab and install pyrox[colab] (transitive: numpyro, equinox, einops, matplotlib, watermark).

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
gaussx    : 0.0.10
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 Patterns 1 and 2 (same RNG seed): noisy sinc, 100 training points in [−10,10][-10, 10], dense test grid in the same range. R²s are directly comparable across the three notebooks.

Training points: 100
Test points:     400

Model 1 — Bayesian linear regression

The Parameterized translation of Pattern 1’s LinearRegressor. Note the new setup() method: register_param declares the leaf, set_prior attaches the Gaussian prior, and __call__ reads the resolved value via get_param.

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

Models 2 & 4 — MLP, MAP and full Bayesian

The BayesianMLP(Parameterized) declares all four weight leaves in setup() with their Gaussian priors. As in Pattern 2, the same module class drives MAP (Model 2) via SVI + AutoDelta and the full posterior (Model 4) via NUTS — only the inference call changes. Distinct pyrox_names namespace the two instances’ trace keys.

Model 2 — MAP via SVI + AutoDelta.

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

Model 4 — full Bayesian via NUTS. Same BayesianMLP class, fresh instance with smaller hidden dim, NUTS instead of SVI.

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

Model 3 — MC-Dropout MLP

The dropout mask still uses numpyro.prng_key() for fresh-per-trace randomness — same Pattern 2 trick. The weights are now Parameterized registered fields.

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

Model 5 — SVR via fixed Random Fourier Features

Fixed spectral frequencies ω and phases bb stored as plain jax.Array fields; the linear weights w are the only registered parameter. Same model semantics as the Pattern 2 fixed-RFF, just declarative.

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

Model 6 — Exact GP via pyrox.gp.GPPrior + RBF + ConditionedGP

This is the abstraction-replaces-workaround moment. Patterns 1 and 2 used Random Fourier Features to approximate a Gaussian process — invented because exact GP inference is O(N3)O(N^3) and was historically too expensive for large NN. With N=100N = 100 training points, O(N3)=106O(N^3) = 10^6 — instant on any laptop. The shipped pyrox.gp.GPPrior + ConditionedGP does the exact GP regression in closed form.

The math. With kernel kθk_\theta, training set (X,y)(X, y), and Gaussian observation noise σ2\sigma^2:

Marginal likelihood (used for hyperparameter inference):

log⁡p(y∣X,θ,σ2)=−12y⊤(K+σ2I)−1y−12log⁡∣K+σ2I∣−N2log⁡(2π).\log p(y \mid X, \theta, \sigma^2) = -\tfrac{1}{2} y^\top (K + \sigma^2 I)^{-1} y - \tfrac{1}{2}\log\lvert K + \sigma^2 I\rvert - \tfrac{N}{2}\log(2\pi).

Posterior predictive at test inputs X∗X_*:

μ∗(X∗)=K∗X(K+σ2I)−1y,Σ∗(X∗)=K∗∗−K∗X(K+σ2I)−1KX∗.\mu_*(X_*) = K_{*X}(K + \sigma^2 I)^{-1} y, \qquad \Sigma_*(X_*) = K_{**} - K_{*X}(K + \sigma^2 I)^{-1} K_{X*}.

gp_factor("obs", prior, y, noise_var) registers the marginal log-likelihood with NumPyro as a numpyro.factor; prior.condition(y, noise_var).predict(X_star) returns (μ∗,diag(Σ∗))(\mu_*, \text{diag}(\Sigma_*)).

The recipe. Three steps:

  1. Build an RBF kernel; attach LogNormal(0, 1) priors to its variance and lengthscale parameters.
  2. Wrap in GPPrior(kernel=..., X=X); register the marginal log-likelihood with gp_factor.
  3. Fit hyperparameters with SVI + AutoNormal. At predict time, instantiate a fresh RBF at the posterior-mean hyperparameters, build a fresh GPPrior, and call .condition(y, noise_var).predict(X_star).
Fitted hyperparameters: variance=0.3165, lengthscale=2.5362
Exact GP via pyrox.gp R² = 0.9978
<Figure size 1200x500 with 1 Axes>

Compare what just happened against Pattern 2’s Model 6: the RFF approximation needed an RBFFourierFeatures layer + an amplitude prior + a linear weight prior + NUTS over ∼60\sim 60 parameters. The native GP needed one prior per hyperparameter and 400 SVI steps over 2 unknowns (variance + lengthscale). The fit is tighter, faster, and better-calibrated — and the code is shorter.

Model 7 — Deep RFF GP (gap call-out)

Pattern 2’s Model 7 stacked two pyrox.nn.RBFFourierFeatures. Pattern 3’s Model 7 should be a “deep GP via two stacked pyrox.gp.GPPrior instances” — but pyrox.gp does not yet ship native deep / hierarchical GP composition primitives. So we fall back to a Parameterized RFF stack and explicitly flag the gap.

What’s missing. A DeepGPPrior(layers=[...]) that composes Cutajar et al. (2017)-style stacked GP layers with the right inter-layer Gaussian-noise jitter and exposes a unified marginal-log-likelihood + predictive interface. Filed (or to be filed) as a follow-up gp(deep) feature issue.

Deep RFF (Parameterized fallback) R² = 0.9976
<Figure size 1200x500 with 1 Axes>

Side-by-side summary

All seven models. The 1×3 panel below stacks the MAP MLP, the Bayesian-MLP via NUTS, and the new exact-GP — note how much tighter the GP’s predictive band is than any RFF approximation in Patterns 1 or 2.

Model                                            Inference         R²
----------------------------------------------------------------------
1. Bayesian linear (degree=1)                    NUTS          -0.017
2. MAP MLP                                       SVI+Delta      0.992
3. MC-Dropout MLP                                SVI+Delta      0.908
4. Bayesian MLP                                  NUTS           0.994
5. SVR via fixed RFF (D=40)                      NUTS           0.996
6. Exact GP (pyrox.gp.GPPrior + RBF)             SVI+Normal     0.998
7. Deep RFF (Parameterized — gap)                NUTS           0.998
<Figure size 1800x500 with 3 Axes>

All three patterns, side-by-side

AspectPattern 1 (tree_at)Pattern 2 (PyroxModule)Pattern 3 (Parameterized)
Where parameters are declaredAs bare eqx.Module fieldsAs bare eqx.Module fieldsIn a setup() method via register_param
Where priors liveIn the model functionInside __call__ (self.pyrox_sample)In setup() (self.set_prior)
Constraint handlingManual (use LogNormal instead of Normal etc.)ManualBuilt-in (constraint=dist.constraints.positive) — guide bijectors are auto-applied
Mode switching (model vs guide)None (write a guide separately)None (auto-guides discover sites)set_mode("model" | "guide") flips get_param behaviour
Bridge into the moduleeqx.tree_at(selector, net, w)NoneNone
Reuse storyRe-define each timeUse pyrox.nn.{MCDropout, RBFFourierFeatures}Use all of pyrox.gp (kernels, GPPrior, ConditionedGP, gp_factor)
What Model 6 looks likeHierarchical RFF + amplitude prior, NUTS over ∼41\sim 41 paramsPackaged RBFFourierFeatures + amplitude, NUTS over ∼41\sim 41 paramsNative exact GP — SVI over 2 hyperparameters, closed-form posterior

Inference machinery (MCMC(NUTS(...)), SVI(...), Predictive(...), gp_factor) is shared across all three. Pattern 3 is what pyrox.gp is built around; Patterns 1 and 2 are what you write before you reach for the package.

Where to go from here

  • Other pyrox.gp kernels. Matern(nu=1.5/2.5), Periodic, Linear, RationalQuadratic, Polynomial, Cosine, White, Constant — all Parameterized, all drop into GPPrior the same way. Compose with + / * (when combinator support lands).
  • Sparse GPs at scale. pyrox.gp.SparseGPPrior + svgp_factor for N≫104N \gg 10^4.
  • Non-Gaussian likelihoods. Swap gp_factor for gp_sample + a non-Gaussian numpyro.sample("obs", ..., obs=y) — see latent_gp_classification.ipynb.
  • Native deep GPs. Open follow-up issue.