@@ -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
0 commit comments