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
hone/optimizers/base.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import numbers
|
|
3
|
+
import os
|
|
4
|
+
import pickle
|
|
5
|
+
import platform
|
|
6
|
+
import time
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from functools import cache, partial
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, ClassVar, List
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from pydantic import BaseModel, ConfigDict
|
|
16
|
+
|
|
17
|
+
from hone.config import OptimizerConfig
|
|
18
|
+
from hone.objective_function import ObjectiveFunction
|
|
19
|
+
from hone.parameters import Parameter, SearchSpace
|
|
20
|
+
from hone.result import GenerationStats, Progress, Result, RunInfo
|
|
21
|
+
|
|
22
|
+
from ._pool import EvaluationPool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnsupportedSearchSpace(ValueError):
|
|
26
|
+
"""The optimizer cannot search this kind of parameter."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EvaluationFailed(Exception):
|
|
30
|
+
"""Raise this from an objective function to record the evaluation as failed rather than stop the run."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Trial(BaseModel):
|
|
34
|
+
"""A candidate handed out by ask(): evaluate params, then report the outcome with tell()."""
|
|
35
|
+
model_config = ConfigDict(frozen=True)
|
|
36
|
+
|
|
37
|
+
id: int
|
|
38
|
+
params: dict[str, Any]
|
|
39
|
+
generation: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Individual(BaseModel):
|
|
43
|
+
"""One candidate solution: a value for every parameter, and its fitness once evaluated."""
|
|
44
|
+
params: dict[str, Any]
|
|
45
|
+
generation: int
|
|
46
|
+
fitness: float | None = None
|
|
47
|
+
failed: bool = False
|
|
48
|
+
id: int = -1
|
|
49
|
+
|
|
50
|
+
# Each individual is a candidate of its own, even when another holds the same values:
|
|
51
|
+
# optimizers keep track of them by identity.
|
|
52
|
+
def __eq__(self, other: object) -> bool:
|
|
53
|
+
return self is other
|
|
54
|
+
|
|
55
|
+
__hash__ = object.__hash__
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Optimizer(ABC):
|
|
59
|
+
"""Base class for population-based optimizers.
|
|
60
|
+
|
|
61
|
+
Use it either way round:
|
|
62
|
+
|
|
63
|
+
- optimize(objective) calls the objective for every candidate and returns the Result.
|
|
64
|
+
- ask() and tell() let you evaluate candidates yourself, anywhere and in any order;
|
|
65
|
+
result() returns the Result so far.
|
|
66
|
+
|
|
67
|
+
The base class runs the generation loop: it hands out each generation's candidates,
|
|
68
|
+
records their outcomes, tracks the best so far, stops at the threshold, and builds the
|
|
69
|
+
Result. A subclass only decides how each generation's population is produced, by
|
|
70
|
+
implementing _next_population.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
search_space: The parameters to optimize over, as a SearchSpace or a list of parameters.
|
|
74
|
+
config: The optimizer's configuration. Defaults are used if omitted.
|
|
75
|
+
initial_points: Points to evaluate first, such as a known good design or the best
|
|
76
|
+
parameters of an earlier run: one dict of parameter values, or a list of them.
|
|
77
|
+
Fixed parameters may be left out.
|
|
78
|
+
"""
|
|
79
|
+
config_class: ClassVar[type[OptimizerConfig]] = OptimizerConfig
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
search_space: SearchSpace | List[Parameter],
|
|
84
|
+
config: OptimizerConfig | None = None,
|
|
85
|
+
*,
|
|
86
|
+
initial_points: dict[str, Any] | list[dict[str, Any]] | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
if not isinstance(search_space, SearchSpace):
|
|
89
|
+
search_space = SearchSpace(search_space)
|
|
90
|
+
self.search_space = search_space
|
|
91
|
+
self.config = config if config is not None else self.config_class()
|
|
92
|
+
if not isinstance(self.config, self.config_class):
|
|
93
|
+
raise TypeError(f"{type(self).__name__} needs a {self.config_class.__name__}, got {type(self.config).__name__}")
|
|
94
|
+
if isinstance(initial_points, dict):
|
|
95
|
+
initial_points = [initial_points]
|
|
96
|
+
self.initial_points = [search_space.check(point) for point in initial_points or []]
|
|
97
|
+
self._workers = 1 # how many evaluations run at once; optimizers may size their population from it
|
|
98
|
+
self.reset()
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
def _next_population(self, previous: list[Individual] | None, generation: int) -> list[Individual]:
|
|
102
|
+
"""The individuals of the given generation, or of the first one when previous is None.
|
|
103
|
+
|
|
104
|
+
Individuals that already have a fitness, such as elites carried over, are not evaluated again.
|
|
105
|
+
Failed individuals in previous have failed set and the worst possible fitness.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# --- ask and tell ------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def reset(self) -> None:
|
|
111
|
+
"""Start over: forget every evaluation and reseed the random number generator."""
|
|
112
|
+
self.rng = np.random.default_rng(self.config.seed)
|
|
113
|
+
self._generation = -1
|
|
114
|
+
self._population: list[Individual] | None = None # the current generation, or the last one finished
|
|
115
|
+
self._needs_generation = True
|
|
116
|
+
self._waiting: dict[int, Individual] = {} # created this generation, not yet told
|
|
117
|
+
self._late: dict[int, Individual] = {} # handed out in an earlier generation, still being evaluated
|
|
118
|
+
self._told_this_generation = 0
|
|
119
|
+
self._quorum = 0 # evaluations of the current generation needed before the next is bred
|
|
120
|
+
self._handed_out: set[int] = set()
|
|
121
|
+
self._handed_out_total = 0 # trials handed out over the whole run
|
|
122
|
+
self._next_id = 0
|
|
123
|
+
self._evaluations = 0
|
|
124
|
+
self._failures = 0
|
|
125
|
+
self._best: Individual | None = None
|
|
126
|
+
self._history: list[GenerationStats] = []
|
|
127
|
+
self._reached_threshold = False
|
|
128
|
+
self._done = False
|
|
129
|
+
# With noise: every successful value seen at each point, and the final re-evaluation's state.
|
|
130
|
+
self._observations: dict[tuple, list[float]] = {}
|
|
131
|
+
self._points: dict[tuple, dict[str, Any]] = {}
|
|
132
|
+
self._finalists: list[tuple] = []
|
|
133
|
+
self._final_round = False
|
|
134
|
+
# For the result's run record: set by optimize(), and timed from the first ask().
|
|
135
|
+
self._objective_name: str | None = None
|
|
136
|
+
self._run_workers: int | None = None
|
|
137
|
+
self._run_timeout: float | None = None
|
|
138
|
+
self._started_at: datetime | None = None
|
|
139
|
+
self._start_clock: float | None = None
|
|
140
|
+
self._end_clock: float | None = None
|
|
141
|
+
self._stopped = False
|
|
142
|
+
self._reissue: list[int] = [] # trials handed out before a checkpoint was loaded, to hand out again
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def done(self) -> bool:
|
|
146
|
+
"""Whether the run has finished: every generation is complete, or the threshold was reached.
|
|
147
|
+
|
|
148
|
+
With min_finished below 1, the run isn't done until every late result has been told.
|
|
149
|
+
"""
|
|
150
|
+
return self._done and not self._late
|
|
151
|
+
|
|
152
|
+
def ask(self, n: int | None = None) -> list[Trial]:
|
|
153
|
+
"""Hand out up to n candidates to evaluate, or all that are ready when n is None.
|
|
154
|
+
|
|
155
|
+
Returns an empty list when the run is done, or when every candidate of the current
|
|
156
|
+
generation has been handed out but not all have been told: the next generation
|
|
157
|
+
depends on their results.
|
|
158
|
+
"""
|
|
159
|
+
if n is not None and n < 1:
|
|
160
|
+
raise ValueError(f"n must be at least 1, got {n}")
|
|
161
|
+
if self._start_clock is None:
|
|
162
|
+
self._started_at, self._start_clock = datetime.now(UTC), time.monotonic()
|
|
163
|
+
while self._needs_generation and not self._done:
|
|
164
|
+
self._start_generation()
|
|
165
|
+
# Trials handed out before a checkpoint was loaded come first, stragglers included;
|
|
166
|
+
# the budget already counts them.
|
|
167
|
+
outstanding = {**self._late, **self._waiting}
|
|
168
|
+
reissued = [outstanding[i] for i in self._reissue if i in outstanding][:n]
|
|
169
|
+
self._reissue = [i for i in self._reissue if i in outstanding and i not in {ind.id for ind in reissued}]
|
|
170
|
+
if self._done:
|
|
171
|
+
return [Trial(id=ind.id, params=dict(ind.params), generation=ind.generation) for ind in reissued]
|
|
172
|
+
ready = [ind for ind_id, ind in self._waiting.items() if ind_id not in self._handed_out]
|
|
173
|
+
limits = [None if n is None else n - len(reissued), self._budget_left()]
|
|
174
|
+
ready = ready[: min((limit for limit in limits if limit is not None), default=len(ready))]
|
|
175
|
+
self._handed_out.update(ind.id for ind in ready)
|
|
176
|
+
self._handed_out_total += len(ready)
|
|
177
|
+
return [Trial(id=ind.id, params=dict(ind.params), generation=ind.generation) for ind in reissued + ready]
|
|
178
|
+
|
|
179
|
+
def tell(self, trial: Trial | int, value: float | None = None, *, failed: bool = False) -> None:
|
|
180
|
+
"""Report the outcome of a trial from ask(), by the trial or its id.
|
|
181
|
+
|
|
182
|
+
Pass the objective's value, or failed=True when the evaluation could not produce one
|
|
183
|
+
(a crashed simulation, say). A value of NaN or infinity also counts as failed. Failed
|
|
184
|
+
trials count as evaluations, rank below every successful one, and are never used as
|
|
185
|
+
parents.
|
|
186
|
+
"""
|
|
187
|
+
trial_id = trial.id if isinstance(trial, Trial) else trial
|
|
188
|
+
late = trial_id in self._late
|
|
189
|
+
individual = self._late.get(trial_id) if late else self._waiting.get(trial_id)
|
|
190
|
+
if individual is None or not (late or trial_id in self._handed_out):
|
|
191
|
+
raise ValueError(f"trial {trial_id} is not waiting for a result; it was never asked for or was already told")
|
|
192
|
+
if not failed:
|
|
193
|
+
if value is None:
|
|
194
|
+
raise ValueError("pass the trial's value, or failed=True")
|
|
195
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Real):
|
|
196
|
+
raise TypeError(f"the value must be a number, got {value!r}")
|
|
197
|
+
failed = not math.isfinite(value)
|
|
198
|
+
|
|
199
|
+
del (self._late if late else self._waiting)[trial_id]
|
|
200
|
+
self._evaluations += 1
|
|
201
|
+
if failed:
|
|
202
|
+
self._failures += 1
|
|
203
|
+
individual.failed = True
|
|
204
|
+
individual.fitness = -math.inf if self.config.maximize else math.inf
|
|
205
|
+
else:
|
|
206
|
+
individual.fitness = float(value)
|
|
207
|
+
if self.config.noise:
|
|
208
|
+
# Average every evaluation of the same point, so a lucky value regresses toward its mean.
|
|
209
|
+
key = _key(individual.params)
|
|
210
|
+
self._observations.setdefault(key, []).append(float(value))
|
|
211
|
+
self._points.setdefault(key, dict(individual.params))
|
|
212
|
+
individual.fitness = float(np.mean(self._observations[key]))
|
|
213
|
+
if self._best is None or self._is_better(individual.fitness, self._best.fitness):
|
|
214
|
+
self._best = individual
|
|
215
|
+
|
|
216
|
+
if late:
|
|
217
|
+
# A straggler from an earlier generation joins the one under way, to breed from.
|
|
218
|
+
if self._population is not None and not self._done:
|
|
219
|
+
self._population.append(individual)
|
|
220
|
+
return
|
|
221
|
+
self._told_this_generation += 1
|
|
222
|
+
if not self._waiting:
|
|
223
|
+
self._finish_generation()
|
|
224
|
+
elif self._handed_out.issuperset(self._waiting) and self._told_this_generation >= self._quorum:
|
|
225
|
+
# Enough of the generation has finished: the rest go on in the background.
|
|
226
|
+
self._late.update(self._waiting)
|
|
227
|
+
self._population = [ind for ind in self._population or [] if ind.fitness is not None]
|
|
228
|
+
self._waiting = {}
|
|
229
|
+
self._finish_generation()
|
|
230
|
+
elif self._budget_left() == 0 and self._handed_out.isdisjoint(self._waiting):
|
|
231
|
+
# Out of budget, with every trial handed out so far reported: end on a partial generation.
|
|
232
|
+
self._finish_generation(final=True)
|
|
233
|
+
|
|
234
|
+
def _budget_left(self) -> int | None:
|
|
235
|
+
if self.config.max_evaluations is None:
|
|
236
|
+
return None
|
|
237
|
+
kept_back = 0 if self._final_round else self.config.reserved_evaluations
|
|
238
|
+
return max(self.config.max_evaluations - kept_back - self._handed_out_total, 0)
|
|
239
|
+
|
|
240
|
+
def result(self) -> Result:
|
|
241
|
+
"""The best result found so far, with a summary of every completed generation."""
|
|
242
|
+
best = self._best_point()
|
|
243
|
+
if best is None:
|
|
244
|
+
raise RuntimeError("no successful evaluations yet")
|
|
245
|
+
best_fitness, best_params = best
|
|
246
|
+
return Result(
|
|
247
|
+
best_fitness=best_fitness,
|
|
248
|
+
best_params=dict(best_params),
|
|
249
|
+
generations=self._history,
|
|
250
|
+
evaluations=self._evaluations,
|
|
251
|
+
failures=self._failures,
|
|
252
|
+
reached_threshold=self._reached_threshold,
|
|
253
|
+
stopped=self._stopped,
|
|
254
|
+
run=self._run_info(),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def _best_point(self) -> tuple[float, dict[str, Any]] | None:
|
|
258
|
+
"""The best value so far and its parameters, or None before any evaluation has succeeded."""
|
|
259
|
+
if self._best is None:
|
|
260
|
+
return None
|
|
261
|
+
if self.config.noise and self._observations:
|
|
262
|
+
# Once the finalists have been re-evaluated, the one with the best average; before
|
|
263
|
+
# that, the most promising point by the optimizer's best estimate.
|
|
264
|
+
key = self._ranked_keys(self._finalists)[0] if self._finalists else self._finalist_order()[0]
|
|
265
|
+
return self._average(key), self._points[key]
|
|
266
|
+
return _fitness(self._best), self._best.params
|
|
267
|
+
|
|
268
|
+
def _run_info(self) -> RunInfo:
|
|
269
|
+
return RunInfo(
|
|
270
|
+
optimizer=type(self).__name__,
|
|
271
|
+
config=self.config.model_dump(mode="json"),
|
|
272
|
+
search_space=self.search_space,
|
|
273
|
+
initial_points=[dict(point) for point in self.initial_points],
|
|
274
|
+
objective=self._objective_name,
|
|
275
|
+
workers=self._run_workers,
|
|
276
|
+
timeout=self._run_timeout,
|
|
277
|
+
hone_version=_hone_version(),
|
|
278
|
+
python_version=platform.python_version(),
|
|
279
|
+
numpy_version=np.__version__,
|
|
280
|
+
started_at=self._started_at,
|
|
281
|
+
elapsed=self._elapsed() if self._start_clock is not None else None,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _elapsed(self) -> float:
|
|
285
|
+
assert self._start_clock is not None
|
|
286
|
+
return (self._end_clock if self._end_clock is not None else time.monotonic()) - self._start_clock
|
|
287
|
+
|
|
288
|
+
def _finish(self, stopped: bool = False) -> None:
|
|
289
|
+
self._done = True
|
|
290
|
+
self._stopped = stopped
|
|
291
|
+
self._end_clock = time.monotonic()
|
|
292
|
+
|
|
293
|
+
# --- checkpoints ------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def save_checkpoint(self, path: str | Path) -> None:
|
|
296
|
+
"""Save the run's whole state to path, so load_checkpoint() can carry it on later, in another process.
|
|
297
|
+
|
|
298
|
+
The file is replaced in one step, so a crash while saving leaves the previous checkpoint intact.
|
|
299
|
+
"""
|
|
300
|
+
state = dict(self.__dict__)
|
|
301
|
+
elapsed = self._elapsed() if self._start_clock is not None else None
|
|
302
|
+
state.update(_start_clock=None, _end_clock=None, _workers=1)
|
|
303
|
+
checkpoint = {
|
|
304
|
+
"format": CHECKPOINT_FORMAT,
|
|
305
|
+
"hone_version": _hone_version(),
|
|
306
|
+
"setup": self._setup(),
|
|
307
|
+
"elapsed": elapsed,
|
|
308
|
+
"state": state,
|
|
309
|
+
}
|
|
310
|
+
path = Path(path)
|
|
311
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
312
|
+
temporary.write_bytes(pickle.dumps(checkpoint, protocol=pickle.HIGHEST_PROTOCOL))
|
|
313
|
+
os.replace(temporary, path)
|
|
314
|
+
|
|
315
|
+
def load_checkpoint(self, path: str | Path) -> None:
|
|
316
|
+
"""Carry on the run saved in path by save_checkpoint(), from where it was saved.
|
|
317
|
+
|
|
318
|
+
The checkpoint must come from an optimizer set up like this one: the same class, config,
|
|
319
|
+
search space and initial points. Trials handed out but not yet told when it was saved
|
|
320
|
+
are handed out again by ask(), and can still be told by their ids.
|
|
321
|
+
|
|
322
|
+
A checkpoint is a pickle, which can run code when it is loaded: only load checkpoints
|
|
323
|
+
you saved yourself.
|
|
324
|
+
"""
|
|
325
|
+
checkpoint = pickle.loads(Path(path).read_bytes())
|
|
326
|
+
if not isinstance(checkpoint, dict) or checkpoint.get("format") != CHECKPOINT_FORMAT:
|
|
327
|
+
raise ValueError(f"{path} is not a checkpoint this version of hone can read")
|
|
328
|
+
if checkpoint["setup"] != self._setup():
|
|
329
|
+
raise ValueError(
|
|
330
|
+
f"{path} was saved by an optimizer set up differently: a checkpoint needs the same "
|
|
331
|
+
"optimizer, config, search space and initial points"
|
|
332
|
+
)
|
|
333
|
+
workers = self._workers
|
|
334
|
+
self.__dict__.update(checkpoint["state"])
|
|
335
|
+
self._workers = workers
|
|
336
|
+
elapsed = checkpoint["elapsed"]
|
|
337
|
+
if elapsed is not None:
|
|
338
|
+
self._start_clock = time.monotonic() - elapsed
|
|
339
|
+
self._end_clock = self._start_clock + elapsed if self._done else None
|
|
340
|
+
self._reissue = list(self._late) + [i for i in self._waiting if i in self._handed_out]
|
|
341
|
+
|
|
342
|
+
def _setup(self) -> dict[str, Any]:
|
|
343
|
+
"""What a checkpoint must share with the optimizer that loads it."""
|
|
344
|
+
return {
|
|
345
|
+
"optimizer": type(self).__name__,
|
|
346
|
+
"config": self.config.model_dump(mode="json"),
|
|
347
|
+
"search_space": self.search_space.model_dump(mode="json"),
|
|
348
|
+
"initial_points": [dict(point) for point in self.initial_points],
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
# --- running the objective for you ----------------------------------------
|
|
352
|
+
|
|
353
|
+
def optimize(
|
|
354
|
+
self,
|
|
355
|
+
objective_function: Callable,
|
|
356
|
+
*,
|
|
357
|
+
workers: int = 1,
|
|
358
|
+
timeout: float | None = None,
|
|
359
|
+
callback: Callable[[Progress], bool | None] | None = None,
|
|
360
|
+
checkpoint: str | Path | None = None,
|
|
361
|
+
) -> Result:
|
|
362
|
+
"""Run the optimizer from the start, calling objective_function for every candidate.
|
|
363
|
+
|
|
364
|
+
The function is called with one keyword argument per parameter and must return a
|
|
365
|
+
number. To record an evaluation as failed instead, raise EvaluationFailed or return NaN
|
|
366
|
+
or infinity; any other exception stops the run. With a seed, every call with the same
|
|
367
|
+
number of workers returns the same result. The genetic optimizer and random search
|
|
368
|
+
return it however many workers are used; CMA-ES and Bayesian optimization do not,
|
|
369
|
+
because their default population grows with the number of workers.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
objective_function: The function to optimize.
|
|
373
|
+
workers: Evaluate candidates in this many background processes at once.
|
|
374
|
+
timeout: The longest, in seconds, one evaluation may take. An evaluation that runs
|
|
375
|
+
longer is stopped and counts as failed, as does one whose process crashes.
|
|
376
|
+
callback: Called with a Progress after each generation. Return True from it to stop
|
|
377
|
+
the run; optimize() then returns the best result so far.
|
|
378
|
+
checkpoint: A file to save the run's state to after each generation. If the file
|
|
379
|
+
exists, the run carries on from it instead of starting over, so a run killed
|
|
380
|
+
partway through picks up where it left off; one that finished returns its
|
|
381
|
+
result at once. The result is the same as an uninterrupted run's.
|
|
382
|
+
|
|
383
|
+
Pressing Ctrl+C also stops the run and returns the best result so far, marked as
|
|
384
|
+
stopped, once at least one evaluation has succeeded.
|
|
385
|
+
|
|
386
|
+
With workers > 1 or a timeout, each evaluation runs in a separate process, so the
|
|
387
|
+
function must be defined at the top level of a module.
|
|
388
|
+
"""
|
|
389
|
+
if workers < 1:
|
|
390
|
+
raise ValueError(f"workers must be at least 1, got {workers}")
|
|
391
|
+
if timeout is not None and timeout <= 0:
|
|
392
|
+
raise ValueError(f"timeout must be positive, got {timeout}")
|
|
393
|
+
objective = ObjectiveFunction(
|
|
394
|
+
name=getattr(objective_function, "__name__", "objective"), function=objective_function, search_space=self.search_space
|
|
395
|
+
)
|
|
396
|
+
evaluate = partial(_evaluate, objective)
|
|
397
|
+
self._workers = workers
|
|
398
|
+
try:
|
|
399
|
+
self.reset()
|
|
400
|
+
self._objective_name, self._run_workers, self._run_timeout = objective.name, workers, timeout
|
|
401
|
+
if checkpoint is not None and Path(checkpoint).exists():
|
|
402
|
+
self.load_checkpoint(checkpoint)
|
|
403
|
+
reported = len(self._history)
|
|
404
|
+
|
|
405
|
+
def after_generation() -> int:
|
|
406
|
+
done = self._report(callback, reported)
|
|
407
|
+
if checkpoint is not None:
|
|
408
|
+
self.save_checkpoint(checkpoint)
|
|
409
|
+
return done
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
if workers == 1 and timeout is None:
|
|
413
|
+
while not self._done:
|
|
414
|
+
for trial in self.ask():
|
|
415
|
+
self._tell_outcome(trial, evaluate(trial.params))
|
|
416
|
+
reported = after_generation()
|
|
417
|
+
elif self.config.min_finished == 1:
|
|
418
|
+
with EvaluationPool(evaluate, workers, timeout) as pool:
|
|
419
|
+
while not self._done:
|
|
420
|
+
trials = self.ask()
|
|
421
|
+
for trial, outcome in zip(trials, pool.map([trial.params for trial in trials])):
|
|
422
|
+
self._tell_outcome(trial, outcome)
|
|
423
|
+
reported = after_generation()
|
|
424
|
+
else:
|
|
425
|
+
with EvaluationPool(evaluate, workers, timeout) as pool:
|
|
426
|
+
running: dict[int, Trial] = {}
|
|
427
|
+
while not self.done:
|
|
428
|
+
# Keep every worker busy: start what the optimizer has ready.
|
|
429
|
+
if pool.idle:
|
|
430
|
+
for trial in self.ask(pool.idle):
|
|
431
|
+
running[trial.id] = trial
|
|
432
|
+
pool.submit(trial.id, trial.params)
|
|
433
|
+
if not running:
|
|
434
|
+
break # nothing out, and nothing more to hand out
|
|
435
|
+
for trial_id, outcome in pool.collect():
|
|
436
|
+
self._tell_outcome(running.pop(trial_id), outcome)
|
|
437
|
+
if len(self._history) > reported:
|
|
438
|
+
reported = after_generation()
|
|
439
|
+
if self._done and (self._stopped or self._reached_threshold):
|
|
440
|
+
self._late = {} # stopped early: the stragglers' results aren't needed
|
|
441
|
+
break
|
|
442
|
+
except KeyboardInterrupt:
|
|
443
|
+
if self._best is None:
|
|
444
|
+
raise # nothing to return
|
|
445
|
+
self._finish(stopped=True)
|
|
446
|
+
finally:
|
|
447
|
+
self._workers = 1
|
|
448
|
+
return self.result()
|
|
449
|
+
|
|
450
|
+
def _report(self, callback: Callable[[Progress], bool | None] | None, reported: int) -> int:
|
|
451
|
+
"""Pass callback each generation finished since the first reported ones, and stop the run if it asks."""
|
|
452
|
+
if callback is None:
|
|
453
|
+
return len(self._history)
|
|
454
|
+
best = self._best_point()
|
|
455
|
+
for stats in self._history[reported:]:
|
|
456
|
+
progress = Progress(
|
|
457
|
+
generation=stats,
|
|
458
|
+
evaluations=stats.evaluations,
|
|
459
|
+
best_fitness=best[0] if best is not None else None,
|
|
460
|
+
best_params=dict(best[1]) if best is not None else None,
|
|
461
|
+
elapsed=self._elapsed(),
|
|
462
|
+
)
|
|
463
|
+
if callback(progress) and not self._done:
|
|
464
|
+
self._finish(stopped=True)
|
|
465
|
+
break
|
|
466
|
+
return len(self._history)
|
|
467
|
+
|
|
468
|
+
def _tell_outcome(self, trial: Trial, outcome: float | None) -> None:
|
|
469
|
+
if outcome is None:
|
|
470
|
+
self.tell(trial, failed=True)
|
|
471
|
+
else:
|
|
472
|
+
self.tell(trial, outcome)
|
|
473
|
+
|
|
474
|
+
# --- the generation loop --------------------------------------------------
|
|
475
|
+
|
|
476
|
+
def _start_generation(self) -> None:
|
|
477
|
+
self._needs_generation = False
|
|
478
|
+
self._generation += 1
|
|
479
|
+
if self._final_round:
|
|
480
|
+
self._population = self._final_population(self._generation)
|
|
481
|
+
else:
|
|
482
|
+
self._population = self._next_population(self._population, self._generation)
|
|
483
|
+
self._waiting = {}
|
|
484
|
+
self._handed_out = set()
|
|
485
|
+
self._told_this_generation = 0
|
|
486
|
+
for individual in self._population:
|
|
487
|
+
if individual.fitness is None:
|
|
488
|
+
individual.id = self._next_id
|
|
489
|
+
self._next_id += 1
|
|
490
|
+
self._waiting[individual.id] = individual
|
|
491
|
+
self._quorum = math.ceil(self.config.min_finished * len(self._waiting) - 1e-9)
|
|
492
|
+
if not self._waiting:
|
|
493
|
+
self._finish_generation()
|
|
494
|
+
|
|
495
|
+
def _finish_generation(self, final: bool = False) -> None:
|
|
496
|
+
assert self._population is not None
|
|
497
|
+
self._history.append(self._summarize(self._generation, self._evaluations, self._population))
|
|
498
|
+
best = self._best_point()
|
|
499
|
+
if best is not None and self._reached(best[0]):
|
|
500
|
+
self._reached_threshold = True
|
|
501
|
+
self._finish()
|
|
502
|
+
elif final or self._generation + 1 >= self.config.generations or self._budget_left() == 0:
|
|
503
|
+
if self.config.reserved_evaluations and not self._final_round and self._observations:
|
|
504
|
+
self._final_round = True
|
|
505
|
+
self._needs_generation = True
|
|
506
|
+
else:
|
|
507
|
+
self._finish()
|
|
508
|
+
else:
|
|
509
|
+
# The next generation is bred from this one when ask() is next called.
|
|
510
|
+
self._needs_generation = True
|
|
511
|
+
|
|
512
|
+
# --- noise -----------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
def _average(self, key: tuple) -> float:
|
|
515
|
+
return float(np.mean(self._observations[key]))
|
|
516
|
+
|
|
517
|
+
def _ranked_keys(self, keys: list[tuple]) -> list[tuple]:
|
|
518
|
+
"""Evaluated points, by key, best average first."""
|
|
519
|
+
return sorted(keys, key=self._average, reverse=self.config.maximize)
|
|
520
|
+
|
|
521
|
+
def _finalist_order(self) -> list[tuple]:
|
|
522
|
+
"""Every successfully evaluated point, by key, most promising first. Optimizers with a
|
|
523
|
+
better estimate of each point's true value than its average, such as a model, override this."""
|
|
524
|
+
return self._ranked_keys(list(self._observations))
|
|
525
|
+
|
|
526
|
+
def _refresh_averages(self, individuals: list[Individual]) -> None:
|
|
527
|
+
"""Set each successful individual's fitness to the current average of every evaluation of its point."""
|
|
528
|
+
for individual in individuals:
|
|
529
|
+
if not individual.failed and individual.fitness is not None:
|
|
530
|
+
individual.fitness = self._average(_key(individual.params))
|
|
531
|
+
|
|
532
|
+
def _final_population(self, generation: int) -> list[Individual]:
|
|
533
|
+
"""The final round with noise: the most promising points, each evaluated again.
|
|
534
|
+
|
|
535
|
+
Up to 5 finalists share the final_evaluations, each getting at least two when there are
|
|
536
|
+
enough, and the result is the finalist with the best average.
|
|
537
|
+
"""
|
|
538
|
+
reserve = self.config.reserved_evaluations
|
|
539
|
+
if self.config.max_evaluations is not None:
|
|
540
|
+
reserve = min(reserve, self.config.max_evaluations - self._handed_out_total)
|
|
541
|
+
order = self._finalist_order()
|
|
542
|
+
self._finalists = order[: max(1, min(5, reserve // 2, len(order)))]
|
|
543
|
+
return [
|
|
544
|
+
Individual(params=dict(self._points[self._finalists[i % len(self._finalists)]]), generation=generation)
|
|
545
|
+
for i in range(reserve)
|
|
546
|
+
]
|
|
547
|
+
|
|
548
|
+
def _outstanding(self) -> list[Individual]:
|
|
549
|
+
"""Individuals of earlier generations still being evaluated."""
|
|
550
|
+
return list(self._late.values())
|
|
551
|
+
|
|
552
|
+
def _initial_individuals(self, generation: int) -> list[Individual]:
|
|
553
|
+
return [Individual(params=dict(point), generation=generation) for point in self.initial_points]
|
|
554
|
+
|
|
555
|
+
def _random_individual(self, generation: int) -> Individual:
|
|
556
|
+
return Individual(params=self.search_space.sample(self.rng), generation=generation)
|
|
557
|
+
|
|
558
|
+
def _is_better(self, fitness: float, other: float) -> bool:
|
|
559
|
+
"""Whether fitness beats other in the configured direction."""
|
|
560
|
+
return fitness > other if self.config.maximize else fitness < other
|
|
561
|
+
|
|
562
|
+
def _ranked(self, individuals: list[Individual]) -> list[Individual]:
|
|
563
|
+
"""The individuals, best first. Failed individuals sort last."""
|
|
564
|
+
return sorted(individuals, key=_fitness, reverse=self.config.maximize)
|
|
565
|
+
|
|
566
|
+
def _reached(self, fitness: float) -> bool:
|
|
567
|
+
"""Whether fitness is good enough to stop early. Never true without a threshold."""
|
|
568
|
+
if self.config.threshold is None:
|
|
569
|
+
return False
|
|
570
|
+
return fitness >= self.config.threshold if self.config.maximize else fitness <= self.config.threshold
|
|
571
|
+
|
|
572
|
+
def _summarize(self, generation: int, evaluations: int, population: list[Individual]) -> GenerationStats:
|
|
573
|
+
evaluated = [ind for ind in population if ind.fitness is not None]
|
|
574
|
+
succeeded = self._ranked([ind for ind in evaluated if not ind.failed])
|
|
575
|
+
failures = len(evaluated) - len(succeeded)
|
|
576
|
+
if not succeeded:
|
|
577
|
+
return GenerationStats(generation=generation, evaluations=evaluations, failures=failures)
|
|
578
|
+
fitnesses = [_fitness(ind) for ind in succeeded]
|
|
579
|
+
return GenerationStats(
|
|
580
|
+
generation=generation,
|
|
581
|
+
evaluations=evaluations,
|
|
582
|
+
failures=failures,
|
|
583
|
+
best_fitness=succeeded[0].fitness,
|
|
584
|
+
best_params=dict(succeeded[0].params),
|
|
585
|
+
worst_fitness=succeeded[-1].fitness,
|
|
586
|
+
mean=float(np.mean(fitnesses)),
|
|
587
|
+
median=float(np.median(fitnesses)),
|
|
588
|
+
std=float(np.std(fitnesses)),
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
CHECKPOINT_FORMAT = 1 # raised when the checkpoint layout changes, so old files are refused clearly
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
@cache
|
|
596
|
+
def _hone_version() -> str:
|
|
597
|
+
try:
|
|
598
|
+
return version("hone-python")
|
|
599
|
+
except PackageNotFoundError: # run from a source checkout that isn't installed
|
|
600
|
+
return "unknown"
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _fitness(individual: Individual) -> float:
|
|
604
|
+
"""The fitness of an individual that has been evaluated."""
|
|
605
|
+
assert individual.fitness is not None, "an individual was ranked before it was evaluated"
|
|
606
|
+
return individual.fitness
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _key(params: dict[str, Any]) -> tuple:
|
|
610
|
+
"""A hashable form of params, so evaluations of the same point can be grouped."""
|
|
611
|
+
return tuple((name, tuple(value) if isinstance(value, list) else value) for name, value in params.items())
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _evaluate(objective: ObjectiveFunction, params: dict[str, Any]) -> float | None:
|
|
615
|
+
"""Call the objective, returning None for a failed evaluation. Runs in worker processes too."""
|
|
616
|
+
try:
|
|
617
|
+
return objective(params)
|
|
618
|
+
except EvaluationFailed:
|
|
619
|
+
return None
|