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,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
@@ -0,0 +1,4 @@
1
+ from .bayesian_config import BayesianConfig
2
+ from .cmaes_config import CMAESConfig
3
+ from .genetic_optimizer_config import GeneticOptimizerConfig, SelectionMethod
4
+ from .optimizer_config import OptimizerConfig
@@ -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