Skip to content

Commit 302d5df

Browse files
author
James Nightingale
authored
Merge pull request #512 from PyAutoLabs/feature/adapt-linear-regularization
feat: AdaptPower regularization — power input + factor-2 scatter fix
2 parents 22191ec + b91b332 commit 302d5df

13 files changed

Lines changed: 1128 additions & 5 deletions

‎autoarray/inversion/regularization/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
from .constant_zeroth import ConstantZeroth
55
from .constant_split import ConstantSplit
66
from .adapt import Adapt
7+
from .adapt_power import AdaptPower
78
from .adapt_split import AdaptSplit
9+
from .adapt_split_power import AdaptSplitPower
810
from .brightness_zeroth import BrightnessZeroth
911
from .adapt_split_zeroth import AdaptSplitZeroth
12+
from .adapt_split_zeroth_power import AdaptSplitZerothPower
1013
from .curvature_mask import CurvatureMask
1114
from .fourth_order_mask import FourthOrderMask
1215
from .gaussian_kernel import GaussianKernel
1316
from .exponential_kernel import ExponentialKernel
1417
from .matern_kernel import MaternKernel
1518
from .matern_adapt_kernel import MaternAdaptKernel
19+
from .matern_adapt_power_kernel import MaternAdaptPowerKernel

‎autoarray/inversion/regularization/adapt.py‎

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010

1111

1212
def adapt_regularization_weights_from(
13-
inner_coefficient: float, outer_coefficient: float, pixel_signals: np.ndarray
13+
inner_coefficient: float,
14+
outer_coefficient: float,
15+
pixel_signals: np.ndarray,
16+
power: float = 2.0,
1417
) -> np.ndarray:
1518
"""
1619
Returns the regularization weights for the adaptive regularization scheme (e.g. ``Adapt``).
@@ -36,6 +39,14 @@ def adapt_regularization_weights_from(
3639
pixel_signals
3740
The estimated signal in every pixelization pixel, used to change the regularization weighting of high signal
3841
and low signal pixelizations.
42+
power
43+
The exponent the interpolated coefficient is raised to. The matrix builders square the returned weights
44+
again, so the coefficient enters the regularization matrix at the power ``2 * power``.
45+
46+
The default ``2.0`` is the historical ``Adapt`` convention (a fourth-power coefficient dependence) and is
47+
what the legacy ``Adapt``, ``AdaptSplit``, ``AdaptSplitZeroth`` and ``MaternAdaptKernel`` classes pass.
48+
``power=1.0`` gives the squared-once convention shared with ``Constant`` and is what the ``*Power``
49+
classes (e.g. ``AdaptPower``) pass by default.
3950
4051
Returns
4152
-------
@@ -45,7 +56,7 @@ def adapt_regularization_weights_from(
4556
"""
4657
return (
4758
inner_coefficient * pixel_signals + outer_coefficient * (1.0 - pixel_signals)
48-
) ** 2.0
59+
) ** power
4960

5061

5162
def weighted_regularization_matrix_from(
@@ -129,6 +140,91 @@ def weighted_regularization_matrix_from(
129140
return mat[:S, :S]
130141

131142

143+
def weighted_regularization_matrix_single_scatter_from(
144+
regularization_weights: np.ndarray,
145+
neighbors: np.ndarray,
146+
xp=np,
147+
) -> np.ndarray:
148+
"""
149+
Returns the regularization matrix of the adaptive regularization scheme, scattering every mesh edge
150+
**once** so that uniform weights reproduce ``Constant`` regularization exactly.
151+
152+
This is the corrected sibling of ``weighted_regularization_matrix_from``, used by the ``*Power``
153+
classes (e.g. ``AdaptPower``). The two differ only in how often each mesh edge is scattered:
154+
155+
- ``weighted_regularization_matrix_from`` adds every ordered neighbor pair to **both** the
156+
``(i, j)`` and ``(j, i)`` entries. Because the neighbor list already holds each unordered edge
157+
twice (once in row ``i``, once in row ``j``), every edge lands four times, which is exactly twice
158+
what ``constant_regularization_matrix_from`` does. ``Adapt(inner=outer=c)`` is therefore
159+
``2 x`` ``Constant(c)``, not equal to it.
160+
- This function adds every ordered neighbor pair once, to ``(i, i)`` and ``(i, j)`` only -- the same
161+
bookkeeping ``Constant`` uses -- so ``AdaptPower(inner=outer=c)`` equals ``Constant(c)`` exactly.
162+
163+
The edge weight is the mean of the two endpoints' squared regularization weights,
164+
``0.5 * (w_i ** 2 + w_j ** 2)``. This is symmetric in ``(i, j)`` (so the matrix is symmetric even
165+
for wildly varying adaptive weights), reduces to ``w ** 2`` when the weights are uniform, and makes
166+
the result a weighted graph Laplacian plus a ``1e-8`` diagonal floor -- so its rows sum to the floor
167+
and it is positive semi-definite by construction.
168+
169+
The legacy builder is left untouched: halving it in place would silently change the effective
170+
regularization of every ``Adapt`` fit ever run.
171+
172+
Parameters
173+
----------
174+
regularization_weights
175+
The regularization weight of each pixel, adaptively governing the degree of gradient regularization
176+
applied to each inversion parameter (e.g. mesh pixels of a ``Mapper``).
177+
neighbors
178+
An array of length (total_pixels) which provides the index of all neighbors of every pixel in
179+
the mesh grid (entries of -1 correspond to no neighbor).
180+
181+
Returns
182+
-------
183+
np.ndarray
184+
The regularization matrix computed using an adaptive regularization scheme where the effective
185+
regularization coefficient of every source pixel is different.
186+
"""
187+
S, P = neighbors.shape
188+
189+
reg_w = regularization_weights**2
190+
191+
# 1) Flatten the (i->j) neighbor pairs
192+
I = xp.repeat(xp.arange(S), P) # (S*P,)
193+
J_raw = neighbors.reshape(-1) # (S*P,)
194+
195+
# 2) Remap "no neighbor" entries to an extra slot S, whose weight = 0
196+
OUT = S
197+
valid = J_raw >= 0
198+
J = xp.where(valid, J_raw, OUT)
199+
200+
# 3) Build an extended weight vector with a zero at index S
201+
reg_w_ext = xp.concatenate([reg_w, xp.zeros((1,))], axis=0)
202+
203+
# 4) Symmetric per-edge weight: the mean of the two endpoints' squared weights. Padded entries are
204+
# masked to zero (their I endpoint is a real pixel, so the mean alone would not vanish).
205+
w_ij = 0.5 * (reg_w_ext[I] + reg_w_ext[J]) * valid
206+
207+
# 5) Start with zeros on an (S+1)x(S+1) canvas so we can scatter into row S safely
208+
mat = xp.zeros((S + 1, S + 1), dtype=regularization_weights.dtype)
209+
210+
diag_updates_i = xp.concatenate(
211+
[xp.full((S,), 1e-8), xp.zeros((1,))], axis=0 # out-of-bounds slot stays zero
212+
)
213+
214+
# 6) Scatter each ordered pair exactly once: onto the diagonal of i and the (i, j) off-diagonal
215+
if xp.__name__.startswith("jax"):
216+
mat = mat.at[xp.diag_indices(S + 1)].add(diag_updates_i)
217+
mat = mat.at[I, I].add(w_ij)
218+
mat = mat.at[I, J].add(-w_ij)
219+
else:
220+
np.add.at(mat, np.diag_indices(S + 1), diag_updates_i)
221+
np.add.at(mat, (I, I), w_ij)
222+
np.add.at(mat, (I, J), -w_ij)
223+
224+
# 7) Drop the extra row/column S and return the SxS result
225+
return mat[:S, :S]
226+
227+
132228
class Adapt(AbstractRegularization):
133229
def __init__(
134230
self,
@@ -180,7 +276,28 @@ def __init__(
180276
neighbors come from a direct scipy call on the traced mesh grid (use
181277
``AdaptSplit`` there). Note the defaults
182278
``inner_coefficient == outer_coefficient == 1.0`` make the weighting
183-
uniform — numerically identical to ``Constant(coefficient=1.0)``.
279+
uniform — but *not* numerically identical to ``Constant(coefficient=1.0)``;
280+
see the coefficient-convention note below.
281+
282+
**Coefficient convention (legacy, ``lambda^4``).** The coefficients are squared twice before they
283+
reach the regularization matrix -- once by ``adapt_regularization_weights_from`` and once by the
284+
matrix builder -- so the matrix scales as the *fourth* power of the coefficient, while
285+
``Constant`` scales as the second. Both carry the same ``LogUniform(1e-6, 1e6)`` prior, so this
286+
scheme explores a far wider effective smoothing range and reaches a numerically non
287+
positive-definite matrix from ``c ~ 1e4`` where ``Constant`` survives to ``c ~ 1e6``.
288+
289+
**It is also 2x ``Constant``, not equal to it.** The matrix builder scatters every mesh edge in
290+
both directions, and the neighbor list already holds each unordered edge twice, so each edge
291+
lands four times where ``Constant`` lands it twice.
292+
``Adapt(inner_coefficient=1.0, outer_coefficient=1.0)`` is therefore exactly ``2 x``
293+
``Constant(coefficient=1.0)``.
294+
295+
This behaviour is preserved deliberately: changing it would alter the coefficient scale of every
296+
adaptive fit ever run. **New work should prefer ``AdaptPower``**, which takes a ``power`` argument
297+
(default ``1.0``, giving the ``Constant``-matching ``lambda^2`` convention) and scatters each edge
298+
once, so ``AdaptPower(inner=outer=c)`` equals ``Constant(c)`` exactly and is more robust to
299+
gradient / NaN pathologies. The migration is ``c_new = c_old ** 2``, and
300+
``AdaptPower(power=2.0)`` reproduces this class's coefficient scaling exactly.
184301
185302
Parameters
186303
----------
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
from __future__ import annotations
2+
import numpy as np
3+
from typing import TYPE_CHECKING
4+
5+
if TYPE_CHECKING:
6+
from autoarray.inversion.linear_obj.linear_obj import LinearObj
7+
8+
from autoarray.inversion.regularization.adapt import Adapt
9+
from autoarray.inversion.regularization.adapt import adapt_regularization_weights_from
10+
from autoarray.inversion.regularization.adapt import (
11+
weighted_regularization_matrix_single_scatter_from,
12+
)
13+
14+
15+
class AdaptPower(Adapt):
16+
def __init__(
17+
self,
18+
inner_coefficient: float = 1.0,
19+
outer_coefficient: float = 1.0,
20+
signal_scale: float = 1.0,
21+
power: float = 1.0,
22+
):
23+
"""
24+
Regularization which uses the neighbors of the mesh (e.g. shared Delaunay vertexes) and values adapted to
25+
the data being fitted to smooth an inversion's solution, with the coefficient convention of ``Constant``.
26+
27+
This is the corrected sibling of ``Adapt``. It reconstructs the same adaptive weighting -- high smoothing
28+
where there is no signal, less where there is (Nightingale, Dye and Massey 2018) -- but fixes the two ways
29+
in which ``Adapt`` diverges from ``Constant``:
30+
31+
1. **The coefficient enters at ``lambda^2``, not ``lambda^4``.** ``Adapt`` squares its coefficients twice
32+
(once when interpolating them into per-pixel weights, once in the matrix builder), so its matrix scales
33+
as the fourth power of the coefficient. This class raises the interpolated coefficient to ``power``
34+
before the builder squares it, so the effective exponent is ``2 * power`` and the default ``power=1.0``
35+
matches ``Constant``. Under the shared ``LogUniform(1e-6, 1e6)`` prior that means the prior now spans
36+
``lambda^2``, and the regularization matrix stays positive-definite to ``c ~ 1e6`` rather than
37+
collapsing from ``c ~ 1e4`` -- the fragility that produced the likelihood-overflow floods seen in
38+
free-coefficient adaptive fits.
39+
2. **Every mesh edge is scattered once.** ``Adapt`` adds each ordered neighbor pair to both the ``(i, j)``
40+
and ``(j, i)`` entries, and the neighbor list already holds each unordered edge twice, so its matrix is
41+
exactly ``2 x`` ``Constant``'s. This class uses
42+
``weighted_regularization_matrix_single_scatter_from``, which scatters each ordered pair once with the
43+
symmetric edge weight ``0.5 * (w_i ** 2 + w_j ** 2)`` -- a weighted graph Laplacian, so still symmetric
44+
and positive semi-definite.
45+
46+
Together these make ``AdaptPower(inner_coefficient=c, outer_coefficient=c)`` **exactly equal** to
47+
``Constant(coefficient=c)`` for any ``c``, which is what ``Adapt``'s docstring always claimed and never
48+
delivered.
49+
50+
A full description of regularization and this matrix can be found in the parent ``AbstractRegularization``
51+
class; the ``B`` matrix construction is described on ``Adapt``.
52+
53+
**Migration from ``Adapt``.** The coefficient scale is squared: ``c_new = c_old ** 2``. To reproduce the
54+
legacy class exactly, pass ``power=2.0`` -- but note the factor-2 scatter is fixed regardless, so
55+
``AdaptPower(power=2.0)`` is ``0.5 x`` ``Adapt`` with the same coefficients.
56+
57+
**JAX & gradient support**: as for ``Adapt`` -- JAX-differentiable and FD-certified on the rectangular
58+
mesh family, but raising ``TracerArrayConversionError`` on the Delaunay mesh family, whose neighbors come
59+
from a direct scipy call on the traced mesh grid (use ``AdaptSplitPower`` there).
60+
61+
Parameters
62+
----------
63+
inner_coefficient
64+
The inner regularization coefficient which controls the degree of smoothing of the inversion
65+
reconstruction in the inner (high signal) regions of a mesh's reconstruction.
66+
outer_coefficient
67+
The outer regularization coefficient which controls the degree of smoothing of the inversion
68+
reconstruction in the outer (low signal) regions of a mesh's reconstruction.
69+
signal_scale
70+
A factor which controls how rapidly the smoothness of regularization varies from high signal regions
71+
to low signal regions.
72+
power
73+
The exponent the interpolated coefficient is raised to before the matrix builder squares it, so the
74+
coefficient enters the regularization matrix at the power ``2 * power``. The default ``1.0`` is the
75+
``Constant`` convention; ``2.0`` is the legacy ``Adapt`` convention. This is a convention switch, not
76+
a model parameter -- the shipped prior config fixes it as a ``Constant`` prior so a search never
77+
samples it.
78+
"""
79+
super().__init__(
80+
inner_coefficient=inner_coefficient,
81+
outer_coefficient=outer_coefficient,
82+
signal_scale=signal_scale,
83+
)
84+
85+
self.power = power
86+
87+
def regularization_weights_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray:
88+
"""
89+
Returns the regularization weights of this regularization scheme.
90+
91+
These are the interpolated inner / outer coefficients raised to ``self.power`` (default ``1.0``), as
92+
opposed to ``Adapt``, which squares them.
93+
94+
Parameters
95+
----------
96+
linear_obj
97+
The linear object (e.g. a ``Mapper``) which uses these weights when performing regularization.
98+
99+
Returns
100+
-------
101+
The regularization weights.
102+
"""
103+
pixel_signals = linear_obj.pixel_signals_from(
104+
signal_scale=self.signal_scale, xp=xp
105+
)
106+
107+
return adapt_regularization_weights_from(
108+
inner_coefficient=self.inner_coefficient,
109+
outer_coefficient=self.outer_coefficient,
110+
pixel_signals=pixel_signals,
111+
power=self.power,
112+
)
113+
114+
def regularization_matrix_from(self, linear_obj: LinearObj, xp=np) -> np.ndarray:
115+
"""
116+
Returns the regularization matrix with shape [pixels, pixels].
117+
118+
Every mesh edge is scattered once (unlike ``Adapt``), so uniform weights reproduce ``Constant`` exactly.
119+
120+
Parameters
121+
----------
122+
linear_obj
123+
The linear object (e.g. a ``Mapper``) which uses this matrix to perform regularization.
124+
125+
Returns
126+
-------
127+
The regularization matrix.
128+
"""
129+
regularization_weights = self.regularization_weights_from(
130+
linear_obj=linear_obj, xp=xp
131+
)
132+
133+
return weighted_regularization_matrix_single_scatter_from(
134+
regularization_weights=regularization_weights,
135+
neighbors=linear_obj.mesh_geometry.neighbors,
136+
xp=xp,
137+
)

‎autoarray/inversion/regularization/adapt_split.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,26 @@ def __init__(
6767
mesh family (e.g. the KNN meshes), structurally incompatible with the
6868
rectangular meshes. Note the defaults
6969
``inner_coefficient == outer_coefficient == 1.0`` make the weighting
70-
uniform — numerically identical to ``ConstantSplit(coefficient=1.0)``.
70+
uniform — but *not* numerically identical to ``ConstantSplit(coefficient=1.0)``;
71+
see the coefficient-convention note below.
72+
73+
**Coefficient convention (legacy, ``lambda^4``).** The coefficients are squared twice before they
74+
reach the regularization matrix -- once by ``adapt_regularization_weights_from`` and once by the
75+
matrix builder -- so the matrix scales as the *fourth* power of the coefficient, while
76+
``Constant`` scales as the second. Both carry the same ``LogUniform(1e-6, 1e6)`` prior, so this
77+
scheme explores a far wider effective smoothing range and reaches a numerically non
78+
positive-definite matrix from ``c ~ 1e4`` where ``Constant`` survives to ``c ~ 1e6``.
79+
80+
The split family does **not** carry the factor-2 scatter asymmetry of ``Adapt``:
81+
it shares ``pixel_splitted_regularization_matrix_from`` with ``ConstantSplit``, so the
82+
coefficient exponent is the only difference between the two.
83+
84+
This behaviour is preserved deliberately: changing it would alter the coefficient scale of every
85+
adaptive fit ever run. **New work should prefer ``AdaptSplitPower``**, which takes a ``power`` argument
86+
(default ``1.0``, giving the ``Constant``-matching ``lambda^2`` convention), so
87+
``AdaptSplitPower(inner=outer=c)`` equals ``ConstantSplit(c)`` exactly and is more robust to
88+
gradient / NaN pathologies. The migration is ``c_new = c_old ** 2``, and
89+
``AdaptSplitPower(power=2.0)`` reproduces this class's coefficient scaling exactly.
7190
7291
Parameters
7392
----------

0 commit comments

Comments
 (0)