Skip to content

Commit f139271

Browse files
Jammy2211claude
andcommitted
fix(inversion): guard data_subtracted_dict on data=None, harden sparse noise check and data-term dtype (#590)
Corrective follow-up to Discussion #13 phase 1 (#589), from the independent review on PyAutoLens#757: - AbstractInversion.data_subtracted_dict raises a clear InversionException (naming fit.inversion_with_data) when the inversion was built without data, instead of TypeError / {mapper: None}; subplot_of_mapper and subplot_mappings catch it and skip the panel. - check_noise_map_real_imag_equal uses atol=0.0 so tiny unequal sigmas are rejected rather than waved through by the default absolute tolerance. - apply_sparse_operator computes the cached data_term from complex128-promoted copies so one-shot equals sparse_terms_from_chunks for complex64 data. Tests for all four; red-checked against the reverted source. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1b0cc5f commit f139271

8 files changed

Lines changed: 127 additions & 7 deletions

File tree

‎autoarray/dataset/interferometer/dataset.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,9 +388,13 @@ def apply_sparse_operator(
388388
# `fast_chi_squared` (term 3) nor `FitInterferometer.noise_normalization` reduces over
389389
# the visibility arrays on every likelihood call. The expressions are the ones those
390390
# two reductions use, so the cached values are bit-for-bit what they would compute.
391+
# Promote local copies to complex128 so the one-shot `data_term` equals
392+
# `sparse_terms_from_chunks`, which promotes every chunk to complex128.
393+
data_c128 = np.asarray(self.data.array, dtype=np.complex128)
394+
noise_c128 = np.asarray(self.noise_map.array, dtype=np.complex128)
391395
data_term = float(
392-
np.sum(self.data.array.real**2.0 / self.noise_map.array.real**2.0)
393-
+ np.sum(self.data.array.imag**2.0 / self.noise_map.array.imag**2.0)
396+
np.sum(data_c128.real**2.0 / noise_c128.real**2.0)
397+
+ np.sum(data_c128.imag**2.0 / noise_c128.imag**2.0)
394398
)
395399
noise_normalization = float(
396400
fit_util.noise_normalization_complex_from(noise_map=self.noise_map.array)

‎autoarray/inversion/inversion/abstract.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from autonerves import cached_property, is_test_mode
99

10+
from autoarray import exc
1011
from autoarray.dataset.imaging.dataset import Imaging
1112
from autoarray.dataset.interferometer.dataset import Interferometer
1213
from autoarray.inversion.inversion.dataset_interface import DatasetInterface
@@ -878,6 +879,14 @@ def data_subtracted_dict(self) -> Dict[LinearObj, Array2D]:
878879
A dictionary of the data subtracted by the reconstructed images of combinations of all but one of the
879880
linear objects the inversion.
880881
"""
882+
if self.data is None:
883+
raise exc.InversionException(
884+
"`data_subtracted_dict` cannot be computed because this inversion was built "
885+
"without data (`data=None` -- the sparse-operator path, which uses the "
886+
"precomputed `data_term` instead of the visibilities). `data_subtracted_dict` "
887+
"needs the fitted data, so use `fit.inversion_with_data` (or `fit.data`) "
888+
"instead of `fit.inversion`."
889+
)
881890

882891
data_subtracted_dict = {}
883892

‎autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ def disable_jax():
3232
def check_noise_map_real_imag_equal(noise_map) -> None:
3333
"""
3434
Raise a `DatasetException` unless every visibility of `noise_map` has equal real and
35-
imaginary noise sigma (`noise_map.real == noise_map.imag`, to `np.allclose`).
35+
imaginary noise sigma (`noise_map.real == noise_map.imag`, to `np.allclose` with
36+
`atol=0.0` and the default relative tolerance, so the check is scale-free and tiny sigmas
37+
such as 1e-9 vs 2e-9 are not waved through by the default absolute tolerance).
3638
3739
This is the precondition of the sparse operator: its precision operator
3840
`W~ = Re(F^H W F)` is built from the real-part sigma alone (see
@@ -55,10 +57,10 @@ def check_noise_map_real_imag_equal(noise_map) -> None:
5557
noise_map_real = np.asarray(np.real(noise_map))
5658
noise_map_imag = np.asarray(np.imag(noise_map))
5759

58-
if np.allclose(noise_map_real, noise_map_imag):
60+
if np.allclose(noise_map_real, noise_map_imag, atol=0.0):
5961
return
6062

61-
unequal = ~np.isclose(noise_map_real, noise_map_imag)
63+
unequal = ~np.isclose(noise_map_real, noise_map_imag, atol=0.0)
6264

6365
denominator = np.maximum(np.abs(noise_map_real), np.abs(noise_map_imag))
6466
relative_difference = np.abs(noise_map_real - noise_map_imag) / np.where(

‎autoarray/inversion/plot/inversion_plots.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from autonerves import conf
88

9+
from autoarray import exc
10+
911
from autoarray.inversion.mappers.abstract import Mapper
1012
from autoarray.plot.array import plot_array
1113
from autoarray.plot.utils import (
@@ -84,7 +86,7 @@ def subplot_of_mapper(
8486
positions=positions,
8587
lines=lines,
8688
)
87-
except (AttributeError, KeyError):
89+
except (AttributeError, KeyError, exc.InversionException):
8890
pass
8991

9092
# panels 1-3: reconstructed operated data (plain, log10, + mesh grid overlay)
@@ -394,7 +396,7 @@ def subplot_mappings(
394396
region_alpha=region_alpha,
395397
region_labels=region_labels,
396398
)
397-
except (AttributeError, KeyError):
399+
except (AttributeError, KeyError, exc.InversionException):
398400
pass
399401

400402
# panel 1: reconstructed operated data

‎test_autoarray/dataset/interferometer/test_dataset.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,53 @@ def test__apply_sparse_operator__populates_data_term_and_noise_normalization(
511511
)
512512

513513

514+
def test__apply_sparse_operator__complex64_data__data_term_is_reduced_in_complex128(
515+
mask_2d_7x7,
516+
):
517+
n_visibilities = 7
518+
rng = np.random.default_rng(seed=1)
519+
uv_wavelengths = rng.normal(size=(n_visibilities, 2)) * 5.0e4
520+
521+
values = np.full(n_visibilities, 10001.0 + 0.0j)
522+
sigma = np.full(n_visibilities, 1.0 + 1.0j)
523+
524+
def dataset_from(dtype):
525+
return aa.Interferometer(
526+
data=aa.Visibilities(visibilities=np.asarray(values, dtype=dtype)),
527+
noise_map=aa.VisibilitiesNoiseMap(
528+
visibilities=np.asarray(sigma, dtype=dtype)
529+
),
530+
uv_wavelengths=uv_wavelengths,
531+
real_space_mask=mask_2d_7x7,
532+
transformer_class=transformer.TransformerDFT,
533+
)
534+
535+
dataset_c64 = dataset_from(np.complex64)
536+
assert dataset_c64.data.array.dtype == np.complex64
537+
538+
data_term_c64 = dataset_c64.apply_sparse_operator(
539+
use_jax=False
540+
).sparse_operator.data_term
541+
data_term_c128 = dataset_from(np.complex128).apply_sparse_operator(
542+
use_jax=False
543+
).sparse_operator.data_term
544+
545+
assert data_term_c128 == 700140007.0
546+
assert data_term_c64 == data_term_c128
547+
548+
# The dataset itself is not promoted.
549+
assert dataset_c64.data.array.dtype == np.complex64
550+
551+
pytest.importorskip("nufftax")
552+
553+
terms = aa.util.inversion_interferometer.sparse_terms_from_chunks(
554+
[(uv_wavelengths, dataset_c64.data.array, dataset_c64.noise_map.array)],
555+
real_space_mask=mask_2d_7x7,
556+
)
557+
558+
assert terms.data_term == pytest.approx(data_term_c64, rel=1.0e-12)
559+
560+
514561
def test__apply_sparse_operator_from_chunks__matches_apply_sparse_operator(
515562
interferometer_7_lop, mask_2d_7x7
516563
):

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,18 @@ def test__fast_chi_squared__data_none_without_a_data_term__raises():
13341334
inversion.fast_chi_squared
13351335

13361336

1337+
def test__data_subtracted_dict__data_none__raises_pointing_to_inversion_with_data():
1338+
dataset_sparse, mapper = _sparse_interface_setup()
1339+
1340+
inversion = aa.Inversion(
1341+
dataset=_interface_from(dataset_sparse, data=None),
1342+
linear_obj_list=[mapper],
1343+
)
1344+
1345+
with pytest.raises(aa.exc.InversionException, match="inversion_with_data"):
1346+
inversion.data_subtracted_dict
1347+
1348+
13371349
def test__fast_chi_squared__data_none__jax_matches_numpy():
13381350
pytest.importorskip("jax")
13391351

‎test_autoarray/inversion/inversion/interferometer/test_inversion_interferometer_util.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,6 +1252,16 @@ def test__sparse_terms_from_chunks__accepts_visibilities_and_real_two_column_arr
12521252
assert terms.noise_normalization == terms_complex.noise_normalization
12531253

12541254

1255+
def test__check_noise_map_real_imag_equal__tiny_sigmas_use_a_relative_tolerance():
1256+
check = aa.util.inversion_interferometer.check_noise_map_real_imag_equal
1257+
1258+
# 1e-9 vs 2e-9 differ by a factor of two; the default `atol=1e-8` would wave it through.
1259+
with pytest.raises(aa.exc.DatasetException):
1260+
check(np.array([1.0e-9 + 2.0e-9j]))
1261+
1262+
check(np.array([1.0e-9 + 1.0e-9j]))
1263+
1264+
12551265
def test__sparse_terms_from_chunks__unequal_real_imag_noise_in_a_later_chunk__raises():
12561266
pytest.importorskip("nufftax")
12571267

‎test_autoarray/inversion/plot/test_inversion_plotters.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import autoarray.plot as aplt
2+
from autoarray import exc
23
from autoarray.inversion.mappers.abstract import Mapper
34
from autoarray.inversion.plot.inversion_plots import save_reconstruction_csv
45

@@ -96,6 +97,39 @@ def test__inversion_subplot_of_mapper__singular_curvature_reg_matrix(
9697
assert str(Path(plot_path) / "inversion_0.png") in plot_patch.paths
9798

9899

100+
def test__inversion_subplots__data_subtracted_dict_raises_inversion_exception(
101+
rectangular_inversion_7x7_3x3,
102+
plot_path,
103+
plot_patch,
104+
monkeypatch,
105+
):
106+
inversion = rectangular_inversion_7x7_3x3
107+
108+
def _raise(self):
109+
raise exc.InversionException("built without data")
110+
111+
monkeypatch.setattr(type(inversion), "data_subtracted_dict", property(_raise))
112+
113+
with pytest.raises(exc.InversionException):
114+
inversion.data_subtracted_dict
115+
116+
aplt.subplot_of_mapper(
117+
inversion=inversion,
118+
mapper_index=0,
119+
output_path=plot_path,
120+
output_format="png",
121+
)
122+
assert str(Path(plot_path) / "inversion_0.png") in plot_patch.paths
123+
124+
aplt.subplot_mappings(
125+
inversion=inversion,
126+
pixelization_index=0,
127+
output_path=plot_path,
128+
output_format="png",
129+
)
130+
assert str(Path(plot_path) / "mappings_0.png") in plot_patch.paths
131+
132+
99133
def test__save_reconstruction_csv__singular_curvature_reg_matrix(
100134
rectangular_inversion_7x7_3x3,
101135
tmp_path,

0 commit comments

Comments
 (0)