|
| 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) |
0 commit comments