Skip to content

Commit efaf304

Browse files
authored
Merge pull request #436 from PyAutoLabs/claude/interferometer-delaunay-phase-2-1p53vk
fix: raise on a degenerate Cholesky pivot instead of returning NaN
2 parents 828d5c1 + 9e9a336 commit efaf304

3 files changed

Lines changed: 229 additions & 2 deletions

File tree

autoarray/util/cholesky_funcs.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,53 @@ def _cholupdate(U, x):
4444
return U
4545

4646

47+
def _pivot_from_schur(schur, diagonal, index):
48+
"""
49+
Turn the Schur complement of a Cholesky insertion into the new pivot.
50+
51+
The pivot is `sqrt(schur)`, which is only defined for a positive-definite
52+
matrix. When the matrix being factorised is singular -- as it is when two
53+
source-plane mesh vertices (near-)coincide, giving (near-)identical columns
54+
in the mapping matrix -- `schur` is zero to within rounding, and which side
55+
of zero it lands on depends purely on floating-point summation order (and
56+
therefore on the BLAS thread count).
57+
58+
All three of those roundings are the same degenerate matrix, so they must
59+
fail the same way. Taking `sqrt` directly does not do that: a tiny negative
60+
raises `ValueError`, but an exact zero yields a zero diagonal in `U` and a
61+
tiny positive yields a pivot that the following `cho_solve` amplifies --
62+
both of which return NaN *without raising*, so the caller cannot tell a
63+
degenerate solve from a good one.
64+
65+
The test is `schur > 0` and nothing stricter, which is deliberate:
66+
67+
* `schur < 0` already raised (`math.sqrt` of a negative), so rejecting it
68+
changes nothing -- `LinAlgError` subclasses `ValueError`, so every
69+
existing `except ValueError` still catches it.
70+
* `schur == 0` is the silent case. The pivot is exactly zero, so the
71+
following `cho_solve` divides by zero and the reconstruction is NaN with
72+
certainty. There is no finite result to preserve.
73+
* `schur > 0`, however small, still yields a positive finite pivot, so it
74+
is passed through untouched and returns a BITWISE IDENTICAL pivot to the
75+
old code.
76+
77+
A relative tolerance was tried here first and rejected: it also refused
78+
small-but-positive pivots that were producing perfectly usable
79+
reconstructions, which would have changed likelihood evaluations. Anything
80+
that survives this check but still degenerates into NaN is caught at the
81+
solver boundary in `fnnls.py`, where the failure is unambiguous.
82+
"""
83+
if not schur > 0.0:
84+
raise np.linalg.LinAlgError(
85+
f"Cholesky insertion at index {index} is not positive definite: "
86+
f"Schur complement is {schur:.6e} (diagonal {diagonal:.6e}). The "
87+
f"matrix is singular to working precision, which for an inversion "
88+
f"means (near-)degenerate mesh vertices."
89+
)
90+
91+
return math.sqrt(schur)
92+
93+
4794
def cholinsert(U, index, x):
4895
from scipy import linalg
4996

@@ -53,7 +100,9 @@ def cholinsert(U, index, x):
53100
U[:index, :index], x[:index], trans=1, lower=False, overwrite_b=True
54101
)
55102

56-
S[index, index] = s22 = math.sqrt(x[index] - S12.dot(S12))
103+
S[index, index] = s22 = _pivot_from_schur(
104+
schur=x[index] - S12.dot(S12), diagonal=x[index], index=index
105+
)
57106

58107
if index == U.shape[0]:
59108
return S
@@ -81,7 +130,9 @@ def cholinsertlast(U, x):
81130
U[:index, :index], x[:index], trans=1, lower=False, overwrite_b=True
82131
)
83132

84-
S[index, index] = s22 = math.sqrt(x[index] - S12.dot(S12))
133+
S[index, index] = s22 = _pivot_from_schur(
134+
schur=x[index] - S12.dot(S12), diagonal=x[index], index=index
135+
)
85136

86137
return S
87138

autoarray/util/fnnls.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,21 @@ def fnnls_cholesky(
114114
if no_update >= max_repetitions:
115115
break
116116

117+
if not np.all(np.isfinite(d)):
118+
# A non-finite solution is not a solution. NaN/inf never raises on its
119+
# own, so without this check a degenerate solve is returned to the
120+
# caller as if it were a valid reconstruction -- and a NaN
121+
# reconstruction goes on to poison the adapt image, and through it the
122+
# mesh vertices of the next pixelization stage, where it finally
123+
# surfaces as an unrelated-looking qhull "Points cannot contain NaN".
124+
# Fail here instead, at the producer, with the same exception type the
125+
# inversion machinery already handles.
126+
raise np.linalg.LinAlgError(
127+
"fnnls_cholesky produced a non-finite solution "
128+
f"({np.count_nonzero(~np.isfinite(d))} of {d.size} entries). The "
129+
f"normal-equations matrix is singular to working precision."
130+
)
131+
117132
return d
118133

119134

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import numpy as np
2+
import pytest
3+
4+
from autoarray.util.cholesky_funcs import cholinsertlast
5+
from autoarray.util.fnnls import fnnls_cholesky
6+
7+
8+
# A (near-)degenerate source mesh puts two vertices at (near-)identical
9+
# positions, which gives two (near-)identical columns in the mapping matrix and
10+
# so a normal-equations matrix that is singular to working precision. The
11+
# Cholesky insertion's Schur complement is then zero up to rounding, and which
12+
# side of zero it lands on depends only on floating-point summation order --
13+
# i.e. on the BLAS thread count, which is why the original failure was
14+
# reproducible on CI but not locally.
15+
#
16+
# Every one of those roundings is the same degenerate matrix, so all of them
17+
# must fail the same way. The regression these tests lock down is that two of
18+
# the three used to return NaN *without raising*, which let a NaN
19+
# reconstruction escape into the adapt image and resurface much later as a
20+
# qhull "Points cannot contain NaN" in the next pixelization stage.
21+
22+
23+
def _normal_equations(n, n_data, jitter, seed):
24+
"""Normal equations for a mapping matrix whose columns 0 and 1 belong to
25+
(near-)coincident mesh vertices. `jitter=0.0` makes them exactly equal."""
26+
rng = np.random.default_rng(seed)
27+
mapping = rng.random((n_data, n))
28+
mapping[:, 1] = mapping[:, 0] + jitter * rng.standard_normal(n_data)
29+
return mapping.T @ mapping, mapping.T @ rng.random(n_data)
30+
31+
32+
def _insert_duplicate_last(ZTZ):
33+
"""Factorise every column but the duplicate, then insert the duplicate --
34+
the exact situation `fnnls_cholesky` reaches via `cholinsertlast`."""
35+
from scipy import linalg as slg
36+
37+
n = ZTZ.shape[0]
38+
order = [0] + list(range(2, n)) + [1]
39+
U = slg.cholesky(ZTZ[np.ix_(order[:-1], order[:-1])])
40+
return U, ZTZ[order[-1]][order]
41+
42+
43+
@pytest.mark.parametrize("seed", range(12))
44+
def test__cholinsertlast__singular_insertion_never_yields_an_unusable_pivot(seed):
45+
# On a singular insertion the Schur complement is zero up to rounding and
46+
# lands on either side of zero depending only on summation order. Before
47+
# the fix, a tiny negative raised but an exact zero produced a zero pivot,
48+
# which the following solve divides by -- NaN, with nothing raised.
49+
#
50+
# The invariant is therefore NOT "always raise" (a small positive pivot is
51+
# still a usable pivot, and rejecting it would change likelihood
52+
# evaluations). It is: either raise, or return a strictly positive finite
53+
# pivot. A zero or non-finite pivot must never be returned.
54+
ZTZ, _ = _normal_equations(n=12, n_data=40, jitter=0.0, seed=seed)
55+
56+
U, x = _insert_duplicate_last(ZTZ)
57+
58+
try:
59+
S = cholinsertlast(U, x)
60+
except np.linalg.LinAlgError:
61+
return
62+
63+
assert S[-1, -1] > 0.0
64+
assert np.all(np.isfinite(S))
65+
66+
67+
def test__cholinsertlast__rejects_a_non_positive_schur_complement():
68+
# The exact-zero pivot is the silent case the fix exists to close, so pin
69+
# it directly rather than relying on a seed happening to produce it.
70+
from autoarray.util.cholesky_funcs import _pivot_from_schur
71+
72+
for schur in (0.0, -0.0, -1e-16, -1.0):
73+
with pytest.raises(np.linalg.LinAlgError):
74+
_pivot_from_schur(schur=schur, diagonal=13.0, index=11)
75+
76+
77+
def test__cholinsertlast__passes_a_positive_schur_complement_through_unchanged():
78+
# Anything positive must return the bitwise-identical pivot the old raw
79+
# `math.sqrt` returned, so no working fit changes.
80+
import math
81+
82+
from autoarray.util.cholesky_funcs import _pivot_from_schur
83+
84+
for schur in (1.776357e-15, 1e-8, 0.5, 13.0):
85+
assert _pivot_from_schur(
86+
schur=schur, diagonal=13.0, index=11
87+
) == math.sqrt(schur)
88+
89+
90+
@pytest.mark.parametrize("seed", range(12))
91+
def test__cholinsertlast__well_conditioned_insertion_still_succeeds(seed):
92+
# The guard must not reject an ordinary, non-degenerate insertion.
93+
rng = np.random.default_rng(seed)
94+
mapping = rng.random((40, 12))
95+
ZTZ = mapping.T @ mapping
96+
97+
U, x = _insert_duplicate_last(ZTZ)
98+
S = cholinsertlast(U, x)
99+
100+
assert S.shape == (12, 12)
101+
assert S[-1, -1] > 0.0
102+
assert np.all(np.isfinite(S))
103+
104+
105+
@pytest.mark.parametrize("jitter", [0.0, 1e-15, 1e-12, 1e-9])
106+
def test__fnnls_cholesky__never_returns_a_non_finite_solution(jitter):
107+
# The producer regression: across the whole near-degenerate band, the
108+
# solver must either return a finite solution or raise -- never hand back
109+
# NaN as though it were a valid reconstruction.
110+
raised = 0
111+
112+
for seed in range(40):
113+
ZTZ, ZTx = _normal_equations(n=12, n_data=40, jitter=jitter, seed=seed)
114+
115+
try:
116+
P_initial = np.linalg.solve(ZTZ, ZTx) > 0
117+
except np.linalg.LinAlgError:
118+
P_initial = np.zeros(ZTZ.shape[0], dtype=bool)
119+
120+
try:
121+
reconstruction = fnnls_cholesky(ZTZ, ZTx.T, P_initial=P_initial)
122+
except np.linalg.LinAlgError:
123+
raised += 1
124+
continue
125+
126+
assert np.all(np.isfinite(reconstruction))
127+
128+
# Sanity: the degenerate band must actually be exercising the guard,
129+
# otherwise the assertion above is passing vacuously.
130+
assert raised > 0
131+
132+
133+
def test__fnnls_cholesky__well_conditioned_problem_is_unaffected():
134+
# No false positives: a well-conditioned problem whose non-negativity
135+
# constraints bind hard must still solve, and solve non-negatively.
136+
for seed in range(40):
137+
rng = np.random.default_rng(seed)
138+
mapping = rng.random((60, 20))
139+
ZTZ = mapping.T @ mapping
140+
ZTZ[np.diag_indices(20)] += 1e-8
141+
142+
# a truth vector with many negative entries makes the constraints bind
143+
truth = rng.normal(size=20)
144+
truth[rng.random(20) < 0.6] *= -1.0
145+
ZTx = ZTZ @ truth
146+
147+
reconstruction = fnnls_cholesky(
148+
ZTZ, ZTx.T, P_initial=np.linalg.solve(ZTZ, ZTx) > 0
149+
)
150+
151+
assert np.all(np.isfinite(reconstruction))
152+
assert np.all(reconstruction >= 0.0)
153+
154+
155+
def test__degenerate_failure_is_caught_by_the_inversion_guard():
156+
# `reconstruction_positive_only_from` guards the solver with
157+
# `except (RuntimeError, np.linalg.LinAlgError, ValueError)`. The type
158+
# raised for a degenerate matrix has to fall inside that tuple, otherwise
159+
# the failure escapes the inversion machinery instead of being converted
160+
# into an InversionException / resample signal.
161+
assert issubclass(np.linalg.LinAlgError, (RuntimeError, ValueError))

0 commit comments

Comments
 (0)