Skip to content

Commit e0f1e5a

Browse files
Jammy2211claude
authored andcommitted
Add RectangularKernelAdaptDensity/Image: kernel-density CDF mesh variants
Opt-in adaptive rectangular meshes whose per-axis CDF transform is a smooth kernel-density CDF F(x) = sum_i w_i * Phi((x - x_i)/h) (Enzi et al. arXiv:2606.30620 RTU formulation) instead of the empirical point-rank CDF. Strictly monotone by construction, C-infinity in interp queries and traced point positions (no ranks, no sorts), duplicate-safe — so mass/shear gradients are smooth in every configuration, including imaging at pixelization over-sampling 1 and the interferometer sparse path where the empirical CDF makes the likelihood exactly piecewise-constant. Forward transform is evaluated exactly (unit square <-> data bounding box, matching the linear convention); inverse is a fixed n_knots table lookup whose queries are unit-grid constants, so gradients flow through the table values. Bandwidth defaults to the mesh pixel scale and is exposed as a mesh kwarg. Linear and spline meshes are untouched. Closes nothing; certification via autolens_workspace_test jax_grad scripts follows on the workspace leg (#373). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e0cb2fa commit e0f1e5a

7 files changed

Lines changed: 920 additions & 1 deletion

File tree

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
"""
2+
Kernel-density CDF variant of the rectangular adaptive interpolator.
3+
4+
The adaptive rectangular mesh transforms source-plane coordinates through a
5+
per-axis CDF so that the uniform mesh pixels adapt to the density (or weight)
6+
of the traced points. The "linear" variant in ``rectangular.py`` uses a
7+
piecewise-linear empirical CDF whose knots are the traced points themselves:
8+
whenever interp queries coincide with knots (imaging at pixelization
9+
over-sampling 1, the interferometer sparse path) the likelihood becomes
10+
exactly piecewise-constant in the mass/shear parameters, and every rank swap
11+
between traced points leaves a kink even when gradients do flow.
12+
13+
This module replaces the empirical CDF with a smooth kernel-density CDF
14+
(the RTU formulation of Enzi et al., arXiv:2606.30620): per axis
15+
16+
F(x) = sum_i w_i * Phi((x - x_i) / h)
17+
18+
where ``Phi`` is the standard normal CDF, ``x_i`` the traced points, ``w_i``
19+
uniform weights (density adaption) or the normalized adapt-image weights
20+
(image adaption), and ``h`` a bandwidth tied to the mesh resolution.
21+
22+
Properties, by construction:
23+
24+
- strictly monotone (no jitter hack, no ``_enforce_strict_monotone``);
25+
- C-infinity in the queries AND the traced-point positions — no ranks, no
26+
sorts, no ``argsort`` anywhere, so there is nothing to swap;
27+
- duplicate-safe: coincident traced points simply stack their weights (no
28+
``1 / Δknot`` terms).
29+
30+
The forward transform is evaluated exactly at the query points (keeping the
31+
C-infinity guarantee) and rescaled so the data bounding box maps onto the
32+
unit square exactly — matching the linear variant's convention, including
33+
clamping to [0, 1] outside the data range. The inverse — only needed at
34+
fixed unit-square grid values (mesh pixel centres/edges) — is a linear-interp
35+
lookup on a small fixed table of ``n_knots`` knots spanning the data range;
36+
because the inverse queries are constants, gradients flow smoothly through
37+
the table values.
38+
39+
The linear meshes in ``rectangular.py`` and the spline meshes in
40+
``rectangular_spline.py`` are untouched; per the pattern established there,
41+
the bilinear-weights steps are copied rather than shared so the variants stay
42+
independently auditable.
43+
"""
44+
import numpy as np
45+
from functools import partial
46+
from typing import Optional
47+
48+
from autoconf import cached_property
49+
50+
from autoarray.inversion.mesh.interpolator.rectangular import (
51+
InterpolatorRectangular,
52+
reverse_interp,
53+
reverse_interp_np,
54+
)
55+
56+
57+
KERNEL_CDF_DEFAULT_BANDWIDTH: float = 1.0
58+
KERNEL_CDF_DEFAULT_KNOTS: int = 64
59+
60+
_SQRT2 = np.sqrt(2.0)
61+
62+
63+
def _norm_cdf(t, xp):
64+
"""Standard normal CDF, xp-aware (scipy erf on numpy, jax.scipy under jax)."""
65+
if xp is np:
66+
from scipy.special import erf
67+
else:
68+
from jax.scipy.special import erf
69+
70+
return 0.5 * (1.0 + erf(t / _SQRT2))
71+
72+
73+
def create_transforms_kernel(
74+
traced_points,
75+
mesh_pixels: int,
76+
mesh_weight_map=None,
77+
bandwidth: float = KERNEL_CDF_DEFAULT_BANDWIDTH,
78+
n_knots: int = KERNEL_CDF_DEFAULT_KNOTS,
79+
xp=np,
80+
):
81+
"""
82+
Build the per-axis kernel-density CDF transform pair.
83+
84+
Drop-in for ``rectangular.create_transforms``: the returned ``transform``
85+
maps (scaled) source-plane coordinates into the unit square and
86+
``inv_transform`` maps unit-square coordinates back to the (scaled)
87+
source plane.
88+
89+
Parameters
90+
----------
91+
traced_points
92+
The (N, 2) scaled source-plane coordinates the CDF adapts to.
93+
mesh_pixels
94+
Mesh pixels per axis; sets the default bandwidth scale
95+
``h = bandwidth * data_span / mesh_pixels`` per axis.
96+
mesh_weight_map
97+
Optional (N,) weights from the adapt image (image adaption). ``None``
98+
gives uniform weights (density adaption).
99+
bandwidth
100+
Bandwidth in units of the mesh pixel scale (data span / mesh_pixels).
101+
Smaller values track the point density more sharply (approaching the
102+
empirical CDF and its staircase); larger values smooth the mesh
103+
geometry towards uniform.
104+
n_knots
105+
Size of the fixed knot table used to invert the CDF.
106+
xp
107+
The array library to use (numpy or jax.numpy).
108+
"""
109+
points = traced_points
110+
N = points.shape[0]
111+
112+
if mesh_weight_map is None:
113+
w = xp.full((N,), 1.0 / N)
114+
else:
115+
w = mesh_weight_map / xp.sum(mesh_weight_map)
116+
117+
lo = xp.min(points, axis=0)
118+
hi = xp.max(points, axis=0)
119+
span = hi - lo
120+
h = bandwidth * span / mesh_pixels
121+
122+
def F_raw(q):
123+
t = (q[:, None, :] - points[None, :, :]) / h[None, None, :]
124+
return xp.sum(w[None, :, None] * _norm_cdf(t, xp), axis=1)
125+
126+
# The unit square maps onto the data bounding box exactly, matching the
127+
# linear variant's convention (its empirical-CDF knots end at the extreme
128+
# points); the kernel tails outside [lo, hi] are absorbed by the rescale.
129+
u = xp.linspace(0.0, 1.0, n_knots)
130+
knots = lo[None, :] + u[:, None] * span[None, :]
131+
132+
F_knots_raw = F_raw(knots)
133+
F_lo = F_knots_raw[0]
134+
F_hi = F_knots_raw[-1]
135+
denom = F_hi - F_lo
136+
137+
F_knots = (F_knots_raw - F_lo[None, :]) / denom[None, :]
138+
139+
def transform(q):
140+
F_q = (F_raw(q) - F_lo[None, :]) / denom[None, :]
141+
return xp.clip(F_q, 0.0, 1.0)
142+
143+
if xp.__name__.startswith("jax"):
144+
inv_transform = partial(reverse_interp, F_knots, knots)
145+
return transform, inv_transform
146+
147+
inv_transform = partial(reverse_interp_np, F_knots, knots)
148+
return transform, inv_transform
149+
150+
151+
def adaptive_rectangular_transformed_grid_from_kernel(
152+
data_grid,
153+
grid,
154+
mesh_pixels: int,
155+
mesh_weight_map=None,
156+
bandwidth: float = KERNEL_CDF_DEFAULT_BANDWIDTH,
157+
n_knots: int = KERNEL_CDF_DEFAULT_KNOTS,
158+
xp=np,
159+
):
160+
"""Kernel-CDF version of ``rectangular.adaptive_rectangular_transformed_grid_from``."""
161+
mu = data_grid.mean(axis=0)
162+
scale = data_grid.std(axis=0).min()
163+
source_grid_scaled = (data_grid - mu) / scale
164+
165+
_, inv_transform = create_transforms_kernel(
166+
source_grid_scaled,
167+
mesh_pixels=mesh_pixels,
168+
mesh_weight_map=mesh_weight_map,
169+
bandwidth=bandwidth,
170+
n_knots=n_knots,
171+
xp=xp,
172+
)
173+
174+
def inv_full(U):
175+
return inv_transform(U) * scale + mu
176+
177+
return inv_full(grid)
178+
179+
180+
def adaptive_rectangular_areas_from_kernel(
181+
source_grid_shape,
182+
data_grid,
183+
mesh_weight_map=None,
184+
bandwidth: float = KERNEL_CDF_DEFAULT_BANDWIDTH,
185+
n_knots: int = KERNEL_CDF_DEFAULT_KNOTS,
186+
xp=np,
187+
):
188+
"""Kernel-CDF version of ``mesh_geometry.rectangular.adaptive_rectangular_areas_from``."""
189+
edges_y = xp.linspace(1, 0, source_grid_shape[0] + 1)
190+
edges_x = xp.linspace(0, 1, source_grid_shape[1] + 1)
191+
192+
mu = data_grid.mean(axis=0)
193+
scale = data_grid.std(axis=0).min()
194+
source_grid_scaled = (data_grid - mu) / scale
195+
196+
_, inv_transform = create_transforms_kernel(
197+
source_grid_scaled,
198+
mesh_pixels=source_grid_shape[0],
199+
mesh_weight_map=mesh_weight_map,
200+
bandwidth=bandwidth,
201+
n_knots=n_knots,
202+
xp=xp,
203+
)
204+
205+
def inv_full(U):
206+
return inv_transform(U) * scale + mu
207+
208+
pixel_edges = inv_full(xp.stack([edges_y, edges_x]).T)
209+
pixel_lengths = xp.diff(pixel_edges, axis=0).squeeze()
210+
211+
dy = pixel_lengths[:, 0]
212+
dx = pixel_lengths[:, 1]
213+
214+
return xp.abs(xp.outer(dy, dx).flatten())
215+
216+
217+
def adaptive_rectangular_mappings_weights_via_interpolation_from_kernel(
218+
source_grid_size: int,
219+
data_grid,
220+
data_grid_over_sampled,
221+
mesh_weight_map=None,
222+
bandwidth: float = KERNEL_CDF_DEFAULT_BANDWIDTH,
223+
n_knots: int = KERNEL_CDF_DEFAULT_KNOTS,
224+
xp=np,
225+
):
226+
"""Kernel-CDF version of the linear helper in ``rectangular.py``.
227+
228+
Steps 1–2 build the kernel transforms. Steps 3–7 (floor/ceil, flatten,
229+
bilinear weights) are identical to the linear path — copied here to keep
230+
the variants independently auditable.
231+
"""
232+
mu = data_grid.mean(axis=0)
233+
scale = data_grid.std(axis=0).min()
234+
source_grid_scaled = (data_grid - mu) / scale
235+
236+
transform, _ = create_transforms_kernel(
237+
source_grid_scaled,
238+
mesh_pixels=source_grid_size,
239+
mesh_weight_map=mesh_weight_map,
240+
bandwidth=bandwidth,
241+
n_knots=n_knots,
242+
xp=xp,
243+
)
244+
245+
grid_over_sampled_scaled = (data_grid_over_sampled - mu) / scale
246+
grid_over_sampled_transformed = transform(grid_over_sampled_scaled)
247+
grid_over_index = (source_grid_size - 3) * grid_over_sampled_transformed + 1
248+
249+
ix_down = xp.floor(grid_over_index[:, 0])
250+
ix_up = xp.ceil(grid_over_index[:, 0])
251+
iy_down = xp.floor(grid_over_index[:, 1])
252+
iy_up = xp.ceil(grid_over_index[:, 1])
253+
254+
idx_tl = xp.stack([ix_up, iy_down], axis=1)
255+
idx_tr = xp.stack([ix_up, iy_up], axis=1)
256+
idx_br = xp.stack([ix_down, iy_up], axis=1)
257+
idx_bl = xp.stack([ix_down, iy_down], axis=1)
258+
259+
def flatten(idx, n):
260+
return (n - idx[:, 0]) * n + idx[:, 1]
261+
262+
flat_tl = flatten(idx_tl, source_grid_size)
263+
flat_tr = flatten(idx_tr, source_grid_size)
264+
flat_bl = flatten(idx_bl, source_grid_size)
265+
flat_br = flatten(idx_br, source_grid_size)
266+
267+
flat_indices = xp.stack([flat_tl, flat_tr, flat_bl, flat_br], axis=1).astype(
268+
"int64"
269+
)
270+
271+
t_row = (grid_over_index[:, 0] - ix_down) / (ix_up - ix_down + 1e-12)
272+
t_col = (grid_over_index[:, 1] - iy_down) / (iy_up - iy_down + 1e-12)
273+
274+
w_tl = (1 - t_row) * (1 - t_col)
275+
w_tr = (1 - t_row) * t_col
276+
w_bl = t_row * (1 - t_col)
277+
w_br = t_row * t_col
278+
weights = xp.stack([w_tl, w_tr, w_bl, w_br], axis=1)
279+
280+
return flat_indices, weights
281+
282+
283+
class InterpolatorRectangularKernel(InterpolatorRectangular):
284+
"""Kernel-density-CDF adaptive rectangular interpolator.
285+
286+
Subclasses :class:`InterpolatorRectangular` so that existing
287+
``isinstance(..., InterpolatorRectangular)`` dispatch sites (e.g.
288+
:mod:`autoarray.plot.inversion`) treat it as an adaptive rectangular
289+
interpolator, and the source-plane mesh reconstruction renders through
290+
the same ``pcolormesh`` path.
291+
"""
292+
293+
def __init__(
294+
self,
295+
mesh,
296+
mesh_grid,
297+
data_grid,
298+
mesh_weight_map,
299+
adapt_data: Optional[np.ndarray] = None,
300+
bandwidth: float = KERNEL_CDF_DEFAULT_BANDWIDTH,
301+
n_knots: int = KERNEL_CDF_DEFAULT_KNOTS,
302+
xp=np,
303+
):
304+
super().__init__(
305+
mesh=mesh,
306+
mesh_grid=mesh_grid,
307+
data_grid=data_grid,
308+
mesh_weight_map=mesh_weight_map,
309+
adapt_data=adapt_data,
310+
xp=xp,
311+
)
312+
self.bandwidth = bandwidth
313+
self.n_knots = n_knots
314+
315+
@cached_property
316+
def mesh_geometry(self):
317+
from autoarray.inversion.mesh.mesh_geometry.rectangular import (
318+
MeshGeometryRectangular,
319+
)
320+
321+
return MeshGeometryRectangular(
322+
mesh=self.mesh,
323+
mesh_grid=self.mesh_grid,
324+
data_grid=self.data_grid,
325+
mesh_weight_map=self.mesh_weight_map,
326+
kernel_bandwidth=self.bandwidth,
327+
kernel_knots=self.n_knots,
328+
xp=self._xp,
329+
)
330+
331+
@cached_property
332+
def _mappings_sizes_weights(self):
333+
mappings, weights = (
334+
adaptive_rectangular_mappings_weights_via_interpolation_from_kernel(
335+
source_grid_size=self.mesh.shape[0],
336+
data_grid=self.data_grid.array,
337+
data_grid_over_sampled=self.data_grid.over_sampled.array,
338+
mesh_weight_map=self.mesh_weight_map,
339+
bandwidth=self.bandwidth,
340+
n_knots=self.n_knots,
341+
xp=self._xp,
342+
)
343+
)
344+
345+
sizes = 4 * self._xp.ones(len(mappings), dtype="int")
346+
347+
return mappings, sizes, weights

autoarray/inversion/mesh/mesh/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from .abstract import AbstractMesh as Mesh
22
from .rectangular_adapt_density import RectangularAdaptDensity
33
from .rectangular_adapt_image import RectangularAdaptImage
4+
from .rectangular_kernel_adapt_density import RectangularKernelAdaptDensity
5+
from .rectangular_kernel_adapt_image import RectangularKernelAdaptImage
46
from .rectangular_spline_adapt_density import RectangularSplineAdaptDensity
57
from .rectangular_spline_adapt_image import RectangularSplineAdaptImage
68
from .rectangular_rotated_adapt_image import RectangularRotatedAdaptImage

0 commit comments

Comments
 (0)