Skip to content

Commit 2897ded

Browse files
committed
feat: kernel-regularization linear algebra — cholesky inverses, jitter kwarg, slogdet-gated exact log-det
Two tiers, split by whether likelihood values move: No likelihood change at defaults (always on): - ExponentialKernel and GaussianKernel invert their SPD kernel covariance via inv_via_cholesky (as MaternKernel) instead of xp.linalg.inv — the identical quantity with better accuracy and symmetry; GaussianKernel keeps its trace-scaled stabilisation jitter on the formed matrix. - All four kernel schemes (Matern/MaternAdapt/Gaussian/Exponential) expose the covariance diagonal jitter as a constructor kwarg. The default None resolves to the historical 1e-8 (byte-identical matrices, asserted by a new test) and keeps af.Model prior counts unchanged. Gated on Settings.log_det_method == "slogdet" (opt-in, default off): - New AbstractRegularization.log_det_regularization_matrix_term_from hook (base returns None). The kernel schemes override it with the analytically exact log det H = pixels*log(coeff) - log det C from a single Cholesky of their covariance C (MaternAdaptKernel omits the coeff term — its adaptive weights live inside C), avoiding the explicit inverse whose round-off (amplified by cond(C) ~ 1e9 on clustered traced mesh vertices) puts a ~1e-6 absolute noise floor on the evidence. C's conditioning does not depend on the regularization coefficient, so the term is also finite at any coefficient. - AbstractInversion.log_det_regularization_matrix_term consults the hook only under "slogdet" with every linear object regularized (per-object sum = block-diagonal log det), falling back wholesale to the existing slogdet path when any scheme lacks a shortcut. The default "cholesky" evidence path never calls it — default values are unchanged (asserted). Extends the PyAutoArray#391 opt-in rather than adding a new toggle; Settings docstring and packaged general.yaml comment updated. Six new unit tests in test_kernel_log_det.py; regularization + inversion-abstract suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013FSqnkgZv97PU9JdkCcthy
1 parent d1863c4 commit 2897ded

9 files changed

Lines changed: 402 additions & 23 deletions

File tree

autoarray/config/general.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ inversion:
99
nnls_jacobi_preconditioning: true # If True (default), the curvature matrix passed to jaxnnls.solve_nnls_primal is Jacobi-preconditioned (D Q D y = D q, x = D y). Fixes NaN backward-pass gradients on ill-conditioned Q and roughly halves forward solve time. Set False to restore the raw unpreconditioned solve.
1010
nnls_target_kappa: 1.0e-11 # Central-path relaxation parameter passed to jaxnnls.solve_nnls_primal. Larger values smooth the relaxed-KKT backward pass and prevent NaN gradients on ill-conditioned Q; smaller values tighten the primal solve. Verified finite gradients across all MGE/rectangular/delaunay pipelines (imaging + interferometer) with scale invariance over 5 orders of magnitude in noise. jaxnnls's own default (1e-3) is too aggressive for the backward pass.
1111
reconstruction_vmax_factor: 0.5 # Plots of an Inversion's reconstruction use the reconstructed data's bright value multiplied by this factor.
12-
log_det_method: cholesky # How the Bayesian-evidence log-determinant terms are computed. "cholesky" (default) is the historical 2*sum(log(diag(cholesky(M)))); "slogdet" uses logabsdet of slogdet(M), which is identical where M is positive-definite but finite (not NaN) where the Cholesky fails, for gradient-based searches (opt-in, non-default; does not change the default evidence). See PyAutoArray#391.
12+
log_det_method: cholesky # How the Bayesian-evidence log-determinant terms are computed. "cholesky" (default) is the historical 2*sum(log(diag(cholesky(M)))); "slogdet" uses logabsdet of slogdet(M), which is identical where M is positive-definite but finite (not NaN) where the Cholesky fails, for gradient-based searches (opt-in, non-default; does not change the default evidence). Under "slogdet" the kernel regularization schemes (Matern/Gaussian/Exponential) also compute the regularization log-det analytically from a Cholesky of their covariance instead of factorizing the formed inverse. See PyAutoArray#391.
1313
numba:
1414
use_numba: true
1515
cache: true

autoarray/inversion/inversion/abstract.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,17 @@ def log_det_regularization_matrix_term(self) -> float:
758758
used for the source reconstruction, this uses scipy sparse linear algebra to solve the determinant efficiently
759759
(or ``slogdet`` when ``Settings.log_det_method == "slogdet"`` — see :meth:`_log_det_symmetric_from`).
760760
761+
Under ``log_det_method == "slogdet"`` (opt-in, default off — PyAutoArray#391), regularization schemes
762+
which know a factorization of their own matrix may additionally supply the term directly via
763+
:meth:`AbstractRegularization.log_det_regularization_matrix_term_from` — the kernel schemes
764+
(``MaternKernel`` etc.) return the analytically exact ``pixels * log(coeff) - log det C`` from a single
765+
Cholesky of their covariance ``C``, avoiding the round-off of factorizing the explicitly formed inverse
766+
(which reaches ~1e-6 absolute in the evidence at cond(C) ~ 1e9 on clustered traced mesh vertices).
767+
Because ``regularization_matrix_reduced`` is the block diagonal of the per-object matrices when every
768+
linear object is regularized, the term is the sum of the per-object terms; if any scheme has no
769+
shortcut (returns ``None``) the whole computation falls back to ``slogdet`` of the formed matrix.
770+
The default ``"cholesky"`` path never consults the shortcut, so default evidence values are unchanged.
771+
761772
Returns
762773
-------
763774
float
@@ -766,6 +777,22 @@ def log_det_regularization_matrix_term(self) -> float:
766777
if not self.has(cls=AbstractRegularization):
767778
return 0.0
768779

780+
if (
781+
self.settings.log_det_method == "slogdet"
782+
and self.all_linear_obj_have_regularization
783+
):
784+
term_list = [
785+
regularization.log_det_regularization_matrix_term_from(
786+
linear_obj=linear_obj, xp=self._xp
787+
)
788+
for linear_obj, regularization in zip(
789+
self.linear_obj_list, self.regularization_list
790+
)
791+
]
792+
793+
if all(term is not None for term in term_list):
794+
return sum(term_list)
795+
769796
return self._log_det_symmetric_from(self.regularization_matrix_reduced)
770797

771798
@property

autoarray/inversion/regularization/abstract.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22
import numpy as np
3-
from typing import TYPE_CHECKING
3+
from typing import Optional, TYPE_CHECKING
44

55
if TYPE_CHECKING:
66
from autoarray.inversion.linear_obj.linear_obj import LinearObj
@@ -167,3 +167,38 @@ def regularization_matrix_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray
167167
The regularization matrix.
168168
"""
169169
raise NotImplementedError
170+
171+
def log_det_regularization_matrix_term_from(
172+
self, linear_obj: LinearObj, xp=np
173+
) -> Optional[float]:
174+
"""
175+
Returns ``log det H`` of this scheme's regularization matrix computed from a
176+
factorization the scheme itself knows about, or ``None`` when no such shortcut
177+
exists (the default).
178+
179+
The kernel regularization schemes build ``H = coefficient * C^-1`` from a dense
180+
covariance ``C`` and can therefore return the analytically exact
181+
``pixels * log(coefficient) - log det C`` from a single Cholesky of ``C`` —
182+
avoiding the explicit inverse, whose round-off (amplified by ``cond(C)``, which
183+
reaches ~1e9 on clustered traced mesh vertices) otherwise leaks into the
184+
evidence. ``C``'s conditioning does not depend on the regularization
185+
coefficient, so this path is also finite where factorizing the formed ``H``
186+
fails at extreme coefficients.
187+
188+
The inversion consumes this ONLY when ``Settings.log_det_method == "slogdet"``
189+
(the opt-in gradient-safe log-det, PyAutoArray#391) — the default
190+
``"cholesky"`` evidence path never calls it, so default likelihood values are
191+
unchanged. See ``AbstractInversion.log_det_regularization_matrix_term``.
192+
193+
Parameters
194+
----------
195+
linear_obj
196+
The linear object (e.g. a ``Mapper``) whose regularization matrix the
197+
log-determinant is of.
198+
199+
Returns
200+
-------
201+
The log determinant of the regularization matrix, or ``None`` when this scheme
202+
has no factorization-aware shortcut.
203+
"""
204+
return None

autoarray/inversion/regularization/exponential_kernel.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22
import numpy as np
3-
from typing import TYPE_CHECKING
3+
from typing import Optional, TYPE_CHECKING
44

55
if TYPE_CHECKING:
66
from autoarray.inversion.linear_obj.linear_obj import LinearObj
@@ -11,6 +11,7 @@
1111
def exp_cov_matrix_from(
1212
scale: float,
1313
pixel_points: np.ndarray, # shape (N, 2)
14+
jitter: float = 1e-8,
1415
xp=np,
1516
) -> np.ndarray: # shape (N, N)
1617
"""
@@ -53,13 +54,18 @@ def exp_cov_matrix_from(
5354

5455
# add a small jitter on the diagonal
5556
N = pts.shape[0]
56-
cov = cov + xp.eye(N, dtype=cov.dtype) * 1e-8
57+
cov = cov + xp.eye(N, dtype=cov.dtype) * jitter
5758

5859
return cov
5960

6061

6162
class ExponentialKernel(AbstractRegularization):
62-
def __init__(self, coefficient: float = 1.0, scale: float = 1.0):
63+
def __init__(
64+
self,
65+
coefficient: float = 1.0,
66+
scale: float = 1.0,
67+
jitter: Optional[float] = None,
68+
):
6369
"""
6470
Regularization which uses an Exponential smoothing kernel to regularize the solution.
6571
@@ -88,12 +94,22 @@ def __init__(self, coefficient: float = 1.0, scale: float = 1.0):
8894
The regularization coefficient which controls the degree of smooth of the inversion reconstruction.
8995
scale
9096
The typical scale of the exponential regularization pattern.
97+
jitter
98+
The small value added to the covariance diagonal for numerical stability.
99+
``None`` (default) uses the historical value 1e-8 — behaviour is identical
100+
to not having this parameter (it is a fixed setting, not a free model
101+
parameter, hence the ``None`` default).
91102
"""
92103
self.coefficient = coefficient
93104
self.scale = scale
105+
self.jitter = jitter
94106

95107
super().__init__()
96108

109+
@property
110+
def jitter_value(self) -> float:
111+
return 1e-8 if self.jitter is None else self.jitter
112+
97113
def regularization_weights_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray:
98114
"""
99115
Returns the regularization weights of this regularization scheme.
@@ -128,10 +144,40 @@ def regularization_matrix_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray
128144
-------
129145
The regularization matrix.
130146
"""
147+
from autoarray.inversion.regularization.matern_kernel import inv_via_cholesky
148+
149+
covariance_matrix = exp_cov_matrix_from(
150+
scale=self.scale,
151+
pixel_points=linear_obj.source_plane_mesh_grid.array,
152+
jitter=self.jitter_value,
153+
xp=xp,
154+
)
155+
156+
# The SPD inverse via Cholesky (as MaternKernel) — identical quantity to
157+
# inv(), with better accuracy and symmetry on the SPD covariance.
158+
return self.coefficient * inv_via_cholesky(covariance_matrix, xp=xp)
159+
160+
def log_det_regularization_matrix_term_from(
161+
self, linear_obj: LinearObj, xp=np
162+
) -> float:
163+
"""
164+
The analytically exact ``log det H`` from a single Cholesky of the kernel
165+
covariance: ``H = coefficient * C^-1``, so
166+
``log det H = pixels * log(coefficient) - 2 * sum(log(diag(cholesky(C))))``.
167+
168+
Consumed by the inversion only when ``Settings.log_det_method == "slogdet"``
169+
(see :meth:`AbstractRegularization.log_det_regularization_matrix_term_from`);
170+
the default evidence path factorizes the formed ``H`` and is unchanged.
171+
"""
131172
covariance_matrix = exp_cov_matrix_from(
132173
scale=self.scale,
133174
pixel_points=linear_obj.source_plane_mesh_grid.array,
175+
jitter=self.jitter_value,
134176
xp=xp,
135177
)
136178

137-
return self.coefficient * xp.linalg.inv(covariance_matrix)
179+
log_det_covariance = 2.0 * xp.sum(
180+
xp.log(xp.diag(xp.linalg.cholesky(covariance_matrix)))
181+
)
182+
183+
return linear_obj.params * np.log(self.coefficient) - log_det_covariance

autoarray/inversion/regularization/gaussian_kernel.py

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22
import numpy as np
3-
from typing import TYPE_CHECKING
3+
from typing import Optional, TYPE_CHECKING
44

55
if TYPE_CHECKING:
66
from autoarray.inversion.linear_obj.linear_obj import LinearObj
@@ -9,7 +9,10 @@
99

1010

1111
def gauss_cov_matrix_from(
12-
scale: float, pixel_points: np.ndarray, xp=np # shape (N, 2)
12+
scale: float,
13+
pixel_points: np.ndarray, # shape (N, 2)
14+
jitter: float = 1e-8,
15+
xp=np,
1316
) -> np.ndarray:
1417
"""
1518
Construct the source‐pixel Gaussian covariance matrix for regularization.
@@ -43,13 +46,18 @@ def gauss_cov_matrix_from(
4346

4447
# Add tiny jitter on the diagonal
4548
N = pts.shape[0]
46-
cov = cov + xp.eye(N, dtype=cov.dtype) * 1e-8
49+
cov = cov + xp.eye(N, dtype=cov.dtype) * jitter
4750

4851
return cov
4952

5053

5154
class GaussianKernel(AbstractRegularization):
52-
def __init__(self, coefficient: float = 1.0, scale: float = 1.0):
55+
def __init__(
56+
self,
57+
coefficient: float = 1.0,
58+
scale: float = 1.0,
59+
jitter: Optional[float] = None,
60+
):
5361
"""
5462
Regularization which uses a Gaussian smoothing kernel to regularize the solution.
5563
@@ -76,11 +84,21 @@ def __init__(self, coefficient: float = 1.0, scale: float = 1.0):
7684
The regularization coefficient which controls the degree of smooth of the inversion reconstruction.
7785
scale
7886
The typical scale of the exponential regularization pattern.
87+
jitter
88+
The small value added to the covariance diagonal for numerical stability.
89+
``None`` (default) uses the historical value 1e-8 — behaviour is identical
90+
to not having this parameter (it is a fixed setting, not a free model
91+
parameter, hence the ``None`` default).
7992
"""
8093
self.coefficient = coefficient
8194
self.scale = scale
95+
self.jitter = jitter
8296
super().__init__()
8397

98+
@property
99+
def jitter_value(self) -> float:
100+
return 1e-8 if self.jitter is None else self.jitter
101+
84102
def regularization_weights_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray:
85103
"""
86104
Returns the regularization weights of this regularization scheme.
@@ -115,25 +133,63 @@ def regularization_matrix_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray
115133
-------
116134
The regularization matrix.
117135
"""
136+
from autoarray.inversion.regularization.matern_kernel import inv_via_cholesky
137+
118138
covariance_matrix = gauss_cov_matrix_from(
119139
scale=self.scale,
120140
pixel_points=linear_obj.source_plane_mesh_grid.array,
141+
jitter=self.jitter_value,
121142
xp=xp,
122143
)
123144

124-
regularization_matrix = self.coefficient * xp.linalg.inv(covariance_matrix)
145+
# The SPD inverse via Cholesky (as MaternKernel) — identical quantity to
146+
# inv(), with better accuracy and symmetry on the SPD covariance.
147+
regularization_matrix = self.coefficient * inv_via_cholesky(
148+
covariance_matrix, xp=xp
149+
)
125150

126-
# inv() loses exact symmetry and can introduce tiny negative eigenvalues
127-
# when the covariance matrix is near-singular (e.g. scale >> pixel
128-
# spacing). Symmetrise and add a trace-scaled diagonal jitter so the
129-
# downstream cholesky in log_det_regularization_matrix_term cannot
130-
# fail on floating-point noise.
151+
# The inverse can still lose exact symmetry and introduce tiny negative
152+
# eigenvalues when the covariance matrix is near-singular (e.g. scale >>
153+
# pixel spacing). Symmetrise and add a trace-scaled diagonal jitter so the
154+
# downstream cholesky in log_det_regularization_matrix_term cannot fail on
155+
# floating-point noise.
131156
regularization_matrix = 0.5 * (regularization_matrix + regularization_matrix.T)
132157
N = regularization_matrix.shape[0]
133158
diag_mean = xp.mean(xp.diag(regularization_matrix))
134-
jitter = 1e-8 * xp.abs(diag_mean)
159+
h_jitter = 1e-8 * xp.abs(diag_mean)
135160
regularization_matrix = regularization_matrix + xp.eye(
136161
N, dtype=regularization_matrix.dtype
137-
) * jitter
162+
) * h_jitter
138163

139164
return regularization_matrix
165+
166+
def log_det_regularization_matrix_term_from(
167+
self, linear_obj: LinearObj, xp=np
168+
) -> float:
169+
"""
170+
The analytically exact ``log det H`` from a single Cholesky of the kernel
171+
covariance: ``H = coefficient * C^-1``, so
172+
``log det H = pixels * log(coefficient) - 2 * sum(log(diag(cholesky(C))))``.
173+
174+
This is the log-determinant of the analytic ``coefficient * C^-1`` — it
175+
deliberately excludes the trace-scaled stabilisation jitter that
176+
:meth:`regularization_matrix_from` adds to the formed matrix (that jitter
177+
exists only to guard the factorization of the explicit inverse, which this
178+
shortcut avoids entirely).
179+
180+
Consumed by the inversion only when ``Settings.log_det_method == "slogdet"``
181+
(see :meth:`AbstractRegularization.log_det_regularization_matrix_term_from`);
182+
the default evidence path factorizes the formed ``H`` and is unchanged.
183+
"""
184+
covariance_matrix = gauss_cov_matrix_from(
185+
scale=self.scale,
186+
pixel_points=linear_obj.source_plane_mesh_grid.array,
187+
jitter=self.jitter_value,
188+
xp=xp,
189+
)
190+
191+
log_det_covariance = 2.0 * xp.sum(
192+
xp.log(xp.diag(xp.linalg.cholesky(covariance_matrix)))
193+
)
194+
195+
return linear_obj.params * np.log(self.coefficient) - log_det_covariance

autoarray/inversion/regularization/matern_adapt_kernel.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22
import numpy as np
3-
from typing import TYPE_CHECKING
3+
from typing import Optional, TYPE_CHECKING
44

55
from autoarray.inversion.regularization.matern_kernel import MaternKernel
66

@@ -21,6 +21,7 @@ def __init__(
2121
inner_coefficient: float = 1.0,
2222
outer_coefficient: float = 1.0,
2323
signal_scale: float = 1.0,
24+
jitter: Optional[float] = None,
2425
):
2526
"""
2627
Regularization which uses a Matern smoothing kernel to regularize the solution with regularization weights
@@ -63,7 +64,7 @@ def __init__(
6364
receive significantly higher weights (and faint pixels lower weights), while smaller values produce a
6465
more uniform weighting. Typical values are of order unity (e.g. 0.5–2.0).
6566
"""
66-
super().__init__(coefficient=0.0, scale=scale, nu=nu)
67+
super().__init__(coefficient=0.0, scale=scale, nu=nu, jitter=jitter)
6768
self.inner_coefficient = inner_coefficient
6869
self.outer_coefficient = outer_coefficient
6970
self.signal_scale = signal_scale
@@ -109,7 +110,35 @@ def regularization_matrix_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray
109110
pixel_points=pixel_points,
110111
nu=self.nu,
111112
weights=kernel_weights,
113+
jitter=self.jitter_value,
112114
xp=xp,
113115
)
114116

115117
return inv_via_cholesky(covariance_matrix, xp=xp)
118+
119+
def log_det_regularization_matrix_term_from(
120+
self, linear_obj: LinearObj, xp=np
121+
) -> float:
122+
"""
123+
The analytically exact ``log det H`` from a single Cholesky of the weighted
124+
kernel covariance: this scheme's ``H = C_w^-1`` (no coefficient scaling — the
125+
adaptive weights are inside ``C_w``), so ``log det H = -log det C_w``.
126+
127+
Consumed by the inversion only when ``Settings.log_det_method == "slogdet"``
128+
(see :meth:`AbstractRegularization.log_det_regularization_matrix_term_from`);
129+
the default evidence path factorizes the formed ``H`` and is unchanged.
130+
"""
131+
kernel_weights = 1.0 / self.regularization_weights_from(
132+
linear_obj=linear_obj, xp=xp
133+
)
134+
135+
covariance_matrix = matern_cov_matrix_from(
136+
scale=self.scale,
137+
pixel_points=linear_obj.source_plane_mesh_grid.array,
138+
nu=self.nu,
139+
weights=kernel_weights,
140+
jitter=self.jitter_value,
141+
xp=xp,
142+
)
143+
144+
return -2.0 * xp.sum(xp.log(xp.diag(xp.linalg.cholesky(covariance_matrix))))

0 commit comments

Comments
 (0)