Skip to content

Commit 2784056

Browse files
authored
Merge pull request #469 from PyAutoLabs/claude/numerical-inversion-failures-7xsp1k
fix: form the reconstruction covariance via Cholesky, not an elementwise sqrt
2 parents a6b07cd + f0aefa8 commit 2784056

3 files changed

Lines changed: 229 additions & 24 deletions

File tree

autoarray/inversion/inversion/abstract.py

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import copy
2+
import warnings
23

34
import numpy as np
45
from typing import Dict, List, Optional, Type, Union
@@ -836,27 +837,99 @@ def log_det_regularization_matrix_term(self) -> float:
836837
return self._log_det_symmetric_from(self.regularization_matrix_reduced)
837838

838839
@property
839-
def reconstruction_noise_map_with_covariance(self) -> np.ndarray:
840+
def reconstruction_covariance_matrix(self) -> np.ndarray:
840841
"""
841-
Returns the noise-map of the reconstruction as a two dimension matrix which accounts for the covariance
842-
of the noise between pixels.
842+
Returns the covariance matrix of the reconstruction, ``C = [F + reg_coeff*H]^-1``.
843+
844+
This is the inverse of the curvature matrix with regularization -- the same matrix used to solve for the
845+
reconstruction via the linear inversion. Its diagonal holds the variance of each reconstructed pixel and
846+
its off-diagonal entries the covariances between pixels; the off-diagonals are routinely negative, which is
847+
a property of the covariance and not an error.
848+
849+
For the RMS standard deviation of each pixel (the quantity used for scientific analysis) use
850+
`reconstruction_noise_map`, which takes the square root of this matrix's diagonal.
851+
852+
The inverse is formed from a Cholesky factorization rather than `np.linalg.inv`, for two reasons:
853+
854+
- `cho_factor` raises `LinAlgError` when the matrix is not positive-definite. `np.linalg.inv` raises only
855+
on an exactly singular matrix, so an indefinite `curvature_reg_matrix` -- which the inversion does
856+
encounter, hence `Settings.no_regularization_add_to_curvature_diag_value` -- previously returned a
857+
plausible-looking covariance with no error and no warning. A covariance is only defined for a
858+
positive-definite matrix, so failing here is correct and is handled by the callers in
859+
`inversion/plot/inversion_plots.py`.
860+
- `np.linalg.inv` is LU-based and exploits neither the symmetry nor the positive-definiteness this matrix
861+
has. Its output drifts out of symmetry as conditioning worsens (measured at ~5e-7 absolute at
862+
`cond ~ 1e12`, against ~3e-16 for the Cholesky solve), while a covariance matrix is symmetric by
863+
definition.
864+
865+
Every failure mode raises `LinAlgError`, including a non-finite `curvature_reg_matrix`. That case is
866+
checked explicitly because scipy would otherwise raise `ValueError`, which the callers -- here and
867+
downstream -- do not catch; the previous implementation returned a silently all-`NaN` matrix instead.
868+
869+
The matrix is symmetrized on input. `cho_factor` reads only the upper triangle, so an asymmetric input
870+
would otherwise be inverted as though its lower triangle matched, silently and with no diagnostic.
871+
`curvature_reg_matrix` is `F + H` and symmetric by construction, so this is defensive only.
872+
873+
This property is NumPy-only: the input is coerced with `np.asarray`, so a JAX `curvature_reg_matrix`
874+
forces a device-to-host transfer. It is a post-fit diagnostic, not part of the likelihood, so it is not on
875+
the JIT path.
876+
877+
Returns
878+
-------
879+
The covariance matrix of the reconstruction, of shape [total_params, total_params].
880+
"""
881+
from scipy.linalg import cho_factor, cho_solve
843882

844-
The diagonal of this matrix is the noise-map of the reconstruction, which can be used for analysing the
845-
reconstruction with noise properties that are representative of the fit and therefore should be used
846-
for any scientific analysis (e.g. source reconstructions of strong lenses).
883+
matrix = np.asarray(self.curvature_reg_matrix)
847884

848-
This noise-map is defined as the RMS standard deviation of the noise in every pixel of the reconstruction.
849-
This definition is identical to the `noise_map` attributes of dataset objects.
885+
if not np.isfinite(matrix).all():
886+
raise np.linalg.LinAlgError(
887+
"The curvature_reg_matrix contains non-finite entries (NaN or inf), so the reconstruction "
888+
"covariance is undefined. Raised as LinAlgError so the plotting and CSV callers, which guard "
889+
"on LinAlgError, degrade gracefully rather than aborting the model-fit."
890+
)
891+
892+
# cho_factor reads only the upper triangle; symmetrize so an asymmetric input cannot be silently
893+
# inverted as though its lower triangle matched its upper.
894+
matrix = 0.5 * (matrix + matrix.T)
895+
896+
covariance = cho_solve(
897+
cho_factor(matrix, check_finite=False),
898+
np.eye(matrix.shape[0], dtype=matrix.dtype),
899+
check_finite=False,
900+
)
850901

851-
It is computed as the square root of the inverse of the curvature matrix with regularization, which is the
852-
same matrix used to solve for the reconstruction via the linear inversion.
902+
# cho_solve is accurate but not bitwise symmetric; a covariance matrix is symmetric by definition.
903+
return 0.5 * (covariance + covariance.T)
904+
905+
@property
906+
def reconstruction_noise_map_with_covariance(self) -> np.ndarray:
907+
"""
908+
Deprecated alias of `reconstruction_covariance_matrix`.
909+
910+
This property previously returned ``np.sqrt(np.linalg.inv(curvature_reg_matrix))`` -- an elementwise square
911+
root of the whole covariance matrix. Because the off-diagonal entries of a covariance matrix are routinely
912+
negative, every such entry was `NaN` by construction, for any input matrix however well-conditioned, and
913+
each call emitted `RuntimeWarning: invalid value encountered in sqrt`.
914+
915+
It now returns the covariance matrix itself, so the values differ: the diagonal holds variances rather
916+
than standard deviations, and the off-diagonals hold covariances rather than `NaN`.
853917
854918
Returns
855919
-------
856-
The noise-map of the reconstruction as a two dimension matrix which accounts for the covariance of the noise
857-
between pixels.
920+
The covariance matrix of the reconstruction (see `reconstruction_covariance_matrix`).
858921
"""
859-
return np.sqrt(np.linalg.inv(self.curvature_reg_matrix))
922+
warnings.warn(
923+
"`reconstruction_noise_map_with_covariance` is deprecated; use "
924+
"`reconstruction_covariance_matrix` instead. Note the values have changed: it now returns the "
925+
"covariance matrix, so its diagonal holds variances rather than standard deviations (the previous "
926+
"elementwise square root made every off-diagonal NaN). For the RMS noise of each pixel use "
927+
"`reconstruction_noise_map`.",
928+
DeprecationWarning,
929+
stacklevel=2,
930+
)
931+
932+
return self.reconstruction_covariance_matrix
860933

861934
@property
862935
def reconstruction_noise_map(self):
@@ -870,15 +943,21 @@ def reconstruction_noise_map(self):
870943
The noise-map of the reconstruction is the RMS standard deviation of the noise in every pixel of the
871944
reconstruction. This definition is identical to the `noise_map` attributes of dataset objects.
872945
873-
It is computed as the square root of the diagonal of the `reconstruction_noise_map_with_covariance` matrix,
874-
which is the same matrix used to solve for the reconstruction via the linear inversion.
946+
It is computed as the square root of the diagonal of `reconstruction_covariance_matrix`, which is the
947+
inverse of the same matrix used to solve for the reconstruction via the linear inversion.
948+
949+
This previously took the diagonal of an elementwise-square-rooted matrix. The two are algebraically
950+
identical -- `np.sqrt` is elementwise, so it commutes with taking the diagonal -- but only numerically
951+
equivalent, since the covariance is now formed by Cholesky rather than LU. The difference is
952+
conditioning-limited roundoff, measured at ~7e-15 relative at `cond ~ 1e3` rising to ~4e-5 at
953+
`cond ~ 1e13`; neither result is the more correct one.
875954
876955
Returns
877956
-------
878957
The noise-map of the reconstruction as a one dimensional ndarray, which does not account for the covariance
879958
of the noise between pixels.
880959
"""
881-
return np.diagonal(self.reconstruction_noise_map_with_covariance)
960+
return np.sqrt(np.diag(self.reconstruction_covariance_matrix))
882961

883962
@property
884963
def reconstruction_noise_map_dict(self) -> Dict[LinearObj, np.ndarray]:

test_autoarray/inversion/inversion/test_abstract.py

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import warnings
2+
13
import numpy as np
24
import pytest
35

@@ -676,19 +678,143 @@ def test__log_det_method__slogdet_is_finite_where_cholesky_fails_on_non_positive
676678
assert result == pytest.approx(np.linalg.slogdet(matrix)[1], 1.0e-8)
677679

678680

679-
def test__reconstruction_noise_map__asymmetric_curvature_reg_matrix__correct_diagonal_noise_values():
681+
def test__reconstruction_noise_map__correct_diagonal_noise_values():
680682
curvature_reg_matrix = np.array([[1.0, 1.0, 1.0], [1.0, 2.0, 1.0], [1.0, 1.0, 3.0]])
681683

682684
inversion = aa.m.MockInversion(curvature_reg_matrix=curvature_reg_matrix)
683685

684-
assert inversion.reconstruction_noise_map_with_covariance[0, 0] == pytest.approx(
685-
np.sqrt(2.5), 1.0e-2
686-
)
686+
assert inversion.reconstruction_covariance_matrix[0, 0] == pytest.approx(2.5, 1.0e-2)
687687
assert inversion.reconstruction_noise_map == pytest.approx(
688688
np.sqrt(np.array([2.5, 1.0, 0.5])), 1.0e-3
689689
)
690690

691691

692+
def test__reconstruction_covariance_matrix__off_diagonals_are_finite_and_negative():
693+
"""
694+
The off-diagonal entries of a covariance matrix are covariances and are routinely negative.
695+
696+
`reconstruction_covariance_matrix` previously applied `np.sqrt` elementwise to the whole inverse, so every
697+
negative off-diagonal became NaN by construction -- for any matrix, however well-conditioned -- while
698+
emitting `RuntimeWarning: invalid value encountered in sqrt`. Only the [0, 0] diagonal element was asserted,
699+
so nothing caught it.
700+
"""
701+
curvature_reg_matrix = np.array([[1.0, 1.0, 1.0], [1.0, 2.0, 1.0], [1.0, 1.0, 3.0]])
702+
703+
inversion = aa.m.MockInversion(curvature_reg_matrix=curvature_reg_matrix)
704+
705+
with warnings.catch_warnings():
706+
warnings.simplefilter("error", RuntimeWarning)
707+
covariance = inversion.reconstruction_covariance_matrix
708+
709+
assert np.all(np.isfinite(covariance))
710+
711+
# this matrix has anti-correlated pixels, so the off-diagonals are genuinely negative
712+
assert covariance[0, 1] < 0.0
713+
assert covariance == pytest.approx(np.linalg.inv(curvature_reg_matrix), 1.0e-8)
714+
715+
716+
def test__reconstruction_covariance_matrix__is_accurate_and_symmetric_when_ill_conditioned():
717+
"""
718+
Ground truth is exact by construction: for `A = Q diag(w) Q.T` the inverse is `Q diag(1/w) Q.T`.
719+
720+
The symmetry half only guards the symmetrization line -- `0.5 * (C + C.T)` is bitwise symmetric for any C --
721+
so the accuracy assertion against the constructed truth is what tests the factorization itself.
722+
"""
723+
rng = np.random.default_rng(1234)
724+
q, _ = np.linalg.qr(rng.standard_normal((25, 25)))
725+
eigenvalues = np.logspace(0, 9, 25)
726+
727+
curvature_reg_matrix = (q * eigenvalues) @ q.T
728+
curvature_reg_matrix = 0.5 * (curvature_reg_matrix + curvature_reg_matrix.T)
729+
730+
covariance_true = (q * (1.0 / eigenvalues)) @ q.T
731+
732+
inversion = aa.m.MockInversion(curvature_reg_matrix=curvature_reg_matrix)
733+
734+
covariance = inversion.reconstruction_covariance_matrix
735+
736+
# cond ~ 1e9, so the achievable accuracy is eps * cond ~ 2e-7; the measured error is ~3e-9. This is not a
737+
# claim that Cholesky beats LU here -- it does not, `np.linalg.inv` measures ~7e-10 on this matrix.
738+
assert covariance == pytest.approx(covariance_true, abs=1.0e-7)
739+
assert covariance == pytest.approx(covariance.T, abs=1.0e-15)
740+
741+
742+
def test__reconstruction_covariance_matrix__asymmetric_input_is_symmetrized_not_silently_upper_triangle():
743+
"""
744+
`cho_factor` reads only the upper triangle, so an asymmetric input would be inverted as though its lower
745+
triangle matched its upper -- silently, and differing from the true inverse.
746+
"""
747+
curvature_reg_matrix = np.array([[2.0, 0.5], [0.1, 2.0]])
748+
symmetrized = 0.5 * (curvature_reg_matrix + curvature_reg_matrix.T)
749+
750+
inversion = aa.m.MockInversion(curvature_reg_matrix=curvature_reg_matrix)
751+
752+
assert inversion.reconstruction_covariance_matrix == pytest.approx(
753+
np.linalg.inv(symmetrized), 1.0e-8
754+
)
755+
756+
757+
def test__reconstruction_covariance_matrix__non_finite_matrix_raises_lin_alg_error():
758+
"""
759+
scipy raises `ValueError` on a non-finite matrix, which the plotting and CSV callers do not catch -- they
760+
guard on `LinAlgError`. The CSV writer explicitly promises not to abort the enclosing model-fit, so the
761+
non-finite case is converted rather than allowed to escape.
762+
"""
763+
curvature_reg_matrix = np.array([[1.0, np.nan], [np.nan, 2.0]])
764+
765+
inversion = aa.m.MockInversion(curvature_reg_matrix=curvature_reg_matrix)
766+
767+
with pytest.raises(np.linalg.LinAlgError, match="non-finite"):
768+
inversion.reconstruction_covariance_matrix
769+
770+
771+
def test__reconstruction_noise_map__is_sqrt_of_covariance_diagonal():
772+
"""
773+
The invariant, asserted directly rather than via hand-computed values.
774+
775+
`reconstruction_noise_map` used to be `np.diagonal(...)` of an already-square-rooted matrix, which was
776+
correct only incidentally -- because `np.sqrt` is elementwise. It now takes the square root of the
777+
covariance diagonal itself, so the relationship is stated rather than emergent.
778+
"""
779+
curvature_reg_matrix = np.array([[1.0, 1.0, 1.0], [1.0, 2.0, 1.0], [1.0, 1.0, 3.0]])
780+
781+
inversion = aa.m.MockInversion(curvature_reg_matrix=curvature_reg_matrix)
782+
783+
assert inversion.reconstruction_noise_map == pytest.approx(
784+
np.sqrt(np.diag(inversion.reconstruction_covariance_matrix)), 1.0e-12
785+
)
786+
787+
788+
def test__reconstruction_covariance_matrix__raises_on_a_non_positive_definite_matrix():
789+
"""
790+
A covariance is only defined for a positive-definite matrix.
791+
792+
`np.linalg.inv` raises only on an exactly singular matrix, so an indefinite `curvature_reg_matrix` returned
793+
a plausible-looking covariance with no error and no warning. The Cholesky factorization rejects it, and the
794+
plotting and CSV callers already catch `LinAlgError`.
795+
"""
796+
# symmetric, non-singular, but indefinite (eigenvalues +1 and -1)
797+
curvature_reg_matrix = np.array([[0.0, 1.0], [1.0, 0.0]])
798+
799+
inversion = aa.m.MockInversion(curvature_reg_matrix=curvature_reg_matrix)
800+
801+
assert np.isfinite(np.linalg.inv(curvature_reg_matrix)).all() # inv is silent here
802+
803+
with pytest.raises(np.linalg.LinAlgError):
804+
inversion.reconstruction_covariance_matrix
805+
806+
807+
def test__reconstruction_noise_map_with_covariance__is_deprecated_alias():
808+
curvature_reg_matrix = np.array([[1.0, 1.0, 1.0], [1.0, 2.0, 1.0], [1.0, 1.0, 3.0]])
809+
810+
inversion = aa.m.MockInversion(curvature_reg_matrix=curvature_reg_matrix)
811+
812+
with pytest.warns(DeprecationWarning, match="reconstruction_covariance_matrix"):
813+
covariance = inversion.reconstruction_noise_map_with_covariance
814+
815+
assert covariance == pytest.approx(inversion.reconstruction_covariance_matrix, 1.0e-12)
816+
817+
692818
def test__max_pixel_list_from_and_centre__returns_top_pixels_and_brightest_centre():
693819

694820
source_plane_mesh_grid = aa.Grid2DIrregular(

test_autoarray/inversion/plot/test_inversion_plotters.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ def test__inversion_subplot_of_mapper__singular_curvature_reg_matrix(
7979

8080
monkeypatch.setattr(
8181
type(inversion),
82-
"reconstruction_noise_map_with_covariance",
83-
property(lambda self: np.sqrt(np.linalg.inv(np.zeros((params, params))))),
82+
"reconstruction_covariance_matrix",
83+
property(lambda self: np.linalg.inv(np.zeros((params, params)))),
8484
)
8585

8686
with pytest.raises(np.linalg.LinAlgError):
@@ -107,8 +107,8 @@ def test__save_reconstruction_csv__singular_curvature_reg_matrix(
107107

108108
monkeypatch.setattr(
109109
type(inversion),
110-
"reconstruction_noise_map_with_covariance",
111-
property(lambda self: np.sqrt(np.linalg.inv(np.zeros((params, params))))),
110+
"reconstruction_covariance_matrix",
111+
property(lambda self: np.linalg.inv(np.zeros((params, params)))),
112112
)
113113

114114
with pytest.raises(np.linalg.LinAlgError):

0 commit comments

Comments
 (0)