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.

Batched SVGP

Batched SVGP — the main reason you’d use one

The first notebook in this series built the standard SVGP scaffold on N=500N = 500 observations. That’s small enough that every optimiser step used the full dataset. The whole point of SVGP, though, is to scale — and the mechanism is mini-batch training against an unbiased ELBO estimator.

This notebook stays inside the exact same pyrox + optax scaffold as notebook 1, flips one switch — replace the full dataset in each gradient step with a randomly sampled mini-batch — and shows the consequence on wallclock, convergence, and the final posterior.

Why SVGP mini-batches at all

The ELBO decomposes additively over data

Every SVGP ELBO (Gaussian or otherwise) has the structure

L=∑n=1NEq(fn) ⁣[log⁡p(yn∣fn)]⏟ELL — sum over data  −  KL(q(u)  ∥  p(u))⏟fixed, cost O(M3).\mathcal{L} = \underbrace{\sum_{n=1}^{N} \mathbb{E}_{q(f_n)}\!\bigl[\log p(y_n \mid f_n)\bigr]}_{\text{ELL — sum over data}} \;-\; \underbrace{\mathrm{KL}\bigl(q(u) \;\|\; p(u)\bigr)}_{\text{fixed, cost } \mathcal{O}(M^3)}.

The first term is a sum over independent data points. So if we pick a uniform random subset B⊂{1,…,N}\mathcal{B} \subset \{1, \dots, N\} of size BB, then the unbiased mini-batch ELBO is

L~(B)=NB∑n∈BEq(fn) ⁣[log⁡p(yn∣fn)]  −  KL(q(u)  ∥  p(u)),\tilde{\mathcal{L}}(\mathcal{B}) = \frac{N}{B}\sum_{n \in \mathcal{B}} \mathbb{E}_{q(f_n)}\!\bigl[\log p(y_n \mid f_n)\bigr] \;-\; \mathrm{KL}\bigl(q(u) \;\|\; p(u)\bigr),

with EB[L~]=L\mathbb{E}_\mathcal{B}[\tilde{\mathcal{L}}] = \mathcal{L}. Stochastic gradients of L~\tilde{\mathcal{L}} are unbiased estimates of the gradients of L\mathcal{L}, and optax.adam handles the variance.

Cost per step

For a single SVGP ELBO evaluation, the dominant terms are

  • O(BM2)\mathcal{O}(B M^2) for the cross-covariance KxZK_{xZ} and the predictive contraction.
  • O(M3)\mathcal{O}(M^3) for the inducing-prior Cholesky (shared across all batches in a step, not scaling with BB).

Full-batch cost is O(NM2+M3)\mathcal{O}(N M^2 + M^3). Mini-batch cost is O(BM2+M3)\mathcal{O}(B M^2 + M^3). Once N≫BN \gg B the speedup approaches N/BN / B — the point of the exercise.

What doesn’t change

The KL term, the inducing inputs, the variational Cholesky — they all sit on the “model side” of the ELBO and are identical to the full-batch version. The only bookkeeping is (i) scale the ELL by N/BN / B, (ii) sample a different batch each step.

Setup

/anaconda/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm

A bigger dataset

N=10 000N = 10\,000 points drawn from a 2-D function with a sharp central feature and smoother tails — enough data that full-batch gradient steps start to feel sluggish, but small enough that we can still run the full-batch baseline as a correctness check.

<Figure size 550x450 with 2 Axes>

Kernel + likelihood + guide

Same RBFLite pattern from notebook 1 and a TrainableGaussianLikelihood wrapper so the noise variance stays positive during optimisation. M=50M = 50 inducing points on a 10×510 \times 5 grid.

The mini-batch ELBO — scale the ELL, not the KL

pyrox.gp.svgp_elbo returns ELL_batch − KL. To turn that into an unbiased estimator of the full-data ELBO from a batch of size BB, multiply the ELL by N/BN/B but leave the KL alone. Algebra:

L~=NB ELLB−KL=NB(svgp_elboB+KL)−KL=NB svgp_elboB+(NB−1)KL.\tilde{\mathcal{L}} = \frac{N}{B}\,\mathrm{ELL}_{\mathcal{B}} - \mathrm{KL} = \frac{N}{B}\bigl(\text{svgp\_elbo}_{\mathcal{B}} + \mathrm{KL}\bigr) - \mathrm{KL} = \frac{N}{B}\,\text{svgp\_elbo}_{\mathcal{B}} + \Bigl(\frac{N}{B} - 1\Bigr)\mathrm{KL}.

The KL is exposed on guide.kl_divergence(K_zz_op), so we can recover it independently.

Baseline — full-batch training

We JIT a step, spin for n_steps_full = 300 and record wallclock.

full batch (10,000 pts)   |  300 steps  |  15.31 s  |  51.0 ms/step

Mini-batch training

Sample a fixed-size batch of indices per step via jr.randint (with replacement — statistically almost identical to without-replacement for B≪NB \ll N and avoids a dynamic choice that would recompile). B = 200 — 50× smaller than NN, should give a comparable per-step speedup.

mini-batch (200 pts)    |  15000 steps  |  60.04 s  |  4.00 ms/step

Per-step speedup

A single mini-batch step should be much faster than a full-batch step. At B=200B = 200 and N=10 000N = 10\,000 the theoretical cost ratio is ~50×; actual hardware behaviour depends on whether the GPU is memory-bound or compute-bound at these sizes.

per-step speedup:  12.8×   (theoretical ceiling ≈ 50.0×)

Convergence curves

Plot loss vs wallclock, not step — step is the wrong axis for this comparison. Mini-batch does many more steps than full-batch in the same wallclock budget, which is exactly the point.

<Figure size 1300x420 with 2 Axes>

The left panel is the honest comparison. The right is the illusion: the mini-batch curve looks noisier per “step” because each step uses 50× less data, so the stochastic gradient has ~50\sqrt{50}× more variance. The stochasticity is a feature of the estimator, not a fit quality problem — the optimiser still converges in expectation.

Posterior agreement

Full and mini-batch should arrive at the same posterior (up to stochastic noise and the extra training steps). Evaluate both on the 60×6060 \times 60 grid and map.

grid RMSE vs truth — full batch : 0.0386
grid RMSE vs truth — mini-batch : 0.0350
grid RMSE mini ↔ full disagreement: 0.0446
<Figure size 1400x420 with 6 Axes>

Batch-size sweep — where does the speedup saturate?

A common practical question: if B=200B = 200 gives a ∼50×\sim 50\times per-step speedup, does B=20B = 20 give 500×500\times? Answer in general: no — GPU dispatch overhead dominates below a certain batch size, so there’s a regime where smaller batches don’t help. Timing sweep below.

B =    32     1.94 ms/step
B =    64     1.74 ms/step
B =   128     3.82 ms/step
B =   256    31.44 ms/step
B =   512    18.52 ms/step
B =  1024    35.41 ms/step
B =  2048    39.59 ms/step
B =  5000    45.52 ms/step
B = 10000 (= N)    67.47 ms/step
<Figure size 800x400 with 1 Axes>

Two readable regimes: a flat region at small BB where per-step cost is dominated by kernel/guide overhead and the O(M3)\mathcal{O}(M^3) inducing Cholesky, and a rising region where O(BM2)\mathcal{O}(B M^2) starts to matter and the curve approaches the full-batch cost. The inflection sits around B≈500B \approx 500 at M=50M = 50 on this hardware; the right batch size is “a bit smaller than the inflection” — enough to amortise the overhead but not so large that you’re paying the full-batch rate.

Summary

  • pyrox.gp.svgp_elbo is a pure JAX function of (prior, guide, lik, X, y) — hand it a batch instead of the whole dataset, rescale the ELL by N/BN/B, and you have an unbiased ELBO estimator.
  • The KL term is computed separately from guide.kl_divergence(K_zz_op) so it can be held out of the ELL rescaling — the clean decomposition is ÑL̂ = (N/B) · svgp_elbo_batch + (N/B − 1) · KL.
  • Per-step wallclock drops roughly proportionally to B/NB/N until GPU dispatch overhead takes over at small BB. The sweet spot sits an order of magnitude below the inflection point of that curve.
  • Mini-batch SGD arrives at the same posterior as full-batch training to within stochastic noise — the noisier loss curve is not a fit quality problem.

Next: notebook 3 keeps the point-inducing + whitened-guide scaffold but replaces Z∈RM×DZ \in \mathbb{R}^{M \times D} with a fixed spherical-harmonic basis, collapsing the O(M3)\mathcal{O}(M^3) inducing Cholesky to O(M)\mathcal{O}(M).