fix: form the reconstruction covariance on the parameters the solve solved for - #493
Merged
Merged
Conversation
…olved for `AbstractInversion.reconstruction` does not always solve the full linear system. Under `use_edge_zeroed_pixels` it subsets `curvature_reg_matrix` to `zeroed_ids_to_keep`, solves the reduced problem and scatters the answer back with exact zeros at the excluded pixels -- the mesh's poorly-constrained boundary vertices, zeroed precisely to keep the inversion stable. `reconstruction_covariance_matrix` inverted the FULL matrix regardless. So it re-admitted into an explicit inverse the very rows the solve dropped to stay stable, and `reconstruction_noise_map` reported a finite noise value for a pixel whose reconstruction reads exactly 0.0 because it was never solved for. The reconstruction and its noise map disagreed about which pixels had been estimated. Not opt-in, as it was previously scoped. `Delaunay.zeroed_pixels` is a count defaulting to 0, but `RectangularRTUAdaptDensity.zeroed_pixels` returns the whole edge ring unconditionally -- there is no setting to turn it off. Every `Rectangular*AdaptDensity` fit is affected: 108 of 784 parameters on a 28x28, 156 of 1600 on a 40x40. The structural fix, not just the local one ------------------------------------------ The predicate deciding whether the solve subsets the system lived inline in `reconstruction` and nowhere else, so the covariance had no way to know. It is now one property, `solve_ids_to_keep`, which both read. It returns None (not "every index") when the solve is full, so that path never enters the indexing and scatter-back code and stays byte-identical. It also reproduces the nesting deliberately: `use_edge_zeroed_pixels` is consulted only when `use_positive_only_solver` is on. That scoping was confirmed intentional in 84b9ed4 and the comment forbidding its "fix" is preserved and extended. Excluded entries are NaN, not zero ---------------------------------- Zero is a legitimate covariance value ("known exactly"), indistinguishable from a real result. These parameters were held at zero by construction and never estimated, so the honest report is that there is no number. Both consumers handle it, and neither needed changing: `norm_from` derives colour limits with `np.nanmax`, and `save_reconstruction_csv` already writes `nan` in this column when the covariance cannot be computed. Verified by rendering the mapper subplot in both linear and log10 scales and writing the CSV. A signal-to-noise map formed as `reconstruction / reconstruction_noise_map` gives `0.0 / NaN = NaN` silently, and `NaN >= threshold` is False, so these pixels fall outside a signal-to-noise cut rather than counting as significant. (Reporting 0 instead would have made that `0/0` -- the same NaN, plus a RuntimeWarning on every call.) This also changes the solved pixels, and needs a release note ------------------------------------------------------------- Inverting the submatrix is not the corresponding block of the full inverse. For symmetric positive-definite A, `[A^-1]_keep >= (A_keep)^-1` in the PSD ordering, so every kept pixel's variance is LOWER than before. The direction is guaranteed; a regression would show up as a kept pixel getting noisier, and is asserted as such. The MAGNITUDE was not measured on a real lens fit here -- this environment has no PyAutoLens. A structural proxy on 20x20/28x28/40x40 meshes puts the median ratio at 0.983-0.992 (max 0.995, never above 1), but that is exactly the kind of proxy that understated a related effect by an order of magnitude in the investigation behind this change, because a random mapping matrix spreads data support across the whole mesh while real ray tracing concentrates it in the arc. Measure on a real fit before quoting a number in the release note. This is unrelated to the NNLS active-set caveat on `reconstruction_noise_map`, where the shipped and free-set-restricted values bracket the truth. Here the excluded parameters are fixed at zero before the solver runs rather than chosen by the data, so conditioning on them is exact and there is no bracket. Also ---- - Corrects three comments that described `mapper_indices` as "ids of values which are on edge so zero-d and not solved for". It is not edge zeroing: it drops the linear objects carrying no regularization. Conflating the two made the reduced matrices and the zeroed solve look like one inconsistent treatment of a single index set when they are unrelated concerns. - `MockMapper` gains the `mesh` accessor for the `mesh` it already stored. Tests: 9 unit cases over a 4x4 mesh (4 interior parameters kept, 12 zeroed) and 1 end-to-end case through the real solver and mesh, asserting the invariant -- `reconstruction[i] == 0.0` exactly where `reconstruction_noise_map[i]` is NaN -- plus the full-system paths unchanged, the one-directional bound, and that a non-finite entry raises LinAlgError only when the solve actually used it. Full suite: 1177 passed, 55 skipped. Known, not fixed here (pre-existing, unreachable from this change): the `np.errstate(all="ignore")` guard in `plot/utils.py:norm_from` does not suppress `np.nanmax`'s All-NaN RuntimeWarning, which is issued through `warnings` rather than the floating-point error state. A per-mapper noise map cannot be all-NaN -- a rectangular mesh is at least 3x3 and keeps its interior, Delaunay zeroes a count strictly below its pixel total -- so this change cannot trigger it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lkq5ww6eLEvJgPFGMgMU1C
…ange
Measuring this change on a real ray-traced fit (PyAutoLens + PyAutoGalaxy +
PyAutoFit at main, against this branch) turned up an overclaim in the previous
commit and supplied the magnitude it said was missing.
The invariant was stated too strongly
-------------------------------------
The docstring and the end-to-end test claimed a biconditional: a pixel reads
exactly 0.0 in the reconstruction exactly where the noise map is NaN. That is
false, and the difference is not marginal. The non-negative solver ALSO pins
pixels it did solve for at exactly 0.0, wherever the fit wants no flux there,
and those are fitted values carrying real error bars.
On a 28x28 RectangularBilinearAdaptDensity fit, 784 parameters:
reconstruction == 0.0 : 603
NaN in noise map : 108 <- only these were never estimated
solved, but pinned at 0 by NNLS : 495
So `NaN => reconstruction == 0.0` holds, the converse does not, and
`np.isnan(reconstruction_noise_map)` is the only way to identify the
never-estimated pixels. The test passed only because the 3x3 fixture it runs on
solves a single pixel, which makes the two sets coincide by accident. It now
asserts the NaN set equals the excluded set exactly, and the one-way
implication, with a comment saying why the converse is deliberately absent.
This is also precisely the Defect 1 / Defect 2 boundary: 495 pixels pinned by
the constraint are the NNLS active-set question (documented in #472, deferred),
and 108 structurally zeroed ones are this change.
Measured magnitude, at the evidence-optimal coefficient
-------------------------------------------------------
The previous commit said the magnitude had not been measured on a real fit and
that the structural proxy standing in for it should not be trusted. It should
not have been: the proxy predicted a uniform 1-2% shift and the real answer is
a much smaller median with a long tail.
Rectangular 28x28 (108 of 784 zeroed), lambda* = 1 by log evidence on a
9-point grid, interior to the grid in every case:
old/new p50 1.0002 p90 1.069 p99 1.44 max 1.81
21% of solved pixels move >1%, 9% move >10%
Delaunay, set up as the workspace's own delaunay.py does (Overlay image mesh
plus a ring of edge points, zeroed_pixels=30) -- which answers the "Delaunay
untested" caveat this change inherited:
old/new p50 1.0045 p99 1.90 max 2.37
With the default zeroed_pixels=0 the property is unchanged to the bit, and
solve_ids_to_keep is None, confirming the no-op path.
Downstream source flux and magnification through the workspace's S/N >= 5 cut
moved by 0.00% in every case. The pixels whose noise changes most sit at the
mesh edge, where the reconstruction has no flux to move: the counts of lit
pixels surviving the cut are identical old and new (7/7, 24/24, 121/121).
So the release note should say: a systematic, one-directional correction
concentrated at the mesh edge, negligible in the median, up to ~2x on
individual edge pixels, with no effect on published source flux or
magnification.
Full suite: 1177 passed, 55 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lkq5ww6eLEvJgPFGMgMU1C
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.
Summary
AbstractInversion.reconstructiondoes not always solve the full linear system. Underuse_edge_zeroed_pixelsit subsetscurvature_reg_matrixtozeroed_ids_to_keep, solves the reduced problem and scatters the answer back with exact zeros at the excluded pixels — the mesh's poorly-constrained boundary vertices, zeroed precisely to keep the inversion stable.reconstruction_covariance_matrixinverted the full matrix regardless. So it re-admitted into an explicit inverse the very rows the solve dropped to stay stable, andreconstruction_noise_mapreported a finite noise value for a pixel whose reconstruction reads exactly0.0because it was never solved for.Not opt-in, as the issue originally scoped it.
Delaunay.zeroed_pixelsis a count defaulting to0, butRectangularRTUAdaptDensity.zeroed_pixelsreturns the whole edge ring unconditionally — 108 of 784 parameters on a 28×28, 156 of 1600 on a 40×40. And the workspace's owndelaunay.pyexample useszeroed_pixels=30, so the documented Delaunay idiom hits this path too.Closes #492.
The structural fix, not just the local one
The predicate deciding whether the solve subsets the system lived inline in
reconstructionand nowhere else, so the covariance had no way to know. It is now one property,solve_ids_to_keep, which both read. It returnsNone(not "every index") when the solve is full, so that path never enters the indexing and scatter-back code and stays byte-identical. It reproduces the deliberate nesting —use_edge_zeroed_pixelsis consulted only whenuse_positive_only_solveris on — and the84b9ed4comment forbidding its "fix" is preserved and extended.Excluded entries are
NaN, not zeroZero is a legitimate covariance value ("known exactly"), indistinguishable from a real result. These parameters were held at zero by construction and never estimated. Both consumers handle
NaNand neither needed changing:norm_fromderives colour limits withnp.nanmax, andsave_reconstruction_csvalready writesnanin this column when the covariance cannot be computed. Verified by rendering the mapper subplot in both linear and log10 scales and writing the CSV.A signal-to-noise map formed as
reconstruction / reconstruction_noise_mapgives0.0 / NaN = NaNsilently, andNaN >= thresholdisFalse, so these pixels fall outside a S/N cut rather than counting as significant. (Reporting0instead would have made that0/0— the sameNaN, plus aRuntimeWarningon every call.)API Changes
No signature changes and no removals. One property added, and the values of two existing properties change whenever the solve subsets the system:
AbstractInversion.solve_ids_to_keep— the single answer to "which parameters did the solve include?"reconstruction_covariance_matrix— formed on the solved index set, scattered back to full[total_params, total_params]shape withNaNoutside it. Shape is unchanged, so callers never branch on the settings.reconstruction_noise_map— inherits the above;NaNat pixels the solve excluded.use_edge_zeroed_pixels=False, whenuse_positive_only_solver=False, when there is no mapper, or on a mesh withzeroed_pixels=0.The value change on the pixels that ARE solved — for the release note
Inverting the submatrix is not the corresponding block of the full inverse. For symmetric positive-definite
A,[A⁻¹]_keep ⪰ (A_keep)⁻¹in the PSD ordering, so every kept pixel's variance is lower than before. Measured on real ray-traced fits (Isothermaleinstein_radius=1.6+ shear, compact Sérsic source,r=3.0"mask,over_sample_size_pixelization=4, PSF and Poisson noise) at the coefficient the Bayesian evidence selects (λ*=1, interior to a 9-point grid):RectangularBilinearAdaptDensity(28,28)Delaunay, workspace idiom,zeroed_pixels=30Delaunay, defaultzeroed_pixels=0Downstream: 0.00% in every case. Source flux and magnification through the workspace's
S/N >= 5cut are unmoved, and lit-pixel counts are identical old vs new (7/7, 24/24, 121/121) — the pixels whose noise changes most sit at the mesh edge, where the reconstruction has no flux to move.So: a systematic, one-directional correction concentrated at the mesh edge, negligible in the median, up to ~2x on individual edge pixels, with no effect on published source flux or magnification.
This is unrelated to the NNLS active-set caveat documented on
reconstruction_noise_map(#472), where the shipped and free-set-restricted values bracket the truth. Here the excluded parameters are fixed at zero before the solver runs rather than chosen by the data, so conditioning on them is exact and there is no bracket.The invariant, stated correctly
An earlier commit in this branch claimed
reconstruction == 0.0⟺noise_map is NaN. That is false, and the real-fit measurement caught it. The non-negative solver also pins pixels it did solve for at exactly0.0. On the 28×28 fit, 784 parameters:NaN ⟹ reconstruction == 0.0holds; the converse does not.np.isnan(reconstruction_noise_map)is the only way to identify the never-estimated pixels. Fixed in the docstring, the test, and the workspace prose.Test Plan
pytest test_autoarray/ -x -q -n auto)inv(submatrix), NaN-not-zero, the one-directional noise bound, all three full-system paths unchanged, andLinAlgErrorraised only when the solve actually used the non-finite entrynan, noRuntimeWarning),norm_fromin both scales, and the mapper subplot rendered in linear and log10mainFull API Changes (for automation & release notes)
Added
AbstractInversion.solve_ids_to_keep→Optional[np.ndarray]— the global parameter indices the reconstruction actually solved for, orNonewhen it solved the full system. Read by bothreconstructionandreconstruction_covariance_matrix.MockMapper.mesh— accessor for themeshthe mock already stored but never exposed.Removed
Changed (values only, no signatures)
AbstractInversion.reconstruction_covariance_matrix— formed onsolve_ids_to_keepand scattered back withNaNat excluded rows/columns. Shape unchanged. The finiteness guard now runs on the submatrix, so a non-finite entry in a row the solve excluded no longer raises — matching the reconstruction, which does not fail on it either.AbstractInversion.reconstruction_noise_map—NaNat pixels the solve excluded.AbstractInversion.reconstruction_noise_map_with_covariance— deprecated alias, inherits both.Migration
np.isnan(inversion.reconstruction_noise_map).inversion.reconstruction == 0.0— that also selects the pixels NNLS pinned, which were solved for and carry real error bars (495 vs 108 on a representative fit).use_edge_zeroed_pixelsneeds anp.nan*reduction or an explicit mask. Both in-repo consumers already did.Also
mapper_indicesas "ids of values which are on edge so zero-d and not solved for". It is not edge zeroing — it drops the linear objects carrying no regularization. Conflating the two made the reduced matrices and the zeroed solve look like one inconsistent treatment of a single index set when they are unrelated concerns.Known, not fixed here
Pre-existing and unreachable from this change: the
np.errstate(all="ignore")guard inplot/utils.py:norm_fromdoes not suppressnp.nanmax's All-NaNRuntimeWarning, which is issued throughwarningsrather than the floating-point error state. A per-mapper noise map cannot be all-NaN— a rectangular mesh is at least 3×3 and keeps its interior, Delaunay zeroes a count strictly below its pixel total — so this change cannot trigger it.Companion workspace PR (documentation only, merges after this one):
autolens_workspacefeature/reconstruction-noise-map-zeroed-pixels.Generated by the PyAutoLabs agent workflow.
Generated by Claude Code