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.
"""


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

import logging
import numpy as np
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 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 de_strategy: str = 'rand1'
[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'] 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)))
[docs] def new_individual(self, individuals, base_index=None): """ Create a new individual for the specified island, according to the set strategy :param base_index: The index to use for the new individual, or None for a random index. :return: """ # Choose a starting parameter set (either a random one or the base_index specified) # and others to cross over (always random) if '1' in self.strategy: pickn = 3 else: pickn = 5 # 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:]] # Iterate through parameters; decide whether to mutate or leave the same. new_pset_vars = [] for p in base: if self.rng.random() < self.mutation_rate: if '1' in self.strategy: update_val = self.mutation_factor * others[0].get_param(p.name).diff(others[1].get_param(p.name)) else: update_val = self.mutation_factor * others[0].get_param(p.name).diff(others[1].get_param(p.name)) +\ self.mutation_factor * others[2].get_param(p.name).diff(others[3].get_param(p.name)) new_pset_vars.append(p.add(update_val)) else: new_pset_vars.append(p) return PSet(new_pset_vars)
[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. """ 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 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 < 3: self.num_per_island = 3 if self.num_islands == 1: print1('Differential evolution requires a population size of at least 3. Increased the population size ' 'to 3.') logger.warning('Increased population size to minimum allowed value of 3') else: print1('Island-based differential evolution requires a population size of at least 3 times ' 'the number of islands. Increased the population size to %i.' % (3*self.num_islands)) logger.warning('Increased population size to minimum allowed value of 3 per island') 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'] self.island_map = dict() # Maps each proposed PSet to its location (island, individual_i) 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() 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_initial_value_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])): self.island_map[self.proposed_individuals[i][j]] = (i, j) if self.num_islands == 1: self.proposed_individuals[i][j].name = 'gen0ind%i' % j else: self.proposed_individuals[i][j].name = 'gen0isl%iind%i' % (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 # Calculate the fitness of this individual, and replace if it is better than the previous one. island, j = self.island_map.pop(pset) 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 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.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): if 'best' in self.strategy: new_pset = self.new_individual(self.individuals[island], best) elif 'all' in self.strategy: new_pset = self.new_individual(self.individuals[island], jj) else: new_pset = self.new_individual(self.individuals[island]) # If the new pset is a duplicate of one already in the island_map, it will cause problems. # As a workaround, perturb it slightly. while new_pset in self.island_map: new_pset = PSet([v.add(self.rng.uniform(-1e-6, 1e-6)) for v in new_pset]) self.proposed_individuals[island][jj] = new_pset self.island_map[new_pset] = (island, jj) if self.num_islands == 1: new_pset.name = 'gen%iind%i' % (self.iter_num[island], jj) else: new_pset.name = 'gen%iisl%iind%i' % (self.iter_num[island], 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 if (np.min(self.fitnesses) != 0) and (np.max(self.fitnesses) / np.min(self.fitnesses) < 1. + self.stop_tolerance): 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 < 3: self.population_size = 3 self.config.config['population_size'] = 3 print1('Asynchronous differential evolution requires a population size of at least 3. ' 'Increasing the population size to 3.') logger.warning('Increased population_size to the minimum allowed value of 3') 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 = [] 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_initial_value_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 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 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 iters_complete % 20 == 0: logger.debug('Completed %i simulations' % self.sims_completed) if iters_complete >= self.max_iterations: return 'STOP' # Convergence check if np.max(self.fitnesses) / np.min(self.fitnesses) < 1. + self.stop_tolerance: return 'STOP' if 'best' in self.strategy: best = np.argmin(self.fitnesses) new_pset = self.new_individual(self.individuals, best) elif 'all' in self.strategy: new_pset = self.new_individual(self.individuals, j) else: new_pset = self.new_individual(self.individuals) new_pset.name = 'gen%iind%i' % (gen+1, j) return [new_pset]