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/_pool.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Worker processes that evaluate candidates, with an optional time limit on each evaluation.
|
|
2
|
+
|
|
3
|
+
concurrent.futures can't stop an evaluation once it has started, so a hung simulation would
|
|
4
|
+
hold up the run forever. Here each worker is a process of its own: one that runs past the
|
|
5
|
+
time limit, or dies, is replaced, and its evaluation counts as failed.
|
|
6
|
+
"""
|
|
7
|
+
import multiprocessing
|
|
8
|
+
import signal
|
|
9
|
+
import time
|
|
10
|
+
from multiprocessing.connection import Connection, wait
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
STOP = None # sent to a worker to make it exit
|
|
14
|
+
READY = "ready" # sent by a worker once it has started and can evaluate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Worker:
|
|
18
|
+
def __init__(self, context: Any, evaluate: Callable[[dict], float | None]) -> None:
|
|
19
|
+
self.connection, child = context.Pipe()
|
|
20
|
+
self.process = context.Process(target=_serve, args=(child, evaluate), daemon=True)
|
|
21
|
+
self.process.start()
|
|
22
|
+
child.close()
|
|
23
|
+
self.ready = False
|
|
24
|
+
self.task: Any = None # key of the candidate being evaluated, or None while idle
|
|
25
|
+
self.deadline = float("inf")
|
|
26
|
+
|
|
27
|
+
def start(self, task: Any, params: dict, timeout: float | None) -> None:
|
|
28
|
+
self.connection.send((task, params))
|
|
29
|
+
self.task = task
|
|
30
|
+
self.deadline = time.monotonic() + timeout if timeout is not None else float("inf")
|
|
31
|
+
|
|
32
|
+
def stop(self, force: bool = False) -> None:
|
|
33
|
+
if not force and self.process.is_alive():
|
|
34
|
+
try:
|
|
35
|
+
self.connection.send(STOP)
|
|
36
|
+
self.process.join(timeout=1)
|
|
37
|
+
except (BrokenPipeError, OSError):
|
|
38
|
+
pass
|
|
39
|
+
if self.process.is_alive():
|
|
40
|
+
self.process.kill()
|
|
41
|
+
self.process.join()
|
|
42
|
+
self.connection.close()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _serve(connection: Connection, evaluate: Callable[[dict], float | None]) -> None:
|
|
46
|
+
"""A worker's loop: evaluate each candidate it is sent, and send back the outcome."""
|
|
47
|
+
# Ctrl+C reaches every process in the terminal; the main process handles it and stops the workers.
|
|
48
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
49
|
+
connection.send(READY)
|
|
50
|
+
while (message := connection.recv()) is not STOP:
|
|
51
|
+
task, params = message
|
|
52
|
+
try:
|
|
53
|
+
connection.send((task, evaluate(params), None))
|
|
54
|
+
except Exception as error:
|
|
55
|
+
try:
|
|
56
|
+
connection.send((task, None, error))
|
|
57
|
+
except Exception: # the error itself can't be pickled
|
|
58
|
+
connection.send((task, None, RuntimeError(f"{type(error).__name__}: {error}")))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EvaluationPool:
|
|
62
|
+
"""Evaluates candidates in worker processes, each within an optional time limit in seconds.
|
|
63
|
+
|
|
64
|
+
evaluate(params) runs in the workers; it returns the value, or None for a failed
|
|
65
|
+
evaluation, and any exception it raises stops every worker and is raised again here.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
evaluate: Callable[[dict], float | None],
|
|
71
|
+
workers: int,
|
|
72
|
+
timeout: float | None = None,
|
|
73
|
+
start_method: str | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._context = multiprocessing.get_context(start_method)
|
|
76
|
+
self._evaluate = evaluate
|
|
77
|
+
self._timeout = timeout
|
|
78
|
+
self._workers = [self._new_worker() for _ in range(workers)]
|
|
79
|
+
self._queue: list[tuple[Any, dict]] = [] # submitted, not yet started
|
|
80
|
+
self._finished: list[tuple[Any, float | None]] = []
|
|
81
|
+
|
|
82
|
+
def _new_worker(self) -> _Worker:
|
|
83
|
+
return _Worker(self._context, self._evaluate)
|
|
84
|
+
|
|
85
|
+
def map(self, candidates: list[dict]) -> list[float | None]:
|
|
86
|
+
"""The outcome of each candidate, in order: its value, or None if it failed, timed out or crashed its worker."""
|
|
87
|
+
outcomes: list[float | None] = [None] * len(candidates)
|
|
88
|
+
for index, params in enumerate(candidates):
|
|
89
|
+
self.submit(index, params)
|
|
90
|
+
while self.pending:
|
|
91
|
+
for index, outcome in self.collect():
|
|
92
|
+
outcomes[index] = outcome
|
|
93
|
+
return outcomes
|
|
94
|
+
|
|
95
|
+
def submit(self, key: Any, params: dict) -> None:
|
|
96
|
+
"""Queue a candidate, identified by key, to be evaluated by the next free worker."""
|
|
97
|
+
self._queue.append((key, params))
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def pending(self) -> int:
|
|
101
|
+
"""Candidates submitted whose outcomes haven't been collected yet."""
|
|
102
|
+
return len(self._queue) + sum(w.task is not None for w in self._workers) + len(self._finished)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def idle(self) -> int:
|
|
106
|
+
"""Workers with nothing to do, counting those still starting up."""
|
|
107
|
+
return max(sum(w.task is None for w in self._workers) - len(self._queue), 0)
|
|
108
|
+
|
|
109
|
+
def collect(self) -> list[tuple[Any, float | None]]:
|
|
110
|
+
"""Wait until at least one candidate is finished, and return (key, outcome) for each one that is."""
|
|
111
|
+
while not self._finished and (self._queue or any(w.task is not None for w in self._workers)):
|
|
112
|
+
for worker in self._workers:
|
|
113
|
+
if worker.ready and worker.task is None and self._queue:
|
|
114
|
+
key, params = self._queue.pop(0)
|
|
115
|
+
worker.start(key, params, self._timeout)
|
|
116
|
+
# The time limit starts once a worker has started, so it never counts start-up time.
|
|
117
|
+
watched = [w for w in self._workers if w.task is not None or not w.ready]
|
|
118
|
+
deadline = min((w.deadline for w in watched), default=float("inf"))
|
|
119
|
+
limit = None if deadline == float("inf") else max(deadline - time.monotonic(), 0)
|
|
120
|
+
heard = wait([w.connection for w in watched], timeout=limit)
|
|
121
|
+
for worker in watched:
|
|
122
|
+
if worker.connection in heard:
|
|
123
|
+
self._collect(worker)
|
|
124
|
+
elif time.monotonic() >= worker.deadline:
|
|
125
|
+
self._replace(worker) # out of time: its outcome is None
|
|
126
|
+
finished, self._finished = self._finished, []
|
|
127
|
+
return finished
|
|
128
|
+
|
|
129
|
+
def _collect(self, worker: _Worker) -> None:
|
|
130
|
+
try:
|
|
131
|
+
message = worker.connection.recv()
|
|
132
|
+
except (EOFError, OSError):
|
|
133
|
+
if not worker.ready:
|
|
134
|
+
raise RuntimeError("a worker process failed to start; its error is printed above") from None
|
|
135
|
+
self._replace(worker) # the worker died, taking the evaluation with it
|
|
136
|
+
return
|
|
137
|
+
if message == READY:
|
|
138
|
+
worker.ready = True
|
|
139
|
+
return
|
|
140
|
+
task, value, error = message
|
|
141
|
+
if error is not None:
|
|
142
|
+
raise error
|
|
143
|
+
self._finished.append((task, value))
|
|
144
|
+
worker.task = None
|
|
145
|
+
|
|
146
|
+
def _replace(self, worker: _Worker) -> None:
|
|
147
|
+
"""Stop worker, counting its evaluation, if any, as failed, and start a new one in its place."""
|
|
148
|
+
if worker.task is not None:
|
|
149
|
+
self._finished.append((worker.task, None))
|
|
150
|
+
worker.stop(force=True)
|
|
151
|
+
self._workers[self._workers.index(worker)] = self._new_worker()
|
|
152
|
+
|
|
153
|
+
def close(self, force: bool = False) -> None:
|
|
154
|
+
for worker in self._workers:
|
|
155
|
+
worker.stop(force)
|
|
156
|
+
|
|
157
|
+
def __enter__(self) -> "EvaluationPool":
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def __exit__(self, kind, error, traceback) -> None:
|
|
161
|
+
self.close(force=error is not None)
|