Skip to content

Commit 0bc03d4

Browse files
Jammy2211claude
andcommitted
feat: exact kNN mesh magnification areas by quadrature of the interpolant
KNearestNeighbor and KNNBarycentric used the Delaunay barycentric dual areas, which integrate only the linear Delaunay interpolant exactly. Their own interpolants differ, biasing magnification by up to +6.10% lensed and -5.11% unlensed against simulator truth. New MeshGeometryKNearestNeighbor (built by the kNN interpolators) computes areas_for_magnification as the integral of each vertex's basis function over the convex hull: the interpolator's own weights at the centres of a 256^2 grid, masked to the hull, summed per vertex and rescaled to the hull area. Host-side and cached on first access, like the Delaunay fallback, so fits never pay for it. Identity-lens error: KNN 1.6e-2 -> 1.3e-5, KNNBarycentric 4.4e-2 -> 1.8e-3. The partial-block neighbour bug (#609) is deliberately not fixed here; the quadrature integrates whatever neighbours the interpolator selects. Closes #665 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7fb3805 commit 0bc03d4

3 files changed

Lines changed: 117 additions & 7 deletions

File tree

‎autoarray/inversion/mesh/interpolator/knn.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,25 @@ class InterpolatorKNearestNeighbor(InterpolatorDelaunay):
150150
# Excluding them would change pixel signals broadly and add a jump in the likelihood (PyAutoArray#627).
151151
exclude_zero_weight_mappings = False
152152

153+
@cached_property
154+
def mesh_geometry(self):
155+
"""
156+
The kNN geometry: Delaunay vertex geometry whose
157+
`areas_for_magnification` integrates this interpolator's own basis
158+
functions (see `MeshGeometryKNearestNeighbor`).
159+
"""
160+
from autoarray.inversion.mesh.mesh_geometry.delaunay import (
161+
MeshGeometryKNearestNeighbor,
162+
)
163+
164+
return MeshGeometryKNearestNeighbor(
165+
mesh=self.mesh,
166+
mesh_grid=self.mesh_grid,
167+
data_grid=self.data_grid,
168+
interpolator_cls=type(self),
169+
xp=self._xp,
170+
)
171+
153172
@property
154173
def dual_areas(self):
155174
"""

‎autoarray/inversion/mesh/mesh_geometry/delaunay.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,87 @@ def areas_for_magnification(self) -> np.ndarray:
271271
scipy.spatial.Delaunay(mesh_grid_xy).simplices,
272272
xp=np,
273273
)
274+
275+
276+
# Quadrature cells per axis for `MeshGeometryKNearestNeighbor.areas_for_magnification`.
277+
# On a 144-vertex mesh the identity-lens magnification moved by < 3e-5 between
278+
# 200, 400 and 800 cells per axis (PyAutoArray#665); 256 costs ~1 s there.
279+
KNN_AREA_QUADRATURE_PIXELS: int = 256
280+
281+
282+
class MeshGeometryKNearestNeighbor(MeshGeometryDelaunay):
283+
def __init__(self, mesh, mesh_grid, data_grid, interpolator_cls, xp=np, **kwargs):
284+
"""
285+
The geometry of the kNN meshes (`KNearestNeighbor`, `KNNBarycentric`):
286+
Delaunay vertex geometry, but magnification areas that integrate the
287+
kNN interpolant itself.
288+
289+
Parameters
290+
----------
291+
interpolator_cls
292+
The kNN interpolator class that built this geometry; its basis
293+
functions are evaluated on a quadrature grid to compute
294+
`areas_for_magnification`.
295+
"""
296+
super().__init__(
297+
mesh=mesh,
298+
mesh_grid=mesh_grid,
299+
data_grid=data_grid,
300+
dual_areas=None,
301+
xp=xp,
302+
**kwargs,
303+
)
304+
305+
self.interpolator_cls = interpolator_cls
306+
307+
@cached_property
308+
def areas_for_magnification(self) -> np.ndarray:
309+
"""
310+
The integral of every vertex's interpolation basis function over the
311+
convex hull of the mesh, so `sum(reconstruction * areas_for_magnification)`
312+
is the integral of the reconstructed source.
313+
314+
The Delaunay barycentric dual areas are these integrals only for the linear
315+
barycentric interpolant. The Wendland-kNN and 3-nearest barycentric
316+
interpolants are not linear on the Delaunay triangles, and weighting by the
317+
dual areas biased their magnification by up to ~6% (PyAutoArray#665).
318+
319+
The integral is a midpoint quadrature: the interpolator's own mapping
320+
weights are evaluated at the centres of a uniform
321+
`KNN_AREA_QUADRATURE_PIXELS`^2 grid over the vertices' bounding box, the
322+
centres outside the convex hull are dropped, and the weights times the
323+
cell area are summed per vertex. The total is rescaled to the hull area,
324+
which removes the cells straddling the hull edge from the sum (the same
325+
domain the Delaunay dual areas tile).
326+
327+
Computed host-side on first access, like the Delaunay dual-area fallback.
328+
"""
329+
import scipy.spatial
330+
331+
nodes = np.asarray(self.mesh_grid_xy)
332+
lo = nodes.min(axis=0)
333+
hi = nodes.max(axis=0)
334+
335+
n = KNN_AREA_QUADRATURE_PIXELS
336+
u = (np.arange(n) + 0.5) / n
337+
grid_0, grid_1 = np.meshgrid(
338+
lo[0] + u * (hi[0] - lo[0]), lo[1] + u * (hi[1] - lo[1]), indexing="ij"
339+
)
340+
points = np.stack([grid_0.ravel(), grid_1.ravel()], axis=1)
341+
points = points[scipy.spatial.Delaunay(nodes).find_simplex(points) >= 0]
342+
343+
interpolator = self.interpolator_cls(
344+
mesh=self.mesh,
345+
mesh_grid=self.mesh_grid,
346+
data_grid=points,
347+
xp=np,
348+
)
349+
mappings, _, weights = interpolator._mappings_sizes_weights
350+
351+
areas = np.bincount(
352+
np.asarray(mappings).ravel(),
353+
weights=np.asarray(weights).ravel(),
354+
minlength=nodes.shape[0],
355+
)
356+
357+
return areas * scipy.spatial.ConvexHull(nodes).volume / np.sum(areas)

‎test_autoarray/inversion/pixelization/mesh_geometry/test_areas_for_magnification.py‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,18 @@
7676
# not the integral of the node's hat once the CDF bends across the cell (the
7777
# rank CDF's staircase on tied coordinates; a steep adapt-image CDF).
7878
#
79-
# Documented approximations (flagged in PyAutoArray#659; phase 4b):
80-
# - DelaunayNN / KNearestNeighbor / KNNBarycentric inherit the Delaunay dual
81-
# areas, which are exact only for the linear interpolant; for the Sibson,
82-
# Wendland-kNN and 3-nearest-barycentric interpolants they are an approximation
83-
# [-4.3e-3, +1.6e-2, -4.4e-2].
79+
# - KNearestNeighbor / KNNBarycentric: the integral of each vertex's kNN basis
80+
# function over the convex hull, by midpoint quadrature of the interpolator's
81+
# own weights (`MeshGeometryKNearestNeighbor`, PyAutoArray#665)
82+
# [+1.3e-5, +1.8e-3; KNNBarycentric's clipped weights are discontinuous, so
83+
# the coarse sub-pixel `Q` is the limit there]. The Delaunay dual areas they
84+
# used before measured [+1.6e-2, -4.4e-2]. The quadrature integrates whatever
85+
# neighbours the interpolator selects, so it stays exact with or without the
86+
# open partial-block neighbour bug PyAutoArray#609.
87+
#
88+
# Documented approximation:
89+
# - DelaunayNN inherits the Delaunay dual areas, exact only for the linear
90+
# interpolant; for its Sibson interpolant they are an approximation [-4.3e-3].
8491
MAGNIFICATION_TOLERANCE = {
8592
"RectangularUniform": 2.5e-3,
8693
"RectangularBilinearAdaptDensity": 1.0e-3,
@@ -89,8 +96,8 @@
8996
"RectangularRTUAdaptImage": 1.0e-2,
9097
"Delaunay": 3.0e-4,
9198
"DelaunayNN": 1.0e-2,
92-
"KNearestNeighbor": 3.0e-2,
93-
"KNNBarycentric": 7.0e-2,
99+
"KNearestNeighbor": 1.0e-4,
100+
"KNNBarycentric": 4.0e-3,
94101
}
95102

96103
RECTANGULAR_ADAPTIVE = [

0 commit comments

Comments
 (0)