1 | import argparse |
---|
2 | import json |
---|
3 | import os |
---|
4 | import pickle |
---|
5 | import time |
---|
6 | import math |
---|
7 | import random |
---|
8 | from abc import ABC, abstractmethod |
---|
9 | |
---|
10 | from ..base.random_sequence_index import RandomIndexSequence |
---|
11 | from ..constants import BAD_FITNESS |
---|
12 | from ..json_encoders import Encoder |
---|
13 | from ..structures.hall_of_fame import HallOfFame |
---|
14 | from ..structures.individual import Individual |
---|
15 | from ..structures.population import PopulationStructures |
---|
16 | |
---|
17 | |
---|
18 | class 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 |
---|