hone-python 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hone/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ from hone.config import BayesianConfig, CMAESConfig, GeneticOptimizerConfig, OptimizerConfig, SelectionMethod
2
+ from hone.optimizers import (
3
+ CMAES,
4
+ BayesianOptimizer,
5
+ EvaluationFailed,
6
+ GeneticOptimizer,
7
+ Optimizer,
8
+ RandomSearch,
9
+ Trial,
10
+ UnsupportedSearchSpace,
11
+ choose_optimizer,
12
+ )
13
+ from hone.parameters import ChoiceParameter, FixedParameter, RangeParameter, SearchSpace
14
+ from hone.result import GenerationStats, Progress, Result, RunInfo, print_progress
15
+
16
+ __all__ = [
17
+ "BayesianConfig",
18
+ "BayesianOptimizer",
19
+ "CMAES",
20
+ "CMAESConfig",
21
+ "ChoiceParameter",
22
+ "EvaluationFailed",
23
+ "FixedParameter",
24
+ "GenerationStats",
25
+ "GeneticOptimizer",
26
+ "GeneticOptimizerConfig",
27
+ "Optimizer",
28
+ "OptimizerConfig",
29
+ "Progress",
30
+ "RandomSearch",
31
+ "RangeParameter",
32
+ "Result",
33
+ "RunInfo",
34
+ "SearchSpace",
35
+ "SelectionMethod",
36
+ "Trial",
37
+ "UnsupportedSearchSpace",
38
+ "choose_optimizer",
39
+ "print_progress",
40
+ ]
@@ -0,0 +1,17 @@
1
+ """Benchmark problems and a runner for comparing optimizers. Run `python -m hone.benchmarks --help`."""
2
+ from .ml import ml_problems
3
+ from .problems import Problem, noisy_problems, quick_problems, standard_problems
4
+ from .runner import (
5
+ BUDGETS,
6
+ DEFAULT_OPTIMIZERS,
7
+ Run,
8
+ SkipBudget,
9
+ Summary,
10
+ load_summaries,
11
+ regressions,
12
+ run_benchmark,
13
+ save_summaries,
14
+ summarize,
15
+ to_markdown,
16
+ wins,
17
+ )
@@ -0,0 +1,84 @@
1
+ """Command-line entry point: `python -m hone.benchmarks`."""
2
+ import argparse
3
+ import sys
4
+ import time
5
+ from collections.abc import Sequence
6
+ from importlib.metadata import version
7
+
8
+ from . import ml
9
+ from .problems import noisy_problems, quick_problems, standard_problems
10
+ from .runner import BUDGETS, load_summaries, regressions, run_benchmark, save_summaries, summarize, to_markdown
11
+
12
+
13
+ QUICK_BUDGETS = (10, 50, 200)
14
+ NOISY_BUDGETS = (10, 40, 200)
15
+
16
+
17
+ def _join(numbers: list[int]) -> str:
18
+ """[10, 20, 50] -> "10, 20 and 50"."""
19
+ words = [f"{n:,}" for n in numbers]
20
+ return words[0] if len(words) == 1 else ", ".join(words[:-1]) + " and " + words[-1]
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ parser = argparse.ArgumentParser(prog="python -m hone.benchmarks", description=__doc__)
25
+ suite = parser.add_mutually_exclusive_group()
26
+ suite.add_argument("--quick", action="store_true", help="run the small CI subset with fewer seeds and evaluations")
27
+ suite.add_argument("--ml", action="store_true", help="run the machine learning tuning problems instead (needs scikit-learn)")
28
+ suite.add_argument("--noisy", action="store_true", help="run the problems with noise added to every evaluation instead")
29
+ parser.add_argument("--seeds", type=int, help="number of seeds per problem (default 10, or 5 with --quick)")
30
+ parser.add_argument(
31
+ "--budgets",
32
+ type=int,
33
+ nargs="+",
34
+ help=f"evaluations per dimension, one run per budget (default {' '.join(map(str, BUDGETS))}, "
35
+ f"{' '.join(map(str, QUICK_BUDGETS))} with --quick, {' '.join(map(str, ml.BUDGETS))} with --ml, "
36
+ f"or {' '.join(map(str, NOISY_BUDGETS))} with --noisy)",
37
+ )
38
+ parser.add_argument("--jobs", type=int, default=1, help="share the runs among this many processes (Linux only)")
39
+ parser.add_argument("--tolerance", type=float, default=1e-4, help="error at or below which a run counts as solved")
40
+ parser.add_argument("--markdown", help="write the results table to this file")
41
+ parser.add_argument("--save", help="write the summaries as JSON to this file, for use as a baseline")
42
+ parser.add_argument("--baseline", help="compare against summaries saved with --save; exit 1 on a regression")
43
+ args = parser.parse_args(argv)
44
+
45
+ default_budgets: Sequence[int]
46
+ if args.quick:
47
+ problems, default_budgets = quick_problems(), QUICK_BUDGETS
48
+ elif args.ml:
49
+ problems, default_budgets = ml.ml_problems(), ml.BUDGETS
50
+ elif args.noisy:
51
+ problems, default_budgets = noisy_problems(), NOISY_BUDGETS
52
+ else:
53
+ problems, default_budgets = standard_problems(), BUDGETS
54
+ seeds = args.seeds or (5 if args.quick else 10)
55
+ budgets = sorted(set(args.budgets or default_budgets))
56
+
57
+ start = time.perf_counter()
58
+ summaries = summarize(run_benchmark(problems, seeds=range(seeds), budgets_per_dimension=budgets, jobs=args.jobs), args.tolerance)
59
+ elapsed = time.perf_counter() - start
60
+
61
+ table = to_markdown(summaries, args.tolerance)
62
+ print(table)
63
+ setup = f"{len(problems)} problems, {seeds} seeds, budgets of {_join(budgets)} evaluations per dimension"
64
+ print(f"{setup}, in {elapsed:.0f}s")
65
+ if args.markdown:
66
+ with open(args.markdown, "w") as f:
67
+ f.write(
68
+ f"# Benchmark results\n\nGenerated by `python -m hone.benchmarks` with hone {version('hone-python')}: "
69
+ f"{setup}.\n\n{table}"
70
+ )
71
+ if args.save:
72
+ save_summaries(summaries, args.save)
73
+
74
+ if args.baseline:
75
+ found = regressions(summaries, load_summaries(args.baseline))
76
+ if found:
77
+ print("\nRegressions against the baseline:", *found, sep="\n ")
78
+ return 1
79
+ print("\nNo regressions against the baseline.")
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
hone/benchmarks/ml.py ADDED
@@ -0,0 +1,133 @@
1
+ """Machine learning tuning problems: choosing a model's hyperparameters by cross-validation.
2
+
3
+ Each problem trains a scikit-learn model on one of its bundled datasets and scores it by
4
+ 5-fold cross-validation with fixed folds, so every evaluation is a real training run and the
5
+ same settings always give the same score. Each problem minimizes 1 − score: 1 − accuracy for
6
+ classification, 1 − R² for regression.
7
+
8
+ The true optimum is unknown, so each problem's optimum is the best value a long reference
9
+ search found (see REFERENCE_SEARCH), and error is measured from there. Settings that span
10
+ orders of magnitude are log-scale ranges.
11
+
12
+ These problems need scikit-learn, which hone itself doesn't depend on: `pip install scikit-learn`.
13
+ """
14
+ from functools import cache
15
+ from typing import Callable
16
+
17
+ from threadpoolctl import threadpool_limits
18
+
19
+ from hone.parameters import ChoiceParameter, RangeParameter, SearchSpace
20
+
21
+ from .problems import Problem
22
+
23
+ # How each problem's optimum was found: the best of these runs, each with seeds 0 to 2.
24
+ REFERENCE_SEARCH = (
25
+ "BayesianOptimizer with 200 evaluations, and CMA-ES (where it applies), the genetic optimizer "
26
+ "and random search with 1,000"
27
+ )
28
+
29
+ # Model tuning is done on small budgets, so these problems run at these budgets only.
30
+ BUDGETS = (10, 20, 50)
31
+
32
+
33
+ def _require_sklearn() -> None:
34
+ try:
35
+ import sklearn # noqa: F401
36
+ except ImportError as error:
37
+ raise ImportError("the machine learning problems need scikit-learn: pip install scikit-learn") from error
38
+
39
+
40
+ @cache
41
+ def _data(name: str):
42
+ from sklearn import datasets
43
+
44
+ return getattr(datasets, f"load_{name}")(return_X_y=True)
45
+
46
+
47
+ def _cross_validate(model, dataset: str, classification: bool) -> float:
48
+ """1 − the mean cross-validated score: accuracy for a classifier, R² for a regressor."""
49
+ from sklearn.model_selection import KFold, StratifiedKFold, cross_val_score
50
+
51
+ X, y = _data(dataset)
52
+ folds = (StratifiedKFold if classification else KFold)(5, shuffle=True, random_state=0)
53
+ # The datasets are small, and threads only add overhead.
54
+ with threadpool_limits(limits=1):
55
+ score = cross_val_score(model, X, y, cv=folds, scoring="accuracy" if classification else "r2").mean()
56
+ return 1.0 - float(score)
57
+
58
+
59
+ def svm_breast_cancer(C: float, gamma: float) -> float:
60
+ """A support vector classifier with an RBF kernel, on 569 breast tumour samples with 30 features."""
61
+ from sklearn.pipeline import make_pipeline
62
+ from sklearn.preprocessing import StandardScaler
63
+ from sklearn.svm import SVC
64
+
65
+ model = make_pipeline(StandardScaler(), SVC(C=C, gamma=gamma))
66
+ return _cross_validate(model, "breast_cancer", classification=True)
67
+
68
+
69
+ def kernel_ridge_diabetes(alpha: float, gamma: float, kernel: str) -> float:
70
+ """Kernel ridge regression of disease progression from 10 measurements, on 442 diabetes patients."""
71
+ from sklearn.kernel_ridge import KernelRidge
72
+ from sklearn.pipeline import make_pipeline
73
+ from sklearn.preprocessing import StandardScaler
74
+
75
+ model = make_pipeline(StandardScaler(), KernelRidge(alpha=alpha, gamma=gamma, kernel=kernel))
76
+ return _cross_validate(model, "diabetes", classification=False)
77
+
78
+
79
+ def random_forest_wine(max_depth: int, min_samples_leaf: int, max_features: float, criterion: str) -> float:
80
+ """A 20-tree random forest classifying 178 wines into 3 cultivars from 13 chemical measurements."""
81
+ from sklearn.ensemble import RandomForestClassifier
82
+
83
+ model = RandomForestClassifier(
84
+ n_estimators=20,
85
+ max_depth=max_depth,
86
+ min_samples_leaf=min_samples_leaf,
87
+ max_features=max_features,
88
+ criterion=criterion,
89
+ random_state=0,
90
+ n_jobs=1,
91
+ )
92
+ return _cross_validate(model, "wine", classification=True)
93
+
94
+
95
+ # The best value of each problem found by REFERENCE_SEARCH.
96
+ OPTIMA = {
97
+ "svm-breast-cancer": 0.014066138798323302, # accuracy 98.6%, found by CMA-ES
98
+ "kernel-ridge-diabetes": 0.4961748264794844, # R² 0.504, found by Bayesian optimization
99
+ "random-forest-wine": 0.005555555555555536, # accuracy 99.4%, found by Bayesian optimization
100
+ }
101
+
102
+ SPACES: dict[str, tuple[Callable[..., float], list]] = {
103
+ "svm-breast-cancer": (
104
+ svm_breast_cancer,
105
+ [RangeParameter("C", 1e-3, 1e3, log=True), RangeParameter("gamma", 1e-5, 10, log=True)],
106
+ ),
107
+ "kernel-ridge-diabetes": (
108
+ kernel_ridge_diabetes,
109
+ [
110
+ RangeParameter("alpha", 1e-4, 100, log=True),
111
+ RangeParameter("gamma", 1e-5, 10, log=True),
112
+ ChoiceParameter("kernel", ["rbf", "laplacian"]),
113
+ ],
114
+ ),
115
+ "random-forest-wine": (
116
+ random_forest_wine,
117
+ [
118
+ RangeParameter("max_depth", 1, 12, integer=True),
119
+ RangeParameter("min_samples_leaf", 1, 20, integer=True),
120
+ RangeParameter("max_features", 0.05, 1.0),
121
+ ChoiceParameter("criterion", ["gini", "entropy"]),
122
+ ],
123
+ ),
124
+ }
125
+
126
+
127
+ def ml_problems() -> list[Problem]:
128
+ """The machine learning tuning problems. Needs scikit-learn."""
129
+ _require_sklearn()
130
+ return [
131
+ Problem(name=name, function=function, search_space=SearchSpace(parameters), optimum=OPTIMA[name], tags=frozenset({"ml"}))
132
+ for name, (function, parameters) in SPACES.items()
133
+ ]
@@ -0,0 +1,236 @@
1
+ """Test problems with known optima.
2
+
3
+ The continuous functions follow the conventions of the BBOB benchmark: every variable lies
4
+ in [-5, 5], and each function is shifted so its optimum sits at a fixed, problem-specific
5
+ point away from the centre of the box. Optimizers that drift toward the centre would
6
+ otherwise look better than they are.
7
+
8
+ Each continuous function except sphere also comes in a rotated version, which turns the
9
+ coordinate axes by a fixed random rotation. The plain versions are separable: every
10
+ variable affects the value independently, so an optimizer can improve one variable at a
11
+ time, and crossover that swaps whole variables between parents does unusually well. In
12
+ the rotated versions the variables interact, as they usually do in engineering problems.
13
+ """
14
+ import math
15
+ import zlib
16
+ from typing import Any, Callable
17
+
18
+ import numpy as np
19
+ from pydantic import BaseModel, ConfigDict
20
+
21
+ from hone.parameters import ChoiceParameter, Parameter, RangeParameter, SearchSpace
22
+
23
+ BOUND = 5.0
24
+
25
+
26
+ class Problem(BaseModel):
27
+ """An objective function, its search space, and the lowest value it can take."""
28
+ model_config = ConfigDict(frozen=True)
29
+
30
+ name: str
31
+ function: Callable[..., float]
32
+ search_space: SearchSpace
33
+ optimum: float
34
+ tags: frozenset[str]
35
+ noise: float = 0.0 # standard deviation of Gaussian noise the benchmark adds to each evaluation
36
+
37
+ @property
38
+ def dimensions(self) -> int:
39
+ return len(self.search_space.parameters)
40
+
41
+
42
+ def _shift(name: str, dimensions: int, integer: bool = False) -> np.ndarray:
43
+ """A fixed optimum location for the named problem, within 60% of the bounds."""
44
+ rng = np.random.default_rng(zlib.crc32(name.encode()))
45
+ shift = rng.uniform(-0.6 * BOUND, 0.6 * BOUND, size=dimensions)
46
+ return np.round(shift) if integer else shift
47
+
48
+
49
+ def _rotation(name: str, dimensions: int) -> np.ndarray:
50
+ """A fixed random rotation for the named problem: an orthogonal matrix with determinant 1."""
51
+ rng = np.random.default_rng(zlib.crc32(f"{name}/rotation".encode()))
52
+ q, r = np.linalg.qr(rng.standard_normal((dimensions, dimensions)))
53
+ q = q * np.sign(np.diag(r)) # makes the rotation uniformly random
54
+ if np.linalg.det(q) < 0:
55
+ q[:, 0] = -q[:, 0]
56
+ return q
57
+
58
+
59
+ def _vector_problem(
60
+ name: str,
61
+ base: Callable[[np.ndarray], float],
62
+ dimensions: int,
63
+ parameters: list[Parameter] | None = None,
64
+ integer: bool = False,
65
+ rotate: bool = False,
66
+ tags: frozenset[str] = frozenset({"continuous"}),
67
+ ) -> Problem:
68
+ """Wrap a function of a vector z, minimal at z = 0, as a shifted problem over x0..x{n-1}.
69
+
70
+ With rotate, z is the shifted point turned by a fixed rotation, so the variables interact.
71
+ """
72
+ names = [f"x{i}" for i in range(dimensions)]
73
+ shift = _shift(name, dimensions, integer)
74
+ rotation = _rotation(name, dimensions) if rotate else np.eye(dimensions)
75
+
76
+ def function(**params: Any) -> float:
77
+ x = np.array([params[n] for n in names], dtype=float)
78
+ return float(base(rotation @ (x - shift)))
79
+
80
+ function.__name__ = name.replace("-", "_")
81
+ if parameters is None:
82
+ parameters = [RangeParameter(n, -BOUND, BOUND, integer=integer) for n in names]
83
+ return Problem(name=name, function=function, search_space=SearchSpace(parameters), optimum=0.0, tags=tags)
84
+
85
+
86
+ # --- base functions, each with minimum 0 at z = 0 ---------------------------
87
+
88
+
89
+ def sphere(z: np.ndarray) -> float:
90
+ return np.sum(z**2)
91
+
92
+
93
+ def ellipsoid(z: np.ndarray) -> float:
94
+ """Sphere with axis weights from 1 to 1e6: tests how an optimizer handles badly scaled variables."""
95
+ weights = 10 ** (6 * np.arange(len(z)) / max(len(z) - 1, 1))
96
+ return np.sum(weights * z**2)
97
+
98
+
99
+ def rosenbrock(z: np.ndarray) -> float:
100
+ """A long, curved valley. The usual form has its minimum at 1, so z is offset by 1."""
101
+ x = z + 1
102
+ return np.sum(100 * (x[1:] - x[:-1] ** 2) ** 2 + (1 - x[:-1]) ** 2)
103
+
104
+
105
+ def rastrigin(z: np.ndarray) -> float:
106
+ """A regular grid of local minima around one global minimum."""
107
+ return 10 * len(z) + np.sum(z**2 - 10 * np.cos(2 * np.pi * z))
108
+
109
+
110
+ def ackley(z: np.ndarray) -> float:
111
+ """A nearly flat outer region with a deep, narrow hole at the minimum."""
112
+ n = len(z)
113
+ return (
114
+ -20 * np.exp(-0.2 * np.sqrt(np.sum(z**2) / n))
115
+ - np.exp(np.sum(np.cos(2 * np.pi * z)) / n)
116
+ + 20
117
+ + math.e
118
+ )
119
+
120
+
121
+ CONTINUOUS = {
122
+ "sphere": sphere,
123
+ "ellipsoid": ellipsoid,
124
+ "rosenbrock": rosenbrock,
125
+ "rastrigin": rastrigin,
126
+ "ackley": ackley,
127
+ }
128
+
129
+ # Sphere is the same under any rotation, so it has no rotated version.
130
+ ROTATED = {name: base for name, base in CONTINUOUS.items() if name != "sphere"}
131
+
132
+
133
+ def rotated(name: str, dimensions: int) -> Problem:
134
+ return _vector_problem(
135
+ f"rotated-{name}-{dimensions}d", ROTATED[name], dimensions, rotate=True, tags=frozenset({"continuous", "rotated"})
136
+ )
137
+
138
+
139
+ # --- mixed problems --------------------------------------------------------
140
+
141
+
142
+ def integer_sphere(dimensions: int) -> Problem:
143
+ """Sphere over integer variables only; the optimum is at an integer point."""
144
+ return _vector_problem(
145
+ f"integer-sphere-{dimensions}d", sphere, dimensions, integer=True, tags=frozenset({"integer"})
146
+ )
147
+
148
+
149
+ def mixed_rastrigin(dimensions: int) -> Problem:
150
+ """Rastrigin with the first half of the variables continuous and the rest integer."""
151
+ name = f"mixed-rastrigin-{dimensions}d"
152
+ n_int = dimensions // 2
153
+ parameters = [
154
+ RangeParameter(f"x{i}", -BOUND, BOUND, integer=i >= dimensions - n_int) for i in range(dimensions)
155
+ ]
156
+ shift = _shift(name, dimensions)
157
+ shift[dimensions - n_int :] = np.round(shift[dimensions - n_int :])
158
+ names = [p.name for p in parameters]
159
+
160
+ def function(**params: Any) -> float:
161
+ x = np.array([params[n] for n in names], dtype=float)
162
+ return float(rastrigin(x - shift))
163
+
164
+ function.__name__ = name.replace("-", "_")
165
+ return Problem(name=name, function=function, search_space=SearchSpace(parameters), optimum=0.0, tags=frozenset({"mixed"}))
166
+
167
+
168
+ MATERIALS = {
169
+ # A synthetic material choice: each material has its own best design point and a base cost.
170
+ "steel": 3.0,
171
+ "aluminium": 1.0,
172
+ "titanium": 0.0,
173
+ "composite": 2.0,
174
+ }
175
+
176
+
177
+ def material_selection(dimensions: int) -> Problem:
178
+ """A categorical choice that changes where the best continuous design lies.
179
+
180
+ Each material moves the optimum of the continuous variables and adds its own base cost,
181
+ so the optimizer has to search both together. Titanium, at cost 0, gives the global optimum.
182
+ """
183
+ name = f"material-selection-{dimensions}d"
184
+ rng = np.random.default_rng(zlib.crc32(name.encode()))
185
+ targets = {m: rng.uniform(-0.6 * BOUND, 0.6 * BOUND, size=dimensions) for m in MATERIALS}
186
+ names = [f"x{i}" for i in range(dimensions)]
187
+ parameters: list[Parameter] = [ChoiceParameter("material", list(MATERIALS))]
188
+ parameters += [RangeParameter(n, -BOUND, BOUND) for n in names]
189
+
190
+ def function(material: str, **params: Any) -> float:
191
+ x = np.array([params[n] for n in names], dtype=float)
192
+ return float(MATERIALS[material] + sphere(x - targets[material]))
193
+
194
+ function.__name__ = name.replace("-", "_")
195
+ return Problem(name=name, function=function, search_space=SearchSpace(parameters), optimum=0.0, tags=frozenset({"mixed", "categorical"}))
196
+
197
+
198
+ def standard_problems(dimensions: tuple[int, ...] = (2, 5, 10)) -> list[Problem]:
199
+ """The full suite: every continuous function, plain and rotated, and every mixed problem, in each dimension."""
200
+ problems = [
201
+ _vector_problem(f"{name}-{d}d", base, d) for d in dimensions for name, base in CONTINUOUS.items()
202
+ ]
203
+ problems += [rotated(name, d) for d in dimensions for name in ROTATED]
204
+ for d in dimensions:
205
+ problems += [integer_sphere(d), mixed_rastrigin(d), material_selection(d)]
206
+ return problems
207
+
208
+
209
+ def quick_problems() -> list[Problem]:
210
+ """A small subset for CI: one dimension, and a problem of each kind."""
211
+ return [
212
+ _vector_problem("sphere-5d", sphere, 5),
213
+ _vector_problem("ellipsoid-5d", ellipsoid, 5),
214
+ _vector_problem("rastrigin-5d", rastrigin, 5),
215
+ rotated("rastrigin", 5),
216
+ integer_sphere(5),
217
+ material_selection(5),
218
+ ]
219
+
220
+
221
+ # Noise the noisy suite adds to each evaluation, as a standard deviation. The problems' values
222
+ # near their optima are a few units, so this hides the differences an optimizer has to find.
223
+ NOISE = 1.0
224
+
225
+
226
+ def noisy_problems() -> list[Problem]:
227
+ """Problems with Gaussian noise added to every evaluation, scored by the true value of the result."""
228
+ plain = [
229
+ _vector_problem("sphere-5d", sphere, 5),
230
+ _vector_problem("rosenbrock-5d", rosenbrock, 5),
231
+ _vector_problem("rastrigin-5d", rastrigin, 5),
232
+ rotated("ackley", 5),
233
+ mixed_rastrigin(5),
234
+ material_selection(5),
235
+ ]
236
+ return [p.model_copy(update={"name": f"noisy-{p.name}", "noise": NOISE, "tags": p.tags | {"noisy"}}) for p in plain]