Skip to content

Commit 8771f29

Browse files
Jammy2211claude
authored andcommitted
feat: NNLS solver knobs via Settings (nnls_solver_tol / nnls_max_iter), default off (#369)
Rework of the parked d8a1c84: the knobs move from general.yaml config keys to per-fit Settings attributes (defaults None = jaxnnls's own tolerance formula and 50-iteration cap — behaviour identical when unset). The vendored autoarray/util/jax_nnls.py driver is unchanged: reuses all jaxnnls building blocks + the relaxed-KKT custom-vjp backward; defaults validated bit-identical to upstream in solutions and gradients on real production systems. Measured on the real HST pixelization+MGE systems (PyAutoArray#369): Settings(nnls_solver_tol=1e-6) saves ~15-20% of solve time with rel delta-objective 3.8e-13 (delta log-evidence ~1e-8); nnls_max_iter also caps the vmap worst-case lane. Full ledger: autolens_profiling results/notes/nnls_solver_ledger.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgtjXWS2iJegXMTDU4GHth
1 parent d8a1c84 commit 8771f29

5 files changed

Lines changed: 52 additions & 39 deletions

File tree

autoarray/config/general.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ inversion:
88
use_border_relocator: false # If True, by default a pixelization's border is used to relocate all pixels outside its border to the border.
99
nnls_jacobi_preconditioning: true # If True (default), the curvature matrix passed to jaxnnls.solve_nnls_primal is Jacobi-preconditioned (D Q D y = D q, x = D y). Fixes NaN backward-pass gradients on ill-conditioned Q and roughly halves forward solve time. Set False to restore the raw unpreconditioned solve.
1010
nnls_target_kappa: 1.0e-11 # Central-path relaxation parameter passed to jaxnnls.solve_nnls_primal. Larger values smooth the relaxed-KKT backward pass and prevent NaN gradients on ill-conditioned Q; smaller values tighten the primal solve. Verified finite gradients across all MGE/rectangular/delaunay pipelines (imaging + interferometer) with scale invariance over 5 orders of magnitude in noise. jaxnnls's own default (1e-3) is too aggressive for the backward pass.
11-
nnls_solver_tol: null # Convergence tolerance (infinity-norm KKT residual) of the JAX NNLS interior-point solve. null reproduces jaxnnls's own tolerance min(n * eps * 5e3, 1e-2) ~ 1.7e-9 at n=1500 fp64. Each solver iteration is a fresh dense Cholesky of the (n, n) system, so looser tolerances buy real speed: 1e-6 saves ~15-20% of solve time with a log-evidence shift of order 1e-8 (PyAutoArray#369).
12-
nnls_max_iter: 50 # Iteration cap of the JAX NNLS interior-point solve (jaxnnls hard-codes 50; production HST pixelization+MGE systems converge in ~19-21 iterations). Under vmap the solve runs until the slowest lane in the batch converges, so this also caps the worst-case batched cost.
1311
reconstruction_vmax_factor: 0.5 # Plots of an Inversion's reconstruction use the reconstructed data's bright value multiplied by this factor.
1412
numba:
1513
use_numba: true

autoarray/inversion/inversion/inversion_util.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -302,17 +302,12 @@ def reconstruction_positive_only_from(
302302
# magnitude in noise.
303303
target_kappa = 1.0e-11
304304

305-
try:
306-
solver_tol = conf.instance["general"]["inversion"]["nnls_solver_tol"]
307-
except KeyError:
308-
# Workspaces ship their own general.yaml that shadows autoarray's;
309-
# None reproduces jaxnnls's own tolerance min(n * eps * 5e3, 1e-2).
310-
solver_tol = None
311-
312-
try:
313-
max_iter = conf.instance["general"]["inversion"]["nnls_max_iter"]
314-
except KeyError:
315-
# jaxnnls's own hard-coded iteration cap.
305+
# Per-fit solver knobs from the Settings class; the defaults (None)
306+
# reproduce jaxnnls's own tolerance min(n * eps * 5e3, 1e-2) and its
307+
# hard-coded 50-iteration cap exactly.
308+
solver_tol = settings.nnls_solver_tol if settings is not None else None
309+
max_iter = settings.nnls_max_iter if settings is not None else None
310+
if max_iter is None:
316311
max_iter = 50
317312

318313
if use_jacobi:

autoarray/settings.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ def __init__(
1515
use_edge_zeroed_pixels: Optional[bool] = None,
1616
use_border_relocator: Optional[bool] = None,
1717
no_regularization_add_to_curvature_diag_value: float = None,
18+
nnls_solver_tol: Optional[float] = None,
19+
nnls_max_iter: Optional[int] = None,
1820
):
1921
"""
2022
The settings of an Inversion, customizing how a linear set of equations are solved for.
@@ -80,8 +82,23 @@ def __init__(
8082
no_regularization_add_to_curvature_diag_value
8183
If a linear func object does not have a corresponding regularization, this value is added to its
8284
diagonal entries of the curvature regularization matrix to ensure the matrix is positive-definite.
85+
nnls_solver_tol
86+
Convergence tolerance (infinity-norm KKT residual) of the JAX positive-only (NNLS) interior-point
87+
solve. `None` (default) uses jaxnnls's own tolerance ``min(n * eps * 5e3, 1e-2)`` (~1.7e-9 at
88+
n=1500 fp64) — behaviour is identical to not having this setting. Each solver iteration is a fresh
89+
dense Cholesky of the (n, n) system, so looser tolerances buy real speed: ``1e-6`` saves ~15-20% of
90+
solve time with a log-evidence shift of order 1e-8 on production HST pixelization+MGE fits
91+
(measured in https://github.com/PyAutoLabs/PyAutoArray/issues/369). Only the JAX (`xp=jnp`) path
92+
honors this; the NumPy fnnls path is unaffected.
93+
nnls_max_iter
94+
Iteration cap of the JAX positive-only (NNLS) interior-point solve. `None` (default) uses
95+
jaxnnls's own hard-coded cap of 50 (production HST pixelization+MGE systems converge in ~19-21
96+
iterations). Under `vmap` the solve runs until the slowest lane in the batch converges, so this
97+
also caps the worst-case batched cost. Only the JAX (`xp=jnp`) path honors this.
8398
"""
8499
self.use_mixed_precision = use_mixed_precision
100+
self.nnls_solver_tol = nnls_solver_tol
101+
self.nnls_max_iter = nnls_max_iter
85102
self._use_positive_only_solver = use_positive_only_solver
86103
self._use_edge_zeroed_pixels = use_edge_zeroed_pixels
87104
self._use_border_relocator = use_border_relocator

autoarray/util/jax_nnls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
backward pass. With the knobs at their defaults the solve and its gradients
1111
are identical to upstream jaxnnls.
1212
13-
The knobs are read from ``general.yaml -> inversion -> nnls_solver_tol /
14-
nnls_max_iter`` by ``inversion_util.reconstruction_positive_only_from``.
13+
The knobs are exposed per-fit through the ``Settings`` class
14+
(``nnls_solver_tol`` / ``nnls_max_iter``, defaults ``None`` = upstream
15+
behaviour) and read by ``inversion_util.reconstruction_positive_only_from``.
1516
Measured motivation (PyAutoArray#369, real HST pixelization+MGE systems):
1617
each PDIP iteration is a fresh dense Cholesky of the (n, n) KKT system, so
1718
iterations are the whole cost; ``solver_tol=1e-6`` saves ~15-20% of solve
Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import importlib
22
import sys
3-
from pathlib import Path
43

54
import numpy as np
65
import pytest
7-
import yaml
86

97
import autoarray as aa
108

@@ -23,36 +21,40 @@ def test__jax_nnls_module_never_imports_jax_at_module_level(monkeypatch):
2321
assert hasattr(module, "solve_nnls_primal")
2422

2523

26-
def test__nnls_solver_config_keys_have_upstream_default_values():
27-
# The packaged general.yaml defaults must reproduce jaxnnls's own
28-
# hard-coded behaviour (tolerance formula and MAX_ITER = 50) so that
29-
# installing this change alone does not alter any solve. The packaged
30-
# file is parsed directly because the test suite (like workspaces)
31-
# pushes its own shadowing config which omits the nnls keys — the
32-
# solver reads missing keys via try/except fallbacks to these values.
33-
packaged = Path(aa.__file__).parent / "config" / "general.yaml"
34-
inversion = yaml.safe_load(packaged.read_text())["inversion"]
24+
def test__settings_nnls_knobs_default_off():
25+
# The Settings defaults must reproduce jaxnnls's own hard-coded
26+
# behaviour (tolerance formula, MAX_ITER = 50) so a default Settings
27+
# object — or none at all — does not alter any solve.
28+
settings = aa.Settings()
3529

36-
assert inversion["nnls_solver_tol"] is None
37-
assert inversion["nnls_max_iter"] == 50
30+
assert settings.nnls_solver_tol is None
31+
assert settings.nnls_max_iter is None
3832

33+
settings = aa.Settings(nnls_solver_tol=1e-6, nnls_max_iter=30)
3934

40-
def test__reconstruction_positive_only_from__numpy_path():
35+
assert settings.nnls_solver_tol == 1e-6
36+
assert settings.nnls_max_iter == 30
37+
38+
39+
def test__reconstruction_positive_only_from__numpy_path_ignores_knobs():
4140
# The xp=np path (fnnls_cholesky) is untouched by the JAX solver knobs;
4241
# solve a small system whose unconstrained solution has a negative
43-
# component and check the non-negative solution.
42+
# component and check the non-negative solution, with and without
43+
# knob-carrying settings.
4444
data_vector = np.array([1.0, 1.0, 2.0])
4545

4646
curvature_reg_matrix = np.array(
4747
[[2.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 1.0]]
4848
)
4949

50-
reconstruction = aa.util.inversion.reconstruction_positive_only_from(
51-
data_vector=data_vector,
52-
curvature_reg_matrix=curvature_reg_matrix,
53-
)
54-
55-
assert np.all(reconstruction >= 0.0)
56-
# Unconstrained solution is [1, -1, 3]; the NNLS solution zeroes the
57-
# negative component and re-solves the free ones.
58-
assert reconstruction == pytest.approx(np.array([0.5, 0.0, 2.0]), 1.0e-4)
50+
for settings in [None, aa.Settings(nnls_solver_tol=1e-6, nnls_max_iter=30)]:
51+
reconstruction = aa.util.inversion.reconstruction_positive_only_from(
52+
data_vector=data_vector,
53+
curvature_reg_matrix=curvature_reg_matrix,
54+
settings=settings,
55+
)
56+
57+
assert np.all(reconstruction >= 0.0)
58+
# Unconstrained solution is [1, -1, 3]; the NNLS solution zeroes the
59+
# negative component and re-solves the free ones.
60+
assert reconstruction == pytest.approx(np.array([0.5, 0.0, 2.0]), 1.0e-4)

0 commit comments

Comments
 (0)