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,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