ctrl-freak 0.1.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.
- ctrl_freak/__init__.py +100 -0
- ctrl_freak/algorithms/__init__.py +10 -0
- ctrl_freak/algorithms/ga.py +233 -0
- ctrl_freak/algorithms/nsga2.py +215 -0
- ctrl_freak/operators/__init__.py +15 -0
- ctrl_freak/operators/base.py +81 -0
- ctrl_freak/operators/selection.py +144 -0
- ctrl_freak/operators/standard.py +275 -0
- ctrl_freak/population.py +203 -0
- ctrl_freak/primitives/__init__.py +23 -0
- ctrl_freak/primitives/pareto.py +222 -0
- ctrl_freak/protocols.py +186 -0
- ctrl_freak/py.typed +0 -0
- ctrl_freak/registry.py +303 -0
- ctrl_freak/results.py +246 -0
- ctrl_freak/selection/__init__.py +13 -0
- ctrl_freak/selection/crowded.py +117 -0
- ctrl_freak/selection/roulette.py +115 -0
- ctrl_freak/selection/tournament.py +104 -0
- ctrl_freak/survival/__init__.py +13 -0
- ctrl_freak/survival/elitist.py +147 -0
- ctrl_freak/survival/nsga2.py +140 -0
- ctrl_freak/survival/truncation.py +104 -0
- ctrl_freak-0.1.0.dist-info/METADATA +238 -0
- ctrl_freak-0.1.0.dist-info/RECORD +27 -0
- ctrl_freak-0.1.0.dist-info/WHEEL +4 -0
- ctrl_freak-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Selection and offspring creation operators for NSGA-II.
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
- select_parents: Binary tournament selection using crowded comparison
|
|
5
|
+
- create_offspring: Create offspring via selection, crossover, and mutation
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from ctrl_freak.population import Population
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def select_parents(
|
|
16
|
+
pop: Population,
|
|
17
|
+
n_parents: int,
|
|
18
|
+
rng: np.random.Generator,
|
|
19
|
+
rank: np.ndarray,
|
|
20
|
+
crowding_distance: np.ndarray,
|
|
21
|
+
) -> np.ndarray:
|
|
22
|
+
"""Select parents using binary tournament selection (vectorized).
|
|
23
|
+
|
|
24
|
+
Uses the crowded comparison operator: lower rank wins, ties broken by
|
|
25
|
+
higher crowding distance.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
pop : Population
|
|
30
|
+
Population used for its size.
|
|
31
|
+
n_parents : int
|
|
32
|
+
Number of parents to select.
|
|
33
|
+
rng : numpy.random.Generator
|
|
34
|
+
Random number generator for reproducibility.
|
|
35
|
+
rank : numpy.ndarray
|
|
36
|
+
Pareto front ranks for all individuals. Shape is ``(n,)``.
|
|
37
|
+
crowding_distance : numpy.ndarray
|
|
38
|
+
Crowding distances for all individuals. Shape is ``(n,)``.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
numpy.ndarray
|
|
43
|
+
Array of shape ``(n_parents,)`` containing indices into ``pop``.
|
|
44
|
+
|
|
45
|
+
Examples
|
|
46
|
+
--------
|
|
47
|
+
>>> import numpy as np
|
|
48
|
+
>>> from ctrl_freak.population import Population
|
|
49
|
+
>>> from ctrl_freak.operators.selection import select_parents
|
|
50
|
+
>>> pop = Population(x=np.zeros((4, 2)), objectives=np.zeros((4, 2)))
|
|
51
|
+
>>> rng = np.random.default_rng(0)
|
|
52
|
+
>>> parents = select_parents(
|
|
53
|
+
... pop,
|
|
54
|
+
... n_parents=10,
|
|
55
|
+
... rng=rng,
|
|
56
|
+
... rank=np.array([0, 0, 1, 1]),
|
|
57
|
+
... crowding_distance=np.array([1.0, 2.0, 1.0, 2.0]),
|
|
58
|
+
... )
|
|
59
|
+
>>> parents.shape
|
|
60
|
+
(10,)
|
|
61
|
+
"""
|
|
62
|
+
n = len(pop.x)
|
|
63
|
+
candidates = rng.integers(0, n, size=(n_parents, 2))
|
|
64
|
+
|
|
65
|
+
rank_a = rank[candidates[:, 0]]
|
|
66
|
+
rank_b = rank[candidates[:, 1]]
|
|
67
|
+
cd_a = crowding_distance[candidates[:, 0]]
|
|
68
|
+
cd_b = crowding_distance[candidates[:, 1]]
|
|
69
|
+
|
|
70
|
+
# a wins if: lower rank OR (same rank AND higher or equal crowding distance)
|
|
71
|
+
a_wins = (rank_a < rank_b) | ((rank_a == rank_b) & (cd_a >= cd_b))
|
|
72
|
+
|
|
73
|
+
return np.where(a_wins, candidates[:, 0], candidates[:, 1])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create_offspring(
|
|
77
|
+
pop: Population,
|
|
78
|
+
n_offspring: int,
|
|
79
|
+
crossover: Callable[[np.ndarray, np.ndarray], np.ndarray],
|
|
80
|
+
mutate: Callable[[np.ndarray], np.ndarray],
|
|
81
|
+
rng: np.random.Generator,
|
|
82
|
+
rank: np.ndarray,
|
|
83
|
+
crowding_distance: np.ndarray,
|
|
84
|
+
) -> np.ndarray:
|
|
85
|
+
"""Create offspring via selection, crossover, and mutation.
|
|
86
|
+
|
|
87
|
+
Selects 2*n_offspring parents using binary tournament, crosses them
|
|
88
|
+
in pairs, and applies mutation to all offspring.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
pop : Population
|
|
93
|
+
Parent population.
|
|
94
|
+
n_offspring : int
|
|
95
|
+
Number of offspring to create.
|
|
96
|
+
crossover : Callable[[numpy.ndarray, numpy.ndarray], numpy.ndarray]
|
|
97
|
+
Crossover function with signature ``(n_vars,), (n_vars,) -> (n_vars,)``.
|
|
98
|
+
mutate : Callable[[numpy.ndarray], numpy.ndarray]
|
|
99
|
+
Mutation function with signature ``(n_vars,) -> (n_vars,)``.
|
|
100
|
+
rng : numpy.random.Generator
|
|
101
|
+
Random number generator for reproducibility.
|
|
102
|
+
rank : numpy.ndarray
|
|
103
|
+
Pareto front ranks for all individuals. Shape is ``(n,)``.
|
|
104
|
+
crowding_distance : numpy.ndarray
|
|
105
|
+
Crowding distances for all individuals. Shape is ``(n,)``.
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
numpy.ndarray
|
|
110
|
+
Array of shape ``(n_offspring, n_vars)`` containing unevaluated
|
|
111
|
+
offspring decision variables.
|
|
112
|
+
|
|
113
|
+
Examples
|
|
114
|
+
--------
|
|
115
|
+
>>> import numpy as np
|
|
116
|
+
>>> from ctrl_freak.population import Population
|
|
117
|
+
>>> from ctrl_freak.operators.selection import create_offspring
|
|
118
|
+
>>> pop = Population(x=np.arange(8.0).reshape(4, 2), objectives=np.zeros((4, 2)))
|
|
119
|
+
>>> rng = np.random.default_rng(0)
|
|
120
|
+
>>> crossover = lambda p1, p2: (p1 + p2) / 2
|
|
121
|
+
>>> mutate = lambda x: x.copy()
|
|
122
|
+
>>> offspring = create_offspring(
|
|
123
|
+
... pop,
|
|
124
|
+
... n_offspring=3,
|
|
125
|
+
... crossover=crossover,
|
|
126
|
+
... mutate=mutate,
|
|
127
|
+
... rng=rng,
|
|
128
|
+
... rank=np.array([0, 0, 1, 1]),
|
|
129
|
+
... crowding_distance=np.array([1.0, 2.0, 1.0, 2.0]),
|
|
130
|
+
... )
|
|
131
|
+
>>> offspring.shape
|
|
132
|
+
(3, 2)
|
|
133
|
+
"""
|
|
134
|
+
parent_idx = select_parents(pop, n_offspring * 2, rng, rank=rank, crowding_distance=crowding_distance)
|
|
135
|
+
|
|
136
|
+
# Crossover pairs (2i, 2i+1) to get n_offspring children
|
|
137
|
+
offspring_x = np.stack(
|
|
138
|
+
[crossover(pop.x[parent_idx[2 * i]], pop.x[parent_idx[2 * i + 1]]) for i in range(n_offspring)]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Mutate all offspring
|
|
142
|
+
offspring_x = np.stack([mutate(x) for x in offspring_x])
|
|
143
|
+
|
|
144
|
+
return offspring_x
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Standard bounded genetic operators for NSGA-II.
|
|
2
|
+
|
|
3
|
+
This module provides Simulated Binary Crossover (SBX) and polynomial mutation
|
|
4
|
+
operators for real-valued decision vectors.
|
|
5
|
+
|
|
6
|
+
Seed-injection contract (consumed by the algorithm layer): `sbx_crossover(...)`
|
|
7
|
+
and `polynomial_mutation(...)` return callable operator objects. Each exposes
|
|
8
|
+
`set_rng(rng: numpy.random.Generator) -> None`, which replaces the operator's
|
|
9
|
+
internal generator. Until `set_rng` is called, the operator uses the generator
|
|
10
|
+
built from its constructor `seed=`. The call signatures are unchanged:
|
|
11
|
+
`crossover(p1, p2) -> child`, `mutate(x) -> x'`. Algorithms unify
|
|
12
|
+
reproducibility by calling `set_rng` with a `SeedSequence.spawn`-derived
|
|
13
|
+
generator after constructing operators; standalone users may ignore `set_rng`
|
|
14
|
+
and rely on `seed=`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
Bounds = tuple[float, float] | tuple[np.ndarray, np.ndarray]
|
|
22
|
+
"""Bounds for decision variables.
|
|
23
|
+
|
|
24
|
+
A scalar pair ``(lower, upper)`` applies the same bounds to all variables.
|
|
25
|
+
A pair of arrays ``(lower_array, upper_array)`` specifies per-variable bounds.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _SeededOperator:
|
|
30
|
+
"""Mixin providing an injectable numpy Generator for genetic operators.
|
|
31
|
+
|
|
32
|
+
The operator owns a ``numpy.random.Generator`` built from ``seed`` at
|
|
33
|
+
construction. An orchestrating algorithm may replace it post-construction
|
|
34
|
+
via :meth:`set_rng` so a single master seed reproduces the entire run.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_rng: np.random.Generator
|
|
38
|
+
|
|
39
|
+
def set_rng(self, rng: np.random.Generator) -> None:
|
|
40
|
+
"""Inject a numpy Generator, replacing the operator's internal RNG.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
rng : numpy.random.Generator
|
|
45
|
+
Generator to use for all subsequent draws. Replaces the generator
|
|
46
|
+
built from the constructor ``seed``.
|
|
47
|
+
"""
|
|
48
|
+
self._rng = rng
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _SBXCrossover(_SeededOperator):
|
|
52
|
+
"""Callable bounded Simulated Binary Crossover operator."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, eta: float, bounds: Bounds, seed: int | None) -> None:
|
|
55
|
+
self.eta = float(eta)
|
|
56
|
+
self.bounds = bounds
|
|
57
|
+
self._rng = np.random.default_rng(seed)
|
|
58
|
+
|
|
59
|
+
def __call__(self, p1: np.ndarray, p2: np.ndarray) -> np.ndarray:
|
|
60
|
+
"""Apply bounded SBX to two parents and return one child.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
p1 : numpy.ndarray
|
|
65
|
+
First parent decision vector.
|
|
66
|
+
p2 : numpy.ndarray
|
|
67
|
+
Second parent decision vector.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
numpy.ndarray
|
|
72
|
+
One child decision vector with the same shape as ``p1``.
|
|
73
|
+
"""
|
|
74
|
+
eps = 1e-14
|
|
75
|
+
eta = self.eta
|
|
76
|
+
exponent = 1.0 / (eta + 1.0)
|
|
77
|
+
rng = self._rng
|
|
78
|
+
|
|
79
|
+
lower, upper = self.bounds
|
|
80
|
+
xl = np.broadcast_to(np.asarray(lower, dtype=float), p1.shape)
|
|
81
|
+
xu = np.broadcast_to(np.asarray(upper, dtype=float), p1.shape)
|
|
82
|
+
|
|
83
|
+
sm = p1 < p2
|
|
84
|
+
y1 = np.where(sm, p1, p2)
|
|
85
|
+
y2 = np.where(sm, p2, p1)
|
|
86
|
+
|
|
87
|
+
eligible = (np.abs(p1 - p2) > eps) & (xl < xu)
|
|
88
|
+
delta = np.where(eligible, y2 - y1, 1.0)
|
|
89
|
+
|
|
90
|
+
rand = rng.random(p1.shape[0])
|
|
91
|
+
|
|
92
|
+
def calc_betaq(beta: np.ndarray) -> np.ndarray:
|
|
93
|
+
alpha = 2.0 - np.power(beta, -(eta + 1.0))
|
|
94
|
+
mask = rand <= (1.0 / alpha)
|
|
95
|
+
return np.where(
|
|
96
|
+
mask,
|
|
97
|
+
np.power(rand * alpha, exponent),
|
|
98
|
+
np.power(1.0 / (2.0 - rand * alpha), exponent),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
beta1 = 1.0 + (2.0 * (y1 - xl) / delta)
|
|
102
|
+
c1 = 0.5 * ((y1 + y2) - calc_betaq(beta1) * delta)
|
|
103
|
+
|
|
104
|
+
beta2 = 1.0 + (2.0 * (xu - y2) / delta)
|
|
105
|
+
c2 = 0.5 * ((y1 + y2) + calc_betaq(beta2) * delta)
|
|
106
|
+
|
|
107
|
+
child_for_p1 = np.where(sm, c1, c2)
|
|
108
|
+
child_for_p2 = np.where(sm, c2, c1)
|
|
109
|
+
child = np.where(rng.random(p1.shape[0]) < 0.5, child_for_p1, child_for_p2)
|
|
110
|
+
child = np.where(eligible, child, p1)
|
|
111
|
+
return np.clip(child, xl, xu)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _PolynomialMutation(_SeededOperator):
|
|
115
|
+
"""Callable bounded polynomial mutation operator."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, eta: float, prob: float | None, bounds: Bounds, seed: int | None) -> None:
|
|
118
|
+
self.eta = float(eta)
|
|
119
|
+
self.prob = prob
|
|
120
|
+
self.bounds = bounds
|
|
121
|
+
self._rng = np.random.default_rng(seed)
|
|
122
|
+
|
|
123
|
+
def __call__(self, x: np.ndarray) -> np.ndarray:
|
|
124
|
+
"""Apply polynomial mutation to an individual.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
x : numpy.ndarray
|
|
129
|
+
Decision vector to mutate.
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
numpy.ndarray
|
|
134
|
+
Mutated decision vector with the same shape as ``x``.
|
|
135
|
+
"""
|
|
136
|
+
n_vars = len(x)
|
|
137
|
+
mutation_prob = self.prob if self.prob is not None else 1.0 / n_vars
|
|
138
|
+
mutation_mask = self._rng.random(n_vars) < mutation_prob
|
|
139
|
+
|
|
140
|
+
if not np.any(mutation_mask):
|
|
141
|
+
return x.copy()
|
|
142
|
+
|
|
143
|
+
lower, upper = self.bounds
|
|
144
|
+
lower_arr = np.broadcast_to(np.asarray(lower, dtype=float), x.shape)
|
|
145
|
+
upper_arr = np.broadcast_to(np.asarray(upper, dtype=float), x.shape)
|
|
146
|
+
delta_max = upper_arr - lower_arr
|
|
147
|
+
degenerate = delta_max == 0
|
|
148
|
+
safe_delta = np.where(degenerate, 1.0, delta_max)
|
|
149
|
+
|
|
150
|
+
delta_l = (x - lower_arr) / safe_delta
|
|
151
|
+
delta_r = (upper_arr - x) / safe_delta
|
|
152
|
+
|
|
153
|
+
u = self._rng.random(n_vars)
|
|
154
|
+
|
|
155
|
+
xy_left = 1.0 - delta_l
|
|
156
|
+
val_left = 2.0 * u + (1.0 - 2.0 * u) * (xy_left ** (self.eta + 1.0))
|
|
157
|
+
delta_q_left = val_left ** (1.0 / (self.eta + 1.0)) - 1.0
|
|
158
|
+
|
|
159
|
+
xy_right = 1.0 - delta_r
|
|
160
|
+
val_right = 2.0 * (1.0 - u) + 2.0 * (u - 0.5) * (xy_right ** (self.eta + 1.0))
|
|
161
|
+
delta_q_right = 1.0 - val_right ** (1.0 / (self.eta + 1.0))
|
|
162
|
+
|
|
163
|
+
delta_q = np.where(u < 0.5, delta_q_left, delta_q_right)
|
|
164
|
+
|
|
165
|
+
effective_mask = mutation_mask & ~degenerate
|
|
166
|
+
mutated = np.where(effective_mask, x + delta_q * delta_max, x)
|
|
167
|
+
return np.clip(mutated, lower_arr, upper_arr)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def sbx_crossover(
|
|
171
|
+
eta: float = 15.0,
|
|
172
|
+
bounds: Bounds = (0.0, 1.0),
|
|
173
|
+
seed: int | None = None,
|
|
174
|
+
) -> Callable[[np.ndarray, np.ndarray], np.ndarray]:
|
|
175
|
+
"""Create a bounded Simulated Binary Crossover operator.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
eta : float, default=15.0
|
|
180
|
+
Distribution index. Higher values produce children closer to parents.
|
|
181
|
+
bounds : tuple, default=(0.0, 1.0)
|
|
182
|
+
Lower and upper decision-variable bounds. Scalars apply to all
|
|
183
|
+
variables; arrays provide per-variable bounds.
|
|
184
|
+
seed : int, optional
|
|
185
|
+
Random seed for the standalone generator used until ``set_rng`` is
|
|
186
|
+
called.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
collections.abc.Callable
|
|
191
|
+
Callable with signature ``(p1, p2) -> child``. The returned object also
|
|
192
|
+
exposes ``set_rng(rng)`` for algorithm-level seed injection.
|
|
193
|
+
|
|
194
|
+
References
|
|
195
|
+
----------
|
|
196
|
+
Deb, K., & Agrawal, R. B. (1995). Simulated binary crossover for
|
|
197
|
+
continuous search space. Complex Systems, 9(2), 115-148.
|
|
198
|
+
|
|
199
|
+
Examples
|
|
200
|
+
--------
|
|
201
|
+
>>> crossover = sbx_crossover(eta=15.0, bounds=(0.0, 1.0), seed=42)
|
|
202
|
+
>>> p1 = np.array([0.2, 0.4, 0.6])
|
|
203
|
+
>>> p2 = np.array([0.3, 0.5, 0.7])
|
|
204
|
+
>>> child = crossover(p1, p2)
|
|
205
|
+
>>> child.shape
|
|
206
|
+
(3,)
|
|
207
|
+
|
|
208
|
+
Per-variable bounds:
|
|
209
|
+
|
|
210
|
+
>>> lower = np.array([0.0, -10.0, 100.0])
|
|
211
|
+
>>> upper = np.array([1.0, 10.0, 200.0])
|
|
212
|
+
>>> crossover = sbx_crossover(eta=15.0, bounds=(lower, upper), seed=42)
|
|
213
|
+
>>> crossover(np.array([0.5, 0.0, 150.0]), np.array([0.8, -5.0, 180.0])).shape
|
|
214
|
+
(3,)
|
|
215
|
+
|
|
216
|
+
Seed injection:
|
|
217
|
+
|
|
218
|
+
>>> cx = sbx_crossover(eta=15.0, bounds=(0.0, 1.0), seed=0)
|
|
219
|
+
>>> cx.set_rng(np.random.default_rng(123))
|
|
220
|
+
>>> child = cx(np.array([0.2, 0.4]), np.array([0.6, 0.8]))
|
|
221
|
+
>>> child.shape
|
|
222
|
+
(2,)
|
|
223
|
+
"""
|
|
224
|
+
return _SBXCrossover(eta=eta, bounds=bounds, seed=seed)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def polynomial_mutation(
|
|
228
|
+
eta: float = 20.0,
|
|
229
|
+
prob: float | None = None,
|
|
230
|
+
bounds: Bounds = (0.0, 1.0),
|
|
231
|
+
seed: int | None = None,
|
|
232
|
+
) -> Callable[[np.ndarray], np.ndarray]:
|
|
233
|
+
"""Create a bounded polynomial mutation operator.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
eta : float, default=20.0
|
|
238
|
+
Distribution index. Higher values produce smaller perturbations.
|
|
239
|
+
prob : float, optional
|
|
240
|
+
Mutation probability per variable. If omitted, uses ``1 / n_vars``.
|
|
241
|
+
bounds : tuple, default=(0.0, 1.0)
|
|
242
|
+
Lower and upper decision-variable bounds. Scalars apply to all
|
|
243
|
+
variables; arrays provide per-variable bounds.
|
|
244
|
+
seed : int, optional
|
|
245
|
+
Random seed for the standalone generator used until ``set_rng`` is
|
|
246
|
+
called.
|
|
247
|
+
|
|
248
|
+
Returns
|
|
249
|
+
-------
|
|
250
|
+
collections.abc.Callable
|
|
251
|
+
Callable with signature ``x -> x'``. The returned object also exposes
|
|
252
|
+
``set_rng(rng)`` for algorithm-level seed injection.
|
|
253
|
+
|
|
254
|
+
References
|
|
255
|
+
----------
|
|
256
|
+
Deb, K., & Goyal, M. (1996). A combined genetic adaptive search (GeneAS)
|
|
257
|
+
for engineering design. Computer Science and Informatics, 26(4), 30-45.
|
|
258
|
+
|
|
259
|
+
Examples
|
|
260
|
+
--------
|
|
261
|
+
>>> mutate = polynomial_mutation(eta=20.0, prob=0.1, bounds=(0.0, 1.0), seed=42)
|
|
262
|
+
>>> x = np.array([0.5, 0.5, 0.5])
|
|
263
|
+
>>> mutated = mutate(x)
|
|
264
|
+
>>> mutated.shape
|
|
265
|
+
(3,)
|
|
266
|
+
|
|
267
|
+
Per-variable bounds:
|
|
268
|
+
|
|
269
|
+
>>> lower = np.array([0.0, -10.0, 100.0])
|
|
270
|
+
>>> upper = np.array([1.0, 10.0, 200.0])
|
|
271
|
+
>>> mutate = polynomial_mutation(eta=20.0, prob=0.1, bounds=(lower, upper), seed=42)
|
|
272
|
+
>>> mutate(np.array([0.5, 0.0, 150.0])).shape
|
|
273
|
+
(3,)
|
|
274
|
+
"""
|
|
275
|
+
return _PolynomialMutation(eta=eta, prob=prob, bounds=bounds, seed=seed)
|
ctrl_freak/population.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Population data structures for multi-objective optimization.
|
|
2
|
+
|
|
3
|
+
This module provides the core data structures for representing populations
|
|
4
|
+
of individuals in multi-objective optimization algorithms:
|
|
5
|
+
|
|
6
|
+
- Population: A struct-of-arrays representation of multiple individuals
|
|
7
|
+
- IndividualView: A read-only view of a single individual
|
|
8
|
+
|
|
9
|
+
Both classes are immutable (frozen dataclasses) to enforce functional style.
|
|
10
|
+
Population is algorithm-agnostic - it only stores decision variables and objectives.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class IndividualView:
|
|
20
|
+
"""Read-only view of a single individual in a population.
|
|
21
|
+
|
|
22
|
+
This class provides a convenient way to access the data for a single
|
|
23
|
+
individual, returned by Population.__getitem__.
|
|
24
|
+
|
|
25
|
+
Attributes
|
|
26
|
+
----------
|
|
27
|
+
x : numpy.ndarray
|
|
28
|
+
Decision variables for this individual.
|
|
29
|
+
objectives : numpy.ndarray | None
|
|
30
|
+
Objective values for this individual, or None.
|
|
31
|
+
|
|
32
|
+
Examples
|
|
33
|
+
--------
|
|
34
|
+
>>> import numpy as np
|
|
35
|
+
>>> from ctrl_freak.population import Population
|
|
36
|
+
>>> pop = Population(x=np.array([[1.0, 2.0], [3.0, 4.0]]))
|
|
37
|
+
>>> individual = pop[0]
|
|
38
|
+
>>> individual.x
|
|
39
|
+
array([1., 2.])
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
x: np.ndarray
|
|
43
|
+
objectives: np.ndarray | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Population:
|
|
48
|
+
"""Immutable struct-of-arrays representation of a population.
|
|
49
|
+
|
|
50
|
+
This class stores multiple individuals using a struct-of-arrays layout
|
|
51
|
+
for efficient vectorized operations. All arrays are copied on construction
|
|
52
|
+
to ensure immutability.
|
|
53
|
+
|
|
54
|
+
Population is algorithm-agnostic - it only stores decision variables and
|
|
55
|
+
objective values. Algorithm-specific data (like Pareto ranks or crowding
|
|
56
|
+
distances) should be managed separately by the algorithm implementation.
|
|
57
|
+
|
|
58
|
+
Attributes
|
|
59
|
+
----------
|
|
60
|
+
x : numpy.ndarray
|
|
61
|
+
Decision variables for all individuals. Shape is ``(n, n_vars)``.
|
|
62
|
+
objectives : numpy.ndarray | None
|
|
63
|
+
Objective values. Shape is ``(n, n_obj)``, or None if not evaluated.
|
|
64
|
+
|
|
65
|
+
Examples
|
|
66
|
+
--------
|
|
67
|
+
>>> import numpy as np
|
|
68
|
+
>>> from ctrl_freak.population import Population
|
|
69
|
+
>>> x = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
|
|
70
|
+
>>> obj = np.array([[0.5, 0.5], [0.3, 0.7], [0.4, 0.6]])
|
|
71
|
+
>>> pop = Population(x=x, objectives=obj)
|
|
72
|
+
>>> len(pop)
|
|
73
|
+
3
|
|
74
|
+
>>> pop.n_vars
|
|
75
|
+
2
|
|
76
|
+
>>> pop.n_obj
|
|
77
|
+
2
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
x: np.ndarray
|
|
81
|
+
objectives: np.ndarray | None = None
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
"""Validate shapes and copy arrays for immutability.
|
|
85
|
+
|
|
86
|
+
Raises
|
|
87
|
+
------
|
|
88
|
+
TypeError
|
|
89
|
+
If x is not a numpy array.
|
|
90
|
+
ValueError
|
|
91
|
+
If array shapes are inconsistent or invalid.
|
|
92
|
+
"""
|
|
93
|
+
# Validate x
|
|
94
|
+
if not isinstance(self.x, np.ndarray):
|
|
95
|
+
raise TypeError(f"x must be a numpy array, got {type(self.x).__name__}")
|
|
96
|
+
if self.x.ndim != 2:
|
|
97
|
+
raise ValueError(f"x must be 2D, got shape {self.x.shape}")
|
|
98
|
+
|
|
99
|
+
n = self.x.shape[0]
|
|
100
|
+
|
|
101
|
+
# Copy x for immutability (use object.__setattr__ for frozen dataclass)
|
|
102
|
+
object.__setattr__(self, "x", self.x.copy())
|
|
103
|
+
|
|
104
|
+
# Validate and copy objectives
|
|
105
|
+
if self.objectives is not None:
|
|
106
|
+
if not isinstance(self.objectives, np.ndarray):
|
|
107
|
+
raise TypeError(f"objectives must be a numpy array, got {type(self.objectives).__name__}")
|
|
108
|
+
if self.objectives.ndim != 2:
|
|
109
|
+
raise ValueError(f"objectives must be 2D, got shape {self.objectives.shape}")
|
|
110
|
+
if self.objectives.shape[0] != n:
|
|
111
|
+
raise ValueError(f"objectives has {self.objectives.shape[0]} individuals, expected {n} to match x")
|
|
112
|
+
object.__setattr__(self, "objectives", self.objectives.copy())
|
|
113
|
+
|
|
114
|
+
def __len__(self) -> int:
|
|
115
|
+
"""Return the number of individuals in the population.
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
int
|
|
120
|
+
Number of individuals.
|
|
121
|
+
"""
|
|
122
|
+
return self.x.shape[0]
|
|
123
|
+
|
|
124
|
+
def __getitem__(self, idx: int) -> IndividualView:
|
|
125
|
+
"""Get a read-only view of a single individual.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
idx : int
|
|
130
|
+
Index of the individual. Negative indexing is supported.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
IndividualView
|
|
135
|
+
Data for the specified individual.
|
|
136
|
+
|
|
137
|
+
Raises
|
|
138
|
+
------
|
|
139
|
+
TypeError
|
|
140
|
+
If idx is not an integer.
|
|
141
|
+
IndexError
|
|
142
|
+
If idx is out of bounds.
|
|
143
|
+
|
|
144
|
+
Examples
|
|
145
|
+
--------
|
|
146
|
+
>>> import numpy as np
|
|
147
|
+
>>> from ctrl_freak.population import Population
|
|
148
|
+
>>> pop = Population(x=np.array([[1.0, 2.0], [3.0, 4.0]]))
|
|
149
|
+
>>> pop[0].x
|
|
150
|
+
array([1., 2.])
|
|
151
|
+
>>> pop[-1].x
|
|
152
|
+
array([3., 4.])
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(idx, (int, np.integer)):
|
|
155
|
+
raise TypeError(f"indices must be integers, got {type(idx).__name__}")
|
|
156
|
+
|
|
157
|
+
n = len(self)
|
|
158
|
+
original_idx = idx
|
|
159
|
+
# Handle negative indexing
|
|
160
|
+
if idx < 0:
|
|
161
|
+
idx = n + idx
|
|
162
|
+
if idx < 0 or idx >= n:
|
|
163
|
+
raise IndexError(f"index {original_idx} is out of bounds for population with {n} individuals")
|
|
164
|
+
|
|
165
|
+
return IndividualView(
|
|
166
|
+
x=self.x[idx],
|
|
167
|
+
objectives=self.objectives[idx] if self.objectives is not None else None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def n_individuals(self) -> int:
|
|
172
|
+
"""Return the number of individuals in the population.
|
|
173
|
+
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
int
|
|
177
|
+
Number of individuals.
|
|
178
|
+
"""
|
|
179
|
+
return self.x.shape[0]
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def n_vars(self) -> int:
|
|
183
|
+
"""Return the number of decision variables per individual.
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
int
|
|
188
|
+
Number of decision variables.
|
|
189
|
+
"""
|
|
190
|
+
return self.x.shape[1]
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def n_obj(self) -> int | None:
|
|
194
|
+
"""Return the number of objectives, or None if not evaluated.
|
|
195
|
+
|
|
196
|
+
Returns
|
|
197
|
+
-------
|
|
198
|
+
int | None
|
|
199
|
+
Number of objectives, or None if objectives is None.
|
|
200
|
+
"""
|
|
201
|
+
if self.objectives is None:
|
|
202
|
+
return None
|
|
203
|
+
return self.objectives.shape[1]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Pareto-based ranking and diversity primitives.
|
|
2
|
+
|
|
3
|
+
Examples
|
|
4
|
+
--------
|
|
5
|
+
>>> import numpy as np
|
|
6
|
+
>>> ranks = non_dominated_sort(np.array([[1.0, 1.0], [2.0, 2.0]]))
|
|
7
|
+
>>> ranks
|
|
8
|
+
array([0, 1])
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ctrl_freak.primitives.pareto import (
|
|
12
|
+
crowding_distance,
|
|
13
|
+
dominates,
|
|
14
|
+
dominates_matrix,
|
|
15
|
+
non_dominated_sort,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"dominates",
|
|
20
|
+
"dominates_matrix",
|
|
21
|
+
"non_dominated_sort",
|
|
22
|
+
"crowding_distance",
|
|
23
|
+
]
|