Kernel regularization: opt-in Cholesky-solve evidence term + relative jitter - #437
Conversation
The kernel regularization schemes build `H = coefficient * C^-1` as an explicit dense inverse. On clustered mesh vertices (the kNN families' traced vertices, cond(C) ~ 1e9) that inverse carries round-off amplified by cond(C), which then enters the Bayesian evidence through the regularization term `s^T H s`. For these schemes the term is `coefficient * s^T C^-1 s`, obtainable from a single Cholesky *solve* against `s` rather than by forming `C^-1` and contracting it — one triangular solve instead of N, and accurate to machine precision. Measured on a clustered fixture at cond(C) = 3.2e9: relative error 5.99e-08 (explicit) vs 2.93e-16 (implicit). This is the `s^T H s` counterpart of the log-det shortcut shipped in #391, and follows its shape exactly: a `regularization_term_from` hook on `AbstractRegularization` returning `None` by default, overridden by the four kernel schemes, consulted by `AbstractInversion.regularization_term` behind a new opt-in `Settings.regularization_term_method` ("matmul" default, "cho_solve" opt-in). Default evidence values are unchanged, preserving comparability with archived results. The setting is deliberately separate from `log_det_method` so the two evidence terms can be moved onto their exact factorizations independently, which is what makes an evidence shift attributable to one term rather than both. Scope note: this does not remove the explicit inverse from the inversion as a whole. `curvature_reg_matrix` is a dense `F + H` feeding the dense solve for the reconstruction, so `H` is still formed there; only the evidence terms avoid it. Notes on the per-scheme overrides: - `MaternAdaptKernel` needs its own override rather than inheriting `MaternKernel`'s: it passes `coefficient=0.0` to the parent (its adaptive weights live inside the covariance), so the inherited form would zero the term. Pinned by a regression test. - `GaussianKernel`'s shortcut is the term of the analytic `coefficient * C^-1`, excluding the symmetrisation and trace-scaled jitter its formed matrix carries — consistent with how its log-det shortcut already behaves, since both exist only to guard a factorization this path avoids. - Schemes with no factorization (`Constant`, `Adapt`, the split families) return `None`, and one `None` makes the whole computation fall back to the formed matrix, so mixed inversions stay correct. Tests: 9 new cases in test_kernel_regularization_term.py covering shortcut == formed matrix for all four schemes, the gate in both directions, the fallback, the MaternAdapt zero-coefficient trap, and the ill-conditioning gain graded against an exactly-known reference (`s = C v` makes `C^-1 s = v`). Full inversion suite green: 244 passed, 9 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KazMzMZYPLfaZoYQ79YQ8Q
Leg 2b. The kernel covariance jitter is a fixed absolute `1e-8 * I`. That is
only meaningful when the covariance diagonal is itself ~1, which holds for the
unweighted kernels (K(0) == 1) but NOT for `MaternAdaptKernel`, whose
`C_ii = w_i^2` spans the adaptive-weight dynamic range.
Measured on a 40-pixel adaptive fixture, faintest pixel's relative distortion
from the jitter:
inner/outer C_ii (faintest) distortion
1.0 / 1.0 1.0e+00 1.0e-08
0.5 / 4.0 3.9e-03 2.6e-06
1.0 / 20.0 6.3e-06 1.6e-03
0.1 / 100.0 1.0e-08 1.0e+00 <- 100% of that pixel's variance
`inner_coefficient` and `outer_coefficient` are free model parameters, so a
sampler can walk into the bottom row mid-fit and silently destroy the kernel
structure of the faintest pixels.
Fix: `jitter_relative=True` applies `jitter * diag(diag(C))`, i.e.
`C_ii *= (1 + jitter)`. Writing `C = D^(1/2) R D^(1/2)` for the correlation
matrix R, this is exactly `D^(1/2) (R + jitter I) D^(1/2)` — the jitter lands on
the correlation matrix, so every pixel gets the same relative protection
whatever its scale.
Two properties verified rather than assumed:
- It does not weaken the Cholesky protection. On clustered (traced) vertices at
nu=2.5 the conditioning is unchanged (3.16e9 both conventions). An earlier
candidate rule (jitter = N * eps * max(diag), "as small as possible") was
rejected for exactly this: it let cond(C) reach 3.2e15, at the edge of
float64, reintroducing the noise leg 2 exists to remove.
- On the three unweighted schemes the two conventions agree to ~1e-8, since
their diagonal is ~1 by construction.
Default is `False` everywhere, byte-identical to previous behaviour, so
archived evidence values are unchanged. Threaded through all three covariance
call sites per scheme (matrix, log-det shortcut, term shortcut), not just the
constructor — pinned by a test.
Tests: 7 new cases in test_kernel_jitter_relative.py. Full suite 917 passed,
58 skipped (3 pre-existing pynufft failures unrelated, missing optional dep).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazMzMZYPLfaZoYQ79YQ8Q
Closes the JAX leg of this work's gate at library level. The workspace script `autolens_workspace_test/scripts/imaging/jax_grad/regularization.py` certifies the same surface end-to-end, but it lives in another repo and needs the whole autolens stack; these cover the code this branch actually adds. Measured: FD certification d(s^T C^-1 s)/d(scale) rel diff 4.6e-09 eager vs jit 3.6e-15 implicit vs explicit inverse (JAX) 1.1e-13 numpy vs jax parity 8.4e-12 Covers both jitter conventions under `jit` (the `jitter_relative` branch must resolve at trace time, not on a tracer), gradients through the scheme-level `regularization_term_from` hook, and FD certification with relative jitter on. Matern is deliberately not covered: its JAX path needs the modified Bessel from `tfp-nightly`, an optional-of-an-optional. Gaussian and Exponential exercise the same shared `apply_jitter` / `quadratic_form_via_cholesky` code without it. Skipped via importorskip when JAX is absent. Full suite with JAX installed: 936 passed, 52 skipped (3 pre-existing pynufft failures unrelated, missing optional dep). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KazMzMZYPLfaZoYQ79YQ8Q
|
CI is red on Both jobs fail on a single test: The same test fails identically on
Same test, same assertion, +30 passing tests from this branch and no new failures. Why it is not this branch. The assertion is the test's own sanity check at # Sanity: the degenerate band must actually be exercising the guard,
# otherwise the assertion above is passing vacuously.
assert raised > 0It requires
Not fixing it here. Deciding what that sanity assertion should be (drop it, seed-tune it, or make the guard deterministic) is a judgement about a different subsystem and belongs in its own change rather than being folded into this one. Happy to file it separately. Generated by Claude Code |
Closes leg 2 of PyAutoMind
draft/feature/autoarray/regularization_jax_gradient_gaps.md.Both changes are opt-in and default-off, so default evidence values are unchanged and archived results stay comparable — the constraint the #391 adversarial probe verdict imposed.
Leg 2a —
s^T H svia one Cholesky solveThe kernel schemes define
H = coefficient * C^-1and were forming that inverse explicitly. Forming an inverse is slower and less accurate than solving: the error is amplified bycond(C), which reaches ~1e9 on the clustered traced vertices of the kNN mesh families, putting a noise floor on the likelihood the sampler sees.regularization_termnow computescoefficient * s^T C^-1 sfrom a single Cholesky solve againsts. Shape mirrors the log-det shortcut shipped in #391 exactly: aregularization_term_fromhook onAbstractRegularizationreturningNoneby default, overridden by the four kernel schemes, consulted behind a newSettings.regularization_term_method("matmul"default,"cho_solve"opt-in).Measured on a clustered fixture at
cond(C) = 3.2e9, graded against an exactly-known reference (choosings = CvmakesC^-1 s = v, so the true form iss^T vand needs no inverse anywhere):Why a separate setting from
log_det_methodDeliberate. The two evidence terms can be moved onto their exact factorizations independently, which is what makes an evidence shift attributable to one term rather than both. Gating
s^T H son a setting named for log-determinants would also have been a lie in the name.Scope limit worth stating
This does not remove the explicit inverse from the inversion as a whole.
curvature_reg_matrixis a denseF + Hfeeding the dense solve for the reconstruction, soHis still formed there. Only the evidence terms avoid it. Doing better needs an iterative-solver design — a different piece of work.Leg 2b — relative jitter
The covariance jitter was a fixed absolute
1e-8 * I, which only makes sense whendiag(C) ~ 1. Measured: true for the three unweighted kernels (K(0) == 1), false forMaternAdaptKernel, whoseC_ii = w_i^2spans the adaptive-weight dynamic range.Distortion of the faintest pixel, 40-pixel fixture:
C_iiinner_coefficient/outer_coefficientare free model parameters, so a sampler can reach that bottom row mid-fit — silently, with no exception and no NaN, just wrong smoothing.jitter_relative=TrueappliesC_ii *= (1 + jitter). SinceC = D^(1/2) R D^(1/2), that is exactlyD^(1/2) (R + jitter I) D^(1/2)— the jitter lands on the correlation matrix, so every pixel gets the same relative protection whatever its scale.A rejected alternative, recorded so it isn't retried:
jitter = N * eps * max(diag)("as small as possible above the round-off floor") fixes the distortion but letscond(C)reach 3.2e15, at the edge of float64 — reintroducing exactly the noise this branch exists to remove. The correlation-relative rule leaves conditioning unchanged (3.16e9 both conventions, measured).Two traps found while implementing, both pinned by tests
MaternAdaptKernelpassescoefficient=0.0toMaternKernel.__init__(its weights live insideC_w). Inheriting the parent's term would have silently zeroed it — wrong but finite, the worst failure mode. It needs its own override.GaussianKernel's formed matrix carries a symmetrisation plus trace-scaled jitter that the analytic shortcut excludes — consistent with how its log-det shortcut already behaves, since both exist only to guard a factorization these paths avoid.Schemes with no factorization (
Constant,Adapt, the split families) returnNone, and oneNonefalls the whole computation back to the formed matrix, so mixed inversions stay correct.Testing
29 new tests across three files:
test_kernel_regularization_term.py(9) — shortcut vs formed matrix for all four schemes, the gate in both directions, the fallback, theMaternAdaptzero-coefficient trap, the ill-conditioning gain.test_kernel_jitter_relative.py(7) — default byte-identical to previous behaviour, relative jitter as a pure diagonal rescaling, the adaptive-weight bug, conditioning preserved on clustered vertices, flag threaded to all three covariance call sites per scheme.test_kernel_jax_gradients.py(13) — the JAX leg of the gate.JAX certification:
d(s^T C^-1 s)/d(scale)Full suite: 936 passed, 52 skipped. Three
test_transformer.pyfailures are pre-existing onmainand unrelated (missing optionalpynufft).Matérn's JAX path is not covered by the new JAX tests — it needs the modified Bessel from
tfp-nightly, an optional-of-an-optional. Gaussian and Exponential exercise the same sharedapply_jitter/quadratic_form_via_choleskycode without it. The workspace-levelautolens_workspace_test/scripts/imaging/jax_grad/regularization.pystill covers the Matérn end-to-end and is unaffected by this branch's defaults.Generated by Claude Code