Skip to content

Native TensorFlow trust-region minimizers (tf-trust-exact, tf-trust-ncg, tf-trust-krylov) - #153

Open
davidwalter2 wants to merge 5 commits into
WMass:mainfrom
davidwalter2:260826_tfMinimizer
Open

Native TensorFlow trust-region minimizers (tf-trust-exact, tf-trust-ncg, tf-trust-krylov)#153
davidwalter2 wants to merge 5 commits into
WMass:mainfrom
davidwalter2:260826_tfMinimizer

Conversation

@davidwalter2

Copy link
Copy Markdown
Collaborator

What

Three native TensorFlow implementations of the trust-region minimizers, selectable via --minimizerMethod, sharing one outer loop (rabbit/minimizer/base.py, mirroring scipy's _minimize_trust_region and plugging into the existing callback / early-stopping / restart / minimizer_result plumbing unchanged):

  • tf-trust-exact (exact.py): Moré–Sorensen, with H + λI, the Cholesky factorizations and the triangular solves on the TF device. No custom op for the LAPACK potrf failure index: tf.linalg.cholesky signals non-PD input by a NaN-filled factor on every backend, and a failed factorization already proves λ is a valid lower bound, so the safeguarded bracket update converges without it. The hard-case machinery stays on device too (inverse-iteration smallest-singular-value estimate) and its acceptance is guarded by an explicit model-value check.
  • tf-trust-ncg (krylov.py): Steihaug–Toint CG with the whole inner loop as a single tf.function while_loop — one graph dispatch per subproblem solve instead of one python round trip (assign, two numpy conversions, forced device sync) per Hessian-vector product. Hz is carried through the CG recurrence, so the boundary/negative-curvature exits and the outer loop's model value cost no extra HVPs (scipy spends 2–3 there).
  • tf-trust-krylov (gltr.py): GLTR (Gould–Lucidi–Roma–Toint, the trust-krylov algorithm). Lanczos with full CGS2 reorthogonalization on device against a fixed-shape basis buffer; the k×k tridiagonal trust-region subproblems (secular solve, hard case included) on the host where they cost microseconds. The Krylov data is radius-independent, so re-solves after rejected outer steps reuse it — usually zero new HVPs.

The preconditioner gains tf_transforms(): graph-side block T/Tᵀ applications, so the reparameterisation runs inside the compiled CG/Lanczos loops (once per HVP, where the numpy path cannot).

Validation

tests/test_native_minimizer.py (33 tests):

  • nearly-exact subproblem vs the exact optimum by eigendecomposition across PD/indefinite models and radii (worst case 96.6% of the optimal model reduction vs scipy trust-exact's 96.0%);
  • Steihaug-CG steps agree with scipy's CGSteihaugSubproblem to float precision (same algorithm, deterministic);
  • GLTR reaches ≥ 99.9% of the exact optimum where truncated CG has no guarantee, hard-case KKT check, and a test counting HVPs across a shrunken-radius re-solve;
  • full Fitter fits for all three methods match the trust-krylov reference, with and without --precondition.

Benchmarks (GTX 1080 Ti, fp64 at 1/32 rate — the least favorable GPU available)

2D Z in-situ fit (24k bins, 3603 nuisances), identical conditions, identical minimum 11249.6383:

  • tf-trust-krylov 1043 s vs scipy trust-krylov 1435 s (93 vs 96 outer iterations — the win is per-iteration subproblem cost, largest in the endgame where scipy's iterations reach ~200 s vs 67–102 s native).

Synthetic (analytic-Hessian objective, n=2000/4000): tf-trust-exact 5.7 s / 16.6 s vs scipy trust-exact 7.7 s / 24.7 s, identical minima; raw fp64 Cholesky on device 2–3× faster than 8-core LAPACK. On CPU-only nodes the native paths are slower than scipy (TF's CPU Cholesky kernel is single-threaded Eigen) — a warning is logged and the scipy methods remain the default; nothing changes unless a tf- method is selected.

Multi-GPU support is intentionally not part of this PR; it will come separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY

davidwalter2 and others added 5 commits August 26, 2026 11:07
…vice

New --minimizerMethod tf-trust-exact: a TensorFlow port of scipy's
trust-exact (More-Sorensen) where the Hessian, the H + lambda*I builds,
the Cholesky factorizations and the triangular solves stay on the TF
device, so the n x n matrix never round-trips to LAPACK per lambda
trial. The python outer loop mirrors scipy's _minimize_trust_region and
plugs into the existing callback / early-stopping / restart / result
plumbing unchanged, including the preconditioner (applied at the same
internal-coordinate boundary as for the scipy methods).

Differences from a line-for-line port, all deliberate:
- No custom op for the potrf failure index: tf.linalg.cholesky signals
  a non-PD input by filling the factor with NaNs on every backend, and
  a failed factorization already proves lambda_current is a valid lower
  bound, so the safeguarded bracket update converges without the
  index-accelerated bound (a few extra cheap factorizations at worst).
- The Hessian closure runs only on accepted steps; scipy's subproblem
  constructor consumes Hessian norms and therefore pays a Hessian per
  proposal.
- lambda_new is clamped to >= 0 (the interior-case Newton correction
  can otherwise push the bracket negative) and the matrix norms are
  computed as matrix norms -- tf.norm's default axis=None flattens the
  tensor and its max|H_ij| can sit below |lambda_min|, silently
  invalidating the lambda_ub bracket.
- The rare hard-case refinement downloads the factor once and reuses
  scipy's host-side smallest-singular-value estimate.

Validated against scipy on random PD/indefinite subproblems (worst-case
model reduction 96.7% of the exact optimum vs scipy's 96.0%, ~1.6
factorizations per solve), on Rosenbrock and an ill-conditioned
quadratic (identical minima to ~1e-8), and through a full Fitter fit on
the test tensor against the scipy path. Without a visible GPU a warning
points users back to scipy trust-exact: TF's CPU Cholesky kernel is
single-threaded Eigen and measured ~7x slower at n=2000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
The matrix-free counterpart of tf-trust-exact: the same truncated-CG
trust-region subproblem as scipy's trust-ncg (the practical stand-in
for trust-krylov, which solves the identical subproblem via GLTR), but
the entire CG iteration runs inside a single tf.function while_loop.
One solve costs one graph dispatch instead of one python round trip --
x assignment, numpy conversion both ways, forced device sync -- per
Hessian-vector product, which is what the scipy callback path pays and
what dominates when individual HVPs are fast.

Exact improvements over scipy's loop, with identical iterates:
- Hz is carried through the CG recurrence, so the boundary and
  negative-curvature exits price their steps from dot products where
  scipy spends two extra HVPs;
- the model value of the returned step falls out of the same
  bookkeeping and is handed to the outer loop, which otherwise needs
  one more HVP per proposal.

The preconditioner gains tf_transforms(): graph versions of the block
T/T^T applications (gather -> dense matvec -> scatter), because for an
HVP subproblem the reparameterisation runs inside the compiled loop,
once per CG iteration, where the numpy path cannot. The subproblem also
re-pins the fitter's parameter state before each solve, since the outer
loop's objective evaluations at proposed points move it in between.

Validated: steps agree with scipy's CGSteihaugSubproblem to float
precision across PD/indefinite models and radii (same algorithm, so
bitwise-deterministic, unlike the nearly-exact solver); Rosenbrock
matches scipy trust-ncg; full Fitter fits match the trust-krylov
reference with and without preconditioning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
The actual trust-krylov algorithm (Gould-Lucidi-Roma-Toint, the method
behind trlib/scipy trust-krylov), completing the native set. Where
Steihaug-CG stops at the first boundary crossing, GLTR keeps expanding
the Krylov subspace and returns the step that is optimal within it: the
Lanczos basis tridiagonalizes H, the projected subproblem
min gamma0*e1.h + h.T_k h/2, ||h|| <= Delta is solved per iteration by
eigendecomposition plus a safeguarded secular solve (hard case
included), and the full-space residual comes free as gamma_k |h_k|.

Division of labour: HVPs, the Lanczos recurrence and full CGS2
reorthogonalization run on the TF device through one compiled step
function against a fixed-shape [kmax, n] basis buffer (zero rows make
masking unnecessary, and fixed shapes mean no retracing); the k x k
tridiagonal solves run in numpy where they cost microseconds against
HVPs costing milliseconds. Boundary-phase residuals use a 10x tighter
tolerance than the interior forcing sequence, as in trlib.

The Krylov data is radius-independent, so the outer loop's
rejected-step path -- re-solving at a shrunken radius -- reuses it and
usually costs zero new HVPs, verified by a test counting them.

Validated: subproblem reaches >= 99.9% of the exact-optimum model
reduction on problems the subspace exhausts (PD and indefinite, all
radii) where truncated CG has no such guarantee; explicit hard-case
KKT check; Rosenbrock matches scipy; full Fitter fits match the
trust-krylov reference with and without preconditioning (32 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
Fixes the two issues the first GPU run surfaced:

- The interior branch estimated the smallest singular value with the
  host-side Cline et al. recurrence, downloading the full factor per
  call -- ~1 s per outer iteration at n=4000 over PCIe, erasing the
  cheap on-device factorizations. Replaced by inverse iteration on
  L L^T: a handful of O(n^2) triangular solves on device, with only two
  scalars and one n-vector crossing to the host. Inverse iteration
  converges fastest exactly in the near-singular regime the hard case
  lives in; far from singularity it only gives an upper bound on
  sigma_min, which every use is safe against (lambda_lb just gets
  looser, and acceptance is guarded, below).

- The hard-case acceptance trusted the s_min estimate outright, so an
  inaccurate estimate could accept a corrected step that raises the
  model; the outer loop then rejects it, which showed up as a doubled
  outer iteration count under GPU rounding at n=2000. Both hard-case
  exits now verify the candidate against the model value (one matvec)
  and fall through to the normal lambda updates when it fails.

Subproblem quality scan is unchanged (worst case 96.6% of the exact
optimum over 60 PD/indefinite cases vs scipy's 96.0%, ~1.5
factorizations per solve); new unit test pins the device estimator to
the true sigma_min in the near-singular regime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
A proposal whose objective overflows to NaN produced a NaN rho, and
IEEE comparisons on NaN are all False: the radius was neither shrunk
nor the step accepted, so the outer loop spun at a fixed radius until
early stopping gave up far from the minimum. First observed on the
first preconditioned run at scale, where an internal-coordinate step of
norm 1 is an enormous physical step whose exponentials overflow. A
non-finite rho now counts as a hard rejection (radius shrinks), with a
regression test whose objective is NaN outside a box around the
minimum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant