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.
@@ -0,0 +1,184 @@
1
+ import math
2
+ from functools import partial
3
+ from typing import Callable
4
+
5
+ from hone.config import GeneticOptimizerConfig, SelectionMethod
6
+ from hone.optimizers.base import Individual, Optimizer, _fitness, _key
7
+ from hone.parameters import ChoiceParameter, FixedParameter, RangeParameter
8
+
9
+ from . import selection
10
+ from .variation import blend_crossover, mutate, uniform_crossover
11
+
12
+
13
+ # Adaptive mutation: how much a child's mutation size varies from its parents', divided by the
14
+ # square root of the number of variables, and the bounds on the size. Chosen on the benchmark.
15
+ STEP_SPREAD = 0.5
16
+ MIN_STEP = 1e-9
17
+ MAX_STEP = 0.5
18
+ # A child that repeats a point already asked for is mutated again, one gene at a time, up to this many times.
19
+ REPEAT_TRIES = 10
20
+
21
+
22
+ class GeneticOptimizer(Optimizer):
23
+ """Function optimizer that employs an implementation of the genetic algorithm.
24
+ The genetic algorithm is a metaheuristic inspired by the process of natural selection.
25
+ It is a population-based algorithm that uses the concepts of selection, crossover, and mutation
26
+ to evolve a population of individuals towards a solution to the objective function.
27
+
28
+ The first generation is any initial points, then random candidates. Each later generation
29
+ keeps the previous one's elites unchanged, then fills the rest of the population with
30
+ children: two parents are selected, crossed over, and mutated. Failed evaluations are
31
+ never elites or parents.
32
+
33
+ With blend crossover, each range value of a child is drawn from between its parents' values,
34
+ and a little beyond, rather than copied from one of them; choices and fixed values are
35
+ still copied.
36
+
37
+ With adaptive mutation, each individual carries its own mutation size, as in evolution
38
+ strategies: a child takes the geometric mean of its parents' sizes, varied at random, so
39
+ individuals with sizes that suit the problem's current scale breed more.
40
+
41
+ Without noise, a child that repeats a point already asked for, such as a copy of a parent
42
+ that no mutation touched, has one gene at a time mutated again until it is new, since
43
+ evaluating it again would only spend the budget. With noise, repeats stay: they refine
44
+ the point's average.
45
+
46
+ Args:
47
+ search_space: The parameters to optimize over, as a SearchSpace or a list of parameters.
48
+ config: A GeneticOptimizerConfig. Defaults are used if omitted.
49
+
50
+ Example:
51
+
52
+ >>> from hone import GeneticOptimizer, GeneticOptimizerConfig, RangeParameter
53
+ >>>
54
+ >>> def objective_function(x, y):
55
+ >>> return x**2 + y**2
56
+ >>>
57
+ >>> x = RangeParameter("x", -5, 5)
58
+ >>> y = RangeParameter("y", -5, 5)
59
+ >>>
60
+ >>> optimizer = GeneticOptimizer(search_space=[x, y], config=GeneticOptimizerConfig(threshold=0.01))
61
+ >>> result = optimizer.optimize(objective_function)
62
+ """
63
+ config_class = GeneticOptimizerConfig
64
+ config: GeneticOptimizerConfig
65
+
66
+ def reset(self) -> None:
67
+ super().reset()
68
+ # The parameters a mutation can change.
69
+ self._movable = [
70
+ p.name for p in self.search_space.parameters
71
+ if not isinstance(p, FixedParameter)
72
+ and not (isinstance(p, RangeParameter) and p.maximum == p.minimum)
73
+ and not (isinstance(p, ChoiceParameter) and len(p.choices) == 1)
74
+ ]
75
+ variables = sum(
76
+ not isinstance(p, FixedParameter) and not (isinstance(p, RangeParameter) and p.maximum == p.minimum)
77
+ for p in self.search_space.parameters
78
+ )
79
+ if self.config.mutation_rate is not None:
80
+ self._mutation_rate = self.config.mutation_rate
81
+ else:
82
+ self._mutation_rate = 0.1 if self.config.noise else max(0.1, 1 / max(variables, 1))
83
+ self._adaptive = self.config.adaptive_mutation if self.config.adaptive_mutation is not None else not self.config.noise
84
+ self._step_spread = STEP_SPREAD / math.sqrt(max(variables, 1))
85
+ self._steps: dict[Individual, float] = {} # each individual's mutation size, with adaptive mutation
86
+ self._asked: set[tuple] = set() # every point put in a population, so children can avoid repeating one
87
+
88
+ def _select(self, individuals: list[Individual]) -> Individual:
89
+ select: Callable[..., Individual]
90
+ match self.config.selection_method:
91
+ case SelectionMethod.TOURNAMENT:
92
+ select = partial(selection.tournament, size=self.config.tournament_size, p=self.config.tournament_p)
93
+ case SelectionMethod.ROULETTE:
94
+ select = selection.roulette
95
+ return select(individuals, self.rng, maximize=self.config.maximize)
96
+
97
+ 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."""
99
+ first, second = self._select(parents), self._select(parents)
100
+ if self.config.blend_crossover:
101
+ params = blend_crossover(
102
+ first.params, second.params, self.search_space, self.rng, rate=self.config.crossover_rate, alpha=self.config.blend_alpha
103
+ )
104
+ else:
105
+ params = uniform_crossover(first.params, second.params, self.rng, rate=self.config.crossover_rate)
106
+ step = self.config.mutation_scale
107
+ if self._adaptive:
108
+ inherited = math.sqrt(self._step(first) * self._step(second))
109
+ step = min(max(inherited * math.exp(self.rng.normal(0, self._step_spread)), MIN_STEP), MAX_STEP)
110
+ params = mutate(params, self.search_space, self.rng, rate=self._mutation_rate, scale=step)
111
+ params = {**params, **(niche or {})}
112
+ if not self.config.noise:
113
+ params = self._new_point(params, step, niche)
114
+ child = Individual(params=params, generation=generation)
115
+ if self._adaptive:
116
+ self._steps[child] = step
117
+ return child
118
+
119
+ def _new_point(self, params: dict, step: float, niche: dict | None) -> dict:
120
+ """params, mutated again a gene at a time while they repeat a point already asked for.
121
+
122
+ Gives up after REPEAT_TRIES, which only happens when the space has few points left.
123
+ A niche's choices stay fixed.
124
+ """
125
+ movable = [name for name in self._movable if not niche or name not in niche]
126
+ for _ in range(REPEAT_TRIES):
127
+ if not movable or _key(params) not in self._asked:
128
+ break
129
+ name = movable[self.rng.integers(len(movable))]
130
+ params = {**params, name: self.search_space[name].mutate(params[name], self.rng, step)}
131
+ self._asked.add(_key(params))
132
+ return params
133
+
134
+ def _step(self, individual: Individual) -> float:
135
+ return self._steps.get(individual, self.config.mutation_scale)
136
+
137
+ def _niche_children(self, parents: list[Individual], count: int, generation: int) -> list[Individual]:
138
+ """Up to count children bred within niches: parents sharing every choice parameter's value.
139
+
140
+ Each niche gets an equal share, so every combination of choices still in the
141
+ population keeps tuning its own numbers instead of being outbred by the leader.
142
+ """
143
+ choices = [p.name for p in self.search_space.parameters if isinstance(p, ChoiceParameter) and len(p.choices) > 1]
144
+ if not choices or count == 0:
145
+ return []
146
+ niches: dict[tuple, list[Individual]] = {}
147
+ for parent in parents:
148
+ niches.setdefault(tuple(repr(parent.params[name]) for name in choices), []).append(parent)
149
+ # With more niches than children, the niches with the fittest members breed.
150
+ ranked = sorted(niches.values(), key=lambda members: _fitness(self._ranked(members)[0]), reverse=self.config.maximize)
151
+ per_niche = max(1, count // len(ranked))
152
+ children: list[Individual] = []
153
+ for members in ranked:
154
+ niche = {name: members[0].params[name] for name in choices}
155
+ for _ in range(min(per_niche, count - len(children))):
156
+ children.append(self._child(members, generation, niche))
157
+ return children
158
+
159
+ def _next_population(self, previous: list[Individual] | None, generation: int) -> list[Individual]:
160
+ size = self.config.population_size
161
+ if previous is None:
162
+ initial = self._initial_individuals(generation)
163
+ population = initial + [self._random_individual(generation) for _ in range(size - len(initial))]
164
+ self._asked.update(_key(individual.params) for individual in population)
165
+ return population
166
+ parents = [individual for individual in previous if not individual.failed]
167
+ if not parents:
168
+ # Every evaluation of the previous generation failed: start afresh.
169
+ population = [self._random_individual(generation) for _ in range(size)]
170
+ self._asked.update(_key(individual.params) for individual in population)
171
+ return population
172
+
173
+ if self.config.noise:
174
+ # A point bred again since it was evaluated has more evaluations now; rank by their average.
175
+ self._refresh_averages(parents)
176
+ # At least one child per generation, however the share rounds, so the search keeps moving.
177
+ elites = self._ranked(parents)[: min(round(size * self.config.elitism_rate), size - 1)] if self.config.elitism else []
178
+ niched = self._niche_children(parents, int(self.config.niche_share * (size - len(elites))), generation)
179
+ population = elites + niched + [self._child(parents, generation) for _ in range(size - len(elites) - len(niched))]
180
+ if self._adaptive:
181
+ # Keep the sizes of individuals that can still breed: this generation, and any still being evaluated.
182
+ keep = set(population) | set(self._outstanding())
183
+ self._steps = {individual: step for individual, step in self._steps.items() if individual in keep}
184
+ return population
@@ -0,0 +1,28 @@
1
+ """Ways to pick a parent from an evaluated population."""
2
+ import numpy as np
3
+
4
+ from hone.optimizers.base import Individual
5
+
6
+
7
+ def tournament(
8
+ individuals: list[Individual], rng: np.random.Generator, *, size: int, p: float, maximize: bool
9
+ ) -> Individual:
10
+ """Draw size individuals, with replacement, and return the fittest with probability p, else the least fit."""
11
+ indices = rng.choice(len(individuals), size=size)
12
+ contenders = sorted((individuals[i] for i in indices), key=lambda x: x.fitness, reverse=maximize)
13
+ return contenders[0] if rng.uniform(0, 1) < p else contenders[-1]
14
+
15
+
16
+ def roulette(individuals: list[Individual], rng: np.random.Generator, *, maximize: bool) -> Individual:
17
+ """Pick an individual with probability proportional to its fitness.
18
+
19
+ Fitnesses are shifted so the worst individual has weight zero, which makes the
20
+ selection work for negative fitnesses and for minimization alike.
21
+ """
22
+ fitnesses = np.array([individual.fitness for individual in individuals], dtype=float)
23
+ weights = fitnesses - fitnesses.min() if maximize else fitnesses.max() - fitnesses
24
+ total = weights.sum()
25
+ if total == 0:
26
+ # Every individual is equally fit.
27
+ return individuals[rng.integers(len(individuals))]
28
+ return individuals[rng.choice(len(individuals), p=weights / total)]
@@ -0,0 +1,47 @@
1
+ """Ways to make a child's parameters from its parents'."""
2
+ from typing import Any
3
+
4
+ import numpy as np
5
+
6
+ from hone.parameters import RangeParameter, SearchSpace
7
+
8
+
9
+ def uniform_crossover(
10
+ first: dict[str, Any], second: dict[str, Any], rng: np.random.Generator, *, rate: float
11
+ ) -> dict[str, Any]:
12
+ """Build a child gene by gene, taking each from the first parent with probability rate."""
13
+ return {name: first[name] if rng.uniform(0, 1) < rate else second[name] for name in first}
14
+
15
+
16
+ def blend_crossover(
17
+ first: dict[str, Any], second: dict[str, Any], search_space: SearchSpace, rng: np.random.Generator, *, rate: float, alpha: float
18
+ ) -> dict[str, Any]:
19
+ """Uniform crossover, then each range gene drawn between its parents' values (BLX-alpha).
20
+
21
+ A range value is drawn uniformly from the span between the parents' values, widened by alpha
22
+ times that span on each side, measured on the range's own scale and clipped to it. Parents
23
+ with the same value pass it on. Other genes come from one parent, as in uniform_crossover.
24
+ """
25
+ params = uniform_crossover(first, second, rng, rate=rate)
26
+ for parameter in search_space.parameters:
27
+ if isinstance(parameter, RangeParameter) and parameter.maximum > parameter.minimum:
28
+ a, b = float(parameter.to_unit(first[parameter.name])), float(parameter.to_unit(second[parameter.name]))
29
+ low, high = min(a, b), max(a, b)
30
+ if high > low:
31
+ value = parameter.from_unit(rng.uniform(low - alpha * (high - low), high + alpha * (high - low)))
32
+ params[parameter.name] = int(value) if parameter.integer else float(value)
33
+ return params
34
+
35
+
36
+ def mutate(
37
+ params: dict[str, Any], search_space: SearchSpace, rng: np.random.Generator, *, rate: float, scale: float
38
+ ) -> dict[str, Any]:
39
+ """Mutate each gene with probability rate, returning new parameters.
40
+
41
+ How a gene changes depends on its parameter: ranges are nudged by scale times their width,
42
+ choices switch to another value, and fixed parameters never change.
43
+ """
44
+ return {
45
+ name: search_space[name].mutate(value, rng, scale) if rng.uniform(0, 1) < rate else value
46
+ for name, value in params.items()
47
+ }
@@ -0,0 +1,13 @@
1
+ from hone.optimizers.base import Individual, Optimizer
2
+
3
+
4
+ class RandomSearch(Optimizer):
5
+ """Evaluates population_size random points per generation and keeps the best.
6
+
7
+ Useful as a baseline: a smarter optimizer should beat it for the same number of evaluations.
8
+ Any initial points take the place of random points in the first generation.
9
+ """
10
+
11
+ def _next_population(self, previous: list[Individual] | None, generation: int) -> list[Individual]:
12
+ initial = self._initial_individuals(generation) if previous is None else []
13
+ return initial + [self._random_individual(generation) for _ in range(self.config.population_size - len(initial))]
@@ -0,0 +1 @@
1
+ from .parameters import ChoiceParameter, FixedParameter, Parameter, RangeParameter, SearchSpace
@@ -0,0 +1,237 @@
1
+ import math
2
+ import numbers
3
+ from collections.abc import Sequence
4
+ from typing import Annotated, Any, Literal
5
+
6
+ import numpy as np
7
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+
9
+ type Value = float | int | str
10
+
11
+
12
+ class _Parameter(BaseModel):
13
+ """A named dimension of the search space, and the values it may take."""
14
+ model_config = ConfigDict(extra="forbid", frozen=True)
15
+
16
+ name: str
17
+
18
+ def __init__(self, **data: Any) -> None:
19
+ # Each kind takes its own fields positionally and passes them all here as keywords.
20
+ super().__init__(**data)
21
+
22
+
23
+ class RangeParameter(_Parameter):
24
+ """A number between minimum and maximum, optionally restricted to integers.
25
+
26
+ With log=True the range is searched on a logarithmic scale: values are drawn evenly in
27
+ their logarithm, so 0.001 to 0.01 gets as much attention as 100 to 1000. Use it for
28
+ settings that span orders of magnitude, such as learning rates and regularization
29
+ strengths. A log range needs a positive minimum.
30
+ """
31
+ kind: Literal["range"] = "range"
32
+ minimum: float
33
+ maximum: float
34
+ integer: bool = False
35
+ log: bool = False
36
+
37
+ def __init__(self, name: str, minimum: float, maximum: float, **data: Any) -> None:
38
+ super().__init__(name=name, minimum=minimum, maximum=maximum, **data)
39
+
40
+ @model_validator(mode="after")
41
+ def _check_bounds(self):
42
+ if self.minimum > self.maximum:
43
+ raise ValueError(f"{self.name}: minimum {self.minimum} is greater than maximum {self.maximum}")
44
+ if self.integer and math.ceil(self.minimum) > math.floor(self.maximum):
45
+ raise ValueError(f"{self.name}: no integer lies between {self.minimum} and {self.maximum}")
46
+ if self.log and self.minimum <= 0:
47
+ raise ValueError(f"{self.name}: a log range needs a positive minimum, got {self.minimum}")
48
+ return self
49
+
50
+ def _scale(self, value):
51
+ return np.log(value) if self.log else value
52
+
53
+ def to_unit(self, value: Any) -> Any:
54
+ """Where value lies in the range, from 0 at the minimum to 1 at the maximum, on the range's scale.
55
+
56
+ Accepts a number or an array of them.
57
+ """
58
+ low, high = self._scale(self.minimum), self._scale(self.maximum)
59
+ if high == low:
60
+ return np.zeros_like(np.asarray(value, dtype=float)) if np.ndim(value) else 0.0
61
+ return (self._scale(np.asarray(value, dtype=float)) - low) / (high - low)
62
+
63
+ def from_unit(self, unit: Any) -> Any:
64
+ """The value at position unit in the range, clipped to it, and rounded for an integer range.
65
+
66
+ The inverse of to_unit. Accepts a number, returning an int or float, or an array.
67
+ """
68
+ low, high = self._scale(self.minimum), self._scale(self.maximum)
69
+ value = low + np.clip(unit, 0, 1) * (high - low)
70
+ if self.log:
71
+ value = np.exp(value)
72
+ if self.integer:
73
+ value = np.clip(np.round(value), math.ceil(self.minimum), math.floor(self.maximum))
74
+ else:
75
+ value = np.clip(value, self.minimum, self.maximum)
76
+ if np.ndim(value):
77
+ return value
78
+ return int(value) if self.integer else float(value)
79
+
80
+ def sample(self, rng: np.random.Generator) -> float | int:
81
+ if self.log:
82
+ return self.from_unit(rng.uniform())
83
+ if self.integer:
84
+ return int(rng.integers(math.ceil(self.minimum), math.floor(self.maximum), endpoint=True))
85
+ return float(rng.uniform(self.minimum, self.maximum))
86
+
87
+ def check(self, value: Any) -> float | int:
88
+ """value as this parameter holds it, or ValueError if it isn't one of its values."""
89
+ if isinstance(value, bool) or not isinstance(value, numbers.Real):
90
+ raise ValueError(f"{self.name}: {value!r} is not a number")
91
+ number = float(value)
92
+ if not self.minimum <= number <= self.maximum:
93
+ raise ValueError(f"{self.name}: {value} is outside [{self.minimum}, {self.maximum}]")
94
+ if self.integer:
95
+ if not number.is_integer():
96
+ raise ValueError(f"{self.name}: {value} is not an integer")
97
+ return int(number)
98
+ return number
99
+
100
+ def mutate(self, value: float | int, rng: np.random.Generator, scale: float) -> float | int:
101
+ """Nudge value by Gaussian noise with a standard deviation of scale times the range width.
102
+
103
+ The result is clipped to the bounds. Integer ranges use a step of at least 1, so a
104
+ mutation can always move the value. A log range is nudged in its logarithm, so the
105
+ step is a proportion of the value rather than a fixed amount.
106
+ """
107
+ if self.log:
108
+ mutated = self.from_unit(self.to_unit(value) + rng.normal(0, scale))
109
+ if self.integer and mutated == value:
110
+ neighbour = min(max(value + rng.choice([-1, 1]), math.ceil(self.minimum)), math.floor(self.maximum))
111
+ mutated = int(neighbour)
112
+ return mutated
113
+ sigma = scale * (self.maximum - self.minimum)
114
+ if self.integer:
115
+ step = round(rng.normal(0, max(sigma, 1)))
116
+ return int(np.clip(value + step, math.ceil(self.minimum), math.floor(self.maximum)))
117
+ return float(np.clip(value + rng.normal(0, sigma), self.minimum, self.maximum))
118
+
119
+
120
+ class FixedParameter(_Parameter):
121
+ """A parameter held at a single value."""
122
+ kind: Literal["fixed"] = "fixed"
123
+ value: Value
124
+
125
+ def __init__(self, name: str, value: Value, **data: Any) -> None:
126
+ super().__init__(name=name, value=value, **data)
127
+
128
+ def sample(self, rng: np.random.Generator) -> Value:
129
+ return self.value
130
+
131
+ def check(self, value: Any) -> Value:
132
+ """value as this parameter holds it, or ValueError if it isn't one of its values."""
133
+ if value != self.value:
134
+ raise ValueError(f"{self.name}: {value!r} is not its fixed value {self.value!r}")
135
+ return self.value
136
+
137
+ def mutate(self, value: Value, rng: np.random.Generator, scale: float) -> Value:
138
+ return self.value
139
+
140
+
141
+ class ChoiceParameter(_Parameter):
142
+ """One value picked from a list of choices, or a list of choose_n distinct choices."""
143
+ kind: Literal["choice"] = "choice"
144
+ choices: list[Value] = Field(min_length=1)
145
+ choose_n: int | None = Field(default=None, ge=1)
146
+
147
+ def __init__(self, name: str, choices: list[Value], **data: Any) -> None:
148
+ super().__init__(name=name, choices=choices, **data)
149
+
150
+ @model_validator(mode="after")
151
+ def _check_choose_n(self):
152
+ if self.choose_n is not None and self.choose_n > len(self.choices):
153
+ raise ValueError(f"{self.name}: choose_n {self.choose_n} exceeds the {len(self.choices)} choices")
154
+ return self
155
+
156
+ def sample(self, rng: np.random.Generator) -> Value | list[Value]:
157
+ if self.choose_n is None:
158
+ return self.choices[rng.integers(len(self.choices))]
159
+ indices = rng.choice(len(self.choices), size=self.choose_n, replace=False)
160
+ return [self.choices[i] for i in indices]
161
+
162
+ def check(self, value: Any) -> Value | list[Value]:
163
+ """value as this parameter holds it, or ValueError if it isn't one of its values."""
164
+ if self.choose_n is None:
165
+ if value not in self.choices:
166
+ raise ValueError(f"{self.name}: {value!r} is not one of {self.choices}")
167
+ return value
168
+ if not isinstance(value, (list, tuple)) or len(value) != self.choose_n:
169
+ raise ValueError(f"{self.name}: expected a list of {self.choose_n} choices, got {value!r}")
170
+ if any(v not in self.choices for v in value) or len(set(map(repr, value))) != len(value):
171
+ raise ValueError(f"{self.name}: {value!r} must be {self.choose_n} different values from {self.choices}")
172
+ return list(value)
173
+
174
+ def mutate(self, value: Value | list[Value], rng: np.random.Generator, scale: float) -> Value | list[Value]:
175
+ """Draw a new value. A single choice switches to a different one when there is one to switch to.
176
+
177
+ Choices have no order, so scale does not apply.
178
+ """
179
+ if self.choose_n is not None or len(self.choices) == 1:
180
+ return self.sample(rng)
181
+ others = [choice for choice in self.choices if choice != value]
182
+ return others[rng.integers(len(others))] if others else self.sample(rng)
183
+
184
+
185
+ type Parameter = Annotated[RangeParameter | FixedParameter | ChoiceParameter, Field(discriminator="kind")]
186
+
187
+
188
+ class SearchSpace(BaseModel):
189
+ """The set of parameters an objective function is optimized over."""
190
+ model_config = ConfigDict(extra="forbid", frozen=True)
191
+
192
+ parameters: list[Parameter] = Field(min_length=1)
193
+
194
+ def __init__(self, parameters: Sequence[Parameter], **data: Any) -> None:
195
+ super().__init__(parameters=list(parameters), **data)
196
+
197
+ @model_validator(mode="after")
198
+ def _check_unique_names(self):
199
+ names = [parameter.name for parameter in self.parameters]
200
+ duplicates = sorted({name for name in names if names.count(name) > 1})
201
+ if duplicates:
202
+ raise ValueError(f"each parameter needs a unique name; repeated: {', '.join(duplicates)}")
203
+ return self
204
+
205
+ @property
206
+ def names(self) -> list[str]:
207
+ return [parameter.name for parameter in self.parameters]
208
+
209
+ 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}
212
+
213
+ def check(self, params: dict[str, Any]) -> dict[str, Any]:
214
+ """params as a complete point of this space, in parameter order, or ValueError if they aren't one.
215
+
216
+ Fixed parameters may be left out; every other parameter needs a value.
217
+ """
218
+ unknown = sorted(set(params) - set(self.names))
219
+ if unknown:
220
+ raise ValueError(f"unknown parameters: {', '.join(unknown)}")
221
+ missing = [p.name for p in self.parameters if p.name not in params and not isinstance(p, FixedParameter)]
222
+ if missing:
223
+ raise ValueError(f"missing values for: {', '.join(missing)}")
224
+ checked = {}
225
+ for p in self.parameters:
226
+ if p.name in params:
227
+ checked[p.name] = p.check(params[p.name])
228
+ else:
229
+ assert isinstance(p, FixedParameter) # every other kind was found missing above
230
+ checked[p.name] = p.value
231
+ return checked
232
+
233
+ def __getitem__(self, name: str) -> Parameter:
234
+ for parameter in self.parameters:
235
+ if parameter.name == name:
236
+ return parameter
237
+ raise KeyError(name)
hone/py.typed ADDED
File without changes
hone/result.py ADDED
@@ -0,0 +1,118 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING, Any, Self
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ from hone.parameters import SearchSpace
8
+
9
+ if TYPE_CHECKING:
10
+ from hone.optimizers import Optimizer
11
+
12
+
13
+ class GenerationStats(BaseModel):
14
+ """Summary of one generation's fitness, over its successful evaluations.
15
+
16
+ The fitness fields are None when every evaluation in the generation failed.
17
+ """
18
+ model_config = ConfigDict(frozen=True)
19
+
20
+ generation: int
21
+ evaluations: int # evaluations up to the end of this generation, failed ones included
22
+ failures: int = 0 # failed evaluations in this generation
23
+ best_fitness: float | None = None
24
+ best_params: dict[str, Any] | None = None
25
+ worst_fitness: float | None = None
26
+ mean: float | None = None
27
+ median: float | None = None
28
+ std: float | None = None
29
+
30
+
31
+ class RunInfo(BaseModel):
32
+ """How a result was produced: enough to repeat the run, and to tell runs apart later."""
33
+ model_config = ConfigDict(frozen=True)
34
+
35
+ optimizer: str # the optimizer's class name, such as "CMAES"
36
+ config: dict[str, Any] # the optimizer's full config, seed included
37
+ search_space: SearchSpace
38
+ initial_points: list[dict[str, Any]] = []
39
+ objective: str | None = None # the objective function's name; None when run with ask() and tell()
40
+ workers: int | None = None # optimize()'s workers, which CMA-ES and Bayesian optimization size their batches by
41
+ timeout: float | None = None # optimize()'s time limit per evaluation, in seconds
42
+ hone_version: str
43
+ python_version: str
44
+ numpy_version: str
45
+ started_at: datetime | None = None # when the first candidate was handed out, in UTC
46
+ elapsed: float | None = None # seconds from then until the run finished, or until the result was taken
47
+
48
+
49
+ class Progress(BaseModel):
50
+ """What optimize() passes its callback after each generation."""
51
+ model_config = ConfigDict(frozen=True)
52
+
53
+ generation: GenerationStats # the generation just finished
54
+ evaluations: int # evaluations made so far, failed ones included
55
+ best_fitness: float | None # the best value so far; None while every evaluation has failed
56
+ best_params: dict[str, Any] | None
57
+ elapsed: float # seconds since the run started
58
+
59
+
60
+ def print_progress(progress: Progress) -> None:
61
+ """A ready-made optimize() callback: print one line per generation."""
62
+ best = "none yet" if progress.best_fitness is None else f"{progress.best_fitness:.6g}"
63
+ print(
64
+ f"generation {progress.generation.generation}: {progress.evaluations:,} evaluations, "
65
+ f"best {best}, {progress.elapsed:.1f} s",
66
+ flush=True,
67
+ )
68
+
69
+
70
+ class Result(BaseModel):
71
+ """Outcome of an optimization run. Save it with save() and read it back with load().
72
+
73
+ Two results are equal when they found the same things; their run records are not compared,
74
+ since a repeated run differs in its timing.
75
+ """
76
+ model_config = ConfigDict(frozen=True)
77
+
78
+ best_fitness: float
79
+ best_params: dict[str, Any]
80
+ generations: list[GenerationStats] # completed generations; empty partway through the first
81
+ evaluations: int # evaluations made, failed ones included
82
+ failures: int = 0 # evaluations that failed
83
+ reached_threshold: bool # whether the run stopped early because it reached config.threshold
84
+ stopped: bool = False # whether the run was stopped before it finished, by its callback or Ctrl+C
85
+ run: RunInfo | None = None # how the result was produced; None in results saved before hone 0.7.1
86
+
87
+ def __eq__(self, other: object) -> bool:
88
+ if not isinstance(other, Result):
89
+ return NotImplemented
90
+ return self.model_dump(exclude={"run"}) == other.model_dump(exclude={"run"})
91
+
92
+ def rebuild_optimizer(self, optimizer_class: "type[Optimizer] | None" = None) -> "Optimizer":
93
+ """A new optimizer set up exactly as this result's was, to repeat or extend the run.
94
+
95
+ Running it gives the same result, given the same objective and, for CMA-ES and
96
+ Bayesian optimization, the same number of workers (run.workers). An optimizer that
97
+ isn't one of hone's own is rebuilt by passing its class.
98
+ """
99
+ from hone import optimizers
100
+
101
+ if self.run is None:
102
+ raise ValueError("this result has no record of its run: it was saved by a version of hone before 0.7.1")
103
+ if optimizer_class is None:
104
+ builtin = {cls.__name__: cls for cls in (
105
+ optimizers.GeneticOptimizer, optimizers.CMAES, optimizers.BayesianOptimizer, optimizers.RandomSearch
106
+ )}
107
+ if self.run.optimizer not in builtin:
108
+ raise ValueError(f"{self.run.optimizer} isn't one of hone's optimizers; pass its class to rebuild it")
109
+ optimizer_class = builtin[self.run.optimizer]
110
+ 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)
112
+
113
+ def save(self, filepath: str | Path) -> None:
114
+ Path(filepath).write_text(self.model_dump_json(indent=2))
115
+
116
+ @classmethod
117
+ def load(cls, filepath: str | Path) -> Self:
118
+ return cls.model_validate_json(Path(filepath).read_text())