Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4e8f8d3
test(oracle): pin conjugate MLL, predict, and LOOCV to closed form
thomaspinder Aug 5, 2026
0ff8f2d
test(equivalence): pin cross-derivation agreement; xfail two-owner ji…
thomaspinder Aug 5, 2026
d195be8
test(integration): fail loudly when golden values drift
thomaspinder Aug 5, 2026
5d96ee2
test(integration): re-pin golden values to current-main behaviour
thomaspinder Aug 5, 2026
880c58f
feat(linalg): stabilised_cholesky — single stabilise-and-factor seam
thomaspinder Aug 5, 2026
5814aa3
feat(dataset): static n_total metadata; get_batch stamps full size
thomaspinder Aug 5, 2026
8c7ba10
feat(likelihoods)!: pure conditional families — drop num_datapoints a…
thomaspinder Aug 5, 2026
9b9a43c
feat!: v1.0 conditioning architecture — JointModel, Posterior, condition
thomaspinder Aug 5, 2026
b5c9884
refactor!: rename sweep — tests, benchmarks, examples onto the v1.0 API
thomaspinder Aug 6, 2026
1ce4ca1
style: ruff format after the rename sweep
thomaspinder Aug 6, 2026
f113932
docs: v1.0 migration guide, CONTEXT.md glossary, ADR-0001
thomaspinder Aug 6, 2026
bb44a85
docs: self-contained migration snippets
thomaspinder Aug 6, 2026
78debf3
test(equivalence): pin predict/MLL same-matrix consistency at non-def…
thomaspinder Aug 6, 2026
72a7212
docs: fix Sphinx build for the v1.0 API
thomaspinder Aug 6, 2026
84a133e
feat(natural-gradients): add fit_natgrads and exponential-family mach…
thomaspinder Jul 26, 2026
12fa290
fix(natural-gradients): address review findings on PR#1
thomaspinder Jul 26, 2026
bc74e69
refactor(natgrads): adapt to the v1.0 conditioning API
thomaspinder Aug 6, 2026
d53c808
docs(fit): replace Markdown code fences with RST doctest block in fit…
thomaspinder Aug 6, 2026
ada0f1d
refactor(variational)!: remove NaturalVariationalGaussian and Expecta…
thomaspinder Jul 26, 2026
2410564
fix(variational): address PR#2 review findings
thomaspinder Jul 26, 2026
21c4ec0
refactor(natgrads): adapt vestigial-family removal to the v1.0 API
thomaspinder Aug 6, 2026
d8cb621
feat(variational): add DualVariationalGaussian and dual_elbo (t-SVGP)
thomaspinder Jul 27, 2026
df14c6e
fix(variational): address PR#3 review findings
thomaspinder Jul 27, 2026
0fbe545
refactor(natgrads): adapt DualVariationalGaussian and dual_elbo to th…
thomaspinder Aug 6, 2026
413f03c
docs(examples): add natural gradients tutorial notebook
thomaspinder Jul 27, 2026
c5a2edd
docs(examples): address natgrads notebook review findings
thomaspinder Jul 27, 2026
1c0555e
refactor(natgrads): adapt natural-gradients notebook to the v1.0 API
thomaspinder Aug 6, 2026
6799c39
docs(examples): add dual/t-SVGP tutorial notebook and wire docs nav
thomaspinder Jul 27, 2026
40f8985
docs(examples): address dual notebook review findings
thomaspinder Jul 27, 2026
6060a74
fix(examples): avoid markdown-katex crash in the dual notebook
thomaspinder Jul 27, 2026
3337bec
fix(natural-gradients): address whole-stack review findings
thomaspinder Jul 27, 2026
c6d2bce
refactor(natgrads): adapt dual notebook to the v1.0 API
thomaspinder Aug 6, 2026
3547786
ci(security): scan every PR, not only those targeting main
thomaspinder Aug 7, 2026
8bc89d3
ci(security): exclude TruffleHog's Lob detector, which flags pytest n…
thomaspinder Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .github/workflows/security-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ name: "Security Analysis"
on:
push:
branches: [ "main" ]
# Deliberately unfiltered: the two jobs below are required contexts on `main`,
# and a `branches: [main]` filter here meant they only ever reported on PRs
# that already targeted `main`. Stacked PRs — which target their predecessor,
# not `main` — produced 16 checks instead of 18, so the whole of a stack got
# reviewed and approved without ever being secret- or dependency-scanned; the
# gate first fired at retarget time, after review. Scan the code, not the
# target branch.
pull_request:
branches: [ "main" ]
schedule:
- cron: '30 4 * * 1' # Weekly on Mondays at 4:30 AM UTC

Expand Down Expand Up @@ -75,4 +81,18 @@ jobs:
uses: trufflesecurity/trufflehog@main
with:
path: ./
extra_args: --debug --only-verified
# `--exclude-detectors=lob`: TruffleHog's Lob key pattern is
# `(live|test)_` followed by 35 more characters, which matches any
# pytest function name that happens to be exactly 40 characters long —
# and its verifier then reports those as *verified*, so `--only-verified`
# does not filter them out. Four real examples in this repo:
# test_fit_natgrads_accepts_optax_schedule,
# test_dual_natgrad_matches_salimbeni_step,
# test_dual_working_matrices_reconstruct_r,
# test_prior_kl_matches_textbook_reference.
# Reproduced standalone: a fresh repo containing only one of those names
# yields `Lob verified=true`. Since `Secrets Detection` is a required
# context on `main`, leaving this on means an arbitrary, length-dependent
# subset of PRs can never merge. GPJax has no Lob (direct-mail API)
# integration, so excluding the detector costs no real coverage.
extra_args: --debug --only-verified --exclude-detectors=lob
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`gpjax.fit_natgrads` and the `gpjax.natural_gradients` module.** Trains a variational
family by alternating one natural-gradient step on the variational distribution with
one step of a supplied Optax optimiser on everything else — kernel and likelihood
hyperparameters, the mean function, and the inducing inputs — following Salimbeni,
Eleftheriadis and Hensman (2018), [arXiv:1803.09151](https://arxiv.org/abs/1803.09151).
`VariationalGaussian` and `WhitenedVariationalGaussian` are supported. For a conjugate
model on the full batch, `natgrad_lr=1.0` reaches the optimal `q` in one iteration.

- **`DualVariationalGaussian` and `gpjax.objectives.dual_elbo`.** The dual (t-SVGP)
parameterisation of Adam, Chang, Khan and Solin (2021),
[arXiv:2111.03412](https://arxiv.org/abs/2111.03412). Instead of the moments of
`q(u)`, the family stores an unnormalised Gaussian *site* on the centred inducing
outputs — `dual_vector` is the site's first natural parameter and `dual_matrix` its
precision — from which `q(u)` is recovered through the working matrix
`R = Kzz + Kzz Lambda_2 Kzz`. Because the stored coordinates are an affine image of
the natural parameters, a natural-gradient step is a convex combination of the
current sites with a closed-form target, so no expectation-to-natural round trip is
needed and the KL is never differentiated. `fit_natgrads` dispatches on the family
and takes that step; the step size means the same thing in both branches, and from
the same starting `q` the dual and Salimbeni E-steps produce identical iterates —
provided the dual branch's computed per-point curvature stays non-negative, so that
its `beta_floor` never engages. That holds for a genuinely log-concave likelihood;
GPJax's `inv_probit` clips its probabilities away from 0 and 1, which breaks
log-concavity in the far tails, and there the two branches diverge. The
dual branch restricts the step size to the interval from zero to one, since the
update is a convex combination.
`dual_elbo` has the same *value* as `elbo` at the implied moments, for any sites and
any hyperparameters, but a different *hyperparameter gradient from `elbo` evaluated on
the matched `VariationalGaussian`*: the prior part of `q` tracks the kernel while the
data-dependent sites stay frozen. The difference is between the two
parameterisations, not between the two functions — calling `elbo` directly on a
`DualVariationalGaussian` returns the same value and the same gradients as
`dual_elbo`, which is simply the batched-marginals fast path for that family. That
frozen-site gradient is what gives the M-step its reported behaviour, so `Kzz` must
not be detached and the implied moments must not be cached on the family.
`DualVariationalGaussian` also works with plain `gpjax.fit`, where it is ordinary
gradient descent in the dual coordinates. The `VariationalParametrisationSuite` ASV
benchmark gains a `dual` axis value.

### Removed

- **`NaturalVariationalGaussian` and `ExpectationVariationalGaussian`.** These were
parameterisation-only classes with no optimiser attached: they stored the natural or
expectation coordinates of `q(u)` but offered no way to take a natural-gradient step
in them. Natural-gradient geometry belongs to the optimiser — the Fisher matrix *is*
the Jacobian dη/dθ, so a natural-gradient step in the natural parameters θ is exactly
an ordinary gradient step in the expectation parameters η, and either coordinate system
can be recovered on the fly from whatever the family happens to store. `fit_natgrads`
therefore operates directly on `VariationalGaussian` and `WhitenedVariationalGaussian`,
which store constraint-respecting coordinates. Users of the removed classes should
switch to `VariationalGaussian` with `gpjax.fit_natgrads`.
The `VariationalParametrisationSuite` ASV benchmark loses its `natural` and
`expectation` axis values; previously recorded results for those two arms are orphaned.

### Fixed

- **`Zero` mean function is trainable and drifts away from zero.** Fitting a
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,18 @@ Functions `(model, Dataset) -> scalar`:
- `conjugate_mll` / `conjugate_loocv` -- for `ConjugatePosterior`
- `log_posterior_density` (alias `non_conjugate_mll`) -- for `NonConjugatePosterior`
- `elbo` / `collapsed_elbo` -- for variational families
- `dual_elbo` -- for `DualVariationalGaussian` (t-SVGP; same value as `elbo`, different hyperparameter gradient)
- `heteroscedastic_elbo` -- for heteroscedastic models

Optimise by negating: `nmll = lambda p, d: -conjugate_mll(p, d)`

### Fitting (`gpjax/fit.py`)

Three optimisers: `fit()` (Optax gradient descent with scan), `fit_scipy()` (SciPy L-BFGS-B), `fit_lbfgs()` (Optax L-BFGS with `while_loop`). All handle the constrained/unconstrained bijection automatically: `paramax.unwrap(model)` is called inside the loss function, and `eqx.partition`/`eqx.combine` with `eqx.is_array` manage trainable vs static parts.
Four optimisers: `fit()` (Optax gradient descent with scan), `fit_scipy()` (SciPy L-BFGS-B), `fit_lbfgs()` (Optax L-BFGS with `while_loop`), `fit_natgrads()` (natural-gradient steps on a variational family, alternated with Optax steps on the hyperparameters). All handle the constrained/unconstrained bijection automatically: `paramax.unwrap(model)` is called inside the loss function, and `eqx.partition`/`eqx.combine` with `eqx.is_array` manage trainable vs static parts.

### Variational inference (`gpjax/variational_families.py`)

`VariationalGaussian`, `WhitenedVariationalGaussian`, `NaturalVariationalGaussian`, `ExpectationVariationalGaussian`, `CollapsedVariationalGaussian`, `GraphVariationalGaussian`, `HeteroscedasticVariationalFamily`. All inherit from `AbstractVariationalFamily` and implement `predict()` + `prior_kl()`.
`VariationalGaussian`, `WhitenedVariationalGaussian`, `DualVariationalGaussian`, `CollapsedVariationalGaussian`, `GraphVariationalGaussian`, `HeteroscedasticVariationalFamily`. All inherit from `AbstractVariationalFamily` and implement `predict()` + `prior_kl()`.

### NumPyro integration (`gpjax/numpyro_extras.py`)

Expand Down
62 changes: 62 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# GPJax domain glossary

The ubiquitous language of this codebase. Code, docs, tests, and reviews use
these terms exactly; when a concept is missing, add it here in the same PR
that introduces it.

## Design principle

**Maths-first, comprehensible to the maths-familiar non-expert.** API names
follow the textbook equation unless the textbook word is jargon a
practitioner would not own. When the two conflict, choose the word a
scikit-learn/PyMC user already speaks and state the maths once in the
docstring (e.g. the class is `JointModel`, and its docstring says "the joint
distribution p(f, y)").

## Terms

**Prior** — the Gaussian process prior p(f), pairing a kernel with a mean
function. Queryable at any inputs: `prior(x)` returns the prior predictive.
Owns the model's single numerical-stabilisation knob, `jitter`.

**Likelihood** — the conditional distribution p(y | f). A *pure* conditional:
it holds no priors, no dataset facts, and no data sizes. (Both were tried and
removed at v1.0 — see ADR-0001.)

**JointModel** — the joint distribution p(f, y) = p(y | f) · p(f), created by
`prior * likelihood`. The *trainable* object: `gpx.fit` optimises its
hyperparameters. It carries state that must exist before conditioning
(hyperparameters; the non-conjugate latent; the heteroscedastic noise-process
model) and no derived quantities. Concrete kinds: `ConjugateModel`,
`NonConjugateModel`, `HeteroscedasticModel` — each holds state the others
cannot.

**condition** — the operation p(f, y) + 𝒟 → p(f | 𝒟), spelled
`model.condition(D)` or the operator form `model | D` (read: "f given D").
Signatures follow the maths: models take data; already-fit variational
families will condition without arguments (post-natgrads stack PR).

**Posterior** — the conditioned process p(f | 𝒟) returned by `condition`. An
*immutable* pytree: the training-covariance factorisation is computed once and
cached; every query is a view of it. The uniform query surface:
`posterior(xtest, covariance="dense"|"diagonal")`, plus per-mode views —
`log_marginal_likelihood` / `loo` / `sample_approx` on the exact mode,
`log_posterior_density` on the latent mode. Users never name the concrete
implementations behind the interface.

**evidence / log marginal likelihood** — p(𝒟), the normalising constant of
conditioning, exposed as `posterior.log_marginal_likelihood`. "Evidence" and
"marginal likelihood" are the same quantity; the attribute uses the
GP-community's term.

**sugar** — a documented one-line composition kept for ergonomics, never a
second implementation. `model.predict(x, D)` and `model(x, D)` are sugar for
`model.condition(D)(x)`; `model | D` is sugar for `model.condition(D)`.

**objective** — a scalar function `(model, Dataset) -> ScalarFloat` consumed
by `gpx.fit`. Objectives are thin: `conjugate_mll` is the evidence view of
the conditioned posterior, not a second derivation.

**Dataset** — the data container. `n_total` records the full-dataset size
when the object is a minibatch view (stamped by `get_batch`); the minibatch
ELBO scale is derived from it, never supplied by hand.
2 changes: 1 addition & 1 deletion benchmarks/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def setup(self):
kernel = gpx.kernels.RBF()
mean = gpx.mean_functions.Zero()
prior = gpx.gps.Prior(kernel=kernel, mean_function=mean)
likelihood = gpx.likelihoods.Gaussian(num_datapoints=n)
likelihood = gpx.likelihoods.Gaussian()
self.posterior = prior * likelihood
self.q = VariationalGaussian(
posterior=self.posterior, inducing_inputs=X[:M_INDUCING]
Expand Down
35 changes: 21 additions & 14 deletions benchmarks/objectives.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
both M (inducing count) and batch_size; VfeElboSuite (collapsed analytic
ELBO) varies M only — the collapsed objective requires the full dataset.

VariationalParametrisationSuite compares the four variational Gaussian
parameterisations (standard, whitened, natural, expectation) at one
fixed (n, M) so users can see the per-step cost of the choice.
VariationalParametrisationSuite compares the three variational Gaussian
parameterisations (standard, whitened, dual) at one fixed (n, M) so users
can see the per-step cost of the choice.

HeteroscedasticElboSuite and OilmmPredictSuite are independent — they
do not participate in the alignment because their model structure
Expand All @@ -26,9 +26,8 @@
from gpjax.likelihoods import HeteroscedasticGaussian
from gpjax.variational_families import (
CollapsedVariationalGaussian,
ExpectationVariationalGaussian,
DualVariationalGaussian,
HeteroscedasticVariationalFamily,
NaturalVariationalGaussian,
VariationalGaussian,
WhitenedVariationalGaussian,
)
Expand All @@ -50,7 +49,7 @@ def _conjugate_posterior(n: int):
kernel = gpx.kernels.RBF()
mean = gpx.mean_functions.Zero()
prior = gpx.gps.Prior(kernel=kernel, mean_function=mean)
likelihood = gpx.likelihoods.Gaussian(num_datapoints=n)
likelihood = gpx.likelihoods.Gaussian()
return prior * likelihood, data


Expand Down Expand Up @@ -117,17 +116,25 @@ def time_collapsed_elbo(self, n, m):
_VARIATIONAL_FAMILIES = {
"standard": VariationalGaussian,
"whitened": WhitenedVariationalGaussian,
"natural": NaturalVariationalGaussian,
"expectation": ExpectationVariationalGaussian,
"dual": DualVariationalGaussian,
}


class VariationalParametrisationSuite:
"""Per-step ELBO cost across the four variational Gaussian families.

All four parameterise the same q(u); the differences are in how the
KL term and predictive moments are computed. Holding (n, M) fixed
isolates the parameterisation cost.
"""Per-step ELBO cost across the variational Gaussian parameterisations.

All three parameterise the same q(u); the differences are in how the
KL term and predictive moments are computed. The dual (t-SVGP) family
stores sites rather than moments and recovers q(u) through
R = Kzz + Kzz Lambda_2 Kzz, so every predict and every KL factorises
both Kzz and R, against one factorisation for the standard family and
none in the whitened KL. Holding (n, M) fixed isolates that cost.

The arm deliberately times the generic ``elbo`` on all three families
rather than ``dual_elbo`` on the dual one: the point of the comparison
is the parameterisation, so the objective has to be held fixed. The
dual family's own fast path is ``dual_elbo``, which replaces the
per-point ``predict`` with one batched ``marginals`` call.
"""

params = (list(_VARIATIONAL_FAMILIES),)
Expand Down Expand Up @@ -157,7 +164,7 @@ def setup(self, n):
noise_prior = gpx.gps.Prior(
kernel=noise_kernel, mean_function=gpx.mean_functions.Zero()
)
likelihood = HeteroscedasticGaussian(num_datapoints=n, noise_prior=noise_prior)
likelihood = HeteroscedasticGaussian(noise_prior=noise_prior)
posterior = signal_prior * likelihood
Z = data.X[:M_INDUCING]
self.q = HeteroscedasticVariationalFamily(
Expand Down
8 changes: 3 additions & 5 deletions benchmarks/state_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import gpjax as gpx
from gpjax.state_space import StateSpacePrior, state_space_mll
from gpjax.state_space.gps import StateSpaceConjugatePosterior
from gpjax.state_space.gps import StateSpaceConjugateModel
import jax.numpy as jnp
import jax.random as jr

Expand All @@ -35,10 +35,8 @@ def setup(self, n):
mean_function=gpx.mean_functions.Zero(),
kernel=gpx.kernels.Matern32(lengthscale=1.0, variance=1.0),
)
likelihood = gpx.likelihoods.Gaussian(num_datapoints=n, obs_stddev=0.1)
self.posterior = StateSpaceConjugatePosterior(
prior=prior, likelihood=likelihood
)
likelihood = gpx.likelihoods.Gaussian(obs_stddev=0.1)
self.posterior = StateSpaceConjugateModel(prior=prior, likelihood=likelihood)
self.data = _temporal_dataset(n)
realise(state_space_mll(self.posterior, self.data))

Expand Down
Loading
Loading