Skip to content

Commit 8e68c5e

Browse files
Jammy2211claude
authored andcommitted
feat: opt-in gradient-safe log-det via Settings.log_det_method (default unchanged)
The two Bayesian-evidence log-det terms (log_det_curvature_reg_matrix_term and log_det_regularization_matrix_term) use 2*sum(log(diag(cholesky(M)))), which returns NaN on the JAX backend (raises on numpy) when M is not numerically positive-definite -- stalling gradient-based searches on ill-conditioned regularization matrices (autolens_workspace_developer#104). Add Settings.log_det_method (Optional -> conf general.yaml fallback, mirroring the use_positive_only_solver pattern): - "cholesky" (default) -- the historical computation, byte-for-byte unchanged including the test-mode LinAlgError guard. Both terms route through a shared _log_det_symmetric_from helper; the default branch is the identical expression. - "slogdet" -- logabsdet of xp.linalg.slogdet(M): identical where M is positive-definite, finite and differentiable where the Cholesky would NaN. Opt-in, non-default, general (serves the adaptive schemes too); intended for gradient-based work and for comparison against the Cholesky evidence. Scope decided by an independent adversarial review of the reg-logdet investigation: the current evidence's coefficient-dependence is correct to machine precision, so the DEFAULT is deliberately not changed. This adds an opt-in alternative only. Full test_autoarray suite 926 passed (every existing log_evidence test unchanged = byte-identical default). New tests: default==cholesky==numpy log det; slogdet==cholesky on a PD matrix; slogdet finite where cholesky fails on a non-PD matrix. No workspace config mirroring needed -- packaged-only keys layer through production config (verified, like nnls_target_kappa). Closes #391. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d6cb7eb commit 8e68c5e

4 files changed

Lines changed: 123 additions & 32 deletions

File tree

autoarray/config/general.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ inversion:
99
nnls_jacobi_preconditioning: true # If True (default), the curvature matrix passed to jaxnnls.solve_nnls_primal is Jacobi-preconditioned (D Q D y = D q, x = D y). Fixes NaN backward-pass gradients on ill-conditioned Q and roughly halves forward solve time. Set False to restore the raw unpreconditioned solve.
1010
nnls_target_kappa: 1.0e-11 # Central-path relaxation parameter passed to jaxnnls.solve_nnls_primal. Larger values smooth the relaxed-KKT backward pass and prevent NaN gradients on ill-conditioned Q; smaller values tighten the primal solve. Verified finite gradients across all MGE/rectangular/delaunay pipelines (imaging + interferometer) with scale invariance over 5 orders of magnitude in noise. jaxnnls's own default (1e-3) is too aggressive for the backward pass.
1111
reconstruction_vmax_factor: 0.5 # Plots of an Inversion's reconstruction use the reconstructed data's bright value multiplied by this factor.
12+
log_det_method: cholesky # How the Bayesian-evidence log-determinant terms are computed. "cholesky" (default) is the historical 2*sum(log(diag(cholesky(M)))); "slogdet" uses logabsdet of slogdet(M), which is identical where M is positive-definite but finite (not NaN) where the Cholesky fails, for gradient-based searches (opt-in, non-default; does not change the default evidence). See PyAutoArray#391.
1213
numba:
1314
use_numba: true
1415
cache: true

autoarray/inversion/inversion/abstract.py

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -702,42 +702,61 @@ def regularization_term(self) -> float:
702702
),
703703
)
704704

705-
@property
706-
def log_det_curvature_reg_matrix_term(self) -> float:
705+
def _log_det_symmetric_from(self, matrix) -> float:
707706
"""
708-
The log determinant of [F + reg_coeff*H] is used to determine the Bayesian evidence of the solution.
707+
Log determinant of a symmetric positive-(semi)definite ``matrix``, used by the two evidence log-det terms.
708+
709+
The method is selected by ``self.settings.log_det_method`` (see :class:`Settings`):
709710
710-
This uses the Cholesky decomposition which is already computed before solving the reconstruction.
711+
- ``"cholesky"`` (default) — ``2 * sum(log(diag(cholesky(matrix))))``. This is the historical
712+
computation and is byte-for-byte unchanged, including the test-mode ``LinAlgError`` guard. On the
713+
NumPy backend a non-positive-definite matrix raises; on the JAX backend the Cholesky returns NaN,
714+
which propagates as a non-finite figure of merit and can stall gradient-based searches
715+
(autolens_workspace_developer#104).
716+
- ``"slogdet"`` — the ``logabsdet`` from ``xp.linalg.slogdet(matrix)``. Wherever ``matrix`` is
717+
positive-definite this equals the Cholesky value exactly (the sign is +1), but where the Cholesky
718+
would NaN it returns a finite, differentiable value instead, so it never stalls a gradient search.
719+
It is **opt-in and non-default** — intended for gradient-based work and for comparison against the
720+
Cholesky evidence, not as a replacement for it. See PyAutoArray#391.
711721
"""
712-
if not self.has(cls=AbstractRegularization):
713-
return 0.0
722+
if self.settings.log_det_method == "slogdet":
723+
return self._xp.linalg.slogdet(matrix)[1]
714724

715725
try:
716726
return 2.0 * self._xp.sum(
717-
self._xp.log(
718-
self._xp.diag(
719-
self._xp.linalg.cholesky(self.curvature_reg_matrix_reduced)
720-
)
721-
)
727+
self._xp.log(self._xp.diag(self._xp.linalg.cholesky(matrix)))
722728
)
723729
except np.linalg.LinAlgError:
724730
if is_test_mode():
725-
# Singular curvature-reg matrix from a fabricated test-mode model;
726-
# this evidence term is discarded. Return a benign 0.0 so unguarded
727-
# test-mode paths do not crash (matches the reconstruction guard in
728-
# inversion_util). Normal runs re-raise unchanged. numpy-only: the
729-
# JAX cholesky returns NaN rather than raising.
731+
# Singular matrix from a fabricated test-mode model; this evidence term is discarded.
732+
# Return a benign 0.0 so unguarded test-mode paths do not crash (matches the reconstruction
733+
# guard in inversion_util). Normal runs re-raise unchanged. numpy-only: the JAX cholesky
734+
# returns NaN rather than raising (which is exactly what "slogdet" exists to avoid).
730735
return 0.0
731736
raise
732737

738+
@property
739+
def log_det_curvature_reg_matrix_term(self) -> float:
740+
"""
741+
The log determinant of [F + reg_coeff*H] is used to determine the Bayesian evidence of the solution.
742+
743+
This uses the Cholesky decomposition which is already computed before solving the reconstruction
744+
(or ``slogdet`` when ``Settings.log_det_method == "slogdet"`` — see :meth:`_log_det_symmetric_from`).
745+
"""
746+
if not self.has(cls=AbstractRegularization):
747+
return 0.0
748+
749+
return self._log_det_symmetric_from(self.curvature_reg_matrix_reduced)
750+
733751
@property
734752
def log_det_regularization_matrix_term(self) -> float:
735753
"""
736754
The Bayesian evidence of an inversion which quantifies its overall goodness-of-fit uses the log determinant
737755
of regularization matrix, Log[Det[Lambda*H]].
738756
739757
Unlike the determinant of the curvature reg matrix, which uses an existing preloading Cholesky decomposition
740-
used for the source reconstruction, this uses scipy sparse linear algebra to solve the determinant efficiently.
758+
used for the source reconstruction, this uses scipy sparse linear algebra to solve the determinant efficiently
759+
(or ``slogdet`` when ``Settings.log_det_method == "slogdet"`` — see :meth:`_log_det_symmetric_from`).
741760
742761
Returns
743762
-------
@@ -747,21 +766,7 @@ def log_det_regularization_matrix_term(self) -> float:
747766
if not self.has(cls=AbstractRegularization):
748767
return 0.0
749768

750-
try:
751-
return 2.0 * self._xp.sum(
752-
self._xp.log(
753-
self._xp.diag(
754-
self._xp.linalg.cholesky(self.regularization_matrix_reduced)
755-
)
756-
)
757-
)
758-
except np.linalg.LinAlgError:
759-
if is_test_mode():
760-
# Singular regularization matrix from a fabricated test-mode model;
761-
# this evidence term is discarded. Benign 0.0 in test mode, unchanged
762-
# (raise) in normal operation. numpy-only (JAX cholesky returns NaN).
763-
return 0.0
764-
raise
769+
return self._log_det_symmetric_from(self.regularization_matrix_reduced)
765770

766771
@property
767772
def reconstruction_noise_map_with_covariance(self) -> np.ndarray:

autoarray/settings.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def __init__(
1717
no_regularization_add_to_curvature_diag_value: float = None,
1818
nnls_solver_tol: Optional[float] = None,
1919
nnls_max_iter: Optional[int] = None,
20+
log_det_method: Optional[str] = None,
2021
):
2122
"""
2223
The settings of an Inversion, customizing how a linear set of equations are solved for.
@@ -95,6 +96,19 @@ def __init__(
9596
jaxnnls's own hard-coded cap of 50 (production HST pixelization+MGE systems converge in ~19-21
9697
iterations). Under `vmap` the solve runs until the slowest lane in the batch converges, so this
9798
also caps the worst-case batched cost. Only the JAX (`xp=jnp`) path honors this.
99+
log_det_method
100+
Which computation is used for the two Bayesian-evidence log-determinant terms
101+
(``log_det_curvature_reg_matrix_term`` and ``log_det_regularization_matrix_term``). `None`
102+
(default) reads the packaged value ``"cholesky"``.
103+
104+
- ``"cholesky"`` (default) — ``2 * sum(log(diag(cholesky(M))))``, the historical computation.
105+
On a non-positive-definite matrix the NumPy backend raises and the JAX backend returns NaN.
106+
- ``"slogdet"`` — the ``logabsdet`` of ``xp.linalg.slogdet(M)``. Where ``M`` is positive-definite
107+
this equals the Cholesky value exactly; where the Cholesky would NaN it returns a finite,
108+
differentiable value instead, so it never stalls a gradient-based search
109+
(autolens_workspace_developer#104). This is an **opt-in, non-default** alternative intended for
110+
gradient-based work and for comparison against the Cholesky evidence — it is not a replacement
111+
for the default and the default evidence path is unchanged. See PyAutoArray#391.
98112
"""
99113
self.use_mixed_precision = use_mixed_precision
100114
self.nnls_solver_tol = nnls_solver_tol
@@ -105,6 +119,7 @@ def __init__(
105119
self._no_regularization_add_to_curvature_diag_value = (
106120
no_regularization_add_to_curvature_diag_value
107121
)
122+
self._log_det_method = log_det_method
108123

109124
@property
110125
def use_positive_only_solver(self):
@@ -135,3 +150,10 @@ def no_regularization_add_to_curvature_diag_value(self):
135150
]
136151

137152
return self._no_regularization_add_to_curvature_diag_value
153+
154+
@property
155+
def log_det_method(self):
156+
if self._log_det_method is None:
157+
return conf.instance["general"]["inversion"]["log_det_method"]
158+
159+
return self._log_det_method

test_autoarray/inversion/inversion/test_abstract.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,69 @@ def test__determinant_of_positive_definite_matrix_via_cholesky__tridiagonal_matr
613613
)
614614

615615

616+
def test__log_det_method__default_is_cholesky_and_matches_numpy_log_det():
617+
# The default log_det_method must reproduce the historical Cholesky value exactly.
618+
assert aa.Settings().log_det_method == "cholesky"
619+
620+
matrix = np.array([[2.0, -1.0, 0.0], [-1.0, 2.0, -1.0], [0.0, -1.0, 2.0]])
621+
622+
inversion = aa.m.MockInversion(
623+
linear_obj_list=[aa.m.MockLinearObj(regularization=aa.m.MockRegularization())],
624+
curvature_reg_matrix=matrix,
625+
)
626+
627+
assert inversion.log_det_curvature_reg_matrix_term == pytest.approx(
628+
np.log(np.linalg.det(matrix)), 1.0e-4
629+
)
630+
631+
632+
def test__log_det_method__slogdet_matches_cholesky_on_positive_definite_matrix():
633+
# Where the matrix is positive-definite, "slogdet" is identical to "cholesky".
634+
matrix = np.array([[2.0, -1.0, 0.0], [-1.0, 2.0, -1.0], [0.0, -1.0, 2.0]])
635+
636+
cholesky_inversion = aa.m.MockInversion(
637+
linear_obj_list=[aa.m.MockLinearObj(regularization=aa.m.MockRegularization())],
638+
curvature_reg_matrix=matrix,
639+
regularization_matrix=matrix,
640+
)
641+
slogdet_inversion = aa.m.MockInversion(
642+
linear_obj_list=[aa.m.MockLinearObj(regularization=aa.m.MockRegularization())],
643+
curvature_reg_matrix=matrix,
644+
regularization_matrix=matrix,
645+
settings=aa.Settings(log_det_method="slogdet"),
646+
)
647+
648+
expected = np.log(np.linalg.det(matrix))
649+
650+
assert cholesky_inversion.log_det_curvature_reg_matrix_term == pytest.approx(
651+
expected, 1.0e-8
652+
)
653+
assert slogdet_inversion.log_det_curvature_reg_matrix_term == pytest.approx(
654+
cholesky_inversion.log_det_curvature_reg_matrix_term, 1.0e-8
655+
)
656+
assert slogdet_inversion.log_det_regularization_matrix_term == pytest.approx(
657+
cholesky_inversion.log_det_regularization_matrix_term, 1.0e-8
658+
)
659+
660+
661+
def test__log_det_method__slogdet_is_finite_where_cholesky_fails_on_non_positive_definite():
662+
# A symmetric matrix with a negative eigenvalue: Cholesky cannot factor it (raises /
663+
# returns NaN), but slogdet returns the finite log|det|. This is the property that
664+
# keeps a gradient-based search from stalling (autolens_workspace_developer#104).
665+
matrix = np.array([[1.0, 2.0, 0.0], [2.0, 1.0, 0.0], [0.0, 0.0, 3.0]])
666+
assert np.linalg.eigvalsh(matrix).min() < 0.0 # confirm it is not positive-definite
667+
668+
inversion = aa.m.MockInversion(
669+
linear_obj_list=[aa.m.MockLinearObj(regularization=aa.m.MockRegularization())],
670+
curvature_reg_matrix=matrix,
671+
settings=aa.Settings(log_det_method="slogdet"),
672+
)
673+
674+
result = inversion.log_det_curvature_reg_matrix_term
675+
assert np.isfinite(result)
676+
assert result == pytest.approx(np.linalg.slogdet(matrix)[1], 1.0e-8)
677+
678+
616679
def test__reconstruction_noise_map__asymmetric_curvature_reg_matrix__correct_diagonal_noise_values():
617680
curvature_reg_matrix = np.array([[1.0, 1.0, 1.0], [1.0, 2.0, 1.0], [1.0, 1.0, 3.0]])
618681

0 commit comments

Comments
 (0)