|
| 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 |
0 commit comments