Skip to content

Commit c39b87e

Browse files
replace differential evolution strategy
1 parent b90f5e4 commit c39b87e

File tree

4 files changed

+453
-30
lines changed

4 files changed

+453
-30
lines changed
Lines changed: 301 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,324 @@
1-
"""The differential evolution strategy that optimizes the search through the parameter space."""
2-
from scipy.optimize import differential_evolution
1+
"""A simple Different Evolution for parameter search."""
2+
import re
3+
import numpy as np
34

45
from kernel_tuner import util
56
from kernel_tuner.searchspace import Searchspace
67
from kernel_tuner.strategies import common
78
from kernel_tuner.strategies.common import CostFunc
89

9-
supported_methods = ["best1bin", "best1exp", "rand1exp", "randtobest1exp", "best2exp", "rand2exp", "randtobest1bin", "best2bin", "rand2bin", "rand1bin"]
10+
_options = dict(
11+
popsize=("population size", 50),
12+
maxiter=("maximum number of generations", 200),
13+
F=("mutation factor (differential weight)", 0.8),
14+
CR=("crossover rate", 0.9),
15+
method=("method", "best1bin")
16+
)
1017

11-
_options = dict(method=(f"Creation method for new population, any of {supported_methods}", "best1bin"),
12-
popsize=("Population size", 20),
13-
maxiter=("Number of generations", 100))
18+
supported_methods = ["best1bin", "rand1bin", "best2bin", "rand2bin", "best1exp", "rand1exp", "best2exp", "rand2exp", "currenttobest1bin", "currenttobest1exp", "randtobest1bin", "randtobest1exp"]
1419

1520

1621
def tune(searchspace: Searchspace, runner, tuning_options):
17-
18-
19-
method, popsize, maxiter = common.get_options(tuning_options.strategy_options, _options)
20-
21-
# build a bounds array as needed for the optimizer
2222
cost_func = CostFunc(searchspace, tuning_options, runner)
2323
bounds = cost_func.get_bounds()
2424

25-
# ensure particles start from legal points
26-
population = list(list(p) for p in searchspace.get_random_sample(popsize))
25+
options = tuning_options.strategy_options
26+
popsize, maxiter, F, CR, method = common.get_options(options, _options)
27+
28+
if method not in supported_methods:
29+
raise ValueError(f"Error {method} not supported, {supported_methods=}")
2730

28-
# call the differential evolution optimizer
29-
opt_result = None
3031
try:
31-
opt_result = differential_evolution(cost_func, bounds, maxiter=maxiter, popsize=popsize, init=population,
32-
polish=False, strategy=method, disp=tuning_options.verbose)
32+
differential_evolution(searchspace, cost_func, bounds, popsize, maxiter, F, CR, method, tuning_options.verbose)
3333
except util.StopCriterionReached as e:
3434
if tuning_options.verbose:
3535
print(e)
3636

37-
if opt_result and tuning_options.verbose:
38-
print(opt_result.message)
39-
4037
return cost_func.results
4138

4239

4340
tune.__doc__ = common.get_strategy_docstring("Differential Evolution", _options)
41+
42+
43+
def values_to_indices(individual_values, tune_params):
44+
"""Converts an individual's values to its corresponding index vector."""
45+
idx = np.zeros(len(individual_values))
46+
for i, v in enumerate(tune_params.values()):
47+
idx[i] = v.index(individual_values[i])
48+
return idx
49+
50+
51+
def indices_to_values(individual_indices, tune_params):
52+
"""Converts an individual's index vector back to its values."""
53+
tune_params_list = list(tune_params.values())
54+
print(f"{tune_params_list=} {individual_indices=}")
55+
values = []
56+
for dim, idx in enumerate(individual_indices):
57+
values.append(tune_params_list[dim][idx])
58+
return np.array(values)
59+
60+
61+
def parse_method(method):
62+
""" Helper func to parse the preferred method into its components. """
63+
pattern = r"^(best|rand|currenttobest|randtobest)(1|2)(bin|exp)$"
64+
match = re.fullmatch(pattern, method)
65+
66+
if match:
67+
return match.group(1) == "best", int(match.group(2)), mutation[match.group(2)], crossover[match.group(3)]
68+
else:
69+
raise ValueError("Error parsing differential evolution method")
70+
71+
72+
def random_draw(idxs, mutation, best):
73+
"""
74+
Draw requested number of random individuals.
75+
76+
Draw without replacement unless there is not enough to draw from.
77+
"""
78+
draw = 2 * mutation + 1 - int(best)
79+
return np.random.choice(idxs, draw, replace=draw>=len(idxs))
80+
81+
82+
def differential_evolution(searchspace, cost_func, bounds, popsize, maxiter, F, CR, method, verbose):
83+
"""
84+
A basic implementation of the Differential Evolution algorithm.
85+
86+
This function finds the minimum of a given cost function within specified bounds.
87+
88+
Args:
89+
cost_func (callable): The objective function to be minimized. It should take a
90+
single argument (a numpy array of parameters) and return a
91+
single scalar value (the cost).
92+
bounds (list of tuples): A list where each tuple contains the (min, max) bounds
93+
for each parameter. e.g., [(-5, 5), (-5, 5)]
94+
popsize (int): The size of the population.
95+
maxiter (int): The maximum number of generations to run.
96+
F (float): The mutation factor, also known as the differential weight.
97+
Should be in the range [0, 2].
98+
CR (float): The crossover probability. Should be in the range [0, 1].
99+
verbose (bool): If True, prints the progress of the algorithm at each generation.
100+
101+
Returns:
102+
dict: A dictionary containing the best solution found ('solution') and its
103+
corresponding cost ('cost').
104+
"""
105+
tune_params = cost_func.tuning_options.tune_params
106+
min_idx = np.zeros(len(tune_params))
107+
max_idx = [len(v)-1 for v in tune_params.values()]
108+
109+
best, mutation, mutation_method, crossover_method = parse_method(method)
110+
111+
# --- 1. Initialization ---
112+
113+
# Get the number of dimensions from the bounds list
114+
dimensions = len(bounds)
115+
116+
# Convert bounds to a numpy array for easier manipulation
117+
bounds = np.array(bounds)
118+
119+
# Initialize the population with random individuals within the bounds
120+
population = np.array(list(list(p) for p in searchspace.get_random_sample(popsize)))
121+
122+
# Calculate the initial cost for each individual in the population
123+
population_cost = np.array([cost_func(ind) for ind in population])
124+
125+
# Keep track of the best solution found so far
126+
best_idx = np.argmin(population_cost)
127+
best_solution = population[best_idx]
128+
best_solution_idx = values_to_indices(best_solution, tune_params)
129+
best_cost = population_cost[best_idx]
130+
131+
# --- 2. Main Loop ---
132+
133+
# Iterate through the specified number of generations
134+
for generation in range(maxiter):
135+
136+
trial_population = []
137+
138+
# Iterate over each individual in the population
139+
for i in range(popsize):
140+
141+
# --- a. Mutation ---
142+
143+
# Select three distinct random individuals (a, b, c) from the population,
144+
# ensuring they are different from the current individual 'i'.
145+
idxs = [idx for idx in range(popsize) if idx != i]
146+
randos = random_draw(idxs, mutation, best)
147+
148+
if mutation_method == mutate_currenttobest1:
149+
randos[0] = i
150+
151+
randos_idx = [values_to_indices(population[rando], tune_params) for rando in randos]
152+
153+
# Apply mutation strategy
154+
donor_vector_idx = mutation_method(best_solution_idx, randos_idx, F, min_idx, max_idx, best)
155+
donor_vector = indices_to_values(donor_vector_idx, tune_params)
156+
157+
# --- b. Crossover ---
158+
trial_vector = crossover_method(donor_vector, population[i], CR)
159+
160+
# Store for selection
161+
trial_population.append(trial_vector)
162+
163+
# --- c. Selection ---
164+
165+
# Calculate the cost of the new trial vectors
166+
trial_population_cost = np.array([cost_func(ind) for ind in trial_population])
167+
168+
# Iterate over each individual in the trial population
169+
for i in range(popsize):
170+
171+
trial_vector = trial_population[i]
172+
trial_cost = trial_population_cost[i]
173+
174+
# If the trial vector has a lower or equal cost, it replaces the
175+
# target vector in the population for the next generation.
176+
if trial_cost <= population_cost[i]:
177+
population[i] = trial_vector
178+
population_cost[i] = trial_cost
179+
180+
# Update the overall best solution if the new one is better
181+
if trial_cost < best_cost:
182+
best_cost = trial_cost
183+
best_solution = trial_vector
184+
best_solution_idx = values_to_indices(best_solution, tune_params)
185+
186+
# Print the progress at the end of the generation
187+
if verbose:
188+
print(f"Generation {generation + 1}, Best Cost: {best_cost:.6f}")
189+
190+
return {'solution': best_solution, 'cost': best_cost}
191+
192+
193+
def round_and_clip(mutant_idx_float, min_idx, max_idx):
194+
""" Helper func to round floating index to nearest integer and clip within bounds. """
195+
# Round to the nearest integer
196+
rounded_idx = np.round(mutant_idx_float)
197+
198+
# Clip the indices to ensure they are within valid index bounds
199+
clipped_idx = np.clip(rounded_idx, min_idx, max_idx)
200+
201+
# Convert final mutant vector to integer type
202+
return clipped_idx.astype(int)
203+
204+
205+
def mutate_currenttobest1(best_idx, randos_idx, F, min_idx, max_idx, best):
206+
"""
207+
Performs the DE/1 currenttobest1 mutation strategy.
208+
209+
This function operates on the indices of the parameters, not their actual values.
210+
The formula v = cur + F * (best - cur + a - b) is applied to the indices, and the result is
211+
then rounded and clipped to ensure it remains a valid index.
212+
"""
213+
cur_idx, b_idx, c_idx = randos_idx
214+
215+
# Apply the DE/currenttobest/1 formula to the indices
216+
mutant_idx_float = cur_idx + F * (best_idx - cur_idx + b_idx - c_idx)
217+
218+
return round_and_clip(mutant_idx_float, min_idx, max_idx)
219+
220+
221+
def mutate_randtobest1(best_idx, randos_idx, F, min_idx, max_idx, best):
222+
"""
223+
Performs the DE/1 randtobest1 mutation strategy.
224+
225+
This function operates on the indices of the parameters, not their actual values.
226+
The formula v = a + F * (best - a + b - c) is applied to the indices, and the result is
227+
then rounded and clipped to ensure it remains a valid index.
228+
"""
229+
a_idx, b_idx, c_idx = randos_idx
230+
231+
# Apply the DE/currenttobest/1 formula to the indices
232+
mutant_idx_float = a_idx + F * (best_idx - a_idx + b_idx - c_idx)
233+
234+
return round_and_clip(mutant_idx_float, min_idx, max_idx)
235+
236+
237+
def mutate_de_1(best_idx, randos_idx, F, min_idx, max_idx, best):
238+
"""
239+
Performs the DE/1 mutation strategy.
240+
241+
This function operates on the indices of the parameters, not their actual values.
242+
The formula v = a + F * (b - c) is applied to the indices, and the result is
243+
then rounded and clipped to ensure it remains a valid index.
244+
245+
"""
246+
if best:
247+
a_idx = best_idx
248+
b_idx, c_idx = randos_idx
249+
else:
250+
a_idx, b_idx, c_idx = randos_idx
251+
252+
# Apply the DE/rand/1 formula to the indices
253+
mutant_idx_float = a_idx + F * (b_idx - c_idx)
254+
255+
return round_and_clip(mutant_idx_float, min_idx, max_idx)
256+
257+
258+
def mutate_de_2(best_idx, randos_idx, F, min_idx, max_idx, best):
259+
"""
260+
Performs the DE/2 mutation strategy for a discrete search space.
261+
262+
This function operates on the indices of the parameters, not their actual values.
263+
The formula v = a + F1 * (b - c) + F2 * (d - e) is applied to the indices,
264+
and the result is then rounded and clipped to ensure it remains a valid index.
265+
266+
"""
267+
if best:
268+
a_idx = best_idx
269+
b_idx, c_idx, d_idx, e_idx = randos_idx
270+
else:
271+
a_idx, b_idx, c_idx, d_idx, e_idx = randos_idx
272+
273+
# Apply the DE/2 formula to the indices
274+
mutant_idx_float = a_idx + F * (b_idx + c_idx - d_idx - e_idx)
275+
276+
return round_and_clip(mutant_idx_float, min_idx, max_idx)
277+
278+
279+
def binomial_crossover(donor_vector, target, CR):
280+
""" Performs binomial crossover of donor_vector with target given crossover rate CR. """
281+
# Create the trial vector by mixing parameters from the target and donor vectors
282+
trial_vector = np.copy(target)
283+
dimensions = len(donor_vector)
284+
285+
# Generate a random array of floats for comparison with the crossover rate CR
286+
crossover_points = np.random.rand(dimensions) < CR
287+
288+
# Ensure at least one parameter is taken from the donor vector
289+
# to prevent the trial vector from being identical to the target vector.
290+
if not np.any(crossover_points):
291+
crossover_points[np.random.randint(0, dimensions)] = True
292+
293+
# Apply crossover
294+
trial_vector[crossover_points] = donor_vector[crossover_points]
295+
296+
return trial_vector
297+
298+
299+
def exponential_crossover(donor_vector, target, CR):
300+
"""
301+
Performs exponential crossover for a discrete search space.
302+
303+
This creates a trial vector by taking a contiguous block of parameters
304+
from the donor vector and the rest from the target vector.
305+
"""
306+
dimensions = len(target)
307+
trial_idx = np.copy(target)
308+
309+
# 1. Select a random starting point for the crossover block.
310+
start_point = np.random.randint(0, dimensions)
311+
312+
# 2. Determine the length of the block to be copied from the mutant.
313+
# The loop continues as long as random numbers are less than CR.
314+
# This ensures at least one parameter is always taken from the mutant.
315+
l = 0
316+
while np.random.rand() < CR and l < dimensions:
317+
crossover_point = (start_point + l) % dimensions
318+
trial_idx[crossover_point] = donor_vector[crossover_point]
319+
l += 1
320+
321+
return trial_idx
322+
323+
mutation = {"1": mutate_de_1, "2": mutate_de_2, "currenttobest1": mutate_currenttobest1, "randtobest1": mutate_randtobest1}
324+
crossover = {"bin": binomial_crossover, "exp": exponential_crossover}

test/strategies/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)