Skip to content

Commit 91240e4

Browse files
author
James Nightingale
authored
Merge pull request #555 from PyAutoLabs/feature/fixed-light-numba-levers-l3
feat(inversion): read log det(F + λH) off the NNLS Cholesky factor; cache curvature_reg_matrix (#267 lever 3)
2 parents 7c230c4 + b4322c3 commit 91240e4

7 files changed

Lines changed: 856 additions & 18 deletions

File tree

‎autoarray/inversion/inversion/abstract.py‎

Lines changed: 130 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ def __init__(
8888

8989
self._preloads = preloads
9090

91+
# The Cholesky factor of the passive submatrix that the positive-only solve builds, and
92+
# the global indices it was built on (`None` when the solve took the full system).
93+
# Published by `reconstruction` and read by `log_det_curvature_reg_matrix_term`, which
94+
# reads `log det(F + lambda*H)` off it instead of factorising that matrix a second time.
95+
# Per-instance and never a module global: one inversion's factor must never be able to
96+
# answer another inversion's log determinant.
97+
self._nnls_factor = None
98+
self._nnls_factor_ids = None
99+
91100
@property
92101
def _xp(self):
93102
if self.use_jax:
@@ -355,15 +364,23 @@ def regularization_matrix_reduced(self) -> Optional[np.ndarray]:
355364
# Zero rows and columns in the matrix we want to ignore
356365
return self.regularization_matrix[ids_to_keep][:, ids_to_keep]
357366

358-
@property
367+
@cached_property
359368
def curvature_reg_matrix(self) -> np.ndarray:
360369
"""
361370
The linear system of equations solves for F + regularization_coefficient*H, which is computed below.
362371
363-
For a single mapper, this function overwrites the cached `curvature_matrix`, because for large matrices this
364-
avoids overheads in memory allocation. The `curvature_matrix` is removed as a cached property as a result,
365-
to ensure if we access it after computing the `curvature_reg_matrix` it is correctly recalculated in a new
366-
array of memory.
372+
The addition is **out-of-place**: `curvature_matrix` is left intact and a new `[params, params]` array is
373+
returned. An earlier version of this property added `H` into `F` in place and then deleted the cached
374+
`curvature_matrix` to force its recomputation, and this docstring described that; the in-place form was
375+
removed in `0766edd4` (2025-06-26), so nothing here invalidates anything (and on the imaging inversions,
376+
where the matrix is large, `curvature_matrix` is itself a `cached_property`).
377+
378+
Cached because the likelihood reaches it more than once per evaluation (`reconstruction` and
379+
`curvature_reg_matrix_reduced`, and previously `log_det_curvature_reg_matrix_term` through the latter),
380+
and each access re-ran the add -- ~2 ms per access of an (n, n) sum at n = 1500. It was a
381+
`cached_property` until the blanket `e819fa12` (2025-11-05) sweep turned it into a plain `property` along
382+
with a dozen others. The cost of the cache is one extra (n, n) array (18 MB at n = 1500, on top of `F`
383+
and `H`) held for the inversion's lifetime.
367384
"""
368385
if not self.has(cls=AbstractRegularization):
369386
return self.curvature_matrix
@@ -606,6 +623,13 @@ def reconstruction(self) -> np.ndarray:
606623
# so it is safe to consult here and nowhere higher up.
607624
ids_to_keep = self.solve_ids_to_keep
608625

626+
# The solve's Cholesky factor of its passive submatrix, published through this
627+
# out-dict and stored on the instance below so that
628+
# `log_det_curvature_reg_matrix_term` can read `log det(F + lambda*H)` off it.
629+
# `reconstruction_positive_only_from` clears it on entry, so it is empty whenever
630+
# no solve completed.
631+
factor = {}
632+
609633
if ids_to_keep is not None:
610634

611635
# Use advanced indexing to select rows/columns
@@ -624,9 +648,13 @@ def reconstruction(self) -> np.ndarray:
624648
fingerprint=self._nnls_warm_start_fingerprint(
625649
ids_to_keep=ids_to_keep
626650
),
651+
factor=factor,
627652
)
628653
)
629654

655+
self._nnls_factor = factor
656+
self._nnls_factor_ids = ids_to_keep
657+
630658
# Allocate full solution array
631659
reconstruction = self._xp.zeros(self.data_vector.shape[0])
632660

@@ -642,14 +670,20 @@ def reconstruction(self) -> np.ndarray:
642670

643671
else:
644672

645-
return inversion_util.reconstruction_positive_only_from(
673+
reconstruction = inversion_util.reconstruction_positive_only_from(
646674
data_vector=self.data_vector,
647675
curvature_reg_matrix=self.curvature_reg_matrix,
648676
settings=self.settings,
649677
xp=self._xp,
650678
fingerprint=self._nnls_warm_start_fingerprint(),
679+
factor=factor,
651680
)
652681

682+
self._nnls_factor = factor
683+
self._nnls_factor_ids = None
684+
685+
return reconstruction
686+
653687
return inversion_util.reconstruction_positive_negative_from(
654688
data_vector=self.data_vector,
655689
curvature_reg_matrix=self.curvature_reg_matrix,
@@ -898,14 +932,102 @@ def log_det_curvature_reg_matrix_term(self) -> float:
898932
"""
899933
The log determinant of [F + reg_coeff*H] is used to determine the Bayesian evidence of the solution.
900934
901-
This uses the Cholesky decomposition which is already computed before solving the reconstruction
902-
(or ``slogdet`` when ``Settings.log_det_method == "slogdet"`` — see :meth:`_log_det_symmetric_from`).
935+
On the NumPy path with the positive-only solver and the default ``"cholesky"`` method this term is read
936+
off the Cholesky factor the reconstruction's non-negative least squares solve has already built, rather
937+
than factorizing ``F + reg_coeff*H`` a second time.
938+
939+
``fnnls_cholesky`` finishes holding a factor ``U`` of ``M[P][:, P]``, where ``M`` is
940+
``curvature_reg_matrix_reduced`` and ``P`` is the solve's final passive set -- 1485 of the 1500 columns
941+
on the production HST Delaunay ``AdaptSplit`` system. The block-determinant identity then gives the
942+
full log determinant from that factor plus a Schur complement over the ~15 remaining columns (see
943+
:func:`inversion_util.log_det_from_passive_cholesky_from`): ~5 ms in place of a ~40 ms dense
944+
factorization of the same matrix, agreeing with it to <= 2e-12 nats.
945+
946+
Because the factor comes from the solve, **this property evaluates** :attr:`reconstruction` (a
947+
``cached_property``) before reading it. The likelihood is unaffected -- it already evaluates
948+
``regularization_term``, and through it the reconstruction, before this term -- but a diagnostic that
949+
asks for this term alone now pays for the reconstruction solve, where before it paid only for one
950+
Cholesky.
951+
952+
The fast path is taken only when every one of these holds; anything else, and any
953+
``np.linalg.LinAlgError`` raised while reading the factor, takes the dense route below unchanged (which
954+
carries the test-mode guard in :meth:`_log_det_symmetric_from`):
955+
956+
- the NumPy backend (the factor is a NumPy buffer built by a numba solver);
957+
- ``Settings.log_det_method == "cholesky"`` (``"slogdet"`` is a different value by construction, and is
958+
reached through the dense route);
959+
- ``Settings.use_positive_only_solver`` (nothing else builds a factor, and this also keeps the
960+
positive-negative route from evaluating the reconstruction it would not otherwise need);
961+
- every linear object is regularized, so ``curvature_reg_matrix_reduced`` is the whole
962+
``curvature_reg_matrix`` the solve was handed rather than a ``mapper_indices`` subset of it;
963+
- a factor is present with a non-empty passive block, and its matrix shape matches;
964+
- the solve covered the whole reduced system. Note this is "no subset **or** the identity subset", not
965+
"no subset": with ``use_edge_zeroed_pixels`` on but no edge pixels to zero, ``solve_ids_to_keep``
966+
returns ``arange(n)`` rather than ``None``, which is the production configuration of the system
967+
above.
903968
"""
904969
if not self.has(cls=AbstractRegularization):
905970
return 0.0
906971

972+
if (
973+
self._xp is np
974+
and self.settings.log_det_method == "cholesky"
975+
and self.settings.use_positive_only_solver
976+
and self.all_linear_obj_have_regularization
977+
):
978+
matrix = self.curvature_reg_matrix_reduced
979+
980+
# Evaluates the solve (a `cached_property`), which is what publishes the factor
981+
# read below. Bound to `_` because the value is not what is wanted here -- the
982+
# side effect of the solve having run is.
983+
_ = self.reconstruction
984+
985+
factor = self._nnls_factor
986+
ids = self._nnls_factor_ids
987+
988+
if (
989+
factor
990+
and factor.get("U_buffer") is not None
991+
and factor.get("k_active", 0) > 0
992+
and factor.get("matrix_shape") == tuple(matrix.shape)
993+
and self._nnls_factor_ids_cover(ids=ids, total=matrix.shape[0])
994+
):
995+
try:
996+
return inversion_util.log_det_from_passive_cholesky_from(
997+
matrix=matrix,
998+
U_buffer=factor["U_buffer"],
999+
k_active=factor["k_active"],
1000+
passive_set=factor["passive_set"],
1001+
)
1002+
except np.linalg.LinAlgError:
1003+
# The Schur complement of the passive block was not positive-definite. That
1004+
# means this matrix is at the edge of what fp64 resolves as positive-definite,
1005+
# and the dense factorization below is the authority on it: it either returns
1006+
# its own value or raises the same error, where the test-mode guard and the
1007+
# likelihood's `FitException` resampling handle it as they always have. It must
1008+
# not be answered with a fabricated 0.0 here.
1009+
pass
1010+
9071011
return self._log_det_symmetric_from(self.curvature_reg_matrix_reduced)
9081012

1013+
@staticmethod
1014+
def _nnls_factor_ids_cover(ids: Optional[np.ndarray], total: int) -> bool:
1015+
"""
1016+
Did the solve whose factor was published cover every parameter of the reduced system?
1017+
1018+
`None` means the solve took the full system. An explicit index array covers it only if it is the
1019+
identity `arange(total)`, which `solve_ids_to_keep` does return: `use_edge_zeroed_pixels` with no edge
1020+
pixels to zero yields `arange(n)`, not `None`, and that is the production configuration of the HST
1021+
Delaunay system this fast path was measured on. Any other index array is a proper subset, whose factor
1022+
says nothing about the full matrix's determinant.
1023+
"""
1024+
if ids is None:
1025+
return True
1026+
1027+
ids = np.asarray(ids)
1028+
1029+
return ids.shape == (total,) and np.array_equal(ids, np.arange(total))
1030+
9091031
@property
9101032
def log_det_regularization_matrix_term(self) -> float:
9111033
"""

‎autoarray/inversion/inversion/imaging/mapping.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,11 @@ def curvature_matrix(self):
8383
concatenated ensuring their `curvature_matrix` values are solved for simultaneously. This includes all
8484
diagonal and off-diagonal terms describing the covariances between linear objects.
8585
86-
The `curvature_matrix` computed here is overwritten in memory when the regularization matrix is added to it,
87-
because for large matrices this avoids overhead. For this reason, `curvature_matrix` is not a cached property
88-
to ensure if we access it after computing the `curvature_reg_matrix` it is correctly recalculated in a new
89-
array of memory.
86+
This `curvature_matrix` is a `cached_property` and is NOT overwritten in memory:
87+
`curvature_reg_matrix` adds the regularization matrix to it out-of-place and leaves it intact. An earlier
88+
version of that property added `H` into `F` in place and deleted this cached value to force its
89+
recomputation, which is what this docstring described; the in-place form was removed in `0766edd4`
90+
(2025-06-26).
9091
"""
9192

9293
return inversion_util.curvature_matrix_via_mapping_matrix_from(

‎autoarray/inversion/inversion/imaging/sparse.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,11 @@ def curvature_matrix(self) -> np.ndarray:
241241
for simultaneously. In the w-tilde formalism this requires us to consider the mappings between data and every
242242
linear object, meaning that the linear alegbra has both on and off diagonal terms.
243243
244-
The `curvature_matrix` computed here is overwritten in memory when the regularization matrix is added to it,
245-
because for large matrices this avoids overhead. For this reason, `curvature_matrix` is not a cached property
246-
to ensure if we access it after computing the `curvature_reg_matrix` it is correctly recalculated in a new
247-
array of memory.
244+
This `curvature_matrix` is a `cached_property` and is NOT overwritten in memory:
245+
`curvature_reg_matrix` adds the regularization matrix to it out-of-place and leaves it intact. An earlier
246+
version of that property added `H` into `F` in place and deleted this cached value to force its
247+
recomputation, which is what this docstring described; the in-place form was removed in `0766edd4`
248+
(2025-06-26).
248249
"""
249250
if self.has(cls=AbstractLinearObjFuncList):
250251
curvature_matrix = self._curvature_matrix_func_list_and_mapper

0 commit comments

Comments
 (0)