Square Systems (Root Finding)
SquareLevenbergMarquardt is a solve-only damped-Newton (Levenberg-Marquardt)
root solver for square nonsingular systems
built for hot inner loops — the motivating case is the algebraic stage solve of semi-explicit index-1 DAEs, where a small square root must be found at every Runge–Kutta stage, warm-started from the previous stage, and differentiated implicitly with respect to everything that defines the equation.
It deliberately does not reuse the underdetermined solver's machinery: at
\(m = n\) the residual-space Gram \(J P J^\top\) squares the Jacobian's
condition number for no benefit, and the general solver's metric, callback,
and geodesic plumbing costs real time per stage. The square solver is a lean
jitted lax.while_loop around a direct dense factorization.
API
from nlls_gram import SquareLevenbergMarquardt
solver = SquareLevenbergMarquardt(
residual_fn, # (x) | (x, args) | (x, args, p)
init_damping=1e-3,
damping_decrease=0.5,
damping_increase=4.0,
has_aux=False,
)
result = solver.solve(
x0, # root-finding guess only — never an AD target
args, # fixed data — never an AD target
p=p, # ALL differentiated equation inputs
max_steps=8,
atol=None, # None -> 1e-6 (float32) / 1e-10 (float64)
)
z = result.x # result.residual_norm, result.steps, result.status
There is no init/update pair — solve is the entire interface. The
residual convention, x pytree flattening, and LMStatus codes match the
underdetermined solver: x may be any pytree, and the residual must return a
single array (or (array, aux) with has_aux=True, where aux is a pytree
whose leaves are JAX numeric types — it is returned through the jitted loop)
whose flattened size equals the flattened size of x, in the same dtype.
The result is a lean
SquareSolveResult with x, residual_norm (the residual 2-norm at the
returned x), steps, status, aux (with has_aux=True), and the
pass-through args and p the solve was called with — the same accessors as
the underdetermined solver's LMSolveResult, so downstream code can read
result.args/result.p uniformly. The loop controls (max_steps, atol,
gtol, xtol) are concrete Python scalars, not traceable arguments.
For a DAE stage 0 = g(y, z, t, args, params) solved for z:
def residual(z, args, p):
y, t, params = p
return g(y, z, t, args, params)
result = solver.solve(z_guess, args, p=(y, t, params), max_steps=8, atol=root_atol)
z = result.x
The Step: Augmented QR, Never the Gram
Each iteration solves the LM subproblem \(\min_s \|r + J s\|_2^2 + \lambda \|s\|_2^2\) through one reduced QR of the augmented matrix
whose normal equations are exactly \((J^\top J + \lambda I)s = -J^\top r\) — solved without ever forming \(J^\top J\) or \(J J^\top\), so the step does not square the Jacobian's condition number. For finite \(J\) and \(\lambda > 0\) the augmented matrix has full column rank (exact arithmetic), so the factorization does not fail: a singular Jacobian still yields a well-defined damped step, which the accept/reject test then vets like any other. Accept/reject on the sum of squared residuals and the multiplicative damping update follow the main solver.
The Jacobian is a dense forward-mode jax.jacfwd and is recomputed only
after an accepted step (lax.cond); rejected steps reuse it. A warm
start that already meets atol exits before the loop without computing a
Jacobian at all — the DAE stage pattern (each stage starts near the next
root) pays one residual evaluation for an already-converged guess (plus one
more at the returned x when has_aux=True).
The linear algebra is deliberately dense and direct in this first version; support for other linear solvers may be added later behind a constructor knob.
Status Semantics
LMStatus.CONVERGEDwith the defaultgtol=xtol=0means exactly the residual criterion \(\|r\|_2 < \text{atol}\) — a DAE caller can trust the status as "found a root," andresult.residual_normis there to enforce a caller-side criterion directly.gtol(on \(\|J^\top r\|\)) andxtol(on an accepted step norm) are opt-in: they can reportCONVERGEDat a stagnation point that is not a root (e.g. the loss-stationary point of \(x^2 + 1\)), which is precisely why they default to off.NONFINITEreports a nonfinite residual atx0; a nonfinite candidate step is simply rejected (acceptance requires a finite loss). Exhausted budgets reportMAX_STEPS. There are no runtime host exceptions under jit; the square-shape and dtype checks raise at trace time.
Implicit Differentiation
solve(...).x carries a custom implicit JVP with respect to p (the same
jax.custom_jvp organization as the underdetermined
solver): differentiate the defining equation at the returned
root rather than the iterations. For \(r(x^*, \text{args}, p) = 0\),
is solved directly with the square dense \(J_x\) (LU) at the solution —
never through \(J_x J_x^\top\). VJPs come from transposing the JVP rule, so
forward, reverse, forward-over-reverse, and reverse-over-forward all compose
(jax.hessian, jax.vmap over differentiated solves, etc.). For the DAE
stage above this gives, with no extra rules in the caller,
Contract notes:
x0andargsreceive zero tangents: the guess and fixed data are not AD targets. Anything that must be differentiated goes inp.- Implicit derivatives are meaningful only when the returned point is a
converged, nonsingular root — check
statusandresidual_normbefore trusting them. A singular \(J_x\) produces a non-finite tangent (loud), consistent with the library's conventions. result.residual_normgets a zero tangent by contract (at a root it is ~0 and not meaningfully differentiable); withhas_aux=True,result.auxis differentiated through both its directpdependence and the root \(x^*(p)\).
Performance
Construct the residual once at setup scope — solvers compare by
configuration, so a fresh equal-settings instance around the same residual
function reuses the compiled loop, but a residual closure rebuilt per call is
a new configuration and retraces, exactly as with the underdetermined
solver. Measured on CPU (float32, 32
warm-started solves inside one jitted lax.scan, the DAE stage pattern;
benchmarks/test_square_lm_benchmark.py), the full adaptive LM solve costs
1.0–1.6x a fixed-iteration direct-Newton fori_loop baseline at
\(n \in \{1, 4, 8\}\) — within the issue #14 gate of ~2x, while adding
damping robustness, early exit on atol, and the implicit-AD boundary.
API Reference
nlls_gram.SquareLevenbergMarquardt
Damped-Newton (Levenberg-Marquardt) root solver for square systems
r(x, args, p) = 0 where the flattened residual and parameter sizes
are equal and the Jacobian is nonsingular at the root.
The step solves the LM subproblem
min_s ||r + J s||^2 + damping ||s||^2 through one reduced QR of the
augmented matrix [J; sqrt(damping) I] -- a direct dense factorization
that never forms J J' or J'J (no condition-number squaring), and
whose augmented matrix has full column rank for any finite J when
damping > 0 -- a singular Jacobian still yields a well-defined damped
step, vetted by the accept/reject test, rather than a host exception.
Only solve is exposed (no init/update); the
loop is a lean lax.while_loop that evaluates the residual once per
iteration plus once at x0 (and once more at the returned x when
has_aux=True), recomputes the Jacobian only after an accepted step,
and exits immediately on a warm start that already meets atol --
without ever computing a Jacobian. x may be any pytree; the residual
must return a single array (or (array, aux) with has_aux=True,
where aux is a pytree whose leaves are JAX numeric types) whose
flattened size equals the flattened size of x, in the same
dtype. Construct the solver once at setup scope; a new instance per call
retraces.
solve(...).x has a custom implicit JVP with respect to p: the
tangent solves the square system J_x xdot = -J_p pdot with a direct
dense solve at the returned solution, and VJPs are obtained by
transposition (jax.custom_jvp), so forward, reverse, and second-order
differentiation all compose. x0 and args receive zero tangents by
contract. Implicit derivatives are meaningful only when the returned
point is a converged, nonsingular root -- check status and
residual_norm.
Source code in src/nlls_gram/square_lm.py
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | |
solve(x0, args=None, *, p=None, max_steps=256, atol=None, gtol=0.0, xtol=0.0, jit=True)
Run damped-Newton steps from x0 until a stopping rule fires.
atol stops on the residual 2-norm; None uses a dtype-aware
default (1e-6 in float32, 1e-10 in float64). gtol stops on
the gradient norm ||J' r|| and xtol on an accepted step norm;
both default to 0 (disabled), so LMStatus.CONVERGED means the
residual criterion unless they are opted into. x0 is a
root-finding guess only; a warm start already at the root returns in
zero steps. The loop controls (max_steps, atol, gtol,
xtol) are concrete Python scalars validated eagerly -- they are
not traceable arguments.
Source code in src/nlls_gram/square_lm.py
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | |
nlls_gram.SquareSolveResult
dataclass
Result of SquareLevenbergMarquardt.solve.
residual_norm is the residual 2-norm at the returned x -- with the
default tolerances, status == LMStatus.CONVERGED holds exactly when it
is below atol, and a caller enforcing its own root criterion can check
it directly. aux is the residual's aux output at the returned x
(has_aux=True only). args and p are the values the solve was
called with, carried through for a uniform result interface with
LMSolveResult (the square solver has no callbacks, so they are exactly
the inputs; p participates in the implicit rule like the
underdetermined solver's).