Skip to content

Kernel regularization: opt-in Cholesky-solve evidence term + relative jitter - #437

Merged
Jammy2211 merged 4 commits into
mainfrom
claude/automind-task-planning-gm4flt
Aug 9, 2026
Merged

Kernel regularization: opt-in Cholesky-solve evidence term + relative jitter#437
Jammy2211 merged 4 commits into
mainfrom
claude/automind-task-planning-gm4flt

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

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 s via one Cholesky solve

The kernel schemes define H = coefficient * C^-1 and were forming that inverse explicitly. Forming an inverse is slower and less accurate than solving: the error is amplified by cond(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_term now computes coefficient * s^T C^-1 s from a single Cholesky solve against s. Shape mirrors the log-det shortcut shipped in #391 exactly: a regularization_term_from hook on AbstractRegularization returning None by default, overridden by the four kernel schemes, consulted behind a new Settings.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 (choosing s = Cv makes C^-1 s = v, so the true form is s^T v and needs no inverse anywhere):

relative error
explicit inverse 5.99e-08
Cholesky solve 2.93e-16

Why a separate setting from log_det_method

Deliberate. 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 s on 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_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. 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 when diag(C) ~ 1. Measured: true for the three unweighted kernels (K(0) == 1), false for MaternAdaptKernel, whose C_ii = w_i^2 spans the adaptive-weight dynamic range.

Distortion of the faintest pixel, 40-pixel fixture:

inner/outer faintest C_ii 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

inner_coefficient/outer_coefficient are 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=True applies C_ii *= (1 + jitter). Since C = D^(1/2) R D^(1/2), that 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.

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 lets cond(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

  • MaternAdaptKernel passes coefficient=0.0 to MaternKernel.__init__ (its weights live inside C_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) return None, and one None falls 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, the MaternAdapt zero-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:

check result
FD certification of d(s^T C^-1 s)/d(scale) 4.6e-09
eager vs jit 3.6e-15
implicit vs explicit inverse (JAX) 1.1e-13
numpy vs jax parity 8.4e-12

Full suite: 936 passed, 52 skipped. Three test_transformer.py failures are pre-existing on main and unrelated (missing optional pynufft).

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 shared apply_jitter / quadratic_form_via_cholesky code without it. The workspace-level autolens_workspace_test/scripts/imaging/jax_grad/regularization.py still covers the Matérn end-to-end and is unaffected by this branch's defaults.


Generated by Claude Code

Claude and others added 3 commits August 9, 2026 17:33
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

Copy link
Copy Markdown
Collaborator Author

CI is red on unittest (3.12) and unittest (3.13), but the failure is pre-existing on main and not caused by this branch.

Both jobs fail on a single test:

FAILED test_autoarray/util/test_cholesky_degenerate.py::test__fnnls_cholesky__never_returns_a_non_finite_solution[0.0] - assert 0 > 0

The same test fails identically on main at efaf3041 — this PR's base commit — in run 31219374045 (2026-08-07):

result
main @ efaf3041 1 failed, 959 passed, 1 skipped
this PR @ dd2cd72 1 failed, 989 passed, 1 skipped

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 test_cholesky_degenerate.py:130:

# Sanity: the degenerate band must actually be exercising the guard,
# otherwise the assertion above is passing vacuously.
assert raised > 0

It requires fnnls_cholesky to raise LinAlgError for at least one of 40 seeds at jitter=0.0. Whether a near-degenerate matrix raises depends on the LAPACK/BLAS build, so this is environment-sensitive by construction. It passes locally (32 passed) and fails on the CI runners.

fnnls_cholesky lives in autoarray/util/fnnls.py, which this branch does not touch. The diff is confined to autoarray/inversion/regularization/*, inversion/abstract.py, settings.py, config/general.yaml, and three new test files — there is no code path from any of those to fnnls.

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

@Jammy2211
Jammy2211 merged commit 5867db0 into main Aug 9, 2026
2 checks passed
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