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

Last change on this file since 1289 was 1289, checked in by Maciej Komosinski, 4 months ago

fitness_set_negative_to_zero boolean (a.k.a. "only positive fitness", needed for novelty and niching diversity control) becomes a command-line flag instead of a hardcoded value

File size: 11.0 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) -> 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(best.rawfitness if self.save_only_best else best)
129        print("%d\t%d\t%g\t%g" % (generation, len(all_individuals), worst.rawfitness, best.rawfitness))
130
131    def initialize_evolution(self, initialgenotype):
132        self.current_generation = 0
133        self.time_elapsed = 0
134        self.stats = []  # stores the best individuals, one from each generation
135        initial_individual = Individual()
136        initial_individual.set_and_evaluate(initialgenotype, self.evaluate)
137        self.hof.add(initial_individual)
138        self.stats.append(
139            initial_individual.rawfitness if self.save_only_best else initial_individual)
140        self.population_structures = PopulationStructures(
141            initial_individual=initial_individual, archive_size=0, popsize=self.popsize)
142
143    def evolve(self, hof_savefile, generations, initialgenotype, pmut, pxov, tournament_size):
144        file_name = self.get_state_filename(hof_savefile)
145        state = self.load_state(file_name)
146        if state is not None:  # loaded state from file
147            # saved generation has been completed, start with the next one
148            self.current_generation += 1
149            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), 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
150        else:
151            self.initialize_evolution(initialgenotype)
152        time0 = time.process_time()
153        for g in range(self.current_generation, generations):
154            self.population_structures.population = self.make_new_population(
155                self.population_structures.population, pmut, pxov, tournament_size)
156            self.update_stats(g, self.population_structures.population)
157            if hof_savefile is not None:
158                self.current_generation = g
159                self.time_elapsed += time.process_time() - time0
160                self.save_state(file_name)
161        if hof_savefile is not None:
162            self.save_genotypes(hof_savefile)
163        return self.population_structures.population, self.stats
164
165    @abstractmethod
166    def mutate(self, gen1):
167        pass
168
169    @abstractmethod
170    def cross_over(self, gen1, gen2):
171        pass
172
173    @abstractmethod
174    def evaluate(self, genotype):
175        pass
176
177    def save_genotypes(self, filename):
178        """Implement if you want to save finall genotypes,in default implementation this function is run once at the end of evolution"""
179        state_to_save = {
180            "number_of_generations": self.current_generation,
181            "hof": [{"genotype": individual.genotype,
182                     "fitness": individual.rawfitness} for individual in self.hof.hof]}
183        with open(f"{filename}.json", 'w') as f:
184            json.dump(state_to_save, f, cls=Encoder)
185
186   
187    @staticmethod
188    def get_args_for_parser():
189        parser = argparse.ArgumentParser()
190        parser.add_argument('-popsize', type=int, default=50,
191                            help="Population size, default: 50.")
192        parser.add_argument('-generations', type=int, default=5,
193                                help="Number of generations, default: 5.")
194        parser.add_argument('-tournament', type=int, default=5,
195                            help="Tournament size, default: 5.")
196        parser.add_argument('-pmut', type=float, default=0.7,
197                        help="Probability of mutation, default: 0.7")
198        parser.add_argument('-pxov', type=float, default=0.2,
199                        help="Probability of crossover, default: 0.2")
200        parser.add_argument('-hof_size', type=int, default=10,
201                            help="Number of genotypes in Hall of Fame. Default: 10.")
202        parser.add_argument('-hof_savefile', type=str, required=False,
203                                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.')
204        parser.add_argument('-save_only_best', type=bool, default=True, required=False,
205                            help="")
206        parser.add_argument('-fitness_set_negative_to_zero', action='store_true',
207                            help="This flag forces fitness to become max(0,fitness), so it is always made non-negative. Using niching or novelty techniques without this flag (thus allowing negative fitness values) requires verifying/improving fitness diversification formulas to work as intended for both positive and negative fitness values.")
208       
209        return parser
Note: See TracBrowser for help on using the repository browser.