Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

argminax: design details

Core types, algorithms, JAX notes, tests and the code survey behind the argminax design.

Core types

Four modules, each a thin layer on optimistix, lineax or gaussx. Every solver is an optimistix solver and every adjoint an optimistix adjoint, so optx.minimise and optx.least_squares run them unchanged.

# Shapes: N state size; M residuals; K probes or CG steps; n draws
from collections.abc import Callable

import equinox as eqx
import gaussx as gx
import lineax as lx
import optimistix as optx
from jaxtyping import Array, Float, PRNGKeyArray


# proposed (argminax.solve)
# (JᵀJ + λI) δ = −Jᵀr, by CG on δ ↦ Jᵀ(Jδ) + λδ; JᵀJ is never formed
class GaussNewtonCG(optx.AbstractGaussNewton):
    rtol: float
    atol: float
    norm: Callable = eqx.field(static=True, default=optx.max_norm)
    damping: float = 0.0  # λ > 0: Levenberg–Marquardt
    max_cg_steps: int = eqx.field(static=True, default=50)
    descent: optx.AbstractDescent = ...  # NewtonDescent(linear_solver=lx.CG)
    search: optx.AbstractSearch = ...  # LearningRate(1.0) or a trust region
    verbose: frozenset[str] = eqx.field(static=True, default=frozenset())


# proposed (argminax.solve)
# (H B Hᵀ + R) λ = d, then δx̂ = B Hᵀ λ: the solve in observation space
def dual_solve(
    jvp: Callable,  # δx ↦ H δx      (N,) → (M,)
    vjp: Callable,  # δy ↦ Hᵀ δy     (M,) → (N,)
    background_cov: lx.AbstractLinearOperator,  # B               (N, N), PSD
    obs_cov: lx.AbstractLinearOperator,  # R               (M, M), PSD
    innovation: Float[Array, " M"],  # d = y − H(x_b)
    *,
    solver: lx.AbstractLinearSolver,
) -> Float[Array, " N"]: ...  # δx̂


# proposed (argminax.curvature)
# the Gaussian at the minimum, N(x̂, Ŝ) with Ŝ = Ĥ⁻¹
class Laplace(eqx.Module):
    mode: Float[Array, " N"]  # x̂
    precision: lx.AbstractLinearOperator  # Ĥ  (N, N), PSD-tagged
    solver: gx.AbstractSolverStrategy = gx.DenseSolver()

    def covariance(self) -> lx.AbstractLinearOperator: ...  # Ŝ, lazy (N, N)
    def logdet_precision(self) -> Float[Array, ""]: ...  # log |Ĥ|
    def sample(self, key: PRNGKeyArray, n: int) -> Float[Array, "n N"]: ...


# proposed (argminax.curvature)
# Ĥ = JᵀJ at x̂ for whitened residuals r(x)
def gauss_newton(residuals: Callable, mode, args) -> Laplace: ...


# proposed (argminax.diagnose)
# a trace and its Monte Carlo standard error (0 when exact)
class TraceEstimate(eqx.Module):
    value: Float[Array, ""]
    stderr: Float[Array, ""]


# proposed (argminax.diagnose)
# A = I − Ŝ Sₐ⁻¹, lazy;  d_s = tr(A): exact for small N, Hutchinson above
def averaging_kernel(posterior: Laplace, prior_cov) -> lx.AbstractLinearOperator: ...
def degrees_of_freedom(
    kernel: lx.AbstractLinearOperator,
    *,
    key: PRNGKeyArray | None = None,  # None: exact trace
    num_probes: int = 32,  # K
) -> TraceEstimate: ...


# proposed (argminax.adjoint)
# ∂x̂/∂θ through the last k iterations; x_{T−k} under stop_gradient
class KStep(optx.AbstractAdjoint):
    k: int = eqx.field(static=True, default=1)

    def apply(self, primal_fn, rewrite_fn, inputs, tags): ...


# proposed (argminax.adjoint)
# k = 1 (Bolte, Pauwels and Vaiter, 2023)
class OneStep(KStep): ...


# proposed (argminax.adjoint)
# pipekit-cycle spec → optimistix adjoint, matched by class name;
# pipekit-cycle (Core tier 2) is never imported
def to_optimistix_adjoint(spec) -> optx.AbstractAdjoint: ...
TypeHoldsInvariants
GaussNewtonCGtolerances, norm, damping λ, CG budget, descent, searchan optx.AbstractGaussNewton; never materialises JᵀJ; λ = 0 is Gauss–Newton, λ > 0 Levenberg–Marquardt
dual_solve(a function)B and R PSD-tagged; on a linear H its δx̂ equals the primal Gauss–Newton step to the CG tolerance
Laplacex̂, Ĥ, a gaussx solver strategyĤ is a PSD-tagged lineax operator; Ŝ is formed only on request; sampling takes an explicit key
TraceEstimatevalue, standard errorstderr = 0 exactly when the trace was computed exactly
KStep, OneStepkapply implemented; the forward value equals the plain solve; gradients flow through k solver steps
Implicita linear solveroptx.ImplicitAdjoint with CG on the curvature as default; valid only at a converged minimum
to_optimistix_adjoint(a function)Implicit → Implicit, Truncated(k) → KStep(k), RecursiveCheckpoint → optx.RecursiveCheckpointAdjoint; Direct and Backsolve raise, being integrix’s

amx.adjoint.Implicit earns its name only if its default differs from optx.ImplicitAdjoint (CG with a PSD tag instead of a dense LU); otherwise users pass optx.ImplicitAdjoint(linear_solver=lx.CG(...)) and argminax adds no name.

Types to adhere to

Algorithms

Each algorithm below is written in whitened coordinates ξ, where the prior is N(0, I) and r(ξ) stacks the measurement and prior residuals, so J(ξ) = ½ ‖r(ξ)‖² and Ĥ = JᵀJ. The Jacobian J here is ∂r/∂ξ, not the cost.

1. Gauss–Newton and Levenberg–Marquardt with an inner CG

(Jk⊤Jk+λkI)δk=−Jk⊤r(ξk)(J_k^\top J_k + \lambda_k I) \delta_k = -J_k^\top r(\xi_k)
ξk+1=ξk+αkδk\xi_{k+1} = \xi_k + \alpha_k \delta_k
  1. Linearise r at ξₖ with jax.linearize, which gives v ↦ Jₖv; transpose it with jax.linear_transpose for w ↦ Jₖᵀw.

  2. Wrap v ↦ Jₖᵀ(Jₖv) + λₖv as an lx.FunctionLinearOperator tagged PSD and solve with lx.CG, capped at max_cg_steps.

  3. Take the step with the search: αₖ = 1 for Gauss–Newton, or a trust region (optx.ClassicalTrustRegion) that updates λₖ from the ratio of actual to predicted decrease.

  4. Stop when the optimistix termination test passes (step and cost change within rtol and atol in norm).

Cost. Per outer step, one linearisation and K CG iterations of one JVP and one VJP each, so about 2K forward-model evaluations; memory O(N + M) plus the linearisation’s stored primals.

Pitfalls.

2. The dual (observation-space) solve

(HBH⊤+R)λ=d(H B H^\top + R) \lambda = d
δx^=BH⊤λ\delta \hat x = B H^\top \lambda
  1. Form the innovation d = y − H(x_b) at the background state.

  2. Solve for λ by CG in observation space on the PSD operator v ↦ H(B(Hᵀv)) + Rv, preconditioned by R⁻¹.

  3. Map back: δx̂ = B Hᵀ λ.

  4. For a nonlinear H, relinearise at x_b + δx̂ and repeat: the incremental (outer-loop) form.

Cost. K CG steps, each one JVP, one VJP and one product with B and with R; memory O(M). It pays when M ≪ N, as for a few hundred spectral channels against a gridded state.

Pitfalls.

3. Laplace covariance and sampling

S^=H^−1=(J⊤J)−1\hat S = \hat H^{-1} = (J^\top J)^{-1}
δ=H^−1J⊤z for z∼N(0,IM+N)\delta = \hat H^{-1} J^\top z \text{ for } z \sim N(0, I_{M+N})
  1. At ξ̂, build Ĥ as a matrix-free operator (step 2 of algorithm 1 with λ = 0), or densely with jax.jacrev and gx.cholesky when N is small (up to a few thousand).

  2. Read marginal variances from the Cholesky factor when dense; when matrix-free, estimate the diagonal of Ŝ by probing, or use a Lanczos low-rank form.

  3. Sample densely as ξ̂ + L⁻ᵀz with Ĥ = LLᵀ. Matrix-free, sample by perturbation: draw z, solve Ĥδ = Jᵀz by CG. Then Cov(δ) = Ĥ⁻¹ JᵀJ Ĥ⁻¹ = Ĥ⁻¹, exact because the prior rows are part of J.

  4. Map back to the physical state: Pₓ = Lₐ Ŝ Lₐᵀ, with Sₐ = LₐLₐᵀ (plumax’s P_S = L P_χ Lᵀ).

Cost. Dense: one O(N³) factorisation, then O(N²) per sample. Matrix-free: one CG solve per sample or per probe.

Pitfalls.

4. Averaging kernel, degrees of freedom and information content

A=I−S^Sa−1A = I - \hat S S_a^{-1}
Aξ=I−H^ξ−1A_\xi = I - \hat H_\xi^{-1}
ds=tr⁡(A)=∑iσi21+σi2d_s = \operatorname{tr}(A) = \sum_i \frac{\sigma_i^2}{1 + \sigma_i^2}
Hinfo=12log⁡∣H^ξ∣=12∑ilog⁡(1+σi2)H_{\mathrm{info}} = \frac{1}{2} \log |\hat H_\xi| = \frac{1}{2} \sum_i \log(1 + \sigma_i^2)

Here σᵢ are the singular values of the whitened measurement Jacobian K̃ = Sε^(−1/2) K Sₐ^(1/2), so Ĥ_ξ = I + K̃ᵀK̃ Rodgers, 2000, chapter 2.

  1. Exact path (small N): an SVD of K̃ or an eigh of Ĥ_ξ gives A, d_s and H_info at once.

  2. Large N: d_s = N − tr(Ĥ_ξ⁻¹). Estimate the trace by Hutchinson with K Rademacher probes zₖ drawn from a JAX key, each a CG solve with Ĥ_ξ: tr(Ĥ⁻¹) ≈ (1/K) Σ zₖᵀĤ⁻¹zₖ.

  3. Report the standard error, the sample standard deviation of zₖᵀĤ⁻¹zₖ over √K, in the TraceEstimate.

  4. The averaging kernel itself stays lazy: A v = v − Ĥ⁻¹v in whitened space; rows of A are formed only on request.

Cost. Exact: O(MN²) for the SVD. Hutchinson: K CG solves, independent of N in count.

Pitfalls.

5. The evidence at the minimum

log⁡p(y)≈−J(ξ^)−12log⁡∣H^ξ∣−12log⁡∣2πSε∣\log p(y) \approx -J(\hat \xi) - \frac{1}{2} \log |\hat H_\xi| - \frac{1}{2} \log |2 \pi S_\varepsilon|
  1. J(ξ̂) comes from the solve; log |Ĥ_ξ| from gx.logdet (dense) or stochastic Lanczos quadrature (matfree, as pyrox-lgm’s PC priors use).

  2. For a prior precision λ (pyrox’s last-layer Laplace), maximise the evidence over log λ: a 1-D problem, cheap once the GGN’s eigenvalues are known, since log |Ĥ| = Σ log(gᵢ + λ).

Cost. Dense: the factorisation already made for Ŝ. Matrix-free: K Lanczos runs of a few tens of steps each.

Pitfalls.

6. Damped Newton for a Gaussian prior and a factorised likelihood

The loop inside gaussx’s laplace_mode, pyrox-gp’s LaplaceInference and GaussNewtonInference, and pyrox-lgm’s inner fit. With prior N(m, K), per-site gradient gₜ and curvature Wₜ = max(−∇²log p, floor):

f~t=(K−1+Wt)−1(Wtft+gt+K−1m)\tilde f_t = (K^{-1} + W_t)^{-1} (W_t f_t + g_t + K^{-1} m)
ft+1=ft+η(f~t−ft)f_{t+1} = f_t + \eta (\tilde f_t - f_t)
  1. Per-site gₜ and Wₜ by vmap of grad and second derivative of log p(yᵢ | fᵢ).

  2. Floor Wₜ (pyrox-gp’s precision_floor, 1e-3 for Gauss–Newton) so the step stays PSD for non-log-concave likelihoods such as Student-t.

  3. One step’s algebra is gx.newton_update; argminax owns the loop: lax.while_loop on ‖fₜ₊₁ − fₜ‖∞ < tol, with damping η ∈ (0, 1].

  4. Make the mode implicit for gradients (gaussx’s custom_root), so a VJP costs one solve with K⁻¹ + W, not a pass through the iterations.

Cost. Per iteration one solve with K⁻¹ + W (O(N³) dense, less for a GMRF or a state-space prior); typically 5–20 iterations.

Pitfalls.

7. The implicit adjoint

∂ξ^∂θ=−(∇ξξ2J)−1∇ξθ2J\frac{\partial \hat \xi}{\partial \theta} = -(\nabla^2_{\xi \xi} J)^{-1} \nabla^2_{\xi \theta} J
  1. Solve forward as usual; optimistix records the optimality condition ∇ξJ(ξ̂, θ) = 0.

  2. On the backward pass, solve (∇²ξξJ)ᵀ w = ḡ for the incoming cotangent ḡ with the adjoint’s linear solver (CG for argminax’s default).

  3. Pull −w through ∇²ξθJ by one VJP to get the cotangent of θ.

Cost. One linear solve (K Hessian-vector products) per VJP, whatever the number of forward iterations; no forward iterates stored.

Pitfalls.

8. k-step and one-step adjoints

ξ^=Φk(ξˉ,θ) with ξˉ=sg(ξT−k)\hat \xi = \Phi^{k}(\bar \xi, \theta) \text{ with } \bar \xi = \mathrm{sg}(\xi_{T-k})

Here Φ is one solver step and sg is stop_gradient. For a Newton-like Φ, ∂Φ/∂ξ → 0 at the minimum, so the one-step derivative is first-order exact there Bolte et al., 2023.

  1. In KStep.apply, run the solve to the end under stop_gradient, keeping the last k + 1 (iterate, solver state) pairs in a ring buffer in the loop carry, so the iterate and state at step T − k are at hand when the loop stops at a T known only then.

  2. From that buffered iterate and its state, replay the final k solver.step calls, differentiably in θ.

  3. Return the replayed iterate. The replay repeats the solve’s own last k steps, so its value is the plain solve’s ξ_T and needs no straight-through correction.

Taking k extra steps from ξ_T instead is a different estimator whenever ξ_T is not an exact fixed point, and a straight-through value + (Φᵏ − sg(Φᵏ)) keeps the value but not the gradient. Gradient descent on f(x, θ) = θx²/2 with unit step, x₀ = 1, θ = 0.5, T = 2 and k = 1 shows it: ξₜ₊₁ = (1 − θ) ξₜ, so the replayed last step gives ∂ξ_T/∂θ = −ξ₁ = −0.5, while an extra step from ξ₂ gives −ξ₂ = −0.25.

Cost. k solver steps of compute for the replay, and memory for k + 1 buffered iterates and states; no linear solve, and no second forward solve.

Pitfalls.

9. Positivity and bounds by reparametrisation

x=g(u)x = g(u)
G=diag⁡(g′(u^))G = \operatorname{diag}(g'(\hat u))
S^x≈GS^uG⊤\hat S_x \approx G \hat S_u G^\top
  1. Solve, take curvature and diagnose in u, with g = softplus or exp and the prior defined in u.

  2. Report Ŝᵤ, or push it forward linearly; better, sample in u and map each draw through g.

Pitfalls.

JAX compatibility

NamejitvmapgradNotes
GaussNewtonCG (under optx.least_squares)yesyesthrough the adjointneeds Jₖv: forward mode, or the transposed VJP for a custom_vjp model
dual_solveyesyesyeslineax CG is differentiable; the CG budget is static
gauss_newton, Laplaceyesyesyesthe dense path fixes N at trace time; sample takes a key
averaging_kernelyesyesyeslazy; rows formed only on request
degrees_of_freedomyesyesyesexact path static on N; Hutchinson needs a key, split per pixel under vmap
damped Newton loopyesyesimplicitlax.while_loop; the mode is a custom_root
Implicityesyesyesvalid only at a converged minimum
KStep, OneStepyesyesyesforward value unchanged; gradient from k steps

Testing

External dependencies

LibraryUsed forConstraint
optimistixsolver and adjoint protocols, minimise, least_squares, ImplicitAdjoint, RecursiveCheckpointAdjoint, trust regionspin the minor version: KStep.apply reads its private inputs layout
lineaxoperators, tags, CGoperators carry positive_semidefinite_tag
gaussxsolves, logdet, cholesky, inv, symmetrize, newton_update, gauss_newton_precisionpublic API only; gaussx never imports argminax
equinox, jax, jaxtypingmodules, transforms, shape annotations—
matfreestochastic Lanczos quadrature for log-determinantsoptional extra, imported lazily
pipekit-cyclenothing: to_optimistix_adjoint matches its adjoint specs by class namenever imported; it sits at Core tier 2, above argminax
SciPyreference solutionstests only

Where it stands

Read on 5 Nov 2026 at geostack’s submodule pins, except where noted.

CodeCommitWhat it does todayMoves to
vardax adjoints/k_step.py KStepAdjoint, one_step.py OneStepAdjoint90839c2optx.AbstractAdjoint subclasses with k: int = 1, but apply() raises NotImplementedError: they are markers that vardax’s _src/solver.py dispatches on with isinstanceamx.adjoint.KStep, OneStep, with apply implemented
vardax adjoints/mapping.py to_optimistix_adjoint90839c2Implicit → optx.ImplicitAdjoint, RecursiveCheckpoint → optx.RecursiveCheckpointAdjoint, Truncated(k) → KStepAdjoint(k); Direct, Backsolve raiseamx.adjoint.to_optimistix_adjoint
vardax posterior/gauss_newton.py GaussNewtonHessian, posterior/laplace.py LaplaceCovariance90839c2HᵀR⁻¹H + B⁻¹ from obs_op.linearize (n_krylov=50); the Posterior container and PosteriorAdapter protocol stayamx.curvature
radtrax retrieval/var3d/solve.pyed04971run_lbfgs; run_gauss_newton, a thin optx.GaussNewton wrapper with throw=False; run_dual_psas with a scalar R⁻¹. All return a NumPy SolveResult, so no retrieval is differentiable todayamx.solve (decision 6)
radtrax retrieval/var3d/diagnostics.pyed04971reduced_chi_squared; degrees_of_freedom_for_signal, a Hutchinson estimate (32 probes) on np.random.default_rng; posterior_covariance_proxy, a CG matvec. No averaging-kernel function: A appears only in docstringsamx.diagnose
plumax assimilation/solve.py28f257erun_lbfgs, run_gauss_newton, run_dual_psas; run_gauss_newton is identical to radtrax’s (empty diff)deleted
plumax les_fvm/fourdvar.py28f257esolve_4dvar by L-BFGS in whitened space; _gauss_newton_hessian dense by jacrev (diffrax’s custom_vjp rejects jacfwd); posterior_covariance by gx.inv and symmetrise, P_S = L P_χ Lᵀ; laplace_sampleamx.curvature; plumax keeps the 4D-Var cost
gaussx _inference/_laplace.py laplace_mode7fa1a38damped Newton loop, mode implicit through jax.lax.custom_root, tolerance floored at 64 eps; newton_update and gauss_newton_precision (a LowRankUpdate) are the single steps; Chandrupatla is already an optimistix root finderlaplace_mode to amx.solve, deprecated in gaussx without forwarding; the steps and Chandrupatla stay
pyrox-gp _inference_nongauss.py94eef13LaplaceInference, GaussNewtonInference (precision_floor=1e-3, damping) as eager Python loops with break; QuasiNewtonInference by optax.lbfgs in a Python loop; hand-written cho_solve. PosteriorLinearization and EP are not Newton methods and staythe loops to amx.solve
pyrox-lgm _inla.py94eef13inner fit by gx.laplace_mode; outer _theta_mode by optax.lbfgs in a Python loop with host float checks; R-INLA’s grid / CCD design staysthe inner fit (amx.solve.laplace_mode) and the outer search to amx.solve
pyrox inference/_laplace.py LastLayerLaplace, last_layer_laplace2ac68a5 (pyrox main, 24 Oct 2026; not at geostack’s pin 94eef13)dense J by forward mode; GGN; diagonal or dense precision; log_marginal_likelihood; optimize_prior_precision on a 4097-point log λ grid, then golden sectionamx.curvature, amx.diagnose, after pyrox#332

Two facts set the order of work. vardax’s adjoints are markers rather than working optimistix adjoints, so wave 1 must implement apply, not just move the files. And every 3D-Var retrieval leaves JAX at its last line, so the solvers and diagnostics need to return arrays and use keys before a plume model can differentiate through them.

References
  1. Rodgers, C. D. (2000). Inverse Methods for Atmospheric Sounding: Theory and Practice. World Scientific.
  2. Bolte, J., Pauwels, E., & Vaiter, S. (2023). One-step differentiation of iterative algorithms. Advances in Neural Information Processing Systems, 36.