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.

Latent GP classification

Latent GP Classification — the Three Patterns

Open In Colab

For non-conjugate likelihoods (Bernoulli, Poisson, StudentT, …) the GP latent function cannot be marginalized analytically — we need gp_sample inside the NumPyro model. This notebook walks the three pyrox patterns through a 2D binary-classification problem.

What you’ll learn:

  1. Use gp_sample to register the latent GP function as a sample site under an arbitrary observation likelihood.
  2. Compose it with a Bernoulli(logits=f) observation model.
  3. Fit kernel hyperparameters with SVI across all three patterns.

Background

Joint model

For a non-conjugate likelihood p(y∣f)p(y \mid f), the hierarchical model is

θ∼p(θ),f∣X,θ∼N ⁣(0,KXX(θ)),yi∣fi∼Bernoulli ⁣(σ(fi)),\theta \sim p(\theta), \quad f \mid X, \theta \sim \mathcal{N}\!\bigl(0, K_{XX}(\theta)\bigr), \quad y_i \mid f_i \sim \mathrm{Bernoulli}\!\bigl(\sigma(f_i)\bigr),

where σ(z)=1/(1+e−z)\sigma(z) = 1 / (1 + e^{-z}) is the logistic link. The joint factorizes as

p(θ,f,y∣X)=p(θ) p(f∣X,θ) ∏i=1Np(yi∣fi).p(\theta, f, y \mid X) = p(\theta)\, p(f \mid X, \theta)\, \prod_{i=1}^{N} p(y_i \mid f_i).

Because the likelihood is not Gaussian, the marginal p(y∣X,θ)=∫p(y∣f) p(f∣X,θ) dfp(y \mid X, \theta) = \int p(y \mid f)\, p(f \mid X, \theta)\, df has no closed form — we can’t collapse ff out the way we did for regression.

Variational inference + the whitening trick

We approximate p(θ,f∣X,y)p(\theta, f \mid X, y) by a factorized variational distribution and maximize the evidence lower bound

L(ϕ)=Eqϕ ⁣[log⁡p(y∣f)]−KL ⁣(qϕ(θ,f) ∥ p(θ,f∣X)).\mathcal{L}(\phi) = \mathbb{E}_{q_\phi}\!\bigl[\log p(y \mid f)\bigr] - \mathrm{KL}\!\bigl(q_\phi(\theta, f) \,\|\, p(\theta, f \mid X)\bigr).

A naive parameterization places qϕ(f)q_\phi(f) directly over the latent values at the training inputs. Under NumPyro’s AutoNormal this becomes a mean-field Gaussian — independent across the NN training latents. That is a terrible approximation here, because the prior p(f∣X,θ)=N(0,KXX)p(f \mid X, \theta) = \mathcal{N}(0, K_{XX}) has strong off-diagonal correlations and the optimum of the ELBO ends up dominated by that KL, not by the data fit. In practice the posterior over ff barely moves and the predictive decision boundary is essentially noise.

The standard fix is the whitening reparameterization (Murray & Adams, 2010; Hensman et al., 2015). Cholesky-factor the prior covariance once,

KXX(θ)+ϵI=LL⊤,u∼N(0,IN),f=Lu,K_{XX}(\theta) + \epsilon I = L L^\top, \qquad u \sim \mathcal{N}(0, I_N), \qquad f = L u,

so the latent enters the likelihood through a deterministic transformation of an i.i.d. unit-Gaussian site. Now mean-field qϕ(u)q_\phi(u) is well-conditioned — there are no a-priori correlations between the uiu_i to begin with — and the GP correlations come back into ff for free through LL. Empirically this is the difference between a chaotic boundary and one that tracks the data.

gp_sample modes — when to reach for which

pyrox exposes gp_sample("f", prior, *, whitened, guide) as the NumPyro-aware registration for the latent function. The three modes are mutually exclusive:

  • default (whitened=False, guide=None) — registers a single N(μ(X),KXX+ϵI)\mathcal{N}(\mu(X), K_{XX} + \epsilon I) sample site via gaussx.MultivariateNormal. The right call for MCMC (NUTS samples directly in the original ff-space and handles the prior covariance correctly) and for conjugate workflows where you marginalize ff out via gp_factor.
  • whitened (whitened=True) — registers an NN-dimensional unit-Gaussian site u∼N(0,I)u \sim \mathcal{N}(0, I) and returns the deterministic f=μ(X)+Luf = \mu(X) + L u shown above. The right call for SVI on non-conjugate likelihoods with NumPyro auto-guides such as AutoNormal. This is what every pattern in this notebook uses.
  • guide (guide=...) — delegates to a concrete sparse variational guide (FullRankGuide, MeanFieldGuide, or WhitenedGuide over inducing values), which is the right call for the sparse SVGP workflow when NN is too large for an NN-dimensional latent.

Posterior predictive

For a test input x∗x_* the posterior predictive is

p(y∗=1∣x∗,D)=∫σ(f∗) p(f∗∣x∗,D) df∗,p(y_* = 1 \mid x_*, \mathcal{D}) = \int \sigma(f_*)\, p(f_* \mid x_*, \mathcal{D})\, df_*,

which we estimate by Monte Carlo + the logistic-Gaussian approximation further below.

The three patterns

PatternKernel hyperparameters live inWhen to reach for it
APure eqx.Module + numpyro.sample + eqx.tree_atLightweight; no base class required.
BCustom PyroxModule kernel that calls pyrox_sample in __call__Self-contained probabilistic kernel.
CParameterized kernel (shipped RBF, Matern, …) with set_priorFull registry, constraints, autoguides, modes.

Only the kernel construction differs across patterns — the whitening boilerplate and the Bernoulli likelihood are shared via a small helper.

Setup

Reproducibility readout.

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
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 2D binary dataset

Two interleaved half-moons — the boundary between them is the function the latent GP has to learn.

Training points: 40
Grid points:     1600
<Figure size 600x500 with 1 Axes>

The common model skeleton

All three patterns share the same whitened latent + Bernoulli likelihood — the only thing that changes is how kernel is built (which is the whole point of comparing the patterns):

prior = GPPrior(kernel, X, jitter=1e-4)   # kernel built per-pattern
f     = gp_sample("f", prior, whitened=True)
        # samples u ~ N(0, I_N) and returns the deterministic
        # f = mu(X) + L u with L = chol(K + jitter*I)
obs   ~ Bernoulli(logits=f)

gp_sample(..., whitened=True) is the model-layer entry point that registers the unit-Gaussian site as "f_u" and the unwhitened function value as the deterministic "f". Each pattern’s model function then reads as “build the kernel, then run the standard whitened-classification pipeline.”

Pattern A — pure Equinox + eqx.tree_at

Pattern B — PyroxModule kernel with pyrox_sample

Pattern C — Parameterized kernel with set_prior

Fit all three with the same SVI loop

pattern      variance  lengthscale
------------------------------------
A               4.707        0.704
B               4.070        0.699
C               4.326        0.767
<Figure size 1200x500 with 1 Axes>

Decision boundaries — MC-averaged posterior predictive

We want the Bayesian posterior predictive

p(y∗=1∣x∗,D)=∬σ(f∗) p(f∗∣x∗,f,θ) q(θ,u) df∗ du dθ,p(y_* = 1 \mid x_*, \mathcal{D}) = \iint \sigma(f_*)\, p(f_* \mid x_*, f, \theta)\, q(\theta, u)\, df_*\, du\, d\theta,

where q(θ,u)q(\theta, u) is the SVI guide posterior over the kernel hyperparameters and the whitened latent, and p(f∗∣x∗,f,θ)p(f_* \mid x_*, f, \theta) is the standard noise-free GP conditional

p(f∗∣x∗,f,θ)=N ⁣(μ∗,σ∗2),μ∗=k∗⊤(KXX+ϵI)−1f,σ∗2=k∗∗−k∗⊤(KXX+ϵI)−1k∗,p(f_* \mid x_*, f, \theta) = \mathcal{N}\!\bigl(\mu_*, \sigma_*^2\bigr), \qquad \mu_* = k_*^\top\bigl(K_{XX} + \epsilon I\bigr)^{-1} f, \qquad \sigma_*^2 = k_{**} - k_*^\top\bigl(K_{XX} + \epsilon I\bigr)^{-1} k_*,

with k∗=K(x∗,X)k_* = K(x_*, X) and k∗∗=K(x∗,x∗)k_{**} = K(x_*, x_*). ε is the same jitter used inside latent_f.

We estimate the outer integral by Monte Carlo and the inner integral by the logistic-Gaussian approximation

∫σ(f∗) N(f∗∣μ∗,σ∗2) df∗  ≈  σ(κ(σ∗2) μ∗),κ(v)=(1+πv/8)−1/2,\int \sigma(f_*)\, \mathcal{N}(f_* \mid \mu_*, \sigma_*^2)\, df_* \;\approx\; \sigma\bigl(\kappa(\sigma_*^2)\, \mu_*\bigr), \qquad \kappa(v) = \bigl(1 + \pi v / 8\bigr)^{-1/2},

which is exact for the probit link and within a percent for the logistic link that we actually use (MacKay, 1992).

Putting it together — for each MC sample ss:

  1. Draw (θs,us)∼qϕ(\theta_s, u_s) \sim q_\phi from the SVI guide.
  2. Reconstruct the training-time latent fs=L(θs) usf_s = L(\theta_s)\, u_s with the matching Cholesky.
  3. Build the GP prior at θs\theta_s and condition on fsf_s to get (μ∗,s,σ∗,s2)(\mu_{*, s}, \sigma_{*, s}^2) at the grid points.
  4. Apply the logistic-Gaussian approximation to get per-sample class probabilities.

Average:

p(y∗=1∣x∗,D)  ≈  1S∑s=1Sσ ⁣(κ(σ∗,s2) μ∗,s).p(y_* = 1 \mid x_*, \mathcal{D}) \;\approx\; \tfrac{1}{S}\sum_{s=1}^{S} \sigma\!\bigl(\kappa(\sigma_{*, s}^2)\, \mu_{*, s}\bigr).

This uses the actual fitted Bernoulli model — no Gaussian-likelihood stand-in anywhere.

<Figure size 1800x500 with 4 Axes>

When to use which

The non-conjugate story is exactly the conjugate story with gp_factor swapped for gp_sample + an explicit likelihood. The pattern choice is still purely ergonomic:

  • Pattern A — lightest; sample hyperparameters at the model level and splice into a pure Equinox kernel. Good for one-off Bayesian extensions.
  • Pattern B — custom PyroxModule kernel that owns its priors. Good when the kernel is a reusable probabilistic building block.
  • Pattern C — full registry via Parameterized. Use the shipped RBF, Matern, etc., and attach priors / autoguides / modes declaratively.

Switch the likelihood, change nothing else — Bernoulli(logits=f) becomes Poisson(rate=jnp.exp(f)) for counts, StudentT(...) for robust regression, and so on.