Skip to content

Commit e0cb2fa

Browse files
authored
Merge pull request #371 from PyAutoLabs/feature/nnls-solver-optimization
feat: NNLS solver knobs via Settings (nnls_solver_tol / nnls_max_iter), default off
2 parents 89a5fbd + 8771f29 commit e0cb2fa

4 files changed

Lines changed: 220 additions & 4 deletions

File tree

autoarray/inversion/inversion/inversion_util.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,10 @@ def reconstruction_positive_only_from(
274274
"""
275275
if xp.__name__.startswith("jax"):
276276

277-
import jaxnnls
278277
from autoconf import conf
279278

279+
from autoarray.util.jax_nnls import solve_nnls_primal
280+
280281
try:
281282
use_jacobi = conf.instance["general"]["inversion"][
282283
"nnls_jacobi_preconditioning"
@@ -301,6 +302,14 @@ def reconstruction_positive_only_from(
301302
# magnitude in noise.
302303
target_kappa = 1.0e-11
303304

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:
311+
max_iter = 50
312+
304313
if use_jacobi:
305314
# Ill-conditioned Q makes jaxnnls's relaxed-KKT backward pass
306315
# produce NaN gradients. Rescale Q so its diagonal is unit:
@@ -312,12 +321,22 @@ def reconstruction_positive_only_from(
312321
Q_pc = (curvature_reg_matrix * D[:, None]) * D[None, :]
313322
q_pc = data_vector * D
314323
return (
315-
jaxnnls.solve_nnls_primal(Q_pc, q_pc, target_kappa=target_kappa)
324+
solve_nnls_primal(
325+
Q_pc,
326+
q_pc,
327+
target_kappa=target_kappa,
328+
solver_tol=solver_tol,
329+
max_iter=max_iter,
330+
)
316331
* D
317332
)
318333

319-
return jaxnnls.solve_nnls_primal(
320-
curvature_reg_matrix, data_vector, target_kappa=target_kappa
334+
return solve_nnls_primal(
335+
curvature_reg_matrix,
336+
data_vector,
337+
target_kappa=target_kappa,
338+
solver_tol=solver_tol,
339+
max_iter=max_iter,
321340
)
322341

323342
try:

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: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""
2+
Configurable driver for the jaxnnls primal-dual interior-point NNLS solver.
3+
4+
jaxnnls hard-codes its convergence tolerance (``n * eps * 5e3``, capped at
5+
1e-2) and iteration cap (``MAX_ITER = 50``) inside ``pdip.solve_nnls``, and
6+
neither is exposed through ``solve_nnls_primal``. This module re-implements
7+
only that ``while_loop`` driver with both knobs as arguments, reusing every
8+
jaxnnls building block (``initialize``, ``pdip_pc_step``,
9+
``solve_relaxed_nnls``, ``diff_nnls``) and the same custom-vjp relaxed-KKT
10+
backward pass. With the knobs at their defaults the solve and its gradients
11+
are identical to upstream jaxnnls.
12+
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``.
16+
Measured motivation (PyAutoArray#369, real HST pixelization+MGE systems):
17+
each PDIP iteration is a fresh dense Cholesky of the (n, n) KKT system, so
18+
iterations are the whole cost; ``solver_tol=1e-6`` saves ~15-20% of solve
19+
time with a log-evidence shift of order 1e-8. Under ``vmap`` the while_loop
20+
runs until the slowest lane converges, so ``max_iter`` also caps the
21+
worst-case batched cost.
22+
23+
JAX is imported inside functions, never at module level (see
24+
``docs/agents/jax_and_decorators.md``); this module must only be imported
25+
on the ``xp=jnp`` path. The solver knobs are static closure parameters —
26+
the ``lru_cache`` returns the same function object for repeated settings so
27+
``jax.jit`` tracing caches hit (a fresh closure per call would cache-bust).
28+
"""
29+
30+
from functools import lru_cache
31+
32+
33+
def solve_nnls(Q, q, solver_tol=None, max_iter=50):
34+
"""
35+
Solve the non-negative least squares problem with the jaxnnls PDIP
36+
algorithm, with configurable convergence tolerance and iteration cap.
37+
38+
Mirrors ``jaxnnls.pdip.solve_nnls`` exactly at the default settings.
39+
40+
Parameters
41+
----------
42+
Q
43+
The (n, n) positive definite matrix (the curvature-regularization
44+
matrix of an inversion).
45+
q
46+
The (n,) vector (the data vector of an inversion).
47+
solver_tol
48+
Infinity-norm KKT residual below which the solve is converged.
49+
``None`` (default) reproduces jaxnnls's own tolerance
50+
``min(n * eps * 5e3, 1e-2)``.
51+
max_iter
52+
Maximum number of PDIP iterations (jaxnnls hard-codes 50).
53+
54+
Returns
55+
-------
56+
The tuple (x, s, z, converged, pdip_iter) of the primal solution, slack
57+
and dual variables, convergence flag and iteration count.
58+
"""
59+
import jax
60+
import jax.numpy as jnp
61+
from jaxnnls.pdip import EPSILON, initialize, pdip_pc_step
62+
63+
x, s, z = initialize(Q, q)
64+
65+
if solver_tol is None:
66+
solver_tol = jax.lax.min(Q.shape[0] * EPSILON, 1e-2)
67+
solver_tol = jnp.asarray(solver_tol, dtype=q.dtype)
68+
69+
def converged_check(inputs):
70+
_, _, _, _, _, _, converged, pdip_iter = inputs
71+
return jnp.logical_and(pdip_iter < max_iter, converged == 0)
72+
73+
init_inputs = (Q, q, x, s, z, solver_tol, 0, 0)
74+
outputs = jax.lax.while_loop(converged_check, pdip_pc_step, init_inputs)
75+
_, _, x, s, z, _, converged, pdip_iter = outputs
76+
return x, s, z, converged, pdip_iter
77+
78+
79+
@lru_cache(maxsize=None)
80+
def _solve_nnls_primal_with(target_kappa, solver_tol, max_iter):
81+
"""
82+
Build (and cache) the differentiable primal solver for one static
83+
setting of the knobs. The returned function takes only (Q, q), so the
84+
custom-vjp backward pass returns exactly (dQ, dq).
85+
"""
86+
import jax
87+
from jaxnnls.diff_qp import diff_nnls
88+
from jaxnnls.pdip_relaxed import solve_relaxed_nnls
89+
90+
def primal(Q, q):
91+
return solve_nnls(Q, q, solver_tol=solver_tol, max_iter=max_iter)[0]
92+
93+
def forward(Q, q):
94+
x, s, z, _, _ = solve_nnls(Q, q, solver_tol=solver_tol, max_iter=max_iter)
95+
# Relax the solution with vanilla Newton steps on the relaxed KKT
96+
# conditions; only the backward pass consumes the relaxed variables.
97+
xr, sr, zr, _, _ = solve_relaxed_nnls(
98+
Q, q, x, s, z, target_kappa=target_kappa
99+
)
100+
return x, (Q, xr, sr, zr)
101+
102+
def backward(res, input_grad):
103+
Q, xr, sr, zr = res
104+
return diff_nnls(Q, xr, sr, zr, input_grad)
105+
106+
primal = jax.custom_vjp(primal)
107+
primal.defvjp(forward, backward)
108+
return primal
109+
110+
111+
def solve_nnls_primal(Q, q, target_kappa=1e-3, solver_tol=None, max_iter=50):
112+
"""
113+
Solve the non-negative least squares problem, differentiable via the
114+
relaxed-KKT implicit backward pass.
115+
116+
Drop-in replacement for ``jaxnnls.solve_nnls_primal`` with two extra
117+
knobs; at their defaults (``solver_tol=None``, ``max_iter=50``) the
118+
forward solve and gradients are identical to upstream.
119+
"""
120+
return _solve_nnls_primal_with(target_kappa, solver_tol, max_iter)(Q, q)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import importlib
2+
import sys
3+
4+
import numpy as np
5+
import pytest
6+
7+
import autoarray as aa
8+
9+
10+
def test__jax_nnls_module_never_imports_jax_at_module_level(monkeypatch):
11+
# Library unit tests are NumPy-only: importing the module must succeed
12+
# even when jax / jaxnnls are unimportable. A None entry in sys.modules
13+
# makes any `import jax` raise ImportError, so a module-level import
14+
# would fail this reload.
15+
monkeypatch.setitem(sys.modules, "jax", None)
16+
monkeypatch.setitem(sys.modules, "jaxnnls", None)
17+
18+
module = importlib.reload(importlib.import_module("autoarray.util.jax_nnls"))
19+
20+
assert hasattr(module, "solve_nnls")
21+
assert hasattr(module, "solve_nnls_primal")
22+
23+
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()
29+
30+
assert settings.nnls_solver_tol is None
31+
assert settings.nnls_max_iter is None
32+
33+
settings = aa.Settings(nnls_solver_tol=1e-6, nnls_max_iter=30)
34+
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():
40+
# The xp=np path (fnnls_cholesky) is untouched by the JAX solver knobs;
41+
# solve a small system whose unconstrained solution has a negative
42+
# component and check the non-negative solution, with and without
43+
# knob-carrying settings.
44+
data_vector = np.array([1.0, 1.0, 2.0])
45+
46+
curvature_reg_matrix = np.array(
47+
[[2.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 1.0]]
48+
)
49+
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)