Source code for pybnf.algorithms.optimizers.simplex

"""SimplexAlgorithm — the parallelized Nelder-Mead simplex (the ``sim`` fit type).

Also used as the refinement step after another fit. Subclasses
:class:`~pybnf.algorithms.optimizers.local_base.StartPointOptimizer` (the shared
start-point / ``u`` <-> ``PSet`` plumbing it now shares with Powell and CMA-ES) and sets
``_is_simplex = True`` so the inherited ``run()`` teardown always clears the Simulations
directory at end-of-fit. It runs from a single ``var`` / ``logvar`` start 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). The start
value is mapped out of sampling space by the parameter's own scale, so log10 and natural-log
vars both work.

Concurrency (#498). One simplex fans out only ``parallel_count`` (roughly
``n_variables - 1``) probes per generation. :class:`SimplexRunner` factors the whole
Nelder-Mead state machine into a headless, picklable per-start object so
:class:`~pybnf.algorithms.optimizers.local_multistart.LocalMultiStartOptimizer` can run
``n_starts`` of them **concurrently** (``n_starts x parallel_count`` probes in flight),
keeping the global best. A point-start / refine fit is a single runner, byte-identical to
the pre-multi-start behavior; each runner keeps Simplex's exact box-clamped reflection /
expansion / contraction arithmetic.
"""


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

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 SimplexConfig(MultiStartConfig, PyBNFConfigModel): """Simplex (Nelder-Mead) config fields, co-located with the method (ADR-0002, ADR-0006) -- the six defaulted ``simplex_*`` knobs ``__init__`` reads unconditionally. NOT here (stay pass-through extras): ``simplex_max_iterations`` and ``simplex_log_step`` are runtime-guarded (``if 'X' in config.config``) so they default at runtime, and ``simplex_start_point`` is an internal key the algorithm sets itself. The Simplex ``var``/``logvar``-vs-prior free-parameter rule stays in ``config.py`` (cross-config knowledge, ADR-0006 #5). Inherits the shared ``n_starts`` multi-start field (``MultiStartConfig``, #498): ``sim`` in box / global-start mode runs ``n_starts`` concurrent starts, keeping the global best. Values are byte-identical to the old ``GlobalConfig`` defaults. The refine->simplex cross-fit_type reach (ADR-0006/0013): ``_refine_best_fit`` runs Simplex on a *non*-simplex config and reads ``simplex_*``. Under narrowing these keys are in a ``sim`` fit's config via this schema, and in any other fit with ``refine == 1`` via the whole-schema overlay ``_build_config`` applies (the ``_REFINER_SCHEMA`` seam in ``config.py``). """ simplex_step: float = 1.0 simplex_reflection: float = 1.0 simplex_expansion: float = 1.0 simplex_contraction: float = 0.5 simplex_shrink: float = 0.5 simplex_stop_tol: float = 0.0 # simplex_max_iterations (-> max_iterations) and simplex_log_step are runtime-guarded # (if 'X' in config.config) so they default at runtime, but ARE valid sim keys -- and # valid on any refine == 1 fit via the refine->simplex overlay (#401). simplex_start_point # is omitted: the algorithm sets it itself, so it never appears in a raw config dict. RUNTIME_KEYS = frozenset({'simplex_max_iterations', 'simplex_log_step'})
[docs] class SimplexRunner: """Headless, picklable per-start Nelder-Mead simplex state machine (#498). Simplex's parallelized state machine (Lee and Wiswall 2007), 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. A runner owns one start's entire mutable state -- the simplex vertices, the per-iteration reflection / expansion / contraction bookkeeping, the pending map -- and works in :class:`~pybnf.pset.PSet` space with Simplex's exact box-clamped arithmetic; it knows nothing about the trajectory, backup, or dask. The orchestrator (:class:`~pybnf.algorithms.optimizers.local_multistart.LocalMultiStartOptimizer`) drives it:: psets = runner.start() # the initial n+1 simplex vertices nxt = runner.got(pset, score) # -> list[PSet] | DONE where ``got`` consumes one completed evaluation (routed to its vertex by PSet name) and returns the next batch of PSets to evaluate, or the :data:`DONE` sentinel when this start has spent its iteration budget or its moves fell below ``simplex_stop_tol``. All state is plain ``list`` / ``dict`` / ``int`` plus PSets, so the optimizer that owns the list of runners pickles for backup/resume exactly like the single-start machine did (ADR-0007). Index convention (unchanged from the original single-simplex code): in the per-iteration lists, index 0 corresponds to the process from the worst point on the simplex, ``simplex[-1]``, index 1 to ``simplex[-2]``, etc. """ def __init__(self, variables, rng, start_point, start_steps, max_iterations, parallel_count, alpha, gamma, beta, tau, stop_tol): self.variables = variables self.rng = rng self.start_point = start_point self.start_steps = start_steps self.max_iterations = max_iterations self.parallel_count = parallel_count self.alpha = alpha # reflection self.gamma = gamma # expansion self.beta = beta # contraction self.tau = tau # shrink self.stop_tol = stop_tol self.iteration = 0 self.fval = None # best simplex score (for progress reporting) self.stop_reason = None self.simplex = [] # (score, PSet) points making up the simplex. Sorted each iteration. # Per-iteration progress bookkeeping (see the index convention above). self.stages = [] # -1 initialization; 1 running first point; 2 running second; 3 done self.first_points = [] # (score, PSet) after the first run of the iteration completes self.second_points = [] # (score, PSet) after the second run completes, if applicable self.cases = [] # which case (1, 2, 3) triggered for the first point self.centroids = [] # centroid of the other simplex points, per working point self.pending = dict() # PSet name (str) -> index of the point in the lists above
[docs] def start(self): """The initial ``n+1`` simplex vertices: the start point, plus one per variable moved by its step size.""" self.start_point.name = 'simplex_init0' init_psets = [self.start_point] self.pending[self.start_point.name] = 0 i = 1 for v in self.variables: new_vars = [] for p in self.start_point: if p.name == v.name: new_vars.append(p.add(self.start_steps[p.name])) else: new_vars.append(p) new_pset = PSet(new_vars) new_pset.name = 'simplex_init%i' % i self.pending[new_pset.name] = i i += 1 init_psets.append(new_pset) self.simplex = [] self.stages = [-1] * len(init_psets) return init_psets
def got(self, pset, score): index = self.pending.pop(pset.name) if self.stages[index] == -1: # Point is part of initialization self.simplex.append((score, pset)) self.stages[index] = 3 elif self.stages[index] == 2: # Point is the 2nd point run within one iteration self.second_points[index] = (score, pset) self.stages[index] = 3 elif self.stages[index] == 1: # Point is the 1st point run within one iteration # We do the case-wise breakdown to pick the 2nd point, if any. self.first_points[index] = (score, pset) if score < self.simplex[0][0]: # Case 1: The point is better than the current global min. # We calculate the expansion point self.cases[index] = 1 new_vars = [] for v in self.variables: new_var = v.set_value(self.a_plus_b_times_c_minus_d(pset[v.name], self.gamma, pset[v.name], self.centroids[index][v.name], v)) new_vars.append(new_var) new_pset = PSet(new_vars) new_pset.name = 'simplex_iter%i_pt%i-2' % (self.iteration, index) self.pending[new_pset.name] = index self.stages[index] = 2 return [new_pset] elif score < self.simplex[-index-2][0]: # Case 2: The point is worse than the current min, but better than the next worst point # Note that simplex[-index-1] is the point that this one was built from, so we check [-index-2] # We don't run a second point in this case. self.cases[index] = 2 self.stages[index] = 3 if min(self.stages) < 3: return [] # Otherwise have to jump to next iteration, below. else: # Case 3: The point is not better than the next worst point. # We calculate the contraction point self.cases[index] = 3 # Work off the original or the reflection, whichever is better if score < self.simplex[-index-1][0]: a_hat = pset else: a_hat = self.simplex[-index-1][1] new_vars = [] for v in self.variables: # I think the equation for this in Lee et al p. 178 is wrong; I am instead using the analog to the # equation on p. 176 # new_dict[v] = self.centroids[index][v] + self.beta * (a_hat[v] - self.centroids[index][v]) new_var = v.set_value(self.a_plus_b_times_c_minus_d(self.centroids[index][v.name], self.beta, a_hat[v.name], self.centroids[index][v.name], v)) new_vars.append(new_var) new_pset = PSet(new_vars) new_pset.name = 'simplex_iter%i_pt%i-2' % (self.iteration, index) self.pending[new_pset.name] = index self.stages[index] = 2 return [new_pset] else: raise RuntimeError('Internal error in SimplexRunner') if min(self.stages) == 3: # All points in current iteration completed. Advance the iteration counter and # record the best score; the orchestrator's _report handles the progress line # and periodic output (the runner is headless -- no trajectory here). self.iteration += 1 self.fval = min(s[0] for s in self.simplex) # If not an initialization iteration, update the simplex based on all the results if len(self.first_points) > 0: productive = False for i in range(len(self.first_points)): si = -i-1 # Index into the simplex if self.cases[i] == 1: productive = True if self.first_points[i][0] < self.second_points[i][0]: self.simplex[si] = self.first_points[i] else: self.simplex[si] = self.second_points[i] elif self.cases[i] == 2: productive = True self.simplex[si] = self.first_points[i] elif self.cases[i] == 3: if (self.second_points[i][0] < self.first_points[i][0] and self.second_points[i][0] < self.simplex[si][0]): productive = True self.simplex[si] = self.second_points[i] elif self.first_points[i][0] < self.simplex[si][0]: self.simplex[si] = self.first_points[i] # else don't edit the simplex, neither is an improvement else: raise RuntimeError('Internal error in SimplexRunner') if self.iteration == self.max_iterations: self.stop_reason = 'reached the %i-iteration budget' % self.max_iterations return DONE # Quit after the final simplex update if not productive: # None of the points in the last iteration improved the simplex. # Now we have to contract the simplex self.simplex = sorted(self.simplex, key=lambda x: x[0]) new_simplex = [] for i in range(1, len(self.simplex)): new_vars = [] for v in self.variables: # new_dict[v] = self.tau * self.simplex[i-1][1][v] + (1 - self.tau) * self.simplex[i][1][v] new_var = v.set_value(self.ab_plus_cd(self.tau, self.simplex[0][1][v.name], 1 - self.tau, self.simplex[i][1][v.name], v)) new_vars.append(new_var) new_pset = PSet(new_vars) new_pset.name = 'simplex_iter%i_pt%i' % (self.iteration, i) self.pending[new_pset.name] = i - 1 new_simplex.append(new_pset) # Prepare for new reinitialization run # We don't need to rescore simplex[0], but the rest of the PSets are new and we do. self.stages = [-1] * len(new_simplex) self.first_points = [] self.second_points = [] self.simplex = [self.simplex[0]] return new_simplex ### # Set up the next iteration # Re-sort the simplex based on the updated objectives self.simplex = sorted(self.simplex, key=lambda x: x[0]) self._check_degeneracy() if self.iteration == self.max_iterations: self.stop_reason = 'reached the %i-iteration budget' % self.max_iterations return DONE # Extra catch if finish on a rebuild the simplex iteration # Find the reflection point for the n worst points reflections = [] self.centroids = [] # Sum of each param value, to help take the reflections sums = self.get_sums() # Returns in log space for log variables max_diff = 0. for ai in range(self.parallel_count): a = self.simplex[-ai-1][1] new_vars = [] this_centroid = dict() for v in self.variables: # Centroid of the other simplex points in sampling space u, # mapped back to a stored value (clamped by the reflection # arithmetic below). For a log variable this is the geometric # mean; the parameter owns the θ↔u transform (#412). centroid = v.from_sampling_space( (sums[v.name] - v.to_sampling_space(a[v.name])) / (len(self.simplex) - 1)) this_centroid[v.name] = centroid # new_dict[v] = centroid + self.alpha * (centroid - a[v]) new_var = v.set_value(self.a_plus_b_times_c_minus_d(centroid, self.alpha, centroid, a[v.name], v)) new_vars.append(new_var) max_diff = max(max_diff, abs(new_var.diff(a.get_param(v.name)))) self.centroids.append(this_centroid) new_pset = PSet(new_vars) new_pset.name = 'simplex_iter%i_pt%i' % (self.iteration, ai) reflections.append(new_pset) self.pending[new_pset.name] = ai # Check for stop criterion due to moves being too small if max_diff < self.stop_tol: self.stop_reason = ('simplex moves fell below simplex_stop_tol (max move %g)' % max_diff) logger.info('Stopping simplex because the maximum move attempted this ' 'iteration was %s' % max_diff) return DONE # Reset data structures to track this iteration self.stages = [1] * len(reflections) self.first_points = [None] * len(reflections) self.second_points = [None] * len(reflections) self.cases = [None] * len(reflections) return reflections else: # Wait for the rest of the parallel jobs to finish this iteration return []
[docs] def get_sums(self): """ Simplex helper function Returns a dict mapping parameter name p to the sum of the parameter value over the entire current simplex :return: dict """ # return {p: sum(point[1][p] for point in self.simplex) for p in self.simplex[0][1].keys()} sums = dict() for p in self.simplex[0][1]: # Sum in sampling space u -- the parameter applies log10 for a log # variable, identity otherwise (#412). sums[p.name] = sum(p.to_sampling_space(point[1][p.name]) for point in self.simplex) return sums
def _check_degeneracy(self): """ Check if the simplex has become degenerate (near-zero volume) and perturb vertices if so. Uses the determinant of the edge matrix to measure simplex volume. """ if len(self.simplex) < 3: return n = len(self.variables) # Build edge matrix: rows are (vertex_i - vertex_0) for i=1..n, in the appropriate space v0 = self.simplex[0][1] edge_matrix = np.zeros((len(self.simplex) - 1, n)) for i in range(1, len(self.simplex)): for j, v in enumerate(self.variables): # Edge length in sampling space u (the parameter applies log10 for # a log variable), so volume is measured where the simplex moves (#412). edge_matrix[i - 1, j] = (v.to_sampling_space(self.simplex[i][1][v.name]) - v.to_sampling_space(v0[v.name])) # Compute a scale factor from the edge lengths to make the threshold relative edge_norms = np.linalg.norm(edge_matrix, axis=1) scale = np.mean(edge_norms) if np.mean(edge_norms) > 0 else 1.0 # For a square matrix, volume ~ |det|. Check if it's near-zero relative to scale. if edge_matrix.shape[0] == edge_matrix.shape[1]: vol = abs(np.linalg.det(edge_matrix)) threshold = (1e-10 * scale) ** n else: # Non-square: use product of singular values as a volume proxy sv = np.linalg.svd(edge_matrix, compute_uv=False) vol = np.prod(sv) threshold = (1e-10 * scale) ** min(edge_matrix.shape) if vol < threshold: logger.warning(f'Simplex is nearly degenerate (volume={vol:.2e}). Perturbing vertices.') for i in range(1, len(self.simplex)): old_pset = self.simplex[i][1] new_vars = [] for v in self.variables: # Perturb in sampling space u (one Gaussian draw per variable), # then map back via the parameter's scale (#412). perturbed = v.from_sampling_space( v.to_sampling_space(old_pset[v.name]) + self.rng.normal(0, 0.01 * scale)) perturbed = max(v.lower_bound, min(v.upper_bound, perturbed)) new_vars.append(v.set_value(perturbed)) new_pset = PSet(new_vars) new_pset.name = old_pset.name self.simplex[i] = (self.simplex[i][0], new_pset)
[docs] def a_plus_b_times_c_minus_d(self, a, b, c, d, v): """ Performs the calculation a + b*(c-d), where a, c, and d are assumed to be in log space if v is in log space, and the final result respects the box constraints on v. :param a: :param b: :param c: :param d: :param v: :type v: FreeParameter :return: """ # Evaluate a + b*(c-d) in sampling space u, then map back via the # parameter's scale (#412); the result is clamped to the box. result = v.from_sampling_space( v.to_sampling_space(a) + b * (v.to_sampling_space(c) - v.to_sampling_space(d))) return max(v.lower_bound, min(v.upper_bound, result))
[docs] def ab_plus_cd(self, a, b, c, d, v): """ Performs the calculation ab + cd where b and d are assumed to be in log space if v is in log space, and the final result respects the box constraints on v :param a: :param b: :param c: :param d: :param v: :type v: FreeParameter :return: """ # Evaluate ab + cd in sampling space u, then map back via the parameter's # scale (#412); the result is clamped to the box. result = v.from_sampling_space( a * v.to_sampling_space(b) + c * v.to_sampling_space(d)) return max(v.lower_bound, min(v.upper_bound, result))
[docs] @register_fit_type('sim', family='optimizer', display_name='Simplex', schema=SimplexConfig, refiner=True, start_from_box=True) class SimplexAlgorithm(LocalMultiStartOptimizer, StartPointOptimizer): """The parallelized Nelder-Mead simplex (Lee and Wiswall 2007), as ``n_starts`` concurrent :class:`SimplexRunner`\\ 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. Sets ``_is_simplex`` so the inherited ``run()`` teardown clears the Simulations directory. """ _is_simplex = True #: Internal config key the refiner start point is injected under, resolved by the shared #: ``StartPointOptimizer._resolve_start_pset`` (box center / point / injected) and written #: by ``pybnf._refine_best_fit`` (the registry-driven refiner dispatch, #403/ADR-0015). START_POINT_KEY = 'simplex_start_point' _method_label = 'Simplex' def __init__(self, config, refine=False): super().__init__(config, refine=refine) if 'simplex_max_iterations' in self.config.config: self.max_iterations = self.config.config['simplex_max_iterations'] else: self.max_iterations = self.config.config['max_iterations'] # Per-variable start step: a variable-specific p2 (var/logvar), else the log-space or # algorithm-wide step. Shared by every start (only the start point differs). self.start_steps = dict() for v in self.variables: if v.type in ('var', 'logvar') and v.p2 is not None: self.start_steps[v.name] = v.p2 elif 'simplex_log_step' in self.config.config and v.log_space: self.start_steps[v.name] = self.config.config['simplex_log_step'] else: self.start_steps[v.name] = self.config.config['simplex_step'] self.parallel_count = min(self.config.config['population_size'], max(len(self.variables) - 1, 1)) self.alpha = self.config.config['simplex_reflection'] self.gamma = self.config.config['simplex_expansion'] self.beta = self.config.config['simplex_contraction'] self.tau = self.config.config['simplex_shrink'] self.stop_tol = self.config.config['simplex_stop_tol'] # --- LocalMultiStartOptimizer leaf hooks -------------------------------- # def _make_runner(self, start_pset, rng): # Each start gets its own Generator (Simplex's degeneracy perturbation draws from it), # so concurrent starts reproduce independently of dask's result order. return SimplexRunner(self.variables, rng, start_pset, self.start_steps, self.max_iterations, self.parallel_count, self.alpha, self.gamma, self.beta, self.tau, self.stop_tol) def _seed(self, idx, runner): return [self._tag(idx, p) for p in runner.start()] def _advance(self, idx, runner, res): # Strip the start's prefix so the runner sees exactly the clean name it generated # (Simplex routes results to vertices by PSet name, so the tag must be invisible to it). res.pset.name = self._strip(idx, res.pset.name) out = runner.got(res.pset, res.score) if out is DONE: return DONE return [self._tag(idx, p) for p in out] def _tag(self, idx, pset): """Tag ``pset`` with the start's prefix (``s<k>_``; start 0 is untagged, so a single-start run's names are byte-identical) so restarts get unique sim folders and a unique routing key, then record it as owned by start ``idx``.""" if idx > 0: pset.name = 's%d_%s' % (idx, pset.name) return self._route(idx, pset) def _strip(self, idx, name): prefix = '' if idx == 0 else 's%d_' % idx if prefix and name.startswith(prefix): return name[len(prefix):] return name def _report(self, runner): if runner.iteration % self.config.config['output_every'] == 0: self.output_results() (print1 if runner.iteration % 10 == 0 else print2)( 'Completed %i of %i Simplex iterations' % (runner.iteration, runner.max_iterations)) print2('Current best score: %f' % runner.fval) def _start_banner(self): return ('Running local optimization by the Simplex algorithm for %i iterations' % self.max_iterations)