hone-python 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hone/__init__.py +40 -0
- hone/benchmarks/__init__.py +17 -0
- hone/benchmarks/__main__.py +84 -0
- hone/benchmarks/ml.py +133 -0
- hone/benchmarks/problems.py +236 -0
- hone/benchmarks/runner.py +295 -0
- hone/config/__init__.py +4 -0
- hone/config/bayesian_config.py +23 -0
- hone/config/cmaes_config.py +11 -0
- hone/config/genetic_optimizer_config.py +74 -0
- hone/config/optimizer_config.py +48 -0
- hone/objective_function/__init__.py +1 -0
- hone/objective_function/objective_function.py +47 -0
- hone/optimizers/__init__.py +6 -0
- hone/optimizers/_gp.py +134 -0
- hone/optimizers/_pool.py +161 -0
- hone/optimizers/base.py +619 -0
- hone/optimizers/bayesian.py +265 -0
- hone/optimizers/choice.py +44 -0
- hone/optimizers/cmaes.py +232 -0
- hone/optimizers/genetic/__init__.py +1 -0
- hone/optimizers/genetic/optimizer.py +184 -0
- hone/optimizers/genetic/selection.py +28 -0
- hone/optimizers/genetic/variation.py +47 -0
- hone/optimizers/random_search.py +13 -0
- hone/parameters/__init__.py +1 -0
- hone/parameters/parameters.py +237 -0
- hone/py.typed +0 -0
- hone/result.py +118 -0
- hone_python-0.9.0.dist-info/METADATA +339 -0
- hone_python-0.9.0.dist-info/RECORD +33 -0
- hone_python-0.9.0.dist-info/WHEEL +4 -0
- hone_python-0.9.0.dist-info/licenses/LICENSE +28 -0
hone/__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]
|