Add O(J^2)-per-test-point predictive variance from the QSM Cholesky - #272
Open
dfm wants to merge 2 commits into
Open
Add O(J^2)-per-test-point predictive variance from the QSM Cholesky#272dfm wants to merge 2 commits into
dfm wants to merge 2 commits into
Conversation
dfm
force-pushed
the
qsm-fast-predict
branch
from
September 9, 2026 19:05
f9a5038 to
5c36a5e
Compare
`Solver.condition` now takes the unresolved kernel (`None` meaning "the
kernel this solver was built with", a signal that survives `jax.jit`
where object identity does not), the test coordinates, the test noise,
and `alpha = K^{-1} (y - mean)`, and returns a `ConditionedComponents`
bundle with the conditional kernel, the conditional mean evaluated at
the test points, and the conditioned process's solver. The producing
solver builds that solver itself, so solver-specific settings such as
`parallel` carry over without special cases in `GaussianProcess`, and
`GaussianProcess` accepts an already constructed solver instance.
`GaussianProcess.predict` is now a thin wrapper around `condition`:
under `jax.jit`, XLA eliminates the unused `N_test x N_test` covariance
and its Cholesky factorization, so the separate `Solver.condition_diag`
hook and the mean-only shortcuts are removed. The generic dense
implementation lives in `tinygp.solvers.direct.dense_condition`, which
evaluates the cross covariance block once and passes the variance to
the resulting `DirectSolver` explicitly (a `variance=` keyword) so that
the full matrix stays dead code when only the variance is read.
Also:
- `kernels.Conditioned` gains a batched `__call__` so that evaluating
the full conditional covariance costs two triangular solves rather
than one per pair of points.
- `means.Conditioned.include_mean` is a static field; previously it was
a traced leaf, which broke conditioning a conditioned process under
`jax.jit` with the default `include_mean=True`.
- The default jitter for the conditioned process is derived from the
data dtype rather than the parent's mean dtype.
Breaking for third-party `Solver` subclasses: `condition` has a new
signature and return type, and `condition_diag` no longer exists.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBWVoYn1nqDHDaF1K89Vrd
When a `QuasisepSolver` conditions at new test points with the kernel it was built with, the predictive variance is now computed in O(J^2) per test point (after two O(N J^2) train-only scans) by reusing the quasiseparable Cholesky factorization, instead of a dense O(M^2 N) conditional covariance. The predictive mean was already scalable via `kernel.matmul(X_test, X_train, alpha)` and is unchanged. The conditioned `GaussianProcess` gets a `LazyDirectSolver`, so its dense covariance is only built (and factorized) when `covariance`, `sample`, or `log_probability` are explicitly requested. The variance uses the same per-point cross-covariance row generators as the rectangular quasiseparable product, factored out of `Quasisep.to_general_qsm` into `Quasisep.anchor`, so there is a single anchoring convention. Every propagation runs forward in time, which keeps the result stable across wide training gaps (the naive form, which pulls a test point back across its gap with an inverse transition, overflows for gaps of a few dozen correlation lengths). `anchor` also evaluates masked transitions at a clamped coordinate, so gradients at far extrapolation stay finite; this fixes a latent NaN gradient in `kernel.matmul` for extrapolating test points. `ops.cholesky` and `ops.cholesky_parallel` now also return the inclusive Riccati carry, and the backward congruence recursion shared with `symm_inv_parallel` lives in `ops.congruence_scan`. Noise models with their own quasiseparable states (e.g. `Banded`) enlarge the factorized matrix beyond the kernel's order, so those cases take the dense path, as they do on `DirectSolver`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBWVoYn1nqDHDaF1K89Vrd
dfm
force-pushed
the
qsm-fast-predict
branch
from
September 10, 2026 01:35
5c36a5e to
5222c16
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #282, which should land first; the diff here is the two commits on top of it.
Summary
When a
QuasisepSolverconditions at new test points with the kernel it was built with (kernel=None, the default), the predictive variance is now computed in O(J²) per test point (after two O(N J²) train-only scans) by reusing the quasiseparable Cholesky factorization, instead of a dense O(M² N) conditional covariance. The predictive mean was already scalable viakernel.matmul(X_test, X_train, alpha)and is unchanged. The conditionedGaussianProcessgets aLazyDirectSolver, so its dense covariance is only built (and factorized) whencovariance,sample, orlog_probabilityare explicitly requested.This comes in two commits.
1. Refactor the solver conditioning interface around a single hook
Solver.conditionnow takes the unresolved kernel (Nonemeaning "the kernel this solver was built with", a signal that survivesjax.jitwhere object identity does not), the test coordinates, the test noise, andalpha = K^{-1} (y - mean), and returns aConditionedComponentsbundle with the conditional kernel, the conditional mean at the test points, and the conditioned process's solver. The producing solver builds that solver itself, so settings such asparallelcarry over without special cases inGaussianProcess(this subsumes theparallelforwarding in #282'sgp.py), andGaussianProcessaccepts an already constructed solver instance.GaussianProcess.predictis now a thin wrapper aroundcondition. Underjax.jit, XLA eliminates the unusedN_test x N_testcovariance and its Cholesky factorization, so the separateSolver.condition_diaghook from #281 and the mean-only shortcuts are removed. (Verified from compiled HLO: on the pre-#281 commit, jittedpredict(return_var=True)already compiled to zero M x M buffers and zeropotrfcalls for both solvers.) The generic dense implementation lives intinygp.solvers.direct.dense_condition, which evaluates the cross covariance block once and passes the variance to the resultingDirectSolverexplicitly, so the full matrix stays dead code when only the variance is read.Also:
kernels.Conditionedgains a batched__call__;means.Conditioned.include_meanis a static field (it was a traced leaf, which broke conditioning a conditioned process underjax.jit); the default jitter for the conditioned process follows the data dtype.Breaking for third-party
Solversubclasses:conditionhas a new signature and return type,condition_diagno longer exists, and solvers must exposekernelandX.2. Add the O(J²)-per-test-point predictive variance
The variance uses the same per-point cross-covariance row generators as the rectangular quasiseparable product, factored out of
Quasisep.to_general_qsmintoQuasisep.anchor, so there is a single anchoring convention. Every propagation runs forward in time, which keeps the result stable across wide training gaps (a formulation that pulls a test point back across its gap with an inverse transition overflows for gaps of a few dozen correlation lengths).anchorevaluates masked transitions at a clamped coordinate, so gradients at far extrapolation stay finite; this also fixes a latent NaN gradient inkernel.matmulfor extrapolating test points.ops.choleskyandops.cholesky_parallelalso return the inclusive Riccati carry, and the backward congruence recursion shared withsymm_inv_parallellives inops.congruence_scan. Noise models with their own quasiseparable states (e.g.Banded) enlarge the factorized matrix beyond the kernel's order, so those cases take the dense path.Bugs fixed along the way
gp.condition(y).gp.condition(y2, X_test)) failed onmainwith the defaultinclude_mean=True.main'sQuasisepSolverdense fallback omitted the test noise from the conditional covariance (inconsistent withDirectSolverand with its ownvariance).kernel.matmulat far extrapolation.Verification
f64[M,M]buffers /potrf/ XLA temp memory for jittedpredict(return_var=True):DirectSolver0 / 0 / 11.5 MB;QuasisepSolver0 / 0 / 0.3 MB (25.3 MB with N x M buffers before this change).mainforDirectSolveracrossX_test,kernel=,include_mean,diag/noise,return_var/return_cov, and mixed dtypes;QuasisepSolvermatchesDirectSolverto <1e-8 everywhere, including chained conditioning under jit withparallel=True.🤖 Generated with Claude Code
https://claude.ai/code/session_01HBWVoYn1nqDHDaF1K89Vrd