source: framspy/FramsticksLib.py @ 1211

Last change on this file since 1211 was 1204, checked in by Maciej Komosinski, 21 months ago

Allow to provide more than one .sim file

File size: 14.1 KB
Line 
1from typing import List  # to be able to specify a type hint of list(something)
2import json
3import sys, os
4import argparse
5import numpy as np
6import frams
7
8
9class FramsticksLib:
10        """Communicates directly with Framsticks library (.dll or .so or .dylib).
11        You can perform basic operations like mutation, crossover, and evaluation of genotypes.
12        This way you can perform evolution controlled by python as well as access and manipulate genotypes.
13        You can even design and use in evolution your own genetic representation implemented entirely in python,
14        or access and control the simulation and simulated creatures step by step.
15
16        Should you want to modify or extend this class, first see and test the examples in frams-test.py.
17
18        You need to provide one or two parameters when you run this class: the path to Framsticks where .dll/.so/.dylib resides
19        and, optionally, the name of the Framsticks dll/so/dylib (if it is non-standard). See::
20                FramsticksLib.py -h"""
21
22        PRINT_FRAMSTICKS_OUTPUT: bool = False  # set to True for debugging
23        DETERMINISTIC: bool = False  # set to True to have the same results in each run
24
25        GENOTYPE_INVALID = "/*invalid*/"  # this is how genotype invalidity is represented in Framsticks
26        EVALUATION_SETTINGS_FILE = [  # all files MUST be compatible with the standard-eval expdef. The order they are loaded in is important!
27                "eval-allcriteria.sim",  # a good trade-off in performance sampling period ("perfperiod") for vertpos and velocity
28                # "deterministic.sim",  # turns off random noise (added for robustness) so that each evaluation yields identical performance values (causes "overfitting")
29                # "sample-period-2.sim", # short performance sampling period so performance (e.g. vertical position) is sampled more often
30                # "sample-period-longest.sim",  # increased performance sampling period so distance and velocity are measured rectilinearly
31        ]
32
33
34        # This function is not needed because in Python, "For efficiency reasons, each module is only imported once per interpreter session."
35        # @staticmethod
36        # def getFramsModuleInstance():
37        #       """If some other party needs access to the frams module to directly access or modify Framsticks objects,
38        #       use this function to avoid importing the "frams" module multiple times and avoid potentially initializing
39        #       it many times."""
40        #       return frams
41
42        def __init__(self, frams_path, frams_lib_name, sim_settings_files):
43                if frams_lib_name is None:
44                        frams.init(frams_path)  # could add support for setting alternative directories using -D and -d
45                else:
46                        frams.init(frams_path, "-L" + frams_lib_name)  # could add support for setting alternative directories using -D and -d
47
48                print('Available objects:', dir(frams))
49                print()
50
51                simplest = self.getSimplest("1")
52                if not (simplest == "X" and type(simplest) is str):
53                        raise RuntimeError('Failed getSimplest() test.')
54                if not (self.isValid(["X[0:0],", "X[0:0]", "X[1:0]"]) == [False, True, False]):
55                        raise RuntimeError('Failed isValid() test.')
56
57                if not self.DETERMINISTIC:
58                        frams.Math.randomize()
59                frams.Simulator.expdef = "standard-eval"  # this expdef (or fully compatible) must be used by EVALUATION_SETTINGS_FILE
60                if sim_settings_files is not None:
61                        self.EVALUATION_SETTINGS_FILE = sim_settings_files.split(";")  # overwrite defaults
62                print('Basic tests OK. Using settings:', self.EVALUATION_SETTINGS_FILE)
63                print()
64                if not isinstance(self.EVALUATION_SETTINGS_FILE, list):  # ensure settings file(s) are provided as a list
65                        raise ValueError("Evaluation settings file(s) '%s' should be provided as a list" % self.EVALUATION_SETTINGS_FILE)
66
67                for simfile in self.EVALUATION_SETTINGS_FILE:
68                        ec = frams.MessageCatcher.new()  # catch potential errors, warnings, messages - just to detect if there are ERRORs
69                        ec.store = 2;  # store all, because they are caught by MessageCatcher and will not appear in output (which we want)
70                        frams.Simulator.ximport(simfile, 4 + 8 + 16)
71                        ec.close()
72                        print(ec.messages)  # output all caught messages
73                        if ec.error_count._value() > 0:
74                                raise ValueError("Problem while importing file '%s'" % simfile)  # make missing files or incorrect paths fatal because error messages are easy to overlook in output, and these errors would not prevent Framsticks simulator from performing genetic operations, starting and running in evaluate()
75
76
77        def getSimplest(self, genetic_format) -> str:
78                return frams.GenMan.getSimplest(genetic_format).genotype._string()
79
80
81        def evaluate(self, genotype_list: List[str]):
82                """
83                Returns:
84                        List of dictionaries containing the performance of genotypes evaluated using self.EVALUATION_SETTINGS_FILE.
85                        Note that for whatever reason (e.g. incorrect genotype), the dictionaries you will get may be empty or
86                        partially empty and may not have the fields you expected, so handle such cases properly.
87                """
88                assert isinstance(genotype_list, list)  # because in python, str has similar capabilities as list and here it would pretend to work too, so to avoid any ambiguity
89
90                if not self.PRINT_FRAMSTICKS_OUTPUT:
91                        ec = frams.MessageCatcher.new()  # mute potential errors, warnings, messages
92                        ec.store = 2;  # store all, because they are caught by MessageCatcher and will not appear in output
93
94                frams.GenePools[0].clear()
95                for g in genotype_list:
96                        frams.GenePools[0].add(g)
97                frams.ExpProperties.evalsavefile = ""  # no need to store results in a file - we will get evaluations directly from Genotype's "data" field
98                frams.Simulator.init()
99                frams.Simulator.start()
100
101                # step = frams.Simulator.step  # cache reference to avoid repeated lookup in the loop (just for performance)
102                # while frams.Simulator.running._int():  # standard-eval.expdef sets running to 0 when the evaluation is complete
103                #       step()
104                frams.Simulator.eval("while(Simulator.running) Simulator.step();")  # fastest
105                # Timing for evaluating a single simple creature 100x:
106                # - python step without caching: 2.2s
107                # - python step with caching   : 1.6s
108                # - pure FramScript and eval() : 0.4s
109
110                if not self.PRINT_FRAMSTICKS_OUTPUT:
111                        ec.close()
112                        if ec.error_count._value() > 0:
113                                print(ec.messages)  # if errors occurred, output all caught messages for debugging
114                                raise RuntimeError("[ERROR] %d error(s) and %d warning(s) while evaluating %d genotype(s)" % (ec.error_count._value(), ec.warning_count._value() - ec.error_count._value(), len(genotype_list)))  # make errors fatal; by default they stop the simulation anyway so let's not use potentially incorrect or partial results and fix the cause first.
115
116                results = []
117                for g in frams.GenePools[0]:
118                        serialized_dict = frams.String.serialize(g.data[frams.ExpProperties.evalsavedata._value()])
119                        evaluations = json.loads(serialized_dict._string())  # Framsticks native ExtValue's get converted to native python types such as int, float, list, str.
120                        # now, for consistency with FramsticksCLI.py, add "num" and "name" keys that are missing because we got data directly from Genotype, not from the file produced by standard-eval.expdef's function printStats(). What we do below is what printStats() does.
121                        result = {"num": g.num._value(), "name": g.name._value(), "evaluations": evaluations}
122                        results.append(result)
123
124                return results
125
126
127        def mutate(self, genotype_list: List[str]) -> List[str]:
128                """
129                Returns:
130                        The genotype(s) of the mutated source genotype(s). self.GENOTYPE_INVALID for genotypes whose mutation failed (for example because the source genotype was invalid).
131                """
132                assert isinstance(genotype_list, list)  # because in python, str has similar capabilities as list and here it would pretend to work too, so to avoid any ambiguity
133
134                mutated = []
135                for g in genotype_list:
136                        mutated.append(frams.GenMan.mutate(frams.Geno.newFromString(g)).genotype._string())
137                if len(genotype_list) != len(mutated):
138                        raise RuntimeError("Submitted %d genotypes, received %d mutants" % (len(genotype_list), len(mutated)))
139                return mutated
140
141
142        def crossOver(self, genotype_parent1: str, genotype_parent2: str) -> str:
143                """
144                Returns:
145                        The genotype of the offspring. self.GENOTYPE_INVALID if the crossing over failed.
146                """
147                return frams.GenMan.crossOver(frams.Geno.newFromString(genotype_parent1), frams.Geno.newFromString(genotype_parent2)).genotype._string()
148
149
150        def dissimilarity(self, genotype_list: List[str], method: int) -> np.ndarray:
151                """
152                        :param method: -1 = genetic Levenshtein distance; 0, 1, 2 = phenetic dissimilarity (SimilMeasureGreedy, SimilMeasureHungarian, SimilMeasureDistribution)
153                        :return: A square array with dissimilarities of each pair of genotypes.
154                """
155                assert isinstance(genotype_list, list)  # because in python, str has similar capabilities as list and here it would pretend to work too, so to avoid any ambiguity
156
157                # if you want to override what EVALUATION_SETTINGS_FILE sets, you can do it below:
158                # frams.SimilMeasureHungarian.simil_partgeom = 1
159                # frams.SimilMeasureHungarian.simil_weightedMDS = 1
160
161                n = len(genotype_list)
162                square_matrix = np.zeros((n, n))
163
164                if method in (0, 1, 2):  # Framsticks phenetic dissimilarity methods
165                        frams.SimilMeasure.simil_type = method
166                        genos = []  # prepare an array of Geno objects so that we don't need to convert raw strings to Geno objects all the time in loops
167                        for g in genotype_list:
168                                genos.append(frams.Geno.newFromString(g))
169                        frams_evaluateDistance = frams.SimilMeasure.evaluateDistance  # cache function reference for better performance in loops
170                        for i in range(n):
171                                for j in range(n):  # maybe calculate only one triangle if you really need a 2x speedup
172                                        square_matrix[i][j] = frams_evaluateDistance(genos[i], genos[j])._double()
173                elif method == -1:
174                        import Levenshtein
175                        for i in range(n):
176                                for j in range(n):  # maybe calculate only one triangle if you really need a 2x speedup
177                                        square_matrix[i][j] = Levenshtein.distance(genotype_list[i], genotype_list[j])
178                else:
179                        raise Exception("Don't know what to do with dissimilarity method = %d" % method)
180
181                for i in range(n):
182                        assert square_matrix[i][i] == 0, "Not a correct dissimilarity matrix, diagonal expected to be 0"
183                non_symmetric_diff = square_matrix - square_matrix.T
184                non_symmetric_count = np.count_nonzero(non_symmetric_diff)
185                if non_symmetric_count > 0:
186                        non_symmetric_diff_abs = np.abs(non_symmetric_diff)
187                        max_pos1d = np.argmax(non_symmetric_diff_abs)  # location of the largest discrepancy
188                        max_pos2d_XY = np.unravel_index(max_pos1d, non_symmetric_diff_abs.shape)  # 2D coordinates of the largest discrepancy
189                        max_pos2d_YX = max_pos2d_XY[1], max_pos2d_XY[0]  # 2D coordinates of the largest discrepancy mirror
190                        worst_guy_XY = square_matrix[max_pos2d_XY]  # this distance and the other below (its mirror) are most different
191                        worst_guy_YX = square_matrix[max_pos2d_YX]
192                        print("[WARN] Dissimilarity matrix: expecting symmetry, but %g out of %d pairs were asymmetrical, max difference was %g (%g %%)" %
193                              (non_symmetric_count / 2,
194                               n * (n - 1) / 2,
195                               non_symmetric_diff_abs[max_pos2d_XY],
196                               non_symmetric_diff_abs[max_pos2d_XY] * 100 / ((worst_guy_XY + worst_guy_YX) / 2)))  # max diff is not necessarily max %
197                return square_matrix
198
199
200        def isValid(self, genotype_list: List[str]) -> List[bool]:
201                """
202                :returns: genetic validity (i.e., not based on trying to build creatures from provided genotypes). For a more thorough check, see isValidCreature().
203                """
204                assert isinstance(genotype_list, list)  # because in python, str has similar capabilities as list and here it would pretend to work too, so to avoid any ambiguity
205                valid = []
206                for g in genotype_list:
207                        valid.append(frams.Geno.newFromString(g).is_valid._int() == 1)
208                if len(genotype_list) != len(valid):
209                        raise RuntimeError("Tested %d genotypes, received %d validity values" % (len(genotype_list), len(valid)))
210                return valid
211
212
213def parseArguments():
214        parser = argparse.ArgumentParser(description='Run this program with "python -u %s" if you want to disable buffering of its output.' % sys.argv[0])
215        parser.add_argument('-path', type=ensureDir, required=True, help='Path to the Framsticks library (.dll or .so or .dylib) without trailing slash.')
216        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.')
217        parser.add_argument('-simsettings', required=False, 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 ';'.")
218        parser.add_argument('-genformat', required=False, help='Genetic format for the demo run, for example 4, 9, or S. If not given, f1 is assumed.')
219        return parser.parse_args()
220
221
222def ensureDir(string):
223        if os.path.isdir(string):
224                return string
225        else:
226                raise NotADirectoryError(string)
227
228
229if __name__ == "__main__":
230        # A demo run.
231
232        # TODO ideas:
233        # - check_validity with three levels (invalid, corrected, valid)
234        # - a pool of binaries running simultaneously, balance load - in particular evaluation
235
236        parsed_args = parseArguments()
237        framsLib = FramsticksLib(parsed_args.path, parsed_args.lib, parsed_args.simsettings)
238
239        print("Sending a direct command to Framsticks library that calculates \"4\"+2 yields", frams.Simulator.eval("return \"4\"+2;"))
240
241        simplest = framsLib.getSimplest('1' if parsed_args.genformat is None else parsed_args.genformat)
242        print("\tSimplest genotype:", simplest)
243        parent1 = framsLib.mutate([simplest])[0]
244        parent2 = parent1
245        MUTATE_COUNT = 10
246        for x in range(MUTATE_COUNT):  # example of a chain of 10 mutations
247                parent2 = framsLib.mutate([parent2])[0]
248        print("\tParent1 (mutated simplest):", parent1)
249        print("\tParent2 (Parent1 mutated %d times):" % MUTATE_COUNT, parent2)
250        offspring = framsLib.crossOver(parent1, parent2)
251        print("\tCrossover (Offspring):", offspring)
252        print('\tDissimilarity of Parent1 and Offspring:', framsLib.dissimilarity([parent1, offspring], 1)[0, 1])
253        print('\tPerformance of Offspring:', framsLib.evaluate([offspring]))
254        print('\tValidity (genetic) of Parent1, Parent 2, and Offspring:', framsLib.isValid([parent1, parent2, offspring]))
Note: See TracBrowser for help on using the repository browser.