Skip to content

Commit 7ecc6ef

Browse files
author
James Nightingale
authored
Merge pull request #641 from PyAutoLabs/feature/sparse-mapped-reconstructed-image
feat: sparse mapped reconstructed image on the interferometer sparse inversion path
2 parents 1dec833 + 422f60a commit 7ecc6ef

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

‎autoarray/inversion/inversion/interferometer/sparse.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from autoarray.inversion.mappers import mapper_util
1515
from autoarray.settings import Settings
1616
from autoarray.inversion.mappers.abstract import Mapper
17+
from autoarray.structures.arrays.uniform_2d import Array2D
1718
from autoarray.structures.visibilities import Visibilities
1819

1920
from autoarray.inversion.inversion import inversion_util
@@ -513,6 +514,93 @@ def _curvature_matrix_func_list_and_mapper(self) -> np.ndarray:
513514

514515
return curvature_matrix
515516

517+
@property
518+
def mapped_reconstructed_data_dict(
519+
self,
520+
) -> Dict[LinearObj, Array2D]:
521+
"""
522+
When constructing the simultaneous linear equations (via vectors and matrices) the quantities of each individual
523+
linear object (e.g. their `mapping_matrix`) are combined into single ndarrays. This does not track which
524+
quantities belong to which linear objects, therefore the linear equation's solutions (which are returned as
525+
ndarrays) do not contain information on which linear object(s) they correspond to.
526+
527+
This function converts an ndarray of a `reconstruction` to a dictionary of ndarrays containing each linear
528+
object's reconstructed image (in real space, before the non-uniform fast Fourier transform), where the keys
529+
are the instances of each linear object in the inversion.
530+
531+
Each linear object's reconstructed image is `L s`, where `L` is its real-space mapping matrix and `s` its
532+
block of the `reconstruction`. This is the mirror of the sparse `data_vector` (`Lᵀ d~`):
533+
534+
- For a `Mapper` the image `A s` is computed from the mapper's sparse COO triplets
535+
(`_sparse_triplets_slim_from`), as a scatter-add of `vals * s[cols]` over the slim image pixel
536+
rows. The dense (masked pixels x source pixels) mapping matrix is therefore never built, which
537+
on a 384 x 384 unmasked grid with a 48² source mesh is 2.7 GB.
538+
- For an `AbstractLinearObjFuncList` the mapping matrix is small (one column per linear function),
539+
so the dense `mapping_matrix @ reconstruction` product is used directly, as in the inherited
540+
implementation.
541+
542+
The result is used by `mapped_reconstructed_operated_data_dict` (model visibilities) and by fit
543+
outputs such as galaxy image dictionaries.
544+
"""
545+
mapped_reconstructed_data_dict = {}
546+
547+
reconstruction_dict = self.source_quantity_dict_from(
548+
source_quantity=self.reconstruction
549+
)
550+
551+
for linear_obj in self.linear_obj_list:
552+
reconstruction = reconstruction_dict[linear_obj]
553+
554+
if isinstance(linear_obj, Mapper):
555+
mapped_reconstructed_data = self._mapped_reconstructed_data_mapper_from(
556+
mapper=linear_obj, reconstruction=reconstruction
557+
)
558+
else:
559+
mapped_reconstructed_data = (
560+
inversion_util.mapped_reconstructed_data_via_mapping_matrix_from(
561+
mapping_matrix=linear_obj.mapping_matrix,
562+
reconstruction=reconstruction,
563+
xp=self._xp,
564+
)
565+
)
566+
567+
mapped_reconstructed_data = Array2D(
568+
values=mapped_reconstructed_data, mask=self.mask
569+
)
570+
571+
mapped_reconstructed_data_dict[linear_obj] = mapped_reconstructed_data
572+
573+
return mapped_reconstructed_data_dict
574+
575+
def _mapped_reconstructed_data_mapper_from(
576+
self, mapper: Mapper, reconstruction
577+
) -> np.ndarray:
578+
"""
579+
Returns a mapper's reconstructed image, `A s`, computed from the sparse triplets of its mapping
580+
operator `A` without building the dense mapping matrix.
581+
582+
Entry `i` (a slim image pixel) is the sum over the non-zeros of `A`'s row `i` of `val * s[col]`,
583+
i.e. a scatter-add of `vals * reconstruction[cols]` over the slim image pixel rows. This is the
584+
transpose of the segment-sum performed by `_data_vector_mapper_from`.
585+
586+
Parameters
587+
----------
588+
mapper
589+
The mapper whose reconstructed image is computed.
590+
reconstruction
591+
The mapper's block of the `reconstruction` (its source pixel values `s`).
592+
"""
593+
rows, cols, vals = self._sparse_triplets_slim_from(mapper=mapper)
594+
595+
contrib = vals * reconstruction[cols]
596+
597+
n_slim = self.mask.pixels_in_mask
598+
599+
if self._xp is np:
600+
return np.bincount(rows, weights=contrib, minlength=n_slim)
601+
602+
return self._xp.zeros(n_slim, dtype=contrib.dtype).at[rows].add(contrib)
603+
516604
@property
517605
def mapped_reconstructed_operated_data_dict(
518606
self,

‎test_autoarray/inversion/inversion/interferometer/test_interferometer.py‎

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1898,3 +1898,221 @@ def test__interferometer_sparse_operator__data_vector_from_triplets_matches_dens
18981898
rtol=1.0e-12,
18991899
atol=1.0e-12 * np.abs(reference).max(),
19001900
)
1901+
1902+
1903+
def _rectangular_mapper_from(mask, shape, over_sample_size):
1904+
from autoarray.inversion.mesh.mesh.rectangular_rtu_adapt_density import (
1905+
overlay_grid_from,
1906+
)
1907+
1908+
grid = aa.Grid2D.from_mask(mask=mask, over_sample_size=over_sample_size)
1909+
1910+
interpolator = aa.mesh.RectangularUniform(shape=shape).interpolator_from(
1911+
source_plane_data_grid=grid,
1912+
source_plane_mesh_grid=aa.Grid2DIrregular(
1913+
overlay_grid_from(shape_native=shape, grid=grid)
1914+
),
1915+
adapt_data=None,
1916+
)
1917+
1918+
return aa.Mapper(
1919+
interpolator=interpolator, regularization=aa.reg.Constant(coefficient=1.0)
1920+
)
1921+
1922+
1923+
def _mapped_reconstructed_parity_setup(seed):
1924+
"""
1925+
A 12x12 circular-mask TransformerDFT dataset with its sparse operator, and the linear object lists
1926+
the sparse `mapped_reconstructed_data_dict` is checked on: a Rectangular mesh, a Delaunay mesh,
1927+
over-sampled (sub_size 2) Rectangular and Delaunay meshes, two mappers, and a mapper plus a linear
1928+
function list (in either order).
1929+
"""
1930+
mask = aa.Mask2D.circular(shape_native=(12, 12), pixel_scales=1.0, radius=4.0)
1931+
1932+
rng = np.random.default_rng(seed=seed)
1933+
n_visibilities = 32
1934+
1935+
dataset = aa.Interferometer(
1936+
data=aa.Visibilities(
1937+
visibilities=rng.normal(size=(n_visibilities, 2)).astype(np.float64)
1938+
),
1939+
noise_map=aa.VisibilitiesNoiseMap(
1940+
visibilities=np.ones((n_visibilities, 2), dtype=np.float64)
1941+
),
1942+
uv_wavelengths=rng.normal(size=(n_visibilities, 2)).astype(np.float64),
1943+
real_space_mask=mask,
1944+
transformer_class=aa.TransformerDFT,
1945+
)
1946+
dataset_sparse = dataset.apply_sparse_operator(use_jax=False)
1947+
1948+
rectangular = _rectangular_mapper_from(mask=mask, shape=(4, 4), over_sample_size=1)
1949+
delaunay = _over_sampled_mapper_from(
1950+
mask=mask, pixels=16, shape=(4, 4), over_sample_size=1
1951+
)
1952+
rectangular_sub_2 = _rectangular_mapper_from(
1953+
mask=mask, shape=(5, 5), over_sample_size=2
1954+
)
1955+
delaunay_sub_2 = _over_sampled_mapper_from(
1956+
mask=mask, pixels=9, shape=(3, 3), over_sample_size=2
1957+
)
1958+
linear_obj = aa.m.MockLinearObjFuncList(
1959+
parameters=2,
1960+
mapping_matrix=rng.normal(size=(mask.pixels_in_mask, 2)),
1961+
)
1962+
1963+
linear_obj_lists = [
1964+
[rectangular],
1965+
[delaunay],
1966+
[rectangular_sub_2],
1967+
[delaunay_sub_2],
1968+
[delaunay_sub_2, rectangular],
1969+
[rectangular, linear_obj],
1970+
[linear_obj, delaunay_sub_2],
1971+
]
1972+
1973+
return mask, rng, dataset_sparse, linear_obj_lists
1974+
1975+
1976+
def _assert_mapped_reconstructed_data_dict_matches_dense(
1977+
image_dict, linear_obj_list, reconstruction, inversion, rtol
1978+
):
1979+
"""
1980+
Each entry of `image_dict` must equal the dense `mapping_matrix @ reconstruction` of its linear object,
1981+
where `reconstruction` is the (NumPy) reconstruction split per linear object.
1982+
"""
1983+
param_range_list = inversion.param_range_list_from(cls=aa.LinearObj)
1984+
1985+
for linear_obj, (start, end) in zip(linear_obj_list, param_range_list):
1986+
reference = np.asarray(linear_obj.mapping_matrix) @ np.asarray(
1987+
reconstruction[start:end]
1988+
)
1989+
1990+
np.testing.assert_allclose(
1991+
np.asarray(image_dict[linear_obj]),
1992+
reference,
1993+
rtol=rtol,
1994+
atol=rtol * np.abs(reference).max(),
1995+
)
1996+
1997+
1998+
def test__interferometer_sparse_operator__mapped_reconstructed_data_dict_matches_dense_mapping_matrix():
1999+
"""
2000+
`InversionInterferometerSparse.mapped_reconstructed_data_dict` reconstructs each mapper's image by a
2001+
scatter-add over the mapper's sparse triplets instead of the dense `mapping_matrix @ reconstruction`.
2002+
It must reproduce the dense product for Rectangular and Delaunay meshes, over-sampled mappers, two
2003+
mappers and a mapper mixed with a linear function list, and its values must be wrapped as an
2004+
`Array2D` on the inversion's real-space mask (so the model visibilities are unchanged).
2005+
"""
2006+
mask, rng, dataset_sparse, linear_obj_lists = _mapped_reconstructed_parity_setup(
2007+
seed=7
2008+
)
2009+
2010+
for linear_obj_list in linear_obj_lists:
2011+
inversion = aa.Inversion(
2012+
dataset=dataset_sparse, linear_obj_list=linear_obj_list
2013+
)
2014+
2015+
assert isinstance(inversion, aa.InversionInterferometerSparse)
2016+
2017+
image_dict = inversion.mapped_reconstructed_data_dict
2018+
2019+
for linear_obj in linear_obj_list:
2020+
assert isinstance(image_dict[linear_obj], aa.Array2D)
2021+
assert (image_dict[linear_obj].mask == mask).all()
2022+
2023+
_assert_mapped_reconstructed_data_dict_matches_dense(
2024+
image_dict=image_dict,
2025+
linear_obj_list=linear_obj_list,
2026+
reconstruction=np.asarray(inversion.reconstruction),
2027+
inversion=inversion,
2028+
rtol=1.0e-10,
2029+
)
2030+
2031+
# The model visibilities consume the dict and must equal the transform of the dense image.
2032+
visibilities_dict = inversion.mapped_reconstructed_operated_data_dict
2033+
2034+
for linear_obj in linear_obj_list:
2035+
reference = dataset_sparse.transformer.visibilities_from(
2036+
image=aa.Array2D(
2037+
values=np.asarray(linear_obj.mapping_matrix)
2038+
@ np.asarray(inversion.reconstruction_dict[linear_obj]),
2039+
mask=mask,
2040+
)
2041+
)
2042+
2043+
np.testing.assert_allclose(
2044+
np.asarray(visibilities_dict[linear_obj]),
2045+
np.asarray(reference),
2046+
rtol=1.0e-10,
2047+
atol=1.0e-10 * np.abs(np.asarray(reference)).max(),
2048+
)
2049+
2050+
2051+
def test__interferometer_sparse_operator__mapped_reconstructed_data_dict_matches_dense_mapping_matrix__jax_jit():
2052+
"""
2053+
The JAX backend of the sparse mapped reconstructed image (a `.at[rows].add` scatter over image pixels)
2054+
must reproduce the dense `mapping_matrix @ reconstruction` under `jax.jit`, with the dirty image traced
2055+
so the reconstruction is computed inside the jitted function.
2056+
"""
2057+
jax = pytest.importorskip("jax")
2058+
2059+
import jax.numpy as jnp
2060+
2061+
mask, rng, dataset_sparse, linear_obj_lists = _mapped_reconstructed_parity_setup(
2062+
seed=9
2063+
)
2064+
2065+
dirty_image = np.asarray(dataset_sparse.sparse_operator.dirty_image)
2066+
2067+
def inversion_from(dirty_image, linear_obj_list, xp):
2068+
return aa.Inversion(
2069+
dataset=aa.DatasetInterface(
2070+
data=dataset_sparse.data,
2071+
noise_map=dataset_sparse.noise_map,
2072+
grids=dataset_sparse.grids,
2073+
transformer=dataset_sparse.transformer,
2074+
sparse_operator=dataset_sparse.sparse_operator,
2075+
sparse_dirty_image=dirty_image,
2076+
),
2077+
linear_obj_list=linear_obj_list,
2078+
xp=xp,
2079+
)
2080+
2081+
for linear_obj_list in linear_obj_lists:
2082+
inversion_np = inversion_from(dirty_image, linear_obj_list, xp=np)
2083+
2084+
assert isinstance(inversion_np, aa.InversionInterferometerSparse)
2085+
2086+
def images_and_reconstruction_from(dirty_image):
2087+
inversion = inversion_from(dirty_image, linear_obj_list, xp=jnp)
2088+
2089+
image_dict = inversion.mapped_reconstructed_data_dict
2090+
2091+
return (
2092+
[image_dict[linear_obj].array for linear_obj in linear_obj_list],
2093+
inversion.reconstruction,
2094+
)
2095+
2096+
image_list, reconstruction = jax.jit(images_and_reconstruction_from)(
2097+
jnp.asarray(dirty_image)
2098+
)
2099+
2100+
# A sanity check only: the NumPy and JAX linear solves differ by more than the matrices they are
2101+
# handed (see `test__interferometer_sparse_operator__numpy_inversion_matches_jax_inversion`), so the
2102+
# image is compared below against the dense product of the jitted reconstruction itself.
2103+
reference = np.asarray(inversion_np.reconstruction)
2104+
2105+
np.testing.assert_allclose(
2106+
np.asarray(reconstruction),
2107+
reference,
2108+
rtol=0.0,
2109+
atol=1.0e-3 * np.abs(reference).max(),
2110+
)
2111+
2112+
_assert_mapped_reconstructed_data_dict_matches_dense(
2113+
image_dict=dict(zip(linear_obj_list, image_list)),
2114+
linear_obj_list=linear_obj_list,
2115+
reconstruction=np.asarray(reconstruction),
2116+
inversion=inversion_np,
2117+
rtol=1.0e-12,
2118+
)

0 commit comments

Comments
 (0)