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. Extracted byte-identical
(M1 Step 4). Subclasses Algorithm and sets ``_is_simplex = True`` so the
inherited run() teardown always clears the Simulations directory at end-of-fit.
The start value is mapped out of sampling space by the parameter's own scale
(``FreeParameter.from_sampling_space``), so log10 and natural-log vars both work.
"""


from ..base import Algorithm
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(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``-only free-parameter rule stays in ``config.py`` (cross-config knowledge, ADR-0006 #5). 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] @register_fit_type('sim', family='optimizer', display_name='Simplex', schema=SimplexConfig, refiner=True) class SimplexAlgorithm(Algorithm): """ Implements a parallelized version of the Simplex local search algorithm, as described in Lee and Wiswall 2007, Computational Economics """ _is_simplex = True #: Internal config key the refiner start point is injected under, read by #: __init__ below and written by pybnf._refine_best_fit (the registry-driven #: refiner dispatch, #403/ADR-0015). Simplex predates StartPointOptimizer and #: keeps its own byte-identical start-point parsing, so this is declared here. START_POINT_KEY = 'simplex_start_point' def __init__(self, config, refine=False): super().__init__(config) if 'simplex_start_point' not in self.config.config: # We need to set up the initial point ourselfs self._parse_start_point() 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'] self.start_point = self.config.config['simplex_start_point'] # Set the start step for each variable to a variable-specific value, or else an algorithm-wide value 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.iteration = 0 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.simplex = [] # (score, PSet) points making up the simplex. Sorted after each iteration. # Data structures to keep track of the progress of one iteration. # In these, index 0 corresponds to the process from the worst point on the simplex, simplex[-1], index 1 to # simplex[-2], etc. self.stages = [] # Which stage of the iteration am I on? -1 initialization; 1 running first point; 2 running # second point; 3 done self.first_points = [] # Store (score, PSet) after the first run of the iteration completes self.second_points = [] # Store (score, PSet) after the second run completes, if applicable self.cases = [] # Which case number triggered after I got the score for the first point? (1, 2 or 3) self.centroids = [] # Contains dicts containing the centroid of all simplex points except the one that I am # working with self.pending = dict() # Maps PSet name (str) to the index of the point in the above 3 lists. self.refine = refine
[docs] def reset(self, bootstrap=None): super().reset(bootstrap) self.iteration = 0 self.simplex = [] self.stages = [] self.first_points = [] self.second_points = [] self.cases = [] self.centroids = [] self.pending = dict()
def _parse_start_point(self): """ Called when the start point is not passed in the config (which is when we're doing a pure simplex run, as opposed to a refinement at the end of the run) Parses the info out of the variable specs, and sets the appropriate PSet into the config. """ start_vars = [] for v in self.variables: # var/logvar/lnvar carry a single start value p1 in the parameter's sampling # space; the scale maps it back to a stored value (10**p1 for log10, exp(p1) # for ln, identity for linear). start_vars.append(v.set_value(v.from_sampling_space(v.p1))) start_pset = PSet(start_vars) self.config.config['simplex_start_point'] = start_pset
[docs] def start_run(self): print2('Running local optimization by the Simplex algorithm for %i iterations' % self.max_iterations) # Generate the initial num_variables+1 points in the simplex by moving parameters, one at a time, by the # specified 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
[docs] def got_result(self, res): pset = res.pset score = res.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 SimplexAlgorithm') if min(self.stages) == 3: # All points in current iteration completed self.iteration += 1 if self.iteration % self.config.config['output_every'] == 0: self.output_results() if self.iteration % 10 == 0: print1('Completed %i of %i iterations' % (self.iteration, self.max_iterations)) else: print2('Completed %i of %i iterations' % (self.iteration, self.max_iterations)) print2(f'Current best score: {sorted(self.simplex, key=lambda x: x[0])[0][0]:f}') # 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 SimplexAlgorithm') if self.iteration == self.max_iterations: return 'STOP' # 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: return 'STOP' # 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.config.config['simplex_stop_tol']: logger.info(f'Stopping simplex because the maximum move attempted this iteration was {max_diff}') return 'STOP' # 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))