|
| 1 | +""" |
| 2 | +One Cholesky factor of ``F + lambda*H`` shared by the positive-negative solve and its log determinant. |
| 3 | +
|
| 4 | +On the NumPy backend with ``use_positive_only_solver=False`` a likelihood call factorized the same |
| 5 | +symmetric positive-definite matrix twice: ``np.linalg.solve`` (an LU) for ``reconstruction`` and |
| 6 | +``np.linalg.cholesky`` for ``log_det_curvature_reg_matrix_term`` (PyAutoArray#633). Both now read |
| 7 | +``AbstractInversion._curvature_reg_matrix_cho_factor``. |
| 8 | +
|
| 9 | +These tests pin, against the two-factorization values computed here independently: |
| 10 | +
|
| 11 | +- the reconstruction (rtol 1e-10) and the log determinant (rtol 1e-9), on imaging and |
| 12 | + interferometer inversions, and that one factorization serves both; |
| 13 | +- the failure semantics: an indefinite-but-nonsingular ``F + H`` is still solved by the LU and still |
| 14 | + makes the log determinant raise (or return 0.0 in test mode); a singular one still makes the solve |
| 15 | + raise (or return its test-mode dummy); |
| 16 | +- the routes that must not use the factor: ``"slogdet"``, the positive-only solver, and a partially |
| 17 | + regularized system's log determinant. |
| 18 | +""" |
| 19 | + |
| 20 | +import numpy as np |
| 21 | +import pytest |
| 22 | +import scipy.linalg |
| 23 | + |
| 24 | +import autoarray as aa |
| 25 | + |
| 26 | +from autoarray import fixtures |
| 27 | + |
| 28 | + |
| 29 | +def _two_factorization_values(inversion): |
| 30 | + """The pre-#633 computation: an LU solve plus a separate dense Cholesky.""" |
| 31 | + matrix = np.asarray(inversion.curvature_reg_matrix) |
| 32 | + |
| 33 | + reconstruction = np.linalg.solve(matrix, np.asarray(inversion.data_vector)) |
| 34 | + |
| 35 | + reduced = np.asarray(inversion.curvature_reg_matrix_reduced) |
| 36 | + |
| 37 | + log_det = 2.0 * np.sum(np.log(np.diag(np.linalg.cholesky(reduced)))) |
| 38 | + |
| 39 | + return reconstruction, log_det |
| 40 | + |
| 41 | + |
| 42 | +def _count_cho_factor(monkeypatch): |
| 43 | + original = scipy.linalg.cho_factor |
| 44 | + |
| 45 | + calls = [] |
| 46 | + |
| 47 | + def _wrapped(*args, **kwargs): |
| 48 | + calls.append(1) |
| 49 | + return original(*args, **kwargs) |
| 50 | + |
| 51 | + monkeypatch.setattr(scipy.linalg, "cho_factor", _wrapped) |
| 52 | + |
| 53 | + return calls |
| 54 | + |
| 55 | + |
| 56 | +def _imaging_inversion(settings, linear_obj_list=None): |
| 57 | + return aa.Inversion( |
| 58 | + dataset=fixtures.make_masked_imaging_7x7(), |
| 59 | + linear_obj_list=linear_obj_list or [fixtures.make_rectangular_mapper_7x7_3x3()], |
| 60 | + settings=settings, |
| 61 | + ) |
| 62 | + |
| 63 | + |
| 64 | +def _interferometer_inversion(settings): |
| 65 | + return aa.Inversion( |
| 66 | + dataset=fixtures.make_interferometer_7(), |
| 67 | + linear_obj_list=[fixtures.make_rectangular_mapper_7x7_3x3()], |
| 68 | + settings=settings, |
| 69 | + ) |
| 70 | + |
| 71 | + |
| 72 | +def _with_matrix(inversion, matrix, data_vector): |
| 73 | + """Seed the cached `curvature_reg_matrix` / `data_vector` with a chosen system.""" |
| 74 | + inversion.__dict__["curvature_reg_matrix"] = matrix |
| 75 | + inversion.__dict__["data_vector"] = data_vector |
| 76 | + return inversion |
| 77 | + |
| 78 | + |
| 79 | +@pytest.mark.parametrize( |
| 80 | + "inversion_from", |
| 81 | + [_imaging_inversion, _interferometer_inversion], |
| 82 | + ids=["imaging", "interferometer"], |
| 83 | +) |
| 84 | +def test__shared_factor__matches_two_factorizations__one_factorization( |
| 85 | + monkeypatch, inversion_from |
| 86 | +): |
| 87 | + calls = _count_cho_factor(monkeypatch) |
| 88 | + |
| 89 | + inversion = inversion_from(aa.Settings(use_positive_only_solver=False)) |
| 90 | + |
| 91 | + reconstruction_ref, log_det_ref = _two_factorization_values(inversion) |
| 92 | + |
| 93 | + # log det first: it must read the factor without evaluating the reconstruction. |
| 94 | + log_det = inversion.log_det_curvature_reg_matrix_term |
| 95 | + |
| 96 | + assert "reconstruction" not in inversion.__dict__ |
| 97 | + |
| 98 | + reconstruction = np.asarray(inversion.reconstruction) |
| 99 | + |
| 100 | + assert len(calls) == 1 |
| 101 | + assert inversion._curvature_reg_matrix_cho_factor is not None |
| 102 | + |
| 103 | + assert reconstruction == pytest.approx(reconstruction_ref, rel=1.0e-10, abs=0.0) |
| 104 | + assert log_det == pytest.approx(log_det_ref, rel=1.0e-9) |
| 105 | + |
| 106 | + # the factor is a copy: `F + H` and `F` are left intact for the reduced matrix / covariance |
| 107 | + assert np.asarray(inversion.curvature_reg_matrix) == pytest.approx( |
| 108 | + np.asarray(inversion.curvature_matrix) |
| 109 | + + np.asarray(inversion.regularization_matrix), |
| 110 | + rel=1.0e-12, |
| 111 | + ) |
| 112 | + |
| 113 | + |
| 114 | +def test__shared_factor__random_spd_system__matches_solve_and_dense_cholesky(): |
| 115 | + rng = np.random.default_rng(3) |
| 116 | + |
| 117 | + n = 9 # the rectangular 3x3 mapper's parameter count |
| 118 | + |
| 119 | + a = rng.normal(size=(n, n)) |
| 120 | + matrix = a @ a.T + 1.0e-3 * np.eye(n) |
| 121 | + data_vector = rng.normal(size=n) |
| 122 | + |
| 123 | + inversion = _with_matrix( |
| 124 | + _imaging_inversion(aa.Settings(use_positive_only_solver=False)), |
| 125 | + matrix=matrix, |
| 126 | + data_vector=data_vector, |
| 127 | + ) |
| 128 | + |
| 129 | + assert np.asarray(inversion.reconstruction) == pytest.approx( |
| 130 | + np.linalg.solve(matrix, data_vector), rel=1.0e-10, abs=0.0 |
| 131 | + ) |
| 132 | + assert inversion.log_det_curvature_reg_matrix_term == pytest.approx( |
| 133 | + 2.0 * np.sum(np.log(np.diag(np.linalg.cholesky(matrix)))), rel=1.0e-9 |
| 134 | + ) |
| 135 | + |
| 136 | + |
| 137 | +def _indefinite_nonsingular(n=9): |
| 138 | + matrix = np.eye(n) |
| 139 | + matrix[0, 0] = -1.0 |
| 140 | + return matrix |
| 141 | + |
| 142 | + |
| 143 | +def test__indefinite_matrix__lu_solve_kept__log_det_raises(monkeypatch): |
| 144 | + monkeypatch.delenv("PYAUTO_TEST_MODE", raising=False) |
| 145 | + |
| 146 | + matrix = _indefinite_nonsingular() |
| 147 | + data_vector = np.arange(1.0, 10.0) |
| 148 | + |
| 149 | + inversion = _with_matrix( |
| 150 | + _imaging_inversion(aa.Settings(use_positive_only_solver=False)), |
| 151 | + matrix=matrix, |
| 152 | + data_vector=data_vector, |
| 153 | + ) |
| 154 | + |
| 155 | + assert inversion._curvature_reg_matrix_cho_factor is None |
| 156 | + |
| 157 | + # as before: the LU solves an indefinite nonsingular system ... |
| 158 | + assert np.asarray(inversion.reconstruction) == pytest.approx( |
| 159 | + np.linalg.solve(matrix, data_vector), rel=1.0e-12 |
| 160 | + ) |
| 161 | + |
| 162 | + # ... and the dense Cholesky log determinant rejects it |
| 163 | + with pytest.raises(np.linalg.LinAlgError): |
| 164 | + inversion.log_det_curvature_reg_matrix_term |
| 165 | + |
| 166 | + |
| 167 | +def test__indefinite_matrix__test_mode__log_det_guard_returns_zero(monkeypatch): |
| 168 | + monkeypatch.setenv("PYAUTO_TEST_MODE", "1") |
| 169 | + |
| 170 | + inversion = _with_matrix( |
| 171 | + _imaging_inversion(aa.Settings(use_positive_only_solver=False)), |
| 172 | + matrix=_indefinite_nonsingular(), |
| 173 | + data_vector=np.ones(9), |
| 174 | + ) |
| 175 | + |
| 176 | + assert inversion.log_det_curvature_reg_matrix_term == 0.0 |
| 177 | + |
| 178 | + |
| 179 | +def test__singular_matrix__solve_raises__test_mode_dummy(monkeypatch): |
| 180 | + matrix = np.ones((9, 9)) |
| 181 | + |
| 182 | + monkeypatch.delenv("PYAUTO_TEST_MODE", raising=False) |
| 183 | + |
| 184 | + inversion = _with_matrix( |
| 185 | + _imaging_inversion(aa.Settings(use_positive_only_solver=False)), |
| 186 | + matrix=matrix, |
| 187 | + data_vector=np.arange(1.0, 10.0), |
| 188 | + ) |
| 189 | + |
| 190 | + with pytest.raises(np.linalg.LinAlgError): |
| 191 | + inversion.reconstruction |
| 192 | + |
| 193 | + monkeypatch.setenv("PYAUTO_TEST_MODE", "1") |
| 194 | + |
| 195 | + inversion = _with_matrix( |
| 196 | + _imaging_inversion(aa.Settings(use_positive_only_solver=False)), |
| 197 | + matrix=matrix, |
| 198 | + data_vector=np.arange(1.0, 10.0), |
| 199 | + ) |
| 200 | + |
| 201 | + assert (np.asarray(inversion.reconstruction) == 1.0).all() |
| 202 | + |
| 203 | + |
| 204 | +def test__non_finite_matrix__no_factor__unchanged_routes(monkeypatch): |
| 205 | + monkeypatch.delenv("PYAUTO_TEST_MODE", raising=False) |
| 206 | + |
| 207 | + matrix = np.eye(9) |
| 208 | + matrix[4, 4] = np.nan |
| 209 | + |
| 210 | + inversion = _with_matrix( |
| 211 | + _imaging_inversion(aa.Settings(use_positive_only_solver=False)), |
| 212 | + matrix=matrix, |
| 213 | + data_vector=np.ones(9), |
| 214 | + ) |
| 215 | + |
| 216 | + assert inversion._curvature_reg_matrix_cho_factor is None |
| 217 | + |
| 218 | + |
| 219 | +def test__slogdet_method__no_factor__unchanged(monkeypatch): |
| 220 | + calls = _count_cho_factor(monkeypatch) |
| 221 | + |
| 222 | + inversion = _imaging_inversion( |
| 223 | + aa.Settings(use_positive_only_solver=False, log_det_method="slogdet") |
| 224 | + ) |
| 225 | + |
| 226 | + matrix = np.asarray(inversion.curvature_reg_matrix_reduced) |
| 227 | + |
| 228 | + assert inversion.log_det_curvature_reg_matrix_term == pytest.approx( |
| 229 | + np.linalg.slogdet(matrix)[1], rel=1.0e-12 |
| 230 | + ) |
| 231 | + assert np.asarray(inversion.reconstruction) == pytest.approx( |
| 232 | + np.linalg.solve(matrix, np.asarray(inversion.data_vector)), rel=1.0e-12 |
| 233 | + ) |
| 234 | + assert inversion._curvature_reg_matrix_cho_factor is None |
| 235 | + assert calls == [] |
| 236 | + |
| 237 | + |
| 238 | +def test__positive_only_solver__no_factor(monkeypatch): |
| 239 | + calls = _count_cho_factor(monkeypatch) |
| 240 | + |
| 241 | + inversion = _imaging_inversion(aa.Settings(use_positive_only_solver=True)) |
| 242 | + |
| 243 | + _ = inversion.reconstruction |
| 244 | + _ = inversion.log_det_curvature_reg_matrix_term |
| 245 | + |
| 246 | + assert inversion._curvature_reg_matrix_cho_factor is None |
| 247 | + assert calls == [] |
| 248 | + |
| 249 | + |
| 250 | +def test__partially_regularized__solve_uses_factor__log_det_dense_reduced(): |
| 251 | + dataset = fixtures.make_masked_imaging_7x7() |
| 252 | + |
| 253 | + grid = aa.Grid2D.from_mask(mask=dataset.mask) |
| 254 | + |
| 255 | + mapping_matrix = np.full(fill_value=0.5, shape=(9, 2)) |
| 256 | + mapping_matrix[0, 0] = 0.8 |
| 257 | + mapping_matrix[1, 1] = 0.4 |
| 258 | + |
| 259 | + linear_obj = aa.m.MockLinearObjFuncList( |
| 260 | + parameters=2, grid=grid, mapping_matrix=mapping_matrix |
| 261 | + ) |
| 262 | + |
| 263 | + inversion = _imaging_inversion( |
| 264 | + aa.Settings(use_positive_only_solver=False), |
| 265 | + linear_obj_list=[linear_obj, fixtures.make_rectangular_mapper_7x7_3x3()], |
| 266 | + ) |
| 267 | + |
| 268 | + assert not inversion.all_linear_obj_have_regularization |
| 269 | + |
| 270 | + reconstruction_ref, log_det_ref = _two_factorization_values(inversion) |
| 271 | + |
| 272 | + assert np.asarray(inversion.reconstruction) == pytest.approx( |
| 273 | + reconstruction_ref, rel=1.0e-10, abs=0.0 |
| 274 | + ) |
| 275 | + assert inversion._curvature_reg_matrix_cho_factor is not None |
| 276 | + |
| 277 | + # the factor is of the full matrix; the log det is of the mapper-only reduced matrix |
| 278 | + assert inversion.log_det_curvature_reg_matrix_term == pytest.approx( |
| 279 | + log_det_ref, rel=1.0e-9 |
| 280 | + ) |
0 commit comments