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,295 @@
|
|
|
1
|
+
"""Run optimizers on benchmark problems and summarize how close they get to each optimum."""
|
|
2
|
+
import functools
|
|
3
|
+
import json
|
|
4
|
+
import multiprocessing
|
|
5
|
+
import statistics
|
|
6
|
+
import zlib
|
|
7
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Iterable
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from pydantic import BaseModel, ConfigDict
|
|
13
|
+
|
|
14
|
+
from hone.config import BayesianConfig, CMAESConfig, GeneticOptimizerConfig, OptimizerConfig
|
|
15
|
+
from hone.optimizers import CMAES, BayesianOptimizer, GeneticOptimizer, Optimizer, RandomSearch, UnsupportedSearchSpace
|
|
16
|
+
|
|
17
|
+
from .problems import Problem
|
|
18
|
+
|
|
19
|
+
# Builds an optimizer for a problem, an evaluation budget and a seed. It may raise
|
|
20
|
+
# UnsupportedSearchSpace for a problem it cannot search, which is then skipped, or
|
|
21
|
+
# SkipBudget for a budget it isn't meant for.
|
|
22
|
+
OptimizerFactory = Callable[[Problem, int, int], Optimizer]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SkipBudget(Exception):
|
|
26
|
+
"""Raised by an optimizer factory to leave out a budget it isn't meant for."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Bayesian optimization takes a fraction of a second per evaluation, which suits expensive
|
|
30
|
+
# objectives but makes long benchmark runs slow, so it runs only up to this many evaluations.
|
|
31
|
+
BAYESIAN_MAX_EVALUATIONS = 200
|
|
32
|
+
|
|
33
|
+
POPULATION_SIZE = 50 # random search's batch size, which doesn't change its results
|
|
34
|
+
|
|
35
|
+
# Evaluations per dimension that the benchmark gives each run. The small budgets show how
|
|
36
|
+
# an optimizer does when each evaluation is expensive, as with a slow simulation.
|
|
37
|
+
BUDGETS = (10, 20, 50, 100, 1000)
|
|
38
|
+
|
|
39
|
+
FLOOR = 1e-8 # errors below this count as equal when comparing results
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def genetic(problem: Problem, budget: int, seed: int) -> Optimizer:
|
|
43
|
+
config = GeneticOptimizerConfig(max_evaluations=budget, seed=seed, noise=problem.noise > 0)
|
|
44
|
+
return GeneticOptimizer(problem.search_space, config)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cma_es(problem: Problem, budget: int, seed: int) -> Optimizer:
|
|
48
|
+
config = CMAESConfig(generations=budget, max_evaluations=budget, seed=seed, noise=problem.noise > 0)
|
|
49
|
+
return CMAES(problem.search_space, config)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bayesian(problem: Problem, budget: int, seed: int) -> Optimizer:
|
|
53
|
+
if budget > BAYESIAN_MAX_EVALUATIONS:
|
|
54
|
+
raise SkipBudget(f"{budget} evaluations is more than Bayesian optimization is run with")
|
|
55
|
+
config = BayesianConfig(max_evaluations=budget, seed=seed, noise=problem.noise > 0)
|
|
56
|
+
return BayesianOptimizer(problem.search_space, config)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def random_search(problem: Problem, budget: int, seed: int) -> Optimizer:
|
|
60
|
+
config = OptimizerConfig(
|
|
61
|
+
population_size=POPULATION_SIZE, generations=budget, max_evaluations=budget, seed=seed, noise=problem.noise > 0
|
|
62
|
+
)
|
|
63
|
+
return RandomSearch(problem.search_space, config)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
DEFAULT_OPTIMIZERS: dict[str, OptimizerFactory] = {
|
|
67
|
+
"genetic": genetic,
|
|
68
|
+
"cma-es": cma_es,
|
|
69
|
+
"bayesian": bayesian,
|
|
70
|
+
"random": random_search,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Run(BaseModel):
|
|
75
|
+
"""One optimizer on one problem with one seed and one budget."""
|
|
76
|
+
model_config = ConfigDict(frozen=True)
|
|
77
|
+
|
|
78
|
+
problem: str
|
|
79
|
+
optimizer: str
|
|
80
|
+
budget_per_dimension: int
|
|
81
|
+
seed: int
|
|
82
|
+
error: float # best value found minus the problem's optimum; 0 means the optimum was found
|
|
83
|
+
evaluations: int
|
|
84
|
+
trace: list[tuple[int, float]] # (evaluations so far, error of the best so far), once per generation
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Summary(BaseModel):
|
|
88
|
+
"""All runs of one optimizer on one problem with one budget."""
|
|
89
|
+
model_config = ConfigDict(frozen=True)
|
|
90
|
+
|
|
91
|
+
problem: str
|
|
92
|
+
optimizer: str
|
|
93
|
+
budget_per_dimension: int
|
|
94
|
+
runs: int
|
|
95
|
+
median_error: float
|
|
96
|
+
lower_quartile: float
|
|
97
|
+
upper_quartile: float
|
|
98
|
+
solved: float # share of runs whose error is at most the tolerance
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def of(cls, runs: list[Run], tolerance: float) -> "Summary":
|
|
102
|
+
errors = sorted(run.error for run in runs)
|
|
103
|
+
quartiles = statistics.quantiles(errors, n=4, method="inclusive") if len(errors) > 1 else errors * 3
|
|
104
|
+
return cls(
|
|
105
|
+
problem=runs[0].problem,
|
|
106
|
+
optimizer=runs[0].optimizer,
|
|
107
|
+
budget_per_dimension=runs[0].budget_per_dimension,
|
|
108
|
+
runs=len(runs),
|
|
109
|
+
median_error=statistics.median(errors),
|
|
110
|
+
lower_quartile=quartiles[0],
|
|
111
|
+
upper_quartile=quartiles[2],
|
|
112
|
+
solved=sum(error <= tolerance for error in errors) / len(errors),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run_benchmark(
|
|
117
|
+
problems: Iterable[Problem],
|
|
118
|
+
optimizers: dict[str, OptimizerFactory] | None = None,
|
|
119
|
+
seeds: Iterable[int] = range(10),
|
|
120
|
+
budgets_per_dimension: Iterable[int] = BUDGETS,
|
|
121
|
+
jobs: int = 1,
|
|
122
|
+
) -> list[Run]:
|
|
123
|
+
"""Run every optimizer on every problem with every seed and every budget.
|
|
124
|
+
|
|
125
|
+
A budget of b evaluations per dimension gives a run b × dimensions evaluations. Each
|
|
126
|
+
budget is a separate run, so an optimizer can choose its settings from the budget.
|
|
127
|
+
An optimizer that cannot search a problem's parameters is skipped for that problem, and
|
|
128
|
+
one whose factory raises SkipBudget is skipped at that budget. With jobs > 1, runs are
|
|
129
|
+
shared among that many processes where the platform can fork them; the results are the same.
|
|
130
|
+
"""
|
|
131
|
+
problems = list(problems)
|
|
132
|
+
optimizers = optimizers if optimizers is not None else DEFAULT_OPTIMIZERS
|
|
133
|
+
seeds, budgets = list(seeds), list(budgets_per_dimension)
|
|
134
|
+
tasks = []
|
|
135
|
+
for index, problem in enumerate(problems):
|
|
136
|
+
for name, factory in optimizers.items():
|
|
137
|
+
for per_dimension in budgets:
|
|
138
|
+
try:
|
|
139
|
+
if seeds:
|
|
140
|
+
factory(problem, per_dimension * problem.dimensions, seeds[0])
|
|
141
|
+
except UnsupportedSearchSpace:
|
|
142
|
+
break
|
|
143
|
+
except SkipBudget:
|
|
144
|
+
continue
|
|
145
|
+
tasks += [(index, name, per_dimension, seed) for seed in seeds]
|
|
146
|
+
|
|
147
|
+
global _shared
|
|
148
|
+
_shared = (problems, optimizers)
|
|
149
|
+
if jobs == 1 or "fork" not in multiprocessing.get_all_start_methods():
|
|
150
|
+
return [_task(task) for task in tasks]
|
|
151
|
+
# Forked processes inherit the problems and factories, which needn't be picklable.
|
|
152
|
+
with ProcessPoolExecutor(jobs, mp_context=multiprocessing.get_context("fork")) as pool:
|
|
153
|
+
return list(pool.map(_task, tasks))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_shared: tuple[list[Problem], dict[str, OptimizerFactory]] = ([], {}) # what run_benchmark's tasks refer to
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _task(task: tuple[int, str, int, int]) -> Run:
|
|
160
|
+
index, name, per_dimension, seed = task
|
|
161
|
+
problems, optimizers = _shared
|
|
162
|
+
problem = problems[index]
|
|
163
|
+
optimizer = optimizers[name](problem, per_dimension * problem.dimensions, seed)
|
|
164
|
+
return _run(problem, name, per_dimension, seed, optimizer)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run(problem: Problem, name: str, per_dimension: int, seed: int, optimizer: Optimizer) -> Run:
|
|
168
|
+
result = optimizer.optimize(_with_noise(problem, seed) if problem.noise else problem.function)
|
|
169
|
+
|
|
170
|
+
def true_value(fitness: float, params: dict) -> float:
|
|
171
|
+
# A noisy run is scored by the noise-free value of the point it chose, not by the
|
|
172
|
+
# noisy value it saw there, which is likely to be a lucky one.
|
|
173
|
+
return problem.function(**params) if problem.noise else fitness
|
|
174
|
+
|
|
175
|
+
trace, best = [], float("inf")
|
|
176
|
+
for generation in result.generations:
|
|
177
|
+
if generation.best_fitness is not None and generation.best_params is not None:
|
|
178
|
+
best = min(best, true_value(generation.best_fitness, generation.best_params) - problem.optimum)
|
|
179
|
+
trace.append((generation.evaluations, max(best, 0.0)))
|
|
180
|
+
return Run(
|
|
181
|
+
problem=problem.name,
|
|
182
|
+
optimizer=name,
|
|
183
|
+
budget_per_dimension=per_dimension,
|
|
184
|
+
seed=seed,
|
|
185
|
+
error=max(true_value(result.best_fitness, result.best_params) - problem.optimum, 0.0),
|
|
186
|
+
evaluations=result.evaluations,
|
|
187
|
+
trace=trace,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _with_noise(problem: Problem, seed: int) -> Callable[..., float]:
|
|
192
|
+
"""problem's function plus Gaussian noise of standard deviation problem.noise, repeatable for the seed."""
|
|
193
|
+
rng = np.random.default_rng([seed, zlib.crc32(problem.name.encode())])
|
|
194
|
+
|
|
195
|
+
@functools.wraps(problem.function)
|
|
196
|
+
def noisy(**params):
|
|
197
|
+
return problem.function(**params) + problem.noise * rng.standard_normal()
|
|
198
|
+
|
|
199
|
+
return noisy
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def summarize(runs: list[Run], tolerance: float = 1e-4) -> list[Summary]:
|
|
203
|
+
"""One Summary per problem, optimizer and budget, in the order the runs were made."""
|
|
204
|
+
groups: dict[tuple[str, str, int], list[Run]] = {}
|
|
205
|
+
for run in runs:
|
|
206
|
+
groups.setdefault((run.problem, run.optimizer, run.budget_per_dimension), []).append(run)
|
|
207
|
+
return [Summary.of(group, tolerance) for group in groups.values()]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def wins(summaries: list[Summary], floor: float = FLOOR) -> dict[int, dict[str, int]]:
|
|
211
|
+
"""For each budget, how many problems each optimizer has the lowest median error on.
|
|
212
|
+
|
|
213
|
+
Ties count for every optimizer in them, and errors below floor count as equal. Only
|
|
214
|
+
problems that at least two optimizers ran on are counted.
|
|
215
|
+
"""
|
|
216
|
+
optimizers = list(dict.fromkeys(s.optimizer for s in summaries))
|
|
217
|
+
groups: dict[tuple[int, str], list[Summary]] = {}
|
|
218
|
+
for s in summaries:
|
|
219
|
+
groups.setdefault((s.budget_per_dimension, s.problem), []).append(s)
|
|
220
|
+
counts: dict[int, dict[str, int]] = {}
|
|
221
|
+
for (budget, _), group in groups.items():
|
|
222
|
+
tally = counts.setdefault(budget, dict.fromkeys(optimizers, 0))
|
|
223
|
+
if len(group) < 2:
|
|
224
|
+
continue
|
|
225
|
+
best = min(max(s.median_error, floor) for s in group)
|
|
226
|
+
for s in group:
|
|
227
|
+
tally[s.optimizer] += max(s.median_error, floor) == best
|
|
228
|
+
return dict(sorted(counts.items()))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def to_markdown(summaries: list[Summary], tolerance: float = 1e-4) -> str:
|
|
232
|
+
"""An overview of wins at each budget, then a table per budget with a row per problem."""
|
|
233
|
+
optimizers = list(dict.fromkeys(s.optimizer for s in summaries))
|
|
234
|
+
budgets = sorted({s.budget_per_dimension for s in summaries})
|
|
235
|
+
by_key = {(s.problem, s.optimizer, s.budget_per_dimension): s for s in summaries}
|
|
236
|
+
problems = list(dict.fromkeys(s.problem for s in summaries))
|
|
237
|
+
|
|
238
|
+
lines = [
|
|
239
|
+
"## Overview",
|
|
240
|
+
"",
|
|
241
|
+
"Problems on which each optimizer has the lowest median error, at each budget. Ties count for each "
|
|
242
|
+
f"optimizer in them, and errors below {FLOOR:g} count as equal.",
|
|
243
|
+
"",
|
|
244
|
+
"| Evaluations per dimension | " + " | ".join(optimizers) + " |",
|
|
245
|
+
"| ---: | " + " | ".join("---:" for _ in optimizers) + " |",
|
|
246
|
+
*(f"| {b} | " + " | ".join(str(t[o]) for o in optimizers) + " |" for b, t in wins(summaries).items()),
|
|
247
|
+
]
|
|
248
|
+
for budget in budgets:
|
|
249
|
+
lines += [
|
|
250
|
+
"",
|
|
251
|
+
f"## {budget} evaluations per dimension",
|
|
252
|
+
"",
|
|
253
|
+
"| Problem | " + " | ".join(f"{o} median | {o} solved" for o in optimizers) + " |",
|
|
254
|
+
"| --- | " + " | ".join("---: | ---:" for _ in optimizers) + " |",
|
|
255
|
+
]
|
|
256
|
+
for problem in problems:
|
|
257
|
+
cells = []
|
|
258
|
+
for o in optimizers:
|
|
259
|
+
s = by_key.get((problem, o, budget))
|
|
260
|
+
cells += [f"{s.median_error:.3g}", f"{s.solved:.0%}"] if s else ["n/a", "n/a"]
|
|
261
|
+
lines.append(f"| {problem} | " + " | ".join(cells) + " |")
|
|
262
|
+
lines += [
|
|
263
|
+
"",
|
|
264
|
+
f"Median error over the runs (best value found minus the optimum); solved means error ≤ {tolerance:g}. "
|
|
265
|
+
"n/a: the optimizer does not support the problem's parameters, or isn't run at this budget.",
|
|
266
|
+
]
|
|
267
|
+
return "\n".join(lines) + "\n"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def save_summaries(summaries: list[Summary], path: str | Path) -> None:
|
|
271
|
+
Path(path).write_text(json.dumps([s.model_dump() for s in summaries], indent=2) + "\n")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def load_summaries(path: str | Path) -> list[Summary]:
|
|
275
|
+
return [Summary.model_validate(s) for s in json.loads(Path(path).read_text())]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def regressions(current: list[Summary], baseline: list[Summary], factor: float = 10.0, floor: float = FLOOR) -> list[str]:
|
|
279
|
+
"""Problems and budgets where an optimizer's median error grew by more than factor against the baseline.
|
|
280
|
+
|
|
281
|
+
Errors below floor count as floor, so changes among values that are already essentially
|
|
282
|
+
zero don't count as regressions.
|
|
283
|
+
"""
|
|
284
|
+
previous = {(s.problem, s.optimizer, s.budget_per_dimension): s for s in baseline}
|
|
285
|
+
found = []
|
|
286
|
+
for s in current:
|
|
287
|
+
before = previous.get((s.problem, s.optimizer, s.budget_per_dimension))
|
|
288
|
+
if before is None:
|
|
289
|
+
continue
|
|
290
|
+
if max(s.median_error, floor) > factor * max(before.median_error, floor):
|
|
291
|
+
found.append(
|
|
292
|
+
f"{s.optimizer} on {s.problem} at {s.budget_per_dimension} evaluations per dimension: "
|
|
293
|
+
f"median error {s.median_error:.3g}, baseline {before.median_error:.3g}"
|
|
294
|
+
)
|
|
295
|
+
return found
|
hone/config/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, model_validator
|
|
4
|
+
|
|
5
|
+
from .optimizer_config import OptimizerConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BayesianConfig(OptimizerConfig):
|
|
9
|
+
"""Configuration for Bayesian optimization. Usually only the budget, max_evaluations, needs setting.
|
|
10
|
+
|
|
11
|
+
Each generation is a batch of candidates chosen together. When max_evaluations is set and
|
|
12
|
+
generations isn't, generations defaults to the budget, so the run ends when the budget is spent.
|
|
13
|
+
"""
|
|
14
|
+
population_size: int | None = Field(default=None, ge=1) # type: ignore[assignment] # candidates per batch; None: 1, or the number of workers
|
|
15
|
+
generations: int = Field(default=100, ge=1)
|
|
16
|
+
initial_samples: int | None = Field(default=None, ge=1) # spread-out candidates before the model is used; None: 2 × (variables + 1)
|
|
17
|
+
|
|
18
|
+
@model_validator(mode="before")
|
|
19
|
+
@classmethod
|
|
20
|
+
def _generations_from_budget(cls, data: Any) -> Any:
|
|
21
|
+
if isinstance(data, dict) and data.get("max_evaluations") is not None and data.get("generations") is None:
|
|
22
|
+
data = {**data, "generations": data["max_evaluations"]}
|
|
23
|
+
return data
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
|
|
3
|
+
from .optimizer_config import OptimizerConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CMAESConfig(OptimizerConfig):
|
|
7
|
+
"""Configuration for CMA-ES. The defaults suit most problems; usually only the budget needs setting."""
|
|
8
|
+
population_size: int | None = Field(default=None, ge=2) # type: ignore[assignment] # None: 4 + 3 ln(n) for n variables, or twice the workers if more
|
|
9
|
+
generations: int = Field(default=1000, ge=1) # an upper bound; max_evaluations is the usual way to set a budget
|
|
10
|
+
initial_step: float = Field(default=0.3, gt=0, le=1) # starting step size, as a fraction of each range
|
|
11
|
+
restarts: bool = True # restart with double the population when a run converges or stalls (IPOP-CMA-ES)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, model_validator
|
|
6
|
+
|
|
7
|
+
from .optimizer_config import OptimizerConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SelectionMethod(StrEnum):
|
|
11
|
+
"""Selection methods for the genetic algorithm."""
|
|
12
|
+
TOURNAMENT = 'tournament'
|
|
13
|
+
ROULETTE = 'roulette'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# With a budget and no population size, the population is sized for about this many
|
|
17
|
+
# generations, within these bounds. Chosen from the benchmark's small budgets.
|
|
18
|
+
GENERATIONS_PER_BUDGET = 20
|
|
19
|
+
MIN_POPULATION = 16
|
|
20
|
+
MAX_POPULATION = 50
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GeneticOptimizerConfig(OptimizerConfig):
|
|
24
|
+
"""Configuration for the genetic optimizer.
|
|
25
|
+
|
|
26
|
+
When max_evaluations is set, population_size and generations default to suit it: the
|
|
27
|
+
population to about a twentieth of the budget, between 16 and 50, and generations to
|
|
28
|
+
as many as the budget allows. Without a budget they default to 100 and 25.
|
|
29
|
+
"""
|
|
30
|
+
# Chance each gene of a child is mutated. None: 1 / the number of variables, at least 0.1;
|
|
31
|
+
# with noise, 0.1, which the benchmark's noisy problems favour at large budgets.
|
|
32
|
+
mutation_rate: float | None = Field(default=None, ge=0, le=1)
|
|
33
|
+
# Size of a range mutation, as a fraction of the range width: the starting size when
|
|
34
|
+
# mutation adapts, and the size throughout when it doesn't.
|
|
35
|
+
mutation_scale: float = Field(default=0.1, gt=0)
|
|
36
|
+
# Each individual carries its own mutation size, which its children inherit and vary, so
|
|
37
|
+
# selection tunes it as the run goes. None: on, except with noise, where it didn't help.
|
|
38
|
+
adaptive_mutation: bool | None = None
|
|
39
|
+
crossover_rate: float = Field(default=0.8, ge=0, le=1) # chance each gene of a child comes from its first parent
|
|
40
|
+
# Draw each range value of a child from between its parents' values, widened by blend_alpha
|
|
41
|
+
# times their distance on each side, rather than copying one.
|
|
42
|
+
blend_crossover: bool = True
|
|
43
|
+
blend_alpha: float = Field(default=0.1, ge=0)
|
|
44
|
+
elitism: bool = True
|
|
45
|
+
elitism_rate: float = Field(default=0.1, ge=0, lt=1) # share of each generation carried over unchanged
|
|
46
|
+
selection_method: SelectionMethod = SelectionMethod.TOURNAMENT
|
|
47
|
+
tournament_size: int = Field(default=4, ge=2)
|
|
48
|
+
tournament_p: float = Field(default=1.0, ge=0, le=1) # chance a tournament picks its best rather than its worst
|
|
49
|
+
# Share of children bred within niches of parents with the same choice parameter values, so
|
|
50
|
+
# each option keeps tuning its own numbers. Helps problems where the best choice is found
|
|
51
|
+
# late, on large budgets; costs on small ones. 0 turns niches off.
|
|
52
|
+
niche_share: float = Field(default=0.0, ge=0, lt=1)
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="before")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _defaults_from_budget(cls, data: Any) -> Any:
|
|
57
|
+
if not isinstance(data, dict) or data.get("max_evaluations") is None:
|
|
58
|
+
return data
|
|
59
|
+
budget = data["max_evaluations"]
|
|
60
|
+
data = dict(data)
|
|
61
|
+
if data.get("population_size") is None:
|
|
62
|
+
population = min(max(budget // GENERATIONS_PER_BUDGET, MIN_POPULATION), MAX_POPULATION)
|
|
63
|
+
data["population_size"] = max(population, data.get("tournament_size") or 0)
|
|
64
|
+
if data.get("generations") is None:
|
|
65
|
+
data["generations"] = budget
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def _check_tournament_size(self):
|
|
70
|
+
if self.tournament_size > self.population_size:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"tournament_size {self.tournament_size} cannot exceed population_size {self.population_size}"
|
|
73
|
+
)
|
|
74
|
+
return self
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OptimizerConfig(BaseModel):
|
|
9
|
+
"""Settings shared by every optimizer."""
|
|
10
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
11
|
+
|
|
12
|
+
population_size: int = Field(default=100, ge=2) # individuals evaluated per generation
|
|
13
|
+
generations: int = Field(default=25, ge=1)
|
|
14
|
+
max_evaluations: int | None = Field(default=None, ge=1) # stop after this many evaluations, even mid-generation
|
|
15
|
+
maximize: bool = False
|
|
16
|
+
threshold: float | None = None # stop once the best fitness reaches this
|
|
17
|
+
seed: int | None = None # fixes the random number generator, so runs can be repeated
|
|
18
|
+
noise: bool = False # the objective is noisy: evaluating the same point twice can give different values
|
|
19
|
+
# With noise, evaluations kept back to re-evaluate the most promising points at the end, so
|
|
20
|
+
# the result is chosen, and its value reported, from averages rather than single lucky values
|
|
21
|
+
final_evaluations: int = Field(default=0, ge=0)
|
|
22
|
+
# The share of each generation's evaluations that must finish before the next generation is
|
|
23
|
+
# bred. Below 1, the slowest evaluations don't hold up the run: they finish in the background,
|
|
24
|
+
# and their results join the generation under way when they arrive. Results then depend on
|
|
25
|
+
# timing, so runs are no longer exactly repeatable.
|
|
26
|
+
min_finished: float = Field(default=1.0, gt=0, le=1)
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def _check_final_evaluations(self):
|
|
30
|
+
if self.final_evaluations and self.max_evaluations is not None and self.final_evaluations >= self.max_evaluations:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"final_evaluations {self.final_evaluations} leaves nothing of max_evaluations {self.max_evaluations} for the search"
|
|
33
|
+
)
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def reserved_evaluations(self) -> int:
|
|
38
|
+
"""Evaluations kept back for the final re-evaluation: none without noise."""
|
|
39
|
+
return self.final_evaluations if self.noise else 0
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_file(cls, filepath: str | Path) -> Self:
|
|
43
|
+
"""Create a config from a JSON or YAML file."""
|
|
44
|
+
filepath = Path(filepath)
|
|
45
|
+
text = filepath.read_text()
|
|
46
|
+
if filepath.suffix.lower() in {".yaml", ".yml"}:
|
|
47
|
+
return cls.model_validate(yaml.safe_load(text) or {})
|
|
48
|
+
return cls.model_validate_json(text)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .objective_function import ObjectiveFunction
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import numbers
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
from hone.parameters import SearchSpace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ObjectiveFunction(BaseModel):
|
|
11
|
+
"""Objective function to be optimized, paired with the search space it is called over."""
|
|
12
|
+
model_config = ConfigDict(frozen=True)
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
function: Callable
|
|
16
|
+
search_space: SearchSpace
|
|
17
|
+
|
|
18
|
+
def model_post_init(self, context: Any) -> None:
|
|
19
|
+
# Checked after validation rather than in a validator, so a mismatch raises a plain ValueError.
|
|
20
|
+
self._check_signature()
|
|
21
|
+
|
|
22
|
+
def __call__(self, params: dict[str, Any]) -> float:
|
|
23
|
+
"""Evaluate the function at params, which must return a real number."""
|
|
24
|
+
value = self.function(**params)
|
|
25
|
+
if not isinstance(value, numbers.Real):
|
|
26
|
+
raise TypeError(f"{self.name}() must return a number, but returned {value!r}")
|
|
27
|
+
return float(value)
|
|
28
|
+
|
|
29
|
+
def _check_signature(self):
|
|
30
|
+
"""Fail before optimizing if the function cannot be called with the search space's parameters."""
|
|
31
|
+
signature = inspect.signature(self.function)
|
|
32
|
+
accepted = signature.parameters
|
|
33
|
+
takes_any_keyword = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in accepted.values())
|
|
34
|
+
|
|
35
|
+
names = set(self.search_space.names)
|
|
36
|
+
unknown = sorted(name for name in names if name not in accepted and not takes_any_keyword)
|
|
37
|
+
missing = sorted(
|
|
38
|
+
name
|
|
39
|
+
for name, p in accepted.items()
|
|
40
|
+
if name not in names
|
|
41
|
+
and p.default is inspect.Parameter.empty
|
|
42
|
+
and p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
|
43
|
+
)
|
|
44
|
+
if unknown:
|
|
45
|
+
raise ValueError(f"{self.name}() does not accept parameters: {', '.join(unknown)}")
|
|
46
|
+
if missing:
|
|
47
|
+
raise ValueError(f"{self.name}() requires parameters missing from the search space: {', '.join(missing)}")
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from .bayesian import BayesianOptimizer
|
|
2
|
+
from .base import EvaluationFailed, Individual, Optimizer, Trial, UnsupportedSearchSpace
|
|
3
|
+
from .choice import choose_optimizer
|
|
4
|
+
from .cmaes import CMAES
|
|
5
|
+
from .genetic import GeneticOptimizer
|
|
6
|
+
from .random_search import RandomSearch
|
hone/optimizers/_gp.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Gaussian process regression, the model behind Bayesian optimization.
|
|
2
|
+
|
|
3
|
+
The kernel is Matérn 5/2 with one length scale per input, so the model can learn that some
|
|
4
|
+
inputs matter more than others. Inputs are expected in [0, 1]; outputs are standardized.
|
|
5
|
+
Hyperparameters are chosen by maximizing the marginal likelihood times weak priors (MAP).
|
|
6
|
+
"""
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from scipy.linalg import cho_factor, cho_solve, solve_triangular
|
|
11
|
+
from scipy.optimize import minimize
|
|
12
|
+
|
|
13
|
+
SQRT5 = math.sqrt(5)
|
|
14
|
+
|
|
15
|
+
# Bounds on the log hyperparameters: length scales, signal variance, noise variance.
|
|
16
|
+
LOG_LENGTH = (math.log(0.01), math.log(20.0))
|
|
17
|
+
LOG_SIGNAL = (math.log(0.05), math.log(20.0))
|
|
18
|
+
LOG_NOISE = (math.log(1e-6), math.log(1.0))
|
|
19
|
+
|
|
20
|
+
# Weak priors on the log hyperparameters, as (mean, standard deviation) of a normal.
|
|
21
|
+
LENGTH_PRIOR = (math.log(0.3), 1.0) # inputs are in [0, 1], so most length scales are a fraction of that
|
|
22
|
+
SIGNAL_PRIOR = (0.0, 1.0) # outputs are standardized
|
|
23
|
+
NOISE_PRIOR = (math.log(1e-4), 2.0) # little noise unless the data says otherwise
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _matern(r: np.ndarray) -> np.ndarray:
|
|
27
|
+
return (1 + SQRT5 * r + 5 / 3 * r**2) * np.exp(-SQRT5 * r)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _scaled_distances(a: np.ndarray, b: np.ndarray, length: np.ndarray) -> np.ndarray:
|
|
31
|
+
a, b = a / length, b / length
|
|
32
|
+
squared = (a**2).sum(1)[:, None] + (b**2).sum(1)[None, :] - 2 * a @ b.T
|
|
33
|
+
return np.sqrt(np.maximum(squared, 0))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GaussianProcess:
|
|
37
|
+
"""A Gaussian process fitted to inputs X, shape (n, d) in [0, 1], and outputs y, shape (n,)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, X: np.ndarray, y: np.ndarray, theta: np.ndarray | None = None) -> None:
|
|
40
|
+
self.X = np.asarray(X, dtype=float)
|
|
41
|
+
y = np.asarray(y, dtype=float)
|
|
42
|
+
self._offset = y.mean()
|
|
43
|
+
self._scale = y.std() if y.std() > 0 else 1.0
|
|
44
|
+
self.z = (y - self._offset) / self._scale
|
|
45
|
+
d = self.X.shape[1]
|
|
46
|
+
self.theta = theta if theta is not None else np.concatenate([np.full(d, LENGTH_PRIOR[0]), [0.0, math.log(1e-4)]])
|
|
47
|
+
self._condition()
|
|
48
|
+
|
|
49
|
+
# --- hyperparameters ------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def fit(self, restarts: int = 0, rng: np.random.Generator | None = None) -> "GaussianProcess":
|
|
52
|
+
"""Choose hyperparameters by MAP, starting from the current ones and restarts random others."""
|
|
53
|
+
d = self.X.shape[1]
|
|
54
|
+
bounds = [LOG_LENGTH] * d + [LOG_SIGNAL, LOG_NOISE]
|
|
55
|
+
starts = [self.theta]
|
|
56
|
+
for _ in range(restarts):
|
|
57
|
+
assert rng is not None, "restarts need a random number generator"
|
|
58
|
+
starts.append(np.array([rng.uniform(low, high) for low, high in bounds]))
|
|
59
|
+
fits = [
|
|
60
|
+
minimize(self._objective, np.clip(start, *np.array(bounds).T), jac=True, method="L-BFGS-B", bounds=bounds)
|
|
61
|
+
for start in starts
|
|
62
|
+
]
|
|
63
|
+
self.theta = min(fits, key=lambda found: found.fun).x
|
|
64
|
+
self._condition()
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def _objective(self, theta: np.ndarray) -> tuple[float, np.ndarray]:
|
|
68
|
+
"""The negative log posterior of theta, and its gradient."""
|
|
69
|
+
d = self.X.shape[1]
|
|
70
|
+
length, signal, noise = np.exp(theta[:d]), math.exp(theta[d]), math.exp(theta[d + 1])
|
|
71
|
+
r = _scaled_distances(self.X, self.X, length)
|
|
72
|
+
correlation = _matern(r)
|
|
73
|
+
K = signal * correlation + (noise + 1e-10) * np.eye(len(self.X))
|
|
74
|
+
try:
|
|
75
|
+
factor = cho_factor(K, lower=True)
|
|
76
|
+
except np.linalg.LinAlgError:
|
|
77
|
+
return 1e10, np.zeros_like(theta)
|
|
78
|
+
alpha = cho_solve(factor, self.z)
|
|
79
|
+
L = factor[0]
|
|
80
|
+
nll = 0.5 * self.z @ alpha + np.log(np.diag(L)).sum() + 0.5 * len(self.z) * math.log(2 * math.pi)
|
|
81
|
+
|
|
82
|
+
W = np.outer(alpha, alpha) - cho_solve(factor, np.eye(len(self.X)))
|
|
83
|
+
# d k / d log(length_j) = signal * 5/3 * (1 + sqrt5 r) exp(-sqrt5 r) * (dx_j / length_j)^2
|
|
84
|
+
S = W * (signal * 5 / 3 * (1 + SQRT5 * r) * np.exp(-SQRT5 * r))
|
|
85
|
+
scaled = self.X / length
|
|
86
|
+
length_gradient = 2 * (S.sum(1) @ scaled**2) - 2 * np.einsum("ij,ik,kj->j", scaled, S, scaled)
|
|
87
|
+
gradient = -0.5 * np.concatenate(
|
|
88
|
+
[length_gradient, [np.sum(W * signal * correlation), noise * np.trace(W)]]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
for index, (mean, sd) in [(slice(0, d), LENGTH_PRIOR), (d, SIGNAL_PRIOR), (d + 1, NOISE_PRIOR)]:
|
|
92
|
+
nll += 0.5 * np.sum(((theta[index] - mean) / sd) ** 2)
|
|
93
|
+
gradient[index] += (theta[index] - mean) / sd**2
|
|
94
|
+
return float(nll), gradient
|
|
95
|
+
|
|
96
|
+
# --- prediction -----------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def _condition(self) -> None:
|
|
99
|
+
d = self.X.shape[1]
|
|
100
|
+
self._length, self._signal = np.exp(self.theta[:d]), math.exp(self.theta[d])
|
|
101
|
+
noise = math.exp(self.theta[d + 1])
|
|
102
|
+
K = self._signal * _matern(_scaled_distances(self.X, self.X, self._length))
|
|
103
|
+
K[np.diag_indices_from(K)] += noise + 1e-10
|
|
104
|
+
jitter = 0.0
|
|
105
|
+
while True:
|
|
106
|
+
try:
|
|
107
|
+
self._L = np.linalg.cholesky(K + jitter * np.eye(len(K)))
|
|
108
|
+
break
|
|
109
|
+
except np.linalg.LinAlgError:
|
|
110
|
+
jitter = max(1e-8, jitter * 10)
|
|
111
|
+
self._alpha = cho_solve((self._L, True), self.z)
|
|
112
|
+
# Predictions multiply by the inverse of L, which is faster than solving with L each time.
|
|
113
|
+
self._L_inverse = solve_triangular(self._L, np.eye(len(K)), lower=True)
|
|
114
|
+
|
|
115
|
+
def predict(self, Xs: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
116
|
+
"""Posterior mean and standard deviation at Xs, in standardized units."""
|
|
117
|
+
k = self._signal * _matern(_scaled_distances(np.atleast_2d(Xs), self.X, self._length))
|
|
118
|
+
mean = k @ self._alpha
|
|
119
|
+
v = self._L_inverse @ k.T
|
|
120
|
+
variance = np.maximum(self._signal - (v**2).sum(0), 1e-12)
|
|
121
|
+
return mean, np.sqrt(variance)
|
|
122
|
+
|
|
123
|
+
def standardize(self, y: float | np.ndarray) -> float | np.ndarray:
|
|
124
|
+
return (np.asarray(y) - self._offset) / self._scale
|
|
125
|
+
|
|
126
|
+
def with_observation(self, x: np.ndarray, z: float) -> "GaussianProcess":
|
|
127
|
+
"""This model with one more observation, given in standardized units, and the same hyperparameters."""
|
|
128
|
+
model = object.__new__(GaussianProcess)
|
|
129
|
+
model.X = np.vstack([self.X, x])
|
|
130
|
+
model._offset, model._scale = self._offset, self._scale
|
|
131
|
+
model.z = np.append(self.z, z)
|
|
132
|
+
model.theta = self.theta
|
|
133
|
+
model._condition()
|
|
134
|
+
return model
|