Skip to content

Commit d8a1c84

Browse files
Jammy2211claude
authored andcommitted
feat: configurable NNLS solver knobs (nnls_solver_tol / nnls_max_iter) — PARKED, not for merge (#369)
Vendored jaxnnls while_loop driver with configurable tolerance and iteration cap; defaults bit-identical to upstream (solutions and gradients, validated on real production systems). Parked at user request 2026-07-09 — the measured win (~15-20% of solve) was judged not worth the new solver code path; this branch preserves the validated implementation should that change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgtjXWS2iJegXMTDU4GHth
1 parent 89a5fbd commit d8a1c84

4 files changed

Lines changed: 207 additions & 4 deletions

File tree

autoarray/config/general.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ 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.
1113
reconstruction_vmax_factor: 0.5 # Plots of an Inversion's reconstruction use the reconstructed data's bright value multiplied by this factor.
1214
numba:
1315
use_numba: true

autoarray/inversion/inversion/inversion_util.py

Lines changed: 28 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,19 @@ def reconstruction_positive_only_from(
301302
# magnitude in noise.
302303
target_kappa = 1.0e-11
303304

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.
316+
max_iter = 50
317+
304318
if use_jacobi:
305319
# Ill-conditioned Q makes jaxnnls's relaxed-KKT backward pass
306320
# produce NaN gradients. Rescale Q so its diagonal is unit:
@@ -312,12 +326,22 @@ def reconstruction_positive_only_from(
312326
Q_pc = (curvature_reg_matrix * D[:, None]) * D[None, :]
313327
q_pc = data_vector * D
314328
return (
315-
jaxnnls.solve_nnls_primal(Q_pc, q_pc, target_kappa=target_kappa)
329+
solve_nnls_primal(
330+
Q_pc,
331+
q_pc,
332+
target_kappa=target_kappa,
333+
solver_tol=solver_tol,
334+
max_iter=max_iter,
335+
)
316336
* D
317337
)
318338

319-
return jaxnnls.solve_nnls_primal(
320-
curvature_reg_matrix, data_vector, target_kappa=target_kappa
339+
return solve_nnls_primal(
340+
curvature_reg_matrix,
341+
data_vector,
342+
target_kappa=target_kappa,
343+
solver_tol=solver_tol,
344+
max_iter=max_iter,
321345
)
322346

323347
try:

autoarray/util/jax_nnls.py

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

0 commit comments

Comments
 (0)