source: framspy/FramsticksEvolution.py @ 1311

Last change on this file since 1311 was 1311, checked in by Maciej Komosinski, 2 weeks ago

Introduced FITNESS_VALUE_INFEASIBLE_SOLUTION and modified selection and statistics so that infeasible solutions are explicitly excluded (previously they were not excluded from statistics calculation, and eliminating/discriminating them during selection only relied on their relatively poor fitness value)

File size: 13.8 KB
Line 
1import argparse
2import os
3import sys
4import numpy as np
5from deap import creator, base, tools, algorithms
6from FramsticksLib import FramsticksLib
7
8# Note: this may be less efficient than running the evolution directly in Framsticks, so if performance is key, compare both options.
9
10
11FITNESS_VALUE_INFEASIBLE_SOLUTION = -999999.0  # DEAP expects fitness to always be a real value (not None), so this special value indicates that a solution is invalid, incorrect, or infeasible. [Related: https://github.com/DEAP/deap/issues/30 ]. Using float('-inf') or -sys.float_info.max here causes DEAP to silently exit. If you are not using DEAP, set this constant to None, float('nan'), or another special/non-float value to avoid clashing with valid real fitness values, and handle such solutions appropriately as a separate case.
12
13
14def genotype_within_constraint(genotype, dict_criteria_values, criterion_name, constraint_value):
15        REPORT_CONSTRAINT_VIOLATIONS = False
16        if constraint_value is not None:
17                actual_value = dict_criteria_values[criterion_name]
18                if actual_value > constraint_value:
19                        if REPORT_CONSTRAINT_VIOLATIONS:
20                                print('Genotype "%s" assigned a special ("infeasible solution") fitness because it violates constraint "%s": %s exceeds the threshold of %s' % (genotype, criterion_name, actual_value, constraint_value))
21                        return False
22        return True
23
24
25def frams_evaluate(frams_lib, individual):
26        FITNESS_CRITERIA_INFEASIBLE_SOLUTION = [FITNESS_VALUE_INFEASIBLE_SOLUTION] * len(OPTIMIZATION_CRITERIA)  # this special fitness value indicates that the solution should not be propagated via selection ("that genotype is invalid"). The floating point value is only used for compatibility with DEAP. If you implement your own optimization algorithm, instead of a negative value in this constant, use a special value like None to properly distinguish between feasible and infeasible solutions.
27        genotype = individual[0]  # individual[0] because we can't (?) have a simple str as a DEAP genotype/individual, only list of str.
28        data = frams_lib.evaluate([genotype])
29        # print("Evaluated '%s'" % genotype, 'evaluation is:', data)
30        valid = True
31        try:
32                first_genotype_data = data[0]
33                evaluation_data = first_genotype_data["evaluations"]
34                default_evaluation_data = evaluation_data[""]
35                fitness = [default_evaluation_data[crit] for crit in OPTIMIZATION_CRITERIA]
36        except (KeyError, TypeError) as e:  # the evaluation may have failed for an invalid genotype (such as X[@][@] with "Don't simulate genotypes with warnings" option), or because the creature failed to stabilize, or for some other reason
37                valid = False
38                print('Problem "%s" so could not evaluate genotype "%s", hence assigned it a special ("infeasible solution") fitness value: %s' % (str(e), genotype, FITNESS_CRITERIA_INFEASIBLE_SOLUTION))
39        if valid:
40                default_evaluation_data['numgenocharacters'] = len(genotype)  # for consistent constraint checking below
41                valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numparts', parsed_args.max_numparts)
42                valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numjoints', parsed_args.max_numjoints)
43                valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numneurons', parsed_args.max_numneurons)
44                valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numconnections', parsed_args.max_numconnections)
45                valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numgenocharacters', parsed_args.max_numgenochars)
46        if not valid:
47                fitness = FITNESS_CRITERIA_INFEASIBLE_SOLUTION
48        return fitness
49
50
51def frams_crossover(frams_lib, individual1, individual2):
52        geno1 = individual1[0]  # individual[0] because we can't (?) have a simple str as a DEAP genotype/individual, only list of str.
53        geno2 = individual2[0]  # individual[0] because we can't (?) have a simple str as a DEAP genotype/individual, only list of str.
54        individual1[0] = frams_lib.crossOver(geno1, geno2)
55        individual2[0] = frams_lib.crossOver(geno1, geno2)
56        return individual1, individual2
57
58
59def frams_mutate(frams_lib, individual):
60        individual[0] = frams_lib.mutate([individual[0]])[0]  # individual[0] because we can't (?) have a simple str as a DEAP genotype/individual, only list of str.
61        return individual,
62
63
64def frams_getsimplest(frams_lib, genetic_format, initial_genotype):
65        return initial_genotype if initial_genotype is not None else frams_lib.getSimplest(genetic_format)
66
67
68def is_feasible_fitness_value(fitness_value: float) -> bool:
69        assert isinstance(fitness_value, float), f"feasible_fitness({fitness_value}): argument is not of type float, it is of type {type(fitness_value)}"  # since we are using DEAP, we unfortunately must represent the fitness of an "infeasible solution" as a float...
70        return fitness_value != FITNESS_VALUE_INFEASIBLE_SOLUTION  # ...so if a valid solution happens to have fitness equal to this special value, such a solution will be considered infeasible :/
71
72
73def is_feasible_fitness_criteria(fitness_criteria: tuple) -> bool:
74        return all(is_feasible_fitness_value(fitness_value) for fitness_value in fitness_criteria)
75
76
77def select_feasible(individuals):
78        """
79        Filters out only feasible individuals (i.e., with fitness different from FITNESS_OF_INFEASIBLE_SOLUTION)
80        """
81        # for ind in individuals:
82        #       print(ind.fitness.values, ind)
83        feasible_individuals = [ind for ind in individuals if is_feasible_fitness_criteria(ind.fitness.values)]
84        count_all = len(individuals)
85        count_infeasible = count_all - len(feasible_individuals)
86        if count_infeasible != 0:
87                print("Selection: ignoring %d infeasible solution%s in a population of size %d" % (count_infeasible, 's' if count_infeasible > 1 else '', count_all))
88        return feasible_individuals
89
90
91def selTournament_only_feasible(individuals, k, tournsize):
92        return tools.selTournament(select_feasible(individuals), k, tournsize=tournsize)
93
94
95def selNSGA2_only_feasible(individuals, k):
96        return tools.selNSGA2(select_feasible(individuals), k)  # this method (unfortunately) decreases population size permanently each time an infeasible solution is removed
97
98
99def prepareToolbox(frams_lib, OPTIMIZATION_CRITERIA, tournament_size, genetic_format, initial_genotype):
100        creator.create("FitnessMax", base.Fitness, weights=[1.0] * len(OPTIMIZATION_CRITERIA))
101        creator.create("Individual", list, fitness=creator.FitnessMax)  # would be nice to have "str" instead of unnecessary "list of str"
102
103        toolbox = base.Toolbox()
104        toolbox.register("attr_simplest_genotype", frams_getsimplest, frams_lib, genetic_format, initial_genotype)  # "Attribute generator"
105        # (failed) struggle to have an individual which is a simple str, not a list of str
106        # toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr_frams)
107        # https://stackoverflow.com/questions/51451815/python-deap-library-using-random-words-as-individuals
108        # https://github.com/DEAP/deap/issues/339
109        # https://gitlab.com/santiagoandre/deap-customize-population-example/-/blob/master/AGbasic.py
110        # https://groups.google.com/forum/#!topic/deap-users/22g1kyrpKy8
111        toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr_simplest_genotype, 1)
112        toolbox.register("population", tools.initRepeat, list, toolbox.individual)
113        toolbox.register("evaluate", frams_evaluate, frams_lib)
114        toolbox.register("mate", frams_crossover, frams_lib)
115        toolbox.register("mutate", frams_mutate, frams_lib)
116        if len(OPTIMIZATION_CRITERIA) <= 1:
117                # toolbox.register("select", tools.selTournament, tournsize=tournament_size) # without explicitly filtering out infeasible solutions - eliminating/discriminating infeasible solutions during selection would only rely on their relatively poor fitness value
118                toolbox.register("select", selTournament_only_feasible, tournsize=tournament_size)
119        else:
120                # toolbox.register("select", selNSGA2) # without explicitly filtering out infeasible solutions - eliminating/discriminating infeasible solutions during selection would only rely on their relatively poor fitness value
121                toolbox.register("select", selNSGA2_only_feasible)
122        return toolbox
123
124
125def parseArguments():
126        parser = argparse.ArgumentParser(description='Run this program with "python -u %s" if you want to disable buffering of its output.' % sys.argv[0])
127        parser.add_argument('-path', type=ensureDir, required=True, help='Path to Framsticks library without trailing slash.')
128        parser.add_argument('-lib', required=False, help='Library name. If not given, "frams-objects.dll" (or .so or .dylib) is assumed depending on the platform.')
129        parser.add_argument('-sim', required=False, default="eval-allcriteria.sim", help="The name of the .sim file with settings for evaluation, mutation, crossover, and similarity estimation. If not given, \"eval-allcriteria.sim\" is assumed by default. Must be compatible with the \"standard-eval\" expdef. If you want to provide more files, separate them with a semicolon ';'.")
130
131        parser.add_argument('-genformat', required=False, help='Genetic format for the simplest initial genotype, for example 4, 9, or B. If not given, f1 is assumed.')
132        parser.add_argument('-initialgenotype', required=False, help='The genotype used to seed the initial population. If given, the -genformat argument is ignored.')
133
134        parser.add_argument('-opt', required=True, help='optimization criteria: vertpos, velocity, distance, vertvel, lifespan, numjoints, numparts, numneurons, numconnections (or other as long as it is provided by the .sim file and its .expdef). For multiple criteria optimization, separate the names by the comma.')
135        parser.add_argument('-popsize', type=int, default=50, help="Population size, default: 50.")
136        parser.add_argument('-generations', type=int, default=5, help="Number of generations, default: 5.")
137        parser.add_argument('-tournament', type=int, default=5, help="Tournament size, default: 5.")
138        parser.add_argument('-pmut', type=float, default=0.9, help="Probability of mutation, default: 0.9")
139        parser.add_argument('-pxov', type=float, default=0.2, help="Probability of crossover, default: 0.2")
140        parser.add_argument('-hof_size', type=int, default=10, help="Number of genotypes in Hall of Fame. Default: 10.")
141        parser.add_argument('-hof_savefile', required=False, help='If set, Hall of Fame will be saved in Framsticks file format (recommended extension *.gen).')
142
143        parser.add_argument('-max_numparts', type=int, default=None, help="Maximum number of Parts. Default: no limit")
144        parser.add_argument('-max_numjoints', type=int, default=None, help="Maximum number of Joints. Default: no limit")
145        parser.add_argument('-max_numneurons', type=int, default=None, help="Maximum number of Neurons. Default: no limit")
146        parser.add_argument('-max_numconnections', type=int, default=None, help="Maximum number of Neural connections. Default: no limit")
147        parser.add_argument('-max_numgenochars', type=int, default=None, help="Maximum number of characters in genotype (including the format prefix, if any). Default: no limit")
148        return parser.parse_args()
149
150
151def ensureDir(string):
152        if os.path.isdir(string):
153                return string
154        else:
155                raise NotADirectoryError(string)
156
157
158def save_genotypes(filename, OPTIMIZATION_CRITERIA, hof):
159        from framsfiles import writer as framswriter
160        with open(filename, "w") as outfile:
161                for ind in hof:
162                        keyval = {}
163                        for i, k in enumerate(OPTIMIZATION_CRITERIA):  # construct a dictionary with criteria names and their values
164                                keyval[k] = ind.fitness.values[i]  # TODO it would be better to save in Individual (after evaluation) all fields returned by Framsticks, and get these fields here, not just the criteria that were actually used as fitness in evolution.
165                        # Note: prior to the release of Framsticks 5.0, saving e.g. numparts (i.e. P) without J,N,C breaks re-calcucation of P,J,N,C in Framsticks and they appear to be zero (nothing serious).
166                        outfile.write(framswriter.from_collection({"_classname": "org", "genotype": ind[0], **keyval}))
167                        outfile.write("\n")
168        print("Saved '%s' (%d)" % (filename, len(hof)))
169
170
171def main():
172        global parsed_args, OPTIMIZATION_CRITERIA  # needed in frams_evaluate(), so made global to avoid passing as arguments
173
174        # random.seed(123)  # see FramsticksLib.DETERMINISTIC below, set to True if you want full determinism
175        FramsticksLib.DETERMINISTIC = False  # must be set before FramsticksLib() constructor call
176        parsed_args = parseArguments()
177        print("Argument values:", ", ".join(['%s=%s' % (arg, getattr(parsed_args, arg)) for arg in vars(parsed_args)]))
178        OPTIMIZATION_CRITERIA = parsed_args.opt.split(",")
179        framsLib = FramsticksLib(parsed_args.path, parsed_args.lib, parsed_args.sim)
180        toolbox = prepareToolbox(framsLib, OPTIMIZATION_CRITERIA, parsed_args.tournament, '1' if parsed_args.genformat is None else parsed_args.genformat, parsed_args.initialgenotype)
181        pop = toolbox.population(n=parsed_args.popsize)
182        hof = tools.HallOfFame(parsed_args.hof_size)
183        stats = tools.Statistics(lambda ind: ind.fitness.values)
184        # calculate statistics excluding infeasible solutions (by filtering out their fitness=FITNESS_OF_INFEASIBLE_SOLUTION)
185        filter_feasible_for_function = lambda function, fitness_criteria: function(list(filter(is_feasible_fitness_criteria, fitness_criteria)))
186        stats.register("avg", lambda fitness_criteria: filter_feasible_for_function(np.mean, fitness_criteria))
187        stats.register("stddev", lambda fitness_criteria: filter_feasible_for_function(np.std, fitness_criteria))
188        stats.register("min", lambda fitness_criteria: filter_feasible_for_function(np.min, fitness_criteria))
189        stats.register("max", lambda fitness_criteria: filter_feasible_for_function(np.max, fitness_criteria))
190        pop, log = algorithms.eaSimple(pop, toolbox, cxpb=parsed_args.pxov, mutpb=parsed_args.pmut, ngen=parsed_args.generations, stats=stats, halloffame=hof, verbose=True)
191        print('Best individuals:')
192        for ind in hof:
193                print(ind.fitness, '\t<--\t', ind[0])
194        if parsed_args.hof_savefile is not None:
195                save_genotypes(parsed_args.hof_savefile, OPTIMIZATION_CRITERIA, hof)
196
197
198if __name__ == "__main__":
199        main()
Note: See TracBrowser for help on using the repository browser.