Source code for pybnf.shooting.solver

"""The inner solver: ``gntr``'s step machine, driven against an augmented subproblem (#563).

ADR-0109's layer ships no inner solver, deliberately -- shipping one would state a
preference the optimizer-agnostic contract exists to avoid. A *consumer* has to choose one,
and for multiple shooting the choice is measured rather than assumed. Over 30 data seeds x 2
starts on the layer's offline shooting problem (120 runs), a trust-region least-squares
solver converged **60/60** while a quasi-Newton one converged 36/60 and stalled out on the
rest. The reason is structural: the KKT stop needs the scaled defect and the first-order
optimality below tolerance in *one* iterate, and a method built from gradient differences
handles an augmented Lagrangian whose penalty term carries a large ``rho`` far less well
than one that sees ``rho J_c^T J_c`` explicitly. So the MVP steps from the Gauss-Newton
form, and a consumer should not treat the inner optimizer as free.

Reusing the fit type rather than reimplementing it
--------------------------------------------------
The Gauss-Newton form is ``(gradient, PSD hessian)`` -- exactly what ``job_type = gntr``
already consumes, and :class:`~pybnf.algorithms.optimizers.gntr._GNTRRunner` is already a
headless, backend-free step machine over that pair: ridge-regularise ``H``, eigen-factor it
into the pseudo-Jacobian that reproduces the Coleman-Li-scaled Newton step, and run ``trf``'s
bound-constrained trust-region-reflective accept/reject state machine unchanged. So this
module is a *driver*, not a method: it feeds that runner the augmented Lagrangian instead of
the fit's own objective, and nothing about the step math is new or separately tuned.

Driving it synchronously is the one difference from ``gntr``'s own use, and it is forced by
the interface above rather than chosen: ADR-0109's contract is
``solve(subproblem, u0, tolerance) -> InnerOutcome``, a blocking call, because an inner
solver "never calls back into the outer loop". Since a segment simulation is not a
:class:`~pybnf.pset.PSet` evaluation either (:mod:`pybnf.shooting.backend`), nothing is lost:
the propose/score loop was never available to this path.

Two tolerances, and why the outer loop's is only a floor away from being obeyed
-------------------------------------------------------------------------------
``tolerance`` is ``omega_k``, the outer loop's inner-optimality target: loose at first and
tightening as the penalty rises. It becomes the runner's ``grad_tol``, so an early
subproblem is solved roughly and a late one tightly -- which is the entire economic argument
for the augmented-Lagrangian frame. It is floored (:attr:`GaussNewtonSolver.grad_tol_floor`)
because ``omega`` decays geometrically and will eventually pass below what any solver can
demonstrate on a finite-precision Hessian; past that point the runner would simply spend its
whole iteration budget every outer iteration, which is the same waste ADR-0109 finding 5.1
measured on a too-loose penalty, arrived at from the other side.
"""

import numpy as np

from ..transcription import InnerOutcome


def _step_machine():
    """``(runner class, DONE sentinel)`` from the ``gntr`` fit type, imported lazily.

    The import is deferred because the dependency runs *against* the usual direction: a fit
    type imports the libraries it needs, and here a library reaches back into a fit type for
    its step machine. Importing :mod:`pybnf.algorithms.optimizers.gntr` at module scope
    executes ``pybnf.algorithms.__init__``, which registers every fit type -- including
    ``ms``, which imports this package -- so the cycle would close at import time. Deferring
    it to the first solve breaks the cycle and keeps :mod:`pybnf.shooting` importable on its
    own, which is what lets the whole package be exercised against a closed-form backend
    with no fit type in the picture.
    """
    from ..algorithms.optimizers.gntr import _GNTRRunner
    from ..algorithms.optimizers.gradient_base import DONE
    return _GNTRRunner, DONE


class _FisherModel:
    """The duck-typed local model :class:`~pybnf.algorithms.optimizers.gntr._GNTRRunner`
    reads: a scalar gradient and a PSD curvature matrix.

    The runner consumes a :class:`~pybnf.gradient.assembly.GradientResult` by attribute
    (``gradient``, ``hessian``) and never by type, so the augmented Lagrangian's own
    ``(grad f + J_c^T(lambda + rho c), H_f + rho J_c^T J_c)`` is handed over as-is. That is
    what "optimizer-agnostic" buys in practice: the fit type needs no knowledge that a
    multiplier exists.
    """

    __slots__ = ('gradient', 'hessian')

    def __init__(self, gradient, hessian):
        self.gradient = gradient
        self.hessian = hessian


[docs] class GaussNewtonSolver: """An inner solver on ADR-0109's contract, stepping from the Gauss-Newton form. :param max_iterations: Trust-region iterations per inner solve. Bounded per *outer* iteration rather than per run: an approximate inner minimisation is what the augmented-Lagrangian method is designed around, and the outer loop's stall detector is what notices a solver that stops achieving anything. :param ridge: The relative Levenberg ridge added to the curvature before the pseudo-Jacobian factorisation, as in ``gntr_ridge``. It matters more here than in an ordinary fit: with one observed state of three, the auxiliary states of the unobserved two carry **no data term at all** and their data-fit curvature is exactly zero, so the constraint block ``rho J_c^T J_c`` is the only curvature they have. :param step_tol: Negligible-step tolerance, as in ``gntr_step_tol``. :param grad_tol_floor: Floor under the outer loop's ``omega_k`` (see the module docstring). :param stop_check: Zero-argument callable; ``True`` truncates the inner solve at whichever iterate it has reached. The wall-clock-budget seam (ADR-0093/0107) -- a truncated inner solve is a normal outcome here, not a failure, and the outer loop keeps the iterate. """ def __init__(self, max_iterations=50, ridge=1e-10, step_tol=1e-10, grad_tol_floor=1e-10, stop_check=None): self.max_iterations = int(max_iterations) self.ridge = float(ridge) self.step_tol = float(step_tol) self.grad_tol_floor = float(grad_tol_floor) self.stop_check = stop_check #: Model evaluations spent across every inner solve this object has driven -- the #: cost accounting the #563 acceptance benchmark reports, and the quantity the #: prototype's paired sweep measured multiple shooting's 2-7x overhead in. self.n_evaluations = 0 def __call__(self, subproblem, u0, tolerance): runner_class, done = _step_machine() lower, upper = subproblem.lower, subproblem.upper runner = runner_class(np.clip(np.asarray(u0, dtype=float), lower, upper), lower, upper, self.max_iterations, grad_tol=max(float(tolerance), self.grad_tol_floor), step_tol=self.step_tol, ridge=self.ridge) point = runner.start() spent = 0 truncated = False while True: if self.stop_check is not None and self.stop_check(): truncated = True break model = subproblem.at(point) spent += 1 self.n_evaluations += 1 nxt = runner.got(point, *_local_model(model)) if nxt is done: break point = np.clip(np.asarray(nxt, dtype=float), lower, upper) if truncated: return InnerOutcome(runner.point, converged=False, n_evaluations=spent, message='inner solve truncated by the run\'s stop check') return InnerOutcome(runner.point, converged=_met_its_tolerance(runner), n_evaluations=spent, message=runner.stop_reason or '') def __repr__(self): return 'GaussNewtonSolver(max_iterations=%i, evaluations=%i)' % ( self.max_iterations, self.n_evaluations)
def _local_model(model): """``(score, local model)`` for one visited point. A point whose segments did not integrate -- or whose curvature could not be assembled -- is handed over as a non-finite score with **no** model, which is the signal the runner already understands: shrink the trust region and propose a shorter step, and at the start point end this solve rather than stepping from a surface that is not there (#492/#528). Multiple shooting meets that case far more often than an ordinary fit does, because it deliberately visits states the model was never integrated from. """ if not model.is_finite(): return float('inf'), None hessian = model.hessian() if hessian is None: return float('inf'), None return model.value, _FisherModel(model.gradient, hessian) def _met_its_tolerance(runner): """Whether an inner solve stopped because it *finished*, rather than ran out. :class:`~pybnf.transcription.outer.InnerOutcome`'s ``converged`` means "met the tolerance it was given, as opposed to running out of iterations or budget". A flat scaled gradient and a negligible step both qualify -- the second is a solver that cannot improve its point further, which is a finished solve of this subproblem however loose ``omega_k`` was. An exhausted iteration budget and a failed start do not. Nothing load-bearing rests on the answer: the outer loop **measures** the projected-gradient optimality itself at each iterate rather than trusting this flag (ADR-0109), precisely so an inner solver's own opinion cannot certify a KKT point. """ if runner.failure is not None or runner.stop_reason is None: return False return 'max_iterations' not in runner.stop_reason