Source code for pybnf.algorithms.optimizers.local_base

"""Shared scaffolding for the start-point local optimizers (Powell + CMA-ES, #403).

Both Powell (conjugate-direction) and CMA-ES are derivative-free, black-box local
optimizers that begin from a single point and search in *sampling space* ``u`` --
``log10`` for log-scaled parameters, linear otherwise. That is the same space the
prior and proposal arithmetic already operate in (``FreeParameter._scale``,
ADR-0003/0010), so log parameters are optimized geometrically (a multiplicative
step is an additive ``u`` step) exactly as Simplex does its log-space arithmetic.

``StartPointOptimizer`` factors out the two pieces of plumbing they share:

* **start-point resolution** -- the injected refiner start point (set by
  ``pybnf._refine_best_fit`` under :attr:`START_POINT_KEY`) when refining; else,
  **per parameter** (#583, ADR-0117): the declared start point
  (``start_point = <p> <v>``, or a ``parameter:`` record's ``initial_value:``,
  resolved into ``Configuration.start_point``); the **box center** in sampling
  space ``u`` for a bounded-support prior -- the global-start mode (#404,
  ADR-0017); or the single-value ``var`` / ``logvar`` spec of a point-start fit;
* the ``u`` <-> :class:`PSet` conversion, which maps each coordinate back to a
  stored value and reflects it into the box via :meth:`FreeParameter.set_value`
  (a no-op for the unbounded ``var`` / ``logvar`` of a point-start fit; active when
  refining or globally searching a bounded fit's parameters).

Every start-point optimizer now shares this base -- ``cmaes``, ``powell``, ``sim``,
``gntr``, ``lbfgs``, ``trf``, ``ms`` and ``profile_likelihood`` all inherit
``_resolve_start_pset`` unchanged. (Simplex once kept its own byte-identical copy of
the start-point parsing; it does not any more, and has not for some time.) These
methods plug into the run loop through ``start_run`` / ``got_result`` only
(ADR-0007); no method overrides ``run()``.
"""

from ..base import Algorithm
from ...pset import PSet

import numpy as np


[docs] class StartPointOptimizer(Algorithm): """Base for the start-point local optimizers. Subclasses implement ``start_run`` / ``got_result`` and set :attr:`START_POINT_KEY`.""" #: The internal config key the refiner start point is injected under #: (mirrors ``SimplexAlgorithm``'s ``'simplex_start_point'``). Set by each #: subclass; ``pybnf._refine_best_fit`` writes the best fit here so refinement #: starts from it instead of parsing the (refiner-irrelevant) variable specs. START_POINT_KEY = None def _resolve_start_pset(self): """The PSet the search starts from. The injected refiner start point wins outright when present: a refine begins from what the search actually **found**, not from where the user said the search should begin, and that is the whole content of a method chain. Otherwise the start is resolved **per parameter**, from the first of these that applies: * the **declared start point** -- ``start_point = <p> <v>``, or the ``initial_value:`` field of a ``parameter:`` record, both resolved and validated into ``Configuration.start_point`` (#583/#559, ADR-0117). Refused, never folded, if it left the declared box: :meth:`FreeParameter.set_value` is called with ``reflect=False`` here, so a value that slipped past config-load validation raises rather than silently reflecting to an arbitrary interior point; * the **box center** for a bounded-support prior -- the 0.5 quantile of the ``uniform_var`` / ``loguniform_var`` box in sampling space ``u`` (#404, ADR-0017). Note this is the prior's *median*, which for a truncated non-uniform prior is not its location parameter: that gap is #583 item 1, and a declared start point is the supported way to close it; * else the single ``var`` / ``logvar`` / ``lnvar`` start point Simplex uses (a single value per parameter; a log variable carries ``p1`` in its sampling space, so ``from_sampling_space`` maps it back to a stored value -- ``10**p1`` for ``logvar``, ``exp(p1)`` for ``lnvar``, identity for ``var``). Resolving per parameter rather than per fit is what lets a **partial** start point work -- the declared coordinates are pinned and the rest keep exactly the behaviour they have today, matching the contract :meth:`Algorithm._seed_start_point_pset` has implemented for the population algorithms since ADR-0043. It also fixes a mixed declaration (some parameters bounded, some not), which previously failed ``_is_box_start`` as a whole and so read **every** parameter's ``p1`` as a start value -- starting a ``uniform_var`` at its own lower bound, silently and at no log level. """ if self.START_POINT_KEY in self.config.config: return self.config.config[self.START_POINT_KEY] declared = getattr(self.config, 'start_point', None) or {} return PSet([self._start_value(v, declared) for v in self.variables]) @staticmethod def _start_value(v, declared): """One parameter's start value, as a :class:`FreeParameter` -- see :meth:`_resolve_start_pset` for the priority order.""" if v.name in declared: return v.set_value(declared[v.name], reflect=False) if v.has_bounded_support: return v.value_from_quantile(0.5) return v.set_value(v.from_sampling_space(v.p1)) def _is_box_start(self): """True when this is a standalone fit over a bounded-prior box (the global-start mode), rather than a point start or an injected refiner start. It holds when no refiner start point was injected and every variable has a bounded-support prior. Note that is *not* only ``uniform_var`` / ``loguniform_var``: a truncated prior of any family reports ``has_bounded_support``, so a bounded ``normal`` satisfies this too -- which is the mechanism behind #583's median-vs-mean displacement, since the box branch's 0.5 quantile is the prior's median rather than its location parameter. This governs only the **scatter** (how many starts, and whether there is a box to draw them from), not where start 0 is: a declared start point pins start 0 without collapsing a multi-start, so it deliberately does not appear here. Whether the fit_type is *allowed* to be here at all is enforced upstream by the ``start_from_box`` registry flag in ``config._load_variables`` (#404).""" return (self.START_POINT_KEY not in self.config.config and bool(self.variables) and all(v.has_bounded_support for v in self.variables)) def _box_widths_u(self): """Per-coordinate box widths in sampling space ``u``, ordered by ``self.variables``. Taken from :meth:`FreeParameter.prior_support`, the prior's own support in ``u``: exactly the box the center is taken from, and independent of the reflecting-bound (``b`` / ``u``) flag. This used to read ``p2 - p1`` directly. For a ``uniform`` / ``loguniform`` box that is the same number bit for bit, because there ``p1`` / ``p2`` *are* the bounds -- but for a **truncated** prior they are the family's location and scale, so the width came out as the scale and, for the entirely ordinary case ``sd == mean``, as exactly 0.0. CMA-ES squares these into its initial covariance diagonal (``cmaes.py``), so such a coordinate got a singular covariance and could never move at all (#583). An unbounded coordinate has no box width; it falls back to the prior's scale, and then to 1.0, so the caller always receives a positive, finite vector.""" widths = [] for v in self.variables: lo, hi = v.prior_support() w = hi - lo if not np.isfinite(w) or w <= 0.0: # No finite support (an untruncated prior). The family's scale is the best # available length for this coordinate; a one-parameter family has no p2. w = abs(v.p2 - v.p1) if v.p2 is not None else 0.0 if not np.isfinite(w) or w <= 0.0: w = 1.0 widths.append(w) return np.array(widths, dtype=float) def _u_from_pset(self, pset): """The parameter vector of ``pset`` in sampling space ``u`` (the inverse of :meth:`Algorithm._pset_from_u`). Delegates to the shared PSet→u bridge :meth:`Algorithm._param_vec`; kept as a named alias because it pairs with ``_pset_from_u`` in this module's ``u`` <-> PSet vocabulary. The inverse bridge ``_pset_from_u`` itself now lives on ``Algorithm``, next to ``_param_vec``, so the u-vector↔PSet conversion is centralized (#412).""" return self._param_vec(pset)