1010
1111
1212def 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
5162def 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+
132228class 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 ----------
0 commit comments