fix: form the reconstruction covariance via Cholesky, not an elementwise sqrt - #469
Merged
Merged
Conversation
… sqrt
`reconstruction_noise_map_with_covariance` applied np.sqrt elementwise to the
whole inverse of curvature_reg_matrix. The off-diagonals of a covariance
matrix are covariances and are routinely negative, so every one was NaN by
construction -- for any matrix, however well-conditioned -- and each call
emitted "RuntimeWarning: invalid value encountered in sqrt". The property's
docstring promised a matrix accounting for "the covariance of the noise
between pixels"; the entries carrying that covariance were the broken ones.
The 1D reconstruction_noise_map was NOT affected: np.sqrt is elementwise, so
it commutes with taking the diagonal.
Replaced with reconstruction_covariance_matrix, which returns C directly and
forms it from a Cholesky factorization. An A/B against np.linalg.inv refuted
two hypotheses and confirmed one:
- inv gives negative diagonals on well-formed SPD matrices: REFUTED
(0 across cond 1e3-1e15, n=400, 20 trials each)
- inv is materially less accurate on the diagonal: REFUTED
(matches cho_solve; at cond 1e15 inv was marginally better)
- near-coincident mesh vertices degrade the inverse: REFUTED
(regularization keeps the matrix PD, cond ~6.8e7 at duplicated columns)
- inv returns asymmetric output: CONFIRMED
(5.2e-7 at cond 1e12, vs 2.6e-16 for cho_solve)
- inv silently succeeds on indefinite matrices: CONFIRMED
The last is the substantive one. cho_factor raises LinAlgError on a matrix
with a negative eigenvalue; inv succeeds and returns a plausible-looking
covariance. At eigenvalue -1e-8 all 300 diagonals came back negative; at -1.0,
zero did -- no NaN, no warning, no error, and wrong numbers. A covariance is
only defined for a positive-definite matrix, so this adds a definiteness check
the code lacked entirely. Both existing call sites already catch LinAlgError
(inversion_plots.py:169 and :395), verified rather than assumed.
reconstruction_noise_map now computes sqrt(diag(C)) directly. It was correct
only incidentally before, via the elementwise sqrt; the invariant is now
stated, and it cannot silently become a variance if the matrix changes.
The old name stays as a deprecated alias. Its values do change -- diagonal
from std-dev to variance, off-diagonals from NaN to covariances -- so the
warning says so explicitly.
Tests: the only prior assertion checked [0, 0], a diagonal element, which is
why this shipped. Added off-diagonal finiteness under -W error::RuntimeWarning,
exact symmetry, the sqrt(diag(C)) invariant, the indefinite-matrix raise, and
the deprecation. Repointed two plotter monkeypatch sites to the new property.
Full suite: 1150 passed, 1 skipped. The 3 test_transformer.py pynufft
failures are pre-existing on a6b07cd and unrelated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0133X4XhMV91SFjzV2mK4Ejh
Addresses an adversarial review of the previous commit. 1. MAJOR regression, now fixed. scipy's cho_factor/cho_solve default to check_finite=True and raise ValueError -- not LinAlgError -- on a NaN/inf matrix. Both callers (inversion_plots.py:169 and :397) catch only LinAlgError, and the CSV writer's docstring explicitly promises that a failure there may not abort the enclosing model-fit. So a NaN-contaminated curvature matrix would have killed a fit that previously wrote a nan column and continued. Verified before and after: the old code returned [nan, nan]; the new code raised builtins.ValueError past the guards. Fixed by checking finiteness explicitly and raising LinAlgError with a message naming the cause, rather than by broadening the callers' except clauses -- the property's contract is now "raises LinAlgError for any input that has no covariance", which holds downstream too. check_finite=False is then passed to scipy, so the explicit check costs nothing net. 2. cho_factor reads only the upper triangle, so an asymmetric input was silently inverted as though its lower triangle matched. Verified: for [[2.0, 0.5], [0.1, 2.0]] it gave diag 0.5333 against the true inverse's 0.5063. The output symmetrization does not address this -- it symmetrizes the result, not the input -- so the input is now symmetrized too. curvature_reg_matrix is F + H and symmetric by construction, so this is defensive, but the silent wrong answer is not an acceptable failure mode. 3. Softened the "no value change" claim for reconstruction_noise_map. The two forms are algebraically identical but only numerically equivalent, since the covariance is now formed by Cholesky rather than LU: ~7e-15 relative at cond 1e3 rising to ~4e-5 at cond 1e13. Neither is the more correct result. 4. Tests. The symmetry test was tautological -- 0.5 * (C + C.T) is bitwise symmetric for any C, so it could only fail if the line were deleted. It now also asserts accuracy against an exactly-constructed ground truth (A = Q diag(w) Q.T), with the tolerance set from the measured error (~3e-9 at cond 1e9) and a comment recording that np.linalg.inv is marginally more accurate on that matrix, not less. Added tests for the non-finite raise and the asymmetric-input symmetrization. Fixed a stale test name that said "asymmetric_curvature_reg_matrix" over a symmetric matrix. Downstream check for the value-changing deprecated alias: no consumer of reconstruction_noise_map_with_covariance exists in PyAutoGalaxy (3ca31bf), PyAutoLens (87e5827) or autolens_workspace -- zero hits for "with_covariance" in any of them. reconstruction_noise_map IS used, in autolens_workspace source_science.py scripts computing signal_to_noise_map = reconstruction / reconstruction_noise_map, and its values are unchanged. Full suite: 1152 passed, 1 skipped. The 3 test_transformer.py pynufft failures are pre-existing on a6b07cd and unrelated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0133X4XhMV91SFjzV2mK4Ejh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #468.
The defect
AbstractInversion.reconstruction_noise_map_with_covariancewas:np.sqrtis applied elementwise to the whole inverse. That inverse is the reconstruction covariance matrix, whose off-diagonals are covariances and are routinely negative — so every one wasNaNby construction, for any input matrix however well-conditioned, with aRuntimeWarningon every call. The docstring promised a matrix that "accounts for the covariance of the noise between pixels"; the entries carrying that covariance were exactly the broken ones.Found during the reproduction gate for #467.
This did not affect the 1D
reconstruction_noise_map.np.sqrtis elementwise, so it commutes with taking the diagonal (diagonal(sqrt(C))[i] == sqrt(C[i,i])). Only the covariance-aware consumer and the warning spam were hit.Changes
reconstruction_covariance_matrix— returnsCitself, formed by Cholesky (cho_factor/cho_solve), input and output symmetrized, with an explicit finiteness guard.reconstruction_noise_map— decoupled, nowsqrt(diag(C))directly. It was previously correct only incidentally, via the elementwise sqrt; the invariant is now stated, so it cannot silently become a variance if the matrix changes.reconstruction_noise_map_with_covariance— deprecated alias whose warning states the value change explicitly.Why Cholesky — on measured evidence, not assumption
An A/B was run before writing the fix, and it refuted two of the arguments originally made for this change:
invgives negative diagonals on well-formed SPDinvis materially less accurate on the diagonalcho_solve; at cond 1e15invwas marginally betterinvreturns asymmetric outputinvsilently succeeds on indefinite matricesThe case for Cholesky is detection, not accuracy.
cho_factorraisesLinAlgErroron a negative eigenvalue;np.linalg.invraises only on an exactly singular matrix and otherwise returns a plausible-looking covariance. At eigenvalue-1e-8all 300 diagonals came back negative (whole noise mapNaN); at-1.0, zero did — noNaN, no warning, no error, and wrong numbers. A covariance is only defined for a positive-definite matrix, so this adds a definiteness check the code lacked entirely.Not established: that a real
curvature_reg_matrixis indefinite in a converged fit.Settings.no_regularization_add_to_curvature_diag_valueand thecurvature_matrix_with_added_to_diag_fromdocstring say non-PD matrices happen, but no fit was instrumented to confirm it.Review fixes (second commit)
An adversarial review of the first commit found a regression it had introduced:
ValueError, notLinAlgError. scipy defaults tocheck_finite=True. Both callers (inversion_plots.py:169,:397) catch onlyLinAlgError, and the CSV writer's docstring explicitly promises a failure there may not abort the enclosing model-fit. Verified: old code returned[nan, nan]; first fix raisedValueErrorpast the guards. Now raisesLinAlgErrorwith a message naming the cause, so the contract holds for downstream callers too.check_finite=Falseis then passed to scipy, so the explicit check costs nothing net.cho_factorreads only the upper triangle, so an asymmetric input was silently inverted as though its lower triangle matched —[[2.0, 0.5], [0.1, 2.0]]gave diag0.5333vs the true inverse's0.5063. Output symmetrization does not address this; the input is now symmetrized too.API and behaviour changes
reconstruction_noise_map_with_covariancevalues change — diagonal from std-dev to variance, off-diagonals fromNaNto covariances. Grepped rather than assumed safe:with_covariancereconstruction_noise_map3ca31bf87e5827Control greps confirm the checkouts are real (394 / 237 / 465
.pyfiles), so the nulls are genuine. No consumer of the alias exists in the stack.reconstruction_noise_mapis used —source_science.pycomputessignal_to_noise_map = reconstruction / reconstruction_noise_map— and its values are unchanged. Not checked:autogalaxy_workspace, HowTo repos, external user code.reconstruction_noise_mapis numerically, not bitwise, equivalent. Algebraically identical, but the covariance is now formed by Cholesky rather than LU: ~7e-15 relative at cond 1e3 rising to ~4e-5 at cond 1e13. Neither result is the more correct one.reconstruction_covariance_matrixnow raises on indefinite/non-finite input whereinvsilently returned numbers. Both existing call sites already catchLinAlgError— verified, not assumed.Tests
The only prior assertion on this property checked
[0, 0], a diagonal element — which is why the off-diagonalNaNs shipped. Added:-W error::RuntimeWarning(fails on the old implementation)A = Q diag(w) Q.T, plus symmetryreconstruction_noise_map == sqrt(diag(C))as an explicit invariantinvis silent on the same inputLinAlgError(notValueError) on a non-finite matrixTwo plotter monkeypatch sites repointed to the new property. Fixed a stale test name asserting
asymmetric_curvature_reg_matrixover a symmetric matrix.Local run: 1152 passed, 1 skipped. Three
test_transformer.pypynufft failures are pre-existing ona6b07cd(verified by stashing) and unrelated — a sandbox dependency issue, expected to be green in CI.Out of scope
The estimator-level defects are tracked separately and deliberately excluded: the covariance describes the unconstrained Warren & Dye solve while
use_positive_only_solver: true(NNLS) is the shipped default; the noise map ignoreszeroed_ids_to_keepunderuse_edge_zeroed_pixels: true; and that setting is silently ignored when the positive-only solver is off. Those need a statistical decision, not a code repair.🤖 Generated with Claude Code
https://claude.ai/code/session_0133X4XhMV91SFjzV2mK4Ejh
Generated by Claude Code