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 exactly like Simplex.

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. It is **serial** -- one objective
evaluation per reactor batch -- so Powell no longer evaluates probes concurrently
(it was only ever two jobs on a single-point search); CMA-ES is the parallel
derivative-free method.

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 ...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(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. """ 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] @register_fit_type('powell', family='optimizer', display_name='Powell', schema=PowellConfig, refiner=True) class PowellAlgorithm(StartPointOptimizer): """Powell's conjugate-direction method as a picklable reactor state machine.""" #: Refiner start-point key (see StartPointOptimizer / pybnf._refine_best_fit). START_POINT_KEY = 'powell_start_point' # 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, config, refine=False): super().__init__(config) self.refine = refine self.n = len(self.variables) 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_cycles = config.config['powell_max_iterations'] else: self.max_cycles = 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 fit. The # line search clamps to these so it never drives a point out of the box # (where set_value would reflect and fold the 1-D slice). lower, upper = [], [] for v in self.variables: if v.bounded: # Box bounds in sampling space u; the parameter applies log10 for a # log variable, identity otherwise (#412). lo = v.to_sampling_space(v.lower_bound) hi = v.to_sampling_space(v.upper_bound) else: lo, hi = -np.inf, np.inf lower.append(lo) upper.append(hi) self._u_lower = np.array(lower, dtype=float) self._u_upper = np.array(upper, dtype=float) self.start_pset = self._resolve_start_pset() self._init_state() def _init_state(self): """(Re)initialize the mutable search state. Directions start as the coordinate axes; everything else is filled in by start_run.""" self.dirs = [np.eye(self.n)[i] for i in range(self.n)] self.point = None # current best point (u-space) self.fval = None # objective at self.point self.cycle = 0 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 # Batch accumulation (mirrors DE's waiting_count): collect a round of # probe jobs before advancing the state machine. self.phase = None self.batch = {} # pset name -> (u_vector, score) self.batch_remaining = 0 self.probe_counter = 0
[docs] def reset(self, bootstrap=None): super().reset(bootstrap) self._init_state()
[docs] def add_iterations(self, n): self.max_cycles += n
# --- batch plumbing ---------------------------------------------------- # def _submit(self, labeled_points, phase): """Queue a batch of probe jobs and return the PSets to run. ``phase`` is the state to dispatch to once every job in the batch has returned.""" self.phase = phase self.batch = {} self.batch_remaining = len(labeled_points) psets = [] for label, u in labeled_points: self.probe_counter += 1 name = 'powell_%i_%s' % (self.probe_counter, label) psets.append(self._pset_from_u(u, name=name)) return psets
[docs] def got_result(self, res): # Record the actual (post-reflection) evaluated point and its score, so # every internal vector is a genuinely evaluated, in-bounds point. self.batch[res.pset.name] = (self._u_from_pset(res.pset), res.score) self.batch_remaining -= 1 if self.batch_remaining > 0: return [] return self._advance()
# --- state machine ----------------------------------------------------- #
[docs] def start_run(self): print2('Running local optimization by Powell\'s method for up to %i cycles' % self.max_cycles) self.point = self._u_from_pset(self.start_pset) return self._submit([('init', self.point)], 'init')
def _advance(self): if self.phase == 'init': (self.point, self.fval), = self.batch.values() return self._begin_cycle() if self.phase == 'line': return self._after_line_point() if self.phase == 'extrap': return self._after_extrap() raise RuntimeError(f'Internal error in PowellAlgorithm: phase {self.phase!r}') 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 (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 (a one-job batch); remember ``t`` for ``_after_line_point``.""" self.ls_pending_t = t u = self.ls_base + t * self.ls_dir return self._submit([('line', u)], 'line') def _after_line_point(self): """Feed the just-evaluated ``(t, score)`` to the line minimizer and either queue its next probe or finish the line search.""" _, score = self._lookup('line') 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). The diagonal Gaussian is unaffected: it reaches the # mode on sweep 0 and stops on the (cycle>=1) second sweep, as before. if self.cycle >= 1 and \ 2.0 * (f0 - fn) <= self.stop_tol * (abs(f0) + abs(fn)) + 1e-30: logger.info('Powell converged after %i cycles (objective %.6g)' % (self.cycle + 1, fn)) return 'STOP' # Extrapolated point P_extrap = 2*P_n - P_0, evaluated for the NR criterion. p_extrap = 2.0 * self.point - self.cycle_start_point return self._submit([('extrap', p_extrap)], 'extrap') def _after_extrap(self): _, f_extrap = self._lookup('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.cycle += 1 if self.cycle % self.config.config['output_every'] == 0: self.output_results() if self.cycle % 10 == 0: print1('Completed %i of %i Powell cycles' % (self.cycle, self.max_cycles)) else: print2('Completed %i of %i Powell cycles' % (self.cycle, self.max_cycles)) print2(f'Current best objective: {self.fval:f}') if self.cycle >= self.max_cycles: return 'STOP' return self._begin_cycle() def _lookup(self, label): """Fetch the (u_vector, score) of the batch job whose name ends in ``label`` (names are ``powell_<counter>_<label>``, unique per batch).""" for name, value in self.batch.items(): if name.endswith('_' + label): return value raise RuntimeError(f'Powell: missing batch result {label!r}')
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