|
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 |
3 | 4 |
|
4 | 5 | from kernel_tuner import util |
5 | 6 | from kernel_tuner.searchspace import Searchspace |
6 | 7 | from kernel_tuner.strategies import common |
7 | 8 | from kernel_tuner.strategies.common import CostFunc |
8 | 9 |
|
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 | +) |
10 | 17 |
|
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"] |
14 | 19 |
|
15 | 20 |
|
16 | 21 | 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 |
22 | 22 | cost_func = CostFunc(searchspace, tuning_options, runner) |
23 | 23 | bounds = cost_func.get_bounds() |
24 | 24 |
|
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=}") |
27 | 30 |
|
28 | | - # call the differential evolution optimizer |
29 | | - opt_result = None |
30 | 31 | 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) |
33 | 33 | except util.StopCriterionReached as e: |
34 | 34 | if tuning_options.verbose: |
35 | 35 | print(e) |
36 | 36 |
|
37 | | - if opt_result and tuning_options.verbose: |
38 | | - print(opt_result.message) |
39 | | - |
40 | 37 | return cost_func.results |
41 | 38 |
|
42 | 39 |
|
43 | 40 | 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} |
0 commit comments