Skip to content

fix: form the reconstruction covariance via Cholesky, not an elementwise sqrt - #469

Merged
Jammy2211 merged 2 commits into
mainfrom
claude/numerical-inversion-failures-7xsp1k
Aug 22, 2026
Merged

fix: form the reconstruction covariance via Cholesky, not an elementwise sqrt#469
Jammy2211 merged 2 commits into
mainfrom
claude/numerical-inversion-failures-7xsp1k

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Closes #468.

The defect

AbstractInversion.reconstruction_noise_map_with_covariance was:

return np.sqrt(np.linalg.inv(self.curvature_reg_matrix))

np.sqrt is 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 was NaN by construction, for any input matrix however well-conditioned, with a RuntimeWarning on 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.sqrt is 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

  • new reconstruction_covariance_matrix — returns C itself, formed by Cholesky (cho_factor/cho_solve), input and output symmetrized, with an explicit finiteness guard.
  • reconstruction_noise_map — decoupled, now sqrt(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:

Claim Verdict
inv gives negative diagonals on well-formed SPD 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 — with regularization the matrix stays PD (cond ~6.8e7 even at exactly duplicated columns)
inv returns asymmetric output Confirmed — 5.2e-7 at cond 1e12, vs 2.6e-16
inv silently succeeds on indefinite matrices Confirmed

The case for Cholesky is detection, not accuracy. cho_factor raises LinAlgError on a negative eigenvalue; np.linalg.inv raises only on an exactly singular matrix and otherwise returns a plausible-looking covariance. At eigenvalue -1e-8 all 300 diagonals came back negative (whole noise map NaN); 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.

Not established: that a real curvature_reg_matrix is indefinite in a converged fit. Settings.no_regularization_add_to_curvature_diag_value and the curvature_matrix_with_added_to_diag_from docstring 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:

  • Non-finite input raised ValueError, not LinAlgError. scipy defaults to check_finite=True. Both callers (inversion_plots.py:169, :397) catch only LinAlgError, 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 raised ValueError past the guards. Now raises LinAlgError with a message naming the cause, so the contract holds for downstream callers too. check_finite=False is then passed to scipy, so the explicit check costs nothing net.
  • cho_factor reads 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 diag 0.5333 vs the true inverse's 0.5063. Output symmetrization does not address this; the input is now symmetrized too.

API and behaviour changes

reconstruction_noise_map_with_covariance values change — diagonal from std-dev to variance, off-diagonals from NaN to covariances. Grepped rather than assumed safe:

Repo with_covariance reconstruction_noise_map
PyAutoGalaxy 3ca31bf none none
PyAutoLens 87e5827 none none
autolens_workspace none 4 scripts + notebooks

Control greps confirm the checkouts are real (394 / 237 / 465 .py files), so the nulls are genuine. No consumer of the alias exists in the stack. reconstruction_noise_map is used — source_science.py computes signal_to_noise_map = reconstruction / reconstruction_noise_map — and its values are unchanged. Not checked: autogalaxy_workspace, HowTo repos, external user code.

reconstruction_noise_map is 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_matrix now raises on indefinite/non-finite input where inv silently returned numbers. Both existing call sites already catch LinAlgError — verified, not assumed.

Tests

The only prior assertion on this property checked [0, 0], a diagonal element — which is why the off-diagonal NaNs shipped. Added:

  • off-diagonals finite and genuinely negative, under -W error::RuntimeWarning (fails on the old implementation)
  • accuracy against an exactly-constructed ground truth A = Q diag(w) Q.T, plus symmetry
  • reconstruction_noise_map == sqrt(diag(C)) as an explicit invariant
  • raises on an indefinite matrix, asserting inv is silent on the same input
  • raises LinAlgError (not ValueError) on a non-finite matrix
  • asymmetric input is symmetrized rather than silently upper-triangled
  • the deprecated alias warns and matches the new property

Two plotter monkeypatch sites repointed to the new property. Fixed a stale test name asserting asymmetric_curvature_reg_matrix over a symmetric matrix.

Local run: 1152 passed, 1 skipped. Three test_transformer.py pynufft failures are pre-existing on a6b07cd (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 ignores zeroed_ids_to_keep under use_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

claude added 2 commits August 22, 2026 13:33
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: reconstruction covariance NaNs every off-diagonal; form it via Cholesky

2 participants