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.
- hone/__init__.py +40 -0
- hone/benchmarks/__init__.py +17 -0
- hone/benchmarks/__main__.py +84 -0
- hone/benchmarks/ml.py +133 -0
- hone/benchmarks/problems.py +236 -0
- hone/benchmarks/runner.py +295 -0
- hone/config/__init__.py +4 -0
- hone/config/bayesian_config.py +23 -0
- hone/config/cmaes_config.py +11 -0
- hone/config/genetic_optimizer_config.py +74 -0
- hone/config/optimizer_config.py +48 -0
- hone/objective_function/__init__.py +1 -0
- hone/objective_function/objective_function.py +47 -0
- hone/optimizers/__init__.py +6 -0
- hone/optimizers/_gp.py +134 -0
- hone/optimizers/_pool.py +161 -0
- hone/optimizers/base.py +619 -0
- hone/optimizers/bayesian.py +265 -0
- hone/optimizers/choice.py +44 -0
- hone/optimizers/cmaes.py +232 -0
- hone/optimizers/genetic/__init__.py +1 -0
- hone/optimizers/genetic/optimizer.py +184 -0
- hone/optimizers/genetic/selection.py +28 -0
- hone/optimizers/genetic/variation.py +47 -0
- hone/optimizers/random_search.py +13 -0
- hone/parameters/__init__.py +1 -0
- hone/parameters/parameters.py +237 -0
- hone/py.typed +0 -0
- hone/result.py +118 -0
- hone_python-0.9.0.dist-info/METADATA +339 -0
- hone_python-0.9.0.dist-info/RECORD +33 -0
- hone_python-0.9.0.dist-info/WHEEL +4 -0
- hone_python-0.9.0.dist-info/licenses/LICENSE +28 -0
|
@@ -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)
|
hone/optimizers/cmaes.py
ADDED
|
@@ -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
|