source: framspy/evolalg/base/experiment_abc.py @ 1288

Last change on this file since 1288 was 1283, checked in by Maciej Komosinski, 15 months ago

Stochastic rounding to deliver on average the expected number of mutants and crossovers per generation

File size: 10.8 KB
Line 
1import argparse
2import json
3import os
4import pickle
5import time
6import math
7import random
8from abc import ABC, abstractmethod
9
10from ..base.random_sequence_index import RandomIndexSequence
11from ..constants import BAD_FITNESS
12from ..json_encoders import Encoder
13from ..structures.hall_of_fame import HallOfFame
14from ..structures.individual import Individual
15from ..structures.population import PopulationStructures
16
17
18class ExperimentABC(ABC):
19
20    population_structures = None
21    hof = []
22    stats = []
23    current_generation = 0
24    time_elapsed = 0
25   
26
27    def __init__(self, popsize, hof_size, save_only_best=True) -> None:
28        self.hof = HallOfFame(hof_size)
29        self.popsize = popsize
30        self.save_only_best = save_only_best
31
32    def select(self, individuals, tournament_size, random_index_sequence):
33        """Tournament selection, returns the index of the best individual from those taking part in the tournament"""
34        best_index = None
35        for i in range(tournament_size):
36            rnd_index = random_index_sequence.getNext()
37            if best_index is None or individuals[rnd_index].fitness > best_index.fitness:
38                best_index = individuals[rnd_index]
39        return best_index
40
41    def addGenotypeIfValid(self, ind_list, genotype):
42        new_individual = Individual()
43        new_individual.set_and_evaluate(genotype, self.evaluate)
44        if new_individual.fitness is not BAD_FITNESS:
45            ind_list.append(new_individual)
46
47    @staticmethod
48    def stochastic_round(value): # https://en.wikipedia.org/wiki/Rounding#Stochastic_rounding
49        # For example, value==2.1 should turn in most cases to int 2, rarely to int 3
50        lower = math.floor(value) # returns int
51        return lower + (random.random() < (value - lower))
52
53    def make_new_population(self, individuals, prob_mut, prob_xov, tournament_size):
54        """'individuals' is the input population (a list of individuals).
55        Assumptions: all genotypes in 'individuals' are valid and evaluated (have fitness set).
56        Returns: a new population of size 'self.popsize' with prob_mut mutants, prob_xov offspring, and the remainder of clones."""
57
58        # if (self.popsize * probability) below is not integer (e.g. popsize=50, prob_xov=0.333, expected number of crossovers = 50*0.333=16.65), stochastic_round() will ensure that you will get on average the expected number of crossovers per generation (e.g. 16.65: less often 16, more often 17).
59        expected_mut = self.stochastic_round(self.popsize * prob_mut) # or int(...) if you accept less mutants in some cases, see the comment above
60        expected_xov = self.stochastic_round(self.popsize * prob_xov) # or int(...) if you accept less crossovers in some cases, see the comment above
61        assert expected_mut + expected_xov <= self.popsize, "If probabilities of mutation (%g) and crossover (%g) added together exceed 1.0, then the population would grow every generation..." % (prob_mut, prob_xov) # can be triggered due to stochastic_round() if prob_mut+prob_xov is close to 1 and the expected number of mutants or crossovers is not integer; if this happens, adjust popsize, prob_mut and prob_xov accordingly.
62
63        newpop = []
64        ris = RandomIndexSequence(len(individuals))
65
66        # adding valid mutants of selected individuals...
67        while len(newpop) < expected_mut:
68            ind = self.select(individuals, tournament_size=tournament_size, random_index_sequence=ris)
69            self.addGenotypeIfValid(newpop, self.mutate(ind.genotype))
70
71        # adding valid crossovers of selected individuals...
72        while len(newpop) < expected_mut + expected_xov:
73            ind1 = self.select(individuals, tournament_size=tournament_size, random_index_sequence=ris)
74            ind2 = self.select(individuals, tournament_size=tournament_size, random_index_sequence=ris)
75            self.addGenotypeIfValid(newpop, self.cross_over(ind1.genotype, ind2.genotype))
76
77        # select clones to fill up the new population until we reach the same size as the input population
78        while len(newpop) < self.popsize:
79            ind = self.select(individuals, tournament_size=tournament_size, random_index_sequence=ris)
80            newpop.append(Individual().copyFrom(ind))
81
82        return newpop
83
84    def save_state(self, state_filename):
85        state = self.get_state()
86        if state_filename is None:
87            return
88        state_filename_tmp = state_filename + ".tmp"
89        try:
90            with open(state_filename_tmp, "wb") as f:
91                pickle.dump(state, f)
92            # ensures the new file was first saved OK (e.g. enough free space on device), then replace
93            os.replace(state_filename_tmp, state_filename)
94        except Exception as ex:
95            raise RuntimeError("Failed to save evolution state '%s' (because: %s). This does not prevent the experiment from continuing, but let's stop here to fix the problem with saving state files." % (
96                state_filename_tmp, ex))
97
98    def load_state(self, state_filename):
99        if state_filename is None:
100            # print("Loading evolution state: file name not provided")
101            return None
102        try:
103            with open(state_filename, 'rb') as f:
104                state = pickle.load(f)
105                self.set_state(state)
106        except FileNotFoundError:
107            return None
108        print("...Loaded evolution state from '%s'" % state_filename)
109        return True
110
111    def get_state_filename(self, save_file_name):
112        return None if save_file_name is None else save_file_name + '_state.pkl'
113
114    def get_state(self):
115        return [self.time_elapsed, self.current_generation, self.population_structures, self.hof, self.stats]
116
117    def set_state(self, state):
118        self.time_elapsed, self.current_generation, self.population_structures, hof_, self.stats = state
119        # sorting: ensure that we add from worst to best so all individuals are added to HOF
120        for h in sorted(hof_, key=lambda x: x.rawfitness):
121            self.hof.add(h)
122
123    def update_stats(self, generation, all_individuals):
124        worst = min(all_individuals, key=lambda item: item.rawfitness)
125        best = max(all_individuals, key=lambda item: item.rawfitness)
126        # instead of single best, could add all individuals in population here, but then the outcome would depend on the order of adding
127        self.hof.add(best)
128        self.stats.append(
129            best.rawfitness if self.save_only_best else best)
130        print("%d\t%d\t%g\t%g" % (generation, len(
131            all_individuals), worst.rawfitness, best.rawfitness))
132
133    def initialize_evolution(self, initialgenotype):
134        self.current_generation = 0
135        self.time_elapsed = 0
136        self.stats = []  # stores the best individuals, one from each generation
137        initial_individual = Individual()
138        initial_individual.set_and_evaluate(initialgenotype, self.evaluate)
139        self.hof.add(initial_individual)
140        self.stats.append(
141            initial_individual.rawfitness if self.save_only_best else initial_individual)
142        self.population_structures = PopulationStructures(
143            initial_individual=initial_individual, archive_size=0, popsize=self.popsize)
144
145    def evolve(self, hof_savefile, generations, initialgenotype, pmut, pxov, tournament_size):
146        file_name = self.get_state_filename(hof_savefile)
147        state = self.load_state(file_name)
148        if state is not None:  # loaded state from file
149            # saved generation has been completed, start with the next one
150            self.current_generation += 1
151            print("...Resuming from saved state: population size = %d, hof size = %d, stats size = %d, archive size = %d, generation = %d/%d" % (len(self.population_structures.population), len(self.hof),
152                                                                                                                                                 len(self.stats),  (len(self.population_structures.archive)), self.current_generation, generations))  # self.current_generation (and g) are 0-based, parsed_args.generations is 1-based
153
154        else:
155            self.initialize_evolution(initialgenotype)
156        time0 = time.process_time()
157        for g in range(self.current_generation, generations):
158            self.population_structures.population = self.make_new_population(
159                self.population_structures.population, pmut, pxov, tournament_size)
160            self.update_stats(g, self.population_structures.population)
161            if hof_savefile is not None:
162                self.current_generation = g
163                self.time_elapsed += time.process_time() - time0
164                self.save_state(file_name)
165        if hof_savefile is not None:
166            self.save_genotypes(hof_savefile)
167        return self.population_structures.population, self.stats
168
169    @abstractmethod
170    def mutate(self, gen1):
171        pass
172
173    @abstractmethod
174    def cross_over(self, gen1, gen2):
175        pass
176
177    @abstractmethod
178    def evaluate(self, genotype):
179        pass
180
181    def save_genotypes(self, filename):
182        """Implement if you want to save finall genotypes,in default implementation this function is run once at the end of evolution"""
183        state_to_save = {
184            "number_of_generations": self.current_generation,
185            "hof": [{"genotype": individual.genotype,
186                     "fitness": individual.rawfitness} for individual in self.hof.hof]}
187        with open(f"{filename}.json", 'w') as f:
188            json.dump(state_to_save, f, cls=Encoder)
189
190   
191    @staticmethod
192    def get_args_for_parser():
193        parser = argparse.ArgumentParser()
194        parser.add_argument('-popsize',type= int, default= 50,
195                            help="Population size, default: 50.")
196        parser.add_argument('-generations',type= int, default= 5,
197                                help="Number of generations, default: 5.")
198        parser.add_argument('-tournament',type= int, default= 5,
199                            help="Tournament size, default: 5.")
200        parser.add_argument('-pmut',type= float, default= 0.7,
201                        help="Probability of mutation, default: 0.7")
202        parser.add_argument('-pxov',type= float, default= 0.2,
203                        help="Probability of crossover, default: 0.2")
204        parser.add_argument('-hof_size',type= int, default= 10,
205                            help="Number of genotypes in Hall of Fame. Default: 10.")
206        parser.add_argument('-hof_savefile',type= str, required= False,
207                                help= 'If set, Hall of Fame will be saved in Framsticks file format (recommended extension *.gen. This also activates saving state (checpoint} file and auto-resuming from the saved state, if this file exists.')
208        parser.add_argument('-save_only_best',type= bool, default= True, required= False,
209                            help="")
210       
211        return parser
Note: See TracBrowser for help on using the repository browser.