hone-python 0.9.0__tar.gz → 0.10.0__tar.gz
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_python-0.9.0 → hone_python-0.10.0}/PKG-INFO +31 -1
- {hone_python-0.9.0 → hone_python-0.10.0}/README.md +30 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/__init__.py +3 -1
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/__init__.py +1 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/__main__.py +4 -0
- hone_python-0.10.0/hone/benchmarks/constrained.py +172 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/base.py +12 -1
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/bayesian.py +29 -8
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/cmaes.py +23 -3
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/genetic/optimizer.py +44 -2
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/random_search.py +1 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/parameters/__init__.py +1 -0
- hone_python-0.10.0/hone/parameters/constraints.py +318 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/parameters/parameters.py +74 -6
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/result.py +8 -3
- {hone_python-0.9.0 → hone_python-0.10.0}/pyproject.toml +1 -1
- {hone_python-0.9.0 → hone_python-0.10.0}/LICENSE +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/ml.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/problems.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/runner.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/__init__.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/bayesian_config.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/cmaes_config.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/genetic_optimizer_config.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/optimizer_config.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/objective_function/__init__.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/objective_function/objective_function.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/__init__.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/_gp.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/_pool.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/choice.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/genetic/__init__.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/genetic/selection.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/genetic/variation.py +0 -0
- {hone_python-0.9.0 → hone_python-0.10.0}/hone/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hone-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: A python-based optimization engine/library.
|
|
5
5
|
License-Expression: BSD-3-Clause
|
|
6
6
|
License-File: LICENSE
|
|
@@ -178,6 +178,35 @@ RangeParameter("n_estimators", 10, 1000, integer=True, log=True)
|
|
|
178
178
|
|
|
179
179
|
Every parameter the objective function requires must be in the search space. The optimizer checks this before it starts.
|
|
180
180
|
|
|
181
|
+
### Constraints
|
|
182
|
+
|
|
183
|
+
Limits across parameters are written with the parameters themselves and passed to `SearchSpace`. Every optimizer then only asks for points that satisfy every constraint, so the objective is never evaluated outside them:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from hone import RangeParameter, SearchSpace
|
|
187
|
+
|
|
188
|
+
width = RangeParameter("width", 1, 10)
|
|
189
|
+
height = RangeParameter("height", 1, 10)
|
|
190
|
+
search_space = SearchSpace([width, height], constraints=[width * height <= 40, width >= 2 * height])
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Range parameters and numbers combine with `+`, `-`, `*`, `/`, `**` and `abs()`, and `<=` or `>=` makes the result a constraint; `<` and `>` mean the same. A point where a constraint can't be computed, such as one that divides by zero, counts as breaking it. There are no equality constraints: an equality leaves nothing to sample, so remove a variable instead and compute it inside the objective. Write a range as two constraints, since Python reads `0 <= width - height <= 5` as two comparisons joined by `and`, and hone raises an error rather than keep one half.
|
|
194
|
+
|
|
195
|
+
For anything an expression can't say, such as a square root or a choice parameter, use a function, which takes parameters by name and is satisfied when it returns zero or less:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from hone import Constraint
|
|
199
|
+
|
|
200
|
+
def stress(width, height, material):
|
|
201
|
+
return bending_stress(width, height) - ALLOWED[material]
|
|
202
|
+
|
|
203
|
+
search_space = SearchSpace([width, height, material], constraints=[Constraint.function(stress)])
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Constraints should be quick to check: hone checks them for every candidate it considers, which can be many times the number of evaluations. Expression constraints are saved with a result; a function constraint saves only its name, so after `Result.load` pass the function again with `rebuild_optimizer(functions={"stress": stress})`.
|
|
207
|
+
|
|
208
|
+
Starting points must satisfy the constraints. If the constraints leave nothing to search, or a region too small to find in 100,000 random draws, the optimizer raises `InfeasibleSearchSpace` when it's created, before any evaluation. Each optimizer keeps to constraints in its own way: random search and Bayesian optimization only consider points that satisfy them, CMA-ES draws a candidate again while it breaks one, and the genetic optimizer moves such a child back toward its fitter parent until it is feasible, which tends to land it near the boundary, where constrained optima usually are. On the benchmark's constrained problems the choice between optimizers follows the same pattern as without constraints; see [benchmarks/README.md](benchmarks/README.md#constrained-problems). [examples/pressure_vessel.py](examples/pressure_vessel.py) designs a pressure vessel of least cost under three constraints and finds the best published design. [examples/maintenance_schedule.py](examples/maintenance_schedule.py) chooses a maintenance schedule for a production line, scored by a noisy simulation of a year, with a constraint on technician hours and a function constraint that depends on the chosen shift; it halves the expected yearly cost of a sensible schedule chosen by hand.
|
|
209
|
+
|
|
181
210
|
### Configuration
|
|
182
211
|
|
|
183
212
|
`GeneticOptimizerConfig` validates its values when created. It can also be loaded from a JSON or YAML file:
|
|
@@ -326,6 +355,7 @@ Requires Python 3.12 or later and [Poetry](https://python-poetry.org/) 2.
|
|
|
326
355
|
| `make benchmark` | Run the full benchmark suite and update `benchmarks/results.md` |
|
|
327
356
|
| `make benchmark-noisy` | Run the noisy problems and update `benchmarks/noisy-results.md` |
|
|
328
357
|
| `make benchmark-ml` | Run the machine learning tuning problems and update `benchmarks/ml-results.md` |
|
|
358
|
+
| `make benchmark-constrained` | Run the constrained problems and update `benchmarks/constrained-results.md` |
|
|
329
359
|
|
|
330
360
|
To release, raise `version` in `pyproject.toml` and add a section for it to `CHANGELOG.md`. Once the change is on `main` and its checks pass, CI tags the version, publishes a GitHub release with that section as its notes and the built package attached, and uploads the same package to PyPI as `hone-python`.
|
|
331
361
|
|
|
@@ -157,6 +157,35 @@ RangeParameter("n_estimators", 10, 1000, integer=True, log=True)
|
|
|
157
157
|
|
|
158
158
|
Every parameter the objective function requires must be in the search space. The optimizer checks this before it starts.
|
|
159
159
|
|
|
160
|
+
### Constraints
|
|
161
|
+
|
|
162
|
+
Limits across parameters are written with the parameters themselves and passed to `SearchSpace`. Every optimizer then only asks for points that satisfy every constraint, so the objective is never evaluated outside them:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from hone import RangeParameter, SearchSpace
|
|
166
|
+
|
|
167
|
+
width = RangeParameter("width", 1, 10)
|
|
168
|
+
height = RangeParameter("height", 1, 10)
|
|
169
|
+
search_space = SearchSpace([width, height], constraints=[width * height <= 40, width >= 2 * height])
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Range parameters and numbers combine with `+`, `-`, `*`, `/`, `**` and `abs()`, and `<=` or `>=` makes the result a constraint; `<` and `>` mean the same. A point where a constraint can't be computed, such as one that divides by zero, counts as breaking it. There are no equality constraints: an equality leaves nothing to sample, so remove a variable instead and compute it inside the objective. Write a range as two constraints, since Python reads `0 <= width - height <= 5` as two comparisons joined by `and`, and hone raises an error rather than keep one half.
|
|
173
|
+
|
|
174
|
+
For anything an expression can't say, such as a square root or a choice parameter, use a function, which takes parameters by name and is satisfied when it returns zero or less:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from hone import Constraint
|
|
178
|
+
|
|
179
|
+
def stress(width, height, material):
|
|
180
|
+
return bending_stress(width, height) - ALLOWED[material]
|
|
181
|
+
|
|
182
|
+
search_space = SearchSpace([width, height, material], constraints=[Constraint.function(stress)])
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Constraints should be quick to check: hone checks them for every candidate it considers, which can be many times the number of evaluations. Expression constraints are saved with a result; a function constraint saves only its name, so after `Result.load` pass the function again with `rebuild_optimizer(functions={"stress": stress})`.
|
|
186
|
+
|
|
187
|
+
Starting points must satisfy the constraints. If the constraints leave nothing to search, or a region too small to find in 100,000 random draws, the optimizer raises `InfeasibleSearchSpace` when it's created, before any evaluation. Each optimizer keeps to constraints in its own way: random search and Bayesian optimization only consider points that satisfy them, CMA-ES draws a candidate again while it breaks one, and the genetic optimizer moves such a child back toward its fitter parent until it is feasible, which tends to land it near the boundary, where constrained optima usually are. On the benchmark's constrained problems the choice between optimizers follows the same pattern as without constraints; see [benchmarks/README.md](benchmarks/README.md#constrained-problems). [examples/pressure_vessel.py](examples/pressure_vessel.py) designs a pressure vessel of least cost under three constraints and finds the best published design. [examples/maintenance_schedule.py](examples/maintenance_schedule.py) chooses a maintenance schedule for a production line, scored by a noisy simulation of a year, with a constraint on technician hours and a function constraint that depends on the chosen shift; it halves the expected yearly cost of a sensible schedule chosen by hand.
|
|
188
|
+
|
|
160
189
|
### Configuration
|
|
161
190
|
|
|
162
191
|
`GeneticOptimizerConfig` validates its values when created. It can also be loaded from a JSON or YAML file:
|
|
@@ -305,6 +334,7 @@ Requires Python 3.12 or later and [Poetry](https://python-poetry.org/) 2.
|
|
|
305
334
|
| `make benchmark` | Run the full benchmark suite and update `benchmarks/results.md` |
|
|
306
335
|
| `make benchmark-noisy` | Run the noisy problems and update `benchmarks/noisy-results.md` |
|
|
307
336
|
| `make benchmark-ml` | Run the machine learning tuning problems and update `benchmarks/ml-results.md` |
|
|
337
|
+
| `make benchmark-constrained` | Run the constrained problems and update `benchmarks/constrained-results.md` |
|
|
308
338
|
|
|
309
339
|
To release, raise `version` in `pyproject.toml` and add a section for it to `CHANGELOG.md`. Once the change is on `main` and its checks pass, CI tags the version, publishes a GitHub release with that section as its notes and the built package attached, and uploads the same package to PyPI as `hone-python`.
|
|
310
340
|
|
|
@@ -10,7 +10,7 @@ from hone.optimizers import (
|
|
|
10
10
|
UnsupportedSearchSpace,
|
|
11
11
|
choose_optimizer,
|
|
12
12
|
)
|
|
13
|
-
from hone.parameters import ChoiceParameter, FixedParameter, RangeParameter, SearchSpace
|
|
13
|
+
from hone.parameters import ChoiceParameter, Constraint, FixedParameter, InfeasibleSearchSpace, RangeParameter, SearchSpace
|
|
14
14
|
from hone.result import GenerationStats, Progress, Result, RunInfo, print_progress
|
|
15
15
|
|
|
16
16
|
__all__ = [
|
|
@@ -19,11 +19,13 @@ __all__ = [
|
|
|
19
19
|
"CMAES",
|
|
20
20
|
"CMAESConfig",
|
|
21
21
|
"ChoiceParameter",
|
|
22
|
+
"Constraint",
|
|
22
23
|
"EvaluationFailed",
|
|
23
24
|
"FixedParameter",
|
|
24
25
|
"GenerationStats",
|
|
25
26
|
"GeneticOptimizer",
|
|
26
27
|
"GeneticOptimizerConfig",
|
|
28
|
+
"InfeasibleSearchSpace",
|
|
27
29
|
"Optimizer",
|
|
28
30
|
"OptimizerConfig",
|
|
29
31
|
"Progress",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Benchmark problems and a runner for comparing optimizers. Run `python -m hone.benchmarks --help`."""
|
|
2
|
+
from .constrained import constrained_problems
|
|
2
3
|
from .ml import ml_problems
|
|
3
4
|
from .problems import Problem, noisy_problems, quick_problems, standard_problems
|
|
4
5
|
from .runner import (
|
|
@@ -6,6 +6,7 @@ from collections.abc import Sequence
|
|
|
6
6
|
from importlib.metadata import version
|
|
7
7
|
|
|
8
8
|
from . import ml
|
|
9
|
+
from .constrained import constrained_problems
|
|
9
10
|
from .problems import noisy_problems, quick_problems, standard_problems
|
|
10
11
|
from .runner import BUDGETS, load_summaries, regressions, run_benchmark, save_summaries, summarize, to_markdown
|
|
11
12
|
|
|
@@ -26,6 +27,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
26
27
|
suite.add_argument("--quick", action="store_true", help="run the small CI subset with fewer seeds and evaluations")
|
|
27
28
|
suite.add_argument("--ml", action="store_true", help="run the machine learning tuning problems instead (needs scikit-learn)")
|
|
28
29
|
suite.add_argument("--noisy", action="store_true", help="run the problems with noise added to every evaluation instead")
|
|
30
|
+
suite.add_argument("--constrained", action="store_true", help="run the problems with constraints instead")
|
|
29
31
|
parser.add_argument("--seeds", type=int, help="number of seeds per problem (default 10, or 5 with --quick)")
|
|
30
32
|
parser.add_argument(
|
|
31
33
|
"--budgets",
|
|
@@ -49,6 +51,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
49
51
|
problems, default_budgets = ml.ml_problems(), ml.BUDGETS
|
|
50
52
|
elif args.noisy:
|
|
51
53
|
problems, default_budgets = noisy_problems(), NOISY_BUDGETS
|
|
54
|
+
elif args.constrained:
|
|
55
|
+
problems, default_budgets = constrained_problems(), BUDGETS
|
|
52
56
|
else:
|
|
53
57
|
problems, default_budgets = standard_problems(), BUDGETS
|
|
54
58
|
seeds = args.seeds or (5 if args.quick else 10)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Constrained test problems, whose optima lie on a constraint's boundary, as they usually do in engineering.
|
|
2
|
+
|
|
3
|
+
- boundary-sphere and boundary-ellipsoid: a shifted sphere or ellipsoid with a linear constraint
|
|
4
|
+
that cuts off its unconstrained optimum, so the answer is on the cutting plane. Both optima are
|
|
5
|
+
known exactly.
|
|
6
|
+
- rosenbrock-disk: Rosenbrock's function inside the disk x² + y² ≤ 2, whose optimum (1, 1) sits
|
|
7
|
+
on the disk's edge.
|
|
8
|
+
- pressure-vessel, tension-spring and welded-beam: three standard engineering design problems,
|
|
9
|
+
scored against the best designs published for them. The welded beam's stress formulas need
|
|
10
|
+
square roots, so its constraints are functions; the others are expressions.
|
|
11
|
+
"""
|
|
12
|
+
import math
|
|
13
|
+
import zlib
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from hone.parameters import Constraint, RangeParameter, SearchSpace
|
|
19
|
+
|
|
20
|
+
from .problems import BOUND, Problem, _shift
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _normal(name: str, dimensions: int) -> np.ndarray:
|
|
24
|
+
"""A fixed unit vector for the named problem, with no component near zero."""
|
|
25
|
+
rng = np.random.default_rng(zlib.crc32(f"{name}/normal".encode()))
|
|
26
|
+
w = rng.uniform(0.3, 1.0, dimensions) * rng.choice([-1.0, 1.0], dimensions)
|
|
27
|
+
return w / np.linalg.norm(w)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _boundary(name: str, dimensions: int, weights: np.ndarray) -> Problem:
|
|
31
|
+
"""Minimize sum(weights * (x - c)²) subject to w·x ≤ w·c - 1, which the unconstrained optimum c breaks.
|
|
32
|
+
|
|
33
|
+
The optimum is at c + z with z = -D⁻¹w / (wᵀD⁻¹w), where D is diag(weights), and its value is 1 / (wᵀD⁻¹w).
|
|
34
|
+
"""
|
|
35
|
+
names = [f"x{i}" for i in range(dimensions)]
|
|
36
|
+
parameters = [RangeParameter(n, -BOUND, BOUND) for n in names]
|
|
37
|
+
centre, w = _shift(name, dimensions), _normal(name, dimensions)
|
|
38
|
+
limit = float(w @ centre) - 1.0
|
|
39
|
+
terms = [float(wi) * p for wi, p in zip(w, parameters)]
|
|
40
|
+
plane = terms[0]
|
|
41
|
+
for term in terms[1:]:
|
|
42
|
+
plane = plane + term
|
|
43
|
+
inverse = 1.0 / weights
|
|
44
|
+
best = centre - inverse * w / float(w @ (inverse * w))
|
|
45
|
+
assert np.all(np.abs(best) <= BOUND), f"{name}: the constrained optimum must lie inside the bounds"
|
|
46
|
+
|
|
47
|
+
def function(**params: Any) -> float:
|
|
48
|
+
x = np.array([params[n] for n in names], dtype=float)
|
|
49
|
+
return float(np.sum(weights * (x - centre) ** 2))
|
|
50
|
+
|
|
51
|
+
function.__name__ = name.replace("-", "_")
|
|
52
|
+
space = SearchSpace(parameters, [plane <= limit])
|
|
53
|
+
return Problem(name=name, function=function, search_space=space, optimum=1.0 / float(w @ (inverse * w)), tags=frozenset({"constrained"}))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def boundary_sphere(dimensions: int) -> Problem:
|
|
57
|
+
return _boundary(f"boundary-sphere-{dimensions}d", dimensions, np.ones(dimensions))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def boundary_ellipsoid(dimensions: int) -> Problem:
|
|
61
|
+
# Axis weights from 1 to 1,000: wide enough to need scaling, narrow enough to keep the optimum in bounds.
|
|
62
|
+
return _boundary(f"boundary-ellipsoid-{dimensions}d", dimensions, 10.0 ** (3 * np.arange(dimensions) / max(dimensions - 1, 1)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def rosenbrock_disk() -> Problem:
|
|
66
|
+
x, y = RangeParameter("x", -1.5, 1.5), RangeParameter("y", -1.5, 1.5)
|
|
67
|
+
|
|
68
|
+
def rosenbrock_disk(x: float, y: float) -> float:
|
|
69
|
+
return (1 - x) ** 2 + 100 * (y - x**2) ** 2
|
|
70
|
+
|
|
71
|
+
space = SearchSpace([x, y], [x**2 + y**2 <= 2])
|
|
72
|
+
return Problem(name="rosenbrock-disk-2d", function=rosenbrock_disk, search_space=space, optimum=0.0, tags=frozenset({"constrained"}))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def pressure_vessel() -> Problem:
|
|
76
|
+
"""A cylindrical vessel with hemispherical heads, at least 1,296,000 cubic inches, of least cost.
|
|
77
|
+
|
|
78
|
+
Shell and head thicknesses come in sixteenths of an inch, so they are integers here. The best
|
|
79
|
+
published design costs 6059.714 (Ts = 13/16, Th = 7/16, R = 42.098, L = 176.637).
|
|
80
|
+
"""
|
|
81
|
+
shell, head = RangeParameter("shell", 1, 99, integer=True), RangeParameter("head", 1, 99, integer=True)
|
|
82
|
+
radius, length = RangeParameter("radius", 10, 200), RangeParameter("length", 10, 200)
|
|
83
|
+
|
|
84
|
+
def pressure_vessel(shell: int, head: int, radius: float, length: float) -> float:
|
|
85
|
+
ts, th = shell / 16, head / 16
|
|
86
|
+
return 0.6224 * ts * radius * length + 1.7781 * th * radius**2 + 3.1661 * ts**2 * length + 19.84 * ts**2 * radius
|
|
87
|
+
|
|
88
|
+
space = SearchSpace(
|
|
89
|
+
[shell, head, radius, length],
|
|
90
|
+
[
|
|
91
|
+
shell / 16 >= 0.0193 * radius,
|
|
92
|
+
head / 16 >= 0.00954 * radius,
|
|
93
|
+
math.pi * radius**2 * length + 4 / 3 * math.pi * radius**3 >= 1_296_000,
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
return Problem(name="pressure-vessel", function=pressure_vessel, search_space=space, optimum=6059.714335, tags=frozenset({"constrained", "mixed", "engineering"}))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def tension_spring() -> Problem:
|
|
100
|
+
"""A coil spring of least weight under limits on deflection, shear stress, surge frequency and diameter.
|
|
101
|
+
|
|
102
|
+
The best published design weighs 0.0126652 (d = 0.051690, D = 0.356750, N = 11.287126).
|
|
103
|
+
"""
|
|
104
|
+
d, coil, n = RangeParameter("wire", 0.05, 2.0), RangeParameter("coil", 0.25, 1.3), RangeParameter("turns", 2.0, 15.0)
|
|
105
|
+
|
|
106
|
+
def tension_spring(wire: float, coil: float, turns: float) -> float:
|
|
107
|
+
return (turns + 2) * coil * wire**2
|
|
108
|
+
|
|
109
|
+
space = SearchSpace(
|
|
110
|
+
[d, coil, n],
|
|
111
|
+
[
|
|
112
|
+
coil**3 * n / (71785 * d**4) >= 1,
|
|
113
|
+
(4 * coil**2 - d * coil) / (12566 * (coil * d**3 - d**4)) + 1 / (5108 * d**2) <= 1,
|
|
114
|
+
140.45 * d / (coil**2 * n) >= 1,
|
|
115
|
+
d + coil <= 1.5,
|
|
116
|
+
],
|
|
117
|
+
)
|
|
118
|
+
return Problem(name="tension-spring", function=tension_spring, search_space=space, optimum=0.012665233, tags=frozenset({"constrained", "engineering"}))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# The welded beam's load and material: load P (lb), length L (in), Young's and shear moduli (psi).
|
|
122
|
+
P, L, E, G = 6000.0, 14.0, 30e6, 12e6
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _shear(weld: float, length: float, thickness: float) -> float:
|
|
126
|
+
primary = P / (math.sqrt(2) * weld * length)
|
|
127
|
+
moment = P * (L + length / 2)
|
|
128
|
+
r = math.sqrt(length**2 / 4 + ((weld + thickness) / 2) ** 2)
|
|
129
|
+
j = 2 * math.sqrt(2) * weld * length * (length**2 / 12 + ((weld + thickness) / 2) ** 2)
|
|
130
|
+
secondary = moment * r / j
|
|
131
|
+
return math.sqrt(primary**2 + primary * secondary * length / r + secondary**2)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _buckling(thickness: float, width: float) -> float:
|
|
135
|
+
return 4.013 * E * math.sqrt(thickness**2 * width**6 / 36) / L**2 * (1 - thickness / (2 * L) * math.sqrt(E / (4 * G)))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def welded_beam() -> Problem:
|
|
139
|
+
"""A beam welded to a support, of least cost under limits on shear, bending, buckling and deflection.
|
|
140
|
+
|
|
141
|
+
The best published design costs 1.724852 (h = 0.205730, l = 3.470489, t = 9.036624, b = 0.205730).
|
|
142
|
+
"""
|
|
143
|
+
weld, length = RangeParameter("weld", 0.1, 2.0), RangeParameter("length", 0.1, 10.0)
|
|
144
|
+
thickness, width = RangeParameter("thickness", 0.1, 10.0), RangeParameter("width", 0.1, 2.0)
|
|
145
|
+
|
|
146
|
+
def welded_beam(weld: float, length: float, thickness: float, width: float) -> float:
|
|
147
|
+
return 1.10471 * weld**2 * length + 0.04811 * thickness * width * (14 + length)
|
|
148
|
+
|
|
149
|
+
def shear(weld: float, length: float, thickness: float) -> float:
|
|
150
|
+
return _shear(weld, length, thickness) - 13600
|
|
151
|
+
|
|
152
|
+
def buckling(thickness: float, width: float) -> float:
|
|
153
|
+
return P - _buckling(thickness, width)
|
|
154
|
+
|
|
155
|
+
space = SearchSpace(
|
|
156
|
+
[weld, length, thickness, width],
|
|
157
|
+
[
|
|
158
|
+
Constraint.function(shear),
|
|
159
|
+
6 * P * L / (width * thickness**2) <= 30000,
|
|
160
|
+
weld <= width,
|
|
161
|
+
0.10471 * weld**2 + 0.04811 * thickness * width * (14 + length) <= 5,
|
|
162
|
+
4 * P * L**3 / (E * thickness**3 * width) <= 0.25,
|
|
163
|
+
Constraint.function(buckling),
|
|
164
|
+
],
|
|
165
|
+
)
|
|
166
|
+
return Problem(name="welded-beam", function=welded_beam, search_space=space, optimum=1.724852, tags=frozenset({"constrained", "engineering"}))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def constrained_problems(dimensions: tuple[int, ...] = (2, 5, 10)) -> list[Problem]:
|
|
170
|
+
"""The constrained suite: boundary sphere and ellipsoid in each dimension, Rosenbrock in a disk, and three engineering designs."""
|
|
171
|
+
problems = [factory(d) for d in dimensions for factory in (boundary_sphere, boundary_ellipsoid)]
|
|
172
|
+
return problems + [rosenbrock_disk(), pressure_vessel(), tension_spring(), welded_beam()]
|
|
@@ -77,6 +77,7 @@ class Optimizer(ABC):
|
|
|
77
77
|
Fixed parameters may be left out.
|
|
78
78
|
"""
|
|
79
79
|
config_class: ClassVar[type[OptimizerConfig]] = OptimizerConfig
|
|
80
|
+
supports_constraints: ClassVar[bool] = False # whether it keeps to a search space's constraints
|
|
80
81
|
|
|
81
82
|
def __init__(
|
|
82
83
|
self,
|
|
@@ -91,9 +92,17 @@ class Optimizer(ABC):
|
|
|
91
92
|
self.config = config if config is not None else self.config_class()
|
|
92
93
|
if not isinstance(self.config, self.config_class):
|
|
93
94
|
raise TypeError(f"{type(self).__name__} needs a {self.config_class.__name__}, got {type(self.config).__name__}")
|
|
95
|
+
if search_space.constraints and not self.supports_constraints:
|
|
96
|
+
raise UnsupportedSearchSpace(f"{type(self).__name__} doesn't support constraints yet; RandomSearch does")
|
|
94
97
|
if isinstance(initial_points, dict):
|
|
95
98
|
initial_points = [initial_points]
|
|
96
99
|
self.initial_points = [search_space.check(point) for point in initial_points or []]
|
|
100
|
+
for point in self.initial_points:
|
|
101
|
+
if broken := search_space.broken(point):
|
|
102
|
+
raise ValueError(f"the initial point {point} breaks the constraint `{broken[0]}`")
|
|
103
|
+
if search_space.constraints and not self.initial_points:
|
|
104
|
+
# Fail now, before any evaluation, if the constraints leave nothing to search.
|
|
105
|
+
search_space.sample(np.random.default_rng(0))
|
|
97
106
|
self._workers = 1 # how many evaluations run at once; optimizers may size their population from it
|
|
98
107
|
self.reset()
|
|
99
108
|
|
|
@@ -297,7 +306,9 @@ class Optimizer(ABC):
|
|
|
297
306
|
|
|
298
307
|
The file is replaced in one step, so a crash while saving leaves the previous checkpoint intact.
|
|
299
308
|
"""
|
|
300
|
-
|
|
309
|
+
# The search space is left out: the loading optimizer has its own, which must match, and a
|
|
310
|
+
# constraint function may not be picklable.
|
|
311
|
+
state = {name: value for name, value in self.__dict__.items() if name != "search_space"}
|
|
301
312
|
elapsed = self._elapsed() if self._start_clock is not None else None
|
|
302
313
|
state.update(_start_clock=None, _end_clock=None, _workers=1)
|
|
303
314
|
checkpoint = {
|
|
@@ -45,6 +45,7 @@ class BayesianOptimizer(Optimizer):
|
|
|
45
45
|
initial_points: Points to evaluate first: one dict of parameter values, or a list of them.
|
|
46
46
|
"""
|
|
47
47
|
config_class = BayesianConfig
|
|
48
|
+
supports_constraints = True
|
|
48
49
|
config: BayesianConfig
|
|
49
50
|
|
|
50
51
|
def __init__(
|
|
@@ -73,12 +74,12 @@ class BayesianOptimizer(Optimizer):
|
|
|
73
74
|
count = self.config.initial_samples or 2 * (self._encoding.variables + 1)
|
|
74
75
|
taken = {tuple(self._encoding.encode(ind.params)) for ind in initial}
|
|
75
76
|
spread = []
|
|
76
|
-
for x in self.
|
|
77
|
+
for x in self._feasible_spread(max(count - len(initial), 0)):
|
|
77
78
|
# Rounding integers can land two points on the same values; try others instead.
|
|
78
79
|
for _ in range(100):
|
|
79
80
|
if tuple(x) not in taken:
|
|
80
81
|
break
|
|
81
|
-
x = self.
|
|
82
|
+
x = self._feasible_spread(1)[0]
|
|
82
83
|
taken.add(tuple(x))
|
|
83
84
|
spread.append(x)
|
|
84
85
|
return initial + [Individual(params=self._encoding.decode(x), generation=generation) for x in spread]
|
|
@@ -98,7 +99,7 @@ class BayesianOptimizer(Optimizer):
|
|
|
98
99
|
def _propose(self, count: int) -> list[np.ndarray]:
|
|
99
100
|
succeeded = [y for y in self._y if y is not None]
|
|
100
101
|
if len(succeeded) < 2:
|
|
101
|
-
return list(self.
|
|
102
|
+
return list(self._feasible_spread(count))
|
|
102
103
|
worst = max(succeeded)
|
|
103
104
|
X = np.array(self._X)
|
|
104
105
|
model = GaussianProcess(X, np.array([worst if y is None else y for y in self._y]), self._theta)
|
|
@@ -129,20 +130,40 @@ class BayesianOptimizer(Optimizer):
|
|
|
129
130
|
scales = self.rng.choice([0.01, 0.05, 0.2], size=(LOCAL_CANDIDATES, 1))
|
|
130
131
|
local = encoding.perturb(self.rng, local, scales)
|
|
131
132
|
candidates = encoding.snap(np.vstack([encoding.spread(self.rng, GLOBAL_CANDIDATES), local]))
|
|
132
|
-
scores = _log_expected_improvement(model, candidates, best)
|
|
133
|
+
scores = self._only_feasible(candidates, _log_expected_improvement(model, candidates, best))
|
|
133
134
|
|
|
134
135
|
columns = encoding.numeric
|
|
135
136
|
if columns.size:
|
|
136
137
|
starts = candidates[np.argsort(-scores)[:REFINED]]
|
|
137
138
|
refined = encoding.snap(_refine(model, starts, columns, best))
|
|
138
139
|
candidates = np.vstack([candidates, refined])
|
|
139
|
-
scores = np.append(scores, _log_expected_improvement(model, refined, best))
|
|
140
|
+
scores = np.append(scores, self._only_feasible(refined, _log_expected_improvement(model, refined, best)))
|
|
140
141
|
|
|
141
142
|
seen = model.X
|
|
142
143
|
for index in np.argsort(-scores):
|
|
144
|
+
if scores[index] == -np.inf and self.search_space.constraints:
|
|
145
|
+
break # the rest break a constraint
|
|
143
146
|
if not np.any(np.all(np.abs(seen - candidates[index]) < 1e-9, axis=1)):
|
|
144
147
|
return candidates[index]
|
|
145
|
-
return
|
|
148
|
+
return self._feasible_spread(1)[0] # every feasible candidate was already evaluated
|
|
149
|
+
|
|
150
|
+
# --- constraints --------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def _feasible_spread(self, count: int) -> np.ndarray:
|
|
153
|
+
"""count points spread over the space, any that break a constraint replaced by random feasible points."""
|
|
154
|
+
X = self._encoding.spread(self.rng, count)
|
|
155
|
+
if self.search_space.constraints:
|
|
156
|
+
for i, x in enumerate(X):
|
|
157
|
+
if not self.search_space.feasible(self._encoding.decode(x)):
|
|
158
|
+
X[i] = self._encoding.encode(self.search_space.sample(self.rng))
|
|
159
|
+
return X
|
|
160
|
+
|
|
161
|
+
def _only_feasible(self, X: np.ndarray, scores: np.ndarray) -> np.ndarray:
|
|
162
|
+
"""scores, with those of points that break a constraint set to minus infinity."""
|
|
163
|
+
if not self.search_space.constraints:
|
|
164
|
+
return scores
|
|
165
|
+
feasible = np.array([self.search_space.feasible(self._encoding.decode(x)) for x in X], dtype=bool)
|
|
166
|
+
return np.where(feasible, scores, -np.inf)
|
|
146
167
|
|
|
147
168
|
|
|
148
169
|
def _refine(model: GaussianProcess, starts: np.ndarray, columns: np.ndarray, best: float) -> np.ndarray:
|
|
@@ -187,7 +208,7 @@ class _Encoding:
|
|
|
187
208
|
"""Between parameter values and points in [0, 1]^columns, where the model works."""
|
|
188
209
|
|
|
189
210
|
def __init__(self, search_space: SearchSpace) -> None:
|
|
190
|
-
self.
|
|
211
|
+
self.names = search_space.names # not the search space itself, whose function constraints may not pickle
|
|
191
212
|
self._blocks: list[tuple[RangeParameter | ChoiceParameter, slice]] = []
|
|
192
213
|
self._constant: dict[str, Any] = {}
|
|
193
214
|
column = 0
|
|
@@ -228,7 +249,7 @@ class _Encoding:
|
|
|
228
249
|
values[p.name] = p.from_unit(float(x[block][0]))
|
|
229
250
|
else:
|
|
230
251
|
values[p.name] = p.choices[int(np.argmax(x[block]))]
|
|
231
|
-
return {name: values[name] for name in self.
|
|
252
|
+
return {name: values[name] for name in self.names}
|
|
232
253
|
|
|
233
254
|
def snap(self, X: np.ndarray) -> np.ndarray:
|
|
234
255
|
"""Each row moved to the nearest point that parameter values can take: whole integers and one choice per block."""
|
|
@@ -27,6 +27,7 @@ INTEGER_SPREAD = 0.25 # minimum standard deviation of an integer variable, in i
|
|
|
27
27
|
MAX_CONDITION = 1e14 # restart when the covariance matrix is this badly conditioned
|
|
28
28
|
MIN_STEP = 1e-12 # restart when every continuous step, in [0, 1] units, falls below this
|
|
29
29
|
FLAT = 1e-12 # restart when recent generations' best values differ by less than this
|
|
30
|
+
RESAMPLES = 100 # draws from the distribution for a feasible candidate before falling back to a random feasible point
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
class CMAES(Optimizer):
|
|
@@ -50,6 +51,7 @@ class CMAES(Optimizer):
|
|
|
50
51
|
initial_points: Points to evaluate first: one dict of parameter values, or a list of them.
|
|
51
52
|
"""
|
|
52
53
|
config_class = CMAESConfig
|
|
54
|
+
supports_constraints = True
|
|
53
55
|
config: CMAESConfig
|
|
54
56
|
|
|
55
57
|
def __init__(
|
|
@@ -137,10 +139,28 @@ class CMAES(Optimizer):
|
|
|
137
139
|
self._widen = np.where(spread < minimum, minimum / np.maximum(spread, 1e-300), 1.0)
|
|
138
140
|
return [self._sample(generation) for _ in range(self.population_size)]
|
|
139
141
|
|
|
140
|
-
def
|
|
142
|
+
def _draw(self) -> np.ndarray:
|
|
141
143
|
z = self.rng.standard_normal(len(self._mean))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
+
return _mirror(self._mean + self._sigma * self._widen * (self._B @ (self._D * z)))
|
|
145
|
+
|
|
146
|
+
def _sample(self, generation: int) -> Individual:
|
|
147
|
+
"""A candidate from the search distribution, drawn again while it breaks a constraint.
|
|
148
|
+
|
|
149
|
+
Redrawing did better on the constrained benchmark than ranking infeasible candidates
|
|
150
|
+
below every feasible one without evaluating them.
|
|
151
|
+
"""
|
|
152
|
+
x = self._draw()
|
|
153
|
+
params = self._decode(x)
|
|
154
|
+
if self.search_space.constraints and not self.search_space.feasible(params):
|
|
155
|
+
for _ in range(RESAMPLES):
|
|
156
|
+
x = self._draw()
|
|
157
|
+
params = self._decode(x)
|
|
158
|
+
if self.search_space.feasible(params):
|
|
159
|
+
break
|
|
160
|
+
else:
|
|
161
|
+
params = self.search_space.sample(self.rng)
|
|
162
|
+
x = self._encode(params)
|
|
163
|
+
individual = Individual(params=params, generation=generation)
|
|
144
164
|
self._vectors[individual] = x
|
|
145
165
|
return individual
|
|
146
166
|
|
|
@@ -17,6 +17,8 @@ MIN_STEP = 1e-9
|
|
|
17
17
|
MAX_STEP = 0.5
|
|
18
18
|
# A child that repeats a point already asked for is mutated again, one gene at a time, up to this many times.
|
|
19
19
|
REPEAT_TRIES = 10
|
|
20
|
+
# A child that breaks a constraint is moved halfway back toward its fitter parent this many times.
|
|
21
|
+
REPAIR_STEPS = 10
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
class GeneticOptimizer(Optimizer):
|
|
@@ -61,6 +63,7 @@ class GeneticOptimizer(Optimizer):
|
|
|
61
63
|
>>> result = optimizer.optimize(objective_function)
|
|
62
64
|
"""
|
|
63
65
|
config_class = GeneticOptimizerConfig
|
|
66
|
+
supports_constraints = True
|
|
64
67
|
config: GeneticOptimizerConfig
|
|
65
68
|
|
|
66
69
|
def reset(self) -> None:
|
|
@@ -95,7 +98,46 @@ class GeneticOptimizer(Optimizer):
|
|
|
95
98
|
return select(individuals, self.rng, maximize=self.config.maximize)
|
|
96
99
|
|
|
97
100
|
def _child(self, parents: list[Individual], generation: int, niche: dict | None = None) -> Individual:
|
|
98
|
-
"""A child of two parents selected from parents. A niche fixes the child's choice parameters.
|
|
101
|
+
"""A child of two parents selected from parents. A niche fixes the child's choice parameters.
|
|
102
|
+
|
|
103
|
+
A child that breaks a constraint is repaired: moved toward the fitter of its parents,
|
|
104
|
+
which satisfies every constraint, by halving the gap REPAIR_STEPS times and keeping the
|
|
105
|
+
last point inside. Its numbers end up near the constraint's boundary, where optima
|
|
106
|
+
under constraints usually are.
|
|
107
|
+
"""
|
|
108
|
+
child, first, second = self._bred(parents, generation, niche)
|
|
109
|
+
if self.search_space.feasible(child.params):
|
|
110
|
+
return child
|
|
111
|
+
fitter = first if (_fitness(first) > _fitness(second)) == self.config.maximize else second
|
|
112
|
+
return self._repaired(child, fitter, generation)
|
|
113
|
+
|
|
114
|
+
def _repaired(self, child: Individual, anchor: Individual, generation: int) -> Individual:
|
|
115
|
+
inside, outside = dict(anchor.params), dict(child.params)
|
|
116
|
+
if not self.search_space.feasible(inside):
|
|
117
|
+
inside = self.search_space.sample(self.rng) # can't happen: every evaluated point is feasible
|
|
118
|
+
for _ in range(REPAIR_STEPS):
|
|
119
|
+
middle = self._between(inside, outside)
|
|
120
|
+
if self.search_space.feasible(middle):
|
|
121
|
+
inside = middle
|
|
122
|
+
else:
|
|
123
|
+
outside = middle
|
|
124
|
+
repaired = Individual(params=inside, generation=generation)
|
|
125
|
+
if child in self._steps:
|
|
126
|
+
self._steps[repaired] = self._steps.pop(child)
|
|
127
|
+
return repaired
|
|
128
|
+
|
|
129
|
+
def _between(self, inside: dict, outside: dict) -> dict:
|
|
130
|
+
"""The midpoint of two points, in each range's unit scale; other values are taken from inside."""
|
|
131
|
+
middle = dict(inside)
|
|
132
|
+
for parameter in self.search_space.parameters:
|
|
133
|
+
if isinstance(parameter, RangeParameter) and parameter.maximum > parameter.minimum:
|
|
134
|
+
unit = (float(parameter.to_unit(inside[parameter.name])) + float(parameter.to_unit(outside[parameter.name]))) / 2
|
|
135
|
+
value = parameter.from_unit(unit)
|
|
136
|
+
middle[parameter.name] = int(value) if parameter.integer else float(value)
|
|
137
|
+
return middle
|
|
138
|
+
|
|
139
|
+
def _bred(self, parents: list[Individual], generation: int, niche: dict | None) -> tuple[Individual, Individual, Individual]:
|
|
140
|
+
"""A child bred from two parents selected from parents, and the parents."""
|
|
99
141
|
first, second = self._select(parents), self._select(parents)
|
|
100
142
|
if self.config.blend_crossover:
|
|
101
143
|
params = blend_crossover(
|
|
@@ -114,7 +156,7 @@ class GeneticOptimizer(Optimizer):
|
|
|
114
156
|
child = Individual(params=params, generation=generation)
|
|
115
157
|
if self._adaptive:
|
|
116
158
|
self._steps[child] = step
|
|
117
|
-
return child
|
|
159
|
+
return child, first, second
|
|
118
160
|
|
|
119
161
|
def _new_point(self, params: dict, step: float, niche: dict | None) -> dict:
|
|
120
162
|
"""params, mutated again a gene at a time while they repeat a point already asked for.
|
|
@@ -7,6 +7,7 @@ class RandomSearch(Optimizer):
|
|
|
7
7
|
Useful as a baseline: a smarter optimizer should beat it for the same number of evaluations.
|
|
8
8
|
Any initial points take the place of random points in the first generation.
|
|
9
9
|
"""
|
|
10
|
+
supports_constraints = True
|
|
10
11
|
|
|
11
12
|
def _next_population(self, previous: list[Individual] | None, generation: int) -> list[Individual]:
|
|
12
13
|
initial = self._initial_individuals(generation) if previous is None else []
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Constraints on a search space: limits that every point an optimizer asks for must satisfy.
|
|
2
|
+
|
|
3
|
+
An expression is built from range parameters and numbers with ordinary arithmetic, and comparing
|
|
4
|
+
it with <= or >= makes a constraint:
|
|
5
|
+
|
|
6
|
+
width * height <= 40
|
|
7
|
+
|
|
8
|
+
Expressions are pydantic models, so a search space with constraints saves and loads like any other.
|
|
9
|
+
Constraint.function covers what an expression can't say, at the cost of not being saved.
|
|
10
|
+
"""
|
|
11
|
+
import inspect
|
|
12
|
+
import math
|
|
13
|
+
import numbers
|
|
14
|
+
from typing import Annotated, Any, Callable, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
TOLERANCE = 1e-9 # a point this close to a limit, relative to the limit's size, satisfies it
|
|
19
|
+
|
|
20
|
+
_PRECEDENCE = {"+": 1, "-": 1, "*": 2, "/": 2, "neg": 3, "**": 4}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Arithmetic:
|
|
24
|
+
"""Arithmetic and comparison for anything that can stand in an expression: range parameters and expressions."""
|
|
25
|
+
|
|
26
|
+
def _as_term(self) -> "Term":
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
def __add__(self, other: Any) -> "BinaryOp":
|
|
30
|
+
return BinaryOp(op="+", left=self._as_term(), right=_term(other))
|
|
31
|
+
|
|
32
|
+
def __radd__(self, other: Any) -> "BinaryOp":
|
|
33
|
+
return BinaryOp(op="+", left=_term(other), right=self._as_term())
|
|
34
|
+
|
|
35
|
+
def __sub__(self, other: Any) -> "BinaryOp":
|
|
36
|
+
return BinaryOp(op="-", left=self._as_term(), right=_term(other))
|
|
37
|
+
|
|
38
|
+
def __rsub__(self, other: Any) -> "BinaryOp":
|
|
39
|
+
return BinaryOp(op="-", left=_term(other), right=self._as_term())
|
|
40
|
+
|
|
41
|
+
def __mul__(self, other: Any) -> "BinaryOp":
|
|
42
|
+
return BinaryOp(op="*", left=self._as_term(), right=_term(other))
|
|
43
|
+
|
|
44
|
+
def __rmul__(self, other: Any) -> "BinaryOp":
|
|
45
|
+
return BinaryOp(op="*", left=_term(other), right=self._as_term())
|
|
46
|
+
|
|
47
|
+
def __truediv__(self, other: Any) -> "BinaryOp":
|
|
48
|
+
return BinaryOp(op="/", left=self._as_term(), right=_term(other))
|
|
49
|
+
|
|
50
|
+
def __rtruediv__(self, other: Any) -> "BinaryOp":
|
|
51
|
+
return BinaryOp(op="/", left=_term(other), right=self._as_term())
|
|
52
|
+
|
|
53
|
+
def __pow__(self, other: Any) -> "BinaryOp":
|
|
54
|
+
return BinaryOp(op="**", left=self._as_term(), right=_term(other))
|
|
55
|
+
|
|
56
|
+
def __rpow__(self, other: Any) -> "BinaryOp":
|
|
57
|
+
return BinaryOp(op="**", left=_term(other), right=self._as_term())
|
|
58
|
+
|
|
59
|
+
def __neg__(self) -> "UnaryOp":
|
|
60
|
+
return UnaryOp(op="neg", operand=self._as_term())
|
|
61
|
+
|
|
62
|
+
def __abs__(self) -> "UnaryOp":
|
|
63
|
+
return UnaryOp(op="abs", operand=self._as_term())
|
|
64
|
+
|
|
65
|
+
# < and > mean the same as <= and >=: for numbers searched continuously the difference has no effect.
|
|
66
|
+
def __le__(self, other: Any) -> "ExpressionConstraint":
|
|
67
|
+
return ExpressionConstraint(left=self._as_term(), op="<=", right=_term(other))
|
|
68
|
+
|
|
69
|
+
def __ge__(self, other: Any) -> "ExpressionConstraint":
|
|
70
|
+
return ExpressionConstraint(left=self._as_term(), op=">=", right=_term(other))
|
|
71
|
+
|
|
72
|
+
__lt__ = __le__
|
|
73
|
+
__gt__ = __ge__
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _term(value: Any) -> "Term":
|
|
77
|
+
if isinstance(value, _Arithmetic):
|
|
78
|
+
return value._as_term()
|
|
79
|
+
if isinstance(value, numbers.Real) and not isinstance(value, bool):
|
|
80
|
+
return Number(value=float(value))
|
|
81
|
+
raise TypeError(
|
|
82
|
+
f"constraints are built from range parameters and numbers, not {value!r}; "
|
|
83
|
+
"for anything else, such as choice parameters, use Constraint.function"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _Term(_Arithmetic, BaseModel):
|
|
88
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
89
|
+
|
|
90
|
+
def _as_term(self) -> "Term":
|
|
91
|
+
return self # type: ignore[return-value]
|
|
92
|
+
|
|
93
|
+
def evaluate(self, params: dict[str, Any]) -> float:
|
|
94
|
+
"""The expression's value at params."""
|
|
95
|
+
raise NotImplementedError
|
|
96
|
+
|
|
97
|
+
def names(self) -> set[str]:
|
|
98
|
+
"""The parameters the expression uses."""
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
def _precedence(self) -> int:
|
|
102
|
+
return 5
|
|
103
|
+
|
|
104
|
+
def __str__(self) -> str:
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Variable(_Term):
|
|
109
|
+
"""A range parameter's value, by name."""
|
|
110
|
+
kind: Literal["variable"] = "variable"
|
|
111
|
+
name: str
|
|
112
|
+
|
|
113
|
+
def evaluate(self, params: dict[str, Any]) -> float:
|
|
114
|
+
return float(params[self.name])
|
|
115
|
+
|
|
116
|
+
def names(self) -> set[str]:
|
|
117
|
+
return {self.name}
|
|
118
|
+
|
|
119
|
+
def __str__(self) -> str:
|
|
120
|
+
return self.name
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Number(_Term):
|
|
124
|
+
kind: Literal["number"] = "number"
|
|
125
|
+
value: float
|
|
126
|
+
|
|
127
|
+
def evaluate(self, params: dict[str, Any]) -> float:
|
|
128
|
+
return self.value
|
|
129
|
+
|
|
130
|
+
def names(self) -> set[str]:
|
|
131
|
+
return set()
|
|
132
|
+
|
|
133
|
+
def __str__(self) -> str:
|
|
134
|
+
return repr(int(self.value)) if self.value.is_integer() and abs(self.value) < 1e15 else repr(self.value)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class BinaryOp(_Term):
|
|
138
|
+
kind: Literal["binary"] = "binary"
|
|
139
|
+
op: Literal["+", "-", "*", "/", "**"]
|
|
140
|
+
left: "Term"
|
|
141
|
+
right: "Term"
|
|
142
|
+
|
|
143
|
+
def evaluate(self, params: dict[str, Any]) -> float:
|
|
144
|
+
a, b = self.left.evaluate(params), self.right.evaluate(params)
|
|
145
|
+
match self.op:
|
|
146
|
+
case "+":
|
|
147
|
+
return a + b
|
|
148
|
+
case "-":
|
|
149
|
+
return a - b
|
|
150
|
+
case "*":
|
|
151
|
+
return a * b
|
|
152
|
+
case "/":
|
|
153
|
+
return a / b
|
|
154
|
+
case "**":
|
|
155
|
+
return a**b
|
|
156
|
+
|
|
157
|
+
def names(self) -> set[str]:
|
|
158
|
+
return self.left.names() | self.right.names()
|
|
159
|
+
|
|
160
|
+
def _precedence(self) -> int:
|
|
161
|
+
return _PRECEDENCE[self.op]
|
|
162
|
+
|
|
163
|
+
def __str__(self) -> str:
|
|
164
|
+
mine = self._precedence()
|
|
165
|
+
# ** groups to the right, the others to the left, so the other side needs brackets at equal precedence.
|
|
166
|
+
left_brackets = self.left._precedence() < mine or (self.op == "**" and self.left._precedence() == mine)
|
|
167
|
+
right_brackets = self.right._precedence() < mine or (self.op in ("-", "/") and self.right._precedence() == mine)
|
|
168
|
+
left = f"({self.left})" if left_brackets else str(self.left)
|
|
169
|
+
right = f"({self.right})" if right_brackets else str(self.right)
|
|
170
|
+
return f"{left} {self.op} {right}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class UnaryOp(_Term):
|
|
174
|
+
kind: Literal["unary"] = "unary"
|
|
175
|
+
op: Literal["neg", "abs"]
|
|
176
|
+
operand: "Term"
|
|
177
|
+
|
|
178
|
+
def evaluate(self, params: dict[str, Any]) -> float:
|
|
179
|
+
value = self.operand.evaluate(params)
|
|
180
|
+
return -value if self.op == "neg" else abs(value)
|
|
181
|
+
|
|
182
|
+
def names(self) -> set[str]:
|
|
183
|
+
return self.operand.names()
|
|
184
|
+
|
|
185
|
+
def _precedence(self) -> int:
|
|
186
|
+
return _PRECEDENCE["neg"] if self.op == "neg" else 5
|
|
187
|
+
|
|
188
|
+
def __str__(self) -> str:
|
|
189
|
+
if self.op == "abs":
|
|
190
|
+
return f"abs({self.operand})"
|
|
191
|
+
return f"-({self.operand})" if self.operand._precedence() < self._precedence() else f"-{self.operand}"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
type Term = Annotated[Variable | Number | BinaryOp | UnaryOp, Field(discriminator="kind")]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Constraint(BaseModel):
|
|
198
|
+
"""A limit every point an optimizer asks for must satisfy.
|
|
199
|
+
|
|
200
|
+
Build one by comparing an expression of range parameters with <= or >=, such as
|
|
201
|
+
`width * height <= 40`, or with Constraint.function for anything an expression can't say.
|
|
202
|
+
"""
|
|
203
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
204
|
+
|
|
205
|
+
def violation(self, params: dict[str, Any]) -> float:
|
|
206
|
+
"""How far params break the constraint: 0 when they satisfy it, infinity when it can't be computed."""
|
|
207
|
+
raise NotImplementedError
|
|
208
|
+
|
|
209
|
+
def satisfied(self, params: dict[str, Any]) -> bool:
|
|
210
|
+
return self.violation(params) == 0
|
|
211
|
+
|
|
212
|
+
def names(self) -> set[str]:
|
|
213
|
+
"""The parameters the constraint uses."""
|
|
214
|
+
raise NotImplementedError
|
|
215
|
+
|
|
216
|
+
def __bool__(self) -> bool:
|
|
217
|
+
raise TypeError(
|
|
218
|
+
f"the constraint `{self}` has no truth value. A chained comparison such as `0 <= x + y <= 10` "
|
|
219
|
+
"keeps only one half; write two constraints, `x + y >= 0` and `x + y <= 10`"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def function(function: Callable[..., float], name: str | None = None) -> "FunctionConstraint":
|
|
224
|
+
"""A constraint computed by function, which is satisfied when it returns zero or less.
|
|
225
|
+
|
|
226
|
+
The function takes parameters by name, like an objective function, and may take only
|
|
227
|
+
some of them. It can use anything, such as square roots or choice parameters, but it
|
|
228
|
+
isn't saved with the search space: a run record stores only its name.
|
|
229
|
+
"""
|
|
230
|
+
return FunctionConstraint(name=name or getattr(function, "__name__", "constraint"), func=function)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _limit(value: float, scale: float) -> float:
|
|
234
|
+
"""value as a violation: 0 within the tolerance, infinity when it couldn't be computed."""
|
|
235
|
+
if isinstance(value, complex) or math.isnan(value):
|
|
236
|
+
return math.inf
|
|
237
|
+
return 0.0 if value <= TOLERANCE * max(1.0, abs(scale)) else float(value)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class ExpressionConstraint(Constraint):
|
|
241
|
+
"""An expression compared with another: left <= right, or left >= right."""
|
|
242
|
+
kind: Literal["expression"] = "expression"
|
|
243
|
+
left: Term
|
|
244
|
+
op: Literal["<=", ">="]
|
|
245
|
+
right: Term
|
|
246
|
+
|
|
247
|
+
def violation(self, params: dict[str, Any]) -> float:
|
|
248
|
+
try:
|
|
249
|
+
left, right = self.left.evaluate(params), self.right.evaluate(params)
|
|
250
|
+
except (ArithmeticError, ValueError):
|
|
251
|
+
return math.inf # division by zero, or an overflow: the point can't be judged, so it isn't allowed
|
|
252
|
+
if isinstance(left, complex) or isinstance(right, complex):
|
|
253
|
+
return math.inf # a negative number to a fractional power
|
|
254
|
+
return _limit(left - right if self.op == "<=" else right - left, right)
|
|
255
|
+
|
|
256
|
+
def names(self) -> set[str]:
|
|
257
|
+
return self.left.names() | self.right.names()
|
|
258
|
+
|
|
259
|
+
def __str__(self) -> str:
|
|
260
|
+
return f"{self.left} {self.op} {self.right}"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class FunctionConstraint(Constraint):
|
|
264
|
+
"""A constraint computed by a function of the parameters: satisfied when it returns zero or less."""
|
|
265
|
+
kind: Literal["function"] = "function"
|
|
266
|
+
name: str
|
|
267
|
+
func: Callable[..., float] | None = Field(default=None, exclude=True) # not saved; None once loaded
|
|
268
|
+
|
|
269
|
+
def violation(self, params: dict[str, Any]) -> float:
|
|
270
|
+
if self.func is None:
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"the constraint {self.name!r} was loaded without its function, which can't be saved; "
|
|
273
|
+
"pass it again, such as with rebuild_optimizer(functions=...)"
|
|
274
|
+
)
|
|
275
|
+
signature = inspect.signature(self.func)
|
|
276
|
+
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in signature.parameters.values()):
|
|
277
|
+
arguments = params
|
|
278
|
+
else:
|
|
279
|
+
arguments = {name: value for name, value in params.items() if name in signature.parameters}
|
|
280
|
+
value = self.func(**arguments)
|
|
281
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Real):
|
|
282
|
+
raise TypeError(f"the constraint {self.name!r} must return a number, but returned {value!r}")
|
|
283
|
+
return _limit(float(value), 0.0)
|
|
284
|
+
|
|
285
|
+
def names(self) -> set[str]:
|
|
286
|
+
if self.func is None:
|
|
287
|
+
return set()
|
|
288
|
+
signature = inspect.signature(self.func)
|
|
289
|
+
return {
|
|
290
|
+
name
|
|
291
|
+
for name, p in signature.parameters.items()
|
|
292
|
+
if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
def required(self) -> set[str]:
|
|
296
|
+
"""The parameters the function can't be called without."""
|
|
297
|
+
if self.func is None:
|
|
298
|
+
return set()
|
|
299
|
+
return {
|
|
300
|
+
name
|
|
301
|
+
for name, p in inspect.signature(self.func).parameters.items()
|
|
302
|
+
if p.default is inspect.Parameter.empty
|
|
303
|
+
and p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
def __str__(self) -> str:
|
|
307
|
+
return self.name
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
type AnyConstraint = Annotated[ExpressionConstraint | FunctionConstraint, Field(discriminator="kind")]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class InfeasibleSearchSpace(ValueError):
|
|
314
|
+
"""No point satisfying every constraint could be found."""
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
BinaryOp.model_rebuild()
|
|
318
|
+
UnaryOp.model_rebuild()
|
|
@@ -6,6 +6,8 @@ from typing import Annotated, Any, Literal
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
8
|
|
|
9
|
+
from .constraints import AnyConstraint, ExpressionConstraint, FunctionConstraint, InfeasibleSearchSpace, Variable, _Arithmetic
|
|
10
|
+
|
|
9
11
|
type Value = float | int | str
|
|
10
12
|
|
|
11
13
|
|
|
@@ -20,7 +22,7 @@ class _Parameter(BaseModel):
|
|
|
20
22
|
super().__init__(**data)
|
|
21
23
|
|
|
22
24
|
|
|
23
|
-
class RangeParameter(_Parameter):
|
|
25
|
+
class RangeParameter(_Arithmetic, _Parameter):
|
|
24
26
|
"""A number between minimum and maximum, optionally restricted to integers.
|
|
25
27
|
|
|
26
28
|
With log=True the range is searched on a logarithmic scale: values are drawn evenly in
|
|
@@ -37,6 +39,9 @@ class RangeParameter(_Parameter):
|
|
|
37
39
|
def __init__(self, name: str, minimum: float, maximum: float, **data: Any) -> None:
|
|
38
40
|
super().__init__(name=name, minimum=minimum, maximum=maximum, **data)
|
|
39
41
|
|
|
42
|
+
def _as_term(self) -> Variable:
|
|
43
|
+
return Variable(name=self.name)
|
|
44
|
+
|
|
40
45
|
@model_validator(mode="after")
|
|
41
46
|
def _check_bounds(self):
|
|
42
47
|
if self.minimum > self.maximum:
|
|
@@ -185,14 +190,22 @@ class ChoiceParameter(_Parameter):
|
|
|
185
190
|
type Parameter = Annotated[RangeParameter | FixedParameter | ChoiceParameter, Field(discriminator="kind")]
|
|
186
191
|
|
|
187
192
|
|
|
193
|
+
MAX_DRAWS = 100_000 # draws in a row without a feasible point before a search space counts as infeasible
|
|
194
|
+
|
|
195
|
+
|
|
188
196
|
class SearchSpace(BaseModel):
|
|
189
|
-
"""The set of parameters an objective function is optimized over.
|
|
197
|
+
"""The set of parameters an objective function is optimized over, and any constraints on them.
|
|
198
|
+
|
|
199
|
+
Every point an optimizer asks for satisfies every constraint, so the objective is never
|
|
200
|
+
evaluated where a constraint rules it out.
|
|
201
|
+
"""
|
|
190
202
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
191
203
|
|
|
192
204
|
parameters: list[Parameter] = Field(min_length=1)
|
|
205
|
+
constraints: list[AnyConstraint] = Field(default_factory=list)
|
|
193
206
|
|
|
194
|
-
def __init__(self, parameters: Sequence[Parameter], **data: Any) -> None:
|
|
195
|
-
super().__init__(parameters=list(parameters), **data)
|
|
207
|
+
def __init__(self, parameters: Sequence[Parameter], constraints: Sequence[AnyConstraint] = (), **data: Any) -> None:
|
|
208
|
+
super().__init__(parameters=list(parameters), constraints=list(constraints), **data)
|
|
196
209
|
|
|
197
210
|
@model_validator(mode="after")
|
|
198
211
|
def _check_unique_names(self):
|
|
@@ -202,13 +215,68 @@ class SearchSpace(BaseModel):
|
|
|
202
215
|
raise ValueError(f"each parameter needs a unique name; repeated: {', '.join(duplicates)}")
|
|
203
216
|
return self
|
|
204
217
|
|
|
218
|
+
@model_validator(mode="after")
|
|
219
|
+
def _check_constraints(self):
|
|
220
|
+
by_name = {parameter.name: parameter for parameter in self.parameters}
|
|
221
|
+
for constraint in self.constraints:
|
|
222
|
+
if isinstance(constraint, ExpressionConstraint):
|
|
223
|
+
for name in sorted(constraint.names()):
|
|
224
|
+
if name not in by_name:
|
|
225
|
+
raise ValueError(f"the constraint `{constraint}` uses {name!r}, which isn't a parameter of this search space")
|
|
226
|
+
if not isinstance(by_name[name], RangeParameter):
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"the constraint `{constraint}` uses {name!r}, which isn't a range parameter; "
|
|
229
|
+
"use Constraint.function for constraints on other kinds"
|
|
230
|
+
)
|
|
231
|
+
elif isinstance(constraint, FunctionConstraint):
|
|
232
|
+
missing = sorted(constraint.required() - set(by_name))
|
|
233
|
+
if missing:
|
|
234
|
+
raise ValueError(f"the constraint {constraint.name!r} needs parameters missing from the search space: {', '.join(missing)}")
|
|
235
|
+
return self
|
|
236
|
+
|
|
205
237
|
@property
|
|
206
238
|
def names(self) -> list[str]:
|
|
207
239
|
return [parameter.name for parameter in self.parameters]
|
|
208
240
|
|
|
209
241
|
def sample(self, rng: np.random.Generator) -> dict[str, Any]:
|
|
210
|
-
"""Draw one value for every parameter.
|
|
211
|
-
|
|
242
|
+
"""Draw one value for every parameter, at a point that satisfies every constraint.
|
|
243
|
+
|
|
244
|
+
Points are drawn until one is feasible. Raises InfeasibleSearchSpace after MAX_DRAWS
|
|
245
|
+
draws in a row without one.
|
|
246
|
+
"""
|
|
247
|
+
for _ in range(MAX_DRAWS if self.constraints else 1):
|
|
248
|
+
point = {parameter.name: parameter.sample(rng) for parameter in self.parameters}
|
|
249
|
+
if self.feasible(point):
|
|
250
|
+
return point
|
|
251
|
+
raise InfeasibleSearchSpace(
|
|
252
|
+
f"no point satisfying every constraint was found in {MAX_DRAWS:,} random draws: the constraints may "
|
|
253
|
+
"contradict each other, or leave too small a region to sample"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def with_functions(self, functions: dict[str, Any]) -> "SearchSpace":
|
|
257
|
+
"""This search space with function constraints given their functions by name, as after loading one.
|
|
258
|
+
|
|
259
|
+
Raises ValueError if a function constraint is left without one.
|
|
260
|
+
"""
|
|
261
|
+
constraints: list[AnyConstraint] = []
|
|
262
|
+
for constraint in self.constraints:
|
|
263
|
+
if isinstance(constraint, FunctionConstraint) and constraint.name in functions:
|
|
264
|
+
constraint = constraint.model_copy(update={"func": functions[constraint.name]})
|
|
265
|
+
if isinstance(constraint, FunctionConstraint) and constraint.func is None:
|
|
266
|
+
raise ValueError(f"the constraint {constraint.name!r} needs its function, which isn't saved; pass it in functions")
|
|
267
|
+
constraints.append(constraint)
|
|
268
|
+
unknown = sorted(set(functions) - {c.name for c in self.constraints if isinstance(c, FunctionConstraint)})
|
|
269
|
+
if unknown:
|
|
270
|
+
raise ValueError(f"no function constraint is named {', '.join(map(repr, unknown))}")
|
|
271
|
+
return SearchSpace(self.parameters, constraints)
|
|
272
|
+
|
|
273
|
+
def feasible(self, params: dict[str, Any]) -> bool:
|
|
274
|
+
"""Whether params satisfy every constraint."""
|
|
275
|
+
return all(constraint.satisfied(params) for constraint in self.constraints)
|
|
276
|
+
|
|
277
|
+
def broken(self, params: dict[str, Any]) -> list[AnyConstraint]:
|
|
278
|
+
"""The constraints params don't satisfy."""
|
|
279
|
+
return [constraint for constraint in self.constraints if not constraint.satisfied(params)]
|
|
212
280
|
|
|
213
281
|
def check(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
214
282
|
"""params as a complete point of this space, in parameter order, or ValueError if they aren't one.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import TYPE_CHECKING, Any, Self
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable, Self
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel, ConfigDict
|
|
6
6
|
|
|
@@ -89,12 +89,17 @@ class Result(BaseModel):
|
|
|
89
89
|
return NotImplemented
|
|
90
90
|
return self.model_dump(exclude={"run"}) == other.model_dump(exclude={"run"})
|
|
91
91
|
|
|
92
|
-
def rebuild_optimizer(
|
|
92
|
+
def rebuild_optimizer(
|
|
93
|
+
self, optimizer_class: "type[Optimizer] | None" = None, *, functions: dict[str, Callable[..., float]] | None = None
|
|
94
|
+
) -> "Optimizer":
|
|
93
95
|
"""A new optimizer set up exactly as this result's was, to repeat or extend the run.
|
|
94
96
|
|
|
95
97
|
Running it gives the same result, given the same objective and, for CMA-ES and
|
|
96
98
|
Bayesian optimization, the same number of workers (run.workers). An optimizer that
|
|
97
99
|
isn't one of hone's own is rebuilt by passing its class.
|
|
100
|
+
|
|
101
|
+
A constraint made with Constraint.function isn't saved with a result: after loading
|
|
102
|
+
one, pass each such constraint's function by name, as functions={"stress": stress}.
|
|
98
103
|
"""
|
|
99
104
|
from hone import optimizers
|
|
100
105
|
|
|
@@ -108,7 +113,7 @@ class Result(BaseModel):
|
|
|
108
113
|
raise ValueError(f"{self.run.optimizer} isn't one of hone's optimizers; pass its class to rebuild it")
|
|
109
114
|
optimizer_class = builtin[self.run.optimizer]
|
|
110
115
|
config = optimizer_class.config_class.model_validate(self.run.config)
|
|
111
|
-
return optimizer_class(self.run.search_space, config, initial_points=self.run.initial_points or None)
|
|
116
|
+
return optimizer_class(self.run.search_space.with_functions(functions or {}), config, initial_points=self.run.initial_points or None)
|
|
112
117
|
|
|
113
118
|
def save(self, filepath: str | Path) -> None:
|
|
114
119
|
Path(filepath).write_text(self.model_dump_json(indent=2))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|