1212_options = dict (
1313 popsize = ("population size" , 30 ),
1414 maxiter = ("maximum number of generations" , 30 ),
15+ constraint_aware = ("constraint-aware optimization (True/False)" , True ),
1516 method = ("crossover method to use, choose any from single_point, two_point, uniform, disruptive_uniform" , "uniform" ),
1617 mutation_chance = ("chance to mutate is 1 in mutation_chance" , 20 ),
1718)
2021def tune (searchspace : Searchspace , runner , tuning_options ):
2122
2223 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 )
2527
2628 # if left to the default, adjust the popsize to a sensible value for small search spaces
2729 if pop_size == _options ["popsize" ][1 ]:
@@ -33,16 +35,17 @@ def tune(searchspace: Searchspace, runner, tuning_options):
3335 best_score = 1e20
3436 cost_func = CostFunc (searchspace , tuning_options , runner )
3537
36- population = list ( list ( p ) for p in searchspace . get_random_sample ( pop_size ) )
38+ population = GA . generate_population ( )
3739
3840 for generation in range (generations ):
3941
4042 # determine fitness of population members
4143 weighted_population = []
4244 for dna in population :
4345 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 :
4649 if tuning_options .verbose :
4750 print (e )
4851 return cost_func .results
@@ -61,18 +64,19 @@ def tune(searchspace: Searchspace, runner, tuning_options):
6164 if tuning_options .verbose :
6265 print ("Generation %d, best_score %f" % (generation , best_score ))
6366
67+ # build new population for next generation
6468 population = []
6569
6670 # crossover and mutate
6771 while len (population ) < pop_size :
68- dna1 , dna2 = weighted_choice (weighted_population , 2 )
72+ dna1 , dna2 = GA . weighted_choice (weighted_population , 2 )
6973
70- children = crossover (dna1 , dna2 )
74+ children = GA . crossover (dna1 , dna2 )
7175
7276 for child in children :
73- child = mutate (child , mutation_chance , searchspace )
77+ child = GA . mutate (child )
7478
75- if child not in population and searchspace . is_param_config_valid ( tuple ( child )) :
79+ if child not in population :
7680 population .append (child )
7781
7882 if len (population ) >= pop_size :
@@ -85,57 +89,117 @@ def tune(searchspace: Searchspace, runner, tuning_options):
8589
8690tune .__doc__ = common .get_strategy_docstring ("Genetic Algorithm" , _options )
8791
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
88101
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 ))
127106 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
132196
133197
134198def single_point_crossover (dna1 , dna2 ):
135199 """Crossover dna1 and dna2 at a random index."""
136200 # 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?
137201 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 :]
139203
140204
141205def two_point_crossover (dna1 , dna2 ):
@@ -147,7 +211,7 @@ def two_point_crossover(dna1, dna2):
147211 pos1 , pos2 = sorted (random .sample (list (range (start , end )), 2 ))
148212 child1 = dna1 [:pos1 ] + dna2 [pos1 :pos2 ] + dna1 [pos2 :]
149213 child2 = dna2 [:pos1 ] + dna1 [pos1 :pos2 ] + dna2 [pos2 :]
150- return ( child1 , child2 )
214+ return child1 , child2
151215
152216
153217def uniform_crossover (dna1 , dna2 ):
@@ -178,7 +242,7 @@ def disruptive_uniform_crossover(dna1, dna2):
178242 child1 [ind ] = dna2 [ind ]
179243 child2 [ind ] = dna1 [ind ]
180244 swaps += 1
181- return ( child1 , child2 )
245+ return child1 , child2
182246
183247
184248supported_methods = {
@@ -187,3 +251,4 @@ def disruptive_uniform_crossover(dna1, dna2):
187251 "uniform" : uniform_crossover ,
188252 "disruptive_uniform" : disruptive_uniform_crossover ,
189253}
254+
0 commit comments