Source code for pybnf.algorithms.optimizers.differential_evolution

"""The Differential Evolution optimizer family (``de`` and ``ade`` fit types).

DifferentialEvolutionBase is the shared base; DifferentialEvolution (``de``) and
AsynchronousDifferentialEvolution (``ade``) subclass it. Extracted byte-identical
(M1 Step 4). The family makes no core.* call of its own — the run loop and the
execution seam are inherited from Algorithm.

With ``de_adapt_mutation = 1`` the family learns its two mutation settings during the
run from a success history (Tanabe and Fukunaga 2013; #667, ADR-0142) instead of using
``mutation_rate`` and ``mutation_factor`` as written: :class:`SuccessHistory` holds the
memory, the base draws each candidate's pair from it and records the outcome, and each
subclass says when a generation has ended.

With ``de_force_mutation`` on (the default under ``edition = 2``, and always with the
learned settings) no candidate is an exact copy of the parameter set it was built from
(#698, ADR-0143). A candidate is its base with some parameters moved by the donors'
difference, and nothing else guaranteed that any parameter moved. Under the default seed
policy a copy runs the same simulations as its base and ties it exactly, so it takes any
slot whose member is worse; in ``ade`` those copies became bases for more copies until
the whole population was one parameter set and the convergence test stopped the run.
Binomial crossover's answer is one parameter chosen in advance that is always mutated.
PyBNF crosses the mutant with the base rather than with the slot the candidate competes
for, so an accepted candidate shares its unmutated values with a member that stays in the
population, members come to share values, and the donors' difference is often zero in
the parameter chosen. The guarantee therefore chooses again among the parameters the
difference does move, and draws other donors when it moves none.

With ``de_cross_with_target = 1`` (off by default, under every edition) the mutant is
crossed with the member the candidate will replace, its target, as published differential
evolution does (#700, ADR-0144), instead of with its base: an unmutated parameter keeps the
target's own value. Crossed with the base, the values a winning candidate kept from its
base sat in two members, spread through the population, and froze any parameter whose
value every member came to share, since no donor difference can move it. Under ``all`` the
base is the target and nothing changes. The copy guarantee then moves one parameter to a
value neither the base nor the target holds, since a zero donor difference would leave the
base's value and a candidate made only of those would be a copy of its base; the learned
settings judge a success against the target.
"""


from ..base import Algorithm
from .multistart import MultiStartConfig, MultiStartOptimizer
from ... import edition
from ...config_schema import PyBNFConfigModel
from ...pset import PSet
from ...printing import print1, print2, PybnfError
from ...registry import register_fit_type

import logging
from typing import Optional

import numpy as np
from pydantic import Field
import re
import copy


# Preserve the original module logger name so log records keep the
# 'pybnf.algorithms' channel.
logger = logging.getLogger('pybnf.algorithms')


[docs] class SuccessHistory: """The success history of the mutation settings (Tanabe and Fukunaga 2013; #667, ADR-0142): a short memory of the ``mutation_rate`` / ``mutation_factor`` pairs that recently produced a candidate better than the member its mutant was crossed with, and the draw that turns that memory into the pair for the next candidate. The memory has ``size`` slots, each a (rate, factor) pair, all starting at the configured pair so a run that never records a success keeps drawing around the settings its author chose. A candidate's pair is drawn around a slot picked at random: the rate from a normal distribution with spread 0.1, clipped to [0, 1]; the factor from a Cauchy distribution with the same spread, capped at 1 and drawn again while it is not positive. The Cauchy tail is what lets a memory sitting at 0.5 still try a factor near 1 now and then, so the history can move once the search's needs change. Successes accumulate as they are reported and are folded into the memory one slot at a time when the method says a generation has ended: the rate as the mean of the successful rates weighted by how much each improved on its reference, the factor as the weighted Lehmer mean, which leans toward the larger factors because a small step succeeds more often but by less, and would otherwise pull the memory toward ever smaller steps. A generation with no success leaves the memory as it was. Plain lists and floats, so it rides the backup pickle with the rest of the optimizer (ADR-0007). ``de`` keeps one per island, ``ade`` keeps one. """ #: The spread of a draw around the remembered pair, on both settings (SHADE's 0.1). SPREAD = 0.1 def __init__(self, size, mutation_rate, mutation_factor): self.size = max(1, int(size)) self.rates = [float(mutation_rate)] * self.size self.factors = [float(mutation_factor)] * self.size self.next_slot = 0 self.pending = [] # (rate, factor, gain) of every success since the last flush
[docs] def draw(self, rng): """The (rate, factor) pair for one new candidate, from the algorithm's ``rng``.""" slot = int(rng.integers(self.size)) rate = min(1.0, max(0.0, rng.normal(self.rates[slot], self.SPREAD))) factor = self.factors[slot] + self.SPREAD * rng.standard_cauchy() while factor <= 0.0: factor = self.factors[slot] + self.SPREAD * rng.standard_cauchy() return rate, min(1.0, factor)
[docs] def record(self, rate, factor, gain): """A success: the pair built a candidate better than its reference by ``gain``, which the caller guarantees is positive and finite.""" self.pending.append((float(rate), float(factor), float(gain)))
[docs] def flush(self): """Fold the successes since the last flush into the next slot. Returns whether anything was folded (nothing is, and the memory is untouched, when no candidate succeeded).""" if not self.pending: return False rates, factors, gains = (np.asarray(col, dtype=float) for col in zip(*self.pending)) weights = gains / gains.sum() self.rates[self.next_slot] = float(np.dot(weights, rates)) self.factors[self.next_slot] = float(np.dot(weights, factors ** 2) / np.dot(weights, factors)) self.next_slot = (self.next_slot + 1) % self.size self.pending = [] return True
[docs] def means(self): """The memory's mean (rate, factor), for reporting.""" return float(np.mean(self.rates)), float(np.mean(self.factors))
[docs] class DEFamilyConfig(PyBNFConfigModel): """Config fields shared by the whole DE family, co-located with ``DifferentialEvolutionBase`` (ADR-0002, ADR-0006) -- exactly the keys that base ``__init__`` reads, and nothing more. Neither ``de`` nor ``ade`` registers against this base directly: each extends it with its own subclass carrying the shared ``n_starts`` multi-start field (``MultiStartConfig``) -- ``de`` also adds the island/migration fields (:class:`DifferentialEvolutionConfig`), ``ade`` adds nothing else (:class:`AsyncDEConfig`). Keeping the shared base itself key-minimal preserves the ADR-0006 "``ade`` adds no keys to the family base" seam. Values are byte-identical to the old ``GlobalConfig`` defaults. """ mutation_rate: float = 0.5 mutation_factor: float = 0.5 stop_tolerance: float = 0.002 # The DE-family convergence tolerance as an absolute range in OBJECTIVE units # (#561, ADR-0115): the population is converged when the spread of its finite # fitnesses, ``max - min``, has collapsed to within this value. ``stop_tolerance`` # was a *ratio* (``max/min``), which only reads as convergence on a positive # objective bounded below by 0; ``de_tolfun`` measures the range directly, so it # is well-defined for a likelihood objective too. It is a range in objective units # where ``stop_tolerance`` was dimensionless, so it gets its own key -- and falls # back to ``stop_tolerance`` when unset (mirroring ``cmaes_tolfun`` -> ``cmaes_stop_tol``, # ADR-0106), so an existing config keeps the threshold magnitude it had. de_tolfun: Optional[float] = Field(default=None, ge=0.0) de_strategy: str = 'rand1' # Learn ``mutation_rate`` and ``mutation_factor`` during the run from a success # history (#667, ADR-0142) instead of using the pair above as written. Off by # default: an existing configuration runs exactly as it did. The configured pair is # where the learning starts. de_adapt_mutation: int = 0 # How many generations' worth of successful settings the history remembers (the # memory size H of Tanabe and Fukunaga; L-SHADE's 6). de_adapt_memory: int = Field(default=6, ge=1) # Never propose a candidate that is an exact copy of its base (#698, ADR-0143): one # parameter the donors' difference moves is always mutated. Unset resolves to on under # edition 2 and off under the legacy edition, whose contract is that an unchanged conf # keeps behaving as it always has; the learned settings above turn it on regardless. de_force_mutation: Optional[int] = None # Cross the mutant with the member the candidate will replace, its target, as published # differential evolution does, rather than with its base (#700, ADR-0144): an unmutated # parameter keeps the target's own value, so values stop spreading from the base into # other members. Off under every edition: it removed the frozen parameters of the base # crossover on analytical targets, but did worse on two real tutorial models and no better # on the stochastic recovery benchmark. de_cross_with_target: int = 0
[docs] class DifferentialEvolutionConfig(MultiStartConfig, DEFamilyConfig): """``de``-specific config: the island/migration fields only synchronous DE reads (``ade`` is async and ignores them), plus the shared ``n_starts`` multi-start field (``MultiStartConfig``, #498). The ``n_starts`` key rides each method's own subclass, not the shared ``DEFamilyConfig`` base, so the ADR-0006 "``ade`` adds no keys to the family base" seam stays intact -- ``ade`` gets ``n_starts`` through its own :class:`AsyncDEConfig` (#501). Demonstrates the shared-base pattern the MCMC family reuses (ADR-0006).""" islands: int = 1 migrate_every: int = 20 num_to_migrate: int = 3
[docs] class AsyncDEConfig(MultiStartConfig, DEFamilyConfig): """``ade``-specific config: the shared DE family fields plus the ``n_starts`` multi-start field (``MultiStartConfig``, #498/ADR-0071), and nothing else. ``ade`` opts into multi-start through its own subclass -- mirroring how ``de`` extends the shared ``DEFamilyConfig`` base (:class:`DifferentialEvolutionConfig`) -- so the ADR-0006 "``ade`` adds no keys to the family base" seam stays intact: ``n_starts`` rides this subclass, not the base ``de`` and ``ade`` share. Unlike ``de``, ``ade`` has no islands or migrations, so this adds no fields of its own -- it is exactly ``DEFamilyConfig`` + ``n_starts`` (#501)."""
[docs] class DifferentialEvolutionBase(Algorithm): def __init__(self, config): super().__init__(config) self.mutation_rate = config.config['mutation_rate'] self.mutation_factor = config.config['mutation_factor'] self.max_iterations = config.config['max_iterations'] self.stop_tolerance = config.config['stop_tolerance'] # The convergence tolerance the run actually uses (#561, ADR-0115): an # absolute range in objective units. Unset ``de_tolfun`` falls back to # ``stop_tolerance`` -- the single knob the test used before -- so an existing # config keeps its threshold magnitude. # # Whether it was set MATTERS, not just what it is (#648, ADR-0127). An explicit # ``de_tolfun`` is a range the author chose in their own objective's units and is # always honoured as one. An unset one is the legacy ``stop_tolerance``, which # was a dimensionless RATIO, and reading a ratio's magnitude as a range is what # #648 is: on an objective of 2e-05 the ratio stops at a spread of 4e-08 while # the range stops at 0.002, roughly fifty thousand times looser. configured_tolfun = config.config['de_tolfun'] self.de_tolfun_is_explicit = configured_tolfun is not None self.de_tolfun = (self.stop_tolerance if configured_tolfun is None else float(configured_tolfun)) self.strategy = config.config['de_strategy'] options = ('rand1', 'rand2', 'best1', 'best2', 'all1', 'all2') if self.strategy not in options: raise PybnfError('Invalid differential evolution strategy "{}". Options are: {}'.format(self.strategy, ','.join(options))) # How many distinct members each candidate is built from, which is the floor on # the population (per island): the '1' strategies draw a base and one donor pair # (3), the '2' strategies a base and two donor pairs (5). new_individual draws # exactly this many with replace=False, so a smaller population makes that draw # raise (#708); the subclasses clamp population_size up to it and each is the # single source of truth for the other. self.min_population = 3 if '1' in self.strategy else 5 # The learned mutation settings (#667, ADR-0142): whether to learn them, how much # to remember, one success history per island (``ade``: one), and the settings of # every candidate still in flight, keyed by the candidate, since ``ade`` returns # results in whatever order the simulations finish. self.adapt_mutation = bool(config.config['de_adapt_mutation']) self.adapt_memory = int(config.config['de_adapt_memory']) self.histories = [] self._trial_settings = dict() # Whether a candidate always mutates a parameter its donors move, so it is never an # exact copy of its base (#698, ADR-0143). self.force_mutation = self._resolve_force_mutation() # Whether the mutant is crossed with the member the candidate will replace rather # than with its base (#700, ADR-0144); off unless asked for. self.cross_with_target = bool(config.config['de_cross_with_target']) def _resolve_force_mutation(self): """Whether the copy guarantee is on (#698, ADR-0143). The learned settings always need it, since a learned rate can sit near 0 where most candidates would otherwise be copies, and a copy can never be a success; otherwise an explicit ``de_force_mutation`` wins, and unset it is on under a modern edition and off under the legacy one, whose contract is that an unchanged conf keeps behaving as it always has (ADR-0031).""" configured = self.config.config.get('de_force_mutation') if self.adapt_mutation: if configured is not None and not int(configured): message = ('de_force_mutation = 0 is set, but de_adapt_mutation = 1 learns a ' 'mutation rate that can sit near 0, where most candidates would be ' 'copies of the parameter set they were built from; every candidate ' 'still mutates at least one parameter') logger.warning(message) print1('Note: ' + message) return True if configured is not None: return bool(int(configured)) return edition.is_modern(edition.resolve_edition(self.config.config.get('edition')))
[docs] def new_individual(self, individuals, base_index=None, island=0, target_index=None, name=None): """ Create a new individual for the specified island, according to the set strategy :param individuals: The island's current population :param base_index: The index to use for the new individual, or None for a random index. :param island: Which island the individual is for. With ``de_adapt_mutation`` on, that island's success history supplies the mutation settings and learns from the outcome (``ade`` has a single island, 0). :param target_index: The index of the member the new individual will compete with. With ``de_cross_with_target`` on, its unmutated parameters are that member's; without an index, or with the key off, they are the base's. :param name: The candidate's name, assigned here rather than by the caller because it is the key its learned-mutation record is filed under, and the record is written below (#812). A caller that only wants the mutation arithmetic may leave it out; a caller putting the candidate in flight must not, since the name is its identity. :return: """ # Choose a starting parameter set (either a random one or the base_index specified) # and others to cross over (always random). The number of distinct members a # candidate needs -- a base plus one donor pair ('1') or two ('2') -- is the same # count the population is floored at (see min_population), so the subclasses # guarantee this draw has enough to draw from (#708). pickn = self.min_population # Choose pickn random unique indices, or if base_index was given, choose base_index followed by pickn-1 unique # indices picks = self.rng.choice(len(individuals), pickn, replace=False) if base_index is not None: if base_index in picks: # If we accidentally picked base_index, replace it with picks[0], preserving uniqueness in our list iswitch = list(picks).index(base_index) picks[iswitch] = picks[0] # Now overwrite picks[0] with base_index. If we have base_index, picks[0] was an "extra pick" we only needed # in case we sampled base_index and had to replace it. picks[0] = base_index base = individuals[picks[0]] others = [individuals[p] for p in picks[1:]] # The member the mutant is crossed with, whose values the unmutated parameters keep: # the target the candidate will replace (#700, ADR-0144), or, as before, the base. crossed = picks[0] if self.cross_with_target and target_index is not None: crossed = target_index target = individuals[crossed] # The mutation settings for this candidate: the configured pair, or a draw from the # island's success history (#667, ADR-0142). Off, nothing here touches the rng. rate, factor = self.mutation_rate, self.mutation_factor if self.adapt_mutation: rate, factor = self.histories[island].draw(self.rng) # The copy guarantee (#698, ADR-0143): one parameter is always moved by the donors to a # value that neither the base nor the member it is crossed with holds, so the candidate # is an exact copy of neither, which under the default seed policy would tie it # exactly. It can change the donors. Off, nothing here touches the rng. forced, forced_value = None, None if self.force_mutation: others, forced, forced_value = self._forced_parameter( individuals, picks[0], base, target, others, factor) # Iterate through parameters; decide whether to mutate or leave the same. new_pset_vars = [] for i, p in enumerate(base): if self.rng.random() < rate or i == forced: if i == forced: new_pset_vars.append(forced_value) else: new_pset_vars.append(p.add(self._difference(p.name, others, factor))) else: new_pset_vars.append(target.get_param(p.name)) new_pset = PSet(new_pset_vars) new_pset.name = name if self.adapt_mutation: # What the outcome will be judged against: the fitness, now, of the member the # mutant was crossed with, which is what these settings were applied to (see # _note_trial_result). reference_fitness = float(self._island_fitnesses(island)[crossed]) # Filed under the name, not the candidate. Keyed by the candidate, two that # landed on identical parameters were one key and the second write dropped the # first one's record -- which #775 answered by moving one of them off the # other, and #812 answers by giving the register an identity that does not # depend on where the candidate landed. Names being unique per in-flight # candidate, ``ade`` no longer drops a record either (#730). self._trial_settings[new_pset.name] = (rate, factor, reference_fitness, island) return new_pset
def _difference(self, name, donors, factor): """How far the donors move parameter ``name``, in its sampling space: ``factor`` times the difference between the first two donors, plus the same for the second two under a ``2`` strategy.""" step = factor * donors[0].get_param(name).diff(donors[1].get_param(name)) if '1' not in self.strategy: step = step + factor * donors[2].get_param(name).diff(donors[3].get_param(name)) return step def _moved(self, param, kept, donors, factor): """``param`` (the base's) moved by the donors' difference, or ``None`` when that is no move: when the difference does not change the base's value (it is zero, the usual case, settled before the moved parameter is built, since building it is the costly part; or too small to change the value in floating point), or when the moved value is ``kept``, the value the candidate keeps where it is not mutated. Crossed with the base, ``kept`` is the base's own value and the two tests are one. Crossed with the target they are two, and both matter: a zero difference would leave the base's value, so a candidate whose every mutated parameter had one would be an exact copy of its base.""" step = self._difference(param.name, donors, factor) if step == 0.0: return None moved = param.add(step) if moved.value == param.value or moved.value == kept.value: return None return moved def _forced_parameter(self, individuals, base_pick, base, crossed, donors, factor): """The parameter a candidate always mutates, with the donors it is mutated by (#698, ADR-0143). ``base_pick`` is the base's position in ``individuals``, and ``crossed`` is the member the mutant is crossed with: the base, or the target (#700). Returns ``(donors, position, moved parameter)``. One parameter is chosen at random, the draw binomial crossover makes. If the donors' difference does not move it (see :meth:`_moved`: the donors share its value, or the moved value is the one the candidate keeps from ``crossed`` anyway), the choice is made again among the parameters it does move (drawn at random from those with a nonzero difference, dropping any the difference still does not move), so each parameter it moves is equally likely to be the one the candidate is sure to change. If it moves none (the donors are one parameter set, or their two differences cancel), other donors are drawn from the members other than the base, up to as many times as the population has members. If none of them moves anything either, the position is ``None`` and the candidate is left a copy of ``crossed``. That many failures in a row are likely only when all but one or two of the other members are one parameter set, so only a population that has all but collapsed can still propose a copy. Otherwise the candidate differs from its base and from ``crossed`` in the parameter moved. When the first parameter chosen changes, as every parameter does until members share values, this is exactly the draw the learned settings made before (#667), so such a run is unchanged until the first time the choice is made again. """ params = list(base) kept = [crossed.get_param(p.name) for p in params] forced = int(self.rng.integers(len(params))) moved = self._moved(params[forced], kept[forced], donors, factor) if moved is not None: return donors, forced, moved pool = [k for k in range(len(individuals)) if k != base_pick] for attempt in range(len(individuals) + 1): if attempt: donors = [individuals[pool[k]] for k in self.rng.choice(len(pool), len(donors), replace=False)] nonzero = [i for i, p in enumerate(params) if self._difference(p.name, donors, factor) != 0.0] while nonzero: position = nonzero.pop(int(self.rng.integers(len(nonzero)))) moved = self._moved(params[position], kept[position], donors, factor) if moved is not None: return donors, position, moved return donors, None, None # --- the learned mutation settings (#667, ADR-0142) ------------------------- # def _island_fitnesses(self, island): """The fitness list of ``island``'s current population, parallel to the ``individuals`` list ``new_individual`` is given. Subclasses provide it.""" raise NotImplementedError def _reset_adaptation(self, n_islands): """Start the success histories over, one per island at the configured pair, with no candidate in flight. Called wherever the search state resets, so each start of a multi-start run learns from its own population rather than inheriting the settings the previous start ended on, which suit a search that is finishing, not one that is beginning.""" self.histories = [SuccessHistory(self.adapt_memory, self.mutation_rate, self.mutation_factor) for _ in range(n_islands)] self._trial_settings = dict() def _note_trial_result(self, pset, score): """Report a finished candidate's score to the history that built it. A success is a candidate that scored strictly better, by a finite amount, than the member its mutant was crossed with, the member whose values it keeps where it was not mutated; the improvement is the success's weight when the history folds it in. That member is the reference because it is what the settings were applied to. Crossed with the target (#700, ADR-0144), as published differential evolution crosses, it is the member the candidate competes with, and this is SHADE's own rule. Crossed with the base, under the ``rand`` and ``best`` strategies it is not: judged against the slot, a candidate that copies a better base would win about half its contests by a wide margin without its settings having done anything, teaching the history that a rate near 0 is best (ADR-0142), so it is judged against the base. A failed simulation on either side is not evidence about the settings, since anything finite beats infinity, so it records nothing. A candidate this history did not build -- the initial population -- records nothing either; it is looked up by name, and its name is not one the register holds. A duplicate used to fall in that last group too: keyed by the candidate, two on identical parameters were one key and one record was lost (#730 for ``ade``, which tolerated it; #775 for ``de``, which moved one of them off the other to avoid it). The register is keyed by name now, so neither happens (#812). """ record = self._trial_settings.pop(pset.name, None) if record is None: return rate, factor, reference_fitness, island = record if np.isfinite(reference_fitness) and np.isfinite(score) and score < reference_fitness: self.histories[island].record(rate, factor, reference_fitness - score) def _flush_adaptation(self, island): """A generation of ``island`` has ended: fold its successes into the history.""" if self.adapt_mutation and self.histories: history = self.histories[island] n = len(history.pending) if history.flush(): logger.debug('Island %d folded %d successful candidate(s) into its mutation history; ' 'memory now averages rate %.3f, factor %.3f' % (island, n, *history.means())) def _learned_settings(self): """The (rate, factor) the histories average to, over their slots and the islands, for the progress report; the configured pair before any history exists.""" if not self.histories: return float(self.mutation_rate), float(self.mutation_factor) rates, factors = zip(*(history.means() for history in self.histories)) return float(np.mean(rates)), float(np.mean(factors))
[docs] def output_results(self, name='', no_move=False): """As the base does, and at the end of the run say what the mutation settings were learned to be, so a run at normal verbosity hears it too (the per-iteration line prints at verbosity 2 only) and the pair can be carried into a fixed-setting run.""" super().output_results(name, no_move) if name == 'final' and self.adapt_mutation: rate, factor = self._learned_settings() message = ('Mutation settings learned by the end of the run: rate %.2f, factor %.2f ' '(the run started from mutation_rate %g, mutation_factor %g)' % (rate, factor, self.mutation_rate, self.mutation_factor)) logger.info(message) print1(message)
def _population_converged(self): """The DE-family convergence test (#561, ADR-0115; #648, ADR-0127), shared by ``de`` and ``ade``. The population is converged when the spread of its objective values, ``max - min`` over the **finite** fitnesses only, has collapsed to within the threshold. What that threshold *means* depends on where it came from, because the two sources carry different units (#648, ADR-0127): * An **explicit** ``de_tolfun`` is a range in objective units, chosen by someone who can see their own objective's scale. It is used exactly as written, on any sign of objective. * An **unset** ``de_tolfun`` falls back to ``stop_tolerance``, which has always been a **dimensionless ratio**. Where the objective is positive it is still read as one, as a spread relative to the best member: ``max - min <= tol * min``, which is the ``max / min <= 1 + tol`` this family used before #561, written without the division. Where the objective is not positive a ratio means nothing, so the same number is read as an absolute range, which is what #561 needs. ADR-0115 justified reading the legacy magnitude as a range by arguing that "an absolute range at the same magnitude is a stricter, well-defined stop". That holds only for an objective above 1. Below it the range is *looser*, without limit: at the 2e-05 a well-scaled sum-of-squares fit reaches, a 0.002 range is fifty thousand times looser than the 0.002 ratio it replaced, so the population satisfies it long before the parameters have separated. The fit stops early and reports an excellent objective at the wrong point (#648). What #561 actually found is that the ratio is a convergence statement only when the objective is positive and bounded below by 0 (a chi-square, an SSE). On a likelihood objective -- a negative log-likelihood, unbounded below -- an all-negative population lands the ratio in ``(0, 1]`` and it fires at generation 0 regardless of the spread; and any single ``inf``-scored failed simulation, paired with a negative fitness, makes the ratio ``-inf`` and defeats *every* threshold. Both failure modes are the negative sign, not a real convergence, so the family was unrunnable on any estimated-sigma likelihood fit. ADR-0115 answered that by dropping the ratio everywhere. ADR-0127 narrows the answer to where the defect is: the ratio is kept where it is well defined, and the range is used where it is not. Neither reading is dropped, and no fit that worked under either one changes. Ignoring the non-finite entries is what makes a failed simulation unable to either satisfy or defeat the test, on both branches. Scaling by ``low`` rather than dividing by it subsumes the original ``!= 0`` guard against an all-zero population, which ``ade`` never had -- an all-zero population takes the range branch anyway, since ``low`` is not positive. An empty finite set (every member still ``inf``, e.g. before the first result) is never "converged". """ finite = np.asarray(self.fitnesses, dtype=float) finite = finite[np.isfinite(finite)] if not finite.size: return False low = finite.min() spread = finite.max() - low if self.de_tolfun_is_explicit or low <= 0.0: return bool(spread <= self.de_tolfun) # A positive objective under the legacy ratio: scale the threshold by the best # member rather than dividing by it, so an all-zero population cannot divide. return bool(spread <= self.stop_tolerance * low)
[docs] def start_run(self): return NotImplementedError("start_run() not implemented in DifferentialEvolutionBase class")
[docs] def got_result(self, res): return NotImplementedError("got_result() not implemented in DifferentialEvolutionBase class")
[docs] @register_fit_type('de', family='optimizer', display_name='Differential Evolution', schema=DifferentialEvolutionConfig) class DifferentialEvolution(MultiStartOptimizer, DifferentialEvolutionBase): """ Implements the parallelized, island-based differential evolution algorithm described in Penas et al 2015. In some cases, I had to make my own decisions for specifics I couldn't find in the original paper. Namely: At each migration, a user-defined number of individuals are migrated from each island. For each individual, a random index is chosen; the same index for all islands. A random permutation is used to redistribute individuals with that index to different islands. Each island performs its migration individually, on the first callback when all islands are ready for that migration. It receives individuals from the migration iteration, regardless of what the current iteration is. This can sometimes lead to wasted effort. For example, suppose migration is set to occur at iteration 40, but island 1 has reached iteration 42 by the time all islands reach 40. Individual j on island 1 after iteration 42 gets replaced with individual j on island X after iteration 40. Some other island Y receives individual j on island 1 after iteration 40. """ # This fit runs one generation at a time and waits for the whole generation to finish # before proposing the next, so some idle workers toward the end of each generation are # expected (#621). The asynchronous variant below does not wait, so it leaves this False. waits_for_full_generation = True def __init__(self, config): """ Initializes algorithm based on the config object. The following config keys specify algorithm parameters. For move information, see config_documentation.txt population_size num_islands max_iterations mutation_rate mutation_factor de_adapt_mutation de_adapt_memory migrate_every num_to_migrate """ super().__init__(config) self.num_islands = config.config['islands'] self.num_per_island = int(config.config['population_size'] / self.num_islands) if self.num_per_island < self.min_population: self.num_per_island = self.min_population if self.num_islands == 1: print1('Differential evolution with de_strategy "%s" requires a population size of at least %i. ' 'Increased the population size to %i.' % (self.strategy, self.min_population, self.min_population)) logger.warning('Increased population size to minimum allowed value of %i' % self.min_population) else: print1('Island-based differential evolution with de_strategy "%s" requires a population size of at ' 'least %i times the number of islands. Increased the population size to %i.' % (self.strategy, self.min_population, self.min_population * self.num_islands)) logger.warning('Increased population size to minimum allowed value of %i per island' % self.min_population) if config.config['population_size'] % config.config['islands'] != 0: logger.warning('Reduced population_size to %i to evenly distribute it over %i islands' % (self.num_islands * self.num_per_island, self.num_islands)) self.migrate_every = config.config['migrate_every'] if self.num_islands == 1: self.migrate_every = np.inf self.num_to_migrate = config.config['num_to_migrate'] # Maps the NAME of each in-flight candidate to its location (island, individual_i). # # Keyed by name and not by the candidate, because a PSet hashes and compares by # value and two candidates on identical parameters are a legitimate state -- the # ordinary end state of a converging population, and likelier still under # ``de_adapt_mutation``, where a low drawn rate leaves most parameters unmutated. # Keyed by value the map could hold only one of them: the second registration # overwrote the first, a slot was credited with another slot's score, and since a # generation does not re-register a key until all of its results are in, the # duplicate's result had nowhere to go and ended the fit with ``KeyError`` (#812). # The proposal path answered that by perturbing one candidate off the other, in a # loop whose fixed 1e-6 step cannot move a linear parameter above ~1e10 at all, so # it never terminated (#721 in ``pso``, the same code here). # # A name is unique per in-flight candidate by construction and says which slot the # result belongs to, so each of two coincident candidates is scored into its own # slot. ``ade`` has always read its slot straight out of the name, which is why it # never had either failure; ``simplex``, ``scatter_search`` and (since #811) # ``pso`` key their in-flight registers the same way. self.island_map = dict() self.iter_num = [0] * self.num_islands # Count the number of completed iterations on each island self.waiting_count = [] # Count of the number of PSets that are pending evaluation on the current iteration of each island. self.individuals = [] # Nested list; individuals[i][j] gives individual j on island i. self.proposed_individuals = [] # Nested list of the same shape, gives individuals proposed for replacement in next generation self.fitnesses = [] # Nested list of same shape, gives fitness of each individual self.migration_ready = [0] * self.num_islands # What migration number is each island ready for self.migration_done = [0] * self.num_islands # What migration number has each island completed # These variables store data related to individual migrations. # Each one has migration number as keys. When the first island starts migration, the required entries are # created. When the last island completes migration, they are deleted to keep these structures small. self.migration_transit = dict() # Store (PSet, fitness) tuples here that are getting migrated - one list per island self.migration_indices = dict() # Which individual numbers are migrating in migration i - a single tuple for # each migration, used for all islands self.migration_perms = dict() # How do we rearrange between islands on migration i? # For each migration, a list of num_to_migrate permutations of range(num_islands)
[docs] def reset(self, bootstrap=None): super().reset(bootstrap) self._reset_search_state()
def _reset_search_state(self): """Clear all search-specific state (populations, per-island iteration and migration counters) WITHOUT touching the trajectory -- so multi-start's :meth:`_search_start_run` can begin a fresh, independent DE run each start while the trajectory keeps accumulating the global best across starts (#498). Shared by ``reset`` (which also resets the trajectory via ``super().reset``) and by each multi-start.""" self.island_map = dict() self.iter_num = [0] * self.num_islands self.waiting_count = [] self.individuals = [] self.proposed_individuals = [] self.fitnesses = [] self.migration_ready = [0] * self.num_islands self.migration_done = [0] * self.num_islands self.migration_transit = dict() self.migration_indices = dict() self.migration_perms = dict() self._reset_adaptation(self.num_islands) def _island_fitnesses(self, island): return self.fitnesses[island] def _candidate_name(self, gen, island, j): """The name of the candidate in slot ``j`` of ``island`` at generation ``gen``. One place, because the name is now the key ``island_map`` and the learned-mutation register are filed under (#812), and two formats that drift apart would be two candidates the fit cannot tell apart. A single-island run keeps the island out of the name, so its simulation folders are named exactly as they always were.""" if self.num_islands == 1: return 'gen%iind%i' % (gen, j) return 'gen%iisl%iind%i' % (gen, island, j) def _search_start_run(self): # Reset every search counter first (the per-island iteration and migration # state start_run itself does not touch), so a multi-start restart begins a # genuinely fresh DE run rather than resuming at the previous start's iteration. self._reset_search_state() if self.num_islands == 1: print2('Running Differential Evolution with population size %i for up to %i iterations' % (self.num_per_island, self.max_iterations)) else: print2('Running island-based Differential Evolution with %i islands of %i individuals each, ' 'for up to %i iterations' % (self.num_islands, self.num_per_island, self.max_iterations)) # Initialize random individuals if self.config.config['initialization'] == 'lh': psets = self.random_latin_hypercube_psets(self.num_islands*self.num_per_island) self.proposed_individuals = [psets[i * self.num_per_island: (i + 1) * self.num_per_island] for i in range(self.num_islands)] else: self.proposed_individuals = [[self.random_pset() for i in range(self.num_per_island)] for j in range(self.num_islands)] # ADR-0043 Phase 2: seed exactly one member of the initial population at the # initial_value point (a no-op unless a parameter: record declares one). One # member only -- the rest stay random so the islands keep their diversity. self.proposed_individuals[0][0] = self._seed_start_point_pset(self.proposed_individuals[0][0]) # Initialize the individual list to empty, will be filled with the proposed_individuals once their fitnesses # are computed. self.individuals = [[None for i in range(self.num_per_island)] for j in range(self.num_islands)] # Set all fitnesses to Inf, guaranteeing a replacement by the first proposed individual self.fitnesses = [[np.inf for i in range(self.num_per_island)] for j in range(self.num_islands)] for i in range(len(self.proposed_individuals)): for j in range(len(self.proposed_individuals[i])): # Named before it is registered, the name being the key. self.proposed_individuals[i][j].name = self._candidate_name(0, i, j) self.island_map[self.proposed_individuals[i][j].name] = (i, j) self.waiting_count = [self.num_per_island] * self.num_islands return [ind for island in self.proposed_individuals for ind in island] def _search_got_result(self, res): """ Called when a simulation run finishes This is not thread safe - the Scheduler must ensure only one process at a time enters this function. (or, I should rewrite this function to make it thread safe) :param res: Result object :return: """ pset = res.pset score = res.score # Tell the success history how the candidate did, against the base it was built # from (a no-op unless de_adapt_mutation built it). self._note_trial_result(pset, score) # Calculate the fitness of this individual, and replace if it is better than the previous one. island, j = self.island_map.pop(pset.name) fitness = score if fitness <= self.fitnesses[island][j]: self.individuals[island][j] = pset self.fitnesses[island][j] = fitness self.waiting_count[island] -= 1 # Determine if the current iteration is over for the current island if self.waiting_count[island] == 0: self.iter_num[island] += 1 # The island's generation is over: fold its successes into its history before # the next generation draws from it. self._flush_adaptation(island) if min(self.iter_num) == self.iter_num[island]: # Last island to complete this iteration if self.iter_num[island] % self.config.config['output_every'] == 0: self.output_results() if self.iter_num[island] % 10 == 0: print1('Completed %i of %i iterations' % (self.iter_num[island], self.max_iterations)) else: print2('Completed %i of %i iterations' % (self.iter_num[island], self.max_iterations)) print2('Current population fitnesses:') for l in self.fitnesses: print2(sorted(l)) if self.adapt_mutation: print2('Mutation settings learned so far: rate %.2f, factor %.2f' % self._learned_settings()) if self.iter_num[island] == self.max_iterations: # Submit no more jobs for this island # Once all islands reach this, simulation is over. if min(self.iter_num) == self.max_iterations: return 'STOP' else: return [] if self.iter_num[island] % self.migrate_every == 0: # This island prepares for migration migration_num = int(self.iter_num[island] / self.migrate_every) if max(self.migration_ready) < migration_num: # This is the first island to reach this migration. # Need to set global parameters for this migration. self.migration_transit[migration_num] = [list() for i in range(self.num_islands)] self.migration_indices[migration_num] = self.rng.choice(self.num_per_island, size=self.num_to_migrate, replace=False) self.migration_perms[migration_num] = [self.rng.permutation(self.num_islands) for i in range(self.num_to_migrate)] logger.debug('Island %i just set up the migration.' % island) # Send the required PSets to migration_transit for j in self.migration_indices[migration_num]: self.migration_transit[migration_num][island].append((self.individuals[island][j], self.fitnesses[island][j])) # Tell other islands that this one is ready for this migration. self.migration_ready[island] = migration_num if self.migration_done[island] < min(self.migration_ready): # This island performs a migration logger.debug('Island %i is migrating!' % island) migration_num = self.migration_done[island] + 1 # Fetch the appropriate new individuals from migration_transit for migrater_index in range(self.num_to_migrate): j = self.migration_indices[migration_num][migrater_index] # Index of the individual newisland = self.migration_perms[migration_num][migrater_index][island] self.individuals[island][j], self.fitnesses[island][j] = \ self.migration_transit[migration_num][newisland][migrater_index] logger.debug('Island %i gained new individual with fitness %f' % (island, self.fitnesses[island][j])) self.migration_done[island] = migration_num if min(self.migration_done) == migration_num: # This is the last island to complete this migration # Delete the migration data to free space. del self.migration_transit[migration_num] del self.migration_perms[migration_num] del self.migration_indices[migration_num] # Set up the next generation best = np.argmin(self.fitnesses[island]) for jj in range(self.num_per_island): name = self._candidate_name(self.iter_num[island], island, jj) if 'best' in self.strategy: new_pset = self.new_individual(self.individuals[island], best, island=island, target_index=jj, name=name) elif 'all' in self.strategy: new_pset = self.new_individual(self.individuals[island], jj, island=island, target_index=jj, name=name) else: new_pset = self.new_individual(self.individuals[island], island=island, target_index=jj, name=name) # new_individual named it, so the register below and the learned-mutation # record it wrote are filed under the same key. self.proposed_individuals[island][jj] = new_pset self.island_map[new_pset.name] = (island, jj) self.waiting_count[island] = self.num_per_island if self.iter_num[island] % 20 == 0: logger.debug('Island %i completed %i iterations' % (island, self.iter_num[island])) # print(sorted(self.fitnesses[island])) # Convergence check: absolute range of the finite population (#561, ADR-0115). # Only assessed once EVERY island has completed at least one iteration # (``min(self.iter_num) >= 1``): until then some islands are still at their # initial ``inf`` sentinel, and since the test (correctly) ignores non-finite # fitnesses, a single finished island's collapsed subpopulation could # otherwise stop the whole run before the others have searched at all. The # old ratio test got this for free -- an unevaluated island's ``inf`` made the # global ``max`` infinite -- but that same coupling is what let a failed sim # defeat the test; the gate restores the "wait for the whole population" half # without the sign/failed-sim half. (For a single island this holds from the # first iteration on, so single-island DE is unaffected.) if min(self.iter_num) >= 1 and self._population_converged(): return 'STOP' # Return a copy, so our internal data structure is not tampered with. return copy.copy(self.proposed_individuals[island]) else: # Add no new jobs, wait for this generation to complete. return []
[docs] @register_fit_type('ade', family='optimizer', display_name='Asynchronous Differential Evolution', schema=AsyncDEConfig) class AsynchronousDifferentialEvolution(MultiStartOptimizer, DifferentialEvolutionBase): """ Implements a simple asynchronous differential evolution algorithm. Contains no islands or migrations. Instead, each time a PSet finishes, proposes a new PSet at the same index using the standard DE formula and whatever the current population happens to be at the time. Opts into ``n_starts`` sequential-restart multi-start (#498/#501): the :class:`~pybnf.algorithms.optimizers.multistart.MultiStartOptimizer` mixin (before the family base in the MRO) runs ``n_starts`` independent searches and keeps the global best. ``ade`` is the async one-in-one-out case the mixin's *draining* path was built for -- a full population stays in flight at each inner ``STOP``, so the mixin drains those stragglers (already scored into the trajectory) before seeding the next start. ``n_starts == 1`` (the default) is byte-identical to the single-run behavior. """ def __init__(self, config): """ Initializes algorithm based on the config object. """ super().__init__(config) self.population_size = config.config['population_size'] if self.population_size < self.min_population: self.population_size = self.min_population self.config.config['population_size'] = self.min_population print1('Asynchronous differential evolution with de_strategy "%s" requires a population size of at least ' '%i. Increasing the population size to %i.' % (self.strategy, self.min_population, self.min_population)) logger.warning('Increased population_size to the minimum allowed value of %i' % self.min_population) self.sims_completed = 0 self.individuals = [] # List of individuals self.fitnesses = [] # List of same shape, gives fitness of each individual
[docs] def reset(self, bootstrap=None): super().reset(bootstrap) self._reset_search_state()
def _reset_search_state(self): """Clear the search-specific state (the population, its fitnesses, and the completed-sim counter) WITHOUT touching the trajectory -- so multi-start's :meth:`_search_start_run` can begin a fresh, independent ADE run each start while the trajectory keeps accumulating the global best across starts (#498). Shared by ``reset`` (which also resets the trajectory via ``super().reset``) and by each multi-start.""" self.sims_completed = 0 self.individuals = [] self.fitnesses = [] self._reset_adaptation(1) def _island_fitnesses(self, island): return self.fitnesses def _search_start_run(self): # Reset the search counter/population first (a no-op on the first start), so a # multi-start restart begins a genuinely fresh ADE run rather than resuming at # the previous start's population and sims_completed count. self._reset_search_state() print2('Running Asyncrhonous Differential Evolution with population size %i for up to %i iterations' % (self.population_size, self.max_iterations)) # Initialize random individuals if self.config.config['initialization'] == 'lh': self.individuals = self.random_latin_hypercube_psets(self.population_size) else: self.individuals = [self.random_pset() for i in range(self.population_size)] # ADR-0043 Phase 2: seed exactly one member at the initial_value point (a no-op # unless a parameter: record declares one); the rest stay random for diversity. self.individuals[0] = self._seed_start_point_pset(self.individuals[0]) # Set all fitnesses to Inf, guaranteeing a replacement by the first proposed individual. # The first replacement will replace with a copy of the same PSet, with the correct objective calculated. self.fitnesses = [np.inf for i in range(self.population_size)] for i in range(len(self.individuals)): self.individuals[i].name = 'gen0ind%i' % i return copy.deepcopy(self.individuals) def _search_got_result(self, res): """ Called when a simulation run finishes :param res: Result object :return: """ pset = res.pset fitness = res.score # Tell the success history how the candidate did, against the base it was built # from (a no-op unless de_adapt_mutation built it). The record travelled with the # candidate, so it does not matter that results come back in any order. self._note_trial_result(pset, fitness) gen = int(re.search(r'(?<=gen)\d+', pset.name).group(0)) j = int(re.search(r'(?<=ind)\d+', pset.name).group(0)) if fitness <= self.fitnesses[j]: self.individuals[j] = pset self.fitnesses[j] = fitness self.sims_completed += 1 # Do various "per iteration" stuff if self.sims_completed % self.population_size == 0: iters_complete = self.sims_completed / self.population_size # A population's worth of results is this method's generation: fold the # successes among them into the history before the next candidate draws from it. self._flush_adaptation(0) if iters_complete % self.config.config['output_every'] == 0: self.output_results() if iters_complete % 10 == 0: print1('Completed %i of %i simulations' % (self.sims_completed, self.max_iterations * self.population_size)) else: print2('Completed %i of %i simulations' % (self.sims_completed, self.max_iterations * self.population_size)) print2('Current population fitnesses:') print2(sorted(self.fitnesses)) if self.adapt_mutation: print2('Mutation settings learned so far: rate %.2f, factor %.2f' % self._learned_settings()) if iters_complete % 20 == 0: logger.debug('Completed %i simulations' % self.sims_completed) if iters_complete >= self.max_iterations: return 'STOP' # Convergence check: absolute range of the finite population (#561, ADR-0115). if self._population_converged(): return 'STOP' # Named as it is built, so the learned-mutation record new_individual writes is # filed under the name this method reads back off the result (#812). name = 'gen%iind%i' % (gen+1, j) if 'best' in self.strategy: best = np.argmin(self.fitnesses) new_pset = self.new_individual(self.individuals, best, target_index=j, name=name) elif 'all' in self.strategy: new_pset = self.new_individual(self.individuals, j, target_index=j, name=name) else: new_pset = self.new_individual(self.individuals, target_index=j, name=name) return [new_pset]