Source code for pybnf.algorithms.optimizers.simulated_annealing

"""Simulated Annealing optimizer (the ``sa`` fit type).

A clean rewrite-to-spec (M2.2, ADR-0008). ``sa`` was historically built on the
Bayesian sampler base and evaluated the *posterior* (prior + likelihood) in its
Metropolis accept -- a silent MAP estimator for normal/lognormal priors, unlike
every other PyBNF optimizer. It is now a true optimizer: it **minimizes the raw
objective**, using the prior only for the initial random draw (like de/pso/ss/sim).
Box constraints are enforced by proposal reflection (``FreeParameter.add``) plus
the parameter bounds, exactly as before.

The algorithm runs ``population_size`` independent annealing chains in parallel.
Each chain makes a fixed-magnitude random-walk proposal and a Metropolis accept at
its own inverse temperature ``beta``; an accepted *uphill* (objective-increasing)
move cools the chain (``beta += cooling``). A chain finishes when its ``beta``
reaches ``beta_max`` or it runs ``max_iterations``; the run stops once every chain
has finished. ``sa`` is deprecated (it warns at dispatch) but still runs.
"""


from ..base import Algorithm
from ...config_schema import PyBNFConfigModel
from ...pset import PSet
from ...printing import print0, print1, print2, PybnfError
from ...registry import register_fit_type

from pydantic import Field

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 SimulatedAnnealingConfig(PyBNFConfigModel): """Simulated-annealing config fields, co-located with the method (ADR-0002, ADR-0008). Standalone -- ``sa`` is an optimizer, no longer part of the MCMC family -- carrying only the four knobs the algorithm reads. The defaults are byte-identical to the values these keys held under the old MCMC-family schema; under narrowing (ADR-0013) they appear only in an ``sa`` fit's effective config. """ step_size: float = 0.2 beta: list = Field(default_factory=lambda: [1.0]) cooling: float = 0.01 beta_max: float = float('inf')
[docs] @register_fit_type('sa', family='optimizer', display_name='Simulated Annealing', deprecated=True, schema=SimulatedAnnealingConfig) class SimulatedAnnealing(Algorithm): """Simulated annealing as a true optimizer (ADR-0008): minimizes the raw objective across ``population_size`` independent annealing chains.""" def __init__(self, config): super().__init__(config) self.num_parallel = self.config.config['population_size'] self.step_size = self.config.config['step_size'] self.cooling = self.config.config['cooling'] self.beta_max = self.config.config['beta_max'] self.betas = self._initial_betas(self.config.config['beta']) # Per-chain state. current_score is the raw objective at the chain's # current point; np.inf until the chain's first result, so the first # result is always accepted. self.current_pset = [None] * self.num_parallel self.current_score = [np.inf] * self.num_parallel self.iteration = [0] * self.num_parallel self.finished = [False] * self.num_parallel self.attempts = 0 self.accepted = 0 self.staged = [] # PSets queued to resume chains after add_iterations def _initial_betas(self, beta): """One starting inverse-temperature per chain. A single value is broadcast to every chain; otherwise the list must give one value per chain (population_size).""" if len(beta) == 1: return [float(beta[0])] * self.num_parallel if len(beta) == self.num_parallel: return [float(b) for b in beta] raise PybnfError( 'For simulated annealing, the beta key must be a single value or one ' 'value per replicate (population_size = %i, but got %i beta values).' % (self.num_parallel, len(beta)))
[docs] def reset(self, bootstrap=None): super().reset(bootstrap) self.current_pset = [None] * self.num_parallel self.current_score = [np.inf] * self.num_parallel self.iteration = [0] * self.num_parallel self.finished = [False] * self.num_parallel self.attempts = 0 self.accepted = 0 self.staged = []
[docs] def start_run(self): print2('Running simulated annealing on %i independent replicates in ' 'parallel, for up to %i iterations each or until each replicate\'s ' '1/T reaches %s.' % (self.num_parallel, self.max_iterations, self.beta_max)) if self.config.config['initialization'] == 'lh': first_psets = self.random_latin_hypercube_psets(self.num_parallel) else: first_psets = [self.random_pset() for _ in range(self.num_parallel)] # ADR-0043 Phase 2: seed exactly one replicate at the initial_value point (a no-op # unless a parameter: record declares one); the others stay random for diversity. first_psets[0] = self._seed_initial_value_pset(first_psets[0]) for i in range(self.num_parallel): first_psets[i].name = 'iter0run%i' % i return first_psets
[docs] def got_result(self, res): index = self._chain_index_from_name(res.pset.name) score = res.score self.attempts += 1 first = self.current_pset[index] is None # Metropolis accept for MINIMIZING the raw objective at inverse temp beta: # ln_p_accept = min(0, current_score - score) is 0 (a downhill move, always # accepted) or negative (an uphill move, accepted w.p. exp(beta*ln_p_accept)). ln_p_accept = 0.0 if first else min(0.0, self.current_score[index] - score) if first or self.rng.random() < np.exp(ln_p_accept * self.betas[index]): self.accepted += 1 self.current_pset[index] = res.pset self.current_score[index] = score if ln_p_accept < 0.0: # Accepted an uphill move -> cool this chain. self.betas[index] += self.cooling if self.betas[index] >= self.beta_max: return self._finish_chain(index, 'beta_max was reached') proposed = self._propose(index) if proposed is None: return self._finish_chain(index, 'max_iterations was reached') proposed.name = 'iter%irun%i' % (self.iteration[index], index) if self.staged: out = [proposed] + self.staged self.staged = [] return out return [proposed]
def _finish_chain(self, index, reason): """Mark chain ``index`` done. Returns ``'STOP'`` once every chain is done, else ``[]`` (this chain idles while the others keep running).""" if not self.finished[index]: self.finished[index] = True logger.info('Finished replicate %i because %s.' % (index, reason)) print2('Finished replicate %i because %s.' % (index, reason)) if all(self.finished): if self.attempts: print0('Overall move accept rate: %f' % (self.accepted / self.attempts)) return 'STOP' return [] def _propose(self, index): """Advance chain ``index`` by one iteration and return its next PSet, or ``None`` if the chain has reached ``max_iterations``.""" self.iteration[index] += 1 if self.iteration[index] == min(self.iteration): if self.iteration[index] % self.config.config['output_every'] == 0: self.output_results() if self.iteration[index] % 10 == 0: print1('Completed iteration %i of %i' % (self.iteration[index], self.max_iterations)) if self.attempts: print2('Current move accept rate: %f' % (self.accepted / self.attempts)) logger.debug('Completed %i iterations' % self.iteration[index]) if self.iteration[index] >= self.max_iterations: logger.info('Finished replicate number %i' % index) print2('Finished replicate number %i' % index) return None return self.choose_new_pset(self.current_pset[index])
[docs] def choose_new_pset(self, oldpset): """Perturb ``oldpset`` by a fixed-magnitude (``step_size``) random-walk move. The direction is isotropic; any component that would leave the box is reflected back inside (``FreeParameter.add`` defaults to reflect=True), so the symmetric proposal keeps the plain Metropolis ratio valid.""" delta = {k: self.rng.normal() for k in oldpset.keys()} magnitude = np.sqrt(sum(x ** 2 for x in delta.values())) normalized = {k: self.step_size * delta[k] / magnitude for k in oldpset.keys()} return PSet([v.add(normalized[v.name]) for v in oldpset])
[docs] def add_iterations(self, n): """Extend the budget by ``n`` iterations, restarting any chain that had already finished at ``max_iterations`` (mirrors the resume behavior of the other iterative methods); chains that finished by reaching ``beta_max`` stay finished.""" oldmax = self.max_iterations self.max_iterations += n for index in range(self.num_parallel): if self.iteration[index] >= oldmax and self.current_pset[index] is not None \ and self.betas[index] < self.beta_max: self.finished[index] = False ps = self.choose_new_pset(self.current_pset[index]) ps.name = 'iter%irun%i' % (self.iteration[index], index) self.staged.append(ps)