Skip to content

Commit 5867db0

Browse files
authored
Merge pull request #437 from PyAutoLabs/claude/automind-task-planning-gm4flt
Kernel regularization: opt-in Cholesky-solve evidence term + relative jitter
2 parents 007904b + 5cf4881 commit 5867db0

11 files changed

Lines changed: 1015 additions & 10 deletions

File tree

autoarray/config/general.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ inversion:
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.
1212
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.
13+
regularization_term_method: matmul # How the Bayesian-evidence regularization term s^T H s is computed. "matmul" (default) is the historical s @ (H @ s) against the explicitly formed regularization matrix; "cho_solve" evaluates coefficient * s^T C^-1 s for the kernel schemes (Matern/Gaussian/Exponential/MaternAdapt) via one Cholesky solve of their covariance C, avoiding the explicit inverse whose round-off is amplified by cond(C) (~1e9 on clustered traced mesh vertices). Opt-in, non-default; does not change the default evidence. Schemes with no such factorization fall back to the formed matrix.
1314
numba:
1415
use_numba: true
1516
cache: true

autoarray/inversion/inversion/abstract.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,10 +691,50 @@ def regularization_term(self) -> float:
691691
692692
The above works include the regularization_matrix coefficient (lambda) in this calculation. In PyAutoLens,
693693
this is already in the regularization matrix and thus implicitly included in the matrix multiplication.
694+
695+
Under ``regularization_term_method == "cho_solve"`` (opt-in, default off), regularization schemes
696+
which know a factorization of their own matrix may instead supply their contribution directly via
697+
:meth:`AbstractRegularization.regularization_term_from` — the kernel schemes (``MaternKernel`` etc.)
698+
return ``coefficient * s^T C^-1 s`` from a single Cholesky solve of their covariance ``C``, avoiding
699+
the round-off of contracting the explicitly formed inverse (whose error is amplified by ``cond(C)``,
700+
~1e9 on clustered traced mesh vertices). Because ``regularization_matrix_reduced`` is the block
701+
diagonal of the per-object matrices when every linear object is regularized, the term is the sum of
702+
the per-object terms; if any scheme has no shortcut (returns ``None``) the whole computation falls
703+
back to the formed matrix. The default ``"matmul"`` path never consults the shortcut, so default
704+
evidence values are unchanged.
705+
706+
Returns
707+
-------
708+
float
709+
The regularization term of the inversion.
694710
"""
695711
if not self.has(cls=AbstractRegularization):
696712
return 0.0
697713

714+
if (
715+
self.settings.regularization_term_method == "cho_solve"
716+
and self.all_linear_obj_have_regularization
717+
):
718+
# `reconstruction_reduced` is the full reconstruction here (the guard above is exactly the
719+
# no-reduction case), so the per-object slices index it directly.
720+
reconstruction = self.reconstruction_reduced
721+
722+
term_list = [
723+
regularization.regularization_term_from(
724+
linear_obj=linear_obj,
725+
reconstruction=reconstruction[param_range[0] : param_range[1]],
726+
xp=self._xp,
727+
)
728+
for linear_obj, regularization, param_range in zip(
729+
self.linear_obj_list,
730+
self.regularization_list,
731+
self.param_range_list_from(cls=LinearObj),
732+
)
733+
]
734+
735+
if all(term is not None for term in term_list):
736+
return sum(term_list)
737+
698738
return self._xp.matmul(
699739
self.reconstruction_reduced.T,
700740
self._xp.matmul(

autoarray/inversion/regularization/abstract.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,47 @@ def log_det_regularization_matrix_term_from(
213213
has no factorization-aware shortcut.
214214
"""
215215
return None
216+
217+
def regularization_term_from(
218+
self, linear_obj: LinearObj, reconstruction: np.ndarray, xp=np
219+
) -> Optional[float]:
220+
"""
221+
Returns this scheme's contribution to the regularization term ``s^T H s``
222+
computed from a factorization the scheme itself knows about, or ``None`` when
223+
no such shortcut exists (the default).
224+
225+
This is the ``s^T H s`` counterpart of
226+
:meth:`log_det_regularization_matrix_term_from`, and exists for the same
227+
reason. The kernel regularization schemes build ``H = coefficient * C^-1``
228+
from a dense covariance ``C``, so their term is
229+
``coefficient * s^T C^-1 s`` — obtainable from a single Cholesky *solve*
230+
against ``s`` rather than by forming ``C^-1`` and contracting it. Forming the
231+
explicit inverse carries round-off amplified by ``cond(C)`` (~1e9 on the
232+
clustered traced vertices of the kNN mesh families), which then enters the
233+
evidence through this term.
234+
235+
Note this cannot remove the explicit inverse from the inversion altogether:
236+
``curvature_reg_matrix`` is a dense ``F + H`` feeding the dense solve for the
237+
reconstruction, so ``H`` is still formed there. This shortcut removes the
238+
formed inverse from the *evidence* terms only.
239+
240+
The inversion consumes this ONLY when
241+
``Settings.regularization_term_method == "cho_solve"`` — the default
242+
``"matmul"`` path never calls it, so default likelihood values are unchanged.
243+
See ``AbstractInversion.regularization_term``.
244+
245+
Parameters
246+
----------
247+
linear_obj
248+
The linear object (e.g. a ``Mapper``) whose regularization matrix the
249+
term is of.
250+
reconstruction
251+
The reconstructed values ``s`` of this linear object's parameters (the
252+
slice of the inversion's reconstruction belonging to ``linear_obj``).
253+
254+
Returns
255+
-------
256+
The scalar ``s^T H s`` for this linear object, or ``None`` when this scheme
257+
has no factorization-aware shortcut.
258+
"""
259+
return None

autoarray/inversion/regularization/exponential_kernel.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def exp_cov_matrix_from(
1212
scale: float,
1313
pixel_points: np.ndarray, # shape (N, 2)
1414
jitter: float = 1e-8,
15+
jitter_relative: bool = False,
1516
xp=np,
1617
) -> np.ndarray: # shape (N, N)
1718
"""
@@ -54,7 +55,9 @@ def exp_cov_matrix_from(
5455

5556
# add a small jitter on the diagonal
5657
N = pts.shape[0]
57-
cov = cov + xp.eye(N, dtype=cov.dtype) * jitter
58+
from autoarray.inversion.regularization.matern_kernel import apply_jitter
59+
60+
cov = apply_jitter(cov, jitter=jitter, jitter_relative=jitter_relative, xp=xp)
5861

5962
return cov
6063

@@ -65,6 +68,7 @@ def __init__(
6568
coefficient: float = 1.0,
6669
scale: float = 1.0,
6770
jitter: Optional[float] = None,
71+
jitter_relative: bool = False,
6872
):
6973
"""
7074
Regularization which uses an Exponential smoothing kernel to regularize the solution.
@@ -99,10 +103,17 @@ def __init__(
99103
``None`` (default) uses the historical value 1e-8 — behaviour is identical
100104
to not having this parameter (it is a fixed setting, not a free model
101105
parameter, hence the ``None`` default).
106+
jitter_relative
107+
If ``True`` the jitter is applied *relative* to each pixel's own variance
108+
(``C_ii *= 1 + jitter``) rather than as a fixed absolute ``jitter * I``.
109+
``False`` (default) preserves the historical behaviour exactly. The absolute
110+
convention assumes ``C_ii ~ 1``, which holds for this unweighted kernel but not
111+
for the adaptive one; see :func:`apply_jitter` for why and when to switch.
102112
"""
103113
self.coefficient = coefficient
104114
self.scale = scale
105115
self.jitter = jitter
116+
self.jitter_relative = jitter_relative
106117

107118
super().__init__()
108119

@@ -150,6 +161,7 @@ def regularization_matrix_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray
150161
scale=self.scale,
151162
pixel_points=linear_obj.source_plane_mesh_grid.array,
152163
jitter=self.jitter_value,
164+
jitter_relative=self.jitter_relative,
153165
xp=xp,
154166
)
155167

@@ -173,6 +185,7 @@ def log_det_regularization_matrix_term_from(
173185
scale=self.scale,
174186
pixel_points=linear_obj.source_plane_mesh_grid.array,
175187
jitter=self.jitter_value,
188+
jitter_relative=self.jitter_relative,
176189
xp=xp,
177190
)
178191

@@ -181,3 +194,33 @@ def log_det_regularization_matrix_term_from(
181194
)
182195

183196
return linear_obj.params * np.log(self.coefficient) - log_det_covariance
197+
198+
def regularization_term_from(
199+
self, linear_obj: LinearObj, reconstruction: np.ndarray, xp=np
200+
) -> float:
201+
"""
202+
The regularization term ``s^T H s`` from a single Cholesky solve of the kernel
203+
covariance: ``H = coefficient * C^-1``, so
204+
``s^T H s = coefficient * s^T C^-1 s``, with the quadratic form evaluated by
205+
solving ``C x = s`` rather than by forming ``C^-1``.
206+
207+
Consumed by the inversion only when
208+
``Settings.regularization_term_method == "cho_solve"`` (see
209+
:meth:`AbstractRegularization.regularization_term_from`); the default
210+
``"matmul"`` path contracts the formed ``H`` and is unchanged.
211+
"""
212+
from autoarray.inversion.regularization.matern_kernel import (
213+
quadratic_form_via_cholesky,
214+
)
215+
216+
covariance_matrix = exp_cov_matrix_from(
217+
scale=self.scale,
218+
pixel_points=linear_obj.source_plane_mesh_grid.array,
219+
jitter=self.jitter_value,
220+
jitter_relative=self.jitter_relative,
221+
xp=xp,
222+
)
223+
224+
return self.coefficient * quadratic_form_via_cholesky(
225+
covariance_matrix, reconstruction, xp=xp
226+
)

autoarray/inversion/regularization/gaussian_kernel.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def gauss_cov_matrix_from(
1212
scale: float,
1313
pixel_points: np.ndarray, # shape (N, 2)
1414
jitter: float = 1e-8,
15+
jitter_relative: bool = False,
1516
xp=np,
1617
) -> np.ndarray:
1718
"""
@@ -46,7 +47,9 @@ def gauss_cov_matrix_from(
4647

4748
# Add tiny jitter on the diagonal
4849
N = pts.shape[0]
49-
cov = cov + xp.eye(N, dtype=cov.dtype) * jitter
50+
from autoarray.inversion.regularization.matern_kernel import apply_jitter
51+
52+
cov = apply_jitter(cov, jitter=jitter, jitter_relative=jitter_relative, xp=xp)
5053

5154
return cov
5255

@@ -57,6 +60,7 @@ def __init__(
5760
coefficient: float = 1.0,
5861
scale: float = 1.0,
5962
jitter: Optional[float] = None,
63+
jitter_relative: bool = False,
6064
):
6165
"""
6266
Regularization which uses a Gaussian smoothing kernel to regularize the solution.
@@ -89,10 +93,17 @@ def __init__(
8993
``None`` (default) uses the historical value 1e-8 — behaviour is identical
9094
to not having this parameter (it is a fixed setting, not a free model
9195
parameter, hence the ``None`` default).
96+
jitter_relative
97+
If ``True`` the jitter is applied *relative* to each pixel's own variance
98+
(``C_ii *= 1 + jitter``) rather than as a fixed absolute ``jitter * I``.
99+
``False`` (default) preserves the historical behaviour exactly. The absolute
100+
convention assumes ``C_ii ~ 1``, which holds for this unweighted kernel but not
101+
for the adaptive one; see :func:`apply_jitter` for why and when to switch.
92102
"""
93103
self.coefficient = coefficient
94104
self.scale = scale
95105
self.jitter = jitter
106+
self.jitter_relative = jitter_relative
96107
super().__init__()
97108

98109
@property
@@ -139,6 +150,7 @@ def regularization_matrix_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray
139150
scale=self.scale,
140151
pixel_points=linear_obj.source_plane_mesh_grid.array,
141152
jitter=self.jitter_value,
153+
jitter_relative=self.jitter_relative,
142154
xp=xp,
143155
)
144156

@@ -157,9 +169,10 @@ def regularization_matrix_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray
157169
N = regularization_matrix.shape[0]
158170
diag_mean = xp.mean(xp.diag(regularization_matrix))
159171
h_jitter = 1e-8 * xp.abs(diag_mean)
160-
regularization_matrix = regularization_matrix + xp.eye(
161-
N, dtype=regularization_matrix.dtype
162-
) * h_jitter
172+
regularization_matrix = (
173+
regularization_matrix
174+
+ xp.eye(N, dtype=regularization_matrix.dtype) * h_jitter
175+
)
163176

164177
return regularization_matrix
165178

@@ -185,6 +198,7 @@ def log_det_regularization_matrix_term_from(
185198
scale=self.scale,
186199
pixel_points=linear_obj.source_plane_mesh_grid.array,
187200
jitter=self.jitter_value,
201+
jitter_relative=self.jitter_relative,
188202
xp=xp,
189203
)
190204

@@ -193,3 +207,39 @@ def log_det_regularization_matrix_term_from(
193207
)
194208

195209
return linear_obj.params * np.log(self.coefficient) - log_det_covariance
210+
211+
def regularization_term_from(
212+
self, linear_obj: LinearObj, reconstruction: np.ndarray, xp=np
213+
) -> float:
214+
"""
215+
The regularization term ``s^T H s`` from a single Cholesky solve of the kernel
216+
covariance: ``H = coefficient * C^-1``, so
217+
``s^T H s = coefficient * s^T C^-1 s``, with the quadratic form evaluated by
218+
solving ``C x = s`` rather than by forming ``C^-1``.
219+
220+
As with :meth:`log_det_regularization_matrix_term_from`, this is the term of
221+
the analytic ``coefficient * C^-1``: it excludes both the symmetrisation and
222+
the trace-scaled stabilisation jitter that :meth:`regularization_matrix_from`
223+
applies to the formed matrix, since both exist only to guard the
224+
factorization of the explicit inverse that this shortcut avoids entirely.
225+
226+
Consumed by the inversion only when
227+
``Settings.regularization_term_method == "cho_solve"`` (see
228+
:meth:`AbstractRegularization.regularization_term_from`); the default
229+
``"matmul"`` path contracts the formed ``H`` and is unchanged.
230+
"""
231+
from autoarray.inversion.regularization.matern_kernel import (
232+
quadratic_form_via_cholesky,
233+
)
234+
235+
covariance_matrix = gauss_cov_matrix_from(
236+
scale=self.scale,
237+
pixel_points=linear_obj.source_plane_mesh_grid.array,
238+
jitter=self.jitter_value,
239+
jitter_relative=self.jitter_relative,
240+
xp=xp,
241+
)
242+
243+
return self.coefficient * quadratic_form_via_cholesky(
244+
covariance_matrix, reconstruction, xp=xp
245+
)

0 commit comments

Comments
 (0)