Source code for pybnf.algorithms.optimizers.powell

"""Powell's conjugate-direction optimizer (the ``powell`` fit type, #403).

A native, derivative-free local optimizer (Numerical Recipes §10.7), implemented
inside PyBNF's run-loop contract -- ``start_run`` / ``got_result`` only, no
``run()`` override (ADR-0007). It is the second user-selectable refiner alongside
Simplex (``refine_method = powell``) and also runs standalone (``fit_type =
powell``), started from a single ``var`` / ``logvar`` point *or*, over a
bounded-prior box, from the box center -- its global-start mode (``start_from_box``,
#404/ADR-0017), which also unlocks concurrent multi-start (``n_starts``, #498).

Why native (not ``scipy.optimize.minimize``): ``scipy`` is a *blocking driver*,
which would force either a ``run()`` override or a bridging thread; reimplementing
the method as an explicit, *picklable* state machine keeps the one shared run loop
(so backup/resume work like every other method) and adds no dependency.

The method, in sampling space ``u`` (``StartPointOptimizer``): hold a set of ``n``
search directions (initially the coordinate axes). One *cycle* line-minimizes
along each direction in turn; then -- per the Numerical Recipes criterion -- the
net move of the cycle may replace the direction of greatest decrease, building up
mutually conjugate directions that converge quadratically on a quadratic bowl.

Line minimization is a **serial bracketing + Brent** 1-D search (ADR-0016,
superseding the original fixed-step parabola): geometric (golden) expansion from
``±powell_step`` brackets a minimum, then Brent's method (parabolic interpolation
with a golden-section fallback) refines it to ``powell_line_tol``. The search is
confined to the *feasible* ``t``-interval -- the range of step lengths that keep
every parameter inside its box -- so the bound reflection never folds the 1-D
slice and Brent sees the true objective; a minimum that wants to lie past a bound
lands on the boundary. Unlike the fixed-step parabola, this follows long curved
(non-quadratic) valleys and adapts its step.

Concurrency (#498). One Powell search is strictly serial -- one line-search probe
in flight at a time -- so a single run uses one worker. :class:`PowellRunner`
factors that serial search into a headless, picklable per-start step machine so
:class:`~pybnf.algorithms.optimizers.local_multistart.LocalMultiStartOptimizer`
can run ``n_starts`` of them **concurrently** (one worker each), the diversity a
purely local method otherwise lacks. A point-start / refine fit is a single runner,
byte-identical to the pre-multi-start behavior.

All state is plain ``numpy`` / ``float`` / ``list`` -- no generator, no thread,
including the ``_BrentLineSearch`` sub-state-machine -- so ``Algorithm.backup``
can pickle the optimizer mid-run and ``run(resume=...)`` continues it.
"""

from .local_base import StartPointOptimizer
from .local_multistart import DONE, LocalMultiStartOptimizer
from .multistart import MultiStartConfig
from ...config_schema import PyBNFConfigModel
from ...printing import print1, print2
from ...registry import register_fit_type

from typing import ClassVar

import logging
import numpy as np


# Preserve the original module logger name so log records keep the
# 'pybnf.algorithms' channel.
logger = logging.getLogger('pybnf.algorithms')


[docs] class PowellConfig(MultiStartConfig, PyBNFConfigModel): """Powell config fields, co-located with the method (ADR-0002, ADR-0006). ``powell_step`` is the initial bracketing step in sampling space ``u`` (a factor of ``10**powell_step`` for a log parameter) -- the first probe distance along each direction, from which the line search expands a bracket and refines. ``powell_line_tol`` is the fractional tolerance to which each 1-D (Brent) line minimum is resolved. ``powell_stop_tol`` ends the search when a whole cycle improves the objective by less than that fraction. Like Simplex's ``simplex_max_iterations``, ``powell_max_iterations`` (the cycle budget) is runtime-guarded -- it defaults to the global ``max_iterations`` when unset -- so it is a valid key but not a schema field. ``powell_start_point`` is internal (the refiner injects it), so it is not modeled here either. Inherits the shared ``n_starts`` multi-start field (``MultiStartConfig``, #498): ``powell`` in box / global-start mode runs ``n_starts`` concurrent starts, keeping the global best. """ powell_step: float = 1.0 powell_line_tol: float = 1e-4 powell_stop_tol: float = 1e-5 RUNTIME_KEYS: ClassVar[frozenset] = frozenset({'powell_max_iterations'})
[docs] class PowellRunner: """Headless, picklable per-start Powell state machine in sampling space ``u`` (#498). Powell's conjugate-direction search, factored out of the optimizer so a single fit can run ``N`` of them **concurrently** -- local multi-start, the diversity a purely local method otherwise lacks (it only ever descends into the one basin its start lands in). A runner owns one start's entire mutable state -- the iterate, the search directions, the ``_BrentLineSearch`` scratch, the reflecting box, the tunables -- and is pure ``numpy`` / ``float`` / ``list``: it knows nothing about :class:`~pybnf.pset.PSet`\\ s, the objective, backup, or dask. The orchestrator (:class:`~pybnf.algorithms.optimizers.local_multistart.LocalMultiStartOptimizer`) drives it one evaluation at a time (Powell is serial):: (u, label), = runner.start() # the first point to evaluate nxt = runner.got(u_point, score) # -> [(u, label), ...] or DONE where each ``(u, label)`` is a next ``u``-point to evaluate (``label`` distinguishes the ``init`` / ``line`` / ``extrap`` phase in the submitted pset name), ``u_point`` is the realized (box-projected) ``u`` of the completed evaluation, and ``score`` its objective. ``got`` returns the next point(s) to evaluate, or the :data:`DONE` sentinel when this start has converged or spent its cycle budget. Because a runner holds only plain ``float`` / ``ndarray`` / ``list``, the optimizer that owns the list of runners pickles for backup/resume exactly like the single-start machine did (ADR-0007). """ # Safety cap on evaluations per 1-D line search (bracketing + Brent combined). # Brent converges superlinearly, so this is essentially never reached; on # exhaustion the line search returns the best point seen (ADR-0016). _MAX_LINE_EVALS = 100 def __init__(self, u0, lower, upper, max_iterations, step, line_tol, stop_tol): self.point = np.array(u0, dtype=float) # current best point (u-space) self._u_lower = lower # reflecting box (constant per fit) self._u_upper = upper self.n = len(self.point) self.max_iterations = max_iterations # cycle budget self.step = step self.line_tol = line_tol self.stop_tol = stop_tol self.fval = None # objective at self.point self.iteration = 0 # completed cycles (drives reporting / budget) self.stop_reason = None # set to a human string when got() returns DONE self._init_state() def _init_state(self): """Initialize the mutable search state. Directions start as the coordinate axes; the rest is filled in as the search runs.""" self.dirs = [np.eye(self.n)[i] for i in range(self.n)] self.dir_index = 0 self.cycle_start_point = None self.cycle_start_fval = None self.biggest_decrease = 0.0 self.ibig = 0 self.in_net_line = False # Line-search scratch. ``ls`` is the picklable _BrentLineSearch driving the # current 1-D search; ``ls_pending_t`` is the abscissa (signed step length # along ls_dir) whose evaluation we are awaiting. self.ls_base = None self.ls_fbase = None self.ls_dir = None self.ls = None self.ls_pending_t = None self.phase = 'init' # --- driver ------------------------------------------------------------ #
[docs] def start(self): """The first point to evaluate (the start point, ``init`` phase).""" return [(self.point, 'init')]
[docs] def got(self, u, score): """Consume one completed evaluation and return the next point(s) or :data:`DONE`.""" if self.phase == 'init': # The realized (post-reflection) evaluated point becomes the current point. self.point = np.array(u, dtype=float) self.fval = score return self._begin_cycle() if self.phase == 'line': return self._after_line_point(score) if self.phase == 'extrap': return self._after_extrap(score) raise RuntimeError(f'Internal error in PowellRunner: phase {self.phase!r}')
# --- state machine ----------------------------------------------------- # def _begin_cycle(self): self.cycle_start_point = self.point.copy() self.cycle_start_fval = self.fval self.biggest_decrease = 0.0 self.ibig = 0 self.dir_index = 0 self.in_net_line = False return self._begin_line_search(self.dirs[self.dir_index]) def _begin_line_search(self, direction): """Start a 1-D Brent line search from the current point along ``direction`` (a unit vector in u-space), confined to the feasible ``t``-interval so the search never drives a point out of the box.""" self.ls_dir = direction self.ls_base = self.point.copy() self.ls_fbase = self.fval t_lo, t_hi = self._feasible_interval(self.ls_base, direction) self.ls = _BrentLineSearch(self.ls_fbase, t_lo, t_hi, self.step, self.line_tol, self._MAX_LINE_EVALS) t = self.ls.first() if t is None: # no feasible room to move along this dir return self._finish_line_search() return self._submit_line_point(t) def _feasible_interval(self, base, direction): """Range of step lengths ``t`` for which ``base + t*direction`` stays inside every parameter's box in u-space. Unbounded coordinates impose no limit, so a standalone var/logvar fit gets ``(-inf, +inf)`` and never clamps; a bounded (box / refine) fit gets the finite segment that avoids the bound reflection (ADR-0016).""" t_lo, t_hi = -np.inf, np.inf for i in range(self.n): di = direction[i] if di == 0.0: continue a = (self._u_lower[i] - base[i]) / di b = (self._u_upper[i] - base[i]) / di lo_i, hi_i = (a, b) if di > 0.0 else (b, a) t_lo = max(t_lo, lo_i) t_hi = min(t_hi, hi_i) return t_lo, t_hi def _submit_line_point(self, t): """Queue the single objective evaluation at abscissa ``t`` along the current line; remember ``t`` for ``_after_line_point``.""" self.ls_pending_t = t self.phase = 'line' return [(self.ls_base + t * self.ls_dir, 'line')] def _after_line_point(self, score): """Feed the just-evaluated ``(t, score)`` to the line minimizer and either queue its next probe or finish the line search.""" action = self.ls.feed(self.ls_pending_t, score) if action[0] == 'eval': return self._submit_line_point(action[1]) return self._finish_line_search() def _finish_line_search(self): best_t, best_f = self.ls.best_t, self.ls.best_f decrease = self.ls_fbase - best_f # The search is box-confined, so base + best_t*dir is exactly the evaluated # in-box point -- no reflection to reconcile. self.point = self.ls_base + best_t * self.ls_dir self.fval = best_f if self.in_net_line: return self._next_cycle() if decrease > self.biggest_decrease: self.biggest_decrease = decrease self.ibig = self.dir_index self.dir_index += 1 if self.dir_index < self.n: return self._begin_line_search(self.dirs[self.dir_index]) return self._end_cycle() def _end_cycle(self): f0 = self.cycle_start_fval fn = self.fval # Convergence: a whole cycle barely moved the objective -- but never before at # least one direction-set update has run. Cycle 0 always extrapolates (the guard # below), so a sweep that lands on a valley floor cannot stop Powell before it has # a chance to build the along-valley conjugate direction (ADR-0016). if self.iteration >= 1 and \ 2.0 * (f0 - fn) <= self.stop_tol * (abs(f0) + abs(fn)) + 1e-30: self.stop_reason = ('converged after %i cycles (objective %.6g)' % (self.iteration + 1, fn)) logger.info('Powell %s' % self.stop_reason) return DONE # Extrapolated point P_extrap = 2*P_n - P_0, evaluated for the NR criterion. self.phase = 'extrap' return [(2.0 * self.point - self.cycle_start_point, 'extrap')] def _after_extrap(self, f_extrap): f0 = self.cycle_start_fval fn = self.fval df = self.biggest_decrease # Numerical Recipes test (Eq. 10.7.x): adopt the net direction only when the # extrapolated point improves on the cycle start and the quadratic criterion is # satisfied -- otherwise keep the current direction set. if f_extrap < f0: t = (2.0 * (f0 - 2.0 * fn + f_extrap) * (f0 - fn - df) ** 2 - df * (f0 - f_extrap) ** 2) if t < 0.0: net = self.point - self.cycle_start_point norm = float(np.linalg.norm(net)) if norm > 1e-12: net = net / norm self.dirs[self.ibig] = self.dirs[self.n - 1] self.dirs[self.n - 1] = net self.in_net_line = True return self._begin_line_search(net) return self._next_cycle() def _next_cycle(self): self.iteration += 1 if self.iteration >= self.max_iterations: self.stop_reason = (self.stop_reason or 'reached the %i-cycle budget' % self.max_iterations) return DONE return self._begin_cycle()
[docs] @register_fit_type('powell', family='optimizer', display_name='Powell', schema=PowellConfig, refiner=True, start_from_box=True) class PowellAlgorithm(LocalMultiStartOptimizer, StartPointOptimizer): """Powell's conjugate-direction method: ``n_starts`` concurrent :class:`PowellRunner`\\ s. A standalone box fit (bounded priors) runs ``n_starts`` independent starts concurrently -- start 0 from the box center, the rest from Latin-hypercube samples -- and keeps the global best (:class:`LocalMultiStartOptimizer`). A point-start (``var`` / ``logvar``) or refiner-injected fit is a single runner, byte-identical to the pre-multi-start behavior. """ #: Refiner start-point key (see StartPointOptimizer / pybnf._refine_best_fit). START_POINT_KEY = 'powell_start_point' _method_label = 'Powell' def __init__(self, config, refine=False): super().__init__(config, refine=refine) self.step = config.config['powell_step'] self.line_tol = config.config['powell_line_tol'] self.stop_tol = config.config['powell_stop_tol'] if 'powell_max_iterations' in config.config: self.max_iterations = config.config['powell_max_iterations'] else: self.max_iterations = config.config['max_iterations'] # Box bounds in sampling space u (log10 for log parameters, identity otherwise), # per variable -- finite only for bounded (uniform/loguniform) parameters; +-inf # for the unbounded var/logvar of a standalone point fit. Each runner confines its # line search to these so it never drives a point out of the box (#412). lower, upper = [], [] for v in self.variables: if v.bounded: lower.append(v.to_sampling_space(v.lower_bound)) upper.append(v.to_sampling_space(v.upper_bound)) else: lower.append(-np.inf) upper.append(np.inf) self._u_lower = np.array(lower, dtype=float) self._u_upper = np.array(upper, dtype=float) # --- LocalMultiStartOptimizer leaf hooks -------------------------------- # def _make_runner(self, start_pset, rng): # Powell's search is deterministic (no rng), so its per-start rng is unused. return PowellRunner(self._u_from_pset(start_pset), self._u_lower, self._u_upper, self.max_iterations, self.step, self.line_tol, self.stop_tol) def _seed(self, idx, runner): return [self._dispatch(idx, u, label) for (u, label) in runner.start()] def _advance(self, idx, runner, res): # The realized (post-reflection) evaluated point in u, so the runner's internal # vectors are all genuinely evaluated, in-bounds points. u = self._u_from_pset(res.pset) out = runner.got(u, float(res.score)) if out is DONE: return DONE return [self._dispatch(idx, u2, label) for (u2, label) in out] def _dispatch(self, idx, u, label): """Wrap a runner's proposed ``u``-point as a uniquely named PSet bound to its owning start. The name carries a single global counter -- ``powell_<k>_<label>`` -- so a single-start fit reproduces the historical ``powell_1_init``, ``powell_2_line``, ... sequence exactly while every name stays unique across concurrent starts (the routing key).""" self.probe_counter += 1 pset = self._pset_from_u(u, name='powell_%i_%s' % (self.probe_counter, label)) return self._route(idx, pset) def _report(self, runner): if runner.iteration % self.config.config['output_every'] == 0: self.output_results() msg = 'Completed %i of %i Powell cycles' % (runner.iteration, runner.max_iterations) (print1 if runner.iteration % 10 == 0 else print2)(msg) print2('Current best objective: %f' % runner.fval) def _start_banner(self): return ("Running local optimization by Powell's method for up to %i cycles" % self.max_iterations)
class _BrentLineSearch: """A resumable, picklable 1-D minimizer along a fixed direction (ADR-0016). Minimizes the objective along ``t``, the signed step length from the line base (``t = 0``, score ``f0``, already known), confined to the feasible interval ``[t_lo, t_hi]`` -- the step lengths that keep the point in the box, so the bound reflection never folds the slice. The method is geometric (golden) bracketing then Brent (parabolic interpolation with a golden-section fallback): mnbrak-style, but a plain golden expansion rather than NR's unbounded parabolic ``mnbrak`` because the feasible interval is finite and a boundary is a legitimate (constrained) minimum. It is driven one objective evaluation at a time so each step is one reactor batch (the optimizer pickles between steps): ``first()`` returns the first ``t`` to evaluate (or ``None`` when the point is pinned), and each ``feed(t, f)`` returns either ``('eval', t_next)`` or ``('done', best_t, best_f)``. The running best (``best_t`` / ``best_f``) is the minimum over every point evaluated, including the base, so a line search that finds no improvement leaves the point unchanged. All state is plain ``float`` / ``int`` / ``bool``. """ GOLD = 1.618034 # golden ratio: outward bracketing expansion factor CGOLD = 0.3819660 # 2 - golden: Brent's golden-section fraction TINY = 1e-20 ZEPS = 1e-10 # protects Brent's tolerance near a zero-valued minimum def __init__(self, f0, t_lo, t_hi, step, tol, max_evals): self.t_lo = float(t_lo) self.t_hi = float(t_hi) self.step = float(step) self.tol = float(tol) self.max_evals = int(max_evals) self.f0 = float(f0) self.evals = 0 self.best_t = 0.0 self.best_f = float(f0) self.phase = 'init' self._have_plus = False self._plus_t = 0.0 # the +step probe, stashed for the both-uphill bracket self._plus_f = float(f0) # Bracket-building points: `near` (inner) and `far` (outer, lowest so far). self.near_t = 0.0 self.near_f = float(f0) self.far_t = 0.0 self.far_f = float(f0) # Brent scratch (Numerical Recipes `brent`). self.a = 0.0 self.b = 0.0 self.x = 0.0 self.w = 0.0 self.v = 0.0 self.fx = 0.0 self.fw = 0.0 self.fv = 0.0 self.d = 0.0 self.e = 0.0 @staticmethod def _sign(a, b): return abs(a) if b >= 0.0 else -abs(a) def _clamp(self, t): return min(self.t_hi, max(self.t_lo, t)) def _record(self, t, f): self.evals += 1 if f < self.best_f: self.best_f = f self.best_t = t # -- entry / driver ----------------------------------------------------- # def first(self): """The first abscissa to evaluate (probe the + side; one eval decides the downhill direction), or ``None`` if there is no feasible room to move.""" if self.t_hi - self.t_lo <= self.TINY: self.phase = 'done' return None p = self._clamp(self.step) if p > self.TINY: self.phase = 'probe_plus' return p m = self._clamp(-self.step) if m < -self.TINY: self.phase = 'probe_minus' return m self.phase = 'done' return None def feed(self, t, f): self._record(t, f) if self.evals >= self.max_evals: self.phase = 'done' return ('done', self.best_t, self.best_f) if self.phase == 'probe_plus': return self._after_probe_plus(t, f) if self.phase == 'probe_minus': return self._after_probe_minus(t, f) if self.phase == 'expand': return self._after_expand(t, f) if self.phase == 'brent': self._brent_incorporate(t, f) return self._brent_propose() raise RuntimeError(f'Internal error in _BrentLineSearch: phase {self.phase!r}') # -- bracketing --------------------------------------------------------- # def _after_probe_plus(self, t, f): if f < self.f0: # downhill in +: expand outward self.near_t, self.near_f = 0.0, self.f0 self.far_t, self.far_f = t, f return self._expand_step() # + is uphill: the minimum is toward - (or at the base). Probe -. m = self._clamp(-self.step) if m < -self.TINY: self._have_plus = True self._plus_t, self._plus_f = t, f self.phase = 'probe_minus' return ('eval', m) self.phase = 'done' # no - room and + uphill -> base is the min return ('done', 0.0, self.f0) def _after_probe_minus(self, t, f): if f < self.f0: # downhill in -: expand outward self.near_t, self.near_f = 0.0, self.f0 self.far_t, self.far_f = t, f return self._expand_step() if self._have_plus: # both sides uphill: (m, 0, p) brackets 0 return self._start_brent(t, f, 0.0, self.f0, self._plus_t, self._plus_f) self.phase = 'done' # came straight here (no + room) -> base is min return ('done', 0.0, self.f0) def _expand_step(self): nxt = self._clamp(self.far_t + self.GOLD * (self.far_t - self.near_t)) if abs(nxt - self.far_t) <= self.TINY: # Hit the box boundary while still descending: the constrained minimum # is at the boundary (= far). self.phase = 'done' return ('done', self.far_t, self.far_f) self.phase = 'expand' return ('eval', nxt) def _after_expand(self, t, f): if f > self.far_f: # rose: (near, far, t) brackets far return self._start_brent(self.near_t, self.near_f, self.far_t, self.far_f, t, f) self.near_t, self.near_f = self.far_t, self.far_f # still descending: shift out self.far_t, self.far_f = t, f return self._expand_step() # -- Brent -------------------------------------------------------------- # def _start_brent(self, t1, f1, tmid, fmid, t2, f2): """Begin Brent on a 3-point bracket whose middle point ``tmid`` is lowest (``fmid``); the two ends ``t1`` / ``t2`` need not be ordered.""" self.a = min(t1, t2) self.b = max(t1, t2) self.x = self.w = self.v = tmid self.fx = self.fw = self.fv = fmid self.d = 0.0 self.e = 0.0 self.phase = 'brent' return self._brent_propose() def _brent_propose(self): x, a, b = self.x, self.a, self.b xm = 0.5 * (a + b) tol1 = self.tol * abs(x) + self.ZEPS tol2 = 2.0 * tol1 if abs(x - xm) <= tol2 - 0.5 * (b - a): self.phase = 'done' return ('done', self.x, self.fx) use_golden = True if abs(self.e) > tol1: r = (x - self.w) * (self.fx - self.fv) q = (x - self.v) * (self.fx - self.fw) p = (x - self.v) * q - (x - self.w) * r q = 2.0 * (q - r) if q > 0.0: p = -p q = abs(q) etemp = self.e self.e = self.d if not (abs(p) >= abs(0.5 * q * etemp) or p <= q * (a - x) or p >= q * (b - x)): self.d = p / q u = x + self.d if (u - a) < tol2 or (b - u) < tol2: self.d = self._sign(tol1, xm - x) use_golden = False if use_golden: self.e = (a - x) if x >= xm else (b - x) self.d = self.CGOLD * self.e step = self.d if abs(self.d) >= tol1 else self._sign(tol1, self.d) return ('eval', self._clamp(x + step)) def _brent_incorporate(self, u, fu): x = self.x if fu <= self.fx: if u >= x: self.a = x else: self.b = x self.v, self.w, self.x = self.w, self.x, u self.fv, self.fw, self.fx = self.fw, self.fx, fu else: if u < x: self.a = u else: self.b = u if fu <= self.fw or self.w == x: self.v, self.w = self.w, u self.fv, self.fw = self.fw, fu elif fu <= self.fv or self.v == x or self.v == self.w: self.v = u self.fv = fu