Source code for pybnf.algorithms.optimizers.trf

"""Trust-Region-Reflective least-squares optimizer (``trf`` fit type, #386 / #460).

The primary gradient-based local optimizer: the workhorse for Gaussian / sum-of-
squares objectives (the common case in PyBNF). It consumes the **residual vector +
residual-Jacobian** #385 assembles from bngsim's forward output sensitivities, and
approximates the objective Hessian as ``JᵀJ`` -- far better conditioned and faster-
converging than feeding a scalar gradient to a generic quasi-Newton method on a
least-squares problem. This is the D2D (Data2Dynamics) workhorse step; see
``examples/becker_d2d_gradient/`` for the standalone methodological reference.

Why native (not ``scipy.optimize.least_squares``): scipy is a *blocking* driver that
calls ``fun``/``jac`` synchronously, so it cannot farm its evaluations to PyBNF's
distributed propose/score loop (the same incompatibility ``powell.py`` documents).
The method is reimplemented as an explicit, *picklable* step machine -- here a headless
:class:`~pybnf.algorithms.optimizers.gradient_base.GradientRunner` (:class:`_TRFRunner`)
that :class:`~pybnf.algorithms.optimizers.gradient_base.GradientOptimizer` drives inside
the run-loop contract, no ``run()`` override (ADR-0007) -- so backup/resume work like
every other method and one evaluation is one scheduler job. Factoring the step machine
into a per-start runner is also what lets a fit run ``N`` of them concurrently (local
multi-start, the orchestration the base owns). scipy is the **test oracle** for the step
math (``tests/test_gradient_runner.py``), never called in the production loop -- the
Branch–Coleman–Li math below is ported in-process (the ``_trf_*`` helpers).

The method (Trust-Region-Reflective, Branch–Coleman–Li 1999)
------------------------------------------------------------
In sampling space ``u`` (``StartPointOptimizer`` / ``GradientOptimizer``), with the
residual ``r`` and residual-Jacobian ``J`` from #385 at the current point ``x``
(gradient ``g = Jᵀr``, Gauss–Newton Hessian ``A = JᵀJ``), the bounds are handled by the
Coleman–Li affine scaling, matching ``scipy.optimize.least_squares(method='trf')``:

* the **Coleman–Li scaling** ``v(x)``: ``vᵢ`` is the distance from ``xᵢ`` to the bound
  the anti-gradient points at (``ubᵢ-xᵢ`` when ``gᵢ<0``, ``xᵢ-lbᵢ`` when ``gᵢ>0``, else
  ``1``). With ``D = diag(√v)`` the first-order optimality condition is ``D²g = 0`` -- so
  the scaled gradient ``‖v·g‖∞`` reads as zero both in the interior (``g=0``) and on an
  active face (``v=0`` there). A bound-active optimum is therefore *recognized* as
  optimal, where a plain ``‖g‖∞`` never would be;
* the trust-region subproblem is solved in the scaled ("hat") space ``x = D x̂``, where
  the Newton model is ``½ x̂ᵀ(DAD + C)x̂ + (Dg)ᵀx̂`` with ``C = diag(g·dv)`` the curvature
  the scaling itself contributes (``dv ∈ {-1,0,1}``), via a single SVD of the augmented
  Jacobian ``[J D; C^{½}]`` and More's root-find on the trust radius ``Δ``;
* **reflective step selection**: a trust-region step that would leave the box is
  *reflected* off the first bound it crosses, and the best (by predicted model
  reduction) of {the constrained trust step, the reflected step, a scaled-gradient
  Cauchy step} is taken, kept strictly interior by a step-back factor ``θ`` (the
  Coleman–Li differentiability requirement). Reflection lets the iterate slide *along*
  an active face and converge cleanly onto a bound-active corner, where simply clipping
  the unconstrained step into the box can stall.

``Δ`` is the trust-region knob: it shrinks when a step is rejected or over-predicted
(``Δ ← ¼‖step‖`` when the gain ratio ``ρ < ¼``) and grows when a good step pushes against
the trust boundary (``Δ ← 2Δ`` when ``ρ > ¾`` and the step hit the boundary). The
initial ``Δ₀`` is derived from the (scaled) start point, not a tunable.

Each iteration costs **one** objective evaluation (the trial). The whole scaled-SVD
subproblem solve + reflective step selection is in-process linear algebra on the cached
``r``/``J`` -- no evaluations. On accept, the trial's own residual/Jacobian -- already
assembled, since master-side scoring returns the simdata -- become the next iterate's, so
an accepted step needs no re-evaluation; on reject, only ``Δ`` shrinks and the step is
re-solved from the same cached SVD. The run stops when the scaled gradient is flat
(``‖v·g‖∞ ≤ trf_grad_tol`` -- the first-order optimality test that respects active
bounds), the step is negligible (``‖δ‖ ≤ trf_step_tol·(‖x‖+trf_step_tol)``), or the
iteration budget is spent. In the box interior (no bound active at the solution) the
scaling and reflection fall away and the method is an ordinary trust-region least-squares
step, MINPACK-style.

Scope. TRF consumes the **exact least-squares residual** -- the Gaussian (any
scale/location) and the Student-t (#459). A fit whose objective is not an exact sum of
squares (an estimated noise scale, a Laplace / count family, active constraints;
``GradientResult.least_squares_exact == False``) has no faithful residual model, so this
optimizer refuses it with a pointer to the L-BFGS-B path (``fit_type = lbfgs``, #386's
fallback). Local multi-start is provided by :class:`GradientOptimizer` (the base runs
``N`` independent :class:`_TRFRunner` starts concurrently and keeps the global best).

All runner state is plain ``numpy`` / ``float`` (the point, residual, Jacobian, the
cached scaling + SVD, the trust radius) -- picklable, so ``Algorithm.backup`` checkpoints
the optimizer (and its list of runners) mid-run.
"""

import math
from typing import ClassVar

import numpy as np

from .gradient_base import DONE, GradientOptimizer, GradientRunner
from ...config_schema import PyBNFConfigModel
from ...printing import PybnfError
from ...registry import register_fit_type

_EPS = np.finfo(float).eps


[docs] class TRFConfig(PyBNFConfigModel): """TRF (Trust-Region-Reflective) config fields, co-located with the method (ADR-0006). ``trf_grad_tol`` ends the run when the largest component of the **scaled** least-squares gradient ``v·Jᵀr`` (in sampling space) falls below it -- a first-order optimality test that reads as the ordinary ``‖Jᵀr‖∞`` in the interior and as zero on a bound the gradient pushes against (the Coleman–Li scaling ``v`` vanishes there). ``trf_step_tol`` ends it when an accepted step ``δ`` becomes negligible relative to the point (``‖δ‖ ≤ trf_step_tol·(‖x‖+trf_step_tol)``). The initial trust radius is derived from the (scaled) start point -- there is no damping/radius tunable. Like Powell's cycle budget, ``trf_max_iterations`` is runtime-guarded -- it defaults to the global ``max_iterations`` when unset -- so it is a valid key but not a schema field. ``trf_start_point`` is internal (the refiner injects it), so it is not modeled here either.""" trf_grad_tol: float = 1e-8 trf_step_tol: float = 1e-8 RUNTIME_KEYS: ClassVar[frozenset] = frozenset({'trf_max_iterations'})
[docs] @register_fit_type('trf', family='optimizer', display_name='Trust-Region Least-Squares', schema=TRFConfig, refiner=True, start_from_box=True) class TRFAlgorithm(GradientOptimizer): """Bounded Trust-Region-Reflective least-squares: a method-agnostic multi-start orchestrator (:class:`GradientOptimizer`) over per-start :class:`_TRFRunner` step machines.""" #: Message label + refiner start-point key (see StartPointOptimizer). fit_type = 'trf' START_POINT_KEY = 'trf_start_point' _method_label = 'TRF' def __init__(self, config, refine=False): super().__init__(config, refine=refine) self.grad_tol = config.config['trf_grad_tol'] self.step_tol = config.config['trf_step_tol'] if 'trf_max_iterations' in config.config: self.max_iterations = config.config['trf_max_iterations'] else: self.max_iterations = config.config['max_iterations'] def _start_banner(self): return ("Running trust-region-reflective least-squares for up to " "%i iterations from %i start point(s)" % (self.max_iterations, self.n_starts)) def _make_runner(self, u0): """One Trust-Region-Reflective step machine seeded at ``u0`` (sampling space), carrying this fit's box + tunables. The orchestrator builds one per start.""" return _TRFRunner(u0, self._u_lower, self._u_upper, self.max_iterations, grad_tol=self.grad_tol, step_tol=self.step_tol)
# --------------------------------------------------------------------------- # # Branch–Coleman–Li trust-region math, ported in-process (#460). # # Faithful ports of ``scipy.optimize._lsq.{common,trf}`` -- the test oracle -- kept # here as pure-``numpy`` module functions so the production loop never imports scipy # (ADR-0007). The runner below uses the ``x_scale = 1``, dense-``exact`` (SVD), # linear-loss path of ``scipy``'s ``method='trf'``, which is exactly what the offline # oracle (``tests/test_gradient_runner.py``) compares against. # --------------------------------------------------------------------------- # def _cl_scaling_vector(x, g, lower, upper): """Coleman–Li scaling vector ``v`` and its diagonal derivative ``dv``. ``vᵢ`` is the distance to the bound the anti-gradient points at (``ubᵢ-xᵢ`` when ``gᵢ<0`` and ``ubᵢ`` is finite, ``xᵢ-lbᵢ`` when ``gᵢ>0`` and ``lbᵢ`` is finite, else ``1``); ``dvᵢ ∈ {-1, 0, 1}`` is ``∂vᵢ/∂xᵢ``. ``v ≥ 0`` and ``g·dv ≥ 0`` everywhere.""" v = np.ones_like(x) dv = np.zeros_like(x) mask = (g < 0) & np.isfinite(upper) v[mask] = upper[mask] - x[mask] dv[mask] = -1 mask = (g > 0) & np.isfinite(lower) v[mask] = x[mask] - lower[mask] dv[mask] = 1 return v, dv def _in_bounds(x, lower, upper): return bool(np.all((x >= lower) & (x <= upper))) def _step_size_to_bound(x, s, lower, upper): """Smallest ``t ≥ 0`` with ``x + t·s`` on a bound, and a ``{-1,0,1}`` hit indicator per coordinate (which bound, if any, is reached at that ``t``).""" non_zero = np.nonzero(s) s_non_zero = s[non_zero] steps = np.empty_like(x) steps.fill(np.inf) with np.errstate(over='ignore'): steps[non_zero] = np.maximum((lower - x)[non_zero] / s_non_zero, (upper - x)[non_zero] / s_non_zero) min_step = np.min(steps) return min_step, np.equal(steps, min_step) * np.sign(s).astype(int) def _find_active_constraints(x, lower, upper, rtol=1e-10): """Per-coordinate ``{-1,0,1}`` active-bound indicator (``rtol=0`` uses exact equality).""" active = np.zeros_like(x, dtype=int) if rtol == 0: active[x <= lower] = -1 active[x >= upper] = 1 return active lower_dist = x - lower upper_dist = upper - x lower_threshold = rtol * np.maximum(1, np.abs(lower)) upper_threshold = rtol * np.maximum(1, np.abs(upper)) lower_active = (np.isfinite(lower) & (lower_dist <= np.minimum(upper_dist, lower_threshold))) active[lower_active] = -1 upper_active = (np.isfinite(upper) & (upper_dist <= np.minimum(lower_dist, upper_threshold))) active[upper_active] = 1 return active def _make_strictly_feasible(x, lower, upper, rstep=1e-10): """Nudge ``x`` off any bound it sits on so the iterate stays strictly interior (the Coleman–Li differentiability requirement); ``rstep=0`` uses :func:`numpy.nextafter`.""" x_new = x.copy() active = _find_active_constraints(x, lower, upper, rstep) lower_mask = np.equal(active, -1) upper_mask = np.equal(active, 1) if rstep == 0: x_new[lower_mask] = np.nextafter(lower[lower_mask], upper[lower_mask]) x_new[upper_mask] = np.nextafter(upper[upper_mask], lower[upper_mask]) else: x_new[lower_mask] = (lower[lower_mask] + rstep * np.maximum(1, np.abs(lower[lower_mask]))) x_new[upper_mask] = (upper[upper_mask] - rstep * np.maximum(1, np.abs(upper[upper_mask]))) tight = (x_new < lower) | (x_new > upper) x_new[tight] = 0.5 * (lower[tight] + upper[tight]) return x_new def _intersect_trust_region(x, s, Delta): """Roots ``t`` of ``‖x + t·s‖ = Δ`` (negative, positive), avoiding cancellation.""" a = np.dot(s, s) if a == 0: raise ValueError('`s` is zero.') b = np.dot(x, s) c = np.dot(x, x) - Delta ** 2 if c > 0: raise ValueError('`x` is not within the trust region.') d = np.sqrt(b * b - a * c) q = -(b + math.copysign(d, b)) t1 = q / a t2 = c / q return (t1, t2) if t1 < t2 else (t2, t1) def _solve_lsq_trust_region(n, m, uf, s, V, Delta, initial_alpha=None, rtol=0.01, max_iter=10): """Solve the least-squares trust-region subproblem from the SVD of the (augmented) Jacobian (More's root-find on the Levenberg–Marquardt parameter ``alpha``). Returns ``(p, alpha, n_iter)``; ``n_iter == 0`` means the Gauss–Newton step was taken.""" def phi_and_derivative(alpha, suf, s, Delta): denom = s ** 2 + alpha p_norm = np.linalg.norm(suf / denom) phi = p_norm - Delta phi_prime = -np.sum(suf ** 2 / denom ** 3) / p_norm return phi, phi_prime suf = s * uf if m >= n: threshold = _EPS * m * s[0] full_rank = s[-1] > threshold else: full_rank = False if full_rank: p = -V.dot(uf / s) if np.linalg.norm(p) <= Delta: return p, 0.0, 0 alpha_upper = np.linalg.norm(suf) / Delta if full_rank: phi, phi_prime = phi_and_derivative(0.0, suf, s, Delta) alpha_lower = -phi / phi_prime else: alpha_lower = 0.0 if initial_alpha is None or not full_rank and initial_alpha == 0: alpha = max(0.001 * alpha_upper, (alpha_lower * alpha_upper) ** 0.5) else: alpha = initial_alpha it = 0 for it in range(max_iter): if alpha < alpha_lower or alpha > alpha_upper: alpha = max(0.001 * alpha_upper, (alpha_lower * alpha_upper) ** 0.5) phi, phi_prime = phi_and_derivative(alpha, suf, s, Delta) if phi < 0: alpha_upper = alpha ratio = phi / phi_prime alpha_lower = max(alpha_lower, alpha - ratio) alpha -= (phi + Delta) * ratio / Delta if np.abs(phi) < rtol * Delta: break p = -V.dot(suf / (s ** 2 + alpha)) p *= Delta / np.linalg.norm(p) # make ‖p‖ == Δ exactly (it barely moves) return p, alpha, it + 1 def _build_quadratic_1d(J, g, s, diag=None, s0=None): """Coefficients ``(a, b[, c])`` of the model ``½(s0+s·t)ᵀ(JᵀJ+diag)(s0+s·t) + gᵀ(s0+s·t)`` as a univariate quadratic in ``t`` (``c`` returned iff ``s0`` given).""" v = J.dot(s) a = np.dot(v, v) if diag is not None: a += np.dot(s * diag, s) a *= 0.5 b = np.dot(g, s) if s0 is not None: u = J.dot(s0) b += np.dot(u, v) c = 0.5 * np.dot(u, u) + np.dot(g, s0) if diag is not None: b += np.dot(s0 * diag, s) c += 0.5 * np.dot(s0 * diag, s0) return a, b, c return a, b def _minimize_quadratic_1d(a, b, lower, upper, c=0): """Minimize ``a·t² + b·t + c`` over ``[lower, upper]`` (both finite); returns ``(argmin, min)``.""" t = [lower, upper] if a != 0: extremum = -0.5 * b / a if lower < extremum < upper: t.append(extremum) t = np.asarray(t) y = t * (a * t + b) + c i = np.argmin(y) return t[i], y[i] def _evaluate_quadratic(J, g, s, diag=None): """The model value ``½ sᵀ(JᵀJ + diag)s + gᵀs`` at step ``s``.""" if s.ndim == 1: Js = J.dot(s) q = np.dot(Js, Js) if diag is not None: q += np.dot(s * diag, s) else: Js = J.dot(s.T) q = np.sum(Js ** 2, axis=0) if diag is not None: q += np.sum(diag * s ** 2, axis=1) return 0.5 * q + np.dot(s, g) def _update_tr_radius(Delta, actual_reduction, predicted_reduction, step_norm, bound_hit): """New trust radius + gain ratio: shrink to ``¼‖step‖`` on a poor step (``ρ<¼``), double when a good step (``ρ>¾``) pushes against the trust boundary.""" if predicted_reduction > 0: ratio = actual_reduction / predicted_reduction elif predicted_reduction == actual_reduction == 0: ratio = 1 else: ratio = 0 if ratio < 0.25: Delta = 0.25 * step_norm elif ratio > 0.75 and bound_hit: Delta *= 2.0 return Delta, ratio def _select_step(x, J_h, diag_h, g_h, p, p_h, d, Delta, lower, upper, theta): """Pick the Trust-Region-Reflective step: the best (by predicted model reduction) of the constrained trust step, the reflected step, and the scaled-gradient Cauchy step, each kept strictly interior. Returns ``(step, step_h, predicted_reduction)``.""" if _in_bounds(x + p, lower, upper): p_value = _evaluate_quadratic(J_h, g_h, p_h, diag=diag_h) return p, p_h, -p_value p_stride, hits = _step_size_to_bound(x, p, lower, upper) # Reflect the trust step off the first bound it crosses. r_h = np.copy(p_h) r_h[hits.astype(bool)] *= -1 r = d * r_h # Restrict the trust step so it lands on that bound. p *= p_stride p_h *= p_stride x_on_bound = x + p # The reflected ray exits the feasible region or the trust region first. _, to_tr = _intersect_trust_region(p_h, r_h, Delta) to_bound, _ = _step_size_to_bound(x_on_bound, r, lower, upper) r_stride = min(to_bound, to_tr) if r_stride > 0: r_stride_l = (1 - theta) * p_stride / r_stride if r_stride == to_bound: r_stride_u = theta * to_bound else: r_stride_u = to_tr else: r_stride_l = 0 r_stride_u = -1 if r_stride_l <= r_stride_u: a, b, c = _build_quadratic_1d(J_h, g_h, r_h, s0=p_h, diag=diag_h) r_stride, r_value = _minimize_quadratic_1d(a, b, r_stride_l, r_stride_u, c=c) r_h *= r_stride r_h += p_h r = r_h * d else: r_value = np.inf # Step the constrained trust step back to strictly interior. p *= theta p_h *= theta p_value = _evaluate_quadratic(J_h, g_h, p_h, diag=diag_h) # Scaled-gradient (Cauchy) step. ag_h = -g_h ag = d * ag_h to_tr = Delta / np.linalg.norm(ag_h) to_bound, _ = _step_size_to_bound(x, ag, lower, upper) ag_stride = theta * to_bound if to_bound < to_tr else to_tr a, b = _build_quadratic_1d(J_h, g_h, ag_h, diag=diag_h) ag_stride, ag_value = _minimize_quadratic_1d(a, b, 0, ag_stride) ag_h *= ag_stride ag *= ag_stride if p_value < r_value and p_value < ag_value: return p, p_h, -p_value elif r_value < p_value and r_value < ag_value: return r, r_h, -r_value else: return ag, ag_h, -ag_value class _TRFRunner(GradientRunner): """One Trust-Region-Reflective start: the picklable step machine, in sampling space ``u``. Holds the iterate, its residual model (``r``, ``J``, ``g = Jᵀr``, ``cost = ½‖r‖²``), the trust radius ``Δ``, and -- cached for the current outer iteration -- the Coleman–Li scaling and the augmented-Jacobian SVD (so a rejected step re-solves the trust-region subproblem without re-evaluating). Consumes ``(u_point, score, grad)`` and returns the next ``u`` to evaluate (or :data:`DONE`). Pure ``numpy`` -- no PSets, objective, or backend (see :class:`GradientRunner`). The step math is the Branch– Coleman–Li bound handling described in the module docstring (the ``_trf_*`` helpers); the orchestrator (:class:`TRFAlgorithm` / :class:`GradientOptimizer`) supplies the assembled :class:`GradientResult` and does the reporting, and this runner requires it to be an **exact** least-squares residual (:meth:`_require_exact`).""" def __init__(self, u0, lower, upper, max_iterations, *, grad_tol, step_tol): super().__init__(u0, lower, upper, max_iterations) self.grad_tol = grad_tol self.step_tol = step_tol # Current-iterate residual model. self.r = None # residual vector f at point (m,) self.J = None # residual Jacobian at point (m, n) self.g = None # Jᵀr at point (n,) self.m = 0 # residual count self.cost = None # ½‖r‖² (== fval) self.Delta = None # trust-region radius self.alpha = 0.0 # LM parameter, carried across solves (scipy's `alpha`) # Scaling + augmented-Jacobian SVD for the current outer iteration (rebuilt per # accepted point; reused while rejecting). All plain ndarray/float -> picklable. self._d = None # √v (the Coleman–Li scale, scale = 1) self._diag_h = None # g·dv (the C-matrix diagonal in hat space) self._g_h = None # d·g (hat-space gradient) self._J_h = None # J·d (hat-space Jacobian, m×n) self._s = None # singular values of the augmented Jacobian self._V = None # right singular vectors (n×n) self._uf = None # Uᵀ·[r; 0] self._theta = None # strict-feasibility step-back factor # Trial currently out for evaluation. self.predicted_reduction = None self.step_h_norm = None # ‖step‖ in hat space (drives the Δ update) self.step_norm = None # ‖step‖ in original space (drives the step-tol test) def progress_detail(self): return 'trust radius %g' % self.Delta def got(self, u_point, score, grad): if self.phase == 'init': return self._after_init(u_point, score, grad) if self.phase == 'step': return self._after_step(u_point, score, grad) raise RuntimeError(f'Internal error in _TRFRunner: phase {self.phase!r}') # --- state machine ----------------------------------------------------- # def _after_init(self, u_point, score, grad): """Seed the state from the start-point evaluation: residual/Jacobian model and the initial trust radius ``Δ₀`` (derived from the scaled start point).""" gr = self._require_exact(grad) self.point = np.array(u_point, dtype=float) self.fval = score self.cost = score self._set_model(gr) if not self.n: self.stop_reason = 'no free parameters to optimize' return DONE v, _ = _cl_scaling_vector(self.point, self.g, self._u_lower, self._u_upper) self.Delta = float(np.linalg.norm(self.point / np.sqrt(v))) if self.Delta == 0.0: self.Delta = 1.0 if self._gradient_converged(): self.stop_reason = 'gradient already flat at the start point' return DONE return self._begin_outer() def _after_step(self, u_point, score, grad): """Score the trial by its gain ratio, update the trust radius, and accept (step + rebuild the scaling/SVD at the new point) or reject (re-solve from the cached SVD at a smaller radius).""" self.iteration += 1 f_new = score step_h_norm = self.step_h_norm if not np.isfinite(f_new): # A non-finite trial gives the trust region its signal to shrink and re-solve # (no accept), exactly like scipy's `Delta = 0.25*step_h_norm; continue`. self.Delta = 0.25 * step_h_norm return self._reject_or_budget() cost_new = f_new actual_reduction = self.cost - cost_new Delta_new, _ = _update_tr_radius( self.Delta, actual_reduction, self.predicted_reduction, step_h_norm, step_h_norm > 0.95 * self.Delta) step_negligible = self._step_negligible(self.step_norm) if actual_reduction > 0.0: # Accept: the trial's own residual/Jacobian (assembled by the orchestrator) # become the next iterate's, so no re-evaluation is needed. gr = self._require_exact(grad) self.point = np.array(u_point, dtype=float) self.cost = cost_new self.fval = cost_new self._set_model(gr) self.alpha *= self.Delta / Delta_new self.Delta = Delta_new stop = self._stop_reason(step_negligible) if stop is not None: self.stop_reason = stop return DONE return self._begin_outer() # Reject: shrink the trust region (a shorter, more gradient-like step) and # re-solve from the same point + cached SVD. A step that became negligible # without reducing the cost means the run has stalled at a minimum. self.alpha *= self.Delta / Delta_new self.Delta = Delta_new if step_negligible: self.stop_reason = self._step_negligible_reason() return DONE return self._reject_or_budget() def _reject_or_budget(self): """Re-solve the trust-region subproblem (cached SVD, current ``Δ``), or stop on a spent iteration budget so a stalled run cannot loop forever.""" if self.iteration >= self.max_iterations: self.stop_reason = 'reached max_iterations (%i)' % self.max_iterations return DONE return self._solve_and_propose() # --- trust-region step ------------------------------------------------- # def _begin_outer(self): """Start a new outer iteration at the current point: rebuild the Coleman–Li scaling + augmented-Jacobian SVD, then solve the first trust-region step.""" self._build_scaling() return self._solve_and_propose() def _build_scaling(self): """Cache the Coleman–Li scaling (``d = √v``, ``diag_h = g·dv``, ``g_h = d·g``, ``J_h = J·d``) and the SVD of the augmented Jacobian ``[J_h; diag(√diag_h)]`` for the current point (``x_scale = 1``, dense `exact` path of scipy's TRF).""" x, g, r, J = self.point, self.g, self.r, self.J m, n = self.m, self.n v, dv = _cl_scaling_vector(x, g, self._u_lower, self._u_upper) g_scaled_norm = float(np.max(np.abs(g * v))) d = np.sqrt(v) diag_h = g * dv J_h = J * d # column scaling (d broadcasts over columns) j_aug = np.zeros((m + n, n)) j_aug[:m] = J_h j_aug[m:] = np.diag(np.sqrt(diag_h)) u, s, vt = np.linalg.svd(j_aug, full_matrices=False) f_aug = np.zeros(m + n) f_aug[:m] = r self._d = d self._diag_h = diag_h self._g_h = d * g self._J_h = J_h self._s = s self._V = vt.T self._uf = u.T @ f_aug # theta controls the strict-feasibility step-back from the bounds. self._theta = max(0.995, 1.0 - g_scaled_norm) def _solve_and_propose(self): """Solve the (scaled) trust-region subproblem at the current ``Δ`` from the cached SVD, pick the reflective step, and return the strictly-feasible trial point.""" x = self.point lower, upper = self._u_lower, self._u_upper p_h, self.alpha, _ = _solve_lsq_trust_region( self.n, self.m, self._uf, self._s, self._V, self.Delta, initial_alpha=self.alpha) p = self._d * p_h step, step_h, predicted_reduction = _select_step( x, self._J_h, self._diag_h, self._g_h, p, p_h, self._d, self.Delta, lower, upper, self._theta) trial = _make_strictly_feasible(x + step, lower, upper, rstep=0.0) self.predicted_reduction = predicted_reduction self.step_h_norm = float(np.linalg.norm(step_h)) self.step_norm = float(np.linalg.norm(step)) self.phase = 'step' return trial def _set_model(self, grad): """Cache the residual model ``r``, ``J``, ``g = Jᵀr`` (and the residual count ``m``) at the current point from an assembled :class:`GradientResult`.""" self.J = grad.jacobian self.r = grad.residual self.g = self.J.T @ self.r self.m = self.J.shape[0] def _require_exact(self, grad): """Require an **exact** least-squares residual from the assembled gradient. TRF models the objective as ``½‖r‖²``; an objective that is not an exact sum of squares (estimated scale, Laplace/count family, constraints) has no faithful residual, so refuse it with a pointer to the L-BFGS-B fallback rather than silently optimizing the wrong surface.""" if not grad.least_squares_exact: raise PybnfError( "fit_type = trf needs an exact least-squares residual (a Gaussian or " "Student-t objective with a fixed noise scale and no constraints), but " "this fit's objective is not an exact sum of squares.", "Use the gradient quasi-Newton fallback 'fit_type = lbfgs', which " "consumes the scalar gradient and handles estimated noise scales, the " "Laplace / count families, and constraint penalties.") return grad # --- convergence ------------------------------------------------------- # def _scaled_gradient_norm(self): """The first-order optimality measure ``‖v·g‖∞``: the ordinary ``‖g‖∞`` in the box interior, and zero on a bound the gradient pushes against (the Coleman–Li scaling ``v`` vanishes there).""" v, _ = _cl_scaling_vector(self.point, self.g, self._u_lower, self._u_upper) return float(np.max(np.abs(self.g * v))) def _gradient_converged(self): return bool(self.n) and self._scaled_gradient_norm() <= self.grad_tol def _step_negligible(self, step_norm): """Whether the step is negligible relative to the (pre-step) point.""" point_norm = float(np.linalg.norm(self.point)) return step_norm < self.step_tol * (self.step_tol + point_norm) def _step_negligible_reason(self): point_norm = float(np.linalg.norm(self.point)) return 'step is negligible (‖δ‖ ≤ %g)' % (self.step_tol * (point_norm + self.step_tol)) def _stop_reason(self, step_negligible): """A termination string after an accepted step, or None to keep going.""" if self._gradient_converged(): return 'gradient is flat (‖v·Jᵀr‖∞ ≤ %g)' % self.grad_tol if step_negligible: return self._step_negligible_reason() if self.iteration >= self.max_iterations: return 'reached max_iterations (%i)' % self.max_iterations return None