Skip to content

Commit 2c06e4a

Browse files
authored
Merge pull request #493 from PyAutoLabs/feature/reconstruction-noise-map-zeroed-pixels
fix: form the reconstruction covariance on the parameters the solve solved for
2 parents da5ec9a + a7703a2 commit 2c06e4a

4 files changed

Lines changed: 410 additions & 14 deletions

File tree

autoarray/inversion/inversion/abstract.py

Lines changed: 157 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,10 @@ def regularization_matrix_reduced(self) -> Optional[np.ndarray]:
345345
if self.all_linear_obj_have_regularization:
346346
return self.regularization_matrix
347347

348-
# ids of values which are on edge so zero-d and not solved for.
348+
# Restrict to the mapper parameters. This is NOT edge zeroing -- `mapper_indices` drops the linear
349+
# objects that carry no regularization (light profiles and the like), which is a different index set
350+
# from `zeroed_ids_to_keep` and applies for a different reason. The two were conflated by an earlier
351+
# version of this comment.
349352
ids_to_keep = self.mapper_indices
350353

351354
# Zero rows and columns in the matrix we want to ignore
@@ -383,7 +386,10 @@ def curvature_reg_matrix_reduced(self) -> Optional[np.ndarray]:
383386
if self.all_linear_obj_have_regularization:
384387
return self.curvature_reg_matrix
385388

386-
# ids of values which are on edge so zero-d and not solved for.
389+
# Restrict to the mapper parameters. This is NOT edge zeroing -- `mapper_indices` drops the linear
390+
# objects that carry no regularization (light profiles and the like), which is a different index set
391+
# from `zeroed_ids_to_keep` and applies for a different reason. The two were conflated by an earlier
392+
# version of this comment.
387393
ids_to_keep = self.mapper_indices
388394

389395
# Zero rows and columns in the matrix we want to ignore
@@ -490,6 +496,44 @@ def zeroed_ids_to_keep(self):
490496

491497
return keep_ids
492498

499+
@property
500+
def solve_ids_to_keep(self) -> Optional[np.ndarray]:
501+
"""
502+
The global parameter indices the reconstruction actually solves for, or `None` when it solves the full
503+
system.
504+
505+
This is the single answer to "which parameters did the solve include?", and it exists so that every
506+
quantity derived from the solve agrees with the solve about what it did. `reconstruction` subsets the
507+
linear system by these indices and scatters its result back with exact zeros elsewhere;
508+
`reconstruction_covariance_matrix` forms the covariance on the same submatrix and scatters back `NaN`.
509+
510+
Before this property the predicate below lived inline in `reconstruction` and nowhere else, so the
511+
covariance had no way to know the solve had been subset -- it inverted the full `curvature_reg_matrix`
512+
and reported a noise value for pixels that were never solved for. Keep the two readers pointed here
513+
rather than re-deriving the condition, or they can drift apart again.
514+
515+
`None` rather than "every index" is deliberate: the full-system path must stay byte-identical to what it
516+
was, and an `arange` would route it through indexing and scatter-back code it never used before.
517+
518+
Note that `use_edge_zeroed_pixels` is consulted only when `use_positive_only_solver` is `True`, mirroring
519+
the nesting in `reconstruction`. That scoping is deliberate -- see the comment there and
520+
`Settings.use_edge_zeroed_pixels`.
521+
522+
Returns
523+
-------
524+
The global indices kept by the solve, or `None` if the full system was solved.
525+
"""
526+
if not self.settings.use_positive_only_solver:
527+
return None
528+
529+
if not self.settings.use_edge_zeroed_pixels:
530+
return None
531+
532+
if not self.has(cls=Mapper):
533+
return None
534+
535+
return self.zeroed_ids_to_keep
536+
493537
@cached_property
494538
def reconstruction(self) -> np.ndarray:
495539
"""
@@ -513,13 +557,17 @@ def reconstruction(self) -> np.ndarray:
513557
# `use_positive_only_solver`: edge-zeroing is scoped to the positive-only solver, and the
514558
# positive-negative branch below solves the full system regardless of its value. This is
515559
# intended, not an oversight -- do not "fix" it by hoisting the check out of this branch.
516-
if self.settings.use_edge_zeroed_pixels and self.has(cls=Mapper):
560+
# `solve_ids_to_keep` encodes that nesting (it returns None unless BOTH settings are on),
561+
# so it is safe to consult here and nowhere higher up.
562+
ids_to_keep = self.solve_ids_to_keep
563+
564+
if ids_to_keep is not None:
517565

518566
# Use advanced indexing to select rows/columns
519-
data_vector = self.data_vector[self.zeroed_ids_to_keep]
520-
curvature_reg_matrix = self.curvature_reg_matrix[
521-
self.zeroed_ids_to_keep
522-
][:, self.zeroed_ids_to_keep]
567+
data_vector = self.data_vector[ids_to_keep]
568+
curvature_reg_matrix = self.curvature_reg_matrix[ids_to_keep][
569+
:, ids_to_keep
570+
]
523571

524572
# Perform reconstruction via fnnls
525573
reconstruction_partial = (
@@ -536,11 +584,11 @@ def reconstruction(self) -> np.ndarray:
536584

537585
# Scatter the partial solution back to the full shape
538586
if self._xp.__name__.startswith("jax"):
539-
reconstruction = reconstruction.at[self.zeroed_ids_to_keep].set(
587+
reconstruction = reconstruction.at[ids_to_keep].set(
540588
reconstruction_partial
541589
)
542590
else:
543-
reconstruction[self.zeroed_ids_to_keep] = reconstruction_partial
591+
reconstruction[ids_to_keep] = reconstruction_partial
544592

545593
return reconstruction
546594

@@ -570,7 +618,10 @@ def reconstruction_reduced(self) -> np.ndarray:
570618
if self.all_linear_obj_have_regularization:
571619
return self.reconstruction
572620

573-
# ids of values which are on edge so zero-d and not solved for.
621+
# Restrict to the mapper parameters. This is NOT edge zeroing -- `mapper_indices` drops the linear
622+
# objects that carry no regularization (light profiles and the like), which is a different index set
623+
# from `zeroed_ids_to_keep` and applies for a different reason. The two were conflated by an earlier
624+
# version of this comment.
574625
ids_to_keep = self.mapper_indices
575626

576627
# Zero rows and columns in the matrix we want to ignore
@@ -853,6 +904,45 @@ def reconstruction_covariance_matrix(self) -> np.ndarray:
853904
For the RMS standard deviation of each pixel (the quantity used for scientific analysis) use
854905
`reconstruction_noise_map`, which takes the square root of this matrix's diagonal.
855906
907+
Formed on the parameters the solve actually solved for
908+
-----------------------------------------------------
909+
When `use_edge_zeroed_pixels` applies (see `solve_ids_to_keep`), `reconstruction` does not solve the
910+
full system: it subsets `curvature_reg_matrix` to `zeroed_ids_to_keep`, solves the reduced problem and
911+
scatters the answer back with **exact zeros** at the excluded pixels. Those pixels are the mesh's
912+
poorly-constrained boundary vertices, zeroed precisely to keep the inversion stable.
913+
914+
This matrix is formed on that same index set and scattered back the same way, so it describes the
915+
estimator that was actually computed. The excluded rows and columns are `NaN`, not zero: zero is a
916+
covariance value ("known exactly"), whereas these parameters were never estimated at all. The returned
917+
shape is always `[total_params, total_params]`, so callers do not have to branch on the settings.
918+
919+
Previously the full matrix was inverted regardless, which re-admitted into an explicit inverse the very
920+
rows the solve dropped to stay stable, and reported a noise value for a pixel whose reconstruction reads
921+
exactly `0` because it was never solved for.
922+
923+
Note this also changes the values on the parameters that ARE solved. Inverting the submatrix is not the
924+
corresponding block of the full inverse: for a symmetric positive-definite `A`,
925+
`[A^-1]_keep >= (A_keep)^-1` in the positive-semidefinite ordering, so every kept pixel's variance is
926+
lower here than it was. That is the correct quantity -- the excluded parameters are held at zero by
927+
construction, so conditioning on them is exact, not an approximation. It is unrelated to the
928+
NNLS active-set caveat documented on `reconstruction_noise_map`, where the pixels held at zero are
929+
chosen by the data rather than fixed in advance.
930+
931+
Measured on real ray-traced fits (Isothermal `einstein_radius=1.6` + shear, compact Sersic source,
932+
`r=3.0"` mask, `over_sample_size_pixelization=4`, PSF and Poisson noise), at the regularization
933+
coefficient the Bayesian evidence selects:
934+
935+
- `RectangularBilinearAdaptDensity(28, 28)`, 108 of 784 parameters zeroed: the old value was
936+
overstated by a median factor of 1.0002, but 21% of solved pixels move by more than 1%, 9% by more
937+
than 10%, and the worst by 1.8x. The change is concentrated near the mesh edge rather than spread.
938+
- `Delaunay` set up as the workspace's own example does (an `Overlay` image mesh plus a ring of edge
939+
points, `zeroed_pixels=30`): median 1.0045, 99th percentile 1.90, worst 2.37. Delaunay is affected
940+
too whenever `zeroed_pixels > 0`; with the default `zeroed_pixels=0` this property is unchanged to
941+
the bit.
942+
- Downstream source flux and magnification through the workspace's `S/N >= 5` cut moved by 0.00% in
943+
every case: the pixels whose noise changes most are near the edge, where the reconstruction has no
944+
flux to move.
945+
856946
The inverse is formed from a Cholesky factorization rather than `np.linalg.inv`, for two reasons:
857947
858948
- `cho_factor` raises `LinAlgError` when the matrix is not positive-definite. `np.linalg.inv` raises only
@@ -884,8 +974,22 @@ def reconstruction_covariance_matrix(self) -> np.ndarray:
884974
"""
885975
from scipy.linalg import cho_factor, cho_solve
886976

887-
matrix = np.asarray(self.curvature_reg_matrix)
977+
full_matrix = np.asarray(self.curvature_reg_matrix)
978+
979+
# Form the covariance on exactly the parameters the solve solved for. `solve_ids_to_keep` is None when
980+
# the solve used the full system, in which case this path is unchanged.
981+
ids_to_keep = self.solve_ids_to_keep
982+
983+
if ids_to_keep is None:
984+
matrix = full_matrix
985+
else:
986+
ids_to_keep = np.asarray(ids_to_keep)
987+
matrix = full_matrix[ids_to_keep][:, ids_to_keep]
888988

989+
# The guard runs on the SUBMATRIX, not the full one. A non-finite entry in a row the solve excluded
990+
# cannot reach the factorization, and the reconstruction does not fail on it either -- failing here
991+
# would make the covariance stricter than the solve it describes. It must also run before the
992+
# scatter-back below, which fills the excluded entries with NaN deliberately.
889993
if not np.isfinite(matrix).all():
890994
raise np.linalg.LinAlgError(
891995
"The curvature_reg_matrix contains non-finite entries (NaN or inf), so the reconstruction "
@@ -904,7 +1008,18 @@ def reconstruction_covariance_matrix(self) -> np.ndarray:
9041008
)
9051009

9061010
# cho_solve is accurate but not bitwise symmetric; a covariance matrix is symmetric by definition.
907-
return 0.5 * (covariance + covariance.T)
1011+
covariance = 0.5 * (covariance + covariance.T)
1012+
1013+
if ids_to_keep is None:
1014+
return covariance
1015+
1016+
# Scatter back to the full parameter shape, so this property's shape does not depend on the settings.
1017+
# The excluded entries are NaN ("never estimated"), which is what the solve says about them -- their
1018+
# reconstruction is an exact structural zero, not a fitted value.
1019+
full_covariance = np.full(full_matrix.shape, np.nan, dtype=covariance.dtype)
1020+
full_covariance[np.ix_(ids_to_keep, ids_to_keep)] = covariance
1021+
1022+
return full_covariance
9081023

9091024
@property
9101025
def reconstruction_noise_map_with_covariance(self) -> np.ndarray:
@@ -919,6 +1034,10 @@ def reconstruction_noise_map_with_covariance(self) -> np.ndarray:
9191034
It now returns the covariance matrix itself, so the values differ: the diagonal holds variances rather
9201035
than standard deviations, and the off-diagonals hold covariances rather than `NaN`.
9211036
1037+
Note it also inherits `reconstruction_covariance_matrix`'s index set: under `use_edge_zeroed_pixels` the
1038+
rows and columns of parameters the solve excluded are `NaN` (never estimated), and the entries that
1039+
remain are the inverse of the submatrix rather than a block of the full inverse. See that property.
1040+
9221041
Returns
9231042
-------
9241043
The covariance matrix of the reconstruction (see `reconstruction_covariance_matrix`).
@@ -950,6 +1069,32 @@ def reconstruction_noise_map(self):
9501069
It is computed as the square root of the diagonal of `reconstruction_covariance_matrix`, which is the
9511070
inverse of the same matrix used to solve for the reconstruction via the linear inversion.
9521071
1072+
Pixels the solve never estimated are `NaN`
1073+
------------------------------------------
1074+
Under `use_edge_zeroed_pixels` (the shipped default, and unconditional for the
1075+
`Rectangular*AdaptDensity` mesh family, whose `zeroed_pixels` is the whole edge ring) the solve excludes
1076+
the mesh's boundary vertices and writes an exact `0.0` into `reconstruction` for them. This noise map
1077+
reports **`NaN`** at exactly those pixels, meaning "never estimated" -- they have no uncertainty because
1078+
they have no fitted value.
1079+
1080+
So the two arrays agree on which pixels were solved. The implication runs one way, and the direction
1081+
matters: **`NaN` implies the reconstruction is exactly `0.0` there, but not the converse.** The
1082+
non-negative solver also pins pixels it DID solve for at exactly `0.0`, and those keep a finite,
1083+
meaningful noise value. On a representative fit -- `RectangularBilinearAdaptDensity(28, 28)`, 784
1084+
parameters -- 603 pixels read `0.0` while only the 108 structurally excluded ones are `NaN`; the other
1085+
495 were solved and pinned by the constraint. `np.isnan(reconstruction_noise_map)` is therefore the way
1086+
to identify the never-estimated pixels; `reconstruction == 0.0` is not, and conflates the two.
1087+
1088+
Previously the excluded pixels carried a finite noise value computed as though they had been solved,
1089+
so there was no way to tell them apart from the pinned ones at all.
1090+
1091+
`NaN` propagates rather than raising, and the consumers handle it: the colour scales derive their
1092+
limits with `np.nanmax` (`plot/utils.py:norm_from`), and `save_reconstruction_csv` already writes `nan`
1093+
into this column when the covariance cannot be computed. A signal-to-noise map formed as
1094+
`reconstruction / reconstruction_noise_map` gives `0.0 / NaN = NaN` at these pixels, silently, and a
1095+
`NaN >= threshold` comparison is `False` -- so they fall outside any signal-to-noise cut rather than
1096+
being counted as significant.
1097+
9531098
This previously took the diagonal of an elementwise-square-rooted matrix. The two are algebraically
9541099
identical -- `np.sqrt` is elementwise, so it commutes with taking the diagonal -- but only numerically
9551100
equivalent, since the covariance is now formed by Cholesky rather than LU. The difference is

autoarray/inversion/mock/mock_mapper.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ def adapt_data(self):
5757
return super().adapt_data
5858
return self._adapt_data
5959

60+
@property
61+
def mesh(self):
62+
if self._mesh is None:
63+
return super().mesh
64+
return self._mesh
65+
6066
@property
6167
def mesh_geometry(self):
6268
if self._mesh_geometry is None:

0 commit comments

Comments
 (0)