hone-python 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ """Bayesian optimization: model the objective with a Gaussian process, and evaluate where the model expects the most improvement.
2
+
3
+ Each candidate costs a model fit and a search over the model, a fraction of a second up to a few
4
+ hundred evaluations, so Bayesian optimization suits objectives that take far longer than that:
5
+ simulations, experiments, training runs. It makes the most of a small budget.
6
+
7
+ Range parameters are scaled to [0, 1], on a log scale where they have one; integers are searched
8
+ as numbers and rounded. Each choice
9
+ parameter becomes one column per choice, holding 1 for the chosen one and 0 for the rest.
10
+ """
11
+ import math
12
+ from typing import Any, List
13
+
14
+ import numpy as np
15
+ from scipy.optimize import minimize
16
+ from scipy.special import erfcx, log_ndtr
17
+ from threadpoolctl import threadpool_limits
18
+
19
+ from hone.config import BayesianConfig
20
+ from hone.optimizers._gp import GaussianProcess
21
+ from hone.optimizers.base import Individual, Optimizer, UnsupportedSearchSpace
22
+ from hone.parameters import ChoiceParameter, Parameter, RangeParameter, SearchSpace
23
+
24
+ GLOBAL_CANDIDATES = 1000 # random candidates per search of the model
25
+ LOCAL_CANDIDATES = 500 # candidates near the best points found so far
26
+ REFINED = 5 # the best candidates are refined by gradient search
27
+ REFIT_EVERY = 5 # past FIT_EVERY_UNTIL observations, refit the model's hyperparameters only this often
28
+ FIT_EVERY_UNTIL = 50
29
+
30
+
31
+ class BayesianOptimizer(Optimizer):
32
+ """Bayesian optimization with a Gaussian process and expected improvement.
33
+
34
+ The first generation is spread over the search space (with any initial points). After that,
35
+ each candidate is the point where a Gaussian process model of the objective predicts the
36
+ largest expected improvement on the best value so far. Failed evaluations are modelled as
37
+ the worst value seen, which steers the search away from them.
38
+
39
+ Range parameters, integer or not, and choice parameters are supported, but not choice
40
+ parameters with choose_n. Fixed parameters are passed through.
41
+
42
+ Args:
43
+ search_space: The parameters to optimize over, as a SearchSpace or a list of parameters.
44
+ config: A BayesianConfig. Defaults are used if omitted.
45
+ initial_points: Points to evaluate first: one dict of parameter values, or a list of them.
46
+ """
47
+ config_class = BayesianConfig
48
+ config: BayesianConfig
49
+
50
+ def __init__(
51
+ self,
52
+ search_space: SearchSpace | List[Parameter],
53
+ config: BayesianConfig | None = None,
54
+ *,
55
+ initial_points: dict[str, Any] | list[dict[str, Any]] | None = None,
56
+ ) -> None:
57
+ if not isinstance(search_space, SearchSpace):
58
+ search_space = SearchSpace(search_space)
59
+ self._encoding = _Encoding(search_space)
60
+ super().__init__(search_space, config, initial_points=initial_points)
61
+
62
+ def reset(self) -> None:
63
+ super().reset()
64
+ self.batch_size = self.config.population_size or self._workers
65
+ self._X: list[np.ndarray] = [] # every evaluated point, encoded
66
+ self._y: list[float | None] = [] # its value to minimize, or None if it failed
67
+ self._theta: np.ndarray | None = None # the model's hyperparameters, kept between fits
68
+ self._fitted_at = 0 # observations at the last hyperparameter fit
69
+
70
+ def _next_population(self, previous: list[Individual] | None, generation: int) -> list[Individual]:
71
+ if previous is None:
72
+ initial = self._initial_individuals(generation)
73
+ count = self.config.initial_samples or 2 * (self._encoding.variables + 1)
74
+ taken = {tuple(self._encoding.encode(ind.params)) for ind in initial}
75
+ spread = []
76
+ for x in self._encoding.spread(self.rng, max(count - len(initial), 0)):
77
+ # Rounding integers can land two points on the same values; try others instead.
78
+ for _ in range(100):
79
+ if tuple(x) not in taken:
80
+ break
81
+ x = self._encoding.spread(self.rng, 1)[0]
82
+ taken.add(tuple(x))
83
+ spread.append(x)
84
+ return initial + [Individual(params=self._encoding.decode(x), generation=generation) for x in spread]
85
+ for individual in previous:
86
+ if individual.fitness is not None:
87
+ self._X.append(self._encoding.encode(individual.params))
88
+ value = individual.fitness if not self.config.maximize else -individual.fitness
89
+ self._y.append(None if individual.failed else value)
90
+ # The model's matrices are small, and splitting their arithmetic across threads makes it many
91
+ # times slower. One thread also makes results the same on every machine.
92
+ with threadpool_limits(limits=1, user_api="blas"):
93
+ proposals = self._propose(self.batch_size)
94
+ return [Individual(params=self._encoding.decode(x), generation=generation) for x in proposals]
95
+
96
+ # --- choosing candidates --------------------------------------------------
97
+
98
+ def _propose(self, count: int) -> list[np.ndarray]:
99
+ succeeded = [y for y in self._y if y is not None]
100
+ if len(succeeded) < 2:
101
+ return list(self._encoding.spread(self.rng, count))
102
+ worst = max(succeeded)
103
+ X = np.array(self._X)
104
+ model = GaussianProcess(X, np.array([worst if y is None else y for y in self._y]), self._theta)
105
+ if self._theta is None or len(X) <= FIT_EVERY_UNTIL or len(X) - self._fitted_at >= REFIT_EVERY:
106
+ model.fit(restarts=2 if self._theta is None else 0, rng=self.rng)
107
+ self._theta, self._fitted_at = model.theta, len(X)
108
+
109
+ # The best value so far. With noise, the best observation is likely a lucky one, so use the
110
+ # lowest the model predicts at any evaluated point instead.
111
+ best = float(model.predict(X)[0].min()) if self.config.noise else float(model.z.min())
112
+ # Candidates still being evaluated are assumed to come out as predicted, so none is proposed again.
113
+ for individual in self._outstanding():
114
+ x = self._encoding.encode(individual.params)
115
+ model = model.with_observation(x, float(model.predict(x)[0][0]))
116
+ chosen = []
117
+ for _ in range(count):
118
+ x = self._maximize_improvement(model, best)
119
+ chosen.append(x)
120
+ # Pretend the model's prediction came true, so the next candidate of the batch goes elsewhere.
121
+ mean, _ = model.predict(x)
122
+ model = model.with_observation(x, float(mean[0]))
123
+ return chosen
124
+
125
+ def _maximize_improvement(self, model: GaussianProcess, best: float) -> np.ndarray:
126
+ encoding = self._encoding
127
+ leaders = model.X[np.argsort(model.z)[:5]]
128
+ local = leaders[self.rng.integers(len(leaders), size=LOCAL_CANDIDATES)]
129
+ scales = self.rng.choice([0.01, 0.05, 0.2], size=(LOCAL_CANDIDATES, 1))
130
+ local = encoding.perturb(self.rng, local, scales)
131
+ candidates = encoding.snap(np.vstack([encoding.spread(self.rng, GLOBAL_CANDIDATES), local]))
132
+ scores = _log_expected_improvement(model, candidates, best)
133
+
134
+ columns = encoding.numeric
135
+ if columns.size:
136
+ starts = candidates[np.argsort(-scores)[:REFINED]]
137
+ refined = encoding.snap(_refine(model, starts, columns, best))
138
+ candidates = np.vstack([candidates, refined])
139
+ scores = np.append(scores, _log_expected_improvement(model, refined, best))
140
+
141
+ seen = model.X
142
+ for index in np.argsort(-scores):
143
+ if not np.any(np.all(np.abs(seen - candidates[index]) < 1e-9, axis=1)):
144
+ return candidates[index]
145
+ return encoding.spread(self.rng, 1)[0] # every candidate was already evaluated
146
+
147
+
148
+ def _refine(model: GaussianProcess, starts: np.ndarray, columns: np.ndarray, best: float) -> np.ndarray:
149
+ """Each start moved uphill in expected improvement by gradient search over the numeric columns.
150
+
151
+ The gradient comes from finite differences, all taken in one vectorized call to the model.
152
+ """
153
+ step = 1e-6
154
+ refined = starts.copy()
155
+ for row, start in enumerate(starts):
156
+
157
+ def negative_and_gradient(u: np.ndarray, start=start) -> tuple[float, np.ndarray]:
158
+ X = np.repeat(start[None, :], len(columns) + 1, axis=0)
159
+ X[:, columns] = u
160
+ X[np.arange(1, len(columns) + 1), columns] += step
161
+ values = _log_expected_improvement(model, X, best)
162
+ return -values[0], -(values[1:] - values[0]) / step
163
+
164
+ found = minimize(negative_and_gradient, start[columns], jac=True, method="L-BFGS-B", bounds=[(0, 1 - step)] * len(columns))
165
+ if found.fun <= -_log_expected_improvement(model, start[None, :], best)[0]:
166
+ refined[row, columns] = found.x
167
+ return refined
168
+
169
+
170
+ def _log_expected_improvement(model: GaussianProcess, X: np.ndarray, best: float) -> np.ndarray:
171
+ """log E[max(best - f(x), 0)] for each row of X, computed stably even where it is tiny."""
172
+ mean, sd = model.predict(X)
173
+ u = (best - mean) / sd
174
+ log_h = np.empty_like(u)
175
+ # h(u) = phi(u) + u Phi(u), the expected improvement of a standard normal past -u.
176
+ upper = u > -1
177
+ log_h[upper] = np.log(np.exp(-0.5 * u[upper] ** 2) / math.sqrt(2 * math.pi) + u[upper] * np.exp(log_ndtr(u[upper])))
178
+ lower = np.maximum(u[~upper], -40.0)
179
+ # For u <= -1: h(u) = phi(u) (1 + u Phi(u) / phi(u)), where Phi(u) / phi(u) = sqrt(pi/2) erfcx(-u / sqrt 2).
180
+ log_h[~upper] = -0.5 * lower**2 - 0.5 * math.log(2 * math.pi) + np.log1p(
181
+ lower * math.sqrt(math.pi / 2) * erfcx(-lower / math.sqrt(2))
182
+ )
183
+ return log_h + np.log(sd)
184
+
185
+
186
+ class _Encoding:
187
+ """Between parameter values and points in [0, 1]^columns, where the model works."""
188
+
189
+ def __init__(self, search_space: SearchSpace) -> None:
190
+ self.search_space = search_space
191
+ self._blocks: list[tuple[RangeParameter | ChoiceParameter, slice]] = []
192
+ self._constant: dict[str, Any] = {}
193
+ column = 0
194
+ numeric = []
195
+ for p in search_space.parameters:
196
+ if isinstance(p, ChoiceParameter) and p.choose_n is not None:
197
+ raise UnsupportedSearchSpace(
198
+ f"Bayesian optimization doesn't support choose_n choices ({p.name}); use GeneticOptimizer"
199
+ )
200
+ if isinstance(p, RangeParameter) and p.maximum > p.minimum:
201
+ self._blocks.append((p, slice(column, column + 1)))
202
+ numeric.append(column)
203
+ column += 1
204
+ elif isinstance(p, ChoiceParameter) and len(p.choices) > 1:
205
+ self._blocks.append((p, slice(column, column + len(p.choices))))
206
+ column += len(p.choices)
207
+ else:
208
+ self._constant[p.name] = p.sample(np.random.default_rng(0)) # fixed, or only one possible value
209
+ if not self._blocks:
210
+ raise UnsupportedSearchSpace("Bayesian optimization needs at least one parameter with more than one possible value")
211
+ self.columns = column
212
+ self.variables = len(self._blocks)
213
+ self.numeric = np.array(numeric, dtype=int)
214
+
215
+ def encode(self, params: dict) -> np.ndarray:
216
+ x = np.zeros(self.columns)
217
+ for p, block in self._blocks:
218
+ if isinstance(p, RangeParameter):
219
+ x[block] = p.to_unit(params[p.name])
220
+ else:
221
+ x[block.start + p.choices.index(params[p.name])] = 1.0
222
+ return x
223
+
224
+ def decode(self, x: np.ndarray) -> dict:
225
+ values = dict(self._constant)
226
+ for p, block in self._blocks:
227
+ if isinstance(p, RangeParameter):
228
+ values[p.name] = p.from_unit(float(x[block][0]))
229
+ else:
230
+ values[p.name] = p.choices[int(np.argmax(x[block]))]
231
+ return {name: values[name] for name in self.search_space.names}
232
+
233
+ def snap(self, X: np.ndarray) -> np.ndarray:
234
+ """Each row moved to the nearest point that parameter values can take: whole integers and one choice per block."""
235
+ X = np.clip(X, 0, 1)
236
+ for p, block in self._blocks:
237
+ if isinstance(p, RangeParameter) and p.integer:
238
+ X[:, block] = p.to_unit(p.from_unit(X[:, block]))
239
+ elif isinstance(p, ChoiceParameter):
240
+ chosen = np.argmax(X[:, block], axis=1)
241
+ X[:, block] = 0.0
242
+ X[np.arange(len(X)), block.start + chosen] = 1.0
243
+ return X
244
+
245
+ def spread(self, rng: np.random.Generator, count: int) -> np.ndarray:
246
+ """count points spread over the space: a Latin hypercube for numbers, random choices otherwise."""
247
+ X = np.zeros((count, self.columns))
248
+ for p, block in self._blocks:
249
+ if isinstance(p, RangeParameter):
250
+ X[:, block.start] = (rng.permutation(count) + rng.uniform(size=count)) / max(count, 1)
251
+ else:
252
+ X[np.arange(count), block.start + rng.integers(len(p.choices), size=count)] = 1.0
253
+ return self.snap(X)
254
+
255
+ def perturb(self, rng: np.random.Generator, X: np.ndarray, scales: np.ndarray) -> np.ndarray:
256
+ """X nudged: numbers by Gaussian steps of the given scale, and each choice switched with probability 0.2."""
257
+ X = X.copy()
258
+ if self.numeric.size:
259
+ X[:, self.numeric] += rng.normal(size=(len(X), self.numeric.size)) * scales
260
+ for p, block in self._blocks:
261
+ if isinstance(p, ChoiceParameter):
262
+ switch = rng.uniform(size=len(X)) < 0.2
263
+ X[switch, block] = 0.0
264
+ X[np.flatnonzero(switch), block.start + rng.integers(len(p.choices), size=switch.sum())] = 1.0
265
+ return X
@@ -0,0 +1,44 @@
1
+ """Choosing an optimizer for a problem, by what the benchmark shows works best."""
2
+ from typing import Any
3
+
4
+ from hone.config import BayesianConfig, CMAESConfig, GeneticOptimizerConfig
5
+ from hone.parameters import ChoiceParameter, Parameter, RangeParameter, SearchSpace
6
+
7
+ from .base import Optimizer
8
+ from .bayesian import BayesianOptimizer
9
+ from .cmaes import CMAES
10
+ from .genetic import GeneticOptimizer
11
+
12
+ # Up to this many evaluations, Bayesian optimization had the lowest median error on most
13
+ # benchmark problems; past it, each of its candidates costs too long to choose.
14
+ BAYESIAN_UP_TO = 200
15
+
16
+
17
+ def choose_optimizer(
18
+ search_space: SearchSpace | list[Parameter],
19
+ max_evaluations: int,
20
+ *,
21
+ initial_points: dict[str, Any] | list[dict[str, Any]] | None = None,
22
+ **config: Any,
23
+ ) -> Optimizer:
24
+ """The optimizer the benchmark suggests for this search space and budget, set up to use it.
25
+
26
+ - Up to 200 evaluations: Bayesian optimization, unless a choice parameter has choose_n.
27
+ - More, with choice parameters: the genetic optimizer, since CMA-ES searches numbers only.
28
+ - More, with integer parameters: the genetic optimizer, which did better on the benchmark's
29
+ integer and mixed problems.
30
+ - More, with continuous numbers only: CMA-ES.
31
+
32
+ Other settings, such as seed, maximize, noise or threshold, are passed on to the chosen
33
+ optimizer's config. The optimizer's class and config show what was chosen.
34
+ """
35
+ if not isinstance(search_space, SearchSpace):
36
+ search_space = SearchSpace(search_space)
37
+ choices = [p for p in search_space.parameters if isinstance(p, ChoiceParameter)]
38
+ settings = dict(config, max_evaluations=max_evaluations)
39
+ if max_evaluations <= BAYESIAN_UP_TO and not any(p.choose_n is not None for p in choices):
40
+ return BayesianOptimizer(search_space, BayesianConfig(**settings), initial_points=initial_points)
41
+ integers = [p for p in search_space.parameters if isinstance(p, RangeParameter) and p.integer and p.maximum > p.minimum]
42
+ if choices or integers:
43
+ return GeneticOptimizer(search_space, GeneticOptimizerConfig(**settings), initial_points=initial_points)
44
+ return CMAES(search_space, CMAESConfig(**settings), initial_points=initial_points)
@@ -0,0 +1,232 @@
1
+ """CMA-ES: the covariance matrix adaptation evolution strategy.
2
+
3
+ The update follows N. Hansen, "The CMA Evolution Strategy: A Tutorial" (arXiv:1604.00772),
4
+ with positive recombination weights. Three additions adapt it to hone's search spaces:
5
+
6
+ - Range parameters are scaled to [0, 1], on a log scale where they have one, and a sample
7
+ that falls outside is mirrored back in. The mirrored point is the one evaluated and used
8
+ in the update.
9
+ - Integer parameters keep a minimum spread of a quarter of one integer step, so the search
10
+ can always move to a neighbouring value. Samples are widened to that spread, but the
11
+ distribution learns from the steps as drawn before widening, as in Hamano et al.,
12
+ "CMA-ES with Margin" (GECCO 2022), so the widening doesn't inflate the step size.
13
+ - With restarts on, a run that converges or stalls starts again from a random point with
14
+ twice the population (Auger and Hansen, "A Restart CMA Evolution Strategy With Increasing
15
+ Population Size", CEC 2005).
16
+ """
17
+ import math
18
+ from typing import Any, List
19
+
20
+ import numpy as np
21
+
22
+ from hone.config import CMAESConfig
23
+ from hone.optimizers.base import Individual, Optimizer, UnsupportedSearchSpace, _fitness
24
+ from hone.parameters import ChoiceParameter, FixedParameter, Parameter, RangeParameter, SearchSpace
25
+
26
+ INTEGER_SPREAD = 0.25 # minimum standard deviation of an integer variable, in integer steps
27
+ MAX_CONDITION = 1e14 # restart when the covariance matrix is this badly conditioned
28
+ MIN_STEP = 1e-12 # restart when every continuous step, in [0, 1] units, falls below this
29
+ FLAT = 1e-12 # restart when recent generations' best values differ by less than this
30
+
31
+
32
+ class CMAES(Optimizer):
33
+ """Covariance matrix adaptation evolution strategy, for range parameters.
34
+
35
+ Each generation samples candidates from a multivariate normal distribution, then moves
36
+ the distribution's centre toward the best of them and reshapes it to stretch along the
37
+ directions that improved. It learns the scale of each variable and how variables
38
+ interact, which makes it strong on smooth continuous problems.
39
+
40
+ Range parameters, continuous or integer, are searched; fixed parameters are passed
41
+ through. Choice parameters are not supported: use GeneticOptimizer for those.
42
+
43
+ The search starts at the centre of every range. With initial points, it evaluates them
44
+ first and starts at the best one instead; a smaller initial_step then keeps the search
45
+ close to it.
46
+
47
+ Args:
48
+ search_space: The parameters to optimize over, as a SearchSpace or a list of parameters.
49
+ config: A CMAESConfig. Defaults are used if omitted.
50
+ initial_points: Points to evaluate first: one dict of parameter values, or a list of them.
51
+ """
52
+ config_class = CMAESConfig
53
+ config: CMAESConfig
54
+
55
+ def __init__(
56
+ self,
57
+ search_space: SearchSpace | List[Parameter],
58
+ config: CMAESConfig | None = None,
59
+ *,
60
+ initial_points: dict[str, Any] | list[dict[str, Any]] | None = None,
61
+ ) -> None:
62
+ if not isinstance(search_space, SearchSpace):
63
+ search_space = SearchSpace(search_space)
64
+ choices = [p.name for p in search_space.parameters if isinstance(p, ChoiceParameter)]
65
+ if choices:
66
+ raise UnsupportedSearchSpace(
67
+ f"CMA-ES searches range parameters only; use GeneticOptimizer for choice parameters ({', '.join(choices)})"
68
+ )
69
+ searched = [p for p in search_space.parameters if isinstance(p, RangeParameter) and p.maximum > p.minimum]
70
+ if not searched:
71
+ raise UnsupportedSearchSpace("CMA-ES needs at least one range parameter with minimum below maximum")
72
+ self._searched = searched
73
+ self._constant = {
74
+ p.name: (p.from_unit(0.0) if isinstance(p, RangeParameter) else p.value)
75
+ for p in search_space.parameters
76
+ if isinstance(p, (RangeParameter, FixedParameter)) and p not in searched
77
+ }
78
+ self._integer = np.array([p.integer for p in searched])
79
+ super().__init__(search_space, config, initial_points=initial_points)
80
+
81
+ # --- state ----------------------------------------------------------------
82
+
83
+ def reset(self) -> None:
84
+ super().reset()
85
+ n = len(self._searched)
86
+ # Each generation waits for its slowest evaluation, so with many workers a population of
87
+ # at least twice their number keeps them busy.
88
+ self.population_size = self.config.population_size or max(4 + int(3 * math.log(n)), 2 * self._workers)
89
+ self._start(mean=np.full(n, 0.5))
90
+ self._evaluating_initial_points = False
91
+
92
+ def _start(self, mean: np.ndarray) -> None:
93
+ """Set up a fresh search distribution around mean for the current population size."""
94
+ n = len(mean)
95
+ lam = self.population_size
96
+ mu = lam // 2
97
+ weights = math.log((lam + 1) / 2) - np.log(np.arange(1, mu + 1))
98
+ self._weights = weights / weights.sum()
99
+ self._mu_eff = 1 / np.sum(self._weights**2)
100
+ mu_eff = self._mu_eff
101
+
102
+ self._c_sigma = (mu_eff + 2) / (n + mu_eff + 5)
103
+ self._d_sigma = 1 + 2 * max(0.0, math.sqrt((mu_eff - 1) / (n + 1)) - 1) + self._c_sigma
104
+ self._c_c = (4 + mu_eff / n) / (n + 4 + 2 * mu_eff / n)
105
+ self._c_1 = 2 / ((n + 1.3) ** 2 + mu_eff)
106
+ self._c_mu = min(1 - self._c_1, 2 * (mu_eff - 2 + 1 / mu_eff) / ((n + 2) ** 2 + mu_eff))
107
+ self._chi_n = math.sqrt(n) * (1 - 1 / (4 * n) + 1 / (21 * n**2))
108
+
109
+ self._mean = mean
110
+ self._sigma = self.config.initial_step
111
+ self._C = np.eye(n)
112
+ self._B = np.eye(n)
113
+ self._D = np.ones(n)
114
+ self._p_sigma = np.zeros(n)
115
+ self._p_c = np.zeros(n)
116
+ self._iteration = 0
117
+ self._recent_bests: list[float] = []
118
+ self._vectors: dict[Individual, np.ndarray] = {} # each individual of the current generation -> its point in [0, 1]^n
119
+
120
+ # --- the generation -------------------------------------------------------
121
+
122
+ def _next_population(self, previous: list[Individual] | None, generation: int) -> list[Individual]:
123
+ if previous is None and self.initial_points:
124
+ self._evaluating_initial_points = True
125
+ return self._initial_individuals(generation)
126
+ if self._evaluating_initial_points:
127
+ assert previous is not None
128
+ self._evaluating_initial_points = False
129
+ succeeded = self._ranked([ind for ind in previous if not ind.failed])
130
+ if succeeded:
131
+ self._mean = self._encode(succeeded[0].params)
132
+ elif previous is not None:
133
+ self._update(previous)
134
+ spread = self._sigma * np.sqrt(np.diag(self._C))
135
+ minimum = self._minimum_spread()
136
+ # Integer variables are sampled wider when their spread falls below the minimum.
137
+ self._widen = np.where(spread < minimum, minimum / np.maximum(spread, 1e-300), 1.0)
138
+ return [self._sample(generation) for _ in range(self.population_size)]
139
+
140
+ def _sample(self, generation: int) -> Individual:
141
+ z = self.rng.standard_normal(len(self._mean))
142
+ x = _mirror(self._mean + self._sigma * self._widen * (self._B @ (self._D * z)))
143
+ individual = Individual(params=self._decode(x), generation=generation)
144
+ self._vectors[individual] = x
145
+ return individual
146
+
147
+ def _update(self, previous: list[Individual]) -> None:
148
+ ranked = [ind for ind in self._ranked(previous) if not ind.failed and ind in self._vectors]
149
+ if ranked:
150
+ self._adapt(np.array([self._vectors[ind] for ind in ranked[: len(self._weights)]]))
151
+ self._recent_bests.append(_fitness(ranked[0]))
152
+ self._vectors = {}
153
+ if self.config.restarts and self._should_restart():
154
+ self.population_size *= 2
155
+ self._start(mean=self.rng.uniform(0, 1, len(self._mean)))
156
+
157
+ def _adapt(self, best: np.ndarray) -> None:
158
+ """Move and reshape the distribution toward best, the top points ordered best first."""
159
+ n = len(self._mean)
160
+ weights = self._weights[: len(best)] / self._weights[: len(best)].sum()
161
+ # The paths, step size and covariance learn from the steps as sampled before integer
162
+ # widening, as in CMA-ES with margin; otherwise the widening would read as the search
163
+ # moving faster than it is, and inflate the step size.
164
+ steps = (best - self._mean) / (self._sigma * self._widen)
165
+ step = weights @ steps
166
+ self._mean = self._mean + self._sigma * self._widen * step
167
+
168
+ inv_sqrt_C = self._B @ np.diag(1 / self._D) @ self._B.T
169
+ self._p_sigma = (1 - self._c_sigma) * self._p_sigma + math.sqrt(
170
+ self._c_sigma * (2 - self._c_sigma) * self._mu_eff
171
+ ) * (inv_sqrt_C @ step)
172
+ self._iteration += 1
173
+ norm = np.linalg.norm(self._p_sigma)
174
+ h_sigma = norm / math.sqrt(1 - (1 - self._c_sigma) ** (2 * self._iteration)) < (1.4 + 2 / (n + 1)) * self._chi_n
175
+ self._p_c = (1 - self._c_c) * self._p_c + h_sigma * math.sqrt(self._c_c * (2 - self._c_c) * self._mu_eff) * step
176
+
177
+ rank_mu = steps.T @ (weights[:, None] * steps)
178
+ correction = (1 - h_sigma) * self._c_c * (2 - self._c_c)
179
+ self._C = (
180
+ (1 - self._c_1 - self._c_mu + self._c_1 * correction) * self._C
181
+ + self._c_1 * np.outer(self._p_c, self._p_c)
182
+ + self._c_mu * rank_mu
183
+ )
184
+ self._sigma *= math.exp((self._c_sigma / self._d_sigma) * (norm / self._chi_n - 1))
185
+
186
+ self._C = (self._C + self._C.T) / 2
187
+ eigenvalues, self._B = np.linalg.eigh(self._C)
188
+ self._D = np.sqrt(np.maximum(eigenvalues, 1e-300))
189
+
190
+ def _should_restart(self) -> bool:
191
+ continuous = ~self._integer
192
+ if continuous.any() and np.all(self._sigma * np.sqrt(np.diag(self._C))[continuous] < MIN_STEP):
193
+ return True
194
+ if (self._D.max() / self._D.min()) ** 2 > MAX_CONDITION:
195
+ return True
196
+ window = 10 + math.ceil(30 * len(self._mean) / self.population_size)
197
+ recent = self._recent_bests[-window:]
198
+ return len(recent) == window and max(recent) - min(recent) <= FLAT * max(1.0, abs(min(recent)))
199
+
200
+ def _minimum_spread(self) -> np.ndarray:
201
+ """The smallest standard deviation each variable keeps, in [0, 1] units: INTEGER_SPREAD integer steps near the mean.
202
+
203
+ On a log scale an integer step is wider at the bottom of the range than at the top,
204
+ so it is measured where the search currently is.
205
+ """
206
+ spread = np.zeros(len(self._searched))
207
+ for i, p in enumerate(self._searched):
208
+ if not p.integer:
209
+ continue
210
+ if p.log:
211
+ value = float(np.exp(np.log(p.minimum) + self._mean[i] * (np.log(p.maximum) - np.log(p.minimum))))
212
+ spread[i] = INTEGER_SPREAD * (p.to_unit(value + 1) - p.to_unit(value))
213
+ else:
214
+ spread[i] = INTEGER_SPREAD / (p.maximum - p.minimum)
215
+ return spread
216
+
217
+ # --- between [0, 1]^n and parameter values --------------------------------
218
+
219
+ def _encode(self, params: dict) -> np.ndarray:
220
+ return np.array([p.to_unit(params[p.name]) for p in self._searched], dtype=float)
221
+
222
+ def _decode(self, x: np.ndarray) -> dict:
223
+ values = dict(self._constant)
224
+ for parameter, u in zip(self._searched, x):
225
+ values[parameter.name] = parameter.from_unit(float(u))
226
+ return {name: values[name] for name in self.search_space.names}
227
+
228
+
229
+ def _mirror(x: np.ndarray) -> np.ndarray:
230
+ """Reflect each coordinate back into [0, 1] at the edges."""
231
+ x = np.mod(x, 2.0)
232
+ return np.where(x > 1, 2 - x, x)
@@ -0,0 +1 @@
1
+ from .optimizer import GeneticOptimizer