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.
Files changed (35) hide show
  1. {hone_python-0.9.0 → hone_python-0.10.0}/PKG-INFO +31 -1
  2. {hone_python-0.9.0 → hone_python-0.10.0}/README.md +30 -0
  3. {hone_python-0.9.0 → hone_python-0.10.0}/hone/__init__.py +3 -1
  4. {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/__init__.py +1 -0
  5. {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/__main__.py +4 -0
  6. hone_python-0.10.0/hone/benchmarks/constrained.py +172 -0
  7. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/base.py +12 -1
  8. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/bayesian.py +29 -8
  9. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/cmaes.py +23 -3
  10. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/genetic/optimizer.py +44 -2
  11. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/random_search.py +1 -0
  12. {hone_python-0.9.0 → hone_python-0.10.0}/hone/parameters/__init__.py +1 -0
  13. hone_python-0.10.0/hone/parameters/constraints.py +318 -0
  14. {hone_python-0.9.0 → hone_python-0.10.0}/hone/parameters/parameters.py +74 -6
  15. {hone_python-0.9.0 → hone_python-0.10.0}/hone/result.py +8 -3
  16. {hone_python-0.9.0 → hone_python-0.10.0}/pyproject.toml +1 -1
  17. {hone_python-0.9.0 → hone_python-0.10.0}/LICENSE +0 -0
  18. {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/ml.py +0 -0
  19. {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/problems.py +0 -0
  20. {hone_python-0.9.0 → hone_python-0.10.0}/hone/benchmarks/runner.py +0 -0
  21. {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/__init__.py +0 -0
  22. {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/bayesian_config.py +0 -0
  23. {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/cmaes_config.py +0 -0
  24. {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/genetic_optimizer_config.py +0 -0
  25. {hone_python-0.9.0 → hone_python-0.10.0}/hone/config/optimizer_config.py +0 -0
  26. {hone_python-0.9.0 → hone_python-0.10.0}/hone/objective_function/__init__.py +0 -0
  27. {hone_python-0.9.0 → hone_python-0.10.0}/hone/objective_function/objective_function.py +0 -0
  28. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/__init__.py +0 -0
  29. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/_gp.py +0 -0
  30. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/_pool.py +0 -0
  31. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/choice.py +0 -0
  32. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/genetic/__init__.py +0 -0
  33. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/genetic/selection.py +0 -0
  34. {hone_python-0.9.0 → hone_python-0.10.0}/hone/optimizers/genetic/variation.py +0 -0
  35. {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.9.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
- state = dict(self.__dict__)
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._encoding.spread(self.rng, max(count - len(initial), 0)):
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._encoding.spread(self.rng, 1)[0]
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._encoding.spread(self.rng, count))
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 encoding.spread(self.rng, 1)[0] # every candidate was already evaluated
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.search_space = search_space
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.search_space.names}
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 _sample(self, generation: int) -> Individual:
142
+ def _draw(self) -> np.ndarray:
141
143
  z = self.rng.standard_normal(len(self._mean))
142
- x = _mirror(self._mean + self._sigma * self._widen * (self._B @ (self._D * z)))
143
- individual = Individual(params=self._decode(x), generation=generation)
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 []
@@ -1 +1,2 @@
1
+ from .constraints import Constraint, InfeasibleSearchSpace
1
2
  from .parameters import ChoiceParameter, FixedParameter, Parameter, RangeParameter, SearchSpace
@@ -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
- return {parameter.name: parameter.sample(rng) for parameter in self.parameters}
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(self, optimizer_class: "type[Optimizer] | None" = None) -> "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))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hone-python"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "A python-based optimization engine/library."
5
5
  authors = [{ name = "David Beare", email = "rnbeare@gmail.com" }]
6
6
  readme = "README.md"
File without changes
File without changes