Skip to content

fix(kernels): parameterise the spectral measure by lengthscale and dimension - #702

Merged
thomaspinder merged 3 commits into
mainfrom
fix/612-spectral-density-parameterisation
Jul 26, 2026
Merged

fix(kernels): parameterise the spectral measure by lengthscale and dimension#702
thomaspinder merged 3 commits into
mainfrom
fix/612-spectral-density-parameterisation

Conversation

@thomaspinder

Copy link
Copy Markdown
Owner

Checklist

  • I've formatted the new code by running uv run poe format before committing.
  • I've added tests for new code.
  • I've added docstrings for the new code.

Description

Fixes the spectral density reported by @ahwillia in #612.

StationaryKernel.spectral_density returned a standardised distribution — Normal(0, 1) for RBF, StudentT(2ν, 0, 1) for Matérn — that ignored the lengthscale and was hard-coded to one dimension. Every lengthscale gave the same curve, which is exactly the superimposed plot in the issue.

Why RFF still worked

BasisFunctionComputation.compute_features divided the sampled frequencies by the lengthscale, and scaling() applied the variance:

scaling_factor = _val(kernel.base_kernel.lengthscale)
z = jnp.matmul(x, (frequencies / scaling_factor).T)

So the approximation compensated downstream and was numerically correct. That compensation is what kept the bug latent — the only in-repo consumer of spectral_density was the one place that cancelled the error out. Anyone using the property directly got a wrong answer silently.

The fix

The measure is now D-dimensional and carries diag(ℓ)⁻¹ as its scale (ARD lengthscales included), and the compensating division is removed from compute_features. It satisfies Bochner's theorem

$$k(\tau) = \sigma^2 , \mathbb{E}_{p(\omega)}\big[e^{i \omega^\top \tau}\big]$$

The variance deliberately stays out of the measure: σ² is its total mass, which normalisation divides out, and it re-enters as the explicit prefactor above. A numpyro Distribution cannot represent the unnormalised S(ω) — log_prob is normalised by construction — so the docstring pins the relation to R&W §4.2.1 instead:

$$S(\omega) = \sigma^2 (2\pi)^D p(\omega), \qquad k(\tau) = (2\pi)^{-D}!\int S(\omega) e^{i\omega^\top\tau},d\omega$$

I verified that formula reproduces R&W's closed form for the RBF in 1-D and 3-D.

Compatibility

RFF Gram and cross-covariance values are bit-identical to before. Scaling by a diagonal matrix is exactly elementwise division, so the frequencies are unchanged — test_rff_effective_frequencies_unchanged asserts jnp.array_equal against the old two-step route across dimensions and lengthscales. The lengthscale simply moved from the feature map into the measure it is drawn from.

Two API changes, both noted in the CHANGELOG:

  • spectral_density now returns MultivariateNormal / MultivariateStudentT with event_shape == (D,). Callers doing .sample(key, (M, D)) should use .sample(key, (M,)).
  • RFF with an explicitly supplied frequencies= now treats them as the frequencies ω directly. They were previously divided by the lengthscale, silently rescaling user-supplied values — arguably a second latent bug.

Tests

New tests/test_kernels/test_spectral_density.py (92 cases). The load-bearing one is a Monte-Carlo Bochner check across RBF/Matérn12/32/52 × D ∈ {1,2,3} × ℓ ∈ {0.5,1,2} × σ² ∈ {1,3} — it pins the parameterisation without reference to any Fourier normalisation convention, so it constrains the measure independently of how it's implemented. Plus the reporter's reproducer, dimensionality, ARD anisotropy, and variance-independence.

uv run poe all-tests: 2582 passed. (The one failure in my working tree is test_root_good picking up an untracked local scratch .md in the repo root — unrelated, and not present in CI.)

Follow-ups this unblocks

Issue Number: #612

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bj9k5fnAZ8JzD4Rg3HMDMj

…mension

`StationaryKernel.spectral_density` returned a standardised distribution —
`Normal(0, 1)` for RBF, `StudentT(2v, 0, 1)` for Matern — that ignored the
lengthscale and was hard-coded to one dimension. Every lengthscale produced
the same curve, so `kernel.spectral_density.log_prob(w)` was wrong for any
direct consumer (closes #612).

RFF was not affected: `BasisFunctionComputation.compute_features` divided the
sampled frequencies by the lengthscale and `scaling()` applied the variance,
so the approximation compensated downstream. That compensation is what hid
the bug, and it is now unnecessary.

The measure is D-dimensional and carries diag(l)^-1 as its scale, honouring
ARD lengthscales, and satisfies Bochner's theorem k(t) = s^2 E_p(w)[e^{i w.t}].
The variance stays out of it: sigma^2 is the measure's total mass, which
normalisation divides out, and it re-enters as the explicit prefactor.

Because scaling by a diagonal matrix is exactly elementwise division, sampled
frequencies are bit-identical to before — verified across dimensions and
lengthscales in `test_rff_effective_frequencies_unchanged`. The isotropic-vs-
tensor-product branch in `RFF.__init__` collapses, since MVN and MVT now both
arrive correctly shaped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj9k5fnAZ8JzD4Rg3HMDMj
@thomaspinder
thomaspinder merged commit f977a89 into main Jul 26, 2026
19 checks passed
@thomaspinder thomaspinder mentioned this pull request Jul 26, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant