1 | import argparse
|
---|
2 | import os
|
---|
3 | import sys
|
---|
4 | import numpy as np
|
---|
5 | from deap import creator, base, tools, algorithms
|
---|
6 | from FramsticksLib import FramsticksLib
|
---|
7 |
|
---|
8 |
|
---|
9 | # Note: this may be less efficient than running the evolution directly in Framsticks, so if performance is key, compare both options.
|
---|
10 |
|
---|
11 |
|
---|
12 | def genotype_within_constraint(genotype, dict_criteria_values, criterion_name, constraint_value):
|
---|
13 | REPORT_CONSTRAINT_VIOLATIONS = False
|
---|
14 | if constraint_value is not None:
|
---|
15 | actual_value = dict_criteria_values[criterion_name]
|
---|
16 | if actual_value > constraint_value:
|
---|
17 | if REPORT_CONSTRAINT_VIOLATIONS:
|
---|
18 | print('Genotype "%s" assigned low fitness because it violates constraint "%s": %s exceeds threshold %s' % (genotype, criterion_name, actual_value, constraint_value))
|
---|
19 | return False
|
---|
20 | return True
|
---|
21 |
|
---|
22 |
|
---|
23 | def frams_evaluate(frams_lib, individual):
|
---|
24 | BAD_FITNESS = [-1] * len(OPTIMIZATION_CRITERIA) # fitness of -1 is intended to discourage further propagation of this genotype via selection ("this genotype is very poor")
|
---|
25 | genotype = individual[0] # individual[0] because we can't (?) have a simple str as a deap genotype/individual, only list of str.
|
---|
26 | data = frams_lib.evaluate([genotype])
|
---|
27 | # print("Evaluated '%s'" % genotype, 'evaluation is:', data)
|
---|
28 | valid = True
|
---|
29 | try:
|
---|
30 | first_genotype_data = data[0]
|
---|
31 | evaluation_data = first_genotype_data["evaluations"]
|
---|
32 | default_evaluation_data = evaluation_data[""]
|
---|
33 | fitness = [default_evaluation_data[crit] for crit in OPTIMIZATION_CRITERIA]
|
---|
34 | 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 for some other reason
|
---|
35 | valid = False
|
---|
36 | print('Problem "%s" so could not evaluate genotype "%s", hence assigned it low fitness: %s' % (str(e), genotype, BAD_FITNESS))
|
---|
37 | if valid:
|
---|
38 | default_evaluation_data['numgenocharacters'] = len(genotype) # for consistent constraint checking below
|
---|
39 | valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numparts', parsed_args.max_numparts)
|
---|
40 | valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numjoints', parsed_args.max_numjoints)
|
---|
41 | valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numneurons', parsed_args.max_numneurons)
|
---|
42 | valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numconnections', parsed_args.max_numconnections)
|
---|
43 | valid &= genotype_within_constraint(genotype, default_evaluation_data, 'numgenocharacters', parsed_args.max_numgenochars)
|
---|
44 | if not valid:
|
---|
45 | fitness = BAD_FITNESS
|
---|
46 | return fitness
|
---|
47 |
|
---|
48 |
|
---|
49 | def frams_crossover(frams_lib, individual1, individual2):
|
---|
50 | geno1 = individual1[0] # individual[0] because we can't (?) have a simple str as a deap genotype/individual, only list of str.
|
---|
51 | geno2 = individual2[0] # individual[0] because we can't (?) have a simple str as a deap genotype/individual, only list of str.
|
---|
52 | individual1[0] = frams_lib.crossOver(geno1, geno2)
|
---|
53 | individual2[0] = frams_lib.crossOver(geno1, geno2)
|
---|
54 | return individual1, individual2
|
---|
55 |
|
---|
56 |
|
---|
57 | def frams_mutate(frams_lib, individual):
|
---|
58 | 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.
|
---|
59 | return individual,
|
---|
60 |
|
---|
61 |
|
---|
62 | def frams_getsimplest(frams_lib, genetic_format, initial_genotype):
|
---|
63 | return initial_genotype if initial_genotype is not None else frams_lib.getSimplest(genetic_format)
|
---|
64 |
|
---|
65 |
|
---|
66 | def prepareToolbox(frams_lib, OPTIMIZATION_CRITERIA, tournament_size, genetic_format, initial_genotype):
|
---|
67 | creator.create("FitnessMax", base.Fitness, weights=[1.0] * len(OPTIMIZATION_CRITERIA))
|
---|
68 | creator.create("Individual", list, fitness=creator.FitnessMax) # would be nice to have "str" instead of unnecessary "list of str"
|
---|
69 |
|
---|
70 | toolbox = base.Toolbox()
|
---|
71 | toolbox.register("attr_simplest_genotype", frams_getsimplest, frams_lib, genetic_format, initial_genotype) # "Attribute generator"
|
---|
72 | # (failed) struggle to have an individual which is a simple str, not a list of str
|
---|
73 | # toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr_frams)
|
---|
74 | # https://stackoverflow.com/questions/51451815/python-deap-library-using-random-words-as-individuals
|
---|
75 | # https://github.com/DEAP/deap/issues/339
|
---|
76 | # https://gitlab.com/santiagoandre/deap-customize-population-example/-/blob/master/AGbasic.py
|
---|
77 | # https://groups.google.com/forum/#!topic/deap-users/22g1kyrpKy8
|
---|
78 | toolbox.register("individual", tools.initRepeat, creator.Individual, toolbox.attr_simplest_genotype, 1)
|
---|
79 | toolbox.register("population", tools.initRepeat, list, toolbox.individual)
|
---|
80 | toolbox.register("evaluate", frams_evaluate, frams_lib)
|
---|
81 | toolbox.register("mate", frams_crossover, frams_lib)
|
---|
82 | toolbox.register("mutate", frams_mutate, frams_lib)
|
---|
83 | if len(OPTIMIZATION_CRITERIA) <= 1:
|
---|
84 | toolbox.register("select", tools.selTournament, tournsize=tournament_size)
|
---|
85 | else:
|
---|
86 | toolbox.register("select", tools.selNSGA2)
|
---|
87 | return toolbox
|
---|
88 |
|
---|
89 |
|
---|
90 | def parseArguments():
|
---|
91 | parser = argparse.ArgumentParser(description='Run this program with "python -u %s" if you want to disable buffering of its output.' % sys.argv[0])
|
---|
92 | parser.add_argument('-path', type=ensureDir, required=True, help='Path to Framsticks library without trailing slash.')
|
---|
93 | 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.')
|
---|
94 | 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 ';'.")
|
---|
95 |
|
---|
96 | 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.')
|
---|
97 | parser.add_argument('-initialgenotype', required=False, help='The genotype used to seed the initial population. If given, the -genformat argument is ignored.')
|
---|
98 |
|
---|
99 | 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.')
|
---|
100 | parser.add_argument('-popsize', type=int, default=50, help="Population size, default: 50.")
|
---|
101 | parser.add_argument('-generations', type=int, default=5, help="Number of generations, default: 5.")
|
---|
102 | parser.add_argument('-tournament', type=int, default=5, help="Tournament size, default: 5.")
|
---|
103 | parser.add_argument('-pmut', type=float, default=0.9, help="Probability of mutation, default: 0.9")
|
---|
104 | parser.add_argument('-pxov', type=float, default=0.2, help="Probability of crossover, default: 0.2")
|
---|
105 | parser.add_argument('-hof_size', type=int, default=10, help="Number of genotypes in Hall of Fame. Default: 10.")
|
---|
106 | parser.add_argument('-hof_savefile', required=False, help='If set, Hall of Fame will be saved in Framsticks file format (recommended extension *.gen).')
|
---|
107 |
|
---|
108 | parser.add_argument('-max_numparts', type=int, default=None, help="Maximum number of Parts. Default: no limit")
|
---|
109 | parser.add_argument('-max_numjoints', type=int, default=None, help="Maximum number of Joints. Default: no limit")
|
---|
110 | parser.add_argument('-max_numneurons', type=int, default=None, help="Maximum number of Neurons. Default: no limit")
|
---|
111 | parser.add_argument('-max_numconnections', type=int, default=None, help="Maximum number of Neural connections. Default: no limit")
|
---|
112 | 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")
|
---|
113 | return parser.parse_args()
|
---|
114 |
|
---|
115 |
|
---|
116 | def ensureDir(string):
|
---|
117 | if os.path.isdir(string):
|
---|
118 | return string
|
---|
119 | else:
|
---|
120 | raise NotADirectoryError(string)
|
---|
121 |
|
---|
122 |
|
---|
123 | def save_genotypes(filename, OPTIMIZATION_CRITERIA, hof):
|
---|
124 | from framsfiles import writer as framswriter
|
---|
125 | with open(filename, "w") as outfile:
|
---|
126 | for ind in hof:
|
---|
127 | keyval = {}
|
---|
128 | for i, k in enumerate(OPTIMIZATION_CRITERIA): # construct a dictionary with criteria names and their values
|
---|
129 | 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.
|
---|
130 | # 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).
|
---|
131 | outfile.write(framswriter.from_collection({"_classname": "org", "genotype": ind[0], **keyval}))
|
---|
132 | outfile.write("\n")
|
---|
133 | print("Saved '%s' (%d)" % (filename, len(hof)))
|
---|
134 |
|
---|
135 |
|
---|
136 | def main():
|
---|
137 | global parsed_args, OPTIMIZATION_CRITERIA # needed in frams_evaluate(), so made global to avoid passing as arguments
|
---|
138 |
|
---|
139 | # random.seed(123) # see FramsticksLib.DETERMINISTIC below, set to True if you want full determinism
|
---|
140 | FramsticksLib.DETERMINISTIC = False # must be set before FramsticksLib() constructor call
|
---|
141 | parsed_args = parseArguments()
|
---|
142 | print("Argument values:", ", ".join(['%s=%s' % (arg, getattr(parsed_args, arg)) for arg in vars(parsed_args)]))
|
---|
143 | OPTIMIZATION_CRITERIA = parsed_args.opt.split(",")
|
---|
144 | framsLib = FramsticksLib(parsed_args.path, parsed_args.lib, parsed_args.sim.split(";"))
|
---|
145 | toolbox = prepareToolbox(framsLib, OPTIMIZATION_CRITERIA, parsed_args.tournament, '1' if parsed_args.genformat is None else parsed_args.genformat, parsed_args.initialgenotype)
|
---|
146 | pop = toolbox.population(n=parsed_args.popsize)
|
---|
147 | hof = tools.HallOfFame(parsed_args.hof_size)
|
---|
148 | stats = tools.Statistics(lambda ind: ind.fitness.values)
|
---|
149 | stats.register("avg", np.mean)
|
---|
150 | stats.register("stddev", np.std)
|
---|
151 | stats.register("min", np.min)
|
---|
152 | stats.register("max", np.max)
|
---|
153 | pop, log = algorithms.eaSimple(pop, toolbox, cxpb=parsed_args.pxov, mutpb=parsed_args.pmut, ngen=parsed_args.generations, stats=stats, halloffame=hof, verbose=True)
|
---|
154 | print('Best individuals:')
|
---|
155 | for ind in hof:
|
---|
156 | print(ind.fitness, '\t-->\t', ind[0])
|
---|
157 | if parsed_args.hof_savefile is not None:
|
---|
158 | save_genotypes(parsed_args.hof_savefile, OPTIMIZATION_CRITERIA, hof)
|
---|
159 |
|
---|
160 |
|
---|
161 | if __name__ == "__main__":
|
---|
162 | main()
|
---|