Skip to content

Commit 2e718f4

Browse files
committed
Merge with constrained_optimization
2 parents 77676c8 + b358265 commit 2e718f4

File tree

10 files changed

+260
-89
lines changed

10 files changed

+260
-89
lines changed

kernel_tuner/strategies/common.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from time import perf_counter
77

88
import numpy as np
9+
from scipy.spatial import distance
910

1011
from kernel_tuner import util
1112
from kernel_tuner.searchspace import Searchspace
@@ -144,8 +145,17 @@ def __call__(self, x, check_restrictions=True):
144145

145146
# else check if this is a legal (non-restricted) configuration
146147
if check_restrictions and self.searchspace.restrictions:
148+
legal = self.searchspace.is_param_config_valid(tuple(params))
147149
params_dict = dict(zip(self.searchspace.tune_params.keys(), params))
148-
legal = util.check_restrictions(self.searchspace.restrictions, params_dict, self.tuning_options.verbose)
150+
151+
if "constraint_aware" in self.tuning_options.strategy_options and self.tuning_options.strategy_options["constraint_aware"]:
152+
# attempt to repair
153+
new_params = unscale_and_snap_to_nearest_valid(x, params, self.searchspace, self.tuning_options.eps)
154+
if new_params:
155+
params = new_params
156+
legal = True
157+
x_int = ",".join([str(i) for i in params])
158+
149159
if not legal:
150160
result = params_dict
151161
result[self.tuning_options.objective] = util.InvalidConfig()
@@ -340,3 +350,28 @@ def scale_from_params(params, tune_params, eps):
340350
for i, v in enumerate(tune_params.values()):
341351
x[i] = 0.5 * eps + v.index(params[i]) * eps
342352
return x
353+
354+
355+
356+
def unscale_and_snap_to_nearest_valid(x, params, searchspace, eps):
357+
"""Helper func to snap to the nearest valid configuration"""
358+
359+
# params is nearest unscaled point, but is not valid
360+
neighbors = get_neighbors(params, searchspace)
361+
362+
if neighbors:
363+
# sort on distance to x
364+
neighbors.sort(key=lambda y: distance.euclidean(x,scale_from_params(y, searchspace.tune_params, eps)))
365+
366+
# return closest valid neighbor
367+
return neighbors[0]
368+
369+
return []
370+
371+
372+
def get_neighbors(params, searchspace):
373+
for neighbor_method in ["strictly-adjacent", "adjacent", "Hamming"]:
374+
neighbors = searchspace.get_neighbors_no_cache(tuple(params), neighbor_method=neighbor_method)
375+
if len(neighbors) > 0:
376+
return neighbors
377+
return []

kernel_tuner/strategies/firefly_algorithm.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
maxiter=("Maximum number of iterations", 100),
1414
B0=("Maximum attractiveness", 1.0),
1515
gamma=("Light absorption coefficient", 1.0),
16-
alpha=("Randomization parameter", 0.2))
16+
alpha=("Randomization parameter", 0.2),
17+
constraint_aware=("constraint-aware optimization (True/False)", True))
1718

1819
def tune(searchspace: Searchspace, runner, tuning_options):
1920

@@ -23,7 +24,7 @@ def tune(searchspace: Searchspace, runner, tuning_options):
2324
# using this instead of get_bounds because scaling is used
2425
bounds, _, eps = cost_func.get_bounds_x0_eps()
2526

26-
num_particles, maxiter, B0, gamma, alpha = common.get_options(tuning_options.strategy_options, _options)
27+
num_particles, maxiter, B0, gamma, alpha, constraint_aware = common.get_options(tuning_options.strategy_options, _options)
2728

2829
best_score_global = sys.float_info.max
2930
best_position_global = []
@@ -34,9 +35,10 @@ def tune(searchspace: Searchspace, runner, tuning_options):
3435
swarm.append(Firefly(bounds))
3536

3637
# ensure particles start from legal points
37-
population = list(list(p) for p in searchspace.get_random_sample(num_particles))
38-
for i, particle in enumerate(swarm):
39-
particle.position = scale_from_params(population[i], searchspace.tune_params, eps)
38+
if constraint_aware:
39+
population = list(list(p) for p in searchspace.get_random_sample(num_particles))
40+
for i, particle in enumerate(swarm):
41+
particle.position = scale_from_params(population[i], searchspace.tune_params, eps)
4042

4143
# compute initial intensities
4244
for j in range(num_particles):

kernel_tuner/strategies/genetic_algorithm.py

Lines changed: 119 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
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
)
@@ -20,8 +21,9 @@
2021
def 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

8690
tune.__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

134198
def 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

141205
def 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

153217
def 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

184248
supported_methods = {
@@ -187,3 +251,4 @@ def disruptive_uniform_crossover(dna1, dna2):
187251
"uniform": uniform_crossover,
188252
"disruptive_uniform": disruptive_uniform_crossover,
189253
}
254+

kernel_tuner/strategies/greedy_ils.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""A simple greedy iterative local search algorithm for parameter search."""
2+
from random import choice as random_choice
3+
24
from kernel_tuner.util import StopCriterionReached
35
from kernel_tuner.searchspace import Searchspace
46
from kernel_tuner.strategies import common
57
from kernel_tuner.strategies.common import CostFunc
6-
from kernel_tuner.strategies.genetic_algorithm import mutate
78
from kernel_tuner.strategies.hillclimbers import base_hillclimb
89

910
_options = dict(neighbor=("Method for selecting neighboring nodes, choose from Hamming or adjacent", "Hamming"),
@@ -58,9 +59,14 @@ def tune(searchspace: Searchspace, runner, tuning_options):
5859

5960
tune.__doc__ = common.get_strategy_docstring("Greedy Iterative Local Search (ILS)", _options)
6061

62+
def mutate(indiv, searchspace: Searchspace):
63+
neighbors = searchspace.get_neighbors_no_cache(tuple(indiv), neighbor_method="Hamming")
64+
return list(random_choice(neighbors))
65+
66+
6167
def random_walk(indiv, permutation_size, no_improve, last_improve, searchspace: Searchspace):
6268
if last_improve >= no_improve:
6369
return searchspace.get_random_sample(1)[0]
6470
for _ in range(permutation_size):
65-
indiv = mutate(indiv, 0, searchspace, cache=False)
71+
indiv = mutate(indiv, searchspace)
6672
return indiv

kernel_tuner/strategies/pso.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
w=("Inertia weight constant", 0.5),
1717
c1=("Cognitive constant", 3.0),
1818
c2=("Social constant", 1.5),
19-
)
20-
19+
constraint_aware=("constraint-aware optimization (True/False)", False))
2120

2221
def tune(searchspace: Searchspace, runner, tuning_options):
2322

@@ -27,7 +26,7 @@ def tune(searchspace: Searchspace, runner, tuning_options):
2726
# using this instead of get_bounds because scaling is used
2827
bounds, _, eps = cost_func.get_bounds_x0_eps()
2928

30-
num_particles, maxiter, w, c1, c2 = common.get_options(tuning_options.strategy_options, _options)
29+
num_particles, maxiter, w, c1, c2, constraint_aware = common.get_options(tuning_options.strategy_options, _options)
3130
num_particles = min(round(searchspace.size / 2), num_particles)
3231

3332
best_score_global = sys.float_info.max
@@ -39,9 +38,10 @@ def tune(searchspace: Searchspace, runner, tuning_options):
3938
swarm.append(Particle(bounds))
4039

4140
# ensure particles start from legal points
42-
population = list(list(p) for p in searchspace.get_random_sample(num_particles))
43-
for i, particle in enumerate(swarm):
44-
particle.position = scale_from_params(population[i], searchspace.tune_params, eps)
41+
if constraint_aware:
42+
population = list(list(p) for p in searchspace.get_random_sample(num_particles))
43+
for i, particle in enumerate(swarm):
44+
particle.position = scale_from_params(population[i], searchspace.tune_params, eps)
4545

4646
# start optimization
4747
for i in range(maxiter):

0 commit comments

Comments
 (0)