hone-python 0.9.0__py3-none-any.whl → 0.11.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 +4 -1
- hone/benchmarks/__init__.py +2 -0
- hone/benchmarks/__main__.py +10 -0
- hone/benchmarks/conditional.py +109 -0
- hone/benchmarks/constrained.py +172 -0
- hone/benchmarks/ml.py +69 -3
- hone/config/genetic_optimizer_config.py +3 -2
- hone/objective_function/objective_function.py +15 -0
- hone/optimizers/base.py +42 -10
- hone/optimizers/bayesian.py +67 -10
- hone/optimizers/cmaes.py +26 -4
- hone/optimizers/genetic/optimizer.py +91 -15
- hone/optimizers/genetic/variation.py +10 -3
- hone/optimizers/random_search.py +2 -0
- hone/parameters/__init__.py +2 -0
- hone/parameters/conditions.py +147 -0
- hone/parameters/constraints.py +336 -0
- hone/parameters/parameters.py +246 -17
- hone/result.py +8 -3
- {hone_python-0.9.0.dist-info → hone_python-0.11.0.dist-info}/METADATA +75 -2
- hone_python-0.11.0.dist-info/RECORD +37 -0
- hone_python-0.9.0.dist-info/RECORD +0 -33
- {hone_python-0.9.0.dist-info → hone_python-0.11.0.dist-info}/WHEEL +0 -0
- {hone_python-0.9.0.dist-info → hone_python-0.11.0.dist-info}/licenses/LICENSE +0 -0
hone/__init__.py
CHANGED
|
@@ -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, Condition, 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,14 @@ __all__ = [
|
|
|
19
19
|
"CMAES",
|
|
20
20
|
"CMAESConfig",
|
|
21
21
|
"ChoiceParameter",
|
|
22
|
+
"Condition",
|
|
23
|
+
"Constraint",
|
|
22
24
|
"EvaluationFailed",
|
|
23
25
|
"FixedParameter",
|
|
24
26
|
"GenerationStats",
|
|
25
27
|
"GeneticOptimizer",
|
|
26
28
|
"GeneticOptimizerConfig",
|
|
29
|
+
"InfeasibleSearchSpace",
|
|
27
30
|
"Optimizer",
|
|
28
31
|
"OptimizerConfig",
|
|
29
32
|
"Progress",
|
hone/benchmarks/__init__.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"""Benchmark problems and a runner for comparing optimizers. Run `python -m hone.benchmarks --help`."""
|
|
2
|
+
from .conditional import conditional_problems
|
|
3
|
+
from .constrained import constrained_problems
|
|
2
4
|
from .ml import ml_problems
|
|
3
5
|
from .problems import Problem, noisy_problems, quick_problems, standard_problems
|
|
4
6
|
from .runner import (
|
hone/benchmarks/__main__.py
CHANGED
|
@@ -6,6 +6,8 @@ from collections.abc import Sequence
|
|
|
6
6
|
from importlib.metadata import version
|
|
7
7
|
|
|
8
8
|
from . import ml
|
|
9
|
+
from .conditional import conditional_problems
|
|
10
|
+
from .constrained import constrained_problems
|
|
9
11
|
from .problems import noisy_problems, quick_problems, standard_problems
|
|
10
12
|
from .runner import BUDGETS, load_summaries, regressions, run_benchmark, save_summaries, summarize, to_markdown
|
|
11
13
|
|
|
@@ -26,6 +28,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
26
28
|
suite.add_argument("--quick", action="store_true", help="run the small CI subset with fewer seeds and evaluations")
|
|
27
29
|
suite.add_argument("--ml", action="store_true", help="run the machine learning tuning problems instead (needs scikit-learn)")
|
|
28
30
|
suite.add_argument("--noisy", action="store_true", help="run the problems with noise added to every evaluation instead")
|
|
31
|
+
suite.add_argument("--constrained", action="store_true", help="run the problems with constraints instead")
|
|
32
|
+
suite.add_argument(
|
|
33
|
+
"--conditional", action="store_true", help="run the problems with conditional parameters, and their flat versions, instead"
|
|
34
|
+
)
|
|
29
35
|
parser.add_argument("--seeds", type=int, help="number of seeds per problem (default 10, or 5 with --quick)")
|
|
30
36
|
parser.add_argument(
|
|
31
37
|
"--budgets",
|
|
@@ -49,6 +55,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
49
55
|
problems, default_budgets = ml.ml_problems(), ml.BUDGETS
|
|
50
56
|
elif args.noisy:
|
|
51
57
|
problems, default_budgets = noisy_problems(), NOISY_BUDGETS
|
|
58
|
+
elif args.constrained:
|
|
59
|
+
problems, default_budgets = constrained_problems(), BUDGETS
|
|
60
|
+
elif args.conditional:
|
|
61
|
+
problems, default_budgets = conditional_problems(), BUDGETS
|
|
52
62
|
else:
|
|
53
63
|
problems, default_budgets = standard_problems(), BUDGETS
|
|
54
64
|
seeds = args.seeds or (5 if args.quick else 10)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Conditional test problems, each in two versions: with conditions, and with every parameter switched on.
|
|
2
|
+
|
|
3
|
+
- branches-3x2d and branches-3x5d: a choice of three branches, each a function of its own
|
|
4
|
+
numbers. The best branch is a steep, narrow bowl reaching 0; the decoys are a wide, shallow
|
|
5
|
+
bowl whose floor is 1 and a shallow Rastrigin grid whose floor is 0.5. Random points score
|
|
6
|
+
better in the decoys, so an optimizer has to look past them.
|
|
7
|
+
- nested-9: a choice of model; one model has a choice of kernel, and one kernel has numbers of
|
|
8
|
+
its own, so the best point is two conditions deep. The other model is a wide, shallow decoy
|
|
9
|
+
whose floor is 0.25.
|
|
10
|
+
- layers-7: a layer count from 1 to 5 whose larger values switch on more numbers, as a
|
|
11
|
+
network's units per layer would be. Three layers is best. Its conditions test a range, so
|
|
12
|
+
CMA-ES can search it.
|
|
13
|
+
|
|
14
|
+
The "-flat" version of each has the same parameters, function and optimum, with no conditions:
|
|
15
|
+
every parameter is always on and the function ignores those it doesn't use, which was the way
|
|
16
|
+
to search such a space before conditions. Comparing the two shows what conditions are worth.
|
|
17
|
+
"""
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from hone.parameters import ChoiceParameter, Condition, Parameter, RangeParameter, SearchSpace
|
|
23
|
+
|
|
24
|
+
from .problems import BOUND, Problem, _shift, rastrigin, sphere
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _problem(name: str, function: Callable[..., float], parameters: list[Parameter], conditions: list[Condition]) -> list[Problem]:
|
|
28
|
+
function.__name__ = name.replace("-", "_")
|
|
29
|
+
return [
|
|
30
|
+
Problem(
|
|
31
|
+
name=name,
|
|
32
|
+
function=function,
|
|
33
|
+
search_space=SearchSpace(parameters, conditions=conditions),
|
|
34
|
+
optimum=0.0,
|
|
35
|
+
tags=frozenset({"conditional"}),
|
|
36
|
+
),
|
|
37
|
+
Problem(
|
|
38
|
+
name=f"{name}-flat",
|
|
39
|
+
function=function,
|
|
40
|
+
search_space=SearchSpace(parameters),
|
|
41
|
+
optimum=0.0,
|
|
42
|
+
tags=frozenset({"conditional", "flat"}),
|
|
43
|
+
),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _numbers(prefix: str, count: int) -> list[RangeParameter]:
|
|
48
|
+
return [RangeParameter(f"{prefix}{i}", -BOUND, BOUND) for i in range(count)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _vector(params: dict[str, Any], numbers: list[RangeParameter]) -> np.ndarray:
|
|
52
|
+
return np.array([params[p.name] for p in numbers], dtype=float)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def branches(dimensions: int) -> list[Problem]:
|
|
56
|
+
name = f"branches-3x{dimensions}d"
|
|
57
|
+
branch = ChoiceParameter("branch", ["narrow", "wide", "grid"])
|
|
58
|
+
numbers = {b: _numbers(f"{b}_", dimensions) for b in branch.choices}
|
|
59
|
+
shifts = {b: _shift(f"{name}/{b}", dimensions) for b in branch.choices}
|
|
60
|
+
# (function, scale, floor) for each branch
|
|
61
|
+
bases = {"narrow": (sphere, 4.0, 0.0), "wide": (sphere, 0.05, 1.0), "grid": (rastrigin, 0.1, 0.5)}
|
|
62
|
+
|
|
63
|
+
def function(branch: str, **params: Any) -> float:
|
|
64
|
+
base, scale, floor = bases[branch]
|
|
65
|
+
return floor + scale * float(base(_vector(params, numbers[branch]) - shifts[branch]))
|
|
66
|
+
|
|
67
|
+
parameters: list[Parameter] = [branch, *(p for b in branch.choices for p in numbers[b])]
|
|
68
|
+
return _problem(name, function, parameters, [branch[b] >> numbers[b] for b in branch.choices])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def nested() -> list[Problem]:
|
|
72
|
+
name = "nested-9"
|
|
73
|
+
model = ChoiceParameter("model", ["kernel_machine", "forest"])
|
|
74
|
+
kernel = ChoiceParameter("kernel", ["linear", "rbf"])
|
|
75
|
+
shared, width, forest = _numbers("s", 2), _numbers("g", 2), _numbers("f", 3)
|
|
76
|
+
centres = {key: _shift(f"{name}/{key}", len(group)) for key, group in [("s", shared), ("g", width), ("f", forest)]}
|
|
77
|
+
|
|
78
|
+
def function(model: str, kernel: str | None = None, **params: Any) -> float:
|
|
79
|
+
if model == "forest":
|
|
80
|
+
return 0.25 + 0.05 * float(sphere(_vector(params, forest) - centres["f"]))
|
|
81
|
+
value = float(sphere(_vector(params, shared) - centres["s"]))
|
|
82
|
+
if kernel == "linear":
|
|
83
|
+
return 0.5 + value
|
|
84
|
+
return value + 4 * float(sphere(_vector(params, width) - centres["g"]))
|
|
85
|
+
|
|
86
|
+
parameters: list[Parameter] = [model, kernel, *shared, *width, *forest]
|
|
87
|
+
conditions = [model["kernel_machine"] >> [kernel, *shared], kernel["rbf"] >> width, model["forest"] >> forest]
|
|
88
|
+
return _problem(name, function, parameters, conditions)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def layers() -> list[Problem]:
|
|
92
|
+
name = "layers-7"
|
|
93
|
+
count = RangeParameter("layers", 1, 5, integer=True)
|
|
94
|
+
rate = RangeParameter("rate", -BOUND, BOUND)
|
|
95
|
+
units = _numbers("units_", 5)
|
|
96
|
+
centre = _shift(f"{name}/units", 5)
|
|
97
|
+
best_rate = float(_shift(f"{name}/rate", 1)[0])
|
|
98
|
+
|
|
99
|
+
def function(layers: int, rate: float, **params: Any) -> float:
|
|
100
|
+
used = units[:layers]
|
|
101
|
+
return 0.5 * abs(layers - 3) + float(sphere(_vector(params, used) - centre[:layers])) + (rate - best_rate) ** 2
|
|
102
|
+
|
|
103
|
+
parameters: list[Parameter] = [count, rate, *units]
|
|
104
|
+
return _problem(name, function, parameters, [(count >= i + 1) >> units[i] for i in range(1, 5)])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def conditional_problems() -> list[Problem]:
|
|
108
|
+
"""Each conditional problem, followed by its flat version."""
|
|
109
|
+
return [*branches(2), *branches(5), *nested(), *layers()]
|
|
@@ -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()]
|
hone/benchmarks/ml.py
CHANGED
|
@@ -16,7 +16,7 @@ from typing import Callable
|
|
|
16
16
|
|
|
17
17
|
from threadpoolctl import threadpool_limits
|
|
18
18
|
|
|
19
|
-
from hone.parameters import ChoiceParameter, RangeParameter, SearchSpace
|
|
19
|
+
from hone.parameters import ChoiceParameter, Condition, Parameter, RangeParameter, SearchSpace
|
|
20
20
|
|
|
21
21
|
from .problems import Problem
|
|
22
22
|
|
|
@@ -92,11 +92,56 @@ def random_forest_wine(max_depth: int, min_samples_leaf: int, max_features: floa
|
|
|
92
92
|
return _cross_validate(model, "wine", classification=True)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
def model_selection_breast_cancer(
|
|
96
|
+
model: str,
|
|
97
|
+
C: float | None = None,
|
|
98
|
+
gamma: float | None = None,
|
|
99
|
+
max_depth: int | None = None,
|
|
100
|
+
min_samples_leaf: int | None = None,
|
|
101
|
+
max_features: float | None = None,
|
|
102
|
+
n_neighbors: int | None = None,
|
|
103
|
+
weights: str | None = None,
|
|
104
|
+
) -> float:
|
|
105
|
+
"""One of three classifiers, and its settings, on the 569 breast tumour samples svm-breast-cancer uses.
|
|
106
|
+
|
|
107
|
+
A support vector classifier with an RBF kernel, a 20-tree random forest, or k nearest
|
|
108
|
+
neighbours. With conditions, only the chosen model's settings are searched.
|
|
109
|
+
"""
|
|
110
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
111
|
+
from sklearn.neighbors import KNeighborsClassifier
|
|
112
|
+
from sklearn.pipeline import make_pipeline
|
|
113
|
+
from sklearn.preprocessing import StandardScaler
|
|
114
|
+
from sklearn.svm import SVC
|
|
115
|
+
|
|
116
|
+
if model == "svm":
|
|
117
|
+
estimator = make_pipeline(StandardScaler(), SVC(C=C, gamma=gamma))
|
|
118
|
+
elif model == "forest":
|
|
119
|
+
estimator = RandomForestClassifier(
|
|
120
|
+
n_estimators=20, max_depth=max_depth, min_samples_leaf=min_samples_leaf, max_features=max_features, random_state=0, n_jobs=1
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
estimator = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=n_neighbors, weights=weights))
|
|
124
|
+
return _cross_validate(estimator, "breast_cancer", classification=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_MODEL = ChoiceParameter("model", ["svm", "forest", "knn"])
|
|
128
|
+
_SVM = [RangeParameter("C", 1e-3, 1e3, log=True), RangeParameter("gamma", 1e-5, 10, log=True)]
|
|
129
|
+
_FOREST = [
|
|
130
|
+
RangeParameter("max_depth", 1, 12, integer=True),
|
|
131
|
+
RangeParameter("min_samples_leaf", 1, 20, integer=True),
|
|
132
|
+
RangeParameter("max_features", 0.05, 1.0),
|
|
133
|
+
]
|
|
134
|
+
_KNN: list[Parameter] = [RangeParameter("n_neighbors", 1, 50, integer=True), ChoiceParameter("weights", ["uniform", "distance"])]
|
|
135
|
+
MODEL_SELECTION: list[Parameter] = [_MODEL, *_SVM, *_FOREST, *_KNN]
|
|
136
|
+
MODEL_CONDITIONS: list[Condition] = [_MODEL["svm"] >> _SVM, _MODEL["forest"] >> _FOREST, _MODEL["knn"] >> _KNN]
|
|
137
|
+
|
|
95
138
|
# The best value of each problem found by REFERENCE_SEARCH.
|
|
96
139
|
OPTIMA = {
|
|
97
140
|
"svm-breast-cancer": 0.014066138798323302, # accuracy 98.6%, found by CMA-ES
|
|
98
141
|
"kernel-ridge-diabetes": 0.4961748264794844, # R² 0.504, found by Bayesian optimization
|
|
99
142
|
"random-forest-wine": 0.005555555555555536, # accuracy 99.4%, found by Bayesian optimization
|
|
143
|
+
# Every reference run chose the support vector classifier, and the best matched svm-breast-cancer's.
|
|
144
|
+
"model-selection-breast-cancer": 0.014066138798323302,
|
|
100
145
|
}
|
|
101
146
|
|
|
102
147
|
SPACES: dict[str, tuple[Callable[..., float], list]] = {
|
|
@@ -125,9 +170,30 @@ SPACES: dict[str, tuple[Callable[..., float], list]] = {
|
|
|
125
170
|
|
|
126
171
|
|
|
127
172
|
def ml_problems() -> list[Problem]:
|
|
128
|
-
"""The machine learning tuning problems. Needs scikit-learn.
|
|
173
|
+
"""The machine learning tuning problems. Needs scikit-learn.
|
|
174
|
+
|
|
175
|
+
model-selection-breast-cancer comes twice: with conditions, and as model-selection-breast-cancer-flat
|
|
176
|
+
with every setting always searched, as before conditions, to show what they are worth.
|
|
177
|
+
"""
|
|
129
178
|
_require_sklearn()
|
|
130
|
-
|
|
179
|
+
problems = [
|
|
131
180
|
Problem(name=name, function=function, search_space=SearchSpace(parameters), optimum=OPTIMA[name], tags=frozenset({"ml"}))
|
|
132
181
|
for name, (function, parameters) in SPACES.items()
|
|
133
182
|
]
|
|
183
|
+
optimum = OPTIMA["model-selection-breast-cancer"]
|
|
184
|
+
return problems + [
|
|
185
|
+
Problem(
|
|
186
|
+
name="model-selection-breast-cancer",
|
|
187
|
+
function=model_selection_breast_cancer,
|
|
188
|
+
search_space=SearchSpace(MODEL_SELECTION, conditions=MODEL_CONDITIONS),
|
|
189
|
+
optimum=optimum,
|
|
190
|
+
tags=frozenset({"ml", "conditional"}),
|
|
191
|
+
),
|
|
192
|
+
Problem(
|
|
193
|
+
name="model-selection-breast-cancer-flat",
|
|
194
|
+
function=model_selection_breast_cancer,
|
|
195
|
+
search_space=SearchSpace(MODEL_SELECTION),
|
|
196
|
+
optimum=optimum,
|
|
197
|
+
tags=frozenset({"ml", "conditional", "flat"}),
|
|
198
|
+
),
|
|
199
|
+
]
|
|
@@ -48,8 +48,9 @@ class GeneticOptimizerConfig(OptimizerConfig):
|
|
|
48
48
|
tournament_p: float = Field(default=1.0, ge=0, le=1) # chance a tournament picks its best rather than its worst
|
|
49
49
|
# Share of children bred within niches of parents with the same choice parameter values, so
|
|
50
50
|
# each option keeps tuning its own numbers. Helps problems where the best choice is found
|
|
51
|
-
# late, on large budgets; costs on small ones. 0 turns niches off.
|
|
52
|
-
|
|
51
|
+
# late, on large budgets; costs on small ones. 0 turns niches off. None, the default, means
|
|
52
|
+
# 0.5 for a search space with conditions, where each branch is a niche, and 0 otherwise.
|
|
53
|
+
niche_share: float | None = Field(default=None, ge=0, lt=1)
|
|
53
54
|
|
|
54
55
|
@model_validator(mode="before")
|
|
55
56
|
@classmethod
|
|
@@ -43,5 +43,20 @@ class ObjectiveFunction(BaseModel):
|
|
|
43
43
|
)
|
|
44
44
|
if unknown:
|
|
45
45
|
raise ValueError(f"{self.name}() does not accept parameters: {', '.join(unknown)}")
|
|
46
|
+
# A parameter a condition switches off isn't passed, so the function needs a default for it.
|
|
47
|
+
no_default = sorted(
|
|
48
|
+
name
|
|
49
|
+
for name in names
|
|
50
|
+
if self.search_space.is_conditional(name)
|
|
51
|
+
and name in accepted
|
|
52
|
+
and accepted[name].default is inspect.Parameter.empty
|
|
53
|
+
and accepted[name].kind is not inspect.Parameter.VAR_KEYWORD
|
|
54
|
+
)
|
|
55
|
+
if no_default:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"{self.name}() isn't passed {', '.join(no_default)} at points where a condition switches "
|
|
58
|
+
f"{'it' if len(no_default) == 1 else 'them'} off, so give {'it' if len(no_default) == 1 else 'each'} a default, "
|
|
59
|
+
f"such as {no_default[0]}=None"
|
|
60
|
+
)
|
|
46
61
|
if missing:
|
|
47
62
|
raise ValueError(f"{self.name}() requires parameters missing from the search space: {', '.join(missing)}")
|
hone/optimizers/base.py
CHANGED
|
@@ -74,9 +74,15 @@ class Optimizer(ABC):
|
|
|
74
74
|
config: The optimizer's configuration. Defaults are used if omitted.
|
|
75
75
|
initial_points: Points to evaluate first, such as a known good design or the best
|
|
76
76
|
parameters of an earlier run: one dict of parameter values, or a list of them.
|
|
77
|
-
Fixed parameters may be left out.
|
|
77
|
+
Fixed parameters may be left out, and so must parameters a condition switches off.
|
|
78
|
+
|
|
79
|
+
Every individual an optimizer breeds holds a value for every parameter, including those
|
|
80
|
+
the conditions switch off at its point, so an optimizer can keep them to use again. The
|
|
81
|
+
base class leaves them out of what it hands out and reports: trials, results and progress.
|
|
78
82
|
"""
|
|
79
83
|
config_class: ClassVar[type[OptimizerConfig]] = OptimizerConfig
|
|
84
|
+
supports_constraints: ClassVar[bool] = False # whether it keeps to a search space's constraints
|
|
85
|
+
supports_conditions: ClassVar[bool] = False # whether it handles parameters that conditions switch on and off
|
|
80
86
|
|
|
81
87
|
def __init__(
|
|
82
88
|
self,
|
|
@@ -91,9 +97,19 @@ class Optimizer(ABC):
|
|
|
91
97
|
self.config = config if config is not None else self.config_class()
|
|
92
98
|
if not isinstance(self.config, self.config_class):
|
|
93
99
|
raise TypeError(f"{type(self).__name__} needs a {self.config_class.__name__}, got {type(self.config).__name__}")
|
|
100
|
+
if search_space.constraints and not self.supports_constraints:
|
|
101
|
+
raise UnsupportedSearchSpace(f"{type(self).__name__} doesn't support constraints yet; RandomSearch does")
|
|
102
|
+
if search_space.conditions and not self.supports_conditions:
|
|
103
|
+
raise UnsupportedSearchSpace(f"{type(self).__name__} doesn't support conditional parameters yet; RandomSearch does")
|
|
94
104
|
if isinstance(initial_points, dict):
|
|
95
105
|
initial_points = [initial_points]
|
|
96
106
|
self.initial_points = [search_space.check(point) for point in initial_points or []]
|
|
107
|
+
for point in self.initial_points:
|
|
108
|
+
if broken := search_space.broken(point):
|
|
109
|
+
raise ValueError(f"the initial point {point} breaks the constraint `{broken[0]}`")
|
|
110
|
+
if search_space.constraints and not self.initial_points:
|
|
111
|
+
# Fail now, before any evaluation, if the constraints leave nothing to search.
|
|
112
|
+
search_space.sample(np.random.default_rng(0))
|
|
97
113
|
self._workers = 1 # how many evaluations run at once; optimizers may size their population from it
|
|
98
114
|
self.reset()
|
|
99
115
|
|
|
@@ -168,13 +184,20 @@ class Optimizer(ABC):
|
|
|
168
184
|
reissued = [outstanding[i] for i in self._reissue if i in outstanding][:n]
|
|
169
185
|
self._reissue = [i for i in self._reissue if i in outstanding and i not in {ind.id for ind in reissued}]
|
|
170
186
|
if self._done:
|
|
171
|
-
return [
|
|
187
|
+
return [self._trial(ind) for ind in reissued]
|
|
172
188
|
ready = [ind for ind_id, ind in self._waiting.items() if ind_id not in self._handed_out]
|
|
173
189
|
limits = [None if n is None else n - len(reissued), self._budget_left()]
|
|
174
190
|
ready = ready[: min((limit for limit in limits if limit is not None), default=len(ready))]
|
|
175
191
|
self._handed_out.update(ind.id for ind in ready)
|
|
176
192
|
self._handed_out_total += len(ready)
|
|
177
|
-
return [
|
|
193
|
+
return [self._trial(ind) for ind in reissued + ready]
|
|
194
|
+
|
|
195
|
+
def _trial(self, individual: Individual) -> Trial:
|
|
196
|
+
return Trial(id=individual.id, params=self.search_space.active(individual.params), generation=individual.generation)
|
|
197
|
+
|
|
198
|
+
def _key(self, params: dict[str, Any]) -> tuple:
|
|
199
|
+
"""A hashable form of the point params are at: two that differ only where conditions switch parameters off share it."""
|
|
200
|
+
return _key(self.search_space.active(params))
|
|
178
201
|
|
|
179
202
|
def tell(self, trial: Trial | int, value: float | None = None, *, failed: bool = False) -> None:
|
|
180
203
|
"""Report the outcome of a trial from ask(), by the trial or its id.
|
|
@@ -206,7 +229,7 @@ class Optimizer(ABC):
|
|
|
206
229
|
individual.fitness = float(value)
|
|
207
230
|
if self.config.noise:
|
|
208
231
|
# Average every evaluation of the same point, so a lucky value regresses toward its mean.
|
|
209
|
-
key = _key(individual.params)
|
|
232
|
+
key = self._key(individual.params)
|
|
210
233
|
self._observations.setdefault(key, []).append(float(value))
|
|
211
234
|
self._points.setdefault(key, dict(individual.params))
|
|
212
235
|
individual.fitness = float(np.mean(self._observations[key]))
|
|
@@ -262,8 +285,8 @@ class Optimizer(ABC):
|
|
|
262
285
|
# Once the finalists have been re-evaluated, the one with the best average; before
|
|
263
286
|
# that, the most promising point by the optimizer's best estimate.
|
|
264
287
|
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
|
|
288
|
+
return self._average(key), self.search_space.active(self._points[key])
|
|
289
|
+
return _fitness(self._best), self.search_space.active(self._best.params)
|
|
267
290
|
|
|
268
291
|
def _run_info(self) -> RunInfo:
|
|
269
292
|
return RunInfo(
|
|
@@ -297,7 +320,9 @@ class Optimizer(ABC):
|
|
|
297
320
|
|
|
298
321
|
The file is replaced in one step, so a crash while saving leaves the previous checkpoint intact.
|
|
299
322
|
"""
|
|
300
|
-
|
|
323
|
+
# The search space is left out: the loading optimizer has its own, which must match, and a
|
|
324
|
+
# constraint function may not be picklable.
|
|
325
|
+
state = {name: value for name, value in self.__dict__.items() if name != "search_space"}
|
|
301
326
|
elapsed = self._elapsed() if self._start_clock is not None else None
|
|
302
327
|
state.update(_start_clock=None, _end_clock=None, _workers=1)
|
|
303
328
|
checkpoint = {
|
|
@@ -527,7 +552,7 @@ class Optimizer(ABC):
|
|
|
527
552
|
"""Set each successful individual's fitness to the current average of every evaluation of its point."""
|
|
528
553
|
for individual in individuals:
|
|
529
554
|
if not individual.failed and individual.fitness is not None:
|
|
530
|
-
individual.fitness = self._average(_key(individual.params))
|
|
555
|
+
individual.fitness = self._average(self._key(individual.params))
|
|
531
556
|
|
|
532
557
|
def _final_population(self, generation: int) -> list[Individual]:
|
|
533
558
|
"""The final round with noise: the most promising points, each evaluated again.
|
|
@@ -550,7 +575,14 @@ class Optimizer(ABC):
|
|
|
550
575
|
return list(self._late.values())
|
|
551
576
|
|
|
552
577
|
def _initial_individuals(self, generation: int) -> list[Individual]:
|
|
553
|
-
|
|
578
|
+
"""The initial points as individuals, with a random value for each parameter a condition switches off."""
|
|
579
|
+
return [
|
|
580
|
+
Individual(
|
|
581
|
+
params={p.name: point[p.name] if p.name in point else p.sample(self.rng) for p in self.search_space.parameters},
|
|
582
|
+
generation=generation,
|
|
583
|
+
)
|
|
584
|
+
for point in self.initial_points
|
|
585
|
+
]
|
|
554
586
|
|
|
555
587
|
def _random_individual(self, generation: int) -> Individual:
|
|
556
588
|
return Individual(params=self.search_space.sample(self.rng), generation=generation)
|
|
@@ -581,7 +613,7 @@ class Optimizer(ABC):
|
|
|
581
613
|
evaluations=evaluations,
|
|
582
614
|
failures=failures,
|
|
583
615
|
best_fitness=succeeded[0].fitness,
|
|
584
|
-
best_params=
|
|
616
|
+
best_params=self.search_space.active(succeeded[0].params),
|
|
585
617
|
worst_fitness=succeeded[-1].fitness,
|
|
586
618
|
mean=float(np.mean(fitnesses)),
|
|
587
619
|
median=float(np.median(fitnesses)),
|