12
12
_options = dict (
13
13
popsize = ("population size" , 30 ),
14
14
maxiter = ("maximum number of generations" , 30 ),
15
+ constraint_aware = ("constraint-aware optimization (True/False)" , True ),
15
16
method = ("crossover method to use, choose any from single_point, two_point, uniform, disruptive_uniform" , "uniform" ),
16
17
mutation_chance = ("chance to mutate is 1 in mutation_chance" , 20 ),
17
18
)
20
21
def tune (searchspace : Searchspace , runner , tuning_options ):
21
22
22
23
options = tuning_options .strategy_options
23
- pop_size , generations , method , mutation_chance = common .get_options (options , _options )
24
- crossover = supported_methods [method ]
24
+ pop_size , generations , constraint_aware , method , mutation_chance = common .get_options (options , _options )
25
+
26
+ GA = GeneticAlgorithm (pop_size , searchspace , constraint_aware , method , mutation_chance )
25
27
26
28
# if left to the default, adjust the popsize to a sensible value for small search spaces
27
29
if pop_size == _options ["popsize" ][1 ]:
@@ -33,16 +35,17 @@ def tune(searchspace: Searchspace, runner, tuning_options):
33
35
best_score = 1e20
34
36
cost_func = CostFunc (searchspace , tuning_options , runner )
35
37
36
- population = list ( list ( p ) for p in searchspace . get_random_sample ( pop_size ) )
38
+ population = GA . generate_population ( )
37
39
38
40
for generation in range (generations ):
39
41
40
42
# determine fitness of population members
41
43
weighted_population = []
42
44
for dna in population :
43
45
try :
44
- time = cost_func (dna , check_restrictions = False )
45
- except StopCriterionReached as e :
46
+ # if we are not constraint-aware we should check restrictions upon evaluation
47
+ time = cost_func (dna , check_restrictions = not constraint_aware )
48
+ except util .StopCriterionReached as e :
46
49
if tuning_options .verbose :
47
50
print (e )
48
51
return cost_func .results
@@ -61,18 +64,19 @@ def tune(searchspace: Searchspace, runner, tuning_options):
61
64
if tuning_options .verbose :
62
65
print ("Generation %d, best_score %f" % (generation , best_score ))
63
66
67
+ # build new population for next generation
64
68
population = []
65
69
66
70
# crossover and mutate
67
71
while len (population ) < pop_size :
68
- dna1 , dna2 = weighted_choice (weighted_population , 2 )
72
+ dna1 , dna2 = GA . weighted_choice (weighted_population , 2 )
69
73
70
- children = crossover (dna1 , dna2 )
74
+ children = GA . crossover (dna1 , dna2 )
71
75
72
76
for child in children :
73
- child = mutate (child , mutation_chance , searchspace )
77
+ child = GA . mutate (child )
74
78
75
- if child not in population and searchspace . is_param_config_valid ( tuple ( child )) :
79
+ if child not in population :
76
80
population .append (child )
77
81
78
82
if len (population ) >= pop_size :
@@ -85,57 +89,117 @@ def tune(searchspace: Searchspace, runner, tuning_options):
85
89
86
90
tune .__doc__ = common .get_strategy_docstring ("Genetic Algorithm" , _options )
87
91
92
+ class GeneticAlgorithm :
93
+
94
+ def __init__ (self , pop_size , searchspace , constraint_aware = False , method = "uniform" , mutation_chance = 10 ):
95
+ self .pop_size = pop_size
96
+ self .searchspace = searchspace
97
+ self .tune_params = searchspace .tune_params .copy ()
98
+ self .constraint_aware = constraint_aware
99
+ self .crossover_method = supported_methods [method ]
100
+ self .mutation_chance = mutation_chance
88
101
89
- def weighted_choice (population , n ):
90
- """Randomly select n unique individuals from a weighted population, fitness determines probability of being selected."""
91
-
92
- def random_index_betavariate (pop_size ):
93
- # has a higher probability of returning index of item at the head of the list
94
- alpha = 1
95
- beta = 2.5
96
- return int (random .betavariate (alpha , beta ) * pop_size )
97
-
98
- def random_index_weighted (pop_size ):
99
- """Use weights to increase probability of selection."""
100
- weights = [w for _ , w in population ]
101
- # invert because lower is better
102
- inverted_weights = [1.0 / w for w in weights ]
103
- prefix_sum = np .cumsum (inverted_weights )
104
- total_weight = sum (inverted_weights )
105
- randf = random .random () * total_weight
106
- # return first index of prefix_sum larger than random number
107
- return next (i for i , v in enumerate (prefix_sum ) if v > randf )
108
-
109
- random_index = random_index_betavariate
110
-
111
- indices = [random_index (len (population )) for _ in range (n )]
112
- chosen = []
113
- for ind in indices :
114
- while ind in chosen :
115
- ind = random_index (len (population ))
116
- chosen .append (ind )
117
-
118
- return [population [ind ][0 ] for ind in chosen ]
119
-
120
-
121
- def mutate (dna , mutation_chance , searchspace : Searchspace , cache = True ):
122
- """Mutate DNA with 1/mutation_chance chance."""
123
- # this is actually a neighbors problem with Hamming distance, choose randomly from returned searchspace list
124
- if int (random .random () * mutation_chance ) == 0 :
125
- if cache :
126
- neighbors = searchspace .get_neighbors (tuple (dna ), neighbor_method = "Hamming" )
102
+ def generate_population (self ):
103
+ """ Constraint-aware population creation method """
104
+ if self .constraint_aware :
105
+ pop = list (list (p ) for p in self .searchspace .get_random_sample (self .pop_size ))
127
106
else :
128
- neighbors = searchspace .get_neighbors_no_cache (tuple (dna ), neighbor_method = "Hamming" )
129
- if len (neighbors ) > 0 :
130
- return list (random .choice (neighbors ))
131
- return dna
107
+ pop = []
108
+ dna_size = len (self .tune_params )
109
+ for _ in range (self .pop_size ):
110
+ dna = []
111
+ for key in self .tune_params :
112
+ dna .append (random .choice (self .tune_params [key ]))
113
+ pop .append (dna )
114
+ return pop
115
+
116
+ def crossover (self , dna1 , dna2 ):
117
+ """ Apply selected crossover method, repair dna if constraint-aware """
118
+ dna1 , dna2 = self .crossover_method (dna1 , dna2 )
119
+ if self .constraint_aware :
120
+ return self .repair (dna1 ), self .repair (dna2 )
121
+ return dna1 , dna2
122
+
123
+ def weighted_choice (self , population , n ):
124
+ """Randomly select n unique individuals from a weighted population, fitness determines probability of being selected."""
125
+
126
+ def random_index_betavariate (pop_size ):
127
+ # has a higher probability of returning index of item at the head of the list
128
+ alpha = 1
129
+ beta = 2.5
130
+ return int (random .betavariate (alpha , beta ) * pop_size )
131
+
132
+ def random_index_weighted (pop_size ):
133
+ """Use weights to increase probability of selection."""
134
+ weights = [w for _ , w in population ]
135
+ # invert because lower is better
136
+ inverted_weights = [1.0 / w for w in weights ]
137
+ prefix_sum = np .cumsum (inverted_weights )
138
+ total_weight = sum (inverted_weights )
139
+ randf = random .random () * total_weight
140
+ # return first index of prefix_sum larger than random number
141
+ return next (i for i , v in enumerate (prefix_sum ) if v > randf )
142
+
143
+ random_index = random_index_betavariate
144
+
145
+ indices = [random_index (len (population )) for _ in range (n )]
146
+ chosen = []
147
+ for ind in indices :
148
+ while ind in chosen :
149
+ ind = random_index (len (population ))
150
+ chosen .append (ind )
151
+
152
+ return [population [ind ][0 ] for ind in chosen ]
153
+
154
+
155
+ def mutate (self , dna , cache = False ):
156
+ """Mutate DNA with 1/mutation_chance chance."""
157
+ # this is actually a neighbors problem with Hamming distance, choose randomly from returned searchspace list
158
+ if int (random .random () * self .mutation_chance ) == 0 :
159
+ if self .constraint_aware :
160
+ if cache :
161
+ neighbors = self .searchspace .get_neighbors (tuple (dna ), neighbor_method = "Hamming" )
162
+ else :
163
+ neighbors = self .searchspace .get_neighbors_no_cache (tuple (dna ), neighbor_method = "Hamming" )
164
+ if len (neighbors ) > 0 :
165
+ return list (random .choice (neighbors ))
166
+ else :
167
+ # select a tunable parameter at random
168
+ mutate_index = random .randint (0 , len (self .tune_params )- 1 )
169
+ mutate_key = list (self .tune_params .keys ())[mutate_index ]
170
+ # get all possible values for this parameter and remove current value
171
+ new_val_options = self .tune_params [mutate_key ].copy ()
172
+ new_val_options .remove (dna [mutate_index ])
173
+ # pick new value at random
174
+ if len (new_val_options ) > 0 :
175
+ new_val = random .choice (new_val_options )
176
+ dna [mutate_index ] = new_val
177
+ return dna
178
+
179
+
180
+ def repair (self , dna ):
181
+ """ It is possible that crossover methods yield a configuration that is not valid. """
182
+ if not self .searchspace .is_param_config_valid (tuple (dna )):
183
+ # dna is not valid, try to repair it
184
+ # search for valid configurations neighboring this config
185
+ # start from strictly-adjacent to increasingly allowing more neighbors
186
+ for neighbor_method in ["strictly-adjacent" , "adjacent" , "Hamming" ]:
187
+ neighbors = self .searchspace .get_neighbors_no_cache (tuple (dna ), neighbor_method = neighbor_method )
188
+
189
+ # if we have found valid neighboring configurations, select one at random
190
+ if len (neighbors ) > 0 :
191
+ new_dna = list (random .choice (neighbors ))
192
+ print (f"GA crossover resulted in invalid config { dna = } , repaired dna to { new_dna = } " )
193
+ return new_dna
194
+
195
+ return dna
132
196
133
197
134
198
def single_point_crossover (dna1 , dna2 ):
135
199
"""Crossover dna1 and dna2 at a random index."""
136
200
# check if you can do the crossovers using the neighbor index: check which valid parameter configuration is closest to the crossover, probably best to use "adjacent" as it is least strict?
137
201
pos = int (random .random () * (len (dna1 )))
138
- return ( dna1 [:pos ] + dna2 [pos :], dna2 [:pos ] + dna1 [pos :])
202
+ return dna1 [:pos ] + dna2 [pos :], dna2 [:pos ] + dna1 [pos :]
139
203
140
204
141
205
def two_point_crossover (dna1 , dna2 ):
@@ -147,7 +211,7 @@ def two_point_crossover(dna1, dna2):
147
211
pos1 , pos2 = sorted (random .sample (list (range (start , end )), 2 ))
148
212
child1 = dna1 [:pos1 ] + dna2 [pos1 :pos2 ] + dna1 [pos2 :]
149
213
child2 = dna2 [:pos1 ] + dna1 [pos1 :pos2 ] + dna2 [pos2 :]
150
- return ( child1 , child2 )
214
+ return child1 , child2
151
215
152
216
153
217
def uniform_crossover (dna1 , dna2 ):
@@ -178,7 +242,7 @@ def disruptive_uniform_crossover(dna1, dna2):
178
242
child1 [ind ] = dna2 [ind ]
179
243
child2 [ind ] = dna1 [ind ]
180
244
swaps += 1
181
- return ( child1 , child2 )
245
+ return child1 , child2
182
246
183
247
184
248
supported_methods = {
@@ -187,3 +251,4 @@ def disruptive_uniform_crossover(dna1, dna2):
187
251
"uniform" : uniform_crossover ,
188
252
"disruptive_uniform" : disruptive_uniform_crossover ,
189
253
}
254
+
0 commit comments