Second-order variants

Every mechanism in this package controls the radius by comparing it against a criticality measure. The first-order measure is ‖g_k‖, which vanishes at every critical point — minimisers, saddles and maxima alike. The second-order measure is

\[\tau_k = \max\bigl\{\,\|g_k\|,\; -\lambda_{\min}(B_k)\,\bigr\},\]

which vanishes only where the model is genuinely second-order critical.

Replacing one by the other is the whole of the upgrade: the update logic does not change. That is why SecondOrder is a wrapper rather than a new family of rules — it forwards update_radius! to the rule it wraps and overrides only criticality.

using TrustRegionRadius, ADNLPModels

nlp = ADNLPModel(p -> p[1]^4 - p[1]^3 + (0.25 - p[1]/2)*p[2]^2 + p[2]^4/4, [-0.5, 0.6])
stats = tr_solve(nlp;
    rule      = RGradTau(μ = 1.0),        # Δ_k = μ_k τ_k, μ updated exactly as in RGrad
    model     = ExactHessian(),           # must be able to report negative curvature
    subsolver = EigenPoint(SteihaugCG()), # must be able to exploit it
    params    = TRParams(tol = 1e-8, tol_H = 1e-6),
    trace     = true)
(stats.status, stats.solution)
(:second_order, [-5.193891395132776e-5, 9.32151241473877e-14])

The three pieces, and why all three are needed

A second-order run needs a measure that stays positive at a saddle, a model that can see the negative curvature, and a solver that can move along it. Any one missing and the run silently degrades to a first-order one.

piecewhat it supplieswhat happens without it
SecondOrder / τa positive radius near a saddlethe radius collapses; see below
ExactHessian, SR1Modelλ_min < 0 when it existsτ ≡ ‖g‖, an expensive no-op — the solver now warns
EigenPoint, ExactMSa step along the negative directionpositive radius, nowhere to go
tol_H > 0refusal to stop at a saddle:first_order reported at a saddle

What τ repairs

At an exact saddle ‖g‖ = 0 while λ_min < 0, so a ‖g‖-anchored rule reports the radius it would use at a solution. The two anchored families then fail differently:

  • RGrad(‖g‖) halts outright. Δ_k = μ_k‖g_k‖ = 0, so every subsequent step is zero, ρ is NaN, and no amount of negative curvature in the model can be used.
  • RDFO(‖g‖) contracts geometrically. The test Δ_k > ζ‖g_k‖ = 0 succeeds at every iteration, so the radius is multiplied by γ₂ < 1 for ever and reaches the stagnation floor in O(log(1/eps)) iterations.

Both are repaired by τ, which equals -λ_min > 0 there and holds the radius at the scale of the curvature actually available.

λ, gn = -1.0, 0.0                    # an exact saddle
r  = RGrad(μ = 1.0)
rτ = RGradTau(μ = 1.0)
(initial_radius(r,  1.0, criticality(r,  gn, λ)),    # dead
 initial_radius(rτ, 1.0, criticality(rτ, gn, λ)))    # alive
(0.0, 1.0)

Stopping

tol_H adds the second-order test to the stopping rule: the run stops when ‖g‖ ≤ tol and λ_min(B) ≥ -tol_H, and reports :second_order. It is disabled by default (tol_H = -1), so nothing changes for a first-order run and no curvature estimate is paid for.

Note what the status does and does not claim. :second_order is a statement about the model Hessian, so it is a statement about f only insofar as the model reports the curvature of f — exactly for ExactHessian, asymptotically for SR1Model under coherence, and never for a model constrained positive definite.

τ ≡ ‖g‖ over a positive definite model

LBFGSModel enforces B ≻ 0, so λ_min > 0 always, so τ = ‖g‖ identically and the second-order variant is an expensive no-op that will nonetheless report :second_order at a saddle. The same holds for ScaledIdentity and SPDTarget. Pair SecondOrder with ExactHessian or SR1Model, and read :lambda_min_trajectory to confirm the model ever reported negative curvature at all. This is declared through reports_negative_curvature and the solver emits a warning once per run when the two are paired, so the no-op is not silent.

Why the subsolver matters

τ-anchoring keeps the radius positive; it does not make the step go anywhere.

SteihaugCG stops at the first direction of negative curvature and runs to the boundary along it. That is a real decrease, but the direction is whichever the CG recurrence happened to reach, and the guaranteed decrease carries no |λ_min|Δ² term. EigenPoint wraps it, and when λ_min < 0 compares the inner step against the eigenpoint ±Δ v_min, which decreases the model by at least ½|λ_min|Δ², keeping whichever is better. ExactMS solves the subproblem exactly, hard case included, so it satisfies the condition unwrapped.

This is what makes the O(max{ε_g^{-2}, ε_H^{-3}}) complexity bound attainable in practice rather than only in principle.

Curvature estimation

lambda_min_estimate uses a dense symmetric eigendecomposition for n ≤ nmax (free when the subsolver is already ExactMS) and Lanczos with full reorthogonalisation above it. curvature_estimate is the type-stable form the solver calls: it always returns a (λ, v) pair and hands it to the subsolver through curv, so EigenPoint no longer recomputes an eigendecomposition the solver has just done.

The Lanczos value is an upper bound

A Ritz value satisfies λ_Ritz ≥ λ_min, so an under-resolved estimate understates the negative curvature. In τ that biases the measure down and makes the second-order test optimistic — it can report :second_order at a saddle whose negative direction Lanczos has not yet found. Raise lanczos_k when the status matters; the dense branch is exact.

Trace

With trace = true and a curvature estimate in play, two further trajectories appear:

ss = stats.solver_specific
(length(ss[:lambda_min_trajectory]),   # λ_min(B_k)
 length(ss[:tau_trajectory]))          # τ_k, the measure the rule actually saw
(15, 15)

Comparing :tau_trajectory against :grad_trajectory is the cheapest second-order diagnostic available: wherever τ > ‖g‖, the run is somewhere the gradient alone calls critical and the curvature does not.

API

TrustRegionRadius.SecondOrder — Type
SecondOrder(rule)

The second-order variant of a criticality-anchored rule: identical update logic, with the radius anchored to τ = max{‖g‖, −λ_min(B)} instead of ‖g‖.

SecondOrder(RGrad(μ = 1.0))        # Δ_k = μ_k τ_k, μ updated exactly as before
SecondOrder(RDFO(ζ = 1.0))         # expands while Δ_k ≤ ζ τ_k

Only rules that actually read the criticality measure may be wrapped; wrapping RDelta or RStep would change nothing, since they never consult it, so it raises ArgumentError rather than producing a silent no-op.

What the upgrade buys

At a first-order critical point that is not a minimiser, ‖g‖ = 0 while λ_min < 0. A rule anchored to ‖g‖ therefore reports the radius it would use at a solution, and the two anchored families fail in different ways:

  • RGrad(‖g‖) sets Δ_k = μ_k‖g_k‖ = 0 and halts outright — every subsequent step is zero, ρ is NaN, and no descent direction can be taken however much negative curvature the model reports;
  • RDFO(‖g‖) finds Δ_k > ζ‖g_k‖ = 0 at every iteration and contracts geometrically, so the radius reaches the stagnation floor in O(log(1/eps)) iterations.

Under τ both are repaired: τ_k = -λ_min > 0 near such a point, the radius stays proportional to the curvature available, and a subsolver that exploits negative curvature escapes. This is the sharpest experiment in the second-order half of the survey, and it is two lines to run — see the docstring of EigenPoint for the other half of what makes it work.

τ ≡ ‖g‖ for a positive definite model

LBFGSModel enforces B ≻ 0, so λ_min > 0 always, so τ = ‖g‖ identically and SecondOrder is an expensive no-op. The same holds for ScaledIdentity, SPDTarget and the outer-product models. A second-order variant is only meaningful over a model that can report negative curvature — ExactHessian or SR1Model.

This is now enforced rather than described: reports_negative_curvature is a trait on ModelHessian, and TRSolver warns once at construction when a τ-anchored rule or tol_H > 0 meets a model that cannot supply the information. The previous docstring claimed the solver warned; nothing did.

Fields

The wrapped rule's fields are forwarded, so SecondOrder(RGrad(μ = 2.0)).μ works and r.inner reaches the rule itself.

source
TrustRegionRadius.RGradTau — Function
RGradTau(; μ = 1.0, γ1, γ2, γ3, half_test)

Δ_k = μ_k τ_k with μ updated exactly as in RGrad: the four branches of Update (R-grad), the ‖s_k‖ > ½Δ_k guard on expansion, no cap.

Shorthand for SecondOrder(RGrad(...)). Since μ_k is now the ratio Δ_k/τ_k rather than Δ_k/‖g_k‖, the geometric search it performs is a search for the second-order inactivity threshold.

source
TrustRegionRadius.RGradCappedTau — Function
RGradCappedTau(; μ, μ_max, γ1, γ2, γ3, half_test)

RGradCapped anchored to τ. The cap carries the same warning as in the first-order case, now against the second-order threshold: μ_max below it leaves the trust region binding for ever.

source
TrustRegionRadius.RDFOTau — Function
RDFOTau(; ζ = 1.0, γ1, γ2, γ3, Δmin, Δmax)

RDFO anchored to τ: contract on an unsuccessful iteration, otherwise shrink when Δ_k > ζ τ_k and expand when Δ_k ≤ ζ τ_k.

source
TrustRegionRadius.criticality — Function
criticality(rule, g_norm, λmin) -> Float64

The quantity rule measures its radius against. ‖g‖ by default; τ for a rule wrapped in SecondOrder.

The solver evaluates this once per iteration and passes the result to initial_radius and update_radius! in place of the gradient norm. A rule that ignores criticality altogether — RDelta, RStep — never sees the difference.

source
TrustRegionRadius.needs_curvature — Function
needs_curvature(rule) -> Bool

Whether rule needs λ_min(B_k), and so whether the solver should pay for a curvature estimate at every iteration.

false by default. The estimate costs a dense symmetric eigendecomposition on small problems and a short Lanczos run otherwise, so it is not levied unless a rule, the second-order stopping test, or EigenPoint asks for it.

source
TrustRegionRadius.tau_criticality — Function
tau_criticality(g_norm, λmin) -> Float64

The second-order criticality measure

\[τ = \max\{\,\|g\|,\; -\lambda_{\min}(B)\,\} = \max\{\|g\|, -\lambda^-\}, \qquad \lambda^- = \min\{\lambda_{\min}(B),\,0\}.\]

τ = 0 exactly when g = 0 and B ⪰ 0, i.e. at a second-order critical point of the model. Positive curvature never enlarges it: λmin > 0 contributes -λ^- = 0 and the measure falls back to ‖g‖.

source
TrustRegionRadius.lambda_min_estimate — Function
lambda_min_estimate(model, nlp, x; nmax = 200, lanczos_k = 40, vector = false)

Smallest eigenvalue of the model Hessian B(x), returned as a Float64, or the pair (λ, v) when vector = true.

Two regimes:

  • n ≤ nmax: a dense symmetric eigendecomposition of dense_hessian. Exact.
  • n > nmax: lanczos_k steps of Lanczos with full reorthogonalisation against hessian_op, returning the smallest Ritz value and its Ritz vector.

Prefer curvature_estimate inside the solver: it has a single return type, whereas this returns either a scalar or a tuple depending on a keyword.

The Lanczos value is an upper bound

A Ritz value satisfies λ_Ritz ≥ λ_min, so an under-resolved run understates the available negative curvature. In τ = max{‖g‖, −λ_min} that biases the measure downward and makes the second-order test optimistic — it can report second-order criticality at a saddle whose negative direction Lanczos has not yet found. Raise lanczos_k when the second-order status matters; the estimate is exact in the dense branch.

source
TrustRegionRadius.model_hessian_norm — Function
model_hessian_norm(model, nlp, x; nmax = 200, lanczos_k = 40) -> Float64

‖H_k‖, the operator norm of the model Hessian at x.

This is the quantity the majorant of Part I is built from: M_k = L + max_{i≤k} ‖H_i‖, against which the radius summability of Part II is stated, Σ_k Δ_k²/M_k < ∞. Reporting Σ_k Δ_k² instead is equivalent only while {‖H_k‖} is bounded, which is exactly the hypothesis the weaker form was introduced to drop, so on a quasi-Newton model whose curvature grows the two statements are different.

Dense and exact for n ≤ nmax; lanczos_k Lanczos steps otherwise, taking max(|θ_min|, |θ_max|) over the Ritz values. Returns NaN rather than throwing when the model cannot supply a Hessian at x: this is a diagnostic and must not be able to fail a run.

The large-n branch shares its recurrence with lambda_min_estimate — lanczos_k means the same thing in both, and the two ends of the spectrum come off the same tridiagonal.

It was a power iteration, and on a clustered spectrum it did not converge

The branch previously ran a fixed thirty steps of power iteration on H² with no convergence test. The per-step contraction is (λ₂/λ₁)², so on a clustered spectrum it removes almost nothing. Measured on eigenvalues {i/25 : i = 1…250}, where (λ₂/λ₁)² = 0.992:

methodmatrix-vector productsrelative error
power iteration, 30 steps606.5e-03
power iteration, 300 steps6003.3e-05
power iteration, 3000 steps60000
Lanczos, k = 40402.1e-05
Lanczos, k = 60609.5e-09

Raising the fixed count and adding a convergence test were both considered; neither is worth doing to the wrong algorithm. At equal cost Lanczos is nearly six orders of magnitude more accurate here, and on a well-separated spectrum both are exact, so nothing is given up.

The error remains one-sided: Ritz values interlace the spectrum, so ‖H_k‖ is understated, M_k = L + max_i‖H_i‖ is understated, and Σ_k Δ_k²/M_k is overstated. That is the safe direction for a claim that the series is finite and the wrong one for a reported value. Raise lanczos_k, or force the dense branch with nmax, before quoting a number from a large problem.

source
TrustRegionRadius.curvature_estimate — Function
curvature_estimate(model, nlp, x, want_vector; nmax, lanczos_k)
    -> (λ::Float64, v::Vector{Float64})

Type-stable wrapper around lambda_min_estimate: always returns a pair, with an empty v when the eigenvector was not requested.

This is what the solver calls, once per iteration, and what it hands to solve_subproblem! through the curv keyword. EigenPoint previously computed its own estimate on top of the solver's, so a τ-anchored run with EigenPoint(SteihaugCG()) paid for two dense eigendecompositions per iteration.

source
TrustRegionRadius.EigenPoint — Type
EigenPoint(inner = SteihaugCG(); nmax = 200, lanczos_k = 40)

Wraps a subproblem solver so that the returned step always achieves at least the model decrease available along the leftmost eigenvector.

When λ_min(B) < 0 this computes the eigenpoint

\[d = \pm\Delta\, v_{\min}, \qquad m(0) - m(d) \;\ge\; \tfrac12 |\lambda_{\min}|\,\Delta^2,\]

with the sign chosen so that gᵀd ≤ 0, and returns whichever of d and the inner solver's step decreases the model more. When λ_min ≥ 0 the inner step is returned untouched and nothing is spent beyond the curvature estimate.

The estimate is taken from the curv argument when the solver has already computed it (which it has whenever the rule is τ-anchored or tol_H > 0), and computed here only otherwise. needs_eigenvector tells the solver to ask for the eigenvector as well, so the shared estimate is the one with v.

Why this is needed

τ-anchoring keeps the radius positive near a saddle; it does not make the step go anywhere. The two halves are independent, and both are required:

  • SteihaugCG terminates on the first direction of negative curvature and runs to the boundary along it. That is a genuine decrease, but the direction is whichever the CG recurrence happened to reach, and the guaranteed decrease carries no |λ_min|Δ² term — the fraction of the eigenpoint decrease it achieves can be arbitrarily small.
  • ExactMS already solves the subproblem exactly, hard case included, so it satisfies the condition on its own and needs no wrapping. Wrapping it is harmless and now costs nothing extra when the solver is already estimating the curvature.

So EigenPoint(SteihaugCG()) is the combination to use for second-order runs on anything but the smallest problems, and it is what makes the O(max{ε_g^{-2}, ε_H^{-3}}) complexity bound of Part II attainable in practice rather than only in principle.

source
TrustRegionRadius.second_order_status — Function
second_order_status(g_norm, λmin, tol_g, tol_H) -> Symbol

:second_order when ‖g‖ ≤ tol_g and λ_min ≥ −tol_H, :first_order when only the gradient test passes, :unknown otherwise.

Kept separate from the solver loop so that a trace can be re-classified after the fact under a different tol_H without re-running anything.

source