Skip to content

Commit 8fe4343

Browse files
Jammy2211claude
andcommitted
test(inversion): certified active-set solver numerics, fallback, jit/vmap, gradients, dispatch, settings
Refs #566 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 66f5a4b commit 8fe4343

4 files changed

Lines changed: 772 additions & 0 deletions

File tree

‎test_autoarray/inversion/inversion/test_inversion_util.py‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,160 @@ def test__curvature_matrix_via_mapping_matrix_from__mixed_precision__weights_are
384384
)
385385

386386
assert jax_curvature == pytest.approx(expected, rel=1.0e-14)
387+
388+
389+
# ----------------------------------------------------------------------------
390+
# The `solver` switch of `reconstruction_positive_only_from` (PyAutoArray#566).
391+
# ----------------------------------------------------------------------------
392+
393+
394+
def _positive_only_system(n=30, seed=7):
395+
"""An SPD system whose unconstrained solution has negative entries, so the constraint binds."""
396+
rng = np.random.default_rng(seed)
397+
A = rng.normal(size=(3 * n, n))
398+
b = rng.normal(size=3 * n)
399+
curvature_reg_matrix = A.T @ A
400+
data_vector = A.T @ b
401+
assert (np.linalg.solve(curvature_reg_matrix, data_vector) < 0.0).any()
402+
return data_vector, curvature_reg_matrix
403+
404+
405+
@requires_jax
406+
@pytest.mark.parametrize("use_jacobi", [True, False])
407+
def test__reconstruction_positive_only_from__certified_matches_pdip_on_jax(
408+
use_jacobi, monkeypatch
409+
):
410+
import jax
411+
412+
jax.config.update("jax_enable_x64", True)
413+
import jax.numpy as jnp
414+
from autonerves import conf
415+
416+
if not use_jacobi:
417+
# Exercise the unpreconditioned branch too: the `solver` switch lives in both. The dict stands in for
418+
# the config; the Settings' certified_* keys resolve through their KeyError defaults.
419+
monkeypatch.setattr(
420+
conf,
421+
"instance",
422+
{
423+
"general": {
424+
"inversion": {
425+
"nnls_jacobi_preconditioning": False,
426+
"nnls_target_kappa": 1.0e-11,
427+
}
428+
}
429+
},
430+
)
431+
432+
for data_vector, curvature_reg_matrix in [
433+
(
434+
np.array([1.0, 1.0, 2.0]),
435+
np.array([[2.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 1.0]]),
436+
),
437+
_positive_only_system(),
438+
]:
439+
pdip = np.asarray(
440+
aa.util.inversion.reconstruction_positive_only_from(
441+
data_vector=jnp.asarray(data_vector),
442+
curvature_reg_matrix=jnp.asarray(curvature_reg_matrix),
443+
settings=aa.Settings(),
444+
xp=jnp,
445+
)
446+
)
447+
448+
stats = {}
449+
certified = np.asarray(
450+
aa.util.inversion.reconstruction_positive_only_from(
451+
data_vector=jnp.asarray(data_vector),
452+
curvature_reg_matrix=jnp.asarray(curvature_reg_matrix),
453+
settings=aa.Settings(),
454+
xp=jnp,
455+
solver="certified",
456+
stats=stats,
457+
)
458+
)
459+
460+
assert stats["solver"] == "certified"
461+
assert bool(stats["certified"])
462+
assert int(stats["passes"]) >= 1
463+
464+
assert (certified >= 0.0).all()
465+
assert certified == pytest.approx(
466+
pdip, rel=1.0e-8, abs=1.0e-8 * np.max(np.abs(certified))
467+
)
468+
469+
470+
@requires_jax
471+
def test__reconstruction_positive_only_from__certified_budget_exhausted_falls_back_to_pdip():
472+
import jax
473+
474+
jax.config.update("jax_enable_x64", True)
475+
import jax.numpy as jnp
476+
477+
data_vector, curvature_reg_matrix = _positive_only_system()
478+
479+
kwargs = dict(
480+
data_vector=jnp.asarray(data_vector),
481+
curvature_reg_matrix=jnp.asarray(curvature_reg_matrix),
482+
xp=jnp,
483+
)
484+
485+
pdip = np.asarray(
486+
aa.util.inversion.reconstruction_positive_only_from(
487+
settings=aa.Settings(), **kwargs
488+
)
489+
)
490+
491+
stats = {}
492+
fallback = np.asarray(
493+
aa.util.inversion.reconstruction_positive_only_from(
494+
settings=aa.Settings(certified_pass_budget=0),
495+
solver="certified",
496+
stats=stats,
497+
**kwargs,
498+
)
499+
)
500+
501+
assert not bool(stats["certified"])
502+
assert np.array_equal(fallback, pdip)
503+
504+
505+
def test__reconstruction_positive_only_from__numpy_path_ignores_solver(monkeypatch):
506+
# The NumPy path must run fnnls whatever `solver` says: spy on it.
507+
from autoarray.util import fnnls
508+
509+
calls = []
510+
original = fnnls.fnnls_cholesky
511+
512+
def spy(*args, **kwargs):
513+
calls.append(1)
514+
return original(*args, **kwargs)
515+
516+
monkeypatch.setattr(fnnls, "fnnls_cholesky", spy)
517+
518+
data_vector = np.array([1.0, 1.0, 2.0])
519+
curvature_reg_matrix = np.array([[2.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 1.0]])
520+
521+
results = []
522+
for solver in ["pdip", "certified"]:
523+
calls.clear()
524+
results.append(
525+
aa.util.inversion.reconstruction_positive_only_from(
526+
data_vector=data_vector,
527+
curvature_reg_matrix=curvature_reg_matrix,
528+
solver=solver,
529+
)
530+
)
531+
assert len(calls) == 1
532+
533+
assert np.array_equal(results[0], results[1])
534+
assert results[0] == pytest.approx(np.array([0.5, 0.0, 2.0]), 1.0e-4)
535+
536+
537+
def test__reconstruction_positive_only_from__invalid_solver_raises():
538+
with pytest.raises(ValueError, match="solver"):
539+
aa.util.inversion.reconstruction_positive_only_from(
540+
data_vector=np.array([1.0]),
541+
curvature_reg_matrix=np.array([[1.0]]),
542+
solver="bogus",
543+
)
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""
2+
Which positive-only solver `AbstractInversion.reconstruction` dispatches to (PyAutoArray#566).
3+
4+
`Settings.positive_only_solver = "certified"` selects the certified active-set solve only on the JAX
5+
backend for mapper-only inversions; inversions with linear light-profile / MGE coefficients
6+
(`AbstractLinearObjFuncList`) and every NumPy inversion keep today's solver.
7+
"""
8+
9+
import importlib.util
10+
11+
import numpy as np
12+
import pytest
13+
14+
import autoarray as aa
15+
16+
17+
requires_jax = pytest.mark.skipif(
18+
importlib.util.find_spec("jax") is None,
19+
reason="requires jax (installed via the [optional] extras; absent on the NumPy-only matrix env)",
20+
)
21+
22+
23+
def _system(n, seed=3):
24+
"""An SPD system whose unconstrained solution has negative entries, so positivity binds."""
25+
rng = np.random.default_rng(seed)
26+
A = rng.normal(size=(3 * n, n))
27+
b = rng.normal(size=3 * n)
28+
curvature_reg_matrix = A.T @ A
29+
data_vector = A.T @ b
30+
assert (np.linalg.solve(curvature_reg_matrix, data_vector) < 0.0).any()
31+
return data_vector, curvature_reg_matrix
32+
33+
34+
def _mapper(pixels=16):
35+
return aa.m.MockMapper(
36+
mesh=aa.mesh.RectangularUniform(shape=(4, 4)),
37+
parameters=pixels,
38+
regularization=aa.reg.Constant(),
39+
# Only its shape is read, by the NumPy warm-start memo's fingerprint.
40+
source_plane_mesh_grid=np.zeros((pixels, 2)),
41+
)
42+
43+
44+
def _inversion(
45+
linear_obj_list,
46+
data_vector,
47+
curvature_reg_matrix,
48+
use_jax,
49+
positive_only_solver="certified",
50+
use_edge_zeroed_pixels=False,
51+
):
52+
if use_jax:
53+
import jax
54+
55+
jax.config.update("jax_enable_x64", True)
56+
import jax.numpy as jnp
57+
58+
data_vector = jnp.asarray(data_vector)
59+
curvature_reg_matrix = jnp.asarray(curvature_reg_matrix)
60+
61+
inversion = aa.m.MockInversion(
62+
linear_obj_list=linear_obj_list,
63+
data_vector=data_vector,
64+
curvature_reg_matrix=curvature_reg_matrix,
65+
settings=aa.Settings(
66+
use_positive_only_solver=True,
67+
use_edge_zeroed_pixels=use_edge_zeroed_pixels,
68+
positive_only_solver=positive_only_solver,
69+
# Off so two NumPy solves of the same system are bit-comparable: with the memo on, the second
70+
# solve starts from the first's passive set and can differ in the last ulp.
71+
nnls_warm_start_memo=False,
72+
),
73+
)
74+
# `MockInversion` does not take `xp`; the backend flag is what `AbstractInversion(xp=jnp)` sets.
75+
inversion.use_jax = use_jax
76+
return inversion
77+
78+
79+
def test__positive_only_solver_used__default_is_pdip():
80+
data_vector, curvature_reg_matrix = _system(16)
81+
82+
inversion = _inversion(
83+
[_mapper()],
84+
data_vector,
85+
curvature_reg_matrix,
86+
use_jax=False,
87+
positive_only_solver=None,
88+
)
89+
90+
assert inversion.settings.positive_only_solver == "pdip"
91+
assert inversion.positive_only_solver_used == "pdip"
92+
93+
94+
def test__positive_only_solver_used__numpy_backend_keeps_pdip_and_fnnls():
95+
data_vector, curvature_reg_matrix = _system(16)
96+
97+
certified = _inversion(
98+
[_mapper()], data_vector, curvature_reg_matrix, use_jax=False
99+
)
100+
default = _inversion(
101+
[_mapper()],
102+
data_vector,
103+
curvature_reg_matrix,
104+
use_jax=False,
105+
positive_only_solver="pdip",
106+
)
107+
108+
assert certified.positive_only_solver_used == "pdip"
109+
assert np.array_equal(certified.reconstruction, default.reconstruction)
110+
111+
112+
@requires_jax
113+
def test__positive_only_solver_used__mapper_only_jax_is_certified_and_matches_pdip():
114+
data_vector, curvature_reg_matrix = _system(16)
115+
116+
certified = _inversion([_mapper()], data_vector, curvature_reg_matrix, use_jax=True)
117+
pdip = _inversion(
118+
[_mapper()],
119+
data_vector,
120+
curvature_reg_matrix,
121+
use_jax=True,
122+
positive_only_solver="pdip",
123+
)
124+
125+
assert certified.positive_only_solver_used == "certified"
126+
assert pdip.positive_only_solver_used == "pdip"
127+
128+
x_certified = np.asarray(certified.reconstruction)
129+
x_pdip = np.asarray(pdip.reconstruction)
130+
131+
assert (x_certified >= 0.0).all()
132+
assert (x_certified == 0.0).any()
133+
assert x_certified == pytest.approx(
134+
x_pdip, rel=1.0e-8, abs=1.0e-8 * np.max(np.abs(x_certified))
135+
)
136+
137+
138+
@requires_jax
139+
def test__positive_only_solver_used__linear_func_list_present_keeps_pdip():
140+
data_vector, curvature_reg_matrix = _system(17)
141+
142+
func_list = aa.m.MockLinearObjFuncList(parameters=1)
143+
144+
certified_setting = _inversion(
145+
[func_list, _mapper()], data_vector, curvature_reg_matrix, use_jax=True
146+
)
147+
pdip = _inversion(
148+
[func_list, _mapper()],
149+
data_vector,
150+
curvature_reg_matrix,
151+
use_jax=True,
152+
positive_only_solver="pdip",
153+
)
154+
155+
assert certified_setting.positive_only_solver_used == "pdip"
156+
assert np.array_equal(
157+
np.asarray(certified_setting.reconstruction), np.asarray(pdip.reconstruction)
158+
)
159+
160+
161+
@requires_jax
162+
def test__positive_only_solver_used__no_mapper_keeps_pdip():
163+
data_vector, curvature_reg_matrix = _system(4)
164+
165+
inversion = _inversion(
166+
[aa.m.MockLinearObjFuncList(parameters=4)],
167+
data_vector,
168+
curvature_reg_matrix,
169+
use_jax=True,
170+
)
171+
172+
assert inversion.positive_only_solver_used == "pdip"
173+
174+
175+
@requires_jax
176+
def test__positive_only_solver_used__edge_zeroed_subset_is_preserved():
177+
# The 4x4 rectangular mesh's 12 edge pixels are zeroed and the 4 interior pixels [5, 6, 9, 10] solved.
178+
data_vector, curvature_reg_matrix = _system(16)
179+
180+
certified = _inversion(
181+
[_mapper()],
182+
data_vector,
183+
curvature_reg_matrix,
184+
use_jax=True,
185+
use_edge_zeroed_pixels=True,
186+
)
187+
pdip = _inversion(
188+
[_mapper()],
189+
data_vector,
190+
curvature_reg_matrix,
191+
use_jax=True,
192+
positive_only_solver="pdip",
193+
use_edge_zeroed_pixels=True,
194+
)
195+
196+
assert certified.positive_only_solver_used == "certified"
197+
assert np.asarray(certified.solve_ids_to_keep) == pytest.approx(
198+
np.array([5, 6, 9, 10])
199+
)
200+
201+
x_certified = np.asarray(certified.reconstruction)
202+
x_pdip = np.asarray(pdip.reconstruction)
203+
204+
keep = np.array([5, 6, 9, 10])
205+
edge = np.setdiff1d(np.arange(16), keep)
206+
207+
assert (x_certified[edge] == 0.0).all()
208+
assert (x_pdip[edge] == 0.0).all()
209+
assert x_certified == pytest.approx(
210+
x_pdip, rel=1.0e-8, abs=1.0e-8 * np.max(np.abs(x_certified))
211+
)

0 commit comments

Comments
 (0)