Skip to content

Commit 5f8a8de

Browse files
author
James Nightingale
authored
Merge pull request #574 from PyAutoLabs/feature/mge-nnls-grad-nan
fix: NaN JAX gradient on MGE positive-only solves after #572
2 parents 3de624b + 44762fa commit 5f8a8de

5 files changed

Lines changed: 203 additions & 25 deletions

File tree

‎autoarray/config/general.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ inversion:
77
no_regularization_add_to_curvature_diag_value : 1.0e-3 # The default value added to the curvature matrix's diagonal when regularization is not applied to a linear object, which prevents inversion's failing due to the matrix being singular.
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.
10-
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_preconditioning_no_mapper: raw # How the JAX positive-only PDIP solve scales inversions with NO mapper (linear light profiles / MGE only). "raw" (default) runs the forward solve on the un-preconditioned system with a data-scaled KKT tolerance (1e-2 * n * eps * max(1, max|data_vector|)) and keeps the Jacobi-space relaxed-KKT gradient; "jacobi" uses the Jacobi-preconditioned solve. Jacobi scaling of signal-free MGE columns (diagonal = the no-regularization floor) made the PDIP dual diverge on 14/48 SLaM source_lp[1] points (PyAutoArray#571). Inversions with a mapper always use jacobi; the NumPy path is unaffected.
10+
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. The relaxed solve must start from an iterate whose complementarity s*z is not far above this value; the "raw" no-mapper mode polishes its forward iterate to ensure that (PyAutoArray#573).
11+
nnls_preconditioning_no_mapper: raw # How the JAX positive-only PDIP solve scales inversions with NO mapper (linear light profiles / MGE only). "raw" (default) runs the forward solve on the un-preconditioned system with a data-scaled KKT tolerance (1e-2 * n * eps * max(1, max|data_vector|)) and keeps the Jacobi-space relaxed-KKT gradient, whose relaxed solve starts from the forward iterate polished by <= 10 tight warm-started PDIP iterations on the Jacobi system (without the polish the loose forward tolerance leaves s*z far above nnls_target_kappa and the relaxed solve diverged to NaN gradients on 4/16 jax_grad/mge.py points, PyAutoArray#573); "jacobi" uses the Jacobi-preconditioned solve. Jacobi scaling of signal-free MGE columns (diagonal = the no-regularization floor) made the PDIP dual diverge on 14/48 SLaM source_lp[1] points (PyAutoArray#571). Inversions with a mapper always use jacobi; the NumPy path is unaffected.
1212
nnls_warm_start_memo: true # If True (default), the NumPy/numba positive-only (fnnls) solve warm-starts its active set from the previous likelihood evaluation's passive set, cutting active-set iterations on successive sampler evaluations. The NNLS optimum is unique so the reconstruction is unchanged. On by default as of PyAutoArray#498, measured on the euclid+hst Delaunay-1250 fiducial (9.9x / 4.0x fewer active-set iterations on successive evaluations, reconstruction unchanged). Set false, or AUTOARRAY_NNLS_WARM_START=0, to disable. JAX path unaffected.
1313
nnls_warm_start_error_tolerance: 1.5 # Relative quality guard on a warm-start memo seed. Each memo entry remembers the error fraction of the most recent dense-sign-started solve for that key; a seeded solve whose own error fraction exceeds this multiple of that reference is dropped, so the next solve restarts from the dense-sign start and refreshes the reference. Default 1.5 sits above the worst seed/dense error-fraction ratio seen in the PyAutoArray#498 32-cell robustness matrix (1.42), so it is protective against unmeasured regimes rather than flapping. Any non-finite or non-positive value (e.g. .inf) disables the guard. NumPy/numba fnnls path only.
1414
positive_only_solver: pdip # Which solver the JAX (xp=jnp) positive-only reconstruction uses. "pdip" (default) is the jaxnnls interior-point solve; "certified" is the certified active-set solve (budgeted masked-Cholesky passes that stop once the KKT conditions certify, exact implicit gradient, PDIP fallback), measured 1.2-2.6x faster on source-only inversions (PyAutoArray#566). Applied only to mapper-only JAX inversions (MGE / linear light profiles keep PDIP); the NumPy path always uses fnnls. Opt-in until the batched (vmap) policy is measured.

‎autoarray/util/jax_nnls.py‎

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
solve on the un-preconditioned system with a data-scaled tolerance
2828
(:func:`data_scaled_solver_tol`) and keeps the Jacobi-space backward pass. It
2929
exists because Jacobi scaling of signal-free MGE columns (diagonal = the
30-
no-regularization floor) makes the PDIP dual diverge.
30+
no-regularization floor) makes the PDIP dual diverge. Its backward pass polishes
31+
the mapped forward iterate with a few tight, warm-started PDIP iterations on the
32+
Jacobi system before the relaxed-KKT solve, which otherwise diverges to NaN from
33+
the loose forward tolerance (PyAutoArray#573); :func:`raw_forward_backward_status`
34+
reports that pass's convergence.
3135
3236
JAX is imported inside functions, never at module level (see
3337
``docs/agents/jax_and_decorators.md``); this module must only be imported
@@ -39,7 +43,7 @@
3943
from functools import lru_cache
4044

4145

42-
def solve_nnls(Q, q, solver_tol=None, max_iter=50):
46+
def solve_nnls(Q, q, solver_tol=None, max_iter=50, init=None):
4347
"""
4448
Solve the non-negative least squares problem with the jaxnnls PDIP
4549
algorithm, with configurable convergence tolerance and iteration cap.
@@ -59,6 +63,10 @@ def solve_nnls(Q, q, solver_tol=None, max_iter=50):
5963
``min(n * eps * 5e3, 1e-2)``.
6064
max_iter
6165
Maximum number of PDIP iterations (jaxnnls hard-codes 50).
66+
init
67+
Optional ``(x, s, z)`` warm start (strictly positive ``s`` and ``z``)
68+
replacing jaxnnls's ``initialize``. ``None`` (default) is the upstream
69+
cold start.
6270
6371
Returns
6472
-------
@@ -69,7 +77,7 @@ def solve_nnls(Q, q, solver_tol=None, max_iter=50):
6977
import jax.numpy as jnp
7078
from jaxnnls.pdip import EPSILON, initialize, pdip_pc_step
7179

72-
x, s, z = initialize(Q, q)
80+
x, s, z = initialize(Q, q) if init is None else init
7381

7482
if solver_tol is None:
7583
solver_tol = jax.lax.min(Q.shape[0] * EPSILON, 1e-2)
@@ -179,10 +187,57 @@ def solve_nnls_primal(Q, q, target_kappa=1e-3, solver_tol=None, max_iter=50):
179187
)[0]
180188

181189

190+
# The backward pass of the ``"raw"`` mode first polishes the mapped raw-forward iterate with at most this many
191+
# PDIP iterations on the Jacobi-scaled system at jaxnnls's own tight tolerance (PyAutoArray#573). Measured on the
192+
# SLaM MGE fixture, the 48 SLaM ``source_lp[1]`` systems and the jax_grad/mge.py points: 4-6 iterations.
193+
RAW_BACKWARD_POLISH_MAX_ITER = 10
194+
195+
196+
def _raw_forward_backward_point(
197+
Q_pc, q_pc, Q, q, D, target_kappa, solver_tol, max_iter
198+
):
199+
"""
200+
The forward solve and the relaxed-KKT point of the ``"raw"`` mode (shared by
201+
the custom-vjp forward pass and :func:`raw_forward_backward_status`).
202+
203+
Returns ``(y, converged, pdip_iter)`` of the raw forward solve (mapped to the
204+
Jacobi coordinates), the relaxed point ``(yr, sr, zr)`` the backward pass
205+
differentiates at, and the status ``(relaxed_converged, relaxed_iter,
206+
polish_converged, polish_iter)``.
207+
"""
208+
import jax.numpy as jnp
209+
from jaxnnls.pdip_relaxed import solve_relaxed_nnls
210+
211+
tol = data_scaled_solver_tol(q) if solver_tol is None else solver_tol
212+
x, s, z, converged, pdip_iter = solve_nnls(Q, q, solver_tol=tol, max_iter=max_iter)
213+
y, sy, zy = x / D, s / D, z * D
214+
215+
# Polish (PyAutoArray#573): the data-scaled tolerance leaves s * z ~ 1e-10 .. 1e-9, far above
216+
# ``target_kappa``, so the relaxed solve below would have to push toward the boundary from z / s ~ 1e13
217+
# and its fixed 50-iteration while_loop overshoots to NaN. A few tight PDIP iterations on the scaled
218+
# system, warm-started from the mapped iterate, bring s * z down to the jaxnnls tolerance first. If the
219+
# polish does not converge (the scaled dual is what diverges on #571's systems from a cold start), the
220+
# mapped iterate is kept, i.e. the pre-polish behaviour.
221+
yp, sp, zp, polish_converged, polish_iter = solve_nnls(
222+
Q_pc, q_pc, max_iter=RAW_BACKWARD_POLISH_MAX_ITER, init=(y, sy, zy)
223+
)
224+
ok = jnp.logical_and(
225+
polish_converged == 1,
226+
jnp.all(jnp.isfinite(yp)) & jnp.all(sp > 0) & jnp.all(zp > 0),
227+
)
228+
yp, sp, zp = (jnp.where(ok, a, b) for a, b in ((yp, y), (sp, sy), (zp, zy)))
229+
230+
yr, sr, zr, relaxed_converged, relaxed_iter = solve_relaxed_nnls(
231+
Q_pc, q_pc, yp, sp, zp, target_kappa=target_kappa
232+
)
233+
status = (relaxed_converged, relaxed_iter, ok.astype(int), polish_iter)
234+
return (y, converged, pdip_iter), (yr, sr, zr), status
235+
236+
182237
@lru_cache(maxsize=None)
183238
def _solve_nnls_raw_forward_with(target_kappa, solver_tol, max_iter):
184239
"""
185-
Build (and cache) the ``"raw"``-mode solver (PyAutoArray#571).
240+
Build (and cache) the ``"raw"``-mode solver (PyAutoArray#571, #573).
186241
187242
The returned function takes the Jacobi-scaled system ``(Q_pc, q_pc)``
188243
(``Q_pc = D Q D``, ``q_pc = D q``) together with the raw system ``(Q, q)``
@@ -196,38 +251,43 @@ def _solve_nnls_raw_forward_with(target_kappa, solver_tol, max_iter):
196251
linear-object-only (MGE) systems, Jacobi scaling turns signal-free columns
197252
whose diagonal is only the no-regularization floor into degenerate
198253
coordinates that make the PDIP dual diverge; the raw solve does not.
199-
- **Backward:** exactly today's Jacobi-mode pass, i.e. the relaxed-KKT implicit
200-
derivative on ``Q_pc``, started from the mapped iterate. The relaxed-KKT
201-
pass on the raw, ill-conditioned ``Q`` produces NaN gradients, which is why
202-
Jacobi scaling was introduced. ``(Q, q, D)`` get zero cotangents: ``y``
203-
depends only on ``(Q_pc, q_pc)``, and the caller's autodiff carries the
204-
dependence of those, and of ``D``, on the raw inputs.
254+
- **Backward:** the relaxed-KKT implicit derivative on ``Q_pc`` (as the
255+
Jacobi mode), started from the mapped iterate after a *polish*: at most
256+
:data:`RAW_BACKWARD_POLISH_MAX_ITER` PDIP iterations on ``(Q_pc, q_pc)``
257+
at jaxnnls's tight tolerance, warm-started from the mapped iterate
258+
(kept only if it converges). Without it the loose forward tolerance
259+
leaves complementarity ``s * z`` orders of magnitude above
260+
``target_kappa`` and the relaxed solve diverges to NaN on a fraction of
261+
points (PyAutoArray#573); with it the relaxed solve converges in about
262+
one iteration. The primal ``y`` is the unpolished forward solution, so
263+
the forward value is unchanged. The relaxed-KKT pass on the raw,
264+
ill-conditioned ``Q`` produces NaN gradients, which is why Jacobi scaling
265+
was introduced. ``(Q, q, D)`` get zero cotangents: ``y`` depends only on
266+
``(Q_pc, q_pc)``, and the caller's autodiff carries the dependence of
267+
those, and of ``D``, on the raw inputs.
268+
269+
The backward-pass convergence is observable through
270+
:func:`raw_forward_backward_status`.
205271
"""
206272
import jax
207273
import jax.numpy as jnp
208274
from jaxnnls.diff_qp import diff_nnls
209-
from jaxnnls.pdip_relaxed import solve_relaxed_nnls
210275

211-
def raw_solve(Q, q, D):
276+
def primal(Q_pc, q_pc, Q, q, D):
212277
tol = data_scaled_solver_tol(q) if solver_tol is None else solver_tol
213-
x, s, z, converged, pdip_iter = solve_nnls(
278+
x, _, _, converged, pdip_iter = solve_nnls(
214279
Q, q, solver_tol=tol, max_iter=max_iter
215280
)
216-
return x / D, s / D, z * D, converged, pdip_iter
217-
218-
def primal(Q_pc, q_pc, Q, q, D):
219-
y, _, _, converged, pdip_iter = raw_solve(Q, q, D)
220-
return y, converged, pdip_iter
281+
return x / D, converged, pdip_iter
221282

222283
def forward(Q_pc, q_pc, Q, q, D):
223-
y, sy, zy, converged, pdip_iter = raw_solve(Q, q, D)
224-
yr, sr, zr, _, _ = solve_relaxed_nnls(
225-
Q_pc, q_pc, y, sy, zy, target_kappa=target_kappa
284+
out, (yr, sr, zr), status = _raw_forward_backward_point(
285+
Q_pc, q_pc, Q, q, D, target_kappa, solver_tol, max_iter
226286
)
227-
return (y, converged, pdip_iter), (Q_pc, yr, sr, zr, Q, q, D)
287+
return out, (Q_pc, yr, sr, zr, status[0], Q, q, D)
228288

229289
def backward(res, output_grad):
230-
Q_pc, yr, sr, zr, Q, q, D = res
290+
Q_pc, yr, sr, zr, _, Q, q, D = res
231291
dQ_pc, dq_pc = diff_nnls(Q_pc, yr, sr, zr, output_grad[0])
232292
return dQ_pc, dq_pc, jnp.zeros_like(Q), jnp.zeros_like(q), jnp.zeros_like(D)
233293

@@ -236,6 +296,24 @@ def backward(res, output_grad):
236296
return primal
237297

238298

299+
def raw_forward_backward_status(
300+
Q_pc, q_pc, Q, q, D, target_kappa=1e-3, solver_tol=None, max_iter=50
301+
):
302+
"""
303+
Diagnostic (not differentiable): the convergence of the ``"raw"`` mode's
304+
backward-pass preparation for one system, as the integer tuple
305+
``(relaxed_converged, relaxed_iter, polish_converged, polish_iter)``.
306+
307+
``relaxed_*`` describe the relaxed-KKT solve whose point the gradient is
308+
taken at; ``polish_*`` the tight warm-started PDIP polish before it
309+
(``polish_converged == 0`` means the mapped iterate was used unpolished).
310+
Arguments are those of :func:`solve_nnls_primal_raw_forward`.
311+
"""
312+
return _raw_forward_backward_point(
313+
Q_pc, q_pc, Q, q, D, target_kappa, solver_tol, max_iter
314+
)[2]
315+
316+
239317
def solve_nnls_primal_raw_forward(
240318
Q_pc, q_pc, Q, q, D, target_kappa=1e-3, solver_tol=None, max_iter=50
241319
):

‎test_autoarray/inversion/inversion/files/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,11 @@
77
`autolens_profiling/scripts/imaging/hazards/mge_nnls_capture.py` (autolens_profiling d6926af), run
88
2026-09-24 on CPU fp64 with PyAutoArray 7fa8d2714f, PyAutoGalaxy 70a61e26cd, PyAutoLens 86054bbc19,
99
PyAutoFit a736840127, jax 0.10.2.
10+
- `mge_grad_nan_systems.npz` — 4 positive-only systems `Q_<k>` (20x20) / `q_<k>` captured from the
11+
autolens_workspace_test `scripts/imaging/jax_grad/mge.py` model (MGE source, NFWSph + ExternalShear) at
12+
`physical_values_from_prior_medians + jax.random.uniform(PRNGKey(p), minval=0.01, maxval=0.05)` for
13+
p = 2, 10, 12, 14 (PyAutoArray#573): on PyAutoArray 3de624b5 the `"raw"`-mode gradient is NaN on each (the
14+
relaxed-KKT backward solve diverges from the loose raw-forward iterate); `meta` holds the per-system JSON.
15+
Captured 2026-09-25 on CPU fp64 via a `jax.debug.callback` on `reconstruction_positive_only_from`, with
16+
autolens_workspace_test 5ec64413d2, PyAutoArray 3de624b5b9, PyAutoGalaxy 70a61e26cd, PyAutoLens 86054bbc19,
17+
PyAutoFit dd9fbe0aab, jax 0.10.2, numpy 2.5.3.
Binary file not shown.

‎test_autoarray/inversion/inversion/test_nnls_mge_convergence.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,95 @@ def test__control__well_conditioned_pdip_unchanged(jnp, n, seed):
323323
np.testing.assert_allclose(
324324
x_raw, x_scipy, rtol=0, atol=1e-8 * np.abs(x_scipy).max()
325325
)
326+
327+
328+
# ---------------------------------------------------------------------------------------------------------------
329+
# PyAutoArray#573: NaN gradients of the "raw" mode.
330+
#
331+
# `files/mge_grad_nan_systems.npz` holds 4 (20 x 20) systems captured from the autolens_workspace_test
332+
# `jax_grad/mge.py` model (MGE source, NFWSph + shear) at the PRNGKey perturbations 2, 10, 12 and 14 (see
333+
# `files/README.md`). The raw forward solve stops at the data-scaled tolerance with s * z ~ 1e-10 .. 2.5e-9, far
334+
# above `nnls_target_kappa = 1e-11`; the relaxed-KKT solve on the Jacobi system then has to push toward the
335+
# boundary from z / s ~ 1e13 and hits its 50-iteration cap with NaN (or "converges" with s < 0), so the gradient
336+
# is NaN. The fix polishes the mapped iterate with a few tight PDIP iterations on the Jacobi system first.
337+
# ---------------------------------------------------------------------------------------------------------------
338+
339+
GRAD_NAN_FIXTURE = Path(__file__).parent / "files" / "mge_grad_nan_systems.npz"
340+
341+
342+
def _load_grad_nan_systems():
343+
with np.load(GRAD_NAN_FIXTURE) as data:
344+
meta = json.loads(str(data["meta"]))
345+
systems = [
346+
(np.asarray(data[f"Q_{s['key']}"]), np.asarray(data[f"q_{s['key']}"]))
347+
for s in meta["systems"]
348+
]
349+
return meta, systems
350+
351+
352+
GRAD_NAN_META, GRAD_NAN_SYSTEMS = _load_grad_nan_systems()
353+
GRAD_NAN_IDS = [f"prng{s['prng_key']}" for s in GRAD_NAN_META["systems"]]
354+
355+
356+
def test__grad_nan_fixture_is_the_captured_jax_grad_mge_set():
357+
assert GRAD_NAN_FIXTURE.stat().st_size < 50_000
358+
assert [s["prng_key"] for s in GRAD_NAN_META["systems"]] == [2, 10, 12, 14]
359+
for Q, q in GRAD_NAN_SYSTEMS:
360+
assert Q.shape == (20, 20) and q.shape == (20,)
361+
362+
363+
@requires_jax
364+
@pytest.mark.parametrize("jit", [False, True], ids=["eager", "jit"])
365+
@pytest.mark.parametrize("index", range(len(GRAD_NAN_SYSTEMS)), ids=GRAD_NAN_IDS)
366+
def test__raw_mode_gradient_is_finite_on_the_captured_grad_nan_systems(jnp, index, jit):
367+
"""Red on PyAutoArray 3de624b5 (#572): the gradient is NaN on all four systems eagerly, and under jit on
368+
prng10 / prng14 (the jitted NaN is rounding-sensitive; prng2 / prng12 happen to pass jitted on main).
369+
"""
370+
import jax
371+
372+
Q, q = GRAD_NAN_SYSTEMS[index]
373+
w = jnp.linspace(0.5, 1.5, q.shape[0])
374+
375+
grad = jax.grad(lambda Q_, q_: w @ _raw(jnp, Q_, q_), argnums=(0, 1))
376+
if jit:
377+
grad = jax.jit(grad)
378+
gQ, gq = grad(jnp.asarray(Q), jnp.asarray(q))
379+
380+
assert np.all(np.isfinite(np.asarray(gQ))) and np.all(np.isfinite(np.asarray(gq)))
381+
assert np.any(np.asarray(gq) != 0.0)
382+
383+
384+
def _backward_status(jnp, Q, q):
385+
from autoarray.util.jax_nnls import raw_forward_backward_status
386+
387+
Qj, qj = jnp.asarray(Q), jnp.asarray(q)
388+
Q_pc, q_pc, D = (jnp.asarray(a) for a in _jacobi(Q, q))
389+
return [
390+
int(v)
391+
for v in raw_forward_backward_status(
392+
Q_pc, q_pc, Qj, qj, D, target_kappa=1.0e-11, max_iter=PRODUCTION_MAX_ITER
393+
)
394+
]
395+
396+
397+
@requires_jax
398+
@pytest.mark.parametrize(
399+
"system",
400+
[("slam", k) for k in KEYS]
401+
+ [("grad_nan", i) for i in range(len(GRAD_NAN_SYSTEMS))],
402+
ids=[f"slam-{i}" for i in IDS] + [f"grad_nan-{i}" for i in GRAD_NAN_IDS],
403+
)
404+
def test__raw_mode_backward_pass_converges(jnp, system):
405+
"""The backward pass reports convergence: the tight polish of the mapped iterate converges (measured <= 6
406+
iterations) and the relaxed-KKT solve then converges well inside its 50-iteration cap (measured 1).
407+
Not a red-on-main witness (``raw_forward_backward_status`` is new with #573); the gradient test is.
408+
"""
409+
kind, index = system
410+
Q, q = (SYSTEMS if kind == "slam" else GRAD_NAN_SYSTEMS)[index]
411+
412+
relaxed_converged, relaxed_iter, polish_converged, polish_iter = _backward_status(
413+
jnp, Q, q
414+
)
415+
416+
assert polish_converged == 1, polish_iter
417+
assert relaxed_converged == 1 and relaxed_iter < PRODUCTION_MAX_ITER, relaxed_iter

0 commit comments

Comments
 (0)