Skip to content

fix: form the reconstruction covariance on the parameters the solve solved for - #493

Merged
Jammy2211 merged 2 commits into
mainfrom
feature/reconstruction-noise-map-zeroed-pixels
Aug 27, 2026
Merged

Jammy2211 merged 2 commits into
mainfrom
feature/reconstruction-noise-map-zeroed-pixels

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Summary

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.

Not opt-in, as the issue originally scoped it. Delaunay.zeroed_pixels is a count defaulting to 0, but RectangularRTUAdaptDensity.zeroed_pixels returns the whole edge ring unconditionally — 108 of 784 parameters on a 28×28, 156 of 1600 on a 40×40. And the workspace's own delaunay.py example uses zeroed_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 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 reproduces the deliberate nesting — use_edge_zeroed_pixels is consulted only when use_positive_only_solver is on — and the 84b9ed4 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. Both consumers handle NaN 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 S/N cut rather than counting as significant. (Reporting 0 instead would have made that 0/0 — the same NaN, plus a RuntimeWarning on 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:

  • Added AbstractInversion.solve_ids_to_keep — the single answer to "which parameters did the solve include?"
  • Changed values reconstruction_covariance_matrix — formed on the solved index set, scattered back to full [total_params, total_params] shape with NaN outside it. Shape is unchanged, so callers never branch on the settings.
  • Changed values reconstruction_noise_map — inherits the above; NaN at pixels the solve excluded.
  • Unchanged to the bit when use_edge_zeroed_pixels=False, when use_positive_only_solver=False, when there is no mapper, or on a mesh with zeroed_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 (Isothermal einstein_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):

mesh zeroed p50 p90 p99 max >1% >10%
RectangularBilinearAdaptDensity(28,28) 108/784 1.0002 1.069 1.44 1.81 21% 9%
Delaunay, workspace idiom, zeroed_pixels=30 30/562 1.0045 — 1.90 2.37 — —
Delaunay, default zeroed_pixels=0 0 1.000 — 1.000 1.000 0% 0%

Downstream: 0.00% in every case. Source flux and magnification through the workspace's S/N >= 5 cut 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 exactly 0.0. On the 28×28 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

NaN ⟹ reconstruction == 0.0 holds; 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

  • Full suite: 1177 passed, 55 skipped (pytest test_autoarray/ -x -q -n auto)
  • 9 unit cases over a 4×4 mesh (4 interior parameters kept, 12 zeroed): shape, exact-NaN placement, kept block equals inv(submatrix), NaN-not-zero, the one-directional noise bound, all three full-system paths unchanged, and LinAlgError raised only when the solve actually used the non-finite entry
  • 1 end-to-end case through the real solver and mesh, asserting the NaN set equals the excluded set exactly plus the one-way implication
  • Consumers smoke-tested directly under a NaN-heavy noise map: CSV writer (writes nan, no RuntimeWarning), norm_from in both scales, and the mapper subplot rendered in linear and log10
  • Measured on real ray-traced fits with PyAutoLens/PyAutoGalaxy/PyAutoFit at main
Full API Changes (for automation & release notes)

Added

  • AbstractInversion.solve_ids_to_keep → Optional[np.ndarray] — the global parameter indices the reconstruction actually solved for, or None when it solved the full system. Read by both reconstruction and reconstruction_covariance_matrix.
  • MockMapper.mesh — accessor for the mesh the mock already stored but never exposed.

Removed

  • Nothing.

Changed (values only, no signatures)

  • AbstractInversion.reconstruction_covariance_matrix — formed on solve_ids_to_keep and scattered back with NaN at 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 — NaN at pixels the solve excluded.
  • AbstractInversion.reconstruction_noise_map_with_covariance — deprecated alias, inherits both.

Migration

  • To find never-estimated pixels: np.isnan(inversion.reconstruction_noise_map).
  • Not 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).
  • Code that assumed the noise map was everywhere finite under use_edge_zeroed_pixels needs a np.nan* reduction or an explicit mask. Both in-repo consumers already did.

Also

  • Corrected three comments describing 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.

Known, not fixed here

Pre-existing and 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 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_workspace feature/reconstruction-noise-map-zeroed-pixels.

Generated by the PyAutoLabs agent workflow.


Generated by Claude Code

claude added 2 commits August 27, 2026 14:33
…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
@Jammy2211 Jammy2211 added the pending-release PR queued for the next release build label Aug 27, 2026 — with Claude
@Jammy2211
Jammy2211 merged commit 2c06e4a into main Aug 27, 2026
3 checks passed
@Jammy2211
Jammy2211 deleted the feature/reconstruction-noise-map-zeroed-pixels branch August 27, 2026 15:31
@Jammy2211 Jammy2211 removed the pending-release PR queued for the next release build label Sep 4, 2026
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.

fix: reconstruction noise map ignores the pixels the solve zeroed

2 participants